diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..7747344767f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.bat] +end_of_line = crlf + +[*.yml] +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 new file mode 100644 index 00000000000..30832ef77b1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,49 @@ +# 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 + +# Declare files that will always have CRLF line endings on checkout. +*.bat eol=crlf + +# Declare files that will always have LF line endings on checkout. +*.pem eol=lf + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.mo binary +*.pdf binary +*.phar binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary + +# Remove files for archives generated using `git archive` +.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 +.mailmap export-ignore +.stickler.yml export-ignore +Makefile export-ignore +phpcs.xml export-ignore +phpstan.neon.dist export-ignore +phpstan-baseline.neon export-ignore +phpunit.xml.dist export-ignore +rector.php export-ignore + +# Split package files +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 new file mode 100644 index 00000000000..0199bc8e717 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,108 @@ +# How to contribute + +CakePHP loves to welcome your contributions. There are several ways to help out: + +* Create an [issue](https://github.com/cakephp/cakephp/issues) on GitHub, if you have found a bug +* Write test cases for open bug issues +* Write patches for open bug/feature issues, preferably with test cases included +* Contribute to the [documentation](https://github.com/cakephp/docs) + +There are a few guidelines that we need contributors to follow so that we have a +chance of keeping on top of things. + +## Code of Conduct + +Help us keep CakePHP open and inclusive. Please read and follow our [Code of Conduct](https://github.com/cakephp/code-of-conduct/blob/master/CODE_OF_CONDUCT.md). + +## Getting Started + +* Make sure you have a [GitHub account](https://github.com/signup/free). +* Submit an [issue](https://github.com/cakephp/cakephp/issues), assuming one does not already exist. + * Clearly describe the issue including steps to reproduce when it is a bug. + * Make sure you fill in the earliest version that you know has the issue. +* Fork the repository on GitHub. + +## Making Changes + +* Create a topic branch from where you want to base your work. + * 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, 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 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 + +* Push your changes to a topic branch in your fork of the repository. +* Submit a pull request to the repository in the CakePHP organization, with the + correct target branch. + +## Test cases, codesniffer and static analysis + +To run the test cases locally use the following command: + + composer test + +You can copy file `phpunit.xml.dist` to `phpunit.xml` and modify the database +driver settings as required to run tests for a particular database. + +To run the sniffs for CakePHP coding standards: + + composer cs-check + +Check the [cakephp-codesniffer](https://github.com/cakephp/cakephp-codesniffer) +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/4/en/contributing/tickets.html#reporting-security-issues). + +# Additional Resources + +* [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/) +* [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/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 new file mode 100644 index 00000000000..316acf5a64c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000000..6b168892c37 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported Versions + +We support fixing security issues on the following releases: + +| Version | Supported | Security fixes until +| ------- | ------------------ | -------------------- +| 5.0 | :white_check_mark: | The release of 5.2 +| 4.5 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) +| 4.4 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) +| 4.3 | :white_check_mark: | 36 Months after the release of 5.0 (09 Sep 2026) +| 4.2 | :x: | No longer supported +| 4.1 | :x: | No longer supported +| 4.0 | :x: | No longer supported +| 3.10.x | :x: | No longer supported +| 2.10.x | :x: | No longer supported + +## 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..6647c42863e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml new file mode 100644 index 00000000000..6a4e37236dd --- /dev/null +++ b/.github/workflows/api-docs.yml @@ -0,0 +1,31 @@ +--- +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 + run: > + curl -XPOST + -H 'Authorization: Bearer ${{ steps.app-token.outputs.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/cancel.yml b/.github/workflows/cancel.yml new file mode 100644 index 00000000000..263c669071d --- /dev/null +++ b/.github/workflows/cancel.yml @@ -0,0 +1,18 @@ +name: Cancel +on: + workflow_run: + workflows: ["CI"] + types: + - requested +permissions: + contents: read + +jobs: + cancel: + permissions: + actions: write # for styfle/cancel-workflow-action to cancel/stop running workflows + runs-on: ubuntu-latest + steps: + - uses: styfle/cancel-workflow-action@0.13.1 + with: + workflow_id: ${{ github.event.workflow.id }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..bd265c7261f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,370 @@ +name: CI + +on: + push: + branches: + - '4.x' + - '5.x' + - '5.next' + pull_request: + branches: + - '*' + workflow_dispatch: + +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@v6 + 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@v6 + 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@v6 + 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@v5 + 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@v6 + 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@v5 + 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@v5 + 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@v5 + 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 + + 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@v6 + 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@v5 + 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..3d1dea6588f --- /dev/null +++ b/.github/workflows/split-packages.yml @@ -0,0 +1,34 @@ +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@v6 + 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: + GITHUB_TOKEN: ${{ secrets.GH_SPLIT_PACKAGES_WRITE_TOKEN }} + run: | + make CURRENT_BRANCH=${{ github.ref_name }} components + make clean-components-branches diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..b450d4bc4d7 --- /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@v10 + 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 6fb2a3bda2b..4135ceccf21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,50 @@ -/app/Config -/app/tmp -/lib/Cake/Console/Templates/skel/tmp/ -/plugins -/vendors +# User specific & automatically generated files # +################################################# /build /dist +/tags +/composer.lock +/phpunit.xml +/phpstan.neon +/tools +/vendor +/composer.phar +*.mo +debug.log +error.log +.phpunit.result.cache +.phpunit.cache +.phpcs.cache + +# OS generated files # +###################### .DS_Store -tags +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? +ehthumbs.db +Thumbs.db + +# Tool specific files # +####################### +# vim +*~ +*.swp +*.swo +# sublime text & textmate +*.sublime-* +*.stTheme.cache +*.tmlanguage.cache +*.tmPreferences.cache +# Eclipse +.settings/* +/.project +/.buildpath +# JetBrains, aka PHPStorm, IntelliJ IDEA +.idea/* +# NetBeans +nbproject/* +# Visual Studio Code +.vscode diff --git a/.htaccess b/.htaccess deleted file mode 100644 index f23dbaf6686..00000000000 --- a/.htaccess +++ /dev/null @@ -1,5 +0,0 @@ - - RewriteEngine on - RewriteRule ^$ app/webroot/ [L] - RewriteRule (.*) app/webroot/$1 [L] - \ No newline at end of file diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000000..8d2c15681dc --- /dev/null +++ b/.mailmap @@ -0,0 +1,134 @@ +Mark Story +Mark Story +Mark Story +José Lorenzo Rodríguez +José Lorenzo Rodríguez +José Lorenzo Rodríguez José Lorenzo Rodríguez Urdaneta +ADmad +Mathew Foscarini +Mathew Foscarini +Ceeram +Ceeram +Walther Lalk +Walther Lalk +Walther Lalk +Walther Lalk +Walther Lalk +Mark Scherer +Mark Scherer +Mark Scherer +Mark Scherer +phpnut +phpnut +AD7six +AD7six +predominant +mariano.iglesias +mariano.iglesias +antograssiot +Florian Krämer +Florian Krämer +Florian Krämer +Rachman Chavik +Rachman Chavik +jperras +renan.saddam +Ber Clausen +Marc Würth +Jad Bitar +Jad Bitar +Jad Bitar +Yves P +dogmatic69 +Majna +Robert Pustułka +Robert Pustułka +Robert Pustułka +Thomas Ploch +Tigran Gabrielyan +Bryan Crowe +Bryan Crowe +Sam +Jorge González +Saleh Souzanchi +Yevgeny Tomenko +Ricardo Arturo Cabral +Cauan Cabral +pirouet +davidsteinsland +jamiemill +Stefan Dickmann +Benjamin Tamási +Gordon Pettey (petteyg) +Mathieu de Ruiter +Cees-Jan Kiewiet +Fiblan +Haithem BEN GHORBAL +Pedro Perejón +Algirdas Gurevicius +Calin +Mikaël Capelle +OKINAKA Kenshin +Walter Nasich +mstra001 +Aymeric Derbois +Daniel +James Michael DuPont +James Michael DuPont +Jan Dorsman +Pierre Martin +Jeremy Harris +Jeremy Harris +Christian Winther +Christian Winther +Christian Winther +Jose Diaz-Gonzalez +Jose Diaz-Gonzalez +Jose Diaz-Gonzalez +Frank de Graaf +Frank de Graaf Frank de Graaf +Frank de Graaf +Marlin Cremers +Marlin Cremers +Marlin Cremers +David Yell +James Watts +Jonas Hartmann +Jonas Hartmann +Jonas Hartmann +Thom Seddon +Thom Seddon +Robbert Noordzij +Robbert Noordzij +Simon East +Simon East +Matt Alexander +Matt Alexander +Mark van Driel +Mark van Driel +Juan Basso +Juan Basso +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..f648a9f744f --- /dev/null +++ b/.phive/phars.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 714d4a82759..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,110 +0,0 @@ -language: php - -php: - - 5.3 - - 5.4 - -env: - - DB=mysql - - DB=pgsql - - DB=sqlite - -before_script: - - sh -c "if [ '$DB' = 'mysql' ]; then mysql -e 'CREATE DATABASE cakephp_test;'; fi" - - sh -c "if [ '$DB' = 'mysql' ]; then mysql -e 'CREATE DATABASE cakephp_test2;'; fi" - - sh -c "if [ '$DB' = 'mysql' ]; then mysql -e 'CREATE DATABASE cakephp_test3;'; fi" - - sh -c "if [ '$DB' = 'pgsql' ]; then psql -c 'CREATE DATABASE cakephp_test;' -U postgres; fi" - - sh -c "if [ '$DB' = 'pgsql' ]; then psql -c 'CREATE SCHEMA test2;' -U postgres -d cakephp_test; fi" - - sh -c "if [ '$DB' = 'pgsql' ]; then psql -c 'CREATE SCHEMA test3;' -U postgres -d cakephp_test; fi" - - chmod -R 777 ./app/tmp - - echo "var net = require('net'); - var server = net.createServer(); - server.listen(80, 'localhost'); - console.log('TCP server listening on port 80 at localhost.');" > app/tmp/socket.js - - sudo node ./app/tmp/socket.js & - - set +H - - echo " array( - 'datasource' => 'Database/Mysql', - 'host' => '0.0.0.0', - 'login' => 'travis' - ), - 'pgsql' => array( - 'datasource' => 'Database/Postgres', - 'host' => '127.0.0.1', - 'login' => 'postgres', - 'database' => 'cakephp_test', - 'schema' => array( - 'default' => 'public', - 'test' => 'public', - 'test2' => 'test2', - 'test_database_three' => 'test3' - ) - ), - 'sqlite' => array( - 'datasource' => 'Database/Sqlite', - 'database' => array( - 'default' => ':memory:', - 'test' => ':memory:', - 'test2' => '/tmp/cakephp_test2.db', - 'test_database_three' => '/tmp/cakephp_test3.db' - ), - ) - ); - public \$default = array( - 'persistent' => false, - 'host' => '', - 'login' => '', - 'password' => '', - 'database' => 'cakephp_test', - 'prefix' => '' - ); - public \$test = array( - 'persistent' => false, - 'host' => '', - 'login' => '', - 'password' => '', - 'database' => 'cakephp_test', - 'prefix' => '' - ); - public \$test2 = array( - 'persistent' => false, - 'host' => '', - 'login' => '', - 'password' => '', - 'database' => 'cakephp_test2', - 'prefix' => '' - ); - public \$test_database_three = array( - 'persistent' => false, - 'host' => '', - 'login' => '', - 'password' => '', - 'database' => 'cakephp_test3', - 'prefix' => '' - ); - public function __construct() { - \$db = 'mysql'; - if (!empty(\$_SERVER['DB'])) { - \$db = \$_SERVER['DB']; - } - foreach (array('default', 'test', 'test2', 'test_database_three') as \$source) { - \$config = array_merge(\$this->{\$source}, \$this->identities[\$db]); - if (is_array(\$config['database'])) { - \$config['database'] = \$config['database'][\$source]; - } - if (!empty(\$config['schema']) && is_array(\$config['schema'])) { - \$config['schema'] = \$config['schema'][\$source]; - } - \$this->{\$source} = \$config; - } - } - }" > app/Config/database.php - -script: - - ./lib/Cake/Console/cake test core AllTests --stderr - -notifications: - email: false \ No newline at end of file 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/Makefile b/Makefile new file mode 100644 index 00000000000..6227133dc26 --- /dev/null +++ b/Makefile @@ -0,0 +1,203 @@ +# The following env variables need to be set: +# - VERSION +# - 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=cache console core collection database datasource event filesystem form http i18n log ORM utility validation +CURRENT_BRANCH=$(shell git branch | grep '*' | tr -d '* ') + +# Github settings +UPLOAD_HOST=https://uploads.github.com +API_HOST=https://api.github.com +OWNER=cakephp +REMOTE=origin + +ifdef GITHUB_TOKEN + AUTH=-H 'Authorization: token $(GITHUB_TOKEN)' +endif + +DASH_VERSION=$(shell echo $(VERSION) | sed -e s/\\./-/g) + +# Used when building packages for older 3.x packages. +# The build scripts clone cakephp/app, and this var selects the +# correct tag in that repo. +# For 3.1.x use 3.1.2 +# For 3.0.x use 3.0.5 +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 + +help: + @echo "CakePHP Makefile" + @echo "================" + @echo "" + @echo "release VERSION=x.y.z" + @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 "" + @echo "components" + @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." + @echo "" + @echo "test" + @echo " Run the tests for CakePHP." + @echo "" + @echo "All other tasks are not intended to be run directly." +.PHONY: help + + +test: install + vendor/bin/phpunit +.PHONY: test + + +# Utility target for checking required parameters +guard-%: + @if [ "$($*)" = '' ]; then \ + echo "Missing required $* variable."; \ + exit 1; \ + fi; + + +# Download composer +composer.phar: + curl -sS https://getcomposer.org/installer | php + +# Install dependencies +install: composer.phar + php composer.phar install +.PHONY: install + + + +# Version bumping & tagging for CakePHP itself +# Update VERSION.txt to new version. +bump-version: guard-VERSION + @echo "Update VERSION.txt to $(VERSION)" + # Work around sed being bad. + mv VERSION.txt VERSION.old + cat VERSION.old | sed s'/^[0-9]\.[0-9]\.[0-9].*/$(VERSION)/' > VERSION.txt + 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 + @echo "Tagging $(VERSION)" + git tag -s $(VERSION) -m "CakePHP $(VERSION)" + git push $(REMOTE) + git push $(REMOTE) --tags + + + +# Tasks for tagging the app skeleton and +# creating a zipball of a fully built app skeleton. +clean: + rm -rf build +.PHONY: clean + +build: + mkdir -p build + +build/app: build + git clone git@github.com:$(OWNER)/app.git build/app/ + cd build/app && git checkout $(APP_VERSION) + +build/cakephp: build + git checkout $(VERSION) + git checkout-index -a -f --prefix=build/cakephp/ + git checkout - + +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 && ../../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 + cp -r build/cakephp build/app/vendor/cakephp/cakephp + # Make a zipball of all the files that are not in .git dirs + # Including .git will make zip balls huge, and the zipball is + # intended for quick start non-git, non-cli users + @echo "Building zipball for $(VERSION)" + cd build/app && find . -not -path '*.git*' | zip ../../dist/cakephp-$(DASH_VERSION).zip -@ + +# Easier to type alias for zip balls +package: clean dist/cakephp-$(DASH_VERSION).zip +.PHONY: package + +# 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 + # 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"]);' + @echo "Uploading zip file to github." + curl $(AUTH) -XPOST \ + $(UPLOAD_HOST)/repos/$(OWNER)/cakephp/releases/`cat ./id.txt`/assets?name=cakephp-$(DASH_VERSION).zip \ + -H "Accept: application/vnd.github.manifold-preview" \ + -H 'Content-Type: application/zip' \ + --data-binary '@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 pkg-$* git@github.com:$(OWNER)/$*.git -f 2> /dev/null) + - (git branch -D $* 2> /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_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 $*)"}' + 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 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 pkg-$* git@github.com:$(OWNER)/$*.git -f 2> /dev/null) + - (git branch -D $* 2> /dev/null) + - git push -f pkg-$* :$(CURRENT_BRANCH) +.PHONY: components-clean + +# Top level alias for doing a release. +release: guard-VERSION tag-release components-tag package publish +.PHONY: release diff --git a/README b/README deleted file mode 100644 index ddf42020f55..00000000000 --- a/README +++ /dev/null @@ -1,28 +0,0 @@ -CakePHP is a rapid development framework for PHP which uses commonly known design patterns like Active Record, Association Data Mapping, Front Controller and MVC. Our primary goal is to provide a structured framework that enables PHP users at all levels to rapidly develop robust web applications, without any loss to flexibility. - -The Cake Software Foundation - promoting development related to CakePHP -http://cakefoundation.org/ - -CakePHP - the rapid development PHP framework -http://www.cakephp.org - -Cookbook - user documentation for learning about CakePHP -http://book.cakephp.org - -API - quick reference to CakePHP -http://api.cakephp.org - -The Bakery - everything CakePHP -http://bakery.cakephp.org - -The Show - live and archived podcasts about CakePHP and more -http://live.cakephp.org - -CakePHP TV - screen casts from events and video tutorials -http://tv.cakephp.org - -CakePHP Google Group - community mailing list and forum -http://groups.google.com/group/cake-php - -#cakephp on irc.freenode.net - chat with CakePHP developers -irc://irc.freenode.net/cakephp diff --git a/README.md b/README.md new file mode 100644 index 00000000000..90514947435 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +

+ + CakePHP + +

+

+ + Software License + + + Coverage Status + + + PHPStan + + + Code Consistency + + + Total Downloads + + + Latest Stable Version + +

+ +[CakePHP](https://cakephp.org) is a rapid development framework for PHP which +uses commonly known design patterns like Associative Data +Mapping, Front Controller, and MVC. Our primary goal is to provide a structured +framework that enables PHP users at all levels to rapidly develop robust web +applications, without any loss to flexibility. + +## Installing CakePHP via Composer + +You can install CakePHP into your project using +[Composer](https://getcomposer.org). If you're starting a new project, we +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 +``` + +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 (`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 `vendor/bin/phpunit`. + +## Learn More + +* [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. +* [CakeFest](https://cakefest.org) - Don't miss our annual CakePHP conference. +* [Cake Software Foundation](https://cakefoundation.org) - Promoting development related to CakePHP. + +## Get Support! + +* [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](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/5/en/contributing.html) - Details about contributing to the project. + +# Security + +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 new file mode 100644 index 00000000000..53a18289616 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1,19 @@ +//////////////////////////////////////////////////////////////////////////////////////////////////// +// +--------------------------------------------------------------------------------------------+ // +// CakePHP Version +// +// Holds a static string representing the current version of CakePHP +// +// CakePHP(tm) : Rapid Development Framework (https://cakephp.org) +// Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +// +// Licensed under The MIT License +// Redistributions of files must retain the above copyright notice. +// +// @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) +// @link https://cakephp.org +// @since CakePHP(tm) v 0.2.9 +// @license https://opensource.org/licenses/mit-license.php MIT License +// +--------------------------------------------------------------------------------------------+ // +//////////////////////////////////////////////////////////////////////////////////////////////////// +5.3.6 diff --git a/app/.htaccess b/app/.htaccess deleted file mode 100644 index fc3aac4b296..00000000000 --- a/app/.htaccess +++ /dev/null @@ -1,5 +0,0 @@ - - RewriteEngine on - RewriteRule ^$ webroot/ [L] - RewriteRule (.*) webroot/$1 [L] - \ No newline at end of file diff --git a/app/Config/Schema/db_acl.php b/app/Config/Schema/db_acl.php deleted file mode 100644 index e03a8950f4d..00000000000 --- a/app/Config/Schema/db_acl.php +++ /dev/null @@ -1,74 +0,0 @@ - array('type'=>'integer', 'null' => false, 'default' => NULL, 'length' => 10, 'key' => 'primary'), - 'parent_id' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'model' => array('type'=>'string', 'null' => true), - 'foreign_key' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'alias' => array('type'=>'string', 'null' => true), - 'lft' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'rght' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)) - ); - - public $aros = array( - 'id' => array('type'=>'integer', 'null' => false, 'default' => NULL, 'length' => 10, 'key' => 'primary'), - 'parent_id' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'model' => array('type'=>'string', 'null' => true), - 'foreign_key' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'alias' => array('type'=>'string', 'null' => true), - 'lft' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'rght' => array('type'=>'integer', 'null' => true, 'default' => NULL, 'length' => 10), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)) - ); - - public $aros_acos = array( - 'id' => array('type'=>'integer', 'null' => false, 'default' => NULL, 'length' => 10, 'key' => 'primary'), - 'aro_id' => array('type'=>'integer', 'null' => false, 'length' => 10, 'key' => 'index'), - 'aco_id' => array('type'=>'integer', 'null' => false, 'length' => 10), - '_create' => array('type'=>'string', 'null' => false, 'default' => '0', 'length' => 2), - '_read' => array('type'=>'string', 'null' => false, 'default' => '0', 'length' => 2), - '_update' => array('type'=>'string', 'null' => false, 'default' => '0', 'length' => 2), - '_delete' => array('type'=>'string', 'null' => false, 'default' => '0', 'length' => 2), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1), 'ARO_ACO_KEY' => array('column' => array('aro_id', 'aco_id'), 'unique' => 1)) - ); - -} diff --git a/app/Config/Schema/db_acl.sql b/app/Config/Schema/db_acl.sql deleted file mode 100644 index f50f3921e8c..00000000000 --- a/app/Config/Schema/db_acl.sql +++ /dev/null @@ -1,40 +0,0 @@ -# $Id$ -# -# Copyright 2005-2012, Cake Software Foundation, Inc. -# -# Licensed under The MIT License -# Redistributions of files must retain the above copyright notice. -# MIT License (http://www.opensource.org/licenses/mit-license.php) - -CREATE TABLE acos ( - id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, - parent_id INTEGER(10) DEFAULT NULL, - model VARCHAR(255) DEFAULT '', - foreign_key INTEGER(10) UNSIGNED DEFAULT NULL, - alias VARCHAR(255) DEFAULT '', - lft INTEGER(10) DEFAULT NULL, - rght INTEGER(10) DEFAULT NULL, - PRIMARY KEY (id) -); - -CREATE TABLE aros_acos ( - id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, - aro_id INTEGER(10) UNSIGNED NOT NULL, - aco_id INTEGER(10) UNSIGNED NOT NULL, - _create CHAR(2) NOT NULL DEFAULT 0, - _read CHAR(2) NOT NULL DEFAULT 0, - _update CHAR(2) NOT NULL DEFAULT 0, - _delete CHAR(2) NOT NULL DEFAULT 0, - PRIMARY KEY(id) -); - -CREATE TABLE aros ( - id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, - parent_id INTEGER(10) DEFAULT NULL, - model VARCHAR(255) DEFAULT '', - foreign_key INTEGER(10) UNSIGNED DEFAULT NULL, - alias VARCHAR(255) DEFAULT '', - lft INTEGER(10) DEFAULT NULL, - rght INTEGER(10) DEFAULT NULL, - PRIMARY KEY (id) -); \ No newline at end of file diff --git a/app/Config/Schema/i18n.php b/app/Config/Schema/i18n.php deleted file mode 100644 index 8ca081c99b9..00000000000 --- a/app/Config/Schema/i18n.php +++ /dev/null @@ -1,51 +0,0 @@ - array('type'=>'integer', 'null' => false, 'default' => NULL, 'length' => 10, 'key' => 'primary'), - 'locale' => array('type'=>'string', 'null' => false, 'length' => 6, 'key' => 'index'), - 'model' => array('type'=>'string', 'null' => false, 'key' => 'index'), - 'foreign_key' => array('type'=>'integer', 'null' => false, 'length' => 10, 'key' => 'index'), - 'field' => array('type'=>'string', 'null' => false, 'key' => 'index'), - 'content' => array('type'=>'text', 'null' => true, 'default' => NULL), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1), 'locale' => array('column' => 'locale', 'unique' => 0), 'model' => array('column' => 'model', 'unique' => 0), 'row_id' => array('column' => 'foreign_key', 'unique' => 0), 'field' => array('column' => 'field', 'unique' => 0)) - ); - -} diff --git a/app/Config/Schema/i18n.sql b/app/Config/Schema/i18n.sql deleted file mode 100644 index 239e146230a..00000000000 --- a/app/Config/Schema/i18n.sql +++ /dev/null @@ -1,26 +0,0 @@ -# $Id$ -# -# Copyright 2005-2012, Cake Software Foundation, Inc. -# -# Licensed under The MIT License -# Redistributions of files must retain the above copyright notice. -# MIT License (http://www.opensource.org/licenses/mit-license.php) - -CREATE TABLE i18n ( - id int(10) NOT NULL auto_increment, - locale varchar(6) NOT NULL, - model varchar(255) NOT NULL, - foreign_key int(10) NOT NULL, - field varchar(255) NOT NULL, - content mediumtext, - PRIMARY KEY (id), -# UNIQUE INDEX I18N_LOCALE_FIELD(locale, model, foreign_key, field), -# INDEX I18N_LOCALE_ROW(locale, model, foreign_key), -# INDEX I18N_LOCALE_MODEL(locale, model), -# INDEX I18N_FIELD(model, foreign_key, field), -# INDEX I18N_ROW(model, foreign_key), - INDEX locale (locale), - INDEX model (model), - INDEX row_id (foreign_key), - INDEX field (field) -); \ No newline at end of file diff --git a/app/Config/Schema/sessions.php b/app/Config/Schema/sessions.php deleted file mode 100644 index 7b7db69ab32..00000000000 --- a/app/Config/Schema/sessions.php +++ /dev/null @@ -1,48 +0,0 @@ - array('type'=>'string', 'null' => false, 'key' => 'primary'), - 'data' => array('type'=>'text', 'null' => true, 'default' => NULL), - 'expires' => array('type'=>'integer', 'null' => true, 'default' => NULL), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)) - ); - -} diff --git a/app/Config/Schema/sessions.sql b/app/Config/Schema/sessions.sql deleted file mode 100644 index b8951b6f5d4..00000000000 --- a/app/Config/Schema/sessions.sql +++ /dev/null @@ -1,16 +0,0 @@ -# $Id$ -# -# Copyright 2005-2012, Cake Software Foundation, Inc. -# 1785 E. Sahara Avenue, Suite 490-204 -# Las Vegas, Nevada 89104 -# -# Licensed under The MIT License -# Redistributions of files must retain the above copyright notice. -# MIT License (http://www.opensource.org/licenses/mit-license.php) - -CREATE TABLE cake_sessions ( - id varchar(255) NOT NULL default '', - data text, - expires int(11) default NULL, - PRIMARY KEY (id) -); \ No newline at end of file diff --git a/app/Config/acl.ini.php b/app/Config/acl.ini.php deleted file mode 100644 index 11ce65b5723..00000000000 --- a/app/Config/acl.ini.php +++ /dev/null @@ -1,68 +0,0 @@ -; -;/** -; * ACL Configuration -; * -; * -; * PHP 5 -; * -; * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) -; * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) -; * -; * Licensed under The MIT License -; * Redistributions of files must retain the above copyright notice. -; * -; * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) -; * @link http://cakephp.org CakePHP(tm) Project -; * @package app.Config -; * @since CakePHP(tm) v 0.10.0.1076 -; * @license MIT License (http://www.opensource.org/licenses/mit-license.php) -; */ - -; acl.ini.php - Cake ACL Configuration -; --------------------------------------------------------------------- -; Use this file to specify user permissions. -; aco = access control object (something in your application) -; aro = access request object (something requesting access) -; -; User records are added as follows: -; -; [uid] -; groups = group1, group2, group3 -; allow = aco1, aco2, aco3 -; deny = aco4, aco5, aco6 -; -; Group records are added in a similar manner: -; -; [gid] -; allow = aco1, aco2, aco3 -; deny = aco4, aco5, aco6 -; -; The allow, deny, and groups sections are all optional. -; NOTE: groups names *cannot* ever be the same as usernames! -; -; ACL permissions are checked in the following order: -; 1. Check for user denies (and DENY if specified) -; 2. Check for user allows (and ALLOW if specified) -; 3. Gather user's groups -; 4. Check group denies (and DENY if specified) -; 5. Check group allows (and ALLOW if specified) -; 6. If no aro, aco, or group information is found, DENY -; -; --------------------------------------------------------------------- - -;------------------------------------- -;Users -;------------------------------------- - -[username-goes-here] -groups = group1, group2 -deny = aco1, aco2 -allow = aco3, aco4 - -;------------------------------------- -;Groups -;------------------------------------- - -[groupname-goes-here] -deny = aco5, aco6 -allow = aco7, aco8 diff --git a/app/Config/acl.php b/app/Config/acl.php deleted file mode 100644 index 21f8ddaa7dd..00000000000 --- a/app/Config/acl.php +++ /dev/null @@ -1,134 +0,0 @@ -Auth->authorize = array('Actions' => array('actionPath' => 'controllers/'),...) - * - * Now, when a user (i.e. jeff) authenticates successfully and requests a controller action (i.e. /invoices/delete) - * that is not allowed by default (e.g. via $this->Auth->allow('edit') in the Invoices controller) then AuthComponent - * will ask the configured ACL interface if access is granted. Under the assumptions 1. and 2. this will be - * done via a call to Acl->check() with - * - * array('User' => array('username' => 'jeff', 'group_id' => 4, ...)) - * - * as ARO and - * - * '/controllers/invoices/delete' - * - * as ACO. - * - * If the configured map looks like - * - * $config['map'] = array( - * 'User' => 'User/username', - * 'Role' => 'User/group_id', - * ); - * - * then PhpAcl will lookup if we defined a role like User/jeff. If that role is not found, PhpAcl will try to - * find a definition for Role/4. If the definition isn't found then a default role (Role/default) will be used to - * check rules for the given ACO. The search can be expanded by defining aliases in the alias configuration. - * E.g. if you want to use a more readable name than Role/4 in your definitions you can define an alias like - * - * $config['alias'] = array( - * 'Role/4' => 'Role/editor', - * ); - * - * In the roles configuration you can define roles on the lhs and inherited roles on the rhs: - * - * $config['roles'] = array( - * 'Role/admin' => null, - * 'Role/accountant' => null, - * 'Role/editor' => null, - * 'Role/manager' => 'Role/editor, Role/accountant', - * 'User/jeff' => 'Role/manager', - * ); - * - * In this example manager inherits all rules from editor and accountant. Role/admin doesn't inherit from any role. - * Lets define some rules: - * - * $config['rules'] = array( - * 'allow' => array( - * '*' => 'Role/admin', - * 'controllers/users/(dashboard|profile)' => 'Role/default', - * 'controllers/invoices/*' => 'Role/accountant', - * 'controllers/articles/*' => 'Role/editor', - * 'controllers/users/*' => 'Role/manager', - * 'controllers/invoices/delete' => 'Role/manager', - * ), - * 'deny' => array( - * 'controllers/invoices/delete' => 'Role/accountant, User/jeff', - * 'controllers/articles/(delete|publish)' => 'Role/editor', - * ), - * ); - * - * Ok, so as jeff inherits from Role/manager he's matched every rule that references User/jeff, Role/manager, - * Role/editor, Role/accountant and Role/default. However, for jeff, rules for User/jeff are more specific than - * rules for Role/manager, rules for Role/manager are more specific than rules for Role/editor and so on. - * This is important when allow and deny rules match for a role. E.g. Role/accountant is allowed - * controllers/invoices/* but at the same time controllers/invoices/delete is denied. But there is a more - * specific rule defined for Role/manager which is allowed controllers/invoices/delete. However, the most specific - * rule denies access to the delete action explicitly for User/jeff, so he'll be denied access to the resource. - * - * If we would remove the role definition for User/jeff, then jeff would be granted access as he would be resolved - * to Role/manager and Role/manager has an allow rule. - */ - -/** - * The role map defines how to resolve the user record from your application - * to the roles you defined in the roles configuration. - */ -$config['map'] = array( - 'User' => 'User/username', - 'Role' => 'User/group_id', -); - -/** - * define aliases to map your model information to - * the roles defined in your role configuration. - */ -$config['alias'] = array( - 'Role/4' => 'Role/editor', -); - -/** - * role configuration - */ -$config['roles'] = array( - 'Role/admin' => null, -); - -/** - * rule configuration - */ -$config['rules'] = array( - 'allow' => array( - '*' => 'Role/admin', - ), - 'deny' => array(), -); diff --git a/app/Config/bootstrap.php b/app/Config/bootstrap.php deleted file mode 100644 index 4c54746488d..00000000000 --- a/app/Config/bootstrap.php +++ /dev/null @@ -1,124 +0,0 @@ - 'File', //[required] - * 'duration'=> 3600, //[optional] - * 'probability'=> 100, //[optional] - * 'path' => CACHE, //[optional] use system tmp directory - remember to use absolute path - * 'prefix' => 'cake_', //[optional] prefix every cache file with this string - * 'lock' => false, //[optional] use file locking - * 'serialize' => true, // [optional] - * 'mask' => 0666, // [optional] permission mask to use when creating cache files - * )); - * - * APC (http://pecl.php.net/package/APC) - * - * Cache::config('default', array( - * 'engine' => 'Apc', //[required] - * 'duration'=> 3600, //[optional] - * 'probability'=> 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * )); - * - * Xcache (http://xcache.lighttpd.net/) - * - * Cache::config('default', array( - * 'engine' => 'Xcache', //[required] - * 'duration'=> 3600, //[optional] - * 'probability'=> 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * 'user' => 'user', //user from xcache.admin.user settings - * 'password' => 'password', //plaintext password (xcache.admin.pass) - * )); - * - * Memcache (http://memcached.org/) - * - * Cache::config('default', array( - * 'engine' => 'Memcache', //[required] - * 'duration'=> 3600, //[optional] - * 'probability'=> 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * 'servers' => array( - * '127.0.0.1:11211' // localhost, default port 11211 - * ), //[optional] - * 'persistent' => true, // [optional] set this to false for non-persistent connections - * 'compress' => false, // [optional] compress data in Memcache (slower, but uses less memory) - * )); - * - * Wincache (http://php.net/wincache) - * - * Cache::config('default', array( - * 'engine' => 'Wincache', //[required] - * 'duration'=> 3600, //[optional] - * 'probability'=> 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * )); - */ -Cache::config('default', array('engine' => 'File')); - -/** - * The settings below can be used to set additional paths to models, views and controllers. - * - * App::build(array( - * 'Plugin' => array('/full/path/to/plugins/', '/next/full/path/to/plugins/'), - * 'Model' => array('/full/path/to/models/', '/next/full/path/to/models/'), - * 'View' => array('/full/path/to/views/', '/next/full/path/to/views/'), - * 'Controller' => array('/full/path/to/controllers/', '/next/full/path/to/controllers/'), - * 'Model/Datasource' => array('/full/path/to/datasources/', '/next/full/path/to/datasources/'), - * 'Model/Behavior' => array('/full/path/to/behaviors/', '/next/full/path/to/behaviors/'), - * 'Controller/Component' => array('/full/path/to/components/', '/next/full/path/to/components/'), - * 'View/Helper' => array('/full/path/to/helpers/', '/next/full/path/to/helpers/'), - * 'Vendor' => array('/full/path/to/vendors/', '/next/full/path/to/vendors/'), - * 'Console/Command' => array('/full/path/to/shells/', '/next/full/path/to/shells/'), - * 'Locale' => array('/full/path/to/locale/', '/next/full/path/to/locale/') - * )); - * - */ - -/** - * Custom Inflector rules, can be set to correctly pluralize or singularize table, model, controller names or whatever other - * string is passed to the inflection functions - * - * Inflector::rules('singular', array('rules' => array(), 'irregular' => array(), 'uninflected' => array())); - * Inflector::rules('plural', array('rules' => array(), 'irregular' => array(), 'uninflected' => array())); - * - */ - -/** - * Plugins need to be loaded manually, you can either load them one by one or all of them in a single call - * Uncomment one of the lines below, as you need. make sure you read the documentation on CakePlugin to use more - * advanced ways of loading plugins - * - * CakePlugin::loadAll(); // Loads all plugins at once - * CakePlugin::load('DebugKit'); //Loads a single plugin named DebugKit - * - */ diff --git a/app/Config/core.php b/app/Config/core.php deleted file mode 100644 index 044dba4bed8..00000000000 --- a/app/Config/core.php +++ /dev/null @@ -1,278 +0,0 @@ - 0 - * and log errors with CakeLog when debug = 0. - * - * Options: - * - * - `handler` - callback - The callback to handle errors. You can set this to any callback type, - * including anonymous functions. - * - `level` - int - The level of errors you are interested in capturing. - * - `trace` - boolean - Include stack traces for errors in log files. - * - * @see ErrorHandler for more information on error handling and configuration. - */ - Configure::write('Error', array( - 'handler' => 'ErrorHandler::handleError', - 'level' => E_ALL & ~E_DEPRECATED, - 'trace' => true - )); - -/** - * Configure the Exception handler used for uncaught exceptions. By default, - * ErrorHandler::handleException() is used. It will display a HTML page for the exception, and - * while debug > 0, framework errors like Missing Controller will be displayed. When debug = 0, - * framework errors will be coerced into generic HTTP errors. - * - * Options: - * - * - `handler` - callback - The callback to handle exceptions. You can set this to any callback type, - * including anonymous functions. - * - `renderer` - string - The class responsible for rendering uncaught exceptions. If you choose a custom class you - * should place the file for that class in app/Lib/Error. This class needs to implement a render method. - * - `log` - boolean - Should Exceptions be logged? - * - * @see ErrorHandler for more information on exception handling and configuration. - */ - Configure::write('Exception', array( - 'handler' => 'ErrorHandler::handleException', - 'renderer' => 'ExceptionRenderer', - 'log' => true - )); - -/** - * Application wide charset encoding - */ - Configure::write('App.encoding', 'UTF-8'); - -/** - * To configure CakePHP *not* to use mod_rewrite and to - * use CakePHP pretty URLs, remove these .htaccess - * files: - * - * /.htaccess - * /app/.htaccess - * /app/webroot/.htaccess - * - * And uncomment the App.baseUrl below: - */ - //Configure::write('App.baseUrl', env('SCRIPT_NAME')); - -/** - * Uncomment the define below to use CakePHP prefix routes. - * - * The value of the define determines the names of the routes - * and their associated controller actions: - * - * Set to an array of prefixes you want to use in your application. Use for - * admin or other prefixed routes. - * - * Routing.prefixes = array('admin', 'manager'); - * - * Enables: - * `admin_index()` and `/admin/controller/index` - * `manager_index()` and `/manager/controller/index` - * - */ - //Configure::write('Routing.prefixes', array('admin')); - -/** - * Turn off all caching application-wide. - * - */ - //Configure::write('Cache.disable', true); - -/** - * Enable cache checking. - * - * If set to true, for view caching you must still use the controller - * public $cacheAction inside your controllers to define caching settings. - * You can either set it controller-wide by setting public $cacheAction = true, - * or in each action using $this->cacheAction = true. - * - */ - //Configure::write('Cache.check', true); - -/** - * Defines the default error type when using the log() function. Used for - * differentiating error logging and debugging. Currently PHP supports LOG_DEBUG. - */ - define('LOG_ERROR', 2); - -/** - * Session configuration. - * - * Contains an array of settings to use for session configuration. The defaults key is - * used to define a default preset to use for sessions, any settings declared here will override - * the settings of the default config. - * - * ## Options - * - * - `Session.cookie` - The name of the cookie to use. Defaults to 'CAKEPHP' - * - `Session.timeout` - The number of minutes you want sessions to live for. This timeout is handled by CakePHP - * - `Session.cookieTimeout` - The number of minutes you want session cookies to live for. - * - `Session.checkAgent` - Do you want the user agent to be checked when starting sessions? You might want to set the - * value to false, when dealing with older versions of IE, Chrome Frame or certain web-browsing devices and AJAX - * - `Session.defaults` - The default configuration set to use as a basis for your session. - * There are four builtins: php, cake, cache, database. - * - `Session.handler` - Can be used to enable a custom session handler. Expects an array of of callables, - * that can be used with `session_save_handler`. Using this option will automatically add `session.save_handler` - * to the ini array. - * - `Session.autoRegenerate` - Enabling this setting, turns on automatic renewal of sessions, and - * sessionids that change frequently. See CakeSession::$requestCountdown. - * - `Session.ini` - An associative array of additional ini values to set. - * - * The built in defaults are: - * - * - 'php' - Uses settings defined in your php.ini. - * - 'cake' - Saves session files in CakePHP's /tmp directory. - * - 'database' - Uses CakePHP's database sessions. - * - 'cache' - Use the Cache class to save sessions. - * - * To define a custom session handler, save it at /app/Model/Datasource/Session/.php. - * Make sure the class implements `CakeSessionHandlerInterface` and set Session.handler to - * - * To use database sessions, run the app/Config/Schema/sessions.php schema using - * the cake shell command: cake schema create Sessions - * - */ - Configure::write('Session', array( - 'defaults' => 'php' - )); - -/** - * The level of CakePHP security. - */ - Configure::write('Security.level', 'medium'); - -/** - * A random string used in security hashing methods. - */ - Configure::write('Security.salt', 'DYhG93b0qyJfIxfs2guVoUubWwvniR2G0FgaC9mi'); - -/** - * A random numeric string (digits only) used to encrypt/decrypt strings. - */ - Configure::write('Security.cipherSeed', '76859309657453542496749683645'); - -/** - * Apply timestamps with the last modified time to static assets (js, css, images). - * Will append a querystring parameter containing the time the file was modified. This is - * useful for invalidating browser caches. - * - * Set to `true` to apply timestamps when debug > 0. Set to 'force' to always enable - * timestamping regardless of debug value. - */ - //Configure::write('Asset.timestamp', true); - -/** - * Compress CSS output by removing comments, whitespace, repeating tags, etc. - * This requires a/var/cache directory to be writable by the web server for caching. - * and /vendors/csspp/csspp.php - * - * To use, prefix the CSS link URL with '/ccss/' instead of '/css/' or use HtmlHelper::css(). - */ - //Configure::write('Asset.filter.css', 'css.php'); - -/** - * Plug in your own custom JavaScript compressor by dropping a script in your webroot to handle the - * output, and setting the config below to the name of the script. - * - * To use, prefix your JavaScript link URLs with '/cjs/' instead of '/js/' or use JavaScriptHelper::link(). - */ - //Configure::write('Asset.filter.js', 'custom_javascript_output_filter.php'); - -/** - * The classname and database used in CakePHP's - * access control lists. - */ - Configure::write('Acl.classname', 'DbAcl'); - Configure::write('Acl.database', 'default'); - -/** - * Uncomment this line and correct your server timezone to fix - * any date & time related errors. - */ - //date_default_timezone_set('UTC'); - -/** - * Pick the caching engine to use. If APC is enabled use it. - * If running via cli - apc is disabled by default. ensure it's available and enabled in this case - * - * Note: 'default' and other application caches should be configured in app/Config/bootstrap.php. - * Please check the comments in boostrap.php for more info on the cache engines available - * and their setttings. - */ -$engine = 'File'; -if (extension_loaded('apc') && function_exists('apc_dec') && (php_sapi_name() !== 'cli' || ini_get('apc.enable_cli'))) { - $engine = 'Apc'; -} - -// In development mode, caches should expire quickly. -$duration = '+999 days'; -if (Configure::read('debug') >= 1) { - $duration = '+10 seconds'; -} - -// Prefix each application on the same server with a different string, to avoid Memcache and APC conflicts. -$prefix = 'myapp_'; - -/** - * Configure the cache used for general framework caching. Path information, - * object listings, and translation cache files are stored with this configuration. - */ -Cache::config('_cake_core_', array( - 'engine' => $engine, - 'prefix' => $prefix . 'cake_core_', - 'path' => CACHE . 'persistent' . DS, - 'serialize' => ($engine === 'File'), - 'duration' => $duration -)); - -/** - * Configure the cache for model and datasource caches. This cache configuration - * is used to store schema descriptions, and table listings in connections. - */ -Cache::config('_cake_model_', array( - 'engine' => $engine, - 'prefix' => $prefix . 'cake_model_', - 'path' => CACHE . 'models' . DS, - 'serialize' => ($engine === 'File'), - 'duration' => $duration -)); diff --git a/app/Config/database.php.default b/app/Config/database.php.default deleted file mode 100644 index d0aeb1f1399..00000000000 --- a/app/Config/database.php.default +++ /dev/null @@ -1,83 +0,0 @@ - The name of a supported datasource; valid options are as follows: - * Database/Mysql - MySQL 4 & 5, - * Database/Sqlite - SQLite (PHP5 only), - * Database/Postgres - PostgreSQL 7 and higher, - * Database/Sqlserver - Microsoft SQL Server 2005 and higher - * - * You can add custom database datasources (or override existing datasources) by adding the - * appropriate file to app/Model/Datasource/Database. Datasources should be named 'MyDatasource.php', - * - * - * persistent => true / false - * Determines whether or not the database should use a persistent connection - * - * host => - * the host you connect to the database. To add a socket or port number, use 'port' => # - * - * prefix => - * Uses the given prefix for all the tables in this database. This setting can be overridden - * on a per-table basis with the Model::$tablePrefix property. - * - * schema => - * For Postgres specifies which schema you would like to use the tables in. Postgres defaults to 'public'. - * - * encoding => - * For MySQL, Postgres specifies the character encoding to use when connecting to the - * database. Uses database default not specified. - * - * unix_socket => - * For MySQL to connect via socket specify the `unix_socket` parameter instead of `host` and `port` - */ -class DATABASE_CONFIG { - - public $default = array( - 'datasource' => 'Database/Mysql', - 'persistent' => false, - 'host' => 'localhost', - 'login' => 'user', - 'password' => 'password', - 'database' => 'database_name', - 'prefix' => '', - //'encoding' => 'utf8', - ); - - public $test = array( - 'datasource' => 'Database/Mysql', - 'persistent' => false, - 'host' => 'localhost', - 'login' => 'user', - 'password' => 'password', - 'database' => 'test_database_name', - 'prefix' => '', - //'encoding' => 'utf8', - ); -} diff --git a/app/Config/email.php.default b/app/Config/email.php.default deleted file mode 100644 index c423f782f29..00000000000 --- a/app/Config/email.php.default +++ /dev/null @@ -1,97 +0,0 @@ - The name of a supported transport; valid options are as follows: - * Mail - Send using PHP mail function - * Smtp - Send using SMTP - * Debug - Do not send the email, just return the result - * - * You can add custom transports (or override existing transports) by adding the - * appropriate file to app/Network/Email. Transports should be named 'YourTransport.php', - * where 'Your' is the name of the transport. - * - * from => - * The origin email. See CakeEmail::from() about the valid values - * - */ -class EmailConfig { - - public $default = array( - 'transport' => 'Mail', - 'from' => 'you@localhost', - //'charset' => 'utf-8', - //'headerCharset' => 'utf-8', - ); - - public $smtp = array( - 'transport' => 'Smtp', - 'from' => array('site@localhost' => 'My Site'), - 'host' => 'localhost', - 'port' => 25, - 'timeout' => 30, - 'username' => 'user', - 'password' => 'secret', - 'client' => null, - 'log' => false - //'charset' => 'utf-8', - //'headerCharset' => 'utf-8', - ); - - public $fast = array( - 'from' => 'you@localhost', - 'sender' => null, - 'to' => null, - 'cc' => null, - 'bcc' => null, - 'replyTo' => null, - 'readReceipt' => null, - 'returnPath' => null, - 'messageId' => true, - 'subject' => null, - 'message' => null, - 'headers' => null, - 'viewRender' => null, - 'template' => false, - 'layout' => false, - 'viewVars' => null, - 'attachments' => null, - 'emailFormat' => null, - 'transport' => 'Smtp', - 'host' => 'localhost', - 'port' => 25, - 'timeout' => 30, - 'username' => 'user', - 'password' => 'secret', - 'client' => null, - 'log' => true, - //'charset' => 'utf-8', - //'headerCharset' => 'utf-8', - ); - -} diff --git a/app/Config/routes.php b/app/Config/routes.php deleted file mode 100644 index b3823779e11..00000000000 --- a/app/Config/routes.php +++ /dev/null @@ -1,44 +0,0 @@ - 'pages', 'action' => 'display', 'home')); -/** - * ...and connect the rest of 'Pages' controller's urls. - */ - Router::connect('/pages/*', array('controller' => 'pages', 'action' => 'display')); - -/** - * Load all plugin routes. See the CakePlugin documentation on - * how to customize the loading of plugin routes. - */ - CakePlugin::routes(); - -/** - * Load the CakePHP default routes. Remove this if you do not want to use - * the built-in default routes. - */ - require CAKE . 'Config' . DS . 'routes.php'; diff --git a/app/Console/Command/AppShell.php b/app/Console/Command/AppShell.php deleted file mode 100644 index 5cc915f6bfe..00000000000 --- a/app/Console/Command/AppShell.php +++ /dev/null @@ -1,31 +0,0 @@ -redirect('/'); - } - $page = $subpage = $title_for_layout = null; - - if (!empty($path[0])) { - $page = $path[0]; - } - if (!empty($path[1])) { - $subpage = $path[1]; - } - if (!empty($path[$count - 1])) { - $title_for_layout = Inflector::humanize($path[$count - 1]); - } - $this->set(compact('page', 'subpage', 'title_for_layout')); - $this->render(implode('/', $path)); - } -} diff --git a/app/Model/AppModel.php b/app/Model/AppModel.php deleted file mode 100644 index 94e5e5f39d1..00000000000 --- a/app/Model/AppModel.php +++ /dev/null @@ -1,34 +0,0 @@ - - ' . $line . "

\n"; -endforeach; -?> \ No newline at end of file diff --git a/app/View/Emails/text/default.ctp b/app/View/Emails/text/default.ctp deleted file mode 100644 index 56be8c13a1e..00000000000 --- a/app/View/Emails/text/default.ctp +++ /dev/null @@ -1,19 +0,0 @@ - - \ No newline at end of file diff --git a/app/View/Errors/error400.ctp b/app/View/Errors/error400.ctp deleted file mode 100644 index 6d508604c7c..00000000000 --- a/app/View/Errors/error400.ctp +++ /dev/null @@ -1,31 +0,0 @@ - -

-

- : - '{$url}'" - ); ?> -

- 0 ): - echo $this->element('exception_stack_trace'); -endif; -?> diff --git a/app/View/Errors/error500.ctp b/app/View/Errors/error500.ctp deleted file mode 100644 index 4e1f36ece56..00000000000 --- a/app/View/Errors/error500.ctp +++ /dev/null @@ -1,28 +0,0 @@ - -

-

- : - -

- 0 ): - echo $this->element('exception_stack_trace'); -endif; -?> diff --git a/app/View/Helper/AppHelper.php b/app/View/Helper/AppHelper.php deleted file mode 100644 index 0fddaea2016..00000000000 --- a/app/View/Helper/AppHelper.php +++ /dev/null @@ -1,34 +0,0 @@ - - - - - <?php echo $title_for_layout;?> - - - - -

This email was sent using the CakePHP Framework

- - \ No newline at end of file diff --git a/app/View/Layouts/Emails/text/default.ctp b/app/View/Layouts/Emails/text/default.ctp deleted file mode 100644 index 94ed2224956..00000000000 --- a/app/View/Layouts/Emails/text/default.ctp +++ /dev/null @@ -1,21 +0,0 @@ - - - -This email was sent using the CakePHP Framework, http://cakephp.org. diff --git a/app/View/Layouts/ajax.ctp b/app/View/Layouts/ajax.ctp deleted file mode 100644 index c0ca27058ff..00000000000 --- a/app/View/Layouts/ajax.ctp +++ /dev/null @@ -1,19 +0,0 @@ - - \ No newline at end of file diff --git a/app/View/Layouts/default.ctp b/app/View/Layouts/default.ctp deleted file mode 100644 index 39704bf4411..00000000000 --- a/app/View/Layouts/default.ctp +++ /dev/null @@ -1,61 +0,0 @@ - - - - - Html->charset(); ?> - - <?php echo $cakeDescription ?>: - <?php echo $title_for_layout; ?> - - Html->meta('icon'); - - echo $this->Html->css('cake.generic'); - - echo $this->fetch('meta'); - echo $this->fetch('css'); - echo $this->fetch('script'); - ?> - - -
- -
- - Session->flash(); ?> - - fetch('content'); ?> -
- -
- element('sql_dump'); ?> - - diff --git a/app/View/Layouts/flash.ctp b/app/View/Layouts/flash.ctp deleted file mode 100644 index 76fae349600..00000000000 --- a/app/View/Layouts/flash.ctp +++ /dev/null @@ -1,37 +0,0 @@ - - - - -Html->charset(); ?> -<?php echo $page_title; ?> - - - - - - - -

- - \ No newline at end of file diff --git a/app/View/Layouts/js/default.ctp b/app/View/Layouts/js/default.ctp deleted file mode 100644 index d94dc903a57..00000000000 --- a/app/View/Layouts/js/default.ctp +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/app/View/Layouts/rss/default.ctp b/app/View/Layouts/rss/default.ctp deleted file mode 100644 index d57624e1a90..00000000000 --- a/app/View/Layouts/rss/default.ctp +++ /dev/null @@ -1,14 +0,0 @@ -Rss->document( - $this->Rss->channel( - array(), $channel, $content_for_layout - ) -); -?> diff --git a/app/View/Layouts/xml/default.ctp b/app/View/Layouts/xml/default.ctp deleted file mode 100644 index 27f20316b37..00000000000 --- a/app/View/Layouts/xml/default.ctp +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/View/Pages/home.ctp b/app/View/Pages/home.ctp deleted file mode 100644 index 6661e9a6240..00000000000 --- a/app/View/Pages/home.ctp +++ /dev/null @@ -1,188 +0,0 @@ - - -

- - 0): - Debugger::checkSecurityKeys(); -endif; -?> -

- - 1) Help me configure it - 2) I don't / can't use URL rewriting -

-

-=')): - echo ''; - echo __d('cake_dev', 'Your version of PHP is 5.2.8 or higher.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your version of PHP is too low. You need PHP 5.2.8 or higher to use CakePHP.'); - echo ''; - endif; -?> -

-

- '; - echo __d('cake_dev', 'Your tmp directory is writable.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your tmp directory is NOT writable.'); - echo ''; - endif; - ?> -

-

- '; - echo __d('cake_dev', 'The %s is being used for core caching. To change the config edit APP/Config/core.php ', ''. $settings['engine'] . 'Engine'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your cache is NOT working. Please check the settings in APP/Config/core.php'); - echo ''; - endif; - ?> -

-

- '; - echo __d('cake_dev', 'Your database configuration file is present.'); - $filePresent = true; - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your database configuration file is NOT present.'); - echo '
'; - echo __d('cake_dev', 'Rename APP/Config/database.php.default to APP/Config/database.php'); - echo '
'; - endif; - ?> -

- -

- isConnected()): - echo ''; - echo __d('cake_dev', 'Cake is able to connect to the database.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Cake is NOT able to connect to the database.'); - echo '

'; - echo $connectionError->getMessage(); - echo '
'; - endif; - ?> -

- -'; - echo __d('cake_dev', 'PCRE has not been compiled with Unicode support.'); - echo '
'; - echo __d('cake_dev', 'Recompile PCRE with Unicode support by adding --enable-unicode-properties when configuring'); - echo '

'; - } -?> -

-

- -To change its layout, create: APP/View/Layouts/default.ctp.
-You can also add some CSS styles for your pages at: APP/webroot/css.'); -?> -

- -

-

- Html->link( - sprintf('%s %s', __d('cake_dev', 'New'), __d('cake_dev', 'CakePHP 2.0 Docs')), - 'http://book.cakephp.org/2.0/en/', - array('target' => '_blank', 'escape' => false) - ); - ?> -

-

- Html->link( - __d('cake_dev', 'The 15 min Blog Tutorial'), - 'http://book.cakephp.org/2.0/en/tutorials-and-examples/blog/blog.html', - array('target' => '_blank', 'escape' => false) - ); - ?> -

- -

-

- -

-

- -

- - diff --git a/app/View/Scaffolds/empty b/app/View/Scaffolds/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/index.php b/app/index.php deleted file mode 100644 index 29f2c572852..00000000000 --- a/app/index.php +++ /dev/null @@ -1,17 +0,0 @@ - - RewriteEngine On - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^(.*)$ index.php [QSA,L] - diff --git a/app/webroot/css/cake.generic.css b/app/webroot/css/cake.generic.css deleted file mode 100644 index 2244ad2f868..00000000000 --- a/app/webroot/css/cake.generic.css +++ /dev/null @@ -1,739 +0,0 @@ -@charset "utf-8"; -/** - * - * Generic CSS for CakePHP - * - * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://cakephp.org CakePHP(tm) Project - * @package app.webroot.css - * @since CakePHP(tm) - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -* { - margin:0; - padding:0; -} - -/** General Style Info **/ -body { - background: #003d4c; - color: #fff; - font-family:'lucida grande',verdana,helvetica,arial,sans-serif; - font-size:90%; - margin: 0; -} -a { - color: #003d4c; - text-decoration: underline; - font-weight: bold; -} -a:hover { - color: #367889; - text-decoration:none; -} -a img { - border:none; -} -h1, h2, h3, h4 { - font-weight: normal; - margin-bottom:0.5em; -} -h1 { - background:#fff; - color: #003d4c; - font-size: 100%; -} -h2 { - background:#fff; - color: #e32; - font-family:'Gill Sans','lucida grande', helvetica, arial, sans-serif; - font-size: 190%; -} -h3 { - color: #2c6877; - font-family:'Gill Sans','lucida grande', helvetica, arial, sans-serif; - font-size: 165%; -} -h4 { - color: #993; - font-weight: normal; -} -ul, li { - margin: 0 12px; -} -p { - margin: 0 0 1em 0; -} - -/** Layout **/ -#container { - text-align: left; -} - -#header{ - padding: 10px 20px; -} -#header h1 { - line-height:20px; - background: #003d4c url('../img/cake.icon.png') no-repeat left; - color: #fff; - padding: 0px 30px; -} -#header h1 a { - color: #fff; - background: #003d4c; - font-weight: normal; - text-decoration: none; -} -#header h1 a:hover { - color: #fff; - background: #003d4c; - text-decoration: underline; -} -#content{ - background: #fff; - clear: both; - color: #333; - padding: 10px 20px 40px 20px; - overflow: auto; -} -#footer { - clear: both; - padding: 6px 10px; - text-align: right; -} - -/** containers **/ -div.form, -div.index, -div.view { - float:right; - width:76%; - border-left:1px solid #666; - padding:10px 2%; -} -div.actions { - float:left; - width:16%; - padding:10px 1.5%; -} -div.actions h3 { - padding-top:0; - color:#777; -} - - -/** Tables **/ -table { - border-right:0; - clear: both; - color: #333; - margin-bottom: 10px; - width: 100%; -} -th { - border:0; - border-bottom:2px solid #555; - text-align: left; - padding:4px; -} -th a { - display: block; - padding: 2px 4px; - text-decoration: none; -} -th a.asc:after { - content: ' ⇣'; -} -th a.desc:after { - content: ' ⇡'; -} -table tr td { - padding: 6px; - text-align: left; - vertical-align: top; - border-bottom:1px solid #ddd; -} -table tr:nth-child(even) { - background: #f9f9f9; -} -td.actions { - text-align: center; - white-space: nowrap; -} -table td.actions a { - margin: 0px 6px; - padding:2px 5px; -} - -/* SQL log */ -.cake-sql-log { - background: #fff; -} -.cake-sql-log td { - padding: 4px 8px; - text-align: left; - font-family: Monaco, Consolas, "Courier New", monospaced; -} -.cake-sql-log caption { - color:#fff; -} - -/** Paging **/ -.paging { - background:#fff; - color: #ccc; - margin-top: 1em; - clear:both; -} -.paging .current, -.paging .disabled, -.paging a { - text-decoration: none; - padding: 5px 8px; - display: inline-block -} -.paging > span { - display: inline-block; - border: 1px solid #ccc; - border-left: 0; -} -.paging > span:hover { - background: #efefef; -} -.paging .prev { - border-left: 1px solid #ccc; - -moz-border-radius: 4px 0 0 4px; - -webkit-border-radius: 4px 0 0 4px; - border-radius: 4px 0 0 4px; -} -.paging .next { - -moz-border-radius: 0 4px 4px 0; - -webkit-border-radius: 0 4px 4px 0; - border-radius: 0 4px 4px 0; -} -.paging .disabled { - color: #ddd; -} -.paging .disabled:hover { - background: transparent; -} -.paging .current { - background: #efefef; - color: #c73e14; -} - -/** Scaffold View **/ -dl { - line-height: 2em; - margin: 0em 0em; - width: 60%; -} -dl dd:nth-child(4n+2), -dl dt:nth-child(4n+1) { - background: #f4f4f4; -} - -dt { - font-weight: bold; - padding-left: 4px; - vertical-align: top; - width: 10em; -} -dd { - margin-left: 10em; - margin-top: -2em; - vertical-align: top; -} - -/** Forms **/ -form { - clear: both; - margin-right: 20px; - padding: 0; - width: 95%; -} -fieldset { - border: none; - margin-bottom: 1em; - padding: 16px 10px; -} -fieldset legend { - color: #e32; - font-size: 160%; - font-weight: bold; -} -fieldset fieldset { - margin-top: 0; - padding: 10px 0 0; -} -fieldset fieldset legend { - font-size: 120%; - font-weight: normal; -} -fieldset fieldset div { - clear: left; - margin: 0 20px; -} -form div { - clear: both; - margin-bottom: 1em; - padding: .5em; - vertical-align: text-top; -} -form .input { - color: #444; -} -form .required { - font-weight: bold; -} -form .required label:after { - color: #e32; - content: '*'; - display:inline; -} -form div.submit { - border: 0; - clear: both; - margin-top: 10px; -} -label { - display: block; - font-size: 110%; - margin-bottom:3px; -} -input, textarea { - clear: both; - font-size: 140%; - font-family: "frutiger linotype", "lucida grande", "verdana", sans-serif; - padding: 1%; - width:98%; -} -select { - clear: both; - font-size: 120%; - vertical-align: text-bottom; -} -select[multiple=multiple] { - width: 100%; -} -option { - font-size: 120%; - padding: 0 3px; -} -input[type=checkbox] { - clear: left; - float: left; - margin: 0px 6px 7px 2px; - width: auto; -} -div.checkbox label { - display: inline; -} -input[type=radio] { - float:left; - width:auto; - margin: 6px 0; - padding: 0; - line-height: 26px; -} -.radio label { - margin: 0 0 6px 20px; - line-height: 26px; -} -input[type=submit] { - display: inline; - font-size: 110%; - width: auto; -} -form .submit input[type=submit] { - background:#62af56; - background-image: -webkit-gradient(linear, left top, left bottom, from(#76BF6B), to(#3B8230)); - background-image: -webkit-linear-gradient(top, #76BF6B, #3B8230); - background-image: -moz-linear-gradient(top, #76BF6B, #3B8230); - border-color: #2d6324; - color: #fff; - text-shadow: rgba(0, 0, 0, 0.5) 0px -1px 0px; - padding: 8px 10px; -} -form .submit input[type=submit]:hover { - background: #5BA150; -} -/* Form errors */ -form .error { - background: #FFDACC; - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - border-radius: 4px; - font-weight: normal; -} -form .error-message { - -moz-border-radius: none; - -webkit-border-radius: none; - border-radius: none; - border: none; - background: none; - margin: 0; - padding-left: 4px; - padding-right: 0; -} -form .error, -form .error-message { - color: #9E2424; - -webkit-box-shadow: none; - -moz-box-shadow: none; - -ms-box-shadow: none; - -o-box-shadow: none; - box-shadow: none; - text-shadow: none; -} - -/** Notices and Errors **/ -.message { - clear: both; - color: #fff; - font-size: 140%; - font-weight: bold; - margin: 0 0 1em 0; - padding: 5px; -} - -.success, -.message, -.cake-error, -.cake-debug, -.notice, -p.error, -.error-message { - background: #ffcc00; - background-repeat: repeat-x; - background-image: -moz-linear-gradient(top, #ffcc00, #E6B800); - background-image: -ms-linear-gradient(top, #ffcc00, #E6B800); - background-image: -webkit-gradient(linear, left top, left bottom, from(#ffcc00), to(#E6B800)); - background-image: -webkit-linear-gradient(top, #ffcc00, #E6B800); - background-image: -o-linear-gradient(top, #ffcc00, #E6B800); - background-image: linear-gradient(top, #ffcc00, #E6B800); - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); - border: 1px solid rgba(0, 0, 0, 0.2); - margin-bottom: 18px; - padding: 7px 14px; - color: #404040; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); - -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); -} -.success, -.message, -.cake-error, -p.error, -.error-message { - clear: both; - color: #fff; - background: #c43c35; - border: 1px solid rgba(0, 0, 0, 0.5); - background-repeat: repeat-x; - background-image: -moz-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -ms-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -webkit-gradient(linear, left top, left bottom, from(#ee5f5b), to(#c43c35)); - background-image: -webkit-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -o-linear-gradient(top, #ee5f5b, #c43c35); - background-image: linear-gradient(top, #ee5f5b, #c43c35); - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); -} -.success { - clear: both; - color: #fff; - border: 1px solid rgba(0, 0, 0, 0.5); - background: #3B8230; - background-repeat: repeat-x; - background-image: -webkit-gradient(linear, left top, left bottom, from(#76BF6B), to(#3B8230)); - background-image: -webkit-linear-gradient(top, #76BF6B, #3B8230); - background-image: -moz-linear-gradient(top, #76BF6B, #3B8230); - background-image: -ms-linear-gradient(top, #76BF6B, #3B8230); - background-image: -o-linear-gradient(top, #76BF6B, #3B8230); - background-image: linear-gradient(top, #76BF6B, #3B8230); - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); -} -p.error { - font-family: Monaco, Consolas, Courier, monospace; - font-size: 120%; - padding: 0.8em; - margin: 1em 0; -} -p.error em { - font-weight: normal; - line-height: 140%; -} -.notice { - color: #000; - display: block; - font-size: 120%; - padding: 0.8em; - margin: 1em 0; -} -.success { - color: #fff; -} - -/** Actions **/ -.actions ul { - margin: 0; - padding: 0; -} -.actions li { - margin:0 0 0.5em 0; - list-style-type: none; - white-space: nowrap; - padding: 0; -} -.actions ul li a { - font-weight: normal; - display: block; - clear: both; -} - -/* Buttons and button links */ -input[type=submit], -.actions ul li a, -.actions a { - font-weight:normal; - padding: 4px 8px; - background: #dcdcdc; - background-image: -webkit-gradient(linear, left top, left bottom, from(#fefefe), to(#dcdcdc)); - background-image: -webkit-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -moz-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -ms-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -o-linear-gradient(top, #fefefe, #dcdcdc); - background-image: linear-gradient(top, #fefefe, #dcdcdc); - color:#333; - border:1px solid #bbb; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - text-decoration: none; - text-shadow: #fff 0px 1px 0px; - min-width: 0; - -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0px 1px 1px rgba(0, 0, 0, 0.2); - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0px 1px 1px rgba(0, 0, 0, 0.2); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0px 1px 1px rgba(0, 0, 0, 0.2); - -webkit-user-select: none; - user-select: none; -} -.actions ul li a:hover, -.actions a:hover { - background: #ededed; - border-color: #acacac; - text-decoration: none; -} -input[type=submit]:active, -.actions ul li a:active, -.actions a:active { - background: #eee; - background-image: -webkit-gradient(linear, left top, left bottom, from(#dfdfdf), to(#eee)); - background-image: -webkit-linear-gradient(top, #dfdfdf, #eee); - background-image: -moz-linear-gradient(top, #dfdfdf, #eee); - background-image: -ms-linear-gradient(top, #dfdfdf, #eee); - background-image: -o-linear-gradient(top, #dfdfdf, #eee); - background-image: linear-gradient(top, #dfdfdf, #eee); - text-shadow: #eee 0px 1px 0px; - -moz-box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); - -webkit-box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); - box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); - border-color: #aaa; - text-decoration: none; -} - -/** Related **/ -.related { - clear: both; - display: block; -} - -/** Debugging **/ -pre { - color: #000; - background: #f0f0f0; - padding: 15px; - -moz-box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); - -webkit-box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); - box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); -} -.cake-debug-output { - padding: 0; - position: relative; -} -.cake-debug-output > span { - position: absolute; - top: 5px; - right: 5px; - background: rgba(255, 255, 255, 0.3); - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - border-radius: 4px; - padding: 5px 6px; - color: #000; - display: block; - float: left; - -moz-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 0 rgba(255, 255, 255, 0.5); - -webkit-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 0 rgba(255, 255, 255, 0.5); - box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 0 rgba(255, 255, 255, 0.5); - text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8); -} -.cake-debug, -.cake-error { - font-size: 16px; - line-height: 20px; - clear: both; -} -.cake-error > a { - text-shadow: none; -} -.cake-error { - white-space: normal; -} -.cake-stack-trace { - background: rgba(255, 255, 255, 0.7); - color: #333; - margin: 10px 0 5px 0; - padding: 10px 10px 0 10px; - font-size: 120%; - line-height: 140%; - overflow: auto; - position: relative; - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - border-radius: 4px; -} -.cake-stack-trace a { - text-shadow: none; - background: rgba(255, 255, 255, 0.7); - padding: 5px; - -moz-border-radius: 10px; - -webkit-border-radius: 10px; - border-radius: 10px; - margin: 0px 4px 10px 2px; - font-family: sans-serif; - font-size: 14px; - line-height: 14px; - display: inline-block; - text-decoration: none; - -moz-box-shadow: inset 0px 1px 0 rgba(0, 0, 0, 0.3); - -webkit-box-shadow: inset 0px 1px 0 rgba(0, 0, 0, 0.3); - box-shadow: inset 0px 1px 0 rgba(0, 0, 0, 0.3); -} -.cake-code-dump pre { - position: relative; - overflow: auto; -} -.cake-context { - margin-bottom: 10px; -} -.cake-stack-trace pre { - color: #000; - background-color: #F0F0F0; - margin: 0px 0 10px 0; - padding: 1em; - overflow: auto; - text-shadow: none; -} -.cake-stack-trace li { - padding: 10px 5px 0px; - margin: 0 0 4px 0; - font-family: monospace; - border: 1px solid #bbb; - -moz-border-radius: 4px; - -wekbkit-border-radius: 4px; - border-radius: 4px; - background: #dcdcdc; - background-image: -webkit-gradient(linear, left top, left bottom, from(#fefefe), to(#dcdcdc)); - background-image: -webkit-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -moz-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -ms-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -o-linear-gradient(top, #fefefe, #dcdcdc); - background-image: linear-gradient(top, #fefefe, #dcdcdc); -} -/* excerpt */ -.cake-code-dump pre, -.cake-code-dump pre code { - clear: both; - font-size: 12px; - line-height: 15px; - margin: 4px 2px; - padding: 4px; - overflow: auto; -} -.cake-code-dump .code-highlight { - display: block; - background-color: rgba(255, 255, 0, 0.5); -} -.code-coverage-results div.code-line { - padding-left:5px; - display:block; - margin-left:10px; -} -.code-coverage-results div.uncovered span.content { - background:#ecc; -} -.code-coverage-results div.covered span.content { - background:#cec; -} -.code-coverage-results div.ignored span.content { - color:#aaa; -} -.code-coverage-results span.line-num { - color:#666; - display:block; - float:left; - width:20px; - text-align:right; - margin-right:5px; -} -.code-coverage-results span.line-num strong { - color:#666; -} -.code-coverage-results div.start { - border:1px solid #aaa; - border-width:1px 1px 0px 1px; - margin-top:30px; - padding-top:5px; -} -.code-coverage-results div.end { - border:1px solid #aaa; - border-width:0px 1px 1px 1px; - margin-bottom:30px; - padding-bottom:5px; -} -.code-coverage-results div.realstart { - margin-top:0px; -} -.code-coverage-results p.note { - color:#bbb; - padding:5px; - margin:5px 0 10px; - font-size:10px; -} -.code-coverage-results span.result-bad { - color: #a00; -} -.code-coverage-results span.result-ok { - color: #fa0; -} -.code-coverage-results span.result-good { - color: #0a0; -} - -/** Elements **/ -#url-rewriting-warning { - display:none; -} diff --git a/app/webroot/favicon.ico b/app/webroot/favicon.ico deleted file mode 100644 index b36e81f2f35..00000000000 Binary files a/app/webroot/favicon.ico and /dev/null differ diff --git a/app/webroot/files/empty b/app/webroot/files/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/webroot/img/test-error-icon.png b/app/webroot/img/test-error-icon.png deleted file mode 100644 index 07bb1241143..00000000000 Binary files a/app/webroot/img/test-error-icon.png and /dev/null differ diff --git a/app/webroot/img/test-fail-icon.png b/app/webroot/img/test-fail-icon.png deleted file mode 100644 index f9d2f147ec4..00000000000 Binary files a/app/webroot/img/test-fail-icon.png and /dev/null differ diff --git a/app/webroot/img/test-pass-icon.png b/app/webroot/img/test-pass-icon.png deleted file mode 100644 index 99c5eb05ad2..00000000000 Binary files a/app/webroot/img/test-pass-icon.png and /dev/null differ diff --git a/app/webroot/img/test-skip-icon.png b/app/webroot/img/test-skip-icon.png deleted file mode 100644 index 749771c9895..00000000000 Binary files a/app/webroot/img/test-skip-icon.png and /dev/null differ diff --git a/app/webroot/index.php b/app/webroot/index.php deleted file mode 100644 index 14ba634724f..00000000000 --- a/app/webroot/index.php +++ /dev/null @@ -1,96 +0,0 @@ -dispatch(new CakeRequest(), new CakeResponse(array('charset' => Configure::read('App.encoding')))); diff --git a/app/webroot/js/empty b/app/webroot/js/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/webroot/test.php b/app/webroot/test.php deleted file mode 100644 index 9a3a072add3..00000000000 --- a/app/webroot/test.php +++ /dev/null @@ -1,92 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing - * @package app.webroot - * @since CakePHP(tm) v 1.2.0.4433 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -set_time_limit(0); -ini_set('display_errors', 1); -/** - * Use the DS to separate the directories in other defines - */ - if (!defined('DS')) { - define('DS', DIRECTORY_SEPARATOR); - } -/** - * These defines should only be edited if you have cake installed in - * a directory layout other than the way it is distributed. - * When using custom settings be sure to use the DS and do not add a trailing DS. - */ - -/** - * The full path to the directory which holds "app", WITHOUT a trailing DS. - * - */ - if (!defined('ROOT')) { - define('ROOT', dirname(dirname(dirname(__FILE__)))); - } -/** - * The actual directory name for the "app". - * - */ - if (!defined('APP_DIR')) { - define('APP_DIR', basename(dirname(dirname(__FILE__)))); - } - -/** - * The absolute path to the "Cake" directory, WITHOUT a trailing DS. - * - * For ease of development CakePHP uses PHP's include_path. If you - * need to cannot modify your include_path, you can set this path. - * - * Leaving this constant undefined will result in it being defined in Cake/bootstrap.php - */ - //define('CAKE_CORE_INCLUDE_PATH', ROOT . DS . 'lib'); - -/** - * Editing below this line should not be necessary. - * Change at your own risk. - * - */ -if (!defined('WEBROOT_DIR')) { - define('WEBROOT_DIR', basename(dirname(__FILE__))); -} -if (!defined('WWW_ROOT')) { - define('WWW_ROOT', dirname(__FILE__) . DS); -} - -if (!defined('CAKE_CORE_INCLUDE_PATH')) { - if (function_exists('ini_set')) { - ini_set('include_path', ROOT . DS . 'lib' . PATH_SEPARATOR . ini_get('include_path')); - } - if (!include('Cake' . DS . 'bootstrap.php')) { - $failed = true; - } -} else { - if (!include(CAKE_CORE_INCLUDE_PATH . DS . 'Cake' . DS . 'bootstrap.php')) { - $failed = true; - } -} -if (!empty($failed)) { - trigger_error("CakePHP core could not be found. Check the value of CAKE_CORE_INCLUDE_PATH in APP/webroot/index.php. It should point to the directory containing your " . DS . "cake core directory and your " . DS . "vendors root directory.", E_USER_ERROR); -} - -if (Configure::read('debug') < 1) { - die(__d('cake_dev', 'Debug setting does not allow access to this url.')); -} - -require_once CAKE . 'TestSuite' . DS . 'CakeTestSuiteDispatcher.php'; - -CakeTestSuiteDispatcher::run(); diff --git a/build.properties b/build.properties deleted file mode 100644 index e3c99ca9177..00000000000 --- a/build.properties +++ /dev/null @@ -1,12 +0,0 @@ -# Name -project.name = CakePHP - -# Git stuff -git.remote = changeme! - -# Directories -build.dir = build -dist.dir = dist - -# Server -pirum.dir = /home/cakephp/www-live/pear.cakephp.org diff --git a/build.xml b/build.xml deleted file mode 100644 index c6542643826..00000000000 --- a/build.xml +++ /dev/null @@ -1,214 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CakePHP - CakePHP Rapid Development Framework - pear.cakephp.org - CakePHP is an application development framework for PHP 5.2+ - - - - - - - - - - - - - MIT License - - - http://github.com/cakephp/cakephp/blob/master/README - - - - - - script - php - php - php - php - - - - - - php - php - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/composer.json b/composer.json new file mode 100644 index 00000000000..e64e685c727 --- /dev/null +++ b/composer.json @@ -0,0 +1,153 @@ +{ + "name": "cakephp/cakephp", + "type": "library", + "description": "The CakePHP framework", + "keywords": [ + "framework", + "mvc", + "rapid-development", + "conventions over configuration", + "dry", + "orm", + "form", + "validation", + "psr-7" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cakephp/graphs/contributors" + } + ], + "require": { + "php": ">=8.2", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "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/log": "^3.0", + "psr/simple-cache": "^2.0 || ^3.0" + }, + "replace": { + "cakephp/cache": "self.version", + "cakephp/collection": "self.version", + "cakephp/console": "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": { + "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.", + "paragonie/csp-builder": "CSP builder, to use the CSP Middleware" + }, + "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/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": { + "Cake\\": "src/" + }, + "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" + ] + }, + "autoload-dev": { + "psr-4": { + "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/" + } + }, + "scripts": { + "check": [ + "@cs-check", + "@test" + ], + "cs-check": "phpcs", + "cs-fix": "phpcbf", + "stan": [ + "tools/phpstan analyse", + "tools/psalm" + ], + "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.4.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.4.x-dev" + } + } +} diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 00000000000..4e95849bc2c --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,21 @@ + 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/pre-commit b/contrib/pre-commit new file mode 100644 index 00000000000..9836ae75070 --- /dev/null +++ b/contrib/pre-commit @@ -0,0 +1,38 @@ +#!/bin/sh +FILES=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.php` +PROJECT=`php -r "echo dirname(dirname(realpath('$0')));"` + +# Determine if a file list is passed +if [ "$#" -eq 1 ] +then + oIFS=$IFS + IFS=' + ' + SFILES="$1" + IFS=$oIFS +fi +SFILES=${SFILES:-$FILES} + +echo "Checking PHP Lint..." +for FILE in $SFILES +do + php -l -d display_errors=0 $PROJECT/$FILE + if [ $? != 0 ] + then + echo "Fix the error before commit." + exit 1 + fi + FILES="$FILES $PROJECT/$FILE" +done + +if [ "$SFILES" != "" ] +then + echo "Running PHPCS" + ./vendor/bin/phpcs $SFILES + if [ $? != 0 ] + then + echo "PHPCS Errors found; commit aborted." + exit 1 + fi +fi +exit $? 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..1cf084cda52 --- /dev/null +++ b/contrib/validate-split-packages-phpstan.php @@ -0,0 +1,73 @@ +#!/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; + } +} +$composerCommand = '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/index.php b/index.php deleted file mode 100644 index 480a9c73206..00000000000 --- a/index.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Apc', - * 'prefix' => 'my_app_' - * )); - * }}} - * - * This would configure an APC 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. - * - * @package Cake.Cache - */ -class Cache { - -/** - * Cache configuration stack - * Keeps the permanent/default settings for each cache engine. - * These settings are used to reset the engines after temporary modification. - * - * @var array - */ - protected static $_config = array(); - -/** - * Whether to reset the settings with the next call to Cache::set(); - * - * @var array - */ - protected static $_reset = false; - -/** - * Engine instances keyed by configuration name. - * - * @var array - */ - protected static $_engines = array(); - -/** - * Set the cache configuration to use. config() can - * both create new configurations, return the settings for already configured - * configurations. - * - * To create a new configuration, or to modify an existing configuration permanently: - * - * `Cache::config('my_config', array('engine' => 'File', 'path' => TMP));` - * - * If you need to modify a configuration temporarily, use Cache::set(). - * To get the settings for a configuration: - * - * `Cache::config('default');` - * - * There are 5 built-in caching engines: - * - * - `FileEngine` - Uses simple files to store content. Poor performance, but good for - * storing large objects, or things that are not IO sensitive. - * - `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. - * - * The following keys are used in core cache engines: - * - * - `duration` Specify how long items in this cache configuration last. - * - `prefix` Prefix appended to all entries. Good for when you need to share a keyspace - * with either another cache config or another application. - * - `probability` Probability of hitting a cache gc cleanup. Setting to 0 will disable - * cache::gc from ever being called automatically. - * - `servers' Used by memcache. Give the address of the memcached servers to use. - * - `compress` Used by memcache. Enables memcache's compressed format. - * - `serialize` Used by FileCache. Should cache objects be serialized first. - * - `path` Used by FileCache. Path to where cachefiles should be saved. - * - `lock` Used by FileCache. Should files be locked before writing to them? - * - `user` Used by Xcache. Username for XCache - * - `password` Used by Xcache. Password for XCache - * - * @see app/Config/core.php for configuration settings - * @param string $name Name of the configuration - * @param array $settings Optional associative array of settings passed to the engine - * @return array(engine, settings) on success, false on failure - * @throws CacheException - */ - public static function config($name = null, $settings = array()) { - if (is_array($name)) { - $settings = $name; - } - - $current = array(); - if (isset(self::$_config[$name])) { - $current = self::$_config[$name]; - } - - if (!empty($settings)) { - self::$_config[$name] = array_merge($current, $settings); - } - - if (empty(self::$_config[$name]['engine'])) { - return false; - } - - $engine = self::$_config[$name]['engine']; - - if (!isset(self::$_engines[$name])) { - self::_buildEngine($name); - $settings = self::$_config[$name] = self::settings($name); - } elseif ($settings = self::set(self::$_config[$name], null, $name)) { - self::$_config[$name] = $settings; - } - return compact('engine', 'settings'); - } - -/** - * Finds and builds the instance of the required engine class. - * - * @param string $name Name of the config array that needs an engine instance built - * @return boolean - * @throws CacheException - */ - protected static function _buildEngine($name) { - $config = self::$_config[$name]; - - list($plugin, $class) = pluginSplit($config['engine'], true); - $cacheClass = $class . 'Engine'; - App::uses($cacheClass, $plugin . 'Cache/Engine'); - if (!class_exists($cacheClass)) { - return false; - } - $cacheClass = $class . 'Engine'; - if (!is_subclass_of($cacheClass, 'CacheEngine')) { - throw new CacheException(__d('cake_dev', 'Cache engines must use CacheEngine as a base class.')); - } - self::$_engines[$name] = new $cacheClass(); - if (self::$_engines[$name]->init($config)) { - if (self::$_engines[$name]->settings['probability'] && time() % self::$_engines[$name]->settings['probability'] === 0) { - self::$_engines[$name]->gc(); - } - return true; - } - return false; - } - -/** - * Returns an array containing the currently configured Cache settings. - * - * @return array Array of configured Cache config names. - */ - public static function configured() { - return array_keys(self::$_config); - } - -/** - * Drops a cache engine. Deletes the cache configuration information - * If the deleted configuration is the last configuration using an certain engine, - * the Engine instance is also unset. - * - * @param string $name A currently configured cache config you wish to remove. - * @return boolean success of the removal, returns false when the config does not exist. - */ - public static function drop($name) { - if (!isset(self::$_config[$name])) { - return false; - } - unset(self::$_config[$name], self::$_engines[$name]); - return true; - } - -/** - * Temporarily change the settings on a cache config. The settings will persist for the next write - * operation (write, decrement, increment, clear). Any reads that are done before the write, will - * use the modified settings. If `$settings` is empty, the settings will be reset to the - * original configuration. - * - * Can be called with 2 or 3 parameters. To set multiple values at once. - * - * `Cache::set(array('duration' => '+30 minutes'), 'my_config');` - * - * Or to set one value. - * - * `Cache::set('duration', '+30 minutes', 'my_config');` - * - * To reset a config back to the originally configured values. - * - * `Cache::set(null, 'my_config');` - * - * @param mixed $settings Optional string for simple name-value pair or array - * @param string $value Optional for a simple name-value pair - * @param string $config The configuration name you are changing. Defaults to 'default' - * @return array Array of settings. - */ - public static function set($settings = array(), $value = null, $config = 'default') { - if (is_array($settings) && $value !== null) { - $config = $value; - } - if (!isset(self::$_config[$config]) || !isset(self::$_engines[$config])) { - return false; - } - if (!empty($settings)) { - self::$_reset = true; - } - - if (self::$_reset === true) { - if (empty($settings)) { - self::$_reset = false; - $settings = self::$_config[$config]; - } else { - if (is_string($settings) && $value !== null) { - $settings = array($settings => $value); - } - $settings = array_merge(self::$_config[$config], $settings); - if (isset($settings['duration']) && !is_numeric($settings['duration'])) { - $settings['duration'] = strtotime($settings['duration']) - time(); - } - } - self::$_engines[$config]->settings = $settings; - } - return self::settings($config); - } - -/** - * Garbage collection - * - * Permanently remove all expired and deleted data - * - * @param string $config The config name you wish to have garbage collected. Defaults to 'default' - * @return void - */ - public static function gc($config = 'default') { - self::$_engines[$config]->gc(); - } - -/** - * Write data for key into cache. Will automatically use the currently - * active cache configuration. To set the currently active configuration use - * Cache::config() - * - * ### Usage: - * - * Writing to the active cache config: - * - * `Cache::write('cached_data', $data);` - * - * Writing to a specific cache config: - * - * `Cache::write('cached_data', $data, 'long_term');` - * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - anything except a resource - * @param string $config Optional string configuration name to write to. Defaults to 'default' - * @return boolean True if the data was successfully cached, false on failure - */ - public static function write($key, $value, $config = 'default') { - $settings = self::settings($config); - - if (empty($settings)) { - return false; - } - if (!self::isInitialized($config)) { - return false; - } - $key = self::$_engines[$config]->key($key); - - if (!$key || is_resource($value)) { - return false; - } - - $success = self::$_engines[$config]->write($settings['prefix'] . $key, $value, $settings['duration']); - self::set(null, $config); - if ($success === false && $value !== '') { - trigger_error( - __d('cake_dev', - "%s cache was unable to write '%s' to %s cache", - $config, - $key, - self::$_engines[$config]->settings['engine'] - ), - E_USER_WARNING - ); - } - return $success; - } - -/** - * Read a key from the cache. Will automatically use the currently - * active cache configuration. To set the currently active configuration use - * Cache::config() - * - * ### Usage: - * - * Reading from the active cache configuration. - * - * `Cache::read('my_data');` - * - * Reading from a specific cache configuration. - * - * `Cache::read('my_data', 'long_term');` - * - * @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 - */ - public static function read($key, $config = 'default') { - $settings = self::settings($config); - - if (empty($settings)) { - return false; - } - if (!self::isInitialized($config)) { - return false; - } - $key = self::$_engines[$config]->key($key); - if (!$key) { - return false; - } - return self::$_engines[$config]->read($settings['prefix'] . $key); - } - -/** - * Increment a number under the key and return incremented value. - * - * @param string $key Identifier for the data - * @param integer $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, - * or if there was an error fetching it. - */ - public static function increment($key, $offset = 1, $config = 'default') { - $settings = self::settings($config); - - if (empty($settings)) { - return false; - } - if (!self::isInitialized($config)) { - return false; - } - $key = self::$_engines[$config]->key($key); - - if (!$key || !is_integer($offset) || $offset < 0) { - return false; - } - $success = self::$_engines[$config]->increment($settings['prefix'] . $key, $offset); - self::set(null, $config); - return $success; - } - -/** - * Decrement a number under the key and return decremented value. - * - * @param string $key Identifier for the data - * @param integer $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, - * or if there was an error fetching it - */ - public static function decrement($key, $offset = 1, $config = 'default') { - $settings = self::settings($config); - - if (empty($settings)) { - return false; - } - if (!self::isInitialized($config)) { - return false; - } - $key = self::$_engines[$config]->key($key); - - if (!$key || !is_integer($offset) || $offset < 0) { - return false; - } - $success = self::$_engines[$config]->decrement($settings['prefix'] . $key, $offset); - self::set(null, $config); - return $success; - } - -/** - * Delete a key from the cache. - * - * ### Usage: - * - * Deleting from the active cache configuration. - * - * `Cache::delete('my_data');` - * - * Deleting from a specific cache configuration. - * - * `Cache::delete('my_data', 'long_term');` - * - * @param string $key Identifier for the data - * @param string $config name of the configuration to use. Defaults to 'default' - * @return boolean 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') { - $settings = self::settings($config); - - if (empty($settings)) { - return false; - } - if (!self::isInitialized($config)) { - return false; - } - $key = self::$_engines[$config]->key($key); - if (!$key) { - return false; - } - - $success = self::$_engines[$config]->delete($settings['prefix'] . $key); - self::set(null, $config); - return $success; - } - -/** - * Delete all keys from the cache. - * - * @param boolean $check if true will check expiration, otherwise delete all - * @param string $config name of the configuration to use. Defaults to 'default' - * @return boolean True if the cache was successfully cleared, false otherwise - */ - public static function clear($check = false, $config = 'default') { - if (!self::isInitialized($config)) { - return false; - } - $success = self::$_engines[$config]->clear($check); - self::set(null, $config); - return $success; - } - -/** - * Check if Cache has initialized a working config for the given name. - * - * @param string $config name of the configuration to use. Defaults to 'default' - * @return boolean Whether or not the config name has been initialized. - */ - public static function isInitialized($config = 'default') { - if (Configure::read('Cache.disable')) { - return false; - } - return isset(self::$_engines[$config]); - } - -/** - * Return the settings for the named cache engine. - * - * @param string $name Name of the configuration to get settings for. Defaults to 'default' - * @return array list of settings for this engine - * @see Cache::config() - */ - public static function settings($name = 'default') { - if (!empty(self::$_engines[$name])) { - return self::$_engines[$name]->settings(); - } - return array(); - } - -} - diff --git a/lib/Cake/Cache/CacheEngine.php b/lib/Cake/Cache/CacheEngine.php deleted file mode 100644 index f71b8a15902..00000000000 --- a/lib/Cake/Cache/CacheEngine.php +++ /dev/null @@ -1,134 +0,0 @@ -settings = array_merge( - array('prefix' => 'cake_', 'duration' => 3600, 'probability' => 100), - $this->settings, - $settings - ); - if (!is_numeric($this->settings['duration'])) { - $this->settings['duration'] = strtotime($this->settings['duration']) - time(); - } - return true; - } - -/** - * Garbage collection - * - * Permanently remove all expired and deleted data - * @return void - */ - public function gc() { - } - -/** - * Write value for a key into cache - * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - * @param mixed $duration How long to cache for. - * @return boolean True if the data was successfully cached, false on failure - */ - abstract public function write($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 - */ - abstract public function read($key); - -/** - * Increment a number under the key and return incremented value - * - * @param string $key Identifier for the data - * @param integer $offset How much to add - * @return New incremented value, false otherwise - */ - abstract public function increment($key, $offset = 1); - -/** - * Decrement a number under the key and return decremented value - * - * @param string $key Identifier for the data - * @param integer $offset How much to subtract - * @return New incremented value, false otherwise - */ - abstract public function decrement($key, $offset = 1); - -/** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return boolean True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - abstract public function delete($key); - -/** - * Delete all keys from the cache - * - * @param boolean $check if true will check expiration, otherwise delete all - * @return boolean True if the cache was successfully cleared, false otherwise - */ - abstract public function clear($check); - -/** - * Cache Engine settings - * - * @return array settings - */ - public function settings() { - return $this->settings; - } - -/** - * Generates a safe key for use with cache engine storage engines. - * - * @param string $key the key passed over - * @return mixed string $key or false - */ - public function key($key) { - if (empty($key)) { - return false; - } - $key = Inflector::underscore(str_replace(array(DS, '/', '.'), '_', strval($key))); - return $key; - } - -} diff --git a/lib/Cake/Cache/Engine/ApcEngine.php b/lib/Cake/Cache/Engine/ApcEngine.php deleted file mode 100644 index a453ecd1fe5..00000000000 --- a/lib/Cake/Cache/Engine/ApcEngine.php +++ /dev/null @@ -1,130 +0,0 @@ - 'Apc', 'prefix' => Inflector::slug(APP_DIR) . '_'), $settings)); - return function_exists('apc_dec'); - } - -/** - * Write data for key into cache - * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - * @param integer $duration How long to cache the data, in seconds - * @return boolean True if the data was successfully cached, false on failure - */ - public function write($key, $value, $duration) { - if ($duration == 0) { - $expires = 0; - } else { - $expires = time() + $duration; - } - apc_store($key . '_expires', $expires, $duration); - return apc_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) { - $time = time(); - $cachetime = intval(apc_fetch($key . '_expires')); - if ($cachetime !== 0 && ($cachetime < $time || ($time + $this->settings['duration']) < $cachetime)) { - return false; - } - return apc_fetch($key); - } - -/** - * Increments the value of an integer cached key - * - * @param string $key Identifier for the data - * @param integer $offset How much to increment - * @return New incremented value, false otherwise - */ - public function increment($key, $offset = 1) { - return apc_inc($key, $offset); - } - -/** - * Decrements the value of an integer cached key - * - * @param string $key Identifier for the data - * @param integer $offset How much to subtract - * @return New decremented value, false otherwise - */ - public function decrement($key, $offset = 1) { - return apc_dec($key, $offset); - } - -/** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return boolean True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($key) { - return apc_delete($key); - } - -/** - * Delete all keys from the cache. This will clear every cache config using APC. - * - * @param boolean $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 boolean True Returns true. - */ - public function clear($check) { - if ($check) { - return true; - } - $info = apc_cache_info('user'); - $cacheKeys = $info['cache_list']; - unset($info); - foreach ($cacheKeys as $key) { - if (strpos($key['info'], $this->settings['prefix']) === 0) { - apc_delete($key['info']); - } - } - return true; - } - -} diff --git a/lib/Cake/Cache/Engine/FileEngine.php b/lib/Cake/Cache/Engine/FileEngine.php deleted file mode 100644 index 9213a5f2a37..00000000000 --- a/lib/Cake/Cache/Engine/FileEngine.php +++ /dev/null @@ -1,326 +0,0 @@ - CACHE - * - prefix = string prefix for filename, default => cake_ - * - lock = enable file locking on write, default => false - * - serialize = serialize the data, default => true - * - * @var array - * @see CacheEngine::__defaults - */ - public $settings = array(); - -/** - * True unless FileEngine::__active(); fails - * - * @var boolean - */ - protected $_init = true; - -/** - * Initialize the Cache Engine - * - * Called automatically by the cache frontend - * To reinitialize the settings call Cache::engine('EngineName', [optional] settings = array()); - * - * @param array $settings array of setting for the engine - * @return boolean True if the engine has been successfully initialized, false if not - */ - public function init($settings = array()) { - parent::init(array_merge( - array( - 'engine' => 'File', 'path' => CACHE, 'prefix' => 'cake_', 'lock' => true, - 'serialize' => true, 'isWindows' => false, 'mask' => 0664 - ), - $settings - )); - - if (DS === '\\') { - $this->settings['isWindows'] = true; - } - if (substr($this->settings['path'], -1) !== DS) { - $this->settings['path'] .= DS; - } - return $this->_active(); - } - -/** - * Garbage collection. Permanently remove all expired and deleted data - * - * @return boolean True if garbage collection was successful, false on failure - */ - public function gc() { - return $this->clear(true); - } - -/** - * Write data for key into cache - * - * @param string $key Identifier for the data - * @param mixed $data Data to be cached - * @param mixed $duration How long to cache the data, in seconds - * @return boolean True if the data was successfully cached, false on failure - */ - public function write($key, $data, $duration) { - if ($data === '' || !$this->_init) { - return false; - } - - if ($this->_setKey($key, true) === false) { - return false; - } - - $lineBreak = "\n"; - - if ($this->settings['isWindows']) { - $lineBreak = "\r\n"; - } - - if (!empty($this->settings['serialize'])) { - if ($this->settings['isWindows']) { - $data = str_replace('\\', '\\\\\\\\', serialize($data)); - } else { - $data = serialize($data); - } - } - - $expires = time() + $duration; - $contents = $expires . $lineBreak . $data . $lineBreak; - - if ($this->settings['lock']) { - $this->_File->flock(LOCK_EX); - } - - $this->_File->rewind(); - $success = $this->_File->ftruncate(0) && $this->_File->fwrite($contents) && $this->_File->fflush(); - - if ($this->settings['lock']) { - $this->_File->flock(LOCK_UN); - } - - 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 - */ - public function read($key) { - if (!$this->_init || $this->_setKey($key) === false) { - return false; - } - - if ($this->settings['lock']) { - $this->_File->flock(LOCK_SH); - } - - $this->_File->rewind(); - $time = time(); - $cachetime = intval($this->_File->current()); - - if ($cachetime !== false && ($cachetime < $time || ($time + $this->settings['duration']) < $cachetime)) { - if ($this->settings['lock']) { - $this->_File->flock(LOCK_UN); - } - return false; - } - - $data = ''; - $this->_File->next(); - while ($this->_File->valid()) { - $data .= $this->_File->current(); - $this->_File->next(); - } - - if ($this->settings['lock']) { - $this->_File->flock(LOCK_UN); - } - - $data = trim($data); - - if ($data !== '' && !empty($this->settings['serialize'])) { - if ($this->settings['isWindows']) { - $data = str_replace('\\\\\\\\', '\\', $data); - } - $data = unserialize((string)$data); - } - return $data; - } - -/** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return boolean True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($key) { - if ($this->_setKey($key) === false || !$this->_init) { - return false; - } - $path = $this->_File->getRealPath(); - $this->_File = null; - return unlink($path); - } - -/** - * Delete all values from the cache - * - * @param boolean $check Optional - only delete expired cache items - * @return boolean True if the cache was successfully cleared, false otherwise - */ - public function clear($check) { - if (!$this->_init) { - return false; - } - $dir = dir($this->settings['path']); - if ($check) { - $now = time(); - $threshold = $now - $this->settings['duration']; - } - $prefixLength = strlen($this->settings['prefix']); - while (($entry = $dir->read()) !== false) { - if (substr($entry, 0, $prefixLength) !== $this->settings['prefix']) { - continue; - } - if ($this->_setKey($entry) === false) { - continue; - } - if ($check) { - $mtime = $this->_File->getMTime(); - - if ($mtime > $threshold) { - continue; - } - - $expires = (int)$this->_File->current(); - - if ($expires > $now) { - continue; - } - } - $path = $this->_File->getRealPath(); - $this->_File = null; - if (file_exists($path)) { - unlink($path); - } - } - $dir->close(); - return true; - } - -/** - * Not implemented - * - * @param string $key - * @param integer $offset - * @return void - * @throws CacheException - */ - public function decrement($key, $offset = 1) { - throw new CacheException(__d('cake_dev', 'Files cannot be atomically decremented.')); - } - -/** - * Not implemented - * - * @param string $key - * @param integer $offset - * @return void - * @throws CacheException - */ - public function increment($key, $offset = 1) { - throw new CacheException(__d('cake_dev', 'Files cannot be atomically incremented.')); - } - -/** - * Sets the current cache key this class is managing, and creates a writable SplFileObject - * for the cache file the key is referring to. - * - * @param string $key The key - * @param boolean $createKey Whether the key should be created if it doesn't exists, or not - * @return boolean true if the cache key could be set, false otherwise - */ - protected function _setKey($key, $createKey = false) { - $path = new SplFileInfo($this->settings['path'] . $key); - - if (!$createKey && !$path->isFile()) { - return false; - } - if (empty($this->_File) || $this->_File->getBaseName() !== $key) { - $exists = file_exists($path->getPathname()); - try { - $this->_File = $path->openFile('c+'); - } catch (Exception $e) { - trigger_error($e->getMessage(), E_USER_WARNING); - return false; - } - unset($path); - - if (!$exists && !chmod($this->_File->getPathname(), (int)$this->settings['mask'])) { - trigger_error(__d( - 'cake_dev', 'Could not apply permission mask "%s" on cache file "%s"', - array($this->_File->getPathname(), $this->settings['mask'])), E_USER_WARNING); - } - } - return true; - } - -/** - * Determine is cache directory is writable - * - * @return boolean - */ - protected function _active() { - $dir = new SplFileInfo($this->settings['path']); - if ($this->_init && !($dir->isDir() && $dir->isWritable())) { - $this->_init = false; - trigger_error(__d('cake_dev', '%s is not writable', $this->settings['path']), E_USER_WARNING); - return false; - } - return true; - } - -} diff --git a/lib/Cake/Cache/Engine/MemcacheEngine.php b/lib/Cake/Cache/Engine/MemcacheEngine.php deleted file mode 100644 index e68035c3f05..00000000000 --- a/lib/Cake/Cache/Engine/MemcacheEngine.php +++ /dev/null @@ -1,238 +0,0 @@ - 127.0.0.1. If an - * array MemcacheEngine will use them as a pool. - * - compress = boolean, default => false - * - * @var array - */ - public $settings = array(); - -/** - * Initialize the Cache Engine - * - * Called automatically by the cache frontend - * To reinitialize the settings call Cache::engine('EngineName', [optional] settings = array()); - * - * @param array $settings array of setting for the engine - * @return boolean True if the engine has been successfully initialized, false if not - */ - public function init($settings = array()) { - if (!class_exists('Memcache')) { - return false; - } - parent::init(array_merge(array( - 'engine' => 'Memcache', - 'prefix' => Inflector::slug(APP_DIR) . '_', - 'servers' => array('127.0.0.1'), - 'compress' => false, - 'persistent' => true - ), $settings) - ); - - if ($this->settings['compress']) { - $this->settings['compress'] = MEMCACHE_COMPRESSED; - } - if (!is_array($this->settings['servers'])) { - $this->settings['servers'] = array($this->settings['servers']); - } - if (!isset($this->_Memcache)) { - $return = false; - $this->_Memcache = new Memcache(); - foreach ($this->settings['servers'] as $server) { - list($host, $port) = $this->_parseServerString($server); - if ($this->_Memcache->addServer($host, $port, $this->settings['persistent'])) { - $return = true; - } - } - return $return; - } - return true; - } - -/** - * Parses the server address into the host/port. Handles both IPv6 and IPv4 - * addresses and Unix sockets - * - * @param string $server The server address string. - * @return array Array containing host, port - */ - protected function _parseServerString($server) { - if ($server[0] == 'u') { - return array($server, 0); - } - if (substr($server, 0, 1) == '[') { - $position = strpos($server, ']:'); - if ($position !== false) { - $position++; - } - } else { - $position = strpos($server, ':'); - } - $port = 11211; - $host = $server; - if ($position !== false) { - $host = substr($server, 0, $position); - $port = substr($server, $position + 1); - } - return array($host, $port); - } - -/** - * Write data for key into cache. When using memcache as your cache engine - * remember that the Memcache 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. - * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - * @param integer $duration How long to cache the data, in seconds - * @return boolean True if the data was successfully cached, false on failure - * @see http://php.net/manual/en/memcache.set.php - */ - public function write($key, $value, $duration) { - if ($duration > 30 * DAY) { - $duration = 0; - } - return $this->_Memcache->set($key, $value, $this->settings['compress'], $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) { - return $this->_Memcache->get($key); - } - -/** - * Increments the value of an integer cached key - * - * @param string $key Identifier for the data - * @param integer $offset How much to increment - * @return New incremented value, false otherwise - * @throws CacheException when you try to increment with compress = true - */ - public function increment($key, $offset = 1) { - if ($this->settings['compress']) { - throw new CacheException( - __d('cake_dev', 'Method increment() not implemented for compressed cache in %s', __CLASS__) - ); - } - return $this->_Memcache->increment($key, $offset); - } - -/** - * Decrements the value of an integer cached key - * - * @param string $key Identifier for the data - * @param integer $offset How much to subtract - * @return New decremented value, false otherwise - * @throws CacheException when you try to decrement with compress = true - */ - public function decrement($key, $offset = 1) { - if ($this->settings['compress']) { - throw new CacheException( - __d('cake_dev', 'Method decrement() not implemented for compressed cache in %s', __CLASS__) - ); - } - return $this->_Memcache->decrement($key, $offset); - } - -/** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return boolean True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($key) { - return $this->_Memcache->delete($key); - } - -/** - * Delete all keys from the cache - * - * @param boolean $check - * @return boolean True if the cache was successfully cleared, false otherwise - */ - public function clear($check) { - if ($check) { - return true; - } - foreach ($this->_Memcache->getExtendedStats('slabs') as $slabs) { - foreach (array_keys($slabs) as $slabId) { - if (!is_numeric($slabId)) { - continue; - } - - foreach ($this->_Memcache->getExtendedStats('cachedump', $slabId) as $stats) { - if (!is_array($stats)) { - continue; - } - foreach (array_keys($stats) as $key) { - if (strpos($key, $this->settings['prefix']) === 0) { - $this->_Memcache->delete($key); - } - } - } - } - } - return true; - } - -/** - * Connects to a server in connection pool - * - * @param string $host host ip address or name - * @param integer $port Server port - * @return boolean True if memcache server was connected - */ - public function connect($host, $port = 11211) { - if ($this->_Memcache->getServerStatus($host, $port) === 0) { - if ($this->_Memcache->connect($host, $port)) { - return true; - } - return false; - } - return true; - } - -} diff --git a/lib/Cake/Cache/Engine/WincacheEngine.php b/lib/Cake/Cache/Engine/WincacheEngine.php deleted file mode 100644 index f6dc6de240c..00000000000 --- a/lib/Cake/Cache/Engine/WincacheEngine.php +++ /dev/null @@ -1,137 +0,0 @@ - 'Wincache', - 'prefix' => Inflector::slug(APP_DIR) . '_'), - $settings)); - return function_exists('wincache_ucache_info'); - } - -/** - * Write data for key into cache - * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - * @param integer $duration How long to cache the data, in seconds - * @return boolean True if the data was successfully cached, false on failure - */ - public function write($key, $value, $duration) { - $expires = time() + $duration; - - $data = array( - $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) { - $time = time(); - $cachetime = intval(wincache_ucache_get($key . '_expires')); - if ($cachetime < $time || ($time + $this->settings['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 integer $offset How much to increment - * @return New incremented value, false otherwise - */ - public function increment($key, $offset = 1) { - return wincache_ucache_inc($key, $offset); - } - -/** - * Decrements the value of an integer cached key - * - * @param string $key Identifier for the data - * @param integer $offset How much to subtract - * @return New decremented value, false otherwise - */ - public function decrement($key, $offset = 1) { - return wincache_ucache_dec($key, $offset); - } - -/** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return boolean True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($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 boolean $check If true, nothing will be cleared, as entries will - * naturally expire in wincache.. - * @return boolean 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->settings['prefix']) === 0) { - wincache_ucache_delete($key['key_name']); - } - } - return true; - } - -} diff --git a/lib/Cake/Cache/Engine/XcacheEngine.php b/lib/Cake/Cache/Engine/XcacheEngine.php deleted file mode 100644 index d41cfdb6db1..00000000000 --- a/lib/Cake/Cache/Engine/XcacheEngine.php +++ /dev/null @@ -1,177 +0,0 @@ - 'Xcache', - 'prefix' => Inflector::slug(APP_DIR) . '_', - 'PHP_AUTH_USER' => 'user', - 'PHP_AUTH_PW' => 'password' - ), $settings) - ); - return function_exists('xcache_info'); - } - -/** - * Write data for key into cache - * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - * @param integer $duration How long to cache the data, in seconds - * @return boolean True if the data was successfully cached, false on failure - */ - public function write($key, $value, $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) { - if (xcache_isset($key)) { - $time = time(); - $cachetime = intval(xcache_get($key . '_expires')); - if ($cachetime < $time || ($time + $this->settings['duration']) < $cachetime) { - return false; - } - return xcache_get($key); - } - 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 integer $offset How much to increment - * @return New incremented value, false otherwise - */ - public function increment($key, $offset = 1) { - 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 integer $offset How much to subtract - * @return New decremented value, false otherwise - */ - public function decrement($key, $offset = 1) { - return xcache_dec($key, $offset); - } - -/** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return boolean True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($key) { - return xcache_unset($key); - } - -/** - * Delete all keys from the cache - * - * @param boolean $check - * @return boolean 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; - } - -/** - * 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 settings) - * - * @param boolean $reverse Revert changes - * @return void - */ - protected function _auth($reverse = false) { - static $backup = array(); - $keys = array('PHP_AUTH_USER' => 'user', 'PHP_AUTH_PW' => 'password'); - foreach ($keys as $key => $setting) { - 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->settings[$setting])) { - $_SERVER[$key] = $this->settings[$setting]; - } elseif (!empty($this->settings[$key])) { - $_SERVER[$key] = $this->settings[$key]; - } else { - $_SERVER[$key] = $value; - } - } - } - } - -} diff --git a/lib/Cake/Config/config.php b/lib/Cake/Config/config.php deleted file mode 100644 index f8528e8ef14..00000000000 --- a/lib/Cake/Config/config.php +++ /dev/null @@ -1,21 +0,0 @@ - $value) { - $plugins[$key] = Inflector::underscore($value); - } - $pluginPattern = implode('|', $plugins); - $match = array('plugin' => $pluginPattern); - $shortParams = array('routeClass' => 'PluginShortRoute', 'plugin' => $pluginPattern); - - foreach ($prefixes as $prefix) { - $params = array('prefix' => $prefix, $prefix => true); - $indexParams = $params + array('action' => 'index'); - Router::connect("/{$prefix}/:plugin", $indexParams, $shortParams); - Router::connect("/{$prefix}/:plugin/:controller", $indexParams, $match); - Router::connect("/{$prefix}/:plugin/:controller/:action/*", $params, $match); - } - Router::connect('/:plugin', array('action' => 'index'), $shortParams); - Router::connect('/:plugin/:controller', array('action' => 'index'), $match); - Router::connect('/:plugin/:controller/:action/*', array(), $match); -} - -foreach ($prefixes as $prefix) { - $params = array('prefix' => $prefix, $prefix => true); - $indexParams = $params + array('action' => 'index'); - Router::connect("/{$prefix}/:controller", $indexParams); - Router::connect("/{$prefix}/:controller/:action/*", $params); -} -Router::connect('/:controller', array('action' => 'index')); -Router::connect('/:controller/:action/*'); - -$namedConfig = Router::namedConfig(); -if ($namedConfig['rules'] === false) { - Router::connectNamed(true); -} - -unset($namedConfig, $params, $indexParams, $prefix, $prefixes, $shortParams, $match, - $pluginPattern, $plugins, $key, $value); diff --git a/lib/Cake/Config/unicode/casefolding/0080_00ff.php b/lib/Cake/Config/unicode/casefolding/0080_00ff.php deleted file mode 100644 index 542f47d2b37..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0080_00ff.php +++ /dev/null @@ -1,73 +0,0 @@ - 181, 'status' => 'C', 'lower' => array(956)); -$config['0080_00ff'][] = array('upper' => 924, 'status' => 'C', 'lower' => array(181)); -$config['0080_00ff'][] = array('upper' => 192, 'status' => 'C', 'lower' => array(224)); /* LATIN CAPITAL LETTER A WITH GRAVE */ -$config['0080_00ff'][] = array('upper' => 193, 'status' => 'C', 'lower' => array(225)); /* LATIN CAPITAL LETTER A WITH ACUTE */ -$config['0080_00ff'][] = array('upper' => 194, 'status' => 'C', 'lower' => array(226)); /* LATIN CAPITAL LETTER A WITH CIRCUMFLEX */ -$config['0080_00ff'][] = array('upper' => 195, 'status' => 'C', 'lower' => array(227)); /* LATIN CAPITAL LETTER A WITH TILDE */ -$config['0080_00ff'][] = array('upper' => 196, 'status' => 'C', 'lower' => array(228)); /* LATIN CAPITAL LETTER A WITH DIAERESIS */ -$config['0080_00ff'][] = array('upper' => 197, 'status' => 'C', 'lower' => array(229)); /* LATIN CAPITAL LETTER A WITH RING ABOVE */ -$config['0080_00ff'][] = array('upper' => 198, 'status' => 'C', 'lower' => array(230)); /* LATIN CAPITAL LETTER AE */ -$config['0080_00ff'][] = array('upper' => 199, 'status' => 'C', 'lower' => array(231)); /* LATIN CAPITAL LETTER C WITH CEDILLA */ -$config['0080_00ff'][] = array('upper' => 200, 'status' => 'C', 'lower' => array(232)); /* LATIN CAPITAL LETTER E WITH GRAVE */ -$config['0080_00ff'][] = array('upper' => 201, 'status' => 'C', 'lower' => array(233)); /* LATIN CAPITAL LETTER E WITH ACUTE */ -$config['0080_00ff'][] = array('upper' => 202, 'status' => 'C', 'lower' => array(234)); /* LATIN CAPITAL LETTER E WITH CIRCUMFLEX */ -$config['0080_00ff'][] = array('upper' => 203, 'status' => 'C', 'lower' => array(235)); /* LATIN CAPITAL LETTER E WITH DIAERESIS */ -$config['0080_00ff'][] = array('upper' => 204, 'status' => 'C', 'lower' => array(236)); /* LATIN CAPITAL LETTER I WITH GRAVE */ -$config['0080_00ff'][] = array('upper' => 205, 'status' => 'C', 'lower' => array(237)); /* LATIN CAPITAL LETTER I WITH ACUTE */ -$config['0080_00ff'][] = array('upper' => 206, 'status' => 'C', 'lower' => array(238)); /* LATIN CAPITAL LETTER I WITH CIRCUMFLEX */ -$config['0080_00ff'][] = array('upper' => 207, 'status' => 'C', 'lower' => array(239)); /* LATIN CAPITAL LETTER I WITH DIAERESIS */ -$config['0080_00ff'][] = array('upper' => 208, 'status' => 'C', 'lower' => array(240)); /* LATIN CAPITAL LETTER ETH */ -$config['0080_00ff'][] = array('upper' => 209, 'status' => 'C', 'lower' => array(241)); /* LATIN CAPITAL LETTER N WITH TILDE */ -$config['0080_00ff'][] = array('upper' => 210, 'status' => 'C', 'lower' => array(242)); /* LATIN CAPITAL LETTER O WITH GRAVE */ -$config['0080_00ff'][] = array('upper' => 211, 'status' => 'C', 'lower' => array(243)); /* LATIN CAPITAL LETTER O WITH ACUTE */ -$config['0080_00ff'][] = array('upper' => 212, 'status' => 'C', 'lower' => array(244)); /* LATIN CAPITAL LETTER O WITH CIRCUMFLEX */ -$config['0080_00ff'][] = array('upper' => 213, 'status' => 'C', 'lower' => array(245)); /* LATIN CAPITAL LETTER O WITH TILDE */ -$config['0080_00ff'][] = array('upper' => 214, 'status' => 'C', 'lower' => array(246)); /* LATIN CAPITAL LETTER O WITH DIAERESIS */ -$config['0080_00ff'][] = array('upper' => 216, 'status' => 'C', 'lower' => array(248)); /* LATIN CAPITAL LETTER O WITH STROKE */ -$config['0080_00ff'][] = array('upper' => 217, 'status' => 'C', 'lower' => array(249)); /* LATIN CAPITAL LETTER U WITH GRAVE */ -$config['0080_00ff'][] = array('upper' => 218, 'status' => 'C', 'lower' => array(250)); /* LATIN CAPITAL LETTER U WITH ACUTE */ -$config['0080_00ff'][] = array('upper' => 219, 'status' => 'C', 'lower' => array(251)); /* LATIN CAPITAL LETTER U WITH CIRCUMFLEX */ -$config['0080_00ff'][] = array('upper' => 220, 'status' => 'C', 'lower' => array(252)); /* LATIN CAPITAL LETTER U WITH DIAERESIS */ -$config['0080_00ff'][] = array('upper' => 221, 'status' => 'C', 'lower' => array(253)); /* LATIN CAPITAL LETTER Y WITH ACUTE */ -$config['0080_00ff'][] = array('upper' => 222, 'status' => 'C', 'lower' => array(254)); /* LATIN CAPITAL LETTER THORN */ -$config['0080_00ff'][] = array('upper' => 223, 'status' => 'F', 'lower' => array(115, 115)); /* LATIN SMALL LETTER SHARP S */ diff --git a/lib/Cake/Config/unicode/casefolding/0100_017f.php b/lib/Cake/Config/unicode/casefolding/0100_017f.php deleted file mode 100644 index e8c3ff6c427..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0100_017f.php +++ /dev/null @@ -1,106 +0,0 @@ - 256, 'status' => 'C', 'lower' => array(257)); /* LATIN CAPITAL LETTER A WITH MACRON */ -$config['0100_017f'][] = array('upper' => 258, 'status' => 'C', 'lower' => array(259)); /* LATIN CAPITAL LETTER A WITH BREVE */ -$config['0100_017f'][] = array('upper' => 260, 'status' => 'C', 'lower' => array(261)); /* LATIN CAPITAL LETTER A WITH OGONEK */ -$config['0100_017f'][] = array('upper' => 262, 'status' => 'C', 'lower' => array(263)); /* LATIN CAPITAL LETTER C WITH ACUTE */ -$config['0100_017f'][] = array('upper' => 264, 'status' => 'C', 'lower' => array(265)); /* LATIN CAPITAL LETTER C WITH CIRCUMFLEX */ -$config['0100_017f'][] = array('upper' => 266, 'status' => 'C', 'lower' => array(267)); /* LATIN CAPITAL LETTER C WITH DOT ABOVE */ -$config['0100_017f'][] = array('upper' => 268, 'status' => 'C', 'lower' => array(269)); /* LATIN CAPITAL LETTER C WITH CARON */ -$config['0100_017f'][] = array('upper' => 270, 'status' => 'C', 'lower' => array(271)); /* LATIN CAPITAL LETTER D WITH CARON */ -$config['0100_017f'][] = array('upper' => 272, 'status' => 'C', 'lower' => array(273)); /* LATIN CAPITAL LETTER D WITH STROKE */ -$config['0100_017f'][] = array('upper' => 274, 'status' => 'C', 'lower' => array(275)); /* LATIN CAPITAL LETTER E WITH MACRON */ -$config['0100_017f'][] = array('upper' => 276, 'status' => 'C', 'lower' => array(277)); /* LATIN CAPITAL LETTER E WITH BREVE */ -$config['0100_017f'][] = array('upper' => 278, 'status' => 'C', 'lower' => array(279)); /* LATIN CAPITAL LETTER E WITH DOT ABOVE */ -$config['0100_017f'][] = array('upper' => 280, 'status' => 'C', 'lower' => array(281)); /* LATIN CAPITAL LETTER E WITH OGONEK */ -$config['0100_017f'][] = array('upper' => 282, 'status' => 'C', 'lower' => array(283)); /* LATIN CAPITAL LETTER E WITH CARON */ -$config['0100_017f'][] = array('upper' => 284, 'status' => 'C', 'lower' => array(285)); /* LATIN CAPITAL LETTER G WITH CIRCUMFLEX */ -$config['0100_017f'][] = array('upper' => 286, 'status' => 'C', 'lower' => array(287)); /* LATIN CAPITAL LETTER G WITH BREVE */ -$config['0100_017f'][] = array('upper' => 288, 'status' => 'C', 'lower' => array(289)); /* LATIN CAPITAL LETTER G WITH DOT ABOVE */ -$config['0100_017f'][] = array('upper' => 290, 'status' => 'C', 'lower' => array(291)); /* LATIN CAPITAL LETTER G WITH CEDILLA */ -$config['0100_017f'][] = array('upper' => 292, 'status' => 'C', 'lower' => array(293)); /* LATIN CAPITAL LETTER H WITH CIRCUMFLEX */ -$config['0100_017f'][] = array('upper' => 294, 'status' => 'C', 'lower' => array(295)); /* LATIN CAPITAL LETTER H WITH STROKE */ -$config['0100_017f'][] = array('upper' => 296, 'status' => 'C', 'lower' => array(297)); /* LATIN CAPITAL LETTER I WITH TILDE */ -$config['0100_017f'][] = array('upper' => 298, 'status' => 'C', 'lower' => array(299)); /* LATIN CAPITAL LETTER I WITH MACRON */ -$config['0100_017f'][] = array('upper' => 300, 'status' => 'C', 'lower' => array(301)); /* LATIN CAPITAL LETTER I WITH BREVE */ -$config['0100_017f'][] = array('upper' => 302, 'status' => 'C', 'lower' => array(303)); /* LATIN CAPITAL LETTER I WITH OGONEK */ -$config['0100_017f'][] = array('upper' => 304, 'status' => 'F', 'lower' => array(105, 775)); /* LATIN CAPITAL LETTER I WITH DOT ABOVE */ -$config['0100_017f'][] = array('upper' => 304, 'status' => 'T', 'lower' => array(105)); /* LATIN CAPITAL LETTER I WITH DOT ABOVE */ -$config['0100_017f'][] = array('upper' => 306, 'status' => 'C', 'lower' => array(307)); /* LATIN CAPITAL LIGATURE IJ */ -$config['0100_017f'][] = array('upper' => 308, 'status' => 'C', 'lower' => array(309)); /* LATIN CAPITAL LETTER J WITH CIRCUMFLEX */ -$config['0100_017f'][] = array('upper' => 310, 'status' => 'C', 'lower' => array(311)); /* LATIN CAPITAL LETTER K WITH CEDILLA */ -$config['0100_017f'][] = array('upper' => 313, 'status' => 'C', 'lower' => array(314)); /* LATIN CAPITAL LETTER L WITH ACUTE */ -$config['0100_017f'][] = array('upper' => 315, 'status' => 'C', 'lower' => array(316)); /* LATIN CAPITAL LETTER L WITH CEDILLA */ -$config['0100_017f'][] = array('upper' => 317, 'status' => 'C', 'lower' => array(318)); /* LATIN CAPITAL LETTER L WITH CARON */ -$config['0100_017f'][] = array('upper' => 319, 'status' => 'C', 'lower' => array(320)); /* LATIN CAPITAL LETTER L WITH MIDDLE DOT */ -$config['0100_017f'][] = array('upper' => 321, 'status' => 'C', 'lower' => array(322)); /* LATIN CAPITAL LETTER L WITH STROKE */ -$config['0100_017f'][] = array('upper' => 323, 'status' => 'C', 'lower' => array(324)); /* LATIN CAPITAL LETTER N WITH ACUTE */ -$config['0100_017f'][] = array('upper' => 325, 'status' => 'C', 'lower' => array(326)); /* LATIN CAPITAL LETTER N WITH CEDILLA */ -$config['0100_017f'][] = array('upper' => 327, 'status' => 'C', 'lower' => array(328)); /* LATIN CAPITAL LETTER N WITH CARON */ -$config['0100_017f'][] = array('upper' => 329, 'status' => 'F', 'lower' => array(700, 110)); /* LATIN SMALL LETTER N PRECEDED BY APOSTROPHE */ -$config['0100_017f'][] = array('upper' => 330, 'status' => 'C', 'lower' => array(331)); /* LATIN CAPITAL LETTER ENG */ -$config['0100_017f'][] = array('upper' => 332, 'status' => 'C', 'lower' => array(333)); /* LATIN CAPITAL LETTER O WITH MACRON */ -$config['0100_017f'][] = array('upper' => 334, 'status' => 'C', 'lower' => array(335)); /* LATIN CAPITAL LETTER O WITH BREVE */ -$config['0100_017f'][] = array('upper' => 336, 'status' => 'C', 'lower' => array(337)); /* LATIN CAPITAL LETTER O WITH DOUBLE ACUTE */ -$config['0100_017f'][] = array('upper' => 338, 'status' => 'C', 'lower' => array(339)); /* LATIN CAPITAL LIGATURE OE */ -$config['0100_017f'][] = array('upper' => 340, 'status' => 'C', 'lower' => array(341)); /* LATIN CAPITAL LETTER R WITH ACUTE */ -$config['0100_017f'][] = array('upper' => 342, 'status' => 'C', 'lower' => array(343)); /* LATIN CAPITAL LETTER R WITH CEDILLA */ -$config['0100_017f'][] = array('upper' => 344, 'status' => 'C', 'lower' => array(345)); /* LATIN CAPITAL LETTER R WITH CARON */ -$config['0100_017f'][] = array('upper' => 346, 'status' => 'C', 'lower' => array(347)); /* LATIN CAPITAL LETTER S WITH ACUTE */ -$config['0100_017f'][] = array('upper' => 348, 'status' => 'C', 'lower' => array(349)); /* LATIN CAPITAL LETTER S WITH CIRCUMFLEX */ -$config['0100_017f'][] = array('upper' => 350, 'status' => 'C', 'lower' => array(351)); /* LATIN CAPITAL LETTER S WITH CEDILLA */ -$config['0100_017f'][] = array('upper' => 352, 'status' => 'C', 'lower' => array(353)); /* LATIN CAPITAL LETTER S WITH CARON */ -$config['0100_017f'][] = array('upper' => 354, 'status' => 'C', 'lower' => array(355)); /* LATIN CAPITAL LETTER T WITH CEDILLA */ -$config['0100_017f'][] = array('upper' => 356, 'status' => 'C', 'lower' => array(357)); /* LATIN CAPITAL LETTER T WITH CARON */ -$config['0100_017f'][] = array('upper' => 358, 'status' => 'C', 'lower' => array(359)); /* LATIN CAPITAL LETTER T WITH STROKE */ -$config['0100_017f'][] = array('upper' => 360, 'status' => 'C', 'lower' => array(361)); /* LATIN CAPITAL LETTER U WITH TILDE */ -$config['0100_017f'][] = array('upper' => 362, 'status' => 'C', 'lower' => array(363)); /* LATIN CAPITAL LETTER U WITH MACRON */ -$config['0100_017f'][] = array('upper' => 364, 'status' => 'C', 'lower' => array(365)); /* LATIN CAPITAL LETTER U WITH BREVE */ -$config['0100_017f'][] = array('upper' => 366, 'status' => 'C', 'lower' => array(367)); /* LATIN CAPITAL LETTER U WITH RING ABOVE */ -$config['0100_017f'][] = array('upper' => 368, 'status' => 'C', 'lower' => array(369)); /* LATIN CAPITAL LETTER U WITH DOUBLE ACUTE */ -$config['0100_017f'][] = array('upper' => 370, 'status' => 'C', 'lower' => array(371)); /* LATIN CAPITAL LETTER U WITH OGONEK */ -$config['0100_017f'][] = array('upper' => 372, 'status' => 'C', 'lower' => array(373)); /* LATIN CAPITAL LETTER W WITH CIRCUMFLEX */ -$config['0100_017f'][] = array('upper' => 374, 'status' => 'C', 'lower' => array(375)); /* LATIN CAPITAL LETTER Y WITH CIRCUMFLEX */ -$config['0100_017f'][] = array('upper' => 376, 'status' => 'C', 'lower' => array(255)); /* LATIN CAPITAL LETTER Y WITH DIAERESIS */ -$config['0100_017f'][] = array('upper' => 377, 'status' => 'C', 'lower' => array(378)); /* LATIN CAPITAL LETTER Z WITH ACUTE */ -$config['0100_017f'][] = array('upper' => 379, 'status' => 'C', 'lower' => array(380)); /* LATIN CAPITAL LETTER Z WITH DOT ABOVE */ -$config['0100_017f'][] = array('upper' => 381, 'status' => 'C', 'lower' => array(382)); /* LATIN CAPITAL LETTER Z WITH CARON */ -$config['0100_017f'][] = array('upper' => 383, 'status' => 'C', 'lower' => array(115)); /* LATIN SMALL LETTER LONG S */ diff --git a/lib/Cake/Config/unicode/casefolding/0180_024F.php b/lib/Cake/Config/unicode/casefolding/0180_024F.php deleted file mode 100644 index e8baaa32c2f..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0180_024F.php +++ /dev/null @@ -1,148 +0,0 @@ - 385, 'status' => 'C', 'lower' => array(595)); /* LATIN CAPITAL LETTER B WITH HOOK */ -$config['0180_024F'][] = array('upper' => 386, 'status' => 'C', 'lower' => array(387)); /* LATIN CAPITAL LETTER B WITH TOPBAR */ -$config['0180_024F'][] = array('upper' => 388, 'status' => 'C', 'lower' => array(389)); /* LATIN CAPITAL LETTER TONE SIX */ -$config['0180_024F'][] = array('upper' => 390, 'status' => 'C', 'lower' => array(596)); /* LATIN CAPITAL LETTER OPEN O */ -$config['0180_024F'][] = array('upper' => 391, 'status' => 'C', 'lower' => array(392)); /* LATIN CAPITAL LETTER C WITH HOOK */ -$config['0180_024F'][] = array('upper' => 393, 'status' => 'C', 'lower' => array(598)); /* LATIN CAPITAL LETTER AFRICAN D */ -$config['0180_024F'][] = array('upper' => 394, 'status' => 'C', 'lower' => array(599)); /* LATIN CAPITAL LETTER D WITH HOOK */ -$config['0180_024F'][] = array('upper' => 395, 'status' => 'C', 'lower' => array(396)); /* LATIN CAPITAL LETTER D WITH TOPBAR */ -$config['0180_024F'][] = array('upper' => 398, 'status' => 'C', 'lower' => array(477)); /* LATIN CAPITAL LETTER REVERSED E */ -$config['0180_024F'][] = array('upper' => 399, 'status' => 'C', 'lower' => array(601)); /* LATIN CAPITAL LETTER SCHWA */ -$config['0180_024F'][] = array('upper' => 400, 'status' => 'C', 'lower' => array(603)); /* LATIN CAPITAL LETTER OPEN E */ -$config['0180_024F'][] = array('upper' => 401, 'status' => 'C', 'lower' => array(402)); /* LATIN CAPITAL LETTER F WITH HOOK */ -$config['0180_024F'][] = array('upper' => 403, 'status' => 'C', 'lower' => array(608)); /* LATIN CAPITAL LETTER G WITH HOOK */ -$config['0180_024F'][] = array('upper' => 404, 'status' => 'C', 'lower' => array(611)); /* LATIN CAPITAL LETTER GAMMA */ -$config['0180_024F'][] = array('upper' => 406, 'status' => 'C', 'lower' => array(617)); /* LATIN CAPITAL LETTER IOTA */ -$config['0180_024F'][] = array('upper' => 407, 'status' => 'C', 'lower' => array(616)); /* LATIN CAPITAL LETTER I WITH STROKE */ -$config['0180_024F'][] = array('upper' => 408, 'status' => 'C', 'lower' => array(409)); /* LATIN CAPITAL LETTER K WITH HOOK */ -$config['0180_024F'][] = array('upper' => 412, 'status' => 'C', 'lower' => array(623)); /* LATIN CAPITAL LETTER TURNED M */ -$config['0180_024F'][] = array('upper' => 413, 'status' => 'C', 'lower' => array(626)); /* LATIN CAPITAL LETTER N WITH LEFT HOOK */ -$config['0180_024F'][] = array('upper' => 415, 'status' => 'C', 'lower' => array(629)); /* LATIN CAPITAL LETTER O WITH MIDDLE TILDE */ -$config['0180_024F'][] = array('upper' => 416, 'status' => 'C', 'lower' => array(417)); /* LATIN CAPITAL LETTER O WITH HORN */ -$config['0180_024F'][] = array('upper' => 418, 'status' => 'C', 'lower' => array(419)); /* LATIN CAPITAL LETTER OI */ -$config['0180_024F'][] = array('upper' => 420, 'status' => 'C', 'lower' => array(421)); /* LATIN CAPITAL LETTER P WITH HOOK */ -$config['0180_024F'][] = array('upper' => 422, 'status' => 'C', 'lower' => array(640)); /* LATIN LETTER YR */ -$config['0180_024F'][] = array('upper' => 423, 'status' => 'C', 'lower' => array(424)); /* LATIN CAPITAL LETTER TONE TWO */ -$config['0180_024F'][] = array('upper' => 425, 'status' => 'C', 'lower' => array(643)); /* LATIN CAPITAL LETTER ESH */ -$config['0180_024F'][] = array('upper' => 428, 'status' => 'C', 'lower' => array(429)); /* LATIN CAPITAL LETTER T WITH HOOK */ -$config['0180_024F'][] = array('upper' => 430, 'status' => 'C', 'lower' => array(648)); /* LATIN CAPITAL LETTER T WITH RETROFLEX HOOK */ -$config['0180_024F'][] = array('upper' => 431, 'status' => 'C', 'lower' => array(432)); /* LATIN CAPITAL LETTER U WITH HORN */ -$config['0180_024F'][] = array('upper' => 433, 'status' => 'C', 'lower' => array(650)); /* LATIN CAPITAL LETTER UPSILON */ -$config['0180_024F'][] = array('upper' => 434, 'status' => 'C', 'lower' => array(651)); /* LATIN CAPITAL LETTER V WITH HOOK */ -$config['0180_024F'][] = array('upper' => 435, 'status' => 'C', 'lower' => array(436)); /* LATIN CAPITAL LETTER Y WITH HOOK */ -$config['0180_024F'][] = array('upper' => 437, 'status' => 'C', 'lower' => array(438)); /* LATIN CAPITAL LETTER Z WITH STROKE */ -$config['0180_024F'][] = array('upper' => 439, 'status' => 'C', 'lower' => array(658)); /* LATIN CAPITAL LETTER EZH */ -$config['0180_024F'][] = array('upper' => 440, 'status' => 'C', 'lower' => array(441)); /* LATIN CAPITAL LETTER EZH REVERSED */ -$config['0180_024F'][] = array('upper' => 444, 'status' => 'C', 'lower' => array(445)); /* LATIN CAPITAL LETTER TONE FIVE */ -$config['0180_024F'][] = array('upper' => 452, 'status' => 'C', 'lower' => array(454)); /* LATIN CAPITAL LETTER DZ WITH CARON */ -$config['0180_024F'][] = array('upper' => 453, 'status' => 'C', 'lower' => array(454)); /* LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON */ -$config['0180_024F'][] = array('upper' => 455, 'status' => 'C', 'lower' => array(457)); /* LATIN CAPITAL LETTER LJ */ -$config['0180_024F'][] = array('upper' => 456, 'status' => 'C', 'lower' => array(457)); /* LATIN CAPITAL LETTER L WITH SMALL LETTER J */ -$config['0180_024F'][] = array('upper' => 458, 'status' => 'C', 'lower' => array(460)); /* LATIN CAPITAL LETTER NJ */ -$config['0180_024F'][] = array('upper' => 459, 'status' => 'C', 'lower' => array(460)); /* LATIN CAPITAL LETTER N WITH SMALL LETTER J */ -$config['0180_024F'][] = array('upper' => 461, 'status' => 'C', 'lower' => array(462)); /* LATIN CAPITAL LETTER A WITH CARON */ -$config['0180_024F'][] = array('upper' => 463, 'status' => 'C', 'lower' => array(464)); /* LATIN CAPITAL LETTER I WITH CARON */ -$config['0180_024F'][] = array('upper' => 465, 'status' => 'C', 'lower' => array(466)); /* LATIN CAPITAL LETTER O WITH CARON */ -$config['0180_024F'][] = array('upper' => 467, 'status' => 'C', 'lower' => array(468)); /* LATIN CAPITAL LETTER U WITH CARON */ -$config['0180_024F'][] = array('upper' => 469, 'status' => 'C', 'lower' => array(470)); /* LATIN CAPITAL LETTER U WITH DIAERESIS AND MACRON */ -$config['0180_024F'][] = array('upper' => 471, 'status' => 'C', 'lower' => array(472)); /* LATIN CAPITAL LETTER U WITH DIAERESIS AND ACUTE */ -$config['0180_024F'][] = array('upper' => 473, 'status' => 'C', 'lower' => array(474)); /* LATIN CAPITAL LETTER U WITH DIAERESIS AND CARON */ -$config['0180_024F'][] = array('upper' => 475, 'status' => 'C', 'lower' => array(476)); /* LATIN CAPITAL LETTER U WITH DIAERESIS AND GRAVE */ -$config['0180_024F'][] = array('upper' => 478, 'status' => 'C', 'lower' => array(479)); /* LATIN CAPITAL LETTER A WITH DIAERESIS AND MACRON */ -$config['0180_024F'][] = array('upper' => 480, 'status' => 'C', 'lower' => array(481)); /* LATIN CAPITAL LETTER A WITH DOT ABOVE AND MACRON */ -$config['0180_024F'][] = array('upper' => 482, 'status' => 'C', 'lower' => array(483)); /* LATIN CAPITAL LETTER AE WITH MACRON */ -$config['0180_024F'][] = array('upper' => 484, 'status' => 'C', 'lower' => array(485)); /* LATIN CAPITAL LETTER G WITH STROKE */ -$config['0180_024F'][] = array('upper' => 486, 'status' => 'C', 'lower' => array(487)); /* LATIN CAPITAL LETTER G WITH CARON */ -$config['0180_024F'][] = array('upper' => 488, 'status' => 'C', 'lower' => array(489)); /* LATIN CAPITAL LETTER K WITH CARON */ -$config['0180_024F'][] = array('upper' => 490, 'status' => 'C', 'lower' => array(491)); /* LATIN CAPITAL LETTER O WITH OGONEK */ -$config['0180_024F'][] = array('upper' => 492, 'status' => 'C', 'lower' => array(493)); /* LATIN CAPITAL LETTER O WITH OGONEK AND MACRON */ -$config['0180_024F'][] = array('upper' => 494, 'status' => 'C', 'lower' => array(495)); /* LATIN CAPITAL LETTER EZH WITH CARON */ -$config['0180_024F'][] = array('upper' => 496, 'status' => 'F', 'lower' => array(106, 780)); /* LATIN SMALL LETTER J WITH CARON */ -$config['0180_024F'][] = array('upper' => 497, 'status' => 'C', 'lower' => array(499)); /* LATIN CAPITAL LETTER DZ */ -$config['0180_024F'][] = array('upper' => 498, 'status' => 'C', 'lower' => array(499)); /* LATIN CAPITAL LETTER D WITH SMALL LETTER Z */ -$config['0180_024F'][] = array('upper' => 500, 'status' => 'C', 'lower' => array(501)); /* LATIN CAPITAL LETTER G WITH ACUTE */ -$config['0180_024F'][] = array('upper' => 502, 'status' => 'C', 'lower' => array(405)); /* LATIN CAPITAL LETTER HWAIR */ -$config['0180_024F'][] = array('upper' => 503, 'status' => 'C', 'lower' => array(447)); /* LATIN CAPITAL LETTER WYNN */ -$config['0180_024F'][] = array('upper' => 504, 'status' => 'C', 'lower' => array(505)); /* LATIN CAPITAL LETTER N WITH GRAVE */ -$config['0180_024F'][] = array('upper' => 506, 'status' => 'C', 'lower' => array(507)); /* LATIN CAPITAL LETTER A WITH RING ABOVE AND ACUTE */ -$config['0180_024F'][] = array('upper' => 508, 'status' => 'C', 'lower' => array(509)); /* LATIN CAPITAL LETTER AE WITH ACUTE */ -$config['0180_024F'][] = array('upper' => 510, 'status' => 'C', 'lower' => array(511)); /* LATIN CAPITAL LETTER O WITH STROKE AND ACUTE */ -$config['0180_024F'][] = array('upper' => 512, 'status' => 'C', 'lower' => array(513)); /* LATIN CAPITAL LETTER A WITH DOUBLE GRAVE */ -$config['0180_024F'][] = array('upper' => 514, 'status' => 'C', 'lower' => array(515)); /* LATIN CAPITAL LETTER A WITH INVERTED BREVE */ -$config['0180_024F'][] = array('upper' => 516, 'status' => 'C', 'lower' => array(517)); /* LATIN CAPITAL LETTER E WITH DOUBLE GRAVE */ -$config['0180_024F'][] = array('upper' => 518, 'status' => 'C', 'lower' => array(519)); /* LATIN CAPITAL LETTER E WITH INVERTED BREVE */ -$config['0180_024F'][] = array('upper' => 520, 'status' => 'C', 'lower' => array(521)); /* LATIN CAPITAL LETTER I WITH DOUBLE GRAVE */ -$config['0180_024F'][] = array('upper' => 522, 'status' => 'C', 'lower' => array(523)); /* LATIN CAPITAL LETTER I WITH INVERTED BREVE */ -$config['0180_024F'][] = array('upper' => 524, 'status' => 'C', 'lower' => array(525)); /* LATIN CAPITAL LETTER O WITH DOUBLE GRAVE */ -$config['0180_024F'][] = array('upper' => 526, 'status' => 'C', 'lower' => array(527)); /* LATIN CAPITAL LETTER O WITH INVERTED BREVE */ -$config['0180_024F'][] = array('upper' => 528, 'status' => 'C', 'lower' => array(529)); /* LATIN CAPITAL LETTER R WITH DOUBLE GRAVE */ -$config['0180_024F'][] = array('upper' => 530, 'status' => 'C', 'lower' => array(531)); /* LATIN CAPITAL LETTER R WITH INVERTED BREVE */ -$config['0180_024F'][] = array('upper' => 532, 'status' => 'C', 'lower' => array(533)); /* LATIN CAPITAL LETTER U WITH DOUBLE GRAVE */ -$config['0180_024F'][] = array('upper' => 534, 'status' => 'C', 'lower' => array(535)); /* LATIN CAPITAL LETTER U WITH INVERTED BREVE */ -$config['0180_024F'][] = array('upper' => 536, 'status' => 'C', 'lower' => array(537)); /* LATIN CAPITAL LETTER S WITH COMMA BELOW */ -$config['0180_024F'][] = array('upper' => 538, 'status' => 'C', 'lower' => array(539)); /* LATIN CAPITAL LETTER T WITH COMMA BELOW */ -$config['0180_024F'][] = array('upper' => 540, 'status' => 'C', 'lower' => array(541)); /* LATIN CAPITAL LETTER YOGH */ -$config['0180_024F'][] = array('upper' => 542, 'status' => 'C', 'lower' => array(543)); /* LATIN CAPITAL LETTER H WITH CARON */ -$config['0180_024F'][] = array('upper' => 544, 'status' => 'C', 'lower' => array(414)); /* LATIN CAPITAL LETTER N WITH LONG RIGHT LEG */ -$config['0180_024F'][] = array('upper' => 546, 'status' => 'C', 'lower' => array(547)); /* LATIN CAPITAL LETTER OU */ -$config['0180_024F'][] = array('upper' => 548, 'status' => 'C', 'lower' => array(549)); /* LATIN CAPITAL LETTER Z WITH HOOK */ -$config['0180_024F'][] = array('upper' => 550, 'status' => 'C', 'lower' => array(551)); /* LATIN CAPITAL LETTER A WITH DOT ABOVE */ -$config['0180_024F'][] = array('upper' => 552, 'status' => 'C', 'lower' => array(553)); /* LATIN CAPITAL LETTER E WITH CEDILLA */ -$config['0180_024F'][] = array('upper' => 554, 'status' => 'C', 'lower' => array(555)); /* LATIN CAPITAL LETTER O WITH DIAERESIS AND MACRON */ -$config['0180_024F'][] = array('upper' => 556, 'status' => 'C', 'lower' => array(557)); /* LATIN CAPITAL LETTER O WITH TILDE AND MACRON */ -$config['0180_024F'][] = array('upper' => 558, 'status' => 'C', 'lower' => array(559)); /* LATIN CAPITAL LETTER O WITH DOT ABOVE */ -$config['0180_024F'][] = array('upper' => 560, 'status' => 'C', 'lower' => array(561)); /* LATIN CAPITAL LETTER O WITH DOT ABOVE AND MACRON */ -$config['0180_024F'][] = array('upper' => 562, 'status' => 'C', 'lower' => array(563)); /* LATIN CAPITAL LETTER Y WITH MACRON */ -$config['0180_024F'][] = array('upper' => 570, 'status' => 'C', 'lower' => array(11365)); /* LATIN CAPITAL LETTER A WITH STROKE */ -$config['0180_024F'][] = array('upper' => 571, 'status' => 'C', 'lower' => array(572)); /* LATIN CAPITAL LETTER C WITH STROKE */ -$config['0180_024F'][] = array('upper' => 573, 'status' => 'C', 'lower' => array(410)); /* LATIN CAPITAL LETTER L WITH BAR */ -$config['0180_024F'][] = array('upper' => 574, 'status' => 'C', 'lower' => array(11366)); /* LATIN CAPITAL LETTER T WITH DIAGONAL STROKE */ -$config['0180_024F'][] = array('upper' => 577, 'status' => 'C', 'lower' => array(578)); /* LATIN CAPITAL LETTER GLOTTAL STOP */ -$config['0180_024F'][] = array('upper' => 579, 'status' => 'C', 'lower' => array(384)); /* LATIN CAPITAL LETTER B WITH STROKE */ -$config['0180_024F'][] = array('upper' => 580, 'status' => 'C', 'lower' => array(649)); /* LATIN CAPITAL LETTER U BAR */ -$config['0180_024F'][] = array('upper' => 581, 'status' => 'C', 'lower' => array(652)); /* LATIN CAPITAL LETTER TURNED V */ -$config['0180_024F'][] = array('upper' => 582, 'status' => 'C', 'lower' => array(583)); /* LATIN CAPITAL LETTER E WITH STROKE */ -$config['0180_024F'][] = array('upper' => 584, 'status' => 'C', 'lower' => array(585)); /* LATIN CAPITAL LETTER J WITH STROKE */ -$config['0180_024F'][] = array('upper' => 586, 'status' => 'C', 'lower' => array(587)); /* LATIN CAPITAL LETTER SMALL Q WITH HOOK TAIL */ -$config['0180_024F'][] = array('upper' => 588, 'status' => 'C', 'lower' => array(589)); /* LATIN CAPITAL LETTER R WITH STROKE */ -$config['0180_024F'][] = array('upper' => 590, 'status' => 'C', 'lower' => array(591)); /* LATIN CAPITAL LETTER Y WITH STROKE */ diff --git a/lib/Cake/Config/unicode/casefolding/0250_02af.php b/lib/Cake/Config/unicode/casefolding/0250_02af.php deleted file mode 100644 index 6ffa59d1b8c..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0250_02af.php +++ /dev/null @@ -1,41 +0,0 @@ - 422, 'status' => 'C', 'lower' => array(640)); diff --git a/lib/Cake/Config/unicode/casefolding/0370_03ff.php b/lib/Cake/Config/unicode/casefolding/0370_03ff.php deleted file mode 100644 index bb7ecde79c8..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0370_03ff.php +++ /dev/null @@ -1,102 +0,0 @@ - 902, 'status' => 'C', 'lower' => array(940)); /* GREEK CAPITAL LETTER ALPHA WITH TONOS */ -$config['0370_03ff'][] = array('upper' => 904, 'status' => 'C', 'lower' => array(941)); /* GREEK CAPITAL LETTER EPSILON WITH TONOS */ -$config['0370_03ff'][] = array('upper' => 905, 'status' => 'C', 'lower' => array(942)); /* GREEK CAPITAL LETTER ETA WITH TONOS */ -$config['0370_03ff'][] = array('upper' => 906, 'status' => 'C', 'lower' => array(943)); /* GREEK CAPITAL LETTER IOTA WITH TONOS */ -$config['0370_03ff'][] = array('upper' => 908, 'status' => 'C', 'lower' => array(972)); /* GREEK CAPITAL LETTER OMICRON WITH TONOS */ -$config['0370_03ff'][] = array('upper' => 910, 'status' => 'C', 'lower' => array(973)); /* GREEK CAPITAL LETTER UPSILON WITH TONOS */ -$config['0370_03ff'][] = array('upper' => 911, 'status' => 'C', 'lower' => array(974)); /* GREEK CAPITAL LETTER OMEGA WITH TONOS */ -//$config['0370_03ff'][] = array('upper' => 912, 'status' => 'F', 'lower' => array(953, 776, 769)); /* GREEK SMALL LETTER IOTA WITH DIALYTIKA AND TONOS */ -$config['0370_03ff'][] = array('upper' => 913, 'status' => 'C', 'lower' => array(945)); /* GREEK CAPITAL LETTER ALPHA */ -$config['0370_03ff'][] = array('upper' => 914, 'status' => 'C', 'lower' => array(946)); /* GREEK CAPITAL LETTER BETA */ -$config['0370_03ff'][] = array('upper' => 915, 'status' => 'C', 'lower' => array(947)); /* GREEK CAPITAL LETTER GAMMA */ -$config['0370_03ff'][] = array('upper' => 916, 'status' => 'C', 'lower' => array(948)); /* GREEK CAPITAL LETTER DELTA */ -$config['0370_03ff'][] = array('upper' => 917, 'status' => 'C', 'lower' => array(949)); /* GREEK CAPITAL LETTER EPSILON */ -$config['0370_03ff'][] = array('upper' => 918, 'status' => 'C', 'lower' => array(950)); /* GREEK CAPITAL LETTER ZETA */ -$config['0370_03ff'][] = array('upper' => 919, 'status' => 'C', 'lower' => array(951)); /* GREEK CAPITAL LETTER ETA */ -$config['0370_03ff'][] = array('upper' => 920, 'status' => 'C', 'lower' => array(952)); /* GREEK CAPITAL LETTER THETA */ -$config['0370_03ff'][] = array('upper' => 921, 'status' => 'C', 'lower' => array(953)); /* GREEK CAPITAL LETTER IOTA */ -$config['0370_03ff'][] = array('upper' => 922, 'status' => 'C', 'lower' => array(954)); /* GREEK CAPITAL LETTER KAPPA */ -$config['0370_03ff'][] = array('upper' => 923, 'status' => 'C', 'lower' => array(955)); /* GREEK CAPITAL LETTER LAMDA */ -$config['0370_03ff'][] = array('upper' => 924, 'status' => 'C', 'lower' => array(956)); /* GREEK CAPITAL LETTER MU */ -$config['0370_03ff'][] = array('upper' => 925, 'status' => 'C', 'lower' => array(957)); /* GREEK CAPITAL LETTER NU */ -$config['0370_03ff'][] = array('upper' => 926, 'status' => 'C', 'lower' => array(958)); /* GREEK CAPITAL LETTER XI */ -$config['0370_03ff'][] = array('upper' => 927, 'status' => 'C', 'lower' => array(959)); /* GREEK CAPITAL LETTER OMICRON */ -$config['0370_03ff'][] = array('upper' => 928, 'status' => 'C', 'lower' => array(960)); /* GREEK CAPITAL LETTER PI */ -$config['0370_03ff'][] = array('upper' => 929, 'status' => 'C', 'lower' => array(961)); /* GREEK CAPITAL LETTER RHO */ -$config['0370_03ff'][] = array('upper' => 931, 'status' => 'C', 'lower' => array(963)); /* GREEK CAPITAL LETTER SIGMA */ -$config['0370_03ff'][] = array('upper' => 932, 'status' => 'C', 'lower' => array(964)); /* GREEK CAPITAL LETTER TAU */ -$config['0370_03ff'][] = array('upper' => 933, 'status' => 'C', 'lower' => array(965)); /* GREEK CAPITAL LETTER UPSILON */ -$config['0370_03ff'][] = array('upper' => 934, 'status' => 'C', 'lower' => array(966)); /* GREEK CAPITAL LETTER PHI */ -$config['0370_03ff'][] = array('upper' => 935, 'status' => 'C', 'lower' => array(967)); /* GREEK CAPITAL LETTER CHI */ -$config['0370_03ff'][] = array('upper' => 936, 'status' => 'C', 'lower' => array(968)); /* GREEK CAPITAL LETTER PSI */ -$config['0370_03ff'][] = array('upper' => 937, 'status' => 'C', 'lower' => array(969)); /* GREEK CAPITAL LETTER OMEGA */ -$config['0370_03ff'][] = array('upper' => 938, 'status' => 'C', 'lower' => array(970)); /* GREEK CAPITAL LETTER IOTA WITH DIALYTIKA */ -$config['0370_03ff'][] = array('upper' => 939, 'status' => 'C', 'lower' => array(971)); /* GREEK CAPITAL LETTER UPSILON WITH DIALYTIKA */ -$config['0370_03ff'][] = array('upper' => 944, 'status' => 'F', 'lower' => array(965, 776, 769)); /* GREEK SMALL LETTER UPSILON WITH DIALYTIKA AND TONOS */ -$config['0370_03ff'][] = array('upper' => 962, 'status' => 'C', 'lower' => array(963)); /* GREEK SMALL LETTER FINAL SIGMA */ -$config['0370_03ff'][] = array('upper' => 976, 'status' => 'C', 'lower' => array(946)); /* GREEK BETA SYMBOL */ -$config['0370_03ff'][] = array('upper' => 977, 'status' => 'C', 'lower' => array(952)); /* GREEK THETA SYMBOL */ -$config['0370_03ff'][] = array('upper' => 981, 'status' => 'C', 'lower' => array(966)); /* GREEK PHI SYMBOL */ -$config['0370_03ff'][] = array('upper' => 982, 'status' => 'C', 'lower' => array(960)); /* GREEK PI SYMBOL */ -$config['0370_03ff'][] = array('upper' => 984, 'status' => 'C', 'lower' => array(985)); /* GREEK LETTER ARCHAIC KOPPA */ -$config['0370_03ff'][] = array('upper' => 986, 'status' => 'C', 'lower' => array(987)); /* GREEK LETTER STIGMA */ -$config['0370_03ff'][] = array('upper' => 988, 'status' => 'C', 'lower' => array(989)); /* GREEK LETTER DIGAMMA */ -$config['0370_03ff'][] = array('upper' => 990, 'status' => 'C', 'lower' => array(991)); /* GREEK LETTER KOPPA */ -$config['0370_03ff'][] = array('upper' => 992, 'status' => 'C', 'lower' => array(993)); /* GREEK LETTER SAMPI */ -$config['0370_03ff'][] = array('upper' => 994, 'status' => 'C', 'lower' => array(995)); /* COPTIC CAPITAL LETTER SHEI */ -$config['0370_03ff'][] = array('upper' => 996, 'status' => 'C', 'lower' => array(997)); /* COPTIC CAPITAL LETTER FEI */ -$config['0370_03ff'][] = array('upper' => 998, 'status' => 'C', 'lower' => array(999)); /* COPTIC CAPITAL LETTER KHEI */ -$config['0370_03ff'][] = array('upper' => 1000, 'status' => 'C', 'lower' => array(1001)); /* COPTIC CAPITAL LETTER HORI */ -$config['0370_03ff'][] = array('upper' => 1002, 'status' => 'C', 'lower' => array(1003)); /* COPTIC CAPITAL LETTER GANGIA */ -$config['0370_03ff'][] = array('upper' => 1004, 'status' => 'C', 'lower' => array(1005)); /* COPTIC CAPITAL LETTER SHIMA */ -$config['0370_03ff'][] = array('upper' => 1006, 'status' => 'C', 'lower' => array(1007)); /* COPTIC CAPITAL LETTER DEI */ -$config['0370_03ff'][] = array('upper' => 1008, 'status' => 'C', 'lower' => array(954)); /* GREEK KAPPA SYMBOL */ -$config['0370_03ff'][] = array('upper' => 1009, 'status' => 'C', 'lower' => array(961)); /* GREEK RHO SYMBOL */ -$config['0370_03ff'][] = array('upper' => 1012, 'status' => 'C', 'lower' => array(952)); /* GREEK CAPITAL THETA SYMBOL */ -$config['0370_03ff'][] = array('upper' => 1013, 'status' => 'C', 'lower' => array(949)); /* GREEK LUNATE EPSILON SYMBOL */ -$config['0370_03ff'][] = array('upper' => 1015, 'status' => 'C', 'lower' => array(1016)); /* GREEK CAPITAL LETTER SHO */ -$config['0370_03ff'][] = array('upper' => 1017, 'status' => 'C', 'lower' => array(1010)); /* GREEK CAPITAL LUNATE SIGMA SYMBOL */ -$config['0370_03ff'][] = array('upper' => 1018, 'status' => 'C', 'lower' => array(1019)); /* GREEK CAPITAL LETTER SAN */ -$config['0370_03ff'][] = array('upper' => 1021, 'status' => 'C', 'lower' => array(891)); /* GREEK CAPITAL REVERSED LUNATE SIGMA SYMBOL */ -$config['0370_03ff'][] = array('upper' => 1022, 'status' => 'C', 'lower' => array(892)); /* GREEK CAPITAL DOTTED LUNATE SIGMA SYMBOL */ -$config['0370_03ff'][] = array('upper' => 1023, 'status' => 'C', 'lower' => array(893)); /* GREEK CAPITAL REVERSED DOTTED LUNATE SIGMA SYMBOL */ diff --git a/lib/Cake/Config/unicode/casefolding/0400_04ff.php b/lib/Cake/Config/unicode/casefolding/0400_04ff.php deleted file mode 100644 index 7a3f93a5cd6..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0400_04ff.php +++ /dev/null @@ -1,164 +0,0 @@ - 1024, 'status' => 'C', 'lower' => array(1104)); /* CYRILLIC CAPITAL LETTER IE WITH GRAVE */ -$config['0400_04ff'][] = array('upper' => 1025, 'status' => 'C', 'lower' => array(1105)); /* CYRILLIC CAPITAL LETTER IO */ -$config['0400_04ff'][] = array('upper' => 1026, 'status' => 'C', 'lower' => array(1106)); /* CYRILLIC CAPITAL LETTER DJE */ -$config['0400_04ff'][] = array('upper' => 1027, 'status' => 'C', 'lower' => array(1107)); /* CYRILLIC CAPITAL LETTER GJE */ -$config['0400_04ff'][] = array('upper' => 1028, 'status' => 'C', 'lower' => array(1108)); /* CYRILLIC CAPITAL LETTER UKRAINIAN IE */ -$config['0400_04ff'][] = array('upper' => 1029, 'status' => 'C', 'lower' => array(1109)); /* CYRILLIC CAPITAL LETTER DZE */ -$config['0400_04ff'][] = array('upper' => 1030, 'status' => 'C', 'lower' => array(1110)); /* CYRILLIC CAPITAL LETTER BYELORUSSIAN-UKRAINIAN I */ -$config['0400_04ff'][] = array('upper' => 1031, 'status' => 'C', 'lower' => array(1111)); /* CYRILLIC CAPITAL LETTER YI */ -$config['0400_04ff'][] = array('upper' => 1032, 'status' => 'C', 'lower' => array(1112)); /* CYRILLIC CAPITAL LETTER JE */ -$config['0400_04ff'][] = array('upper' => 1033, 'status' => 'C', 'lower' => array(1113)); /* CYRILLIC CAPITAL LETTER LJE */ -$config['0400_04ff'][] = array('upper' => 1034, 'status' => 'C', 'lower' => array(1114)); /* CYRILLIC CAPITAL LETTER NJE */ -$config['0400_04ff'][] = array('upper' => 1035, 'status' => 'C', 'lower' => array(1115)); /* CYRILLIC CAPITAL LETTER TSHE */ -$config['0400_04ff'][] = array('upper' => 1036, 'status' => 'C', 'lower' => array(1116)); /* CYRILLIC CAPITAL LETTER KJE */ -$config['0400_04ff'][] = array('upper' => 1037, 'status' => 'C', 'lower' => array(1117)); /* CYRILLIC CAPITAL LETTER I WITH GRAVE */ -$config['0400_04ff'][] = array('upper' => 1038, 'status' => 'C', 'lower' => array(1118)); /* CYRILLIC CAPITAL LETTER SHORT U */ -$config['0400_04ff'][] = array('upper' => 1039, 'status' => 'C', 'lower' => array(1119)); /* CYRILLIC CAPITAL LETTER DZHE */ -$config['0400_04ff'][] = array('upper' => 1040, 'status' => 'C', 'lower' => array(1072)); /* CYRILLIC CAPITAL LETTER A */ -$config['0400_04ff'][] = array('upper' => 1041, 'status' => 'C', 'lower' => array(1073)); /* CYRILLIC CAPITAL LETTER BE */ -$config['0400_04ff'][] = array('upper' => 1042, 'status' => 'C', 'lower' => array(1074)); /* CYRILLIC CAPITAL LETTER VE */ -$config['0400_04ff'][] = array('upper' => 1043, 'status' => 'C', 'lower' => array(1075)); /* CYRILLIC CAPITAL LETTER GHE */ -$config['0400_04ff'][] = array('upper' => 1044, 'status' => 'C', 'lower' => array(1076)); /* CYRILLIC CAPITAL LETTER DE */ -$config['0400_04ff'][] = array('upper' => 1045, 'status' => 'C', 'lower' => array(1077)); /* CYRILLIC CAPITAL LETTER IE */ -$config['0400_04ff'][] = array('upper' => 1046, 'status' => 'C', 'lower' => array(1078)); /* CYRILLIC CAPITAL LETTER ZHE */ -$config['0400_04ff'][] = array('upper' => 1047, 'status' => 'C', 'lower' => array(1079)); /* CYRILLIC CAPITAL LETTER ZE */ -$config['0400_04ff'][] = array('upper' => 1048, 'status' => 'C', 'lower' => array(1080)); /* CYRILLIC CAPITAL LETTER I */ -$config['0400_04ff'][] = array('upper' => 1049, 'status' => 'C', 'lower' => array(1081)); /* CYRILLIC CAPITAL LETTER SHORT I */ -$config['0400_04ff'][] = array('upper' => 1050, 'status' => 'C', 'lower' => array(1082)); /* CYRILLIC CAPITAL LETTER KA */ -$config['0400_04ff'][] = array('upper' => 1051, 'status' => 'C', 'lower' => array(1083)); /* CYRILLIC CAPITAL LETTER EL */ -$config['0400_04ff'][] = array('upper' => 1052, 'status' => 'C', 'lower' => array(1084)); /* CYRILLIC CAPITAL LETTER EM */ -$config['0400_04ff'][] = array('upper' => 1053, 'status' => 'C', 'lower' => array(1085)); /* CYRILLIC CAPITAL LETTER EN */ -$config['0400_04ff'][] = array('upper' => 1054, 'status' => 'C', 'lower' => array(1086)); /* CYRILLIC CAPITAL LETTER O */ -$config['0400_04ff'][] = array('upper' => 1055, 'status' => 'C', 'lower' => array(1087)); /* CYRILLIC CAPITAL LETTER PE */ -$config['0400_04ff'][] = array('upper' => 1056, 'status' => 'C', 'lower' => array(1088)); /* CYRILLIC CAPITAL LETTER ER */ -$config['0400_04ff'][] = array('upper' => 1057, 'status' => 'C', 'lower' => array(1089)); /* CYRILLIC CAPITAL LETTER ES */ -$config['0400_04ff'][] = array('upper' => 1058, 'status' => 'C', 'lower' => array(1090)); /* CYRILLIC CAPITAL LETTER TE */ -$config['0400_04ff'][] = array('upper' => 1059, 'status' => 'C', 'lower' => array(1091)); /* CYRILLIC CAPITAL LETTER U */ -$config['0400_04ff'][] = array('upper' => 1060, 'status' => 'C', 'lower' => array(1092)); /* CYRILLIC CAPITAL LETTER EF */ -$config['0400_04ff'][] = array('upper' => 1061, 'status' => 'C', 'lower' => array(1093)); /* CYRILLIC CAPITAL LETTER HA */ -$config['0400_04ff'][] = array('upper' => 1062, 'status' => 'C', 'lower' => array(1094)); /* CYRILLIC CAPITAL LETTER TSE */ -$config['0400_04ff'][] = array('upper' => 1063, 'status' => 'C', 'lower' => array(1095)); /* CYRILLIC CAPITAL LETTER CHE */ -$config['0400_04ff'][] = array('upper' => 1064, 'status' => 'C', 'lower' => array(1096)); /* CYRILLIC CAPITAL LETTER SHA */ -$config['0400_04ff'][] = array('upper' => 1065, 'status' => 'C', 'lower' => array(1097)); /* CYRILLIC CAPITAL LETTER SHCHA */ -$config['0400_04ff'][] = array('upper' => 1066, 'status' => 'C', 'lower' => array(1098)); /* CYRILLIC CAPITAL LETTER HARD SIGN */ -$config['0400_04ff'][] = array('upper' => 1067, 'status' => 'C', 'lower' => array(1099)); /* CYRILLIC CAPITAL LETTER YERU */ -$config['0400_04ff'][] = array('upper' => 1068, 'status' => 'C', 'lower' => array(1100)); /* CYRILLIC CAPITAL LETTER SOFT SIGN */ -$config['0400_04ff'][] = array('upper' => 1069, 'status' => 'C', 'lower' => array(1101)); /* CYRILLIC CAPITAL LETTER E */ -$config['0400_04ff'][] = array('upper' => 1070, 'status' => 'C', 'lower' => array(1102)); /* CYRILLIC CAPITAL LETTER YU */ -$config['0400_04ff'][] = array('upper' => 1071, 'status' => 'C', 'lower' => array(1103)); /* CYRILLIC CAPITAL LETTER YA */ -$config['0400_04ff'][] = array('upper' => 1120, 'status' => 'C', 'lower' => array(1121)); /* CYRILLIC CAPITAL LETTER OMEGA */ -$config['0400_04ff'][] = array('upper' => 1122, 'status' => 'C', 'lower' => array(1123)); /* CYRILLIC CAPITAL LETTER YAT */ -$config['0400_04ff'][] = array('upper' => 1124, 'status' => 'C', 'lower' => array(1125)); /* CYRILLIC CAPITAL LETTER IOTIFIED E */ -$config['0400_04ff'][] = array('upper' => 1126, 'status' => 'C', 'lower' => array(1127)); /* CYRILLIC CAPITAL LETTER LITTLE YUS */ -$config['0400_04ff'][] = array('upper' => 1128, 'status' => 'C', 'lower' => array(1129)); /* CYRILLIC CAPITAL LETTER IOTIFIED LITTLE YUS */ -$config['0400_04ff'][] = array('upper' => 1130, 'status' => 'C', 'lower' => array(1131)); /* CYRILLIC CAPITAL LETTER BIG YUS */ -$config['0400_04ff'][] = array('upper' => 1132, 'status' => 'C', 'lower' => array(1133)); /* CYRILLIC CAPITAL LETTER IOTIFIED BIG YUS */ -$config['0400_04ff'][] = array('upper' => 1134, 'status' => 'C', 'lower' => array(1135)); /* CYRILLIC CAPITAL LETTER KSI */ -$config['0400_04ff'][] = array('upper' => 1136, 'status' => 'C', 'lower' => array(1137)); /* CYRILLIC CAPITAL LETTER PSI */ -$config['0400_04ff'][] = array('upper' => 1138, 'status' => 'C', 'lower' => array(1139)); /* CYRILLIC CAPITAL LETTER FITA */ -$config['0400_04ff'][] = array('upper' => 1140, 'status' => 'C', 'lower' => array(1141)); /* CYRILLIC CAPITAL LETTER IZHITSA */ -$config['0400_04ff'][] = array('upper' => 1142, 'status' => 'C', 'lower' => array(1143)); /* CYRILLIC CAPITAL LETTER IZHITSA WITH DOUBLE GRAVE ACCENT */ -$config['0400_04ff'][] = array('upper' => 1144, 'status' => 'C', 'lower' => array(1145)); /* CYRILLIC CAPITAL LETTER UK */ -$config['0400_04ff'][] = array('upper' => 1146, 'status' => 'C', 'lower' => array(1147)); /* CYRILLIC CAPITAL LETTER ROUND OMEGA */ -$config['0400_04ff'][] = array('upper' => 1148, 'status' => 'C', 'lower' => array(1149)); /* CYRILLIC CAPITAL LETTER OMEGA WITH TITLO */ -$config['0400_04ff'][] = array('upper' => 1150, 'status' => 'C', 'lower' => array(1151)); /* CYRILLIC CAPITAL LETTER OT */ -$config['0400_04ff'][] = array('upper' => 1152, 'status' => 'C', 'lower' => array(1153)); /* CYRILLIC CAPITAL LETTER KOPPA */ -$config['0400_04ff'][] = array('upper' => 1162, 'status' => 'C', 'lower' => array(1163)); /* CYRILLIC CAPITAL LETTER SHORT I WITH TAIL */ -$config['0400_04ff'][] = array('upper' => 1164, 'status' => 'C', 'lower' => array(1165)); /* CYRILLIC CAPITAL LETTER SEMISOFT SIGN */ -$config['0400_04ff'][] = array('upper' => 1166, 'status' => 'C', 'lower' => array(1167)); /* CYRILLIC CAPITAL LETTER ER WITH TICK */ -$config['0400_04ff'][] = array('upper' => 1168, 'status' => 'C', 'lower' => array(1169)); /* CYRILLIC CAPITAL LETTER GHE WITH UPTURN */ -$config['0400_04ff'][] = array('upper' => 1170, 'status' => 'C', 'lower' => array(1171)); /* CYRILLIC CAPITAL LETTER GHE WITH STROKE */ -$config['0400_04ff'][] = array('upper' => 1172, 'status' => 'C', 'lower' => array(1173)); /* CYRILLIC CAPITAL LETTER GHE WITH MIDDLE HOOK */ -$config['0400_04ff'][] = array('upper' => 1174, 'status' => 'C', 'lower' => array(1175)); /* CYRILLIC CAPITAL LETTER ZHE WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1176, 'status' => 'C', 'lower' => array(1177)); /* CYRILLIC CAPITAL LETTER ZE WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1178, 'status' => 'C', 'lower' => array(1179)); /* CYRILLIC CAPITAL LETTER KA WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1180, 'status' => 'C', 'lower' => array(1181)); /* CYRILLIC CAPITAL LETTER KA WITH VERTICAL STROKE */ -$config['0400_04ff'][] = array('upper' => 1182, 'status' => 'C', 'lower' => array(1183)); /* CYRILLIC CAPITAL LETTER KA WITH STROKE */ -$config['0400_04ff'][] = array('upper' => 1184, 'status' => 'C', 'lower' => array(1185)); /* CYRILLIC CAPITAL LETTER BASHKIR KA */ -$config['0400_04ff'][] = array('upper' => 1186, 'status' => 'C', 'lower' => array(1187)); /* CYRILLIC CAPITAL LETTER EN WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1188, 'status' => 'C', 'lower' => array(1189)); /* CYRILLIC CAPITAL LIGATURE EN GHE */ -$config['0400_04ff'][] = array('upper' => 1190, 'status' => 'C', 'lower' => array(1191)); /* CYRILLIC CAPITAL LETTER PE WITH MIDDLE HOOK */ -$config['0400_04ff'][] = array('upper' => 1192, 'status' => 'C', 'lower' => array(1193)); /* CYRILLIC CAPITAL LETTER ABKHASIAN HA */ -$config['0400_04ff'][] = array('upper' => 1194, 'status' => 'C', 'lower' => array(1195)); /* CYRILLIC CAPITAL LETTER ES WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1196, 'status' => 'C', 'lower' => array(1197)); /* CYRILLIC CAPITAL LETTER TE WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1198, 'status' => 'C', 'lower' => array(1199)); /* CYRILLIC CAPITAL LETTER STRAIGHT U */ -$config['0400_04ff'][] = array('upper' => 1200, 'status' => 'C', 'lower' => array(1201)); /* CYRILLIC CAPITAL LETTER STRAIGHT U WITH STROKE */ -$config['0400_04ff'][] = array('upper' => 1202, 'status' => 'C', 'lower' => array(1203)); /* CYRILLIC CAPITAL LETTER HA WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1204, 'status' => 'C', 'lower' => array(1205)); /* CYRILLIC CAPITAL LIGATURE TE TSE */ -$config['0400_04ff'][] = array('upper' => 1206, 'status' => 'C', 'lower' => array(1207)); /* CYRILLIC CAPITAL LETTER CHE WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1208, 'status' => 'C', 'lower' => array(1209)); /* CYRILLIC CAPITAL LETTER CHE WITH VERTICAL STROKE */ -$config['0400_04ff'][] = array('upper' => 1210, 'status' => 'C', 'lower' => array(1211)); /* CYRILLIC CAPITAL LETTER SHHA */ -$config['0400_04ff'][] = array('upper' => 1212, 'status' => 'C', 'lower' => array(1213)); /* CYRILLIC CAPITAL LETTER ABKHASIAN CHE */ -$config['0400_04ff'][] = array('upper' => 1214, 'status' => 'C', 'lower' => array(1215)); /* CYRILLIC CAPITAL LETTER ABKHASIAN CHE WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1216, 'status' => 'C', 'lower' => array(1231)); /* CYRILLIC LETTER PALOCHKA */ -$config['0400_04ff'][] = array('upper' => 1217, 'status' => 'C', 'lower' => array(1218)); /* CYRILLIC CAPITAL LETTER ZHE WITH BREVE */ -$config['0400_04ff'][] = array('upper' => 1219, 'status' => 'C', 'lower' => array(1220)); /* CYRILLIC CAPITAL LETTER KA WITH HOOK */ -$config['0400_04ff'][] = array('upper' => 1221, 'status' => 'C', 'lower' => array(1222)); /* CYRILLIC CAPITAL LETTER EL WITH TAIL */ -$config['0400_04ff'][] = array('upper' => 1223, 'status' => 'C', 'lower' => array(1224)); /* CYRILLIC CAPITAL LETTER EN WITH HOOK */ -$config['0400_04ff'][] = array('upper' => 1225, 'status' => 'C', 'lower' => array(1226)); /* CYRILLIC CAPITAL LETTER EN WITH TAIL */ -$config['0400_04ff'][] = array('upper' => 1227, 'status' => 'C', 'lower' => array(1228)); /* CYRILLIC CAPITAL LETTER KHAKASSIAN CHE */ -$config['0400_04ff'][] = array('upper' => 1229, 'status' => 'C', 'lower' => array(1230)); /* CYRILLIC CAPITAL LETTER EM WITH TAIL */ -$config['0400_04ff'][] = array('upper' => 1232, 'status' => 'C', 'lower' => array(1233)); /* CYRILLIC CAPITAL LETTER A WITH BREVE */ -$config['0400_04ff'][] = array('upper' => 1234, 'status' => 'C', 'lower' => array(1235)); /* CYRILLIC CAPITAL LETTER A WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1236, 'status' => 'C', 'lower' => array(1237)); /* CYRILLIC CAPITAL LIGATURE A IE */ -$config['0400_04ff'][] = array('upper' => 1238, 'status' => 'C', 'lower' => array(1239)); /* CYRILLIC CAPITAL LETTER IE WITH BREVE */ -$config['0400_04ff'][] = array('upper' => 1240, 'status' => 'C', 'lower' => array(1241)); /* CYRILLIC CAPITAL LETTER SCHWA */ -$config['0400_04ff'][] = array('upper' => 1242, 'status' => 'C', 'lower' => array(1243)); /* CYRILLIC CAPITAL LETTER SCHWA WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1244, 'status' => 'C', 'lower' => array(1245)); /* CYRILLIC CAPITAL LETTER ZHE WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1246, 'status' => 'C', 'lower' => array(1247)); /* CYRILLIC CAPITAL LETTER ZE WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1248, 'status' => 'C', 'lower' => array(1249)); /* CYRILLIC CAPITAL LETTER ABKHASIAN DZE */ -$config['0400_04ff'][] = array('upper' => 1250, 'status' => 'C', 'lower' => array(1251)); /* CYRILLIC CAPITAL LETTER I WITH MACRON */ -$config['0400_04ff'][] = array('upper' => 1252, 'status' => 'C', 'lower' => array(1253)); /* CYRILLIC CAPITAL LETTER I WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1254, 'status' => 'C', 'lower' => array(1255)); /* CYRILLIC CAPITAL LETTER O WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1256, 'status' => 'C', 'lower' => array(1257)); /* CYRILLIC CAPITAL LETTER BARRED O */ -$config['0400_04ff'][] = array('upper' => 1258, 'status' => 'C', 'lower' => array(1259)); /* CYRILLIC CAPITAL LETTER BARRED O WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1260, 'status' => 'C', 'lower' => array(1261)); /* CYRILLIC CAPITAL LETTER E WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1262, 'status' => 'C', 'lower' => array(1263)); /* CYRILLIC CAPITAL LETTER U WITH MACRON */ -$config['0400_04ff'][] = array('upper' => 1264, 'status' => 'C', 'lower' => array(1265)); /* CYRILLIC CAPITAL LETTER U WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1266, 'status' => 'C', 'lower' => array(1267)); /* CYRILLIC CAPITAL LETTER U WITH DOUBLE ACUTE */ -$config['0400_04ff'][] = array('upper' => 1268, 'status' => 'C', 'lower' => array(1269)); /* CYRILLIC CAPITAL LETTER CHE WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1270, 'status' => 'C', 'lower' => array(1271)); /* CYRILLIC CAPITAL LETTER GHE WITH DESCENDER */ -$config['0400_04ff'][] = array('upper' => 1272, 'status' => 'C', 'lower' => array(1273)); /* CYRILLIC CAPITAL LETTER YERU WITH DIAERESIS */ -$config['0400_04ff'][] = array('upper' => 1274, 'status' => 'C', 'lower' => array(1275)); /* CYRILLIC CAPITAL LETTER GHE WITH STROKE AND HOOK */ -$config['0400_04ff'][] = array('upper' => 1276, 'status' => 'C', 'lower' => array(1277)); /* CYRILLIC CAPITAL LETTER HA WITH HOOK */ -$config['0400_04ff'][] = array('upper' => 1278, 'status' => 'C', 'lower' => array(1279)); /* CYRILLIC CAPITAL LETTER HA WITH STROKE */ diff --git a/lib/Cake/Config/unicode/casefolding/0500_052f.php b/lib/Cake/Config/unicode/casefolding/0500_052f.php deleted file mode 100644 index 73b81fa8134..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0500_052f.php +++ /dev/null @@ -1,50 +0,0 @@ - 1280, 'status' => 'C', 'lower' => array(1281)); /* CYRILLIC CAPITAL LETTER KOMI DE */ -$config['0500_052f'][] = array('upper' => 1282, 'status' => 'C', 'lower' => array(1283)); /* CYRILLIC CAPITAL LETTER KOMI DJE */ -$config['0500_052f'][] = array('upper' => 1284, 'status' => 'C', 'lower' => array(1285)); /* CYRILLIC CAPITAL LETTER KOMI ZJE */ -$config['0500_052f'][] = array('upper' => 1286, 'status' => 'C', 'lower' => array(1287)); /* CYRILLIC CAPITAL LETTER KOMI DZJE */ -$config['0500_052f'][] = array('upper' => 1288, 'status' => 'C', 'lower' => array(1289)); /* CYRILLIC CAPITAL LETTER KOMI LJE */ -$config['0500_052f'][] = array('upper' => 1290, 'status' => 'C', 'lower' => array(1291)); /* CYRILLIC CAPITAL LETTER KOMI NJE */ -$config['0500_052f'][] = array('upper' => 1292, 'status' => 'C', 'lower' => array(1293)); /* CYRILLIC CAPITAL LETTER KOMI SJE */ -$config['0500_052f'][] = array('upper' => 1294, 'status' => 'C', 'lower' => array(1295)); /* CYRILLIC CAPITAL LETTER KOMI TJE */ -$config['0500_052f'][] = array('upper' => 1296, 'status' => 'C', 'lower' => array(1297)); /* CYRILLIC CAPITAL LETTER ZE */ -$config['0500_052f'][] = array('upper' => 1298, 'status' => 'C', 'lower' => array(1299)); /* CYRILLIC CAPITAL LETTER El with hook */ diff --git a/lib/Cake/Config/unicode/casefolding/0530_058f.php b/lib/Cake/Config/unicode/casefolding/0530_058f.php deleted file mode 100644 index 4ffb553e90a..00000000000 --- a/lib/Cake/Config/unicode/casefolding/0530_058f.php +++ /dev/null @@ -1,78 +0,0 @@ - 1329, 'status' => 'C', 'lower' => array(1377)); /* ARMENIAN CAPITAL LETTER AYB */ -$config['0530_058f'][] = array('upper' => 1330, 'status' => 'C', 'lower' => array(1378)); /* ARMENIAN CAPITAL LETTER BEN */ -$config['0530_058f'][] = array('upper' => 1331, 'status' => 'C', 'lower' => array(1379)); /* ARMENIAN CAPITAL LETTER GIM */ -$config['0530_058f'][] = array('upper' => 1332, 'status' => 'C', 'lower' => array(1380)); /* ARMENIAN CAPITAL LETTER DA */ -$config['0530_058f'][] = array('upper' => 1333, 'status' => 'C', 'lower' => array(1381)); /* ARMENIAN CAPITAL LETTER ECH */ -$config['0530_058f'][] = array('upper' => 1334, 'status' => 'C', 'lower' => array(1382)); /* ARMENIAN CAPITAL LETTER ZA */ -$config['0530_058f'][] = array('upper' => 1335, 'status' => 'C', 'lower' => array(1383)); /* ARMENIAN CAPITAL LETTER EH */ -$config['0530_058f'][] = array('upper' => 1336, 'status' => 'C', 'lower' => array(1384)); /* ARMENIAN CAPITAL LETTER ET */ -$config['0530_058f'][] = array('upper' => 1337, 'status' => 'C', 'lower' => array(1385)); /* ARMENIAN CAPITAL LETTER TO */ -$config['0530_058f'][] = array('upper' => 1338, 'status' => 'C', 'lower' => array(1386)); /* ARMENIAN CAPITAL LETTER ZHE */ -$config['0530_058f'][] = array('upper' => 1339, 'status' => 'C', 'lower' => array(1387)); /* ARMENIAN CAPITAL LETTER INI */ -$config['0530_058f'][] = array('upper' => 1340, 'status' => 'C', 'lower' => array(1388)); /* ARMENIAN CAPITAL LETTER LIWN */ -$config['0530_058f'][] = array('upper' => 1341, 'status' => 'C', 'lower' => array(1389)); /* ARMENIAN CAPITAL LETTER XEH */ -$config['0530_058f'][] = array('upper' => 1342, 'status' => 'C', 'lower' => array(1390)); /* ARMENIAN CAPITAL LETTER CA */ -$config['0530_058f'][] = array('upper' => 1343, 'status' => 'C', 'lower' => array(1391)); /* ARMENIAN CAPITAL LETTER KEN */ -$config['0530_058f'][] = array('upper' => 1344, 'status' => 'C', 'lower' => array(1392)); /* ARMENIAN CAPITAL LETTER HO */ -$config['0530_058f'][] = array('upper' => 1345, 'status' => 'C', 'lower' => array(1393)); /* ARMENIAN CAPITAL LETTER JA */ -$config['0530_058f'][] = array('upper' => 1346, 'status' => 'C', 'lower' => array(1394)); /* ARMENIAN CAPITAL LETTER GHAD */ -$config['0530_058f'][] = array('upper' => 1347, 'status' => 'C', 'lower' => array(1395)); /* ARMENIAN CAPITAL LETTER CHEH */ -$config['0530_058f'][] = array('upper' => 1348, 'status' => 'C', 'lower' => array(1396)); /* ARMENIAN CAPITAL LETTER MEN */ -$config['0530_058f'][] = array('upper' => 1349, 'status' => 'C', 'lower' => array(1397)); /* ARMENIAN CAPITAL LETTER YI */ -$config['0530_058f'][] = array('upper' => 1350, 'status' => 'C', 'lower' => array(1398)); /* ARMENIAN CAPITAL LETTER NOW */ -$config['0530_058f'][] = array('upper' => 1351, 'status' => 'C', 'lower' => array(1399)); /* ARMENIAN CAPITAL LETTER SHA */ -$config['0530_058f'][] = array('upper' => 1352, 'status' => 'C', 'lower' => array(1400)); /* ARMENIAN CAPITAL LETTER VO */ -$config['0530_058f'][] = array('upper' => 1353, 'status' => 'C', 'lower' => array(1401)); /* ARMENIAN CAPITAL LETTER CHA */ -$config['0530_058f'][] = array('upper' => 1354, 'status' => 'C', 'lower' => array(1402)); /* ARMENIAN CAPITAL LETTER PEH */ -$config['0530_058f'][] = array('upper' => 1355, 'status' => 'C', 'lower' => array(1403)); /* ARMENIAN CAPITAL LETTER JHEH */ -$config['0530_058f'][] = array('upper' => 1356, 'status' => 'C', 'lower' => array(1404)); /* ARMENIAN CAPITAL LETTER RA */ -$config['0530_058f'][] = array('upper' => 1357, 'status' => 'C', 'lower' => array(1405)); /* ARMENIAN CAPITAL LETTER SEH */ -$config['0530_058f'][] = array('upper' => 1358, 'status' => 'C', 'lower' => array(1406)); /* ARMENIAN CAPITAL LETTER VEW */ -$config['0530_058f'][] = array('upper' => 1359, 'status' => 'C', 'lower' => array(1407)); /* ARMENIAN CAPITAL LETTER TIWN */ -$config['0530_058f'][] = array('upper' => 1360, 'status' => 'C', 'lower' => array(1408)); /* ARMENIAN CAPITAL LETTER REH */ -$config['0530_058f'][] = array('upper' => 1361, 'status' => 'C', 'lower' => array(1409)); /* ARMENIAN CAPITAL LETTER CO */ -$config['0530_058f'][] = array('upper' => 1362, 'status' => 'C', 'lower' => array(1410)); /* ARMENIAN CAPITAL LETTER YIWN */ -$config['0530_058f'][] = array('upper' => 1363, 'status' => 'C', 'lower' => array(1411)); /* ARMENIAN CAPITAL LETTER PIWR */ -$config['0530_058f'][] = array('upper' => 1364, 'status' => 'C', 'lower' => array(1412)); /* ARMENIAN CAPITAL LETTER KEH */ -$config['0530_058f'][] = array('upper' => 1365, 'status' => 'C', 'lower' => array(1413)); /* ARMENIAN CAPITAL LETTER OH */ -$config['0530_058f'][] = array('upper' => 1366, 'status' => 'C', 'lower' => array(1414)); /* ARMENIAN CAPITAL LETTER FEH */ diff --git a/lib/Cake/Config/unicode/casefolding/1e00_1eff.php b/lib/Cake/Config/unicode/casefolding/1e00_1eff.php deleted file mode 100644 index 5b534a3c410..00000000000 --- a/lib/Cake/Config/unicode/casefolding/1e00_1eff.php +++ /dev/null @@ -1,168 +0,0 @@ - 7680, 'status' => 'C', 'lower' => array(7681)); /* LATIN CAPITAL LETTER A WITH RING BELOW */ -$config['1e00_1eff'][] = array('upper' => 7682, 'status' => 'C', 'lower' => array(7683)); /* LATIN CAPITAL LETTER B WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7684, 'status' => 'C', 'lower' => array(7685)); /* LATIN CAPITAL LETTER B WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7686, 'status' => 'C', 'lower' => array(7687)); /* LATIN CAPITAL LETTER B WITH LINE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7688, 'status' => 'C', 'lower' => array(7689)); /* LATIN CAPITAL LETTER C WITH CEDILLA AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7690, 'status' => 'C', 'lower' => array(7691)); /* LATIN CAPITAL LETTER D WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7692, 'status' => 'C', 'lower' => array(7693)); /* LATIN CAPITAL LETTER D WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7694, 'status' => 'C', 'lower' => array(7695)); /* LATIN CAPITAL LETTER D WITH LINE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7696, 'status' => 'C', 'lower' => array(7697)); /* LATIN CAPITAL LETTER D WITH CEDILLA */ -$config['1e00_1eff'][] = array('upper' => 7698, 'status' => 'C', 'lower' => array(7699)); /* LATIN CAPITAL LETTER D WITH CIRCUMFLEX BELOW */ -$config['1e00_1eff'][] = array('upper' => 7700, 'status' => 'C', 'lower' => array(7701)); /* LATIN CAPITAL LETTER E WITH MACRON AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7702, 'status' => 'C', 'lower' => array(7703)); /* LATIN CAPITAL LETTER E WITH MACRON AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7704, 'status' => 'C', 'lower' => array(7705)); /* LATIN CAPITAL LETTER E WITH CIRCUMFLEX BELOW */ -$config['1e00_1eff'][] = array('upper' => 7706, 'status' => 'C', 'lower' => array(7707)); /* LATIN CAPITAL LETTER E WITH TILDE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7708, 'status' => 'C', 'lower' => array(7709)); /* LATIN CAPITAL LETTER E WITH CEDILLA AND BREVE */ -$config['1e00_1eff'][] = array('upper' => 7710, 'status' => 'C', 'lower' => array(7711)); /* LATIN CAPITAL LETTER F WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7712, 'status' => 'C', 'lower' => array(7713)); /* LATIN CAPITAL LETTER G WITH MACRON */ -$config['1e00_1eff'][] = array('upper' => 7714, 'status' => 'C', 'lower' => array(7715)); /* LATIN CAPITAL LETTER H WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7716, 'status' => 'C', 'lower' => array(7717)); /* LATIN CAPITAL LETTER H WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7718, 'status' => 'C', 'lower' => array(7719)); /* LATIN CAPITAL LETTER H WITH DIAERESIS */ -$config['1e00_1eff'][] = array('upper' => 7720, 'status' => 'C', 'lower' => array(7721)); /* LATIN CAPITAL LETTER H WITH CEDILLA */ -$config['1e00_1eff'][] = array('upper' => 7722, 'status' => 'C', 'lower' => array(7723)); /* LATIN CAPITAL LETTER H WITH BREVE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7724, 'status' => 'C', 'lower' => array(7725)); /* LATIN CAPITAL LETTER I WITH TILDE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7726, 'status' => 'C', 'lower' => array(7727)); /* LATIN CAPITAL LETTER I WITH DIAERESIS AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7728, 'status' => 'C', 'lower' => array(7729)); /* LATIN CAPITAL LETTER K WITH ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7730, 'status' => 'C', 'lower' => array(7731)); /* LATIN CAPITAL LETTER K WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7732, 'status' => 'C', 'lower' => array(7733)); /* LATIN CAPITAL LETTER K WITH LINE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7734, 'status' => 'C', 'lower' => array(7735)); /* LATIN CAPITAL LETTER L WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7736, 'status' => 'C', 'lower' => array(7737)); /* LATIN CAPITAL LETTER L WITH DOT BELOW AND MACRON */ -$config['1e00_1eff'][] = array('upper' => 7738, 'status' => 'C', 'lower' => array(7739)); /* LATIN CAPITAL LETTER L WITH LINE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7740, 'status' => 'C', 'lower' => array(7741)); /* LATIN CAPITAL LETTER L WITH CIRCUMFLEX BELOW */ -$config['1e00_1eff'][] = array('upper' => 7742, 'status' => 'C', 'lower' => array(7743)); /* LATIN CAPITAL LETTER M WITH ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7744, 'status' => 'C', 'lower' => array(7745)); /* LATIN CAPITAL LETTER M WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7746, 'status' => 'C', 'lower' => array(7747)); /* LATIN CAPITAL LETTER M WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7748, 'status' => 'C', 'lower' => array(7749)); /* LATIN CAPITAL LETTER N WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7750, 'status' => 'C', 'lower' => array(7751)); /* LATIN CAPITAL LETTER N WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7752, 'status' => 'C', 'lower' => array(7753)); /* LATIN CAPITAL LETTER N WITH LINE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7754, 'status' => 'C', 'lower' => array(7755)); /* LATIN CAPITAL LETTER N WITH CIRCUMFLEX BELOW */ -$config['1e00_1eff'][] = array('upper' => 7756, 'status' => 'C', 'lower' => array(7757)); /* LATIN CAPITAL LETTER O WITH TILDE AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7758, 'status' => 'C', 'lower' => array(7759)); /* LATIN CAPITAL LETTER O WITH TILDE AND DIAERESIS */ -$config['1e00_1eff'][] = array('upper' => 7760, 'status' => 'C', 'lower' => array(7761)); /* LATIN CAPITAL LETTER O WITH MACRON AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7762, 'status' => 'C', 'lower' => array(7763)); /* LATIN CAPITAL LETTER O WITH MACRON AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7764, 'status' => 'C', 'lower' => array(7765)); /* LATIN CAPITAL LETTER P WITH ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7766, 'status' => 'C', 'lower' => array(7767)); /* LATIN CAPITAL LETTER P WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7768, 'status' => 'C', 'lower' => array(7769)); /* LATIN CAPITAL LETTER R WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7770, 'status' => 'C', 'lower' => array(7771)); /* LATIN CAPITAL LETTER R WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7772, 'status' => 'C', 'lower' => array(7773)); /* LATIN CAPITAL LETTER R WITH DOT BELOW AND MACRON */ -$config['1e00_1eff'][] = array('upper' => 7774, 'status' => 'C', 'lower' => array(7775)); /* LATIN CAPITAL LETTER R WITH LINE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7776, 'status' => 'C', 'lower' => array(7777)); /* LATIN CAPITAL LETTER S WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7778, 'status' => 'C', 'lower' => array(7779)); /* LATIN CAPITAL LETTER S WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7780, 'status' => 'C', 'lower' => array(7781)); /* LATIN CAPITAL LETTER S WITH ACUTE AND DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7782, 'status' => 'C', 'lower' => array(7783)); /* LATIN CAPITAL LETTER S WITH CARON AND DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7784, 'status' => 'C', 'lower' => array(7785)); /* LATIN CAPITAL LETTER S WITH DOT BELOW AND DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7786, 'status' => 'C', 'lower' => array(7787)); /* LATIN CAPITAL LETTER T WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7788, 'status' => 'C', 'lower' => array(7789)); /* LATIN CAPITAL LETTER T WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7790, 'status' => 'C', 'lower' => array(7791)); /* LATIN CAPITAL LETTER T WITH LINE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7792, 'status' => 'C', 'lower' => array(7793)); /* LATIN CAPITAL LETTER T WITH CIRCUMFLEX BELOW */ -$config['1e00_1eff'][] = array('upper' => 7794, 'status' => 'C', 'lower' => array(7795)); /* LATIN CAPITAL LETTER U WITH DIAERESIS BELOW */ -$config['1e00_1eff'][] = array('upper' => 7796, 'status' => 'C', 'lower' => array(7797)); /* LATIN CAPITAL LETTER U WITH TILDE BELOW */ -$config['1e00_1eff'][] = array('upper' => 7798, 'status' => 'C', 'lower' => array(7799)); /* LATIN CAPITAL LETTER U WITH CIRCUMFLEX BELOW */ -$config['1e00_1eff'][] = array('upper' => 7800, 'status' => 'C', 'lower' => array(7801)); /* LATIN CAPITAL LETTER U WITH TILDE AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7802, 'status' => 'C', 'lower' => array(7803)); /* LATIN CAPITAL LETTER U WITH MACRON AND DIAERESIS */ -$config['1e00_1eff'][] = array('upper' => 7804, 'status' => 'C', 'lower' => array(7805)); /* LATIN CAPITAL LETTER V WITH TILDE */ -$config['1e00_1eff'][] = array('upper' => 7806, 'status' => 'C', 'lower' => array(7807)); /* LATIN CAPITAL LETTER V WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7808, 'status' => 'C', 'lower' => array(7809)); /* LATIN CAPITAL LETTER W WITH GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7810, 'status' => 'C', 'lower' => array(7811)); /* LATIN CAPITAL LETTER W WITH ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7812, 'status' => 'C', 'lower' => array(7813)); /* LATIN CAPITAL LETTER W WITH DIAERESIS */ -$config['1e00_1eff'][] = array('upper' => 7814, 'status' => 'C', 'lower' => array(7815)); /* LATIN CAPITAL LETTER W WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7816, 'status' => 'C', 'lower' => array(7817)); /* LATIN CAPITAL LETTER W WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7818, 'status' => 'C', 'lower' => array(7819)); /* LATIN CAPITAL LETTER X WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7820, 'status' => 'C', 'lower' => array(7821)); /* LATIN CAPITAL LETTER X WITH DIAERESIS */ -$config['1e00_1eff'][] = array('upper' => 7822, 'status' => 'C', 'lower' => array(7823)); /* LATIN CAPITAL LETTER Y WITH DOT ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7824, 'status' => 'C', 'lower' => array(7825)); /* LATIN CAPITAL LETTER Z WITH CIRCUMFLEX */ -$config['1e00_1eff'][] = array('upper' => 7826, 'status' => 'C', 'lower' => array(7827)); /* LATIN CAPITAL LETTER Z WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7828, 'status' => 'C', 'lower' => array(7829)); /* LATIN CAPITAL LETTER Z WITH LINE BELOW */ - -//$config['1e00_1eff'][] = array('upper' => 7830, 'status' => 'F', 'lower' => array(104, 817)); /* LATIN SMALL LETTER H WITH LINE BELOW */ -//$config['1e00_1eff'][] = array('upper' => 7831, 'status' => 'F', 'lower' => array(116, 776)); /* LATIN SMALL LETTER T WITH DIAERESIS */ -//$config['1e00_1eff'][] = array('upper' => 7832, 'status' => 'F', 'lower' => array(119, 778)); /* LATIN SMALL LETTER W WITH RING ABOVE */ -//$config['1e00_1eff'][] = array('upper' => 7833, 'status' => 'F', 'lower' => array(121, 778)); /* LATIN SMALL LETTER Y WITH RING ABOVE */ -//$config['1e00_1eff'][] = array('upper' => 7834, 'status' => 'F', 'lower' => array(97, 702)); /* LATIN SMALL LETTER A WITH RIGHT HALF RING */ -//$config['1e00_1eff'][] = array('upper' => 7835, 'status' => 'C', 'lower' => array(7777)); /* LATIN SMALL LETTER LONG S WITH DOT ABOVE */ - -$config['1e00_1eff'][] = array('upper' => 7840, 'status' => 'C', 'lower' => array(7841)); /* LATIN CAPITAL LETTER A WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7842, 'status' => 'C', 'lower' => array(7843)); /* LATIN CAPITAL LETTER A WITH HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7844, 'status' => 'C', 'lower' => array(7845)); /* LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7846, 'status' => 'C', 'lower' => array(7847)); /* LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7848, 'status' => 'C', 'lower' => array(7849)); /* LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7850, 'status' => 'C', 'lower' => array(7851)); /* LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND TILDE */ -$config['1e00_1eff'][] = array('upper' => 7852, 'status' => 'C', 'lower' => array(7853)); /* LATIN CAPITAL LETTER A WITH CIRCUMFLEX AND DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7854, 'status' => 'C', 'lower' => array(7855)); /* LATIN CAPITAL LETTER A WITH BREVE AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7856, 'status' => 'C', 'lower' => array(7857)); /* LATIN CAPITAL LETTER A WITH BREVE AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7858, 'status' => 'C', 'lower' => array(7859)); /* LATIN CAPITAL LETTER A WITH BREVE AND HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7860, 'status' => 'C', 'lower' => array(7861)); /* LATIN CAPITAL LETTER A WITH BREVE AND TILDE */ -$config['1e00_1eff'][] = array('upper' => 7862, 'status' => 'C', 'lower' => array(7863)); /* LATIN CAPITAL LETTER A WITH BREVE AND DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7864, 'status' => 'C', 'lower' => array(7865)); /* LATIN CAPITAL LETTER E WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7866, 'status' => 'C', 'lower' => array(7867)); /* LATIN CAPITAL LETTER E WITH HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7868, 'status' => 'C', 'lower' => array(7869)); /* LATIN CAPITAL LETTER E WITH TILDE */ -$config['1e00_1eff'][] = array('upper' => 7870, 'status' => 'C', 'lower' => array(7871)); /* LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7872, 'status' => 'C', 'lower' => array(7873)); /* LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7874, 'status' => 'C', 'lower' => array(7875)); /* LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7876, 'status' => 'C', 'lower' => array(7877)); /* LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND TILDE */ -$config['1e00_1eff'][] = array('upper' => 7878, 'status' => 'C', 'lower' => array(7879)); /* LATIN CAPITAL LETTER E WITH CIRCUMFLEX AND DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7880, 'status' => 'C', 'lower' => array(7881)); /* LATIN CAPITAL LETTER I WITH HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7882, 'status' => 'C', 'lower' => array(7883)); /* LATIN CAPITAL LETTER I WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7884, 'status' => 'C', 'lower' => array(7885)); /* LATIN CAPITAL LETTER O WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7886, 'status' => 'C', 'lower' => array(7887)); /* LATIN CAPITAL LETTER O WITH HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7888, 'status' => 'C', 'lower' => array(7889)); /* LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7890, 'status' => 'C', 'lower' => array(7891)); /* LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7892, 'status' => 'C', 'lower' => array(7893)); /* LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7894, 'status' => 'C', 'lower' => array(7895)); /* LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND TILDE */ -$config['1e00_1eff'][] = array('upper' => 7896, 'status' => 'C', 'lower' => array(7897)); /* LATIN CAPITAL LETTER O WITH CIRCUMFLEX AND DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7898, 'status' => 'C', 'lower' => array(7899)); /* LATIN CAPITAL LETTER O WITH HORN AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7900, 'status' => 'C', 'lower' => array(7901)); /* LATIN CAPITAL LETTER O WITH HORN AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7902, 'status' => 'C', 'lower' => array(7903)); /* LATIN CAPITAL LETTER O WITH HORN AND HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7904, 'status' => 'C', 'lower' => array(7905)); /* LATIN CAPITAL LETTER O WITH HORN AND TILDE */ -$config['1e00_1eff'][] = array('upper' => 7906, 'status' => 'C', 'lower' => array(7907)); /* LATIN CAPITAL LETTER O WITH HORN AND DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7908, 'status' => 'C', 'lower' => array(7909)); /* LATIN CAPITAL LETTER U WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7910, 'status' => 'C', 'lower' => array(7911)); /* LATIN CAPITAL LETTER U WITH HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7912, 'status' => 'C', 'lower' => array(7913)); /* LATIN CAPITAL LETTER U WITH HORN AND ACUTE */ -$config['1e00_1eff'][] = array('upper' => 7914, 'status' => 'C', 'lower' => array(7915)); /* LATIN CAPITAL LETTER U WITH HORN AND GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7916, 'status' => 'C', 'lower' => array(7917)); /* LATIN CAPITAL LETTER U WITH HORN AND HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7918, 'status' => 'C', 'lower' => array(7919)); /* LATIN CAPITAL LETTER U WITH HORN AND TILDE */ -$config['1e00_1eff'][] = array('upper' => 7920, 'status' => 'C', 'lower' => array(7921)); /* LATIN CAPITAL LETTER U WITH HORN AND DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7922, 'status' => 'C', 'lower' => array(7923)); /* LATIN CAPITAL LETTER Y WITH GRAVE */ -$config['1e00_1eff'][] = array('upper' => 7924, 'status' => 'C', 'lower' => array(7925)); /* LATIN CAPITAL LETTER Y WITH DOT BELOW */ -$config['1e00_1eff'][] = array('upper' => 7926, 'status' => 'C', 'lower' => array(7927)); /* LATIN CAPITAL LETTER Y WITH HOOK ABOVE */ -$config['1e00_1eff'][] = array('upper' => 7928, 'status' => 'C', 'lower' => array(7929)); /* LATIN CAPITAL LETTER Y WITH TILDE */ diff --git a/lib/Cake/Config/unicode/casefolding/1f00_1fff.php b/lib/Cake/Config/unicode/casefolding/1f00_1fff.php deleted file mode 100644 index be09aa5a157..00000000000 --- a/lib/Cake/Config/unicode/casefolding/1f00_1fff.php +++ /dev/null @@ -1,216 +0,0 @@ - 7944, 'status' => 'C', 'lower' => array(7936, 953)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 7945, 'status' => 'C', 'lower' => array(7937)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 7946, 'status' => 'C', 'lower' => array(7938)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7947, 'status' => 'C', 'lower' => array(7939)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7948, 'status' => 'C', 'lower' => array(7940)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7949, 'status' => 'C', 'lower' => array(7941)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7950, 'status' => 'C', 'lower' => array(7942)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 7951, 'status' => 'C', 'lower' => array(7943)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 7960, 'status' => 'C', 'lower' => array(7952)); /* GREEK CAPITAL LETTER EPSILON WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 7961, 'status' => 'C', 'lower' => array(7953)); /* GREEK CAPITAL LETTER EPSILON WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 7962, 'status' => 'C', 'lower' => array(7954)); /* GREEK CAPITAL LETTER EPSILON WITH PSILI AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7963, 'status' => 'C', 'lower' => array(7955)); /* GREEK CAPITAL LETTER EPSILON WITH DASIA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7964, 'status' => 'C', 'lower' => array(7956)); /* GREEK CAPITAL LETTER EPSILON WITH PSILI AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7965, 'status' => 'C', 'lower' => array(7957)); /* GREEK CAPITAL LETTER EPSILON WITH DASIA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7976, 'status' => 'C', 'lower' => array(7968)); /* GREEK CAPITAL LETTER ETA WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 7977, 'status' => 'C', 'lower' => array(7969)); /* GREEK CAPITAL LETTER ETA WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 7978, 'status' => 'C', 'lower' => array(7970)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7979, 'status' => 'C', 'lower' => array(7971)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7980, 'status' => 'C', 'lower' => array(7972)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7981, 'status' => 'C', 'lower' => array(7973)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7982, 'status' => 'C', 'lower' => array(7974)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 7983, 'status' => 'C', 'lower' => array(7975)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 7992, 'status' => 'C', 'lower' => array(7984)); /* GREEK CAPITAL LETTER IOTA WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 7993, 'status' => 'C', 'lower' => array(7985)); /* GREEK CAPITAL LETTER IOTA WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 7994, 'status' => 'C', 'lower' => array(7986)); /* GREEK CAPITAL LETTER IOTA WITH PSILI AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7995, 'status' => 'C', 'lower' => array(7987)); /* GREEK CAPITAL LETTER IOTA WITH DASIA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 7996, 'status' => 'C', 'lower' => array(7988)); /* GREEK CAPITAL LETTER IOTA WITH PSILI AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7997, 'status' => 'C', 'lower' => array(7989)); /* GREEK CAPITAL LETTER IOTA WITH DASIA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 7998, 'status' => 'C', 'lower' => array(7990)); /* GREEK CAPITAL LETTER IOTA WITH PSILI AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 7999, 'status' => 'C', 'lower' => array(7991)); /* GREEK CAPITAL LETTER IOTA WITH DASIA AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8008, 'status' => 'C', 'lower' => array(8000)); /* GREEK CAPITAL LETTER OMICRON WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 8009, 'status' => 'C', 'lower' => array(8001)); /* GREEK CAPITAL LETTER OMICRON WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 8010, 'status' => 'C', 'lower' => array(8002)); /* GREEK CAPITAL LETTER OMICRON WITH PSILI AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8011, 'status' => 'C', 'lower' => array(8003)); /* GREEK CAPITAL LETTER OMICRON WITH DASIA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8012, 'status' => 'C', 'lower' => array(8004)); /* GREEK CAPITAL LETTER OMICRON WITH PSILI AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8013, 'status' => 'C', 'lower' => array(8005)); /* GREEK CAPITAL LETTER OMICRON WITH DASIA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8016, 'status' => 'F', 'lower' => array(965, 787)); /* GREEK SMALL LETTER UPSILON WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 8018, 'status' => 'F', 'lower' => array(965, 787, 768)); /* GREEK SMALL LETTER UPSILON WITH PSILI AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8020, 'status' => 'F', 'lower' => array(965, 787, 769)); /* GREEK SMALL LETTER UPSILON WITH PSILI AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8022, 'status' => 'F', 'lower' => array(965, 787, 834)); /* GREEK SMALL LETTER UPSILON WITH PSILI AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8025, 'status' => 'C', 'lower' => array(8017)); /* GREEK CAPITAL LETTER UPSILON WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 8027, 'status' => 'C', 'lower' => array(8019)); /* GREEK CAPITAL LETTER UPSILON WITH DASIA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8029, 'status' => 'C', 'lower' => array(8021)); /* GREEK CAPITAL LETTER UPSILON WITH DASIA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8031, 'status' => 'C', 'lower' => array(8023)); /* GREEK CAPITAL LETTER UPSILON WITH DASIA AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8040, 'status' => 'C', 'lower' => array(8032)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 8041, 'status' => 'C', 'lower' => array(8033)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 8042, 'status' => 'C', 'lower' => array(8034)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8043, 'status' => 'C', 'lower' => array(8035)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8044, 'status' => 'C', 'lower' => array(8036)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8045, 'status' => 'C', 'lower' => array(8037)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8046, 'status' => 'C', 'lower' => array(8038)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8047, 'status' => 'C', 'lower' => array(8039)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8064, 'status' => 'F', 'lower' => array(7936, 953)); /* GREEK SMALL LETTER ALPHA WITH PSILI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8065, 'status' => 'F', 'lower' => array(7937, 953)); /* GREEK SMALL LETTER ALPHA WITH DASIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8066, 'status' => 'F', 'lower' => array(7938, 953)); /* GREEK SMALL LETTER ALPHA WITH PSILI AND VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8067, 'status' => 'F', 'lower' => array(7939, 953)); /* GREEK SMALL LETTER ALPHA WITH DASIA AND VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8068, 'status' => 'F', 'lower' => array(7940, 953)); /* GREEK SMALL LETTER ALPHA WITH PSILI AND OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8069, 'status' => 'F', 'lower' => array(7941, 953)); /* GREEK SMALL LETTER ALPHA WITH DASIA AND OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8070, 'status' => 'F', 'lower' => array(7942, 953)); /* GREEK SMALL LETTER ALPHA WITH PSILI AND PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8071, 'status' => 'F', 'lower' => array(7943, 953)); /* GREEK SMALL LETTER ALPHA WITH DASIA AND PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8072, 'status' => 'F', 'lower' => array(7936, 953)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8072, 'status' => 'S', 'lower' => array(8064)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8073, 'status' => 'F', 'lower' => array(7937, 953)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8073, 'status' => 'S', 'lower' => array(8065)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8074, 'status' => 'F', 'lower' => array(7938, 953)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8074, 'status' => 'S', 'lower' => array(8066)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8075, 'status' => 'F', 'lower' => array(7939, 953)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8075, 'status' => 'S', 'lower' => array(8067)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8076, 'status' => 'F', 'lower' => array(7940, 953)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8076, 'status' => 'S', 'lower' => array(8068)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8077, 'status' => 'F', 'lower' => array(7941, 953)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8077, 'status' => 'S', 'lower' => array(8069)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8078, 'status' => 'F', 'lower' => array(7942, 953)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8078, 'status' => 'S', 'lower' => array(8070)); /* GREEK CAPITAL LETTER ALPHA WITH PSILI AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8079, 'status' => 'F', 'lower' => array(7943, 953)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8079, 'status' => 'S', 'lower' => array(8071)); /* GREEK CAPITAL LETTER ALPHA WITH DASIA AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8080, 'status' => 'F', 'lower' => array(7968, 953)); /* GREEK SMALL LETTER ETA WITH PSILI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8081, 'status' => 'F', 'lower' => array(7969, 953)); /* GREEK SMALL LETTER ETA WITH DASIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8082, 'status' => 'F', 'lower' => array(7970, 953)); /* GREEK SMALL LETTER ETA WITH PSILI AND VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8083, 'status' => 'F', 'lower' => array(7971, 953)); /* GREEK SMALL LETTER ETA WITH DASIA AND VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8084, 'status' => 'F', 'lower' => array(7972, 953)); /* GREEK SMALL LETTER ETA WITH PSILI AND OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8085, 'status' => 'F', 'lower' => array(7973, 953)); /* GREEK SMALL LETTER ETA WITH DASIA AND OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8086, 'status' => 'F', 'lower' => array(7974, 953)); /* GREEK SMALL LETTER ETA WITH PSILI AND PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8087, 'status' => 'F', 'lower' => array(7975, 953)); /* GREEK SMALL LETTER ETA WITH DASIA AND PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8088, 'status' => 'F', 'lower' => array(7968, 953)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8088, 'status' => 'S', 'lower' => array(8080)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8089, 'status' => 'F', 'lower' => array(7969, 953)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8089, 'status' => 'S', 'lower' => array(8081)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8090, 'status' => 'F', 'lower' => array(7970, 953)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8090, 'status' => 'S', 'lower' => array(8082)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8091, 'status' => 'F', 'lower' => array(7971, 953)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8091, 'status' => 'S', 'lower' => array(8083)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8092, 'status' => 'F', 'lower' => array(7972, 953)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8092, 'status' => 'S', 'lower' => array(8084)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8093, 'status' => 'F', 'lower' => array(7973, 953)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8093, 'status' => 'S', 'lower' => array(8085)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8094, 'status' => 'F', 'lower' => array(7974, 953)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8094, 'status' => 'S', 'lower' => array(8086)); /* GREEK CAPITAL LETTER ETA WITH PSILI AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8095, 'status' => 'F', 'lower' => array(7975, 953)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8095, 'status' => 'S', 'lower' => array(8087)); /* GREEK CAPITAL LETTER ETA WITH DASIA AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8096, 'status' => 'F', 'lower' => array(8032, 953)); /* GREEK SMALL LETTER OMEGA WITH PSILI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8097, 'status' => 'F', 'lower' => array(8033, 953)); /* GREEK SMALL LETTER OMEGA WITH DASIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8098, 'status' => 'F', 'lower' => array(8034, 953)); /* GREEK SMALL LETTER OMEGA WITH PSILI AND VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8099, 'status' => 'F', 'lower' => array(8035, 953)); /* GREEK SMALL LETTER OMEGA WITH DASIA AND VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8100, 'status' => 'F', 'lower' => array(8036, 953)); /* GREEK SMALL LETTER OMEGA WITH PSILI AND OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8101, 'status' => 'F', 'lower' => array(8037, 953)); /* GREEK SMALL LETTER OMEGA WITH DASIA AND OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8102, 'status' => 'F', 'lower' => array(8038, 953)); /* GREEK SMALL LETTER OMEGA WITH PSILI AND PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8103, 'status' => 'F', 'lower' => array(8039, 953)); /* GREEK SMALL LETTER OMEGA WITH DASIA AND PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8104, 'status' => 'F', 'lower' => array(8032, 953)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8104, 'status' => 'S', 'lower' => array(8096)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8105, 'status' => 'F', 'lower' => array(8033, 953)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8105, 'status' => 'S', 'lower' => array(8097)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8106, 'status' => 'F', 'lower' => array(8034, 953)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8106, 'status' => 'S', 'lower' => array(8098)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8107, 'status' => 'F', 'lower' => array(8035, 953)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8107, 'status' => 'S', 'lower' => array(8099)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND VARIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8108, 'status' => 'F', 'lower' => array(8036, 953)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8108, 'status' => 'S', 'lower' => array(8100)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8109, 'status' => 'F', 'lower' => array(8037, 953)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8109, 'status' => 'S', 'lower' => array(8101)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND OXIA AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8110, 'status' => 'F', 'lower' => array(8038, 953)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8110, 'status' => 'S', 'lower' => array(8102)); /* GREEK CAPITAL LETTER OMEGA WITH PSILI AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8111, 'status' => 'F', 'lower' => array(8039, 953)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8111, 'status' => 'S', 'lower' => array(8103)); /* GREEK CAPITAL LETTER OMEGA WITH DASIA AND PERISPOMENI AND PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8114, 'status' => 'F', 'lower' => array(8048, 953)); /* GREEK SMALL LETTER ALPHA WITH VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8115, 'status' => 'F', 'lower' => array(945, 953)); /* GREEK SMALL LETTER ALPHA WITH YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8116, 'status' => 'F', 'lower' => array(940, 953)); /* GREEK SMALL LETTER ALPHA WITH OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8118, 'status' => 'F', 'lower' => array(945, 834)); /* GREEK SMALL LETTER ALPHA WITH PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8119, 'status' => 'F', 'lower' => array(945, 834, 953)); /* GREEK SMALL LETTER ALPHA WITH PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8120, 'status' => 'C', 'lower' => array(8112)); /* GREEK CAPITAL LETTER ALPHA WITH VRACHY */ -$config['1f00_1fff'][] = array('upper' => 8121, 'status' => 'C', 'lower' => array(8113)); /* GREEK CAPITAL LETTER ALPHA WITH MACRON */ -$config['1f00_1fff'][] = array('upper' => 8122, 'status' => 'C', 'lower' => array(8048)); /* GREEK CAPITAL LETTER ALPHA WITH VARIA */ -$config['1f00_1fff'][] = array('upper' => 8123, 'status' => 'C', 'lower' => array(8049)); /* GREEK CAPITAL LETTER ALPHA WITH OXIA */ -$config['1f00_1fff'][] = array('upper' => 8124, 'status' => 'F', 'lower' => array(945, 953)); /* GREEK CAPITAL LETTER ALPHA WITH PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8124, 'status' => 'S', 'lower' => array(8115)); /* GREEK CAPITAL LETTER ALPHA WITH PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8126, 'status' => 'C', 'lower' => array(953)); /* GREEK PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8130, 'status' => 'F', 'lower' => array(8052, 953)); /* GREEK SMALL LETTER ETA WITH VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8131, 'status' => 'F', 'lower' => array(951, 953)); /* GREEK SMALL LETTER ETA WITH YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8132, 'status' => 'F', 'lower' => array(942, 953)); /* GREEK SMALL LETTER ETA WITH OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8134, 'status' => 'F', 'lower' => array(951, 834)); /* GREEK SMALL LETTER ETA WITH PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8135, 'status' => 'F', 'lower' => array(951, 834, 953)); /* GREEK SMALL LETTER ETA WITH PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8136, 'status' => 'C', 'lower' => array(8050)); /* GREEK CAPITAL LETTER EPSILON WITH VARIA */ -$config['1f00_1fff'][] = array('upper' => 8137, 'status' => 'C', 'lower' => array(8051)); /* GREEK CAPITAL LETTER EPSILON WITH OXIA */ -$config['1f00_1fff'][] = array('upper' => 8138, 'status' => 'C', 'lower' => array(8052)); /* GREEK CAPITAL LETTER ETA WITH VARIA */ -$config['1f00_1fff'][] = array('upper' => 8139, 'status' => 'C', 'lower' => array(8053)); /* GREEK CAPITAL LETTER ETA WITH OXIA */ -$config['1f00_1fff'][] = array('upper' => 8140, 'status' => 'F', 'lower' => array(951, 953)); /* GREEK CAPITAL LETTER ETA WITH PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8140, 'status' => 'S', 'lower' => array(8131)); /* GREEK CAPITAL LETTER ETA WITH PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8146, 'status' => 'F', 'lower' => array(953, 776, 768)); /* GREEK SMALL LETTER IOTA WITH DIALYTIKA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8147, 'status' => 'F', 'lower' => array(953, 776, 769)); /* GREEK SMALL LETTER IOTA WITH DIALYTIKA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8150, 'status' => 'F', 'lower' => array(953, 834)); /* GREEK SMALL LETTER IOTA WITH PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8151, 'status' => 'F', 'lower' => array(953, 776, 834)); /* GREEK SMALL LETTER IOTA WITH DIALYTIKA AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8152, 'status' => 'C', 'lower' => array(8144)); /* GREEK CAPITAL LETTER IOTA WITH VRACHY */ -$config['1f00_1fff'][] = array('upper' => 8153, 'status' => 'C', 'lower' => array(8145)); /* GREEK CAPITAL LETTER IOTA WITH MACRON */ -$config['1f00_1fff'][] = array('upper' => 8154, 'status' => 'C', 'lower' => array(8054)); /* GREEK CAPITAL LETTER IOTA WITH VARIA */ -$config['1f00_1fff'][] = array('upper' => 8155, 'status' => 'C', 'lower' => array(8055)); /* GREEK CAPITAL LETTER IOTA WITH OXIA */ -$config['1f00_1fff'][] = array('upper' => 8162, 'status' => 'F', 'lower' => array(965, 776, 768)); /* GREEK SMALL LETTER UPSILON WITH DIALYTIKA AND VARIA */ -$config['1f00_1fff'][] = array('upper' => 8163, 'status' => 'F', 'lower' => array(965, 776, 769)); /* GREEK SMALL LETTER UPSILON WITH DIALYTIKA AND OXIA */ -$config['1f00_1fff'][] = array('upper' => 8164, 'status' => 'F', 'lower' => array(961, 787)); /* GREEK SMALL LETTER RHO WITH PSILI */ -$config['1f00_1fff'][] = array('upper' => 8166, 'status' => 'F', 'lower' => array(965, 834)); /* GREEK SMALL LETTER UPSILON WITH PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8167, 'status' => 'F', 'lower' => array(965, 776, 834)); /* GREEK SMALL LETTER UPSILON WITH DIALYTIKA AND PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8168, 'status' => 'C', 'lower' => array(8160)); /* GREEK CAPITAL LETTER UPSILON WITH VRACHY */ -$config['1f00_1fff'][] = array('upper' => 8169, 'status' => 'C', 'lower' => array(8161)); /* GREEK CAPITAL LETTER UPSILON WITH MACRON */ -$config['1f00_1fff'][] = array('upper' => 8170, 'status' => 'C', 'lower' => array(8058)); /* GREEK CAPITAL LETTER UPSILON WITH VARIA */ -$config['1f00_1fff'][] = array('upper' => 8171, 'status' => 'C', 'lower' => array(8059)); /* GREEK CAPITAL LETTER UPSILON WITH OXIA */ -$config['1f00_1fff'][] = array('upper' => 8172, 'status' => 'C', 'lower' => array(8165)); /* GREEK CAPITAL LETTER RHO WITH DASIA */ -$config['1f00_1fff'][] = array('upper' => 8178, 'status' => 'F', 'lower' => array(8060, 953)); /* GREEK SMALL LETTER OMEGA WITH VARIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8179, 'status' => 'F', 'lower' => array(969, 953)); /* GREEK SMALL LETTER OMEGA WITH YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8180, 'status' => 'F', 'lower' => array(974, 953)); /* GREEK SMALL LETTER OMEGA WITH OXIA AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8182, 'status' => 'F', 'lower' => array(969, 834)); /* GREEK SMALL LETTER OMEGA WITH PERISPOMENI */ -$config['1f00_1fff'][] = array('upper' => 8183, 'status' => 'F', 'lower' => array(969, 834, 953)); /* GREEK SMALL LETTER OMEGA WITH PERISPOMENI AND YPOGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8184, 'status' => 'C', 'lower' => array(8056)); /* GREEK CAPITAL LETTER OMICRON WITH VARIA */ -$config['1f00_1fff'][] = array('upper' => 8185, 'status' => 'C', 'lower' => array(8057)); /* GREEK CAPITAL LETTER OMICRON WITH OXIA */ -$config['1f00_1fff'][] = array('upper' => 8186, 'status' => 'C', 'lower' => array(8060)); /* GREEK CAPITAL LETTER OMEGA WITH VARIA */ -$config['1f00_1fff'][] = array('upper' => 8187, 'status' => 'C', 'lower' => array(8061)); /* GREEK CAPITAL LETTER OMEGA WITH OXIA */ -$config['1f00_1fff'][] = array('upper' => 8188, 'status' => 'F', 'lower' => array(969, 953)); /* GREEK CAPITAL LETTER OMEGA WITH PROSGEGRAMMENI */ -$config['1f00_1fff'][] = array('upper' => 8188, 'status' => 'S', 'lower' => array(8179)); /* GREEK CAPITAL LETTER OMEGA WITH PROSGEGRAMMENI */ diff --git a/lib/Cake/Config/unicode/casefolding/2100_214f.php b/lib/Cake/Config/unicode/casefolding/2100_214f.php deleted file mode 100644 index 77705ede8fb..00000000000 --- a/lib/Cake/Config/unicode/casefolding/2100_214f.php +++ /dev/null @@ -1,44 +0,0 @@ - 8486, 'status' => 'C', 'lower' => array(969)); /* OHM SIGN */ -$config['2100_214f'][] = array('upper' => 8490, 'status' => 'C', 'lower' => array(107)); /* KELVIN SIGN */ -$config['2100_214f'][] = array('upper' => 8491, 'status' => 'C', 'lower' => array(229)); /* ANGSTROM SIGN */ -$config['2100_214f'][] = array('upper' => 8498, 'status' => 'C', 'lower' => array(8526)); /* TURNED CAPITAL F */ diff --git a/lib/Cake/Config/unicode/casefolding/2150_218f.php b/lib/Cake/Config/unicode/casefolding/2150_218f.php deleted file mode 100644 index 8821fbdea8b..00000000000 --- a/lib/Cake/Config/unicode/casefolding/2150_218f.php +++ /dev/null @@ -1,57 +0,0 @@ - 8544, 'status' => 'C', 'lower' => array(8560)); /* ROMAN NUMERAL ONE */ -$config['2150_218f'][] = array('upper' => 8545, 'status' => 'C', 'lower' => array(8561)); /* ROMAN NUMERAL TWO */ -$config['2150_218f'][] = array('upper' => 8546, 'status' => 'C', 'lower' => array(8562)); /* ROMAN NUMERAL THREE */ -$config['2150_218f'][] = array('upper' => 8547, 'status' => 'C', 'lower' => array(8563)); /* ROMAN NUMERAL FOUR */ -$config['2150_218f'][] = array('upper' => 8548, 'status' => 'C', 'lower' => array(8564)); /* ROMAN NUMERAL FIVE */ -$config['2150_218f'][] = array('upper' => 8549, 'status' => 'C', 'lower' => array(8565)); /* ROMAN NUMERAL SIX */ -$config['2150_218f'][] = array('upper' => 8550, 'status' => 'C', 'lower' => array(8566)); /* ROMAN NUMERAL SEVEN */ -$config['2150_218f'][] = array('upper' => 8551, 'status' => 'C', 'lower' => array(8567)); /* ROMAN NUMERAL EIGHT */ -$config['2150_218f'][] = array('upper' => 8552, 'status' => 'C', 'lower' => array(8568)); /* ROMAN NUMERAL NINE */ -$config['2150_218f'][] = array('upper' => 8553, 'status' => 'C', 'lower' => array(8569)); /* ROMAN NUMERAL TEN */ -$config['2150_218f'][] = array('upper' => 8554, 'status' => 'C', 'lower' => array(8570)); /* ROMAN NUMERAL ELEVEN */ -$config['2150_218f'][] = array('upper' => 8555, 'status' => 'C', 'lower' => array(8571)); /* ROMAN NUMERAL TWELVE */ -$config['2150_218f'][] = array('upper' => 8556, 'status' => 'C', 'lower' => array(8572)); /* ROMAN NUMERAL FIFTY */ -$config['2150_218f'][] = array('upper' => 8557, 'status' => 'C', 'lower' => array(8573)); /* ROMAN NUMERAL ONE HUNDRED */ -$config['2150_218f'][] = array('upper' => 8558, 'status' => 'C', 'lower' => array(8574)); /* ROMAN NUMERAL FIVE HUNDRED */ -$config['2150_218f'][] = array('upper' => 8559, 'status' => 'C', 'lower' => array(8575)); /* ROMAN NUMERAL ONE THOUSAND */ -$config['2150_218f'][] = array('upper' => 8579, 'status' => 'C', 'lower' => array(8580)); /* ROMAN NUMERAL REVERSED ONE HUNDRED */ diff --git a/lib/Cake/Config/unicode/casefolding/2460_24ff.php b/lib/Cake/Config/unicode/casefolding/2460_24ff.php deleted file mode 100644 index 08b26159444..00000000000 --- a/lib/Cake/Config/unicode/casefolding/2460_24ff.php +++ /dev/null @@ -1,66 +0,0 @@ - 9398, 'status' => 'C', 'lower' => array(9424)); /* CIRCLED LATIN CAPITAL LETTER A */ -$config['2460_24ff'][] = array('upper' => 9399, 'status' => 'C', 'lower' => array(9425)); /* CIRCLED LATIN CAPITAL LETTER B */ -$config['2460_24ff'][] = array('upper' => 9400, 'status' => 'C', 'lower' => array(9426)); /* CIRCLED LATIN CAPITAL LETTER C */ -$config['2460_24ff'][] = array('upper' => 9401, 'status' => 'C', 'lower' => array(9427)); /* CIRCLED LATIN CAPITAL LETTER D */ -$config['2460_24ff'][] = array('upper' => 9402, 'status' => 'C', 'lower' => array(9428)); /* CIRCLED LATIN CAPITAL LETTER E */ -$config['2460_24ff'][] = array('upper' => 9403, 'status' => 'C', 'lower' => array(9429)); /* CIRCLED LATIN CAPITAL LETTER F */ -$config['2460_24ff'][] = array('upper' => 9404, 'status' => 'C', 'lower' => array(9430)); /* CIRCLED LATIN CAPITAL LETTER G */ -$config['2460_24ff'][] = array('upper' => 9405, 'status' => 'C', 'lower' => array(9431)); /* CIRCLED LATIN CAPITAL LETTER H */ -$config['2460_24ff'][] = array('upper' => 9406, 'status' => 'C', 'lower' => array(9432)); /* CIRCLED LATIN CAPITAL LETTER I */ -$config['2460_24ff'][] = array('upper' => 9407, 'status' => 'C', 'lower' => array(9433)); /* CIRCLED LATIN CAPITAL LETTER J */ -$config['2460_24ff'][] = array('upper' => 9408, 'status' => 'C', 'lower' => array(9434)); /* CIRCLED LATIN CAPITAL LETTER K */ -$config['2460_24ff'][] = array('upper' => 9409, 'status' => 'C', 'lower' => array(9435)); /* CIRCLED LATIN CAPITAL LETTER L */ -$config['2460_24ff'][] = array('upper' => 9410, 'status' => 'C', 'lower' => array(9436)); /* CIRCLED LATIN CAPITAL LETTER M */ -$config['2460_24ff'][] = array('upper' => 9411, 'status' => 'C', 'lower' => array(9437)); /* CIRCLED LATIN CAPITAL LETTER N */ -$config['2460_24ff'][] = array('upper' => 9412, 'status' => 'C', 'lower' => array(9438)); /* CIRCLED LATIN CAPITAL LETTER O */ -$config['2460_24ff'][] = array('upper' => 9413, 'status' => 'C', 'lower' => array(9439)); /* CIRCLED LATIN CAPITAL LETTER P */ -$config['2460_24ff'][] = array('upper' => 9414, 'status' => 'C', 'lower' => array(9440)); /* CIRCLED LATIN CAPITAL LETTER Q */ -$config['2460_24ff'][] = array('upper' => 9415, 'status' => 'C', 'lower' => array(9441)); /* CIRCLED LATIN CAPITAL LETTER R */ -$config['2460_24ff'][] = array('upper' => 9416, 'status' => 'C', 'lower' => array(9442)); /* CIRCLED LATIN CAPITAL LETTER S */ -$config['2460_24ff'][] = array('upper' => 9417, 'status' => 'C', 'lower' => array(9443)); /* CIRCLED LATIN CAPITAL LETTER T */ -$config['2460_24ff'][] = array('upper' => 9418, 'status' => 'C', 'lower' => array(9444)); /* CIRCLED LATIN CAPITAL LETTER U */ -$config['2460_24ff'][] = array('upper' => 9419, 'status' => 'C', 'lower' => array(9445)); /* CIRCLED LATIN CAPITAL LETTER V */ -$config['2460_24ff'][] = array('upper' => 9420, 'status' => 'C', 'lower' => array(9446)); /* CIRCLED LATIN CAPITAL LETTER W */ -$config['2460_24ff'][] = array('upper' => 9421, 'status' => 'C', 'lower' => array(9447)); /* CIRCLED LATIN CAPITAL LETTER X */ -$config['2460_24ff'][] = array('upper' => 9422, 'status' => 'C', 'lower' => array(9448)); /* CIRCLED LATIN CAPITAL LETTER Y */ -$config['2460_24ff'][] = array('upper' => 9423, 'status' => 'C', 'lower' => array(9449)); /* CIRCLED LATIN CAPITAL LETTER Z */ diff --git a/lib/Cake/Config/unicode/casefolding/2c00_2c5f.php b/lib/Cake/Config/unicode/casefolding/2c00_2c5f.php deleted file mode 100644 index 185f39011ab..00000000000 --- a/lib/Cake/Config/unicode/casefolding/2c00_2c5f.php +++ /dev/null @@ -1,87 +0,0 @@ - 11264, 'status' => 'C', 'lower' => array(11312)); /* GLAGOLITIC CAPITAL LETTER AZU */ -$config['2c00_2c5f'][] = array('upper' => 11265, 'status' => 'C', 'lower' => array(11313)); /* GLAGOLITIC CAPITAL LETTER BUKY */ -$config['2c00_2c5f'][] = array('upper' => 11266, 'status' => 'C', 'lower' => array(11314)); /* GLAGOLITIC CAPITAL LETTER VEDE */ -$config['2c00_2c5f'][] = array('upper' => 11267, 'status' => 'C', 'lower' => array(11315)); /* GLAGOLITIC CAPITAL LETTER GLAGOLI */ -$config['2c00_2c5f'][] = array('upper' => 11268, 'status' => 'C', 'lower' => array(11316)); /* GLAGOLITIC CAPITAL LETTER DOBRO */ -$config['2c00_2c5f'][] = array('upper' => 11269, 'status' => 'C', 'lower' => array(11317)); /* GLAGOLITIC CAPITAL LETTER YESTU */ -$config['2c00_2c5f'][] = array('upper' => 11270, 'status' => 'C', 'lower' => array(11318)); /* GLAGOLITIC CAPITAL LETTER ZHIVETE */ -$config['2c00_2c5f'][] = array('upper' => 11271, 'status' => 'C', 'lower' => array(11319)); /* GLAGOLITIC CAPITAL LETTER DZELO */ -$config['2c00_2c5f'][] = array('upper' => 11272, 'status' => 'C', 'lower' => array(11320)); /* GLAGOLITIC CAPITAL LETTER ZEMLJA */ -$config['2c00_2c5f'][] = array('upper' => 11273, 'status' => 'C', 'lower' => array(11321)); /* GLAGOLITIC CAPITAL LETTER IZHE */ -$config['2c00_2c5f'][] = array('upper' => 11274, 'status' => 'C', 'lower' => array(11322)); /* GLAGOLITIC CAPITAL LETTER INITIAL IZHE */ -$config['2c00_2c5f'][] = array('upper' => 11275, 'status' => 'C', 'lower' => array(11323)); /* GLAGOLITIC CAPITAL LETTER I */ -$config['2c00_2c5f'][] = array('upper' => 11276, 'status' => 'C', 'lower' => array(11324)); /* GLAGOLITIC CAPITAL LETTER DJERVI */ -$config['2c00_2c5f'][] = array('upper' => 11277, 'status' => 'C', 'lower' => array(11325)); /* GLAGOLITIC CAPITAL LETTER KAKO */ -$config['2c00_2c5f'][] = array('upper' => 11278, 'status' => 'C', 'lower' => array(11326)); /* GLAGOLITIC CAPITAL LETTER LJUDIJE */ -$config['2c00_2c5f'][] = array('upper' => 11279, 'status' => 'C', 'lower' => array(11327)); /* GLAGOLITIC CAPITAL LETTER MYSLITE */ -$config['2c00_2c5f'][] = array('upper' => 11280, 'status' => 'C', 'lower' => array(11328)); /* GLAGOLITIC CAPITAL LETTER NASHI */ -$config['2c00_2c5f'][] = array('upper' => 11281, 'status' => 'C', 'lower' => array(11329)); /* GLAGOLITIC CAPITAL LETTER ONU */ -$config['2c00_2c5f'][] = array('upper' => 11282, 'status' => 'C', 'lower' => array(11330)); /* GLAGOLITIC CAPITAL LETTER POKOJI */ -$config['2c00_2c5f'][] = array('upper' => 11283, 'status' => 'C', 'lower' => array(11331)); /* GLAGOLITIC CAPITAL LETTER RITSI */ -$config['2c00_2c5f'][] = array('upper' => 11284, 'status' => 'C', 'lower' => array(11332)); /* GLAGOLITIC CAPITAL LETTER SLOVO */ -$config['2c00_2c5f'][] = array('upper' => 11285, 'status' => 'C', 'lower' => array(11333)); /* GLAGOLITIC CAPITAL LETTER TVRIDO */ -$config['2c00_2c5f'][] = array('upper' => 11286, 'status' => 'C', 'lower' => array(11334)); /* GLAGOLITIC CAPITAL LETTER UKU */ -$config['2c00_2c5f'][] = array('upper' => 11287, 'status' => 'C', 'lower' => array(11335)); /* GLAGOLITIC CAPITAL LETTER FRITU */ -$config['2c00_2c5f'][] = array('upper' => 11288, 'status' => 'C', 'lower' => array(11336)); /* GLAGOLITIC CAPITAL LETTER HERU */ -$config['2c00_2c5f'][] = array('upper' => 11289, 'status' => 'C', 'lower' => array(11337)); /* GLAGOLITIC CAPITAL LETTER OTU */ -$config['2c00_2c5f'][] = array('upper' => 11290, 'status' => 'C', 'lower' => array(11338)); /* GLAGOLITIC CAPITAL LETTER PE */ -$config['2c00_2c5f'][] = array('upper' => 11291, 'status' => 'C', 'lower' => array(11339)); /* GLAGOLITIC CAPITAL LETTER SHTA */ -$config['2c00_2c5f'][] = array('upper' => 11292, 'status' => 'C', 'lower' => array(11340)); /* GLAGOLITIC CAPITAL LETTER TSI */ -$config['2c00_2c5f'][] = array('upper' => 11293, 'status' => 'C', 'lower' => array(11341)); /* GLAGOLITIC CAPITAL LETTER CHRIVI */ -$config['2c00_2c5f'][] = array('upper' => 11294, 'status' => 'C', 'lower' => array(11342)); /* GLAGOLITIC CAPITAL LETTER SHA */ -$config['2c00_2c5f'][] = array('upper' => 11295, 'status' => 'C', 'lower' => array(11343)); /* GLAGOLITIC CAPITAL LETTER YERU */ -$config['2c00_2c5f'][] = array('upper' => 11296, 'status' => 'C', 'lower' => array(11344)); /* GLAGOLITIC CAPITAL LETTER YERI */ -$config['2c00_2c5f'][] = array('upper' => 11297, 'status' => 'C', 'lower' => array(11345)); /* GLAGOLITIC CAPITAL LETTER YATI */ -$config['2c00_2c5f'][] = array('upper' => 11298, 'status' => 'C', 'lower' => array(11346)); /* GLAGOLITIC CAPITAL LETTER SPIDERY HA */ -$config['2c00_2c5f'][] = array('upper' => 11299, 'status' => 'C', 'lower' => array(11347)); /* GLAGOLITIC CAPITAL LETTER YU */ -$config['2c00_2c5f'][] = array('upper' => 11300, 'status' => 'C', 'lower' => array(11348)); /* GLAGOLITIC CAPITAL LETTER SMALL YUS */ -$config['2c00_2c5f'][] = array('upper' => 11301, 'status' => 'C', 'lower' => array(11349)); /* GLAGOLITIC CAPITAL LETTER SMALL YUS WITH TAIL */ -$config['2c00_2c5f'][] = array('upper' => 11302, 'status' => 'C', 'lower' => array(11350)); /* GLAGOLITIC CAPITAL LETTER YO */ -$config['2c00_2c5f'][] = array('upper' => 11303, 'status' => 'C', 'lower' => array(11351)); /* GLAGOLITIC CAPITAL LETTER IOTATED SMALL YUS */ -$config['2c00_2c5f'][] = array('upper' => 11304, 'status' => 'C', 'lower' => array(11352)); /* GLAGOLITIC CAPITAL LETTER BIG YUS */ -$config['2c00_2c5f'][] = array('upper' => 11305, 'status' => 'C', 'lower' => array(11353)); /* GLAGOLITIC CAPITAL LETTER IOTATED BIG YUS */ -$config['2c00_2c5f'][] = array('upper' => 11306, 'status' => 'C', 'lower' => array(11354)); /* GLAGOLITIC CAPITAL LETTER FITA */ -$config['2c00_2c5f'][] = array('upper' => 11307, 'status' => 'C', 'lower' => array(11355)); /* GLAGOLITIC CAPITAL LETTER IZHITSA */ -$config['2c00_2c5f'][] = array('upper' => 11308, 'status' => 'C', 'lower' => array(11356)); /* GLAGOLITIC CAPITAL LETTER SHTAPIC */ -$config['2c00_2c5f'][] = array('upper' => 11309, 'status' => 'C', 'lower' => array(11357)); /* GLAGOLITIC CAPITAL LETTER TROKUTASTI A */ -$config['2c00_2c5f'][] = array('upper' => 11310, 'status' => 'C', 'lower' => array(11358)); /* GLAGOLITIC CAPITAL LETTER LATINATE MYSLITE */ diff --git a/lib/Cake/Config/unicode/casefolding/2c60_2c7f.php b/lib/Cake/Config/unicode/casefolding/2c60_2c7f.php deleted file mode 100644 index f0f1e906673..00000000000 --- a/lib/Cake/Config/unicode/casefolding/2c60_2c7f.php +++ /dev/null @@ -1,48 +0,0 @@ - 11360, 'status' => 'C', 'lower' => array(11361)); /* LATIN CAPITAL LETTER L WITH DOUBLE BAR */ -$config['2c60_2c7f'][] = array('upper' => 11362, 'status' => 'C', 'lower' => array(619)); /* LATIN CAPITAL LETTER L WITH MIDDLE TILDE */ -$config['2c60_2c7f'][] = array('upper' => 11363, 'status' => 'C', 'lower' => array(7549)); /* LATIN CAPITAL LETTER P WITH STROKE */ -$config['2c60_2c7f'][] = array('upper' => 11364, 'status' => 'C', 'lower' => array(637)); /* LATIN CAPITAL LETTER R WITH TAIL */ -$config['2c60_2c7f'][] = array('upper' => 11367, 'status' => 'C', 'lower' => array(11368)); /* LATIN CAPITAL LETTER H WITH DESCENDER */ -$config['2c60_2c7f'][] = array('upper' => 11369, 'status' => 'C', 'lower' => array(11370)); /* LATIN CAPITAL LETTER K WITH DESCENDER */ -$config['2c60_2c7f'][] = array('upper' => 11371, 'status' => 'C', 'lower' => array(11372)); /* LATIN CAPITAL LETTER Z WITH DESCENDER */ -$config['2c60_2c7f'][] = array('upper' => 11381, 'status' => 'C', 'lower' => array(11382)); /* LATIN CAPITAL LETTER HALF H */ diff --git a/lib/Cake/Config/unicode/casefolding/2c80_2cff.php b/lib/Cake/Config/unicode/casefolding/2c80_2cff.php deleted file mode 100644 index c073a534f2f..00000000000 --- a/lib/Cake/Config/unicode/casefolding/2c80_2cff.php +++ /dev/null @@ -1,90 +0,0 @@ - 11392, 'status' => 'C', 'lower' => array(11393)); /* COPTIC CAPITAL LETTER ALFA */ -$config['2c80_2cff'][] = array('upper' => 11394, 'status' => 'C', 'lower' => array(11395)); /* COPTIC CAPITAL LETTER VIDA */ -$config['2c80_2cff'][] = array('upper' => 11396, 'status' => 'C', 'lower' => array(11397)); /* COPTIC CAPITAL LETTER GAMMA */ -$config['2c80_2cff'][] = array('upper' => 11398, 'status' => 'C', 'lower' => array(11399)); /* COPTIC CAPITAL LETTER DALDA */ -$config['2c80_2cff'][] = array('upper' => 11400, 'status' => 'C', 'lower' => array(11401)); /* COPTIC CAPITAL LETTER EIE */ -$config['2c80_2cff'][] = array('upper' => 11402, 'status' => 'C', 'lower' => array(11403)); /* COPTIC CAPITAL LETTER SOU */ -$config['2c80_2cff'][] = array('upper' => 11404, 'status' => 'C', 'lower' => array(11405)); /* COPTIC CAPITAL LETTER ZATA */ -$config['2c80_2cff'][] = array('upper' => 11406, 'status' => 'C', 'lower' => array(11407)); /* COPTIC CAPITAL LETTER HATE */ -$config['2c80_2cff'][] = array('upper' => 11408, 'status' => 'C', 'lower' => array(11409)); /* COPTIC CAPITAL LETTER THETHE */ -$config['2c80_2cff'][] = array('upper' => 11410, 'status' => 'C', 'lower' => array(11411)); /* COPTIC CAPITAL LETTER IAUDA */ -$config['2c80_2cff'][] = array('upper' => 11412, 'status' => 'C', 'lower' => array(11413)); /* COPTIC CAPITAL LETTER KAPA */ -$config['2c80_2cff'][] = array('upper' => 11414, 'status' => 'C', 'lower' => array(11415)); /* COPTIC CAPITAL LETTER LAULA */ -$config['2c80_2cff'][] = array('upper' => 11416, 'status' => 'C', 'lower' => array(11417)); /* COPTIC CAPITAL LETTER MI */ -$config['2c80_2cff'][] = array('upper' => 11418, 'status' => 'C', 'lower' => array(11419)); /* COPTIC CAPITAL LETTER NI */ -$config['2c80_2cff'][] = array('upper' => 11420, 'status' => 'C', 'lower' => array(11421)); /* COPTIC CAPITAL LETTER KSI */ -$config['2c80_2cff'][] = array('upper' => 11422, 'status' => 'C', 'lower' => array(11423)); /* COPTIC CAPITAL LETTER O */ -$config['2c80_2cff'][] = array('upper' => 11424, 'status' => 'C', 'lower' => array(11425)); /* COPTIC CAPITAL LETTER PI */ -$config['2c80_2cff'][] = array('upper' => 11426, 'status' => 'C', 'lower' => array(11427)); /* COPTIC CAPITAL LETTER RO */ -$config['2c80_2cff'][] = array('upper' => 11428, 'status' => 'C', 'lower' => array(11429)); /* COPTIC CAPITAL LETTER SIMA */ -$config['2c80_2cff'][] = array('upper' => 11430, 'status' => 'C', 'lower' => array(11431)); /* COPTIC CAPITAL LETTER TAU */ -$config['2c80_2cff'][] = array('upper' => 11432, 'status' => 'C', 'lower' => array(11433)); /* COPTIC CAPITAL LETTER UA */ -$config['2c80_2cff'][] = array('upper' => 11434, 'status' => 'C', 'lower' => array(11435)); /* COPTIC CAPITAL LETTER FI */ -$config['2c80_2cff'][] = array('upper' => 11436, 'status' => 'C', 'lower' => array(11437)); /* COPTIC CAPITAL LETTER KHI */ -$config['2c80_2cff'][] = array('upper' => 11438, 'status' => 'C', 'lower' => array(11439)); /* COPTIC CAPITAL LETTER PSI */ -$config['2c80_2cff'][] = array('upper' => 11440, 'status' => 'C', 'lower' => array(11441)); /* COPTIC CAPITAL LETTER OOU */ -$config['2c80_2cff'][] = array('upper' => 11442, 'status' => 'C', 'lower' => array(11443)); /* COPTIC CAPITAL LETTER DIALECT-P ALEF */ -$config['2c80_2cff'][] = array('upper' => 11444, 'status' => 'C', 'lower' => array(11445)); /* COPTIC CAPITAL LETTER OLD COPTIC AIN */ -$config['2c80_2cff'][] = array('upper' => 11446, 'status' => 'C', 'lower' => array(11447)); /* COPTIC CAPITAL LETTER CRYPTOGRAMMIC EIE */ -$config['2c80_2cff'][] = array('upper' => 11448, 'status' => 'C', 'lower' => array(11449)); /* COPTIC CAPITAL LETTER DIALECT-P KAPA */ -$config['2c80_2cff'][] = array('upper' => 11450, 'status' => 'C', 'lower' => array(11451)); /* COPTIC CAPITAL LETTER DIALECT-P NI */ -$config['2c80_2cff'][] = array('upper' => 11452, 'status' => 'C', 'lower' => array(11453)); /* COPTIC CAPITAL LETTER CRYPTOGRAMMIC NI */ -$config['2c80_2cff'][] = array('upper' => 11454, 'status' => 'C', 'lower' => array(11455)); /* COPTIC CAPITAL LETTER OLD COPTIC OOU */ -$config['2c80_2cff'][] = array('upper' => 11456, 'status' => 'C', 'lower' => array(11457)); /* COPTIC CAPITAL LETTER SAMPI */ -$config['2c80_2cff'][] = array('upper' => 11458, 'status' => 'C', 'lower' => array(11459)); /* COPTIC CAPITAL LETTER CROSSED SHEI */ -$config['2c80_2cff'][] = array('upper' => 11460, 'status' => 'C', 'lower' => array(11461)); /* COPTIC CAPITAL LETTER OLD COPTIC SHEI */ -$config['2c80_2cff'][] = array('upper' => 11462, 'status' => 'C', 'lower' => array(11463)); /* COPTIC CAPITAL LETTER OLD COPTIC ESH */ -$config['2c80_2cff'][] = array('upper' => 11464, 'status' => 'C', 'lower' => array(11465)); /* COPTIC CAPITAL LETTER AKHMIMIC KHEI */ -$config['2c80_2cff'][] = array('upper' => 11466, 'status' => 'C', 'lower' => array(11467)); /* COPTIC CAPITAL LETTER DIALECT-P HORI */ -$config['2c80_2cff'][] = array('upper' => 11468, 'status' => 'C', 'lower' => array(11469)); /* COPTIC CAPITAL LETTER OLD COPTIC HORI */ -$config['2c80_2cff'][] = array('upper' => 11470, 'status' => 'C', 'lower' => array(11471)); /* COPTIC CAPITAL LETTER OLD COPTIC HA */ -$config['2c80_2cff'][] = array('upper' => 11472, 'status' => 'C', 'lower' => array(11473)); /* COPTIC CAPITAL LETTER L-SHAPED HA */ -$config['2c80_2cff'][] = array('upper' => 11474, 'status' => 'C', 'lower' => array(11475)); /* COPTIC CAPITAL LETTER OLD COPTIC HEI */ -$config['2c80_2cff'][] = array('upper' => 11476, 'status' => 'C', 'lower' => array(11477)); /* COPTIC CAPITAL LETTER OLD COPTIC HAT */ -$config['2c80_2cff'][] = array('upper' => 11478, 'status' => 'C', 'lower' => array(11479)); /* COPTIC CAPITAL LETTER OLD COPTIC GANGIA */ -$config['2c80_2cff'][] = array('upper' => 11480, 'status' => 'C', 'lower' => array(11481)); /* COPTIC CAPITAL LETTER OLD COPTIC DJA */ -$config['2c80_2cff'][] = array('upper' => 11482, 'status' => 'C', 'lower' => array(11483)); /* COPTIC CAPITAL LETTER OLD COPTIC SHIMA */ -$config['2c80_2cff'][] = array('upper' => 11484, 'status' => 'C', 'lower' => array(11485)); /* COPTIC CAPITAL LETTER OLD NUBIAN SHIMA */ -$config['2c80_2cff'][] = array('upper' => 11486, 'status' => 'C', 'lower' => array(11487)); /* COPTIC CAPITAL LETTER OLD NUBIAN NGI */ -$config['2c80_2cff'][] = array('upper' => 11488, 'status' => 'C', 'lower' => array(11489)); /* COPTIC CAPITAL LETTER OLD NUBIAN NYI */ -$config['2c80_2cff'][] = array('upper' => 11490, 'status' => 'C', 'lower' => array(11491)); /* COPTIC CAPITAL LETTER OLD NUBIAN WAU */ diff --git a/lib/Cake/Config/unicode/casefolding/ff00_ffef.php b/lib/Cake/Config/unicode/casefolding/ff00_ffef.php deleted file mode 100644 index 22d7a2d4686..00000000000 --- a/lib/Cake/Config/unicode/casefolding/ff00_ffef.php +++ /dev/null @@ -1,66 +0,0 @@ - 65313, 'status' => 'C', 'lower' => array(65345)); /* FULLWIDTH LATIN CAPITAL LETTER A */ -$config['ff00_ffef'][] = array('upper' => 65314, 'status' => 'C', 'lower' => array(65346)); /* FULLWIDTH LATIN CAPITAL LETTER B */ -$config['ff00_ffef'][] = array('upper' => 65315, 'status' => 'C', 'lower' => array(65347)); /* FULLWIDTH LATIN CAPITAL LETTER C */ -$config['ff00_ffef'][] = array('upper' => 65316, 'status' => 'C', 'lower' => array(65348)); /* FULLWIDTH LATIN CAPITAL LETTER D */ -$config['ff00_ffef'][] = array('upper' => 65317, 'status' => 'C', 'lower' => array(65349)); /* FULLWIDTH LATIN CAPITAL LETTER E */ -$config['ff00_ffef'][] = array('upper' => 65318, 'status' => 'C', 'lower' => array(65350)); /* FULLWIDTH LATIN CAPITAL LETTER F */ -$config['ff00_ffef'][] = array('upper' => 65319, 'status' => 'C', 'lower' => array(65351)); /* FULLWIDTH LATIN CAPITAL LETTER G */ -$config['ff00_ffef'][] = array('upper' => 65320, 'status' => 'C', 'lower' => array(65352)); /* FULLWIDTH LATIN CAPITAL LETTER H */ -$config['ff00_ffef'][] = array('upper' => 65321, 'status' => 'C', 'lower' => array(65353)); /* FULLWIDTH LATIN CAPITAL LETTER I */ -$config['ff00_ffef'][] = array('upper' => 65322, 'status' => 'C', 'lower' => array(65354)); /* FULLWIDTH LATIN CAPITAL LETTER J */ -$config['ff00_ffef'][] = array('upper' => 65323, 'status' => 'C', 'lower' => array(65355)); /* FULLWIDTH LATIN CAPITAL LETTER K */ -$config['ff00_ffef'][] = array('upper' => 65324, 'status' => 'C', 'lower' => array(65356)); /* FULLWIDTH LATIN CAPITAL LETTER L */ -$config['ff00_ffef'][] = array('upper' => 65325, 'status' => 'C', 'lower' => array(65357)); /* FULLWIDTH LATIN CAPITAL LETTER M */ -$config['ff00_ffef'][] = array('upper' => 65326, 'status' => 'C', 'lower' => array(65358)); /* FULLWIDTH LATIN CAPITAL LETTER N */ -$config['ff00_ffef'][] = array('upper' => 65327, 'status' => 'C', 'lower' => array(65359)); /* FULLWIDTH LATIN CAPITAL LETTER O */ -$config['ff00_ffef'][] = array('upper' => 65328, 'status' => 'C', 'lower' => array(65360)); /* FULLWIDTH LATIN CAPITAL LETTER P */ -$config['ff00_ffef'][] = array('upper' => 65329, 'status' => 'C', 'lower' => array(65361)); /* FULLWIDTH LATIN CAPITAL LETTER Q */ -$config['ff00_ffef'][] = array('upper' => 65330, 'status' => 'C', 'lower' => array(65362)); /* FULLWIDTH LATIN CAPITAL LETTER R */ -$config['ff00_ffef'][] = array('upper' => 65331, 'status' => 'C', 'lower' => array(65363)); /* FULLWIDTH LATIN CAPITAL LETTER S */ -$config['ff00_ffef'][] = array('upper' => 65332, 'status' => 'C', 'lower' => array(65364)); /* FULLWIDTH LATIN CAPITAL LETTER T */ -$config['ff00_ffef'][] = array('upper' => 65333, 'status' => 'C', 'lower' => array(65365)); /* FULLWIDTH LATIN CAPITAL LETTER U */ -$config['ff00_ffef'][] = array('upper' => 65334, 'status' => 'C', 'lower' => array(65366)); /* FULLWIDTH LATIN CAPITAL LETTER V */ -$config['ff00_ffef'][] = array('upper' => 65335, 'status' => 'C', 'lower' => array(65367)); /* FULLWIDTH LATIN CAPITAL LETTER W */ -$config['ff00_ffef'][] = array('upper' => 65336, 'status' => 'C', 'lower' => array(65368)); /* FULLWIDTH LATIN CAPITAL LETTER X */ -$config['ff00_ffef'][] = array('upper' => 65337, 'status' => 'C', 'lower' => array(65369)); /* FULLWIDTH LATIN CAPITAL LETTER Y */ -$config['ff00_ffef'][] = array('upper' => 65338, 'status' => 'C', 'lower' => array(65370)); /* FULLWIDTH LATIN CAPITAL LETTER Z */ diff --git a/lib/Cake/Configure/ConfigReaderInterface.php b/lib/Cake/Configure/ConfigReaderInterface.php deleted file mode 100644 index f1373550519..00000000000 --- a/lib/Cake/Configure/ConfigReaderInterface.php +++ /dev/null @@ -1,33 +0,0 @@ - array('password' => 'secret'))` - * - * You can nest properties as deeply as needed using `.`'s. In addition to using `.` you - * can use standard ini section notation to create nested structures: - * - * {{{ - * [section] - * key = value - * }}} - * - * Once loaded into Configure, the above would be accessed using: - * - * `Configure::read('section.key'); - * - * You can combine `.` separated values with sections to create more deeply - * nested structures. - * - * IniReader also manipulates how the special ini values of - * 'yes', 'no', 'on', 'off', 'null' are handled. These values will be - * converted to their boolean equivalents. - * - * @package Cake.Configure - * @see http://php.net/parse_ini_file - */ -class IniReader implements ConfigReaderInterface { - -/** - * The path to read ini files from. - * - * @var array - */ - protected $_path; - -/** - * The section to read, if null all sections will be read. - * - * @var string - */ - protected $_section; - -/** - * Build and construct a new ini file parser. The parser can be used to read - * ini files that are on the filesystem. - * - * @param string $path Path to load ini config files from. - * @param string $section Only get one section, leave null to parse and fetch - * all sections in the ini file. - */ - public function __construct($path, $section = null) { - $this->_path = $path; - $this->_section = $section; - } - -/** - * Read an ini file and return the results as an array. - * - * @param string $file Name of the file to read. The chosen file - * must be on the reader's path. - * @return array - * @throws ConfigureException - */ - public function read($file) { - $filename = $this->_path . $file; - if (!file_exists($filename)) { - $filename .= '.ini'; - if (!file_exists($filename)) { - throw new ConfigureException(__d('cake_dev', 'Could not load configuration files: %s or %s', substr($filename, 0, -4), $filename)); - } - } - $contents = parse_ini_file($filename, true); - if (!empty($this->_section) && isset($contents[$this->_section])) { - $values = $this->_parseNestedValues($contents[$this->_section]); - } else { - $values = array(); - foreach ($contents as $section => $attribs) { - if (is_array($attribs)) { - $values[$section] = $this->_parseNestedValues($attribs); - } else { - $parse = $this->_parseNestedValues(array($attribs)); - $values[$section] = array_shift($parse); - } - } - } - return $values; - } - -/** - * parses nested values out of keys. - * - * @param array $values Values to be exploded. - * @return array Array of values exploded - */ - protected function _parseNestedValues($values) { - foreach ($values as $key => $value) { - if ($value === '1') { - $value = true; - } - if ($value === '') { - $value = false; - } - if (strpos($key, '.') !== false) { - $values = Set::insert($values, $key, $value); - } else { - $values[$key] = $value; - } - } - return $values; - } - -} diff --git a/lib/Cake/Configure/PhpReader.php b/lib/Cake/Configure/PhpReader.php deleted file mode 100644 index 2043351b88b..00000000000 --- a/lib/Cake/Configure/PhpReader.php +++ /dev/null @@ -1,91 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/2.0/en/development/configuration.html#loading-configuration-files CakePHP(tm) Configuration - * @package Cake.Configure - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -/** - * PHP Reader allows Configure to load configuration values from - * files containing simple PHP arrays. - * - * Files compatible with PhpReader should define a `$config` variable, that - * contains all of the configuration data contained in the file. - * - * @package Cake.Configure - */ -class PhpReader implements ConfigReaderInterface { - -/** - * The path this reader finds files on. - * - * @var string - */ - protected $_path = null; - -/** - * Constructor for PHP Config file reading. - * - * @param string $path The path to read config files from. Defaults to APP . 'Config' . DS - */ - public function __construct($path = null) { - if (!$path) { - $path = APP . 'Config' . DS; - } - $this->_path = $path; - } - -/** - * Read a config file and return its contents. - * - * 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 App::pluginPath(). - * - * @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 ConfigureException 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) { - if (strpos($key, '..') !== false) { - throw new ConfigureException(__d('cake_dev', 'Cannot load configuration files with ../ in them.')); - } - if (substr($key, -4) === '.php') { - $key = substr($key, 0, -4); - } - list($plugin, $key) = pluginSplit($key); - - if ($plugin) { - $file = App::pluginPath($plugin) . 'Config' . DS . $key; - } else { - $file = $this->_path . $key; - } - $file .= '.php'; - if (!is_file($file)) { - if (!is_file(substr($file, 0, -4))) { - throw new ConfigureException(__d('cake_dev', 'Could not load configuration files: %s or %s', $file, substr($file, 0, -4))); - } - } - include $file; - if (!isset($config)) { - throw new ConfigureException( - sprintf(__d('cake_dev', 'No variable $config found in %s.php'), $file) - ); - } - return $config; - } - -} diff --git a/lib/Cake/Console/Command/AclShell.php b/lib/Cake/Console/Command/AclShell.php deleted file mode 100644 index a935f6b2a1c..00000000000 --- a/lib/Cake/Console/Command/AclShell.php +++ /dev/null @@ -1,608 +0,0 @@ -params['connection'])) { - $this->connection = $this->params['connection']; - } - - if (!in_array(Configure::read('Acl.classname'), array('DbAcl', 'DB_ACL'))) { - $out = "--------------------------------------------------\n"; - $out .= __d('cake_console', 'Error: Your current Cake configuration is set to an ACL implementation other than DB.') . "\n"; - $out .= __d('cake_console', 'Please change your core config to reflect your decision to use DbAcl before attempting to use this script') . "\n"; - $out .= "--------------------------------------------------\n"; - $out .= __d('cake_console', 'Current ACL Classname: %s', Configure::read('Acl.classname')) . "\n"; - $out .= "--------------------------------------------------\n"; - $this->err($out); - $this->_stop(); - } - - if ($this->command) { - if (!config('database')) { - $this->out(__d('cake_console', 'Your database configuration was not found. Take a moment to create one.'), true); - $this->args = null; - return $this->DbConfig->execute(); - } - require_once (APP . 'Config' . DS . 'database.php'); - - if (!in_array($this->command, array('initdb'))) { - $collection = new ComponentCollection(); - $this->Acl = new AclComponent($collection); - $controller = new Controller(); - $this->Acl->startup($controller); - } - } - } - -/** - * Override main() for help message hook - * - * @return void - */ - public function main() { - $this->out($this->OptionParser->help()); - } - -/** - * Creates an ARO/ACO node - * - * @return void - */ - public function create() { - extract($this->_dataVars()); - - $class = ucfirst($this->args[0]); - $parent = $this->parseIdentifier($this->args[1]); - - if (!empty($parent) && $parent != '/' && $parent != 'root') { - $parent = $this->_getNodeId($class, $parent); - } else { - $parent = null; - } - - $data = $this->parseIdentifier($this->args[2]); - if (is_string($data) && $data != '/') { - $data = array('alias' => $data); - } elseif (is_string($data)) { - $this->error(__d('cake_console', '/ can not be used as an alias!') . __d('cake_console', " / is the root, please supply a sub alias")); - } - - $data['parent_id'] = $parent; - $this->Acl->{$class}->create(); - if ($this->Acl->{$class}->save($data)) { - $this->out(__d('cake_console', "New %s '%s' created.", $class, $this->args[2]), 2); - } else { - $this->err(__d('cake_console', "There was a problem creating a new %s '%s'.", $class, $this->args[2])); - } - } - -/** - * Delete an ARO/ACO node. - * - * @return void - */ - public function delete() { - extract($this->_dataVars()); - - $identifier = $this->parseIdentifier($this->args[1]); - $nodeId = $this->_getNodeId($class, $identifier); - - if (!$this->Acl->{$class}->delete($nodeId)) { - $this->error(__d('cake_console', 'Node Not Deleted') . __d('cake_console', 'There was an error deleting the %s. Check that the node exists.', $class) . "\n"); - } - $this->out(__d('cake_console', '%s deleted.', $class), 2); - } - -/** - * Set parent for an ARO/ACO node. - * - * @return void - */ - public function setParent() { - extract($this->_dataVars()); - $target = $this->parseIdentifier($this->args[1]); - $parent = $this->parseIdentifier($this->args[2]); - - $data = array( - $class => array( - 'id' => $this->_getNodeId($class, $target), - 'parent_id' => $this->_getNodeId($class, $parent) - ) - ); - $this->Acl->{$class}->create(); - if (!$this->Acl->{$class}->save($data)) { - $this->out(__d('cake_console', 'Error in setting new parent. Please make sure the parent node exists, and is not a descendant of the node specified.'), true); - } else { - $this->out(__d('cake_console', 'Node parent set to %s', $this->args[2]) . "\n", true); - } - } - -/** - * Get path to specified ARO/ACO node. - * - * @return void - */ - public function getPath() { - extract($this->_dataVars()); - $identifier = $this->parseIdentifier($this->args[1]); - - $id = $this->_getNodeId($class, $identifier); - $nodes = $this->Acl->{$class}->getPath($id); - - if (empty($nodes)) { - $this->error( - __d('cake_console', "Supplied Node '%s' not found", $this->args[1]), - __d('cake_console', 'No tree returned.') - ); - } - $this->out(__d('cake_console', 'Path:')); - $this->hr(); - for ($i = 0, $len = count($nodes); $i < $len; $i++) { - $this->_outputNode($class, $nodes[$i], $i); - } - } - -/** - * Outputs a single node, Either using the alias or Model.key - * - * @param string $class Class name that is being used. - * @param array $node Array of node information. - * @param integer $indent indent level. - * @return void - */ - protected function _outputNode($class, $node, $indent) { - $indent = str_repeat(' ', $indent); - $data = $node[$class]; - if ($data['alias']) { - $this->out($indent . "[" . $data['id'] . "] " . $data['alias']); - } else { - $this->out($indent . "[" . $data['id'] . "] " . $data['model'] . '.' . $data['foreign_key']); - } - } - -/** - * Check permission for a given ARO to a given ACO. - * - * @return void - */ - public function check() { - extract($this->_getParams()); - - if ($this->Acl->check($aro, $aco, $action)) { - $this->out(__d('cake_console', '%s is allowed.', $aroName), true); - } else { - $this->out(__d('cake_console', '%s is not allowed.', $aroName), true); - } - } - -/** - * Grant permission for a given ARO to a given ACO. - * - * @return void - */ - public function grant() { - extract($this->_getParams()); - - if ($this->Acl->allow($aro, $aco, $action)) { - $this->out(__d('cake_console', 'Permission granted.'), true); - } else { - $this->out(__d('cake_console', 'Permission was not granted.'), true); - } - } - -/** - * Deny access for an ARO to an ACO. - * - * @return void - */ - public function deny() { - extract($this->_getParams()); - - if ($this->Acl->deny($aro, $aco, $action)) { - $this->out(__d('cake_console', 'Permission denied.'), true); - } else { - $this->out(__d('cake_console', 'Permission was not denied.'), true); - } - } - -/** - * Set an ARO to inherit permission to an ACO. - * - * @return void - */ - public function inherit() { - extract($this->_getParams()); - - if ($this->Acl->inherit($aro, $aco, $action)) { - $this->out(__d('cake_console', 'Permission inherited.'), true); - } else { - $this->out(__d('cake_console', 'Permission was not inherited.'), true); - } - } - -/** - * Show a specific ARO/ACO node. - * - * @return void - */ - public function view() { - extract($this->_dataVars()); - - if (isset($this->args[1])) { - $identity = $this->parseIdentifier($this->args[1]); - - $topNode = $this->Acl->{$class}->find('first', array( - 'conditions' => array($class . '.id' => $this->_getNodeId($class, $identity)) - )); - - $nodes = $this->Acl->{$class}->find('all', array( - 'conditions' => array( - $class . '.lft >=' => $topNode[$class]['lft'], - $class . '.lft <=' => $topNode[$class]['rght'] - ), - 'order' => $class . '.lft ASC' - )); - } else { - $nodes = $this->Acl->{$class}->find('all', array('order' => $class . '.lft ASC')); - } - - if (empty($nodes)) { - if (isset($this->args[1])) { - $this->error(__d('cake_console', '%s not found', $this->args[1]), __d('cake_console', 'No tree returned.')); - } elseif (isset($this->args[0])) { - $this->error(__d('cake_console', '%s not found', $this->args[0]), __d('cake_console', 'No tree returned.')); - } - } - $this->out($class . ' tree:'); - $this->hr(); - - $stack = array(); - $last = null; - - foreach ($nodes as $n) { - $stack[] = $n; - if (!empty($last)) { - $end = end($stack); - if ($end[$class]['rght'] > $last) { - foreach ($stack as $k => $v) { - $end = end($stack); - if ($v[$class]['rght'] < $end[$class]['rght']) { - unset($stack[$k]); - } - } - } - } - $last = $n[$class]['rght']; - $count = count($stack); - - $this->_outputNode($class, $n, $count); - } - $this->hr(); - } - -/** - * Initialize ACL database. - * - * @return mixed - */ - public function initdb() { - return $this->dispatchShell('schema create DbAcl'); - } - -/** - * Get the option parser. - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - - $type = array( - 'choices' => array('aro', 'aco'), - 'required' => true, - 'help' => __d('cake_console', 'Type of node to create.') - ); - - $parser->description( - __d('cake_console', 'A console tool for managing the DbAcl') - )->addSubcommand('create', array( - 'help' => __d('cake_console', 'Create a new ACL node'), - 'parser' => array( - 'description' => __d('cake_console', 'Creates a new ACL object under the parent'), - 'arguments' => array( - 'type' => $type, - 'parent' => array( - 'help' => __d('cake_console', 'The node selector for the parent.'), - 'required' => true - ), - 'alias' => array( - 'help' => __d('cake_console', 'The alias to use for the newly created node.'), - 'required' => true - ) - ) - ) - ))->addSubcommand('delete', array( - 'help' => __d('cake_console', 'Deletes the ACL object with the given reference'), - 'parser' => array( - 'description' => __d('cake_console', 'Delete an ACL node.'), - 'arguments' => array( - 'type' => $type, - 'node' => array( - 'help' => __d('cake_console', 'The node identifier to delete.'), - 'required' => true, - ) - ) - ) - ))->addSubcommand('setparent', array( - 'help' => __d('cake_console', 'Moves the ACL node under a new parent.'), - 'parser' => array( - 'description' => __d('cake_console', 'Moves the ACL object specified by beneath '), - 'arguments' => array( - 'type' => $type, - 'node' => array( - 'help' => __d('cake_console', 'The node to move'), - 'required' => true, - ), - 'parent' => array( - 'help' => __d('cake_console', 'The new parent for .'), - 'required' => true - ) - ) - ) - ))->addSubcommand('getpath', array( - 'help' => __d('cake_console', 'Print out the path to an ACL node.'), - 'parser' => array( - 'description' => array( - __d('cake_console', "Returns the path to the ACL object specified by ."), - __d('cake_console', "This command is useful in determining the inheritance of permissions for a certain object in the tree.") - ), - 'arguments' => array( - 'type' => $type, - 'node' => array( - 'help' => __d('cake_console', 'The node to get the path of'), - 'required' => true, - ) - ) - ) - ))->addSubcommand('check', array( - 'help' => __d('cake_console', 'Check the permissions between an ACO and ARO.'), - 'parser' => array( - 'description' => array( - __d('cake_console', 'Use this command to check ACL permissions.') - ), - 'arguments' => array( - 'aro' => array('help' => __d('cake_console', 'ARO to check.'), 'required' => true), - 'aco' => array('help' => __d('cake_console', 'ACO to check.'), 'required' => true), - 'action' => array('help' => __d('cake_console', 'Action to check'), 'default' => 'all') - ) - ) - ))->addSubcommand('grant', array( - 'help' => __d('cake_console', 'Grant an ARO permissions to an ACO.'), - 'parser' => array( - 'description' => array( - __d('cake_console', 'Use this command to grant ACL permissions. Once executed, the ARO specified (and its children, if any) will have ALLOW access to the specified ACO action (and the ACO\'s children, if any).') - ), - 'arguments' => array( - 'aro' => array('help' => __d('cake_console', 'ARO to grant permission to.'), 'required' => true), - 'aco' => array('help' => __d('cake_console', 'ACO to grant access to.'), 'required' => true), - 'action' => array('help' => __d('cake_console', 'Action to grant'), 'default' => 'all') - ) - ) - ))->addSubcommand('deny', array( - 'help' => __d('cake_console', 'Deny an ARO permissions to an ACO.'), - 'parser' => array( - 'description' => array( - __d('cake_console', 'Use this command to deny ACL permissions. Once executed, the ARO specified (and its children, if any) will have DENY access to the specified ACO action (and the ACO\'s children, if any).') - ), - 'arguments' => array( - 'aro' => array('help' => __d('cake_console', 'ARO to deny.'), 'required' => true), - 'aco' => array('help' => __d('cake_console', 'ACO to deny.'), 'required' => true), - 'action' => array('help' => __d('cake_console', 'Action to deny'), 'default' => 'all') - ) - ) - ))->addSubcommand('inherit', array( - 'help' => __d('cake_console', 'Inherit an ARO\'s parent permissions.'), - 'parser' => array( - 'description' => array( - __d('cake_console', "Use this command to force a child ARO object to inherit its permissions settings from its parent.") - ), - 'arguments' => array( - 'aro' => array('help' => __d('cake_console', 'ARO to have permissions inherit.'), 'required' => true), - 'aco' => array('help' => __d('cake_console', 'ACO to inherit permissions on.'), 'required' => true), - 'action' => array('help' => __d('cake_console', 'Action to inherit'), 'default' => 'all') - ) - ) - ))->addSubcommand('view', array( - 'help' => __d('cake_console', 'View a tree or a single node\'s subtree.'), - 'parser' => array( - 'description' => array( - __d('cake_console', "The view command will return the ARO or ACO tree."), - __d('cake_console', "The optional node parameter allows you to return"), - __d('cake_console', "only a portion of the requested tree.") - ), - 'arguments' => array( - 'type' => $type, - 'node' => array('help' => __d('cake_console', 'The optional node to view the subtree of.')) - ) - ) - ))->addSubcommand('initdb', array( - 'help' => __d('cake_console', 'Initialize the DbAcl tables. Uses this command : cake schema create DbAcl') - ))->epilog( - array( - 'Node and parent arguments can be in one of the following formats:', - '', - ' - . - The node will be bound to a specific record of the given model.', - '', - ' - - The node will be given a string alias (or path, in the case of )', - " i.e. 'John'. When used with , this takes the form of an alias path,", - " i.e. //.", - '', - "To add a node at the root level, enter 'root' or '/' as the parameter." - ) - ); - return $parser; - } - -/** - * Checks that given node exists - * - * @return boolean Success - */ - public function nodeExists() { - if (!isset($this->args[0]) || !isset($this->args[1])) { - return false; - } - $dataVars = $this->_dataVars($this->args[0]); - extract($dataVars); - $key = is_numeric($this->args[1]) ? $dataVars['secondary_id'] : 'alias'; - $conditions = array($class . '.' . $key => $this->args[1]); - $possibility = $this->Acl->{$class}->find('all', compact('conditions')); - if (empty($possibility)) { - $this->error(__d('cake_console', '%s not found', $this->args[1]), __d('cake_console', 'No tree returned.')); - } - return $possibility; - } - -/** - * Parse an identifier into Model.foreignKey or an alias. - * Takes an identifier determines its type and returns the result as used by other methods. - * - * @param string $identifier Identifier to parse - * @return mixed a string for aliases, and an array for model.foreignKey - */ - public function parseIdentifier($identifier) { - if (preg_match('/^([\w]+)\.(.*)$/', $identifier, $matches)) { - return array( - 'model' => $matches[1], - 'foreign_key' => $matches[2], - ); - } - return $identifier; - } - -/** - * Get the node for a given identifier. $identifier can either be a string alias - * or an array of properties to use in AcoNode::node() - * - * @param string $class Class type you want (Aro/Aco) - * @param mixed $identifier A mixed identifier for finding the node. - * @return integer Integer of NodeId. Will trigger an error if nothing is found. - */ - protected function _getNodeId($class, $identifier) { - $node = $this->Acl->{$class}->node($identifier); - if (empty($node)) { - if (is_array($identifier)) { - $identifier = var_export($identifier, true); - } - $this->error(__d('cake_console', 'Could not find node using reference "%s"', $identifier)); - } - return Set::extract($node, "0.{$class}.id"); - } - -/** - * get params for standard Acl methods - * - * @return array aro, aco, action - */ - protected function _getParams() { - $aro = is_numeric($this->args[0]) ? intval($this->args[0]) : $this->args[0]; - $aco = is_numeric($this->args[1]) ? intval($this->args[1]) : $this->args[1]; - $aroName = $aro; - $acoName = $aco; - - if (is_string($aro)) { - $aro = $this->parseIdentifier($aro); - } - if (is_string($aco)) { - $aco = $this->parseIdentifier($aco); - } - $action = '*'; - if (isset($this->args[2]) && !in_array($this->args[2], array('', 'all'))) { - $action = $this->args[2]; - } - return compact('aro', 'aco', 'action', 'aroName', 'acoName'); - } - -/** - * Build data parameters based on node type - * - * @param string $type Node type (ARO/ACO) - * @return array Variables - */ - protected function _dataVars($type = null) { - if ($type == null) { - $type = $this->args[0]; - } - $vars = array(); - $class = ucwords($type); - $vars['secondary_id'] = (strtolower($class) == 'aro') ? 'foreign_key' : 'object_id'; - $vars['data_name'] = $type; - $vars['table_name'] = $type . 's'; - $vars['class'] = $class; - return $vars; - } - -} diff --git a/lib/Cake/Console/Command/ApiShell.php b/lib/Cake/Console/Command/ApiShell.php deleted file mode 100644 index 8ba50e7fbbf..00000000000 --- a/lib/Cake/Console/Command/ApiShell.php +++ /dev/null @@ -1,238 +0,0 @@ -paths = array_merge($this->paths, array( - 'behavior' => CAKE . 'Model' . DS . 'Behavior' . DS, - 'cache' => CAKE . 'Cache' . DS, - 'controller' => CAKE . 'Controller' . DS, - 'component' => CAKE . 'Controller' . DS . 'Component' . DS, - 'helper' => CAKE . 'View' . DS . 'Helper' . DS, - 'model' => CAKE . 'Model' . DS, - 'view' => CAKE . 'View' . DS, - 'core' => CAKE - )); - } - -/** - * Override main() to handle action - * - * @return void - */ - public function main() { - if (empty($this->args)) { - return $this->out($this->OptionParser->help()); - } - - $type = strtolower($this->args[0]); - - if (isset($this->paths[$type])) { - $path = $this->paths[$type]; - } else { - $path = $this->paths['core']; - } - - if (count($this->args) == 1) { - $file = $type; - $class = Inflector::camelize($type); - } elseif (count($this->args) > 1) { - $file = Inflector::underscore($this->args[1]); - $class = Inflector::camelize($this->args[1]); - } - $objects = App::objects('class', $path); - if (in_array($class, $objects)) { - if (in_array($type, array('behavior', 'component', 'helper')) && $type !== $file) { - if (!preg_match('/' . Inflector::camelize($type) . '$/', $class)) { - $class .= Inflector::camelize($type); - } - } - - } else { - $this->error(__d('cake_console', '%s not found', $class)); - } - - $parsed = $this->_parseClass($path . $class . '.php', $class); - - if (!empty($parsed)) { - if (isset($this->params['method'])) { - if (!isset($parsed[$this->params['method']])) { - $this->err(__d('cake_console', '%s::%s() could not be found', $class, $this->params['method'])); - $this->_stop(); - } - $method = $parsed[$this->params['method']]; - $this->out($class . '::' . $method['method'] . $method['parameters']); - $this->hr(); - $this->out($method['comment'], true); - } else { - $this->out(ucwords($class)); - $this->hr(); - $i = 0; - foreach ($parsed as $method) { - $list[] = ++$i . ". " . $method['method'] . $method['parameters']; - } - $this->out($list); - - $methods = array_keys($parsed); - while ($number = strtolower($this->in(__d('cake_console', 'Select a number to see the more information about a specific method. q to quit. l to list.'), null, 'q'))) { - if ($number === 'q') { - $this->out(__d('cake_console', 'Done')); - return $this->_stop(); - } - - if ($number === 'l') { - $this->out($list); - } - - if (isset($methods[--$number])) { - $method = $parsed[$methods[$number]]; - $this->hr(); - $this->out($class . '::' . $method['method'] . $method['parameters']); - $this->hr(); - $this->out($method['comment'], true); - } - } - } - } - } - -/** - * Get and configure the optionparser. - * - * @return ConsoleOptionParser - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - $parser->addArgument('type', array( - 'help' => __d('cake_console', 'Either a full path or type of class (model, behavior, controller, component, view, helper)') - ))->addArgument('className', array( - 'help' => __d('cake_console', 'A CakePHP core class name (e.g: Component, HtmlHelper).') - ))->addOption('method', array( - 'short' => 'm', - 'help' => __d('cake_console', 'The specific method you want help on.') - ))->description(__d('cake_console', 'Lookup doc block comments for classes in CakePHP.')); - return $parser; - } - -/** - * Show help for this shell. - * - * @return void - */ - public function help() { - $head = "Usage: cake api [] [-m ]\n"; - $head .= "-----------------------------------------------\n"; - $head .= "Parameters:\n\n"; - - $commands = array( - 'path' => "\t\n" . - "\t\tEither a full path or type of class (model, behavior, controller, component, view, helper).\n" . - "\t\tAvailable values:\n\n" . - "\t\tbehavior\tLook for class in CakePHP behavior path\n" . - "\t\tcache\tLook for class in CakePHP cache path\n" . - "\t\tcontroller\tLook for class in CakePHP controller path\n" . - "\t\tcomponent\tLook for class in CakePHP component path\n" . - "\t\thelper\tLook for class in CakePHP helper path\n" . - "\t\tmodel\tLook for class in CakePHP model path\n" . - "\t\tview\tLook for class in CakePHP view path\n", - 'className' => "\t\n" . - "\t\tA CakePHP core class name (e.g: Component, HtmlHelper).\n" - ); - - $this->out($head); - if (!isset($this->args[1])) { - foreach ($commands as $cmd) { - $this->out("{$cmd}\n\n"); - } - } elseif (isset($commands[strtolower($this->args[1])])) { - $this->out($commands[strtolower($this->args[1])] . "\n\n"); - } else { - $this->out(__d('cake_console', 'Command %s not found', $this->args[1])); - } - } - -/** - * Parse a given class (located on given file) and get public methods and their - * signatures. - * - * @param string $path File path - * @param string $class Class name - * @return array Methods and signatures indexed by method name - */ - protected function _parseClass($path, $class) { - $parsed = array(); - - if (!class_exists($class)) { - if (!include_once $path) { - $this->err(__d('cake_console', '%s could not be found', $path)); - } - } - - $reflection = new ReflectionClass($class); - - foreach ($reflection->getMethods() as $method) { - if (!$method->isPublic() || strpos($method->getName(), '_') === 0) { - continue; - } - if ($method->getDeclaringClass()->getName() != $class) { - continue; - } - $args = array(); - foreach ($method->getParameters() as $param) { - $paramString = '$' . $param->getName(); - if ($param->isDefaultValueAvailable()) { - $paramString .= ' = ' . str_replace("\n", '', var_export($param->getDefaultValue(), true)); - } - $args[] = $paramString; - } - $parsed[$method->getName()] = array( - 'comment' => str_replace(array('/*', '*/', '*'), '', $method->getDocComment()), - 'method' => $method->getName(), - 'parameters' => '(' . implode(', ', $args) . ')' - ); - } - ksort($parsed); - return $parsed; - } - -} diff --git a/lib/Cake/Console/Command/AppShell.php b/lib/Cake/Console/Command/AppShell.php deleted file mode 100644 index 5cc915f6bfe..00000000000 --- a/lib/Cake/Console/Command/AppShell.php +++ /dev/null @@ -1,31 +0,0 @@ -connection to the active task if a connection param is set. - * - * @return void - */ - public function startup() { - parent::startup(); - Configure::write('debug', 2); - Configure::write('Cache.disable', 1); - - $task = Inflector::classify($this->command); - if (isset($this->{$task}) && !in_array($task, array('Project', 'DbConfig'))) { - if (isset($this->params['connection'])) { - $this->{$task}->connection = $this->params['connection']; - } - } - } - -/** - * Override main() to handle action - * - * @return mixed - */ - public function main() { - if (!is_dir($this->DbConfig->path)) { - $path = $this->Project->execute(); - if (!empty($path)) { - $this->DbConfig->path = $path . 'Config' . DS; - } else { - return false; - } - } - - if (!config('database')) { - $this->out(__d('cake_console', 'Your database configuration was not found. Take a moment to create one.')); - $this->args = null; - return $this->DbConfig->execute(); - } - $this->out(__d('cake_console', 'Interactive Bake Shell')); - $this->hr(); - $this->out(__d('cake_console', '[D]atabase Configuration')); - $this->out(__d('cake_console', '[M]odel')); - $this->out(__d('cake_console', '[V]iew')); - $this->out(__d('cake_console', '[C]ontroller')); - $this->out(__d('cake_console', '[P]roject')); - $this->out(__d('cake_console', '[F]ixture')); - $this->out(__d('cake_console', '[T]est case')); - $this->out(__d('cake_console', '[Q]uit')); - - $classToBake = strtoupper($this->in(__d('cake_console', 'What would you like to Bake?'), array('D', 'M', 'V', 'C', 'P', 'F', 'T', 'Q'))); - switch ($classToBake) { - case 'D': - $this->DbConfig->execute(); - break; - case 'M': - $this->Model->execute(); - break; - case 'V': - $this->View->execute(); - break; - case 'C': - $this->Controller->execute(); - break; - case 'P': - $this->Project->execute(); - break; - case 'F': - $this->Fixture->execute(); - break; - case 'T': - $this->Test->execute(); - break; - case 'Q': - exit(0); - break; - default: - $this->out(__d('cake_console', 'You have made an invalid selection. Please choose a type of class to Bake by entering D, M, V, F, T, or C.')); - } - $this->hr(); - $this->main(); - } - -/** - * Quickly bake the MVC - * - * @return void - */ - public function all() { - $this->out('Bake All'); - $this->hr(); - - if (!isset($this->params['connection']) && empty($this->connection)) { - $this->connection = $this->DbConfig->getConfig(); - } - - if (empty($this->args)) { - $this->Model->interactive = true; - $name = $this->Model->getName($this->connection); - } - - foreach (array('Model', 'Controller', 'View') as $task) { - $this->{$task}->connection = $this->connection; - $this->{$task}->interactive = false; - } - - if (!empty($this->args[0])) { - $name = $this->args[0]; - } - - $modelExists = false; - $model = $this->_modelName($name); - - App::uses('AppModel', 'Model'); - App::uses($model, 'Model'); - if (class_exists($model)) { - $object = new $model(); - $modelExists = true; - } else { - $object = new Model(array('name' => $name, 'ds' => $this->connection)); - } - - $modelBaked = $this->Model->bake($object, false); - - if ($modelBaked && $modelExists === false) { - if ($this->_checkUnitTest()) { - $this->Model->bakeFixture($model); - $this->Model->bakeTest($model); - } - $modelExists = true; - } - - if ($modelExists === true) { - $controller = $this->_controllerName($name); - if ($this->Controller->bake($controller, $this->Controller->bakeActions($controller))) { - if ($this->_checkUnitTest()) { - $this->Controller->bakeTest($controller); - } - } - App::uses($controller . 'Controller', 'Controller'); - if (class_exists($controller . 'Controller')) { - $this->View->args = array($name); - $this->View->execute(); - } - $this->out('', 1, Shell::QUIET); - $this->out(__d('cake_console', 'Bake All complete'), 1, Shell::QUIET); - array_shift($this->args); - } else { - $this->error(__d('cake_console', 'Bake All could not continue without a valid model')); - } - return $this->_stop(); - } - -/** - * get the option parser. - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description(__d('cake_console', - 'The Bake script generates controllers, views and models for your application.' . - ' If run with no command line arguments, Bake guides the user through the class creation process.' . - ' You can customize the generation process by telling Bake where different parts of your application are using command line arguments.' - ))->addSubcommand('all', array( - 'help' => __d('cake_console', 'Bake a complete MVC. optional of a Model'), - ))->addSubcommand('project', array( - 'help' => __d('cake_console', 'Bake a new app folder in the path supplied or in current directory if no path is specified'), - 'parser' => $this->Project->getOptionParser() - ))->addSubcommand('plugin', array( - 'help' => __d('cake_console', 'Bake a new plugin folder in the path supplied or in current directory if no path is specified.'), - 'parser' => $this->Plugin->getOptionParser() - ))->addSubcommand('db_config', array( - 'help' => __d('cake_console', 'Bake a database.php file in config directory.'), - 'parser' => $this->DbConfig->getOptionParser() - ))->addSubcommand('model', array( - 'help' => __d('cake_console', 'Bake a model.'), - 'parser' => $this->Model->getOptionParser() - ))->addSubcommand('view', array( - 'help' => __d('cake_console', 'Bake views for controllers.'), - 'parser' => $this->View->getOptionParser() - ))->addSubcommand('controller', array( - 'help' => __d('cake_console', 'Bake a controller.'), - 'parser' => $this->Controller->getOptionParser() - ))->addSubcommand('fixture', array( - 'help' => __d('cake_console', 'Bake a fixture.'), - 'parser' => $this->Fixture->getOptionParser() - ))->addSubcommand('test', array( - 'help' => __d('cake_console', 'Bake a unit test.'), - 'parser' => $this->Test->getOptionParser() - ))->addOption('connection', array( - 'help' => __d('cake_console', 'Database connection to use in conjunction with `bake all`.'), - 'short' => 'c', - 'default' => 'default' - )); - } - -} diff --git a/lib/Cake/Console/Command/CommandListShell.php b/lib/Cake/Console/Command/CommandListShell.php deleted file mode 100644 index 9b5db93fd42..00000000000 --- a/lib/Cake/Console/Command/CommandListShell.php +++ /dev/null @@ -1,234 +0,0 @@ -params['xml'])) { - parent::startup(); - } - } - -/** - * Main function Prints out the list of shells. - * - * @return void - */ - public function main() { - if (empty($this->params['xml'])) { - $this->out(__d('cake_console', "Current Paths:"), 2); - $this->out(" -app: " . APP_DIR); - $this->out(" -working: " . rtrim(APP, DS)); - $this->out(" -root: " . rtrim(ROOT, DS)); - $this->out(" -core: " . rtrim(CORE_PATH, DS)); - $this->out(""); - $this->out(__d('cake_console', "Changing Paths:"), 2); - $this->out(__d('cake_console', "Your working path should be the same as your application path to change your path use the '-app' param.")); - $this->out(__d('cake_console', "Example: -app relative/path/to/myapp or -app /absolute/path/to/myapp"), 2); - - $this->out(__d('cake_console', "Available Shells:"), 2); - } - - $shellList = $this->_getShellList(); - - if ($shellList) { - ksort($shellList); - if (empty($this->params['xml'])) { - if (!empty($this->params['sort'])) { - $this->_asSorted($shellList); - } else { - $this->_asText($shellList); - } - } else { - $this->_asXml($shellList); - } - } - } - -/** - * Gets the shell command listing. - * - * @return array - */ - protected function _getShellList() { - $shellList = array(); - $skipFiles = array('AppShell'); - - $corePath = App::core('Console/Command'); - $shells = App::objects('file', $corePath[0]); - $shells = array_diff($shells, $skipFiles); - $shellList = $this->_appendShells('CORE', $shells, $shellList); - - $appShells = App::objects('Console/Command', null, false); - $appShells = array_diff($appShells, $shells, $skipFiles); - $shellList = $this->_appendShells('app', $appShells, $shellList); - - $plugins = CakePlugin::loaded(); - foreach ($plugins as $plugin) { - $pluginShells = App::objects($plugin . '.Console/Command'); - $shellList = $this->_appendShells($plugin, $pluginShells, $shellList); - } - - return $shellList; - } - -/** - * Scan the provided paths for shells, and append them into $shellList - * - * @param string $type - * @param array $shells - * @param array $shellList - * @return array - */ - protected function _appendShells($type, $shells, $shellList) { - foreach ($shells as $shell) { - $shell = Inflector::underscore(str_replace('Shell', '', $shell)); - $shellList[$shell][$type] = $type; - } - return $shellList; - } - -/** - * Output text. - * - * @param array $shellList - * @return void - */ - protected function _asText($shellList) { - if (DS === '/') { - $width = exec('tput cols') - 2; - } - if (empty($width)) { - $width = 80; - } - $columns = max(1, floor($width / 30)); - $rows = ceil(count($shellList) / $columns); - - foreach ($shellList as $shell => $types) { - sort($types); - $shellList[$shell] = str_pad($shell . ' [' . implode ($types, ', ') . ']', $width / $columns); - } - $out = array_chunk($shellList, $rows); - for ($i = 0; $i < $rows; $i++) { - $row = ''; - for ($j = 0; $j < $columns; $j++) { - if (!isset($out[$j][$i])) { - continue; - } - $row .= $out[$j][$i]; - } - $this->out(" " . $row); - } - $this->out(); - $this->out(__d('cake_console', "To run an app or core command, type cake shell_name [args]")); - $this->out(__d('cake_console', "To run a plugin command, type cake Plugin.shell_name [args]")); - $this->out(__d('cake_console', "To get help on a specific command, type cake shell_name --help"), 2); - } - -/** - * Generates the shell list sorted by where the shells are found. - * - * @param array $shellList - * @return void - */ - protected function _asSorted($shellList) { - $grouped = array(); - foreach ($shellList as $shell => $types) { - foreach ($types as $type) { - $type = Inflector::camelize($type); - if (empty($grouped[$type])) { - $grouped[$type] = array(); - } - $grouped[$type][] = $shell; - } - } - if (!empty($grouped['App'])) { - sort($grouped['App'], SORT_STRING); - $this->out('[ App ]'); - $this->out(' ' . implode(', ', $grouped['App']), 2); - unset($grouped['App']); - } - foreach ($grouped as $section => $shells) { - if ($section == 'CORE') { - continue; - } - sort($shells, SORT_STRING); - $this->out('[ ' . $section . ' ]'); - $this->out(' ' . implode(', ', $shells), 2); - } - if (!empty($grouped['CORE'])) { - sort($grouped['CORE'], SORT_STRING); - $this->out('[ Core ]'); - $this->out(' ' . implode(', ', $grouped['CORE']), 2); - } - $this->out(); - } - -/** - * Output as XML - * - * @param array $shellList - * @return void - */ - protected function _asXml($shellList) { - $plugins = CakePlugin::loaded(); - $shells = new SimpleXmlElement(''); - foreach ($shellList as $name => $location) { - $source = current($location); - $callable = $name; - if (in_array($source, $plugins)) { - $callable = Inflector::camelize($source) . '.' . $name; - } - $shell = $shells->addChild('shell'); - $shell->addAttribute('name', $name); - $shell->addAttribute('call_as', $callable); - $shell->addAttribute('provider', $source); - $shell->addAttribute('help', $callable . ' -h'); - } - $this->stdout->outputAs(ConsoleOutput::RAW); - $this->out($shells->saveXml()); - } - -/** - * get the option parser - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description(__d('cake_console', 'Get the list of available shells for this CakePHP application.')) - ->addOption('xml', array( - 'help' => __d('cake_console', 'Get the listing as XML.'), - 'boolean' => true - ))->addOption('sort', array( - 'help' => __d('cake_console', 'Sorts the commands by where they are located.'), - 'boolean' => true - )); - } - -} diff --git a/lib/Cake/Console/Command/ConsoleShell.php b/lib/Cake/Console/Command/ConsoleShell.php deleted file mode 100644 index 2471440c3dd..00000000000 --- a/lib/Cake/Console/Command/ConsoleShell.php +++ /dev/null @@ -1,359 +0,0 @@ -Dispatcher = new Dispatcher(); - $this->models = App::objects('Model'); - - foreach ($this->models as $model) { - $class = $model; - $this->models[$model] = $class; - App::uses($class, 'Model'); - $this->{$class} = new $class(); - } - $this->out(__d('cake_console', 'Model classes:')); - $this->hr(); - - foreach ($this->models as $model) { - $this->out(" - {$model}"); - } - $this->_loadRoutes(); - } - -/** - * Prints the help message - * - * @return void - */ - public function help() { - $out = 'Console help:'; - $out .= '-------------'; - $out .= 'The interactive console is a tool for testing parts of your app before you'; - $out .= 'write code.'; - $out .= "\n"; - $out .= 'Model testing:'; - $out .= 'To test model results, use the name of your model without a leading $'; - $out .= 'e.g. Foo->find("all")'; - $out .= "\n"; - $out .= 'To dynamically set associations, you can do the following:'; - $out .= "\tModelA bind ModelB"; - $out .= "where the supported associations are hasOne, hasMany, belongsTo, hasAndBelongsToMany"; - $out .= "\n"; - $out .= 'To dynamically remove associations, you can do the following:'; - $out .= "\t ModelA unbind ModelB"; - $out .= "where the supported associations are the same as above"; - $out .= "\n"; - $out .= "To save a new field in a model, you can do the following:"; - $out .= "\tModelA->save(array('foo' => 'bar', 'baz' => 0))"; - $out .= "where you are passing a hash of data to be saved in the format"; - $out .= "of field => value pairs"; - $out .= "\n"; - $out .= "To get column information for a model, use the following:"; - $out .= "\tModelA columns"; - $out .= "which returns a list of columns and their type"; - $out .= "\n"; - $out .= "\n"; - $out .= 'Route testing:'; - $out .= "\n"; - $out .= 'To test URLs against your app\'s route configuration, type:'; - $out .= "\n"; - $out .= "\tRoute "; - $out .= "\n"; - $out .= "where url is the path to your your action plus any query parameters,"; - $out .= "minus the application's base path. For example:"; - $out .= "\n"; - $out .= "\tRoute /posts/view/1"; - $out .= "\n"; - $out .= "will return something like the following:"; - $out .= "\n"; - $out .= "\tarray("; - $out .= "\t [...]"; - $out .= "\t 'controller' => 'posts',"; - $out .= "\t 'action' => 'view',"; - $out .= "\t [...]"; - $out .= "\t)"; - $out .= "\n"; - $out .= 'Alternatively, you can use simple array syntax to test reverse'; - $out .= 'To reload your routes config (Config/routes.php), do the following:'; - $out .= "\n"; - $out .= "\tRoutes reload"; - $out .= "\n"; - $out .= 'To show all connected routes, do the following:'; - $out .= "\tRoutes show"; - $this->out($out); - } - -/** - * Override main() to handle action - * - * @param string $command - * @return void - */ - public function main($command = null) { - while (true) { - if (empty($command)) { - $command = trim($this->in('')); - } - - switch ($command) { - case 'help': - $this->help(); - break; - case 'quit': - case 'exit': - return true; - break; - case 'models': - $this->out(__d('cake_console', 'Model classes:')); - $this->hr(); - foreach ($this->models as $model) { - $this->out(" - {$model}"); - } - break; - case (preg_match("/^(\w+) bind (\w+) (\w+)/", $command, $tmp) == true): - foreach ($tmp as $data) { - $data = strip_tags($data); - $data = str_replace($this->badCommandChars, "", $data); - } - - $modelA = $tmp[1]; - $association = $tmp[2]; - $modelB = $tmp[3]; - - if ($this->_isValidModel($modelA) && $this->_isValidModel($modelB) && in_array($association, $this->associations)) { - $this->{$modelA}->bindModel(array($association => array($modelB => array('className' => $modelB))), false); - $this->out(__d('cake_console', "Created %s association between %s and %s", - $association, $modelA, $modelB)); - } else { - $this->out(__d('cake_console', "Please verify you are using valid models and association types")); - } - break; - case (preg_match("/^(\w+) unbind (\w+) (\w+)/", $command, $tmp) == true): - foreach ($tmp as $data) { - $data = strip_tags($data); - $data = str_replace($this->badCommandChars, "", $data); - } - - $modelA = $tmp[1]; - $association = $tmp[2]; - $modelB = $tmp[3]; - - // Verify that there is actually an association to unbind - $currentAssociations = $this->{$modelA}->getAssociated(); - $validCurrentAssociation = false; - - foreach ($currentAssociations as $model => $currentAssociation) { - if ($model == $modelB && $association == $currentAssociation) { - $validCurrentAssociation = true; - } - } - - if ($this->_isValidModel($modelA) && $this->_isValidModel($modelB) && in_array($association, $this->associations) && $validCurrentAssociation) { - $this->{$modelA}->unbindModel(array($association => array($modelB))); - $this->out(__d('cake_console', "Removed %s association between %s and %s", - $association, $modelA, $modelB)); - } else { - $this->out(__d('cake_console', "Please verify you are using valid models, valid current association, and valid association types")); - } - break; - case (strpos($command, "->find") > 0): - // Remove any bad info - $command = strip_tags($command); - $command = str_replace($this->badCommandChars, "", $command); - - // Do we have a valid model? - list($modelToCheck, $tmp) = explode('->', $command); - - if ($this->_isValidModel($modelToCheck)) { - $findCommand = "\$data = \$this->$command;"; - @eval($findCommand); - - if (is_array($data)) { - foreach ($data as $idx => $results) { - if (is_numeric($idx)) { // findAll() output - foreach ($results as $modelName => $result) { - $this->out("$modelName"); - - foreach ($result as $field => $value) { - if (is_array($value)) { - foreach ($value as $field2 => $value2) { - $this->out("\t$field2: $value2"); - } - - $this->out(); - } else { - $this->out("\t$field: $value"); - } - } - } - } else { // find() output - $this->out($idx); - - foreach ($results as $field => $value) { - if (is_array($value)) { - foreach ($value as $field2 => $value2) { - $this->out("\t$field2: $value2"); - } - - $this->out(); - } else { - $this->out("\t$field: $value"); - } - } - } - } - } else { - $this->out(); - $this->out(__d('cake_console', "No result set found")); - } - } else { - $this->out(__d('cake_console', "%s is not a valid model", $modelToCheck)); - } - - break; - case (strpos($command, '->save') > 0): - // Validate the model we're trying to save here - $command = strip_tags($command); - $command = str_replace($this->badCommandChars, "", $command); - list($modelToSave, $tmp) = explode("->", $command); - - if ($this->_isValidModel($modelToSave)) { - // Extract the array of data we are trying to build - list($foo, $data) = explode("->save", $command); - $data = preg_replace('/^\(*(array)?\(*(.+?)\)*$/i', '\\2', $data); - $saveCommand = "\$this->{$modelToSave}->save(array('{$modelToSave}' => array({$data})));"; - @eval($saveCommand); - $this->out(__d('cake_console', 'Saved record for %s', $modelToSave)); - } - break; - case (preg_match("/^(\w+) columns/", $command, $tmp) == true): - $modelToCheck = strip_tags(str_replace($this->badCommandChars, "", $tmp[1])); - - if ($this->_isValidModel($modelToCheck)) { - // Get the column info for this model - $fieldsCommand = "\$data = \$this->{$modelToCheck}->getColumnTypes();"; - @eval($fieldsCommand); - - if (is_array($data)) { - foreach ($data as $field => $type) { - $this->out("\t{$field}: {$type}"); - } - } - } else { - $this->out(__d('cake_console', "Please verify that you selected a valid model")); - } - break; - case (preg_match("/^routes\s+reload/i", $command, $tmp) == true): - $router = Router::getInstance(); - if (!$this->_loadRoutes()) { - $this->out(__d('cake_console', "There was an error loading the routes config. Please check that the file exists and is free of parse errors.")); - break; - } - $this->out(__d('cake_console', "Routes configuration reloaded, %d routes connected", count($router->routes))); - break; - case (preg_match("/^routes\s+show/i", $command, $tmp) == true): - $router = Router::getInstance(); - $this->out(implode("\n", Set::extract($router->routes, '{n}.0'))); - break; - case (preg_match("/^route\s+(\(.*\))$/i", $command, $tmp) == true): - if ($url = eval('return array' . $tmp[1] . ';')) { - $this->out(Router::url($url)); - } - break; - case (preg_match("/^route\s+(.*)/i", $command, $tmp) == true): - $this->out(var_export(Router::parse($tmp[1]), true)); - break; - default: - $this->out(__d('cake_console', "Invalid command")); - $this->out(); - break; - } - $command = ''; - } - } - -/** - * Tells if the specified model is included in the list of available models - * - * @param string $modelToCheck - * @return boolean true if is an available model, false otherwise - */ - protected function _isValidModel($modelToCheck) { - return in_array($modelToCheck, $this->models); - } - -/** - * Reloads the routes configuration from app/Config/routes.php, and compiles - * all routes found - * - * @return boolean True if config reload was a success, otherwise false - */ - protected function _loadRoutes() { - Router::reload(); - extract(Router::getNamedExpressions()); - - if (!@include APP . 'Config' . DS . 'routes.php') { - return false; - } - CakePlugin::routes(); - - Router::parse('/'); - - foreach (array_keys(Router::getNamedExpressions()) as $var) { - unset(${$var}); - } - - foreach (Router::$routes as $route) { - $route->compile(); - } - return true; - } - -} diff --git a/lib/Cake/Console/Command/I18nShell.php b/lib/Cake/Console/Command/I18nShell.php deleted file mode 100644 index 73e67a26025..00000000000 --- a/lib/Cake/Console/Command/I18nShell.php +++ /dev/null @@ -1,121 +0,0 @@ -_welcome(); - if (isset($this->params['datasource'])) { - $this->dataSource = $this->params['datasource']; - } - - if ($this->command && !in_array($this->command, array('help'))) { - if (!config('database')) { - $this->out(__d('cake_console', 'Your database configuration was not found. Take a moment to create one.'), true); - return $this->DbConfig->execute(); - } - } - } - -/** - * Override main() for help message hook - * - * @return void - */ - public function main() { - $this->out(__d('cake_console', 'I18n Shell')); - $this->hr(); - $this->out(__d('cake_console', '[E]xtract POT file from sources')); - $this->out(__d('cake_console', '[I]nitialize i18n database table')); - $this->out(__d('cake_console', '[H]elp')); - $this->out(__d('cake_console', '[Q]uit')); - - $choice = strtolower($this->in(__d('cake_console', 'What would you like to do?'), array('E', 'I', 'H', 'Q'))); - switch ($choice) { - case 'e': - $this->Extract->execute(); - break; - case 'i': - $this->initdb(); - break; - case 'h': - $this->out($this->OptionParser->help()); - break; - case 'q': - exit(0); - break; - default: - $this->out(__d('cake_console', 'You have made an invalid selection. Please choose a command to execute by entering E, I, H, or Q.')); - } - $this->hr(); - $this->main(); - } - -/** - * Initialize I18N database. - * - * @return void - */ - public function initdb() { - $this->dispatchShell('schema create i18n'); - } - -/** - * Get and configure the Option parser - * - * @return ConsoleOptionParser - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description( - __d('cake_console', 'I18n Shell initializes i18n database table for your application and generates .pot files(s) with translations.') - )->addSubcommand('initdb', array( - 'help' => __d('cake_console', 'Initialize the i18n table.') - ))->addSubcommand('extract', array( - 'help' => __d('cake_console', 'Extract the po translations from your application'), - 'parser' => $this->Extract->getOptionParser() - )); - } - -} diff --git a/lib/Cake/Console/Command/SchemaShell.php b/lib/Cake/Console/Command/SchemaShell.php deleted file mode 100644 index eb332150096..00000000000 --- a/lib/Cake/Console/Command/SchemaShell.php +++ /dev/null @@ -1,533 +0,0 @@ -_welcome(); - $this->out('Cake Schema Shell'); - $this->hr(); - - $name = $path = $connection = $plugin = null; - if (!empty($this->params['name'])) { - $name = $this->params['name']; - } elseif (!empty($this->args[0]) && $this->args[0] !== 'snapshot') { - $name = $this->params['name'] = $this->args[0]; - } - - if (strpos($name, '.')) { - list($this->params['plugin'], $splitName) = pluginSplit($name); - $name = $this->params['name'] = $splitName; - } - - if ($name) { - $this->params['file'] = Inflector::underscore($name); - } - - if (empty($this->params['file'])) { - $this->params['file'] = 'schema.php'; - } - if (strpos($this->params['file'], '.php') === false) { - $this->params['file'] .= '.php'; - } - $file = $this->params['file']; - - if (!empty($this->params['path'])) { - $path = $this->params['path']; - } - - if (!empty($this->params['connection'])) { - $connection = $this->params['connection']; - } - if (!empty($this->params['plugin'])) { - $plugin = $this->params['plugin']; - if (empty($name)) { - $name = $plugin; - } - } - $this->Schema = new CakeSchema(compact('name', 'path', 'file', 'connection', 'plugin')); - } - -/** - * Read and output contents of schema object - * path to read as second arg - * - * @return void - */ - public function view() { - $File = new File($this->Schema->path . DS . $this->params['file']); - if ($File->exists()) { - $this->out($File->read()); - $this->_stop(); - } else { - $file = $this->Schema->path . DS . $this->params['file']; - $this->err(__d('cake_console', 'Schema file (%s) could not be found.', $file)); - $this->_stop(); - } - } - -/** - * Read database and Write schema object - * accepts a connection as first arg or path to save as second arg - * - * @return void - */ - public function generate() { - $this->out(__d('cake_console', 'Generating Schema...')); - $options = array(); - if ($this->params['force']) { - $options = array('models' => false); - } - - $snapshot = false; - if (isset($this->args[0]) && $this->args[0] === 'snapshot') { - $snapshot = true; - } - - if (!$snapshot && file_exists($this->Schema->path . DS . $this->params['file'])) { - $snapshot = true; - $prompt = __d('cake_console', "Schema file exists.\n [O]verwrite\n [S]napshot\n [Q]uit\nWould you like to do?"); - $result = strtolower($this->in($prompt, array('o', 's', 'q'), 's')); - if ($result === 'q') { - return $this->_stop(); - } - if ($result === 'o') { - $snapshot = false; - } - } - - $cacheDisable = Configure::read('Cache.disable'); - Configure::write('Cache.disable', true); - - $content = $this->Schema->read($options); - $content['file'] = $this->params['file']; - - Configure::write('Cache.disable', $cacheDisable); - - if ($snapshot === true) { - $fileName = rtrim($this->params['file'], '.php'); - $Folder = new Folder($this->Schema->path); - $result = $Folder->read(); - - $numToUse = false; - if (isset($this->params['snapshot'])) { - $numToUse = $this->params['snapshot']; - } - - $count = 0; - if (!empty($result[1])) { - foreach ($result[1] as $file) { - if (preg_match('/' . preg_quote($fileName) . '(?:[_\d]*)?\.php$/', $file)) { - $count++; - } - } - } - - if ($numToUse !== false) { - if ($numToUse > $count) { - $count = $numToUse; - } - } - - $content['file'] = $fileName . '_' . $count . '.php'; - } - - if ($this->Schema->write($content)) { - $this->out(__d('cake_console', 'Schema file: %s generated', $content['file'])); - $this->_stop(); - } else { - $this->err(__d('cake_console', 'Schema file: %s generated')); - $this->_stop(); - } - } - -/** - * Dump Schema object to sql file - * Use the `write` param to enable and control SQL file output location. - * Simply using -write will write the sql file to the same dir as the schema file. - * If -write contains a full path name the file will be saved there. If -write only - * contains no DS, that will be used as the file name, in the same dir as the schema file. - * - * @return string - */ - public function dump() { - $write = false; - $Schema = $this->Schema->load(); - if (!$Schema) { - $this->err(__d('cake_console', 'Schema could not be loaded')); - $this->_stop(); - } - if (!empty($this->params['write'])) { - if ($this->params['write'] == 1) { - $write = Inflector::underscore($this->Schema->name); - } else { - $write = $this->params['write']; - } - } - $db = ConnectionManager::getDataSource($this->Schema->connection); - $contents = "\n\n" . $db->dropSchema($Schema) . "\n\n" . $db->createSchema($Schema); - - if ($write) { - if (strpos($write, '.sql') === false) { - $write .= '.sql'; - } - if (strpos($write, DS) !== false) { - $File = new File($write, true); - } else { - $File = new File($this->Schema->path . DS . $write, true); - } - - if ($File->write($contents)) { - $this->out(__d('cake_console', 'SQL dump file created in %s', $File->pwd())); - $this->_stop(); - } else { - $this->err(__d('cake_console', 'SQL dump could not be created')); - $this->_stop(); - } - } - $this->out($contents); - return $contents; - } - -/** - * Run database create commands. Alias for run create. - * - * @return void - */ - public function create() { - list($Schema, $table) = $this->_loadSchema(); - $this->_create($Schema, $table); - } - -/** - * Run database create commands. Alias for run create. - * - * @return void - */ - public function update() { - list($Schema, $table) = $this->_loadSchema(); - $this->_update($Schema, $table); - } - -/** - * Prepares the Schema objects for database operations. - * - * @return void - */ - protected function _loadSchema() { - $name = $plugin = null; - if (!empty($this->params['name'])) { - $name = $this->params['name']; - } - if (!empty($this->params['plugin'])) { - $plugin = $this->params['plugin']; - } - - if (!empty($this->params['dry'])) { - $this->_dry = true; - $this->out(__d('cake_console', 'Performing a dry run.')); - } - - $options = array('name' => $name, 'plugin' => $plugin); - if (!empty($this->params['snapshot'])) { - $fileName = rtrim($this->Schema->file, '.php'); - $options['file'] = $fileName . '_' . $this->params['snapshot'] . '.php'; - } - - $Schema = $this->Schema->load($options); - - if (!$Schema) { - $this->err(__d('cake_console', '%s could not be loaded', $this->Schema->path . DS . $this->Schema->file)); - $this->_stop(); - } - $table = null; - if (isset($this->args[1])) { - $table = $this->args[1]; - } - return array(&$Schema, $table); - } - -/** - * Create database from Schema object - * Should be called via the run method - * - * @param CakeSchema $Schema - * @param string $table - * @return void - */ - protected function _create($Schema, $table = null) { - $db = ConnectionManager::getDataSource($this->Schema->connection); - - $drop = $create = array(); - - if (!$table) { - foreach ($Schema->tables as $table => $fields) { - $drop[$table] = $db->dropSchema($Schema, $table); - $create[$table] = $db->createSchema($Schema, $table); - } - } elseif (isset($Schema->tables[$table])) { - $drop[$table] = $db->dropSchema($Schema, $table); - $create[$table] = $db->createSchema($Schema, $table); - } - if (empty($drop) || empty($create)) { - $this->out(__d('cake_console', 'Schema is up to date.')); - $this->_stop(); - } - - $this->out("\n" . __d('cake_console', 'The following table(s) will be dropped.')); - $this->out(array_keys($drop)); - - if ('y' == $this->in(__d('cake_console', 'Are you sure you want to drop the table(s)?'), array('y', 'n'), 'n')) { - $this->out(__d('cake_console', 'Dropping table(s).')); - $this->_run($drop, 'drop', $Schema); - } - - $this->out("\n" . __d('cake_console', 'The following table(s) will be created.')); - $this->out(array_keys($create)); - - if ('y' == $this->in(__d('cake_console', 'Are you sure you want to create the table(s)?'), array('y', 'n'), 'y')) { - $this->out(__d('cake_console', 'Creating table(s).')); - $this->_run($create, 'create', $Schema); - } - $this->out(__d('cake_console', 'End create.')); - } - -/** - * Update database with Schema object - * Should be called via the run method - * - * @param CakeSchema $Schema - * @param string $table - * @return void - */ - protected function _update(&$Schema, $table = null) { - $db = ConnectionManager::getDataSource($this->Schema->connection); - - $this->out(__d('cake_console', 'Comparing Database to Schema...')); - $options = array(); - if (isset($this->params['force'])) { - $options['models'] = false; - } - $Old = $this->Schema->read($options); - $compare = $this->Schema->compare($Old, $Schema); - - $contents = array(); - - if (empty($table)) { - foreach ($compare as $table => $changes) { - $contents[$table] = $db->alterSchema(array($table => $changes), $table); - } - } elseif (isset($compare[$table])) { - $contents[$table] = $db->alterSchema(array($table => $compare[$table]), $table); - } - - if (empty($contents)) { - $this->out(__d('cake_console', 'Schema is up to date.')); - $this->_stop(); - } - - $this->out("\n" . __d('cake_console', 'The following statements will run.')); - $this->out(array_map('trim', $contents)); - if ('y' == $this->in(__d('cake_console', 'Are you sure you want to alter the tables?'), array('y', 'n'), 'n')) { - $this->out(); - $this->out(__d('cake_console', 'Updating Database...')); - $this->_run($contents, 'update', $Schema); - } - - $this->out(__d('cake_console', 'End update.')); - } - -/** - * Runs sql from _create() or _update() - * - * @param array $contents - * @param string $event - * @param CakeSchema $Schema - * @return void - */ - protected function _run($contents, $event, &$Schema) { - if (empty($contents)) { - $this->err(__d('cake_console', 'Sql could not be run')); - return; - } - Configure::write('debug', 2); - $db = ConnectionManager::getDataSource($this->Schema->connection); - - foreach ($contents as $table => $sql) { - if (empty($sql)) { - $this->out(__d('cake_console', '%s is up to date.', $table)); - } else { - if ($this->_dry === true) { - $this->out(__d('cake_console', 'Dry run for %s :', $table)); - $this->out($sql); - } else { - if (!$Schema->before(array($event => $table))) { - return false; - } - $error = null; - try { - $db->execute($sql); - } catch (PDOException $e) { - $error = $table . ': ' . $e->getMessage(); - } - - $Schema->after(array($event => $table, 'errors' => $error)); - - if (!empty($error)) { - $this->err($error); - } else { - $this->out(__d('cake_console', '%s updated.', $table)); - } - } - } - } - } - -/** - * get the option parser - * - * @return void - */ - public function getOptionParser() { - $plugin = array( - 'short' => 'p', - 'help' => __d('cake_console', 'The plugin to use.'), - ); - $connection = array( - 'short' => 'c', - 'help' => __d('cake_console', 'Set the db config to use.'), - 'default' => 'default' - ); - $path = array( - 'help' => __d('cake_console', 'Path to read and write schema.php'), - 'default' => APP . 'Config' . DS . 'Schema' - ); - $file = array( - 'help' => __d('cake_console', 'File name to read and write.'), - 'default' => 'schema.php' - ); - $name = array( - 'help' => __d('cake_console', 'Classname to use. If its Plugin.class, both name and plugin options will be set.') - ); - $snapshot = array( - 'short' => 's', - 'help' => __d('cake_console', 'Snapshot number to use/make.') - ); - $dry = array( - 'help' => __d('cake_console', 'Perform a dry run on create and update commands. Queries will be output instead of run.'), - 'boolean' => true - ); - $force = array( - 'short' => 'f', - 'help' => __d('cake_console', 'Force "generate" to create a new schema'), - 'boolean' => true - ); - $write = array( - 'help' => __d('cake_console', 'Write the dumped SQL to a file.') - ); - - $parser = parent::getOptionParser(); - $parser->description( - __d('cake_console', 'The Schema Shell generates a schema object from the database and updates the database from the schema.') - )->addSubcommand('view', array( - 'help' => __d('cake_console', 'Read and output the contents of a schema file'), - 'parser' => array( - 'options' => compact('plugin', 'path', 'file', 'name', 'connection'), - 'arguments' => compact('name') - ) - ))->addSubcommand('generate', array( - 'help' => __d('cake_console', 'Reads from --connection and writes to --path. Generate snapshots with -s'), - 'parser' => array( - 'options' => compact('plugin', 'path', 'file', 'name', 'connection', 'snapshot', 'force'), - 'arguments' => array( - 'snapshot' => array('help' => __d('cake_console', 'Generate a snapshot.')) - ) - ) - ))->addSubcommand('dump', array( - 'help' => __d('cake_console', 'Dump database SQL based on a schema file to stdout.'), - 'parser' => array( - 'options' => compact('plugin', 'path', 'file', 'name', 'connection', 'write'), - 'arguments' => compact('name') - ) - ))->addSubcommand('create', array( - 'help' => __d('cake_console', 'Drop and create tables based on the schema file.'), - 'parser' => array( - 'options' => compact('plugin', 'path', 'file', 'name', 'connection', 'dry', 'snapshot'), - 'args' => array( - 'name' => array( - 'help' => __d('cake_console', 'Name of schema to use.') - ), - 'table' => array( - 'help' => __d('cake_console', 'Only create the specified table.') - ) - ) - ) - ))->addSubcommand('update', array( - 'help' => __d('cake_console', 'Alter the tables based on the schema file.'), - 'parser' => array( - 'options' => compact('plugin', 'path', 'file', 'name', 'connection', 'dry', 'snapshot', 'force'), - 'args' => array( - 'name' => array( - 'help' => __d('cake_console', 'Name of schema to use.') - ), - 'table' => array( - 'help' => __d('cake_console', 'Only create the specified table.') - ) - ) - ) - )); - return $parser; - } - -} diff --git a/lib/Cake/Console/Command/Task/BakeTask.php b/lib/Cake/Console/Command/Task/BakeTask.php deleted file mode 100644 index e32e2539e38..00000000000 --- a/lib/Cake/Console/Command/Task/BakeTask.php +++ /dev/null @@ -1,93 +0,0 @@ -path; - if (isset($this->plugin)) { - $path = $this->_pluginPath($this->plugin) . $this->name . DS; - } - return $path; - } - -/** - * Base execute method parses some parameters and sets some properties on the bake tasks. - * call when overriding execute() - * - * @return void - */ - public function execute() { - foreach ($this->args as $i => $arg) { - if (strpos($arg, '.')) { - list($this->params['plugin'], $this->args[$i]) = pluginSplit($arg); - break; - } - } - if (isset($this->params['plugin'])) { - $this->plugin = $this->params['plugin']; - } - } - -} diff --git a/lib/Cake/Console/Command/Task/ControllerTask.php b/lib/Cake/Console/Command/Task/ControllerTask.php deleted file mode 100644 index ca68389adfa..00000000000 --- a/lib/Cake/Console/Command/Task/ControllerTask.php +++ /dev/null @@ -1,470 +0,0 @@ -path = current(App::path('Controller')); - } - -/** - * Execution method always used for tasks - * - * @return void - */ - public function execute() { - parent::execute(); - if (empty($this->args)) { - return $this->_interactive(); - } - - if (isset($this->args[0])) { - if (!isset($this->connection)) { - $this->connection = 'default'; - } - if (strtolower($this->args[0]) == 'all') { - return $this->all(); - } - - $controller = $this->_controllerName($this->args[0]); - $actions = ''; - - if (!empty($this->params['public'])) { - $this->out(__d('cake_console', 'Baking basic crud methods for ') . $controller); - $actions .= $this->bakeActions($controller); - } - if (!empty($this->params['admin'])) { - $admin = $this->Project->getPrefix(); - if ($admin) { - $this->out(__d('cake_console', 'Adding %s methods', $admin)); - $actions .= "\n" . $this->bakeActions($controller, $admin); - } - } - if (empty($actions)) { - $actions = 'scaffold'; - } - - if ($this->bake($controller, $actions)) { - if ($this->_checkUnitTest()) { - $this->bakeTest($controller); - } - } - } - } - -/** - * Bake All the controllers at once. Will only bake controllers for models that exist. - * - * @return void - */ - public function all() { - $this->interactive = false; - $this->listAll($this->connection, false); - ClassRegistry::config('Model', array('ds' => $this->connection)); - $unitTestExists = $this->_checkUnitTest(); - foreach ($this->__tables as $table) { - $model = $this->_modelName($table); - $controller = $this->_controllerName($model); - App::uses($model, 'Model'); - if (class_exists($model)) { - $actions = $this->bakeActions($controller); - if ($this->bake($controller, $actions) && $unitTestExists) { - $this->bakeTest($controller); - } - } - } - } - -/** - * Interactive - * - * @return void - */ - protected function _interactive() { - $this->interactive = true; - $this->hr(); - $this->out(__d('cake_console', "Bake Controller\nPath: %s", $this->getPath())); - $this->hr(); - - if (empty($this->connection)) { - $this->connection = $this->DbConfig->getConfig(); - } - - $controllerName = $this->getName(); - $this->hr(); - $this->out(__d('cake_console', 'Baking %sController', $controllerName)); - $this->hr(); - - $helpers = $components = array(); - $actions = ''; - $wannaUseSession = 'y'; - $wannaBakeAdminCrud = 'n'; - $useDynamicScaffold = 'n'; - $wannaBakeCrud = 'y'; - - $question[] = __d('cake_console', "Would you like to build your controller interactively?"); - if (file_exists($this->path . $controllerName . 'Controller.php')) { - $question[] = __d('cake_console', "Warning: Choosing no will overwrite the %sController.", $controllerName); - } - $doItInteractive = $this->in(implode("\n", $question), array('y', 'n'), 'y'); - - if (strtolower($doItInteractive) == 'y') { - $this->interactive = true; - $useDynamicScaffold = $this->in( - __d('cake_console', "Would you like to use dynamic scaffolding?"), array('y', 'n'), 'n' - ); - - if (strtolower($useDynamicScaffold) == 'y') { - $wannaBakeCrud = 'n'; - $actions = 'scaffold'; - } else { - list($wannaBakeCrud, $wannaBakeAdminCrud) = $this->_askAboutMethods(); - - $helpers = $this->doHelpers(); - $components = $this->doComponents(); - - $wannaUseSession = $this->in( - __d('cake_console', "Would you like to use Session flash messages?"), array('y','n'), 'y' - ); - } - } else { - list($wannaBakeCrud, $wannaBakeAdminCrud) = $this->_askAboutMethods(); - } - - if (strtolower($wannaBakeCrud) == 'y') { - $actions = $this->bakeActions($controllerName, null, strtolower($wannaUseSession) == 'y'); - } - if (strtolower($wannaBakeAdminCrud) == 'y') { - $admin = $this->Project->getPrefix(); - $actions .= $this->bakeActions($controllerName, $admin, strtolower($wannaUseSession) == 'y'); - } - - $baked = false; - if ($this->interactive === true) { - $this->confirmController($controllerName, $useDynamicScaffold, $helpers, $components); - $looksGood = $this->in(__d('cake_console', 'Look okay?'), array('y','n'), 'y'); - - if (strtolower($looksGood) == 'y') { - $baked = $this->bake($controllerName, $actions, $helpers, $components); - if ($baked && $this->_checkUnitTest()) { - $this->bakeTest($controllerName); - } - } - } else { - $baked = $this->bake($controllerName, $actions, $helpers, $components); - if ($baked && $this->_checkUnitTest()) { - $this->bakeTest($controllerName); - } - } - return $baked; - } - -/** - * Confirm a to be baked controller with the user - * - * @param string $controllerName - * @param string $useDynamicScaffold - * @param array $helpers - * @param array $components - * @return void - */ - public function confirmController($controllerName, $useDynamicScaffold, $helpers, $components) { - $this->out(); - $this->hr(); - $this->out(__d('cake_console', 'The following controller will be created:')); - $this->hr(); - $this->out(__d('cake_console', "Controller Name:\n\t%s", $controllerName)); - - if (strtolower($useDynamicScaffold) == 'y') { - $this->out("public \$scaffold;"); - } - - $properties = array( - 'helpers' => __d('cake_console', 'Helpers:'), - 'components' => __d('cake_console', 'Components:'), - ); - - foreach ($properties as $var => $title) { - if (count($$var)) { - $output = ''; - $length = count($$var); - foreach ($$var as $i => $propElement) { - if ($i != $length - 1) { - $output .= ucfirst($propElement) . ', '; - } else { - $output .= ucfirst($propElement); - } - } - $this->out($title . "\n\t" . $output); - } - } - $this->hr(); - } - -/** - * Interact with the user and ask about which methods (admin or regular they want to bake) - * - * @return array Array containing (bakeRegular, bakeAdmin) answers - */ - protected function _askAboutMethods() { - $wannaBakeCrud = $this->in( - __d('cake_console', "Would you like to create some basic class methods \n(index(), add(), view(), edit())?"), - array('y','n'), 'n' - ); - $wannaBakeAdminCrud = $this->in( - __d('cake_console', "Would you like to create the basic class methods for admin routing?"), - array('y','n'), 'n' - ); - return array($wannaBakeCrud, $wannaBakeAdminCrud); - } - -/** - * Bake scaffold actions - * - * @param string $controllerName Controller name - * @param string $admin Admin route to use - * @param boolean $wannaUseSession Set to true to use sessions, false otherwise - * @return string Baked actions - */ - public function bakeActions($controllerName, $admin = null, $wannaUseSession = true) { - $currentModelName = $modelImport = $this->_modelName($controllerName); - $plugin = $this->plugin; - if ($plugin) { - $plugin .= '.'; - } - App::uses($modelImport, $plugin . 'Model'); - if (!class_exists($modelImport)) { - $this->err(__d('cake_console', 'You must have a model for this class to build basic methods. Please try again.')); - $this->_stop(); - } - - $modelObj = ClassRegistry::init($currentModelName); - $controllerPath = $this->_controllerPath($controllerName); - $pluralName = $this->_pluralName($currentModelName); - $singularName = Inflector::variable($currentModelName); - $singularHumanName = $this->_singularHumanName($controllerName); - $pluralHumanName = $this->_pluralName($controllerName); - $displayField = $modelObj->displayField; - $primaryKey = $modelObj->primaryKey; - - $this->Template->set(compact( - 'plugin', 'admin', 'controllerPath', 'pluralName', 'singularName', - 'singularHumanName', 'pluralHumanName', 'modelObj', 'wannaUseSession', 'currentModelName', - 'displayField', 'primaryKey' - )); - $actions = $this->Template->generate('actions', 'controller_actions'); - return $actions; - } - -/** - * Assembles and writes a Controller file - * - * @param string $controllerName Controller name already pluralized and correctly cased. - * @param string $actions Actions to add, or set the whole controller to use $scaffold (set $actions to 'scaffold') - * @param array $helpers Helpers to use in controller - * @param array $components Components to use in controller - * @return string Baked controller - */ - public function bake($controllerName, $actions = '', $helpers = null, $components = null) { - $this->out("\n" . __d('cake_console', 'Baking controller class for %s...', $controllerName), 1, Shell::QUIET); - - $isScaffold = ($actions === 'scaffold') ? true : false; - - $this->Template->set(array( - 'plugin' => $this->plugin, - 'pluginPath' => empty($this->plugin) ? '' : $this->plugin . '.' - )); - $this->Template->set(compact('controllerName', 'actions', 'helpers', 'components', 'isScaffold')); - $contents = $this->Template->generate('classes', 'controller'); - - $path = $this->getPath(); - $filename = $path . $controllerName . 'Controller.php'; - if ($this->createFile($filename, $contents)) { - return $contents; - } - return false; - } - -/** - * Assembles and writes a unit test file - * - * @param string $className Controller class name - * @return string Baked test - */ - public function bakeTest($className) { - $this->Test->plugin = $this->plugin; - $this->Test->connection = $this->connection; - $this->Test->interactive = $this->interactive; - return $this->Test->bake('Controller', $className); - } - -/** - * Interact with the user and get a list of additional helpers - * - * @return array Helpers that the user wants to use. - */ - public function doHelpers() { - return $this->_doPropertyChoices( - __d('cake_console', "Would you like this controller to use other helpers\nbesides HtmlHelper and FormHelper?"), - __d('cake_console', "Please provide a comma separated list of the other\nhelper names you'd like to use.\nExample: 'Ajax, Javascript, Time'") - ); - } - -/** - * Interact with the user and get a list of additional components - * - * @return array Components the user wants to use. - */ - public function doComponents() { - return $this->_doPropertyChoices( - __d('cake_console', "Would you like this controller to use any components?"), - __d('cake_console', "Please provide a comma separated list of the component names you'd like to use.\nExample: 'Acl, Security, RequestHandler'") - ); - } - -/** - * Common code for property choice handling. - * - * @param string $prompt A yes/no question to precede the list - * @param string $example A question for a comma separated list, with examples. - * @return array Array of values for property. - */ - protected function _doPropertyChoices($prompt, $example) { - $proceed = $this->in($prompt, array('y','n'), 'n'); - $property = array(); - if (strtolower($proceed) == 'y') { - $propertyList = $this->in($example); - $propertyListTrimmed = str_replace(' ', '', $propertyList); - $property = explode(',', $propertyListTrimmed); - } - return array_filter($property); - } - -/** - * Outputs and gets the list of possible controllers from database - * - * @param string $useDbConfig Database configuration name - * @return array Set of controllers - */ - public function listAll($useDbConfig = null) { - if (is_null($useDbConfig)) { - $useDbConfig = $this->connection; - } - $this->__tables = $this->Model->getAllTables($useDbConfig); - - if ($this->interactive == true) { - $this->out(__d('cake_console', 'Possible Controllers based on your current database:')); - $this->_controllerNames = array(); - $count = count($this->__tables); - for ($i = 0; $i < $count; $i++) { - $this->_controllerNames[] = $this->_controllerName($this->_modelName($this->__tables[$i])); - $this->out($i + 1 . ". " . $this->_controllerNames[$i]); - } - return $this->_controllerNames; - } - return $this->__tables; - } - -/** - * Forces the user to specify the controller he wants to bake, and returns the selected controller name. - * - * @param string $useDbConfig Connection name to get a controller name for. - * @return string Controller name - */ - public function getName($useDbConfig = null) { - $controllers = $this->listAll($useDbConfig); - $enteredController = ''; - - while ($enteredController == '') { - $enteredController = $this->in(__d('cake_console', "Enter a number from the list above,\ntype in the name of another controller, or 'q' to exit"), null, 'q'); - if ($enteredController === 'q') { - $this->out(__d('cake_console', 'Exit')); - return $this->_stop(); - } - - if ($enteredController == '' || intval($enteredController) > count($controllers)) { - $this->err(__d('cake_console', "The Controller name you supplied was empty,\nor the number you selected was not an option. Please try again.")); - $enteredController = ''; - } - } - - if (intval($enteredController) > 0 && intval($enteredController) <= count($controllers) ) { - $controllerName = $controllers[intval($enteredController) - 1]; - } else { - $controllerName = Inflector::camelize($enteredController); - } - return $controllerName; - } - -/** - * get the option parser. - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description( - __d('cake_console', 'Bake a controller for a model. Using options you can bake public, admin or both.') - )->addArgument('name', array( - 'help' => __d('cake_console', 'Name of the controller to bake. Can use Plugin.name to bake controllers into plugins.') - ))->addOption('public', array( - 'help' => __d('cake_console', 'Bake a controller with basic crud actions (index, view, add, edit, delete).'), - 'boolean' => true - ))->addOption('admin', array( - 'help' => __d('cake_console', 'Bake a controller with crud actions for one of the Routing.prefixes.'), - 'boolean' => true - ))->addOption('plugin', array( - 'short' => 'p', - 'help' => __d('cake_console', 'Plugin to bake the controller into.') - ))->addOption('connection', array( - 'short' => 'c', - 'help' => __d('cake_console', 'The connection the controller\'s model is on.') - ))->addSubcommand('all', array( - 'help' => __d('cake_console', 'Bake all controllers with CRUD methods.') - ))->epilog(__d('cake_console', 'Omitting all arguments and options will enter into an interactive mode.')); - } - -} diff --git a/lib/Cake/Console/Command/Task/DbConfigTask.php b/lib/Cake/Console/Command/Task/DbConfigTask.php deleted file mode 100644 index 0bf28ff9659..00000000000 --- a/lib/Cake/Console/Command/Task/DbConfigTask.php +++ /dev/null @@ -1,384 +0,0 @@ - 'default', - 'datasource' => 'Database/Mysql', - 'persistent' => 'false', - 'host' => 'localhost', - 'login' => 'root', - 'password' => 'password', - 'database' => 'project_name', - 'schema' => null, - 'prefix' => null, - 'encoding' => null, - 'port' => null - ); - -/** - * String name of the database config class name. - * Used for testing. - * - * @var string - */ - public $databaseClassName = 'DATABASE_CONFIG'; - -/** - * initialization callback - * - * @return void - */ - public function initialize() { - $this->path = APP . 'Config' . DS; - } - -/** - * Execution method always used for tasks - * - * @return void - */ - public function execute() { - if (empty($this->args)) { - $this->_interactive(); - $this->_stop(); - } - } - -/** - * Interactive interface - * - * @return void - */ - protected function _interactive() { - $this->hr(); - $this->out(__d('cake_console', 'Database Configuration:')); - $this->hr(); - $done = false; - $dbConfigs = array(); - - while ($done == false) { - $name = ''; - - while ($name == '') { - $name = $this->in(__d('cake_console', "Name:"), null, 'default'); - if (preg_match('/[^a-z0-9_]/i', $name)) { - $name = ''; - $this->out(__d('cake_console', 'The name may only contain unaccented latin characters, numbers or underscores')); - } elseif (preg_match('/^[^a-z_]/i', $name)) { - $name = ''; - $this->out(__d('cake_console', 'The name must start with an unaccented latin character or an underscore')); - } - } - - $datasource = $this->in(__d('cake_console', 'Datasource:'), array('Mysql', 'Postgres', 'Sqlite', 'Sqlserver'), 'Mysql'); - - $persistent = $this->in(__d('cake_console', 'Persistent Connection?'), array('y', 'n'), 'n'); - if (strtolower($persistent) == 'n') { - $persistent = 'false'; - } else { - $persistent = 'true'; - } - - $host = ''; - while ($host == '') { - $host = $this->in(__d('cake_console', 'Database Host:'), null, 'localhost'); - } - - $port = ''; - while ($port == '') { - $port = $this->in(__d('cake_console', 'Port?'), null, 'n'); - } - - if (strtolower($port) == 'n') { - $port = null; - } - - $login = ''; - while ($login == '') { - $login = $this->in(__d('cake_console', 'User:'), null, 'root'); - } - $password = ''; - $blankPassword = false; - - while ($password == '' && $blankPassword == false) { - $password = $this->in(__d('cake_console', 'Password:')); - - if ($password == '') { - $blank = $this->in(__d('cake_console', 'The password you supplied was empty. Use an empty password?'), array('y', 'n'), 'n'); - if ($blank == 'y') { - $blankPassword = true; - } - } - } - - $database = ''; - while ($database == '') { - $database = $this->in(__d('cake_console', 'Database Name:'), null, 'cake'); - } - - $prefix = ''; - while ($prefix == '') { - $prefix = $this->in(__d('cake_console', 'Table Prefix?'), null, 'n'); - } - if (strtolower($prefix) == 'n') { - $prefix = null; - } - - $encoding = ''; - while ($encoding == '') { - $encoding = $this->in(__d('cake_console', 'Table encoding?'), null, 'n'); - } - if (strtolower($encoding) == 'n') { - $encoding = null; - } - - $schema = ''; - if ($datasource == 'postgres') { - while ($schema == '') { - $schema = $this->in(__d('cake_console', 'Table schema?'), null, 'n'); - } - } - if (strtolower($schema) == 'n') { - $schema = null; - } - - $config = compact('name', 'datasource', 'persistent', 'host', 'login', 'password', 'database', 'prefix', 'encoding', 'port', 'schema'); - - while ($this->_verify($config) == false) { - $this->_interactive(); - } - - $dbConfigs[] = $config; - $doneYet = $this->in(__d('cake_console', 'Do you wish to add another database configuration?'), null, 'n'); - - if (strtolower($doneYet == 'n')) { - $done = true; - } - } - - $this->bake($dbConfigs); - config('database'); - return true; - } - -/** - * Output verification message and bake if it looks good - * - * @param array $config - * @return boolean True if user says it looks good, false otherwise - */ - protected function _verify($config) { - $config = array_merge($this->_defaultConfig, $config); - extract($config); - $this->out(); - $this->hr(); - $this->out(__d('cake_console', 'The following database configuration will be created:')); - $this->hr(); - $this->out(__d('cake_console', "Name: %s", $name)); - $this->out(__d('cake_console', "Datasource: %s", $datasource)); - $this->out(__d('cake_console', "Persistent: %s", $persistent)); - $this->out(__d('cake_console', "Host: %s", $host)); - - if ($port) { - $this->out(__d('cake_console', "Port: %s", $port)); - } - - $this->out(__d('cake_console', "User: %s", $login)); - $this->out(__d('cake_console', "Pass: %s", str_repeat('*', strlen($password)))); - $this->out(__d('cake_console', "Database: %s", $database)); - - if ($prefix) { - $this->out(__d('cake_console', "Table prefix: %s", $prefix)); - } - - if ($schema) { - $this->out(__d('cake_console', "Schema: %s", $schema)); - } - - if ($encoding) { - $this->out(__d('cake_console', "Encoding: %s", $encoding)); - } - - $this->hr(); - $looksGood = $this->in(__d('cake_console', 'Look okay?'), array('y', 'n'), 'y'); - - if (strtolower($looksGood) == 'y') { - return $config; - } - return false; - } - -/** - * Assembles and writes database.php - * - * @param array $configs Configuration settings to use - * @return boolean Success - */ - public function bake($configs) { - if (!is_dir($this->path)) { - $this->err(__d('cake_console', '%s not found', $this->path)); - return false; - } - - $filename = $this->path . 'database.php'; - $oldConfigs = array(); - - if (file_exists($filename)) { - config('database'); - $db = new $this->databaseClassName; - $temp = get_class_vars(get_class($db)); - - foreach ($temp as $configName => $info) { - $info = array_merge($this->_defaultConfig, $info); - - if (!isset($info['schema'])) { - $info['schema'] = null; - } - if (!isset($info['encoding'])) { - $info['encoding'] = null; - } - if (!isset($info['port'])) { - $info['port'] = null; - } - - if ($info['persistent'] === false) { - $info['persistent'] = 'false'; - } else { - $info['persistent'] = ($info['persistent'] == true) ? 'true' : 'false'; - } - - $oldConfigs[] = array( - 'name' => $configName, - 'datasource' => $info['datasource'], - 'persistent' => $info['persistent'], - 'host' => $info['host'], - 'port' => $info['port'], - 'login' => $info['login'], - 'password' => $info['password'], - 'database' => $info['database'], - 'prefix' => $info['prefix'], - 'schema' => $info['schema'], - 'encoding' => $info['encoding'] - ); - } - } - - foreach ($oldConfigs as $key => $oldConfig) { - foreach ($configs as $key1 => $config) { - if ($oldConfig['name'] == $config['name']) { - unset($oldConfigs[$key]); - } - } - } - - $configs = array_merge($oldConfigs, $configs); - $out = "_defaultConfig, $config); - extract($config); - - $out .= "\tpublic \${$name} = array(\n"; - $out .= "\t\t'datasource' => 'Database/{$datasource}',\n"; - $out .= "\t\t'persistent' => {$persistent},\n"; - $out .= "\t\t'host' => '{$host}',\n"; - - if ($port) { - $out .= "\t\t'port' => {$port},\n"; - } - - $out .= "\t\t'login' => '{$login}',\n"; - $out .= "\t\t'password' => '{$password}',\n"; - $out .= "\t\t'database' => '{$database}',\n"; - - if ($schema) { - $out .= "\t\t'schema' => '{$schema}',\n"; - } - - if ($prefix) { - $out .= "\t\t'prefix' => '{$prefix}',\n"; - } - - if ($encoding) { - $out .= "\t\t'encoding' => '{$encoding}'\n"; - } - - $out .= "\t);\n"; - } - - $out .= "}\n"; - $filename = $this->path . 'database.php'; - return $this->createFile($filename, $out); - } - -/** - * Get a user specified Connection name - * - * @return void - */ - public function getConfig() { - App::uses('ConnectionManager', 'Model'); - $configs = ConnectionManager::enumConnectionObjects(); - - $useDbConfig = key($configs); - if (!is_array($configs) || empty($configs)) { - return $this->execute(); - } - $connections = array_keys($configs); - - if (count($connections) > 1) { - $useDbConfig = $this->in(__d('cake_console', 'Use Database Config') . ':', $connections, $useDbConfig); - } - return $useDbConfig; - } - -/** - * get the option parser - * - * @return ConsoleOptionParser - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description( - __d('cake_console', 'Bake new database configuration settings.') - ); - } - -} diff --git a/lib/Cake/Console/Command/Task/ExtractTask.php b/lib/Cake/Console/Command/Task/ExtractTask.php deleted file mode 100644 index 64aad3b032f..00000000000 --- a/lib/Cake/Console/Command/Task/ExtractTask.php +++ /dev/null @@ -1,700 +0,0 @@ -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 (!CakePlugin::loaded($plugin)) { - CakePlugin::load($plugin); - } - $this->_paths = array(CakePlugin::path($plugin)); - $this->params['plugin'] = $plugin; - } else { - $defaultPath = APP; - $message = __d('cake_console', "What is the path you would like to extract?\n[Q]uit [D]one"); - while (true) { - $response = $this->in($message, null, $defaultPath); - if (strtoupper($response) === 'Q') { - $this->out(__d('cake_console', 'Extract Aborted')); - $this->_stop(); - } elseif (strtoupper($response) === 'D') { - $this->out(); - break; - } elseif (is_dir($response)) { - $this->_paths[] = $response; - $defaultPath = 'D'; - } else { - $this->err(__d('cake_console', 'The directory path you supplied was not found. Please try again.')); - } - $this->out(); - } - } - - if (!empty($this->params['exclude-plugins']) && $this->_isExtractingApp()) { - $this->_exclude = array_merge($this->_exclude, App::path('plugins')); - } - - if (!empty($this->params['ignore-model-validation']) || (!$this->_isExtractingApp() && empty($plugin))) { - $this->_extractValidation = false; - } - if (!empty($this->params['validation-domain'])) { - $this->_validationDomain = $this->params['validation-domain']; - } - - if (isset($this->params['output'])) { - $this->_output = $this->params['output']; - } elseif (isset($this->params['plugin'])) { - $this->_output = $this->_paths[0] . DS . 'Locale'; - } else { - $message = __d('cake_console', "What is the path you would like to output?\n[Q]uit", $this->_paths[0] . DS . 'Locale'); - while (true) { - $response = $this->in($message, null, rtrim($this->_paths[0], DS) . DS . 'Locale'); - if (strtoupper($response) === 'Q') { - $this->out(__d('cake_console', 'Extract Aborted')); - $this->_stop(); - } elseif (is_dir($response)) { - $this->_output = $response . DS; - break; - } else { - $this->err(__d('cake_console', '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(__d('cake_console', 'Would you like to merge all domains strings into the default.pot file?'), array('y', 'n'), 'n'); - $this->_merge = strtolower($response) === 'y'; - } - - if (empty($this->_files)) { - $this->_searchFiles(); - } - $this->_output = rtrim($this->_output, DS) . DS; - $this->_extract(); - } - -/** - * Add a translation to the internal translations property - * - * Takes care of duplicate translations - * - * @param string $domain - * @param string $msgid - * @param array $details - */ - protected function _addTranslation($domain, $msgid, $details = array()) { - if (empty($this->_translations[$domain][$msgid])) { - $this->_translations[$domain][$msgid] = array( - 'msgid_plural' => false - ); - } - - if (isset($details['msgid_plural'])) { - $this->_translations[$domain][$msgid]['msgid_plural'] = $details['msgid_plural']; - } - - if (isset($details['file'])) { - $line = 0; - if (isset($details['line'])) { - $line = $details['line']; - } - $this->_translations[$domain][$msgid]['references'][$details['file']][] = $line; - } - } - -/** - * Extract text - * - * @return void - */ - protected function _extract() { - $this->out(); - $this->out(); - $this->out(__d('cake_console', 'Extracting...')); - $this->hr(); - $this->out(__d('cake_console', 'Paths:')); - foreach ($this->_paths as $path) { - $this->out(' ' . $path); - } - $this->out(__d('cake_console', 'Output Directory: ') . $this->_output); - $this->hr(); - $this->_extractTokens(); - $this->_extractValidationMessages(); - $this->_buildFiles(); - $this->_writeFiles(); - $this->_paths = $this->_files = $this->_storage = array(); - $this->_translations = $this->_tokens = array(); - $this->_extractValidation = true; - $this->out(); - $this->out(__d('cake_console', 'Done.')); - } - -/** - * Get & configure the option parser - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description(__d('cake_console', 'CakePHP Language String Extraction:')) - ->addOption('app', array('help' => __d('cake_console', 'Directory where your application is located.'))) - ->addOption('paths', array('help' => __d('cake_console', 'Comma separated list of paths.'))) - ->addOption('merge', array( - 'help' => __d('cake_console', 'Merge all domain strings into the default.po file.'), - 'choices' => array('yes', 'no') - )) - ->addOption('output', array('help' => __d('cake_console', 'Full path to output directory.'))) - ->addOption('files', array('help' => __d('cake_console', 'Comma separated list of files.'))) - ->addOption('exclude-plugins', array( - 'boolean' => true, - 'default' => true, - 'help' => __d('cake_console', 'Ignores all files in plugins if this command is run inside from the same app directory.') - )) - ->addOption('plugin', array( - 'help' => __d('cake_console', 'Extracts tokens only from the plugin specified and puts the result in the plugin\'s Locale directory.') - )) - ->addOption('ignore-model-validation', array( - 'boolean' => true, - 'default' => false, - 'help' => __d('cake_console', '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', array( - 'help' => __d('cake_console', 'If set to a value, the localization domain to be used for model validation messages.') - )) - ->addOption('exclude', array( - 'help' => __d('cake_console', 'Comma separated list of directories to exclude.' . - ' Any path containing a path segment with the provided values will be skipped. E.g. test,vendors') - )); - } - -/** - * Extract tokens out of all files to be processed - * - * @return void - */ - protected function _extractTokens() { - foreach ($this->_files as $file) { - $this->_file = $file; - $this->out(__d('cake_console', 'Processing %s...', $file)); - - $code = file_get_contents($file); - $allTokens = token_get_all($code); - - $this->_tokens = array(); - foreach ($allTokens as $token) { - if (!is_array($token) || ($token[0] != T_WHITESPACE && $token[0] != T_INLINE_HTML)) { - $this->_tokens[] = $token; - } - } - unset($allTokens); - $this->_parse('__', array('singular')); - $this->_parse('__n', array('singular', 'plural')); - $this->_parse('__d', array('domain', 'singular')); - $this->_parse('__c', array('singular')); - $this->_parse('__dc', array('domain', 'singular')); - $this->_parse('__dn', array('domain', 'singular', 'plural')); - $this->_parse('__dcn', array('domain', 'singular', 'plural')); - } - } - -/** - * 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 == 0) { - if ($this->_tokens[$position] == '(') { - $depth++; - } elseif ($this->_tokens[$position] == ')') { - $depth--; - } - $position++; - } - - $mapCount = count($map); - $strings = $this->_getStrings($position, $mapCount); - - if ($mapCount == count($strings)) { - extract(array_combine($map, $strings)); - $domain = isset($domain) ? $domain : 'default'; - $details = array( - 'file' => $this->_file, - 'line' => $line, - ); - if (isset($plural)) { - $details['msgid_plural'] = $plural; - } - $this->_addTranslation($domain, $singular, $details); - } else { - $this->_markerError($this->_file, $line, $functionName, $count); - } - } - $count++; - } - } - -/** - * Looks for models in the application and extracts the validation messages - * to be added to the translation map - * - * @return void - */ - protected function _extractValidationMessages() { - if (!$this->_extractValidation) { - return; - } - - App::uses('AppModel', 'Model'); - $plugin = null; - if (!empty($this->params['plugin'])) { - App::uses($this->params['plugin'] . 'AppModel', $this->params['plugin'] . '.Model'); - $plugin = $this->params['plugin'] . '.'; - } - $models = App::objects($plugin . 'Model', null, false); - - foreach ($models as $model) { - App::uses($model, $plugin . 'Model'); - $reflection = new ReflectionClass($model); - if (!$reflection->isSubClassOf('Model')) { - continue; - } - $properties = $reflection->getDefaultProperties(); - $validate = $properties['validate']; - if (empty($validate)) { - continue; - } - - $file = $reflection->getFileName(); - $domain = $this->_validationDomain; - if (!empty($properties['validationDomain'])) { - $domain = $properties['validationDomain']; - } - foreach ($validate as $field => $rules) { - $this->_processValidationRules($field, $rules, $file, $domain); - } - } - } - -/** - * Process a validation rule for a field and looks for a message to be added - * to the translation map - * - * @param string $field the name of the field that is being processed - * @param array $rules the set of validation rules for the field - * @param string $file the file name where this validation rule was found - * @param string $domain default domain to bind the validations to - * @return void - */ - protected function _processValidationRules($field, $rules, $file, $domain) { - if (!is_array($rules)) { - return; - } - - $dims = Set::countDim($rules); - if ($dims == 1 || ($dims == 2 && isset($rules['message']))) { - $rules = array($rules); - } - - foreach ($rules as $rule => $validateProp) { - $msgid = null; - if (isset($validateProp['message'])) { - if (is_array($validateProp['message'])) { - $msgid = $validateProp['message'][0]; - } else { - $msgid = $validateProp['message']; - } - } elseif (is_string($rule)) { - $msgid = $rule; - } - if ($msgid) { - $details = array( - 'file' => $file, - 'line' => 'validation for field ' . $field - ); - $this->_addTranslation($domain, $msgid, $details); - } - } - } - -/** - * Build the translate template file contents out of obtained strings - * - * @return void - */ - protected function _buildFiles() { - foreach ($this->_translations as $domain => $translations) { - foreach ($translations as $msgid => $details) { - $plural = $details['msgid_plural']; - $files = $details['references']; - $occurrences = array(); - foreach ($files as $file => $lines) { - $lines = array_unique($lines); - $occurrences[] = $file . ':' . implode(';', $lines); - } - $occurrences = implode("\n#: ", $occurrences); - $header = '#: ' . str_replace($this->_paths, '', $occurrences) . "\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"; - } - - $this->_store($domain, $header, $sentence); - if ($domain != 'default' && $this->_merge) { - $this->_store('default', $header, $sentence); - } - } - } - } - -/** - * Prepare a file to be stored - * - * @param string $domain - * @param string $header - * @param string $sentence - * @return void - */ - protected function _store($domain, $header, $sentence) { - if (!isset($this->_storage[$domain])) { - $this->_storage[$domain] = array(); - } - 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; - foreach ($this->_storage as $domain => $sentences) { - $output = $this->_writeHeader(); - foreach ($sentences as $sentence => $header) { - $output .= $header . $sentence; - } - - $filename = $domain . '.pot'; - $File = new File($this->_output . $filename); - $response = ''; - while ($overwriteAll === false && $File->exists() && strtoupper($response) !== 'Y') { - $this->out(); - $response = $this->in( - __d('cake_console', 'Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename), - array('y', 'n', 'a'), - 'y' - ); - if (strtoupper($response) === 'N') { - $response = ''; - while ($response == '') { - $response = $this->in(__d('cake_console', "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 integer $position Actual position on tokens array - * @param integer $target Number of strings to extract - * @return array Strings extracted - */ - protected function _getStrings(&$position, $target) { - $strings = array(); - $count = count($strings); - while ($count < $target && ($this->_tokens[$position] == ',' || $this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING)) { - $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]); - } - $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, array("\\'" => "'", "\\\\" => "\\")); - } - $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 integer $line Line number - * @param string $marker Marker found - * @param integer $count Count - * @return void - */ - protected function _markerError($file, $line, $marker, $count) { - $this->out(__d('cake_console', "Invalid marker content in %s:%s\n* %s(", $file, $line, $marker), true); - $count += 2; - $tokenCount = count($this->_tokens); - $parenthesis = 1; - - while ((($tokenCount - $count) > 0) && $parenthesis) { - if (is_array($this->_tokens[$count])) { - $this->out($this->_tokens[$count][1], false); - } else { - $this->out($this->_tokens[$count], false); - if ($this->_tokens[$count] == '(') { - $parenthesis++; - } - - if ($this->_tokens[$count] == ')') { - $parenthesis--; - } - } - $count++; - } - $this->out("\n", true); - } - -/** - * Search files that may contain translatable strings - * - * @return void - */ - protected function _searchFiles() { - $pattern = false; - if (!empty($this->_exclude)) { - $exclude = array(); - foreach ($this->_exclude as $e) { - if (DS !== '\\' && $e[0] !== DS) { - $e = DS . $e; - } - $exclude[] = preg_quote($e, '/'); - } - $pattern = '/' . implode('|', $exclude) . '/'; - } - foreach ($this->_paths as $path) { - $Folder = new Folder($path); - $files = $Folder->findRecursive('.*\.(php|ctp|thtml|inc|tpl)', true); - if (!empty($pattern)) { - foreach ($files as $i => $file) { - if (preg_match($pattern, $file)) { - unset($files[$i]); - } - } - $files = array_values($files); - } - $this->_files = array_merge($this->_files, $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 boolean - */ - protected function _isExtractingApp() { - return $this->_paths === array(APP); - } - -} diff --git a/lib/Cake/Console/Command/Task/FixtureTask.php b/lib/Cake/Console/Command/Task/FixtureTask.php deleted file mode 100644 index c4bf93d6251..00000000000 --- a/lib/Cake/Console/Command/Task/FixtureTask.php +++ /dev/null @@ -1,418 +0,0 @@ -path = APP . 'Test' . DS . 'Fixture' . DS; - } - -/** - * get the option parser. - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description( - __d('cake_console', 'Generate fixtures for use with the test suite. You can use `bake fixture all` to bake all fixtures.') - )->addArgument('name', array( - 'help' => __d('cake_console', 'Name of the fixture to bake. Can use Plugin.name to bake plugin fixtures.') - ))->addOption('count', array( - 'help' => __d('cake_console', 'When using generated data, the number of records to include in the fixture(s).'), - 'short' => 'n', - 'default' => 10 - ))->addOption('connection', array( - 'help' => __d('cake_console', 'Which database configuration to use for baking.'), - 'short' => 'c', - 'default' => 'default' - ))->addOption('plugin', array( - 'help' => __d('cake_console', 'CamelCased name of the plugin to bake fixtures for.'), - 'short' => 'p', - ))->addOption('records', array( - 'help' => __d('cake_console', 'Used with --count and /all commands to pull [n] records from the live tables, where [n] is either --count or the default of 10'), - 'short' => 'r', - 'boolean' => true - ))->epilog(__d('cake_console', 'Omitting all arguments and options will enter into an interactive mode.')); - } - -/** - * Execution method always used for tasks - * Handles dispatching to interactive, named, or all processes. - * - * @return void - */ - public function execute() { - parent::execute(); - if (empty($this->args)) { - $this->_interactive(); - } - - if (isset($this->args[0])) { - $this->interactive = false; - if (!isset($this->connection)) { - $this->connection = 'default'; - } - if (strtolower($this->args[0]) == 'all') { - return $this->all(); - } - $model = $this->_modelName($this->args[0]); - $this->bake($model); - } - } - -/** - * Bake All the Fixtures at once. Will only bake fixtures for models that exist. - * - * @return void - */ - public function all() { - $this->interactive = false; - $this->Model->interactive = false; - $tables = $this->Model->listAll($this->connection, false); - foreach ($tables as $table) { - $model = $this->_modelName($table); - $this->bake($model); - } - } - -/** - * Interactive baking function - * - * @return void - */ - protected function _interactive() { - $this->DbConfig->interactive = $this->Model->interactive = $this->interactive = true; - $this->hr(); - $this->out(__d('cake_console', "Bake Fixture\nPath: %s", $this->getPath())); - $this->hr(); - - if (!isset($this->connection)) { - $this->connection = $this->DbConfig->getConfig(); - } - $modelName = $this->Model->getName($this->connection); - $useTable = $this->Model->getTable($modelName, $this->connection); - $importOptions = $this->importOptions($modelName); - $this->bake($modelName, $useTable, $importOptions); - } - -/** - * Interacts with the User to setup an array of import options. For a fixture. - * - * @param string $modelName Name of model you are dealing with. - * @return array Array of import options. - */ - public function importOptions($modelName) { - $options = array(); - $doSchema = $this->in(__d('cake_console', 'Would you like to import schema for this fixture?'), array('y', 'n'), 'n'); - if ($doSchema == 'y') { - $options['schema'] = $modelName; - } - $doRecords = $this->in(__d('cake_console', 'Would you like to use record importing for this fixture?'), array('y', 'n'), 'n'); - if ($doRecords == 'y') { - $options['records'] = true; - } - if ($doRecords == 'n') { - $prompt = __d('cake_console', "Would you like to build this fixture with data from %s's table?", $modelName); - $fromTable = $this->in($prompt, array('y', 'n'), 'n'); - if (strtolower($fromTable) == 'y') { - $options['fromTable'] = true; - } - } - return $options; - } - -/** - * Assembles and writes a Fixture file - * - * @param string $model Name of model to bake. - * @param string $useTable Name of table to use. - * @param array $importOptions Options for public $import - * @return string Baked fixture content - */ - public function bake($model, $useTable = false, $importOptions = array()) { - App::uses('CakeSchema', 'Model'); - $table = $schema = $records = $import = $modelImport = null; - $importBits = array(); - - if (!$useTable) { - $useTable = Inflector::tableize($model); - } elseif ($useTable != Inflector::tableize($model)) { - $table = $useTable; - } - - if (!empty($importOptions)) { - if (isset($importOptions['schema'])) { - $modelImport = true; - $importBits[] = "'model' => '{$importOptions['schema']}'"; - } - if (isset($importOptions['records'])) { - $importBits[] = "'records' => true"; - } - if ($this->connection != 'default') { - $importBits[] .= "'connection' => '{$this->connection}'"; - } - if (!empty($importBits)) { - $import = sprintf("array(%s)", implode(', ', $importBits)); - } - } - - $this->_Schema = new CakeSchema(); - $data = $this->_Schema->read(array('models' => false, 'connection' => $this->connection)); - if (!isset($data['tables'][$useTable])) { - $this->err('Could not find your selected table ' . $useTable); - return false; - } - - $tableInfo = $data['tables'][$useTable]; - if (is_null($modelImport)) { - $schema = $this->_generateSchema($tableInfo); - } - - if (empty($importOptions['records']) && !isset($importOptions['fromTable'])) { - $recordCount = 1; - if (isset($this->params['count'])) { - $recordCount = $this->params['count']; - } - $records = $this->_makeRecordString($this->_generateRecords($tableInfo, $recordCount)); - } - if (!empty($this->params['records']) || isset($importOptions['fromTable'])) { - $records = $this->_makeRecordString($this->_getRecordsFromTable($model, $useTable)); - } - $out = $this->generateFixtureFile($model, compact('records', 'table', 'schema', 'import', 'fields')); - return $out; - } - -/** - * Generate the fixture file, and write to disk - * - * @param string $model name of the model being generated - * @param string $otherVars Contents of the fixture file. - * @return string Content saved into fixture file. - */ - public function generateFixtureFile($model, $otherVars) { - $defaults = array('table' => null, 'schema' => null, 'records' => null, 'import' => null, 'fields' => null); - $vars = array_merge($defaults, $otherVars); - - $path = $this->getPath(); - $filename = Inflector::camelize($model) . 'Fixture.php'; - - $this->Template->set('model', $model); - $this->Template->set($vars); - $content = $this->Template->generate('classes', 'fixture'); - - $this->out("\n" . __d('cake_console', 'Baking test fixture for %s...', $model), 1, Shell::QUIET); - $this->createFile($path . $filename, $content); - return $content; - } - -/** - * Get the path to the fixtures. - * - * @return string Path for the fixtures - */ - public function getPath() { - $path = $this->path; - if (isset($this->plugin)) { - $path = $this->_pluginPath($this->plugin) . 'Test' . DS . 'Fixture' . DS; - } - return $path; - } - -/** - * Generates a string representation of a schema. - * - * @param array $tableInfo Table schema array - * @return string fields definitions - */ - protected function _generateSchema($tableInfo) { - $schema = $this->_Schema->generateTable('f', $tableInfo); - return substr($schema, 13, -2); - } - -/** - * Generate String representation of Records - * - * @param array $tableInfo Table schema array - * @param integer $recordCount - * @return array Array of records to use in the fixture. - */ - protected function _generateRecords($tableInfo, $recordCount = 1) { - $records = array(); - for ($i = 0; $i < $recordCount; $i++) { - $record = array(); - foreach ($tableInfo as $field => $fieldInfo) { - if (empty($fieldInfo['type'])) { - continue; - } - switch ($fieldInfo['type']) { - case 'integer': - case 'float': - $insert = $i + 1; - break; - case 'string': - case 'binary': - $isPrimaryUuid = ( - isset($fieldInfo['key']) && strtolower($fieldInfo['key']) == 'primary' && - isset($fieldInfo['length']) && $fieldInfo['length'] == 36 - ); - if ($isPrimaryUuid) { - $insert = String::uuid(); - } else { - $insert = "Lorem ipsum dolor sit amet"; - if (!empty($fieldInfo['length'])) { - $insert = substr($insert, 0, (int)$fieldInfo['length'] - 2); - } - } - break; - case 'timestamp': - $insert = time(); - break; - case 'datetime': - $insert = date('Y-m-d H:i:s'); - break; - case 'date': - $insert = date('Y-m-d'); - break; - case 'time': - $insert = date('H:i:s'); - break; - case 'boolean': - $insert = 1; - break; - case 'text': - $insert = "Lorem ipsum dolor sit amet, aliquet feugiat."; - $insert .= " Convallis morbi fringilla gravida,"; - $insert .= " phasellus feugiat dapibus velit nunc, pulvinar eget sollicitudin"; - $insert .= " venenatis cum nullam, vivamus ut a sed, mollitia lectus. Nulla"; - $insert .= " vestibulum massa neque ut et, id hendrerit sit,"; - $insert .= " feugiat in taciti enim proin nibh, tempor dignissim, rhoncus"; - $insert .= " duis vestibulum nunc mattis convallis."; - break; - } - $record[$field] = $insert; - } - $records[] = $record; - } - return $records; - } - -/** - * Convert a $records array into a a string. - * - * @param array $records Array of records to be converted to string - * @return string A string value of the $records array. - */ - protected function _makeRecordString($records) { - $out = "array(\n"; - foreach ($records as $record) { - $values = array(); - foreach ($record as $field => $value) { - $val = var_export($value, true); - $values[] = "\t\t\t'$field' => $val"; - } - $out .= "\t\tarray(\n"; - $out .= implode(",\n", $values); - $out .= "\n\t\t),\n"; - } - $out .= "\t)"; - return $out; - } - -/** - * Interact with the user to get a custom SQL condition and use that to extract data - * to build a fixture. - * - * @param string $modelName name of the model to take records from. - * @param string $useTable Name of table to use. - * @return array Array of records. - */ - protected function _getRecordsFromTable($modelName, $useTable = null) { - if ($this->interactive) { - $condition = null; - $prompt = __d('cake_console', "Please provide a SQL fragment to use as conditions\nExample: WHERE 1=1"); - while (!$condition) { - $condition = $this->in($prompt, null, 'WHERE 1=1'); - } - $prompt = __d('cake_console', "How many records do you want to import?"); - $recordCount = $this->in($prompt, null, 10); - } else { - $condition = 'WHERE 1=1'; - $recordCount = (isset($this->params['count']) ? $this->params['count'] : 10); - } - $modelObject = new Model(array('name' => $modelName, 'table' => $useTable, 'ds' => $this->connection)); - $records = $modelObject->find('all', array( - 'conditions' => $condition, - 'recursive' => -1, - 'limit' => $recordCount - )); - $db = $modelObject->getDatasource(); - $schema = $modelObject->schema(true); - $out = array(); - foreach ($records as $record) { - $row = array(); - foreach ($record[$modelObject->alias] as $field => $value) { - if ($schema[$field]['type'] === 'boolean') { - $value = (int)(bool)$value; - } - $row[$field] = $value; - } - $out[] = $row; - } - return $out; - } - -} diff --git a/lib/Cake/Console/Command/Task/ModelTask.php b/lib/Cake/Console/Command/Task/ModelTask.php deleted file mode 100644 index 424674950ed..00000000000 --- a/lib/Cake/Console/Command/Task/ModelTask.php +++ /dev/null @@ -1,988 +0,0 @@ -path = current(App::path('Model')); - } - -/** - * Execution method always used for tasks - * - * @return void - */ - public function execute() { - parent::execute(); - - if (empty($this->args)) { - $this->_interactive(); - } - - if (!empty($this->args[0])) { - $this->interactive = false; - if (!isset($this->connection)) { - $this->connection = 'default'; - } - if (strtolower($this->args[0]) == 'all') { - return $this->all(); - } - $model = $this->_modelName($this->args[0]); - $this->listAll($this->connection); - $useTable = $this->getTable($model); - $object = $this->_getModelObject($model, $useTable); - if ($this->bake($object, false)) { - if ($this->_checkUnitTest()) { - $this->bakeFixture($model, $useTable); - $this->bakeTest($model); - } - } - } - } - -/** - * Bake all models at once. - * - * @return void - */ - public function all() { - $this->listAll($this->connection, false); - $unitTestExists = $this->_checkUnitTest(); - foreach ($this->_tables as $table) { - if (in_array($table, $this->skipTables)) { - continue; - } - $modelClass = Inflector::classify($table); - $this->out(__d('cake_console', 'Baking %s', $modelClass)); - $object = $this->_getModelObject($modelClass, $table); - if ($this->bake($object, false) && $unitTestExists) { - $this->bakeFixture($modelClass, $table); - $this->bakeTest($modelClass); - } - } - } - -/** - * Get a model object for a class name. - * - * @param string $className Name of class you want model to be. - * @param string $table Table name - * @return Model Model instance - */ - protected function _getModelObject($className, $table = null) { - if (!$table) { - $table = Inflector::tableize($className); - } - $object = new Model(array('name' => $className, 'table' => $table, 'ds' => $this->connection)); - $fields = $object->schema(true); - foreach ($fields as $name => $field) { - if (isset($field['key']) && $field['key'] == 'primary') { - $object->primaryKey = $name; - break; - } - } - return $object; - } - -/** - * Generate a key value list of options and a prompt. - * - * @param array $options Array of options to use for the selections. indexes must start at 0 - * @param string $prompt Prompt to use for options list. - * @param integer $default The default option for the given prompt. - * @return integer result of user choice. - */ - public function inOptions($options, $prompt = null, $default = null) { - $valid = false; - $max = count($options); - while (!$valid) { - foreach ($options as $i => $option) { - $this->out($i + 1 . '. ' . $option); - } - if (empty($prompt)) { - $prompt = __d('cake_console', 'Make a selection from the choices above'); - } - $choice = $this->in($prompt, null, $default); - if (intval($choice) > 0 && intval($choice) <= $max) { - $valid = true; - } - } - return $choice - 1; - } - -/** - * Handles interactive baking - * - * @return boolean - */ - protected function _interactive() { - $this->hr(); - $this->out(__d('cake_console', "Bake Model\nPath: %s", $this->getPath())); - $this->hr(); - $this->interactive = true; - - $primaryKey = 'id'; - $validate = $associations = array(); - - if (empty($this->connection)) { - $this->connection = $this->DbConfig->getConfig(); - } - $currentModelName = $this->getName(); - $useTable = $this->getTable($currentModelName); - $db = ConnectionManager::getDataSource($this->connection); - $fullTableName = $db->fullTableName($useTable); - if (!in_array($useTable, $this->_tables)) { - $prompt = __d('cake_console', "The table %s doesn't exist or could not be automatically detected\ncontinue anyway?", $useTable); - $continue = $this->in($prompt, array('y', 'n')); - if (strtolower($continue) == 'n') { - return false; - } - } - - $tempModel = new Model(array('name' => $currentModelName, 'table' => $useTable, 'ds' => $this->connection)); - - $knownToExist = false; - try { - $fields = $tempModel->schema(true); - $knownToExist = true; - } catch (Exception $e) { - $fields = array($tempModel->primaryKey); - } - if (!array_key_exists('id', $fields)) { - $primaryKey = $this->findPrimaryKey($fields); - } - - if ($knownToExist) { - $displayField = $tempModel->hasField(array('name', 'title')); - if (!$displayField) { - $displayField = $this->findDisplayField($tempModel->schema()); - } - - $prompt = __d('cake_console', "Would you like to supply validation criteria \nfor the fields in your model?"); - $wannaDoValidation = $this->in($prompt, array('y','n'), 'y'); - if (array_search($useTable, $this->_tables) !== false && strtolower($wannaDoValidation) == 'y') { - $validate = $this->doValidation($tempModel); - } - - $prompt = __d('cake_console', "Would you like to define model associations\n(hasMany, hasOne, belongsTo, etc.)?"); - $wannaDoAssoc = $this->in($prompt, array('y','n'), 'y'); - if (strtolower($wannaDoAssoc) == 'y') { - $associations = $this->doAssociations($tempModel); - } - } - - $this->out(); - $this->hr(); - $this->out(__d('cake_console', 'The following Model will be created:')); - $this->hr(); - $this->out(__d('cake_console', "Name: %s", $currentModelName)); - - if ($this->connection !== 'default') { - $this->out(__d('cake_console', "DB Config: %s", $this->connection)); - } - if ($fullTableName !== Inflector::tableize($currentModelName)) { - $this->out(__d('cake_console', 'DB Table: %s', $fullTableName)); - } - if ($primaryKey != 'id') { - $this->out(__d('cake_console', 'Primary Key: %s', $primaryKey)); - } - if (!empty($validate)) { - $this->out(__d('cake_console', 'Validation: %s', print_r($validate, true))); - } - if (!empty($associations)) { - $this->out(__d('cake_console', 'Associations:')); - $assocKeys = array('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany'); - foreach ($assocKeys as $assocKey) { - $this->_printAssociation($currentModelName, $assocKey, $associations); - } - } - - $this->hr(); - $looksGood = $this->in(__d('cake_console', 'Look okay?'), array('y', 'n'), 'y'); - - if (strtolower($looksGood) == 'y') { - $vars = compact('associations', 'validate', 'primaryKey', 'useTable', 'displayField'); - $vars['useDbConfig'] = $this->connection; - if ($this->bake($currentModelName, $vars)) { - if ($this->_checkUnitTest()) { - $this->bakeFixture($currentModelName, $useTable); - $this->bakeTest($currentModelName, $useTable, $associations); - } - } - } else { - return false; - } - } - -/** - * Print out all the associations of a particular type - * - * @param string $modelName Name of the model relations belong to. - * @param string $type Name of association you want to see. i.e. 'belongsTo' - * @param string $associations Collection of associations. - * @return void - */ - protected function _printAssociation($modelName, $type, $associations) { - if (!empty($associations[$type])) { - for ($i = 0, $len = count($associations[$type]); $i < $len; $i++) { - $out = "\t" . $modelName . ' ' . $type . ' ' . $associations[$type][$i]['alias']; - $this->out($out); - } - } - } - -/** - * Finds a primary Key in a list of fields. - * - * @param array $fields Array of fields that might have a primary key. - * @return string Name of field that is a primary key. - */ - public function findPrimaryKey($fields) { - $name = 'id'; - foreach ($fields as $name => $field) { - if (isset($field['key']) && $field['key'] == 'primary') { - break; - } - } - return $this->in(__d('cake_console', 'What is the primaryKey?'), null, $name); - } - -/** - * interact with the user to find the displayField value for a model. - * - * @param array $fields Array of fields to look for and choose as a displayField - * @return mixed Name of field to use for displayField or false if the user declines to choose - */ - public function findDisplayField($fields) { - $fieldNames = array_keys($fields); - $prompt = __d('cake_console', "A displayField could not be automatically detected\nwould you like to choose one?"); - $continue = $this->in($prompt, array('y', 'n')); - if (strtolower($continue) == 'n') { - return false; - } - $prompt = __d('cake_console', 'Choose a field from the options above:'); - $choice = $this->inOptions($fieldNames, $prompt); - return $fieldNames[$choice]; - } - -/** - * Handles Generation and user interaction for creating validation. - * - * @param Model $model Model to have validations generated for. - * @return array $validate Array of user selected validations. - */ - public function doValidation($model) { - if (!is_object($model)) { - return false; - } - $fields = $model->schema(); - - if (empty($fields)) { - return false; - } - $validate = array(); - $this->initValidations(); - foreach ($fields as $fieldName => $field) { - $validation = $this->fieldValidation($fieldName, $field, $model->primaryKey); - if (!empty($validation)) { - $validate[$fieldName] = $validation; - } - } - return $validate; - } - -/** - * Populate the _validations array - * - * @return void - */ - public function initValidations() { - $options = $choices = array(); - if (class_exists('Validation')) { - $options = get_class_methods('Validation'); - } - sort($options); - $default = 1; - foreach ($options as $key => $option) { - if ($option{0} != '_') { - $choices[$default] = strtolower($option); - $default++; - } - } - $choices[$default] = 'none'; // Needed since index starts at 1 - $this->_validations = $choices; - return $choices; - } - -/** - * Does individual field validation handling. - * - * @param string $fieldName Name of field to be validated. - * @param array $metaData metadata for field - * @param string $primaryKey - * @return array Array of validation for the field. - */ - public function fieldValidation($fieldName, $metaData, $primaryKey = 'id') { - $defaultChoice = count($this->_validations); - $validate = $alreadyChosen = array(); - - $anotherValidator = 'y'; - while ($anotherValidator == 'y') { - if ($this->interactive) { - $this->out(); - $this->out(__d('cake_console', 'Field: %s', $fieldName)); - $this->out(__d('cake_console', 'Type: %s', $metaData['type'])); - $this->hr(); - $this->out(__d('cake_console', 'Please select one of the following validation options:')); - $this->hr(); - } - - $prompt = ''; - for ($i = 1; $i < $defaultChoice; $i++) { - $prompt .= $i . ' - ' . $this->_validations[$i] . "\n"; - } - $prompt .= __d('cake_console', "%s - Do not do any validation on this field.\n", $defaultChoice); - $prompt .= __d('cake_console', "... or enter in a valid regex validation string.\n"); - - $methods = array_flip($this->_validations); - $guess = $defaultChoice; - if ($metaData['null'] != 1 && !in_array($fieldName, array($primaryKey, 'created', 'modified', 'updated'))) { - if ($fieldName == 'email') { - $guess = $methods['email']; - } elseif ($metaData['type'] == 'string' && $metaData['length'] == 36) { - $guess = $methods['uuid']; - } elseif ($metaData['type'] == 'string') { - $guess = $methods['notempty']; - } elseif ($metaData['type'] == 'text') { - $guess = $methods['notempty']; - } elseif ($metaData['type'] == 'integer') { - $guess = $methods['numeric']; - } elseif ($metaData['type'] == 'boolean') { - $guess = $methods['boolean']; - } elseif ($metaData['type'] == 'date') { - $guess = $methods['date']; - } elseif ($metaData['type'] == 'time') { - $guess = $methods['time']; - } elseif ($metaData['type'] == 'inet') { - $guess = $methods['ip']; - } - } - - if ($this->interactive === true) { - $choice = $this->in($prompt, null, $guess); - if (in_array($choice, $alreadyChosen)) { - $this->out(__d('cake_console', "You have already chosen that validation rule,\nplease choose again")); - continue; - } - if (!isset($this->_validations[$choice]) && is_numeric($choice)) { - $this->out(__d('cake_console', 'Please make a valid selection.')); - continue; - } - $alreadyChosen[] = $choice; - } else { - $choice = $guess; - } - - if (isset($this->_validations[$choice])) { - $validatorName = $this->_validations[$choice]; - } else { - $validatorName = Inflector::slug($choice); - } - - if ($choice != $defaultChoice) { - if (is_numeric($choice) && isset($this->_validations[$choice])) { - $validate[$validatorName] = $this->_validations[$choice]; - } else { - $validate[$validatorName] = $choice; - } - } - if ($this->interactive == true && $choice != $defaultChoice) { - $anotherValidator = $this->in(__d('cake_console', 'Would you like to add another validation rule?'), array('y', 'n'), 'n'); - } else { - $anotherValidator = 'n'; - } - } - return $validate; - } - -/** - * Handles associations - * - * @param Model $model - * @return array $associations - */ - public function doAssociations($model) { - if (!is_object($model)) { - return false; - } - if ($this->interactive === true) { - $this->out(__d('cake_console', 'One moment while the associations are detected.')); - } - - $fields = $model->schema(true); - if (empty($fields)) { - return array(); - } - - if (empty($this->_tables)) { - $this->_tables = (array)$this->getAllTables(); - } - - $associations = array( - 'belongsTo' => array(), - 'hasMany' => array(), - 'hasOne' => array(), - 'hasAndBelongsToMany' => array() - ); - - $associations = $this->findBelongsTo($model, $associations); - $associations = $this->findHasOneAndMany($model, $associations); - $associations = $this->findHasAndBelongsToMany($model, $associations); - - if ($this->interactive !== true) { - unset($associations['hasOne']); - } - - if ($this->interactive === true) { - $this->hr(); - if (empty($associations)) { - $this->out(__d('cake_console', 'None found.')); - } else { - $this->out(__d('cake_console', 'Please confirm the following associations:')); - $this->hr(); - $associations = $this->confirmAssociations($model, $associations); - } - $associations = $this->doMoreAssociations($model, $associations); - } - return $associations; - } - -/** - * Find belongsTo relations and add them to the associations list. - * - * @param Model $model Model instance of model being generated. - * @param array $associations Array of in progress associations - * @return array $associations with belongsTo added in. - */ - public function findBelongsTo(Model $model, $associations) { - $fields = $model->schema(true); - foreach ($fields as $fieldName => $field) { - $offset = strpos($fieldName, '_id'); - if ($fieldName != $model->primaryKey && $fieldName != 'parent_id' && $offset !== false) { - $tmpModelName = $this->_modelNameFromKey($fieldName); - $associations['belongsTo'][] = array( - 'alias' => $tmpModelName, - 'className' => $tmpModelName, - 'foreignKey' => $fieldName, - ); - } elseif ($fieldName == 'parent_id') { - $associations['belongsTo'][] = array( - 'alias' => 'Parent' . $model->name, - 'className' => $model->name, - 'foreignKey' => $fieldName, - ); - } - } - return $associations; - } - -/** - * Find the hasOne and HasMany relations and add them to associations list - * - * @param Model $model Model instance being generated - * @param array $associations Array of in progress associations - * @return array $associations with hasOne and hasMany added in. - */ - public function findHasOneAndMany(Model $model, $associations) { - $foreignKey = $this->_modelKey($model->name); - foreach ($this->_tables as $otherTable) { - $tempOtherModel = $this->_getModelObject($this->_modelName($otherTable), $otherTable); - $modelFieldsTemp = $tempOtherModel->schema(true); - - $pattern = '/_' . preg_quote($model->table, '/') . '|' . preg_quote($model->table, '/') . '_/'; - $possibleJoinTable = preg_match($pattern, $otherTable); - if ($possibleJoinTable == true) { - continue; - } - foreach ($modelFieldsTemp as $fieldName => $field) { - $assoc = false; - if ($fieldName != $model->primaryKey && $fieldName == $foreignKey) { - $assoc = array( - 'alias' => $tempOtherModel->name, - 'className' => $tempOtherModel->name, - 'foreignKey' => $fieldName - ); - } elseif ($otherTable == $model->table && $fieldName == 'parent_id') { - $assoc = array( - 'alias' => 'Child' . $model->name, - 'className' => $model->name, - 'foreignKey' => $fieldName - ); - } - if ($assoc) { - $associations['hasOne'][] = $assoc; - $associations['hasMany'][] = $assoc; - } - - } - } - return $associations; - } - -/** - * Find the hasAndBelongsToMany relations and add them to associations list - * - * @param Model $model Model instance being generated - * @param array $associations Array of in-progress associations - * @return array $associations with hasAndBelongsToMany added in. - */ - public function findHasAndBelongsToMany(Model $model, $associations) { - $foreignKey = $this->_modelKey($model->name); - foreach ($this->_tables as $otherTable) { - $tempOtherModel = $this->_getModelObject($this->_modelName($otherTable), $otherTable); - $modelFieldsTemp = $tempOtherModel->schema(true); - - $offset = strpos($otherTable, $model->table . '_'); - $otherOffset = strpos($otherTable, '_' . $model->table); - - if ($offset !== false) { - $offset = strlen($model->table . '_'); - $habtmName = $this->_modelName(substr($otherTable, $offset)); - $associations['hasAndBelongsToMany'][] = array( - 'alias' => $habtmName, - 'className' => $habtmName, - 'foreignKey' => $foreignKey, - 'associationForeignKey' => $this->_modelKey($habtmName), - 'joinTable' => $otherTable - ); - } elseif ($otherOffset !== false) { - $habtmName = $this->_modelName(substr($otherTable, 0, $otherOffset)); - $associations['hasAndBelongsToMany'][] = array( - 'alias' => $habtmName, - 'className' => $habtmName, - 'foreignKey' => $foreignKey, - 'associationForeignKey' => $this->_modelKey($habtmName), - 'joinTable' => $otherTable - ); - } - } - return $associations; - } - -/** - * Interact with the user and confirm associations. - * - * @param array $model Temporary Model instance. - * @param array $associations Array of associations to be confirmed. - * @return array Array of confirmed associations - */ - public function confirmAssociations(Model $model, $associations) { - foreach ($associations as $type => $settings) { - if (!empty($associations[$type])) { - foreach ($associations[$type] as $i => $assoc) { - $prompt = "{$model->name} {$type} {$assoc['alias']}?"; - $response = $this->in($prompt, array('y', 'n'), 'y'); - - if ('n' == strtolower($response)) { - unset($associations[$type][$i]); - } elseif ($type == 'hasMany') { - unset($associations['hasOne'][$i]); - } - } - $associations[$type] = array_merge($associations[$type]); - } - } - return $associations; - } - -/** - * Interact with the user and generate additional non-conventional associations - * - * @param Model $model Temporary model instance - * @param array $associations Array of associations. - * @return array Array of associations. - */ - public function doMoreAssociations(Model $model, $associations) { - $prompt = __d('cake_console', 'Would you like to define some additional model associations?'); - $wannaDoMoreAssoc = $this->in($prompt, array('y', 'n'), 'n'); - $possibleKeys = $this->_generatePossibleKeys(); - while (strtolower($wannaDoMoreAssoc) == 'y') { - $assocs = array('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany'); - $this->out(__d('cake_console', 'What is the association type?')); - $assocType = intval($this->inOptions($assocs, __d('cake_console', 'Enter a number'))); - - $this->out(__d('cake_console', "For the following options be very careful to match your setup exactly.\n" . - "Any spelling mistakes will cause errors.")); - $this->hr(); - - $alias = $this->in(__d('cake_console', 'What is the alias for this association?')); - $className = $this->in(__d('cake_console', 'What className will %s use?', $alias), null, $alias ); - - if ($assocType == 0) { - if (!empty($possibleKeys[$model->table])) { - $showKeys = $possibleKeys[$model->table]; - } else { - $showKeys = null; - } - $suggestedForeignKey = $this->_modelKey($alias); - } else { - $otherTable = Inflector::tableize($className); - if (in_array($otherTable, $this->_tables)) { - if ($assocType < 3) { - if (!empty($possibleKeys[$otherTable])) { - $showKeys = $possibleKeys[$otherTable]; - } else { - $showKeys = null; - } - } else { - $showKeys = null; - } - } else { - $otherTable = $this->in(__d('cake_console', 'What is the table for this model?')); - $showKeys = $possibleKeys[$otherTable]; - } - $suggestedForeignKey = $this->_modelKey($model->name); - } - if (!empty($showKeys)) { - $this->out(__d('cake_console', 'A helpful List of possible keys')); - $foreignKey = $this->inOptions($showKeys, __d('cake_console', 'What is the foreignKey?')); - $foreignKey = $showKeys[intval($foreignKey)]; - } - if (!isset($foreignKey)) { - $foreignKey = $this->in(__d('cake_console', 'What is the foreignKey? Specify your own.'), null, $suggestedForeignKey); - } - if ($assocType == 3) { - $associationForeignKey = $this->in(__d('cake_console', 'What is the associationForeignKey?'), null, $this->_modelKey($model->name)); - $joinTable = $this->in(__d('cake_console', 'What is the joinTable?')); - } - $associations[$assocs[$assocType]] = array_values((array)$associations[$assocs[$assocType]]); - $count = count($associations[$assocs[$assocType]]); - $i = ($count > 0) ? $count : 0; - $associations[$assocs[$assocType]][$i]['alias'] = $alias; - $associations[$assocs[$assocType]][$i]['className'] = $className; - $associations[$assocs[$assocType]][$i]['foreignKey'] = $foreignKey; - if ($assocType == 3) { - $associations[$assocs[$assocType]][$i]['associationForeignKey'] = $associationForeignKey; - $associations[$assocs[$assocType]][$i]['joinTable'] = $joinTable; - } - $wannaDoMoreAssoc = $this->in(__d('cake_console', 'Define another association?'), array('y', 'n'), 'y'); - } - return $associations; - } - -/** - * Finds all possible keys to use on custom associations. - * - * @return array array of tables and possible keys - */ - protected function _generatePossibleKeys() { - $possible = array(); - foreach ($this->_tables as $otherTable) { - $tempOtherModel = new Model(array('table' => $otherTable, 'ds' => $this->connection)); - $modelFieldsTemp = $tempOtherModel->schema(true); - foreach ($modelFieldsTemp as $fieldName => $field) { - if ($field['type'] == 'integer' || $field['type'] == 'string') { - $possible[$otherTable][] = $fieldName; - } - } - } - return $possible; - } - -/** - * Assembles and writes a Model file. - * - * @param mixed $name Model name or object - * @param mixed $data if array and $name is not an object assume bake data, otherwise boolean. - * @return string - */ - public function bake($name, $data = array()) { - if (is_object($name)) { - if ($data == false) { - $data = array(); - $data['associations'] = $this->doAssociations($name); - $data['validate'] = $this->doValidation($name); - } - $data['primaryKey'] = $name->primaryKey; - $data['useTable'] = $name->table; - $data['useDbConfig'] = $name->useDbConfig; - $data['name'] = $name = $name->name; - } else { - $data['name'] = $name; - } - $defaults = array( - 'associations' => array(), - 'validate' => array(), - 'primaryKey' => 'id', - 'useTable' => null, - 'useDbConfig' => 'default', - 'displayField' => null - ); - $data = array_merge($defaults, $data); - - $pluginPath = ''; - if ($this->plugin) { - $pluginPath = $this->plugin . '.'; - } - - $this->Template->set($data); - $this->Template->set(array( - 'plugin' => $this->plugin, - 'pluginPath' => $pluginPath - )); - $out = $this->Template->generate('classes', 'model'); - - $path = $this->getPath(); - $filename = $path . $name . '.php'; - $this->out("\n" . __d('cake_console', 'Baking model class for %s...', $name), 1, Shell::QUIET); - $this->createFile($filename, $out); - ClassRegistry::flush(); - return $out; - } - -/** - * Assembles and writes a unit test file - * - * @param string $className Model class name - * @return string - */ - public function bakeTest($className) { - $this->Test->interactive = $this->interactive; - $this->Test->plugin = $this->plugin; - $this->Test->connection = $this->connection; - return $this->Test->bake('Model', $className); - } - -/** - * outputs the a list of possible models or controllers from database - * - * @param string $useDbConfig Database configuration name - * @return array - */ - public function listAll($useDbConfig = null) { - $this->_tables = (array)$this->getAllTables($useDbConfig); - - $this->_modelNames = array(); - $count = count($this->_tables); - for ($i = 0; $i < $count; $i++) { - $this->_modelNames[] = $this->_modelName($this->_tables[$i]); - } - if ($this->interactive === true) { - $this->out(__d('cake_console', 'Possible Models based on your current database:')); - for ($i = 0; $i < $count; $i++) { - $this->out($i + 1 . ". " . $this->_modelNames[$i]); - } - } - return $this->_tables; - } - -/** - * Interact with the user to determine the table name of a particular model - * - * @param string $modelName Name of the model you want a table for. - * @param string $useDbConfig Name of the database config you want to get tables from. - * @return string Table name - */ - public function getTable($modelName, $useDbConfig = null) { - $useTable = Inflector::tableize($modelName); - if (in_array($modelName, $this->_modelNames)) { - $modelNames = array_flip($this->_modelNames); - $useTable = $this->_tables[$modelNames[$modelName]]; - } - - if ($this->interactive === true) { - if (!isset($useDbConfig)) { - $useDbConfig = $this->connection; - } - $db = ConnectionManager::getDataSource($useDbConfig); - $fullTableName = $db->fullTableName($useTable, false); - $tableIsGood = false; - if (array_search($useTable, $this->_tables) === false) { - $this->out(); - $this->out(__d('cake_console', "Given your model named '%s',\nCake would expect a database table named '%s'", $modelName, $fullTableName)); - $tableIsGood = $this->in(__d('cake_console', 'Do you want to use this table?'), array('y', 'n'), 'y'); - } - if (strtolower($tableIsGood) == 'n') { - $useTable = $this->in(__d('cake_console', 'What is the name of the table?')); - } - } - return $useTable; - } - -/** - * Get an Array of all the tables in the supplied connection - * will halt the script if no tables are found. - * - * @param string $useDbConfig Connection name to scan. - * @return array Array of tables in the database. - */ - public function getAllTables($useDbConfig = null) { - if (!isset($useDbConfig)) { - $useDbConfig = $this->connection; - } - - $tables = array(); - $db = ConnectionManager::getDataSource($useDbConfig); - $db->cacheSources = false; - $usePrefix = empty($db->config['prefix']) ? '' : $db->config['prefix']; - if ($usePrefix) { - foreach ($db->listSources() as $table) { - if (!strncmp($table, $usePrefix, strlen($usePrefix))) { - $tables[] = substr($table, strlen($usePrefix)); - } - } - } else { - $tables = $db->listSources(); - } - if (empty($tables)) { - $this->err(__d('cake_console', 'Your database does not have any tables.')); - $this->_stop(); - } - return $tables; - } - -/** - * Forces the user to specify the model he wants to bake, and returns the selected model name. - * - * @param string $useDbConfig Database config name - * @return string the model name - */ - public function getName($useDbConfig = null) { - $this->listAll($useDbConfig); - - $enteredModel = ''; - - while ($enteredModel == '') { - $enteredModel = $this->in(__d('cake_console', "Enter a number from the list above,\n" . - "type in the name of another model, or 'q' to exit"), null, 'q'); - - if ($enteredModel === 'q') { - $this->out(__d('cake_console', 'Exit')); - $this->_stop(); - } - - if ($enteredModel == '' || intval($enteredModel) > count($this->_modelNames)) { - $this->err(__d('cake_console', "The model name you supplied was empty,\n" . - "or the number you selected was not an option. Please try again.")); - $enteredModel = ''; - } - } - if (intval($enteredModel) > 0 && intval($enteredModel) <= count($this->_modelNames)) { - $currentModelName = $this->_modelNames[intval($enteredModel) - 1]; - } else { - $currentModelName = $enteredModel; - } - return $currentModelName; - } - -/** - * get the option parser. - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description( - __d('cake_console', 'Bake models.') - )->addArgument('name', array( - 'help' => __d('cake_console', 'Name of the model to bake. Can use Plugin.name to bake plugin models.') - ))->addSubcommand('all', array( - 'help' => __d('cake_console', 'Bake all model files with associations and validation.') - ))->addOption('plugin', array( - 'short' => 'p', - 'help' => __d('cake_console', 'Plugin to bake the model into.') - ))->addOption('connection', array( - 'short' => 'c', - 'help' => __d('cake_console', 'The connection the model table is on.') - ))->epilog(__d('cake_console', 'Omitting all arguments and options will enter into an interactive mode.')); - } - -/** - * Interact with FixtureTask to automatically bake fixtures when baking models. - * - * @param string $className Name of class to bake fixture for - * @param string $useTable Optional table name for fixture to use. - * @return void - * @see FixtureTask::bake - */ - public function bakeFixture($className, $useTable = null) { - $this->Fixture->interactive = $this->interactive; - $this->Fixture->connection = $this->connection; - $this->Fixture->plugin = $this->plugin; - $this->Fixture->bake($className, $useTable); - } - -} diff --git a/lib/Cake/Console/Command/Task/PluginTask.php b/lib/Cake/Console/Command/Task/PluginTask.php deleted file mode 100644 index a10e072ce0d..00000000000 --- a/lib/Cake/Console/Command/Task/PluginTask.php +++ /dev/null @@ -1,196 +0,0 @@ -path = current(App::path('plugins')); - } - -/** - * Execution method always used for tasks - * - * @return void - */ - public function execute() { - if (isset($this->args[0])) { - $plugin = Inflector::camelize($this->args[0]); - $pluginPath = $this->_pluginPath($plugin); - if (is_dir($pluginPath)) { - $this->out(__d('cake_console', 'Plugin: %s', $plugin)); - $this->out(__d('cake_console', 'Path: %s', $pluginPath)); - } else { - $this->_interactive($plugin); - } - } else { - return $this->_interactive(); - } - } - -/** - * Interactive interface - * - * @param string $plugin - * @return void - */ - protected function _interactive($plugin = null) { - while ($plugin === null) { - $plugin = $this->in(__d('cake_console', 'Enter the name of the plugin in CamelCase format')); - } - - if (!$this->bake($plugin)) { - $this->error(__d('cake_console', "An error occurred trying to bake: %s in %s", $plugin, $this->path . $plugin)); - } - } - -/** - * Bake the plugin, create directories and files - * - * @param string $plugin Name of the plugin in CamelCased format - * @return boolean - */ - public function bake($plugin) { - $pathOptions = App::path('plugins'); - if (count($pathOptions) > 1) { - $this->findPath($pathOptions); - } - $this->hr(); - $this->out(__d('cake_console', "Plugin Name: %s", $plugin)); - $this->out(__d('cake_console', "Plugin Directory: %s", $this->path . $plugin)); - $this->hr(); - - $looksGood = $this->in(__d('cake_console', 'Look okay?'), array('y', 'n', 'q'), 'y'); - - if (strtolower($looksGood) == 'y') { - $Folder = new Folder($this->path . $plugin); - $directories = array( - 'Config' . DS . 'Schema', - 'Model' . DS . 'Behavior', - 'Model' . DS . 'Datasource', - 'Console' . DS . 'Command' . DS . 'Task', - 'Controller' . DS . 'Component', - 'Lib', - 'View' . DS . 'Helper', - 'Test' . DS . 'Case' . DS . 'Controller' . DS . 'Component', - 'Test' . DS . 'Case' . DS . 'View' . DS . 'Helper', - 'Test' . DS . 'Case' . DS . 'Model' . DS . 'Behavior', - 'Test' . DS . 'Fixture', - 'Vendor', - 'webroot' - ); - - foreach ($directories as $directory) { - $dirPath = $this->path . $plugin . DS . $directory; - $Folder->create($dirPath); - $File = new File($dirPath . DS . 'empty', true); - } - - foreach ($Folder->messages() as $message) { - $this->out($message, 1, Shell::VERBOSE); - } - - $errors = $Folder->errors(); - if (!empty($errors)) { - return false; - } - - $controllerFileName = $plugin . 'AppController.php'; - - $out = "createFile($this->path . $plugin . DS . 'Controller' . DS . $controllerFileName, $out); - - $modelFileName = $plugin . 'AppModel.php'; - - $out = "createFile($this->path . $plugin . DS . 'Model' . DS . $modelFileName, $out); - - $this->hr(); - $this->out(__d('cake_console', 'Created: %s in %s', $plugin, $this->path . $plugin), 2); - } - - return true; - } - -/** - * find and change $this->path to the user selection - * - * @param array $pathOptions - * @return string plugin path - */ - public function findPath($pathOptions) { - $valid = false; - foreach ($pathOptions as $i => $path) { - if (!is_dir($path)) { - array_splice($pathOptions, $i, 1); - } - } - $max = count($pathOptions); - while (!$valid) { - foreach ($pathOptions as $i => $option) { - $this->out($i + 1 . '. ' . $option); - } - $prompt = __d('cake_console', 'Choose a plugin path from the paths above.'); - $choice = $this->in($prompt); - if (intval($choice) > 0 && intval($choice) <= $max) { - $valid = true; - } - } - $this->path = $pathOptions[$choice - 1]; - } - -/** - * get the option parser for the plugin task - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description(__d('cake_console', - 'Create the directory structure, AppModel and AppController classes for a new plugin. ' . - 'Can create plugins in any of your bootstrapped plugin paths.' - ))->addArgument('name', array( - 'help' => __d('cake_console', 'CamelCased name of the plugin to create.') - )); - } - -} diff --git a/lib/Cake/Console/Command/Task/ProjectTask.php b/lib/Cake/Console/Command/Task/ProjectTask.php deleted file mode 100644 index 92135906a1d..00000000000 --- a/lib/Cake/Console/Command/Task/ProjectTask.php +++ /dev/null @@ -1,434 +0,0 @@ -args[0])) { - $project = $this->args[0]; - } - - while (!$project) { - $prompt = __d('cake_console', "What is the path to the project you want to bake?"); - $project = $this->in($prompt, null, APP . 'myapp'); - } - - if ($project && !Folder::isAbsolute($project) && isset($_SERVER['PWD'])) { - $project = $_SERVER['PWD'] . DS . $project; - } - - $response = false; - while ($response == false && is_dir($project) === true && file_exists($project . 'Config' . 'core.php')) { - $prompt = __d('cake_console', 'A project already exists in this location: %s Overwrite?', $project); - $response = $this->in($prompt, array('y', 'n'), 'n'); - if (strtolower($response) === 'n') { - $response = $project = false; - } - } - - $success = true; - if ($this->bake($project)) { - $path = Folder::slashTerm($project); - if ($this->createHome($path)) { - $this->out(__d('cake_console', ' * Welcome page created')); - } else { - $this->err(__d('cake_console', 'The Welcome page was NOT created')); - $success = false; - } - - if ($this->securitySalt($path) === true) { - $this->out(__d('cake_console', ' * Random hash key created for \'Security.salt\'')); - } else { - $this->err(__d('cake_console', 'Unable to generate random hash for \'Security.salt\', you should change it in %s', APP . 'Config' . DS . 'core.php')); - $success = false; - } - - if ($this->securityCipherSeed($path) === true) { - $this->out(__d('cake_console', ' * Random seed created for \'Security.cipherSeed\'')); - } else { - $this->err(__d('cake_console', 'Unable to generate random seed for \'Security.cipherSeed\', you should change it in %s', APP . 'Config' . DS . 'core.php')); - $success = false; - } - - if ($this->consolePath($path) === true) { - $this->out(__d('cake_console', ' * app/Console/cake.php path set.')); - } else { - $this->err(__d('cake_console', 'Unable to set console path for app/Console.')); - $success = false; - } - - $hardCode = false; - if ($this->cakeOnIncludePath()) { - $this->out(__d('cake_console', 'CakePHP is on your `include_path`. CAKE_CORE_INCLUDE_PATH will be set, but commented out.')); - } else { - $this->out(__d('cake_console', 'CakePHP is not on your `include_path`, CAKE_CORE_INCLUDE_PATH will be hard coded.')); - $this->out(__d('cake_console', 'You can fix this by adding CakePHP to your `include_path`.')); - $hardCode = true; - } - $success = $this->corePath($path, $hardCode) === true; - if ($success) { - $this->out(__d('cake_console', ' * CAKE_CORE_INCLUDE_PATH set to %s in webroot/index.php', CAKE_CORE_INCLUDE_PATH)); - $this->out(__d('cake_console', ' * CAKE_CORE_INCLUDE_PATH set to %s in webroot/test.php', CAKE_CORE_INCLUDE_PATH)); - } else { - $this->err(__d('cake_console', 'Unable to set CAKE_CORE_INCLUDE_PATH, you should change it in %s', $path . 'webroot' . DS . 'index.php')); - $success = false; - } - if ($success && $hardCode) { - $this->out(__d('cake_console', ' * Remember to check these values after moving to production server')); - } - - $Folder = new Folder($path); - if (!$Folder->chmod($path . 'tmp', 0777)) { - $this->err(__d('cake_console', 'Could not set permissions on %s', $path . DS . 'tmp')); - $this->out(__d('cake_console', 'chmod -R 0777 %s', $path . DS . 'tmp')); - $success = false; - } - if ($success) { - $this->out(__d('cake_console', 'Project baked successfully!')); - } else { - $this->out(__d('cake_console', 'Project baked but with some issues..')); - } - return $path; - } - } - -/** - * Checks PHP's include_path for CakePHP. - * - * @return boolean Indicates whether or not CakePHP exists on include_path - */ - public function cakeOnIncludePath() { - $paths = explode(PATH_SEPARATOR, ini_get('include_path')); - foreach ($paths as $path) { - if (file_exists($path . DS . 'Cake' . DS . 'bootstrap.php')) { - return true; - } - } - return false; - } - -/** - * Looks for a skeleton template of a Cake application, - * and if not found asks the user for a path. When there is a path - * this method will make a deep copy of the skeleton to the project directory. - * - * @param string $path Project path - * @param string $skel Path to copy from - * @param string $skip array of directories to skip when copying - * @return mixed - */ - public function bake($path, $skel = null, $skip = array('empty')) { - if (!$skel && !empty($this->params['skel'])) { - $skel = $this->params['skel']; - } - while (!$skel) { - $skel = $this->in( - __d('cake_console', "What is the path to the directory layout you wish to copy?"), - null, - CAKE . 'Console' . DS . 'Templates' . DS . 'skel' - ); - if (!$skel) { - $this->err(__d('cake_console', 'The directory path you supplied was empty. Please try again.')); - } else { - while (is_dir($skel) === false) { - $skel = $this->in( - __d('cake_console', 'Directory path does not exist please choose another:'), - null, - CAKE . 'Console' . DS . 'Templates' . DS . 'skel' - ); - } - } - } - - $app = basename($path); - - $this->out(__d('cake_console', 'Skel Directory: ') . $skel); - $this->out(__d('cake_console', 'Will be copied to: ') . $path); - $this->hr(); - - $looksGood = $this->in(__d('cake_console', 'Look okay?'), array('y', 'n', 'q'), 'y'); - - switch (strtolower($looksGood)) { - case 'y': - $Folder = new Folder($skel); - if (!empty($this->params['empty'])) { - $skip = array(); - } - - if ($Folder->copy(array('to' => $path, 'skip' => $skip))) { - $this->hr(); - $this->out(__d('cake_console', 'Created: %s in %s', $app, $path)); - $this->hr(); - } else { - $this->err(__d('cake_console', "Could not create '%s' properly.", $app)); - return false; - } - - foreach ($Folder->messages() as $message) { - $this->out(String::wrap(' * ' . $message), 1, Shell::VERBOSE); - } - - return true; - case 'n': - unset($this->args[0]); - $this->execute(); - return false; - case 'q': - $this->out(__d('cake_console', 'Bake Aborted.')); - return false; - } - } - -/** - * Writes a file with a default home page to the project. - * - * @param string $dir Path to project - * @return boolean Success - */ - public function createHome($dir) { - $app = basename($dir); - $path = $dir . 'View' . DS . 'Pages' . DS; - $source = CAKE . 'Console' . DS . 'Templates' . DS . 'default' . DS . 'views' . DS . 'home.ctp'; - include $source; - return $this->createFile($path . 'home.ctp', $output); - } - -/** - * Generates the correct path to the CakePHP libs that are generating the project - * and points app/console/cake.php to the right place - * - * @param string $path Project path. - * @return boolean success - */ - public function consolePath($path) { - $File = new File($path . 'Console' . DS . 'cake.php'); - $contents = $File->read(); - if (preg_match('/(__CAKE_PATH__)/', $contents, $match)) { - $root = strpos(CAKE_CORE_INCLUDE_PATH, '/') === 0 ? " \$ds . '" : "'"; - $replacement = $root . str_replace(DS, "' . \$ds . '", trim(CAKE_CORE_INCLUDE_PATH, DS)) . "'"; - $result = str_replace($match[0], $replacement, $contents); - if ($File->write($result)) { - return true; - } - return false; - } - return false; - } - -/** - * Generates and writes 'Security.salt' - * - * @param string $path Project path - * @return boolean Success - */ - public function securitySalt($path) { - $File = new File($path . 'Config' . DS . 'core.php'); - $contents = $File->read(); - if (preg_match('/([\s]*Configure::write\(\'Security.salt\',[\s\'A-z0-9]*\);)/', $contents, $match)) { - $string = Security::generateAuthKey(); - $result = str_replace($match[0], "\t" . 'Configure::write(\'Security.salt\', \'' . $string . '\');', $contents); - if ($File->write($result)) { - return true; - } - return false; - } - return false; - } - -/** - * Generates and writes 'Security.cipherSeed' - * - * @param string $path Project path - * @return boolean Success - */ - public function securityCipherSeed($path) { - $File = new File($path . 'Config' . DS . 'core.php'); - $contents = $File->read(); - if (preg_match('/([\s]*Configure::write\(\'Security.cipherSeed\',[\s\'A-z0-9]*\);)/', $contents, $match)) { - App::uses('Security', 'Utility'); - $string = substr(bin2hex(Security::generateAuthKey()), 0, 30); - $result = str_replace($match[0], "\t" . 'Configure::write(\'Security.cipherSeed\', \'' . $string . '\');', $contents); - if ($File->write($result)) { - return true; - } - return false; - } - return false; - } - -/** - * Generates and writes CAKE_CORE_INCLUDE_PATH - * - * @param string $path Project path - * @param boolean $hardCode Wether or not define calls should be hardcoded. - * @return boolean Success - */ - public function corePath($path, $hardCode = true) { - if (dirname($path) !== CAKE_CORE_INCLUDE_PATH) { - $filename = $path . 'webroot' . DS . 'index.php'; - if (!$this->_replaceCorePath($filename, $hardCode)) { - return false; - } - $filename = $path . 'webroot' . DS . 'test.php'; - if (!$this->_replaceCorePath($filename, $hardCode)) { - return false; - } - return true; - } - } - -/** - * Replaces the __CAKE_PATH__ placeholder in the template files. - * - * @param string $filename The filename to operate on. - * @param boolean $hardCode Whether or not the define should be uncommented. - * @return boolean Success - */ - protected function _replaceCorePath($filename, $hardCode) { - $contents = file_get_contents($filename); - - $root = strpos(CAKE_CORE_INCLUDE_PATH, '/') === 0 ? " DS . '" : "'"; - $corePath = $root . str_replace(DS, "' . DS . '", trim(CAKE_CORE_INCLUDE_PATH, DS)) . "'"; - - $result = str_replace('__CAKE_PATH__', $corePath, $contents, $count); - if ($hardCode) { - $result = str_replace('//define(\'CAKE_CORE', 'define(\'CAKE_CORE', $result); - } - if (!file_put_contents($filename, $result)) { - return false; - } - if ($count == 0) { - return false; - } - return true; - } - -/** - * Enables Configure::read('Routing.prefixes') in /app/Config/core.php - * - * @param string $name Name to use as admin routing - * @return boolean Success - */ - public function cakeAdmin($name) { - $path = (empty($this->configPath)) ? APP . 'Config' . DS : $this->configPath; - $File = new File($path . 'core.php'); - $contents = $File->read(); - if (preg_match('%(\s*[/]*Configure::write\(\'Routing.prefixes\',[\s\'a-z,\)\(]*\);)%', $contents, $match)) { - $result = str_replace($match[0], "\n" . 'Configure::write(\'Routing.prefixes\', array(\'' . $name . '\'));', $contents); - if ($File->write($result)) { - Configure::write('Routing.prefixes', array($name)); - return true; - } else { - return false; - } - } else { - return false; - } - } - -/** - * Checks for Configure::read('Routing.prefixes') and forces user to input it if not enabled - * - * @return string Admin route to use - */ - public function getPrefix() { - $admin = ''; - $prefixes = Configure::read('Routing.prefixes'); - if (!empty($prefixes)) { - if (count($prefixes) == 1) { - return $prefixes[0] . '_'; - } - if ($this->interactive) { - $this->out(); - $this->out(__d('cake_console', 'You have more than one routing prefix configured')); - } - $options = array(); - foreach ($prefixes as $i => $prefix) { - $options[] = $i + 1; - if ($this->interactive) { - $this->out($i + 1 . '. ' . $prefix); - } - } - $selection = $this->in(__d('cake_console', 'Please choose a prefix to bake with.'), $options, 1); - return $prefixes[$selection - 1] . '_'; - } - if ($this->interactive) { - $this->hr(); - $this->out(__d('cake_console', 'You need to enable Configure::write(\'Routing.prefixes\',array(\'admin\')) in /app/Config/core.php to use prefix routing.')); - $this->out(__d('cake_console', 'What would you like the prefix route to be?')); - $this->out(__d('cake_console', 'Example: www.example.com/admin/controller')); - while ($admin == '') { - $admin = $this->in(__d('cake_console', 'Enter a routing prefix:'), null, 'admin'); - } - if ($this->cakeAdmin($admin) !== true) { - $this->out(__d('cake_console', 'Unable to write to /app/Config/core.php.')); - $this->out(__d('cake_console', 'You need to enable Configure::write(\'Routing.prefixes\',array(\'admin\')) in /app/Config/core.php to use prefix routing.')); - $this->_stop(); - } - return $admin . '_'; - } - return ''; - } - -/** - * get the option parser. - * - * @return ConsoleOptionParser - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description( - __d('cake_console', 'Generate a new CakePHP project skeleton.') - )->addArgument('name', array( - 'help' => __d('cake_console', 'Application directory to make, if it starts with "/" the path is absolute.') - ))->addOption('empty', array( - 'boolean' => true, - 'help' => __d('cake_console', 'Create empty files in each of the directories. Good if you are using git') - ))->addOption('skel', array( - 'default' => current(App::core('Console')) . 'Templates' . DS . 'skel', - 'help' => __d('cake_console', 'The directory layout to use for the new application skeleton. Defaults to cake/Console/Templates/skel of CakePHP used to create the project.') - )); - } - -} diff --git a/lib/Cake/Console/Command/Task/TemplateTask.php b/lib/Cake/Console/Command/Task/TemplateTask.php deleted file mode 100644 index d9a0e3f4378..00000000000 --- a/lib/Cake/Console/Command/Task/TemplateTask.php +++ /dev/null @@ -1,218 +0,0 @@ - $path - * - * @var array - */ - public $templatePaths = array(); - -/** - * Initialize callback. Setup paths for the template task. - * - * @return void - */ - public function initialize() { - $this->templatePaths = $this->_findThemes(); - } - -/** - * Find the paths to all the installed shell themes in the app. - * - * Bake themes are directories not named `skel` inside a `Console/Templates` path. - * - * @return array Array of bake themes that are installed. - */ - protected function _findThemes() { - $paths = array(); - $core = current(App::core('Console')); - $separator = DS === '/' ? '/' : '\\\\'; - $core = preg_replace('#shells' . $separator . '$#', '', $core); - - $Folder = new Folder($core . 'Templates' . DS . 'default'); - - $contents = $Folder->read(); - $themeFolders = $contents[0]; - - $plugins = App::objects('plugin'); - $paths[] = $core; - foreach ($plugins as $plugin) { - $paths[] = $this->_pluginPath($plugin) . 'Console' . DS; - } - - $paths = array_merge($paths, App::path('Console')); - - // TEMPORARY TODO remove when all paths are DS terminated - foreach ($paths as $i => $path) { - $paths[$i] = rtrim($path, DS) . DS; - } - - $themes = array(); - foreach ($paths as $path) { - $Folder = new Folder($path . 'Templates', false); - $contents = $Folder->read(); - $subDirs = $contents[0]; - foreach ($subDirs as $dir) { - if (empty($dir) || preg_match('@^skel$|_skel$@', $dir)) { - continue; - } - $Folder = new Folder($path . 'Templates' . DS . $dir); - $contents = $Folder->read(); - $subDirs = $contents[0]; - if (array_intersect($contents[0], $themeFolders)) { - $templateDir = $path . 'Templates' . DS . $dir . DS; - $themes[$dir] = $templateDir; - } - } - } - return $themes; - } - -/** - * Set variable values to the template scope - * - * @param string|array $one A string or an array of data. - * @param mixed $two Value in case $one is a string (which then works as the key). - * Unused if $one is an associative array, otherwise serves as the values to $one's keys. - * @return void - */ - public function set($one, $two = null) { - if (is_array($one)) { - if (is_array($two)) { - $data = array_combine($one, $two); - } else { - $data = $one; - } - } else { - $data = array($one => $two); - } - - if ($data == null) { - return false; - } - $this->templateVars = $data + $this->templateVars; - } - -/** - * Runs the template - * - * @param string $directory directory / type of thing you want - * @param string $filename template name - * @param array $vars Additional vars to set to template scope. - * @return string contents of generated code template - */ - public function generate($directory, $filename, $vars = null) { - if ($vars !== null) { - $this->set($vars); - } - if (empty($this->templatePaths)) { - $this->initialize(); - } - $themePath = $this->getThemePath(); - $templateFile = $this->_findTemplate($themePath, $directory, $filename); - if ($templateFile) { - extract($this->templateVars); - ob_start(); - ob_implicit_flush(0); - include $templateFile; - $content = ob_get_clean(); - return $content; - } - return ''; - } - -/** - * Find the theme name for the current operation. - * If there is only one theme in $templatePaths it will be used. - * If there is a -theme param in the cli args, it will be used. - * If there is more than one installed theme user interaction will happen - * - * @return string returns the path to the selected theme. - */ - public function getThemePath() { - if (count($this->templatePaths) == 1) { - $paths = array_values($this->templatePaths); - return $paths[0]; - } - if (!empty($this->params['theme']) && isset($this->templatePaths[$this->params['theme']])) { - return $this->templatePaths[$this->params['theme']]; - } - - $this->hr(); - $this->out(__d('cake_console', 'You have more than one set of templates installed.')); - $this->out(__d('cake_console', 'Please choose the template set you wish to use:')); - $this->hr(); - - $i = 1; - $indexedPaths = array(); - foreach ($this->templatePaths as $key => $path) { - $this->out($i . '. ' . $key); - $indexedPaths[$i] = $path; - $i++; - } - $index = $this->in(__d('cake_console', 'Which bake theme would you like to use?'), range(1, $i - 1), 1); - $themeNames = array_keys($this->templatePaths); - $this->params['theme'] = $themeNames[$index - 1]; - return $indexedPaths[$index]; - } - -/** - * Find a template inside a directory inside a path. - * Will scan all other theme dirs if the template is not found in the first directory. - * - * @param string $path The initial path to look for the file on. If it is not found fallbacks will be used. - * @param string $directory Subdirectory to look for ie. 'views', 'objects' - * @param string $filename lower_case_underscored filename you want. - * @return string filename will exit program if template is not found. - */ - protected function _findTemplate($path, $directory, $filename) { - $themeFile = $path . $directory . DS . $filename . '.ctp'; - if (file_exists($themeFile)) { - return $themeFile; - } - foreach ($this->templatePaths as $path) { - $templatePath = $path . $directory . DS . $filename . '.ctp'; - if (file_exists($templatePath)) { - return $templatePath; - } - } - $this->err(__d('cake_console', 'Could not find template for %s', $filename)); - return false; - } - -} diff --git a/lib/Cake/Console/Command/Task/TestTask.php b/lib/Cake/Console/Command/Task/TestTask.php deleted file mode 100644 index e90239e0107..00000000000 --- a/lib/Cake/Console/Command/Task/TestTask.php +++ /dev/null @@ -1,535 +0,0 @@ - 'Model', - 'Controller' => 'Controller', - 'Component' => 'Controller/Component', - 'Behavior' => 'Model/Behavior', - 'Helper' => 'View/Helper' - ); - -/** - * Internal list of fixtures that have been added so far. - * - * @var array - */ - protected $_fixtures = array(); - -/** - * Execution method always used for tasks - * - * @return void - */ - public function execute() { - parent::execute(); - if (empty($this->args)) { - $this->_interactive(); - } - - if (count($this->args) == 1) { - $this->_interactive($this->args[0]); - } - - if (count($this->args) > 1) { - $type = Inflector::underscore($this->args[0]); - if ($this->bake($type, $this->args[1])) { - $this->out('Done'); - } - } - } - -/** - * Handles interactive baking - * - * @param string $type - * @return string|boolean - */ - protected function _interactive($type = null) { - $this->interactive = true; - $this->hr(); - $this->out(__d('cake_console', 'Bake Tests')); - $this->out(__d('cake_console', 'Path: %s', $this->getPath())); - $this->hr(); - - if ($type) { - $type = Inflector::camelize($type); - if (!isset($this->classTypes[$type])) { - $this->error(__d('cake_console', 'Incorrect type provided. Please choose one of %s', implode(', ', array_keys($this->classTypes)))); - } - } else { - $type = $this->getObjectType(); - } - $className = $this->getClassName($type); - return $this->bake($type, $className); - } - -/** - * Completes final steps for generating data to create test case. - * - * @param string $type Type of object to bake test case for ie. Model, Controller - * @param string $className the 'cake name' for the class ie. Posts for the PostsController - * @return string|boolean - */ - public function bake($type, $className) { - $plugin = null; - if ($this->plugin) { - $plugin = $this->plugin . '.'; - } - - $realType = $this->mapType($type, $plugin); - $fullClassName = $this->getRealClassName($type, $className); - - if ($this->typeCanDetectFixtures($type) && $this->isLoadableClass($realType, $fullClassName)) { - $this->out(__d('cake_console', 'Bake is detecting possible fixtures...')); - $testSubject = $this->buildTestSubject($type, $className); - $this->generateFixtureList($testSubject); - } elseif ($this->interactive) { - $this->getUserFixtures(); - } - App::uses($fullClassName, $realType); - - $methods = array(); - if (class_exists($fullClassName)) { - $methods = $this->getTestableMethods($fullClassName); - } - $mock = $this->hasMockClass($type, $fullClassName); - list($preConstruct, $construction, $postConstruct) = $this->generateConstructor($type, $fullClassName); - $uses = $this->generateUses($type, $realType, $fullClassName); - - $this->out("\n" . __d('cake_console', 'Baking test case for %s %s ...', $className, $type), 1, Shell::QUIET); - - $this->Template->set('fixtures', $this->_fixtures); - $this->Template->set('plugin', $plugin); - $this->Template->set(compact( - 'className', 'methods', 'type', 'fullClassName', 'mock', - 'realType', 'preConstruct', 'postConstruct', 'construction', - 'uses' - )); - $out = $this->Template->generate('classes', 'test'); - - $filename = $this->testCaseFileName($type, $className); - $made = $this->createFile($filename, $out); - if ($made) { - return $out; - } - return false; - } - -/** - * Interact with the user and get their chosen type. Can exit the script. - * - * @return string Users chosen type. - */ - public function getObjectType() { - $this->hr(); - $this->out(__d('cake_console', 'Select an object type:')); - $this->hr(); - - $keys = array(); - $i = 0; - foreach ($this->classTypes as $option => $package) { - $this->out(++$i . '. ' . $option); - $keys[] = $i; - } - $keys[] = 'q'; - $selection = $this->in(__d('cake_console', 'Enter the type of object to bake a test for or (q)uit'), $keys, 'q'); - if ($selection == 'q') { - return $this->_stop(); - } - $types = array_keys($this->classTypes); - return $types[$selection - 1]; - } - -/** - * Get the user chosen Class name for the chosen type - * - * @param string $objectType Type of object to list classes for i.e. Model, Controller. - * @return string Class name the user chose. - */ - public function getClassName($objectType) { - $type = ucfirst(strtolower($objectType)); - $typeLength = strlen($type); - $type = $this->classTypes[$type]; - if ($this->plugin) { - $plugin = $this->plugin . '.'; - $options = App::objects($plugin . $type); - } else { - $options = App::objects($type); - } - $this->out(__d('cake_console', 'Choose a %s class', $objectType)); - $keys = array(); - foreach ($options as $key => $option) { - $this->out(++$key . '. ' . $option); - $keys[] = $key; - } - while (empty($selection)) { - $selection = $this->in(__d('cake_console', 'Choose an existing class, or enter the name of a class that does not exist')); - if (is_numeric($selection) && isset($options[$selection - 1])) { - $selection = $options[$selection - 1]; - } - if ($type !== 'Model') { - $selection = substr($selection, 0, $typeLength * - 1); - } - } - return $selection; - } - -/** - * Checks whether the chosen type can find its own fixtures. - * Currently only model, and controller are supported - * - * @param string $type The Type of object you are generating tests for eg. controller - * @return boolean - */ - public function typeCanDetectFixtures($type) { - $type = strtolower($type); - return in_array($type, array('controller', 'model')); - } - -/** - * Check if a class with the given package is loaded or can be loaded. - * - * @param string $package The package of object you are generating tests for eg. controller - * @param string $class the Classname of the class the test is being generated for. - * @return boolean - */ - public function isLoadableClass($package, $class) { - App::uses($class, $package); - return class_exists($class); - } - -/** - * Construct an instance of the class to be tested. - * So that fixtures can be detected - * - * @param string $type The Type of object you are generating tests for eg. controller - * @param string $class the Classname of the class the test is being generated for. - * @return object And instance of the class that is going to be tested. - */ - public function &buildTestSubject($type, $class) { - ClassRegistry::flush(); - App::import($type, $class); - $class = $this->getRealClassName($type, $class); - if (strtolower($type) == 'model') { - $instance = ClassRegistry::init($class); - } else { - $instance = new $class(); - } - return $instance; - } - -/** - * Gets the real class name from the cake short form. If the class name is already - * suffixed with the type, the type will not be duplicated. - * - * @param string $type The Type of object you are generating tests for eg. controller - * @param string $class the Classname of the class the test is being generated for. - * @return string Real classname - */ - public function getRealClassName($type, $class) { - if (strtolower($type) == 'model' || empty($this->classTypes[$type])) { - return $class; - } - - $position = strpos($class, $type); - - if ($position !== false && strlen($class) - $position == strlen($type)) { - return $class; - } - return $class . $type; - } - -/** - * Map the types that TestTask uses to concrete types that App::uses can use. - * - * @param string $type The type of thing having a test generated. - * @param string $plugin The plugin name. - * @return string - * @throws CakeException When invalid object types are requested. - */ - public function mapType($type, $plugin) { - $type = ucfirst($type); - if (empty($this->classTypes[$type])) { - throw new CakeException(__d('cake_dev', 'Invalid object type.')); - } - $real = $this->classTypes[$type]; - if ($plugin) { - $real = trim($plugin, '.') . '.' . $real; - } - return $real; - } - -/** - * Get methods declared in the class given. - * No parent methods will be returned - * - * @param string $className Name of class to look at. - * @return array Array of method names. - */ - public function getTestableMethods($className) { - $classMethods = get_class_methods($className); - $parentMethods = get_class_methods(get_parent_class($className)); - $thisMethods = array_diff($classMethods, $parentMethods); - $out = array(); - foreach ($thisMethods as $method) { - if (substr($method, 0, 1) != '_' && $method != strtolower($className)) { - $out[] = $method; - } - } - return $out; - } - -/** - * Generate the list of fixtures that will be required to run this test based on - * loaded models. - * - * @param object $subject The object you want to generate fixtures for. - * @return array Array of fixtures to be included in the test. - */ - public function generateFixtureList($subject) { - $this->_fixtures = array(); - if (is_a($subject, 'Model')) { - $this->_processModel($subject); - } elseif (is_a($subject, 'Controller')) { - $this->_processController($subject); - } - return array_values($this->_fixtures); - } - -/** - * Process a model recursively and pull out all the - * model names converting them to fixture names. - * - * @param Model $subject A Model class to scan for associations and pull fixtures off of. - * @return void - */ - protected function _processModel($subject) { - $this->_addFixture($subject->name); - $associated = $subject->getAssociated(); - foreach ($associated as $alias => $type) { - $className = $subject->{$alias}->name; - if (!isset($this->_fixtures[$className])) { - $this->_processModel($subject->{$alias}); - } - if ($type == 'hasAndBelongsToMany') { - if (!empty($subject->hasAndBelongsToMany[$alias]['with'])) { - list($plugin, $joinModel) = pluginSplit($subject->hasAndBelongsToMany[$alias]['with']); - } else { - $joinModel = Inflector::classify($subject->hasAndBelongsToMany[$alias]['joinTable']); - } - if (!isset($this->_fixtures[$joinModel])) { - $this->_processModel($subject->{$joinModel}); - } - } - } - } - -/** - * Process all the models attached to a controller - * and generate a fixture list. - * - * @param Controller $subject A controller to pull model names off of. - * @return void - */ - protected function _processController($subject) { - $subject->constructClasses(); - $models = array(Inflector::classify($subject->name)); - if (!empty($subject->uses)) { - $models = $subject->uses; - } - foreach ($models as $model) { - $this->_processModel($subject->{$model}); - } - } - -/** - * Add classname to the fixture list. - * Sets the app. or plugin.plugin_name. prefix. - * - * @param string $name Name of the Model class that a fixture might be required for. - * @return void - */ - protected function _addFixture($name) { - $parent = get_parent_class($name); - $prefix = 'app.'; - if (strtolower($parent) != 'appmodel' && strtolower(substr($parent, - 8)) == 'appmodel') { - $pluginName = substr($parent, 0, strlen($parent) - 8); - $prefix = 'plugin.' . Inflector::underscore($pluginName) . '.'; - } - $fixture = $prefix . Inflector::underscore($name); - $this->_fixtures[$name] = $fixture; - } - -/** - * Interact with the user to get additional fixtures they want to use. - * - * @return array Array of fixtures the user wants to add. - */ - public function getUserFixtures() { - $proceed = $this->in(__d('cake_console', 'Bake could not detect fixtures, would you like to add some?'), array('y', 'n'), 'n'); - $fixtures = array(); - if (strtolower($proceed) == 'y') { - $fixtureList = $this->in(__d('cake_console', "Please provide a comma separated list of the fixtures names you'd like to use.\nExample: 'app.comment, app.post, plugin.forums.post'")); - $fixtureListTrimmed = str_replace(' ', '', $fixtureList); - $fixtures = explode(',', $fixtureListTrimmed); - } - $this->_fixtures = array_merge($this->_fixtures, $fixtures); - return $fixtures; - } - -/** - * Is a mock class required for this type of test? - * Controllers require a mock class. - * - * @param string $type The type of object tests are being generated for eg. controller. - * @return boolean - */ - public function hasMockClass($type) { - $type = strtolower($type); - return $type == 'controller'; - } - -/** - * Generate a constructor code snippet for the type and classname - * - * @param string $type The Type of object you are generating tests for eg. controller - * @param string $fullClassName The Classname of the class the test is being generated for. - * @return array Constructor snippets for the thing you are building. - */ - public function generateConstructor($type, $fullClassName) { - $type = strtolower($type); - $pre = $post = ''; - if ($type == 'model') { - $construct = "ClassRegistry::init('$fullClassName');\n"; - } - if ($type == 'behavior') { - $construct = "new $fullClassName();\n"; - } - if ($type == 'controller') { - $className = substr($fullClassName, 0, strlen($fullClassName) - 10); - $construct = "new Test$fullClassName();\n"; - $post = "\$this->{$className}->constructClasses();\n"; - } - if ($type == 'helper') { - $pre = "\$View = new View();\n"; - $construct = "new {$fullClassName}(\$View);\n"; - } - if ($type == 'component') { - $pre = "\$Collection = new ComponentCollection();\n"; - $construct = "new {$fullClassName}(\$Collection);\n"; - } - return array($pre, $construct, $post); - } - -/** - * Generate the uses() calls for a type & classname - * - * @param string $type The Type of object you are generating tests for eg. controller - * @param string $realType The package name for the class. - * @param string $className The Classname of the class the test is being generated for. - * @return array An array containing used classes - */ - public function generateUses($type, $realType, $className) { - $uses = array(); - if ($type == 'component') { - $uses[] = array('ComponentCollection', 'Controller'); - $uses[] = array('Component', 'Controller'); - } - if ($type == 'helper') { - $uses[] = array('View', 'View'); - $uses[] = array('Helper', 'View'); - } - $uses[] = array($className, $realType); - return $uses; - } - -/** - * Make the filename for the test case. resolve the suffixes for controllers - * and get the plugin path if needed. - * - * @param string $type The Type of object you are generating tests for eg. controller - * @param string $className the Classname of the class the test is being generated for. - * @return string filename the test should be created on. - */ - public function testCaseFileName($type, $className) { - $path = $this->getPath() . 'Case' . DS; - $type = Inflector::camelize($type); - if (isset($this->classTypes[$type])) { - $path .= $this->classTypes[$type] . DS; - } - $className = $this->getRealClassName($type, $className); - return str_replace('/', DS, $path) . Inflector::camelize($className) . 'Test.php'; - } - -/** - * get the option parser. - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description(__d('cake_console', 'Bake test case skeletons for classes.')) - ->addArgument('type', array( - 'help' => __d('cake_console', 'Type of class to bake, can be any of the following: controller, model, helper, component or behavior.'), - 'choices' => array( - 'Controller', 'controller', - 'Model', 'model', - 'Helper', 'helper', - 'Component', 'component', - 'Behavior', 'behavior' - ) - ))->addArgument('name', array( - 'help' => __d('cake_console', 'An existing class to bake tests for.') - ))->addOption('plugin', array( - 'short' => 'p', - 'help' => __d('cake_console', 'CamelCased name of the plugin to bake tests for.') - ))->epilog(__d('cake_console', 'Omitting all arguments and options will enter into an interactive mode.')); - } - -} diff --git a/lib/Cake/Console/Command/Task/ViewTask.php b/lib/Cake/Console/Command/Task/ViewTask.php deleted file mode 100644 index 8baf4263e24..00000000000 --- a/lib/Cake/Console/Command/Task/ViewTask.php +++ /dev/null @@ -1,468 +0,0 @@ -path = current(App::path('View')); - } - -/** - * Execution method always used for tasks - * - * @return mixed - */ - public function execute() { - parent::execute(); - if (empty($this->args)) { - $this->_interactive(); - } - if (empty($this->args[0])) { - return; - } - if (!isset($this->connection)) { - $this->connection = 'default'; - } - $action = null; - $this->controllerName = $this->_controllerName($this->args[0]); - - $this->Project->interactive = false; - if (strtolower($this->args[0]) == 'all') { - return $this->all(); - } - - if (isset($this->args[1])) { - $this->template = $this->args[1]; - } - if (isset($this->args[2])) { - $action = $this->args[2]; - } - if (!$action) { - $action = $this->template; - } - if ($action) { - return $this->bake($action, true); - } - - $vars = $this->_loadController(); - $methods = $this->_methodsToBake(); - - foreach ($methods as $method) { - $content = $this->getContent($method, $vars); - if ($content) { - $this->bake($method, $content); - } - } - } - -/** - * Get a list of actions that can / should have views baked for them. - * - * @return array Array of action names that should be baked - */ - protected function _methodsToBake() { - $methods = array_diff( - array_map('strtolower', get_class_methods($this->controllerName . 'Controller')), - array_map('strtolower', get_class_methods('AppController')) - ); - $scaffoldActions = false; - if (empty($methods)) { - $scaffoldActions = true; - $methods = $this->scaffoldActions; - } - $adminRoute = $this->Project->getPrefix(); - foreach ($methods as $i => $method) { - if ($adminRoute && !empty($this->params['admin'])) { - if ($scaffoldActions) { - $methods[$i] = $adminRoute . $method; - continue; - } elseif (strpos($method, $adminRoute) === false) { - unset($methods[$i]); - } - } - if ($method[0] === '_' || $method == strtolower($this->controllerName . 'Controller')) { - unset($methods[$i]); - } - } - return $methods; - } - -/** - * Bake All views for All controllers. - * - * @return void - */ - public function all() { - $this->Controller->interactive = false; - $tables = $this->Controller->listAll($this->connection, false); - - $actions = null; - if (isset($this->args[1])) { - $actions = array($this->args[1]); - } - $this->interactive = false; - foreach ($tables as $table) { - $model = $this->_modelName($table); - $this->controllerName = $this->_controllerName($model); - App::uses($model, 'Model'); - if (class_exists($model)) { - $vars = $this->_loadController(); - if (!$actions) { - $actions = $this->_methodsToBake(); - } - $this->bakeActions($actions, $vars); - $actions = null; - } - } - } - -/** - * Handles interactive baking - * - * @return void - */ - protected function _interactive() { - $this->hr(); - $this->out(sprintf("Bake View\nPath: %s", $this->getPath())); - $this->hr(); - - $this->DbConfig->interactive = $this->Controller->interactive = $this->interactive = true; - - if (empty($this->connection)) { - $this->connection = $this->DbConfig->getConfig(); - } - - $this->Controller->connection = $this->connection; - $this->controllerName = $this->Controller->getName(); - - $prompt = __d('cake_console', "Would you like bake to build your views interactively?\nWarning: Choosing no will overwrite %s views if it exist.", $this->controllerName); - $interactive = $this->in($prompt, array('y', 'n'), 'n'); - - if (strtolower($interactive) == 'n') { - $this->interactive = false; - } - - $prompt = __d('cake_console', "Would you like to create some CRUD views\n(index, add, view, edit) for this controller?\nNOTE: Before doing so, you'll need to create your controller\nand model classes (including associated models)."); - $wannaDoScaffold = $this->in($prompt, array('y', 'n'), 'y'); - - $wannaDoAdmin = $this->in(__d('cake_console', "Would you like to create the views for admin routing?"), array('y', 'n'), 'n'); - - if (strtolower($wannaDoScaffold) == 'y' || strtolower($wannaDoAdmin) == 'y') { - $vars = $this->_loadController(); - if (strtolower($wannaDoScaffold) == 'y') { - $actions = $this->scaffoldActions; - $this->bakeActions($actions, $vars); - } - if (strtolower($wannaDoAdmin) == 'y') { - $admin = $this->Project->getPrefix(); - $regularActions = $this->scaffoldActions; - $adminActions = array(); - foreach ($regularActions as $action) { - $adminActions[] = $admin . $action; - } - $this->bakeActions($adminActions, $vars); - } - $this->hr(); - $this->out(); - $this->out(__d('cake_console', "View Scaffolding Complete.\n")); - } else { - $this->customAction(); - } - } - -/** - * Loads Controller and sets variables for the template - * Available template variables - * 'modelClass', 'primaryKey', 'displayField', 'singularVar', 'pluralVar', - * 'singularHumanName', 'pluralHumanName', 'fields', 'foreignKeys', - * 'belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany' - * - * @return array Returns an variables to be made available to a view template - */ - protected function _loadController() { - if (!$this->controllerName) { - $this->err(__d('cake_console', 'Controller not found')); - } - - $plugin = null; - if ($this->plugin) { - $plugin = $this->plugin . '.'; - } - - $controllerClassName = $this->controllerName . 'Controller'; - App::uses($controllerClassName, $plugin . 'Controller'); - if (!class_exists($controllerClassName)) { - $file = $controllerClassName . '.php'; - $this->err(__d('cake_console', "The file '%s' could not be found.\nIn order to bake a view, you'll need to first create the controller.", $file)); - $this->_stop(); - } - $controllerObj = new $controllerClassName(); - $controllerObj->plugin = $this->plugin; - $controllerObj->constructClasses(); - $modelClass = $controllerObj->modelClass; - $modelObj = $controllerObj->{$controllerObj->modelClass}; - - if ($modelObj) { - $primaryKey = $modelObj->primaryKey; - $displayField = $modelObj->displayField; - $singularVar = Inflector::variable($modelClass); - $singularHumanName = $this->_singularHumanName($this->controllerName); - $schema = $modelObj->schema(true); - $fields = array_keys($schema); - $associations = $this->_associations($modelObj); - } else { - $primaryKey = $displayField = null; - $singularVar = Inflector::variable(Inflector::singularize($this->controllerName)); - $singularHumanName = $this->_singularHumanName($this->controllerName); - $fields = $schema = $associations = array(); - } - $pluralVar = Inflector::variable($this->controllerName); - $pluralHumanName = $this->_pluralHumanName($this->controllerName); - - return compact('modelClass', 'schema', 'primaryKey', 'displayField', 'singularVar', 'pluralVar', - 'singularHumanName', 'pluralHumanName', 'fields', 'associations'); - } - -/** - * Bake a view file for each of the supplied actions - * - * @param array $actions Array of actions to make files for. - * @param array $vars - * @return void - */ - public function bakeActions($actions, $vars) { - foreach ($actions as $action) { - $content = $this->getContent($action, $vars); - $this->bake($action, $content); - } - } - -/** - * handle creation of baking a custom action view file - * - * @return void - */ - public function customAction() { - $action = ''; - while ($action == '') { - $action = $this->in(__d('cake_console', 'Action Name? (use lowercase_underscored function name)')); - if ($action == '') { - $this->out(__d('cake_console', 'The action name you supplied was empty. Please try again.')); - } - } - $this->out(); - $this->hr(); - $this->out(__d('cake_console', 'The following view will be created:')); - $this->hr(); - $this->out(__d('cake_console', 'Controller Name: %s', $this->controllerName)); - $this->out(__d('cake_console', 'Action Name: %s', $action)); - $this->out(__d('cake_console', 'Path: %s', $this->getPath() . $this->controllerName . DS . Inflector::underscore($action) . ".ctp")); - $this->hr(); - $looksGood = $this->in(__d('cake_console', 'Look okay?'), array('y', 'n'), 'y'); - if (strtolower($looksGood) == 'y') { - $this->bake($action, ' '); - $this->_stop(); - } else { - $this->out(__d('cake_console', 'Bake Aborted.')); - } - } - -/** - * Assembles and writes bakes the view file. - * - * @param string $action Action to bake - * @param string $content Content to write - * @return boolean Success - */ - public function bake($action, $content = '') { - if ($content === true) { - $content = $this->getContent($action); - } - if (empty($content)) { - return false; - } - $this->out("\n" . __d('cake_console', 'Baking `%s` view file...', $action), 1, Shell::QUIET); - $path = $this->getPath(); - $filename = $path . $this->controllerName . DS . Inflector::underscore($action) . '.ctp'; - return $this->createFile($filename, $content); - } - -/** - * Builds content from template and variables - * - * @param string $action name to generate content to - * @param array $vars passed for use in templates - * @return string content from template - */ - public function getContent($action, $vars = null) { - if (!$vars) { - $vars = $this->_loadController(); - } - - $this->Template->set('action', $action); - $this->Template->set('plugin', $this->plugin); - $this->Template->set($vars); - $template = $this->getTemplate($action); - if ($template) { - return $this->Template->generate('views', $template); - } - return false; - } - -/** - * Gets the template name based on the action name - * - * @param string $action name - * @return string template name - */ - public function getTemplate($action) { - if ($action != $this->template && in_array($action, $this->noTemplateActions)) { - return false; - } - if (!empty($this->template) && $action != $this->template) { - return $this->template; - } - $themePath = $this->Template->getThemePath(); - if (file_exists($themePath . 'views' . DS . $action . '.ctp')) { - return $action; - } - $template = $action; - $prefixes = Configure::read('Routing.prefixes'); - foreach ((array)$prefixes as $prefix) { - if (strpos($template, $prefix) !== false) { - $template = str_replace($prefix . '_', '', $template); - } - } - if (in_array($template, array('add', 'edit'))) { - $template = 'form'; - } elseif (preg_match('@(_add|_edit)$@', $template)) { - $template = str_replace(array('_add', '_edit'), '_form', $template); - } - return $template; - } - -/** - * get the option parser for this task - * - * @return ConsoleOptionParser - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - return $parser->description( - __d('cake_console', 'Bake views for a controller, using built-in or custom templates.') - )->addArgument('controller', array( - 'help' => __d('cake_console', 'Name of the controller views to bake. Can be Plugin.name as a shortcut for plugin baking.') - ))->addArgument('action', array( - 'help' => __d('cake_console', "Will bake a single action's file. core templates are (index, add, edit, view)") - ))->addArgument('alias', array( - 'help' => __d('cake_console', 'Will bake the template in but create the filename after .') - ))->addOption('plugin', array( - 'short' => 'p', - 'help' => __d('cake_console', 'Plugin to bake the view into.') - ))->addOption('admin', array( - 'help' => __d('cake_console', 'Set to only bake views for a prefix in Routing.prefixes'), - 'boolean' => true - ))->addOption('connection', array( - 'short' => 'c', - 'help' => __d('cake_console', 'The connection the connected model is on.') - ))->addSubcommand('all', array( - 'help' => __d('cake_console', 'Bake all CRUD action views for all controllers. Requires models and controllers to exist.') - ))->epilog(__d('cake_console', 'Omitting all arguments and options will enter into an interactive mode.')); - } - -/** - * Returns associations for controllers models. - * - * @param Model $model - * @return array $associations - */ - protected function _associations(Model $model) { - $keys = array('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany'); - $associations = array(); - - foreach ($keys as $key => $type) { - foreach ($model->{$type} as $assocKey => $assocData) { - list($plugin, $modelClass) = pluginSplit($assocData['className']); - $associations[$type][$assocKey]['primaryKey'] = $model->{$assocKey}->primaryKey; - $associations[$type][$assocKey]['displayField'] = $model->{$assocKey}->displayField; - $associations[$type][$assocKey]['foreignKey'] = $assocData['foreignKey']; - $associations[$type][$assocKey]['controller'] = Inflector::pluralize(Inflector::underscore($modelClass)); - $associations[$type][$assocKey]['fields'] = array_keys($model->{$assocKey}->schema(true)); - } - } - return $associations; - } - -} diff --git a/lib/Cake/Console/Command/TestShell.php b/lib/Cake/Console/Command/TestShell.php deleted file mode 100644 index a3b410ba09c..00000000000 --- a/lib/Cake/Console/Command/TestShell.php +++ /dev/null @@ -1,434 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @since CakePHP(tm) v 1.2.0.4433 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Shell', 'Console'); -App::uses('CakeTestSuiteDispatcher', 'TestSuite'); -App::uses('CakeTestSuiteCommand', 'TestSuite'); -App::uses('CakeTestLoader', 'TestSuite'); - -/** - * Provides a CakePHP wrapper around PHPUnit. - * Adds in CakePHP's fixtures and gives access to plugin, app and core test cases - * - * @package Cake.Console.Command - */ -class TestShell extends Shell { - -/** - * Dispatcher object for the run. - * - * @var CakeTestDispatcher - */ - protected $_dispatcher = null; - -/** - * get the option parser for the test suite. - * - * @return void - */ - public function getOptionParser() { - $parser = new ConsoleOptionParser($this->name); - $parser->description(array( - __d('cake_console', 'The CakePHP Testsuite allows you to run test cases from the command line'), - ))->addArgument('category', array( - 'help' => __d('cake_console', 'The category for the test, or test file, to test.'), - 'required' => false, - ))->addArgument('file', array( - 'help' => __d('cake_console', 'The path to the file, or test file, to test.'), - 'required' => false, - ))->addOption('log-junit', array( - 'help' => __d('cake_console', ' Log test execution in JUnit XML format to file.'), - 'default' => false - ))->addOption('log-json', array( - 'help' => __d('cake_console', ' Log test execution in TAP format to file.'), - 'default' => false - ))->addOption('log-tap', array( - 'help' => __d('cake_console', ' Log test execution in TAP format to file.'), - 'default' => false - ))->addOption('log-dbus', array( - 'help' => __d('cake_console', 'Log test execution to DBUS.'), - 'default' => false - ))->addOption('coverage-html', array( - 'help' => __d('cake_console', ' Generate code coverage report in HTML format.'), - 'default' => false - ))->addOption('coverage-clover', array( - 'help' => __d('cake_console', ' Write code coverage data in Clover XML format.'), - 'default' => false - ))->addOption('testdox-html', array( - 'help' => __d('cake_console', ' Write agile documentation in HTML format to file.'), - 'default' => false - ))->addOption('testdox-text', array( - 'help' => __d('cake_console', ' Write agile documentation in Text format to file.'), - 'default' => false - ))->addOption('filter', array( - 'help' => __d('cake_console', ' Filter which tests to run.'), - 'default' => false - ))->addOption('group', array( - 'help' => __d('cake_console', ' Only runs tests from the specified group(s).'), - 'default' => false - ))->addOption('exclude-group', array( - 'help' => __d('cake_console', ' Exclude tests from the specified group(s).'), - 'default' => false - ))->addOption('list-groups', array( - 'help' => __d('cake_console', 'List available test groups.'), - 'boolean' => true - ))->addOption('loader', array( - 'help' => __d('cake_console', 'TestSuiteLoader implementation to use.'), - 'default' => false - ))->addOption('repeat', array( - 'help' => __d('cake_console', ' Runs the test(s) repeatedly.'), - 'default' => false - ))->addOption('tap', array( - 'help' => __d('cake_console', 'Report test execution progress in TAP format.'), - 'boolean' => true - ))->addOption('testdox', array( - 'help' => __d('cake_console', 'Report test execution progress in TestDox format.'), - 'default' => false, - 'boolean' => true - ))->addOption('no-colors', array( - 'help' => __d('cake_console', 'Do not use colors in output.'), - 'boolean' => true - ))->addOption('stderr', array( - 'help' => __d('cake_console', 'Write to STDERR instead of STDOUT.'), - 'boolean' => true - ))->addOption('stop-on-error', array( - 'help' => __d('cake_console', 'Stop execution upon first error or failure.'), - 'boolean' => true - ))->addOption('stop-on-failure', array( - 'help' => __d('cake_console', 'Stop execution upon first failure.'), - 'boolean' => true - ))->addOption('stop-on-skipped ', array( - 'help' => __d('cake_console', 'Stop execution upon first skipped test.'), - 'boolean' => true - ))->addOption('stop-on-incomplete', array( - 'help' => __d('cake_console', 'Stop execution upon first incomplete test.'), - 'boolean' => true - ))->addOption('strict', array( - 'help' => __d('cake_console', 'Mark a test as incomplete if no assertions are made.'), - 'boolean' => true - ))->addOption('wait', array( - 'help' => __d('cake_console', 'Waits for a keystroke after each test.'), - 'boolean' => true - ))->addOption('process-isolation', array( - 'help' => __d('cake_console', 'Run each test in a separate PHP process.'), - 'boolean' => true - ))->addOption('no-globals-backup', array( - 'help' => __d('cake_console', 'Do not backup and restore $GLOBALS for each test.'), - 'boolean' => true - ))->addOption('static-backup ', array( - 'help' => __d('cake_console', 'Backup and restore static attributes for each test.'), - 'boolean' => true - ))->addOption('syntax-check', array( - 'help' => __d('cake_console', 'Try to check source files for syntax errors.'), - 'boolean' => true - ))->addOption('bootstrap', array( - 'help' => __d('cake_console', ' A "bootstrap" PHP file that is run before the tests.'), - 'default' => false - ))->addOption('configuration', array( - 'help' => __d('cake_console', ' Read configuration from XML file.'), - 'default' => false - ))->addOption('no-configuration', array( - 'help' => __d('cake_console', 'Ignore default configuration file (phpunit.xml).'), - 'boolean' => true - ))->addOption('include-path', array( - 'help' => __d('cake_console', ' Prepend PHP include_path with given path(s).'), - 'default' => false - ))->addOption('directive', array( - 'help' => __d('cake_console', 'key[=value] Sets a php.ini value.'), - 'default' => false - ))->addOption('fixture', array( - 'help' => __d('cake_console', 'Choose a custom fixture manager.'), - ))->addOption('debug', array( - 'help' => __d('cake_console', 'More verbose output.'), - )); - - return $parser; - } - -/** - * Initialization method installs PHPUnit and loads all plugins - * - * @return void - * @throws Exception - */ - public function initialize() { - $this->_dispatcher = new CakeTestSuiteDispatcher(); - $sucess = $this->_dispatcher->loadTestFramework(); - if (!$sucess) { - throw new Exception(__d('cake_dev', 'Please install PHPUnit framework (http://www.phpunit.de)')); - } - } - -/** - * Parse the CLI options into an array CakeTestDispatcher can use. - * - * @return array Array of params for CakeTestDispatcher - */ - protected function _parseArgs() { - if (empty($this->args)) { - return; - } - $params = array( - 'core' => false, - 'app' => false, - 'plugin' => null, - 'output' => 'text', - ); - - if (strpos($this->args[0], '.php')) { - $category = $this->_mapFileToCategory($this->args[0]); - $params['case'] = $this->_mapFileToCase($this->args[0], $category); - } else { - $category = $this->args[0]; - if (isset($this->args[1])) { - $params['case'] = $this->args[1]; - } - } - - if ($category === 'core') { - $params['core'] = true; - } elseif ($category === 'app') { - $params['app'] = true; - } else { - $params['plugin'] = $category; - } - - return $params; - } - -/** - * Converts the options passed to the shell as options for the PHPUnit cli runner - * - * @return array Array of params for CakeTestDispatcher - */ - protected function _runnerOptions() { - $options = array(); - $params = $this->params; - unset($params['help']); - - if (!empty($params['no-colors'])) { - unset($params['no-colors'], $params['colors']); - } else { - $params['colors'] = true; - } - - foreach ($params as $param => $value) { - if ($value === false) { - continue; - } - $options[] = '--' . $param; - if (is_string($value)) { - $options[] = $value; - } - } - return $options; - } - -/** - * Main entry point to this shell - * - * @return void - */ - public function main() { - $this->out(__d('cake_console', 'CakePHP Test Shell')); - $this->hr(); - - $args = $this->_parseArgs(); - - if (empty($args['case'])) { - return $this->available(); - } - - $this->_run($args, $this->_runnerOptions()); - } - -/** - * Runs the test case from $runnerArgs - * - * @param array $runnerArgs list of arguments as obtained from _parseArgs() - * @param array $options list of options as constructed by _runnerOptions() - * @return void - */ - protected function _run($runnerArgs, $options = array()) { - restore_error_handler(); - restore_error_handler(); - - $testCli = new CakeTestSuiteCommand('CakeTestLoader', $runnerArgs); - $testCli->run($options); - } - -/** - * Shows a list of available test cases and gives the option to run one of them - * - * @return void - */ - public function available() { - $params = $this->_parseArgs(); - $testCases = CakeTestLoader::generateTestList($params); - $app = $params['app']; - $plugin = $params['plugin']; - - $title = "Core Test Cases:"; - $category = 'core'; - if ($app) { - $title = "App Test Cases:"; - $category = 'app'; - } elseif ($plugin) { - $title = Inflector::humanize($plugin) . " Test Cases:"; - $category = $plugin; - } - - if (empty($testCases)) { - $this->out(__d('cake_console', "No test cases available \n\n")); - return $this->out($this->OptionParser->help()); - } - - $this->out($title); - $i = 1; - $cases = array(); - foreach ($testCases as $testCaseFile => $testCase) { - $case = str_replace('Test.php', '', $testCase); - $this->out("[$i] $case"); - $cases[$i] = $case; - $i++; - } - - while ($choice = $this->in(__d('cake_console', 'What test case would you like to run?'), null, 'q')) { - if (is_numeric($choice) && isset($cases[$choice])) { - $this->args[0] = $category; - $this->args[1] = $cases[$choice]; - $this->_run($this->_parseArgs(), $this->_runnerOptions()); - break; - } - - if (is_string($choice) && in_array($choice, $cases)) { - $this->args[0] = $category; - $this->args[1] = $choice; - $this->_run($this->_parseArgs(), $this->_runnerOptions()); - break; - } - - if ($choice == 'q') { - break; - } - } - } - -/** - * Find the test case for the passed file. The file could itself be a test. - * - * @param mixed $file - * @param mixed $category - * @param mixed $throwOnMissingFile - * @access protected - * @return array(type, case) - * @throws Exception - */ - protected function _mapFileToCase($file, $category, $throwOnMissingFile = true) { - if (!$category || (substr($file, -4) !== '.php')) { - return false; - } - - $_file = realpath($file); - if ($_file) { - $file = $_file; - } - - $testFile = $testCase = null; - - if (preg_match('@Test[\\\/]@', $file)) { - - if (substr($file, -8) === 'Test.php') { - - $testCase = substr($file, 0, -8); - $testCase = str_replace(DS, '/', $testCase); - - if ($testCase = preg_replace('@.*Test\/Case\/@', '', $testCase)) { - - if ($category === 'core') { - $testCase = str_replace('lib/Cake', '', $testCase); - } - - return $testCase; - } - - throw new Exception(__d('cake_dev', 'Test case %s cannot be run via this shell', $testFile)); - } - } - - $file = substr($file, 0, -4); - if ($category === 'core') { - - $testCase = str_replace(DS, '/', $file); - $testCase = preg_replace('@.*lib/Cake/@', '', $file); - $testCase[0] = strtoupper($testCase[0]); - $testFile = CAKE . 'Test/Case/' . $testCase . 'Test.php'; - - if (!file_exists($testFile) && $throwOnMissingFile) { - throw new Exception(__d('cake_dev', 'Test case %s not found', $testFile)); - } - - return $testCase; - } - - if ($category === 'app') { - $testFile = str_replace(APP, APP . 'Test/Case/', $file) . 'Test.php'; - } else { - $testFile = preg_replace( - "@((?:plugins|Plugin)[\\/]{$category}[\\/])(.*)$@", - '\1Test/Case/\2Test.php', - $file - ); - } - - if (!file_exists($testFile) && $throwOnMissingFile) { - throw new Exception(__d('cake_dev', 'Test case %s not found', $testFile)); - } - - $testCase = substr($testFile, 0, -8); - $testCase = str_replace(DS, '/', $testCase); - $testCase = preg_replace('@.*Test/Case/@', '', $testCase); - - return $testCase; - } - -/** - * For the given file, what category of test is it? returns app, core or the name of the plugin - * - * @param mixed $file - * @access protected - * @return string - */ - protected function _mapFileToCategory($file) { - $_file = realpath($file); - if ($_file) { - $file = $_file; - } - - $file = str_replace(DS, '/', $file); - if (strpos($file, 'lib/Cake/') !== false) { - return 'core'; - } elseif (preg_match('@(?:plugins|Plugin)/([^/]*)@', $file, $match)) { - return $match[1]; - } - return 'app'; - } - -} diff --git a/lib/Cake/Console/Command/TestsuiteShell.php b/lib/Cake/Console/Command/TestsuiteShell.php deleted file mode 100644 index 71decbeff27..00000000000 --- a/lib/Cake/Console/Command/TestsuiteShell.php +++ /dev/null @@ -1,101 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @since CakePHP(tm) v 1.2.0.4433 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('TestShell', 'Console/Command'); -App::uses('AppShell', 'Console/Command'); -App::uses('CakeTestSuiteDispatcher', 'TestSuite'); -App::uses('CakeTestSuiteCommand', 'TestSuite'); -App::uses('CakeTestLoader', 'TestSuite'); - -/** - * Provides a CakePHP wrapper around PHPUnit. - * Adds in CakePHP's fixtures and gives access to plugin, app and core test cases - * - * @package Cake.Console.Command - */ -class TestsuiteShell extends TestShell { - -/** - * get the option parser for the test suite. - * - * @return void - */ - public function getOptionParser() { - $parser = parent::getOptionParser(); - $parser->description(array( - __d('cake_console', 'The CakePHP Testsuite allows you to run test cases from the command line'), - __d('cake_console', 'This shell is for backwards-compatibility only'), - __d('cake_console', 'use the test shell instead') - )); - - return $parser; - } - -/** - * Parse the CLI options into an array CakeTestDispatcher can use. - * - * @return array Array of params for CakeTestDispatcher - */ - protected function _parseArgs() { - if (empty($this->args)) { - return; - } - $params = array( - 'core' => false, - 'app' => false, - 'plugin' => null, - 'output' => 'text', - ); - - $category = $this->args[0]; - - if ($category == 'core') { - $params['core'] = true; - } elseif ($category == 'app') { - $params['app'] = true; - } elseif ($category != 'core') { - $params['plugin'] = $category; - } - - if (isset($this->args[1])) { - $params['case'] = $this->args[1]; - } - return $params; - } - -/** - * Main entry point to this shell - * - * @return void - */ - public function main() { - $this->out(__d('cake_console', 'CakePHP Test Shell')); - $this->hr(); - - $args = $this->_parseArgs(); - - if (empty($args['case'])) { - return $this->available(); - } - - $this->_run($args, $this->_runnerOptions()); - } - -} diff --git a/lib/Cake/Console/Command/UpgradeShell.php b/lib/Cake/Console/Command/UpgradeShell.php deleted file mode 100644 index a7e8e10a040..00000000000 --- a/lib/Cake/Console/Command/UpgradeShell.php +++ /dev/null @@ -1,858 +0,0 @@ - 'Controller', - 'Component' => 'Controller/Component', - 'Model' => 'Model', - 'Behavior' => 'Model/Behavior', - 'Datasource' => 'Model/Datasource', - 'Dbo' => 'Model/Datasource/Database', - 'View' => 'View', - 'Helper' => 'View/Helper', - 'Shell' => 'Console/Command', - 'Task' => 'Console/Command/Task', - 'Case' => 'Test/Case', - 'Fixture' => 'Test/Fixture', - 'Error' => 'Lib/Error', - ); - -/** - * Shell startup, prints info message about dry run. - * - * @return void - */ - public function startup() { - parent::startup(); - if ($this->params['dry-run']) { - $this->out(__d('cake_console', 'Dry-run mode enabled!'), 1, Shell::QUIET); - } - if ($this->params['git'] && !is_dir('.git')) { - $this->out(__d('cake_console', 'No git repository detected!'), 1, Shell::QUIET); - } - } - -/** - * Run all upgrade steps one at a time - * - * @return void - */ - public function all() { - foreach ($this->OptionParser->subcommands() as $command) { - $name = $command->name(); - if ($name === 'all') { - continue; - } - $this->out(__d('cake_console', 'Running %s', $name)); - $this->$name(); - } - } - -/** - * Update tests. - * - * - Update tests class names to FooTest rather than FooTestCase. - * - * @return void - */ - public function tests() { - $this->_paths = array(APP . 'tests' . DS); - if (!empty($this->params['plugin'])) { - $this->_paths = array(App::pluginPath($this->params['plugin']) . 'tests' . DS); - } - $patterns = array( - array( - '*TestCase extends CakeTestCase to *Test extends CakeTestCase', - '/([a-zA-Z]*Test)Case extends CakeTestCase/', - '\1 extends CakeTestCase' - ), - ); - - $this->_filesRegexpUpdate($patterns); - } - -/** - * Move files and folders to their new homes - * - * Moves folders containing files which cannot necessarily be auto-detected (libs and templates) - * and then looks for all php files except vendors, and moves them to where Cake 2.0 expects - * to find them. - * - * @return void - */ - public function locations() { - $cwd = getcwd(); - - if (!empty($this->params['plugin'])) { - chdir(App::pluginPath($this->params['plugin'])); - } - - if (is_dir('plugins')) { - $Folder = new Folder('plugins'); - list($plugins) = $Folder->read(); - foreach ($plugins as $plugin) { - chdir($cwd . DS . 'plugins' . DS . $plugin); - $this->locations(); - } - $this->_files = array(); - chdir($cwd); - } - $moves = array( - 'config' => 'Config', - 'Config' . DS . 'schema' => 'Config' . DS . 'Schema', - 'libs' => 'Lib', - 'tests' => 'Test', - 'views' => 'View', - 'models' => 'Model', - 'Model' . DS . 'behaviors' => 'Model' . DS . 'Behavior', - 'Model' . DS . 'datasources' => 'Model' . DS . 'Datasource', - 'Test' . DS . 'cases' => 'Test' . DS . 'Case', - 'Test' . DS . 'fixtures' => 'Test' . DS . 'Fixture', - 'vendors' . DS . 'shells' . DS . 'templates' => 'Console' . DS . 'Templates', - ); - foreach ($moves as $old => $new) { - if (is_dir($old)) { - $this->out(__d('cake_console', 'Moving %s to %s', $old, $new)); - if (!$this->params['dry-run']) { - if ($this->params['git']) { - exec('git mv -f ' . escapeshellarg($old) . ' ' . escapeshellarg($old . '__')); - exec('git mv -f ' . escapeshellarg($old . '__') . ' ' . escapeshellarg($new)); - } else { - $Folder = new Folder($old); - $Folder->move($new); - } - } - } - } - - $this->_moveViewFiles(); - $this->_moveAppClasses(); - - $sourceDirs = array( - '.' => array('recursive' => false), - 'Console', - 'controllers', - 'Controller', - 'Lib' => array('checkFolder' => false), - 'models', - 'Model', - 'tests', - 'Test' => array('regex' => '@class (\S*Test) extends CakeTestCase@'), - 'views', - 'View', - 'vendors/shells', - ); - - $defaultOptions = array( - 'recursive' => true, - 'checkFolder' => true, - 'regex' => '@class (\S*) .*(\s|\v)*{@i' - ); - foreach ($sourceDirs as $dir => $options) { - if (is_numeric($dir)) { - $dir = $options; - $options = array(); - } - $options = array_merge($defaultOptions, $options); - $this->_movePhpFiles($dir, $options); - } - } - -/** - * Update helpers. - * - * - Converts helpers usage to new format. - * - * @return void - */ - public function helpers() { - $this->_paths = array_diff(App::path('views'), App::core('views')); - - if (!empty($this->params['plugin'])) { - $this->_paths = array(App::pluginPath($this->params['plugin']) . 'views' . DS); - } - - $patterns = array(); - App::build(array( - 'View/Helper' => App::core('View/Helper'), - ), App::APPEND); - $helpers = App::objects('helper'); - $plugins = App::objects('plugin'); - $pluginHelpers = array(); - foreach ($plugins as $plugin) { - CakePlugin::load($plugin); - $pluginHelpers = array_merge( - $pluginHelpers, - App::objects('helper', App::pluginPath($plugin) . DS . 'views' . DS . 'helpers' . DS, false) - ); - } - $helpers = array_merge($pluginHelpers, $helpers); - foreach ($helpers as $helper) { - $helper = preg_replace('/Helper$/', '', $helper); - $oldHelper = strtolower(substr($helper, 0, 1)) . substr($helper, 1); - $patterns[] = array( - "\${$oldHelper} to \$this->{$helper}", - "/\\\${$oldHelper}->/", - "\\\$this->{$helper}->" - ); - } - - $this->_filesRegexpUpdate($patterns); - } - -/** - * Update i18n. - * - * - Removes extra true param. - * - Add the echo to __*() calls that didn't need them before. - * - * @return void - */ - public function i18n() { - $this->_paths = array( - APP - ); - if (!empty($this->params['plugin'])) { - $this->_paths = array(App::pluginPath($this->params['plugin'])); - } - - $patterns = array( - array( - '_filesRegexpUpdate($patterns); - } - -/** - * Upgrade the removed basics functions. - * - * - a(*) -> array(*) - * - e(*) -> echo * - * - ife(*, *, *) -> !empty(*) ? * : * - * - a(*) -> array(*) - * - r(*, *, *) -> str_replace(*, *, *) - * - up(*) -> strtoupper(*) - * - low(*, *, *) -> strtolower(*) - * - getMicrotime() -> microtime(true) - * - * @return void - */ - public function basics() { - $this->_paths = array( - APP - ); - if (!empty($this->params['plugin'])) { - $this->_paths = array(App::pluginPath($this->params['plugin'])); - } - $patterns = array( - array( - 'a(*) -> array(*)', - '/\ba\((.*)\)/', - 'array(\1)' - ), - array( - 'e(*) -> echo *', - '/\be\((.*)\)/', - 'echo \1' - ), - array( - 'ife(*, *, *) -> !empty(*) ? * : *', - '/ife\((.*), (.*), (.*)\)/', - '!empty(\1) ? \2 : \3' - ), - array( - 'r(*, *, *) -> str_replace(*, *, *)', - '/\br\(/', - 'str_replace(' - ), - array( - 'up(*) -> strtoupper(*)', - '/\bup\(/', - 'strtoupper(' - ), - array( - 'low(*) -> strtolower(*)', - '/\blow\(/', - 'strtolower(' - ), - array( - 'getMicrotime() -> microtime(true)', - '/getMicrotime\(\)/', - 'microtime(true)' - ), - ); - $this->_filesRegexpUpdate($patterns); - } - -/** - * Update the properties moved to CakeRequest. - * - * @return void - */ - public function request() { - $views = array_diff(App::path('views'), App::core('views')); - $controllers = array_diff(App::path('controllers'), App::core('controllers'), array(APP)); - $components = array_diff(App::path('components'), App::core('components')); - - $this->_paths = array_merge($views, $controllers, $components); - - if (!empty($this->params['plugin'])) { - $pluginPath = App::pluginPath($this->params['plugin']); - $this->_paths = array( - $pluginPath . 'controllers' . DS, - $pluginPath . 'controllers' . DS . 'components' . DS, - $pluginPath . 'views' . DS, - ); - } - $patterns = array( - array( - '$this->data -> $this->request->data', - '/(\$this->data\b(?!\())/', - '$this->request->data' - ), - array( - '$this->params -> $this->request->params', - '/(\$this->params\b(?!\())/', - '$this->request->params' - ), - array( - '$this->webroot -> $this->request->webroot', - '/(\$this->webroot\b(?!\())/', - '$this->request->webroot' - ), - array( - '$this->base -> $this->request->base', - '/(\$this->base\b(?!\())/', - '$this->request->base' - ), - array( - '$this->here -> $this->request->here', - '/(\$this->here\b(?!\())/', - '$this->request->here' - ), - array( - '$this->action -> $this->request->action', - '/(\$this->action\b(?!\())/', - '$this->request->action' - ), - ); - $this->_filesRegexpUpdate($patterns); - } - -/** - * Update Configure::read() calls with no params. - * - * @return void - */ - public function configure() { - $this->_paths = array( - APP - ); - if (!empty($this->params['plugin'])) { - $this->_paths = array(App::pluginPath($this->params['plugin'])); - } - $patterns = array( - array( - "Configure::read() -> Configure::read('debug')", - '/Configure::read\(\)/', - 'Configure::read(\'debug\')' - ), - ); - $this->_filesRegexpUpdate($patterns); - } - -/** - * constants - * - * @return void - */ - public function constants() { - $this->_paths = array( - APP - ); - if (!empty($this->params['plugin'])) { - $this->_paths = array(App::pluginPath($this->params['plugin'])); - } - $patterns = array( - array( - "LIBS -> CAKE", - '/\bLIBS\b/', - 'CAKE' - ), - array( - "CONFIGS -> APP . 'Config' . DS", - '/\bCONFIGS\b/', - 'APP . \'Config\' . DS' - ), - array( - "CONTROLLERS -> APP . 'Controller' . DS", - '/\bCONTROLLERS\b/', - 'APP . \'Controller\' . DS' - ), - array( - "COMPONENTS -> APP . 'Controller' . DS . 'Component' . DS", - '/\bCOMPONENTS\b/', - 'APP . \'Controller\' . DS . \'Component\'' - ), - array( - "MODELS -> APP . 'Model' . DS", - '/\bMODELS\b/', - 'APP . \'Model\' . DS' - ), - array( - "BEHAVIORS -> APP . 'Model' . DS . 'Behavior' . DS", - '/\bBEHAVIORS\b/', - 'APP . \'Model\' . DS . \'Behavior\' . DS' - ), - array( - "VIEWS -> APP . 'View' . DS", - '/\bVIEWS\b/', - 'APP . \'View\' . DS' - ), - array( - "HELPERS -> APP . 'View' . DS . 'Helper' . DS", - '/\bHELPERS\b/', - 'APP . \'View\' . DS . \'Helper\' . DS' - ), - array( - "LAYOUTS -> APP . 'View' . DS . 'Layouts' . DS", - '/\bLAYOUTS\b/', - 'APP . \'View\' . DS . \'Layouts\' . DS' - ), - array( - "ELEMENTS -> APP . 'View' . DS . 'Elements' . DS", - '/\bELEMENTS\b/', - 'APP . \'View\' . DS . \'Elements\' . DS' - ), - array( - "CONSOLE_LIBS -> CAKE . 'Console' . DS", - '/\bCONSOLE_LIBS\b/', - 'CAKE . \'Console\' . DS' - ), - array( - "CAKE_TESTS_LIB -> CAKE . 'TestSuite' . DS", - '/\bCAKE_TESTS_LIB\b/', - 'CAKE . \'TestSuite\' . DS' - ), - array( - "CAKE_TESTS -> CAKE . 'Test' . DS", - '/\bCAKE_TESTS\b/', - 'CAKE . \'Test\' . DS' - ) - ); - $this->_filesRegexpUpdate($patterns); - } - -/** - * Update components. - * - * - Make components that extend Object to extend Component. - * - * @return void - */ - public function components() { - $this->_paths = App::Path('Controller/Component'); - if (!empty($this->params['plugin'])) { - $this->_paths = App::Path('Controller/Component', $this->params['plugin']); - } - $patterns = array( - array( - '*Component extends Object to *Component extends Component', - '/([a-zA-Z]*Component extends) Object/', - '\1 Component' - ), - ); - - $this->_filesRegexpUpdate($patterns); - } - -/** - * Replace cakeError with built-in exceptions. - * NOTE: this ignores calls where you've passed your own secondary parameters to cakeError(). - * @return void - */ - public function exceptions() { - $controllers = array_diff(App::path('controllers'), App::core('controllers'), array(APP)); - $components = array_diff(App::path('components'), App::core('components')); - - $this->_paths = array_merge($controllers, $components); - - if (!empty($this->params['plugin'])) { - $pluginPath = App::pluginPath($this->params['plugin']); - $this->_paths = array( - $pluginPath . 'controllers' . DS, - $pluginPath . 'controllers' . DS . 'components' . DS, - ); - } - $patterns = array( - array( - '$this->cakeError("error400") -> throw new BadRequestException()', - '/(\$this->cakeError\(["\']error400["\']\));/', - 'throw new BadRequestException();' - ), - array( - '$this->cakeError("error404") -> throw new NotFoundException()', - '/(\$this->cakeError\(["\']error404["\']\));/', - 'throw new NotFoundException();' - ), - array( - '$this->cakeError("error500") -> throw new InternalErrorException()', - '/(\$this->cakeError\(["\']error500["\']\));/', - 'throw new InternalErrorException();' - ), - ); - $this->_filesRegexpUpdate($patterns); - } - -/** - * Move application views files to where they now should be - * - * Find all view files in the folder and determine where cake expects the file to be - * - * @return void - */ - protected function _moveViewFiles() { - if (!is_dir('View')) { - return; - } - - $dirs = scandir('View'); - foreach ($dirs as $old) { - if (!is_dir('View' . DS . $old) || $old === '.' || $old === '..') { - continue; - } - - $new = 'View' . DS . Inflector::camelize($old); - $old = 'View' . DS . $old; - if ($new == $old) { - continue; - } - - $this->out(__d('cake_console', 'Moving %s to %s', $old, $new)); - if (!$this->params['dry-run']) { - if ($this->params['git']) { - exec('git mv -f ' . escapeshellarg($old) . ' ' . escapeshellarg($old . '__')); - exec('git mv -f ' . escapeshellarg($old . '__') . ' ' . escapeshellarg($new)); - } else { - $Folder = new Folder($old); - $Folder->move($new); - } - } - } - } - -/** - * Move the AppController, and AppModel classes. - * - * @return void - */ - protected function _moveAppClasses() { - $files = array( - APP . 'app_controller.php' => APP . 'Controller' . DS . 'AppController.php', - APP . 'controllers' . DS . 'app_controller.php' => APP . 'Controller' . DS . 'AppController.php', - APP . 'app_model.php' => APP . 'Model' . DS . 'AppModel.php', - APP . 'models' . DS . 'app_model.php' => APP . 'Model' . DS . 'AppModel.php', - ); - foreach ($files as $old => $new) { - if (file_exists($old)) { - $this->out(__d('cake_console', 'Moving %s to %s', $old, $new)); - - if ($this->params['dry-run']) { - continue; - } - if ($this->params['git']) { - exec('git mv -f ' . escapeshellarg($old) . ' ' . escapeshellarg($old . '__')); - exec('git mv -f ' . escapeshellarg($old . '__') . ' ' . escapeshellarg($new)); - } else { - rename($old, $new); - } - } - } - } - -/** - * Move application php files to where they now should be - * - * Find all php files in the folder (honoring recursive) and determine where cake expects the file to be - * If the file is not exactly where cake expects it - move it. - * - * @param mixed $path - * @param mixed $options array(recursive, checkFolder) - * @return void - */ - protected function _movePhpFiles($path, $options) { - if (!is_dir($path)) { - return; - } - - $paths = $this->_paths; - - $this->_paths = array($path); - $this->_files = array(); - if ($options['recursive']) { - $this->_findFiles('php'); - } else { - $this->_files = scandir($path); - foreach ($this->_files as $i => $file) { - if (strlen($file) < 5 || substr($file, -4) !== '.php') { - unset($this->_files[$i]); - } - } - } - - $cwd = getcwd(); - foreach ($this->_files as &$file) { - $file = $cwd . DS . $file; - - $contents = file_get_contents($file); - preg_match($options['regex'], $contents, $match); - if (!$match) { - continue; - } - - $class = $match[1]; - - if (substr($class, 0, 3) === 'Dbo') { - $type = 'Dbo'; - } else { - preg_match('@([A-Z][^A-Z]*)$@', $class, $match); - if ($match) { - $type = $match[1]; - } else { - $type = 'unknown'; - } - } - - preg_match('@^.*[\\\/]plugins[\\\/](.*?)[\\\/]@', $file, $match); - $base = $cwd . DS; - $plugin = false; - if ($match) { - $base = $match[0]; - $plugin = $match[1]; - } - - if ($options['checkFolder'] && !empty($this->_map[$type])) { - $folder = str_replace('/', DS, $this->_map[$type]); - $new = $base . $folder . DS . $class . '.php'; - } else { - $new = dirname($file) . DS . $class . '.php'; - } - - if ($file === $new) { - continue; - } - - $dir = dirname($new); - if (!is_dir($dir)) { - new Folder($dir, true); - } - - $this->out(__d('cake_console', 'Moving %s to %s', $file, $new), 1, Shell::VERBOSE); - if (!$this->params['dry-run']) { - if ($this->params['git']) { - exec('git mv -f ' . escapeshellarg($file) . ' ' . escapeshellarg($file . '__')); - exec('git mv -f ' . escapeshellarg($file . '__') . ' ' . escapeshellarg($new)); - } else { - rename($file, $new); - } - } - } - - $this->_paths = $paths; - } - -/** - * Updates files based on regular expressions. - * - * @param array $patterns Array of search and replacement patterns. - * @return void - */ - protected function _filesRegexpUpdate($patterns) { - $this->_findFiles($this->params['ext']); - foreach ($this->_files as $file) { - $this->out(__d('cake_console', 'Updating %s...', $file), 1, Shell::VERBOSE); - $this->_updateFile($file, $patterns); - } - } - -/** - * Searches the paths and finds files based on extension. - * - * @param string $extensions - * @return void - */ - protected function _findFiles($extensions = '') { - $this->_files = array(); - foreach ($this->_paths as $path) { - if (!is_dir($path)) { - continue; - } - $Iterator = new RegexIterator( - new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)), - '/^.+\.(' . $extensions . ')$/i', - RegexIterator::MATCH - ); - foreach ($Iterator as $file) { - if ($file->isFile()) { - $this->_files[] = $file->getPathname(); - } - } - } - } - -/** - * Update a single file. - * - * @param string $file The file to update - * @param array $patterns The replacement patterns to run. - * @return void - */ - protected function _updateFile($file, $patterns) { - $contents = file_get_contents($file); - - foreach ($patterns as $pattern) { - $this->out(__d('cake_console', ' * Updating %s', $pattern[0]), 1, Shell::VERBOSE); - $contents = preg_replace($pattern[1], $pattern[2], $contents); - } - - $this->out(__d('cake_console', 'Done updating %s', $file), 1); - if (!$this->params['dry-run']) { - file_put_contents($file, $contents); - } - } - -/** - * get the option parser - * - * @return ConsoleOptionParser - */ - public function getOptionParser() { - $subcommandParser = array( - 'options' => array( - 'plugin' => array( - 'short' => 'p', - 'help' => __d('cake_console', 'The plugin to update. Only the specified plugin will be updated.') - ), - 'ext' => array( - 'short' => 'e', - 'help' => __d('cake_console', 'The extension(s) to search. A pipe delimited list, or a preg_match compatible subpattern'), - 'default' => 'php|ctp|thtml|inc|tpl' - ), - 'git' => array( - 'short' => 'g', - 'help' => __d('cake_console', 'Use git command for moving files around.'), - 'boolean' => true - ), - 'dry-run' => array( - 'short' => 'd', - 'help' => __d('cake_console', 'Dry run the update, no files will actually be modified.'), - 'boolean' => true - ) - ) - ); - - return parent::getOptionParser() - ->description(__d('cake_console', "A shell to help automate upgrading from CakePHP 1.3 to 2.0. \n" . - "Be sure to have a backup of your application before running these commands.")) - ->addSubcommand('all', array( - 'help' => __d('cake_console', 'Run all upgrade commands.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('tests', array( - 'help' => __d('cake_console', 'Update tests class names to FooTest rather than FooTestCase.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('locations', array( - 'help' => __d('cake_console', 'Move files and folders to their new homes.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('i18n', array( - 'help' => __d('cake_console', 'Update the i18n translation method calls.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('helpers', array( - 'help' => __d('cake_console', 'Update calls to helpers.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('basics', array( - 'help' => __d('cake_console', 'Update removed basics functions to PHP native functions.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('request', array( - 'help' => __d('cake_console', 'Update removed request access, and replace with $this->request.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('configure', array( - 'help' => __d('cake_console', "Update Configure::read() to Configure::read('debug')"), - 'parser' => $subcommandParser - )) - ->addSubcommand('constants', array( - 'help' => __d('cake_console', "Replace Obsolete constants"), - 'parser' => $subcommandParser - )) - ->addSubcommand('components', array( - 'help' => __d('cake_console', 'Update components to extend Component class.'), - 'parser' => $subcommandParser - )) - ->addSubcommand('exceptions', array( - 'help' => __d('cake_console', 'Replace use of cakeError with exceptions.'), - 'parser' => $subcommandParser - )); - } - -} diff --git a/lib/Cake/Console/ConsoleErrorHandler.php b/lib/Cake/Console/ConsoleErrorHandler.php deleted file mode 100644 index 043c621256d..00000000000 --- a/lib/Cake/Console/ConsoleErrorHandler.php +++ /dev/null @@ -1,98 +0,0 @@ -write(__d('cake_console', "Error: %s\n%s", - $exception->getMessage(), - $exception->getTraceAsString() - )); - $this->_stop($exception->getCode() ? $exception->getCode() : 1); - } - -/** - * Handle errors in the console environment. Writes errors to stderr, - * and logs messages if Configure::read('debug') is 0. - * - * @param integer $code Error code - * @param string $description Description of the error. - * @param string $file The file the error occurred in. - * @param integer $line The line the error occurred on. - * @param array $context The backtrace of the error. - * @return void - */ - public function handleError($code, $description, $file = null, $line = null, $context = null) { - if (error_reporting() === 0) { - return; - } - $stderr = self::getStderr(); - list($name, $log) = ErrorHandler::mapErrorCode($code); - $message = __d('cake_console', '%s in [%s, line %s]', $description, $file, $line); - $stderr->write(__d('cake_console', "%s Error: %s\n", $name, $message)); - - if (Configure::read('debug') == 0) { - CakeLog::write($log, $message); - } - } - -/** - * Wrapper for exit(), used for testing. - * - * @param $code int The exit code. - */ - protected function _stop($code = 0) { - exit($code); - } - -} diff --git a/lib/Cake/Console/ConsoleInput.php b/lib/Cake/Console/ConsoleInput.php deleted file mode 100644 index eb4928b3807..00000000000 --- a/lib/Cake/Console/ConsoleInput.php +++ /dev/null @@ -1,51 +0,0 @@ -_input = fopen($handle, 'r'); - } - -/** - * Read a value from the stream - * - * @return mixed The value of the stream - */ - public function read() { - return fgets($this->_input); - } - -} diff --git a/lib/Cake/Console/ConsoleInputArgument.php b/lib/Cake/Console/ConsoleInputArgument.php deleted file mode 100644 index ef40e0a942f..00000000000 --- a/lib/Cake/Console/ConsoleInputArgument.php +++ /dev/null @@ -1,170 +0,0 @@ - $value) { - $this->{'_' . $key} = $value; - } - } else { - $this->_name = $name; - $this->_help = $help; - $this->_required = $required; - $this->_choices = $choices; - } - } - -/** - * Get the value of the name attribute. - * - * @return string Value of this->_name. - */ - public function name() { - return $this->_name; - } - -/** - * Generate the help for this argument. - * - * @param integer $width The width to make the name of the option. - * @return string - */ - public function help($width = 0) { - $name = $this->_name; - if (strlen($name) < $width) { - $name = str_pad($name, $width, ' '); - } - $optional = ''; - if (!$this->isRequired()) { - $optional = __d('cake_console', ' (optional)'); - } - if (!empty($this->_choices)) { - $optional .= __d('cake_console', ' (choices: %s)', implode('|', $this->_choices)); - } - return sprintf('%s%s%s', $name, $this->_help, $optional); - } - -/** - * Get the usage value for this argument - * - * @return string - */ - public function usage() { - $name = $this->_name; - if (!empty($this->_choices)) { - $name = implode('|', $this->_choices); - } - $name = '<' . $name . '>'; - if (!$this->isRequired()) { - $name = '[' . $name . ']'; - } - return $name; - } - -/** - * Check if this argument is a required argument - * - * @return boolean - */ - public function isRequired() { - return (bool)$this->_required; - } - -/** - * Check that $value is a valid choice for this argument. - * - * @param string $value - * @return boolean - * @throws ConsoleException - */ - public function validChoice($value) { - if (empty($this->_choices)) { - return true; - } - if (!in_array($value, $this->_choices)) { - throw new ConsoleException( - __d('cake_console', '"%s" is not a valid value for %s. Please use one of "%s"', - $value, $this->_name, implode(', ', $this->_choices) - )); - } - return true; - } - -/** - * Append this arguments XML representation to the passed in SimpleXml object. - * - * @param SimpleXmlElement $parent The parent element. - * @return SimpleXmlElement The parent with this argument appended. - */ - public function xml(SimpleXmlElement $parent) { - $option = $parent->addChild('argument'); - $option->addAttribute('name', $this->_name); - $option->addAttribute('help', $this->_help); - $option->addAttribute('required', $this->isRequired()); - $choices = $option->addChild('choices'); - foreach ($this->_choices as $valid) { - $choices->addChild('choice', $valid); - } - return $parent; - } - -} diff --git a/lib/Cake/Console/ConsoleInputOption.php b/lib/Cake/Console/ConsoleInputOption.php deleted file mode 100644 index 889166be7d6..00000000000 --- a/lib/Cake/Console/ConsoleInputOption.php +++ /dev/null @@ -1,221 +0,0 @@ - $value) { - $this->{'_' . $key} = $value; - } - } else { - $this->_name = $name; - $this->_short = $short; - $this->_help = $help; - $this->_boolean = $boolean; - $this->_default = $default; - $this->_choices = $choices; - } - if (strlen($this->_short) > 1) { - throw new ConsoleException( - __d('cake_console', 'Short options must be one letter.') - ); - } - } - -/** - * Get the value of the name attribute. - * - * @return string Value of this->_name. - */ - public function name() { - return $this->_name; - } - -/** - * Get the value of the short attribute. - * - * @return string Value of this->_short. - */ - public function short() { - return $this->_short; - } - -/** - * Generate the help for this this option. - * - * @param integer $width The width to make the name of the option. - * @return string - */ - public function help($width = 0) { - $default = $short = ''; - if (!empty($this->_default) && $this->_default !== true) { - $default = __d('cake_console', ' (default: %s)', $this->_default); - } - if (!empty($this->_choices)) { - $default .= __d('cake_console', ' (choices: %s)', implode('|', $this->_choices)); - } - if (!empty($this->_short)) { - $short = ', -' . $this->_short; - } - $name = sprintf('--%s%s', $this->_name, $short); - if (strlen($name) < $width) { - $name = str_pad($name, $width, ' '); - } - return sprintf('%s%s%s', $name, $this->_help, $default); - } - -/** - * Get the usage value for this option - * - * @return string - */ - public function usage() { - $name = empty($this->_short) ? '--' . $this->_name : '-' . $this->_short; - $default = ''; - if (!empty($this->_default) && $this->_default !== true) { - $default = ' ' . $this->_default; - } - if (!empty($this->_choices)) { - $default = ' ' . implode('|', $this->_choices); - } - return sprintf('[%s%s]', $name, $default); - } - -/** - * Get the default value for this option - * - * @return mixed - */ - public function defaultValue() { - return $this->_default; - } - -/** - * Check if this option is a boolean option - * - * @return boolean - */ - public function isBoolean() { - return (bool)$this->_boolean; - } - -/** - * Check that a value is a valid choice for this option. - * - * @param string $value - * @return boolean - * @throws ConsoleException - */ - public function validChoice($value) { - if (empty($this->_choices)) { - return true; - } - if (!in_array($value, $this->_choices)) { - throw new ConsoleException( - __d('cake_console', '"%s" is not a valid value for --%s. Please use one of "%s"', - $value, $this->_name, implode(', ', $this->_choices) - )); - } - return true; - } - -/** - * 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) { - $option = $parent->addChild('option'); - $option->addAttribute('name', '--' . $this->_name); - $short = ''; - if (strlen($this->_short)) { - $short = $this->_short; - } - $option->addAttribute('short', '-' . $short); - $option->addAttribute('boolean', $this->_boolean); - $option->addChild('default', $this->_default); - $choices = $option->addChild('choices'); - foreach ($this->_choices as $valid) { - $choices->addChild('choice', $valid); - } - return $parent; - } - -} diff --git a/lib/Cake/Console/ConsoleInputSubcommand.php b/lib/Cake/Console/ConsoleInputSubcommand.php deleted file mode 100644 index b9520644f52..00000000000 --- a/lib/Cake/Console/ConsoleInputSubcommand.php +++ /dev/null @@ -1,121 +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; - } - -/** - * Generate the help for this this subcommand. - * - * @param integer $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 mixed 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/lib/Cake/Console/ConsoleOptionParser.php b/lib/Cake/Console/ConsoleOptionParser.php deleted file mode 100644 index 703023c7d4f..00000000000 --- a/lib/Cake/Console/ConsoleOptionParser.php +++ /dev/null @@ -1,651 +0,0 @@ -addOption()` - * you can define new options. The name of the option is used as its long form, and you - * can supply an additional short form, with the `short` option. Short options should - * only be one letter long. Using more than one letter for a short option will raise an exception. - * - * Calling options can be done using syntax similar to most *nix command line tools. Long options - * cane either include an `=` or leave it out. - * - * `cake myshell command --connection default --name=something` - * - * Short options can be defined signally or in groups. - * - * `cake myshell command -cn` - * - * Short options can be combined into groups as seen above. Each letter in a group - * will be treated as a separate option. The previous example is equivalent to: - * - * `cake myshell command -c -n` - * - * Short options can also accept values: - * - * `cake myshell command -c default` - * - * ### Positional arguments - * - * If no positional arguments are defined, all of them will be parsed. If you define positional - * arguments any arguments greater than those defined will cause exceptions. Additionally you can - * declare arguments as optional, by setting the required param to false. - * - * `$parser->addArgument('model', array('required' => false));` - * - * ### Providing Help text - * - * By providing help text for your positional arguments and named arguments, the ConsoleOptionParser - * can generate a help display for you. You can view the help for shells by using the `--help` or `-h` switch. - * - * @package Cake.Console - */ -class ConsoleOptionParser { - -/** - * Description text - displays before options when help is generated - * - * @see ConsoleOptionParser::description() - * @var string - */ - protected $_description = null; - -/** - * Epilog text - displays after options when help is generated - * - * @see ConsoleOptionParser::epilog() - * @var string - */ - protected $_epilog = null; - -/** - * Option definitions. - * - * @see ConsoleOptionParser::addOption() - * @var array - */ - protected $_options = array(); - -/** - * Map of short -> long options, generated when using addOption() - * - * @var string - */ - protected $_shortOptions = array(); - -/** - * Positional argument definitions. - * - * @see ConsoleOptionParser::addArgument() - * @var array - */ - protected $_args = array(); - -/** - * Subcommands for this Shell. - * - * @see ConsoleOptionParser::addSubcommand() - * @var array - */ - protected $_subcommands = array(); - -/** - * Command name. - * - * @var string - */ - protected $_command = ''; - -/** - * Construct an OptionParser so you can define its behavior - * - * @param string $command The command name this parser is for. The command name is used for generating help. - * @param boolean $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) { - $this->command($command); - - $this->addOption('help', array( - 'short' => 'h', - 'help' => __d('cake_console', 'Display this help.'), - 'boolean' => true - )); - - if ($defaultOptions) { - $this->addOption('verbose', array( - 'short' => 'v', - 'help' => __d('cake_console', 'Enable verbose output.'), - 'boolean' => true - ))->addOption('quiet', array( - 'short' => 'q', - 'help' => __d('cake_console', 'Enable quiet output.'), - 'boolean' => true - )); - } - } - -/** - * Static factory method for creating new OptionParsers so you can chain methods off of them. - * - * @param string $command The command name this parser is for. The command name is used for generating help. - * @param boolean $defaultOptions Whether you want the verbose and quiet options set. - * @return ConsoleOptionParser - */ - public static function create($command, $defaultOptions = true) { - return new ConsoleOptionParser($command, $defaultOptions); - } - -/** - * Build a parser from an array. Uses an array like - * - * {{{ - * $spec = array( - * 'description' => 'text', - * 'epilog' => 'text', - * 'arguments' => array( - * // list of arguments compatible with addArguments. - * ), - * 'options' => array( - * // list of options compatible with addOptions - * ), - * 'subcommands' => array( - * // list of subcommands to add. - * ) - * ); - * }}} - * - * @param array $spec The spec to build the OptionParser with. - * @return ConsoleOptionParser - */ - public static function buildFromArray($spec) { - $parser = new ConsoleOptionParser($spec['command']); - if (!empty($spec['arguments'])) { - $parser->addArguments($spec['arguments']); - } - if (!empty($spec['options'])) { - $parser->addOptions($spec['options']); - } - if (!empty($spec['subcommands'])) { - $parser->addSubcommands($spec['subcommands']); - } - if (!empty($spec['description'])) { - $parser->description($spec['description']); - } - if (!empty($spec['epilog'])) { - $parser->epilog($spec['epilog']); - } - return $parser; - } - -/** - * Get or set the command name for shell/task. - * - * @param string $text The text to set, or null if you want to read - * @return mixed If reading, the value of the command. If setting $this will be returned - */ - public function command($text = null) { - if ($text !== null) { - $this->_command = Inflector::underscore($text); - return $this; - } - return $this->_command; - } - -/** - * Get or set the description text for shell/task. - * - * @param mixed $text The text to set, or null if you want to read. If an array the - * text will be imploded with "\n" - * @return mixed If reading, the value of the description. If setting $this will be returned - */ - public function description($text = null) { - if ($text !== null) { - if (is_array($text)) { - $text = implode("\n", $text); - } - $this->_description = $text; - return $this; - } - return $this->_description; - } - -/** - * Get or set an epilog to the parser. The epilog is added to the end of - * the options and arguments listing when help is generated. - * - * @param mixed $text Text when setting or null when reading. If an array the text will be imploded with "\n" - * @return mixed If reading, the value of the epilog. If setting $this will be returned. - */ - public function epilog($text = null) { - if ($text !== null) { - if (is_array($text)) { - $text = implode("\n", $text); - } - $this->_epilog = $text; - return $this; - } - return $this->_epilog; - } - -/** - * 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. - * - * ### Options - * - * - `short` - The single letter variant for this option, leave undefined for none. - * - `help` - Help text for this option. Used when generating help for the option. - * - `default` - The default value for this option. Defaults are added into the parsed params when the - * attached option is not provided or has no value. Using default and boolean together will not work. - * are added into the parsed parameters when the option is undefined. Defaults to null. - * - `boolean` - The option uses no value, its just a boolean switch. Defaults to false. - * If an option is defined as boolean, it will always be added to the parsed params. If no present - * it will be false, if present it will be true. - * - `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 mixed $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 ConsoleOptionParser $this. - */ - public function addOption($name, $options = array()) { - if (is_object($name) && $name instanceof ConsoleInputOption) { - $option = $name; - $name = $option->name(); - } else { - $defaults = array( - 'name' => $name, - 'short' => null, - 'help' => '', - 'default' => null, - 'boolean' => false, - 'choices' => array() - ); - $options = array_merge($defaults, $options); - $option = new ConsoleInputOption($options); - } - $this->_options[$name] = $option; - if ($option->short() !== null) { - $this->_shortOptions[$option->short()] = $name; - } - return $this; - } - -/** - * Add a positional argument to the option parser. - * - * ### Params - * - * - `help` The help text to display for this argument. - * - `required` Whether this parameter is required. - * - `index` The index for the arg, if left undefined the argument will be put - * onto the end of the arguments. If you define the same index twice the first - * 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. - * - * @param mixed $name The name of the argument. Will also accept an instance of ConsoleInputArgument - * @param array $params Parameters for the argument, see above. - * @return ConsoleOptionParser $this. - */ - public function addArgument($name, $params = array()) { - if (is_object($name) && $name instanceof ConsoleInputArgument) { - $arg = $name; - $index = count($this->_args); - } else { - $defaults = array( - 'name' => $name, - 'help' => '', - 'index' => count($this->_args), - 'required' => false, - 'choices' => array() - ); - $options = array_merge($defaults, $params); - $index = $options['index']; - unset($options['index']); - $arg = new ConsoleInputArgument($options); - } - $this->_args[$index] = $arg; - return $this; - } - -/** - * 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. - * @see ConsoleOptionParser::addArgument() - * @return ConsoleOptionParser $this - */ - public function addArguments(array $args) { - foreach ($args as $name => $params) { - $this->addArgument($name, $params); - } - return $this; - } - -/** - * 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. - * @see ConsoleOptionParser::addOption() - * @return ConsoleOptionParser $this - */ - public function addOptions(array $options) { - foreach ($options as $name => $params) { - $this->addOption($name, $params); - } - return $this; - } - -/** - * 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 mixed $name Name of the subcommand. Will also accept an instance of ConsoleInputSubcommand - * @param array $options Array of params, see above. - * @return ConsoleOptionParser $this. - */ - public function addSubcommand($name, $options = array()) { - if (is_object($name) && $name instanceof ConsoleInputSubcommand) { - $command = $name; - $name = $command->name(); - } else { - $defaults = array( - 'name' => $name, - 'help' => '', - 'parser' => null - ); - $options = array_merge($defaults, $options); - $command = new ConsoleInputSubcommand($options); - } - $this->_subcommands[$name] = $command; - return $this; - } - -/** - * Add multiple subcommands at once. - * - * @param array $commands Array of subcommands. - * @return ConsoleOptionParser $this - */ - public function addSubcommands(array $commands) { - foreach ($commands as $name => $params) { - $this->addSubcommand($name, $params); - } - return $this; - } - -/** - * Gets the arguments defined in the parser. - * - * @return array Array of argument descriptions - */ - public function arguments() { - return $this->_args; - } - -/** - * Get the defined options in the parser. - * - * @return array - */ - public function options() { - return $this->_options; - } - -/** - * Get the array of defined subcommands - * - * @return array - */ - 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 - * - * @param array $argv Array of args (argv) to parse. - * @param string $command The subcommand to use. If this parameter is a subcommand, that has a parser, - * That parser will be used to parse $argv instead. - * @return Array array($params, $args) - * @throws ConsoleException When an invalid parameter is encountered. - */ - public function parse($argv, $command = null) { - if (isset($this->_subcommands[$command]) && $this->_subcommands[$command]->parser()) { - return $this->_subcommands[$command]->parser()->parse($argv); - } - $params = $args = array(); - $this->_tokens = $argv; - while (($token = array_shift($this->_tokens)) !== null) { - if (substr($token, 0, 2) == '--') { - $params = $this->_parseLongOption($token, $params); - } elseif (substr($token, 0, 1) == '-') { - $params = $this->_parseShortOption($token, $params); - } else { - $args = $this->_parseArg($token, $args); - } - } - foreach ($this->_args as $i => $arg) { - if ($arg->isRequired() && !isset($args[$i]) && empty($params['help'])) { - throw new ConsoleException( - __d('cake_console', 'Missing required arguments. %s is required.', $arg->name()) - ); - } - } - foreach ($this->_options as $option) { - $name = $option->name(); - $isBoolean = $option->isBoolean(); - $default = $option->defaultValue(); - - if ($default !== null && !isset($params[$name]) && !$isBoolean) { - $params[$name] = $default; - } - if ($isBoolean && !isset($params[$name])) { - $params[$name] = false; - } - } - return array($params, $args); - } - -/** - * Gets formatted help for this parser object. - * Generates help text based on the description, options, arguments, subcommands and epilog - * in the parser. - * - * @param string $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 integer $width The width to format user content to. Defaults to 72 - * @return string Generated help. - */ - public function help($subcommand = null, $format = 'text', $width = 72) { - if ( - isset($this->_subcommands[$subcommand]) && - $this->_subcommands[$subcommand]->parser() instanceof self - ) { - $subparser = $this->_subcommands[$subcommand]->parser(); - $subparser->command($this->command() . ' ' . $subparser->command()); - return $subparser->help(null, $format, $width); - } - $formatter = new HelpFormatter($this); - if ($format == 'text' || $format === true) { - return $formatter->text($width); - } elseif ($format == 'xml') { - return $formatter->xml(); - } - } - -/** - * 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 - * @return array Params with $option added in. - */ - protected function _parseLongOption($option, $params) { - $name = substr($option, 2); - if (strpos($name, '=') !== false) { - list($name, $value) = explode('=', $name, 2); - array_unshift($this->_tokens, $value); - } - return $this->_parseOption($name, $params); - } - -/** - * Parse the value for a short option out of $this->_tokens - * If the $option is a combination of multiple shortcuts like -otf - * 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. - * @throws ConsoleException When unknown short options are encountered. - */ - protected function _parseShortOption($option, $params) { - $key = substr($option, 1); - if (strlen($key) > 1) { - $flags = str_split($key); - $key = $flags[0]; - for ($i = 1, $len = count($flags); $i < $len; $i++) { - array_unshift($this->_tokens, '-' . $flags[$i]); - } - } - if (!isset($this->_shortOptions[$key])) { - throw new ConsoleException(__d('cake_console', 'Unknown short option `%s`', $key)); - } - $name = $this->_shortOptions[$key]; - return $this->_parseOption($name, $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. - * @throws ConsoleException - */ - protected function _parseOption($name, $params) { - if (!isset($this->_options[$name])) { - throw new ConsoleException(__d('cake_console', 'Unknown option `%s`', $name)); - } - $option = $this->_options[$name]; - $isBoolean = $option->isBoolean(); - $nextValue = $this->_nextToken(); - if (!$isBoolean && !empty($nextValue) && !$this->_optionExists($nextValue)) { - array_shift($this->_tokens); - $value = $nextValue; - } elseif ($isBoolean) { - $value = true; - } else { - $value = $option->defaultValue(); - } - if ($option->validChoice($value)) { - $params[$name] = $value; - return $params; - } - } - -/** - * Check to see if $name has an option (short/long) defined for it. - * - * @param string $name The name of the option. - * @return boolean - */ - protected function _optionExists($name) { - if (substr($name, 0, 2) === '--') { - return isset($this->_options[substr($name, 2)]); - } - if ($name{0} === '-' && $name{1} !== '-') { - return isset($this->_shortOptions[$name{1}]); - } - return false; - } - -/** - * Parse an argument, and ensure that the argument doesn't exceed the number of arguments - * and that the argument is a valid choice. - * - * @param string $argument The argument to append - * @param array $args The array of parsed args to append to. - * @return array Args - * @throws ConsoleException - */ - protected function _parseArg($argument, $args) { - if (empty($this->_args)) { - array_push($args, $argument); - return $args; - } - $next = count($args); - if (!isset($this->_args[$next])) { - throw new ConsoleException(__d('cake_console', 'Too many arguments.')); - } - - if ($this->_args[$next]->validChoice($argument)) { - array_push($args, $argument); - return $args; - } - } - -/** - * Find the next token in the argv set. - * - * @return string next token or '' - */ - protected function _nextToken() { - return isset($this->_tokens[0]) ? $this->_tokens[0] : ''; - } - -} diff --git a/lib/Cake/Console/ConsoleOutput.php b/lib/Cake/Console/ConsoleOutput.php deleted file mode 100644 index 9b0bd3a963c..00000000000 --- a/lib/Cake/Console/ConsoleOutput.php +++ /dev/null @@ -1,288 +0,0 @@ -out('Overwrite: foo.php was overwritten.');` - * - * This would create orange 'Overwrite:' text, while the rest of the text would remain the normal color. - * See ConsoleOutput::styles() to learn more about defining your own styles. Nested styles are not supported - * at this time. - * - * @package Cake.Console - */ -class ConsoleOutput { -/** - * Raw output constant - no modification of output text. - */ - const RAW = 0; - -/** - * Plain output - tags will be stripped. - */ - const PLAIN = 1; - -/** - * Color output - Convert known tags in to ANSI color escape codes. - */ - const COLOR = 2; - -/** - * Constant for a newline. - */ - const LF = PHP_EOL; - -/** - * File handle for output. - * - * @var resource - */ - protected $_output; - -/** - * The current output type. Manipulated with ConsoleOutput::outputAs(); - * - * @var integer. - */ - protected $_outputAs = self::COLOR; - -/** - * text colors used in colored output. - * - * @var array - */ - protected static $_foregroundColors = array( - 'black' => 30, - 'red' => 31, - 'green' => 32, - 'yellow' => 33, - 'blue' => 34, - 'magenta' => 35, - 'cyan' => 36, - 'white' => 37 - ); - -/** - * background colors used in colored output. - * - * @var array - */ - protected static $_backgroundColors = array( - 'black' => 40, - 'red' => 41, - 'green' => 42, - 'yellow' => 43, - 'blue' => 44, - 'magenta' => 45, - 'cyan' => 46, - 'white' => 47 - ); - -/** - * formatting options for colored output - * - * @var string - */ - protected static $_options = array( - 'bold' => 1, - 'underline' => 4, - 'blink' => 5, - 'reverse' => 7, - ); - -/** - * Styles that are available as tags in console output. - * You can modify these styles with ConsoleOutput::styles() - * - * @var array - */ - protected static $_styles = array( - 'error' => array('text' => 'red', 'underline' => true), - 'warning' => array('text' => 'yellow'), - 'info' => array('text' => 'cyan'), - 'success' => array('text' => 'green'), - 'comment' => array('text' => 'blue'), - 'question' => array('text' => "magenta"), - ); - -/** - * Construct the output object. - * - * Checks for a pretty console environment. Ansicon allows pretty consoles - * on windows, and is supported. - * - * @param string $stream The identifier of the stream to write output to. - */ - public function __construct($stream = 'php://stdout') { - $this->_output = fopen($stream, 'w'); - - if (DS == '\\' && !(bool)env('ANSICON')) { - $this->_outputAs = self::PLAIN; - } - } - -/** - * Outputs a single or multiple messages to stdout. If no parameters - * are passed, outputs just a newline. - * - * @param mixed $message A string or a an array of strings to output - * @param integer $newlines Number of newlines to append - * @return integer Returns the number of bytes returned from writing to stdout. - */ - public function write($message, $newlines = 1) { - if (is_array($message)) { - $message = implode(self::LF, $message); - } - return $this->_write($this->styleText($message . str_repeat(self::LF, $newlines))); - } - -/** - * Apply styling to text. - * - * @param string $text Text with styling tags. - * @return string String with color codes added. - */ - public function styleText($text) { - if ($this->_outputAs == self::RAW) { - return $text; - } - if ($this->_outputAs == self::PLAIN) { - $tags = implode('|', array_keys(self::$_styles)); - return preg_replace('##', '', $text); - } - return preg_replace_callback( - '/<(?[a-z0-9-_]+)>(?.*?)<\/(\1)>/ims', array($this, '_replaceTags'), $text - ); - } - -/** - * Replace tags with color codes. - * - * @param array $matches. - * @return string - */ - protected function _replaceTags($matches) { - $style = $this->styles($matches['tag']); - if (empty($style)) { - return '<' . $matches['tag'] . '>' . $matches['text'] . ''; - } - - $styleInfo = array(); - if (!empty($style['text']) && isset(self::$_foregroundColors[$style['text']])) { - $styleInfo[] = self::$_foregroundColors[$style['text']]; - } - if (!empty($style['background']) && isset(self::$_backgroundColors[$style['background']])) { - $styleInfo[] = self::$_backgroundColors[$style['background']]; - } - unset($style['text'], $style['background']); - foreach ($style as $option => $value) { - if ($value) { - $styleInfo[] = self::$_options[$option]; - } - } - return "\033[" . implode($styleInfo, ';') . 'm' . $matches['text'] . "\033[0m"; - } - -/** - * Writes a message to the output stream. - * - * @param string $message Message to write. - * @return boolean success - */ - protected function _write($message) { - return fwrite($this->_output, $message); - } - -/** - * Get the current styles offered, or append new ones in. - * - * ### Get a style definition - * - * `$this->output->styles('error');` - * - * ### Get all the style definitions - * - * `$this->output->styles();` - * - * ### Create or modify an existing style - * - * `$this->output->styles('annoy', array('text' => 'purple', 'background' => 'yellow', 'blink' => true));` - * - * ### Remove a style - * - * `$this->output->styles('annoy', false);` - * - * @param string $style The style to get or create. - * @param mixed $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. - */ - public function styles($style = null, $definition = null) { - if ($style === null && $definition === null) { - return self::$_styles; - } - if (is_string($style) && $definition === null) { - return isset(self::$_styles[$style]) ? self::$_styles[$style] : null; - } - if ($definition === false) { - unset(self::$_styles[$style]); - return true; - } - self::$_styles[$style] = $definition; - return true; - } - -/** - * Get/Set the output type to use. The output type how formatting tags are treated. - * - * @param integer $type The output type to use. Should be one of the class constants. - * @return mixed 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() { - fclose($this->_output); - } - -} diff --git a/lib/Cake/Console/HelpFormatter.php b/lib/Cake/Console/HelpFormatter.php deleted file mode 100644 index 2b495b9e170..00000000000 --- a/lib/Cake/Console/HelpFormatter.php +++ /dev/null @@ -1,201 +0,0 @@ -help($command, 'xml'); is usually - * how you would access help. Or via the `--help=xml` option on the command line. - * - * Xml output is useful for integration with other tools like IDE's or other build tools. - * - * @package Cake.Console - * @since CakePHP(tm) v 2.0 - */ -class HelpFormatter { - -/** - * The maximum number of arguments shown when generating usage. - * - * @var integer - */ - protected $_maxArgs = 6; - -/** - * The maximum number of options shown when generating usage. - * - * @var integer - */ - protected $_maxOptions = 6; - -/** - * Build the help formatter for a an OptionParser - * - * @param ConsoleOptionParser $parser The option parser help is being generated for. - */ - public function __construct(ConsoleOptionParser $parser) { - $this->_parser = $parser; - } - -/** - * Get the help as formatted text suitable for output on the command line. - * - * @param integer $width The width of the help output. - * @return string - */ - public function text($width = 72) { - $parser = $this->_parser; - $out = array(); - $description = $parser->description(); - if (!empty($description)) { - $out[] = String::wrap($description, $width); - $out[] = ''; - } - $out[] = __d('cake_console', 'Usage:'); - $out[] = $this->_generateUsage(); - $out[] = ''; - $subcommands = $parser->subcommands(); - if (!empty($subcommands)) { - $out[] = __d('cake_console', 'Subcommands:'); - $out[] = ''; - $max = $this->_getMaxLength($subcommands) + 2; - foreach ($subcommands as $command) { - $out[] = String::wrap($command->help($max), array( - 'width' => $width, - 'indent' => str_repeat(' ', $max), - 'indentAt' => 1 - )); - } - $out[] = ''; - $out[] = __d('cake_console', 'To see help on a subcommand use `cake %s [subcommand] --help`', $parser->command()); - $out[] = ''; - } - - $options = $parser->options(); - if (!empty($options)) { - $max = $this->_getMaxLength($options) + 8; - $out[] = __d('cake_console', 'Options:'); - $out[] = ''; - foreach ($options as $option) { - $out[] = String::wrap($option->help($max), array( - 'width' => $width, - 'indent' => str_repeat(' ', $max), - 'indentAt' => 1 - )); - } - $out[] = ''; - } - - $arguments = $parser->arguments(); - if (!empty($arguments)) { - $max = $this->_getMaxLength($arguments) + 2; - $out[] = __d('cake_console', 'Arguments:'); - $out[] = ''; - foreach ($arguments as $argument) { - $out[] = String::wrap($argument->help($max), array( - 'width' => $width, - 'indent' => str_repeat(' ', $max), - 'indentAt' => 1 - )); - } - $out[] = ''; - } - $epilog = $parser->epilog(); - if (!empty($epilog)) { - $out[] = String::wrap($epilog, $width); - $out[] = ''; - } - return implode("\n", $out); - } - -/** - * Generate the usage for a shell based on its arguments and options. - * Usage strings favor short options over the long ones. and optional args will - * be indicated with [] - * - * @return string - */ - protected function _generateUsage() { - $usage = array('cake ' . $this->_parser->command()); - $subcommands = $this->_parser->subcommands(); - if (!empty($subcommands)) { - $usage[] = '[subcommand]'; - } - $options = array(); - foreach ($this->_parser->options() as $option) { - $options[] = $option->usage(); - } - if (count($options) > $this->_maxOptions) { - $options = array('[options]'); - } - $usage = array_merge($usage, $options); - $args = array(); - foreach ($this->_parser->arguments() as $argument) { - $args[] = $argument->usage(); - } - if (count($args) > $this->_maxArgs) { - $args = array('[arguments]'); - } - $usage = array_merge($usage, $args); - return implode(' ', $usage); - } - -/** - * Iterate over a collection and find the longest named thing. - * - * @param array $collection - * @return integer - */ - protected function _getMaxLength($collection) { - $max = 0; - foreach ($collection as $item) { - $max = (strlen($item->name()) > $max) ? strlen($item->name()) : $max; - } - return $max; - } - -/** - * Get the help as an xml string. - * - * @param boolean $string Return the SimpleXml object or a string. Defaults to true. - * @return mixed. See $string - */ - public function xml($string = true) { - $parser = $this->_parser; - $xml = new SimpleXmlElement(''); - $xml->addChild('command', $parser->command()); - $xml->addChild('description', $parser->description()); - - $xml->addChild('epilog', $parser->epilog()); - $subcommands = $xml->addChild('subcommands'); - foreach ($parser->subcommands() as $command) { - $command->xml($subcommands); - } - $options = $xml->addChild('options'); - foreach ($parser->options() as $option) { - $option->xml($options); - } - $arguments = $xml->addChild('arguments'); - foreach ($parser->arguments() as $argument) { - $argument->xml($arguments); - } - return $string ? $xml->asXml() : $xml; - } - -} diff --git a/lib/Cake/Console/Shell.php b/lib/Cake/Console/Shell.php deleted file mode 100644 index 788bb8b397c..00000000000 --- a/lib/Cake/Console/Shell.php +++ /dev/null @@ -1,816 +0,0 @@ -name == null) { - $this->name = Inflector::camelize(str_replace(array('Shell', 'Task'), '', get_class($this))); - } - $this->Tasks = new TaskCollection($this); - - $this->stdout = $stdout; - $this->stderr = $stderr; - $this->stdin = $stdin; - if ($this->stdout == null) { - $this->stdout = new ConsoleOutput('php://stdout'); - } - if ($this->stderr == null) { - $this->stderr = new ConsoleOutput('php://stderr'); - } - if ($this->stdin == null) { - $this->stdin = new ConsoleInput('php://stdin'); - } - - $parent = get_parent_class($this); - if ($this->tasks !== null && $this->tasks !== false) { - $this->_mergeVars(array('tasks'), $parent, true); - } - if ($this->uses !== null && $this->uses !== false) { - $this->_mergeVars(array('uses'), $parent, false); - } - } - -/** - * Initializes the Shell - * acts as constructor for subclasses - * allows configuration of tasks prior to shell execution - * - * @return void - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::initialize - */ - public function initialize() { - $this->_loadModels(); - } - -/** - * 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 http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::startup - */ - public function startup() { - $this->_welcome(); - } - -/** - * Displays a header for the shell - * - * @return void - */ - protected function _welcome() { - $this->out(); - $this->out(__d('cake_console', 'Welcome to CakePHP %s Console', 'v' . Configure::version())); - $this->hr(); - $this->out(__d('cake_console', 'App : %s', APP_DIR)); - $this->out(__d('cake_console', 'Path: %s', APP)); - $this->hr(); - } - -/** - * If $uses = true - * Loads AppModel file and constructs AppModel class - * makes $this->AppModel available to subclasses - * If public $uses is an array of models will load those models - * - * @return boolean - */ - protected function _loadModels() { - if ($this->uses === null || $this->uses === false) { - return; - } - App::uses('ClassRegistry', 'Utility'); - - if ($this->uses !== true && !empty($this->uses)) { - $uses = is_array($this->uses) ? $this->uses : array($this->uses); - - $modelClassName = $uses[0]; - if (strpos($uses[0], '.') !== false) { - list($plugin, $modelClassName) = explode('.', $uses[0]); - } - $this->modelClass = $modelClassName; - - foreach ($uses as $modelClass) { - list($plugin, $modelClass) = pluginSplit($modelClass, true); - $this->{$modelClass} = ClassRegistry::init($plugin . $modelClass); - } - return true; - } - return false; - } - -/** - * Loads tasks defined in public $tasks - * - * @return boolean - */ - public function loadTasks() { - if ($this->tasks === true || empty($this->tasks) || empty($this->Tasks)) { - return true; - } - $this->_taskMap = TaskCollection::normalizeObjectArray((array)$this->tasks); - foreach ($this->_taskMap as $task => $properties) { - $this->taskNames[] = $task; - } - return true; - } - -/** - * Check to see if this shell has a task with the provided name. - * - * @param string $task The task name to check. - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::hasTask - */ - 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 boolean - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::hasMethod - */ - public function hasMethod($name) { - try { - $method = new ReflectionMethod($this, $name); - if (!$method->isPublic() || substr($name, 0, 1) === '_') { - return false; - } - if ($method->getDeclaringClass()->name == 'Shell') { - return false; - } - return true; - } 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');` - * - * @return mixed The return of the other shell. - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::dispatchShell - */ - public function dispatchShell() { - $args = func_get_args(); - if (is_string($args[0]) && count($args) == 1) { - $args = explode(' ', $args[0]); - } - - $Dispatcher = new ShellDispatcher($args, false); - return $Dispatcher->dispatch(); - } - -/** - * 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. - * - * @param string $command The command name to run on this shell. If this argument is empty, - * and the shell has a `main()` method, that will be called instead. - * @param array $argv Array of arguments to run the shell with. This array should be missing the shell name. - * @return void - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::runCommand - */ - public function runCommand($command, $argv) { - $isTask = $this->hasTask($command); - $isMethod = $this->hasMethod($command); - $isMain = $this->hasMethod('main'); - - if ($isTask || $isMethod && $command !== 'execute') { - array_shift($argv); - } - - try { - $this->OptionParser = $this->getOptionParser(); - list($this->params, $this->args) = $this->OptionParser->parse($argv, $command); - } catch (ConsoleException $e) { - $this->out($this->OptionParser->help($command)); - return false; - } - - $this->command = $command; - if (!empty($this->params['help'])) { - return $this->_displayHelp($command); - } - - if (($isTask || $isMethod || $isMain) && $command !== 'execute') { - $this->startup(); - } - - if ($isTask) { - $command = Inflector::camelize($command); - return $this->{$command}->runCommand('execute', $argv); - } - if ($isMethod) { - return $this->{$command}(); - } - if ($isMain) { - return $this->main(); - } - $this->out($this->OptionParser->help($command)); - return false; - } - -/** - * Display the help in the correct format - * - * @param string $command - * @return void - */ - protected function _displayHelp($command) { - $format = 'text'; - if (!empty($this->args[0]) && $this->args[0] == 'xml') { - $format = 'xml'; - $this->stdout->outputAs(ConsoleOutput::RAW); - } else { - $this->_welcome(); - } - 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 ConsoleOptionParser - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::getOptionParser - */ - public function getOptionParser() { - $name = ($this->plugin ? $this->plugin . '.' : '') . $this->name; - $parser = new ConsoleOptionParser($name); - return $parser; - } - -/** - * Overload get for lazy building of tasks - * - * @param string $name - * @return 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['settings']); - $this->{$name}->args =& $this->args; - $this->{$name}->params =& $this->params; - $this->{$name}->initialize(); - $this->{$name}->loadTasks(); - } - return $this->{$name}; - } - -/** - * Prompts the user for input, and returns it. - * - * @param string $prompt Prompt text. - * @param mixed $options Array or string of options. - * @param string $default Default input value. - * @return mixed Either the default value, or the user-provided input. - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::in - */ - public function in($prompt, $options = null, $default = null) { - if (!$this->interactive) { - return $default; - } - $originalOptions = $options; - $in = $this->_getInput($prompt, $originalOptions, $default); - - if ($options && is_string($options)) { - if (strpos($options, ',')) { - $options = explode(',', $options); - } elseif (strpos($options, '/')) { - $options = explode('/', $options); - } else { - $options = array($options); - } - } - if (is_array($options)) { - $options = array_merge( - array_map('strtolower', $options), - array_map('strtoupper', $options), - $options - ); - while ($in === '' || !in_array($in, $options)) { - $in = $this->_getInput($prompt, $originalOptions, $default); - } - } - return $in; - } - -/** - * Prompts the user for input, and returns it. - * - * @param string $prompt Prompt text. - * @param mixed $options Array or string of options. - * @param string $default Default input value. - * @return Either the default value, or the user-provided input. - */ - protected function _getInput($prompt, $options, $default) { - if (!is_array($options)) { - $printOptions = ''; - } else { - $printOptions = '(' . implode('/', $options) . ')'; - } - - if ($default === null) { - $this->stdout->write('' . $prompt . '' . " $printOptions \n" . '> ', 0); - } else { - $this->stdout->write('' . $prompt . '' . " $printOptions \n" . "[$default] > ", 0); - } - $result = $this->stdin->read(); - - if ($result === false) { - $this->_stop(1); - } - $result = trim($result); - - if ($default !== null && ($result === '' || $result === null)) { - return $default; - } - return $result; - } - -/** - * 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 mixed $options Array of options to use, or an integer to wrap the text to. - * @return string Wrapped / indented text - * @see String::wrap() - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::wrapText - */ - public function wrapText($text, $options = array()) { - return String::wrap($text, $options); - } - -/** - * 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 mixed $message A string or a an array of strings to output - * @param integer $newlines Number of newlines to append - * @param integer $level The message's output level, see above. - * @return integer|boolean Returns the number of bytes returned from writing to stdout. - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::out - */ - public function out($message = null, $newlines = 1, $level = Shell::NORMAL) { - $currentLevel = Shell::NORMAL; - if (!empty($this->params['verbose'])) { - $currentLevel = Shell::VERBOSE; - } - if (!empty($this->params['quiet'])) { - $currentLevel = Shell::QUIET; - } - if ($level <= $currentLevel) { - return $this->stdout->write($message, $newlines); - } - return true; - } - -/** - * Outputs a single or multiple error messages to stderr. If no parameters - * are passed outputs just a newline. - * - * @param mixed $message A string or a an array of strings to output - * @param integer $newlines Number of newlines to append - * @return void - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::err - */ - public function err($message = null, $newlines = 1) { - $this->stderr->write($message, $newlines); - } - -/** - * Returns a single or multiple linefeeds sequences. - * - * @param integer $multiplier Number of times the linefeed sequence should be repeated - * @return string - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::nl - */ - public function nl($multiplier = 1) { - return str_repeat(ConsoleOutput::LF, $multiplier); - } - -/** - * Outputs a series of minus characters to the standard output, acts as a visual separator. - * - * @param integer $newlines Number of newlines to pre- and append - * @param integer $width Width of the line, defaults to 63 - * @return void - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::hr - */ - public function hr($newlines = 0, $width = 63) { - $this->out(null, $newlines); - $this->out(str_repeat('-', $width)); - $this->out(null, $newlines); - } - -/** - * Displays a formatted error message - * and exits the application with status code 1 - * - * @param string $title Title of the error - * @param string $message An optional error message - * @return void - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::error - */ - public function error($title, $message = null) { - $this->err(__d('cake_console', 'Error: %s', $title)); - - if (!empty($message)) { - $this->err($message); - } - $this->_stop(1); - } - -/** - * Clear the console - * - * @return void - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::clear - */ - public function clear() { - if (empty($this->params['noclear'])) { - if (DS === '/') { - 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 boolean Success - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::createFile - */ - public function createFile($path, $contents) { - $path = str_replace(DS . DS, DS, $path); - - $this->out(); - - if (is_file($path) && $this->interactive === true) { - $this->out(__d('cake_console', 'File `%s` exists', $path)); - $key = $this->in(__d('cake_console', 'Do you want to overwrite?'), array('y', 'n', 'q'), 'n'); - - if (strtolower($key) == 'q') { - $this->out(__d('cake_console', 'Quitting.'), 2); - $this->_stop(); - } elseif (strtolower($key) != 'y') { - $this->out(__d('cake_console', 'Skip `%s`', $path), 2); - return false; - } - } else { - $this->out(__d('cake_console', 'Creating file %s', $path)); - } - - $File = new File($path, true); - if ($File->exists() && $File->writable()) { - $data = $File->prepare($contents); - $File->write($data); - $this->out(__d('cake_console', 'Wrote `%s`', $path)); - return true; - } else { - $this->err(__d('cake_console', 'Could not write to `%s`.', $path), 2); - return false; - } - } - -/** - * Action to create a Unit Test - * - * @return boolean Success - */ - protected function _checkUnitTest() { - if (App::import('Vendor', 'phpunit', array('file' => 'PHPUnit' . DS . 'Autoload.php'))) { - return true; - } - if (@include 'PHPUnit' . DS . 'Autoload.php') { - return true; - } - $prompt = __d('cake_console', 'PHPUnit is not installed. Do you want to bake unit test files anyway?'); - $unitTest = $this->in($prompt, array('y', 'n'), 'y'); - $result = strtolower($unitTest) == 'y' || strtolower($unitTest) == 'yes'; - - if ($result) { - $this->out(); - $this->out(__d('cake_console', 'You can download PHPUnit from %s', 'http://phpunit.de')); - } - return $result; - } - -/** - * Makes absolute file path easier to read - * - * @param string $file Absolute file path - * @return string short path - * @link http://book.cakephp.org/2.0/en/console-and-shells.html#Shell::shortPath - */ - public function shortPath($file) { - $shortPath = str_replace(ROOT, null, $file); - $shortPath = str_replace('..' . DS, '', $shortPath); - return str_replace(DS . DS, DS, $shortPath); - } - -/** - * Creates the proper controller path for the specified controller class name - * - * @param string $name Controller class name - * @return string Path to controller - */ - protected function _controllerPath($name) { - return Inflector::underscore($name); - } - -/** - * Creates the proper controller plural name for the specified controller class name - * - * @param string $name Controller class name - * @return string Controller plural name - */ - protected function _controllerName($name) { - return Inflector::pluralize(Inflector::camelize($name)); - } - -/** - * Creates the proper model camelized name (singularized) for the specified name - * - * @param string $name Name - * @return string Camelized and singularized model name - */ - protected function _modelName($name) { - return Inflector::camelize(Inflector::singularize($name)); - } - -/** - * Creates the proper underscored model key for associations - * - * @param string $name Model class name - * @return string Singular model key - */ - protected function _modelKey($name) { - return Inflector::underscore($name) . '_id'; - } - -/** - * Creates the proper model name from a foreign key - * - * @param string $key Foreign key - * @return string Model name - */ - protected function _modelNameFromKey($key) { - return Inflector::camelize(str_replace('_id', '', $key)); - } - -/** - * creates the singular name for use in views. - * - * @param string $name - * @return string $name - */ - protected function _singularName($name) { - return Inflector::variable(Inflector::singularize($name)); - } - -/** - * Creates the plural name for views - * - * @param string $name Name to use - * @return string Plural name for views - */ - protected function _pluralName($name) { - return Inflector::variable(Inflector::pluralize($name)); - } - -/** - * Creates the singular human name used in views - * - * @param string $name Controller name - * @return string Singular human name - */ - protected function _singularHumanName($name) { - return Inflector::humanize(Inflector::underscore(Inflector::singularize($name))); - } - -/** - * Creates the plural human name used in views - * - * @param string $name Controller name - * @return string Plural human name - */ - protected function _pluralHumanName($name) { - return Inflector::humanize(Inflector::underscore($name)); - } - -/** - * Find the correct path for a plugin. Scans $pluginPaths for the plugin you want. - * - * @param string $pluginName Name of the plugin you want ie. DebugKit - * @return string $path path to the correct plugin. - */ - protected function _pluginPath($pluginName) { - if (CakePlugin::loaded($pluginName)) { - return CakePlugin::path($pluginName); - } - return current(App::path('plugins')) . $pluginName . DS; - } - -} diff --git a/lib/Cake/Console/ShellDispatcher.php b/lib/Cake/Console/ShellDispatcher.php deleted file mode 100644 index 5f2f5a6aa5d..00000000000 --- a/lib/Cake/Console/ShellDispatcher.php +++ /dev/null @@ -1,332 +0,0 @@ -_initConstants(); - } - $this->parseParams($args); - if ($bootstrap) { - $this->_initEnvironment(); - } - } - -/** - * Run the dispatcher - * - * @param array $argv The argv from PHP - * @return void - */ - public static function run($argv) { - $dispatcher = new ShellDispatcher($argv); - $dispatcher->_stop($dispatcher->dispatch() === false ? 1 : 0); - } - -/** - * Defines core configuration. - * - * @return void - */ - protected function _initConstants() { - if (function_exists('ini_set')) { - ini_set('html_errors', false); - ini_set('implicit_flush', true); - ini_set('max_execution_time', 0); - } - - if (!defined('CAKE_CORE_INCLUDE_PATH')) { - define('DS', DIRECTORY_SEPARATOR); - define('CAKE_CORE_INCLUDE_PATH', dirname(dirname(dirname(__FILE__)))); - define('CAKEPHP_SHELL', true); - if (!defined('CORE_PATH')) { - define('CORE_PATH', CAKE_CORE_INCLUDE_PATH . DS); - } - } - } - -/** - * Defines current working environment. - * - * @return void - * @throws CakeException - */ - protected function _initEnvironment() { - if (!$this->_bootstrap()) { - $message = "Unable to load CakePHP core.\nMake sure " . DS . 'lib' . DS . 'Cake exists in ' . CAKE_CORE_INCLUDE_PATH; - throw new CakeException($message); - } - - if (!isset($this->args[0]) || !isset($this->params['working'])) { - $message = "This file has been loaded incorrectly and cannot continue.\n" . - "Please make sure that " . DS . 'lib' . DS . 'Cake' . DS . "Console is in your system path,\n" . - "and check the cookbook for the correct usage of this command.\n" . - "(http://book.cakephp.org/)"; - throw new CakeException($message); - } - - $this->shiftArgs(); - } - -/** - * Initializes the environment and loads the Cake core. - * - * @return boolean Success. - */ - protected function _bootstrap() { - define('ROOT', $this->params['root']); - define('APP_DIR', $this->params['app']); - define('APP', $this->params['working'] . DS); - define('WWW_ROOT', APP . $this->params['webroot'] . DS); - if (!is_dir(ROOT . DS . APP_DIR . DS . 'tmp')) { - define('TMP', CAKE_CORE_INCLUDE_PATH . DS . 'Cake' . DS . 'Console' . DS . 'Templates' . DS . 'skel' . DS . 'tmp' . DS); - } - $boot = file_exists(ROOT . DS . APP_DIR . DS . 'Config' . DS . 'bootstrap.php'); - require CORE_PATH . 'Cake' . DS . 'bootstrap.php'; - - if (!file_exists(APP . 'Config' . DS . 'core.php')) { - include_once CAKE_CORE_INCLUDE_PATH . DS . 'Cake' . DS . 'Console' . DS . 'Templates' . DS . 'skel' . DS . 'Config' . DS . 'core.php'; - App::build(); - } - require_once CAKE . 'Console' . DS . 'ConsoleErrorHandler.php'; - $ErrorHandler = new ConsoleErrorHandler(); - set_exception_handler(array($ErrorHandler, 'handleException')); - set_error_handler(array($ErrorHandler, 'handleError'), Configure::read('Error.level')); - - if (!defined('FULL_BASE_URL')) { - define('FULL_BASE_URL', 'http://localhost'); - } - - return true; - } - -/** - * Dispatches a CLI request - * - * @return boolean - * @throws MissingShellMethodException - */ - public function dispatch() { - $shell = $this->shiftArgs(); - - if (!$shell) { - $this->help(); - return false; - } - if (in_array($shell, array('help', '--help', '-h'))) { - $this->help(); - return true; - } - - $Shell = $this->_getShell($shell); - - $command = null; - if (isset($this->args[0])) { - $command = $this->args[0]; - } - - if ($Shell instanceof Shell) { - $Shell->initialize(); - $Shell->loadTasks(); - return $Shell->runCommand($command, $this->args); - } - $methods = array_diff(get_class_methods($Shell), get_class_methods('Shell')); - $added = in_array($command, $methods); - $private = $command[0] == '_' && method_exists($Shell, $command); - - if (!$private) { - if ($added) { - $this->shiftArgs(); - $Shell->startup(); - return $Shell->{$command}(); - } - if (method_exists($Shell, 'main')) { - $Shell->startup(); - return $Shell->main(); - } - } - throw new MissingShellMethodException(array('shell' => $shell, 'method' => $arg)); - } - -/** - * Get shell to use, either plugin shell or application shell - * - * All paths in the loaded shell paths are searched. - * - * @param string $shell Optionally the name of a plugin - * @return mixed An object - * @throws MissingShellException when errors are encountered. - */ - protected function _getShell($shell) { - list($plugin, $shell) = pluginSplit($shell, true); - - $plugin = Inflector::camelize($plugin); - $class = Inflector::camelize($shell) . 'Shell'; - - App::uses('Shell', 'Console'); - App::uses('AppShell', 'Console/Command'); - App::uses($class, $plugin . 'Console/Command'); - - if (!class_exists($class)) { - throw new MissingShellException(array( - 'class' => $class - )); - } - $Shell = new $class(); - $Shell->plugin = trim($plugin, '.'); - return $Shell; - } - -/** - * Parses command line options and extracts the directory paths from $params - * - * @param array $args Parameters to parse - * @return void - */ - public function parseParams($args) { - $this->_parsePaths($args); - - $defaults = array( - 'app' => 'app', - 'root' => dirname(dirname(dirname(dirname(__FILE__)))), - 'working' => null, - 'webroot' => 'webroot' - ); - $params = array_merge($defaults, array_intersect_key($this->params, $defaults)); - $isWin = false; - foreach ($defaults as $default => $value) { - if (strpos($params[$default], '\\') !== false) { - $isWin = true; - break; - } - } - $params = str_replace('\\', '/', $params); - - if (isset($params['working'])) { - $params['working'] = trim($params['working']); - } - if (!empty($params['working']) && (!isset($this->args[0]) || isset($this->args[0]) && $this->args[0]{0} !== '.')) { - if (empty($this->params['app']) && $params['working'] != $params['root']) { - $params['root'] = dirname($params['working']); - $params['app'] = basename($params['working']); - } else { - $params['root'] = $params['working']; - } - } - - if ($params['app'][0] == '/' || preg_match('/([a-z])(:)/i', $params['app'], $matches)) { - $params['root'] = dirname($params['app']); - } elseif (strpos($params['app'], '/')) { - $params['root'] .= '/' . dirname($params['app']); - } - - $params['app'] = basename($params['app']); - $params['working'] = rtrim($params['root'], '/'); - if (!$isWin || !preg_match('/^[A-Z]:$/i', $params['app'])) { - $params['working'] .= '/' . $params['app']; - } - - if (!empty($matches[0]) || !empty($isWin)) { - $params = str_replace('/', '\\', $params); - } - - $this->params = array_merge($this->params, $params); - } - -/** - * Parses out the paths from from the argv - * - * @param array $args - * @return void - */ - protected function _parsePaths($args) { - $parsed = array(); - $keys = array('-working', '--working', '-app', '--app', '-root', '--root'); - foreach ($keys as $key) { - while (($index = array_search($key, $args)) !== false) { - $keyname = str_replace('-', '', $key); - $valueIndex = $index + 1; - $parsed[$keyname] = $args[$valueIndex]; - array_splice($args, $index, 2); - } - } - $this->args = $args; - $this->params = $parsed; - } - -/** - * 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(array('command_list'), $this->args); - $this->dispatch(); - } - -/** - * Stop execution of the current script - * - * @param integer|string $status see http://php.net/exit for values - * @return void - */ - protected function _stop($status = 0) { - exit($status); - } - -} diff --git a/lib/Cake/Console/TaskCollection.php b/lib/Cake/Console/TaskCollection.php deleted file mode 100644 index 35f21eecf68..00000000000 --- a/lib/Cake/Console/TaskCollection.php +++ /dev/null @@ -1,82 +0,0 @@ -_Shell = $Shell; - } - -/** - * Loads/constructs a task. Will return the instance in the collection - * if it already exists. - * - * @param string $task Task name to load - * @param array $settings Settings for the task. - * @return Task A task object, Either the existing loaded task or a new one. - * @throws MissingTaskException when the task could not be found - */ - public function load($task, $settings = array()) { - list($plugin, $name) = pluginSplit($task, true); - - if (isset($this->_loaded[$name])) { - return $this->_loaded[$name]; - } - $taskClass = $name . 'Task'; - App::uses($taskClass, $plugin . 'Console/Command/Task'); - if (!class_exists($taskClass)) { - if (!class_exists($taskClass)) { - throw new MissingTaskException(array( - 'class' => $taskClass - )); - } - } - - $this->_loaded[$name] = new $taskClass( - $this->_Shell->stdout, $this->_Shell->stderr, $this->_Shell->stdin - ); - return $this->_loaded[$name]; - } - -} diff --git a/lib/Cake/Console/Templates/default/actions/controller_actions.ctp b/lib/Cake/Console/Templates/default/actions/controller_actions.ctp deleted file mode 100644 index c0921c2c4ac..00000000000 --- a/lib/Cake/Console/Templates/default/actions/controller_actions.ctp +++ /dev/null @@ -1,157 +0,0 @@ - - -/** - * index method - * - * @return void - */ - public function index() { - $this->->recursive = 0; - $this->set('', $this->paginate()); - } - -/** - * view method - * - * @param string $id - * @return void - */ - public function view($id = null) { - $this->->id = $id; - if (!$this->->exists()) { - throw new NotFoundException(__('Invalid ')); - } - $this->set('', $this->->read(null, $id)); - } - - -/** - * add method - * - * @return void - */ - public function add() { - if ($this->request->is('post')) { - $this->->create(); - if ($this->->save($this->request->data)) { - - $this->Session->setFlash(__('The has been saved')); - $this->redirect(array('action' => 'index')); - - $this->flash(__(' saved.'), array('action' => 'index')); - - } else { - - $this->Session->setFlash(__('The could not be saved. Please, try again.')); - - } - } -{$assoc} as $associationName => $relation): - if (!empty($associationName)): - $otherModelName = $this->_modelName($associationName); - $otherPluralName = $this->_pluralName($associationName); - echo "\t\t\${$otherPluralName} = \$this->{$currentModelName}->{$otherModelName}->find('list');\n"; - $compact[] = "'{$otherPluralName}'"; - endif; - endforeach; - endforeach; - if (!empty($compact)): - echo "\t\t\$this->set(compact(".join(', ', $compact)."));\n"; - endif; -?> - } - - -/** - * edit method - * - * @param string $id - * @return void - */ - public function edit($id = null) { - $this->->id = $id; - if (!$this->->exists()) { - throw new NotFoundException(__('Invalid ')); - } - if ($this->request->is('post') || $this->request->is('put')) { - if ($this->->save($this->request->data)) { - - $this->Session->setFlash(__('The has been saved')); - $this->redirect(array('action' => 'index')); - - $this->flash(__('The has been saved.'), array('action' => 'index')); - - } else { - - $this->Session->setFlash(__('The could not be saved. Please, try again.')); - - } - } else { - $this->request->data = $this->->read(null, $id); - } -{$assoc} as $associationName => $relation): - if (!empty($associationName)): - $otherModelName = $this->_modelName($associationName); - $otherPluralName = $this->_pluralName($associationName); - echo "\t\t\${$otherPluralName} = \$this->{$currentModelName}->{$otherModelName}->find('list');\n"; - $compact[] = "'{$otherPluralName}'"; - endif; - endforeach; - endforeach; - if (!empty($compact)): - echo "\t\t\$this->set(compact(".join(', ', $compact)."));\n"; - endif; - ?> - } - -/** - * delete method - * - * @param string $id - * @return void - */ - public function delete($id = null) { - if (!$this->request->is('post')) { - throw new MethodNotAllowedException(); - } - $this->->id = $id; - if (!$this->->exists()) { - throw new NotFoundException(__('Invalid ')); - } - if ($this->->delete()) { - - $this->Session->setFlash(__(' deleted')); - $this->redirect(array('action' => 'index')); - - $this->flash(__(' deleted'), array('action' => 'index')); - - } - - $this->Session->setFlash(__(' was not deleted')); - - $this->flash(__(' was not deleted'), array('action' => 'index')); - - $this->redirect(array('action' => 'index')); - } \ No newline at end of file diff --git a/lib/Cake/Console/Templates/default/classes/controller.ctp b/lib/Cake/Console/Templates/default/classes/controller.ctp deleted file mode 100644 index 741e06b0079..00000000000 --- a/lib/Cake/Console/Templates/default/classes/controller.ctp +++ /dev/null @@ -1,81 +0,0 @@ - -/** - * Controller - * - - */ -class Controller extends AppController { - - -/** - * Scaffold - * - * @var mixed - */ - public $scaffold; - - - -} diff --git a/lib/Cake/Console/Templates/default/classes/fixture.ctp b/lib/Cake/Console/Templates/default/classes/fixture.ctp deleted file mode 100644 index 48f6c78345a..00000000000 --- a/lib/Cake/Console/Templates/default/classes/fixture.ctp +++ /dev/null @@ -1,62 +0,0 @@ - - -/** - * Fixture - * - */ -class Fixture extends CakeTestFixture { - -/** - * Table name - * - * @var string - */ - public $table = ''; - - -/** - * Import - * - * @var array - */ - public $import = ; - - - -/** - * Fields - * - * @var array - */ - public $fields = ; - - - -/** - * Records - * - * @var array - */ - public $records = ; - -} diff --git a/lib/Cake/Console/Templates/default/classes/model.ctp b/lib/Cake/Console/Templates/default/classes/model.ctp deleted file mode 100644 index 3634b802ff5..00000000000 --- a/lib/Cake/Console/Templates/default/classes/model.ctp +++ /dev/null @@ -1,175 +0,0 @@ - -/** - * Model - * - - */ -class extends AppModel { - -/** - * Use database config - * - * @var string - */ - public $useDbConfig = ''; - - -/** - * Primary key field - * - * @var string - */ - public $primaryKey = ''; - -/** - * Display field - * - * @var string - */ - public $displayField = ''; - $validations): - echo "\t\t'$field' => array(\n"; - foreach ($validations as $key => $validator): - echo "\t\t\t'$key' => array(\n"; - echo "\t\t\t\t'rule' => array('$validator'),\n"; - echo "\t\t\t\t//'message' => 'Your custom message here',\n"; - echo "\t\t\t\t//'allowEmpty' => false,\n"; - echo "\t\t\t\t//'required' => false,\n"; - echo "\t\t\t\t//'last' => false, // Stop validation after this rule\n"; - echo "\t\t\t\t//'on' => 'create', // Limit validation to 'create' or 'update' operations\n"; - echo "\t\t\t),\n"; - endforeach; - echo "\t\t),\n"; - endforeach; - echo "\t);\n"; -endif; - -foreach ($associations as $assoc): - if (!empty($assoc)): -?> - - //The Associations below have been created with all possible keys, those that are not needed can be removed - $relation): - $out = "\n\t\t'{$relation['alias']}' => array(\n"; - $out .= "\t\t\t'className' => '{$relation['className']}',\n"; - $out .= "\t\t\t'foreignKey' => '{$relation['foreignKey']}',\n"; - $out .= "\t\t\t'conditions' => '',\n"; - $out .= "\t\t\t'fields' => '',\n"; - $out .= "\t\t\t'order' => ''\n"; - $out .= "\t\t)"; - if ($i + 1 < $typeCount) { - $out .= ","; - } - echo $out; - endforeach; - echo "\n\t);\n"; - endif; -endforeach; - -if (!empty($associations['hasMany'])): - $belongsToCount = count($associations['hasMany']); - echo "\n/**\n * hasMany associations\n *\n * @var array\n */"; - echo "\n\tpublic \$hasMany = array("; - foreach ($associations['hasMany'] as $i => $relation): - $out = "\n\t\t'{$relation['alias']}' => array(\n"; - $out .= "\t\t\t'className' => '{$relation['className']}',\n"; - $out .= "\t\t\t'foreignKey' => '{$relation['foreignKey']}',\n"; - $out .= "\t\t\t'dependent' => false,\n"; - $out .= "\t\t\t'conditions' => '',\n"; - $out .= "\t\t\t'fields' => '',\n"; - $out .= "\t\t\t'order' => '',\n"; - $out .= "\t\t\t'limit' => '',\n"; - $out .= "\t\t\t'offset' => '',\n"; - $out .= "\t\t\t'exclusive' => '',\n"; - $out .= "\t\t\t'finderQuery' => '',\n"; - $out .= "\t\t\t'counterQuery' => ''\n"; - $out .= "\t\t)"; - if ($i + 1 < $belongsToCount) { - $out .= ","; - } - echo $out; - endforeach; - echo "\n\t);\n\n"; -endif; - -if (!empty($associations['hasAndBelongsToMany'])): - $habtmCount = count($associations['hasAndBelongsToMany']); - echo "\n/**\n * hasAndBelongsToMany associations\n *\n * @var array\n */"; - echo "\n\tpublic \$hasAndBelongsToMany = array("; - foreach ($associations['hasAndBelongsToMany'] as $i => $relation): - $out = "\n\t\t'{$relation['alias']}' => array(\n"; - $out .= "\t\t\t'className' => '{$relation['className']}',\n"; - $out .= "\t\t\t'joinTable' => '{$relation['joinTable']}',\n"; - $out .= "\t\t\t'foreignKey' => '{$relation['foreignKey']}',\n"; - $out .= "\t\t\t'associationForeignKey' => '{$relation['associationForeignKey']}',\n"; - $out .= "\t\t\t'unique' => 'keepExisting',\n"; - $out .= "\t\t\t'conditions' => '',\n"; - $out .= "\t\t\t'fields' => '',\n"; - $out .= "\t\t\t'order' => '',\n"; - $out .= "\t\t\t'limit' => '',\n"; - $out .= "\t\t\t'offset' => '',\n"; - $out .= "\t\t\t'finderQuery' => '',\n"; - $out .= "\t\t\t'deleteQuery' => '',\n"; - $out .= "\t\t\t'insertQuery' => ''\n"; - $out .= "\t\t)"; - if ($i + 1 < $habtmCount) { - $out .= ","; - } - echo $out; - endforeach; - echo "\n\t);\n\n"; -endif; -?> -} diff --git a/lib/Cake/Console/Templates/default/classes/test.ctp b/lib/Cake/Console/Templates/default/classes/test.ctp deleted file mode 100644 index ecf8ea8a5d1..00000000000 --- a/lib/Cake/Console/Templates/default/classes/test.ctp +++ /dev/null @@ -1,100 +0,0 @@ - - -App::uses('', ''); - - - -/** - * Test - * - */ -class Test extends { -/** - * Auto render - * - * @var boolean - */ - public $autoRender = false; - -/** - * Redirect action - * - * @param mixed $url - * @param mixed $status - * @param boolean $exit - * @return void - */ - public function redirect($url, $status = null, $exit = true) { - $this->redirectUrl = $url; - } -} - - -/** - * Test Case - * - */ -class TestCase extends CakeTestCase { - -/** - * Fixtures - * - * @var array - */ - public $fixtures = array(''); - - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - - $this-> - - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - unset($this->); - - parent::tearDown(); - } - - -/** - * test method - * - * @return void - */ - public function test() { - - } - -} diff --git a/lib/Cake/Console/Templates/default/views/form.ctp b/lib/Cake/Console/Templates/default/views/form.ctp deleted file mode 100644 index aaf3c23d7cf..00000000000 --- a/lib/Cake/Console/Templates/default/views/form.ctp +++ /dev/null @@ -1,65 +0,0 @@ - -
-Form->create('{$modelClass}');?>\n";?> -
- ", Inflector::humanize($action), $singularHumanName); ?> -Form->input('{$field}');\n"; - } - } - if (!empty($associations['hasAndBelongsToMany'])) { - foreach ($associations['hasAndBelongsToMany'] as $assocName => $assocData) { - echo "\t\techo \$this->Form->input('{$assocName}');\n"; - } - } - echo "\t?>\n"; -?> -
-Form->end(__('Submit'));?>\n"; -?> -
-
-

"; ?>

-
    - - -
  • Form->postLink(__('Delete'), array('action' => 'delete', \$this->Form->value('{$modelClass}.{$primaryKey}')), null, __('Are you sure you want to delete # %s?', \$this->Form->value('{$modelClass}.{$primaryKey}'))); ?>";?>
  • - -
  • Html->link(__('List " . $pluralHumanName . "'), array('action' => 'index'));?>";?>
  • - $data) { - foreach ($data as $alias => $details) { - if ($details['controller'] != $this->name && !in_array($details['controller'], $done)) { - echo "\t\t
  • Html->link(__('List " . Inflector::humanize($details['controller']) . "'), array('controller' => '{$details['controller']}', 'action' => 'index')); ?>
  • \n"; - echo "\t\t
  • Html->link(__('New " . Inflector::humanize(Inflector::underscore($alias)) . "'), array('controller' => '{$details['controller']}', 'action' => 'add')); ?>
  • \n"; - $done[] = $details['controller']; - } - } - } -?> -
-
diff --git a/lib/Cake/Console/Templates/default/views/home.ctp b/lib/Cake/Console/Templates/default/views/home.ctp deleted file mode 100644 index 424051981fc..00000000000 --- a/lib/Cake/Console/Templates/default/views/home.ctp +++ /dev/null @@ -1,113 +0,0 @@ - -

For updates and important announcements, visit http://cakefest.org

-\n"; -$output .= "

Sweet, \"" . Inflector::humanize($app) . "\" got Baked by CakePHP!

\n"; -$output .=" - 0): - Debugger::checkSecurityKeys(); -endif; -?> -

-=')): - echo ''; - echo __d('cake_dev', 'Your version of PHP is 5.2.8 or higher.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your version of PHP is too low. You need PHP 5.2.8 or higher to use CakePHP.'); - echo ''; - endif; -?> -

-

-'; - echo __d('cake_dev', 'Your tmp directory is writable.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your tmp directory is NOT writable.'); - echo ''; - endif; -?> -

-

-'; - echo __d('cake_dev', 'The %s is being used for core caching. To change the config edit APP/Config/core.php ', ''. \$settings['engine'] . 'Engine'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your cache is NOT working. Please check the settings in APP/Config/core.php'); - echo ''; - endif; -?> -

-

-'; - echo __d('cake_dev', 'Your database configuration file is present.'); - \$filePresent = true; - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your database configuration file is NOT present.'); - echo '
'; - echo __d('cake_dev', 'Rename APP/Config/database.php.default to APP/Config/database.php'); - echo '
'; - endif; -?> -

- -

- isConnected()): - echo ''; - echo __d('cake_dev', 'Cake is able to connect to the database.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Cake is NOT able to connect to the database.'); - echo ''; - endif; - ?> -

- -'; - __d('cake_dev', 'PCRE has not been compiled with Unicode support.'); - echo '
'; - __d('cake_dev', 'Recompile PCRE with Unicode support by adding --enable-unicode-properties when configuring'); - echo '

'; - } -?>\n"; -$output .= "

\n"; -$output .= "

\n"; -$output .= "', APP . 'View' . DS . 'Layouts' . DS . 'default.ctp.
', APP . 'webroot' . DS . 'css');\n"; -$output .= "?>\n"; -$output .= "

\n"; -?> diff --git a/lib/Cake/Console/Templates/default/views/index.ctp b/lib/Cake/Console/Templates/default/views/index.ctp deleted file mode 100644 index ded100e8e67..00000000000 --- a/lib/Cake/Console/Templates/default/views/index.ctp +++ /dev/null @@ -1,93 +0,0 @@ - -
-

";?>

- - - - - - - - \n"; - echo "\t\n"; - foreach ($fields as $field) { - $isKey = false; - if (!empty($associations['belongsTo'])) { - foreach ($associations['belongsTo'] as $alias => $details) { - if ($field === $details['foreignKey']) { - $isKey = true; - echo "\t\t\n"; - break; - } - } - } - if ($isKey !== true) { - echo "\t\t\n"; - } - } - - echo "\t\t\n"; - echo "\t\n"; - - echo "\n"; - ?> -
Paginator->sort('{$field}');?>";?>";?>
\n\t\t\tHtml->link(\${$singularVar}['{$alias}']['{$details['displayField']}'], array('controller' => '{$details['controller']}', 'action' => 'view', \${$singularVar}['{$alias}']['{$details['primaryKey']}'])); ?>\n\t\t \n"; - echo "\t\t\tHtml->link(__('View'), array('action' => 'view', \${$singularVar}['{$modelClass}']['{$primaryKey}'])); ?>\n"; - echo "\t\t\tHtml->link(__('Edit'), array('action' => 'edit', \${$singularVar}['{$modelClass}']['{$primaryKey}'])); ?>\n"; - echo "\t\t\tForm->postLink(__('Delete'), array('action' => 'delete', \${$singularVar}['{$modelClass}']['{$primaryKey}']), null, __('Are you sure you want to delete # %s?', \${$singularVar}['{$modelClass}']['{$primaryKey}'])); ?>\n"; - echo "\t\t
-

- Paginator->counter(array( - 'format' => __('Page {:page} of {:pages}, showing {:current} records out of {:count} total, starting on record {:start}, ending on {:end}') - )); - ?>";?> -

- -
- Paginator->prev('< ' . __('previous'), array(), null, array('class' => 'prev disabled'));\n"; - echo "\t\techo \$this->Paginator->numbers(array('separator' => ''));\n"; - echo "\t\techo \$this->Paginator->next(__('next') . ' >', array(), null, array('class' => 'next disabled'));\n"; - echo "\t?>\n"; - ?> -
-
-
-

"; ?>

-
    -
  • Html->link(__('New " . $singularHumanName . "'), array('action' => 'add')); ?>";?>
  • - $data) { - foreach ($data as $alias => $details) { - if ($details['controller'] != $this->name && !in_array($details['controller'], $done)) { - echo "\t\t
  • Html->link(__('List " . Inflector::humanize($details['controller']) . "'), array('controller' => '{$details['controller']}', 'action' => 'index')); ?>
  • \n"; - echo "\t\t
  • Html->link(__('New " . Inflector::humanize(Inflector::underscore($alias)) . "'), array('controller' => '{$details['controller']}', 'action' => 'add')); ?>
  • \n"; - $done[] = $details['controller']; - } - } - } -?> -
-
diff --git a/lib/Cake/Console/Templates/default/views/view.ctp b/lib/Cake/Console/Templates/default/views/view.ctp deleted file mode 100644 index 5daf35f2d13..00000000000 --- a/lib/Cake/Console/Templates/default/views/view.ctp +++ /dev/null @@ -1,139 +0,0 @@ - -
-

";?>

-
- $details) { - if ($field === $details['foreignKey']) { - $isKey = true; - echo "\t\t
\n"; - echo "\t\t
\n\t\t\tHtml->link(\${$singularVar}['{$alias}']['{$details['displayField']}'], array('controller' => '{$details['controller']}', 'action' => 'view', \${$singularVar}['{$alias}']['{$details['primaryKey']}'])); ?>\n\t\t\t \n\t\t
\n"; - break; - } - } - } - if ($isKey !== true) { - echo "\t\t
\n"; - echo "\t\t
\n\t\t\t\n\t\t\t \n\t\t
\n"; - } -} -?> -
-
-
-

"; ?>

-
    -Html->link(__('Edit " . $singularHumanName ."'), array('action' => 'edit', \${$singularVar}['{$modelClass}']['{$primaryKey}'])); ?> \n"; - echo "\t\t
  • Form->postLink(__('Delete " . $singularHumanName . "'), array('action' => 'delete', \${$singularVar}['{$modelClass}']['{$primaryKey}']), null, __('Are you sure you want to delete # %s?', \${$singularVar}['{$modelClass}']['{$primaryKey}'])); ?>
  • \n"; - echo "\t\t
  • Html->link(__('List " . $pluralHumanName . "'), array('action' => 'index')); ?>
  • \n"; - echo "\t\t
  • Html->link(__('New " . $singularHumanName . "'), array('action' => 'add')); ?>
  • \n"; - - $done = array(); - foreach ($associations as $type => $data) { - foreach ($data as $alias => $details) { - if ($details['controller'] != $this->name && !in_array($details['controller'], $done)) { - echo "\t\t
  • Html->link(__('List " . Inflector::humanize($details['controller']) . "'), array('controller' => '{$details['controller']}', 'action' => 'index')); ?>
  • \n"; - echo "\t\t
  • Html->link(__('New " . Inflector::humanize(Inflector::underscore($alias)) . "'), array('controller' => '{$details['controller']}', 'action' => 'add')); ?>
  • \n"; - $done[] = $details['controller']; - } - } - } -?> -
-
- $details): ?> - - $details): - $otherSingularVar = Inflector::variable($alias); - $otherPluralHumanName = Inflector::humanize($details['controller']); - ?> - - diff --git a/lib/Cake/Console/Templates/skel/.htaccess b/lib/Cake/Console/Templates/skel/.htaccess deleted file mode 100644 index fc3aac4b296..00000000000 --- a/lib/Cake/Console/Templates/skel/.htaccess +++ /dev/null @@ -1,5 +0,0 @@ - - RewriteEngine on - RewriteRule ^$ webroot/ [L] - RewriteRule (.*) webroot/$1 [L] - \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/Config/Schema/db_acl.php b/lib/Cake/Console/Templates/skel/Config/Schema/db_acl.php deleted file mode 100644 index de359628265..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/Schema/db_acl.php +++ /dev/null @@ -1,74 +0,0 @@ - array('type' => 'integer', 'null' => false, 'default' => null, 'length' => 10, 'key' => 'primary'), - 'parent_id' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'model' => array('type' => 'string', 'null' => true), - 'foreign_key' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'alias' => array('type' => 'string', 'null' => true), - 'lft' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'rght' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)) - ); - - public $aros = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => 10, 'key' => 'primary'), - 'parent_id' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'model' => array('type' => 'string', 'null' => true), - 'foreign_key' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'alias' => array('type' => 'string', 'null' => true), - 'lft' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'rght' => array('type' => 'integer', 'null' => true, 'default' => null, 'length' => 10), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)) - ); - - public $aros_acos = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => 10, 'key' => 'primary'), - 'aro_id' => array('type' => 'integer', 'null' => false, 'length' => 10, 'key' => 'index'), - 'aco_id' => array('type' => 'integer', 'null' => false, 'length' => 10), - '_create' => array('type' => 'string', 'null' => false, 'default' => '0', 'length' => 2), - '_read' => array('type' => 'string', 'null' => false, 'default' => '0', 'length' => 2), - '_update' => array('type' => 'string', 'null' => false, 'default' => '0', 'length' => 2), - '_delete' => array('type' => 'string', 'null' => false, 'default' => '0', 'length' => 2), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1), 'ARO_ACO_KEY' => array('column' => array('aro_id', 'aco_id'), 'unique' => 1)) - ); - -} diff --git a/lib/Cake/Console/Templates/skel/Config/Schema/db_acl.sql b/lib/Cake/Console/Templates/skel/Config/Schema/db_acl.sql deleted file mode 100644 index f50f3921e8c..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/Schema/db_acl.sql +++ /dev/null @@ -1,40 +0,0 @@ -# $Id$ -# -# Copyright 2005-2012, Cake Software Foundation, Inc. -# -# Licensed under The MIT License -# Redistributions of files must retain the above copyright notice. -# MIT License (http://www.opensource.org/licenses/mit-license.php) - -CREATE TABLE acos ( - id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, - parent_id INTEGER(10) DEFAULT NULL, - model VARCHAR(255) DEFAULT '', - foreign_key INTEGER(10) UNSIGNED DEFAULT NULL, - alias VARCHAR(255) DEFAULT '', - lft INTEGER(10) DEFAULT NULL, - rght INTEGER(10) DEFAULT NULL, - PRIMARY KEY (id) -); - -CREATE TABLE aros_acos ( - id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, - aro_id INTEGER(10) UNSIGNED NOT NULL, - aco_id INTEGER(10) UNSIGNED NOT NULL, - _create CHAR(2) NOT NULL DEFAULT 0, - _read CHAR(2) NOT NULL DEFAULT 0, - _update CHAR(2) NOT NULL DEFAULT 0, - _delete CHAR(2) NOT NULL DEFAULT 0, - PRIMARY KEY(id) -); - -CREATE TABLE aros ( - id INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT, - parent_id INTEGER(10) DEFAULT NULL, - model VARCHAR(255) DEFAULT '', - foreign_key INTEGER(10) UNSIGNED DEFAULT NULL, - alias VARCHAR(255) DEFAULT '', - lft INTEGER(10) DEFAULT NULL, - rght INTEGER(10) DEFAULT NULL, - PRIMARY KEY (id) -); \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/Config/Schema/i18n.php b/lib/Cake/Console/Templates/skel/Config/Schema/i18n.php deleted file mode 100644 index 1a29ffd986a..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/Schema/i18n.php +++ /dev/null @@ -1,51 +0,0 @@ - array('type' => 'integer', 'null' => false, 'default' => null, 'length' => 10, 'key' => 'primary'), - 'locale' => array('type' => 'string', 'null' => false, 'length' => 6, 'key' => 'index'), - 'model' => array('type' => 'string', 'null' => false, 'key' => 'index'), - 'foreign_key' => array('type' => 'integer', 'null' => false, 'length' => 10, 'key' => 'index'), - 'field' => array('type' => 'string', 'null' => false, 'key' => 'index'), - 'content' => array('type' => 'text', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1), 'locale' => array('column' => 'locale', 'unique' => 0), 'model' => array('column' => 'model', 'unique' => 0), 'row_id' => array('column' => 'foreign_key', 'unique' => 0), 'field' => array('column' => 'field', 'unique' => 0)) - ); - -} diff --git a/lib/Cake/Console/Templates/skel/Config/Schema/i18n.sql b/lib/Cake/Console/Templates/skel/Config/Schema/i18n.sql deleted file mode 100644 index 239e146230a..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/Schema/i18n.sql +++ /dev/null @@ -1,26 +0,0 @@ -# $Id$ -# -# Copyright 2005-2012, Cake Software Foundation, Inc. -# -# Licensed under The MIT License -# Redistributions of files must retain the above copyright notice. -# MIT License (http://www.opensource.org/licenses/mit-license.php) - -CREATE TABLE i18n ( - id int(10) NOT NULL auto_increment, - locale varchar(6) NOT NULL, - model varchar(255) NOT NULL, - foreign_key int(10) NOT NULL, - field varchar(255) NOT NULL, - content mediumtext, - PRIMARY KEY (id), -# UNIQUE INDEX I18N_LOCALE_FIELD(locale, model, foreign_key, field), -# INDEX I18N_LOCALE_ROW(locale, model, foreign_key), -# INDEX I18N_LOCALE_MODEL(locale, model), -# INDEX I18N_FIELD(model, foreign_key, field), -# INDEX I18N_ROW(model, foreign_key), - INDEX locale (locale), - INDEX model (model), - INDEX row_id (foreign_key), - INDEX field (field) -); \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/Config/Schema/sessions.php b/lib/Cake/Console/Templates/skel/Config/Schema/sessions.php deleted file mode 100644 index d83e09662e5..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/Schema/sessions.php +++ /dev/null @@ -1,48 +0,0 @@ - array('type' => 'string', 'null' => false, 'key' => 'primary'), - 'data' => array('type' => 'text', 'null' => true, 'default' => null), - 'expires' => array('type' => 'integer', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)) - ); - -} diff --git a/lib/Cake/Console/Templates/skel/Config/Schema/sessions.sql b/lib/Cake/Console/Templates/skel/Config/Schema/sessions.sql deleted file mode 100644 index b8951b6f5d4..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/Schema/sessions.sql +++ /dev/null @@ -1,16 +0,0 @@ -# $Id$ -# -# Copyright 2005-2012, Cake Software Foundation, Inc. -# 1785 E. Sahara Avenue, Suite 490-204 -# Las Vegas, Nevada 89104 -# -# Licensed under The MIT License -# Redistributions of files must retain the above copyright notice. -# MIT License (http://www.opensource.org/licenses/mit-license.php) - -CREATE TABLE cake_sessions ( - id varchar(255) NOT NULL default '', - data text, - expires int(11) default NULL, - PRIMARY KEY (id) -); \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/Config/acl.ini.php b/lib/Cake/Console/Templates/skel/Config/acl.ini.php deleted file mode 100644 index 11ce65b5723..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/acl.ini.php +++ /dev/null @@ -1,68 +0,0 @@ -; -;/** -; * ACL Configuration -; * -; * -; * PHP 5 -; * -; * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) -; * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) -; * -; * Licensed under The MIT License -; * Redistributions of files must retain the above copyright notice. -; * -; * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) -; * @link http://cakephp.org CakePHP(tm) Project -; * @package app.Config -; * @since CakePHP(tm) v 0.10.0.1076 -; * @license MIT License (http://www.opensource.org/licenses/mit-license.php) -; */ - -; acl.ini.php - Cake ACL Configuration -; --------------------------------------------------------------------- -; Use this file to specify user permissions. -; aco = access control object (something in your application) -; aro = access request object (something requesting access) -; -; User records are added as follows: -; -; [uid] -; groups = group1, group2, group3 -; allow = aco1, aco2, aco3 -; deny = aco4, aco5, aco6 -; -; Group records are added in a similar manner: -; -; [gid] -; allow = aco1, aco2, aco3 -; deny = aco4, aco5, aco6 -; -; The allow, deny, and groups sections are all optional. -; NOTE: groups names *cannot* ever be the same as usernames! -; -; ACL permissions are checked in the following order: -; 1. Check for user denies (and DENY if specified) -; 2. Check for user allows (and ALLOW if specified) -; 3. Gather user's groups -; 4. Check group denies (and DENY if specified) -; 5. Check group allows (and ALLOW if specified) -; 6. If no aro, aco, or group information is found, DENY -; -; --------------------------------------------------------------------- - -;------------------------------------- -;Users -;------------------------------------- - -[username-goes-here] -groups = group1, group2 -deny = aco1, aco2 -allow = aco3, aco4 - -;------------------------------------- -;Groups -;------------------------------------- - -[groupname-goes-here] -deny = aco5, aco6 -allow = aco7, aco8 diff --git a/lib/Cake/Console/Templates/skel/Config/bootstrap.php b/lib/Cake/Console/Templates/skel/Config/bootstrap.php deleted file mode 100644 index 78ac2896e0a..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/bootstrap.php +++ /dev/null @@ -1,65 +0,0 @@ - 'File')); - -/** - * The settings below can be used to set additional paths to models, views and controllers. - * - * App::build(array( - * 'Plugin' => array('/full/path/to/plugins/', '/next/full/path/to/plugins/'), - * 'Model' => array('/full/path/to/models/', '/next/full/path/to/models/'), - * 'View' => array('/full/path/to/views/', '/next/full/path/to/views/'), - * 'Controller' => array('/full/path/to/controllers/', '/next/full/path/to/controllers/'), - * 'Model/Datasource' => array('/full/path/to/datasources/', '/next/full/path/to/datasources/'), - * 'Model/Behavior' => array('/full/path/to/behaviors/', '/next/full/path/to/behaviors/'), - * 'Controller/Component' => array('/full/path/to/components/', '/next/full/path/to/components/'), - * 'View/Helper' => array('/full/path/to/helpers/', '/next/full/path/to/helpers/'), - * 'Vendor' => array('/full/path/to/vendors/', '/next/full/path/to/vendors/'), - * 'Console/Command' => array('/full/path/to/shells/', '/next/full/path/to/shells/'), - * 'Locale' => array('/full/path/to/locale/', '/next/full/path/to/locale/') - * )); - * - */ - -/** - * Custom Inflector rules, can be set to correctly pluralize or singularize table, model, controller names or whatever other - * string is passed to the inflection functions - * - * Inflector::rules('singular', array('rules' => array(), 'irregular' => array(), 'uninflected' => array())); - * Inflector::rules('plural', array('rules' => array(), 'irregular' => array(), 'uninflected' => array())); - * - */ - -/** - * Plugins need to be loaded manually, you can either load them one by one or all of them in a single call - * Uncomment one of the lines below, as you need. make sure you read the documentation on CakePlugin to use more - * advanced ways of loading plugins - * - * CakePlugin::loadAll(); // Loads all plugins at once - * CakePlugin::load('DebugKit'); //Loads a single plugin named DebugKit - * - */ diff --git a/lib/Cake/Console/Templates/skel/Config/core.php b/lib/Cake/Console/Templates/skel/Config/core.php deleted file mode 100644 index 20c354bc96b..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/core.php +++ /dev/null @@ -1,336 +0,0 @@ - 0 - * and log errors with CakeLog when debug = 0. - * - * Options: - * - * - `handler` - callback - The callback to handle errors. You can set this to any callback type, - * including anonymous functions. - * - `level` - int - The level of errors you are interested in capturing. - * - `trace` - boolean - Include stack traces for errors in log files. - * - * @see ErrorHandler for more information on error handling and configuration. - */ - Configure::write('Error', array( - 'handler' => 'ErrorHandler::handleError', - 'level' => E_ALL & ~E_DEPRECATED, - 'trace' => true - )); - -/** - * Configure the Exception handler used for uncaught exceptions. By default, - * ErrorHandler::handleException() is used. It will display a HTML page for the exception, and - * while debug > 0, framework errors like Missing Controller will be displayed. When debug = 0, - * framework errors will be coerced into generic HTTP errors. - * - * Options: - * - * - `handler` - callback - The callback to handle exceptions. You can set this to any callback type, - * including anonymous functions. - * - `renderer` - string - The class responsible for rendering uncaught exceptions. If you choose a custom class you - * should place the file for that class in app/Lib/Error. This class needs to implement a render method. - * - `log` - boolean - Should Exceptions be logged? - * - * @see ErrorHandler for more information on exception handling and configuration. - */ - Configure::write('Exception', array( - 'handler' => 'ErrorHandler::handleException', - 'renderer' => 'ExceptionRenderer', - 'log' => true - )); - -/** - * Application wide charset encoding - */ - Configure::write('App.encoding', 'UTF-8'); - -/** - * To configure CakePHP *not* to use mod_rewrite and to - * use CakePHP pretty URLs, remove these .htaccess - * files: - * - * /.htaccess - * /app/.htaccess - * /app/webroot/.htaccess - * - * And uncomment the App.baseUrl below: - */ - //Configure::write('App.baseUrl', env('SCRIPT_NAME')); - -/** - * Uncomment the define below to use CakePHP prefix routes. - * - * The value of the define determines the names of the routes - * and their associated controller actions: - * - * Set to an array of prefixes you want to use in your application. Use for - * admin or other prefixed routes. - * - * Routing.prefixes = array('admin', 'manager'); - * - * Enables: - * `admin_index()` and `/admin/controller/index` - * `manager_index()` and `/manager/controller/index` - * - */ - //Configure::write('Routing.prefixes', array('admin')); - -/** - * Turn off all caching application-wide. - * - */ - //Configure::write('Cache.disable', true); - -/** - * Enable cache checking. - * - * If set to true, for view caching you must still use the controller - * public $cacheAction inside your controllers to define caching settings. - * You can either set it controller-wide by setting public $cacheAction = true, - * or in each action using $this->cacheAction = true. - * - */ - //Configure::write('Cache.check', true); - -/** - * Defines the default error type when using the log() function. Used for - * differentiating error logging and debugging. Currently PHP supports LOG_DEBUG. - */ - define('LOG_ERROR', 2); - -/** - * Session configuration. - * - * Contains an array of settings to use for session configuration. The defaults key is - * used to define a default preset to use for sessions, any settings declared here will override - * the settings of the default config. - * - * ## Options - * - * - `Session.cookie` - The name of the cookie to use. Defaults to 'CAKEPHP' - * - `Session.timeout` - The number of minutes you want sessions to live for. This timeout is handled by CakePHP - * - `Session.cookieTimeout` - The number of minutes you want session cookies to live for. - * - `Session.checkAgent` - Do you want the user agent to be checked when starting sessions? You might want to set the - * value to false, when dealing with older versions of IE, Chrome Frame or certain web-browsing devices and AJAX - * - `Session.defaults` - The default configuration set to use as a basis for your session. - * There are four builtins: php, cake, cache, database. - * - `Session.handler` - Can be used to enable a custom session handler. Expects an array of of callables, - * that can be used with `session_save_handler`. Using this option will automatically add `session.save_handler` - * to the ini array. - * - `Session.autoRegenerate` - Enabling this setting, turns on automatic renewal of sessions, and - * sessionids that change frequently. See CakeSession::$requestCountdown. - * - `Session.ini` - An associative array of additional ini values to set. - * - * The built in defaults are: - * - * - 'php' - Uses settings defined in your php.ini. - * - 'cake' - Saves session files in CakePHP's /tmp directory. - * - 'database' - Uses CakePHP's database sessions. - * - 'cache' - Use the Cache class to save sessions. - * - * To define a custom session handler, save it at /app/Model/Datasource/Session/.php. - * Make sure the class implements `CakeSessionHandlerInterface` and set Session.handler to - * - * To use database sessions, run the app/Config/Schema/sessions.php schema using - * the cake shell command: cake schema create Sessions - * - */ - Configure::write('Session', array( - 'defaults' => 'php' - )); - -/** - * The level of CakePHP security. - */ - Configure::write('Security.level', 'medium'); - -/** - * A random string used in security hashing methods. - */ - Configure::write('Security.salt', 'DYhG93b0qyJfIxfs2guVoUubWwvniR2G0FgaC9mi'); - -/** - * A random numeric string (digits only) used to encrypt/decrypt strings. - */ - Configure::write('Security.cipherSeed', '76859309657453542496749683645'); - -/** - * Apply timestamps with the last modified time to static assets (js, css, images). - * Will append a querystring parameter containing the time the file was modified. This is - * useful for invalidating browser caches. - * - * Set to `true` to apply timestamps when debug > 0. Set to 'force' to always enable - * timestamping regardless of debug value. - */ - //Configure::write('Asset.timestamp', true); - -/** - * Compress CSS output by removing comments, whitespace, repeating tags, etc. - * This requires a/var/cache directory to be writable by the web server for caching. - * and /vendors/csspp/csspp.php - * - * To use, prefix the CSS link URL with '/ccss/' instead of '/css/' or use HtmlHelper::css(). - */ - //Configure::write('Asset.filter.css', 'css.php'); - -/** - * Plug in your own custom JavaScript compressor by dropping a script in your webroot to handle the - * output, and setting the config below to the name of the script. - * - * To use, prefix your JavaScript link URLs with '/cjs/' instead of '/js/' or use JavaScriptHelper::link(). - */ - //Configure::write('Asset.filter.js', 'custom_javascript_output_filter.php'); - -/** - * The classname and database used in CakePHP's - * access control lists. - */ - Configure::write('Acl.classname', 'DbAcl'); - Configure::write('Acl.database', 'default'); - -/** - * Uncomment this line and correct your server timezone to fix - * any date & time related errors. - */ - //date_default_timezone_set('UTC'); - -/** - * - * Cache Engine Configuration - * Default settings provided below - * - * File storage engine. - * - * Cache::config('default', array( - * 'engine' => 'File', //[required] - * 'duration' => 3600, //[optional] - * 'probability' => 100, //[optional] - * 'path' => CACHE, //[optional] use system tmp directory - remember to use absolute path - * 'prefix' => 'cake_', //[optional] prefix every cache file with this string - * 'lock' => false, //[optional] use file locking - * 'serialize' => true, [optional] - * )); - * - * APC (http://pecl.php.net/package/APC) - * - * Cache::config('default', array( - * 'engine' => 'Apc', //[required] - * 'duration' => 3600, //[optional] - * 'probability' => 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * )); - * - * Xcache (http://xcache.lighttpd.net/) - * - * Cache::config('default', array( - * 'engine' => 'Xcache', //[required] - * 'duration' => 3600, //[optional] - * 'probability' => 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * 'user' => 'user', //user from xcache.admin.user settings - * 'password' => 'password', //plaintext password (xcache.admin.pass) - * )); - * - * Memcache (http://www.danga.com/memcached/) - * - * Cache::config('default', array( - * 'engine' => 'Memcache', //[required] - * 'duration' => 3600, //[optional] - * 'probability' => 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * 'servers' => array( - * '127.0.0.1:11211' // localhost, default port 11211 - * ), //[optional] - * 'persistent' => true, // [optional] set this to false for non-persistent connections - * 'compress' => false, // [optional] compress data in Memcache (slower, but uses less memory) - * )); - * - * Wincache (http://php.net/wincache) - * - * Cache::config('default', array( - * 'engine' => 'Wincache', //[required] - * 'duration' => 3600, //[optional] - * 'probability' => 100, //[optional] - * 'prefix' => Inflector::slug(APP_DIR) . '_', //[optional] prefix every cache file with this string - * )); - */ - -/** - * Pick the caching engine to use. If APC is enabled use it. - * If running via cli - apc is disabled by default. ensure it's available and enabled in this case - * - */ -$engine = 'File'; -if (extension_loaded('apc') && function_exists('apc_dec') && (php_sapi_name() !== 'cli' || ini_get('apc.enable_cli'))) { - $engine = 'Apc'; -} - -// In development mode, caches should expire quickly. -$duration = '+999 days'; -if (Configure::read('debug') >= 1) { - $duration = '+10 seconds'; -} - -// Prefix each application on the same server with a different string, to avoid Memcache and APC conflicts. -$prefix = 'myapp_'; - -/** - * Configure the cache used for general framework caching. Path information, - * object listings, and translation cache files are stored with this configuration. - */ -Cache::config('_cake_core_', array( - 'engine' => $engine, - 'prefix' => $prefix . 'cake_core_', - 'path' => CACHE . 'persistent' . DS, - 'serialize' => ($engine === 'File'), - 'duration' => $duration -)); - -/** - * Configure the cache for model and datasource caches. This cache configuration - * is used to store schema descriptions, and table listings in connections. - */ -Cache::config('_cake_model_', array( - 'engine' => $engine, - 'prefix' => $prefix . 'cake_model_', - 'path' => CACHE . 'models' . DS, - 'serialize' => ($engine === 'File'), - 'duration' => $duration -)); diff --git a/lib/Cake/Console/Templates/skel/Config/database.php.default b/lib/Cake/Console/Templates/skel/Config/database.php.default deleted file mode 100644 index 245c28222da..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/database.php.default +++ /dev/null @@ -1,84 +0,0 @@ - The name of a supported datasource; valid options are as follows: - * Database/Mysql - MySQL 4 & 5, - * Database/Sqlite - SQLite (PHP5 only), - * Database/Postgres - PostgreSQL 7 and higher, - * Database/Sqlserver - Microsoft SQL Server 2005 and higher - * - * You can add custom database datasources (or override existing datasources) by adding the - * appropriate file to app/Model/Datasource/Database. Datasources should be named 'MyDatasource.php', - * - * - * persistent => true / false - * Determines whether or not the database should use a persistent connection - * - * host => - * the host you connect to the database. To add a socket or port number, use 'port' => # - * - * prefix => - * Uses the given prefix for all the tables in this database. This setting can be overridden - * on a per-table basis with the Model::$tablePrefix property. - * - * schema => - * For Postgres/Sqlserver specifies which schema you would like to use the tables in. Postgres defaults to 'public'. For Sqlserver, it defaults to empty and use - * the connected user's default schema (typically 'dbo'). - * - * encoding => - * For MySQL, Postgres specifies the character encoding to use when connecting to the - * database. Uses database default not specified. - * - * unix_socket => - * For MySQL to connect via socket specify the `unix_socket` parameter instead of `host` and `port` - */ -class DATABASE_CONFIG { - - public $default = array( - 'datasource' => 'Database/Mysql', - 'persistent' => false, - 'host' => 'localhost', - 'login' => 'user', - 'password' => 'password', - 'database' => 'database_name', - 'prefix' => '', - //'encoding' => 'utf8', - ); - - public $test = array( - 'datasource' => 'Database/Mysql', - 'persistent' => false, - 'host' => 'localhost', - 'login' => 'user', - 'password' => 'password', - 'database' => 'test_database_name', - 'prefix' => '', - //'encoding' => 'utf8', - ); -} diff --git a/lib/Cake/Console/Templates/skel/Config/email.php.default b/lib/Cake/Console/Templates/skel/Config/email.php.default deleted file mode 100644 index c423f782f29..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/email.php.default +++ /dev/null @@ -1,97 +0,0 @@ - The name of a supported transport; valid options are as follows: - * Mail - Send using PHP mail function - * Smtp - Send using SMTP - * Debug - Do not send the email, just return the result - * - * You can add custom transports (or override existing transports) by adding the - * appropriate file to app/Network/Email. Transports should be named 'YourTransport.php', - * where 'Your' is the name of the transport. - * - * from => - * The origin email. See CakeEmail::from() about the valid values - * - */ -class EmailConfig { - - public $default = array( - 'transport' => 'Mail', - 'from' => 'you@localhost', - //'charset' => 'utf-8', - //'headerCharset' => 'utf-8', - ); - - public $smtp = array( - 'transport' => 'Smtp', - 'from' => array('site@localhost' => 'My Site'), - 'host' => 'localhost', - 'port' => 25, - 'timeout' => 30, - 'username' => 'user', - 'password' => 'secret', - 'client' => null, - 'log' => false - //'charset' => 'utf-8', - //'headerCharset' => 'utf-8', - ); - - public $fast = array( - 'from' => 'you@localhost', - 'sender' => null, - 'to' => null, - 'cc' => null, - 'bcc' => null, - 'replyTo' => null, - 'readReceipt' => null, - 'returnPath' => null, - 'messageId' => true, - 'subject' => null, - 'message' => null, - 'headers' => null, - 'viewRender' => null, - 'template' => false, - 'layout' => false, - 'viewVars' => null, - 'attachments' => null, - 'emailFormat' => null, - 'transport' => 'Smtp', - 'host' => 'localhost', - 'port' => 25, - 'timeout' => 30, - 'username' => 'user', - 'password' => 'secret', - 'client' => null, - 'log' => true, - //'charset' => 'utf-8', - //'headerCharset' => 'utf-8', - ); - -} diff --git a/lib/Cake/Console/Templates/skel/Config/routes.php b/lib/Cake/Console/Templates/skel/Config/routes.php deleted file mode 100644 index 82cd2dbe9e4..00000000000 --- a/lib/Cake/Console/Templates/skel/Config/routes.php +++ /dev/null @@ -1,44 +0,0 @@ - 'pages', 'action' => 'display', 'home')); -/** - * ...and connect the rest of 'Pages' controller's urls. - */ - Router::connect('/pages/*', array('controller' => 'pages', 'action' => 'display')); - -/** - * Load all plugin routes. See the CakePlugin documentation on - * how to customize the loading of plugin routes. - */ - CakePlugin::routes(); - -/** - * Load the CakePHP default routes. Remove this if you do not want to use - * the built-in default routes. - */ - require CAKE . 'Config' . DS . 'routes.php'; diff --git a/lib/Cake/Console/Templates/skel/Console/Command/AppShell.php b/lib/Cake/Console/Templates/skel/Console/Command/AppShell.php deleted file mode 100644 index 5cc915f6bfe..00000000000 --- a/lib/Cake/Console/Templates/skel/Console/Command/AppShell.php +++ /dev/null @@ -1,31 +0,0 @@ -redirect('/'); - } - $page = $subpage = $title = null; - - if (!empty($path[0])) { - $page = $path[0]; - } - if (!empty($path[1])) { - $subpage = $path[1]; - } - if (!empty($path[$count - 1])) { - $title = Inflector::humanize($path[$count - 1]); - } - $this->set(compact('page', 'subpage')); - $this->set('title_for_layout', $title); - $this->render(implode('/', $path)); - } - -} diff --git a/lib/Cake/Console/Templates/skel/Lib/empty b/lib/Cake/Console/Templates/skel/Lib/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/lib/Cake/Console/Templates/skel/Locale/eng/LC_MESSAGES/empty b/lib/Cake/Console/Templates/skel/Locale/eng/LC_MESSAGES/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/lib/Cake/Console/Templates/skel/Model/AppModel.php b/lib/Cake/Console/Templates/skel/Model/AppModel.php deleted file mode 100644 index 94e5e5f39d1..00000000000 --- a/lib/Cake/Console/Templates/skel/Model/AppModel.php +++ /dev/null @@ -1,34 +0,0 @@ - - ' . $line . '

'; -endforeach; -?> \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/View/Emails/text/default.ctp b/lib/Cake/Console/Templates/skel/View/Emails/text/default.ctp deleted file mode 100644 index 8b5e8c8da8c..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Emails/text/default.ctp +++ /dev/null @@ -1,19 +0,0 @@ - - \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/View/Errors/error400.ctp b/lib/Cake/Console/Templates/skel/View/Errors/error400.ctp deleted file mode 100644 index 6d508604c7c..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Errors/error400.ctp +++ /dev/null @@ -1,31 +0,0 @@ - -

-

- : - '{$url}'" - ); ?> -

- 0 ): - echo $this->element('exception_stack_trace'); -endif; -?> diff --git a/lib/Cake/Console/Templates/skel/View/Errors/error500.ctp b/lib/Cake/Console/Templates/skel/View/Errors/error500.ctp deleted file mode 100644 index 4e1f36ece56..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Errors/error500.ctp +++ /dev/null @@ -1,28 +0,0 @@ - -

-

- : - -

- 0 ): - echo $this->element('exception_stack_trace'); -endif; -?> diff --git a/lib/Cake/Console/Templates/skel/View/Helper/AppHelper.php b/lib/Cake/Console/Templates/skel/View/Helper/AppHelper.php deleted file mode 100644 index caceda86062..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Helper/AppHelper.php +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - <?php echo $title_for_layout;?> - - - - - -

This email was sent using the CakePHP Framework

- - \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/View/Layouts/Emails/text/default.ctp b/lib/Cake/Console/Templates/skel/View/Layouts/Emails/text/default.ctp deleted file mode 100644 index ba3086ad930..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Layouts/Emails/text/default.ctp +++ /dev/null @@ -1,22 +0,0 @@ - - - - -This email was sent using the CakePHP Framework, http://cakephp.org. diff --git a/lib/Cake/Console/Templates/skel/View/Layouts/ajax.ctp b/lib/Cake/Console/Templates/skel/View/Layouts/ajax.ctp deleted file mode 100644 index 9475d5f7d62..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Layouts/ajax.ctp +++ /dev/null @@ -1,19 +0,0 @@ - - \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/View/Layouts/default.ctp b/lib/Cake/Console/Templates/skel/View/Layouts/default.ctp deleted file mode 100644 index 4aab010a999..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Layouts/default.ctp +++ /dev/null @@ -1,59 +0,0 @@ - - - - - Html->charset(); ?> - - <?php echo __('CakePHP: the rapid development php framework:'); ?> - <?php echo $title_for_layout; ?> - - Html->meta('icon'); - - echo $this->Html->css('cake.generic'); - - echo $this->fetch('meta'); - echo $this->fetch('css'); - echo $this->fetch('script'); - ?> - - -
- -
- - Session->flash(); ?> - - fetch('content'); ?> -
- -
- element('sql_dump'); ?> - - diff --git a/lib/Cake/Console/Templates/skel/View/Layouts/flash.ctp b/lib/Cake/Console/Templates/skel/View/Layouts/flash.ctp deleted file mode 100644 index 35f5a942ff8..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Layouts/flash.ctp +++ /dev/null @@ -1,37 +0,0 @@ - - - - -Html->charset(); ?> -<?php echo $page_title; ?> - - - - - - - -

- - \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/View/Layouts/js/default.ctp b/lib/Cake/Console/Templates/skel/View/Layouts/js/default.ctp deleted file mode 100644 index d94dc903a57..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Layouts/js/default.ctp +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/View/Layouts/rss/default.ctp b/lib/Cake/Console/Templates/skel/View/Layouts/rss/default.ctp deleted file mode 100644 index 94067f2bfbc..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Layouts/rss/default.ctp +++ /dev/null @@ -1,17 +0,0 @@ -header(); - -if (!isset($channel)) { - $channel = array(); -} -if (!isset($channel['title'])) { - $channel['title'] = $title_for_layout; -} - -echo $rss->document( - $rss->channel( - array(), $channel, $content_for_layout - ) -); - -?> \ No newline at end of file diff --git a/lib/Cake/Console/Templates/skel/View/Layouts/xml/default.ctp b/lib/Cake/Console/Templates/skel/View/Layouts/xml/default.ctp deleted file mode 100644 index 27f20316b37..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Layouts/xml/default.ctp +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/Cake/Console/Templates/skel/View/Pages/home.ctp b/lib/Cake/Console/Templates/skel/View/Pages/home.ctp deleted file mode 100644 index 6661e9a6240..00000000000 --- a/lib/Cake/Console/Templates/skel/View/Pages/home.ctp +++ /dev/null @@ -1,188 +0,0 @@ - - -

- - 0): - Debugger::checkSecurityKeys(); -endif; -?> -

- - 1) Help me configure it - 2) I don't / can't use URL rewriting -

-

-=')): - echo ''; - echo __d('cake_dev', 'Your version of PHP is 5.2.8 or higher.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your version of PHP is too low. You need PHP 5.2.8 or higher to use CakePHP.'); - echo ''; - endif; -?> -

-

- '; - echo __d('cake_dev', 'Your tmp directory is writable.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your tmp directory is NOT writable.'); - echo ''; - endif; - ?> -

-

- '; - echo __d('cake_dev', 'The %s is being used for core caching. To change the config edit APP/Config/core.php ', ''. $settings['engine'] . 'Engine'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your cache is NOT working. Please check the settings in APP/Config/core.php'); - echo ''; - endif; - ?> -

-

- '; - echo __d('cake_dev', 'Your database configuration file is present.'); - $filePresent = true; - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Your database configuration file is NOT present.'); - echo '
'; - echo __d('cake_dev', 'Rename APP/Config/database.php.default to APP/Config/database.php'); - echo '
'; - endif; - ?> -

- -

- isConnected()): - echo ''; - echo __d('cake_dev', 'Cake is able to connect to the database.'); - echo ''; - else: - echo ''; - echo __d('cake_dev', 'Cake is NOT able to connect to the database.'); - echo '

'; - echo $connectionError->getMessage(); - echo '
'; - endif; - ?> -

- -'; - echo __d('cake_dev', 'PCRE has not been compiled with Unicode support.'); - echo '
'; - echo __d('cake_dev', 'Recompile PCRE with Unicode support by adding --enable-unicode-properties when configuring'); - echo '

'; - } -?> -

-

- -To change its layout, create: APP/View/Layouts/default.ctp.
-You can also add some CSS styles for your pages at: APP/webroot/css.'); -?> -

- -

-

- Html->link( - sprintf('%s %s', __d('cake_dev', 'New'), __d('cake_dev', 'CakePHP 2.0 Docs')), - 'http://book.cakephp.org/2.0/en/', - array('target' => '_blank', 'escape' => false) - ); - ?> -

-

- Html->link( - __d('cake_dev', 'The 15 min Blog Tutorial'), - 'http://book.cakephp.org/2.0/en/tutorials-and-examples/blog/blog.html', - array('target' => '_blank', 'escape' => false) - ); - ?> -

- -

-

- -

-

- -

- - diff --git a/lib/Cake/Console/Templates/skel/View/Scaffolds/empty b/lib/Cake/Console/Templates/skel/View/Scaffolds/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/lib/Cake/Console/Templates/skel/index.php b/lib/Cake/Console/Templates/skel/index.php deleted file mode 100644 index 29f2c572852..00000000000 --- a/lib/Cake/Console/Templates/skel/index.php +++ /dev/null @@ -1,17 +0,0 @@ - - RewriteEngine On - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^(.*)$ index.php [QSA,L] - diff --git a/lib/Cake/Console/Templates/skel/webroot/css/cake.generic.css b/lib/Cake/Console/Templates/skel/webroot/css/cake.generic.css deleted file mode 100644 index 2244ad2f868..00000000000 --- a/lib/Cake/Console/Templates/skel/webroot/css/cake.generic.css +++ /dev/null @@ -1,739 +0,0 @@ -@charset "utf-8"; -/** - * - * Generic CSS for CakePHP - * - * CakePHP(tm) : Rapid Development Framework (http://cakephp.org) - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://cakephp.org CakePHP(tm) Project - * @package app.webroot.css - * @since CakePHP(tm) - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -* { - margin:0; - padding:0; -} - -/** General Style Info **/ -body { - background: #003d4c; - color: #fff; - font-family:'lucida grande',verdana,helvetica,arial,sans-serif; - font-size:90%; - margin: 0; -} -a { - color: #003d4c; - text-decoration: underline; - font-weight: bold; -} -a:hover { - color: #367889; - text-decoration:none; -} -a img { - border:none; -} -h1, h2, h3, h4 { - font-weight: normal; - margin-bottom:0.5em; -} -h1 { - background:#fff; - color: #003d4c; - font-size: 100%; -} -h2 { - background:#fff; - color: #e32; - font-family:'Gill Sans','lucida grande', helvetica, arial, sans-serif; - font-size: 190%; -} -h3 { - color: #2c6877; - font-family:'Gill Sans','lucida grande', helvetica, arial, sans-serif; - font-size: 165%; -} -h4 { - color: #993; - font-weight: normal; -} -ul, li { - margin: 0 12px; -} -p { - margin: 0 0 1em 0; -} - -/** Layout **/ -#container { - text-align: left; -} - -#header{ - padding: 10px 20px; -} -#header h1 { - line-height:20px; - background: #003d4c url('../img/cake.icon.png') no-repeat left; - color: #fff; - padding: 0px 30px; -} -#header h1 a { - color: #fff; - background: #003d4c; - font-weight: normal; - text-decoration: none; -} -#header h1 a:hover { - color: #fff; - background: #003d4c; - text-decoration: underline; -} -#content{ - background: #fff; - clear: both; - color: #333; - padding: 10px 20px 40px 20px; - overflow: auto; -} -#footer { - clear: both; - padding: 6px 10px; - text-align: right; -} - -/** containers **/ -div.form, -div.index, -div.view { - float:right; - width:76%; - border-left:1px solid #666; - padding:10px 2%; -} -div.actions { - float:left; - width:16%; - padding:10px 1.5%; -} -div.actions h3 { - padding-top:0; - color:#777; -} - - -/** Tables **/ -table { - border-right:0; - clear: both; - color: #333; - margin-bottom: 10px; - width: 100%; -} -th { - border:0; - border-bottom:2px solid #555; - text-align: left; - padding:4px; -} -th a { - display: block; - padding: 2px 4px; - text-decoration: none; -} -th a.asc:after { - content: ' ⇣'; -} -th a.desc:after { - content: ' ⇡'; -} -table tr td { - padding: 6px; - text-align: left; - vertical-align: top; - border-bottom:1px solid #ddd; -} -table tr:nth-child(even) { - background: #f9f9f9; -} -td.actions { - text-align: center; - white-space: nowrap; -} -table td.actions a { - margin: 0px 6px; - padding:2px 5px; -} - -/* SQL log */ -.cake-sql-log { - background: #fff; -} -.cake-sql-log td { - padding: 4px 8px; - text-align: left; - font-family: Monaco, Consolas, "Courier New", monospaced; -} -.cake-sql-log caption { - color:#fff; -} - -/** Paging **/ -.paging { - background:#fff; - color: #ccc; - margin-top: 1em; - clear:both; -} -.paging .current, -.paging .disabled, -.paging a { - text-decoration: none; - padding: 5px 8px; - display: inline-block -} -.paging > span { - display: inline-block; - border: 1px solid #ccc; - border-left: 0; -} -.paging > span:hover { - background: #efefef; -} -.paging .prev { - border-left: 1px solid #ccc; - -moz-border-radius: 4px 0 0 4px; - -webkit-border-radius: 4px 0 0 4px; - border-radius: 4px 0 0 4px; -} -.paging .next { - -moz-border-radius: 0 4px 4px 0; - -webkit-border-radius: 0 4px 4px 0; - border-radius: 0 4px 4px 0; -} -.paging .disabled { - color: #ddd; -} -.paging .disabled:hover { - background: transparent; -} -.paging .current { - background: #efefef; - color: #c73e14; -} - -/** Scaffold View **/ -dl { - line-height: 2em; - margin: 0em 0em; - width: 60%; -} -dl dd:nth-child(4n+2), -dl dt:nth-child(4n+1) { - background: #f4f4f4; -} - -dt { - font-weight: bold; - padding-left: 4px; - vertical-align: top; - width: 10em; -} -dd { - margin-left: 10em; - margin-top: -2em; - vertical-align: top; -} - -/** Forms **/ -form { - clear: both; - margin-right: 20px; - padding: 0; - width: 95%; -} -fieldset { - border: none; - margin-bottom: 1em; - padding: 16px 10px; -} -fieldset legend { - color: #e32; - font-size: 160%; - font-weight: bold; -} -fieldset fieldset { - margin-top: 0; - padding: 10px 0 0; -} -fieldset fieldset legend { - font-size: 120%; - font-weight: normal; -} -fieldset fieldset div { - clear: left; - margin: 0 20px; -} -form div { - clear: both; - margin-bottom: 1em; - padding: .5em; - vertical-align: text-top; -} -form .input { - color: #444; -} -form .required { - font-weight: bold; -} -form .required label:after { - color: #e32; - content: '*'; - display:inline; -} -form div.submit { - border: 0; - clear: both; - margin-top: 10px; -} -label { - display: block; - font-size: 110%; - margin-bottom:3px; -} -input, textarea { - clear: both; - font-size: 140%; - font-family: "frutiger linotype", "lucida grande", "verdana", sans-serif; - padding: 1%; - width:98%; -} -select { - clear: both; - font-size: 120%; - vertical-align: text-bottom; -} -select[multiple=multiple] { - width: 100%; -} -option { - font-size: 120%; - padding: 0 3px; -} -input[type=checkbox] { - clear: left; - float: left; - margin: 0px 6px 7px 2px; - width: auto; -} -div.checkbox label { - display: inline; -} -input[type=radio] { - float:left; - width:auto; - margin: 6px 0; - padding: 0; - line-height: 26px; -} -.radio label { - margin: 0 0 6px 20px; - line-height: 26px; -} -input[type=submit] { - display: inline; - font-size: 110%; - width: auto; -} -form .submit input[type=submit] { - background:#62af56; - background-image: -webkit-gradient(linear, left top, left bottom, from(#76BF6B), to(#3B8230)); - background-image: -webkit-linear-gradient(top, #76BF6B, #3B8230); - background-image: -moz-linear-gradient(top, #76BF6B, #3B8230); - border-color: #2d6324; - color: #fff; - text-shadow: rgba(0, 0, 0, 0.5) 0px -1px 0px; - padding: 8px 10px; -} -form .submit input[type=submit]:hover { - background: #5BA150; -} -/* Form errors */ -form .error { - background: #FFDACC; - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - border-radius: 4px; - font-weight: normal; -} -form .error-message { - -moz-border-radius: none; - -webkit-border-radius: none; - border-radius: none; - border: none; - background: none; - margin: 0; - padding-left: 4px; - padding-right: 0; -} -form .error, -form .error-message { - color: #9E2424; - -webkit-box-shadow: none; - -moz-box-shadow: none; - -ms-box-shadow: none; - -o-box-shadow: none; - box-shadow: none; - text-shadow: none; -} - -/** Notices and Errors **/ -.message { - clear: both; - color: #fff; - font-size: 140%; - font-weight: bold; - margin: 0 0 1em 0; - padding: 5px; -} - -.success, -.message, -.cake-error, -.cake-debug, -.notice, -p.error, -.error-message { - background: #ffcc00; - background-repeat: repeat-x; - background-image: -moz-linear-gradient(top, #ffcc00, #E6B800); - background-image: -ms-linear-gradient(top, #ffcc00, #E6B800); - background-image: -webkit-gradient(linear, left top, left bottom, from(#ffcc00), to(#E6B800)); - background-image: -webkit-linear-gradient(top, #ffcc00, #E6B800); - background-image: -o-linear-gradient(top, #ffcc00, #E6B800); - background-image: linear-gradient(top, #ffcc00, #E6B800); - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); - border: 1px solid rgba(0, 0, 0, 0.2); - margin-bottom: 18px; - padding: 7px 14px; - color: #404040; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); - -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); -} -.success, -.message, -.cake-error, -p.error, -.error-message { - clear: both; - color: #fff; - background: #c43c35; - border: 1px solid rgba(0, 0, 0, 0.5); - background-repeat: repeat-x; - background-image: -moz-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -ms-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -webkit-gradient(linear, left top, left bottom, from(#ee5f5b), to(#c43c35)); - background-image: -webkit-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -o-linear-gradient(top, #ee5f5b, #c43c35); - background-image: linear-gradient(top, #ee5f5b, #c43c35); - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); -} -.success { - clear: both; - color: #fff; - border: 1px solid rgba(0, 0, 0, 0.5); - background: #3B8230; - background-repeat: repeat-x; - background-image: -webkit-gradient(linear, left top, left bottom, from(#76BF6B), to(#3B8230)); - background-image: -webkit-linear-gradient(top, #76BF6B, #3B8230); - background-image: -moz-linear-gradient(top, #76BF6B, #3B8230); - background-image: -ms-linear-gradient(top, #76BF6B, #3B8230); - background-image: -o-linear-gradient(top, #76BF6B, #3B8230); - background-image: linear-gradient(top, #76BF6B, #3B8230); - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.3); -} -p.error { - font-family: Monaco, Consolas, Courier, monospace; - font-size: 120%; - padding: 0.8em; - margin: 1em 0; -} -p.error em { - font-weight: normal; - line-height: 140%; -} -.notice { - color: #000; - display: block; - font-size: 120%; - padding: 0.8em; - margin: 1em 0; -} -.success { - color: #fff; -} - -/** Actions **/ -.actions ul { - margin: 0; - padding: 0; -} -.actions li { - margin:0 0 0.5em 0; - list-style-type: none; - white-space: nowrap; - padding: 0; -} -.actions ul li a { - font-weight: normal; - display: block; - clear: both; -} - -/* Buttons and button links */ -input[type=submit], -.actions ul li a, -.actions a { - font-weight:normal; - padding: 4px 8px; - background: #dcdcdc; - background-image: -webkit-gradient(linear, left top, left bottom, from(#fefefe), to(#dcdcdc)); - background-image: -webkit-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -moz-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -ms-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -o-linear-gradient(top, #fefefe, #dcdcdc); - background-image: linear-gradient(top, #fefefe, #dcdcdc); - color:#333; - border:1px solid #bbb; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - text-decoration: none; - text-shadow: #fff 0px 1px 0px; - min-width: 0; - -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0px 1px 1px rgba(0, 0, 0, 0.2); - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0px 1px 1px rgba(0, 0, 0, 0.2); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0px 1px 1px rgba(0, 0, 0, 0.2); - -webkit-user-select: none; - user-select: none; -} -.actions ul li a:hover, -.actions a:hover { - background: #ededed; - border-color: #acacac; - text-decoration: none; -} -input[type=submit]:active, -.actions ul li a:active, -.actions a:active { - background: #eee; - background-image: -webkit-gradient(linear, left top, left bottom, from(#dfdfdf), to(#eee)); - background-image: -webkit-linear-gradient(top, #dfdfdf, #eee); - background-image: -moz-linear-gradient(top, #dfdfdf, #eee); - background-image: -ms-linear-gradient(top, #dfdfdf, #eee); - background-image: -o-linear-gradient(top, #dfdfdf, #eee); - background-image: linear-gradient(top, #dfdfdf, #eee); - text-shadow: #eee 0px 1px 0px; - -moz-box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); - -webkit-box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); - box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); - border-color: #aaa; - text-decoration: none; -} - -/** Related **/ -.related { - clear: both; - display: block; -} - -/** Debugging **/ -pre { - color: #000; - background: #f0f0f0; - padding: 15px; - -moz-box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); - -webkit-box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); - box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); -} -.cake-debug-output { - padding: 0; - position: relative; -} -.cake-debug-output > span { - position: absolute; - top: 5px; - right: 5px; - background: rgba(255, 255, 255, 0.3); - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - border-radius: 4px; - padding: 5px 6px; - color: #000; - display: block; - float: left; - -moz-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 0 rgba(255, 255, 255, 0.5); - -webkit-box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 0 rgba(255, 255, 255, 0.5); - box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.25), 0 1px 0 rgba(255, 255, 255, 0.5); - text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8); -} -.cake-debug, -.cake-error { - font-size: 16px; - line-height: 20px; - clear: both; -} -.cake-error > a { - text-shadow: none; -} -.cake-error { - white-space: normal; -} -.cake-stack-trace { - background: rgba(255, 255, 255, 0.7); - color: #333; - margin: 10px 0 5px 0; - padding: 10px 10px 0 10px; - font-size: 120%; - line-height: 140%; - overflow: auto; - position: relative; - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - border-radius: 4px; -} -.cake-stack-trace a { - text-shadow: none; - background: rgba(255, 255, 255, 0.7); - padding: 5px; - -moz-border-radius: 10px; - -webkit-border-radius: 10px; - border-radius: 10px; - margin: 0px 4px 10px 2px; - font-family: sans-serif; - font-size: 14px; - line-height: 14px; - display: inline-block; - text-decoration: none; - -moz-box-shadow: inset 0px 1px 0 rgba(0, 0, 0, 0.3); - -webkit-box-shadow: inset 0px 1px 0 rgba(0, 0, 0, 0.3); - box-shadow: inset 0px 1px 0 rgba(0, 0, 0, 0.3); -} -.cake-code-dump pre { - position: relative; - overflow: auto; -} -.cake-context { - margin-bottom: 10px; -} -.cake-stack-trace pre { - color: #000; - background-color: #F0F0F0; - margin: 0px 0 10px 0; - padding: 1em; - overflow: auto; - text-shadow: none; -} -.cake-stack-trace li { - padding: 10px 5px 0px; - margin: 0 0 4px 0; - font-family: monospace; - border: 1px solid #bbb; - -moz-border-radius: 4px; - -wekbkit-border-radius: 4px; - border-radius: 4px; - background: #dcdcdc; - background-image: -webkit-gradient(linear, left top, left bottom, from(#fefefe), to(#dcdcdc)); - background-image: -webkit-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -moz-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -ms-linear-gradient(top, #fefefe, #dcdcdc); - background-image: -o-linear-gradient(top, #fefefe, #dcdcdc); - background-image: linear-gradient(top, #fefefe, #dcdcdc); -} -/* excerpt */ -.cake-code-dump pre, -.cake-code-dump pre code { - clear: both; - font-size: 12px; - line-height: 15px; - margin: 4px 2px; - padding: 4px; - overflow: auto; -} -.cake-code-dump .code-highlight { - display: block; - background-color: rgba(255, 255, 0, 0.5); -} -.code-coverage-results div.code-line { - padding-left:5px; - display:block; - margin-left:10px; -} -.code-coverage-results div.uncovered span.content { - background:#ecc; -} -.code-coverage-results div.covered span.content { - background:#cec; -} -.code-coverage-results div.ignored span.content { - color:#aaa; -} -.code-coverage-results span.line-num { - color:#666; - display:block; - float:left; - width:20px; - text-align:right; - margin-right:5px; -} -.code-coverage-results span.line-num strong { - color:#666; -} -.code-coverage-results div.start { - border:1px solid #aaa; - border-width:1px 1px 0px 1px; - margin-top:30px; - padding-top:5px; -} -.code-coverage-results div.end { - border:1px solid #aaa; - border-width:0px 1px 1px 1px; - margin-bottom:30px; - padding-bottom:5px; -} -.code-coverage-results div.realstart { - margin-top:0px; -} -.code-coverage-results p.note { - color:#bbb; - padding:5px; - margin:5px 0 10px; - font-size:10px; -} -.code-coverage-results span.result-bad { - color: #a00; -} -.code-coverage-results span.result-ok { - color: #fa0; -} -.code-coverage-results span.result-good { - color: #0a0; -} - -/** Elements **/ -#url-rewriting-warning { - display:none; -} diff --git a/lib/Cake/Console/Templates/skel/webroot/favicon.ico b/lib/Cake/Console/Templates/skel/webroot/favicon.ico deleted file mode 100644 index b36e81f2f35..00000000000 Binary files a/lib/Cake/Console/Templates/skel/webroot/favicon.ico and /dev/null differ diff --git a/lib/Cake/Console/Templates/skel/webroot/files/empty b/lib/Cake/Console/Templates/skel/webroot/files/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/lib/Cake/Console/Templates/skel/webroot/img/cake.icon.png b/lib/Cake/Console/Templates/skel/webroot/img/cake.icon.png deleted file mode 100644 index 394fa42d513..00000000000 Binary files a/lib/Cake/Console/Templates/skel/webroot/img/cake.icon.png and /dev/null differ diff --git a/lib/Cake/Console/Templates/skel/webroot/img/test-error-icon.png b/lib/Cake/Console/Templates/skel/webroot/img/test-error-icon.png deleted file mode 100644 index 07bb1241143..00000000000 Binary files a/lib/Cake/Console/Templates/skel/webroot/img/test-error-icon.png and /dev/null differ diff --git a/lib/Cake/Console/Templates/skel/webroot/img/test-fail-icon.png b/lib/Cake/Console/Templates/skel/webroot/img/test-fail-icon.png deleted file mode 100644 index f9d2f147ec4..00000000000 Binary files a/lib/Cake/Console/Templates/skel/webroot/img/test-fail-icon.png and /dev/null differ diff --git a/lib/Cake/Console/Templates/skel/webroot/img/test-pass-icon.png b/lib/Cake/Console/Templates/skel/webroot/img/test-pass-icon.png deleted file mode 100644 index 99c5eb05ad2..00000000000 Binary files a/lib/Cake/Console/Templates/skel/webroot/img/test-pass-icon.png and /dev/null differ diff --git a/lib/Cake/Console/Templates/skel/webroot/img/test-skip-icon.png b/lib/Cake/Console/Templates/skel/webroot/img/test-skip-icon.png deleted file mode 100644 index 749771c9895..00000000000 Binary files a/lib/Cake/Console/Templates/skel/webroot/img/test-skip-icon.png and /dev/null differ diff --git a/lib/Cake/Console/Templates/skel/webroot/index.php b/lib/Cake/Console/Templates/skel/webroot/index.php deleted file mode 100644 index 17b461c0c1b..00000000000 --- a/lib/Cake/Console/Templates/skel/webroot/index.php +++ /dev/null @@ -1,101 +0,0 @@ -dispatch( - new CakeRequest(), - new CakeResponse(array('charset' => Configure::read('App.encoding'))) -); diff --git a/lib/Cake/Console/Templates/skel/webroot/js/empty b/lib/Cake/Console/Templates/skel/webroot/js/empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/lib/Cake/Console/Templates/skel/webroot/test.php b/lib/Cake/Console/Templates/skel/webroot/test.php deleted file mode 100644 index c287579435f..00000000000 --- a/lib/Cake/Console/Templates/skel/webroot/test.php +++ /dev/null @@ -1,94 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing - * @package app.webroot - * @since CakePHP(tm) v 1.2.0.4433 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -set_time_limit(0); -ini_set('display_errors', 1); -/** - * Use the DS to separate the directories in other defines - */ -if (!defined('DS')) { - define('DS', DIRECTORY_SEPARATOR); -} - -/** - * These defines should only be edited if you have cake installed in - * a directory layout other than the way it is distributed. - * When using custom settings be sure to use the DS and do not add a trailing DS. - */ - -/** - * The full path to the directory which holds "app", WITHOUT a trailing DS. - * - */ -if (!defined('ROOT')) { - define('ROOT', dirname(dirname(dirname(__FILE__)))); -} - -/** - * The actual directory name for the "app". - * - */ -if (!defined('APP_DIR')) { - define('APP_DIR', basename(dirname(dirname(__FILE__)))); -} - -/** - * The absolute path to the "Cake" directory, WITHOUT a trailing DS. - * - * For ease of development CakePHP uses PHP's include_path. If you - * need to cannot modify your include_path, you can set this path. - * - * Leaving this constant undefined will result in it being defined in Cake/bootstrap.php - */ -//define('CAKE_CORE_INCLUDE_PATH', __CAKE_PATH__); - -/** - * Editing below this line should not be necessary. - * Change at your own risk. - * - */ -if (!defined('WEBROOT_DIR')) { - define('WEBROOT_DIR', basename(dirname(__FILE__))); -} -if (!defined('WWW_ROOT')) { - define('WWW_ROOT', dirname(__FILE__) . DS); -} - -if (!defined('CAKE_CORE_INCLUDE_PATH')) { - if (function_exists('ini_set')) { - ini_set('include_path', ROOT . DS . 'lib' . PATH_SEPARATOR . ini_get('include_path')); - } - if (!include 'Cake' . DS . 'bootstrap.php') { - $failed = true; - } -} else { - if (!include CAKE_CORE_INCLUDE_PATH . DS . 'Cake' . DS . 'bootstrap.php') { - $failed = true; - } -} -if (!empty($failed)) { - trigger_error("CakePHP core could not be found. Check the value of CAKE_CORE_INCLUDE_PATH in APP/webroot/index.php. It should point to the directory containing your " . DS . "cake core directory and your " . DS . "vendors root directory.", E_USER_ERROR); -} - -if (Configure::read('debug') < 1) { - die(__d('cake_dev', 'Debug setting does not allow access to this url.')); -} - -require_once CAKE . 'TestSuite' . DS . 'CakeTestSuiteDispatcher.php'; - -CakeTestSuiteDispatcher::run(); diff --git a/lib/Cake/Console/cake b/lib/Cake/Console/cake deleted file mode 100755 index 42486696692..00000000000 --- a/lib/Cake/Console/cake +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -################################################################################ -# -# Bake is a shell script for running CakePHP bake script -# PHP 5 -# -# CakePHP(tm) : Rapid Development Framework (http://cakephp.org) -# Copyright 2005-2012, Cake Software Foundation, Inc. -# -# Licensed under The MIT License -# Redistributions of files must retain the above copyright notice. -# -# @copyright Copyright 2005-2012, Cake Software Foundation, Inc. -# @link http://cakephp.org CakePHP(tm) Project -# @package cake.console -# @since CakePHP(tm) v 1.2.0.5012 -# @license MIT License (http://www.opensource.org/licenses/mit-license.php) -# -################################################################################ -LIB=$(cd -P -- "$(dirname -- "$0")" && pwd -P) && LIB=$LIB/$(basename -- "$0") - -while [ -h "$LIB" ]; do - DIR=$(dirname -- "$LIB") - SYM=$(readlink "$LIB") - LIB=$(cd "$DIR" && cd $(dirname -- "$SYM") && pwd)/$(basename -- "$SYM") -done - -LIB=$(dirname -- "$LIB")/ -APP=`pwd` - -exec php -q "$LIB"cake.php -working "$APP" "$@" - -exit; diff --git a/lib/Cake/Console/cake.bat b/lib/Cake/Console/cake.bat deleted file mode 100644 index 4444f385df3..00000000000 --- a/lib/Cake/Console/cake.bat +++ /dev/null @@ -1,32 +0,0 @@ -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -:: -:: Bake is a shell script for running CakePHP bake script -:: PHP 5 -:: -:: CakePHP(tm) : Rapid Development Framework (http://cakephp.org) -:: Copyright 2005-2012, Cake Software Foundation, Inc. -:: -:: Licensed under The MIT License -:: Redistributions of files must retain the above copyright notice. -:: -:: @copyright Copyright 2005-2012, Cake Software Foundation, Inc. -:: @link http://cakephp.org CakePHP(tm) Project -:: @package cake.console -:: @since CakePHP(tm) v 1.2.0.5012 -:: @license MIT License (http://www.opensource.org/licenses/mit-license.php) -:: -:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: - -:: In order for this script to work as intended, the cake\console\ folder must be in your PATH - -@echo. -@echo off - -SET app=%0 -SET lib=%~dp0 - -php -q "%lib%cake.php" -working "%CD% " %* - -echo. - -exit /B %ERRORLEVEL% diff --git a/lib/Cake/Console/cake.php b/lib/Cake/Console/cake.php deleted file mode 100644 index bae9cf3325f..00000000000 --- a/lib/Cake/Console/cake.php +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/php -q -components[] = 'RequestHandler'; - } - $this->constructClasses(); - $this->Components->trigger('initialize', array(&$this)); - - $this->_set(array('cacheAction' => false, 'viewPath' => 'Errors')); - if (isset($this->RequestHandler)) { - $this->RequestHandler->startup($this); - } - } - -/** - * Escapes the viewVars. - * - * @return void - */ - public function beforeRender() { - parent::beforeRender(); - foreach ($this->viewVars as $key => $value) { - if (!is_object($value)) { - $this->viewVars[$key] = h($value); - } - } - } - -} diff --git a/lib/Cake/Controller/Component.php b/lib/Cake/Controller/Component.php deleted file mode 100644 index cecf39e059a..00000000000 --- a/lib/Cake/Controller/Component.php +++ /dev/null @@ -1,165 +0,0 @@ -_Collection = $collection; - $this->settings = $settings; - $this->_set($settings); - if (!empty($this->components)) { - $this->_componentMap = ComponentCollection::normalizeObjectArray($this->components); - } - } - -/** - * Magic method for lazy loading $components. - * - * @param string $name Name of component to get. - * @return mixed A Component object or null. - */ - public function __get($name) { - if (isset($this->_componentMap[$name]) && !isset($this->{$name})) { - $settings = array_merge((array)$this->_componentMap[$name]['settings'], array('enabled' => false)); - $this->{$name} = $this->_Collection->load($this->_componentMap[$name]['class'], $settings); - } - if (isset($this->{$name})) { - return $this->{$name}; - } - } - -/** - * Called before the Controller::beforeFilter(). - * - * @param Controller $controller Controller with components to initialize - * @return void - * @link http://book.cakephp.org/2.0/en/controllers/components.html#Component::initialize - */ - public function initialize(Controller $controller) { - } - -/** - * Called after the Controller::beforeFilter() and before the controller action - * - * @param Controller $controller Controller with components to startup - * @return void - * @link http://book.cakephp.org/2.0/en/controllers/components.html#Component::startup - */ - public function startup(Controller $controller) { - } - -/** - * Called before the Controller::beforeRender(), and before - * the view class is loaded, and before Controller::render() - * - * @param Controller $controller Controller with components to beforeRender - * @return void - * @link http://book.cakephp.org/2.0/en/controllers/components.html#Component::beforeRender - */ - public function beforeRender(Controller $controller) { - } - -/** - * Called after Controller::render() and before the output is printed to the browser. - * - * @param Controller $controller Controller with components to shutdown - * @return void - * @link @link http://book.cakephp.org/2.0/en/controllers/components.html#Component::shutdown - */ - public function shutdown(Controller $controller) { - } - -/** - * Called before Controller::redirect(). Allows you to replace the url that will - * be redirected to with a new url. The return of this method can either be an array or a string. - * - * If the return is an array and contains a 'url' key. You may also supply the following: - * - * - `status` The status code for the redirect - * - `exit` Whether or not the redirect should exit. - * - * If your response is a string or an array that does not contain a 'url' key it will - * be used as the new url to redirect to. - * - * @param Controller $controller Controller with components to beforeRedirect - * @param string|array $url Either the string or url array that is being redirected to. - * @param integer $status The status code of the redirect - * @param boolean $exit Will the script exit. - * @return array|null Either an array or null. - * @link @link http://book.cakephp.org/2.0/en/controllers/components.html#Component::beforeRedirect - */ - public function beforeRedirect(Controller $controller, $url, $status = null, $exit = true) { - } - -} diff --git a/lib/Cake/Controller/Component/Acl/AclInterface.php b/lib/Cake/Controller/Component/Acl/AclInterface.php deleted file mode 100644 index 7cd98135b81..00000000000 --- a/lib/Cake/Controller/Component/Acl/AclInterface.php +++ /dev/null @@ -1,70 +0,0 @@ -Aro = ClassRegistry::init(array('class' => 'Aro', 'alias' => 'Aro')); - $this->Aco = ClassRegistry::init(array('class' => 'Aco', 'alias' => 'Aco')); - } - -/** - * Initializes the containing component and sets the Aro/Aco objects to it. - * - * @param AclComponent $component - * @return void - */ - public function initialize(Component $component) { - $component->Aro = $this->Aro; - $component->Aco = $this->Aco; - } - -/** - * Checks if the given $aro has access to action $action in $aco - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success (true if ARO has access to action in ACO, false otherwise) - * @link http://book.cakephp.org/2.0/en/core-libraries/components/access-control-lists.html#checking-permissions-the-acl-component - */ - public function check($aro, $aco, $action = "*") { - if ($aro == null || $aco == null) { - return false; - } - - $permKeys = $this->_getAcoKeys($this->Aro->Permission->schema()); - $aroPath = $this->Aro->node($aro); - $acoPath = $this->Aco->node($aco); - - if (empty($aroPath) || empty($acoPath)) { - trigger_error(__d('cake_dev', "DbAcl::check() - Failed ARO/ACO node lookup in permissions check. Node references:\nAro: ") . print_r($aro, true) . "\nAco: " . print_r($aco, true), E_USER_WARNING); - return false; - } - - if ($acoPath == null || $acoPath == array()) { - trigger_error(__d('cake_dev', "DbAcl::check() - Failed ACO node lookup in permissions check. Node references:\nAro: ") . print_r($aro, true) . "\nAco: " . print_r($aco, true), E_USER_WARNING); - return false; - } - - if ($action != '*' && !in_array('_' . $action, $permKeys)) { - trigger_error(__d('cake_dev', "ACO permissions key %s does not exist in DbAcl::check()", $action), E_USER_NOTICE); - return false; - } - - $inherited = array(); - $acoIDs = Set::extract($acoPath, '{n}.' . $this->Aco->alias . '.id'); - - $count = count($aroPath); - for ($i = 0; $i < $count; $i++) { - $permAlias = $this->Aro->Permission->alias; - - $perms = $this->Aro->Permission->find('all', array( - 'conditions' => array( - "{$permAlias}.aro_id" => $aroPath[$i][$this->Aro->alias]['id'], - "{$permAlias}.aco_id" => $acoIDs - ), - 'order' => array($this->Aco->alias . '.lft' => 'desc'), - 'recursive' => 0 - )); - - if (empty($perms)) { - continue; - } else { - $perms = Set::extract($perms, '{n}.' . $this->Aro->Permission->alias); - foreach ($perms as $perm) { - if ($action == '*') { - - foreach ($permKeys as $key) { - if (!empty($perm)) { - if ($perm[$key] == -1) { - return false; - } elseif ($perm[$key] == 1) { - $inherited[$key] = 1; - } - } - } - - if (count($inherited) === count($permKeys)) { - return true; - } - } else { - switch ($perm['_' . $action]) { - case -1: - return false; - case 0: - continue; - break; - case 1: - return true; - break; - } - } - } - } - } - return false; - } - -/** - * Allow $aro to have access to action $actions in $aco - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $actions Action (defaults to *) - * @param integer $value Value to indicate access type (1 to give access, -1 to deny, 0 to inherit) - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/core-libraries/components/access-control-lists.html#assigning-permissions - */ - public function allow($aro, $aco, $actions = "*", $value = 1) { - $perms = $this->getAclLink($aro, $aco); - $permKeys = $this->_getAcoKeys($this->Aro->Permission->schema()); - $save = array(); - - if ($perms == false) { - trigger_error(__d('cake_dev', 'DbAcl::allow() - Invalid node'), E_USER_WARNING); - return false; - } - if (isset($perms[0])) { - $save = $perms[0][$this->Aro->Permission->alias]; - } - - if ($actions == "*") { - $permKeys = $this->_getAcoKeys($this->Aro->Permission->schema()); - $save = array_combine($permKeys, array_pad(array(), count($permKeys), $value)); - } else { - if (!is_array($actions)) { - $actions = array('_' . $actions); - } - if (is_array($actions)) { - foreach ($actions as $action) { - if ($action{0} != '_') { - $action = '_' . $action; - } - if (in_array($action, $permKeys)) { - $save[$action] = $value; - } - } - } - } - list($save['aro_id'], $save['aco_id']) = array($perms['aro'], $perms['aco']); - - if ($perms['link'] != null && !empty($perms['link'])) { - $save['id'] = $perms['link'][0][$this->Aro->Permission->alias]['id']; - } else { - unset($save['id']); - $this->Aro->Permission->id = null; - } - return ($this->Aro->Permission->save($save) !== false); - } - -/** - * Deny access for $aro to action $action in $aco - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/core-libraries/components/access-control-lists.html#assigning-permissions - */ - public function deny($aro, $aco, $action = "*") { - return $this->allow($aro, $aco, $action, -1); - } - -/** - * Let access for $aro to action $action in $aco be inherited - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function inherit($aro, $aco, $action = "*") { - return $this->allow($aro, $aco, $action, 0); - } - -/** - * Allow $aro to have access to action $actions in $aco - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - * @see allow() - */ - public function grant($aro, $aco, $action = "*") { - return $this->allow($aro, $aco, $action); - } - -/** - * Deny access for $aro to action $action in $aco - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - * @see deny() - */ - public function revoke($aro, $aco, $action = "*") { - return $this->deny($aro, $aco, $action); - } - -/** - * Get an array of access-control links between the given Aro and Aco - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @return array Indexed array with: 'aro', 'aco' and 'link' - */ - public function getAclLink($aro, $aco) { - $obj = array(); - $obj['Aro'] = $this->Aro->node($aro); - $obj['Aco'] = $this->Aco->node($aco); - - if (empty($obj['Aro']) || empty($obj['Aco'])) { - return false; - } - - return array( - 'aro' => Set::extract($obj, 'Aro.0.' . $this->Aro->alias . '.id'), - 'aco' => Set::extract($obj, 'Aco.0.' . $this->Aco->alias . '.id'), - 'link' => $this->Aro->Permission->find('all', array('conditions' => array( - $this->Aro->Permission->alias . '.aro_id' => Set::extract($obj, 'Aro.0.' . $this->Aro->alias . '.id'), - $this->Aro->Permission->alias . '.aco_id' => Set::extract($obj, 'Aco.0.' . $this->Aco->alias . '.id') - ))) - ); - } - -/** - * Get the keys used in an ACO - * - * @param array $keys Permission model info - * @return array ACO keys - */ - protected function _getAcoKeys($keys) { - $newKeys = array(); - $keys = array_keys($keys); - foreach ($keys as $key) { - if (!in_array($key, array('id', 'aro_id', 'aco_id'))) { - $newKeys[] = $key; - } - } - return $newKeys; - } - -} diff --git a/lib/Cake/Controller/Component/Acl/IniAcl.php b/lib/Cake/Controller/Component/Acl/IniAcl.php deleted file mode 100644 index 8af85ca5061..00000000000 --- a/lib/Cake/Controller/Component/Acl/IniAcl.php +++ /dev/null @@ -1,172 +0,0 @@ -config == null) { - $this->config = $this->readConfigFile(APP . 'Config' . DS . 'acl.ini.php'); - } - $aclConfig = $this->config; - - if (is_array($aro)) { - $aro = Set::classicExtract($aro, $this->userPath); - } - - if (isset($aclConfig[$aro]['deny'])) { - $userDenies = $this->arrayTrim(explode(",", $aclConfig[$aro]['deny'])); - - if (array_search($aco, $userDenies)) { - return false; - } - } - - if (isset($aclConfig[$aro]['allow'])) { - $userAllows = $this->arrayTrim(explode(",", $aclConfig[$aro]['allow'])); - - if (array_search($aco, $userAllows)) { - return true; - } - } - - if (isset($aclConfig[$aro]['groups'])) { - $userGroups = $this->arrayTrim(explode(",", $aclConfig[$aro]['groups'])); - - foreach ($userGroups as $group) { - if (array_key_exists($group, $aclConfig)) { - if (isset($aclConfig[$group]['deny'])) { - $groupDenies = $this->arrayTrim(explode(",", $aclConfig[$group]['deny'])); - - if (array_search($aco, $groupDenies)) { - return false; - } - } - - if (isset($aclConfig[$group]['allow'])) { - $groupAllows = $this->arrayTrim(explode(",", $aclConfig[$group]['allow'])); - - if (array_search($aco, $groupAllows)) { - return true; - } - } - } - } - } - return false; - } - -/** - * Parses an INI file and returns an array that reflects the - * INI file's section structure. Double-quote friendly. - * - * @param string $filename File - * @return array INI section structure - */ - public function readConfigFile($filename) { - App::uses('IniReader', 'Configure'); - $iniFile = new IniReader(dirname($filename) . DS); - return $iniFile->read(basename($filename)); - } - -/** - * Removes trailing spaces on all array elements (to prepare for searching) - * - * @param array $array Array to trim - * @return array Trimmed array - */ - public function arrayTrim($array) { - foreach ($array as $key => $value) { - $array[$key] = trim($value); - } - array_unshift($array, ""); - return $array; - } - -} diff --git a/lib/Cake/Controller/Component/Acl/PhpAcl.php b/lib/Cake/Controller/Component/Acl/PhpAcl.php deleted file mode 100644 index d2af2f960f9..00000000000 --- a/lib/Cake/Controller/Component/Acl/PhpAcl.php +++ /dev/null @@ -1,539 +0,0 @@ -options = array( - 'policy' => self::DENY, - 'config' => APP . 'Config' . DS . 'acl.php', - ); - } - -/** - * Initialize method - * - * @param AclComponent $Component Component instance - * @return void - */ - public function initialize(Component $Component) { - if (!empty($Component->settings['adapter'])) { - $this->options = array_merge($this->options, $Component->settings['adapter']); - } - - App::uses('PhpReader', 'Configure'); - $Reader = new PhpReader(dirname($this->options['config']) . DS); - $config = $Reader->read(basename($this->options['config'])); - $this->build($config); - $Component->Aco = $this->Aco; - $Component->Aro = $this->Aro; - } - -/** - * build and setup internal ACL representation - * - * @param array $config configuration array, see docs - * @return void - * @throws AclException When required keys are missing. - */ - public function build(array $config) { - if (empty($config['roles'])) { - throw new AclException(__d('cake_dev','"roles" section not found in configuration.')); - } - - if (empty($config['rules']['allow']) && empty($config['rules']['deny'])) { - throw new AclException(__d('cake_dev','Neither "allow" nor "deny" rules were provided in configuration.')); - } - - $rules['allow'] = !empty($config['rules']['allow']) ? $config['rules']['allow'] : array(); - $rules['deny'] = !empty($config['rules']['deny']) ? $config['rules']['deny'] : array(); - $roles = !empty($config['roles']) ? $config['roles'] : array(); - $map = !empty($config['map']) ? $config['map'] : array(); - $alias = !empty($config['alias']) ? $config['alias'] : array(); - - $this->Aro = new PhpAro($roles, $map, $alias); - $this->Aco = new PhpAco($rules); - } - -/** - * No op method, allow cannot be done with PhpAcl - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function allow($aro, $aco, $action = "*") { - return $this->Aco->access($this->Aro->resolve($aro), $aco, $action, 'allow'); - } - -/** - * deny ARO access to ACO - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function deny($aro, $aco, $action = "*") { - return $this->Aco->access($this->Aro->resolve($aro), $aco, $action, 'deny'); - } - -/** - * No op method - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function inherit($aro, $aco, $action = "*") { - return false; - } - -/** - * Main ACL check function. Checks to see if the ARO (access request object) has access to the - * ACO (access control object). - * - * @param string $aro ARO - * @param string $aco ACO - * @param string $action Action - * @return boolean true if access is granted, false otherwise - */ - public function check($aro, $aco, $action = "*") { - $allow = $this->options['policy']; - $prioritizedAros = $this->Aro->roles($aro); - - if ($action && $action != "*") { - $aco .= '/' . $action; - } - - $path = $this->Aco->path($aco); - - if (empty($path)) { - return $allow; - } - - foreach ($path as $depth => $node) { - foreach ($prioritizedAros as $aros) { - if (!empty($node['allow'])) { - $allow = $allow || count(array_intersect($node['allow'], $aros)) > 0; - } - - if (!empty($node['deny'])) { - $allow = $allow && count(array_intersect($node['deny'], $aros)) == 0; - } - } - } - - return $allow; - } - -} - -/** - * Access Control Object - * - */ -class PhpAco { - -/** - * holds internal ACO representation - * - * @var array - */ - protected $_tree = array(); - -/** - * map modifiers for ACO paths to their respective PCRE pattern - * - * @var array - */ - public static $modifiers = array( - '*' => '.*', - ); - - public function __construct(array $rules = array()) { - foreach (array('allow', 'deny') as $type) { - if (empty($rules[$type])) { - $rules[$type] = array(); - } - } - - $this->build($rules['allow'], $rules['deny']); - } - -/** - * return path to the requested ACO with allow and deny rules attached on each level - * - * @return array - */ - public function path($aco) { - $aco = $this->resolve($aco); - $path = array(); - $level = 0; - $root = $this->_tree; - $stack = array(array($root, 0)); - - while (!empty($stack)) { - list($root, $level) = array_pop($stack); - - if (empty($path[$level])) { - $path[$level] = array(); - } - - foreach ($root as $node => $elements) { - $pattern = '/^' . str_replace(array_keys(self::$modifiers), array_values(self::$modifiers), $node) . '$/'; - - if ($node == $aco[$level] || preg_match($pattern, $aco[$level])) { - // merge allow/denies with $path of current level - foreach (array('allow', 'deny') as $policy) { - if (!empty($elements[$policy])) { - if (empty($path[$level][$policy])) { - $path[$level][$policy] = array(); - } - $path[$level][$policy] = array_merge($path[$level][$policy], $elements[$policy]); - } - } - - // traverse - if (!empty($elements['children']) && isset($aco[$level + 1])) { - array_push($stack, array($elements['children'], $level + 1)); - } - } - } - } - - return $path; - } - -/** - * allow/deny ARO access to ARO - * - * @return void - */ - public function access($aro, $aco, $action, $type = 'deny') { - $aco = $this->resolve($aco); - $depth = count($aco); - $root = $this->_tree; - $tree = &$root; - - foreach ($aco as $i => $node) { - if (!isset($tree[$node])) { - $tree[$node] = array( - 'children' => array(), - ); - } - - if ($i < $depth - 1) { - $tree = &$tree[$node]['children']; - } else { - if (empty($tree[$node][$type])) { - $tree[$node][$type] = array(); - } - - $tree[$node][$type] = array_merge(is_array($aro) ? $aro : array($aro), $tree[$node][$type]); - } - } - - $this->_tree = &$root; - } - -/** - * resolve given ACO string to a path - * - * @param string $aco ACO string - * @return array path - */ - public function resolve($aco) { - if (is_array($aco)) { - return array_map('strtolower', $aco); - } - - // strip multiple occurences of '/' - $aco = preg_replace('#/+#', '/', $aco); - // make case insensitive - $aco = ltrim(strtolower($aco), '/'); - return array_filter(array_map('trim', explode('/', $aco))); - } - -/** - * build a tree representation from the given allow/deny informations for ACO paths - * - * @param array $allow ACO allow rules - * @param array $deny ACO deny rules - * @return void - */ - public function build(array $allow, array $deny = array()) { - $stack = array(); - $this->_tree = array(); - $tree = array(); - $root = &$tree; - - foreach ($allow as $dotPath => $aros) { - if (is_string($aros)) { - $aros = array_map('trim', explode(',', $aros)); - } - - $this->access($aros, $dotPath, null, 'allow'); - } - - foreach ($deny as $dotPath => $aros) { - if (is_string($aros)) { - $aros = array_map('trim', explode(',', $aros)); - } - - $this->access($aros, $dotPath, null, 'deny'); - } - } - -} - -/** - * Access Request Object - * - */ -class PhpAro { - -/** - * role to resolve to when a provided ARO is not listed in - * the internal tree - * - * @var string - */ - const DEFAULT_ROLE = 'Role/default'; - -/** - * map external identifiers. E.g. if - * - * array('User' => array('username' => 'jeff', 'role' => 'editor')) - * - * is passed as an ARO to one of the methods of AclComponent, PhpAcl - * will check if it can be resolved to an User or a Role defined in the - * configuration file. - * - * @var array - * @see app/Config/acl.php - */ - public $map = array( - 'User' => 'User/username', - 'Role' => 'User/role', - ); - -/** - * aliases to map - * - * @var array - */ - public $aliases = array(); - -/** - * internal ARO representation - * - * @var array - */ - protected $_tree = array(); - - public function __construct(array $aro = array(), array $map = array(), array $aliases = array()) { - if (!empty($map)) { - $this->map = $map; - } - - $this->aliases = $aliases; - $this->build($aro); - } - -/** - * From the perspective of the given ARO, walk down the tree and - * collect all inherited AROs levelwise such that AROs from different - * branches with equal distance to the requested ARO will be collected at the same - * index. The resulting array will contain a prioritized list of (list of) roles ordered from - * the most distant AROs to the requested one itself. - * - * @param mixed $aro An ARO identifier - * @return array prioritized AROs - */ - public function roles($aro) { - $aros = array(); - $aro = $this->resolve($aro); - $stack = array(array($aro, 0)); - - while (!empty($stack)) { - list($element, $depth) = array_pop($stack); - $aros[$depth][] = $element; - - foreach ($this->_tree as $node => $children) { - if (in_array($element, $children)) { - array_push($stack, array($node, $depth + 1)); - } - } - } - - return array_reverse($aros); - } - -/** - * resolve an ARO identifier to an internal ARO string using - * the internal mapping information. - * - * @param mixed $aro ARO identifier (User.jeff, array('User' => ...), etc) - * @return string internal aro string (e.g. User/jeff, Role/default) - */ - public function resolve($aro) { - foreach ($this->map as $aroGroup => $map) { - list ($model, $field) = explode('/', $map, 2); - $mapped = ''; - - if (is_array($aro)) { - if (isset($aro['model']) && isset($aro['foreign_key']) && $aro['model'] == $aroGroup) { - $mapped = $aroGroup . '/' . $aro['foreign_key']; - } elseif (isset($aro[$model][$field])) { - $mapped = $aroGroup . '/' . $aro[$model][$field]; - } elseif (isset($aro[$field])) { - $mapped = $aroGroup . '/' . $aro[$field]; - } - } elseif (is_string($aro)) { - $aro = ltrim($aro, '/'); - - if (strpos($aro, '/') === false) { - $mapped = $aroGroup . '/' . $aro; - } else { - list($aroModel, $aroValue) = explode('/', $aro, 2); - - $aroModel = Inflector::camelize($aroModel); - - if ($aroModel == $model || $aroModel == $aroGroup) { - $mapped = $aroGroup . '/' . $aroValue; - } - } - } - - if (isset($this->_tree[$mapped])) { - return $mapped; - } - - // is there a matching alias defined (e.g. Role/1 => Role/admin)? - if (!empty($this->aliases[$mapped])) { - return $this->aliases[$mapped]; - } - } - return self::DEFAULT_ROLE; - } - -/** - * adds a new ARO to the tree - * - * @param array $aro one or more ARO records - * @return void - */ - public function addRole(array $aro) { - foreach ($aro as $role => $inheritedRoles) { - if (!isset($this->_tree[$role])) { - $this->_tree[$role] = array(); - } - - if (!empty($inheritedRoles)) { - if (is_string($inheritedRoles)) { - $inheritedRoles = array_map('trim', explode(',', $inheritedRoles)); - } - - foreach ($inheritedRoles as $dependency) { - // detect cycles - $roles = $this->roles($dependency); - - if (in_array($role, Set::flatten($roles))) { - $path = ''; - - foreach ($roles as $roleDependencies) { - $path .= implode('|', (array)$roleDependencies) . ' -> '; - } - - trigger_error(__d('cake_dev', 'cycle detected when inheriting %s from %s. Path: %s', $role, $dependency, $path . $role)); - continue; - } - - if (!isset($this->_tree[$dependency])) { - $this->_tree[$dependency] = array(); - } - - $this->_tree[$dependency][] = $role; - } - } - } - } - -/** - * adds one or more aliases to the internal map. Overwrites existing entries. - * - * @param array $alias alias from => to (e.g. Role/13 -> Role/editor) - * @return void - */ - public function addAlias(array $alias) { - $this->aliases = array_merge($this->aliases, $alias); - } - -/** - * build an ARO tree structure for internal processing - * - * @param array $aros array of AROs as key and their inherited AROs as values - * @return void - */ - public function build(array $aros) { - $this->_tree = array(); - $this->addRole($aros); - } - -} diff --git a/lib/Cake/Controller/Component/AclComponent.php b/lib/Cake/Controller/Component/AclComponent.php deleted file mode 100644 index 659a503cb76..00000000000 --- a/lib/Cake/Controller/Component/AclComponent.php +++ /dev/null @@ -1,181 +0,0 @@ -adapter($name); - } - -/** - * Sets or gets the Adapter object currently in the AclComponent. - * - * `$this->Acl->adapter();` will get the current adapter class while - * `$this->Acl->adapter($obj);` will set the adapter class - * - * Will call the initialize method on the adapter if setting a new one. - * - * @param mixed $adapter Instance of AclInterface or a string name of the class to use. (optional) - * @return mixed either null, or the adapter implementation. - * @throws CakeException when the given class is not an instance of AclInterface - */ - public function adapter($adapter = null) { - if ($adapter) { - if (is_string($adapter)) { - $adapter = new $adapter(); - } - if (!$adapter instanceof AclInterface) { - throw new CakeException(__d('cake_dev', 'AclComponent adapters must implement AclInterface')); - } - $this->_Instance = $adapter; - $this->_Instance->initialize($this); - return; - } - return $this->_Instance; - } - -/** - * Pass-thru function for ACL check instance. Check methods - * are used to check whether or not an ARO can access an ACO - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function check($aro, $aco, $action = "*") { - return $this->_Instance->check($aro, $aco, $action); - } - -/** - * Pass-thru function for ACL allow instance. Allow methods - * are used to grant an ARO access to an ACO. - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function allow($aro, $aco, $action = "*") { - return $this->_Instance->allow($aro, $aco, $action); - } - -/** - * Pass-thru function for ACL deny instance. Deny methods - * are used to remove permission from an ARO to access an ACO. - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function deny($aro, $aco, $action = "*") { - return $this->_Instance->deny($aro, $aco, $action); - } - -/** - * Pass-thru function for ACL inherit instance. Inherit methods - * modify the permission for an ARO to be that of its parent object. - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - */ - public function inherit($aro, $aco, $action = "*") { - return $this->_Instance->inherit($aro, $aco, $action); - } - -/** - * Pass-thru function for ACL grant instance. An alias for AclComponent::allow() - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - * @deprecated - */ - public function grant($aro, $aco, $action = "*") { - trigger_error(__d('cake_dev', 'AclComponent::grant() is deprecated, use allow() instead'), E_USER_WARNING); - return $this->_Instance->allow($aro, $aco, $action); - } - -/** - * Pass-thru function for ACL grant instance. An alias for AclComponent::deny() - * - * @param string $aro ARO The requesting object identifier. - * @param string $aco ACO The controlled object identifier. - * @param string $action Action (defaults to *) - * @return boolean Success - * @deprecated - */ - public function revoke($aro, $aco, $action = "*") { - trigger_error(__d('cake_dev', 'AclComponent::revoke() is deprecated, use deny() instead'), E_USER_WARNING); - return $this->_Instance->deny($aro, $aco, $action); - } - -} diff --git a/lib/Cake/Controller/Component/Auth/ActionsAuthorize.php b/lib/Cake/Controller/Component/Auth/ActionsAuthorize.php deleted file mode 100644 index 3f279b0787d..00000000000 --- a/lib/Cake/Controller/Component/Auth/ActionsAuthorize.php +++ /dev/null @@ -1,42 +0,0 @@ -_Collection->load('Acl'); - $user = array($this->settings['userModel'] => $user); - return $Acl->check($user, $this->action($request)); - } - -} diff --git a/lib/Cake/Controller/Component/Auth/BaseAuthenticate.php b/lib/Cake/Controller/Component/Auth/BaseAuthenticate.php deleted file mode 100644 index bcb519b33ce..00000000000 --- a/lib/Cake/Controller/Component/Auth/BaseAuthenticate.php +++ /dev/null @@ -1,138 +0,0 @@ - 1).` - * - `recursive` The value of the recursive key passed to find(). Defaults to 0. - * - * @var array - */ - public $settings = array( - 'fields' => array( - 'username' => 'username', - 'password' => 'password' - ), - 'userModel' => 'User', - 'scope' => array(), - 'recursive' => 0 - ); - -/** - * A Component collection, used to get more components. - * - * @var ComponentCollection - */ - protected $_Collection; - -/** - * Constructor - * - * @param ComponentCollection $collection The Component collection used on this request. - * @param array $settings Array of settings to use. - */ - public function __construct(ComponentCollection $collection, $settings) { - $this->_Collection = $collection; - $this->settings = Set::merge($this->settings, $settings); - } - -/** - * Find a user record using the standard options. - * - * @param string $username The username/identifier. - * @param string $password The unhashed password. - * @return Mixed Either false on failure, or an array of user data. - */ - protected function _findUser($username, $password) { - $userModel = $this->settings['userModel']; - list($plugin, $model) = pluginSplit($userModel); - $fields = $this->settings['fields']; - - $conditions = array( - $model . '.' . $fields['username'] => $username, - $model . '.' . $fields['password'] => $this->_password($password), - ); - if (!empty($this->settings['scope'])) { - $conditions = array_merge($conditions, $this->settings['scope']); - } - $result = ClassRegistry::init($userModel)->find('first', array( - 'conditions' => $conditions, - 'recursive' => (int)$this->settings['recursive'] - )); - if (empty($result) || empty($result[$model])) { - return false; - } - unset($result[$model][$fields['password']]); - return $result[$model]; - } - -/** - * Hash the plain text password so that it matches the hashed/encrypted password - * in the datasource. - * - * @param string $password The plain text password. - * @return string The hashed form of the password. - */ - protected function _password($password) { - return Security::hash($password, null, true); - } - -/** - * Authenticate a user based on the request information. - * - * @param CakeRequest $request Request to get authentication information from. - * @param CakeResponse $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(CakeRequest $request, CakeResponse $response); - -/** - * Allows you to hook into AuthComponent::logout(), - * and implement specialized logout behavior. - * - * All attached authentication objects will have this method - * called when a user logs out. - * - * @param array $user The user about to be logged out. - * @return void - */ - public function logout($user) { - } - -/** - * Get a user based on information in the request. Primarily used by stateless authentication - * systems like basic and digest auth. - * - * @param CakeRequest $request Request object. - * @return mixed Either false or an array of user information - */ - public function getUser($request) { - return false; - } - -} diff --git a/lib/Cake/Controller/Component/Auth/BaseAuthorize.php b/lib/Cake/Controller/Component/Auth/BaseAuthorize.php deleted file mode 100644 index 432886c78f4..00000000000 --- a/lib/Cake/Controller/Component/Auth/BaseAuthorize.php +++ /dev/null @@ -1,161 +0,0 @@ -action(); - * - `actionMap` - Action -> crud mappings. Used by authorization objects that want to map actions to CRUD roles. - * - `userModel` - Model name that ARO records can be found under. Defaults to 'User'. - * - * @var array - */ - public $settings = array( - 'actionPath' => null, - 'actionMap' => array( - 'index' => 'read', - 'add' => 'create', - 'edit' => 'update', - 'view' => 'read', - 'delete' => 'delete', - 'remove' => 'delete' - ), - 'userModel' => 'User' - ); - -/** - * Constructor - * - * @param ComponentCollection $collection The controller for this request. - * @param string $settings An array of settings. This class does not use any settings. - */ - public function __construct(ComponentCollection $collection, $settings = array()) { - $this->_Collection = $collection; - $controller = $collection->getController(); - $this->controller($controller); - $this->settings = Set::merge($this->settings, $settings); - } - -/** - * Checks user authorization. - * - * @param array $user Active user data - * @param CakeRequest $request - * @return boolean - */ - abstract public function authorize($user, CakeRequest $request); - -/** - * Accessor to the controller object. - * - * @param mixed $controller null to get, a controller to set. - * @return mixed - * @throws CakeException - */ - public function controller(Controller $controller = null) { - if ($controller) { - if (!$controller instanceof Controller) { - throw new CakeException(__d('cake_dev', '$controller needs to be an instance of Controller')); - } - $this->_Controller = $controller; - return true; - } - return $this->_Controller; - } - -/** - * Get the action path for a given request. Primarily used by authorize objects - * that need to get information about the plugin, controller, and action being invoked. - * - * @param CakeRequest $request The request a path is needed for. - * @param string $path - * @return string the action path for the given request. - */ - public function action($request, $path = '/:plugin/:controller/:action') { - $plugin = empty($request['plugin']) ? null : Inflector::camelize($request['plugin']) . '/'; - $path = str_replace( - array(':controller', ':action', ':plugin/'), - array(Inflector::camelize($request['controller']), $request['action'], $plugin), - $this->settings['actionPath'] . $path - ); - $path = str_replace('//', '/', $path); - return trim($path, '/'); - } - -/** - * Maps crud actions to actual action names. Used to modify or get the current mapped actions. - * - * Create additional mappings for a standard CRUD operation: - * - * {{{ - * $this->Auth->mapActions(array('create' => array('add', 'register')); - * }}} - * - * Create mappings for custom CRUD operations: - * - * {{{ - * $this->Auth->mapActions(array('my_action' => 'admin')); - * }}} - * - * You can use the custom CRUD operations to create additional generic permissions - * that behave like CRUD operations. Doing this will require additional columns on the - * permissions lookup. When using with DbAcl, you'll have to add additional _admin type columns - * to the `aros_acos` table. - * - * @param mixed $map Either an array of mappings, or undefined to get current values. - * @return mixed Either the current mappings or null when setting. - * @see AuthComponent::mapActions() - */ - public function mapActions($map = array()) { - if (empty($map)) { - return $this->settings['actionMap']; - } - $crud = array('create', 'read', 'update', 'delete'); - foreach ($map as $action => $type) { - if (in_array($action, $crud) && is_array($type)) { - foreach ($type as $typedAction) { - $this->settings['actionMap'][$typedAction] = $action; - } - } else { - $this->settings['actionMap'][$action] = $type; - } - } - } - -} diff --git a/lib/Cake/Controller/Component/Auth/BasicAuthenticate.php b/lib/Cake/Controller/Component/Auth/BasicAuthenticate.php deleted file mode 100644 index c8e772e1cb7..00000000000 --- a/lib/Cake/Controller/Component/Auth/BasicAuthenticate.php +++ /dev/null @@ -1,126 +0,0 @@ - array( - * 'authenticate' => array('Basic') - * ) - * ); - * }}} - * - * In your login function just call `$this->Auth->login()` without any checks for POST data. This - * will send the authentication headers, and trigger the login dialog in the browser/client. - * - * @package Cake.Controller.Component.Auth - * @since 2.0 - */ -class BasicAuthenticate extends BaseAuthenticate { - -/** - * Settings for this object. - * - * - `fields` The fields to use to identify a user by. - * - `userModel` The model name of the User, defaults to User. - * - `scope` Additional conditions to use when looking up and authenticating users, - * i.e. `array('User.is_active' => 1).` - * - `recursive` The value of the recursive key passed to find(). Defaults to 0. - * - `realm` The realm authentication is for. Defaults the server name. - * - * @var array - */ - public $settings = array( - 'fields' => array( - 'username' => 'username', - 'password' => 'password' - ), - 'userModel' => 'User', - 'scope' => array(), - 'recursive' => 0, - 'realm' => '', - ); - -/** - * Constructor, completes configuration for basic authentication. - * - * @param ComponentCollection $collection The Component collection used on this request. - * @param array $settings An array of settings. - */ - public function __construct(ComponentCollection $collection, $settings) { - parent::__construct($collection, $settings); - if (empty($this->settings['realm'])) { - $this->settings['realm'] = env('SERVER_NAME'); - } - } - -/** - * Authenticate a user using basic HTTP auth. Will use the configured User model and attempt a - * login using basic HTTP auth. - * - * @param CakeRequest $request The request to authenticate with. - * @param CakeResponse $response The response to add headers to. - * @return mixed Either false on failure, or an array of user data on success. - */ - public function authenticate(CakeRequest $request, CakeResponse $response) { - $result = $this->getUser($request); - - if (empty($result)) { - $response->header($this->loginHeaders()); - $response->statusCode(401); - $response->send(); - return false; - } - return $result; - } - -/** - * Get a user based on information in the request. Used by cookie-less auth for stateless clients. - * - * @param CakeRequest $request Request object. - * @return mixed Either false or an array of user information - */ - public function getUser($request) { - $username = env('PHP_AUTH_USER'); - $pass = env('PHP_AUTH_PW'); - - if (empty($username) || empty($pass)) { - return false; - } - return $this->_findUser($username, $pass); - } - -/** - * Generate the login headers - * - * @return string Headers for logging in. - */ - public function loginHeaders() { - return sprintf('WWW-Authenticate: Basic realm="%s"', $this->settings['realm']); - } - -} diff --git a/lib/Cake/Controller/Component/Auth/ControllerAuthorize.php b/lib/Cake/Controller/Component/Auth/ControllerAuthorize.php deleted file mode 100644 index d3c502aa788..00000000000 --- a/lib/Cake/Controller/Component/Auth/ControllerAuthorize.php +++ /dev/null @@ -1,67 +0,0 @@ -request->params['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. - * - * @package Cake.Controller.Component.Auth - * @since 2.0 - * @see AuthComponent::$authenticate - */ -class ControllerAuthorize extends BaseAuthorize { - -/** - * Get/set the controller this authorize object will be working with. Also checks that isAuthorized is implemented. - * - * @param mixed $controller null to get, a controller to set. - * @return mixed - * @throws CakeException - */ - public function controller(Controller $controller = null) { - if ($controller) { - if (!method_exists($controller, 'isAuthorized')) { - throw new CakeException(__d('cake_dev', '$controller does not implement an isAuthorized() method.')); - } - } - return parent::controller($controller); - } - -/** - * Checks user authorization using a controller callback. - * - * @param array $user Active user data - * @param CakeRequest $request - * @return boolean - */ - public function authorize($user, CakeRequest $request) { - return (bool)$this->_Controller->isAuthorized($user); - } - -} diff --git a/lib/Cake/Controller/Component/Auth/CrudAuthorize.php b/lib/Cake/Controller/Component/Auth/CrudAuthorize.php deleted file mode 100644 index 83761b1e22b..00000000000 --- a/lib/Cake/Controller/Component/Auth/CrudAuthorize.php +++ /dev/null @@ -1,102 +0,0 @@ -_setPrefixMappings(); - } - -/** - * sets the crud mappings for prefix routes. - * - * @return void - */ - protected function _setPrefixMappings() { - $crud = array('create', 'read', 'update', 'delete'); - $map = array_combine($crud, $crud); - - $prefixes = Router::prefixes(); - if (!empty($prefixes)) { - foreach ($prefixes as $prefix) { - $map = array_merge($map, array( - $prefix . '_index' => 'read', - $prefix . '_add' => 'create', - $prefix . '_edit' => 'update', - $prefix . '_view' => 'read', - $prefix . '_remove' => 'delete', - $prefix . '_create' => 'create', - $prefix . '_read' => 'read', - $prefix . '_update' => 'update', - $prefix . '_delete' => 'delete' - )); - } - } - $this->mapActions($map); - } - -/** - * Authorize a user using the mapped actions and the AclComponent. - * - * @param array $user The user to authorize - * @param CakeRequest $request The request needing authorization. - * @return boolean - */ - public function authorize($user, CakeRequest $request) { - if (!isset($this->settings['actionMap'][$request->params['action']])) { - trigger_error(__d('cake_dev', - 'CrudAuthorize::authorize() - Attempted access of un-mapped action "%1$s" in controller "%2$s"', - $request->action, - $request->controller - ), - E_USER_WARNING - ); - return false; - } - $user = array($this->settings['userModel'] => $user); - $Acl = $this->_Collection->load('Acl'); - return $Acl->check( - $user, - $this->action($request, ':controller'), - $this->settings['actionMap'][$request->params['action']] - ); - } - -} diff --git a/lib/Cake/Controller/Component/Auth/DigestAuthenticate.php b/lib/Cake/Controller/Component/Auth/DigestAuthenticate.php deleted file mode 100644 index 27722a9c778..00000000000 --- a/lib/Cake/Controller/Component/Auth/DigestAuthenticate.php +++ /dev/null @@ -1,268 +0,0 @@ - array( - * 'authenticate' => array('Digest') - * ) - * ); - * }}} - * - * In your login function just call `$this->Auth->login()` without any checks for POST data. This - * will send the authentication headers, and trigger the login dialog in the browser/client. - * - * ### Generating passwords compatible with Digest authentication. - * - * Due to the Digest authentication specification, digest auth requires a special password value. You - * can generate this password using `DigestAuthenticate::password()` - * - * `$digestPass = DigestAuthenticate::password($username, env('SERVER_NAME'), $password);` - * - * Its recommended that you store this digest auth only password separate from password hashes used for other - * login methods. 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. - * - * @package Cake.Controller.Component.Auth - * @since 2.0 - */ -class DigestAuthenticate extends BaseAuthenticate { - -/** - * Settings for this object. - * - * - `fields` The fields to use to identify a user by. - * - `userModel` The model name of the User, defaults to User. - * - `scope` Additional conditions to use when looking up and authenticating users, - * i.e. `array('User.is_active' => 1).` - * - `realm` The realm authentication is for, Defaults to the servername. - * - `nonce` A nonce used for authentication. Defaults to `uniqid()`. - * - `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($settings['realm'])` - * - * @var array - */ - public $settings = array( - 'fields' => array( - 'username' => 'username', - 'password' => 'password' - ), - 'userModel' => 'User', - 'scope' => array(), - 'recursive' => 0, - 'realm' => '', - 'qop' => 'auth', - 'nonce' => '', - 'opaque' => '' - ); - -/** - * Constructor, completes configuration for digest authentication. - * - * @param ComponentCollection $collection The Component collection used on this request. - * @param array $settings An array of settings. - */ - public function __construct(ComponentCollection $collection, $settings) { - parent::__construct($collection, $settings); - if (empty($this->settings['realm'])) { - $this->settings['realm'] = env('SERVER_NAME'); - } - if (empty($this->settings['nonce'])) { - $this->settings['nonce'] = uniqid(''); - } - if (empty($this->settings['opaque'])) { - $this->settings['opaque'] = md5($this->settings['realm']); - } - } - -/** - * Authenticate a user using Digest HTTP auth. Will use the configured User model and attempt a - * login using Digest HTTP auth. - * - * @param CakeRequest $request The request to authenticate with. - * @param CakeResponse $response The response to add headers to. - * @return mixed Either false on failure, or an array of user data on success. - */ - public function authenticate(CakeRequest $request, CakeResponse $response) { - $user = $this->getUser($request); - - if (empty($user)) { - $response->header($this->loginHeaders()); - $response->statusCode(401); - $response->send(); - return false; - } - return $user; - } - -/** - * Get a user based on information in the request. Used by cookie-less auth for stateless clients. - * - * @param CakeRequest $request Request object. - * @return mixed Either false or an array of user information - */ - public function getUser($request) { - $digest = $this->_getDigest(); - if (empty($digest)) { - return false; - } - $user = $this->_findUser($digest['username'], null); - if (empty($user)) { - return false; - } - $password = $user[$this->settings['fields']['password']]; - unset($user[$this->settings['fields']['password']]); - if ($digest['response'] === $this->generateResponseHash($digest, $password)) { - return $user; - } - return false; - } - -/** - * Find a user record using the standard options. - * - * @param string $username The username/identifier. - * @param string $password Unused password, digest doesn't require passwords. - * @return Mixed Either false on failure, or an array of user data. - */ - protected function _findUser($username, $password) { - $userModel = $this->settings['userModel']; - list($plugin, $model) = pluginSplit($userModel); - $fields = $this->settings['fields']; - - $conditions = array( - $model . '.' . $fields['username'] => $username, - ); - if (!empty($this->settings['scope'])) { - $conditions = array_merge($conditions, $this->settings['scope']); - } - $result = ClassRegistry::init($userModel)->find('first', array( - 'conditions' => $conditions, - 'recursive' => (int)$this->settings['recursive'] - )); - if (empty($result) || empty($result[$model])) { - return false; - } - return $result[$model]; - } - -/** - * Gets the digest headers from the request/environment. - * - * @return array Array of digest information. - */ - protected function _getDigest() { - $digest = env('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 An array of digest authentication headers - */ - public function parseAuthData($digest) { - if (substr($digest, 0, 7) == 'Digest ') { - $digest = substr($digest, 7); - } - $keys = $match = array(); - $req = array('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() - * @return string Response hash - */ - public function generateResponseHash($digest, $password) { - return md5( - $password . - ':' . $digest['nonce'] . ':' . $digest['nc'] . ':' . $digest['cnonce'] . ':' . $digest['qop'] . ':' . - md5(env('REQUEST_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 - * - * @return string Headers for logging in. - */ - public function loginHeaders() { - $options = array( - 'realm' => $this->settings['realm'], - 'qop' => $this->settings['qop'], - 'nonce' => $this->settings['nonce'], - 'opaque' => $this->settings['opaque'] - ); - $opts = array(); - foreach ($options as $k => $v) { - $opts[] = sprintf('%s="%s"', $k, $v); - } - return 'WWW-Authenticate: Digest ' . implode(',', $opts); - } - -} diff --git a/lib/Cake/Controller/Component/Auth/FormAuthenticate.php b/lib/Cake/Controller/Component/Auth/FormAuthenticate.php deleted file mode 100644 index 0a51f527efe..00000000000 --- a/lib/Cake/Controller/Component/Auth/FormAuthenticate.php +++ /dev/null @@ -1,68 +0,0 @@ -Auth->authenticate = array( - * 'Form' => array( - * 'scope' => array('User.active' => 1) - * ) - * ) - * }}} - * - * When configuring FormAuthenticate you can pass in settings to which fields, model and additional conditions - * are used. See FormAuthenticate::$settings for more information. - * - * @package Cake.Controller.Component.Auth - * @since 2.0 - * @see AuthComponent::$authenticate - */ -class FormAuthenticate extends BaseAuthenticate { - -/** - * Authenticates the identity contained in a request. Will use the `settings.userModel`, and `settings.fields` - * to find POST data that is used to find a matching record in the `settings.userModel`. Will return false if - * there is no post data, either username or password is missing, of if the scope conditions have not been met. - * - * @param CakeRequest $request The request that contains login information. - * @param CakeResponse $response Unused response object. - * @return mixed. False on login failure. An array of User data on success. - */ - public function authenticate(CakeRequest $request, CakeResponse $response) { - $userModel = $this->settings['userModel']; - list($plugin, $model) = pluginSplit($userModel); - - $fields = $this->settings['fields']; - if (empty($request->data[$model])) { - return false; - } - if ( - empty($request->data[$model][$fields['username']]) || - empty($request->data[$model][$fields['password']]) - ) { - return false; - } - return $this->_findUser( - $request->data[$model][$fields['username']], - $request->data[$model][$fields['password']] - ); - } - -} diff --git a/lib/Cake/Controller/Component/AuthComponent.php b/lib/Cake/Controller/Component/AuthComponent.php deleted file mode 100644 index 24a812f695c..00000000000 --- a/lib/Cake/Controller/Component/AuthComponent.php +++ /dev/null @@ -1,722 +0,0 @@ -Auth->authenticate = array( - * 'Form' => array( - * 'userModel' => 'Users.User' - * ) - * ); - * }}} - * - * Using the class name without 'Authenticate' as the key, you can pass in an array of settings for each - * authentication object. Additionally you can define settings that should be set to all authentications objects - * using the 'all' key: - * - * {{{ - * $this->Auth->authenticate = array( - * 'all' => array( - * 'userModel' => 'Users.User', - * 'scope' => array('User.active' => 1) - * ), - * 'Form', - * 'Basic' - * ); - * }}} - * - * You can also use AuthComponent::ALL instead of the string 'all'. - * - * @var array - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html - */ - public $authenticate = array('Form'); - -/** - * Objects that will be used for authentication checks. - * - * @var array - */ - protected $_authenticateObjects = array(); - -/** - * 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->authorize = array( - * 'Crud' => array( - * 'actionPath' => 'controllers/' - * ) - * ); - * }}} - * - * Using the class name without 'Authorize' as the key, you can pass in an array of settings for each - * authorization object. Additionally you can define settings that should be set to all authorization objects - * using the 'all' key: - * - * {{{ - * $this->Auth->authorize = array( - * 'all' => array( - * 'actionPath' => 'controllers/' - * ), - * 'Crud', - * 'CustomAuth' - * ); - * }}} - * - * You can also use AuthComponent::ALL instead of the string 'all' - * - * @var mixed - * @link http://book.cakephp.org/view/1275/authorize - */ - public $authorize = false; - -/** - * Objects that will be used for authorization checks. - * - * @var array - */ - protected $_authorizeObjects = array(); - -/** - * The name of an optional view element to render when an Ajax request is made - * with an invalid or expired session - * - * @var string - */ - public $ajaxLogin = null; - -/** - * Settings to use when Auth needs to do a flash message with SessionComponent::setFlash(). - * Available keys are: - * - * - `element` - The element to use, defaults to 'default'. - * - `key` - The key to use, defaults to 'auth' - * - `params` - The array of additional params to use, defaults to array() - * - * @var array - */ - public $flash = array( - 'element' => 'default', - 'key' => 'auth', - 'params' => array() - ); - -/** - * The session key name where the record of the current user is stored. If - * unspecified, it will be "Auth.User". - * - * @var string - */ - public static $sessionKey = 'Auth.User'; - -/** - * The current user, used for stateless authentication when - * sessions are not available. - * - * @var array - */ - protected static $_user = array(); - -/** - * A URL (defined as a string or array) to the controller action that handles - * logins. Defaults to `/users/login` - * - * @var mixed - */ - public $loginAction = array( - 'controller' => 'users', - 'action' => 'login', - 'plugin' => null - ); - -/** - * 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, the user will be redirected to the page specified in $loginRedirect. - * - * @var mixed - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#AuthComponent::$loginRedirect - */ - public $loginRedirect = null; - -/** - * 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 AuthComponent::$loginAction. - * - * @var mixed - * @see AuthComponent::$loginAction - * @see AuthComponent::logout() - */ - public $logoutRedirect = null; - -/** - * Error to display when user attempts to access an object or action to which they do not have - * access. - * - * @var string - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#AuthComponent::$authError - */ - public $authError = null; - -/** - * Controller actions for which user validation is not required. - * - * @var array - * @see AuthComponent::allow() - */ - public $allowedActions = array(); - -/** - * Request object - * - * @var CakeRequest - */ - public $request; - -/** - * Response object - * - * @var CakeResponse - */ - public $response; - -/** - * Method list for bound controller - * - * @var array - */ - protected $_methods = array(); - -/** - * Initializes AuthComponent for use in the controller - * - * @param Controller $controller A reference to the instantiating controller object - * @return void - */ - public function initialize(Controller $controller) { - $this->request = $controller->request; - $this->response = $controller->response; - $this->_methods = $controller->methods; - - if (Configure::read('debug') > 0) { - Debugger::checkSecurityKeys(); - } - } - -/** - * Main execution method. Handles redirecting of invalid users, and processing - * of login form data. - * - * @param Controller $controller A reference to the instantiating controller object - * @return boolean - */ - public function startup(Controller $controller) { - if ($controller->name == 'CakeError') { - return true; - } - - $methods = array_flip(array_map('strtolower', $controller->methods)); - $action = strtolower($controller->request->params['action']); - - $isMissingAction = ( - $controller->scaffold === false && - !isset($methods[$action]) - ); - - if ($isMissingAction) { - return true; - } - - if (!$this->_setDefaults()) { - return false; - } - $request = $controller->request; - - $url = ''; - - if (isset($request->url)) { - $url = $request->url; - } - $url = Router::normalize($url); - $loginAction = Router::normalize($this->loginAction); - - $allowedActions = $this->allowedActions; - $isAllowed = ( - $this->allowedActions == array('*') || - in_array($action, array_map('strtolower', $allowedActions)) - ); - - if ($loginAction != $url && $isAllowed) { - return true; - } - - if ($loginAction == $url) { - if (empty($request->data)) { - if (!$this->Session->check('Auth.redirect') && !$this->loginRedirect && env('HTTP_REFERER')) { - $this->Session->write('Auth.redirect', $controller->referer(null, true)); - } - } - return true; - } else { - if (!$this->_getUser()) { - if (!$request->is('ajax')) { - $this->flash($this->authError); - $this->Session->write('Auth.redirect', $request->here()); - $controller->redirect($loginAction); - return false; - } elseif (!empty($this->ajaxLogin)) { - $controller->viewPath = 'Elements'; - echo $controller->render($this->ajaxLogin, $this->RequestHandler->ajaxLayout); - $this->_stop(); - return false; - } else { - $controller->redirect(null, 403); - } - } - } - if (empty($this->authorize) || $this->isAuthorized($this->user())) { - return true; - } - - $this->flash($this->authError); - $controller->redirect($controller->referer('/'), null, true); - return false; - } - -/** - * Attempts to introspect the correct values for object properties. - * - * @return boolean - */ - protected function _setDefaults() { - $defaults = array( - 'logoutRedirect' => $this->loginAction, - 'authError' => __d('cake', 'You are not authorized to access that location.') - ); - foreach ($defaults as $key => $value) { - if (empty($this->{$key})) { - $this->{$key} = $value; - } - } - return true; - } - -/** - * 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 mixed $user The user to check the authorization of. If empty the user in the session will be used. - * @param CakeRequest $request The request to authenticate for. If empty, the current request will be used. - * @return boolean True if $user is authorized, otherwise false - */ - public function isAuthorized($user = null, $request = null) { - if (empty($user) && !$this->user()) { - return false; - } elseif (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) { - return true; - } - } - return false; - } - -/** - * Loads the authorization objects configured. - * - * @return mixed Either null when authorize is empty, or the loaded authorization objects. - * @throws CakeException - */ - public function constructAuthorize() { - if (empty($this->authorize)) { - return; - } - $this->_authorizeObjects = array(); - $config = Set::normalize($this->authorize); - $global = array(); - if (isset($config[AuthComponent::ALL])) { - $global = $config[AuthComponent::ALL]; - unset($config[AuthComponent::ALL]); - } - foreach ($config as $class => $settings) { - list($plugin, $class) = pluginSplit($class, true); - $className = $class . 'Authorize'; - App::uses($className, $plugin . 'Controller/Component/Auth'); - if (!class_exists($className)) { - throw new CakeException(__d('cake_dev', 'Authorization adapter "%s" was not found.', $class)); - } - if (!method_exists($className, 'authorize')) { - throw new CakeException(__d('cake_dev', 'Authorization objects must implement an authorize method.')); - } - $settings = array_merge($global, (array)$settings); - $this->_authorizeObjects[] = new $className($this->_Collection, $settings); - } - return $this->_authorizeObjects; - } - -/** - * 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 var args. - * - * `$this->Auth->allow(array('edit', 'add'));` or - * `$this->Auth->allow('edit', 'add');` or - * `$this->Auth->allow();` to allow all actions - * - * @param mixed $action,... Controller action name or array of actions - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#making-actions-public - */ - public function allow($action = null) { - $args = func_get_args(); - if (empty($args) || $action === null) { - $this->allowedActions = $this->_methods; - } else { - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } - $this->allowedActions = array_merge($this->allowedActions, $args); - } - } - -/** - * Removes items from the list of allowed/no authentication required actions. - * - * You can use deny with either an array, or var args. - * - * `$this->Auth->deny(array('edit', 'add'));` or - * `$this->Auth->deny('edit', 'add');` or - * `$this->Auth->deny();` to remove all items from the allowed list - * - * @param mixed $action,... Controller action name or array of actions - * @return void - * @see AuthComponent::allow() - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#making-actions-require-authorization - */ - public function deny($action = null) { - $args = func_get_args(); - if (empty($args) || $action === null) { - $this->allowedActions = array(); - } else { - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } - foreach ($args as $arg) { - $i = array_search($arg, $this->allowedActions); - if (is_int($i)) { - unset($this->allowedActions[$i]); - } - } - $this->allowedActions = array_values($this->allowedActions); - } - } - -/** - * Maps action names to CRUD operations. Used for controller-based authentication. Make sure - * to configure the authorize property before calling this method. As it delegates $map to all the - * attached authorize objects. - * - * @param array $map Actions to map - * @return void - * @see BaseAuthorize::mapActions() - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#mapping-actions-when-using-crudauthorize - */ - public function mapActions($map = array()) { - if (empty($this->_authorizeObjects)) { - $this->constructAuthorize(); - } - foreach ($this->_authorizeObjects as $auth) { - $auth->mapActions($map); - } - } - -/** - * Log a user in. If a $user is provided that data will be stored as the logged in user. If `$user` is empty or not - * specified, the request will be used to identify a user. If the identification was successful, - * the user record is written to the session key specified in AuthComponent::$sessionKey. Logging in - * will also change the session id in order to help mitigate session replays. - * - * @param mixed $user Either an array of user data, or null to identify a user using the current request. - * @return boolean True on login success, false on failure - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#identifying-users-and-logging-them-in - */ - public function login($user = null) { - $this->_setDefaults(); - - if (empty($user)) { - $user = $this->identify($this->request, $this->response); - } - if ($user) { - $this->Session->renew(); - $this->Session->write(self::$sessionKey, $user); - } - return $this->loggedIn(); - } - -/** - * Logs a user out, and returns the login action to redirect to. - * Triggers the logout() method of all the authenticate objects, so they can perform - * custom logout logic. AuthComponent will remove the session data, so - * there is no need to do that in an authentication object. Logging out - * will also renew the session id. This helps mitigate issues with session replays. - * - * @return string AuthComponent::$logoutRedirect - * @see AuthComponent::$logoutRedirect - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#logging-users-out - */ - public function logout() { - $this->_setDefaults(); - if (empty($this->_authenticateObjects)) { - $this->constructAuthenticate(); - } - $user = $this->user(); - foreach ($this->_authenticateObjects as $auth) { - $auth->logout($user); - } - $this->Session->delete(self::$sessionKey); - $this->Session->delete('Auth.redirect'); - $this->Session->renew(); - return Router::normalize($this->logoutRedirect); - } - -/** - * Get the current user. - * - * Will prefer the static user cache over sessions. The static user - * cache is primarily used for stateless authentication. For stateful authentication, - * cookies + sessions will be used. - * - * @param string $key field to retrieve. Leave null to get entire User record - * @return mixed User record. or null if no user is logged in. - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#accessing-the-logged-in-user - */ - public static function user($key = null) { - if (empty(self::$_user) && !CakeSession::check(self::$sessionKey)) { - return null; - } - if (!empty(self::$_user)) { - $user = self::$_user; - } else { - $user = CakeSession::read(self::$sessionKey); - } - if ($key === null) { - return $user; - } - if (isset($user[$key])) { - return $user[$key]; - } - return null; - } - -/** - * Similar to AuthComponent::user() except if the session user cannot be found, connected authentication - * objects will have their getUser() methods called. This lets stateless authentication methods function correctly. - * - * @return boolean 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)) { - self::$_user = $result; - return true; - } - } - return false; - } - -/** - * If no parameter is passed, gets the authentication redirect URL. Pass a url in to - * set the destination a user should be redirected to upon logging in. Will fallback to - * AuthComponent::$loginRedirect if there is no stored redirect value. - * - * @param mixed $url Optional URL to write as the login redirect URL. - * @return string Redirect URL - */ - public function redirect($url = null) { - if (!is_null($url)) { - $redir = $url; - $this->Session->write('Auth.redirect', $redir); - } elseif ($this->Session->check('Auth.redirect')) { - $redir = $this->Session->read('Auth.redirect'); - $this->Session->delete('Auth.redirect'); - - if (Router::normalize($redir) == Router::normalize($this->loginAction)) { - $redir = $this->loginRedirect; - } - } else { - $redir = $this->loginRedirect; - } - return Router::normalize($redir); - } - -/** - * Use the configured authentication adapters, and attempt to identify the user - * by credentials contained in $request. - * - * @param CakeRequest $request The request that contains authentication data. - * @param CakeResponse $response The response - * @return array User record data, or false, if the user could not be identified. - */ - public function identify(CakeRequest $request, CakeResponse $response) { - if (empty($this->_authenticateObjects)) { - $this->constructAuthenticate(); - } - foreach ($this->_authenticateObjects as $auth) { - $result = $auth->authenticate($request, $response); - if (!empty($result) && is_array($result)) { - return $result; - } - } - return false; - } - -/** - * loads the configured authentication objects. - * - * @return mixed either null on empty authenticate value, or an array of loaded objects. - * @throws CakeException - */ - public function constructAuthenticate() { - if (empty($this->authenticate)) { - return; - } - $this->_authenticateObjects = array(); - $config = Set::normalize($this->authenticate); - $global = array(); - if (isset($config[AuthComponent::ALL])) { - $global = $config[AuthComponent::ALL]; - unset($config[AuthComponent::ALL]); - } - foreach ($config as $class => $settings) { - list($plugin, $class) = pluginSplit($class, true); - $className = $class . 'Authenticate'; - App::uses($className, $plugin . 'Controller/Component/Auth'); - if (!class_exists($className)) { - throw new CakeException(__d('cake_dev', 'Authentication adapter "%s" was not found.', $class)); - } - if (!method_exists($className, 'authenticate')) { - throw new CakeException(__d('cake_dev', 'Authentication objects must implement an authenticate method.')); - } - $settings = array_merge($global, (array)$settings); - $this->_authenticateObjects[] = new $className($this->_Collection, $settings); - } - return $this->_authenticateObjects; - } - -/** - * Hash a password with the application's salt value (as defined with Configure::write('Security.salt'); - * - * This method is intended as a convenience wrapper for Security::hash(). If you want to use - * a hashing/encryption system not supported by that method, do not use this method. - * - * @param string $password Password to hash - * @return string Hashed password - * @link http://book.cakephp.org/2.0/en/core-libraries/components/authentication.html#hashing-passwords - */ - public static function password($password) { - return Security::hash($password, null, true); - } - -/** - * Component shutdown. If user is logged in, wipe out redirect. - * - * @param Controller $controller Instantiating controller - * @return void - */ - public function shutdown(Controller $controller) { - if ($this->loggedIn()) { - $this->Session->delete('Auth.redirect'); - } - } - -/** - * Check whether or not the current user has data in the session, and is considered logged in. - * - * @return boolean true if the user is logged in, false otherwise - */ - public function loggedIn() { - return $this->user() != array(); - } - -/** - * Set a flash message. Uses the Session component, and values from AuthComponent::$flash. - * - * @param string $message The message to set. - * @return void - */ - public function flash($message) { - $this->Session->setFlash($message, $this->flash['element'], $this->flash['params'], $this->flash['key']); - } - -} diff --git a/lib/Cake/Controller/Component/CookieComponent.php b/lib/Cake/Controller/Component/CookieComponent.php deleted file mode 100644 index 31d2778f6e7..00000000000 --- a/lib/Cake/Controller/Component/CookieComponent.php +++ /dev/null @@ -1,514 +0,0 @@ -Cookie->name = 'CookieName'; - * - * @var string - */ - public $name = 'CakeCookie'; - -/** - * The time a cookie will remain valid. - * - * Can be either integer Unix timestamp or a date string. - * - * Overridden with the controller beforeFilter(); - * $this->Cookie->time = '5 Days'; - * - * @var mixed - */ - public $time = null; - -/** - * Cookie path. - * - * Overridden with the controller beforeFilter(); - * $this->Cookie->path = '/'; - * - * The path on the server in which the cookie will be available on. - * If public $cookiePath is set to '/foo/', the cookie will only be available - * within the /foo/ directory and all sub-directories such as /foo/bar/ of domain. - * The default value is the entire domain. - * - * @var string - */ - public $path = '/'; - -/** - * Domain path. - * - * The domain that the cookie is available. - * - * Overridden with the controller beforeFilter(); - * $this->Cookie->domain = '.example.com'; - * - * To make the cookie available on all subdomains of example.com. - * Set $this->Cookie->domain = '.example.com'; in your controller beforeFilter - * - * @var string - */ - public $domain = ''; - -/** - * Secure HTTPS only cookie. - * - * Overridden with the controller beforeFilter(); - * $this->Cookie->secure = true; - * - * Indicates that the cookie should only be transmitted over a secure HTTPS connection. - * When set to true, the cookie will only be set if a secure connection exists. - * - * @var boolean - */ - public $secure = false; - -/** - * Encryption key. - * - * Overridden with the controller beforeFilter(); - * $this->Cookie->key = 'SomeRandomString'; - * - * @var string - */ - public $key = null; - -/** - * HTTP only cookie - * - * Set to true to make HTTP only cookies. Cookies that are HTTP only - * are not accessible in Javascript. - * - * @var boolean - */ - public $httpOnly = false; - -/** - * Values stored in the cookie. - * - * Accessed in the controller using $this->Cookie->read('Name.key'); - * - * @see CookieComponent::read(); - * @var string - */ - protected $_values = array(); - -/** - * Type of encryption to use. - * - * Currently only one method is available - * Defaults to Security::cipher(); - * - * @var string - * @todo add additional encryption methods - */ - protected $_type = 'cipher'; - -/** - * Used to reset cookie time if $expire is passed to CookieComponent::write() - * - * @var string - */ - protected $_reset = null; - -/** - * Expire time of the cookie - * - * This is controlled by CookieComponent::time; - * - * @var string - */ - protected $_expires = 0; - -/** - * A reference to the Controller's CakeResponse object - * - * @var CakeResponse - */ - protected $_response = null; - -/** - * Constructor - * - * @param ComponentCollection $collection A ComponentCollection for this component - * @param array $settings Array of settings. - */ - public function __construct(ComponentCollection $collection, $settings = array()) { - $this->key = Configure::read('Security.salt'); - parent::__construct($collection, $settings); - if (isset($this->time)) { - $this->_expire($this->time); - } - - $controller = $collection->getController(); - if ($controller && isset($controller->response)) { - $this->_response = $controller->response; - } else { - $this->_response = new CakeResponse(array('charset' => Configure::read('App.encoding'))); - } - } - -/** - * Start CookieComponent for use in the controller - * - * @param Controller $controller - * @return void - */ - public function startup(Controller $controller) { - $this->_expire($this->time); - - $this->_values[$this->name] = array(); - if (isset($_COOKIE[$this->name])) { - $this->_values[$this->name] = $this->_decrypt($_COOKIE[$this->name]); - } - } - -/** - * Write a value to the $_COOKIE[$key]; - * - * Optional [Name.], required key, optional $value, optional $encrypt, optional $expires - * $this->Cookie->write('[Name.]key, $value); - * - * By default all values are encrypted. - * You must pass $encrypt false to store values in clear test - * - * 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 mixed $key Key for the value - * @param mixed $value Value - * @param boolean $encrypt Set to true to encrypt value, false otherwise - * @param string $expires Can be either Unix timestamp, or date string - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/cookie.html#CookieComponent::write - */ - public function write($key, $value = null, $encrypt = true, $expires = null) { - if (empty($this->_values[$this->name])) { - $this->read(); - } - - if (is_null($encrypt)) { - $encrypt = true; - } - $this->_encrypted = $encrypt; - $this->_expire($expires); - - if (!is_array($key)) { - $key = array($key => $value); - } - - foreach ($key as $name => $value) { - if (strpos($name, '.') === false) { - $this->_values[$this->name][$name] = $value; - $this->_write("[$name]", $value); - } else { - $names = explode('.', $name, 2); - if (!isset($this->_values[$this->name][$names[0]])) { - $this->_values[$this->name][$names[0]] = array(); - } - $this->_values[$this->name][$names[0]] = Set::insert($this->_values[$this->name][$names[0]], $names[1], $value); - $this->_write('[' . implode('][', $names) . ']', $value); - } - } - $this->_encrypted = true; - } - -/** - * Read the value of the $_COOKIE[$key]; - * - * Optional [Name.], required key - * $this->Cookie->read(Name.key); - * - * @param mixed $key Key of the value to be obtained. If none specified, obtain map key => values - * @return string or null, value for specified key - * @link http://book.cakephp.org/2.0/en/core-libraries/components/cookie.html#CookieComponent::read - */ - public function read($key = null) { - if (empty($this->_values[$this->name]) && isset($_COOKIE[$this->name])) { - $this->_values[$this->name] = $this->_decrypt($_COOKIE[$this->name]); - } - if (empty($this->_values[$this->name])) { - $this->_values[$this->name] = array(); - } - if (is_null($key)) { - return $this->_values[$this->name]; - } - - if (strpos($key, '.') !== false) { - $names = explode('.', $key, 2); - $key = $names[0]; - } - if (!isset($this->_values[$this->name][$key])) { - return null; - } - - if (!empty($names[1])) { - return Set::extract($this->_values[$this->name][$key], $names[1]); - } - return $this->_values[$this->name][$key]; - } - -/** - * Delete a cookie value - * - * Optional [Name.], required key - * $this->Cookie->read('Name.key); - * - * 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 $key Key of the value to be deleted - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/cookie.html#CookieComponent::delete - */ - public function delete($key) { - if (empty($this->_values[$this->name])) { - $this->read(); - } - if (strpos($key, '.') === false) { - if (isset($this->_values[$this->name][$key]) && is_array($this->_values[$this->name][$key])) { - foreach ($this->_values[$this->name][$key] as $idx => $val) { - $this->_delete("[$key][$idx]"); - } - } - $this->_delete("[$key]"); - unset($this->_values[$this->name][$key]); - return; - } - $names = explode('.', $key, 2); - if (isset($this->_values[$this->name][$names[0]])) { - $this->_values[$this->name][$names[0]] = Set::remove($this->_values[$this->name][$names[0]], $names[1]); - } - $this->_delete('[' . implode('][', $names) . ']'); - } - -/** - * Destroy current cookie - * - * You must use this method before any output is sent to the browser. - * Failure to do so will result in header already sent errors. - * - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/cookie.html#CookieComponent::destroy - */ - public function destroy() { - if (isset($_COOKIE[$this->name])) { - $this->_values[$this->name] = $this->_decrypt($_COOKIE[$this->name]); - } - - foreach ($this->_values[$this->name] as $name => $value) { - if (is_array($value)) { - foreach ($value as $key => $val) { - unset($this->_values[$this->name][$name][$key]); - $this->_delete("[$name][$key]"); - } - } - unset($this->_values[$this->name][$name]); - $this->_delete("[$name]"); - } - } - -/** - * Will allow overriding default encryption method. - * - * @param string $type Encryption method - * @return void - * @todo NOT IMPLEMENTED - */ - public function type($type = 'cipher') { - $this->_type = 'cipher'; - } - -/** - * Set the expire time for a session variable. - * - * Creates a new expire time for a session variable. - * $expire can be either integer Unix timestamp or a date string. - * - * Used by write() - * CookieComponent::write(string, string, boolean, 8400); - * CookieComponent::write(string, string, boolean, '5 Days'); - * - * @param mixed $expires Can be either Unix timestamp, or date string - * @return integer Unix timestamp - */ - protected function _expire($expires = null) { - $now = time(); - if (is_null($expires)) { - return $this->_expires; - } - $this->_reset = $this->_expires; - - if ($expires == 0) { - return $this->_expires = 0; - } - - if (is_integer($expires) || is_numeric($expires)) { - return $this->_expires = $now + intval($expires); - } - return $this->_expires = strtotime($expires, $now); - } - -/** - * Set cookie - * - * @param string $name Name for cookie - * @param string $value Value for cookie - * @return void - */ - protected function _write($name, $value) { - $this->_response->cookie(array( - 'name' => $this->name . $name, - 'value' => $this->_encrypt($value), - 'expire' => $this->_expires, - 'path' => $this->path, - 'domain' => $this->domain, - 'secure' => $this->secure, - 'httpOnly' => $this->httpOnly - )); - - if (!is_null($this->_reset)) { - $this->_expires = $this->_reset; - $this->_reset = null; - } - } - -/** - * Sets a cookie expire time to remove cookie value - * - * @param string $name Name of cookie - * @return void - */ - protected function _delete($name) { - $this->_response->cookie(array( - 'name' => $this->name . $name, - 'value' => '', - 'expire' => time() - 42000, - 'path' => $this->path, - 'domain' => $this->domain, - 'secure' => $this->secure, - 'httpOnly' => $this->httpOnly - )); - } - -/** - * Encrypts $value using public $type method in Security class - * - * @param string $value Value to encrypt - * @return string encrypted string - * @return string Encoded values - */ - protected function _encrypt($value) { - if (is_array($value)) { - $value = $this->_implode($value); - } - - if ($this->_encrypted === true) { - $type = $this->_type; - $value = "Q2FrZQ==." . base64_encode(Security::$type($value, $this->key)); - } - return $value; - } - -/** - * Decrypts $value using public $type method in Security class - * - * @param array $values Values to decrypt - * @return string decrypted string - */ - protected function _decrypt($values) { - $decrypted = array(); - $type = $this->_type; - - foreach ((array)$values as $name => $value) { - if (is_array($value)) { - foreach ($value as $key => $val) { - $pos = strpos($val, 'Q2FrZQ==.'); - $decrypted[$name][$key] = $this->_explode($val); - - if ($pos !== false) { - $val = substr($val, 8); - $decrypted[$name][$key] = $this->_explode(Security::$type(base64_decode($val), $this->key)); - } - } - } else { - $pos = strpos($value, 'Q2FrZQ==.'); - $decrypted[$name] = $this->_explode($value); - - if ($pos !== false) { - $value = substr($value, 8); - $decrypted[$name] = $this->_explode(Security::$type(base64_decode($value), $this->key)); - } - } - } - return $decrypted; - } - -/** - * Implode method to keep keys are multidimensional arrays - * - * @param array $array Map of key and values - * @return string A json encoded string. - */ - protected function _implode(array $array) { - return json_encode($array); - } - -/** - * Explode method to return array from string set in CookieComponent::_implode() - * Maintains reading backwards compatibility with 1.x CookieComponent::_implode(). - * - * @param string $string A string containing JSON encoded data, or a bare string. - * @return array Map of key and values - */ - protected function _explode($string) { - $first = substr($string, 0, 1); - if ($first === '{' || $first === '[') { - $ret = json_decode($string, true); - return ($ret != null) ? $ret : $string; - } - $array = array(); - foreach (explode(',', $string) as $pair) { - $key = explode('|', $pair); - if (!isset($key[1])) { - return $key[0]; - } - $array[$key[0]] = $key[1]; - } - return $array; - } -} - diff --git a/lib/Cake/Controller/Component/EmailComponent.php b/lib/Cake/Controller/Component/EmailComponent.php deleted file mode 100644 index 5b2dd1d9f33..00000000000 --- a/lib/Cake/Controller/Component/EmailComponent.php +++ /dev/null @@ -1,489 +0,0 @@ -_controller = $collection->getController(); - parent::__construct($collection, $settings); - } - -/** - * Initialize component - * - * @param Controller $controller Instantiating controller - * @return void - */ - public function initialize(Controller $controller) { - if (Configure::read('App.encoding') !== null) { - $this->charset = Configure::read('App.encoding'); - } - } - -/** - * Send an email using the specified content, template and layout - * - * @param mixed $content Either an array of text lines, or a string with contents - * If you are rendering a template this variable will be sent to the templates as `$content` - * @param string $template Template to use when sending email - * @param string $layout Layout to use to enclose email body - * @return boolean Success - */ - public function send($content = null, $template = null, $layout = null) { - $lib = new CakeEmail(); - $lib->charset = $this->charset; - - $lib->from($this->_formatAddresses((array)$this->from)); - if (!empty($this->to)) { - $lib->to($this->_formatAddresses((array)$this->to)); - } - if (!empty($this->cc)) { - $lib->cc($this->_formatAddresses((array)$this->cc)); - } - if (!empty($this->bcc)) { - $lib->bcc($this->_formatAddresses((array)$this->bcc)); - } - if (!empty($this->replyTo)) { - $lib->replyTo($this->_formatAddresses((array)$this->replyTo)); - } - if (!empty($this->return)) { - $lib->returnPath($this->_formatAddresses((array)$this->return)); - } - if (!empty($readReceipt)) { - $lib->readReceipt($this->_formatAddresses((array)$this->readReceipt)); - } - - $lib->subject($this->subject)->messageID($this->messageId); - $lib->helpers($this->_controller->helpers); - - $headers = array('X-Mailer' => $this->xMailer); - foreach ($this->headers as $key => $value) { - $headers['X-' . $key] = $value; - } - if ($this->date != false) { - $headers['Date'] = $this->date; - } - $lib->setHeaders($headers); - - if ($template) { - $this->template = $template; - } - if ($layout) { - $this->layout = $layout; - } - $lib->template($this->template, $this->layout)->viewVars($this->_controller->viewVars)->emailFormat($this->sendAs); - - if (!empty($this->attachments)) { - $lib->attachments($this->_formatAttachFiles()); - } - - $lib->transport(ucfirst($this->delivery)); - if ($this->delivery === 'mail') { - $lib->config(array('eol' => $this->lineFeed, 'additionalParameters' => $this->additionalParams)); - } elseif ($this->delivery === 'smtp') { - $lib->config($this->smtpOptions); - } else { - $lib->config(array()); - } - - $sent = $lib->send($content); - - $this->htmlMessage = $lib->message(CakeEmail::MESSAGE_HTML); - if (empty($this->htmlMessage)) { - $this->htmlMessage = null; - } - $this->textMessage = $lib->message(CakeEmail::MESSAGE_TEXT); - if (empty($this->textMessage)) { - $this->textMessage = null; - } - - $this->_header = array(); - $this->_message = array(); - - return $sent; - } - -/** - * Reset all EmailComponent internal variables to be able to send out a new email. - * - * @return void - */ - public function reset() { - $this->template = null; - $this->to = array(); - $this->from = null; - $this->replyTo = null; - $this->return = null; - $this->cc = array(); - $this->bcc = array(); - $this->subject = null; - $this->additionalParams = null; - $this->date = null; - $this->attachments = array(); - $this->htmlMessage = null; - $this->textMessage = null; - $this->messageId = true; - $this->delivery = 'mail'; - } - -/** - * Format the attach array - * - * @return array - */ - protected function _formatAttachFiles() { - $files = array(); - foreach ($this->attachments as $filename => $attachment) { - $file = $this->_findFiles($attachment); - if (!empty($file)) { - if (is_int($filename)) { - $filename = basename($file); - } - $files[$filename] = $file; - } - } - return $files; - } - -/** - * Find the specified attachment in the list of file paths - * - * @param string $attachment Attachment file name to find - * @return string Path to located file - */ - protected function _findFiles($attachment) { - if (file_exists($attachment)) { - return $attachment; - } - foreach ($this->filePaths as $path) { - if (file_exists($path . DS . $attachment)) { - $file = $path . DS . $attachment; - return $file; - } - } - return null; - } - -/** - * Encode the specified string using the current charset - * - * @param string $subject String to encode - * @return string Encoded string - */ - protected function _encode($subject) { - $subject = $this->_strip($subject); - - $nl = "\r\n"; - if ($this->delivery == 'mail') { - $nl = ''; - } - $internalEncoding = function_exists('mb_internal_encoding'); - if ($internalEncoding) { - $restore = mb_internal_encoding(); - mb_internal_encoding($this->charset); - } - $return = mb_encode_mimeheader($subject, $this->charset, 'B', $nl); - if ($internalEncoding) { - mb_internal_encoding($restore); - } - return $return; - } - -/** - * Format addresses to be an array with email as key and alias as value - * - * @param array $addresses - * @return array - */ - protected function _formatAddresses($addresses) { - $formatted = array(); - foreach ($addresses as $address) { - if (preg_match('/((.*))?\s?<(.+)>/', $address, $matches) && !empty($matches[2])) { - $formatted[$this->_strip($matches[3])] = $this->_encode($matches[2]); - } else { - $address = $this->_strip($address); - $formatted[$address] = $address; - } - } - return $formatted; - } - -/** - * Remove certain elements (such as bcc:, to:, %0a) from given value. - * Helps prevent header injection / manipulation on user content. - * - * @param string $value Value to strip - * @param boolean $message Set to true to indicate main message content - * @return string Stripped value - */ - protected function _strip($value, $message = false) { - $search = '%0a|%0d|Content-(?:Type|Transfer-Encoding)\:'; - $search .= '|charset\=|mime-version\:|multipart/mixed|(?:[^a-z]to|b?cc)\:.*'; - - if ($message !== true) { - $search .= '|\r|\n'; - } - $search = '#(?:' . $search . ')#i'; - while (preg_match($search, $value)) { - $value = preg_replace($search, '', $value); - } - return $value; - } - -} diff --git a/lib/Cake/Controller/Component/PaginatorComponent.php b/lib/Cake/Controller/Component/PaginatorComponent.php deleted file mode 100644 index 9758e66ebf7..00000000000 --- a/lib/Cake/Controller/Component/PaginatorComponent.php +++ /dev/null @@ -1,381 +0,0 @@ -Paginator->settings = array( - * 'limit' => 20, - * 'maxLimit' => 100 - * ); - * }}} - * - * The above settings will be used to paginate any model. You can configure model specific settings by - * keying the settings with the model name. - * - * {{{ - * $this->Paginator->settings = array( - * 'Post' => array( - * 'limit' => 20, - * 'maxLimit' => 100 - * ), - * 'Comment' => array( ... ) - * ); - * }}} - * - * This would allow you to have different pagination settings for `Comment` and `Post` models. - * - * @package Cake.Controller.Component - * @link http://book.cakephp.org/2.0/en/core-libraries/components/pagination.html - */ -class PaginatorComponent extends Component { - -/** - * Pagination settings. These settings control pagination at a general level. - * You can also define sub arrays for pagination settings for specific models. - * - * - `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. - * - `paramType` What type of parameters you want pagination to use? - * - `named` Use named parameters / routed parameters. - * - `querystring` Use query string parameters. - * - * @var array - */ - public $settings = array( - 'page' => 1, - 'limit' => 20, - 'maxLimit' => 100, - 'paramType' => 'named' - ); - -/** - * 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. - * - * @var array - */ - public $whitelist = array( - 'limit', 'sort', 'page', 'direction' - ); - -/** - * Constructor - * - * @param ComponentCollection $collection A ComponentCollection this component can use to lazy load its components - * @param array $settings Array of configuration settings. - */ - public function __construct(ComponentCollection $collection, $settings = array()) { - $settings = array_merge($this->settings, (array)$settings); - $this->Controller = $collection->getController(); - parent::__construct($collection, $settings); - } - -/** - * Handles automatic pagination of model records. - * - * @param mixed $object Model to paginate (e.g: model instance, or 'Model', or 'Model.InnerModel') - * @param mixed $scope Additional find conditions to use while paginating - * @param array $whitelist List of allowed fields for ordering. This allows you to prevent ordering - * on non-indexed, or undesirable columns. - * @return array Model query results - * @throws MissingModelException - */ - public function paginate($object = null, $scope = array(), $whitelist = array()) { - if (is_array($object)) { - $whitelist = $scope; - $scope = $object; - $object = null; - } - - $object = $this->_getObject($object); - - if (!is_object($object)) { - throw new MissingModelException($object); - } - - $options = $this->mergeOptions($object->alias); - $options = $this->validateSort($object, $options, $whitelist); - $options = $this->checkLimit($options); - - $conditions = $fields = $order = $limit = $page = $recursive = null; - - if (!isset($options['conditions'])) { - $options['conditions'] = array(); - } - - $type = 'all'; - - if (isset($options[0])) { - $type = $options[0]; - unset($options[0]); - } - - extract($options); - - if (is_array($scope) && !empty($scope)) { - $conditions = array_merge($conditions, $scope); - } elseif (is_string($scope)) { - $conditions = array($conditions, $scope); - } - if ($recursive === null) { - $recursive = $object->recursive; - } - - $extra = array_diff_key($options, compact( - 'conditions', 'fields', 'order', 'limit', 'page', 'recursive' - )); - if ($type !== 'all') { - $extra['type'] = $type; - } - - if (intval($page) < 1) { - $page = 1; - } - $page = $options['page'] = (int)$page; - - if ($object->hasMethod('paginate')) { - $results = $object->paginate( - $conditions, $fields, $order, $limit, $page, $recursive, $extra - ); - } else { - $parameters = compact('conditions', 'fields', 'order', 'limit', 'page'); - if ($recursive != $object->recursive) { - $parameters['recursive'] = $recursive; - } - $results = $object->find($type, array_merge($parameters, $extra)); - } - $defaults = $this->getDefaults($object->alias); - unset($defaults[0]); - - if ($object->hasMethod('paginateCount')) { - $count = $object->paginateCount($conditions, $recursive, $extra); - } else { - $parameters = compact('conditions'); - if ($recursive != $object->recursive) { - $parameters['recursive'] = $recursive; - } - $count = $object->find('count', array_merge($parameters, $extra)); - } - $pageCount = intval(ceil($count / $limit)); - - $paging = array( - 'page' => $page, - 'current' => count($results), - 'count' => $count, - 'prevPage' => ($page > 1), - 'nextPage' => ($count > ($page * $limit)), - 'pageCount' => $pageCount, - 'order' => $order, - 'limit' => $limit, - 'options' => Set::diff($options, $defaults), - 'paramType' => $options['paramType'] - ); - if (!isset($this->Controller->request['paging'])) { - $this->Controller->request['paging'] = array(); - } - $this->Controller->request['paging'] = array_merge( - (array)$this->Controller->request['paging'], - array($object->alias => $paging) - ); - - if ( - !in_array('Paginator', $this->Controller->helpers) && - !array_key_exists('Paginator', $this->Controller->helpers) - ) { - $this->Controller->helpers[] = 'Paginator'; - } - return $results; - } - -/** - * Get the object pagination will occur on. - * - * @param mixed $object The object you are looking for. - * @return mixed The model object to paginate on. - */ - protected function _getObject($object) { - if (is_string($object)) { - $assoc = null; - if (strpos($object, '.') !== false) { - list($object, $assoc) = pluginSplit($object); - } - - if ($assoc && isset($this->Controller->{$object}->{$assoc})) { - $object = $this->Controller->{$object}->{$assoc}; - } elseif ( - $assoc && isset($this->Controller->{$this->Controller->modelClass}) && - isset($this->Controller->{$this->Controller->modelClass}->{$assoc} - )) { - $object = $this->Controller->{$this->Controller->modelClass}->{$assoc}; - } elseif (isset($this->Controller->{$object})) { - $object = $this->Controller->{$object}; - } elseif ( - isset($this->Controller->{$this->Controller->modelClass}) && isset($this->Controller->{$this->Controller->modelClass}->{$object} - )) { - $object = $this->Controller->{$this->Controller->modelClass}->{$object}; - } - } elseif (empty($object) || $object === null) { - if (isset($this->Controller->{$this->Controller->modelClass})) { - $object = $this->Controller->{$this->Controller->modelClass}; - } else { - $className = null; - $name = $this->Controller->uses[0]; - if (strpos($this->Controller->uses[0], '.') !== false) { - list($name, $className) = explode('.', $this->Controller->uses[0]); - } - if ($className) { - $object = $this->Controller->{$className}; - } else { - $object = $this->Controller->{$name}; - } - } - } - return $object; - } - -/** - * 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 - * PaginatorComponent::$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. - * @return array Array of merged options. - */ - public function mergeOptions($alias) { - $defaults = $this->getDefaults($alias); - switch ($defaults['paramType']) { - case 'named': - $request = $this->Controller->request->params['named']; - break; - case 'querystring': - $request = $this->Controller->request->query; - break; - } - $request = array_intersect_key($request, array_flip($this->whitelist)); - return array_merge($defaults, $request); - } - -/** - * Get the default settings for a $model. If there are no settings for a specific model, the general settings - * will be used. - * - * @param string $alias Model name to get default settings for. - * @return array An array of pagination defaults for a model, or the general settings. - */ - public function getDefaults($alias) { - if (isset($this->settings[$alias])) { - $defaults = $this->settings[$alias]; - } else { - $defaults = $this->settings; - } - return array_merge( - array('page' => 1, 'limit' => 20, 'maxLimit' => 100, 'paramType' => 'named'), - $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. - * - * @param Model $object The model being paginated. - * @param array $options The pagination options being used for this request. - * @param array $whitelist The list of columns that can be used for sorting. If empty all keys are allowed. - * @return array An array of options with sort + direction removed and replaced with order if possible. - */ - public function validateSort($object, $options, $whitelist = array()) { - if (isset($options['sort'])) { - $direction = null; - if (isset($options['direction'])) { - $direction = strtolower($options['direction']); - } - if ($direction != 'asc' && $direction != 'desc') { - $direction = 'asc'; - } - $options['order'] = array($options['sort'] => $direction); - } - - if (!empty($whitelist) && isset($options['order']) && is_array($options['order'])) { - $field = key($options['order']); - if (!in_array($field, $whitelist)) { - $options['order'] = null; - } - } - - if (!empty($options['order']) && is_array($options['order'])) { - $order = array(); - foreach ($options['order'] as $key => $value) { - $field = $key; - $alias = $object->alias; - if (strpos($key, '.') !== false) { - list($alias, $field) = explode('.', $key); - } - - if ($object->hasField($field)) { - $order[$alias . '.' . $field] = $value; - } elseif ($object->hasField($key, true)) { - $order[$field] = $value; - } elseif (isset($object->{$alias}) && $object->{$alias}->hasField($field, true)) { - $order[$alias . '.' . $field] = $value; - } - } - $options['order'] = $order; - } - - return $options; - } - -/** - * Check the limit parameter and ensure its 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($options) { - $options['limit'] = (int)$options['limit']; - if (empty($options['limit']) || $options['limit'] < 1) { - $options['limit'] = 1; - } - $options['limit'] = min((int)$options['limit'], $options['maxLimit']); - return $options; - } - -} diff --git a/lib/Cake/Controller/Component/RequestHandlerComponent.php b/lib/Cake/Controller/Component/RequestHandlerComponent.php deleted file mode 100644 index b8831fe1dac..00000000000 --- a/lib/Cake/Controller/Component/RequestHandlerComponent.php +++ /dev/null @@ -1,730 +0,0 @@ - array('json_decode', true) - ); - -/** - * Constructor. Parses the accepted content types accepted by the client using HTTP_ACCEPT - * - * @param ComponentCollection $collection ComponentCollection object. - * @param array $settings Array of settings. - */ - public function __construct(ComponentCollection $collection, $settings = array()) { - $default = array('checkHttpCache' => true); - parent::__construct($collection, $settings + $default); - $this->addInputType('xml', array(array($this, 'convertXml'))); - - $Controller = $collection->getController(); - $this->request = $Controller->request; - $this->response = $Controller->response; - } - -/** - * Checks to see if a file extension has been parsed by the Router, or if the - * HTTP_ACCEPT_TYPE has matches only one content type with the supported extensions. - * If there is only one matching type between the supported content types & extensions, - * and the requested mime-types, RequestHandler::$ext is set to that value. - * - * @param Controller $controller A reference to the controller - * @param array $settings Array of settings to _set(). - * @return void - * @see Router::parseExtensions() - */ - public function initialize(Controller $controller, $settings = array()) { - if (isset($this->request->params['ext'])) { - $this->ext = $this->request->params['ext']; - } - if (empty($this->ext) || $this->ext == 'html') { - $this->_setExtension(); - } - $this->params = $controller->params; - $this->_set($settings); - } - -/** - * 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. - * - * 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. - * - * @return void - */ - protected function _setExtension() { - $accept = $this->request->parseAccept(); - if (empty($accept)) { - return; - } - $extensions = Router::extensions(); - $preferred = array_shift($accept); - $preferredTypes = $this->response->mapType($preferred); - $similarTypes = array_intersect($extensions, $preferredTypes); - if (count($similarTypes) === 1 && !in_array('xhtml', $preferredTypes) && !in_array('html', $preferredTypes)) { - $this->ext = array_shift($similarTypes); - } - } - -/** - * The startup method of the RequestHandler enables several automatic behaviors - * related to the detection of certain properties of the HTTP request, including: - * - * - Disabling layout rendering for Ajax requests (based on the HTTP_X_REQUESTED_WITH header) - * - If Router::parseExtensions() is enabled, the layout and template type are - * switched based on the parsed extension or Accept-Type 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-Type: 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 CakeResponse. If you need to declare - * additional mime-types, you can do so using CakeResponse::type() in your controllers 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. - * - 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 Controller $controller A reference to the controller - * @return void - */ - public function startup(Controller $controller) { - $controller->request->params['isAjax'] = $this->request->is('ajax'); - $isRecognized = ( - !in_array($this->ext, array('html', 'htm')) && - $this->response->getMimeType($this->ext) - ); - - if (!empty($this->ext) && $isRecognized) { - $this->renderAs($controller, $this->ext); - } elseif ($this->request->is('ajax')) { - $this->renderAs($controller, 'ajax'); - } elseif (empty($this->ext) || in_array($this->ext, array('html', 'htm'))) { - $this->respondAs('html', array('charset' => Configure::read('App.encoding'))); - } - - foreach ($this->_inputTypeMap as $type => $handler) { - if ($this->requestedWith($type)) { - $input = call_user_func_array(array($controller->request, 'input'), $handler); - $controller->request->data = $input; - } - } - } - -/** - * Helper method to parse xml input data, due to lack of anonymous functions - * this lives here. - * - * @param string $xml - * @return array Xml array data - */ - public function convertXml($xml) { - try { - $xml = Xml::build($xml); - if (isset($xml->data)) { - return Xml::toArray($xml->data); - } - return Xml::toArray($xml); - } catch (XmlException $e) { - return array(); - } - } - -/** - * Handles (fakes) redirects for Ajax requests using requestAction() - * - * @param Controller $controller A reference to the controller - * @param string|array $url A string or array containing the redirect location - * @param mixed $status HTTP Status for redirect - * @param boolean $exit - * @return void - */ - public function beforeRedirect(Controller $controller, $url, $status = null, $exit = true) { - if (!$this->request->is('ajax')) { - return; - } - foreach ($_POST as $key => $val) { - unset($_POST[$key]); - } - if (is_array($url)) { - $url = Router::url($url + array('base' => false)); - } - if (!empty($status)) { - $statusCode = $this->response->httpCodes($status); - $code = key($statusCode); - $this->response->statusCode($code); - } - $this->response->body($this->requestAction($url, array('return', 'bare' => false))); - $this->response->send(); - $this->_stop(); - } - -/** - * 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. - * - * @params Controller $controller - * @return boolean false if the render process should be aborted - **/ - public function beforeRender(Controller $controller) { - $shouldCheck = $this->settings['checkHttpCache']; - if ($shouldCheck && $this->response->checkNotModified($this->request)) { - return false; - } - } - -/** - * Returns true if the current HTTP request is Ajax, false otherwise - * - * @return boolean True if call is Ajax - * @deprecated use `$this->request->is('ajax')` instead. - */ - public function isAjax() { - return $this->request->is('ajax'); - } - -/** - * Returns true if the current HTTP request is coming from a Flash-based client - * - * @return boolean True if call is from Flash - * @deprecated use `$this->request->is('flash')` instead. - */ - public function isFlash() { - return $this->request->is('flash'); - } - -/** - * Returns true if the current request is over HTTPS, false otherwise. - * - * @return boolean True if call is over HTTPS - * @deprecated use `$this->request->is('ssl')` instead. - */ - public function isSSL() { - return $this->request->is('ssl'); - } - -/** - * Returns true if the current call accepts an XML response, false otherwise - * - * @return boolean 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 boolean 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 boolean 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 boolean True if user agent is a mobile web browser - */ - public function isMobile() { - return $this->request->is('mobile') || $this->accepts('wap'); - } - -/** - * Returns true if the client accepts WAP content - * - * @return boolean - */ - public function isWap() { - return $this->prefers('wap'); - } - -/** - * Returns true if the current call a POST request - * - * @return boolean True if call is a POST - * @deprecated Use $this->request->is('post'); from your controller. - */ - public function isPost() { - return $this->request->is('post'); - } - -/** - * Returns true if the current call a PUT request - * - * @return boolean True if call is a PUT - * @deprecated Use $this->request->is('put'); from your controller. - */ - public function isPut() { - return $this->request->is('put'); - } - -/** - * Returns true if the current call a GET request - * - * @return boolean True if call is a GET - * @deprecated Use $this->request->is('get'); from your controller. - */ - public function isGet() { - return $this->request->is('get'); - } - -/** - * Returns true if the current call a DELETE request - * - * @return boolean True if call is a DELETE - * @deprecated Use $this->request->is('delete'); from your controller. - */ - public function isDelete() { - return $this->request->is('delete'); - } - -/** - * Gets Prototype version if call is Ajax, otherwise empty string. - * The Prototype library sets a special "Prototype version" HTTP header. - * - * @return string Prototype version of component making Ajax call - */ - public function getAjaxVersion() { - if (env('HTTP_X_PROTOTYPE_VERSION') != null) { - return env('HTTP_X_PROTOTYPE_VERSION'); - } - return false; - } - -/** - * Adds/sets the Content-type(s) for the given name. This method allows - * content-types to be mapped to friendly aliases (or extensions), which allows - * RequestHandler to automatically respond to requests of that type in the - * startup method. - * - * @param string $name The name of the Content-type, i.e. "html", "xml", "css" - * @param mixed $type The Content-type or array of Content-types assigned to the name, - * i.e. "text/html", or "application/xml" - * @return void - * @deprecated use `$this->response->type()` instead. - */ - public function setContent($name, $type = null) { - $this->response->type(array($name => $type)); - } - -/** - * Gets the server name from which this request was referred - * - * @return string Server address - * @deprecated use $this->request->referer() from your controller instead - */ - public function getReferer() { - return $this->request->referer(false); - } - -/** - * Gets remote client IP - * - * @param boolean $safe - * @return string Client IP address - * @deprecated use $this->request->clientIp() from your, controller instead. - */ - public function getClientIP($safe = true) { - return $this->request->clientIp($safe); - } - -/** - * 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 CakeRequest::accepts() this method deals entirely with mapped content types. - * - * Usage: - * - * `$this->RequestHandler->accepts(array('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 mixed $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. - * @see RequestHandlerComponent::setContent() - */ - public function accepts($type = null) { - $accepted = $this->request->accepts(); - - if ($type == null) { - return $this->mapType($accepted); - } elseif (is_array($type)) { - foreach ($type as $t) { - $t = $this->mapAlias($t); - if (in_array($t, $accepted)) { - return true; - } - } - return false; - } elseif (is_string($type)) { - $type = $this->mapAlias($type); - return in_array($type, $accepted); - } - return false; - } - -/** - * Determines the content type of the data the client has sent (i.e. in a POST request) - * - * @param mixed $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) { - if (!$this->request->is('post') && !$this->request->is('put')) { - return null; - } - - list($contentType) = explode(';', env('CONTENT_TYPE')); - if ($type == null) { - return $this->mapType($contentType); - } elseif (is_array($type)) { - foreach ($type as $t) { - if ($this->requestedWith($t)) { - return $t; - } - } - return false; - } elseif (is_string($type)) { - return ($type == $this->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 mixed $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. - * @see RequestHandlerComponent::setContent() - */ - public function prefers($type = null) { - $acceptRaw = $this->request->parseAccept(); - - if (empty($acceptRaw)) { - return $this->ext; - } - $accepts = array_shift($acceptRaw); - $accepts = $this->mapType($accepts); - - if ($type == null) { - if (empty($this->ext) && !empty($accepts)) { - return $accepts[0]; - } - return $this->ext; - } - - $types = (array)$type; - - if (count($types) === 1) { - if (!empty($this->ext)) { - return in_array($this->ext, $types); - } - return in_array($types[0], $accepts); - } - - $intersect = array_values(array_intersect($accepts, $types)); - if (empty($intersect)) { - return false; - } - return $intersect[0]; - } - -/** - * Sets the layout and template paths for the content type defined by $type. - * - * ### 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', array('attachment' => 'myfile.xml');` - * - * @param 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 RequestHandlerComponent::setContent() - * @see RequestHandlerComponent::respondAs() - */ - public function renderAs(Controller $controller, $type, $options = array()) { - $defaults = array('charset' => 'UTF-8'); - - if (Configure::read('App.encoding') !== null) { - $defaults['charset'] = Configure::read('App.encoding'); - } - $options = array_merge($defaults, $options); - - if ($type == 'ajax') { - $controller->layout = $this->ajaxLayout; - return $this->respondAs('html', $options); - } - $controller->ext = '.ctp'; - - $viewClass = Inflector::classify($type); - $viewName = $viewClass . 'View'; - if (!class_exists($viewName)) { - App::uses($viewName, 'View'); - } - if (class_exists($viewName)) { - $controller->viewClass = $viewClass; - } elseif (empty($this->_renderType)) { - $controller->viewPath .= DS . $type; - } else { - $remove = preg_replace("/([\/\\\\]{$this->_renderType})$/", DS . $type, $controller->viewPath); - $controller->viewPath = $remove; - } - $this->_renderType = $type; - $controller->layoutPath = $type; - - if ($this->response->getMimeType($type)) { - $this->respondAs($type, $options); - } - - $helper = ucfirst($type); - $isAdded = ( - in_array($helper, $controller->helpers) || - array_key_exists($helper, $controller->helpers) - ); - - if (!$isAdded) { - App::uses('AppHelper', 'View/Helper'); - App::uses($helper . 'Helper', 'View/Helper'); - if (class_exists($helper . 'Helper')) { - $controller->helpers[] = $helper; - } - } - } - -/** - * Sets the response header based on type map index name. This wraps several methods - * available on CakeResponse. It also allows you to use Content-Type aliases. - * - * @param mixed $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 boolean 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. - * @see RequestHandlerComponent::setContent() - */ - public function respondAs($type, $options = array()) { - $defaults = array('index' => null, 'charset' => null, 'attachment' => false); - $options = $options + $defaults; - - if (strpos($type, '/') === false) { - $cType = $this->response->getMimeType($type); - if ($cType === false) { - return false; - } - if (is_array($cType) && isset($cType[$options['index']])) { - $cType = $cType[$options['index']]; - } - if (is_array($cType)) { - if ($this->prefers($cType)) { - $cType = $this->prefers($cType); - } else { - $cType = $cType[0]; - } - } - } else { - $cType = $type; - } - - if ($cType != null) { - if (empty($this->request->params['requested'])) { - $this->response->type($cType); - } - - if (!empty($options['charset'])) { - $this->response->charset($options['charset']); - } - if (!empty($options['attachment'])) { - $this->response->download($options['attachment']); - } - return true; - } - return false; - } - -/** - * 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() { - return $this->mapType($this->response->type()); - } - -/** - * Maps a content-type back to an alias - * - * @param mixed $cType Either a string content type to map, or an array of types. - * @return mixed Aliases for the types provided. - * @deprecated Use $this->response->mapType() in your controller instead. - */ - public function mapType($cType) { - return $this->response->mapType($cType); - } - -/** - * Maps a content type alias back to its mime-type(s) - * - * @param mixed $alias String alias to convert back into a content type. Or an array of aliases to map. - * @return mixed 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. - */ - public function mapAlias($alias) { - if (is_array($alias)) { - return array_map(array($this, 'mapAlias'), $alias); - } - $type = $this->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 CakeException - */ - public function addInputType($type, $handler) { - if (!is_array($handler) || !isset($handler[0]) || !is_callable($handler[0])) { - throw new CakeException(__d('cake_dev', 'You must give a handler callback.')); - } - $this->_inputTypeMap[$type] = $handler; - } - -} diff --git a/lib/Cake/Controller/Component/SecurityComponent.php b/lib/Cake/Controller/Component/SecurityComponent.php deleted file mode 100644 index 1617c46a12b..00000000000 --- a/lib/Cake/Controller/Component/SecurityComponent.php +++ /dev/null @@ -1,596 +0,0 @@ -request = $controller->request; - $this->_action = $this->request->params['action']; - $this->_methodsRequired($controller); - $this->_secureRequired($controller); - $this->_authRequired($controller); - - $isPost = ($this->request->is('post') || $this->request->is('put')); - $isNotRequestAction = ( - !isset($controller->request->params['requested']) || - $controller->request->params['requested'] != 1 - ); - - if ($isPost && $isNotRequestAction && $this->validatePost) { - if ($this->_validatePost($controller) === false) { - return $this->blackHole($controller, 'auth'); - } - } - if ($isPost && $isNotRequestAction && $this->csrfCheck) { - if ($this->_validateCsrf($controller) === false) { - return $this->blackHole($controller, 'csrf'); - } - } - $this->generateToken($controller->request); - if ($isPost) { - unset($controller->request->data['_Token']); - } - } - -/** - * Sets the actions that require a POST request, or empty for all actions - * - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html#SecurityComponent::requirePost - */ - public function requirePost() { - $args = func_get_args(); - $this->_requireMethod('Post', $args); - } - -/** - * Sets the actions that require a GET request, or empty for all actions - * - * @return void - */ - public function requireGet() { - $args = func_get_args(); - $this->_requireMethod('Get', $args); - } - -/** - * Sets the actions that require a PUT request, or empty for all actions - * - * @return void - */ - public function requirePut() { - $args = func_get_args(); - $this->_requireMethod('Put', $args); - } - -/** - * Sets the actions that require a DELETE request, or empty for all actions - * - * @return void - */ - public function requireDelete() { - $args = func_get_args(); - $this->_requireMethod('Delete', $args); - } - -/** - * Sets the actions that require a request that is SSL-secured, or empty for all actions - * - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html#SecurityComponent::requireSecure - */ - public function requireSecure() { - $args = func_get_args(); - $this->_requireMethod('Secure', $args); - } - -/** - * Sets the actions that require an authenticated request, or empty for all actions - * - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html#SecurityComponent::requireAuth - */ - public function requireAuth() { - $args = func_get_args(); - $this->_requireMethod('Auth', $args); - } - -/** - * 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 Controller $controller Instantiating controller - * @param string $error Error method - * @return mixed If specified, controller blackHoleCallback's response, or no return otherwise - * @see SecurityComponent::$blackHoleCallback - * @link http://book.cakephp.org/2.0/en/core-libraries/components/security-component.html#handling-blackhole-callbacks - * @throws BadRequestException - */ - public function blackHole(Controller $controller, $error = '') { - if ($this->blackHoleCallback == null) { - throw new BadRequestException(__d('cake_dev', 'The request has been black-holed')); - } else { - return $this->_callback($controller, $this->blackHoleCallback, array($error)); - } - } - -/** - * 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 = array()) { - if (isset($actions[0]) && is_array($actions[0])) { - $actions = $actions[0]; - } - $this->{'require' . $method} = (empty($actions)) ? array('*'): $actions; - } - -/** - * Check if HTTP methods are required - * - * @param Controller $controller Instantiating controller - * @return boolean true if $method is required - */ - protected function _methodsRequired(Controller $controller) { - foreach (array('Post', 'Get', 'Put', 'Delete') as $method) { - $property = 'require' . $method; - if (is_array($this->$property) && !empty($this->$property)) { - $require = $this->$property; - if (in_array($this->_action, $require) || $this->$property == array('*')) { - if (!$this->request->is($method)) { - if (!$this->blackHole($controller, $method)) { - return null; - } - } - } - } - } - return true; - } - -/** - * Check if access requires secure connection - * - * @param Controller $controller Instantiating controller - * @return boolean true if secure connection required - */ - protected function _secureRequired(Controller $controller) { - if (is_array($this->requireSecure) && !empty($this->requireSecure)) { - $requireSecure = $this->requireSecure; - - if (in_array($this->_action, $requireSecure) || $this->requireSecure == array('*')) { - if (!$this->request->is('ssl')) { - if (!$this->blackHole($controller, 'secure')) { - return null; - } - } - } - } - return true; - } - -/** - * Check if authentication is required - * - * @param Controller $controller Instantiating controller - * @return boolean true if authentication required - */ - protected function _authRequired(Controller $controller) { - if (is_array($this->requireAuth) && !empty($this->requireAuth) && !empty($this->request->data)) { - $requireAuth = $this->requireAuth; - - if (in_array($this->request->params['action'], $requireAuth) || $this->requireAuth == array('*')) { - if (!isset($controller->request->data['_Token'] )) { - if (!$this->blackHole($controller, 'auth')) { - return null; - } - } - - if ($this->Session->check('_Token')) { - $tData = $this->Session->read('_Token'); - - if ( - !empty($tData['allowedControllers']) && - !in_array($this->request->params['controller'], $tData['allowedControllers']) || - !empty($tData['allowedActions']) && - !in_array($this->request->params['action'], $tData['allowedActions']) - ) { - if (!$this->blackHole($controller, 'auth')) { - return null; - } - } - } else { - if (!$this->blackHole($controller, 'auth')) { - return null; - } - } - } - } - return true; - } - -/** - * Validate submitted form - * - * @param Controller $controller Instantiating controller - * @return boolean true if submitted form is valid - */ - protected function _validatePost(Controller $controller) { - if (empty($controller->request->data)) { - return true; - } - $data = $controller->request->data; - - if (!isset($data['_Token']) || !isset($data['_Token']['fields']) || !isset($data['_Token']['unlocked'])) { - return false; - } - - $locked = ''; - $check = $controller->request->data; - $token = urldecode($check['_Token']['fields']); - $unlocked = urldecode($check['_Token']['unlocked']); - - if (strpos($token, ':')) { - list($token, $locked) = explode(':', $token, 2); - } - unset($check['_Token']); - - $locked = explode('|', $locked); - $unlocked = explode('|', $unlocked); - - $lockedFields = array(); - $fields = Set::flatten($check); - $fieldList = array_keys($fields); - $multi = array(); - - foreach ($fieldList as $i => $key) { - if (preg_match('/(\.\d+)+$/', $key)) { - $multi[$i] = preg_replace('/(\.\d+)+$/', '', $key); - unset($fieldList[$i]); - } - } - if (!empty($multi)) { - $fieldList += array_unique($multi); - } - - $unlockedFields = array_unique( - array_merge((array)$this->disabledFields, (array)$this->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($unlocked, SORT_STRING); - sort($fieldList, SORT_STRING); - ksort($lockedFields, SORT_STRING); - - $fieldList += $lockedFields; - $unlocked = implode('|', $unlocked); - $check = Security::hash(serialize($fieldList) . $unlocked . Configure::read('Security.salt')); - return ($token === $check); - } - -/** - * Manually add CSRF token information into the provided request object. - * - * @param CakeRequest $request The request object to add into. - * @return boolean - */ - public function generateToken(CakeRequest $request) { - if (isset($request->params['requested']) && $request->params['requested'] === 1) { - if ($this->Session->check('_Token')) { - $request->params['_Token'] = $this->Session->read('_Token'); - } - return false; - } - $authKey = Security::generateAuthKey(); - $token = array( - 'key' => $authKey, - 'allowedControllers' => $this->allowedControllers, - 'allowedActions' => $this->allowedActions, - 'unlockedFields' => array_merge($this->disabledFields, $this->unlockedFields), - 'csrfTokens' => array() - ); - - $tokenData = array(); - if ($this->Session->check('_Token')) { - $tokenData = $this->Session->read('_Token'); - if (!empty($tokenData['csrfTokens']) && is_array($tokenData['csrfTokens'])) { - $token['csrfTokens'] = $this->_expireTokens($tokenData['csrfTokens']); - } - } - if ($this->csrfUseOnce || empty($token['csrfTokens'])) { - $token['csrfTokens'][$authKey] = strtotime($this->csrfExpires); - } - if (!$this->csrfUseOnce) { - $csrfTokens = array_keys($token['csrfTokens']); - $token['key'] = $csrfTokens[0]; - } - $this->Session->write('_Token', $token); - $request->params['_Token'] = array( - 'key' => $token['key'], - 'unlockedFields' => $token['unlockedFields'] - ); - return true; - } - -/** - * Validate that the controller has a CSRF token in the POST data - * and that the token is legit/not expired. If the token is valid - * it will be removed from the list of valid tokens. - * - * @param Controller $controller A controller to check - * @return boolean Valid csrf token. - */ - protected function _validateCsrf(Controller $controller) { - $token = $this->Session->read('_Token'); - $requestToken = $controller->request->data('_Token.key'); - if (isset($token['csrfTokens'][$requestToken]) && $token['csrfTokens'][$requestToken] >= time()) { - if ($this->csrfUseOnce) { - $this->Session->delete('_Token.csrfTokens.' . $requestToken); - } - return true; - } - return false; - } - -/** - * Expire CSRF nonces and remove them from the valid tokens. - * Uses a simple timeout to expire the tokens. - * - * @param array $tokens An array of nonce => expires. - * @return array An array of nonce => expires. - */ - protected function _expireTokens($tokens) { - $now = time(); - foreach ($tokens as $nonce => $expires) { - if ($expires < $now) { - unset($tokens[$nonce]); - } - } - $overflow = count($tokens) - $this->csrfLimit; - if ($overflow > 0) { - $tokens = array_slice($tokens, $overflow + 1, null, true); - } - return $tokens; - } - -/** - * Calls a controller callback method - * - * @param Controller $controller Controller to run callback on - * @param string $method Method to execute - * @param array $params Parameters to send to method - * @return mixed Controller callback method's response - */ - protected function _callback(Controller $controller, $method, $params = array()) { - if (is_callable(array($controller, $method))) { - return call_user_func_array(array(&$controller, $method), empty($params) ? null : $params); - } else { - return null; - } - } - -} diff --git a/lib/Cake/Controller/Component/SessionComponent.php b/lib/Cake/Controller/Component/SessionComponent.php deleted file mode 100644 index 21e6603e7bf..00000000000 --- a/lib/Cake/Controller/Component/SessionComponent.php +++ /dev/null @@ -1,186 +0,0 @@ -Session->write('Controller.sessKey', 'session value'); - * - * @param string $name The name of the key your are setting in the session. - * This should be in a Controller.key format for better organizing - * @param string $value The value you want to store in a session. - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/core-libraries/components/sessions.html#SessionComponent::write - */ - public function write($name, $value = null) { - return CakeSession::write($name, $value); - } - -/** - * Used to read a session values for a key or return values for all keys. - * - * In your controller: $this->Session->read('Controller.sessKey'); - * Calling the method without a param will return all session vars - * - * @param string $name the name of the session key you want to read - * @return mixed value from the session vars - * @link http://book.cakephp.org/2.0/en/core-libraries/components/sessions.html#SessionComponent::read - */ - public function read($name = null) { - return CakeSession::read($name); - } - -/** - * Wrapper for SessionComponent::del(); - * - * In your controller: $this->Session->delete('Controller.sessKey'); - * - * @param string $name the name of the session key you want to delete - * @return boolean true is session variable is set and can be deleted, false is variable was not set. - * @link http://book.cakephp.org/2.0/en/core-libraries/components/sessions.html#SessionComponent::delete - */ - public function delete($name) { - return CakeSession::delete($name); - } - -/** - * Used to check if a session variable is set - * - * In your controller: $this->Session->check('Controller.sessKey'); - * - * @param string $name the name of the session key you want to check - * @return boolean true is session variable is set, false if not - * @link http://book.cakephp.org/2.0/en/core-libraries/components/sessions.html#SessionComponent::check - */ - public function check($name) { - return CakeSession::check($name); - } - -/** - * Used to determine the last error in a session. - * - * In your controller: $this->Session->error(); - * - * @return string Last session error - */ - public function error() { - return CakeSession::error(); - } - -/** - * Used to set a session variable that can be used to output messages in the view. - * - * In your controller: $this->Session->setFlash('This has been saved'); - * - * Additional params below can be passed to customize the output, or the Message.[key]. - * You can also set additional parameters when rendering flash messages. See SessionHelper::flash() - * for more information on how to do that. - * - * @param string $message Message to be flashed - * @param string $element Element to wrap flash message in. - * @param array $params Parameters to be sent to layout as view variables - * @param string $key Message key, default is 'flash' - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/sessions.html#creating-notification-messages - */ - public function setFlash($message, $element = 'default', $params = array(), $key = 'flash') { - CakeSession::write('Message.' . $key, compact('message', 'element', 'params')); - } - -/** - * Used to renew a session id - * - * In your controller: $this->Session->renew(); - * - * @return void - */ - public function renew() { - return CakeSession::renew(); - } - -/** - * Used to check for a valid session. - * - * In your controller: $this->Session->valid(); - * - * @return boolean true is session is valid, false is session is invalid - */ - public function valid() { - return CakeSession::valid(); - } - -/** - * Used to destroy sessions - * - * In your controller: $this->Session->destroy(); - * - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/components/sessions.html#SessionComponent::destroy - */ - public function destroy() { - return CakeSession::destroy(); - } - -/** - * Returns Session id - * - * If $id is passed in a beforeFilter, the Session will be started - * with the specified id - * - * @param string $id - * @return string - */ - public function id($id = null) { - return CakeSession::id($id); - } - -/** - * Returns a bool, whether or not the session has been started. - * - * @return boolean - */ - public function started() { - return CakeSession::started(); - } - -} diff --git a/lib/Cake/Controller/ComponentCollection.php b/lib/Cake/Controller/ComponentCollection.php deleted file mode 100644 index 43dca086f0e..00000000000 --- a/lib/Cake/Controller/ComponentCollection.php +++ /dev/null @@ -1,129 +0,0 @@ -components)) { - return; - } - $this->_Controller = $Controller; - $components = ComponentCollection::normalizeObjectArray($Controller->components); - foreach ($components as $name => $properties) { - $Controller->{$name} = $this->load($properties['class'], $properties['settings']); - } - } - -/** - * Get the controller associated with the collection. - * - * @return Controller. - */ - public function getController() { - return $this->_Controller; - } - -/** - * Loads/constructs a component. Will return the instance in the registry if it already exists. - * You can use `$settings['enabled'] = false` to disable callbacks on a component when loading it. - * Callbacks default to on. Disabled component methods work as normal, only callbacks are disabled. - * - * You can alias your component as an existing component by setting the 'className' key, i.e., - * {{{ - * public $components = array( - * 'Email' => array( - * 'className' => 'AliasedEmail' - * ); - * ); - * }}} - * All calls to the `Email` component would use `AliasedEmail` instead. - * - * @param string $component Component name to load - * @param array $settings Settings for the component. - * @return Component A component object, Either the existing loaded component or a new one. - * @throws MissingComponentException when the component could not be found - */ - public function load($component, $settings = array()) { - if (is_array($settings) && isset($settings['className'])) { - $alias = $component; - $component = $settings['className']; - } - list($plugin, $name) = pluginSplit($component, true); - if (!isset($alias)) { - $alias = $name; - } - if (isset($this->_loaded[$alias])) { - return $this->_loaded[$alias]; - } - $componentClass = $name . 'Component'; - App::uses($componentClass, $plugin . 'Controller/Component'); - if (!class_exists($componentClass)) { - throw new MissingComponentException(array( - 'class' => $componentClass, - 'plugin' => substr($plugin, 0, -1) - )); - } - $this->_loaded[$alias] = new $componentClass($this, $settings); - $enable = isset($settings['enabled']) ? $settings['enabled'] : true; - if ($enable) { - $this->enable($alias); - } - return $this->_loaded[$alias]; - } - -/** - * Returns the implemented events that will get routed to the trigger function - * in order to dispatch them separately on each component - * - * @return array - */ - public function implementedEvents() { - return array( - 'Controller.initialize' => array('callable' => 'trigger'), - 'Controller.startup' => array('callable' => 'trigger'), - 'Controller.beforeRender' => array('callable' => 'trigger'), - 'Controller.beforeRedirect' => array('callable' => 'trigger'), - 'Controller.shutdown' => array('callable' => 'trigger'), - ); - } - -} diff --git a/lib/Cake/Controller/Controller.php b/lib/Cake/Controller/Controller.php deleted file mode 100644 index bef8bfcc964..00000000000 --- a/lib/Cake/Controller/Controller.php +++ /dev/null @@ -1,1230 +0,0 @@ -request`. The request object contains all the POST, GET and FILES - * that were part of the request. - * - * After performing the required actions, controllers are responsible for creating a response. This usually - * takes the form of a generated View, or possibly a redirection to another controller action. In either case - * `$this->response` allows you to manipulate all aspects of the response. - * - * Controllers are created by Dispatcher 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(). - * - * @package Cake.Controller - * @property AclComponent $Acl - * @property AuthComponent $Auth - * @property CookieComponent $Cookie - * @property EmailComponent $Email - * @property PaginatorComponent $Paginator - * @property RequestHandlerComponent $RequestHandler - * @property SecurityComponent $Security - * @property SessionComponent $Session - * @link http://book.cakephp.org/2.0/en/controllers.html - */ -class Controller extends Object implements CakeEventListener { - -/** - * The name of this controller. Controller names are plural, named after the model they manipulate. - * - * @var string - * @link http://book.cakephp.org/2.0/en/controllers.html#controller-attributes - */ - public $name = null; - -/** - * An array containing the class names of models this controller uses. - * - * Example: `public $uses = array('Product', 'Post', 'Comment');` - * - * Can be set to several values to express different options: - * - * - `true` Use the default inflected model name. - * - `array()` Use only models defined in the parent class. - * - `false` Use no models at all, do not merge with parent class either. - * - `array('Post', 'Comment')` Use only the Post and Comment models. Models - * Will also be merged with the parent class. - * - * The default value is `true`. - * - * @var mixed A single name as a string or a list of names as an array. - * @link http://book.cakephp.org/2.0/en/controllers.html#components-helpers-and-uses - */ - public $uses = true; - -/** - * An array containing the names of helpers this controller uses. The array elements should - * not contain the "Helper" part of the classname. - * - * Example: `public $helpers = array('Html', 'Javascript', 'Time', 'Ajax');` - * - * @var mixed A single name as a string or a list of names as an array. - * @link http://book.cakephp.org/2.0/en/controllers.html#components-helpers-and-uses - */ - public $helpers = array('Session', 'Html', 'Form'); - -/** - * An instance of a CakeRequest 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 CakeRequest - */ - public $request; - -/** - * An instance of a CakeResponse object that contains information about the impending response - * - * @var CakeResponse - */ - public $response; - -/** - * The classname to use for creating the response object. - * - * @var string - */ - protected $_responseClass = 'CakeResponse'; - -/** - * The name of the views subfolder containing views for this controller. - * - * @var string - */ - public $viewPath = null; - -/** - * The name of the layouts subfolder containing layouts for this controller. - * - * @var string - */ - public $layoutPath = null; - -/** - * Contains variables to be handed to the view. - * - * @var array - */ - public $viewVars = array(); - -/** - * The name of the view file to render. The name specified - * is the filename in /app/View/ without the .ctp extension. - * - * @var string - */ - public $view = null; - -/** - * The name of the layout file to render the view inside of. The name specified - * is the filename of the layout in /app/View/Layouts without the .ctp - * extension. - * - * @var string - */ - public $layout = 'default'; - -/** - * Set to true to automatically render the view - * after action logic. - * - * @var boolean - */ - public $autoRender = true; - -/** - * Set to true to automatically render the layout around views. - * - * @var boolean - */ - public $autoLayout = true; - -/** - * Instance of ComponentCollection used to handle callbacks. - * - * @var ComponentCollection - */ - public $Components = null; - -/** - * Array containing the names of components this controller uses. Component names - * should not contain the "Component" portion of the classname. - * - * Example: `public $components = array('Session', 'RequestHandler', 'Acl');` - * - * @var array - * @link http://book.cakephp.org/view/961/components-helpers-and-uses - */ - public $components = array('Session'); - -/** - * The name of the View class this controller sends output to. - * - * @var string - */ - public $viewClass = 'View'; - -/** - * Instance of the View created during rendering. Won't be set until after Controller::render() is called. - * - * @var View - */ - public $View; - -/** - * File extension for view templates. Defaults to Cake's conventional ".ctp". - * - * @var string - */ - public $ext = '.ctp'; - -/** - * Automatically set to the name of a plugin. - * - * @var string - */ - public $plugin = null; - -/** - * Used to define methods a controller that will be cached. To cache a - * single action, the value is set to an array containing keys that match - * action names and values that denote cache expiration times (in seconds). - * - * Example: - * - * {{{ - * public $cacheAction = array( - * 'view/23/' => 21600, - * 'recalled/' => 86400 - * ); - * }}} - * - * $cacheAction can also be set to a strtotime() compatible string. This - * marks all the actions in the controller for view caching. - * - * @var mixed - * @link http://book.cakephp.org/view/1380/Caching-in-the-Controller - */ - public $cacheAction = false; - -/** - * Holds all params passed and named. - * - * @var mixed - */ - public $passedArgs = array(); - -/** - * Triggers Scaffolding - * - * @var mixed - * @link http://book.cakephp.org/view/1103/Scaffolding - */ - public $scaffold = false; - -/** - * Holds current methods of the controller. This is a list of all the methods reachable - * via url. Modifying this array, will allow you to change which methods can be reached. - * - * @var array - */ - public $methods = array(); - -/** - * This controller's primary model class name, the Inflector::singularize()'ed version of - * the controller's $name property. - * - * Example: For a controller named 'Comments', the modelClass would be 'Comment' - * - * @var string - */ - public $modelClass = null; - -/** - * This controller's model key name, an underscored version of the controller's $modelClass property. - * - * Example: For a controller named 'ArticleComments', the modelKey would be 'article_comment' - * - * @var string - */ - public $modelKey = null; - -/** - * Holds any validation errors produced by the last call of the validateErrors() method/ - * - * @var array Validation errors, or false if none - */ - public $validationErrors = null; - -/** - * The class name of the parent class you wish to merge with. - * Typically this is AppController, but you may wish to merge vars with a different - * parent class. - * - * @var string - */ - protected $_mergeParent = 'AppController'; - -/** - * Instance of the CakeEventManager this controller is using - * to dispatch inner events. - * - * @var CakeEventManager - */ - protected $_eventManager = null; - -/** - * Constructor. - * - * @param CakeRequest $request Request object for this controller. Can be null for testing, - * but expect that features that use the request parameters will not work. - * @param CakeResponse $response Response object for this controller. - */ - public function __construct($request = null, $response = null) { - if ($this->name === null) { - $this->name = substr(get_class($this), 0, strlen(get_class($this)) - 10); - } - - if ($this->viewPath == null) { - $this->viewPath = $this->name; - } - - $this->modelClass = Inflector::singularize($this->name); - $this->modelKey = Inflector::underscore($this->modelClass); - $this->Components = new ComponentCollection(); - - $childMethods = get_class_methods($this); - $parentMethods = get_class_methods('Controller'); - - $this->methods = array_diff($childMethods, $parentMethods); - - if ($request instanceof CakeRequest) { - $this->setRequest($request); - } - if ($response instanceof CakeResponse) { - $this->response = $response; - } - parent::__construct(); - } - -/** - * Provides backwards compatibility to avoid problems with empty and isset to alias properties. - * Lazy loads models using the loadModel() method if declared in $uses - * - * @param string $name - * @return void - */ - public function __isset($name) { - switch ($name) { - case 'base': - case 'here': - case 'webroot': - case 'data': - case 'action': - case 'params': - return true; - } - - if (is_array($this->uses)) { - foreach ($this->uses as $modelClass) { - list($plugin, $class) = pluginSplit($modelClass, true); - if ($name === $class) { - return $this->loadModel($modelClass); - } - } - } - - if ($name === $this->modelClass) { - list($plugin, $class) = pluginSplit($name, true); - if (!$plugin) { - $plugin = $this->plugin ? $this->plugin . '.' : null; - } - return $this->loadModel($plugin . $this->modelClass); - } - - return false; - } - -/** - * Provides backwards compatibility access to the request object properties. - * Also provides the params alias. - * - * @param string $name - * @return void - */ - public function __get($name) { - switch ($name) { - case 'base': - case 'here': - case 'webroot': - case 'data': - return $this->request->{$name}; - case 'action': - return isset($this->request->params['action']) ? $this->request->params['action'] : ''; - case 'params': - return $this->request; - case 'paginate': - return $this->Components->load('Paginator')->settings; - } - - if (isset($this->{$name})) { - return $this->{$name}; - } - - return null; - } - -/** - * Provides backwards compatibility access for setting values to the request object. - * - * @param string $name - * @param mixed $value - * @return void - */ - public function __set($name, $value) { - switch ($name) { - case 'base': - case 'here': - case 'webroot': - case 'data': - return $this->request->{$name} = $value; - case 'action': - return $this->request->params['action'] = $value; - case 'params': - return $this->request->params = $value; - case 'paginate': - return $this->Components->load('Paginator')->settings = $value; - } - return $this->{$name} = $value; - } - -/** - * Sets the request objects and configures a number of controller properties - * based on the contents of the request. The properties that get set are - * - * - $this->request - To the $request parameter - * - $this->plugin - To the $request->params['plugin'] - * - $this->view - To the $request->params['action'] - * - $this->autoLayout - To the false if $request->params['bare']; is set. - * - $this->autoRender - To false if $request->params['return'] == 1 - * - $this->passedArgs - The the combined results of params['named'] and params['pass] - * - * @param CakeRequest $request - * @return void - */ - public function setRequest(CakeRequest $request) { - $this->request = $request; - $this->plugin = isset($request->params['plugin']) ? Inflector::camelize($request->params['plugin']) : null; - $this->view = isset($request->params['action']) ? $request->params['action'] : null; - if (isset($request->params['pass']) && isset($request->params['named'])) { - $this->passedArgs = array_merge($request->params['pass'], $request->params['named']); - } - - if (array_key_exists('return', $request->params) && $request->params['return'] == 1) { - $this->autoRender = false; - } - if (!empty($request->params['bare'])) { - $this->autoLayout = false; - } - } - -/** - * Dispatches the controller action. Checks that the action - * exists and isn't private. - * - * @param CakeRequest $request - * @return mixed The resulting response. - * @throws PrivateActionException When actions are not public or prefixed by _ - * @throws MissingActionException When actions are not defined and scaffolding is - * not enabled. - */ - public function invokeAction(CakeRequest $request) { - try { - $method = new ReflectionMethod($this, $request->params['action']); - - if ($this->_isPrivateAction($method, $request)) { - throw new PrivateActionException(array( - 'controller' => $this->name . "Controller", - 'action' => $request->params['action'] - )); - } - return $method->invokeArgs($this, $request->params['pass']); - - } catch (ReflectionException $e) { - if ($this->scaffold !== false) { - return $this->_getScaffold($request); - } - throw new MissingActionException(array( - 'controller' => $this->name . "Controller", - 'action' => $request->params['action'] - )); - } - } - -/** - * Check if the request's action is marked as private, with an underscore, - * or if the request is attempting to directly accessing a prefixed action. - * - * @param ReflectionMethod $method The method to be invoked. - * @param CakeRequest $request The request to check. - * @return boolean - */ - protected function _isPrivateAction(ReflectionMethod $method, CakeRequest $request) { - $privateAction = ( - $method->name[0] === '_' || - !$method->isPublic() || - !in_array($method->name, $this->methods) - ); - $prefixes = Router::prefixes(); - - if (!$privateAction && !empty($prefixes)) { - if (empty($request->params['prefix']) && strpos($request->params['action'], '_') > 0) { - list($prefix) = explode('_', $request->params['action']); - $privateAction = in_array($prefix, $prefixes); - } - } - return $privateAction; - } - -/** - * Returns a scaffold object to use for dynamically scaffolded controllers. - * - * @param CakeRequest $request - * @return Scaffold - */ - protected function _getScaffold(CakeRequest $request) { - return new Scaffold($this, $request); - } - -/** - * Merge components, helpers, and uses vars from - * Controller::$_mergeParent and PluginAppController. - * - * @return void - */ - protected function _mergeControllerVars() { - $pluginController = $pluginDot = null; - $mergeParent = is_subclass_of($this, $this->_mergeParent); - $pluginVars = array(); - $appVars = array(); - - if (!empty($this->plugin)) { - $pluginController = $this->plugin . 'AppController'; - if (!is_subclass_of($this, $pluginController)) { - $pluginController = null; - } - $pluginDot = $this->plugin . '.'; - } - - if ($pluginController) { - $merge = array('components', 'helpers'); - $this->_mergeVars($merge, $pluginController); - } - - if ($mergeParent || !empty($pluginController)) { - $appVars = get_class_vars($this->_mergeParent); - $uses = $appVars['uses']; - $merge = array('components', 'helpers'); - $this->_mergeVars($merge, $this->_mergeParent, true); - } - - if ($this->uses === null) { - $this->uses = false; - } - if ($this->uses === true) { - $this->uses = array($pluginDot . $this->modelClass); - } - if (isset($appVars['uses']) && $appVars['uses'] === $this->uses) { - array_unshift($this->uses, $pluginDot . $this->modelClass); - } - if ($pluginController) { - $pluginVars = get_class_vars($pluginController); - } - if ($this->uses !== false) { - $this->_mergeUses($pluginVars); - $this->_mergeUses($appVars); - } else { - $this->uses = array(); - $this->modelClass = ''; - } - } - -/** - * Helper method for merging the $uses property together. - * - * Merges the elements not already in $this->uses into - * $this->uses. - * - * @param mixed $merge The data to merge in. - * @return void - */ - protected function _mergeUses($merge) { - if (!isset($merge['uses'])) { - return; - } - if ($merge['uses'] === true) { - return; - } - $this->uses = array_merge( - $this->uses, - array_diff($merge['uses'], $this->uses) - ); - } - -/** - * Returns a list of all events that will fire in the controller during it's lifecycle. - * You can override this function to add you own listener callbacks - * - * @return array - */ - public function implementedEvents() { - return array( - 'Controller.initialize' => 'beforeFilter', - 'Controller.beforeRender' => 'beforeRender', - 'Controller.beforeRedirect' => array('callable' => 'beforeRedirect', 'passParams' => true), - 'Controller.shutdown' => 'afterFilter' - ); - } - -/** - * Loads Model classes based on the uses property - * see Controller::loadModel(); for more info. - * Loads Components and prepares them for initialization. - * - * @return mixed true if models found and instance created. - * @see Controller::loadModel() - * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::constructClasses - * @throws MissingModelException - */ - public function constructClasses() { - $this->_mergeControllerVars(); - $this->Components->init($this); - if ($this->uses) { - $this->uses = (array)$this->uses; - list(, $this->modelClass) = pluginSplit(current($this->uses)); - } - return true; - } - -/** - * Returns the CakeEventManager manager instance that is handling any callbacks. - * You can use this instance to register any new listeners or callbacks to the - * controller events, or create your own events and trigger them at will. - * - * @return CakeEventManager - */ - public function getEventManager() { - if (empty($this->_eventManager)) { - $this->_eventManager = new CakeEventManager(); - $this->_eventManager->attach($this->Components); - $this->_eventManager->attach($this); - } - return $this->_eventManager; - } - -/** - * Perform the startup process for this controller. - * Fire the Components and Controller callbacks in the correct order. - * - * - Initializes components, which fires their `initialize` callback - * - Calls the controller `beforeFilter`. - * - triggers Component `startup` methods. - * - * @return void - */ - public function startupProcess() { - $this->getEventManager()->dispatch(new CakeEvent('Controller.initialize', $this)); - $this->getEventManager()->dispatch(new CakeEvent('Controller.startup', $this)); - } - -/** - * Perform the various shutdown processes for this controller. - * Fire the Components and Controller callbacks in the correct order. - * - * - triggers the component `shutdown` callback. - * - calls the Controller's `afterFilter` method. - * - * @return void - */ - public function shutdownProcess() { - $this->getEventManager()->dispatch(new CakeEvent('Controller.shutdown', $this)); - } - -/** - * Queries & sets valid HTTP response codes & messages. - * - * @param mixed $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 'code' and 'message' keys of each nested array are added to the default - * HTTP codes. Example: - * - * httpCodes(404); // returns array(404 => 'Not Found') - * - * httpCodes(array( - * 701 => 'Unicorn Moved', - * 800 => 'Unexpected Minotaur' - * )); // sets these new values, and returns true - * - * @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. - * @deprecated Use CakeResponse::httpCodes(); - */ - public function httpCodes($code = null) { - return $this->response->httpCodes($code); - } - -/** - * Loads and instantiates models required by this controller. - * If the model is non existent, it will throw a missing database table error, as Cake generates - * dynamic models for the time being. - * - * @param string $modelClass Name of model class to load - * @param mixed $id Initial ID the instanced model class should have - * @return mixed true when single model found and instance created, error returned if model not found. - * @throws MissingModelException if the model class cannot be found. - */ - public function loadModel($modelClass = null, $id = null) { - if ($modelClass === null) { - $modelClass = $this->modelClass; - } - - $this->uses = ($this->uses) ? (array)$this->uses : array(); - if (!in_array($modelClass, $this->uses)) { - $this->uses[] = $modelClass; - } - - list($plugin, $modelClass) = pluginSplit($modelClass, true); - - $this->{$modelClass} = ClassRegistry::init(array( - 'class' => $plugin . $modelClass, 'alias' => $modelClass, 'id' => $id - )); - if (!$this->{$modelClass}) { - throw new MissingModelException($modelClass); - } - return true; - } - -/** - * Redirects to given $url, after turning off $this->autoRender. - * Script execution is halted after the redirect. - * - * @param mixed $url A string or array-based URL pointing to another location within the app, - * or an absolute URL - * @param integer $status Optional HTTP status code (eg: 404) - * @param boolean $exit If true, exit() will be called after the redirect - * @return mixed void if $exit = false. Terminates script if $exit = true - * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::redirect - */ - public function redirect($url, $status = null, $exit = true) { - $this->autoRender = false; - - if (is_array($status)) { - extract($status, EXTR_OVERWRITE); - } - $event = new CakeEvent('Controller.beforeRedirect', $this, array($url, $status, $exit)); - //TODO: Remove the following line when the events are fully migrated to the CakeEventManager - list($event->break, $event->breakOn, $event->collectReturn) = array(true, false, true); - $this->getEventManager()->dispatch($event); - - if ($event->isStopped()) { - return; - } - $response = $event->result; - extract($this->_parseBeforeRedirect($response, $url, $status, $exit), EXTR_OVERWRITE); - - if (function_exists('session_write_close')) { - session_write_close(); - } - - if (!empty($status) && is_string($status)) { - $codes = array_flip($this->response->httpCodes()); - if (isset($codes[$status])) { - $status = $codes[$status]; - } - } - - if ($url !== null) { - $this->response->header('Location', Router::url($url, true)); - } - - if (!empty($status) && ($status >= 300 && $status < 400)) { - $this->response->statusCode($status); - } - - if ($exit) { - $this->response->send(); - $this->_stop(); - } - } - -/** - * Parse beforeRedirect Response - * - * @param mixed $response Response from beforeRedirect callback - * @param mixed $url The same value of beforeRedirect - * @param integer $status The same value of beforeRedirect - * @param boolean $exit The same value of beforeRedirect - * @return array Array with keys url, status and exit - */ - protected function _parseBeforeRedirect($response, $url, $status, $exit) { - if (is_array($response)) { - foreach ($response as $resp) { - if (is_array($resp) && isset($resp['url'])) { - extract($resp, EXTR_OVERWRITE); - } elseif ($resp !== null) { - $url = $resp; - } - } - } - return compact('url', 'status', 'exit'); - } - -/** - * Convenience and object wrapper method for CakeResponse::header(). - * - * @param string $status The header message that is being set. - * @return void - * @deprecated Use CakeResponse::header() - */ - public function header($status) { - $this->response->header($status); - } - -/** - * Saves a variable for use inside a view template. - * - * @param mixed $one A string or an array of data. - * @param mixed $two Value in case $one is a string (which then works as the key). - * Unused if $one is an associative array, otherwise serves as the values to $one's keys. - * @return void - * @link http://book.cakephp.org/2.0/en/controllers.html#interacting-with-views - */ - public function set($one, $two = null) { - if (is_array($one)) { - if (is_array($two)) { - $data = array_combine($one, $two); - } else { - $data = $one; - } - } else { - $data = array($one => $two); - } - $this->viewVars = $data + $this->viewVars; - } - -/** - * 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 - * @param mixed Any other parameters passed to this method will be passed as - * parameters to the new action. - * @return mixed Returns the return value of the called action - */ - public function setAction($action) { - $this->request->params['action'] = $action; - $this->view = $action; - $args = func_get_args(); - unset($args[0]); - return call_user_func_array(array(&$this, $action), $args); - } - -/** - * Returns number of errors in a submitted FORM. - * - * @return integer Number of errors - */ - public function validate() { - $args = func_get_args(); - $errors = call_user_func_array(array(&$this, 'validateErrors'), $args); - - if ($errors === false) { - return 0; - } - return count($errors); - } - -/** - * Validates models passed by parameters. Example: - * - * `$errors = $this->validateErrors($this->Article, $this->User);` - * - * @param mixed A list of models as a variable argument - * @return array Validation errors, or false if none - */ - public function validateErrors() { - $objects = func_get_args(); - - if (empty($objects)) { - return false; - } - - $errors = array(); - foreach ($objects as $object) { - if (isset($this->{$object->alias})) { - $object = $this->{$object->alias}; - } - $object->set($object->data); - $errors = array_merge($errors, $object->invalidFields()); - } - - return $this->validationErrors = (!empty($errors) ? $errors : false); - } - -/** - * Instantiates the correct view class, hands it its data, and uses it to render the view output. - * - * @param string $view View to use for rendering - * @param string $layout Layout to use - * @return CakeResponse A response object containing the rendered view. - * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::render - */ - public function render($view = null, $layout = null) { - $event = new CakeEvent('Controller.beforeRender', $this); - $this->getEventManager()->dispatch($event); - if ($event->isStopped()) { - $this->autoRender = false; - return $this->response; - } - - if (!empty($this->uses) && is_array($this->uses)) { - foreach ($this->uses as $model) { - list($plugin, $className) = pluginSplit($model); - $this->request->params['models'][$className] = compact('plugin', 'className'); - } - } - - $viewClass = $this->viewClass; - if ($this->viewClass != 'View') { - list($plugin, $viewClass) = pluginSplit($viewClass, true); - $viewClass = $viewClass . 'View'; - App::uses($viewClass, $plugin . 'View'); - } - - $View = new $viewClass($this); - - $models = ClassRegistry::keys(); - foreach ($models as $currentModel) { - $currentObject = ClassRegistry::getObject($currentModel); - if (is_a($currentObject, 'Model')) { - $className = get_class($currentObject); - list($plugin) = pluginSplit(App::location($className)); - $this->request->params['models'][$currentObject->alias] = compact('plugin', 'className'); - $View->validationErrors[$currentObject->alias] =& $currentObject->validationErrors; - } - } - - $this->autoRender = false; - $this->View = $View; - $this->response->body($View->render($view, $layout)); - return $this->response; - } - -/** - * Returns the referring URL for this request. - * - * @param string $default Default URL to use if HTTP_REFERER cannot be read from headers - * @param boolean $local If true, restrict referring URLs to local server - * @return string Referring URL - * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::referer - */ - public function referer($default = null, $local = false) { - if ($this->request) { - $referer = $this->request->referer($local); - if ($referer == '/' && $default != null) { - return Router::url($default, true); - } - return $referer; - } - return '/'; - } - -/** - * Forces the user's browser not to cache the results of the current request. - * - * @return void - * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::disableCache - * @deprecated Use CakeResponse::disableCache() - */ - public function disableCache() { - $this->response->disableCache(); - } - -/** - * Shows a message to the user for $pause seconds, then redirects to $url. - * Uses flash.ctp as the default layout for the message. - * Does not work if the current debug level is higher than 0. - * - * @param string $message Message to display to the user - * @param mixed $url Relative string or array-based URL to redirect to after the time expires - * @param integer $pause Time to show the message - * @param string $layout Layout you want to use, defaults to 'flash' - * @return void Renders flash layout - * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::flash - */ - public function flash($message, $url, $pause = 1, $layout = 'flash') { - $this->autoRender = false; - $this->set('url', Router::url($url)); - $this->set('message', $message); - $this->set('pause', $pause); - $this->set('page_title', $message); - $this->render(false, $layout); - } - -/** - * Converts POST'ed form data to a model conditions array, suitable for use in a Model::find() call. - * - * @param array $data POST'ed data organized by model and field - * @param mixed $op A string containing an SQL comparison operator, or an array matching operators - * to fields - * @param string $bool SQL boolean operator: AND, OR, XOR, etc. - * @param boolean $exclusive If true, and $op is an array, fields not included in $op will not be - * included in the returned conditions - * @return array An array of model conditions - * @deprecated - */ - public function postConditions($data = array(), $op = null, $bool = 'AND', $exclusive = false) { - if (!is_array($data) || empty($data)) { - if (!empty($this->request->data)) { - $data = $this->request->data; - } else { - return null; - } - } - $cond = array(); - - if ($op === null) { - $op = ''; - } - - $arrayOp = is_array($op); - foreach ($data as $model => $fields) { - foreach ($fields as $field => $value) { - $key = $model . '.' . $field; - $fieldOp = $op; - if ($arrayOp) { - if (array_key_exists($key, $op)) { - $fieldOp = $op[$key]; - } elseif (array_key_exists($field, $op)) { - $fieldOp = $op[$field]; - } else { - $fieldOp = false; - } - } - if ($exclusive && $fieldOp === false) { - continue; - } - $fieldOp = strtoupper(trim($fieldOp)); - if ($fieldOp === 'LIKE') { - $key = $key . ' LIKE'; - $value = '%' . $value . '%'; - } elseif ($fieldOp && $fieldOp != '=') { - $key = $key . ' ' . $fieldOp; - } - $cond[$key] = $value; - } - } - if ($bool != null && strtoupper($bool) != 'AND') { - $cond = array($bool => $cond); - } - return $cond; - } - -/** - * Handles automatic pagination of model records. - * - * @param mixed $object Model to paginate (e.g: model instance, or 'Model', or 'Model.InnerModel') - * @param mixed $scope Conditions to use while paginating - * @param array $whitelist List of allowed options for paging - * @return array Model query results - * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::paginate - * @deprecated Use PaginatorComponent instead - */ - public function paginate($object = null, $scope = array(), $whitelist = array()) { - return $this->Components->load('Paginator', $this->paginate)->paginate($object, $scope, $whitelist); - } - -/** - * 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. - * - * @return void - * @link http://book.cakephp.org/2.0/en/controllers.html#request-life-cycle-callbacks - */ - public function beforeFilter() { - } - -/** - * 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. - * - * @return void - * @link http://book.cakephp.org/2.0/en/controllers.html#request-life-cycle-callbacks - */ - public function beforeRender() { - } - -/** - * The beforeRedirect method is invoked when the controller's redirect method is called but before any - * further action. If this method returns false the controller will not continue on to redirect the request. - * The $url, $status and $exit variables have same meaning as for the controller's method. You can also - * return a string which will be interpreted as the url to redirect to or return associative array with - * key 'url' and optionally 'status' and 'exit'. - * - * @param mixed $url A string or array-based URL pointing to another location within the app, - * or an absolute URL - * @param integer $status Optional HTTP status code (eg: 404) - * @param boolean $exit If true, exit() will be called after the redirect - * @return mixed - * false to stop redirection event, - * string controllers a new redirection url or - * array with the keys url, status and exit to be used by the redirect method. - * @link http://book.cakephp.org/2.0/en/controllers.html#request-life-cycle-callbacks - */ - public function beforeRedirect($url, $status = null, $exit = true) { - } - -/** - * Called after the controller action is run and rendered. - * - * @return void - * @link http://book.cakephp.org/2.0/en/controllers.html#request-life-cycle-callbacks - */ - public function afterFilter() { - } - -/** - * This method should be overridden in child classes. - * - * @param string $method name of method called example index, edit, etc. - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/controllers.html#callbacks - */ - public function beforeScaffold($method) { - return true; - } - -/** - * Alias to beforeScaffold() - * - * @param string $method - * @return boolean - * @see Controller::beforeScaffold() - * @deprecated - */ - protected function _beforeScaffold($method) { - return $this->beforeScaffold($method); - } - -/** - * This method should be overridden in child classes. - * - * @param string $method name of method called either edit or update. - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/controllers.html#callbacks - */ - public function afterScaffoldSave($method) { - return true; - } - -/** - * Alias to afterScaffoldSave() - * - * @param string $method - * @return boolean - * @see Controller::afterScaffoldSave() - * @deprecated - */ - protected function _afterScaffoldSave($method) { - return $this->afterScaffoldSave($method); - } - -/** - * This method should be overridden in child classes. - * - * @param string $method name of method called either edit or update. - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/controllers.html#callbacks - */ - public function afterScaffoldSaveError($method) { - return true; - } - -/** - * Alias to afterScaffoldSaveError() - * - * @param string $method - * @return boolean - * @see Controller::afterScaffoldSaveError() - * @deprecated - */ - protected function _afterScaffoldSaveError($method) { - return $this->afterScaffoldSaveError($method); - } - -/** - * This method should be overridden in child classes. - * If not it will render a scaffold error. - * Method MUST return true in child classes - * - * @param string $method name of method called example index, edit, etc. - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/controllers.html#callbacks - */ - public function scaffoldError($method) { - return false; - } - -/** - * Alias to scaffoldError() - * - * @param string $method - * @return boolean - * @see Controller::scaffoldError() - * @deprecated - */ - protected function _scaffoldError($method) { - return $this->scaffoldError($method); - } - -} diff --git a/lib/Cake/Controller/Scaffold.php b/lib/Cake/Controller/Scaffold.php deleted file mode 100644 index 1d0fd3e1a0f..00000000000 --- a/lib/Cake/Controller/Scaffold.php +++ /dev/null @@ -1,447 +0,0 @@ -controller = $controller; - - $count = count($this->_passedVars); - for ($j = 0; $j < $count; $j++) { - $var = $this->_passedVars[$j]; - $this->{$var} = $controller->{$var}; - } - - $this->redirect = array('action' => 'index'); - - $this->modelClass = $controller->modelClass; - $this->modelKey = $controller->modelKey; - - if (!is_object($this->controller->{$this->modelClass})) { - throw new MissingModelException($this->modelClass); - } - - $this->ScaffoldModel = $this->controller->{$this->modelClass}; - $this->scaffoldTitle = Inflector::humanize(Inflector::underscore($this->viewPath)); - $this->scaffoldActions = $controller->scaffold; - $title = __d('cake', 'Scaffold :: ') . Inflector::humanize($request->action) . ' :: ' . $this->scaffoldTitle; - $modelClass = $this->controller->modelClass; - $primaryKey = $this->ScaffoldModel->primaryKey; - $displayField = $this->ScaffoldModel->displayField; - $singularVar = Inflector::variable($modelClass); - $pluralVar = Inflector::variable($this->controller->name); - $singularHumanName = Inflector::humanize(Inflector::underscore($modelClass)); - $pluralHumanName = Inflector::humanize(Inflector::underscore($this->controller->name)); - $scaffoldFields = array_keys($this->ScaffoldModel->schema()); - $associations = $this->_associations(); - - $this->controller->set(compact( - 'title_for_layout', 'modelClass', 'primaryKey', 'displayField', 'singularVar', 'pluralVar', - 'singularHumanName', 'pluralHumanName', 'scaffoldFields', 'associations' - )); - $this->controller->set('title_for_layout', $title); - - if ($this->controller->viewClass) { - $this->controller->viewClass = 'Scaffold'; - } - $this->_validSession = ( - isset($this->controller->Session) && $this->controller->Session->valid() != false - ); - $this->_scaffold($request); - } - -/** - * Renders a view action of scaffolded model. - * - * @param CakeRequest $request Request Object for scaffolding - * @return mixed A rendered view of a row from Models database table - * @throws NotFoundException - */ - protected function _scaffoldView(CakeRequest $request) { - if ($this->controller->beforeScaffold('view')) { - if (isset($request->params['pass'][0])) { - $this->ScaffoldModel->id = $request->params['pass'][0]; - } - if (!$this->ScaffoldModel->exists()) { - throw new NotFoundException(__d('cake', 'Invalid %s', Inflector::humanize($this->modelKey))); - } - $this->ScaffoldModel->recursive = 1; - $this->controller->request->data = $this->ScaffoldModel->read(); - $this->controller->set( - Inflector::variable($this->controller->modelClass), $this->request->data - ); - $this->controller->render($this->request['action'], $this->layout); - } elseif ($this->controller->scaffoldError('view') === false) { - return $this->_scaffoldError(); - } - } - -/** - * Renders index action of scaffolded model. - * - * @param array $params Parameters for scaffolding - * @return mixed A rendered view listing rows from Models database table - */ - protected function _scaffoldIndex($params) { - if ($this->controller->beforeScaffold('index')) { - $this->ScaffoldModel->recursive = 0; - $this->controller->set( - Inflector::variable($this->controller->name), $this->controller->paginate() - ); - $this->controller->render($this->request['action'], $this->layout); - } elseif ($this->controller->scaffoldError('index') === false) { - return $this->_scaffoldError(); - } - } - -/** - * Renders an add or edit action for scaffolded model. - * - * @param string $action Action (add or edit) - * @return mixed A rendered view with a form to edit or add a record in the Models database table - */ - protected function _scaffoldForm($action = 'edit') { - $this->controller->viewVars['scaffoldFields'] = array_merge( - $this->controller->viewVars['scaffoldFields'], - array_keys($this->ScaffoldModel->hasAndBelongsToMany) - ); - $this->controller->render($action, $this->layout); - } - -/** - * Saves or updates the scaffolded model. - * - * @param CakeRequest $request Request Object for scaffolding - * @param string $action add or edit - * @return mixed Success on save/update, add/edit form if data is empty or error if save or update fails - * @throws NotFoundException - */ - protected function _scaffoldSave(CakeRequest $request, $action = 'edit') { - $formAction = 'edit'; - $success = __d('cake', 'updated'); - if ($action === 'add') { - $formAction = 'add'; - $success = __d('cake', 'saved'); - } - - if ($this->controller->beforeScaffold($action)) { - if ($action == 'edit') { - if (isset($request->params['pass'][0])) { - $this->ScaffoldModel->id = $request['pass'][0]; - } - if (!$this->ScaffoldModel->exists()) { - throw new NotFoundException(__d('cake', 'Invalid %s', Inflector::humanize($this->modelKey))); - } - } - - if (!empty($request->data)) { - if ($action == 'create') { - $this->ScaffoldModel->create(); - } - - if ($this->ScaffoldModel->save($request->data)) { - if ($this->controller->afterScaffoldSave($action)) { - $message = __d('cake', - 'The %1$s has been %2$s', - Inflector::humanize($this->modelKey), - $success - ); - return $this->_sendMessage($message); - } else { - return $this->controller->afterScaffoldSaveError($action); - } - } else { - if ($this->_validSession) { - $this->controller->Session->setFlash(__d('cake', 'Please correct errors below.')); - } - } - } - - if (empty($request->data)) { - if ($this->ScaffoldModel->id) { - $this->controller->data = $request->data = $this->ScaffoldModel->read(); - } else { - $this->controller->data = $request->data = $this->ScaffoldModel->create(); - } - } - - foreach ($this->ScaffoldModel->belongsTo as $assocName => $assocData) { - $varName = Inflector::variable(Inflector::pluralize( - preg_replace('/(?:_id)$/', '', $assocData['foreignKey']) - )); - $this->controller->set($varName, $this->ScaffoldModel->{$assocName}->find('list')); - } - foreach ($this->ScaffoldModel->hasAndBelongsToMany as $assocName => $assocData) { - $varName = Inflector::variable(Inflector::pluralize($assocName)); - $this->controller->set($varName, $this->ScaffoldModel->{$assocName}->find('list')); - } - - return $this->_scaffoldForm($formAction); - } elseif ($this->controller->scaffoldError($action) === false) { - return $this->_scaffoldError(); - } - } - -/** - * Performs a delete on given scaffolded Model. - * - * @param CakeRequest $request Request for scaffolding - * @return mixed Success on delete, error if delete fails - * @throws MethodNotAllowedException When HTTP method is not a DELETE - * @throws NotFoundException When id being deleted does not exist. - */ - protected function _scaffoldDelete(CakeRequest $request) { - if ($this->controller->beforeScaffold('delete')) { - if (!$request->is('post')) { - throw new MethodNotAllowedException(); - } - $id = false; - if (isset($request->params['pass'][0])) { - $id = $request->params['pass'][0]; - } - $this->ScaffoldModel->id = $id; - if (!$this->ScaffoldModel->exists()) { - throw new NotFoundException(__d('cake', 'Invalid %s', Inflector::humanize($this->modelClass))); - } - if ($this->ScaffoldModel->delete()) { - $message = __d('cake', 'The %1$s with id: %2$d has been deleted.', Inflector::humanize($this->modelClass), $id); - return $this->_sendMessage($message); - } else { - $message = __d('cake', - 'There was an error deleting the %1$s with id: %2$d', - Inflector::humanize($this->modelClass), - $id - ); - return $this->_sendMessage($message); - } - } elseif ($this->controller->scaffoldError('delete') === false) { - return $this->_scaffoldError(); - } - } - -/** - * Sends a message to the user. Either uses Sessions or flash messages depending - * on the availability of a session - * - * @param string $message Message to display - * @return void - */ - protected function _sendMessage($message) { - if ($this->_validSession) { - $this->controller->Session->setFlash($message); - $this->controller->redirect($this->redirect); - } else { - $this->controller->flash($message, $this->redirect); - } - } - -/** - * Show a scaffold error - * - * @return mixed A rendered view showing the error - */ - protected function _scaffoldError() { - return $this->controller->render('error', $this->layout); - } - -/** - * When methods are now present in a controller - * scaffoldView is used to call default Scaffold methods if: - * `public $scaffold;` is placed in the controller's class definition. - * - * @param CakeRequest $request Request object for scaffolding - * @return mixed A rendered view of scaffold action, or showing the error - * @throws MissingActionException When methods are not scaffolded. - * @throws MissingDatabaseException When the database connection is undefined. - */ - protected function _scaffold(CakeRequest $request) { - $db = ConnectionManager::getDataSource($this->ScaffoldModel->useDbConfig); - $prefixes = Configure::read('Routing.prefixes'); - $scaffoldPrefix = $this->scaffoldActions; - - if (isset($db)) { - if (empty($this->scaffoldActions)) { - $this->scaffoldActions = array( - 'index', 'list', 'view', 'add', 'create', 'edit', 'update', 'delete' - ); - } elseif (!empty($prefixes) && in_array($scaffoldPrefix, $prefixes)) { - $this->scaffoldActions = array( - $scaffoldPrefix . '_index', - $scaffoldPrefix . '_list', - $scaffoldPrefix . '_view', - $scaffoldPrefix . '_add', - $scaffoldPrefix . '_create', - $scaffoldPrefix . '_edit', - $scaffoldPrefix . '_update', - $scaffoldPrefix . '_delete' - ); - } - - if (in_array($request->params['action'], $this->scaffoldActions)) { - if (!empty($prefixes)) { - $request->params['action'] = str_replace($scaffoldPrefix . '_', '', $request->params['action']); - } - switch ($request->params['action']) { - case 'index': - case 'list': - $this->_scaffoldIndex($request); - break; - case 'view': - $this->_scaffoldView($request); - break; - case 'add': - case 'create': - $this->_scaffoldSave($request, 'add'); - break; - case 'edit': - case 'update': - $this->_scaffoldSave($request, 'edit'); - break; - case 'delete': - $this->_scaffoldDelete($request); - break; - } - } else { - throw new MissingActionException(array( - 'controller' => $this->controller->name, - 'action' => $request->action - )); - } - } else { - throw new MissingDatabaseException(array('connection' => $this->ScaffoldModel->useDbConfig)); - } - } - -/** - * Returns associations for controllers models. - * - * @return array Associations for model - */ - protected function _associations() { - $keys = array('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany'); - $associations = array(); - - foreach ($keys as $key => $type) { - foreach ($this->ScaffoldModel->{$type} as $assocKey => $assocData) { - $associations[$type][$assocKey]['primaryKey'] = - $this->ScaffoldModel->{$assocKey}->primaryKey; - - $associations[$type][$assocKey]['displayField'] = - $this->ScaffoldModel->{$assocKey}->displayField; - - $associations[$type][$assocKey]['foreignKey'] = - $assocData['foreignKey']; - - $associations[$type][$assocKey]['controller'] = - Inflector::pluralize(Inflector::underscore($assocData['className'])); - - if ($type == 'hasAndBelongsToMany') { - $associations[$type][$assocKey]['with'] = $assocData['with']; - } - } - } - return $associations; - } - -} diff --git a/lib/Cake/Core/App.php b/lib/Cake/Core/App.php deleted file mode 100644 index e0817462a0e..00000000000 --- a/lib/Cake/Core/App.php +++ /dev/null @@ -1,897 +0,0 @@ - array('extends' => null, 'core' => true), - 'file' => array('extends' => null, 'core' => true), - 'model' => array('extends' => 'AppModel', 'core' => false), - 'behavior' => array('suffix' => 'Behavior', 'extends' => 'Model/ModelBehavior', 'core' => true), - 'controller' => array('suffix' => 'Controller', 'extends' => 'AppController', 'core' => true), - 'component' => array('suffix' => 'Component', 'extends' => null, 'core' => true), - 'lib' => array('extends' => null, 'core' => true), - 'view' => array('suffix' => 'View', 'extends' => null, 'core' => true), - 'helper' => array('suffix' => 'Helper', 'extends' => 'AppHelper', 'core' => true), - 'vendor' => array('extends' => null, 'core' => true), - 'shell' => array('suffix' => 'Shell', 'extends' => 'AppShell', 'core' => true), - 'plugin' => array('extends' => null, 'core' => true) - ); - -/** - * Paths to search for files. - * - * @var array - */ - public static $search = array(); - -/** - * Whether or not to return the file that is loaded. - * - * @var boolean - */ - public static $return = false; - -/** - * Holds key/value pairs of $type => file path. - * - * @var array - */ - protected static $_map = array(); - -/** - * Holds and key => value array of object types. - * - * @var array - */ - protected static $_objects = array(); - -/** - * Holds the location of each class - * - * @var array - */ - protected static $_classMap = array(); - -/** - * Holds the possible paths for each package name - * - * @var array - */ - protected static $_packages = array(); - -/** - * Holds the templates for each customizable package path in the application - * - * @var array - */ - protected static $_packageFormat = array(); - -/** - * Maps an old style CakePHP class type to the corresponding package - * - * @var array - */ - public static $legacy = array( - 'models' => 'Model', - 'behaviors' => 'Model/Behavior', - 'datasources' => 'Model/Datasource', - 'controllers' => 'Controller', - 'components' => 'Controller/Component', - 'views' => 'View', - 'helpers' => 'View/Helper', - 'shells' => 'Console/Command', - 'libs' => 'Lib', - 'vendors' => 'Vendor', - 'plugins' => 'Plugin', - 'locales' => 'Locale' - ); - -/** - * Indicates whether the class cache should be stored again because of an addition to it - * - * @var boolean - */ - protected static $_cacheChange = false; - -/** - * Indicates whether the object cache should be stored again because of an addition to it - * - * @var boolean - */ - protected static $_objectCacheChange = false; - -/** - * Indicates the the Application is in the bootstrapping process. Used to better cache - * loaded classes while the cache libraries have not been yet initialized - * - * @var boolean - */ - public static $bootstrapping = false; - -/** - * Used to read information stored path - * - * Usage: - * - * `App::path('Model'); will return all paths for models` - * - * `App::path('Model/Datasource', 'MyPlugin'); will return the path for datasources under the 'MyPlugin' plugin` - * - * @param string $type type of path - * @param string $plugin name of plugin - * @return array - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::path - */ - public static function path($type, $plugin = null) { - if (!empty(self::$legacy[$type])) { - $type = self::$legacy[$type]; - } - - if (!empty($plugin)) { - $path = array(); - $pluginPath = self::pluginPath($plugin); - $packageFormat = self::_packageFormat(); - if (!empty($packageFormat[$type])) { - foreach ($packageFormat[$type] as $f) { - $path[] = sprintf($f, $pluginPath); - } - } - return $path; - } - - if (!isset(self::$_packages[$type])) { - return array(); - } - return self::$_packages[$type]; - } - -/** - * Get all the currently loaded paths from App. Useful for inspecting - * or storing all paths App knows about. For a paths to a specific package - * use App::path() - * - * @return array An array of packages and their associated paths. - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::paths - */ - public static function paths() { - return self::$_packages; - } - -/** - * Sets up each package location on the file system. You can configure multiple search paths - * for each package, those will be used to look for files one folder at a time in the specified order - * All paths should be terminated with a Directory separator - * - * Usage: - * - * `App::build(array(Model' => array('/a/full/path/to/models/'))); will setup a new search path for the Model package` - * - * `App::build(array('Model' => array('/path/to/models/')), App::RESET); will setup the path as the only valid path for searching models` - * - * `App::build(array('View/Helper' => array('/path/to/helpers/', '/another/path/'))); will setup multiple search paths for helpers` - * - * `App::build(array('Service' => array('%s' . 'Service' . DS)), App::REGISTER); will register new package 'Service'` - * - * If reset is set to true, all loaded plugins will be forgotten and they will be needed to be loaded again. - * - * @param array $paths associative array with package names as keys and a list of directories for new search paths - * @param mixed $mode App::RESET will set paths, App::APPEND with append paths, App::PREPEND will prepend paths (default) - * App::REGISTER will register new packages and their paths, %s in path will be replaced by APP path - * @return void - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::build - */ - public static function build($paths = array(), $mode = App::PREPEND) { - //Provides Backwards compatibility for old-style package names - $legacyPaths = array(); - foreach ($paths as $type => $path) { - if (!empty(self::$legacy[$type])) { - $type = self::$legacy[$type]; - } - $legacyPaths[$type] = $path; - } - $paths = $legacyPaths; - - if ($mode === App::RESET) { - foreach ($paths as $type => $new) { - self::$_packages[$type] = (array)$new; - self::objects($type, null, false); - } - return; - } - - if (empty($paths)) { - self::$_packageFormat = null; - } - - $packageFormat = self::_packageFormat(); - - if ($mode === App::REGISTER) { - foreach ($paths as $package => $formats) { - if (empty($packageFormat[$package])) { - $packageFormat[$package] = $formats; - } else { - $formats = array_merge($packageFormat[$package], $formats); - $packageFormat[$package] = array_values(array_unique($formats)); - } - } - self::$_packageFormat = $packageFormat; - } - - $defaults = array(); - foreach ($packageFormat as $package => $format) { - foreach ($format as $f) { - $defaults[$package][] = sprintf($f, APP); - } - } - - if (empty($paths)) { - self::$_packages = $defaults; - return; - } - - if ($mode === App::REGISTER) { - $paths = array(); - } - - foreach ($defaults as $type => $default) { - if (!empty(self::$_packages[$type])) { - $path = self::$_packages[$type]; - } else { - $path = $default; - } - - if (!empty($paths[$type])) { - $newPath = (array)$paths[$type]; - - if ($mode === App::PREPEND) { - $path = array_merge($newPath, $path); - } else { - $path = array_merge($path, $newPath); - } - - $path = array_values(array_unique($path)); - } - - self::$_packages[$type] = $path; - } - } - -/** - * Gets the path that a plugin is on. Searches through the defined plugin paths. - * - * Usage: - * - * `App::pluginPath('MyPlugin'); will return the full path to 'MyPlugin' plugin'` - * - * @param string $plugin CamelCased/lower_cased plugin name to find the path of. - * @return string full path to the plugin. - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::pluginPath - */ - public static function pluginPath($plugin) { - return CakePlugin::path($plugin); - } - -/** - * Finds the path that a theme is on. Searches through the defined theme paths. - * - * Usage: - * - * `App::themePath('MyTheme'); will return the full path to the 'MyTheme' theme` - * - * @param string $theme theme name to find the path of. - * @return string full path to the theme. - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::themePath - */ - public static function themePath($theme) { - $themeDir = 'Themed' . DS . Inflector::camelize($theme); - foreach (self::$_packages['View'] as $path) { - if (is_dir($path . $themeDir)) { - return $path . $themeDir . DS; - } - } - return self::$_packages['View'][0] . $themeDir . DS; - } - -/** - * Returns the full path to a package inside the CakePHP core - * - * Usage: - * - * `App::core('Cache/Engine'); will return the full path to the cache engines package` - * - * @param string $type - * @return array full path to package - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::core - */ - public static function core($type) { - return array(CAKE . str_replace('/', DS, $type) . DS); - } - -/** - * Returns an array of objects of the given type. - * - * Example usage: - * - * `App::objects('plugin');` returns `array('DebugKit', 'Blog', 'User');` - * - * `App::objects('Controller');` returns `array('PagesController', 'BlogController');` - * - * You can also search only within a plugin's objects by using the plugin dot - * syntax. - * - * `App::objects('MyPlugin.Model');` returns `array('MyPluginPost', 'MyPluginComment');` - * - * When scanning directories, files and directories beginning with `.` will be excluded as these - * are commonly used by version control systems. - * - * @param string $type Type of object, i.e. 'Model', 'Controller', 'View/Helper', 'file', 'class' or 'plugin' - * @param mixed $path Optional Scan only the path given. If null, paths for the chosen type will be used. - * @param boolean $cache Set to false to rescan objects of the chosen type. Defaults to true. - * @return mixed Either false on incorrect / miss. Or an array of found objects. - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::objects - */ - public static function objects($type, $path = null, $cache = true) { - $extension = '/\.php$/'; - $includeDirectories = false; - $name = $type; - - if ($type === 'plugin') { - $type = 'plugins'; - } - - if ($type == 'plugins') { - $extension = '/.*/'; - $includeDirectories = true; - } - - list($plugin, $type) = pluginSplit($type); - - if (isset(self::$legacy[$type . 's'])) { - $type = self::$legacy[$type . 's']; - } - - if ($type === 'file' && !$path) { - return false; - } elseif ($type === 'file') { - $extension = '/\.php$/'; - $name = $type . str_replace(DS, '', $path); - } - - if (empty(self::$_objects) && $cache === true) { - self::$_objects = Cache::read('object_map', '_cake_core_'); - } - - $cacheLocation = empty($plugin) ? 'app' : $plugin; - - if ($cache !== true || !isset(self::$_objects[$cacheLocation][$name])) { - $objects = array(); - - if (empty($path)) { - $path = self::path($type, $plugin); - } - - foreach ((array)$path as $dir) { - if ($dir != APP && is_dir($dir)) { - $files = new RegexIterator(new DirectoryIterator($dir), $extension); - foreach ($files as $file) { - $fileName = basename($file); - if (!$file->isDot() && $fileName[0] !== '.') { - $isDir = $file->isDir(); - if ($isDir && $includeDirectories) { - $objects[] = $fileName; - } elseif (!$includeDirectories && !$isDir) { - $objects[] = substr($fileName, 0, -4); - } - } - } - } - } - - if ($type !== 'file') { - foreach ($objects as $key => $value) { - $objects[$key] = Inflector::camelize($value); - } - } - - sort($objects); - if ($plugin) { - return $objects; - } - - self::$_objects[$cacheLocation][$name] = $objects; - if ($cache) { - self::$_objectCacheChange = true; - } - } - - return self::$_objects[$cacheLocation][$name]; - } - -/** - * Declares a package for a class. This package location will be used - * by the automatic class loader if the class is tried to be used - * - * Usage: - * - * `App::uses('MyCustomController', 'Controller');` will setup the class to be found under Controller package - * - * `App::uses('MyHelper', 'MyPlugin.View/Helper');` will setup the helper class to be found in plugin's helper package - * - * @param string $className the name of the class to configure package for - * @param string $location the package name - * @return void - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::uses - */ - public static function uses($className, $location) { - self::$_classMap[$className] = $location; - } - -/** - * Method to handle the automatic class loading. It will look for each class' package - * defined using App::uses() and with this information it will resolve the package name to a full path - * to load the class from. File name for each class should follow the class name. For instance, - * if a class is name `MyCustomClass` the file name should be `MyCustomClass.php` - * - * @param string $className the name of the class to load - * @return boolean - */ - public static function load($className) { - if (!isset(self::$_classMap[$className])) { - return false; - } - - $parts = explode('.', self::$_classMap[$className], 2); - list($plugin, $package) = count($parts) > 1 ? $parts : array(null, current($parts)); - - if ($file = self::_mapped($className, $plugin)) { - return include $file; - } - $paths = self::path($package, $plugin); - - if (empty($plugin)) { - $appLibs = empty(self::$_packages['Lib']) ? APPLIBS : current(self::$_packages['Lib']); - $paths[] = $appLibs . $package . DS; - $paths[] = APP . $package . DS; - $paths[] = CAKE . $package . DS; - } else { - $pluginPath = self::pluginPath($plugin); - $paths[] = $pluginPath . 'Lib' . DS . $package . DS; - $paths[] = $pluginPath . $package . DS; - } - foreach ($paths as $path) { - $file = $path . $className . '.php'; - if (file_exists($file)) { - self::_map($file, $className, $plugin); - return include $file; - } - } - - return false; - } - -/** - * Returns the package name where a class was defined to be located at - * - * @param string $className name of the class to obtain the package name from - * @return string package name or null if not declared - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#App::location - */ - public static function location($className) { - if (!empty(self::$_classMap[$className])) { - return self::$_classMap[$className]; - } - return null; - } - -/** - * Finds classes based on $name or specific file(s) to search. Calling App::import() will - * not construct any classes contained in the files. It will only find and require() the file. - * - * @link http://book.cakephp.org/2.0/en/core-utility-libraries/app.html#including-files-with-app-import - * @param mixed $type The type of Class if passed as a string, or all params can be passed as - * an single array to $type, - * @param string $name Name of the Class or a unique name for the file - * @param mixed $parent boolean true if Class Parent should be searched, accepts key => value - * array('parent' => $parent ,'file' => $file, 'search' => $search, 'ext' => '$ext'); - * $ext allows setting the extension of the file name - * based on Inflector::underscore($name) . ".$ext"; - * @param array $search paths to search for files, array('path 1', 'path 2', 'path 3'); - * @param string $file full name of the file to search for including extension - * @param boolean $return Return the loaded file, the file must have a return - * statement in it to work: return $variable; - * @return boolean true if Class is already in memory or if file is found and loaded, false if not - */ - public static function import($type = null, $name = null, $parent = true, $search = array(), $file = null, $return = false) { - $ext = null; - - if (is_array($type)) { - extract($type, EXTR_OVERWRITE); - } - - if (is_array($parent)) { - extract($parent, EXTR_OVERWRITE); - } - - if ($name == null && $file == null) { - return false; - } - - if (is_array($name)) { - foreach ($name as $class) { - if (!App::import(compact('type', 'parent', 'search', 'file', 'return') + array('name' => $class))) { - return false; - } - } - return true; - } - - $originalType = strtolower($type); - $specialPackage = in_array($originalType, array('file', 'vendor')); - if (!$specialPackage && isset(self::$legacy[$originalType . 's'])) { - $type = self::$legacy[$originalType . 's']; - } - list($plugin, $name) = pluginSplit($name); - if (!empty($plugin)) { - if (!CakePlugin::loaded($plugin)) { - return false; - } - } - - if (!$specialPackage) { - return self::_loadClass($name, $plugin, $type, $originalType, $parent); - } - - if ($originalType == 'file' && !empty($file)) { - return self::_loadFile($name, $plugin, $search, $file, $return); - } - - if ($originalType == 'vendor') { - return self::_loadVendor($name, $plugin, $file, $ext); - } - - return false; - } - -/** - * Helper function to include classes - * This is a compatibility wrapper around using App::uses() and automatic class loading - * - * @param string $name unique name of the file for identifying it inside the application - * @param string $plugin camel cased plugin name if any - * @param string $type name of the packed where the class is located - * @param string $originalType type name as supplied initially by the user - * @param boolean $parent whether to load the class parent or not - * @return boolean true indicating the successful load and existence of the class - */ - protected static function _loadClass($name, $plugin, $type, $originalType, $parent) { - if ($type == 'Console/Command' && $name == 'Shell') { - $type = 'Console'; - } elseif (isset(self::$types[$originalType]['suffix'])) { - $suffix = self::$types[$originalType]['suffix']; - $name .= ($suffix == $name) ? '' : $suffix; - } - if ($parent && isset(self::$types[$originalType]['extends'])) { - $extends = self::$types[$originalType]['extends']; - $extendType = $type; - if (strpos($extends, '/') !== false) { - $parts = explode('/', $extends); - $extends = array_pop($parts); - $extendType = implode('/', $parts); - } - App::uses($extends, $extendType); - if ($plugin && in_array($originalType, array('controller', 'model'))) { - App::uses($plugin . $extends, $plugin . '.' . $type); - } - } - if ($plugin) { - $plugin .= '.'; - } - $name = Inflector::camelize($name); - App::uses($name, $plugin . $type); - return class_exists($name); - } - -/** - * Helper function to include single files - * - * @param string $name unique name of the file for identifying it inside the application - * @param string $plugin camel cased plugin name if any - * @param array $search list of paths to search the file into - * @param string $file filename if known, the $name param will be used otherwise - * @param boolean $return whether this function should return the contents of the file after being parsed by php or just a success notice - * @return mixed if $return contents of the file after php parses it, boolean indicating success otherwise - */ - protected static function _loadFile($name, $plugin, $search, $file, $return) { - $mapped = self::_mapped($name, $plugin); - if ($mapped) { - $file = $mapped; - } elseif (!empty($search)) { - foreach ($search as $path) { - $found = false; - if (file_exists($path . $file)) { - $file = $path . $file; - $found = true; - break; - } - if (empty($found)) { - $file = false; - } - } - } - if (!empty($file) && file_exists($file)) { - self::_map($file, $name, $plugin); - $returnValue = include $file; - if ($return) { - return $returnValue; - } - return (bool)$returnValue; - } - return false; - } - -/** - * Helper function to load files from vendors folders - * - * @param string $name unique name of the file for identifying it inside the application - * @param string $plugin camel cased plugin name if any - * @param string $file file name if known - * @param string $ext file extension if known - * @return boolean true if the file was loaded successfully, false otherwise - */ - protected static function _loadVendor($name, $plugin, $file, $ext) { - if ($mapped = self::_mapped($name, $plugin)) { - return (bool)include_once $mapped; - } - $fileTries = array(); - $paths = ($plugin) ? App::path('vendors', $plugin) : App::path('vendors'); - if (empty($ext)) { - $ext = 'php'; - } - if (empty($file)) { - $fileTries[] = $name . '.' . $ext; - $fileTries[] = Inflector::underscore($name) . '.' . $ext; - } else { - $fileTries[] = $file; - } - - foreach ($fileTries as $file) { - foreach ($paths as $path) { - if (file_exists($path . $file)) { - self::_map($path . $file, $name, $plugin); - return (bool)include $path . $file; - } - } - } - return false; - } - -/** - * Initializes the cache for App, registers a shutdown function. - * - * @return void - */ - public static function init() { - self::$_map += (array)Cache::read('file_map', '_cake_core_'); - self::$_objects += (array)Cache::read('object_map', '_cake_core_'); - register_shutdown_function(array('App', 'shutdown')); - } - -/** - * Maps the $name to the $file. - * - * @param string $file full path to file - * @param string $name unique name for this map - * @param string $plugin camelized if object is from a plugin, the name of the plugin - * @return void - */ - protected static function _map($file, $name, $plugin = null) { - $key = $name; - if ($plugin) { - $key = 'plugin.' . $name; - } - if ($plugin && empty(self::$_map[$name])) { - self::$_map[$key] = $file; - } - if (!$plugin && empty(self::$_map['plugin.' . $name])) { - self::$_map[$key] = $file; - } - if (!self::$bootstrapping) { - self::$_cacheChange = true; - } - } - -/** - * Returns a file's complete path. - * - * @param string $name unique name - * @param string $plugin camelized if object is from a plugin, the name of the plugin - * @return mixed file path if found, false otherwise - */ - protected static function _mapped($name, $plugin = null) { - $key = $name; - if ($plugin) { - $key = 'plugin.' . $name; - } - return isset(self::$_map[$key]) ? self::$_map[$key] : false; - } - -/** - * Sets then returns the templates for each customizable package path - * - * @return array templates for each customizable package path - */ - protected static function _packageFormat() { - if (empty(self::$_packageFormat)) { - self::$_packageFormat = array( - 'Model' => array( - '%s' . 'Model' . DS - ), - 'Model/Behavior' => array( - '%s' . 'Model' . DS . 'Behavior' . DS - ), - 'Model/Datasource' => array( - '%s' . 'Model' . DS . 'Datasource' . DS - ), - 'Model/Datasource/Database' => array( - '%s' . 'Model' . DS . 'Datasource' . DS . 'Database' . DS - ), - 'Model/Datasource/Session' => array( - '%s' . 'Model' . DS . 'Datasource' . DS . 'Session' . DS - ), - 'Controller' => array( - '%s' . 'Controller' . DS - ), - 'Controller/Component' => array( - '%s' . 'Controller' . DS . 'Component' . DS - ), - 'Controller/Component/Auth' => array( - '%s' . 'Controller' . DS . 'Component' . DS . 'Auth' . DS - ), - 'Controller/Component/Acl' => array( - '%s' . 'Controller' . DS . 'Component' . DS . 'Acl' . DS - ), - 'View' => array( - '%s' . 'View' . DS - ), - 'View/Helper' => array( - '%s' . 'View' . DS . 'Helper' . DS - ), - 'Console' => array( - '%s' . 'Console' . DS - ), - 'Console/Command' => array( - '%s' . 'Console' . DS . 'Command' . DS - ), - 'Console/Command/Task' => array( - '%s' . 'Console' . DS . 'Command' . DS . 'Task' . DS - ), - 'Lib' => array( - '%s' . 'Lib' . DS - ), - 'Locale' => array( - '%s' . 'Locale' . DS - ), - 'Vendor' => array( - '%s' . 'Vendor' . DS, - dirname(dirname(CAKE)) . DS . 'vendors' . DS, - ), - 'Plugin' => array( - APP . 'Plugin' . DS, - dirname(dirname(CAKE)) . DS . 'plugins' . DS - ) - ); - } - - return self::$_packageFormat; - } - -/** - * Object destructor. - * - * Writes cache file if changes have been made to the $_map - * - * @return void - */ - public static function shutdown() { - if (self::$_cacheChange) { - Cache::write('file_map', array_filter(self::$_map), '_cake_core_'); - } - if (self::$_objectCacheChange) { - Cache::write('object_map', self::$_objects, '_cake_core_'); - } - } - -} diff --git a/lib/Cake/Core/CakePlugin.php b/lib/Cake/Core/CakePlugin.php deleted file mode 100644 index 471057b5d5a..00000000000 --- a/lib/Cake/Core/CakePlugin.php +++ /dev/null @@ -1,228 +0,0 @@ - true, 'routes' => true))` will load the bootstrap.php and routes.php files - * `CakePlugin::load('DebugKit', array('bootstrap' => false, 'routes' => true))` will load routes.php file but not bootstrap.php - * `CakePlugin::load('DebugKit', array('bootstrap' => array('config1', 'config2')))` will load config1.php and config2.php files - * `CakePlugin::load('DebugKit', array('bootstrap' => 'aCallableMethod'))` will run the aCallableMethod function to initialize it - * - * Bootstrap initialization functions can be expressed as a PHP callback type, including closures. Callbacks will receive two - * parameters (plugin name, plugin configuration) - * - * It is also possible to load multiple plugins at once. Examples: - * - * `CakePlugin::load(array('DebugKit', 'ApiGenerator'))` will load the DebugKit and ApiGenerator plugins - * `CakePlugin::load(array('DebugKit', 'ApiGenerator'), array('bootstrap' => true))` will load bootstrap file for both plugins - * - * {{{ - * CakePlugin::load(array( - * 'DebugKit' => array('routes' => true), - * 'ApiGenerator' - * ), array('bootstrap' => true)) - * }}} - * - * Will only load the bootstrap for ApiGenerator and only the routes for DebugKit - * - * @param mixed $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 MissingPluginException if the folder for the plugin to be loaded is not found - * @return void - */ - public static function load($plugin, $config = array()) { - if (is_array($plugin)) { - foreach ($plugin as $name => $conf) { - list($name, $conf) = (is_numeric($name)) ? array($conf, $config) : array($name, $conf); - self::load($name, $conf); - } - return; - } - $config += array('bootstrap' => false, 'routes' => false); - if (empty($config['path'])) { - foreach (App::path('plugins') as $path) { - if (is_dir($path . $plugin)) { - self::$_plugins[$plugin] = $config + array('path' => $path . $plugin . DS); - break; - } - - //Backwards compatibility to make easier to migrate to 2.0 - $underscored = Inflector::underscore($plugin); - if (is_dir($path . $underscored)) { - self::$_plugins[$plugin] = $config + array('path' => $path . $underscored . DS); - break; - } - } - } else { - self::$_plugins[$plugin] = $config; - } - - if (empty(self::$_plugins[$plugin]['path'])) { - throw new MissingPluginException(array('plugin' => $plugin)); - } - if (!empty(self::$_plugins[$plugin]['bootstrap'])) { - self::bootstrap($plugin); - } - } - -/** - * Will load all the plugins located in the configured plugins folders - * 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: - * - * {{{ - * CakePlugin::loadAll(array( - * array('bootstrap' => true), - * 'DebugKit' => array('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. - * - * @param array $options - * @return void - */ - public static function loadAll($options = array()) { - $plugins = App::objects('plugins'); - foreach ($plugins as $p) { - $opts = isset($options[$p]) ? $options[$p] : null; - if ($opts === null && isset($options[0])) { - $opts = $options[0]; - } - self::load($p, (array)$opts); - } - } - -/** - * Returns the filesystem path for a plugin - * - * @param string $plugin name of the plugin in CamelCase format - * @return string path to the plugin folder - * @throws MissingPluginException if the folder for plugin was not found or plugin has not been loaded - */ - public static function path($plugin) { - if (empty(self::$_plugins[$plugin])) { - throw new MissingPluginException(array('plugin' => $plugin)); - } - return self::$_plugins[$plugin]['path']; - } - -/** - * Loads the bootstrapping files for a plugin, or calls the initialization setup in the configuration - * - * @param string $plugin name of the plugin - * @return mixed - * @see CakePlugin::load() for examples of bootstrap configuration - */ - public static function bootstrap($plugin) { - $config = self::$_plugins[$plugin]; - if ($config['bootstrap'] === false) { - return false; - } - if (is_callable($config['bootstrap'])) { - return call_user_func_array($config['bootstrap'], array($plugin, $config)); - } - - $path = self::path($plugin); - if ($config['bootstrap'] === true) { - return include $path . 'Config' . DS . 'bootstrap.php'; - } - - $bootstrap = (array)$config['bootstrap']; - foreach ($bootstrap as $file) { - include $path . 'Config' . DS . $file . '.php'; - } - - return true; - } - -/** - * Loads the routes file for a plugin, or all plugins configured to load their respective routes file - * - * @param string $plugin name of the plugin, if null will operate on all plugins having enabled the - * loading of routes files - * @return boolean - */ - public static function routes($plugin = null) { - if ($plugin === null) { - foreach (self::loaded() as $p) { - self::routes($p); - } - return true; - } - $config = self::$_plugins[$plugin]; - if ($config['routes'] === false) { - return false; - } - return (bool)include self::path($plugin) . 'Config' . DS . 'routes.php'; - } - -/** - * Returns true if the plugin $plugin is already loaded - * If plugin is null, it will return a list of all loaded plugins - * - * @param string $plugin - * @return mixed boolean true if $plugin is already loaded. - * If $plugin is null, returns a list of plugins that have been loaded - */ - public static function loaded($plugin = null) { - if ($plugin) { - return isset(self::$_plugins[$plugin]); - } - $return = array_keys(self::$_plugins); - sort($return); - return $return; - } - -/** - * Forgets a loaded plugin or all of them if first parameter is null - * - * @param string $plugin name of the plugin to forget - * @return void - */ - public static function unload($plugin = null) { - if (is_null($plugin)) { - self::$_plugins = array(); - } else { - unset(self::$_plugins[$plugin]); - } - } - -} diff --git a/lib/Cake/Core/Configure.php b/lib/Cake/Core/Configure.php deleted file mode 100644 index 887b2b1e1cd..00000000000 --- a/lib/Cake/Core/Configure.php +++ /dev/null @@ -1,365 +0,0 @@ - 0 - ); - -/** - * Configured reader classes, used to load config files from resources - * - * @var array - * @see Configure::load() - */ - protected static $_readers = array(); - -/** - * Initializes configure and runs the bootstrap process. - * Bootstrapping includes the following steps: - * - * - Setup App array in Configure. - * - Include app/Config/core.php. - * - Configure core cache configurations. - * - Load App cache files. - * - Include app/Config/bootstrap.php. - * - Setup error/exception handlers. - * - * @param boolean $boot - * @return void - */ - public static function bootstrap($boot = true) { - if ($boot) { - self::write('App', array( - 'base' => false, - 'baseUrl' => false, - 'dir' => APP_DIR, - 'webroot' => WEBROOT_DIR, - 'www_root' => WWW_ROOT - )); - - if (!include APP . 'Config' . DS . 'core.php') { - trigger_error(__d('cake_dev', "Can't find application core file. Please create %score.php, and make sure it is readable by PHP.", APP . 'Config' . DS), E_USER_ERROR); - } - App::$bootstrapping = false; - App::init(); - App::build(); - - $exception = array( - 'handler' => 'ErrorHandler::handleException', - ); - $error = array( - 'handler' => 'ErrorHandler::handleError', - 'level' => E_ALL & ~E_DEPRECATED, - ); - self::_setErrorHandlers($error, $exception); - - if (!include APP . 'Config' . DS . 'bootstrap.php') { - trigger_error(__d('cake_dev', "Can't find application bootstrap file. Please create %sbootstrap.php, and make sure it is readable by PHP.", APP . 'Config' . DS), E_USER_ERROR); - } - restore_error_handler(); - - self::_setErrorHandlers( - self::$_values['Error'], - self::$_values['Exception'] - ); - unset($error, $exception); - } - } - -/** - * Used to store a dynamic variable in Configure. - * - * Usage: - * {{{ - * Configure::write('One.key1', 'value of the Configure::One[key1]'); - * Configure::write(array('One.key1' => 'value of the Configure::One[key1]')); - * Configure::write('One', array( - * 'key1' => 'value of the Configure::One[key1]', - * 'key2' => 'value of the Configure::One[key2]' - * ); - * - * Configure::write(array( - * 'One.key1' => 'value of the Configure::One[key1]', - * 'One.key2' => 'value of the Configure::One[key2]' - * )); - * }}} - * - * @link http://book.cakephp.org/2.0/en/development/configuration.html#Configure::write - * @param array $config Name of var to write - * @param mixed $value Value to set for var - * @return boolean True if write was successful - */ - public static function write($config, $value = null) { - if (!is_array($config)) { - $config = array($config => $value); - } - - foreach ($config as $name => $value) { - $pointer = &self::$_values; - foreach (explode('.', $name) as $key) { - $pointer = &$pointer[$key]; - } - $pointer = $value; - unset($pointer); - } - - if (isset($config['debug']) && function_exists('ini_set')) { - if (self::$_values['debug']) { - ini_set('display_errors', 1); - } else { - ini_set('display_errors', 0); - } - } - return true; - } - -/** - * Used to read information stored in Configure. Its not - * possible to store `null` values in Configure. - * - * Usage: - * {{{ - * Configure::read('Name'); will return all values for Name - * Configure::read('Name.key'); will return only the value of Configure::Name[key] - * }}} - * - * @linkhttp://book.cakephp.org/2.0/en/development/configuration.html#Configure::read - * @param string $var Variable to obtain. Use '.' to access array elements. - * @return mixed value stored in configure, or null. - */ - public static function read($var = null) { - if ($var === null) { - return self::$_values; - } - if (isset(self::$_values[$var])) { - return self::$_values[$var]; - } - $pointer = &self::$_values; - foreach (explode('.', $var) as $key) { - if (isset($pointer[$key])) { - $pointer = &$pointer[$key]; - } else { - return null; - } - } - return $pointer; - } - -/** - * Used to delete a variable from Configure. - * - * Usage: - * {{{ - * Configure::delete('Name'); will delete the entire Configure::Name - * Configure::delete('Name.key'); will delete only the Configure::Name[key] - * }}} - * - * @link http://book.cakephp.org/2.0/en/development/configuration.html#Configure::delete - * @param string $var the var to be deleted - * @return void - */ - public static function delete($var = null) { - $keys = explode('.', $var); - $last = array_pop($keys); - $pointer = &self::$_values; - foreach ($keys as $key) { - $pointer = &$pointer[$key]; - } - unset($pointer[$last]); - } - -/** - * Add a new reader to Configure. Readers allow you to read configuration - * files in various formats/storage locations. CakePHP comes with two built-in readers - * PhpReader and IniReader. You can also implement your own reader classes in your application. - * - * To add a new reader to Configure: - * - * `Configure::config('ini', new IniReader());` - * - * @param string $name The name of the reader being configured. This alias is used later to - * read values from a specific reader. - * @param ConfigReaderInterface $reader The reader to append. - * @return void - */ - public static function config($name, ConfigReaderInterface $reader) { - self::$_readers[$name] = $reader; - } - -/** - * Gets the names of the configured reader objects. - * - * @param string $name - * @return array Array of the configured reader objects. - */ - public static function configured($name = null) { - if ($name) { - return isset(self::$_readers[$name]); - } - return array_keys(self::$_readers); - } - -/** - * Remove a configured reader. This will unset the reader - * and make any future attempts to use it cause an Exception. - * - * @param string $name Name of the reader to drop. - * @return boolean Success - */ - public static function drop($name) { - if (!isset(self::$_readers[$name])) { - return false; - } - unset(self::$_readers[$name]); - return true; - } - -/** - * Loads stored configuration information from a resource. You can add - * config file resource readers with `Configure::config()`. - * - * Loaded configuration information will be merged with the current - * runtime configuration. You can load configuration files from plugins - * by preceding the filename with the plugin name. - * - * `Configure::load('Users.user', 'default')` - * - * Would load the 'user' config file using the default config reader. You can load - * app config files by giving the name of the resource you want loaded. - * - * `Configure::load('setup', 'default');` - * - * If using `default` config and no reader has been configured for it yet, - * one will be automatically created using PhpReader - * - * @link http://book.cakephp.org/2.0/en/development/configuration.html#Configure::load - * @param string $key name of configuration resource to load. - * @param string $config Name of the configured reader to use to read the resource identified by $key. - * @param boolean $merge if config files should be merged instead of simply overridden - * @return mixed false if file not found, void if load successful. - * @throws ConfigureException Will throw any exceptions the reader raises. - */ - public static function load($key, $config = 'default', $merge = true) { - if (!isset(self::$_readers[$config])) { - if ($config === 'default') { - App::uses('PhpReader', 'Configure'); - self::$_readers[$config] = new PhpReader(); - } else { - return false; - } - } - $values = self::$_readers[$config]->read($key); - - if ($merge) { - $keys = array_keys($values); - foreach ($keys as $key) { - if (($c = self::read($key)) && is_array($values[$key]) && is_array($c)) { - $values[$key] = Set::merge($c, $values[$key]); - } - } - } - - return self::write($values); - } - -/** - * Used to determine the current version of CakePHP. - * - * Usage `Configure::version();` - * - * @return string Current version of CakePHP - */ - public static function version() { - if (!isset(self::$_values['Cake']['version'])) { - require CAKE . 'Config' . DS . 'config.php'; - self::write($config); - } - return self::$_values['Cake']['version']; - } - -/** - * Used to write runtime configuration into Cache. Stored runtime configuration can be - * restored using `Configure::restore()`. These methods can be used to enable configuration managers - * frontends, or other GUI type interfaces for configuration. - * - * @param string $name The storage name for the saved configuration. - * @param string $cacheConfig The cache configuration to save into. Defaults to 'default' - * @param array $data Either an array of data to store, or leave empty to store all values. - * @return boolean Success - */ - public static function store($name, $cacheConfig = 'default', $data = null) { - if ($data === null) { - $data = self::$_values; - } - return Cache::write($name, $data, $cacheConfig); - } - -/** - * Restores configuration data stored in the Cache into configure. Restored - * values will overwrite existing ones. - * - * @param string $name Name of the stored config file to load. - * @param string $cacheConfig Name of the Cache configuration to read from. - * @return boolean Success. - */ - public static function restore($name, $cacheConfig = 'default') { - $values = Cache::read($name, $cacheConfig); - if ($values) { - return self::write($values); - } - return false; - } - -/** - * Set the error and exception handlers. - * - * @param array $error The Error handling configuration. - * @param array $exception The exception handling configuration. - * @return void - */ - protected static function _setErrorHandlers($error, $exception) { - $level = -1; - if (isset($error['level'])) { - error_reporting($error['level']); - $level = $error['level']; - } - if (!empty($error['handler'])) { - set_error_handler($error['handler'], $level); - } - if (!empty($exception['handler'])) { - set_exception_handler($exception['handler']); - } - } -} diff --git a/lib/Cake/Core/Object.php b/lib/Cake/Core/Object.php deleted file mode 100644 index 34ea4fbe135..00000000000 --- a/lib/Cake/Core/Object.php +++ /dev/null @@ -1,205 +0,0 @@ - 0, 'return' => 1, 'bare' => 1, 'requested' => 1), $extra); - $data = isset($extra['data']) ? $extra['data'] : null; - unset($extra['data']); - - if (is_string($url) && strpos($url, FULL_BASE_URL) === 0) { - $url = Router::normalize(str_replace(FULL_BASE_URL, '', $url)); - } - if (is_string($url)) { - $request = new CakeRequest($url); - } elseif (is_array($url)) { - $params = $url + array('pass' => array(), 'named' => array(), 'base' => false); - $params = array_merge($params, $extra); - $request = new CakeRequest(Router::reverse($params), false); - } - if (isset($data)) { - $request->data = $data; - } - $dispatcher = new Dispatcher(); - $result = $dispatcher->dispatch($request, new CakeResponse(), $extra); - Router::popRequest(); - return $result; - } - -/** - * Calls a method on this object with the given parameters. Provides an OO wrapper - * for `call_user_func_array` - * - * @param string $method Name of the method to call - * @param array $params Parameter list to use when calling $method - * @return mixed Returns the result of the method call - */ - public function dispatchMethod($method, $params = array()) { - switch (count($params)) { - case 0: - return $this->{$method}(); - case 1: - return $this->{$method}($params[0]); - case 2: - return $this->{$method}($params[0], $params[1]); - case 3: - return $this->{$method}($params[0], $params[1], $params[2]); - case 4: - return $this->{$method}($params[0], $params[1], $params[2], $params[3]); - case 5: - return $this->{$method}($params[0], $params[1], $params[2], $params[3], $params[4]); - default: - return call_user_func_array(array(&$this, $method), $params); - break; - } - } - -/** - * Stop execution of the current script. Wraps exit() making - * testing easier. - * - * @param integer|string $status see http://php.net/exit for values - * @return void - */ - protected function _stop($status = 0) { - exit($status); - } - -/** - * Convenience method to write a message to CakeLog. See CakeLog::write() - * for more information on writing to logs. - * - * @param string $msg Log message - * @param integer $type Error type constant. Defined in app/Config/core.php. - * @return boolean Success of log write - */ - public function log($msg, $type = LOG_ERROR) { - App::uses('CakeLog', 'Log'); - if (!is_string($msg)) { - $msg = print_r($msg, true); - } - return CakeLog::write($type, $msg); - } - -/** - * Allows setting of multiple properties of the object in a single line of code. Will only set - * properties that are part of a class declaration. - * - * @param array $properties An associative array containing properties and corresponding values. - * @return void - */ - protected function _set($properties = array()) { - if (is_array($properties) && !empty($properties)) { - $vars = get_object_vars($this); - foreach ($properties as $key => $val) { - if (array_key_exists($key, $vars)) { - $this->{$key} = $val; - } - } - } - } - -/** - * Merges this objects $property with the property in $class' definition. - * This classes value for the property will be merged on top of $class' - * - * This provides some of the DRY magic CakePHP provides. If you want to shut it off, redefine - * this method as an empty function. - * - * @param array $properties The name of the properties to merge. - * @param string $class The class to merge the property with. - * @param boolean $normalize Set to true to run the properties through Set::normalize() before merging. - * @return void - */ - protected function _mergeVars($properties, $class, $normalize = true) { - $classProperties = get_class_vars($class); - foreach ($properties as $var) { - if ( - isset($classProperties[$var]) && - !empty($classProperties[$var]) && - is_array($this->{$var}) && - $this->{$var} != $classProperties[$var] - ) { - if ($normalize) { - $classProperties[$var] = Set::normalize($classProperties[$var]); - $this->{$var} = Set::normalize($this->{$var}); - } - $this->{$var} = Set::merge($classProperties[$var], $this->{$var}); - } - } - } - -} diff --git a/lib/Cake/Error/ErrorHandler.php b/lib/Cake/Error/ErrorHandler.php deleted file mode 100644 index 55c78bebe10..00000000000 --- a/lib/Cake/Error/ErrorHandler.php +++ /dev/null @@ -1,228 +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. - * - * - Set Configure::write('Exception.handler', 'YourClass::yourMethod'); - * - Create AppController::appError(); - * - Set Configure::write('Exception.renderer', 'YourClass'); - * - * #### Create your own Exception handler with `Exception.handler` - * - * This gives you full control over the exception handling process. The class you choose should be - * loaded in your app/Config/bootstrap.php, so its available to handle any exceptions. You can - * define the handler as any callback type. Using Exception.handler overrides all other exception - * handling settings and logic. - * - * #### Using `AppController::appError();` - * - * This controller method is called instead of the default exception rendering. It receives the - * thrown exception as its only argument. You should implement your error handling in that method. - * Using AppController::appError(), will supersede any configuration for Exception.renderer. - * - * #### Using a custom renderer with `Exception.renderer` - * - * If you don't want to take control of the exception handling, but want to change how exceptions are - * rendered you can use `Exception.renderer` to choose a class to render exception pages. By default - * `ExceptionRenderer` is used. Your custom exception renderer class should be placed in app/Lib/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 `Exception.log` to true in your core.php. - * Enabling this will log every exception to CakeLog 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 CakeLog. You can control which errors are logged by setting - * `Error.level` in your core.php. - * - * #### Logging errors - * - * When ErrorHandler is used for handling errors, you can enable error logging by setting `Error.log` 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 `Error.level`. Setting this - * to one or a combination of a few of the E_* constants will only enable the specified errors. - * - * e.g. `Configure::write('Error.level', E_ALL & ~E_NOTICE);` - * - * Would enable handling for all non Notice errors. - * - * @package Cake.Error - * @see ExceptionRenderer for more information on how to customize exception rendering. - */ -class ErrorHandler { - -/** - * Set as the default exception handler by the CakePHP bootstrap process. - * - * This will either use custom exception renderer class if configured, - * or use the default ExceptionRenderer. - * - * @param Exception $exception - * @return void - * @see http://php.net/manual/en/function.set-exception-handler.php - */ - public static function handleException(Exception $exception) { - $config = Configure::read('Exception'); - if (!empty($config['log'])) { - $message = sprintf("[%s] %s\n%s", - get_class($exception), - $exception->getMessage(), - $exception->getTraceAsString() - ); - CakeLog::write(LOG_ERR, $message); - } - $renderer = $config['renderer']; - if ($renderer !== 'ExceptionRenderer') { - list($plugin, $renderer) = pluginSplit($renderer, true); - App::uses($renderer, $plugin . 'Error'); - } - try { - $error = new $renderer($exception); - $error->render(); - } catch (Exception $e) { - set_error_handler(Configure::read('Error.handler')); // Should be using configured ErrorHandler - Configure::write('Error.trace', false); // trace is useless here since it's internal - $message = sprintf("[%s] %s\n%s", // Keeping same message format - get_class($e), - $e->getMessage(), - $e->getTraceAsString() - ); - trigger_error($message, E_USER_ERROR); - } - } - -/** - * Set as the default error handler by CakePHP. Use Configure::write('Error.handler', $callback), to use your own - * error handling methods. This function will use Debugger to display errors when debug > 0. And - * will log errors to CakeLog, when debug == 0. - * - * You can use Configure::write('Error.level', $value); to set what type of errors will be handled here. - * Stack traces for errors can be enabled with Configure::write('Error.trace', true); - * - * @param integer $code Code of error - * @param string $description Error description - * @param string $file File on which error occurred - * @param integer $line Line that triggered the error - * @param array $context Context - * @return boolean true if error was handled - */ - public static function handleError($code, $description, $file = null, $line = null, $context = null) { - if (error_reporting() === 0) { - return false; - } - $errorConfig = Configure::read('Error'); - list($error, $log) = self::mapErrorCode($code); - - $debug = Configure::read('debug'); - if ($debug) { - $data = array( - 'level' => $log, - 'code' => $code, - 'error' => $error, - 'description' => $description, - 'file' => $file, - 'line' => $line, - 'context' => $context, - 'start' => 2, - 'path' => Debugger::trimPath($file) - ); - return Debugger::getInstance()->outputError($data); - } else { - $message = $error . ' (' . $code . '): ' . $description . ' in [' . $file . ', line ' . $line . ']'; - if (!empty($errorConfig['trace'])) { - $trace = Debugger::trace(array('start' => 1, 'format' => 'log')); - $message .= "\nTrace:\n" . $trace . "\n"; - } - return CakeLog::write($log, $message); - } - } - -/** - * Map an error code into an Error word, and log location. - * - * @param integer $code Error code to map - * @return array Array of error word, and log location. - */ - public static function mapErrorCode($code) { - $error = $log = null; - switch ($code) { - case E_PARSE: - case E_ERROR: - case E_CORE_ERROR: - case E_COMPILE_ERROR: - case E_USER_ERROR: - $error = 'Fatal Error'; - $log = LOG_ERROR; - break; - case E_WARNING: - case E_USER_WARNING: - case E_COMPILE_WARNING: - case E_RECOVERABLE_ERROR: - $error = 'Warning'; - $log = LOG_WARNING; - break; - case E_NOTICE: - case E_USER_NOTICE: - $error = 'Notice'; - $log = LOG_NOTICE; - break; - case E_STRICT: - $error = 'Strict'; - $log = LOG_NOTICE; - break; - case E_DEPRECATED: - case E_USER_DEPRECATED: - $error = 'Deprecated'; - $log = LOG_NOTICE; - break; - } - return array($error, $log); - } - -} diff --git a/lib/Cake/Error/ExceptionRenderer.php b/lib/Cake/Error/ExceptionRenderer.php deleted file mode 100644 index 16c57796953..00000000000 --- a/lib/Cake/Error/ExceptionRenderer.php +++ /dev/null @@ -1,290 +0,0 @@ - 1. - * When debug < 1 a CakeException will render 404 or 500 errors. If an uncaught exception is thrown - * and it is a type that ExceptionHandler does not know about it will be treated as a 500 error. - * - * ### Implementing application specific exception rendering - * - * You can implement application specific exception handling in one of a few ways: - * - * - Create a AppController::appError(); - * - Create a subclass of ExceptionRenderer and configure it to be the `Exception.renderer` - * - * #### Using AppController::appError(); - * - * This controller method is called instead of the default exception handling. It receives the - * thrown exception as its only argument. You should implement your error handling in that method. - * - * #### Using a subclass of ExceptionRenderer - * - * Using a subclass of ExceptionRenderer gives you full control over how Exceptions are rendered, you - * can configure your class in your core.php, with `Configure::write('Exception.renderer', 'MyClass');` - * You should place any custom exception renderers in `app/Lib/Error`. - * - * @package Cake.Error - */ -class ExceptionRenderer { - -/** - * Controller instance. - * - * @var Controller - */ - public $controller = null; - -/** - * template to render for CakeException - * - * @var string - */ - public $template = ''; - -/** - * The method corresponding to the Exception this object is for. - * - * @var string - */ - public $method = ''; - -/** - * The exception being handled. - * - * @var Exception - */ - public $error = null; - -/** - * Creates the controller to perform rendering on the error response. - * If the error is a CakeException it will be converted to either a 400 or a 500 - * code error depending on the code used to construct the error. - * - * @param Exception $exception Exception - */ - public function __construct(Exception $exception) { - $this->controller = $this->_getController($exception); - - if (method_exists($this->controller, 'apperror')) { - return $this->controller->appError($exception); - } - $method = $template = Inflector::variable(str_replace('Exception', '', get_class($exception))); - $code = $exception->getCode(); - - $methodExists = method_exists($this, $method); - - if ($exception instanceof CakeException && !$methodExists) { - $method = '_cakeError'; - if (empty($template)) { - $template = 'error500'; - } - if ($template == 'internalError') { - $template = 'error500'; - } - } elseif ($exception instanceof PDOException) { - $method = 'pdoError'; - $template = 'pdo_error'; - $code = 500; - } elseif (!$methodExists) { - $method = 'error500'; - if ($code >= 400 && $code < 500) { - $method = 'error400'; - } - } - - if (Configure::read('debug') == 0) { - if ($method == '_cakeError') { - $method = 'error400'; - } - if ($code == 500) { - $method = 'error500'; - } - } - $this->template = $template; - $this->method = $method; - $this->error = $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 `CakeErrorController` normally, or if an error is repeated - * a bare controller will be used. - * - * @param Exception $exception The exception to get a controller for. - * @return Controller - */ - protected function _getController($exception) { - App::uses('CakeErrorController', 'Controller'); - if (!$request = Router::getRequest(false)) { - $request = new CakeRequest(); - } - $response = new CakeResponse(array('charset' => Configure::read('App.encoding'))); - try { - $controller = new CakeErrorController($request, $response); - } catch (Exception $e) { - $controller = new Controller($request, $response); - $controller->viewPath = 'Errors'; - } - return $controller; - } - -/** - * Renders the response for the exception. - * - * @return void - */ - public function render() { - if ($this->method) { - call_user_func_array(array($this, $this->method), array($this->error)); - } - } - -/** - * Generic handler for the internal framework errors CakePHP can generate. - * - * @param CakeException $error - * @return void - */ - protected function _cakeError(CakeException $error) { - $url = $this->controller->request->here(); - $code = ($error->getCode() >= 400 && $error->getCode() < 506) ? $error->getCode() : 500; - $this->controller->response->statusCode($code); - $this->controller->set(array( - 'code' => $code, - 'url' => h($url), - 'name' => $error->getMessage(), - 'error' => $error, - '_serialize' => array('code', 'url', 'name') - )); - $this->controller->set($error->getAttributes()); - $this->_outputMessage($this->template); - } - -/** - * Convenience method to display a 400 series page. - * - * @param Exception $error - * @return void - */ - public function error400($error) { - $message = $error->getMessage(); - if (Configure::read('debug') == 0 && $error instanceof CakeException) { - $message = __d('cake', 'Not Found'); - } - $url = $this->controller->request->here(); - $this->controller->response->statusCode($error->getCode()); - $this->controller->set(array( - 'name' => $message, - 'url' => h($url), - 'error' => $error, - '_serialize' => array('name', 'url') - )); - $this->_outputMessage('error400'); - } - -/** - * Convenience method to display a 500 page. - * - * @param Exception $error - * @return void - */ - public function error500($error) { - $message = $error->getMessage(); - if (Configure::read('debug') == 0) { - $message = __d('cake', 'An Internal Error Has Occurred.'); - } - $url = $this->controller->request->here(); - $code = ($error->getCode() > 500 && $error->getCode() < 506) ? $error->getCode() : 500; - $this->controller->response->statusCode($code); - $this->controller->set(array( - 'name' => $message, - 'message' => h($url), - 'error' => $error, - '_serialize' => array('name', 'message') - )); - $this->_outputMessage('error500'); - } - -/** - * Convenience method to display a PDOException. - * - * @param PDOException $error - * @return void - */ - public function pdoError(PDOException $error) { - $url = $this->controller->request->here(); - $code = 500; - $this->controller->response->statusCode($code); - $this->controller->set(array( - 'code' => $code, - 'url' => h($url), - 'name' => $error->getMessage(), - 'error' => $error, - '_serialize' => array('code', 'url', 'name', 'error') - )); - $this->_outputMessage($this->template); - } - -/** - * Generate the response using the controller object. - * - * @param string $template The template to render. - * @return void - */ - protected function _outputMessage($template) { - try { - $this->controller->render($template); - $this->controller->afterFilter(); - $this->controller->response->send(); - } catch (Exception $e) { - $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 void - */ - protected function _outputMessageSafe($template) { - $this->controller->layoutPath = ''; - $this->controller->subDir = ''; - $this->controller->viewPath = 'Errors/'; - $this->controller->viewClass = 'View'; - $this->controller->helpers = array('Form', 'Html', 'Session'); - - $this->controller->render($template); - $this->controller->response->type('html'); - $this->controller->response->send(); - } - -} diff --git a/lib/Cake/Error/exceptions.php b/lib/Cake/Error/exceptions.php deleted file mode 100644 index 6b6d1e26bfd..00000000000 --- a/lib/Cake/Error/exceptions.php +++ /dev/null @@ -1,519 +0,0 @@ -_attributes = $message; - $message = __d('cake_dev', $this->_messageTemplate, $message); - } - parent::__construct($message, $code); - } - -/** - * Get the passed in attributes - * - * @return array - */ - public function getAttributes() { - return $this->_attributes; - } - -} - -/** - * Missing Controller exception - used when a controller - * cannot be found. - * - * @package Cake.Error - */ -class MissingControllerException extends CakeException { - - protected $_messageTemplate = 'Controller class %s could not be found.'; - - public function __construct($message, $code = 404) { - parent::__construct($message, $code); - } - -} - -/** - * Missing Action exception - used when a controller action - * cannot be found. - * - * @package Cake.Error - */ -class MissingActionException extends CakeException { - - protected $_messageTemplate = 'Action %s::%s() could not be found.'; - - public function __construct($message, $code = 404) { - parent::__construct($message, $code); - } - -} - -/** - * Private Action exception - used when a controller action - * starts with a `_`. - * - * @package Cake.Error - */ -class PrivateActionException extends CakeException { - - protected $_messageTemplate = 'Private Action %s::%s() is not directly accessible.'; - - public function __construct($message, $code = 404, Exception $previous = null) { - parent::__construct($message, $code, $previous); - } - -} - -/** - * Used when a component cannot be found. - * - * @package Cake.Error - */ -class MissingComponentException extends CakeException { - - protected $_messageTemplate = 'Component class %s could not be found.'; - -} - -/** - * Used when a behavior cannot be found. - * - * @package Cake.Error - */ -class MissingBehaviorException extends CakeException { - - protected $_messageTemplate = 'Behavior class %s could not be found.'; - -} - -/** - * Used when a view file cannot be found. - * - * @package Cake.Error - */ -class MissingViewException extends CakeException { - - protected $_messageTemplate = 'View file "%s" is missing.'; - -} - -/** - * Used when a layout file cannot be found. - * - * @package Cake.Error - */ -class MissingLayoutException extends CakeException { - - protected $_messageTemplate = 'Layout file "%s" is missing.'; - -} - -/** - * Used when a helper cannot be found. - * - * @package Cake.Error - */ -class MissingHelperException extends CakeException { - - protected $_messageTemplate = 'Helper class %s could not be found.'; - -} - -/** - * Runtime Exceptions for ConnectionManager - * - * @package Cake.Error - */ -class MissingDatabaseException extends CakeException { - - protected $_messageTemplate = 'Database connection "%s" could not be found.'; - -} - -/** - * Used when no connections can be found. - * - * @package Cake.Error - */ -class MissingConnectionException extends CakeException { - - protected $_messageTemplate = 'Database connection "%s" is missing, or could not be created.'; - -} - -/** - * Used when a Task cannot be found. - * - * @package Cake.Error - */ -class MissingTaskException extends CakeException { - - protected $_messageTemplate = 'Task class %s could not be found.'; - -} - -/** - * Used when a shell method cannot be found. - * - * @package Cake.Error - */ -class MissingShellMethodException extends CakeException { - - protected $_messageTemplate = "Unknown command %1\$s %2\$s.\nFor usage try `cake %1\$s --help`"; - -} - -/** - * Used when a shell cannot be found. - * - * @package Cake.Error - */ -class MissingShellException extends CakeException { - - protected $_messageTemplate = 'Shell class %s could not be found.'; - -} - -/** - * Exception class to be thrown when a datasource configuration is not found - * - * @package Cake.Error - */ -class MissingDatasourceConfigException extends CakeException { - - protected $_messageTemplate = 'The datasource configuration "%s" was not found in database.php'; - -} - -/** - * Used when a datasource cannot be found. - * - * @package Cake.Error - */ -class MissingDatasourceException extends CakeException { - - protected $_messageTemplate = 'Datasource class %s could not be found.'; - -} - -/** - * Exception class to be thrown when a database table is not found in the datasource - * - * @package Cake.Error - */ -class MissingTableException extends CakeException { - - protected $_messageTemplate = 'Table %s for model %s was not found in datasource %s.'; - -} - -/** - * Exception raised when a Model could not be found. - * - * @package Cake.Error - */ -class MissingModelException extends CakeException { - - protected $_messageTemplate = 'Model %s could not be found.'; - -} - -/** - * Exception raised when a test loader could not be found - * - * @package Cake.Error - */ -class MissingTestLoaderException extends CakeException { - - protected $_messageTemplate = 'Test loader %s could not be found.'; - -} - -/** - * Exception raised when a plugin could not be found - * - * @package Cake.Error - */ -class MissingPluginException extends CakeException { - - protected $_messageTemplate = 'Plugin %s could not be found.'; - -} - -/** - * Exception class for AclComponent and Interface implementations. - * - * @package Cake.Error - */ -class AclException extends CakeException { -} - -/** - * Exception class for Cache. This exception will be thrown from Cache when it - * encounters an error. - * - * @package Cake.Error - */ -class CacheException extends CakeException { -} - -/** - * Exception class for Router. This exception will be thrown from Router when it - * encounters an error. - * - * @package Cake.Error - */ -class RouterException extends CakeException { -} - -/** - * Exception class for CakeLog. This exception will be thrown from CakeLog when it - * encounters an error. - * - * @package Cake.Error - */ -class CakeLogException extends CakeException { -} - -/** - * Exception class for CakeSession. This exception will be thrown from CakeSession when it - * encounters an error. - * - * @package Cake.Error - */ -class CakeSessionException extends CakeException { -} - -/** - * Exception class for Configure. This exception will be thrown from Configure when it - * encounters an error. - * - * @package Cake.Error - */ -class ConfigureException extends CakeException { -} - -/** - * Exception class for Socket. This exception will be thrown from CakeSocket, CakeEmail, HttpSocket - * SmtpTransport, MailTransport and HttpResponse when it encounters an error. - * - * @package Cake.Error - */ -class SocketException extends CakeException { -} - -/** - * Exception class for Xml. This exception will be thrown from Xml when it - * encounters an error. - * - * @package Cake.Error - */ -class XmlException extends CakeException { -} - -/** - * Exception class for Console libraries. This exception will be thrown from Console library - * classes when they encounter an error. - * - * @package Cake.Error - */ -class ConsoleException extends CakeException { -} diff --git a/lib/Cake/Event/CakeEvent.php b/lib/Cake/Event/CakeEvent.php deleted file mode 100644 index 52de3cf2346..00000000000 --- a/lib/Cake/Event/CakeEvent.php +++ /dev/null @@ -1,132 +0,0 @@ - $userData)); - * $event = new CakeEvent('User.afterRegister', $UserModel); - * }}} - * - */ - public function __construct($name, $subject = null, $data = null) { - $this->_name = $name; - $this->data = $data; - $this->_subject = $subject; - } - -/** - * Dynamically returns the name and subject if accessed directly - * - * @param string $attribute - * @return mixed - */ - public function __get($attribute) { - if ($attribute === 'name' || $attribute === 'subject') { - return $this->{$attribute}(); - } - } - -/** - * Returns the name of this event. This is usually used as the event identifier - * - * @return string - */ - public function name() { - return $this->_name; - } - -/** - * Returns the subject of this event - * - * @return string - */ - public function subject() { - return $this->_subject; - } - -/** - * Stops the event from being used anymore - * - * @return void - */ - public function stopPropagation() { - return $this->_stopped = true; - } - -/** - * Check if the event is stopped - * - * @return boolean True if the event is stopped - */ - public function isStopped() { - return $this->_stopped; - } - -} diff --git a/lib/Cake/Event/CakeEventListener.php b/lib/Cake/Event/CakeEventListener.php deleted file mode 100644 index c4915949db1..00000000000 --- a/lib/Cake/Event/CakeEventListener.php +++ /dev/null @@ -1,48 +0,0 @@ - 'sendEmail', - * 'Article.afterBuy' => 'decrementInventory', - * 'User.onRegister' => array('callable' => 'logRegistration', 'priority' => 20, 'passParams' => true) - * ); - * } - * }}} - * - * @return array 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(); - -} diff --git a/lib/Cake/Event/CakeEventManager.php b/lib/Cake/Event/CakeEventManager.php deleted file mode 100644 index 10e657f6f4f..00000000000 --- a/lib/Cake/Event/CakeEventManager.php +++ /dev/null @@ -1,276 +0,0 @@ -_isGlobal = true; - return self::$_generalManager; - } - -/** - * Adds a new listener to an event. Listeners - * - * @param callback|CakeEventListener $callable PHP valid callback type or instance of CakeEventListener to be called - * when the event named with $eventKey is triggered. If a CakeEventListener instances 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 mixed $eventKey The event unique identifier name to with the callback will be associated. If $callable - * is an instance of CakeEventListener this argument will be ignored - * - * @param array $options used to set the `priority` and `passParams` flags to the listener. - * Priorities are handled like queues, and multiple attachments into the same priority queue will be treated in - * the order of insertion. `passParams` means that the event data property will be converted to function arguments - * when the listener is called. If $called is an instance of CakeEventListener, this parameter will be ignored - * - * @return void - * @throws InvalidArgumentException When event key is missing or callable is not an - * instance of CakeEventListener. - */ - public function attach($callable, $eventKey = null, $options = array()) { - if (!$eventKey && !($callable instanceof CakeEventListener)) { - throw new InvalidArgumentException(__d('cake_dev', 'The eventKey variable is required')); - } - if ($callable instanceof CakeEventListener) { - $this->_attachSubscriber($callable); - return; - } - $options = $options + array('priority' => self::$defaultPriority, 'passParams' => false); - $this->_listeners[$eventKey][$options['priority']][] = array( - 'callable' => $callable, - 'passParams' => $options['passParams'], - ); - } - -/** - * Auxiliary function to attach all implemented callbacks of a CakeEventListener class instance - * as individual methods on this manager - * - * @param CakeEventListener $subscriber - * @return void - */ - protected function _attachSubscriber(CakeEventListener $subscriber) { - foreach ($subscriber->implementedEvents() as $eventKey => $function) { - $options = array(); - $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->attach($method, $eventKey, $options); - } - continue; - } - if (is_string($method)) { - $method = array($subscriber, $function); - } - $this->attach($method, $eventKey, $options); - } - } - -/** - * 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 CakeEventListener - * - * @param array $function the array taken from a handler definition for a event - * @param CakeEventListener $object The handler object - * @return callback - */ - protected function _extractCallable($function, $object) { - $method = $function['callable']; - $options = $function; - unset($options['callable']); - if (is_string($method)) { - $method = array($object, $method); - } - return array($method, $options); - } - -/** - * Removes a listener from the active listeners. - * - * @param callback|CakeEventListener $callable any valid PHP callback type or an instance of CakeEventListener - * @return void - */ - public function detach($callable, $eventKey = null) { - if ($callable instanceof CakeEventListener) { - return $this->_detachSubscriber($callable, $eventKey); - } - if (empty($eventKey)) { - foreach (array_keys($this->_listeners) as $eventKey) { - $this->detach($callable, $eventKey); - } - return; - } - if (empty($this->_listeners[$eventKey])) { - return; - } - foreach ($this->_listeners[$eventKey] as $priority => $callables) { - foreach ($callables as $k => $callback) { - if ($callback['callable'] === $callable) { - unset($this->_listeners[$eventKey][$priority][$k]); - break; - } - } - } - } - -/** - * Auxiliary function to help detach all listeners provided by an object implementing CakeEventListener - * - * @param CakeEventListener $subscriber the subscriber to be detached - * @param string $eventKey optional event key name to unsubscribe the listener from - * @return void - */ - protected function _detachSubscriber(CakeEventListener $subscriber, $eventKey = null) { - $events = $subscriber->implementedEvents(); - if (!empty($eventKey) && empty($events[$eventKey])) { - return; - } elseif (!empty($eventKey)) { - $events = array($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->detach(array($subscriber, $handler), $key); - } - continue; - } - $function = $function['callable']; - } - $this->detach(array($subscriber, $function), $key); - } - } - -/** - * Dispatches a new event to all configured listeners - * - * @param mixed $event the event key name or instance of CakeEvent - * @return void - */ - public function dispatch($event) { - if (is_string($event)) { - $event = new CakeEvent($event); - } - - if (!$this->_isGlobal) { - self::instance()->dispatch($event); - } - - if (empty($this->_listeners[$event->name()])) { - return; - } - - foreach ($this->listeners($event->name()) as $listener) { - if ($event->isStopped()) { - break; - } - if ($listener['passParams'] === true) { - $result = call_user_func_array($listener['callable'], $event->data); - } else { - $result = call_user_func($listener['callable'], $event); - } - if ($result === false) { - $event->stopPropagation(); - } - if ($result !== null) { - $event->result = $result; - } - continue; - } - } - -/** - * Returns a list of all listeners for a eventKey in the order they should be called - * - * @param string $eventKey - * @return array - */ - public function listeners($eventKey) { - if (empty($this->_listeners[$eventKey])) { - return array(); - } - ksort($this->_listeners[$eventKey]); - $result = array(); - foreach ($this->_listeners[$eventKey] as $priorityQ) { - $result = array_merge($result, $priorityQ); - } - return $result; - } - -} diff --git a/lib/Cake/I18n/I18n.php b/lib/Cake/I18n/I18n.php deleted file mode 100644 index 856f8f82461..00000000000 --- a/lib/Cake/I18n/I18n.php +++ /dev/null @@ -1,631 +0,0 @@ -l10n = new L10n(); - } - -/** - * Return a static instance of the I18n class - * - * @return I18n - */ - public static function &getInstance() { - static $instance = array(); - if (!$instance) { - $instance[0] = new I18n(); - } - return $instance[0]; - } - -/** - * Used by the translation functions in basics.php - * Returns a translated string based on current language and translation files stored in locale folder - * - * @param string $singular String to translate - * @param string $plural Plural string (if any) - * @param string $domain Domain The domain of the translation. Domains are often used by plugin translations - * @param string $category Category The integer value of the category to use. - * @param integer $count Count Count is used with $plural to choose the correct plural form. - * @param string $language Language to translate string to. - * If null it checks for language in session followed by Config.language configuration variable. - * @return string translated string. - */ - public static function translate($singular, $plural = null, $domain = null, $category = 6, $count = null, $language = null) { - $_this = I18n::getInstance(); - - if (strpos($singular, "\r\n") !== false) { - $singular = str_replace("\r\n", "\n", $singular); - } - if ($plural !== null && strpos($plural, "\r\n") !== false) { - $plural = str_replace("\r\n", "\n", $plural); - } - - if (is_numeric($category)) { - $_this->category = $_this->_categories[$category]; - } - - if (empty($language)) { - if (!empty($_SESSION['Config']['language'])) { - $language = $_SESSION['Config']['language']; - } else { - $language = Configure::read('Config.language'); - } - } - - if (($_this->_lang && $_this->_lang !== $language) || !$_this->_lang) { - $lang = $_this->l10n->get($language); - $_this->_lang = $lang; - } - - if (is_null($domain)) { - $domain = self::$defaultDomain; - } - - $_this->domain = $domain . '_' . $_this->l10n->lang; - - if (!isset($_this->_domains[$domain][$_this->_lang])) { - $_this->_domains[$domain][$_this->_lang] = Cache::read($_this->domain, '_cake_core_'); - } - - if (!isset($_this->_domains[$domain][$_this->_lang][$_this->category])) { - $_this->_bindTextDomain($domain); - Cache::write($_this->domain, $_this->_domains[$domain][$_this->_lang], '_cake_core_'); - } - - if ($_this->category == 'LC_TIME') { - return $_this->_translateTime($singular, $domain); - } - - if (!isset($count)) { - $plurals = 0; - } elseif (!empty($_this->_domains[$domain][$_this->_lang][$_this->category]["%plural-c"]) && $_this->_noLocale === false) { - $header = $_this->_domains[$domain][$_this->_lang][$_this->category]["%plural-c"]; - $plurals = $_this->_pluralGuess($header, $count); - } else { - if ($count != 1) { - $plurals = 1; - } else { - $plurals = 0; - } - } - - if (!empty($_this->_domains[$domain][$_this->_lang][$_this->category][$singular])) { - if (($trans = $_this->_domains[$domain][$_this->_lang][$_this->category][$singular]) || ($plurals) && ($trans = $_this->_domains[$domain][$_this->_lang][$_this->category][$plural])) { - if (is_array($trans)) { - if (isset($trans[$plurals])) { - $trans = $trans[$plurals]; - } else { - trigger_error( - __d('cake_dev', - 'Missing plural form translation for "%s" in "%s" domain, "%s" locale. ' . - ' Check your po file for correct plurals and valid Plural-Forms header.', - $singular, - $domain, - $_this->_lang - ), - E_USER_WARNING - ); - $trans = $trans[0]; - } - } - if (strlen($trans)) { - return $trans; - } - } - } - - if (!empty($plurals)) { - return $plural; - } - return $singular; - } - -/** - * Clears the domains internal data array. Useful for testing i18n. - * - * @return void - */ - public static function clear() { - $self = I18n::getInstance(); - $self->_domains = array(); - } - -/** - * Get the loaded domains cache. - * - * @return array - */ - public static function domains() { - $self = I18n::getInstance(); - return $self->_domains; - } - -/** - * Attempts to find the plural form of a string. - * - * @param string $header Type - * @param integer $n Number - * @return integer plural match - */ - protected function _pluralGuess($header, $n) { - if (!is_string($header) || $header === "nplurals=1;plural=0;" || !isset($header[0])) { - return 0; - } - - if ($header === "nplurals=2;plural=n!=1;") { - return $n != 1 ? 1 : 0; - } elseif ($header === "nplurals=2;plural=n>1;") { - return $n > 1 ? 1 : 0; - } - - if (strpos($header, "plurals=3")) { - if (strpos($header, "100!=11")) { - if (strpos($header, "10<=4")) { - return $n % 10 == 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2); - } elseif (strpos($header, "100<10")) { - return $n % 10 == 1 && $n % 100 != 11 ? 0 : ($n % 10 >= 2 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2); - } - return $n % 10 == 1 && $n % 100 != 11 ? 0 : ($n != 0 ? 1 : 2); - } elseif (strpos($header, "n==2")) { - return $n == 1 ? 0 : ($n == 2 ? 1 : 2); - } elseif (strpos($header, "n==0")) { - return $n == 1 ? 0 : ($n == 0 || ($n % 100 > 0 && $n % 100 < 20) ? 1 : 2); - } elseif (strpos($header, "n>=2")) { - return $n == 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2); - } elseif (strpos($header, "10>=2")) { - return $n == 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2); - } - return $n % 10 == 1 ? 0 : ($n % 10 == 2 ? 1 : 2); - } elseif (strpos($header, "plurals=4")) { - if (strpos($header, "100==2")) { - return $n % 100 == 1 ? 0 : ($n % 100 == 2 ? 1 : ($n % 100 == 3 || $n % 100 == 4 ? 2 : 3)); - } elseif (strpos($header, "n>=3")) { - return $n == 1 ? 0 : ($n == 2 ? 1 : ($n == 0 || ($n >= 3 && $n <= 10) ? 2 : 3)); - } elseif (strpos($header, "100>=1")) { - return $n == 1 ? 0 : ($n == 0 || ($n % 100 >= 1 && $n % 100 <= 10) ? 1 : ($n % 100 >= 11 && $n % 100 <= 20 ? 2 : 3)); - } - } elseif (strpos($header, "plurals=5")) { - return $n == 1 ? 0 : ($n == 2 ? 1 : ($n >= 3 && $n <= 6 ? 2 : ($n >= 7 && $n <= 10 ? 3 : 4))); - } - } - -/** - * Binds the given domain to a file in the specified directory. - * - * @param string $domain Domain to bind - * @return string Domain binded - */ - protected function _bindTextDomain($domain) { - $this->_noLocale = true; - $core = true; - $merge = array(); - $searchPaths = App::path('locales'); - $plugins = CakePlugin::loaded(); - - if (!empty($plugins)) { - foreach ($plugins as $plugin) { - $pluginDomain = Inflector::underscore($plugin); - if ($pluginDomain === $domain) { - $searchPaths[] = CakePlugin::path($plugin) . 'Locale' . DS; - $searchPaths = array_reverse($searchPaths); - break; - } - } - } - - foreach ($searchPaths as $directory) { - foreach ($this->l10n->languagePath as $lang) { - $localeDef = $directory . $lang . DS . $this->category; - if (is_file($localeDef)) { - $definitions = self::loadLocaleDefinition($localeDef); - if ($definitions !== false) { - $this->_domains[$domain][$this->_lang][$this->category] = self::loadLocaleDefinition($localeDef); - $this->_noLocale = false; - return $domain; - } - } - - if ($core) { - $app = $directory . $lang . DS . $this->category . DS . 'core'; - $translations = false; - - if (is_file($app . '.mo')) { - $translations = self::loadMo($app . '.mo'); - } - if ($translations === false && is_file($app . '.po')) { - $translations = self::loadPo($app . '.po'); - } - - if ($translations !== false) { - $this->_domains[$domain][$this->_lang][$this->category] = $translations; - $merge[$domain][$this->_lang][$this->category] = $this->_domains[$domain][$this->_lang][$this->category]; - $this->_noLocale = false; - $core = null; - } - } - - $file = $directory . $lang . DS . $this->category . DS . $domain; - $translations = false; - - if (is_file($file . '.mo')) { - $translations = self::loadMo($file . '.mo'); - } - if ($translations === false && is_file($file . '.po')) { - $translations = self::loadPo($file . '.po'); - } - - if ($translations !== false) { - $this->_domains[$domain][$this->_lang][$this->category] = $translations; - $this->_noLocale = false; - break 2; - } - } - } - - if (empty($this->_domains[$domain][$this->_lang][$this->category])) { - $this->_domains[$domain][$this->_lang][$this->category] = array(); - return $domain; - } - - if (isset($this->_domains[$domain][$this->_lang][$this->category][""])) { - $head = $this->_domains[$domain][$this->_lang][$this->category][""]; - - foreach (explode("\n", $head) as $line) { - $header = strtok($line,":"); - $line = trim(strtok("\n")); - $this->_domains[$domain][$this->_lang][$this->category]["%po-header"][strtolower($header)] = $line; - } - - if (isset($this->_domains[$domain][$this->_lang][$this->category]["%po-header"]["plural-forms"])) { - $switch = preg_replace("/(?:[() {}\\[\\]^\\s*\\]]+)/", "", $this->_domains[$domain][$this->_lang][$this->category]["%po-header"]["plural-forms"]); - $this->_domains[$domain][$this->_lang][$this->category]["%plural-c"] = $switch; - unset($this->_domains[$domain][$this->_lang][$this->category]["%po-header"]); - } - $this->_domains = Set::pushDiff($this->_domains, $merge); - - if (isset($this->_domains[$domain][$this->_lang][$this->category][null])) { - unset($this->_domains[$domain][$this->_lang][$this->category][null]); - } - } - - return $domain; - } - -/** - * Loads the binary .mo file and returns array of translations - * - * @param string $filename Binary .mo file to load - * @return mixed Array of translations on success or false on failure - */ - public static function loadMo($filename) { - $translations = false; - - // @codingStandardsIgnoreStart - // Binary files extracted makes non-standard local variables - if ($data = file_get_contents($filename)) { - $translations = array(); - $header = substr($data, 0, 20); - $header = unpack("L1magic/L1version/L1count/L1o_msg/L1o_trn", $header); - extract($header); - - if ((dechex($magic) == '950412de' || dechex($magic) == 'ffffffff950412de') && $version == 0) { - for ($n = 0; $n < $count; $n++) { - $r = unpack("L1len/L1offs", substr($data, $o_msg + $n * 8, 8)); - $msgid = substr($data, $r["offs"], $r["len"]); - unset($msgid_plural); - - if (strpos($msgid, "\000")) { - list($msgid, $msgid_plural) = explode("\000", $msgid); - } - $r = unpack("L1len/L1offs", substr($data, $o_trn + $n * 8, 8)); - $msgstr = substr($data, $r["offs"], $r["len"]); - - if (strpos($msgstr, "\000")) { - $msgstr = explode("\000", $msgstr); - } - $translations[$msgid] = $msgstr; - - if (isset($msgid_plural)) { - $translations[$msgid_plural] =& $translations[$msgid]; - } - } - } - } - // @codingStandardsIgnoreEnd - - return $translations; - } - -/** - * Loads the text .po file and returns array of translations - * - * @param string $filename Text .po file to load - * @return mixed Array of translations on success or false on failure - */ - public static function loadPo($filename) { - if (!$file = fopen($filename, "r")) { - return false; - } - - $type = 0; - $translations = array(); - $translationKey = ""; - $plural = 0; - $header = ""; - - do { - $line = trim(fgets($file)); - if ($line == "" || $line[0] == "#") { - continue; - } - if (preg_match("/msgid[[:space:]]+\"(.+)\"$/i", $line, $regs)) { - $type = 1; - $translationKey = stripcslashes($regs[1]); - } elseif (preg_match("/msgid[[:space:]]+\"\"$/i", $line, $regs)) { - $type = 2; - $translationKey = ""; - } elseif (preg_match("/^\"(.*)\"$/i", $line, $regs) && ($type == 1 || $type == 2 || $type == 3)) { - $type = 3; - $translationKey .= stripcslashes($regs[1]); - } elseif (preg_match("/msgstr[[:space:]]+\"(.+)\"$/i", $line, $regs) && ($type == 1 || $type == 3) && $translationKey) { - $translations[$translationKey] = stripcslashes($regs[1]); - $type = 4; - } elseif (preg_match("/msgstr[[:space:]]+\"\"$/i", $line, $regs) && ($type == 1 || $type == 3) && $translationKey) { - $type = 4; - $translations[$translationKey] = ""; - } elseif (preg_match("/^\"(.*)\"$/i", $line, $regs) && $type == 4 && $translationKey) { - $translations[$translationKey] .= stripcslashes($regs[1]); - } elseif (preg_match("/msgid_plural[[:space:]]+\".*\"$/i", $line, $regs)) { - $type = 6; - } elseif (preg_match("/^\"(.*)\"$/i", $line, $regs) && $type == 6 && $translationKey) { - $type = 6; - } elseif (preg_match("/msgstr\[(\d+)\][[:space:]]+\"(.+)\"$/i", $line, $regs) && ($type == 6 || $type == 7) && $translationKey) { - $plural = $regs[1]; - $translations[$translationKey][$plural] = stripcslashes($regs[2]); - $type = 7; - } elseif (preg_match("/msgstr\[(\d+)\][[:space:]]+\"\"$/i", $line, $regs) && ($type == 6 || $type == 7) && $translationKey) { - $plural = $regs[1]; - $translations[$translationKey][$plural] = ""; - $type = 7; - } elseif (preg_match("/^\"(.*)\"$/i", $line, $regs) && $type == 7 && $translationKey) { - $translations[$translationKey][$plural] .= stripcslashes($regs[1]); - } elseif (preg_match("/msgstr[[:space:]]+\"(.+)\"$/i", $line, $regs) && $type == 2 && !$translationKey) { - $header .= stripcslashes($regs[1]); - $type = 5; - } elseif (preg_match("/msgstr[[:space:]]+\"\"$/i", $line, $regs) && !$translationKey) { - $header = ""; - $type = 5; - } elseif (preg_match("/^\"(.*)\"$/i", $line, $regs) && $type == 5) { - $header .= stripcslashes($regs[1]); - } else { - unset($translations[$translationKey]); - $type = 0; - $translationKey = ""; - $plural = 0; - } - } while (!feof($file)); - fclose($file); - - $merge[""] = $header; - return array_merge($merge, $translations); - } - -/** - * Parses a locale definition file following the POSIX standard - * - * @param string $filename Locale definition filename - * @return mixed Array of definitions on success or false on failure - */ - public static function loadLocaleDefinition($filename) { - if (!$file = fopen($filename, "r")) { - return false; - } - - $definitions = array(); - $comment = '#'; - $escape = '\\'; - $currentToken = false; - $value = ''; - $_this = I18n::getInstance(); - while ($line = fgets($file)) { - $line = trim($line); - if (empty($line) || $line[0] === $comment) { - continue; - } - $parts = preg_split("/[[:space:]]+/", $line); - if ($parts[0] === 'comment_char') { - $comment = $parts[1]; - continue; - } - if ($parts[0] === 'escape_char') { - $escape = $parts[1]; - continue; - } - $count = count($parts); - if ($count == 2) { - $currentToken = $parts[0]; - $value = $parts[1]; - } elseif ($count == 1) { - $value .= $parts[0]; - } else { - continue; - } - - $len = strlen($value) - 1; - if ($value[$len] === $escape) { - $value = substr($value, 0, $len); - continue; - } - - $mustEscape = array($escape . ',', $escape . ';', $escape . '<', $escape . '>', $escape . $escape); - $replacements = array_map('crc32', $mustEscape); - $value = str_replace($mustEscape, $replacements, $value); - $value = explode(';', $value); - $_this->_escape = $escape; - foreach ($value as $i => $val) { - $val = trim($val, '"'); - $val = preg_replace_callback('/(?:<)?(.[^>]*)(?:>)?/', array(&$_this, '_parseLiteralValue'), $val); - $val = str_replace($replacements, $mustEscape, $val); - $value[$i] = $val; - } - if (count($value) == 1) { - $definitions[$currentToken] = array_pop($value); - } else { - $definitions[$currentToken] = $value; - } - } - - return $definitions; - } - -/** - * Auxiliary function to parse a symbol from a locale definition file - * - * @param string $string Symbol to be parsed - * @return string parsed symbol - */ - protected function _parseLiteralValue($string) { - $string = $string[1]; - if (substr($string, 0, 2) === $this->_escape . 'x') { - $delimiter = $this->_escape . 'x'; - return join('', array_map('chr', array_map('hexdec',array_filter(explode($delimiter, $string))))); - } - if (substr($string, 0, 2) === $this->_escape . 'd') { - $delimiter = $this->_escape . 'd'; - return join('', array_map('chr', array_filter(explode($delimiter, $string)))); - } - if ($string[0] === $this->_escape && isset($string[1]) && is_numeric($string[1])) { - $delimiter = $this->_escape; - return join('', array_map('chr', array_filter(explode($delimiter, $string)))); - } - if (substr($string, 0, 3) === 'U00') { - $delimiter = 'U00'; - return join('', array_map('chr', array_map('hexdec', array_filter(explode($delimiter, $string))))); - } - if (preg_match('/U([0-9a-fA-F]{4})/', $string, $match)) { - return Multibyte::ascii(array(hexdec($match[1]))); - } - return $string; - } - -/** - * Returns a Time format definition from corresponding domain - * - * @param string $format Format to be translated - * @param string $domain Domain where format is stored - * @return mixed translated format string if only value or array of translated strings for corresponding format. - */ - protected function _translateTime($format, $domain) { - if (!empty($this->_domains[$domain][$this->_lang]['LC_TIME'][$format])) { - if (($trans = $this->_domains[$domain][$this->_lang][$this->category][$format])) { - return $trans; - } - } - return $format; - } - -} diff --git a/lib/Cake/I18n/L10n.php b/lib/Cake/I18n/L10n.php deleted file mode 100644 index 587db27fd35..00000000000 --- a/lib/Cake/I18n/L10n.php +++ /dev/null @@ -1,473 +0,0 @@ - 'af', - /* Albanian */ 'alb' => 'sq', - /* Arabic */ 'ara' => 'ar', - /* Armenian - Armenia */ 'hye' => 'hy', - /* Basque */ 'baq' => 'eu', - /* Tibetan */ 'bod' => 'bo', - /* Bosnian */ 'bos' => 'bs', - /* Bulgarian */ 'bul' => 'bg', - /* Byelorussian */ 'bel' => 'be', - /* Catalan */ 'cat' => 'ca', - /* Chinese */ 'chi' => 'zh', - /* Chinese */ 'zho' => 'zh', - /* Croatian */ 'hrv' => 'hr', - /* Czech */ 'cze' => 'cs', - /* Czech */ 'ces' => 'cs', - /* Danish */ 'dan' => 'da', - /* Dutch (Standard) */ 'dut' => 'nl', - /* Dutch (Standard) */ 'nld' => 'nl', - /* English */ 'eng' => 'en', - /* Estonian */ 'est' => 'et', - /* Faeroese */ 'fao' => 'fo', - /* Farsi */ 'fas' => 'fa', - /* Farsi */ 'per' => 'fa', - /* Finnish */ 'fin' => 'fi', - /* French (Standard) */ 'fre' => 'fr', - /* French (Standard) */ 'fra' => 'fr', - /* Gaelic (Scots) */ 'gla' => 'gd', - /* Galician */ 'glg' => 'gl', - /* German (Standard) */ 'deu' => 'de', - /* German (Standard) */ 'ger' => 'de', - /* Greek */ 'gre' => 'el', - /* Greek */ 'ell' => 'el', - /* Hebrew */ 'heb' => 'he', - /* Hindi */ 'hin' => 'hi', - /* Hungarian */ 'hun' => 'hu', - /* Icelandic */ 'ice' => 'is', - /* Icelandic */ 'isl' => 'is', - /* Indonesian */ 'ind' => 'id', - /* Irish */ 'gle' => 'ga', - /* Italian */ 'ita' => 'it', - /* Japanese */ 'jpn' => 'ja', - /* Korean */ 'kor' => 'ko', - /* Latvian */ 'lav' => 'lv', - /* Lithuanian */ 'lit' => 'lt', - /* Macedonian */ 'mac' => 'mk', - /* Macedonian */ 'mkd' => 'mk', - /* Malaysian */ 'may' => 'ms', - /* Malaysian */ 'msa' => 'ms', - /* Maltese */ 'mlt' => 'mt', - /* Norwegian */ 'nor' => 'no', - /* Norwegian Bokmal */ 'nob' => 'nb', - /* Norwegian Nynorsk */ 'nno' => 'nn', - /* Polish */ 'pol' => 'pl', - /* Portuguese (Portugal) */ 'por' => 'pt', - /* Rhaeto-Romanic */ 'roh' => 'rm', - /* Romanian */ 'rum' => 'ro', - /* Romanian */ 'ron' => 'ro', - /* Russian */ 'rus' => 'ru', - /* Sami (Lappish) */ 'smi' => 'sz', - /* Serbian */ 'scc' => 'sr', - /* Serbian */ 'srp' => 'sr', - /* Slovak */ 'slo' => 'sk', - /* Slovak */ 'slk' => 'sk', - /* Slovenian */ 'slv' => 'sl', - /* Sorbian */ 'wen' => 'sb', - /* Spanish (Spain - Traditional) */ 'spa' => 'es', - /* Swedish */ 'swe' => 'sv', - /* Thai */ 'tha' => 'th', - /* Tsonga */ 'tso' => 'ts', - /* Tswana */ 'tsn' => 'tn', - /* Turkish */ 'tur' => 'tr', - /* Ukrainian */ 'ukr' => 'uk', - /* Urdu */ 'urd' => 'ur', - /* Venda */ 'ven' => 've', - /* Vietnamese */ 'vie' => 'vi', - /* Welsh */ 'cym' => 'cy', - /* Xhosa */ 'xho' => 'xh', - /* Yiddish */ 'yid' => 'yi', - /* Zulu */ 'zul' => 'zu'); - -/** - * HTTP_ACCEPT_LANGUAGE catalog - * - * holds all information related to a language - * - * @var array - */ - protected $_l10nCatalog = array('af' => array('language' => 'Afrikaans', 'locale' => 'afr', 'localeFallback' => 'afr', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ar' => array('language' => 'Arabic', 'locale' => 'ara', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ae' => array('language' => 'Arabic (U.A.E.)', 'locale' => 'ar_ae', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-bh' => array('language' => 'Arabic (Bahrain)', 'locale' => 'ar_bh', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-dz' => array('language' => 'Arabic (Algeria)', 'locale' => 'ar_dz', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-eg' => array('language' => 'Arabic (Egypt)', 'locale' => 'ar_eg', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-iq' => array('language' => 'Arabic (Iraq)', 'locale' => 'ar_iq', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-jo' => array('language' => 'Arabic (Jordan)', 'locale' => 'ar_jo', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-kw' => array('language' => 'Arabic (Kuwait)', 'locale' => 'ar_kw', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-lb' => array('language' => 'Arabic (Lebanon)', 'locale' => 'ar_lb', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ly' => array('language' => 'Arabic (Libya)', 'locale' => 'ar_ly', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ma' => array('language' => 'Arabic (Morocco)', 'locale' => 'ar_ma', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-om' => array('language' => 'Arabic (Oman)', 'locale' => 'ar_om', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-qa' => array('language' => 'Arabic (Qatar)', 'locale' => 'ar_qa', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-sa' => array('language' => 'Arabic (Saudi Arabia)', 'locale' => 'ar_sa', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-sy' => array('language' => 'Arabic (Syria)', 'locale' => 'ar_sy', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-tn' => array('language' => 'Arabic (Tunisia)', 'locale' => 'ar_tn', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ye' => array('language' => 'Arabic (Yemen)', 'locale' => 'ar_ye', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'be' => array('language' => 'Byelorussian', 'locale' => 'bel', 'localeFallback' => 'bel', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'bg' => array('language' => 'Bulgarian', 'locale' => 'bul', 'localeFallback' => 'bul', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'bo' => array('language' => 'Tibetan', 'locale' => 'bod', 'localeFallback' => 'bod', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'bo-cn' => array('language' => 'Tibetan (China)', 'locale' => 'bo_cn', 'localeFallback' => 'bod', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'bo-in' => array('language' => 'Tibetan (India)', 'locale' => 'bo_in', 'localeFallback' => 'bod', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'bs' => array('language' => 'Bosnian', 'locale' => 'bos', 'localeFallback' => 'bos', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ca' => array('language' => 'Catalan', 'locale' => 'cat', 'localeFallback' => 'cat', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'cs' => array('language' => 'Czech', 'locale' => 'cze', 'localeFallback' => 'cze', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'da' => array('language' => 'Danish', 'locale' => 'dan', 'localeFallback' => 'dan', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de' => array('language' => 'German (Standard)', 'locale' => 'deu', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-at' => array('language' => 'German (Austria)', 'locale' => 'de_at', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-ch' => array('language' => 'German (Swiss)', 'locale' => 'de_ch', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-de' => array('language' => 'German (Germany)', 'locale' => 'de_de', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-li' => array('language' => 'German (Liechtenstein)', 'locale' => 'de_li', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-lu' => array('language' => 'German (Luxembourg)', 'locale' => 'de_lu', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'e' => array('language' => 'Greek', 'locale' => 'gre', 'localeFallback' => 'gre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'el' => array('language' => 'Greek', 'locale' => 'gre', 'localeFallback' => 'gre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en' => array('language' => 'English', 'locale' => 'eng', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-au' => array('language' => 'English (Australian)', 'locale' => 'en_au', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-bz' => array('language' => 'English (Belize)', 'locale' => 'en_bz', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-ca' => array('language' => 'English (Canadian)', 'locale' => 'en_ca', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-gb' => array('language' => 'English (British)', 'locale' => 'en_gb', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-ie' => array('language' => 'English (Ireland)', 'locale' => 'en_ie', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-jm' => array('language' => 'English (Jamaica)', 'locale' => 'en_jm', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-nz' => array('language' => 'English (New Zealand)', 'locale' => 'en_nz', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-tt' => array('language' => 'English (Trinidad)', 'locale' => 'en_tt', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-us' => array('language' => 'English (United States)', 'locale' => 'en_us', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-za' => array('language' => 'English (South Africa)', 'locale' => 'en_za', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es' => array('language' => 'Spanish (Spain - Traditional)', 'locale' => 'spa', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ar' => array('language' => 'Spanish (Argentina)', 'locale' => 'es_ar', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-bo' => array('language' => 'Spanish (Bolivia)', 'locale' => 'es_bo', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-cl' => array('language' => 'Spanish (Chile)', 'locale' => 'es_cl', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-co' => array('language' => 'Spanish (Colombia)', 'locale' => 'es_co', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-cr' => array('language' => 'Spanish (Costa Rica)', 'locale' => 'es_cr', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-do' => array('language' => 'Spanish (Dominican Republic)', 'locale' => 'es_do', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ec' => array('language' => 'Spanish (Ecuador)', 'locale' => 'es_ec', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-es' => array('language' => 'Spanish (Spain)', 'locale' => 'es_es', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-gt' => array('language' => 'Spanish (Guatemala)', 'locale' => 'es_gt', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-hn' => array('language' => 'Spanish (Honduras)', 'locale' => 'es_hn', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-mx' => array('language' => 'Spanish (Mexican)', 'locale' => 'es_mx', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ni' => array('language' => 'Spanish (Nicaragua)', 'locale' => 'es_ni', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-pa' => array('language' => 'Spanish (Panama)', 'locale' => 'es_pa', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-pe' => array('language' => 'Spanish (Peru)', 'locale' => 'es_pe', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-pr' => array('language' => 'Spanish (Puerto Rico)', 'locale' => 'es_pr', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-py' => array('language' => 'Spanish (Paraguay)', 'locale' => 'es_py', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-sv' => array('language' => 'Spanish (El Salvador)', 'locale' => 'es_sv', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-uy' => array('language' => 'Spanish (Uruguay)', 'locale' => 'es_uy', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ve' => array('language' => 'Spanish (Venezuela)', 'locale' => 'es_ve', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'et' => array('language' => 'Estonian', 'locale' => 'est', 'localeFallback' => 'est', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'eu' => array('language' => 'Basque', 'locale' => 'baq', 'localeFallback' => 'baq', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fa' => array('language' => 'Farsi', 'locale' => 'per', 'localeFallback' => 'per', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'fi' => array('language' => 'Finnish', 'locale' => 'fin', 'localeFallback' => 'fin', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fo' => array('language' => 'Faeroese', 'locale' => 'fao', 'localeFallback' => 'fao', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr' => array('language' => 'French (Standard)', 'locale' => 'fre', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-be' => array('language' => 'French (Belgium)', 'locale' => 'fr_be', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-ca' => array('language' => 'French (Canadian)', 'locale' => 'fr_ca', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-ch' => array('language' => 'French (Swiss)', 'locale' => 'fr_ch', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-fr' => array('language' => 'French (France)', 'locale' => 'fr_fr', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-lu' => array('language' => 'French (Luxembourg)', 'locale' => 'fr_lu', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ga' => array('language' => 'Irish', 'locale' => 'gle', 'localeFallback' => 'gle', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'gd' => array('language' => 'Gaelic (Scots)', 'locale' => 'gla', 'localeFallback' => 'gla', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'gd-ie' => array('language' => 'Gaelic (Irish)', 'locale' => 'gd_ie', 'localeFallback' => 'gla', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'gl' => array('language' => 'Galician', 'locale' => 'glg', 'localeFallback' => 'glg', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'he' => array('language' => 'Hebrew', 'locale' => 'heb', 'localeFallback' => 'heb', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'hi' => array('language' => 'Hindi', 'locale' => 'hin', 'localeFallback' => 'hin', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'hr' => array('language' => 'Croatian', 'locale' => 'hrv', 'localeFallback' => 'hrv', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'hu' => array('language' => 'Hungarian', 'locale' => 'hun', 'localeFallback' => 'hun', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'hy' => array('language' => 'Armenian - Armenia', 'locale' => 'hye', 'localeFallback' => 'hye', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'id' => array('language' => 'Indonesian', 'locale' => 'ind', 'localeFallback' => 'ind', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'in' => array('language' => 'Indonesian', 'locale' => 'ind', 'localeFallback' => 'ind', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'is' => array('language' => 'Icelandic', 'locale' => 'ice', 'localeFallback' => 'ice', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'it' => array('language' => 'Italian', 'locale' => 'ita', 'localeFallback' => 'ita', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'it-ch' => array('language' => 'Italian (Swiss) ', 'locale' => 'it_ch', 'localeFallback' => 'ita', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ja' => array('language' => 'Japanese', 'locale' => 'jpn', 'localeFallback' => 'jpn', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ko' => array('language' => 'Korean', 'locale' => 'kor', 'localeFallback' => 'kor', 'charset' => 'kr', 'direction' => 'ltr'), - 'ko-kp' => array('language' => 'Korea (North)', 'locale' => 'ko_kp', 'localeFallback' => 'kor', 'charset' => 'kr', 'direction' => 'ltr'), - 'ko-kr' => array('language' => 'Korea (South)', 'locale' => 'ko_kr', 'localeFallback' => 'kor', 'charset' => 'kr', 'direction' => 'ltr'), - 'koi8-r' => array('language' => 'Russian', 'locale' => 'koi8_r', 'localeFallback' => 'rus', 'charset' => 'koi8-r', 'direction' => 'ltr'), - 'lt' => array('language' => 'Lithuanian', 'locale' => 'lit', 'localeFallback' => 'lit', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'lv' => array('language' => 'Latvian', 'locale' => 'lav', 'localeFallback' => 'lav', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'mk' => array('language' => 'FYRO Macedonian', 'locale' => 'mk', 'localeFallback' => 'mac', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'mk-mk' => array('language' => 'Macedonian', 'locale' => 'mk_mk', 'localeFallback' => 'mac', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ms' => array('language' => 'Malaysian', 'locale' => 'may', 'localeFallback' => 'may', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'mt' => array('language' => 'Maltese', 'locale' => 'mlt', 'localeFallback' => 'mlt', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'n' => array('language' => 'Dutch (Standard)', 'locale' => 'dut', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'nb' => array('language' => 'Norwegian Bokmal', 'locale' => 'nob', 'localeFallback' => 'nor', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'nl' => array('language' => 'Dutch (Standard)', 'locale' => 'dut', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'nl-be' => array('language' => 'Dutch (Belgium)', 'locale' => 'nl_be', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'nn' => array('language' => 'Norwegian Nynorsk', 'locale' => 'nno', 'localeFallback' => 'nor', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'no' => array('language' => 'Norwegian', 'locale' => 'nor', 'localeFallback' => 'nor', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'p' => array('language' => 'Polish', 'locale' => 'pol', 'localeFallback' => 'pol', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'pl' => array('language' => 'Polish', 'locale' => 'pol', 'localeFallback' => 'pol', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'pt' => array('language' => 'Portuguese (Portugal)', 'locale' => 'por', 'localeFallback' => 'por', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'pt-br' => array('language' => 'Portuguese (Brazil)', 'locale' => 'pt_br', 'localeFallback' => 'por', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'rm' => array('language' => 'Rhaeto-Romanic', 'locale' => 'roh', 'localeFallback' => 'roh', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ro' => array('language' => 'Romanian', 'locale' => 'rum', 'localeFallback' => 'rum', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ro-mo' => array('language' => 'Romanian (Moldavia)', 'locale' => 'ro_mo', 'localeFallback' => 'rum', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ru' => array('language' => 'Russian', 'locale' => 'rus', 'localeFallback' => 'rus', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ru-mo' => array('language' => 'Russian (Moldavia)', 'locale' => 'ru_mo', 'localeFallback' => 'rus', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sb' => array('language' => 'Sorbian', 'locale' => 'wen', 'localeFallback' => 'wen', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sk' => array('language' => 'Slovak', 'locale' => 'slo', 'localeFallback' => 'slo', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sl' => array('language' => 'Slovenian', 'locale' => 'slv', 'localeFallback' => 'slv', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sq' => array('language' => 'Albanian', 'locale' => 'alb', 'localeFallback' => 'alb', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sr' => array('language' => 'Serbian', 'locale' => 'scc', 'localeFallback' => 'scc', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sv' => array('language' => 'Swedish', 'locale' => 'swe', 'localeFallback' => 'swe', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sv-fi' => array('language' => 'Swedish (Finland)', 'locale' => 'sv_fi', 'localeFallback' => 'swe', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sx' => array('language' => 'Sutu', 'locale' => 'sx', 'localeFallback' => 'sx', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sz' => array('language' => 'Sami (Lappish)', 'locale' => 'smi', 'localeFallback' => 'smi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'th' => array('language' => 'Thai', 'locale' => 'tha', 'localeFallback' => 'tha', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'tn' => array('language' => 'Tswana', 'locale' => 'tsn', 'localeFallback' => 'tsn', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'tr' => array('language' => 'Turkish', 'locale' => 'tur', 'localeFallback' => 'tur', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ts' => array('language' => 'Tsonga', 'locale' => 'tso', 'localeFallback' => 'tso', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'uk' => array('language' => 'Ukrainian', 'locale' => 'ukr', 'localeFallback' => 'ukr', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ur' => array('language' => 'Urdu', 'locale' => 'urd', 'localeFallback' => 'urd', 'charset' => 'utf-8', 'direction' => 'rtl'), - 've' => array('language' => 'Venda', 'locale' => 'ven', 'localeFallback' => 'ven', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'vi' => array('language' => 'Vietnamese', 'locale' => 'vie', 'localeFallback' => 'vie', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'cy' => array('language' => 'Welsh', 'locale' => 'cym', 'localeFallback' => 'cym', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'xh' => array('language' => 'Xhosa', 'locale' => 'xho', 'localeFallback' => 'xho', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'yi' => array('language' => 'Yiddish', 'locale' => 'yid', 'localeFallback' => 'yid', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zh' => array('language' => 'Chinese', 'locale' => 'chi', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zh-cn' => array('language' => 'Chinese (PRC)', 'locale' => 'zh_cn', 'localeFallback' => 'chi', 'charset' => 'GB2312', 'direction' => 'ltr'), - 'zh-hk' => array('language' => 'Chinese (Hong Kong)', 'locale' => 'zh_hk', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zh-sg' => array('language' => 'Chinese (Singapore)', 'locale' => 'zh_sg', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zh-tw' => array('language' => 'Chinese (Taiwan)', 'locale' => 'zh_tw', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zu' => array('language' => 'Zulu', 'locale' => 'zul', 'localeFallback' => 'zul', 'charset' => 'utf-8', 'direction' => 'ltr')); - -/** - * Class constructor - */ - public function __construct() { - if (defined('DEFAULT_LANGUAGE')) { - $this->default = DEFAULT_LANGUAGE; - } - } - -/** - * Gets the settings for $language. - * If $language is null it attempt to get settings from L10n::_autoLanguage(); if this fails - * the method will get the settings from L10n::_setLanguage(); - * - * @param string $language Language (if null will use DEFAULT_LANGUAGE if defined) - * @return mixed - */ - public function get($language = null) { - if ($language !== null) { - return $this->_setLanguage($language); - } elseif ($this->_autoLanguage() === false) { - return $this->_setLanguage(); - } - } - -/** - * Sets the class vars to correct values for $language. - * If $language is null it will use the DEFAULT_LANGUAGE if defined - * - * @param string $language Language (if null will use DEFAULT_LANGUAGE if defined) - * @return mixed - */ - protected function _setLanguage($language = null) { - $langKey = null; - if ($language !== null && isset($this->_l10nMap[$language]) && isset($this->_l10nCatalog[$this->_l10nMap[$language]])) { - $langKey = $this->_l10nMap[$language]; - } elseif ($language !== null && isset($this->_l10nCatalog[$language])) { - $langKey = $language; - } elseif (defined('DEFAULT_LANGUAGE')) { - $langKey = $language = DEFAULT_LANGUAGE; - } - - if ($langKey !== null && isset($this->_l10nCatalog[$langKey])) { - $this->language = $this->_l10nCatalog[$langKey]['language']; - $this->languagePath = array( - $this->_l10nCatalog[$langKey]['locale'], - $this->_l10nCatalog[$langKey]['localeFallback'] - ); - $this->lang = $language; - $this->locale = $this->_l10nCatalog[$langKey]['locale']; - $this->charset = $this->_l10nCatalog[$langKey]['charset']; - $this->direction = $this->_l10nCatalog[$langKey]['direction']; - } else { - $this->lang = $language; - $this->languagePath = array($language); - } - - if ($this->default) { - if (isset($this->_l10nMap[$this->default]) && isset($this->_l10nCatalog[$this->_l10nMap[$this->default]])) { - $this->languagePath[] = $this->_l10nCatalog[$this->_l10nMap[$this->default]]['localeFallback']; - } elseif (isset($this->_l10nCatalog[$this->default])) { - $this->languagePath[] = $this->_l10nCatalog[$this->default]['localeFallback']; - } - } - $this->found = true; - - if (Configure::read('Config.language') === null) { - Configure::write('Config.language', $this->lang); - } - - if ($language) { - return $language; - } - } - -/** - * Attempts to find the locale settings based on the HTTP_ACCEPT_LANGUAGE variable - * - * @return boolean Success - */ - protected function _autoLanguage() { - $_detectableLanguages = CakeRequest::acceptLanguage(); - foreach ($_detectableLanguages as $key => $langKey) { - if (isset($this->_l10nCatalog[$langKey])) { - $this->_setLanguage($langKey); - return true; - } elseif (strpos($langKey, '-') !== false) { - $langKey = substr($langKey, 0, 2); - if (isset($this->_l10nCatalog[$langKey])) { - $this->_setLanguage($langKey); - return true; - } - } - } - return false; - } - -/** - * Attempts to find locale for language, or language for locale - * - * @param mixed $mixed 2/3 char string (language/locale), array of those strings, or null - * @return mixed string language/locale, array of those values, whole map as an array, - * or false when language/locale doesn't exist - */ - public function map($mixed = null) { - if (is_array($mixed)) { - $result = array(); - foreach ($mixed as $_mixed) { - if ($_result = $this->map($_mixed)) { - $result[$_mixed] = $_result; - } - } - return $result; - } elseif (is_string($mixed)) { - if (strlen($mixed) === 2 && in_array($mixed, $this->_l10nMap)) { - return array_search($mixed, $this->_l10nMap); - } elseif (isset($this->_l10nMap[$mixed])) { - return $this->_l10nMap[$mixed]; - } - return false; - } - return $this->_l10nMap; - } - -/** - * Attempts to find catalog record for requested language - * - * @param mixed $language string requested language, array of requested languages, or null for whole catalog - * @return mixed array catalog record for requested language, array of catalog records, whole catalog, - * or false when language doesn't exist - */ - public function catalog($language = null) { - if (is_array($language)) { - $result = array(); - foreach ($language as $_language) { - if ($_result = $this->catalog($_language)) { - $result[$_language] = $_result; - } - } - return $result; - } elseif (is_string($language)) { - if (isset($this->_l10nCatalog[$language])) { - return $this->_l10nCatalog[$language]; - } elseif (isset($this->_l10nMap[$language]) && isset($this->_l10nCatalog[$this->_l10nMap[$language]])) { - return $this->_l10nCatalog[$this->_l10nMap[$language]]; - } - return false; - } - return $this->_l10nCatalog; - } - -} diff --git a/lib/Cake/I18n/Multibyte.php b/lib/Cake/I18n/Multibyte.php deleted file mode 100644 index b741ef4d604..00000000000 --- a/lib/Cake/I18n/Multibyte.php +++ /dev/null @@ -1,1134 +0,0 @@ - 1) { - $matches[$needle[0]] = $matches[$needle[0]] - 1; - } elseif ($i === $needleCount) { - $found = true; - } - } - - if (!$found && isset($haystack[$position])) { - $parts[] = $haystack[$position]; - unset($haystack[$position]); - } - $position++; - } - - if ($found && $part && !empty($parts)) { - return Multibyte::ascii($parts); - } elseif ($found && !empty($haystack)) { - return Multibyte::ascii($haystack); - } - return false; - } - -/** - * Finds the last occurrence of a character in a string within another, case insensitive. - * - * @param string $haystack The string from which to get the last occurrence of $needle. - * @param string $needle The string to find in $haystack. - * @param boolean $part Determines which portion of $haystack this function returns. - * If set to true, it returns all of $haystack from the beginning to the last occurrence of $needle. - * If set to false, it returns all of $haystack from the last occurrence of $needle to the end, - * Default value is false. - * @return string|boolean The portion of $haystack. or false if $needle is not found. - */ - public static function strrichr($haystack, $needle, $part = false) { - $check = Multibyte::strtoupper($haystack); - $check = Multibyte::utf8($check); - $found = false; - - $haystack = Multibyte::utf8($haystack); - $haystackCount = count($haystack); - - $matches = array_count_values($check); - - $needle = Multibyte::strtoupper($needle); - $needle = Multibyte::utf8($needle); - $needleCount = count($needle); - - $parts = array(); - $position = 0; - - while (($found === false) && ($position < $haystackCount)) { - if (isset($needle[0]) && $needle[0] === $check[$position]) { - for ($i = 1; $i < $needleCount; $i++) { - if ($needle[$i] !== $check[$position + $i]) { - if ($needle[$i] === $check[($position + $i) - 1]) { - $found = true; - } - unset($parts[$position - 1]); - $haystack = array_merge(array($haystack[$position]), $haystack); - break; - } - } - if (isset($matches[$needle[0]]) && $matches[$needle[0]] > 1) { - $matches[$needle[0]] = $matches[$needle[0]] - 1; - } elseif ($i === $needleCount) { - $found = true; - } - } - - if (!$found && isset($haystack[$position])) { - $parts[] = $haystack[$position]; - unset($haystack[$position]); - } - $position++; - } - - if ($found && $part && !empty($parts)) { - return Multibyte::ascii($parts); - } elseif ($found && !empty($haystack)) { - return Multibyte::ascii($haystack); - } - return false; - } - -/** - * Finds position of last occurrence of a string within another, case insensitive - * - * @param string $haystack The string from which to get the position of the last occurrence of $needle. - * @param string $needle The string to find in $haystack. - * @param integer $offset The position in $haystack to start searching. - * @return integer|boolean The numeric position of the last occurrence of $needle in the $haystack string, - * or false if $needle is not found. - */ - public static function strripos($haystack, $needle, $offset = 0) { - if (Multibyte::checkMultibyte($haystack)) { - $found = false; - $haystack = Multibyte::strtoupper($haystack); - $haystack = Multibyte::utf8($haystack); - $haystackCount = count($haystack); - - $matches = array_count_values($haystack); - - $needle = Multibyte::strtoupper($needle); - $needle = Multibyte::utf8($needle); - $needleCount = count($needle); - - $position = $offset; - - while (($found === false) && ($position < $haystackCount)) { - if (isset($needle[0]) && $needle[0] === $haystack[$position]) { - for ($i = 1; $i < $needleCount; $i++) { - if ($needle[$i] !== $haystack[$position + $i]) { - if ($needle[$i] === $haystack[($position + $i) - 1]) { - $position--; - $found = true; - continue; - } - } - } - - if (!$offset && isset($matches[$needle[0]]) && $matches[$needle[0]] > 1) { - $matches[$needle[0]] = $matches[$needle[0]] - 1; - } elseif ($i === $needleCount) { - $found = true; - $position--; - } - } - $position++; - } - return ($found) ? $position : false; - } - return strripos($haystack, $needle, $offset); - } - -/** - * Find position of last occurrence of a string in a string. - * - * @param string $haystack The string being checked, for the last occurrence of $needle. - * @param string $needle The string to find in $haystack. - * @param integer $offset May be specified to begin searching an arbitrary number of characters into the string. - * Negative values will stop searching at an arbitrary point prior to the end of the string. - * @return integer|boolean The numeric position of the last occurrence of $needle in the $haystack string. - * If $needle is not found, it returns false. - */ - public static function strrpos($haystack, $needle, $offset = 0) { - if (Multibyte::checkMultibyte($haystack)) { - $found = false; - - $haystack = Multibyte::utf8($haystack); - $haystackCount = count($haystack); - - $matches = array_count_values($haystack); - - $needle = Multibyte::utf8($needle); - $needleCount = count($needle); - - $position = $offset; - - while (($found === false) && ($position < $haystackCount)) { - if (isset($needle[0]) && $needle[0] === $haystack[$position]) { - for ($i = 1; $i < $needleCount; $i++) { - if ($needle[$i] !== $haystack[$position + $i]) { - if ($needle[$i] === $haystack[($position + $i) - 1]) { - $position--; - $found = true; - continue; - } - } - } - - if (!$offset && isset($matches[$needle[0]]) && $matches[$needle[0]] > 1) { - $matches[$needle[0]] = $matches[$needle[0]] - 1; - } elseif ($i === $needleCount) { - $found = true; - $position--; - } - } - $position++; - } - return ($found) ? $position : false; - } - return strrpos($haystack, $needle, $offset); - } - -/** - * Finds first occurrence of a string within another - * - * @param string $haystack The string from which to get the first occurrence of $needle. - * @param string $needle The string to find in $haystack - * @param boolean $part Determines which portion of $haystack this function returns. - * If set to true, it returns all of $haystack from the beginning to the first occurrence of $needle. - * If set to false, it returns all of $haystack from the first occurrence of $needle to the end, - * Default value is FALSE. - * @return string|boolean The portion of $haystack, or true if $needle is not found. - */ - public static function strstr($haystack, $needle, $part = false) { - $php = (PHP_VERSION < 5.3); - - if (($php && $part) || Multibyte::checkMultibyte($haystack)) { - $check = Multibyte::utf8($haystack); - $found = false; - - $haystack = Multibyte::utf8($haystack); - $haystackCount = count($haystack); - - $needle = Multibyte::utf8($needle); - $needleCount = count($needle); - - $parts = array(); - $position = 0; - - while (($found === false) && ($position < $haystackCount)) { - if (isset($needle[0]) && $needle[0] === $check[$position]) { - for ($i = 1; $i < $needleCount; $i++) { - if ($needle[$i] !== $check[$position + $i]) { - break; - } - } - if ($i === $needleCount) { - $found = true; - } - } - if (!$found) { - $parts[] = $haystack[$position]; - unset($haystack[$position]); - } - $position++; - } - - if ($found && $part && !empty($parts)) { - return Multibyte::ascii($parts); - } elseif ($found && !empty($haystack)) { - return Multibyte::ascii($haystack); - } - return false; - } - - if (!$php) { - return strstr($haystack, $needle, $part); - } - return strstr($haystack, $needle); - } - -/** - * Make a string lowercase - * - * @param string $string The string being lowercased. - * @return string with all alphabetic characters converted to lowercase. - */ - public static function strtolower($string) { - $utf8Map = Multibyte::utf8($string); - - $length = count($utf8Map); - $lowerCase = array(); - - for ($i = 0; $i < $length; $i++) { - $char = $utf8Map[$i]; - - if ($char < 128) { - $str = strtolower(chr($char)); - $strlen = strlen($str); - for ($ii = 0; $ii < $strlen; $ii++) { - $lower = ord(substr($str, $ii, 1)); - } - $lowerCase[] = $lower; - $matched = true; - } else { - $matched = false; - $keys = self::_find($char, 'upper'); - - if (!empty($keys)) { - foreach ($keys as $key => $value) { - if ($keys[$key]['upper'] == $char && count($keys[$key]['lower'][0]) === 1) { - $lowerCase[] = $keys[$key]['lower'][0]; - $matched = true; - break 1; - } - } - } - } - if ($matched === false) { - $lowerCase[] = $char; - } - } - return Multibyte::ascii($lowerCase); - } - -/** - * Make a string uppercase - * - * @param string $string The string being uppercased. - * @return string with all alphabetic characters converted to uppercase. - */ - public static function strtoupper($string) { - $utf8Map = Multibyte::utf8($string); - - $length = count($utf8Map); - $replaced = array(); - $upperCase = array(); - - for ($i = 0; $i < $length; $i++) { - $char = $utf8Map[$i]; - - if ($char < 128) { - $str = strtoupper(chr($char)); - $strlen = strlen($str); - for ($ii = 0; $ii < $strlen; $ii++) { - $upper = ord(substr($str, $ii, 1)); - } - $upperCase[] = $upper; - $matched = true; - - } else { - $matched = false; - $keys = self::_find($char); - $keyCount = count($keys); - - if (!empty($keys)) { - foreach ($keys as $key => $value) { - $matched = false; - $replace = 0; - if ($length > 1 && count($keys[$key]['lower']) > 1) { - $j = 0; - - for ($ii = 0, $count = count($keys[$key]['lower']); $ii < $count; $ii++) { - $nextChar = $utf8Map[$i + $ii]; - - if (isset($nextChar) && ($nextChar == $keys[$key]['lower'][$j + $ii])) { - $replace++; - } - } - if ($replace == $count) { - $upperCase[] = $keys[$key]['upper']; - $replaced = array_merge($replaced, array_values($keys[$key]['lower'])); - $matched = true; - break 1; - } - } elseif ($length > 1 && $keyCount > 1) { - $j = 0; - for ($ii = 1; $ii < $keyCount; $ii++) { - $nextChar = $utf8Map[$i + $ii - 1]; - - if (in_array($nextChar, $keys[$ii]['lower'])) { - - for ($jj = 0, $count = count($keys[$ii]['lower']); $jj < $count; $jj++) { - $nextChar = $utf8Map[$i + $jj]; - - if (isset($nextChar) && ($nextChar == $keys[$ii]['lower'][$j + $jj])) { - $replace++; - } - } - if ($replace == $count) { - $upperCase[] = $keys[$ii]['upper']; - $replaced = array_merge($replaced, array_values($keys[$ii]['lower'])); - $matched = true; - break 2; - } - } - } - } - if ($keys[$key]['lower'][0] == $char) { - $upperCase[] = $keys[$key]['upper']; - $matched = true; - break 1; - } - } - } - } - if ($matched === false && !in_array($char, $replaced, true)) { - $upperCase[] = $char; - } - } - return Multibyte::ascii($upperCase); - } - -/** - * Count the number of substring occurrences - * - * @param string $haystack The string being checked. - * @param string $needle The string being found. - * @return integer The number of times the $needle substring occurs in the $haystack string. - */ - public static function substrCount($haystack, $needle) { - $count = 0; - $haystack = Multibyte::utf8($haystack); - $haystackCount = count($haystack); - $matches = array_count_values($haystack); - $needle = Multibyte::utf8($needle); - $needleCount = count($needle); - - if ($needleCount === 1 && isset($matches[$needle[0]])) { - return $matches[$needle[0]]; - } - - for ($i = 0; $i < $haystackCount; $i++) { - if (isset($needle[0]) && $needle[0] === $haystack[$i]) { - for ($ii = 1; $ii < $needleCount; $ii++) { - if ($needle[$ii] === $haystack[$i + 1]) { - if ((isset($needle[$ii + 1]) && $haystack[$i + 2]) && $needle[$ii + 1] !== $haystack[$i + 2]) { - $count--; - } else { - $count++; - } - } - } - } - } - return $count; - } - -/** - * Get part of string - * - * @param string $string The string being checked. - * @param integer $start The first position used in $string. - * @param integer $length The maximum length of the returned string. - * @return string The portion of $string specified by the $string and $length parameters. - */ - public static function substr($string, $start, $length = null) { - if ($start === 0 && $length === null) { - return $string; - } - - $string = Multibyte::utf8($string); - - for ($i = 1; $i <= $start; $i++) { - unset($string[$i - 1]); - } - - if ($length === null || count($string) < $length) { - return Multibyte::ascii($string); - } - $string = array_values($string); - - $value = array(); - for ($i = 0; $i < $length; $i++) { - $value[] = $string[$i]; - } - return Multibyte::ascii($value); - } - -/** - * Prepare a string for mail transport, using the provided encoding - * - * @param string $string value to encode - * @param string $charset charset to use for encoding. defaults to UTF-8 - * @param string $newline - * @return string - * @TODO: add support for 'Q'('Quoted Printable') encoding - */ - public static function mimeEncode($string, $charset = null, $newline = "\r\n") { - if (!Multibyte::checkMultibyte($string) && strlen($string) < 75) { - return $string; - } - - if (empty($charset)) { - $charset = Configure::read('App.encoding'); - } - $charset = strtoupper($charset); - - $start = '=?' . $charset . '?B?'; - $end = '?='; - $spacer = $end . $newline . ' ' . $start; - - $length = 75 - strlen($start) - strlen($end); - $length = $length - ($length % 4); - if ($charset == 'UTF-8') { - $parts = array(); - $maxchars = floor(($length * 3) / 4); - while (strlen($string) > $maxchars) { - $i = (int)$maxchars; - $test = ord($string[$i]); - while ($test >= 128 && $test <= 191) { - $i--; - $test = ord($string[$i]); - } - $parts[] = base64_encode(substr($string, 0, $i)); - $string = substr($string, $i); - } - $parts[] = base64_encode($string); - $string = implode($spacer, $parts); - } else { - $string = chunk_split(base64_encode($string), $length, $spacer); - $string = preg_replace('/' . preg_quote($spacer) . '$/', '', $string); - } - return $start . $string . $end; - } - -/** - * Return the Code points range for Unicode characters - * - * @param integer $decimal - * @return string - */ - protected static function _codepoint($decimal) { - if ($decimal > 128 && $decimal < 256) { - $return = '0080_00ff'; // Latin-1 Supplement - } elseif ($decimal < 384) { - $return = '0100_017f'; // Latin Extended-A - } elseif ($decimal < 592) { - $return = '0180_024F'; // Latin Extended-B - } elseif ($decimal < 688) { - $return = '0250_02af'; // IPA Extensions - } elseif ($decimal >= 880 && $decimal < 1024) { - $return = '0370_03ff'; // Greek and Coptic - } elseif ($decimal < 1280) { - $return = '0400_04ff'; // Cyrillic - } elseif ($decimal < 1328) { - $return = '0500_052f'; // Cyrillic Supplement - } elseif ($decimal < 1424) { - $return = '0530_058f'; // Armenian - } elseif ($decimal >= 7680 && $decimal < 7936) { - $return = '1e00_1eff'; // Latin Extended Additional - } elseif ($decimal < 8192) { - $return = '1f00_1fff'; // Greek Extended - } elseif ($decimal >= 8448 && $decimal < 8528) { - $return = '2100_214f'; // Letterlike Symbols - } elseif ($decimal < 8592) { - $return = '2150_218f'; // Number Forms - } elseif ($decimal >= 9312 && $decimal < 9472) { - $return = '2460_24ff'; // Enclosed Alphanumerics - } elseif ($decimal >= 11264 && $decimal < 11360) { - $return = '2c00_2c5f'; // Glagolitic - } elseif ($decimal < 11392) { - $return = '2c60_2c7f'; // Latin Extended-C - } elseif ($decimal < 11520) { - $return = '2c80_2cff'; // Coptic - } elseif ($decimal >= 65280 && $decimal < 65520) { - $return = 'ff00_ffef'; // Halfwidth and Fullwidth Forms - } else { - $return = false; - } - self::$_codeRange[$decimal] = $return; - return $return; - } - -/** - * Find the related code folding values for $char - * - * @param integer $char decimal value of character - * @param string $type - * @return array - */ - protected static function _find($char, $type = 'lower') { - $found = array(); - if (!isset(self::$_codeRange[$char])) { - $range = self::_codepoint($char); - if ($range === false) { - return null; - } - if (!Configure::configured('_cake_core_')) { - App::uses('PhpReader', 'Configure'); - Configure::config('_cake_core_', new PhpReader(CAKE . 'Config' . DS)); - } - Configure::load('unicode' . DS . 'casefolding' . DS . $range, '_cake_core_'); - self::$_caseFold[$range] = Configure::read($range); - Configure::delete($range); - } - - if (!self::$_codeRange[$char]) { - return null; - } - self::$_table = self::$_codeRange[$char]; - $count = count(self::$_caseFold[self::$_table]); - - for ($i = 0; $i < $count; $i++) { - if ($type === 'lower' && self::$_caseFold[self::$_table][$i][$type][0] === $char) { - $found[] = self::$_caseFold[self::$_table][$i]; - } elseif ($type === 'upper' && self::$_caseFold[self::$_table][$i][$type] === $char) { - $found[] = self::$_caseFold[self::$_table][$i]; - } - } - return $found; - } - -/** - * Check the $string for multibyte characters - * @param string $string value to test - * @return boolean - */ - public static function checkMultibyte($string) { - $length = strlen($string); - - for ($i = 0; $i < $length; $i++ ) { - $value = ord(($string[$i])); - if ($value > 128) { - return true; - } - } - return false; - } - -} diff --git a/lib/Cake/LICENSE.txt b/lib/Cake/LICENSE.txt deleted file mode 100644 index b4d0c66a26c..00000000000 --- a/lib/Cake/LICENSE.txt +++ /dev/null @@ -1,22 +0,0 @@ -The MIT License - -CakePHP(tm) : The Rapid Development PHP Framework (http://cakephp.org) -Copyright 2005-2012, Cake Software Foundation, Inc. - -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. \ No newline at end of file diff --git a/lib/Cake/Log/CakeLog.php b/lib/Cake/Log/CakeLog.php deleted file mode 100644 index 5b718ebcb4c..00000000000 --- a/lib/Cake/Log/CakeLog.php +++ /dev/null @@ -1,209 +0,0 @@ - 'FileLog'));` - * - * See the documentation on CakeLog::config() for more detail. - * - * ### Writing to the log - * - * You write to the logs using CakeLog::write(). See its documentation for more information. - * - * @package Cake.Log - */ -class CakeLog { - -/** - * An array of connected streams. - * Each stream represents a callable that will be called when write() is called. - * - * @var array - */ - protected static $_streams = array(); - -/** - * Configure and add a new logging stream to CakeLog - * You can use add loggers from app/Log/Engine use app.loggername, or any plugin/Log/Engine using plugin.loggername. - * - * ### Usage: - * - * {{{ - * CakeLog::config('second_file', array( - * 'engine' => 'FileLog', - * 'path' => '/var/logs/my_app/' - * )); - * }}} - * - * Will configure a FileLog instance to use the specified path. All options that are not `engine` - * are passed onto the logging adapter, and handled there. Any class can be configured as a logging - * adapter as long as it implements the methods in CakeLogInterface. - * - * @param string $key The keyname for this logger, used to remove the logger later. - * @param array $config Array of configuration information for the logger - * @return boolean success of configuration. - * @throws CakeLogException - */ - public static function config($key, $config) { - if (empty($config['engine'])) { - throw new CakeLogException(__d('cake_dev', 'Missing logger classname')); - } - $loggerName = $config['engine']; - unset($config['engine']); - $className = self::_getLogger($loggerName); - $logger = new $className($config); - if (!$logger instanceof CakeLogInterface) { - throw new CakeLogException(sprintf( - __d('cake_dev', 'logger class %s does not implement a write method.'), $loggerName - )); - } - self::$_streams[$key] = $logger; - return true; - } - -/** - * Attempts to import a logger class from the various paths it could be on. - * Checks that the logger class implements a write method as well. - * - * @param string $loggerName the plugin.className of the logger class you want to build. - * @return mixed boolean false on any failures, string of classname to use if search was successful. - * @throws CakeLogException - */ - protected static function _getLogger($loggerName) { - list($plugin, $loggerName) = pluginSplit($loggerName, true); - - App::uses($loggerName, $plugin . 'Log/Engine'); - if (!class_exists($loggerName)) { - throw new CakeLogException(__d('cake_dev', 'Could not load class %s', $loggerName)); - } - return $loggerName; - } - -/** - * Returns the keynames of the currently active streams - * - * @return array Array of configured log streams. - */ - public static function configured() { - return array_keys(self::$_streams); - } - -/** - * Removes a stream from the active streams. Once a stream has been removed - * it will no longer have messages sent to it. - * - * @param string $streamName Key name of a configured stream to remove. - * @return void - */ - public static function drop($streamName) { - unset(self::$_streams[$streamName]); - } - -/** - * Configures the automatic/default stream a FileLog. - * - * @return void - */ - protected static function _autoConfig() { - self::_getLogger('FileLog'); - self::$_streams['default'] = new FileLog(array('path' => LOGS)); - } - -/** - * Writes the given message and type to all of the configured log adapters. - * Configured adapters are passed both the $type and $message variables. $type - * is one of the following strings/values. - * - * ### Types: - * - * - `LOG_WARNING` => 'warning', - * - `LOG_NOTICE` => 'notice', - * - `LOG_INFO` => 'info', - * - `LOG_DEBUG` => 'debug', - * - `LOG_ERR` => 'error', - * - `LOG_ERROR` => 'error' - * - * ### Usage: - * - * Write a message to the 'warning' log: - * - * `CakeLog::write('warning', 'Stuff is broken here');` - * - * @param string $type Type of message being written - * @param string $message Message content to log - * @return boolean Success - */ - public static function write($type, $message) { - if (!defined('LOG_ERROR')) { - define('LOG_ERROR', 2); - } - if (!defined('LOG_ERR')) { - define('LOG_ERR', LOG_ERROR); - } - $levels = array( - LOG_WARNING => 'warning', - LOG_NOTICE => 'notice', - LOG_INFO => 'info', - LOG_DEBUG => 'debug', - LOG_ERR => 'error', - LOG_ERROR => 'error' - ); - - if (is_int($type) && isset($levels[$type])) { - $type = $levels[$type]; - } - if (empty(self::$_streams)) { - self::_autoConfig(); - } - foreach (self::$_streams as $logger) { - $logger->write($type, $message); - } - return true; - } - -} diff --git a/lib/Cake/Log/CakeLogInterface.php b/lib/Cake/Log/CakeLogInterface.php deleted file mode 100644 index d31e863f8f2..00000000000 --- a/lib/Cake/Log/CakeLogInterface.php +++ /dev/null @@ -1,37 +0,0 @@ - LOGS); - $this->_path = $options['path']; - } - -/** - * Implements writing to log files. - * - * @param string $type The type of log you are making. - * @param string $message The message you want to log. - * @return boolean success of write. - */ - public function write($type, $message) { - $debugTypes = array('notice', 'info', 'debug'); - - if ($type == 'error' || $type == 'warning') { - $filename = $this->_path . 'error.log'; - } elseif (in_array($type, $debugTypes)) { - $filename = $this->_path . 'debug.log'; - } else { - $filename = $this->_path . $type . '.log'; - } - $output = date('Y-m-d H:i:s') . ' ' . ucfirst($type) . ': ' . $message . "\n"; - return file_put_contents($filename, $output, FILE_APPEND); - } - -} diff --git a/lib/Cake/Model/AclNode.php b/lib/Cake/Model/AclNode.php deleted file mode 100644 index a832f6526a0..00000000000 --- a/lib/Cake/Model/AclNode.php +++ /dev/null @@ -1,182 +0,0 @@ - array('type' => 'nested')); - -/** - * Constructor - * - */ - public function __construct() { - $config = Configure::read('Acl.database'); - if (isset($config)) { - $this->useDbConfig = $config; - } - parent::__construct(); - } - -/** - * Retrieves the Aro/Aco node for this model - * - * @param mixed $ref Array with 'model' and 'foreign_key', model object, or string value - * @return array Node found in database - * @throws CakeException when binding to a model that doesn't exist. - */ - public function node($ref = null) { - $db = $this->getDataSource(); - $type = $this->alias; - $result = null; - - if (!empty($this->useTable)) { - $table = $this->useTable; - } else { - $table = Inflector::pluralize(Inflector::underscore($type)); - } - - if (empty($ref)) { - return null; - } elseif (is_string($ref)) { - $path = explode('/', $ref); - $start = $path[0]; - unset($path[0]); - - $queryData = array( - 'conditions' => array( - $db->name("{$type}.lft") . ' <= ' . $db->name("{$type}0.lft"), - $db->name("{$type}.rght") . ' >= ' . $db->name("{$type}0.rght")), - 'fields' => array('id', 'parent_id', 'model', 'foreign_key', 'alias'), - 'joins' => array(array( - 'table' => $table, - 'alias' => "{$type}0", - 'type' => 'LEFT', - 'conditions' => array("{$type}0.alias" => $start) - )), - 'order' => $db->name("{$type}.lft") . ' DESC' - ); - - foreach ($path as $i => $alias) { - $j = $i - 1; - - $queryData['joins'][] = array( - 'table' => $table, - 'alias' => "{$type}{$i}", - 'type' => 'LEFT', - 'conditions' => array( - $db->name("{$type}{$i}.lft") . ' > ' . $db->name("{$type}{$j}.lft"), - $db->name("{$type}{$i}.rght") . ' < ' . $db->name("{$type}{$j}.rght"), - $db->name("{$type}{$i}.alias") . ' = ' . $db->value($alias, 'string'), - $db->name("{$type}{$j}.id") . ' = ' . $db->name("{$type}{$i}.parent_id") - ) - ); - - $queryData['conditions'] = array('or' => array( - $db->name("{$type}.lft") . ' <= ' . $db->name("{$type}0.lft") . ' AND ' . $db->name("{$type}.rght") . ' >= ' . $db->name("{$type}0.rght"), - $db->name("{$type}.lft") . ' <= ' . $db->name("{$type}{$i}.lft") . ' AND ' . $db->name("{$type}.rght") . ' >= ' . $db->name("{$type}{$i}.rght")) - ); - } - $result = $db->read($this, $queryData, -1); - $path = array_values($path); - - if ( - !isset($result[0][$type]) || - (!empty($path) && $result[0][$type]['alias'] != $path[count($path) - 1]) || - (empty($path) && $result[0][$type]['alias'] != $start) - ) { - return false; - } - } elseif (is_object($ref) && is_a($ref, 'Model')) { - $ref = array('model' => $ref->name, 'foreign_key' => $ref->id); - } elseif (is_array($ref) && !(isset($ref['model']) && isset($ref['foreign_key']))) { - $name = key($ref); - list($plugin, $alias) = pluginSplit($name); - - $model = ClassRegistry::init(array('class' => $name, 'alias' => $alias)); - - if (empty($model)) { - throw new CakeException('cake_dev', "Model class '%s' not found in AclNode::node() when trying to bind %s object", $type, $this->alias); - } - - $tmpRef = null; - if (method_exists($model, 'bindNode')) { - $tmpRef = $model->bindNode($ref); - } - if (empty($tmpRef)) { - $ref = array('model' => $alias, 'foreign_key' => $ref[$name][$model->primaryKey]); - } else { - if (is_string($tmpRef)) { - return $this->node($tmpRef); - } - $ref = $tmpRef; - } - } - if (is_array($ref)) { - if (is_array(current($ref)) && is_string(key($ref))) { - $name = key($ref); - $ref = current($ref); - } - foreach ($ref as $key => $val) { - if (strpos($key, $type) !== 0 && strpos($key, '.') === false) { - unset($ref[$key]); - $ref["{$type}0.{$key}"] = $val; - } - } - $queryData = array( - 'conditions' => $ref, - 'fields' => array('id', 'parent_id', 'model', 'foreign_key', 'alias'), - 'joins' => array(array( - 'table' => $table, - 'alias' => "{$type}0", - 'type' => 'LEFT', - 'conditions' => array( - $db->name("{$type}.lft") . ' <= ' . $db->name("{$type}0.lft"), - $db->name("{$type}.rght") . ' >= ' . $db->name("{$type}0.rght") - ) - )), - 'order' => $db->name("{$type}.lft") . ' DESC' - ); - $result = $db->read($this, $queryData, -1); - - if (!$result) { - throw new CakeException(__d('cake_dev', "AclNode::node() - Couldn't find %s node identified by \"%s\"", $type, print_r($ref, true))); - } - } - return $result; - } - -} diff --git a/lib/Cake/Model/Aco.php b/lib/Cake/Model/Aco.php deleted file mode 100644 index 0ee696e070b..00000000000 --- a/lib/Cake/Model/Aco.php +++ /dev/null @@ -1,41 +0,0 @@ - array('with' => 'Permission')); -} \ No newline at end of file diff --git a/lib/Cake/Model/AcoAction.php b/lib/Cake/Model/AcoAction.php deleted file mode 100644 index 2783600b9c4..00000000000 --- a/lib/Cake/Model/AcoAction.php +++ /dev/null @@ -1,41 +0,0 @@ - array('with' => 'Permission')); -} diff --git a/lib/Cake/Model/Behavior/AclBehavior.php b/lib/Cake/Model/Behavior/AclBehavior.php deleted file mode 100644 index 0998784ed80..00000000000 --- a/lib/Cake/Model/Behavior/AclBehavior.php +++ /dev/null @@ -1,141 +0,0 @@ - 'Aro', 'controlled' => 'Aco', 'both' => array('Aro', 'Aco')); - -/** - * Sets up the configuration for the model, and loads ACL models if they haven't been already - * - * @param Model $model - * @param array $config - * @return void - */ - public function setup(Model $model, $config = array()) { - if (isset($config[0])) { - $config['type'] = $config[0]; - unset($config[0]); - } - $this->settings[$model->name] = array_merge(array('type' => 'controlled'), $config); - $this->settings[$model->name]['type'] = strtolower($this->settings[$model->name]['type']); - - $types = $this->_typeMaps[$this->settings[$model->name]['type']]; - - if (!is_array($types)) { - $types = array($types); - } - foreach ($types as $type) { - $model->{$type} = ClassRegistry::init($type); - } - if (!method_exists($model, 'parentNode')) { - trigger_error(__d('cake_dev', 'Callback parentNode() not defined in %s', $model->alias), E_USER_WARNING); - } - } - -/** - * Retrieves the Aro/Aco node for this model - * - * @param Model $model - * @param mixed $ref - * @param string $type Only needed when Acl is set up as 'both', specify 'Aro' or 'Aco' to get the correct node - * @return array - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/acl.html#node - */ - public function node(Model $model, $ref = null, $type = null) { - if (empty($type)) { - $type = $this->_typeMaps[$this->settings[$model->name]['type']]; - if (is_array($type)) { - trigger_error(__d('cake_dev', 'AclBehavior is setup with more then one type, please specify type parameter for node()'), E_USER_WARNING); - return null; - } - } - if (empty($ref)) { - $ref = array('model' => $model->name, 'foreign_key' => $model->id); - } - return $model->{$type}->node($ref); - } - -/** - * Creates a new ARO/ACO node bound to this record - * - * @param Model $model - * @param boolean $created True if this is a new record - * @return void - */ - public function afterSave(Model $model, $created) { - $types = $this->_typeMaps[$this->settings[$model->name]['type']]; - if (!is_array($types)) { - $types = array($types); - } - foreach ($types as $type) { - $parent = $model->parentNode(); - if (!empty($parent)) { - $parent = $this->node($model, $parent, $type); - } - $data = array( - 'parent_id' => isset($parent[0][$type]['id']) ? $parent[0][$type]['id'] : null, - 'model' => $model->name, - 'foreign_key' => $model->id - ); - if (!$created) { - $node = $this->node($model, null, $type); - $data['id'] = isset($node[0][$type]['id']) ? $node[0][$type]['id'] : null; - } - $model->{$type}->create(); - $model->{$type}->save($data); - } - } - -/** - * Destroys the ARO/ACO node bound to the deleted record - * - * @param Model $model - * @return void - */ - public function afterDelete(Model $model) { - $types = $this->_typeMaps[$this->settings[$model->name]['type']]; - if (!is_array($types)) { - $types = array($types); - } - foreach ($types as $type) { - $node = Set::extract($this->node($model, null, $type), "0.{$type}.id"); - if (!empty($node)) { - $model->{$type}->delete($node); - } - } - } - -} diff --git a/lib/Cake/Model/Behavior/ContainableBehavior.php b/lib/Cake/Model/Behavior/ContainableBehavior.php deleted file mode 100644 index e5929f29a10..00000000000 --- a/lib/Cake/Model/Behavior/ContainableBehavior.php +++ /dev/null @@ -1,428 +0,0 @@ -settings[$Model->alias])) { - $this->settings[$Model->alias] = array('recursive' => true, 'notices' => true, 'autoFields' => true); - } - $this->settings[$Model->alias] = array_merge($this->settings[$Model->alias], $settings); - } - -/** - * Runs before a find() operation. Used to allow 'contain' setting - * as part of the find call, like this: - * - * `Model->find('all', array('contain' => array('Model1', 'Model2')));` - * - * {{{ - * Model->find('all', array('contain' => array( - * 'Model1' => array('Model11', 'Model12'), - * 'Model2', - * 'Model3' => array( - * 'Model31' => 'Model311', - * 'Model32', - * 'Model33' => array('Model331', 'Model332') - * ))); - * }}} - * - * @param Model $Model Model using the behavior - * @param array $query Query parameters as set by cake - * @return array - */ - public function beforeFind(Model $Model, $query) { - $reset = (isset($query['reset']) ? $query['reset'] : true); - $noContain = ( - (isset($this->runtime[$Model->alias]['contain']) && empty($this->runtime[$Model->alias]['contain'])) || - (isset($query['contain']) && empty($query['contain'])) - ); - $contain = array(); - if (isset($this->runtime[$Model->alias]['contain'])) { - $contain = $this->runtime[$Model->alias]['contain']; - unset($this->runtime[$Model->alias]['contain']); - } - if (isset($query['contain'])) { - $contain = array_merge($contain, (array)$query['contain']); - } - if ( - $noContain || !$contain || in_array($contain, array(null, false), true) || - (isset($contain[0]) && $contain[0] === null) - ) { - if ($noContain) { - $query['recursive'] = -1; - } - return $query; - } - if ((isset($contain[0]) && is_bool($contain[0])) || is_bool(end($contain))) { - $reset = is_bool(end($contain)) - ? array_pop($contain) - : array_shift($contain); - } - $containments = $this->containments($Model, $contain); - $map = $this->containmentsMap($containments); - - $mandatory = array(); - foreach ($containments['models'] as $name => $model) { - $instance = $model['instance']; - $needed = $this->fieldDependencies($instance, $map, false); - if (!empty($needed)) { - $mandatory = array_merge($mandatory, $needed); - } - if ($contain) { - $backupBindings = array(); - foreach ($this->types as $relation) { - if (!empty($instance->__backAssociation[$relation])) { - $backupBindings[$relation] = $instance->__backAssociation[$relation]; - } else { - $backupBindings[$relation] = $instance->{$relation}; - } - } - foreach ($this->types as $type) { - $unbind = array(); - foreach ($instance->{$type} as $assoc => $options) { - if (!isset($model['keep'][$assoc])) { - $unbind[] = $assoc; - } - } - if (!empty($unbind)) { - if (!$reset && empty($instance->__backOriginalAssociation)) { - $instance->__backOriginalAssociation = $backupBindings; - } - $instance->unbindModel(array($type => $unbind), $reset); - } - foreach ($instance->{$type} as $assoc => $options) { - if (isset($model['keep'][$assoc]) && !empty($model['keep'][$assoc])) { - if (isset($model['keep'][$assoc]['fields'])) { - $model['keep'][$assoc]['fields'] = $this->fieldDependencies($containments['models'][$assoc]['instance'], $map, $model['keep'][$assoc]['fields']); - } - if (!$reset && empty($instance->__backOriginalAssociation)) { - $instance->__backOriginalAssociation = $backupBindings; - } elseif ($reset) { - $instance->__backAssociation[$type] = $backupBindings[$type]; - } - $instance->{$type}[$assoc] = array_merge($instance->{$type}[$assoc], $model['keep'][$assoc]); - } - if (!$reset) { - $instance->__backInnerAssociation[] = $assoc; - } - } - } - } - } - - if ($this->settings[$Model->alias]['recursive']) { - $query['recursive'] = (isset($query['recursive'])) ? $query['recursive'] : $containments['depth']; - } - - $autoFields = ($this->settings[$Model->alias]['autoFields'] - && !in_array($Model->findQueryType, array('list', 'count')) - && !empty($query['fields'])); - - if (!$autoFields) { - return $query; - } - - $query['fields'] = (array)$query['fields']; - foreach (array('hasOne', 'belongsTo') as $type) { - if (!empty($Model->{$type})) { - foreach ($Model->{$type} as $assoc => $data) { - if ($Model->useDbConfig == $Model->{$assoc}->useDbConfig && !empty($data['fields'])) { - foreach ((array)$data['fields'] as $field) { - $query['fields'][] = (strpos($field, '.') === false ? $assoc . '.' : '') . $field; - } - } - } - } - } - - if (!empty($mandatory[$Model->alias])) { - foreach ($mandatory[$Model->alias] as $field) { - if ($field == '--primaryKey--') { - $field = $Model->primaryKey; - } elseif (preg_match('/^.+\.\-\-[^-]+\-\-$/', $field)) { - list($modelName, $field) = explode('.', $field); - if ($Model->useDbConfig == $Model->{$modelName}->useDbConfig) { - $field = $modelName . '.' . ( - ($field === '--primaryKey--') ? $Model->$modelName->primaryKey : $field - ); - } else { - $field = null; - } - } - if ($field !== null) { - $query['fields'][] = $field; - } - } - } - $query['fields'] = array_unique($query['fields']); - return $query; - } - -/** - * Unbinds all relations from a model except the specified ones. Calling this function without - * parameters unbinds all related models. - * - * @param Model $Model Model on which binding restriction is being applied - * @return void - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/containable.html#using-containable - */ - public function contain(Model $Model) { - $args = func_get_args(); - $contain = call_user_func_array('am', array_slice($args, 1)); - $this->runtime[$Model->alias]['contain'] = $contain; - } - -/** - * Permanently restore the original binding settings of given model, useful - * for restoring the bindings after using 'reset' => false as part of the - * contain call. - * - * @param Model $Model Model on which to reset bindings - * @return void - */ - public function resetBindings(Model $Model) { - if (!empty($Model->__backOriginalAssociation)) { - $Model->__backAssociation = $Model->__backOriginalAssociation; - unset($Model->__backOriginalAssociation); - } - $Model->resetAssociations(); - if (!empty($Model->__backInnerAssociation)) { - $assocs = $Model->__backInnerAssociation; - $Model->__backInnerAssociation = array(); - foreach ($assocs as $currentModel) { - $this->resetBindings($Model->$currentModel); - } - } - } - -/** - * Process containments for model. - * - * @param Model $Model Model on which binding restriction is being applied - * @param array $contain Parameters to use for restricting this model - * @param array $containments Current set of containments - * @param boolean $throwErrors Whether non-existent bindings show throw errors - * @return array Containments - */ - public function containments(Model $Model, $contain, $containments = array(), $throwErrors = null) { - $options = array('className', 'joinTable', 'with', 'foreignKey', 'associationForeignKey', 'conditions', 'fields', 'order', 'limit', 'offset', 'unique', 'finderQuery', 'deleteQuery', 'insertQuery'); - $keep = array(); - if ($throwErrors === null) { - $throwErrors = (empty($this->settings[$Model->alias]) ? true : $this->settings[$Model->alias]['notices']); - } - foreach ((array)$contain as $name => $children) { - if (is_numeric($name)) { - $name = $children; - $children = array(); - } - if (preg_match('/(? $children); - } - - $children = (array)$children; - foreach ($children as $key => $val) { - if (is_string($key) && is_string($val) && !in_array($key, $options, true)) { - $children[$key] = (array)$val; - } - } - - $keys = array_keys($children); - if ($keys && isset($children[0])) { - $keys = array_merge(array_values($children), $keys); - } - - foreach ($keys as $i => $key) { - if (is_array($key)) { - continue; - } - $optionKey = in_array($key, $options, true); - if (!$optionKey && is_string($key) && preg_match('/^[a-z(]/', $key) && (!isset($Model->{$key}) || !is_object($Model->{$key}))) { - $option = 'fields'; - $val = array($key); - if ($key{0} == '(') { - $val = preg_split('/\s*,\s*/', substr(substr($key, 1), 0, -1)); - } elseif (preg_match('/ASC|DESC$/', $key)) { - $option = 'order'; - $val = $Model->{$name}->alias . '.' . $key; - } elseif (preg_match('/[ =!]/', $key)) { - $option = 'conditions'; - $val = $Model->{$name}->alias . '.' . $key; - } - $children[$option] = is_array($val) ? $val : array($val); - $newChildren = null; - if (!empty($name) && !empty($children[$key])) { - $newChildren = $children[$key]; - } - unset($children[$key], $children[$i]); - $key = $option; - $optionKey = true; - if (!empty($newChildren)) { - $children = Set::merge($children, $newChildren); - } - } - if ($optionKey && isset($children[$key])) { - if (!empty($keep[$name][$key]) && is_array($keep[$name][$key])) { - $keep[$name][$key] = array_merge((isset($keep[$name][$key]) ? $keep[$name][$key] : array()), (array)$children[$key]); - } else { - $keep[$name][$key] = $children[$key]; - } - unset($children[$key]); - } - } - - if (!isset($Model->{$name}) || !is_object($Model->{$name})) { - if ($throwErrors) { - trigger_error(__d('cake_dev', 'Model "%s" is not associated with model "%s"', $Model->alias, $name), E_USER_WARNING); - } - continue; - } - - $containments = $this->containments($Model->{$name}, $children, $containments); - $depths[] = $containments['depth'] + 1; - if (!isset($keep[$name])) { - $keep[$name] = array(); - } - } - - if (!isset($containments['models'][$Model->alias])) { - $containments['models'][$Model->alias] = array('keep' => array(), 'instance' => &$Model); - } - - $containments['models'][$Model->alias]['keep'] = array_merge($containments['models'][$Model->alias]['keep'], $keep); - $containments['depth'] = empty($depths) ? 0 : max($depths); - return $containments; - } - -/** - * Calculate needed fields to fetch the required bindings for the given model. - * - * @param Model $Model Model - * @param array $map Map of relations for given model - * @param mixed $fields If array, fields to initially load, if false use $Model as primary model - * @return array Fields - */ - public function fieldDependencies(Model $Model, $map, $fields = array()) { - if ($fields === false) { - foreach ($map as $parent => $children) { - foreach ($children as $type => $bindings) { - foreach ($bindings as $dependency) { - if ($type == 'hasAndBelongsToMany') { - $fields[$parent][] = '--primaryKey--'; - } elseif ($type == 'belongsTo') { - $fields[$parent][] = $dependency . '.--primaryKey--'; - } - } - } - } - return $fields; - } - if (empty($map[$Model->alias])) { - return $fields; - } - foreach ($map[$Model->alias] as $type => $bindings) { - foreach ($bindings as $dependency) { - $innerFields = array(); - switch ($type) { - case 'belongsTo': - $fields[] = $Model->{$type}[$dependency]['foreignKey']; - break; - case 'hasOne': - case 'hasMany': - $innerFields[] = $Model->$dependency->primaryKey; - $fields[] = $Model->primaryKey; - break; - } - if (!empty($innerFields) && !empty($Model->{$type}[$dependency]['fields'])) { - $Model->{$type}[$dependency]['fields'] = array_unique(array_merge($Model->{$type}[$dependency]['fields'], $innerFields)); - } - } - } - return array_unique($fields); - } - -/** - * Build the map of containments - * - * @param array $containments Containments - * @return array Built containments - */ - public function containmentsMap($containments) { - $map = array(); - foreach ($containments['models'] as $name => $model) { - $instance = $model['instance']; - foreach ($this->types as $type) { - foreach ($instance->{$type} as $assoc => $options) { - if (isset($model['keep'][$assoc])) { - $map[$name][$type] = isset($map[$name][$type]) ? array_merge($map[$name][$type], (array)$assoc) : (array)$assoc; - } - } - } - } - return $map; - } - -} diff --git a/lib/Cake/Model/Behavior/TranslateBehavior.php b/lib/Cake/Model/Behavior/TranslateBehavior.php deleted file mode 100644 index 5f3f2f32ac4..00000000000 --- a/lib/Cake/Model/Behavior/TranslateBehavior.php +++ /dev/null @@ -1,577 +0,0 @@ - array('field_one', - * 'field_two' => 'FieldAssoc', 'field_three')) - * - * With above example only one permanent hasMany will be joined (for field_two - * as FieldAssoc) - * - * $config could be empty - and translations configured dynamically by - * bindTranslation() method - * - * @param Model $model Model the behavior is being attached to. - * @param array $config Array of configuration information. - * @return mixed - */ - public function setup(Model $model, $config = array()) { - $db = ConnectionManager::getDataSource($model->useDbConfig); - if (!$db->connected) { - trigger_error( - __d('cake_dev', 'Datasource %s for TranslateBehavior of model %s is not connected', $model->useDbConfig, $model->alias), - E_USER_ERROR - ); - return false; - } - - $this->settings[$model->alias] = array(); - $this->runtime[$model->alias] = array('fields' => array()); - $this->translateModel($model); - return $this->bindTranslation($model, $config, false); - } - -/** - * Cleanup Callback unbinds bound translations and deletes setting information. - * - * @param Model $model Model being detached. - * @return void - */ - public function cleanup(Model $model) { - $this->unbindTranslation($model); - unset($this->settings[$model->alias]); - unset($this->runtime[$model->alias]); - } - -/** - * beforeFind Callback - * - * @param Model $model Model find is being run on. - * @param array $query Array of Query parameters. - * @return array Modified query - */ - public function beforeFind(Model $model, $query) { - $this->runtime[$model->alias]['virtualFields'] = $model->virtualFields; - $locale = $this->_getLocale($model); - if (empty($locale)) { - return $query; - } - $db = $model->getDataSource(); - $RuntimeModel = $this->translateModel($model); - - if (!empty($RuntimeModel->tablePrefix)) { - $tablePrefix = $RuntimeModel->tablePrefix; - } else { - $tablePrefix = $db->config['prefix']; - } - $joinTable = new StdClass(); - $joinTable->tablePrefix = $tablePrefix; - $joinTable->table = $RuntimeModel->table; - $joinTable->schemaName = $RuntimeModel->getDataSource()->getSchemaName(); - - $this->_joinTable = $joinTable; - $this->_runtimeModel = $RuntimeModel; - - if (is_string($query['fields']) && 'COUNT(*) AS ' . $db->name('count') == $query['fields']) { - $query['fields'] = 'COUNT(DISTINCT(' . $db->name($model->alias . '.' . $model->primaryKey) . ')) ' . $db->alias . 'count'; - $query['joins'][] = array( - 'type' => 'INNER', - 'alias' => $RuntimeModel->alias, - 'table' => $joinTable, - 'conditions' => array( - $model->alias . '.' . $model->primaryKey => $db->identifier($RuntimeModel->alias . '.foreign_key'), - $RuntimeModel->alias . '.model' => $model->name, - $RuntimeModel->alias . '.locale' => $locale - ) - ); - $conditionFields = $this->_checkConditions($model, $query); - foreach ($conditionFields as $field) { - $query = $this->_addJoin($model, $query, $field, $field, $locale); - } - unset($this->_joinTable, $this->_runtimeModel); - return $query; - } - - $fields = array_merge($this->settings[$model->alias], $this->runtime[$model->alias]['fields']); - $addFields = array(); - if (empty($query['fields'])) { - $addFields = $fields; - } elseif (is_array($query['fields'])) { - foreach ($fields as $key => $value) { - $field = (is_numeric($key)) ? $value : $key; - - if (in_array($model->alias . '.*', $query['fields']) || in_array($model->alias . '.' . $field, $query['fields']) || in_array($field, $query['fields'])) { - $addFields[] = $field; - } - } - } - - $this->runtime[$model->alias]['virtualFields'] = $model->virtualFields; - if ($addFields) { - foreach ($addFields as $_f => $field) { - $aliasField = is_numeric($_f) ? $field : $_f; - - foreach (array($aliasField, $model->alias . '.' . $aliasField) as $_field) { - $key = array_search($_field, (array)$query['fields']); - - if ($key !== false) { - unset($query['fields'][$key]); - } - } - $query = $this->_addJoin($model, $query, $field, $aliasField, $locale); - } - } - $this->runtime[$model->alias]['beforeFind'] = $addFields; - unset($this->_joinTable, $this->_runtimeModel); - return $query; - } - -/** - * Check a query's conditions for translated fields. - * Return an array of translated fields found in the conditions. - * - * @param Model $model The model being read. - * @param array $query The query array. - * @return array The list of translated fields that are in the conditions. - */ - protected function _checkConditions(Model $model, $query) { - $conditionFields = array(); - if (empty($query['conditions']) || (!empty($query['conditions']) && !is_array($query['conditions'])) ) { - return $conditionFields; - } - foreach ($query['conditions'] as $col => $val) { - foreach ($this->settings[$model->alias] as $field => $assoc) { - if (is_numeric($field)) { - $field = $assoc; - } - if (strpos($col, $field) !== false) { - $conditionFields[] = $field; - } - } - } - return $conditionFields; - } - -/** - * Appends a join for translated fields and possibly a field. - * - * @param Model $model The model being worked on. - * @param object $joinTable The jointable object. - * @param array $query The query array to append a join to. - * @param string $field The field name being joined. - * @param string $aliasField The aliased field name being joined. - * @param mixed $locale The locale(s) having joins added. - * @param boolean $addField Whether or not to add a field. - * @return array The modfied query - */ - protected function _addJoin(Model $model, $query, $field, $aliasField, $locale, $addField = false) { - $db = ConnectionManager::getDataSource($model->useDbConfig); - - $RuntimeModel = $this->_runtimeModel; - $joinTable = $this->_joinTable; - - if (is_array($locale)) { - foreach ($locale as $_locale) { - $model->virtualFields['i18n_' . $field . '_' . $_locale] = 'I18n__' . $field . '__' . $_locale . '.content'; - if (!empty($query['fields']) && is_array($query['fields'])) { - $query['fields'][] = 'i18n_' . $field . '_' . $_locale; - } - $query['joins'][] = array( - 'type' => 'LEFT', - 'alias' => 'I18n__' . $field . '__' . $_locale, - 'table' => $joinTable, - 'conditions' => array( - $model->alias . '.' . $model->primaryKey => $db->identifier("I18n__{$field}__{$_locale}.foreign_key"), - 'I18n__' . $field . '__' . $_locale . '.model' => $model->name, - 'I18n__' . $field . '__' . $_locale . '.' . $RuntimeModel->displayField => $aliasField, - 'I18n__' . $field . '__' . $_locale . '.locale' => $_locale - ) - ); - } - } else { - $model->virtualFields['i18n_' . $field] = 'I18n__' . $field . '.content'; - if (!empty($query['fields']) && is_array($query['fields'])) { - $query['fields'][] = 'i18n_' . $field; - } - $query['joins'][] = array( - 'type' => 'INNER', - 'alias' => 'I18n__' . $field, - 'table' => $joinTable, - 'conditions' => array( - $model->alias . '.' . $model->primaryKey => $db->identifier("I18n__{$field}.foreign_key"), - 'I18n__' . $field . '.model' => $model->name, - 'I18n__' . $field . '.' . $RuntimeModel->displayField => $aliasField, - 'I18n__' . $field . '.locale' => $locale - ) - ); - } - return $query; - } - -/** - * afterFind Callback - * - * @param Model $model Model find was run on - * @param array $results Array of model results. - * @param boolean $primary Did the find originate on $model. - * @return array Modified results - */ - public function afterFind(Model $model, $results, $primary) { - $model->virtualFields = $this->runtime[$model->alias]['virtualFields']; - $this->runtime[$model->alias]['virtualFields'] = $this->runtime[$model->alias]['fields'] = array(); - $locale = $this->_getLocale($model); - - if (empty($locale) || empty($results) || empty($this->runtime[$model->alias]['beforeFind'])) { - return $results; - } - $beforeFind = $this->runtime[$model->alias]['beforeFind']; - - foreach ($results as $key => &$row) { - $results[$key][$model->alias]['locale'] = (is_array($locale)) ? current($locale) : $locale; - foreach ($beforeFind as $_f => $field) { - $aliasField = is_numeric($_f) ? $field : $_f; - - if (is_array($locale)) { - foreach ($locale as $_locale) { - if (!isset($row[$model->alias][$aliasField]) && !empty($row[$model->alias]['i18n_' . $field . '_' . $_locale])) { - $row[$model->alias][$aliasField] = $row[$model->alias]['i18n_' . $field . '_' . $_locale]; - $row[$model->alias]['locale'] = $_locale; - } - unset($row[$model->alias]['i18n_' . $field . '_' . $_locale]); - } - - if (!isset($row[$model->alias][$aliasField])) { - $row[$model->alias][$aliasField] = ''; - } - } else { - $value = ''; - if (!empty($row[$model->alias]['i18n_' . $field])) { - $value = $row[$model->alias]['i18n_' . $field]; - } - $row[$model->alias][$aliasField] = $value; - unset($row[$model->alias]['i18n_' . $field]); - } - } - } - return $results; - } - -/** - * beforeValidate Callback - * - * @param Model $model Model invalidFields was called on. - * @return boolean - */ - public function beforeValidate(Model $model) { - $locale = $this->_getLocale($model); - if (empty($locale)) { - return true; - } - $fields = array_merge($this->settings[$model->alias], $this->runtime[$model->alias]['fields']); - $tempData = array(); - - foreach ($fields as $key => $value) { - $field = (is_numeric($key)) ? $value : $key; - - if (isset($model->data[$model->alias][$field])) { - $tempData[$field] = $model->data[$model->alias][$field]; - if (is_array($model->data[$model->alias][$field])) { - if (is_string($locale) && !empty($model->data[$model->alias][$field][$locale])) { - $model->data[$model->alias][$field] = $model->data[$model->alias][$field][$locale]; - } else { - $values = array_values($model->data[$model->alias][$field]); - $model->data[$model->alias][$field] = $values[0]; - } - } - } - } - $this->runtime[$model->alias]['beforeSave'] = $tempData; - return true; - } - -/** - * afterSave Callback - * - * @param Model $model Model the callback is called on - * @param boolean $created Whether or not the save created a record. - * @return void - */ - public function afterSave(Model $model, $created) { - if (!isset($this->runtime[$model->alias]['beforeSave'])) { - return true; - } - $locale = $this->_getLocale($model); - $tempData = $this->runtime[$model->alias]['beforeSave']; - unset($this->runtime[$model->alias]['beforeSave']); - $conditions = array('model' => $model->alias, 'foreign_key' => $model->id); - $RuntimeModel = $this->translateModel($model); - - foreach ($tempData as $field => $value) { - unset($conditions['content']); - $conditions['field'] = $field; - if (is_array($value)) { - $conditions['locale'] = array_keys($value); - } else { - $conditions['locale'] = $locale; - if (is_array($locale)) { - $value = array($locale[0] => $value); - } else { - $value = array($locale => $value); - } - } - $translations = $RuntimeModel->find('list', array('conditions' => $conditions, 'fields' => array($RuntimeModel->alias . '.locale', $RuntimeModel->alias . '.id'))); - foreach ($value as $_locale => $_value) { - $RuntimeModel->create(); - $conditions['locale'] = $_locale; - $conditions['content'] = $_value; - if (array_key_exists($_locale, $translations)) { - $RuntimeModel->save(array($RuntimeModel->alias => array_merge($conditions, array('id' => $translations[$_locale])))); - } else { - $RuntimeModel->save(array($RuntimeModel->alias => $conditions)); - } - } - } - } - -/** - * afterDelete Callback - * - * @param Model $model Model the callback was run on. - * @return void - */ - public function afterDelete(Model $model) { - $RuntimeModel = $this->translateModel($model); - $conditions = array('model' => $model->alias, 'foreign_key' => $model->id); - $RuntimeModel->deleteAll($conditions); - } - -/** - * Get selected locale for model - * - * @param Model $model Model the locale needs to be set/get on. - * @return mixed string or false - */ - protected function _getLocale(Model $model) { - if (!isset($model->locale) || is_null($model->locale)) { - $I18n = I18n::getInstance(); - $I18n->l10n->get(Configure::read('Config.language')); - $model->locale = $I18n->l10n->locale; - } - - return $model->locale; - } - -/** - * Get instance of model for translations. - * - * If the model has a translateModel property set, this will be used as the class - * name to find/use. If no translateModel property is found 'I18nModel' will be used. - * - * @param Model $model Model to get a translatemodel for. - * @return Model - */ - public function translateModel(Model $model) { - if (!isset($this->runtime[$model->alias]['model'])) { - if (!isset($model->translateModel) || empty($model->translateModel)) { - $className = 'I18nModel'; - } else { - $className = $model->translateModel; - } - - $this->runtime[$model->alias]['model'] = ClassRegistry::init($className, 'Model'); - } - if (!empty($model->translateTable) && $model->translateTable !== $this->runtime[$model->alias]['model']->useTable) { - $this->runtime[$model->alias]['model']->setSource($model->translateTable); - } elseif (empty($model->translateTable) && empty($model->translateModel)) { - $this->runtime[$model->alias]['model']->setSource('i18n'); - } - return $this->runtime[$model->alias]['model']; - } - -/** - * Bind translation for fields, optionally with hasMany association for - * fake field. - * - * *Note* You should avoid binding translations that overlap existing model properties. - * This can cause un-expected and un-desirable behavior. - * - * @param Model $model instance of model - * @param string|array $fields string with field or array(field1, field2=>AssocName, field3) - * @param boolean $reset - * @return boolean - * @throws CakeException when attempting to bind a translating called name. This is not allowed - * as it shadows Model::$name. - */ - public function bindTranslation(Model $model, $fields, $reset = true) { - if (is_string($fields)) { - $fields = array($fields); - } - $associations = array(); - $RuntimeModel = $this->translateModel($model); - $default = array('className' => $RuntimeModel->alias, 'foreignKey' => 'foreign_key'); - - foreach ($fields as $key => $value) { - if (is_numeric($key)) { - $field = $value; - $association = null; - } else { - $field = $key; - $association = $value; - } - if ($association === 'name') { - throw new CakeException( - __d('cake_dev', 'You cannot bind a translation named "name".') - ); - } - - if (array_key_exists($field, $this->settings[$model->alias])) { - unset($this->settings[$model->alias][$field]); - } elseif (in_array($field, $this->settings[$model->alias])) { - $this->settings[$model->alias] = array_merge(array_diff_assoc($this->settings[$model->alias], array($field))); - } - - if (array_key_exists($field, $this->runtime[$model->alias]['fields'])) { - unset($this->runtime[$model->alias]['fields'][$field]); - } elseif (in_array($field, $this->runtime[$model->alias]['fields'])) { - $this->runtime[$model->alias]['fields'] = array_merge(array_diff_assoc($this->runtime[$model->alias]['fields'], array($field))); - } - - if (is_null($association)) { - if ($reset) { - $this->runtime[$model->alias]['fields'][] = $field; - } else { - $this->settings[$model->alias][] = $field; - } - } else { - if ($reset) { - $this->runtime[$model->alias]['fields'][$field] = $association; - } else { - $this->settings[$model->alias][$field] = $association; - } - - foreach (array('hasOne', 'hasMany', 'belongsTo', 'hasAndBelongsToMany') as $type) { - if (isset($model->{$type}[$association]) || isset($model->__backAssociation[$type][$association])) { - trigger_error( - __d('cake_dev', 'Association %s is already bound to model %s', $association, $model->alias), - E_USER_ERROR - ); - return false; - } - } - $associations[$association] = array_merge($default, array('conditions' => array( - 'model' => $model->alias, - $RuntimeModel->displayField => $field - ))); - } - } - - if (!empty($associations)) { - $model->bindModel(array('hasMany' => $associations), $reset); - } - return true; - } - -/** - * Unbind translation for fields, optionally unbinds hasMany association for - * fake field - * - * @param Model $model instance of model - * @param mixed $fields string with field, or array(field1, field2=>AssocName, field3), or null for - * unbind all original translations - * @return boolean - */ - public function unbindTranslation(Model $model, $fields = null) { - if (empty($fields) && empty($this->settings[$model->alias])) { - return false; - } - if (empty($fields)) { - return $this->unbindTranslation($model, $this->settings[$model->alias]); - } - - if (is_string($fields)) { - $fields = array($fields); - } - $RuntimeModel = $this->translateModel($model); - $associations = array(); - - foreach ($fields as $key => $value) { - if (is_numeric($key)) { - $field = $value; - $association = null; - } else { - $field = $key; - $association = $value; - } - - if (array_key_exists($field, $this->settings[$model->alias])) { - unset($this->settings[$model->alias][$field]); - } elseif (in_array($field, $this->settings[$model->alias])) { - $this->settings[$model->alias] = array_merge(array_diff_assoc($this->settings[$model->alias], array($field))); - } - - if (array_key_exists($field, $this->runtime[$model->alias]['fields'])) { - unset($this->runtime[$model->alias]['fields'][$field]); - } elseif (in_array($field, $this->runtime[$model->alias]['fields'])) { - $this->runtime[$model->alias]['fields'] = array_merge(array_diff_assoc($this->runtime[$model->alias]['fields'], array($field))); - } - - if (!is_null($association) && (isset($model->hasMany[$association]) || isset($model->__backAssociation['hasMany'][$association]))) { - $associations[] = $association; - } - } - - if (!empty($associations)) { - $model->unbindModel(array('hasMany' => $associations), false); - } - return true; - } - -} - diff --git a/lib/Cake/Model/Behavior/TreeBehavior.php b/lib/Cake/Model/Behavior/TreeBehavior.php deleted file mode 100644 index 03a8f2662f8..00000000000 --- a/lib/Cake/Model/Behavior/TreeBehavior.php +++ /dev/null @@ -1,1003 +0,0 @@ - 'parent_id', 'left' => 'lft', 'right' => 'rght', - 'scope' => '1 = 1', 'type' => 'nested', '__parentChange' => false, 'recursive' => -1 - ); - -/** - * Used to preserve state between delete callbacks. - * - * @var array - */ - protected $_deletedRow = null; - -/** - * Initiate Tree behavior - * - * @param Model $Model instance of model - * @param array $config array of configuration settings. - * @return void - */ - public function setup(Model $Model, $config = array()) { - if (isset($config[0])) { - $config['type'] = $config[0]; - unset($config[0]); - } - $settings = array_merge($this->_defaults, $config); - - if (in_array($settings['scope'], $Model->getAssociated('belongsTo'))) { - $data = $Model->getAssociated($settings['scope']); - $parent = $Model->{$settings['scope']}; - $settings['scope'] = $Model->alias . '.' . $data['foreignKey'] . ' = ' . $parent->alias . '.' . $parent->primaryKey; - $settings['recursive'] = 0; - } - $this->settings[$Model->alias] = $settings; - } - -/** - * After save method. Called after all saves - * - * Overridden to transparently manage setting the lft and rght fields if and only if the parent field is included in the - * parameters to be saved. - * - * @param Model $Model Model instance. - * @param boolean $created indicates whether the node just saved was created or updated - * @return boolean true on success, false on failure - */ - public function afterSave(Model $Model, $created) { - extract($this->settings[$Model->alias]); - if ($created) { - if ((isset($Model->data[$Model->alias][$parent])) && $Model->data[$Model->alias][$parent]) { - return $this->_setParent($Model, $Model->data[$Model->alias][$parent], $created); - } - } elseif ($this->settings[$Model->alias]['__parentChange']) { - $this->settings[$Model->alias]['__parentChange'] = false; - return $this->_setParent($Model, $Model->data[$Model->alias][$parent]); - } - } - -/** - * Runs before a find() operation - * - * @param Model $Model Model using the behavior - * @param array $query Query parameters as set by cake - * @return array - */ - public function beforeFind(Model $Model, $query) { - if ($Model->findQueryType == 'threaded' && !isset($query['parent'])) { - $query['parent'] = $this->settings[$Model->alias]['parent']; - } - return $query; - } - -/** - * Stores the record about to be deleted. - * - * This is used to delete child nodes in the afterDelete. - * - * @param Model $Model Model instance - * @param boolean $cascade - * @return boolean - */ - public function beforeDelete(Model $Model, $cascade = true) { - extract($this->settings[$Model->alias]); - $data = current($Model->find('first', array( - 'conditions' => array($Model->alias . '.' . $Model->primaryKey => $Model->id), - 'fields' => array($Model->alias . '.' . $left, $Model->alias . '.' . $right), - 'recursive' => -1))); - $this->_deletedRow = $data; - return true; - } - -/** - * After delete method. - * - * Will delete the current node and all children using the deleteAll method and sync the table - * - * @param Model $Model Model instance - * @return boolean true to continue, false to abort the delete - */ - public function afterDelete(Model $Model) { - extract($this->settings[$Model->alias]); - $data = $this->_deletedRow; - $this->_deletedRow = null; - - if (!$data[$right] || !$data[$left]) { - return true; - } - $diff = $data[$right] - $data[$left] + 1; - - if ($diff > 2) { - if (is_string($scope)) { - $scope = array($scope); - } - $scope[]["{$Model->alias}.{$left} BETWEEN ? AND ?"] = array($data[$left] + 1, $data[$right] - 1); - $Model->deleteAll($scope); - } - $this->_sync($Model, $diff, '-', '> ' . $data[$right]); - return true; - } - -/** - * Before save method. Called before all saves - * - * Overridden to transparently manage setting the lft and rght fields if and only if the parent field is included in the - * parameters to be saved. For newly created nodes with NO parent the left and right field values are set directly by - * this method bypassing the setParent logic. - * - * @since 1.2 - * @param Model $Model Model instance - * @return boolean true to continue, false to abort the save - */ - public function beforeSave(Model $Model) { - extract($this->settings[$Model->alias]); - - $this->_addToWhitelist($Model, array($left, $right)); - if (!$Model->id) { - if (array_key_exists($parent, $Model->data[$Model->alias]) && $Model->data[$Model->alias][$parent]) { - $parentNode = $Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $Model->data[$Model->alias][$parent]), - 'fields' => array($Model->primaryKey, $right), 'recursive' => $recursive - )); - if (!$parentNode) { - return false; - } - list($parentNode) = array_values($parentNode); - $Model->data[$Model->alias][$left] = 0; - $Model->data[$Model->alias][$right] = 0; - } else { - $edge = $this->_getMax($Model, $scope, $right, $recursive); - $Model->data[$Model->alias][$left] = $edge + 1; - $Model->data[$Model->alias][$right] = $edge + 2; - } - } elseif (array_key_exists($parent, $Model->data[$Model->alias])) { - if ($Model->data[$Model->alias][$parent] != $Model->field($parent)) { - $this->settings[$Model->alias]['__parentChange'] = true; - } - if (!$Model->data[$Model->alias][$parent]) { - $Model->data[$Model->alias][$parent] = null; - $this->_addToWhitelist($Model, $parent); - } else { - $values = $Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $Model->id), - 'fields' => array($Model->primaryKey, $parent, $left, $right), 'recursive' => $recursive) - ); - - if ($values === false) { - return false; - } - list($node) = array_values($values); - - $parentNode = $Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $Model->data[$Model->alias][$parent]), - 'fields' => array($Model->primaryKey, $left, $right), 'recursive' => $recursive - )); - if (!$parentNode) { - return false; - } - list($parentNode) = array_values($parentNode); - - if (($node[$left] < $parentNode[$left]) && ($parentNode[$right] < $node[$right])) { - return false; - } elseif ($node[$Model->primaryKey] == $parentNode[$Model->primaryKey]) { - return false; - } - } - } - return true; - } - -/** - * Get the number of child nodes - * - * If the direct parameter is set to true, only the direct children are counted (based upon the parent_id field) - * If false is passed for the id parameter, all top level nodes are counted, or all nodes are counted. - * - * @param Model $Model Model instance - * @param mixed $id The ID of the record to read or false to read all top level nodes - * @param boolean $direct whether to count direct, or all, children - * @return integer number of child nodes - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::childCount - */ - public function childCount(Model $Model, $id = null, $direct = false) { - if (is_array($id)) { - extract (array_merge(array('id' => null), $id)); - } - if ($id === null && $Model->id) { - $id = $Model->id; - } elseif (!$id) { - $id = null; - } - extract($this->settings[$Model->alias]); - - if ($direct) { - return $Model->find('count', array('conditions' => array($scope, $Model->escapeField($parent) => $id))); - } - - if ($id === null) { - return $Model->find('count', array('conditions' => $scope)); - } elseif ($Model->id === $id && isset($Model->data[$Model->alias][$left]) && isset($Model->data[$Model->alias][$right])) { - $data = $Model->data[$Model->alias]; - } else { - $data = $Model->find('first', array('conditions' => array($scope, $Model->escapeField() => $id), 'recursive' => $recursive)); - if (!$data) { - return 0; - } - $data = $data[$Model->alias]; - } - return ($data[$right] - $data[$left] - 1) / 2; - } - -/** - * Get the child nodes of the current model - * - * If the direct parameter is set to true, only the direct children are returned (based upon the parent_id field) - * If false is passed for the id parameter, top level, or all (depending on direct parameter appropriate) are counted. - * - * @param Model $Model Model instance - * @param mixed $id The ID of the record to read - * @param boolean $direct whether to return only the direct, or all, children - * @param mixed $fields Either a single string of a field name, or an array of field names - * @param string $order SQL ORDER BY conditions (e.g. "price DESC" or "name ASC") defaults to the tree order - * @param integer $limit SQL LIMIT clause, for calculating items per page. - * @param integer $page Page number, for accessing paged data - * @param integer $recursive The number of levels deep to fetch associated records - * @return array Array of child nodes - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::children - */ - public function children(Model $Model, $id = null, $direct = false, $fields = null, $order = null, $limit = null, $page = 1, $recursive = null) { - if (is_array($id)) { - extract (array_merge(array('id' => null), $id)); - } - $overrideRecursive = $recursive; - - if ($id === null && $Model->id) { - $id = $Model->id; - } elseif (!$id) { - $id = null; - } - - extract($this->settings[$Model->alias]); - - if (!is_null($overrideRecursive)) { - $recursive = $overrideRecursive; - } - if (!$order) { - $order = $Model->alias . '.' . $left . ' asc'; - } - if ($direct) { - $conditions = array($scope, $Model->escapeField($parent) => $id); - return $Model->find('all', compact('conditions', 'fields', 'order', 'limit', 'page', 'recursive')); - } - - if (!$id) { - $conditions = $scope; - } else { - $result = array_values((array)$Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $id), - 'fields' => array($left, $right), - 'recursive' => $recursive - ))); - - if (empty($result) || !isset($result[0])) { - return array(); - } - $conditions = array($scope, - $Model->escapeField($right) . ' <' => $result[0][$right], - $Model->escapeField($left) . ' >' => $result[0][$left] - ); - } - return $Model->find('all', compact('conditions', 'fields', 'order', 'limit', 'page', 'recursive')); - } - -/** - * A convenience method for returning a hierarchical array used for HTML select boxes - * - * @param Model $Model Model instance - * @param mixed $conditions SQL conditions as a string or as an array('field' =>'value',...) - * @param string $keyPath A string path to the key, i.e. "{n}.Post.id" - * @param string $valuePath A string path to the value, i.e. "{n}.Post.title" - * @param string $spacer The character or characters which will be repeated - * @param integer $recursive The number of levels deep to fetch associated records - * @return array An associative array of records, where the id is the key, and the display field is the value - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::generateTreeList - */ - public function generateTreeList(Model $Model, $conditions = null, $keyPath = null, $valuePath = null, $spacer = '_', $recursive = null) { - $overrideRecursive = $recursive; - extract($this->settings[$Model->alias]); - if (!is_null($overrideRecursive)) { - $recursive = $overrideRecursive; - } - - if ($keyPath == null && $valuePath == null && $Model->hasField($Model->displayField)) { - $fields = array($Model->primaryKey, $Model->displayField, $left, $right); - } else { - $fields = null; - } - - if ($keyPath == null) { - $keyPath = '{n}.' . $Model->alias . '.' . $Model->primaryKey; - } - - if ($valuePath == null) { - $valuePath = array('{0}{1}', '{n}.tree_prefix', '{n}.' . $Model->alias . '.' . $Model->displayField); - - } elseif (is_string($valuePath)) { - $valuePath = array('{0}{1}', '{n}.tree_prefix', $valuePath); - - } else { - $valuePath[0] = '{' . (count($valuePath) - 1) . '}' . $valuePath[0]; - $valuePath[] = '{n}.tree_prefix'; - } - $order = $Model->alias . '.' . $left . ' asc'; - $results = $Model->find('all', compact('conditions', 'fields', 'order', 'recursive')); - $stack = array(); - - foreach ($results as $i => $result) { - while ($stack && ($stack[count($stack) - 1] < $result[$Model->alias][$right])) { - array_pop($stack); - } - $results[$i]['tree_prefix'] = str_repeat($spacer, count($stack)); - $stack[] = $result[$Model->alias][$right]; - } - if (empty($results)) { - return array(); - } - return Set::combine($results, $keyPath, $valuePath); - } - -/** - * Get the parent node - * - * reads the parent id and returns this node - * - * @param Model $Model Model instance - * @param mixed $id The ID of the record to read - * @param string|array $fields - * @param integer $recursive The number of levels deep to fetch associated records - * @return array|boolean Array of data for the parent node - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::getParentNode - */ - public function getParentNode(Model $Model, $id = null, $fields = null, $recursive = null) { - if (is_array($id)) { - extract (array_merge(array('id' => null), $id)); - } - $overrideRecursive = $recursive; - if (empty ($id)) { - $id = $Model->id; - } - extract($this->settings[$Model->alias]); - if (!is_null($overrideRecursive)) { - $recursive = $overrideRecursive; - } - $parentId = $Model->find('first', array('conditions' => array($Model->primaryKey => $id), 'fields' => array($parent), 'recursive' => -1)); - - if ($parentId) { - $parentId = $parentId[$Model->alias][$parent]; - $parent = $Model->find('first', array('conditions' => array($Model->escapeField() => $parentId), 'fields' => $fields, 'recursive' => $recursive)); - - return $parent; - } - return false; - } - -/** - * Get the path to the given node - * - * @param Model $Model Model instance - * @param mixed $id The ID of the record to read - * @param mixed $fields Either a single string of a field name, or an array of field names - * @param integer $recursive The number of levels deep to fetch associated records - * @return array Array of nodes from top most parent to current node - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::getPath - */ - public function getPath(Model $Model, $id = null, $fields = null, $recursive = null) { - if (is_array($id)) { - extract (array_merge(array('id' => null), $id)); - } - $overrideRecursive = $recursive; - if (empty ($id)) { - $id = $Model->id; - } - extract($this->settings[$Model->alias]); - if (!is_null($overrideRecursive)) { - $recursive = $overrideRecursive; - } - $result = $Model->find('first', array('conditions' => array($Model->escapeField() => $id), 'fields' => array($left, $right), 'recursive' => $recursive)); - if ($result) { - $result = array_values($result); - } else { - return null; - } - $item = $result[0]; - $results = $Model->find('all', array( - 'conditions' => array($scope, $Model->escapeField($left) . ' <=' => $item[$left], $Model->escapeField($right) . ' >=' => $item[$right]), - 'fields' => $fields, 'order' => array($Model->escapeField($left) => 'asc'), 'recursive' => $recursive - )); - return $results; - } - -/** - * Reorder 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 - * - * @param Model $Model Model instance - * @param mixed $id The ID of the record to move - * @param integer|boolean $number how many places to move the node or true to move to last position - * @return boolean true on success, false on failure - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::moveDown - */ - public function moveDown(Model $Model, $id = null, $number = 1) { - if (is_array($id)) { - extract (array_merge(array('id' => null), $id)); - } - if (!$number) { - return false; - } - if (empty ($id)) { - $id = $Model->id; - } - extract($this->settings[$Model->alias]); - list($node) = array_values($Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $id), - 'fields' => array($Model->primaryKey, $left, $right, $parent), 'recursive' => $recursive - ))); - if ($node[$parent]) { - list($parentNode) = array_values($Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $node[$parent]), - 'fields' => array($Model->primaryKey, $left, $right), 'recursive' => $recursive - ))); - if (($node[$right] + 1) == $parentNode[$right]) { - return false; - } - } - $nextNode = $Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField($left) => ($node[$right] + 1)), - 'fields' => array($Model->primaryKey, $left, $right), 'recursive' => $recursive) - ); - if ($nextNode) { - list($nextNode) = array_values($nextNode); - } else { - return false; - } - $edge = $this->_getMax($Model, $scope, $right, $recursive); - $this->_sync($Model, $edge - $node[$left] + 1, '+', 'BETWEEN ' . $node[$left] . ' AND ' . $node[$right]); - $this->_sync($Model, $nextNode[$left] - $node[$left], '-', 'BETWEEN ' . $nextNode[$left] . ' AND ' . $nextNode[$right]); - $this->_sync($Model, $edge - $node[$left] - ($nextNode[$right] - $nextNode[$left]), '-', '> ' . $edge); - - if (is_int($number)) { - $number--; - } - if ($number) { - $this->moveDown($Model, $id, $number); - } - return true; - } - -/** - * Reorder the node without changing the parent. - * - * If the node is the first child, or is a top level node with no previous node this method will return false - * - * @param Model $Model Model instance - * @param mixed $id The ID of the record to move - * @param integer|boolean $number how many places to move the node, or true to move to first position - * @return boolean true on success, false on failure - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::moveUp - */ - public function moveUp(Model $Model, $id = null, $number = 1) { - if (is_array($id)) { - extract (array_merge(array('id' => null), $id)); - } - if (!$number) { - return false; - } - if (empty ($id)) { - $id = $Model->id; - } - extract($this->settings[$Model->alias]); - list($node) = array_values($Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $id), - 'fields' => array($Model->primaryKey, $left, $right, $parent), 'recursive' => $recursive - ))); - if ($node[$parent]) { - list($parentNode) = array_values($Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $node[$parent]), - 'fields' => array($Model->primaryKey, $left, $right), 'recursive' => $recursive - ))); - if (($node[$left] - 1) == $parentNode[$left]) { - return false; - } - } - $previousNode = $Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField($right) => ($node[$left] - 1)), - 'fields' => array($Model->primaryKey, $left, $right), - 'recursive' => $recursive - )); - - if ($previousNode) { - list($previousNode) = array_values($previousNode); - } else { - return false; - } - $edge = $this->_getMax($Model, $scope, $right, $recursive); - $this->_sync($Model, $edge - $previousNode[$left] + 1, '+', 'BETWEEN ' . $previousNode[$left] . ' AND ' . $previousNode[$right]); - $this->_sync($Model, $node[$left] - $previousNode[$left], '-', 'BETWEEN ' . $node[$left] . ' AND ' . $node[$right]); - $this->_sync($Model, $edge - $previousNode[$left] - ($node[$right] - $node[$left]), '-', '> ' . $edge); - if (is_int($number)) { - $number--; - } - if ($number) { - $this->moveUp($Model, $id, $number); - } - return true; - } - -/** - * Recover a corrupted tree - * - * The mode parameter is used to specify the source of info that is valid/correct. The opposite source of data - * will be populated based upon that source of info. E.g. if the MPTT fields are corrupt or empty, with the $mode - * 'parent' the values of the parent_id field will be used to populate the left and right fields. The missingParentAction - * parameter only applies to "parent" mode and determines what to do if the parent field contains an id that is not present. - * - * @todo Could be written to be faster, *maybe*. Ideally using a subquery and putting all the logic burden on the DB. - * @param Model $Model Model instance - * @param string $mode parent or tree - * @param mixed $missingParentAction 'return' to do nothing and return, 'delete' to - * delete, or the id of the parent to set as the parent_id - * @return boolean true on success, false on failure - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::recover - */ - public function recover(Model $Model, $mode = 'parent', $missingParentAction = null) { - if (is_array($mode)) { - extract (array_merge(array('mode' => 'parent'), $mode)); - } - extract($this->settings[$Model->alias]); - $Model->recursive = $recursive; - if ($mode == 'parent') { - $Model->bindModel(array('belongsTo' => array('VerifyParent' => array( - 'className' => $Model->name, - 'foreignKey' => $parent, - 'fields' => array($Model->primaryKey, $left, $right, $parent), - )))); - $missingParents = $Model->find('list', array( - 'recursive' => 0, - 'conditions' => array($scope, array( - 'NOT' => array($Model->escapeField($parent) => null), $Model->VerifyParent->escapeField() => null - )) - )); - $Model->unbindModel(array('belongsTo' => array('VerifyParent'))); - if ($missingParents) { - if ($missingParentAction == 'return') { - foreach ($missingParents as $id => $display) { - $this->errors[] = 'cannot find the parent for ' . $Model->alias . ' with id ' . $id . '(' . $display . ')'; - } - return false; - } elseif ($missingParentAction == 'delete') { - $Model->deleteAll(array($Model->primaryKey => array_flip($missingParents))); - } else { - $Model->updateAll(array($parent => $missingParentAction), array($Model->escapeField($Model->primaryKey) => array_flip($missingParents))); - } - } - $count = 1; - foreach ($Model->find('all', array('conditions' => $scope, 'fields' => array($Model->primaryKey), 'order' => $left)) as $array) { - $lft = $count++; - $rght = $count++; - $Model->create(false); - $Model->id = $array[$Model->alias][$Model->primaryKey]; - $Model->save(array($left => $lft, $right => $rght), array('callbacks' => false, 'validate' => false)); - } - foreach ($Model->find('all', array('conditions' => $scope, 'fields' => array($Model->primaryKey, $parent), 'order' => $left)) as $array) { - $Model->create(false); - $Model->id = $array[$Model->alias][$Model->primaryKey]; - $this->_setParent($Model, $array[$Model->alias][$parent]); - } - } else { - $db = ConnectionManager::getDataSource($Model->useDbConfig); - foreach ($Model->find('all', array('conditions' => $scope, 'fields' => array($Model->primaryKey, $parent), 'order' => $left)) as $array) { - $path = $this->getPath($Model, $array[$Model->alias][$Model->primaryKey]); - if ($path == null || count($path) < 2) { - $parentId = null; - } else { - $parentId = $path[count($path) - 2][$Model->alias][$Model->primaryKey]; - } - $Model->updateAll(array($parent => $db->value($parentId, $parent)), array($Model->escapeField() => $array[$Model->alias][$Model->primaryKey])); - } - } - return true; - } - -/** - * Reorder method. - * - * Reorders the nodes (and child nodes) of the tree according to the field and direction specified in the parameters. - * This method does not change the parent of any node. - * - * Requires a valid tree, by default it verifies the tree before beginning. - * - * Options: - * - * - 'id' id of record to use as top node for reordering - * - 'field' Which field to use in reordering defaults to displayField - * - 'order' Direction to order either DESC or ASC (defaults to ASC) - * - 'verify' Whether or not to verify the tree before reorder. defaults to true. - * - * @param Model $Model Model instance - * @param array $options array of options to use in reordering. - * @return boolean true on success, false on failure - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::reorder - */ - public function reorder(Model $Model, $options = array()) { - $options = array_merge(array('id' => null, 'field' => $Model->displayField, 'order' => 'ASC', 'verify' => true), $options); - extract($options); - if ($verify && !$this->verify($Model)) { - return false; - } - $verify = false; - extract($this->settings[$Model->alias]); - $fields = array($Model->primaryKey, $field, $left, $right); - $sort = $field . ' ' . $order; - $nodes = $this->children($Model, $id, true, $fields, $sort, null, null, $recursive); - - $cacheQueries = $Model->cacheQueries; - $Model->cacheQueries = false; - if ($nodes) { - foreach ($nodes as $node) { - $id = $node[$Model->alias][$Model->primaryKey]; - $this->moveDown($Model, $id, true); - if ($node[$Model->alias][$left] != $node[$Model->alias][$right] - 1) { - $this->reorder($Model, compact('id', 'field', 'order', 'verify')); - } - } - } - $Model->cacheQueries = $cacheQueries; - return true; - } - -/** - * Remove the current node from the tree, and reparent all children up one level. - * - * If the parameter delete is false, the node will become a new top level node. Otherwise the node will be deleted - * after the children are reparented. - * - * @param Model $Model Model instance - * @param mixed $id The ID of the record to remove - * @param boolean $delete whether to delete the node after reparenting children (if any) - * @return boolean true on success, false on failure - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::removeFromTree - */ - public function removeFromTree(Model $Model, $id = null, $delete = false) { - if (is_array($id)) { - extract (array_merge(array('id' => null), $id)); - } - extract($this->settings[$Model->alias]); - - list($node) = array_values($Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $id), - 'fields' => array($Model->primaryKey, $left, $right, $parent), - 'recursive' => $recursive - ))); - - if ($node[$right] == $node[$left] + 1) { - if ($delete) { - return $Model->delete($id); - } else { - $Model->id = $id; - return $Model->saveField($parent, null); - } - } elseif ($node[$parent]) { - list($parentNode) = array_values($Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $node[$parent]), - 'fields' => array($Model->primaryKey, $left, $right), - 'recursive' => $recursive - ))); - } else { - $parentNode[$right] = $node[$right] + 1; - } - - $db = ConnectionManager::getDataSource($Model->useDbConfig); - $Model->updateAll( - array($parent => $db->value($node[$parent], $parent)), - array($Model->escapeField($parent) => $node[$Model->primaryKey]) - ); - $this->_sync($Model, 1, '-', 'BETWEEN ' . ($node[$left] + 1) . ' AND ' . ($node[$right] - 1)); - $this->_sync($Model, 2, '-', '> ' . ($node[$right])); - $Model->id = $id; - - if ($delete) { - $Model->updateAll( - array( - $Model->escapeField($left) => 0, - $Model->escapeField($right) => 0, - $Model->escapeField($parent) => null - ), - array($Model->escapeField() => $id) - ); - return $Model->delete($id); - } else { - $edge = $this->_getMax($Model, $scope, $right, $recursive); - if ($node[$right] == $edge) { - $edge = $edge - 2; - } - $Model->id = $id; - return $Model->save( - array($left => $edge + 1, $right => $edge + 2, $parent => null), - array('callbacks' => false, 'validate' => false) - ); - } - } - -/** - * Check if the current tree is valid. - * - * Returns true if the tree is valid otherwise an array of (type, incorrect left/right index, message) - * - * @param Model $Model Model instance - * @return mixed true if the tree is valid or empty, otherwise an array of (error type [index, node], - * [incorrect left/right index,node id], message) - * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/tree.html#TreeBehavior::verify - */ - public function verify(Model $Model) { - extract($this->settings[$Model->alias]); - if (!$Model->find('count', array('conditions' => $scope))) { - return true; - } - $min = $this->_getMin($Model, $scope, $left, $recursive); - $edge = $this->_getMax($Model, $scope, $right, $recursive); - $errors = array(); - - for ($i = $min; $i <= $edge; $i++) { - $count = $Model->find('count', array('conditions' => array( - $scope, 'OR' => array($Model->escapeField($left) => $i, $Model->escapeField($right) => $i) - ))); - if ($count != 1) { - if ($count == 0) { - $errors[] = array('index', $i, 'missing'); - } else { - $errors[] = array('index', $i, 'duplicate'); - } - } - } - $node = $Model->find('first', array('conditions' => array($scope, $Model->escapeField($right) . '< ' . $Model->escapeField($left)), 'recursive' => 0)); - if ($node) { - $errors[] = array('node', $node[$Model->alias][$Model->primaryKey], 'left greater than right.'); - } - - $Model->bindModel(array('belongsTo' => array('VerifyParent' => array( - 'className' => $Model->name, - 'foreignKey' => $parent, - 'fields' => array($Model->primaryKey, $left, $right, $parent) - )))); - - foreach ($Model->find('all', array('conditions' => $scope, 'recursive' => 0)) as $instance) { - if (is_null($instance[$Model->alias][$left]) || is_null($instance[$Model->alias][$right])) { - $errors[] = array('node', $instance[$Model->alias][$Model->primaryKey], - 'has invalid left or right values'); - } elseif ($instance[$Model->alias][$left] == $instance[$Model->alias][$right]) { - $errors[] = array('node', $instance[$Model->alias][$Model->primaryKey], - 'left and right values identical'); - } elseif ($instance[$Model->alias][$parent]) { - if (!$instance['VerifyParent'][$Model->primaryKey]) { - $errors[] = array('node', $instance[$Model->alias][$Model->primaryKey], - 'The parent node ' . $instance[$Model->alias][$parent] . ' doesn\'t exist'); - } elseif ($instance[$Model->alias][$left] < $instance['VerifyParent'][$left]) { - $errors[] = array('node', $instance[$Model->alias][$Model->primaryKey], - 'left less than parent (node ' . $instance['VerifyParent'][$Model->primaryKey] . ').'); - } elseif ($instance[$Model->alias][$right] > $instance['VerifyParent'][$right]) { - $errors[] = array('node', $instance[$Model->alias][$Model->primaryKey], - 'right greater than parent (node ' . $instance['VerifyParent'][$Model->primaryKey] . ').'); - } - } elseif ($Model->find('count', array('conditions' => array($scope, $Model->escapeField($left) . ' <' => $instance[$Model->alias][$left], $Model->escapeField($right) . ' >' => $instance[$Model->alias][$right]), 'recursive' => 0))) { - $errors[] = array('node', $instance[$Model->alias][$Model->primaryKey], 'The parent field is blank, but has a parent'); - } - } - if ($errors) { - return $errors; - } - return true; - } - -/** - * Sets the parent of the given node - * - * The force parameter is used to override the "don't change the parent to the current parent" logic in the event - * of recovering a corrupted table, or creating new nodes. Otherwise it should always be false. In reality this - * method could be private, since calling save with parent_id set also calls setParent - * - * @param Model $Model Model instance - * @param mixed $parentId - * @param boolean $created - * @return boolean true on success, false on failure - */ - protected function _setParent(Model $Model, $parentId = null, $created = false) { - extract($this->settings[$Model->alias]); - list($node) = array_values($Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $Model->id), - 'fields' => array($Model->primaryKey, $parent, $left, $right), - 'recursive' => $recursive - ))); - $edge = $this->_getMax($Model, $scope, $right, $recursive, $created); - - if (empty ($parentId)) { - $this->_sync($Model, $edge - $node[$left] + 1, '+', 'BETWEEN ' . $node[$left] . ' AND ' . $node[$right], $created); - $this->_sync($Model, $node[$right] - $node[$left] + 1, '-', '> ' . $node[$left], $created); - } else { - $values = $Model->find('first', array( - 'conditions' => array($scope, $Model->escapeField() => $parentId), - 'fields' => array($Model->primaryKey, $left, $right), - 'recursive' => $recursive - )); - - if ($values === false) { - return false; - } - $parentNode = array_values($values); - - if (empty($parentNode) || empty($parentNode[0])) { - return false; - } - $parentNode = $parentNode[0]; - - if (($Model->id == $parentId)) { - return false; - } elseif (($node[$left] < $parentNode[$left]) && ($parentNode[$right] < $node[$right])) { - return false; - } - if (empty($node[$left]) && empty($node[$right])) { - $this->_sync($Model, 2, '+', '>= ' . $parentNode[$right], $created); - $result = $Model->save( - array($left => $parentNode[$right], $right => $parentNode[$right] + 1, $parent => $parentId), - array('validate' => false, 'callbacks' => false) - ); - $Model->data = $result; - } else { - $this->_sync($Model, $edge - $node[$left] + 1, '+', 'BETWEEN ' . $node[$left] . ' AND ' . $node[$right], $created); - $diff = $node[$right] - $node[$left] + 1; - - if ($node[$left] > $parentNode[$left]) { - if ($node[$right] < $parentNode[$right]) { - $this->_sync($Model, $diff, '-', 'BETWEEN ' . $node[$right] . ' AND ' . ($parentNode[$right] - 1), $created); - $this->_sync($Model, $edge - $parentNode[$right] + $diff + 1, '-', '> ' . $edge, $created); - } else { - $this->_sync($Model, $diff, '+', 'BETWEEN ' . $parentNode[$right] . ' AND ' . $node[$right], $created); - $this->_sync($Model, $edge - $parentNode[$right] + 1, '-', '> ' . $edge, $created); - } - } else { - $this->_sync($Model, $diff, '-', 'BETWEEN ' . $node[$right] . ' AND ' . ($parentNode[$right] - 1), $created); - $this->_sync($Model, $edge - $parentNode[$right] + $diff + 1, '-', '> ' . $edge, $created); - } - } - } - return true; - } - -/** - * get the maximum index value in the table. - * - * @param Model $Model - * @param string $scope - * @param string $right - * @param integer $recursive - * @param boolean $created - * @return integer - */ - protected function _getMax(Model $Model, $scope, $right, $recursive = -1, $created = false) { - $db = ConnectionManager::getDataSource($Model->useDbConfig); - if ($created) { - if (is_string($scope)) { - $scope .= " AND {$Model->alias}.{$Model->primaryKey} <> "; - $scope .= $db->value($Model->id, $Model->getColumnType($Model->primaryKey)); - } else { - $scope['NOT'][$Model->alias . '.' . $Model->primaryKey] = $Model->id; - } - } - $name = $Model->alias . '.' . $right; - list($edge) = array_values($Model->find('first', array( - 'conditions' => $scope, - 'fields' => $db->calculate($Model, 'max', array($name, $right)), - 'recursive' => $recursive - ))); - return (empty($edge[$right])) ? 0 : $edge[$right]; - } - -/** - * get the minimum index value in the table. - * - * @param Model $Model - * @param string $scope - * @param string $left - * @param integer $recursive - * @return integer - */ - protected function _getMin(Model $Model, $scope, $left, $recursive = -1) { - $db = ConnectionManager::getDataSource($Model->useDbConfig); - $name = $Model->alias . '.' . $left; - list($edge) = array_values($Model->find('first', array( - 'conditions' => $scope, - 'fields' => $db->calculate($Model, 'min', array($name, $left)), - 'recursive' => $recursive - ))); - return (empty($edge[$left])) ? 0 : $edge[$left]; - } - -/** - * Table sync method. - * - * Handles table sync operations, Taking account of the behavior scope. - * - * @param Model $Model - * @param integer $shift - * @param string $dir - * @param array $conditions - * @param boolean $created - * @param string $field - * @return void - */ - protected function _sync(Model $Model, $shift, $dir = '+', $conditions = array(), $created = false, $field = 'both') { - $ModelRecursive = $Model->recursive; - extract($this->settings[$Model->alias]); - $Model->recursive = $recursive; - - if ($field == 'both') { - $this->_sync($Model, $shift, $dir, $conditions, $created, $left); - $field = $right; - } - if (is_string($conditions)) { - $conditions = array("{$Model->alias}.{$field} {$conditions}"); - } - if (($scope != '1 = 1' && $scope !== true) && $scope) { - $conditions[] = $scope; - } - if ($created) { - $conditions['NOT'][$Model->alias . '.' . $Model->primaryKey] = $Model->id; - } - $Model->updateAll(array($Model->alias . '.' . $field => $Model->escapeField($field) . ' ' . $dir . ' ' . $shift), $conditions); - $Model->recursive = $ModelRecursive; - } - -} diff --git a/lib/Cake/Model/BehaviorCollection.php b/lib/Cake/Model/BehaviorCollection.php deleted file mode 100644 index 76c1996283d..00000000000 --- a/lib/Cake/Model/BehaviorCollection.php +++ /dev/null @@ -1,295 +0,0 @@ -modelName = $modelName; - - if (!empty($behaviors)) { - foreach (BehaviorCollection::normalizeObjectArray($behaviors) as $behavior => $config) { - $this->load($config['class'], $config['settings']); - } - } - } - -/** - * Backwards compatible alias for load() - * - * @param string $behavior - * @param array $config - * @return void - * @deprecated Replaced with load() - */ - public function attach($behavior, $config = array()) { - return $this->load($behavior, $config); - } - -/** - * Loads a behavior into the collection. You can use use `$config['enabled'] = false` - * to load a behavior with callbacks disabled. By default callbacks are enabled. Disable behaviors - * can still be used as normal. - * - * You can alias your behavior as an existing behavior by setting the 'className' key, i.e., - * {{{ - * public $actsAs = array( - * 'Tree' => array( - * 'className' => 'AliasedTree' - * ); - * ); - * }}} - * All calls to the `Tree` behavior would use `AliasedTree` instead. - * - * @param string $behavior CamelCased name of the behavior to load - * @param array $config Behavior configuration parameters - * @return boolean True on success, false on failure - * @throws MissingBehaviorException when a behavior could not be found. - */ - public function load($behavior, $config = array()) { - if (is_array($config) && isset($config['className'])) { - $alias = $behavior; - $behavior = $config['className']; - } - $configDisabled = isset($config['enabled']) && $config['enabled'] === false; - unset($config['enabled'], $config['className']); - - list($plugin, $name) = pluginSplit($behavior, true); - if (!isset($alias)) { - $alias = $name; - } - - $class = $name . 'Behavior'; - - App::uses($class, $plugin . 'Model/Behavior'); - if (!class_exists($class)) { - throw new MissingBehaviorException(array( - 'class' => $class, - 'plugin' => substr($plugin, 0, -1) - )); - } - - if (!isset($this->{$alias})) { - if (ClassRegistry::isKeySet($class)) { - $this->_loaded[$alias] = ClassRegistry::getObject($class); - } else { - $this->_loaded[$alias] = new $class(); - ClassRegistry::addObject($class, $this->_loaded[$alias]); - if (!empty($plugin)) { - ClassRegistry::addObject($plugin . '.' . $class, $this->_loaded[$alias]); - } - } - } elseif (isset($this->_loaded[$alias]->settings) && isset($this->_loaded[$alias]->settings[$this->modelName])) { - if ($config !== null && $config !== false) { - $config = array_merge($this->_loaded[$alias]->settings[$this->modelName], $config); - } else { - $config = array(); - } - } - if (empty($config)) { - $config = array(); - } - $this->_loaded[$alias]->setup(ClassRegistry::getObject($this->modelName), $config); - - foreach ($this->_loaded[$alias]->mapMethods as $method => $methodAlias) { - $this->_mappedMethods[$method] = array($alias, $methodAlias); - } - $methods = get_class_methods($this->_loaded[$alias]); - $parentMethods = array_flip(get_class_methods('ModelBehavior')); - $callbacks = array( - 'setup', 'cleanup', 'beforeFind', 'afterFind', 'beforeSave', 'afterSave', - 'beforeDelete', 'afterDelete', 'onError' - ); - - foreach ($methods as $m) { - if (!isset($parentMethods[$m])) { - $methodAllowed = ( - $m[0] != '_' && !array_key_exists($m, $this->_methods) && - !in_array($m, $callbacks) - ); - if ($methodAllowed) { - $this->_methods[$m] = array($alias, $m); - } - } - } - - if (!in_array($alias, $this->_enabled) && !$configDisabled) { - $this->enable($alias); - } else { - $this->disable($alias); - } - return true; - } - -/** - * Detaches a behavior from a model - * - * @param string $name CamelCased name of the behavior to unload - * @return void - */ - public function unload($name) { - list($plugin, $name) = pluginSplit($name); - if (isset($this->_loaded[$name])) { - $this->_loaded[$name]->cleanup(ClassRegistry::getObject($this->modelName)); - parent::unload($name); - } - foreach ($this->_methods as $m => $callback) { - if (is_array($callback) && $callback[0] == $name) { - unset($this->_methods[$m]); - } - } - } - -/** - * Backwards compatible alias for unload() - * - * @param string $name Name of behavior - * @return void - * @deprecated Use unload instead. - */ - public function detach($name) { - return $this->unload($name); - } - -/** - * Dispatches a behavior method. Will call either normal methods or mapped methods. - * - * If a method is not handled by the BehaviorCollection, and $strict is false, a - * special return of `array('unhandled')` will be returned to signal the method was not found. - * - * @param Model $model The model the method was originally called on. - * @param string $method The method called. - * @param array $params Parameters for the called method. - * @param boolean $strict If methods are not found, trigger an error. - * @return array All methods for all behaviors attached to this object - */ - public function dispatchMethod($model, $method, $params = array(), $strict = false) { - $method = $this->hasMethod($method, true); - - if ($strict && empty($method)) { - trigger_error(__d('cake_dev', "BehaviorCollection::dispatchMethod() - Method %s not found in any attached behavior", $method), E_USER_WARNING); - return null; - } - if (empty($method)) { - return array('unhandled'); - } - if (count($method) === 3) { - array_unshift($params, $method[2]); - unset($method[2]); - } - return call_user_func_array( - array($this->_loaded[$method[0]], $method[1]), - array_merge(array(&$model), $params) - ); - } - -/** - * Gets the method list for attached behaviors, i.e. all public, non-callback methods. - * This does not include mappedMethods. - * - * @return array All public methods for all behaviors attached to this collection - */ - public function methods() { - return $this->_methods; - } - -/** - * Check to see if a behavior in this collection implements the provided method. Will - * also check mappedMethods. - * - * @param string $method The method to find. - * @param boolean $callback Return the callback for the method. - * @return mixed If $callback is false, a boolean will be returned, if its true, an array - * containing callback information will be returned. For mapped methods the array will have 3 elements. - */ - public function hasMethod($method, $callback = false) { - if (isset($this->_methods[$method])) { - return $callback ? $this->_methods[$method] : true; - } - foreach ($this->_mappedMethods as $pattern => $target) { - if (preg_match($pattern . 'i', $method)) { - if ($callback) { - $target[] = $method; - return $target; - } - return true; - } - } - return false; - } - -/** - * Returns the implemented events that will get routed to the trigger function - * in order to dispatch them separately on each behavior - * - * @return array - */ - public function implementedEvents() { - return array( - 'Model.beforeFind' => 'trigger', - 'Model.afterFind' => 'trigger', - 'Model.beforeValidate' => 'trigger', - 'Model.beforeSave' => 'trigger', - 'Model.afterSave' => 'trigger', - 'Model.beforeDelete' => 'trigger', - 'Model.afterDelete' => 'trigger' - ); - } - -} diff --git a/lib/Cake/Model/CakeSchema.php b/lib/Cake/Model/CakeSchema.php deleted file mode 100644 index b9ae0ad0d0a..00000000000 --- a/lib/Cake/Model/CakeSchema.php +++ /dev/null @@ -1,707 +0,0 @@ -name = preg_replace('/schema$/i', '', get_class($this)); - } - if (!empty($options['plugin'])) { - $this->plugin = $options['plugin']; - } - - if (strtolower($this->name) === 'cake') { - $this->name = Inflector::camelize(Inflector::slug(Configure::read('App.dir'))); - } - - if (empty($options['path'])) { - $this->path = APP . 'Config' . DS . 'Schema'; - } - - $options = array_merge(get_object_vars($this), $options); - $this->build($options); - } - -/** - * Builds schema object properties - * - * @param array $data loaded object properties - * @return void - */ - public function build($data) { - $file = null; - foreach ($data as $key => $val) { - if (!empty($val)) { - if (!in_array($key, array('plugin', 'name', 'path', 'file', 'connection', 'tables', '_log'))) { - if ($key[0] === '_') { - continue; - } - $this->tables[$key] = $val; - unset($this->{$key}); - } elseif ($key !== 'tables') { - if ($key === 'name' && $val !== $this->name && !isset($data['file'])) { - $file = Inflector::underscore($val) . '.php'; - } - $this->{$key} = $val; - } - } - } - if (file_exists($this->path . DS . $file) && is_file($this->path . DS . $file)) { - $this->file = $file; - } elseif (!empty($this->plugin)) { - $this->path = CakePlugin::path($this->plugin) . 'Config' . DS . 'Schema'; - } - } - -/** - * Before callback to be implemented in subclasses - * - * @param array $event schema object properties - * @return boolean Should process continue - */ - public function before($event = array()) { - return true; - } - -/** - * After callback to be implemented in subclasses - * - * @param array $event schema object properties - * @return void - */ - public function after($event = array()) { - } - -/** - * Reads database and creates schema tables - * - * @param array $options schema object properties - * @return array Set of name and tables - */ - public function load($options = array()) { - if (is_string($options)) { - $options = array('path' => $options); - } - - $this->build($options); - extract(get_object_vars($this)); - - $class = $name . 'Schema'; - - if (!class_exists($class)) { - if (file_exists($path . DS . $file) && is_file($path . DS . $file)) { - require_once $path . DS . $file; - } elseif (file_exists($path . DS . 'schema.php') && is_file($path . DS . 'schema.php')) { - require_once $path . DS . 'schema.php'; - } - } - - if (class_exists($class)) { - $Schema = new $class($options); - return $Schema; - } - return false; - } - -/** - * Reads database and creates schema tables - * - * Options - * - * - 'connection' - the db connection to use - * - 'name' - name of the schema - * - 'models' - a list of models to use, or false to ignore models - * - * @param array $options schema object properties - * @return array Array indexed by name and tables - */ - public function read($options = array()) { - extract(array_merge( - array( - 'connection' => $this->connection, - 'name' => $this->name, - 'models' => true, - ), - $options - )); - $db = ConnectionManager::getDataSource($connection); - - if (isset($this->plugin)) { - App::uses($this->plugin . 'AppModel', $this->plugin . '.Model'); - } - - $tables = array(); - $currentTables = (array)$db->listSources(); - - $prefix = null; - if (isset($db->config['prefix'])) { - $prefix = $db->config['prefix']; - } - - if (!is_array($models) && $models !== false) { - if (isset($this->plugin)) { - $models = App::objects($this->plugin . '.Model', null, false); - } else { - $models = App::objects('Model'); - } - } - - if (is_array($models)) { - foreach ($models as $model) { - $importModel = $model; - $plugin = null; - if ($model == 'AppModel') { - continue; - } - - if (isset($this->plugin)) { - if ($model == $this->plugin . 'AppModel') { - continue; - } - $importModel = $model; - $plugin = $this->plugin . '.'; - } - - App::uses($importModel, $plugin . 'Model'); - if (!class_exists($importModel)) { - continue; - } - - $vars = get_class_vars($model); - if (empty($vars['useDbConfig']) || $vars['useDbConfig'] != $connection) { - continue; - } - - try { - $Object = ClassRegistry::init(array('class' => $model, 'ds' => $connection)); - } catch (CakeException $e) { - continue; - } - - $db = $Object->getDataSource(); - if (is_object($Object) && $Object->useTable !== false) { - $fulltable = $table = $db->fullTableName($Object, false, false); - if ($prefix && strpos($table, $prefix) !== 0) { - continue; - } - $table = $this->_noPrefixTable($prefix, $table); - - if (in_array($fulltable, $currentTables)) { - $key = array_search($fulltable, $currentTables); - if (empty($tables[$table])) { - $tables[$table] = $this->_columns($Object); - $tables[$table]['indexes'] = $db->index($Object); - $tables[$table]['tableParameters'] = $db->readTableParameters($fulltable); - unset($currentTables[$key]); - } - if (!empty($Object->hasAndBelongsToMany)) { - foreach ($Object->hasAndBelongsToMany as $Assoc => $assocData) { - if (isset($assocData['with'])) { - $class = $assocData['with']; - } - if (is_object($Object->$class)) { - $withTable = $db->fullTableName($Object->$class, false, false); - if ($prefix && strpos($withTable, $prefix) !== 0) { - continue; - } - if (in_array($withTable, $currentTables)) { - $key = array_search($withTable, $currentTables); - $noPrefixWith = $this->_noPrefixTable($prefix, $withTable); - - $tables[$noPrefixWith] = $this->_columns($Object->$class); - $tables[$noPrefixWith]['indexes'] = $db->index($Object->$class); - $tables[$noPrefixWith]['tableParameters'] = $db->readTableParameters($withTable); - unset($currentTables[$key]); - } - } - } - } - } - } - } - } - - if (!empty($currentTables)) { - foreach ($currentTables as $table) { - if ($prefix) { - if (strpos($table, $prefix) !== 0) { - continue; - } - $table = $this->_noPrefixTable($prefix, $table); - } - $Object = new AppModel(array( - 'name' => Inflector::classify($table), 'table' => $table, 'ds' => $connection - )); - - $systemTables = array( - 'aros', 'acos', 'aros_acos', Configure::read('Session.table'), 'i18n' - ); - - $fulltable = $db->fullTableName($Object, false, false); - - if (in_array($table, $systemTables)) { - $tables[$Object->table] = $this->_columns($Object); - $tables[$Object->table]['indexes'] = $db->index($Object); - $tables[$Object->table]['tableParameters'] = $db->readTableParameters($fulltable); - } elseif ($models === false) { - $tables[$table] = $this->_columns($Object); - $tables[$table]['indexes'] = $db->index($Object); - $tables[$table]['tableParameters'] = $db->readTableParameters($fulltable); - } else { - $tables['missing'][$table] = $this->_columns($Object); - $tables['missing'][$table]['indexes'] = $db->index($Object); - $tables['missing'][$table]['tableParameters'] = $db->readTableParameters($fulltable); - } - } - } - - ksort($tables); - return compact('name', 'tables'); - } - -/** - * Writes schema file from object or options - * - * @param mixed $object schema object or options array - * @param array $options schema object properties to override object - * @return mixed false or string written to file - */ - public function write($object, $options = array()) { - if (is_object($object)) { - $object = get_object_vars($object); - $this->build($object); - } - - if (is_array($object)) { - $options = $object; - unset($object); - } - - extract(array_merge( - get_object_vars($this), $options - )); - - $out = "class {$name}Schema extends CakeSchema {\n\n"; - - if ($path !== $this->path) { - $out .= "\tpublic \$path = '{$path}';\n\n"; - } - - if ($file !== $this->file) { - $out .= "\tpublic \$file = '{$file}';\n\n"; - } - - if ($connection !== 'default') { - $out .= "\tpublic \$connection = '{$connection}';\n\n"; - } - - $out .= "\tpublic function before(\$event = array()) {\n\t\treturn true;\n\t}\n\n\tpublic function after(\$event = array()) {\n\t}\n\n"; - - if (empty($tables)) { - $this->read(); - } - - foreach ($tables as $table => $fields) { - if (!is_numeric($table) && $table !== 'missing') { - $out .= $this->generateTable($table, $fields); - } - } - $out .= "}\n"; - - $file = new File($path . DS . $file, true); - $content = "write($content)) { - return $content; - } - return false; - } - -/** - * Generate the code for a table. Takes a table name and $fields array - * Returns a completed variable declaration to be used in schema classes - * - * @param string $table Table name you want returned. - * @param array $fields Array of field information to generate the table with. - * @return string Variable declaration for a schema class - */ - public function generateTable($table, $fields) { - $out = "\tpublic \${$table} = array(\n"; - if (is_array($fields)) { - $cols = array(); - foreach ($fields as $field => $value) { - if ($field != 'indexes' && $field != 'tableParameters') { - if (is_string($value)) { - $type = $value; - $value = array('type' => $type); - } - $col = "\t\t'{$field}' => array('type' => '" . $value['type'] . "', "; - unset($value['type']); - $col .= join(', ', $this->_values($value)); - } elseif ($field == 'indexes') { - $col = "\t\t'indexes' => array("; - $props = array(); - foreach ((array)$value as $key => $index) { - $props[] = "'{$key}' => array(" . join(', ', $this->_values($index)) . ")"; - } - $col .= join(', ', $props); - } elseif ($field == 'tableParameters') { - $col = "\t\t'tableParameters' => array("; - $props = array(); - foreach ((array)$value as $key => $param) { - $props[] = "'{$key}' => '$param'"; - } - $col .= join(', ', $props); - } - $col .= ")"; - $cols[] = $col; - } - $out .= join(",\n", $cols); - } - $out .= "\n\t);\n"; - return $out; - } - -/** - * Compares two sets of schemas - * - * @param mixed $old Schema object or array - * @param mixed $new Schema object or array - * @return array Tables (that are added, dropped, or changed) - */ - public function compare($old, $new = null) { - if (empty($new)) { - $new = $this; - } - if (is_array($new)) { - if (isset($new['tables'])) { - $new = $new['tables']; - } - } else { - $new = $new->tables; - } - - if (is_array($old)) { - if (isset($old['tables'])) { - $old = $old['tables']; - } - } else { - $old = $old->tables; - } - $tables = array(); - foreach ($new as $table => $fields) { - if ($table == 'missing') { - continue; - } - if (!array_key_exists($table, $old)) { - $tables[$table]['add'] = $fields; - } else { - $diff = $this->_arrayDiffAssoc($fields, $old[$table]); - if (!empty($diff)) { - $tables[$table]['add'] = $diff; - } - $diff = $this->_arrayDiffAssoc($old[$table], $fields); - if (!empty($diff)) { - $tables[$table]['drop'] = $diff; - } - } - - foreach ($fields as $field => $value) { - if (!empty($old[$table][$field])) { - $diff = $this->_arrayDiffAssoc($value, $old[$table][$field]); - if (!empty($diff) && $field !== 'indexes' && $field !== 'tableParameters') { - $tables[$table]['change'][$field] = $value; - } - } - - if (isset($tables[$table]['add'][$field]) && $field !== 'indexes' && $field !== 'tableParameters') { - $wrapper = array_keys($fields); - if ($column = array_search($field, $wrapper)) { - if (isset($wrapper[$column - 1])) { - $tables[$table]['add'][$field]['after'] = $wrapper[$column - 1]; - } - } - } - } - - if (isset($old[$table]['indexes']) && isset($new[$table]['indexes'])) { - $diff = $this->_compareIndexes($new[$table]['indexes'], $old[$table]['indexes']); - if ($diff) { - if (!isset($tables[$table])) { - $tables[$table] = array(); - } - if (isset($diff['drop'])) { - $tables[$table]['drop']['indexes'] = $diff['drop']; - } - if ($diff && isset($diff['add'])) { - $tables[$table]['add']['indexes'] = $diff['add']; - } - } - } - if (isset($old[$table]['tableParameters']) && isset($new[$table]['tableParameters'])) { - $diff = $this->_compareTableParameters($new[$table]['tableParameters'], $old[$table]['tableParameters']); - if ($diff) { - $tables[$table]['change']['tableParameters'] = $diff; - } - } - } - return $tables; - } - -/** - * Extended array_diff_assoc noticing change from/to NULL values - * - * It behaves almost the same way as array_diff_assoc except for NULL values: if - * one of the values is not NULL - change is detected. It is useful in situation - * where one value is strval('') ant other is strval(null) - in string comparing - * methods this results as EQUAL, while it is not. - * - * @param array $array1 Base array - * @param array $array2 Corresponding array checked for equality - * @return array Difference as array with array(keys => values) from input array - * where match was not found. - */ - protected function _arrayDiffAssoc($array1, $array2) { - $difference = array(); - foreach ($array1 as $key => $value) { - if (!array_key_exists($key, $array2)) { - $difference[$key] = $value; - continue; - } - $correspondingValue = $array2[$key]; - if (is_null($value) !== is_null($correspondingValue)) { - $difference[$key] = $value; - continue; - } - if (is_bool($value) !== is_bool($correspondingValue)) { - $difference[$key] = $value; - continue; - } - if (is_array($value) && is_array($correspondingValue)) { - continue; - } - if ($value === $correspondingValue) { - continue; - } - $difference[$key] = $value; - } - return $difference; - } - -/** - * Formats Schema columns from Model Object - * - * @param array $values options keys(type, null, default, key, length, extra) - * @return array Formatted values - */ - protected function _values($values) { - $vals = array(); - if (is_array($values)) { - foreach ($values as $key => $val) { - if (is_array($val)) { - $vals[] = "'{$key}' => array('" . implode("', '", $val) . "')"; - } elseif (!is_numeric($key)) { - $val = var_export($val, true); - $vals[] = "'{$key}' => {$val}"; - } - } - } - return $vals; - } - -/** - * Formats Schema columns from Model Object - * - * @param array $Obj model object - * @return array Formatted columns - */ - protected function _columns(&$Obj) { - $db = $Obj->getDataSource(); - $fields = $Obj->schema(true); - - $columns = $props = array(); - foreach ($fields as $name => $value) { - if ($Obj->primaryKey == $name) { - $value['key'] = 'primary'; - } - if (!isset($db->columns[$value['type']])) { - trigger_error(__d('cake_dev', 'Schema generation error: invalid column type %s for %s.%s does not exist in DBO', $value['type'], $Obj->name, $name), E_USER_NOTICE); - continue; - } else { - $defaultCol = $db->columns[$value['type']]; - if (isset($defaultCol['limit']) && $defaultCol['limit'] == $value['length']) { - unset($value['length']); - } elseif (isset($defaultCol['length']) && $defaultCol['length'] == $value['length']) { - unset($value['length']); - } - unset($value['limit']); - } - - if (isset($value['default']) && ($value['default'] === '' || $value['default'] === false)) { - unset($value['default']); - } - if (empty($value['length'])) { - unset($value['length']); - } - if (empty($value['key'])) { - unset($value['key']); - } - $columns[$name] = $value; - } - - return $columns; - } - -/** - * Compare two schema files table Parameters - * - * @param array $new New indexes - * @param array $old Old indexes - * @return mixed False on failure, or an array of parameters to add & drop. - */ - protected function _compareTableParameters($new, $old) { - if (!is_array($new) || !is_array($old)) { - return false; - } - $change = $this->_arrayDiffAssoc($new, $old); - return $change; - } - -/** - * Compare two schema indexes - * - * @param array $new New indexes - * @param array $old Old indexes - * @return mixed false on failure or array of indexes to add and drop - */ - protected function _compareIndexes($new, $old) { - if (!is_array($new) || !is_array($old)) { - return false; - } - - $add = $drop = array(); - - $diff = $this->_arrayDiffAssoc($new, $old); - if (!empty($diff)) { - $add = $diff; - } - - $diff = $this->_arrayDiffAssoc($old, $new); - if (!empty($diff)) { - $drop = $diff; - } - - foreach ($new as $name => $value) { - if (isset($old[$name])) { - $newUnique = isset($value['unique']) ? $value['unique'] : 0; - $oldUnique = isset($old[$name]['unique']) ? $old[$name]['unique'] : 0; - $newColumn = $value['column']; - $oldColumn = $old[$name]['column']; - - $diff = false; - - if ($newUnique != $oldUnique) { - $diff = true; - } elseif (is_array($newColumn) && is_array($oldColumn)) { - $diff = ($newColumn !== $oldColumn); - } elseif (is_string($newColumn) && is_string($oldColumn)) { - $diff = ($newColumn != $oldColumn); - } else { - $diff = true; - } - if ($diff) { - $drop[$name] = null; - $add[$name] = $value; - } - } - } - return array_filter(compact('add', 'drop')); - } - -/** - * Trim the table prefix from the full table name, and return the prefix-less table - * - * @param string $prefix Table prefix - * @param string $table Full table name - * @return string Prefix-less table name - */ - protected function _noPrefixTable($prefix, $table) { - return preg_replace('/^' . preg_quote($prefix) . '/', '', $table); - } - -} diff --git a/lib/Cake/Model/ConnectionManager.php b/lib/Cake/Model/ConnectionManager.php deleted file mode 100644 index 57bb9ed3e3f..00000000000 --- a/lib/Cake/Model/ConnectionManager.php +++ /dev/null @@ -1,266 +0,0 @@ -{$name}); - self::$_dataSources[$name]->configKeyName = $name; - - return self::$_dataSources[$name]; - } - -/** - * Gets the list of available DataSource connections - * This will only return the datasources instantiated by this manager - * It differs from enumConnectionObjects, since the latter will return all configured connections - * - * @return array List of available connections - */ - public static function sourceList() { - if (empty(self::$_init)) { - self::_init(); - } - return array_keys(self::$_dataSources); - } - -/** - * Gets a DataSource name from an object reference. - * - * @param DataSource $source DataSource object - * @return string Datasource name, or null if source is not present - * in the ConnectionManager. - */ - public static function getSourceName($source) { - if (empty(self::$_init)) { - self::_init(); - } - foreach (self::$_dataSources as $name => $ds) { - if ($ds === $source) { - return $name; - } - } - return null; - } - -/** - * Loads the DataSource class for the given connection name - * - * @param mixed $connName A string name of the connection, as defined in app/Config/database.php, - * or an array containing the filename (without extension) and class name of the object, - * to be found in app/Model/Datasource/ or lib/Cake/Model/Datasource/. - * @return boolean True on success, null on failure or false if the class is already loaded - * @throws MissingDatasourceException - */ - public static function loadDataSource($connName) { - if (empty(self::$_init)) { - self::_init(); - } - - if (is_array($connName)) { - $conn = $connName; - } else { - $conn = self::$_connectionsEnum[$connName]; - } - - if (class_exists($conn['classname'], false)) { - return false; - } - - $plugin = $package = null; - if (!empty($conn['plugin'])) { - $plugin = $conn['plugin'] . '.'; - } - if (!empty($conn['package'])) { - $package = '/' . $conn['package']; - } - - App::uses($conn['classname'], $plugin . 'Model/Datasource' . $package); - if (!class_exists($conn['classname'])) { - throw new MissingDatasourceException(array( - 'class' => $conn['classname'], - 'plugin' => substr($plugin, 0, -1) - )); - } - return true; - } - -/** - * Return a list of connections - * - * @return array An associative array of elements where the key is the connection name - * (as defined in Connections), and the value is an array with keys 'filename' and 'classname'. - */ - public static function enumConnectionObjects() { - if (empty(self::$_init)) { - self::_init(); - } - return (array)self::$config; - } - -/** - * Dynamically creates a DataSource object at runtime, with the given name and settings - * - * @param string $name The DataSource name - * @param array $config The DataSource configuration settings - * @return DataSource A reference to the DataSource object, or null if creation failed - */ - public static function create($name = '', $config = array()) { - if (empty(self::$_init)) { - self::_init(); - } - - if (empty($name) || empty($config) || array_key_exists($name, self::$_connectionsEnum)) { - return null; - } - self::$config->{$name} = $config; - self::$_connectionsEnum[$name] = self::_connectionData($config); - $return = self::getDataSource($name); - return $return; - } - -/** - * Removes a connection configuration at runtime given its name - * - * @param string $name the connection name as it was created - * @return boolean success if connection was removed, false if it does not exist - */ - public static function drop($name) { - if (empty(self::$_init)) { - self::_init(); - } - - if (!isset(self::$config->{$name})) { - return false; - } - unset(self::$_connectionsEnum[$name], self::$_dataSources[$name], self::$config->{$name}); - return true; - } - -/** - * Gets a list of class and file names associated with the user-defined DataSource connections - * - * @param string $name Connection name - * @return void - * @throws MissingDatasourceConfigException - */ - protected static function _getConnectionObject($name) { - if (!empty(self::$config->{$name})) { - self::$_connectionsEnum[$name] = self::_connectionData(self::$config->{$name}); - } else { - throw new MissingDatasourceConfigException(array('config' => $name)); - } - } - -/** - * Returns the file, class name, and parent for the given driver. - * - * @param array $config Array with connection configuration. Key 'datasource' is required - * @return array An indexed array with: filename, classname, plugin and parent - */ - protected static function _connectionData($config) { - $package = $classname = $plugin = null; - - list($plugin, $classname) = pluginSplit($config['datasource']); - if (strpos($classname, '/') !== false) { - $package = dirname($classname); - $classname = basename($classname); - } - return compact('package', 'classname', 'plugin'); - } - -} diff --git a/lib/Cake/Model/Datasource/CakeSession.php b/lib/Cake/Model/Datasource/CakeSession.php deleted file mode 100644 index 2abddbaec0a..00000000000 --- a/lib/Cake/Model/Datasource/CakeSession.php +++ /dev/null @@ -1,678 +0,0 @@ - values - * @param array $new New set of variable => value - * @return void - */ - protected static 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; - } - } - -/** - * Return error description for given error number. - * - * @param integer $errorNumber Error to set - * @return string Error as string - */ - protected static function _error($errorNumber) { - if (!is_array(self::$error) || !array_key_exists($errorNumber, self::$error)) { - return false; - } else { - return self::$error[$errorNumber]; - } - } - -/** - * Returns last occurred error as a string, if any. - * - * @return mixed Error description as a string, or false. - */ - public static function error() { - if (self::$lastError) { - return self::_error(self::$lastError); - } - return false; - } - -/** - * Returns true if session is valid. - * - * @return boolean Success - */ - public static function valid() { - if (self::read('Config')) { - if (self::_validAgentAndTime() && self::$error === false) { - self::$valid = true; - } else { - self::$valid = false; - self::_setError(1, 'Session Highjacking Attempted !!!'); - } - } - return self::$valid; - } - -/** - * Tests that the user agent is valid and that the session hasn't 'timed out'. - * Since timeouts are implemented in CakeSession it checks the current self::$time - * against the time the session is set to expire. The User agent is only checked - * if Session.checkAgent == true. - * - * @return boolean - */ - protected static function _validAgentAndTime() { - $config = self::read('Config'); - $validAgent = ( - Configure::read('Session.checkAgent') === false || - self::$_userAgent == $config['userAgent'] - ); - return ($validAgent && self::$time <= $config['time']); - } - -/** - * Get / Set the userAgent - * - * @param string $userAgent Set the userAgent - * @return void - */ - public static function userAgent($userAgent = null) { - if ($userAgent) { - self::$_userAgent = $userAgent; - } - if (empty(self::$_userAgent)) { - CakeSession::init(self::$path); - } - return self::$_userAgent; - } - -/** - * Returns given session variable, or all of them, if no parameters given. - * - * @param mixed $name The name of the session variable (or a path as sent to Set.extract) - * @return mixed The value of the session variable - */ - public static function read($name = null) { - if (!self::started() && !self::start()) { - return false; - } - if (is_null($name)) { - return self::_returnSessionVars(); - } - if (empty($name)) { - return false; - } - $result = Set::classicExtract($_SESSION, $name); - - if (!is_null($result)) { - return $result; - } - self::_setError(2, "$name doesn't exist"); - return null; - } - -/** - * Returns all session variables. - * - * @return mixed Full $_SESSION array, or false on error. - */ - protected static function _returnSessionVars() { - if (!empty($_SESSION)) { - return $_SESSION; - } - self::_setError(2, 'No Session vars set'); - return false; - } - -/** - * Writes value to given session variable name. - * - * @param mixed $name Name of variable - * @param string $value Value to write - * @return boolean True if the write was successful, false if the write failed - */ - public static function write($name, $value = null) { - if (!self::started() && !self::start()) { - return false; - } - if (empty($name)) { - return false; - } - $write = $name; - if (!is_array($name)) { - $write = array($name => $value); - } - foreach ($write as $key => $val) { - self::_overwrite($_SESSION, Set::insert($_SESSION, $key, $val)); - if (Set::classicExtract($_SESSION, $key) !== $val) { - return false; - } - } - return true; - } - -/** - * Helper method to destroy invalid sessions. - * - * @return void - */ - public static function destroy() { - if (self::started()) { - session_destroy(); - } - self::clear(); - } - -/** - * Clears the session, the session id, and renew's the session. - * - * @return void - */ - public static function clear() { - $_SESSION = null; - self::$id = null; - self::start(); - self::renew(); - } - -/** - * Helper method to initialize a session, based on Cake core settings. - * - * Sessions can be configured with a few shortcut names as well as have any number of ini settings declared. - * - * @return void - * @throws CakeSessionException Throws exceptions when ini_set() fails. - */ - protected static function _configureSession() { - $sessionConfig = Configure::read('Session'); - $iniSet = function_exists('ini_set'); - - if (isset($sessionConfig['defaults'])) { - $defaults = self::_defaultConfig($sessionConfig['defaults']); - if ($defaults) { - $sessionConfig = Set::merge($defaults, $sessionConfig); - } - } - if (!isset($sessionConfig['ini']['session.cookie_secure']) && env('HTTPS')) { - $sessionConfig['ini']['session.cookie_secure'] = 1; - } - if (isset($sessionConfig['timeout']) && !isset($sessionConfig['cookieTimeout'])) { - $sessionConfig['cookieTimeout'] = $sessionConfig['timeout']; - } - if (!isset($sessionConfig['ini']['session.cookie_lifetime'])) { - $sessionConfig['ini']['session.cookie_lifetime'] = $sessionConfig['cookieTimeout'] * 60; - } - if (!isset($sessionConfig['ini']['session.name'])) { - $sessionConfig['ini']['session.name'] = $sessionConfig['cookie']; - } - if (!empty($sessionConfig['handler'])) { - $sessionConfig['ini']['session.save_handler'] = 'user'; - } - - if (empty($_SESSION)) { - if (!empty($sessionConfig['ini']) && is_array($sessionConfig['ini'])) { - foreach ($sessionConfig['ini'] as $setting => $value) { - if (ini_set($setting, $value) === false) { - throw new CakeSessionException(sprintf( - __d('cake_dev', 'Unable to configure the session, setting %s failed.'), - $setting - )); - } - } - } - } - if (!empty($sessionConfig['handler']) && !isset($sessionConfig['handler']['engine'])) { - call_user_func_array('session_set_save_handler', $sessionConfig['handler']); - } - if (!empty($sessionConfig['handler']['engine'])) { - $handler = self::_getHandler($sessionConfig['handler']['engine']); - session_set_save_handler( - array($handler, 'open'), - array($handler, 'close'), - array($handler, 'read'), - array($handler, 'write'), - array($handler, 'destroy'), - array($handler, 'gc') - ); - } - Configure::write('Session', $sessionConfig); - self::$sessionTime = self::$time + ($sessionConfig['timeout'] * 60); - } - -/** - * Find the handler class and make sure it implements the correct interface. - * - * @param string $handler - * @return void - * @throws CakeSessionException - */ - protected static function _getHandler($handler) { - list($plugin, $class) = pluginSplit($handler, true); - App::uses($class, $plugin . 'Model/Datasource/Session'); - if (!class_exists($class)) { - throw new CakeSessionException(__d('cake_dev', 'Could not load %s to handle the session.', $class)); - } - $handler = new $class(); - if ($handler instanceof CakeSessionHandlerInterface) { - return $handler; - } - throw new CakeSessionException(__d('cake_dev', 'Chosen SessionHandler does not implement CakeSessionHandlerInterface it cannot be used with an engine key.')); - } - -/** - * Get one of the prebaked default session configurations. - * - * @param string $name - * @return boolean|array - */ - protected static function _defaultConfig($name) { - $defaults = array( - 'php' => array( - 'cookie' => 'CAKEPHP', - 'timeout' => 240, - 'ini' => array( - 'session.use_trans_sid' => 0, - 'session.cookie_path' => self::$path - ) - ), - 'cake' => array( - 'cookie' => 'CAKEPHP', - 'timeout' => 240, - 'ini' => array( - 'session.use_trans_sid' => 0, - 'url_rewriter.tags' => '', - 'session.serialize_handler' => 'php', - 'session.use_cookies' => 1, - 'session.cookie_path' => self::$path, - 'session.auto_start' => 0, - 'session.save_path' => TMP . 'sessions', - 'session.save_handler' => 'files' - ) - ), - 'cache' => array( - 'cookie' => 'CAKEPHP', - 'timeout' => 240, - 'ini' => array( - 'session.use_trans_sid' => 0, - 'url_rewriter.tags' => '', - 'session.auto_start' => 0, - 'session.use_cookies' => 1, - 'session.cookie_path' => self::$path, - 'session.save_handler' => 'user', - ), - 'handler' => array( - 'engine' => 'CacheSession', - 'config' => 'default' - ) - ), - 'database' => array( - 'cookie' => 'CAKEPHP', - 'timeout' => 240, - 'ini' => array( - 'session.use_trans_sid' => 0, - 'url_rewriter.tags' => '', - 'session.auto_start' => 0, - 'session.use_cookies' => 1, - 'session.cookie_path' => self::$path, - 'session.save_handler' => 'user', - 'session.serialize_handler' => 'php', - ), - 'handler' => array( - 'engine' => 'DatabaseSession', - 'model' => 'Session' - ) - ) - ); - if (isset($defaults[$name])) { - return $defaults[$name]; - } - return false; - } - -/** - * Helper method to start a session - * - * @return boolean Success - */ - protected static function _startSession() { - if (headers_sent()) { - if (empty($_SESSION)) { - $_SESSION = array(); - } - } else { - session_start(); - } - return true; - } - -/** - * Helper method to create a new session. - * - * @return void - */ - protected static function _checkValid() { - if (!self::started() && !self::start()) { - self::$valid = false; - return false; - } - if ($config = self::read('Config')) { - $sessionConfig = Configure::read('Session'); - - if (self::_validAgentAndTime()) { - self::write('Config.time', self::$sessionTime); - if (isset($sessionConfig['autoRegenerate']) && $sessionConfig['autoRegenerate'] === true) { - $check = $config['countdown']; - $check -= 1; - self::write('Config.countdown', $check); - - if ($check < 1) { - self::renew(); - self::write('Config.countdown', self::$requestCountdown); - } - } - self::$valid = true; - } else { - self::destroy(); - self::$valid = false; - self::_setError(1, 'Session Highjacking Attempted !!!'); - } - } else { - self::write('Config.userAgent', self::$_userAgent); - self::write('Config.time', self::$sessionTime); - self::write('Config.countdown', self::$requestCountdown); - self::$valid = true; - } - } - -/** - * Restarts this session. - * - * @return void - */ - public static function renew() { - if (session_id()) { - if (session_id() != '' || isset($_COOKIE[session_name()])) { - setcookie(Configure::read('Session.cookie'), '', time() - 42000, self::$path); - } - session_regenerate_id(true); - } - } - -/** - * Helper method to set an internal error message. - * - * @param integer $errorNumber Number of the error - * @param string $errorMessage Description of the error - * @return void - */ - protected static function _setError($errorNumber, $errorMessage) { - if (self::$error === false) { - self::$error = array(); - } - self::$error[$errorNumber] = $errorMessage; - self::$lastError = $errorNumber; - } - -} diff --git a/lib/Cake/Model/Datasource/DataSource.php b/lib/Cake/Model/Datasource/DataSource.php deleted file mode 100644 index 085a2c91009..00000000000 --- a/lib/Cake/Model/Datasource/DataSource.php +++ /dev/null @@ -1,432 +0,0 @@ -setConfig($config); - } - -/** - * Caches/returns cached results for child instances - * - * @param mixed $data - * @return array Array of sources available in this datasource. - */ - public function listSources($data = null) { - if ($this->cacheSources === false) { - return null; - } - - if ($this->_sources !== null) { - return $this->_sources; - } - - $key = ConnectionManager::getSourceName($this) . '_' . $this->config['database'] . '_list'; - $key = preg_replace('/[^A-Za-z0-9_\-.+]/', '_', $key); - $sources = Cache::read($key, '_cake_model_'); - - if (empty($sources)) { - $sources = $data; - Cache::write($key, $data, '_cake_model_'); - } - - return $this->_sources = $sources; - } - -/** - * Returns a Model description (metadata) or null if none found. - * - * @param Model|string $model - * @return array Array of Metadata for the $model - */ - public function describe($model) { - if ($this->cacheSources === false) { - return null; - } - if (is_string($model)) { - $table = $model; - } else { - $table = $model->tablePrefix . $model->table; - } - - if (isset($this->_descriptions[$table])) { - return $this->_descriptions[$table]; - } - $cache = $this->_cacheDescription($table); - - if ($cache !== null) { - $this->_descriptions[$table] =& $cache; - return $cache; - } - return null; - } - -/** - * Begin a transaction - * - * @return boolean Returns true if a transaction is not in progress - */ - public function begin() { - return !$this->_transactionStarted; - } - -/** - * Commit a transaction - * - * @return boolean Returns true if a transaction is in progress - */ - public function commit() { - return $this->_transactionStarted; - } - -/** - * Rollback a transaction - * - * @return boolean Returns true if a transaction is in progress - */ - public function rollback() { - return $this->_transactionStarted; - } - -/** - * Converts column types to basic types - * - * @param string $real Real column type (i.e. "varchar(255)") - * @return string Abstract column type (i.e. "string") - */ - public function column($real) { - return false; - } - -/** - * Used to create new records. The "C" CRUD. - * - * To-be-overridden in subclasses. - * - * @param Model $model The Model to be created. - * @param array $fields An Array of fields to be saved. - * @param array $values An Array of values to save. - * @return boolean success - */ - public function create(Model $model, $fields = null, $values = null) { - return false; - } - -/** - * Used to read records from the Datasource. The "R" in CRUD - * - * To-be-overridden in subclasses. - * - * @param Model $model The model being read. - * @param array $queryData An array of query data used to find the data you want - * @return mixed - */ - public function read(Model $model, $queryData = array()) { - return false; - } - -/** - * Update a record(s) in the datasource. - * - * To-be-overridden in subclasses. - * - * @param Model $model Instance of the model class being updated - * @param array $fields Array of fields to be updated - * @param array $values Array of values to be update $fields to. - * @return boolean Success - */ - public function update(Model $model, $fields = null, $values = null) { - return false; - } - -/** - * Delete a record(s) in the datasource. - * - * To-be-overridden in subclasses. - * - * @param Model $model The model class having record(s) deleted - * @param mixed $conditions The conditions to use for deleting. - * @return void - */ - public function delete(Model $model, $id = null) { - return false; - } - -/** - * Returns the ID generated from the previous INSERT operation. - * - * @param mixed $source - * @return mixed Last ID key generated in previous INSERT - */ - public function lastInsertId($source = null) { - return false; - } - -/** - * Returns the number of rows returned by last operation. - * - * @param mixed $source - * @return integer Number of rows returned by last operation - */ - public function lastNumRows($source = null) { - return false; - } - -/** - * Returns the number of rows affected by last query. - * - * @param mixed $source - * @return integer Number of rows affected by last query. - */ - public function lastAffected($source = null) { - return false; - } - -/** - * Check whether the conditions for the Datasource being available - * are satisfied. Often used from connect() to check for support - * before establishing a connection. - * - * @return boolean Whether or not the Datasources conditions for use are met. - */ - public function enabled() { - return true; - } - -/** - * Sets the configuration for the DataSource. - * Merges the $config information with the _baseConfig and the existing $config property. - * - * @param array $config The configuration array - * @return void - */ - public function setConfig($config = array()) { - $this->config = array_merge($this->_baseConfig, $this->config, $config); - } - -/** - * Cache the DataSource description - * - * @param string $object The name of the object (model) to cache - * @param mixed $data The description of the model, usually a string or array - * @return mixed - */ - protected function _cacheDescription($object, $data = null) { - if ($this->cacheSources === false) { - return null; - } - - if ($data !== null) { - $this->_descriptions[$object] =& $data; - } - - $key = ConnectionManager::getSourceName($this) . '_' . $object; - $cache = Cache::read($key, '_cake_model_'); - - if (empty($cache)) { - $cache = $data; - Cache::write($key, $cache, '_cake_model_'); - } - - return $cache; - } - -/** - * Replaces `{$__cakeID__$}` and `{$__cakeForeignKey__$}` placeholders in query data. - * - * @param string $query Query string needing replacements done. - * @param array $data Array of data with values that will be inserted in placeholders. - * @param string $association Name of association model being replaced - * @param array $assocData - * @param Model $model Instance of the model to replace $__cakeID__$ - * @param Model $linkModel Instance of model to replace $__cakeForeignKey__$ - * @param array $stack - * @return string String of query data with placeholders replaced. - * @todo Remove and refactor $assocData, ensure uses of the method have the param removed too. - */ - public function insertQueryData($query, $data, $association, $assocData, Model $model, Model $linkModel, $stack) { - $keys = array('{$__cakeID__$}', '{$__cakeForeignKey__$}'); - - foreach ($keys as $key) { - $val = null; - $type = null; - - if (strpos($query, $key) !== false) { - switch ($key) { - case '{$__cakeID__$}': - if (isset($data[$model->alias]) || isset($data[$association])) { - if (isset($data[$model->alias][$model->primaryKey])) { - $val = $data[$model->alias][$model->primaryKey]; - } elseif (isset($data[$association][$model->primaryKey])) { - $val = $data[$association][$model->primaryKey]; - } - } else { - $found = false; - foreach (array_reverse($stack) as $assoc) { - if (isset($data[$assoc]) && isset($data[$assoc][$model->primaryKey])) { - $val = $data[$assoc][$model->primaryKey]; - $found = true; - break; - } - } - if (!$found) { - $val = ''; - } - } - $type = $model->getColumnType($model->primaryKey); - break; - case '{$__cakeForeignKey__$}': - foreach ($model->associations() as $id => $name) { - foreach ($model->$name as $assocName => $assoc) { - if ($assocName === $association) { - if (isset($assoc['foreignKey'])) { - $foreignKey = $assoc['foreignKey']; - $assocModel = $model->$assocName; - $type = $assocModel->getColumnType($assocModel->primaryKey); - - if (isset($data[$model->alias][$foreignKey])) { - $val = $data[$model->alias][$foreignKey]; - } elseif (isset($data[$association][$foreignKey])) { - $val = $data[$association][$foreignKey]; - } else { - $found = false; - foreach (array_reverse($stack) as $assoc) { - if (isset($data[$assoc]) && isset($data[$assoc][$foreignKey])) { - $val = $data[$assoc][$foreignKey]; - $found = true; - break; - } - } - if (!$found) { - $val = ''; - } - } - } - break 3; - } - } - } - break; - } - if (empty($val) && $val !== '0') { - return false; - } - $query = str_replace($key, $this->value($val, $type), $query); - } - } - return $query; - } - -/** - * To-be-overridden in subclasses. - * - * @param Model $model Model instance - * @param string $key Key name to make - * @return string Key name for model. - */ - public function resolveKey(Model $model, $key) { - return $model->alias . $key; - } - -/** - * Returns the schema name. Override this in subclasses. - * - * @return string schema name - * @access public - */ - public function getSchemaName() { - return null; - } - -/** - * Closes the current datasource. - * - */ - public function __destruct() { - if ($this->_transactionStarted) { - $this->rollback(); - } - if ($this->connected) { - $this->close(); - } - } - -} diff --git a/lib/Cake/Model/Datasource/Database/Mysql.php b/lib/Cake/Model/Datasource/Database/Mysql.php deleted file mode 100644 index b5421f43d93..00000000000 --- a/lib/Cake/Model/Datasource/Database/Mysql.php +++ /dev/null @@ -1,699 +0,0 @@ - true, - 'host' => 'localhost', - 'login' => 'root', - 'password' => '', - 'database' => 'cake', - 'port' => '3306' - ); - -/** - * Reference to the PDO object connection - * - * @var PDO $_connection - */ - protected $_connection = null; - -/** - * Start quote - * - * @var string - */ - public $startQuote = "`"; - -/** - * End quote - * - * @var string - */ - public $endQuote = "`"; - -/** - * use alias for update and delete. Set to true if version >= 4.1 - * - * @var boolean - */ - protected $_useAlias = true; - -/** - * Index of basic SQL commands - * - * @var array - */ - protected $_commands = array( - 'begin' => 'START TRANSACTION', - 'commit' => 'COMMIT', - 'rollback' => 'ROLLBACK' - ); - -/** - * List of engine specific additional field parameters used on table creating - * - * @var array - */ - public $fieldParameters = array( - 'charset' => array('value' => 'CHARACTER SET', 'quote' => false, 'join' => ' ', 'column' => false, 'position' => 'beforeDefault'), - 'collate' => array('value' => 'COLLATE', 'quote' => false, 'join' => ' ', 'column' => 'Collation', 'position' => 'beforeDefault'), - 'comment' => array('value' => 'COMMENT', 'quote' => true, 'join' => ' ', 'column' => 'Comment', 'position' => 'afterDefault') - ); - -/** - * List of table engine specific parameters used on table creating - * - * @var array - */ - public $tableParameters = array( - 'charset' => array('value' => 'DEFAULT CHARSET', 'quote' => false, 'join' => '=', 'column' => 'charset'), - 'collate' => array('value' => 'COLLATE', 'quote' => false, 'join' => '=', 'column' => 'Collation'), - 'engine' => array('value' => 'ENGINE', 'quote' => false, 'join' => '=', 'column' => 'Engine') - ); - -/** - * MySQL column definition - * - * @var array - */ - public $columns = array( - 'primary_key' => array('name' => 'NOT NULL AUTO_INCREMENT'), - 'string' => array('name' => 'varchar', 'limit' => '255'), - 'text' => array('name' => 'text'), - 'integer' => array('name' => 'int', 'limit' => '11', 'formatter' => 'intval'), - 'float' => array('name' => 'float', 'formatter' => 'floatval'), - 'datetime' => array('name' => 'datetime', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'timestamp' => array('name' => 'timestamp', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'time' => array('name' => 'time', 'format' => 'H:i:s', 'formatter' => 'date'), - 'date' => array('name' => 'date', 'format' => 'Y-m-d', 'formatter' => 'date'), - 'binary' => array('name' => 'blob'), - 'boolean' => array('name' => 'tinyint', 'limit' => '1') - ); - -/** - * Connects to the database using options in the given configuration array. - * - * @return boolean True if the database could be connected, else false - * @throws MissingConnectionException - */ - public function connect() { - $config = $this->config; - $this->connected = false; - try { - $flags = array( - PDO::ATTR_PERSISTENT => $config['persistent'], - PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION - ); - if (!empty($config['encoding'])) { - $flags[PDO::MYSQL_ATTR_INIT_COMMAND] = 'SET NAMES ' . $config['encoding']; - } - if (empty($config['unix_socket'])) { - $dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']}"; - } else { - $dsn = "mysql:unix_socket={$config['unix_socket']};dbname={$config['database']}"; - } - $this->_connection = new PDO( - $dsn, - $config['login'], - $config['password'], - $flags - ); - $this->connected = true; - } catch (PDOException $e) { - throw new MissingConnectionException(array('class' => $e->getMessage())); - } - - $this->_useAlias = (bool)version_compare($this->getVersion(), "4.1", ">="); - - return $this->connected; - } - -/** - * Check whether the MySQL extension is installed/loaded - * - * @return boolean - */ - public function enabled() { - return in_array('mysql', PDO::getAvailableDrivers()); - } - -/** - * Returns an array of sources (tables) in the database. - * - * @param mixed $data - * @return array Array of table names in the database - */ - public function listSources($data = null) { - $cache = parent::listSources(); - if ($cache != null) { - return $cache; - } - $result = $this->_execute('SHOW TABLES FROM ' . $this->name($this->config['database'])); - - if (!$result) { - $result->closeCursor(); - return array(); - } else { - $tables = array(); - - while ($line = $result->fetch(PDO::FETCH_NUM)) { - $tables[] = $line[0]; - } - - $result->closeCursor(); - parent::listSources($tables); - return $tables; - } - } - -/** - * Builds a map of the columns contained in a result - * - * @param PDOStatement $results - * @return void - */ - public function resultSet($results) { - $this->map = array(); - $numFields = $results->columnCount(); - $index = 0; - - while ($numFields-- > 0) { - $column = $results->getColumnMeta($index); - if (empty($column['native_type'])) { - $type = ($column['len'] == 1) ? 'boolean' : 'string'; - } else { - $type = $column['native_type']; - } - if (!empty($column['table']) && strpos($column['name'], $this->virtualFieldSeparator) === false) { - $this->map[$index++] = array($column['table'], $column['name'], $type); - } else { - $this->map[$index++] = array(0, $column['name'], $type); - } - } - } - -/** - * Fetches the next row from the current result set - * - * @return mixed array with results fetched and mapped to column names or false if there is no results left to fetch - */ - public function fetchResult() { - if ($row = $this->_result->fetch(PDO::FETCH_NUM)) { - $resultRow = array(); - foreach ($this->map as $col => $meta) { - list($table, $column, $type) = $meta; - $resultRow[$table][$column] = $row[$col]; - if ($type === 'boolean' && $row[$col] !== null) { - $resultRow[$table][$column] = $this->boolean($resultRow[$table][$column]); - } - } - return $resultRow; - } - $this->_result->closeCursor(); - return false; - } - -/** - * Gets the database encoding - * - * @return string The database encoding - */ - public function getEncoding() { - return $this->_execute('SHOW VARIABLES LIKE ?', array('character_set_client'))->fetchObject()->Value; - } - -/** - * Gets the version string of the database server - * - * @return string The database encoding - */ - public function getVersion() { - return $this->_connection->getAttribute(PDO::ATTR_SERVER_VERSION); - } - -/** - * Query charset by collation - * - * @param string $name Collation name - * @return string Character set name - */ - public function getCharsetName($name) { - if ((bool)version_compare($this->getVersion(), "5", ">=")) { - $r = $this->_execute('SELECT CHARACTER_SET_NAME FROM INFORMATION_SCHEMA.COLLATIONS WHERE COLLATION_NAME = ?', array($name)); - $cols = $r->fetch(PDO::FETCH_ASSOC); - - if (isset($cols['CHARACTER_SET_NAME'])) { - return $cols['CHARACTER_SET_NAME']; - } - } - return false; - } - -/** - * Returns an array of the fields in given table name. - * - * @param Model|string $model Name of database table to inspect or model instance - * @return array Fields in table. Keys are name and type - * @throws CakeException - */ - public function describe($model) { - $key = $this->fullTableName($model, false); - $cache = parent::describe($key); - if ($cache != null) { - return $cache; - } - $table = $this->fullTableName($model); - - $fields = false; - $cols = $this->_execute('SHOW FULL COLUMNS FROM ' . $table); - if (!$cols) { - throw new CakeException(__d('cake_dev', 'Could not describe table for %s', $table)); - } - - while ($column = $cols->fetch(PDO::FETCH_OBJ)) { - $fields[$column->Field] = array( - 'type' => $this->column($column->Type), - 'null' => ($column->Null === 'YES' ? true : false), - 'default' => $column->Default, - 'length' => $this->length($column->Type), - ); - if (!empty($column->Key) && isset($this->index[$column->Key])) { - $fields[$column->Field]['key'] = $this->index[$column->Key]; - } - foreach ($this->fieldParameters as $name => $value) { - if (!empty($column->{$value['column']})) { - $fields[$column->Field][$name] = $column->{$value['column']}; - } - } - if (isset($fields[$column->Field]['collate'])) { - $charset = $this->getCharsetName($fields[$column->Field]['collate']); - if ($charset) { - $fields[$column->Field]['charset'] = $charset; - } - } - } - $this->_cacheDescription($key, $fields); - $cols->closeCursor(); - return $fields; - } - -/** - * Generates and executes an SQL UPDATE statement for given model, fields, and values. - * - * @param Model $model - * @param array $fields - * @param array $values - * @param mixed $conditions - * @return array - */ - public function update(Model $model, $fields = array(), $values = null, $conditions = null) { - if (!$this->_useAlias) { - return parent::update($model, $fields, $values, $conditions); - } - - if ($values == null) { - $combined = $fields; - } else { - $combined = array_combine($fields, $values); - } - - $alias = $joins = false; - $fields = $this->_prepareUpdateFields($model, $combined, empty($conditions), !empty($conditions)); - $fields = implode(', ', $fields); - $table = $this->fullTableName($model); - - if (!empty($conditions)) { - $alias = $this->name($model->alias); - if ($model->name == $model->alias) { - $joins = implode(' ', $this->_getJoins($model)); - } - } - $conditions = $this->conditions($this->defaultConditions($model, $conditions, $alias), true, true, $model); - - if ($conditions === false) { - return false; - } - - if (!$this->execute($this->renderStatement('update', compact('table', 'alias', 'joins', 'fields', 'conditions')))) { - $model->onError(); - return false; - } - return true; - } - -/** - * Generates and executes an SQL DELETE statement for given id/conditions on given model. - * - * @param Model $model - * @param mixed $conditions - * @return boolean Success - */ - public function delete(Model $model, $conditions = null) { - if (!$this->_useAlias) { - return parent::delete($model, $conditions); - } - $alias = $this->name($model->alias); - $table = $this->fullTableName($model); - $joins = implode(' ', $this->_getJoins($model)); - - if (empty($conditions)) { - $alias = $joins = false; - } - $complexConditions = false; - foreach ((array)$conditions as $key => $value) { - if (strpos($key, $model->alias) === false) { - $complexConditions = true; - break; - } - } - if (!$complexConditions) { - $joins = false; - } - - $conditions = $this->conditions($this->defaultConditions($model, $conditions, $alias), true, true, $model); - if ($conditions === false) { - return false; - } - if ($this->execute($this->renderStatement('delete', compact('alias', 'table', 'joins', 'conditions'))) === false) { - $model->onError(); - return false; - } - return true; - } - -/** - * Sets the database encoding - * - * @param string $enc Database encoding - * @return boolean - */ - public function setEncoding($enc) { - return $this->_execute('SET NAMES ' . $enc) !== false; - } - -/** - * Returns an array of the indexes in given datasource name. - * - * @param string $model Name of model to inspect - * @return array Fields in table. Keys are column and unique - */ - public function index($model) { - $index = array(); - $table = $this->fullTableName($model); - $old = version_compare($this->getVersion(), '4.1', '<='); - if ($table) { - $indices = $this->_execute('SHOW INDEX FROM ' . $table); - // @codingStandardsIgnoreStart - // MySQL columns don't match the cakephp conventions. - while ($idx = $indices->fetch(PDO::FETCH_OBJ)) { - if ($old) { - $idx = (object)current((array)$idx); - } - if (!isset($index[$idx->Key_name]['column'])) { - $col = array(); - $index[$idx->Key_name]['column'] = $idx->Column_name; - $index[$idx->Key_name]['unique'] = intval($idx->Non_unique == 0); - } else { - if (!empty($index[$idx->Key_name]['column']) && !is_array($index[$idx->Key_name]['column'])) { - $col[] = $index[$idx->Key_name]['column']; - } - $col[] = $idx->Column_name; - $index[$idx->Key_name]['column'] = $col; - } - } - // @codingStandardsIgnoreEnd - $indices->closeCursor(); - } - return $index; - } - -/** - * Generate a MySQL Alter Table syntax for the given Schema comparison - * - * @param array $compare Result of a CakeSchema::compare() - * @param string $table - * @return array Array of alter statements to make. - */ - public function alterSchema($compare, $table = null) { - if (!is_array($compare)) { - return false; - } - $out = ''; - $colList = array(); - foreach ($compare as $curTable => $types) { - $indexes = $tableParameters = $colList = array(); - if (!$table || $table == $curTable) { - $out .= 'ALTER TABLE ' . $this->fullTableName($curTable) . " \n"; - foreach ($types as $type => $column) { - if (isset($column['indexes'])) { - $indexes[$type] = $column['indexes']; - unset($column['indexes']); - } - if (isset($column['tableParameters'])) { - $tableParameters[$type] = $column['tableParameters']; - unset($column['tableParameters']); - } - switch ($type) { - case 'add': - foreach ($column as $field => $col) { - $col['name'] = $field; - $alter = 'ADD ' . $this->buildColumn($col); - if (isset($col['after'])) { - $alter .= ' AFTER ' . $this->name($col['after']); - } - $colList[] = $alter; - } - break; - case 'drop': - foreach ($column as $field => $col) { - $col['name'] = $field; - $colList[] = 'DROP ' . $this->name($field); - } - break; - case 'change': - foreach ($column as $field => $col) { - if (!isset($col['name'])) { - $col['name'] = $field; - } - $colList[] = 'CHANGE ' . $this->name($field) . ' ' . $this->buildColumn($col); - } - break; - } - } - $colList = array_merge($colList, $this->_alterIndexes($curTable, $indexes)); - $colList = array_merge($colList, $this->_alterTableParameters($curTable, $tableParameters)); - $out .= "\t" . implode(",\n\t", $colList) . ";\n\n"; - } - } - return $out; - } - -/** - * Generate a MySQL "drop table" statement for the given Schema object - * - * @param CakeSchema $schema An instance of a subclass of CakeSchema - * @param string $table Optional. If specified only the table name given will be generated. - * Otherwise, all tables defined in the schema are generated. - * @return string - */ - public function dropSchema(CakeSchema $schema, $table = null) { - $out = ''; - foreach ($schema->tables as $curTable => $columns) { - if (!$table || $table === $curTable) { - $out .= 'DROP TABLE IF EXISTS ' . $this->fullTableName($curTable) . ";\n"; - } - } - return $out; - } - -/** - * Generate MySQL table parameter alteration statements for a table. - * - * @param string $table Table to alter parameters for. - * @param array $parameters Parameters to add & drop. - * @return array Array of table property alteration statements. - * @todo Implement this method. - */ - protected function _alterTableParameters($table, $parameters) { - if (isset($parameters['change'])) { - return $this->buildTableParameters($parameters['change']); - } - return array(); - } - -/** - * Generate MySQL index alteration statements for a table. - * - * @param string $table Table to alter indexes for - * @param array $indexes Indexes to add and drop - * @return array Index alteration statements - */ - protected function _alterIndexes($table, $indexes) { - $alter = array(); - if (isset($indexes['drop'])) { - foreach ($indexes['drop'] as $name => $value) { - $out = 'DROP '; - if ($name == 'PRIMARY') { - $out .= 'PRIMARY KEY'; - } else { - $out .= 'KEY ' . $name; - } - $alter[] = $out; - } - } - if (isset($indexes['add'])) { - foreach ($indexes['add'] as $name => $value) { - $out = 'ADD '; - if ($name == 'PRIMARY') { - $out .= 'PRIMARY '; - $name = null; - } else { - if (!empty($value['unique'])) { - $out .= 'UNIQUE '; - } - } - if (is_array($value['column'])) { - $out .= 'KEY ' . $name . ' (' . implode(', ', array_map(array(&$this, 'name'), $value['column'])) . ')'; - } else { - $out .= 'KEY ' . $name . ' (' . $this->name($value['column']) . ')'; - } - $alter[] = $out; - } - } - return $alter; - } - -/** - * Returns an detailed array of sources (tables) in the database. - * - * @param string $name Table name to get parameters - * @return array Array of table names in the database - */ - public function listDetailedSources($name = null) { - $condition = ''; - if (is_string($name)) { - $condition = ' WHERE name = ' . $this->value($name); - } - $result = $this->_connection->query('SHOW TABLE STATUS ' . $condition, PDO::FETCH_ASSOC); - - if (!$result) { - $result->closeCursor(); - return array(); - } else { - $tables = array(); - foreach ($result as $row) { - $tables[$row['Name']] = (array)$row; - unset($tables[$row['Name']]['queryString']); - if (!empty($row['Collation'])) { - $charset = $this->getCharsetName($row['Collation']); - if ($charset) { - $tables[$row['Name']]['charset'] = $charset; - } - } - } - $result->closeCursor(); - if (is_string($name) && isset($tables[$name])) { - return $tables[$name]; - } - return $tables; - } - } - -/** - * Converts database-layer column types to basic types - * - * @param string $real Real database-layer column type (i.e. "varchar(255)") - * @return string Abstract column type (i.e. "string") - */ - public function column($real) { - if (is_array($real)) { - $col = $real['name']; - if (isset($real['limit'])) { - $col .= '(' . $real['limit'] . ')'; - } - return $col; - } - - $col = str_replace(')', '', $real); - $limit = $this->length($real); - if (strpos($col, '(') !== false) { - list($col, $vals) = explode('(', $col); - } - - if (in_array($col, array('date', 'time', 'datetime', 'timestamp'))) { - return $col; - } - if (($col === 'tinyint' && $limit == 1) || $col === 'boolean') { - return 'boolean'; - } - if (strpos($col, 'int') !== false) { - return 'integer'; - } - if (strpos($col, 'char') !== false || $col === 'tinytext') { - return 'string'; - } - if (strpos($col, 'text') !== false) { - return 'text'; - } - if (strpos($col, 'blob') !== false || $col === 'binary') { - return 'binary'; - } - if (strpos($col, 'float') !== false || strpos($col, 'double') !== false || strpos($col, 'decimal') !== false) { - return 'float'; - } - if (strpos($col, 'enum') !== false) { - return "enum($vals)"; - } - return 'text'; - } - -/** - * Gets the schema name - * - * @return string The schema name - */ - public function getSchemaName() { - return $this->config['database']; - } - -} diff --git a/lib/Cake/Model/Datasource/Database/Postgres.php b/lib/Cake/Model/Datasource/Database/Postgres.php deleted file mode 100644 index 41d1844d0cc..00000000000 --- a/lib/Cake/Model/Datasource/Database/Postgres.php +++ /dev/null @@ -1,909 +0,0 @@ - 'BEGIN', - 'commit' => 'COMMIT', - 'rollback' => 'ROLLBACK' - ); - -/** - * Base driver configuration settings. Merged with user settings. - * - * @var array - */ - protected $_baseConfig = array( - 'persistent' => true, - 'host' => 'localhost', - 'login' => 'root', - 'password' => '', - 'database' => 'cake', - 'schema' => 'public', - 'port' => 5432, - 'encoding' => '' - ); - -/** - * Columns - * - * @var array - */ - public $columns = array( - 'primary_key' => array('name' => 'serial NOT NULL'), - 'string' => array('name' => 'varchar', 'limit' => '255'), - 'text' => array('name' => 'text'), - 'integer' => array('name' => 'integer', 'formatter' => 'intval'), - 'float' => array('name' => 'float', 'formatter' => 'floatval'), - 'datetime' => array('name' => 'timestamp', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'timestamp' => array('name' => 'timestamp', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'time' => array('name' => 'time', 'format' => 'H:i:s', 'formatter' => 'date'), - 'date' => array('name' => 'date', 'format' => 'Y-m-d', 'formatter' => 'date'), - 'binary' => array('name' => 'bytea'), - 'boolean' => array('name' => 'boolean'), - 'number' => array('name' => 'numeric'), - 'inet' => array('name' => 'inet') - ); - -/** - * Starting Quote - * - * @var string - */ - public $startQuote = '"'; - -/** - * Ending Quote - * - * @var string - */ - public $endQuote = '"'; - -/** - * Contains mappings of custom auto-increment sequences, if a table uses a sequence name - * other than what is dictated by convention. - * - * @var array - */ - protected $_sequenceMap = array(); - -/** - * Connects to the database using options in the given configuration array. - * - * @return boolean True if successfully connected. - * @throws MissingConnectionException - */ - public function connect() { - $config = $this->config; - $this->connected = false; - try { - $flags = array( - PDO::ATTR_PERSISTENT => $config['persistent'], - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION - ); - $this->_connection = new PDO( - "pgsql:host={$config['host']};port={$config['port']};dbname={$config['database']}", - $config['login'], - $config['password'], - $flags - ); - - $this->connected = true; - if (!empty($config['encoding'])) { - $this->setEncoding($config['encoding']); - } - if (!empty($config['schema'])) { - $this->_execute('SET search_path TO ' . $config['schema']); - } - } catch (PDOException $e) { - throw new MissingConnectionException(array('class' => $e->getMessage())); - } - - return $this->connected; - } - -/** - * Check if PostgreSQL is enabled/loaded - * - * @return boolean - */ - public function enabled() { - return in_array('pgsql', PDO::getAvailableDrivers()); - } - -/** - * Returns an array of tables in the database. If there are no tables, an error is raised and the application exits. - * - * @param mixed $data - * @return array Array of table names in the database - */ - public function listSources($data = null) { - $cache = parent::listSources(); - - if ($cache != null) { - return $cache; - } - - $schema = $this->config['schema']; - $sql = "SELECT table_name as name FROM INFORMATION_SCHEMA.tables WHERE table_schema = ?"; - $result = $this->_execute($sql, array($schema)); - - if (!$result) { - return array(); - } else { - $tables = array(); - - foreach ($result as $item) { - $tables[] = $item->name; - } - - $result->closeCursor(); - parent::listSources($tables); - return $tables; - } - } - -/** - * Returns an array of the fields in given table name. - * - * @param Model|string $model Name of database table to inspect - * @return array Fields in table. Keys are name and type - */ - public function describe($model) { - $table = $this->fullTableName($model, false, false); - $fields = parent::describe($table); - $this->_sequenceMap[$table] = array(); - $cols = null; - - if ($fields === null) { - $cols = $this->_execute( - "SELECT DISTINCT table_schema AS schema, column_name AS name, data_type AS type, is_nullable AS null, - column_default AS default, ordinal_position AS position, character_maximum_length AS char_length, - character_octet_length AS oct_length FROM information_schema.columns - WHERE table_name = ? AND table_schema = ? ORDER BY position", - array($table, $this->config['schema']) - ); - - // @codingStandardsIgnoreStart - // Postgres columns don't match the coding standards. - foreach ($cols as $c) { - $type = $c->type; - if (!empty($c->oct_length) && $c->char_length === null) { - if ($c->type == 'character varying') { - $length = null; - $type = 'text'; - } elseif ($c->type == 'uuid') { - $length = 36; - } else { - $length = intval($c->oct_length); - } - } elseif (!empty($c->char_length)) { - $length = intval($c->char_length); - } else { - $length = $this->length($c->type); - } - if (empty($length)) { - $length = null; - } - $fields[$c->name] = array( - 'type' => $this->column($type), - 'null' => ($c->null == 'NO' ? false : true), - 'default' => preg_replace( - "/^'(.*)'$/", - "$1", - preg_replace('/::.*/', '', $c->default) - ), - 'length' => $length - ); - if ($model instanceof Model) { - if ($c->name == $model->primaryKey) { - $fields[$c->name]['key'] = 'primary'; - if ($fields[$c->name]['type'] !== 'string') { - $fields[$c->name]['length'] = 11; - } - } - } - if ( - $fields[$c->name]['default'] == 'NULL' || - preg_match('/nextval\([\'"]?([\w.]+)/', $c->default, $seq) - ) { - $fields[$c->name]['default'] = null; - if (!empty($seq) && isset($seq[1])) { - if (strpos($seq[1], '.') === false) { - $sequenceName = $c->schema . '.' . $seq[1]; - } else { - $sequenceName = $seq[1]; - } - $this->_sequenceMap[$table][$c->name] = $sequenceName; - } - } - if ($fields[$c->name]['type'] == 'boolean' && !empty($fields[$c->name]['default'])) { - $fields[$c->name]['default'] = constant($fields[$c->name]['default']); - } - } - $this->_cacheDescription($table, $fields); - } - // @codingStandardsIgnoreEnd - - if (isset($model->sequence)) { - $this->_sequenceMap[$table][$model->primaryKey] = $model->sequence; - } - - if ($cols) { - $cols->closeCursor(); - } - return $fields; - } - -/** - * Returns the ID generated from the previous INSERT operation. - * - * @param string $source Name of the database table - * @param string $field Name of the ID database field. Defaults to "id" - * @return integer - */ - public function lastInsertId($source = null, $field = 'id') { - $seq = $this->getSequence($source, $field); - return $this->_connection->lastInsertId($seq); - } - -/** - * Gets the associated sequence for the given table/field - * - * @param mixed $table Either a full table name (with prefix) as a string, or a model object - * @param string $field Name of the ID database field. Defaults to "id" - * @return string The associated sequence name from the sequence map, defaults to "{$table}_{$field}_seq" - */ - public function getSequence($table, $field = 'id') { - if (is_object($table)) { - $table = $this->fullTableName($table, false, false); - } - if (isset($this->_sequenceMap[$table]) && isset($this->_sequenceMap[$table][$field])) { - return $this->_sequenceMap[$table][$field]; - } else { - return "{$table}_{$field}_seq"; - } - } - -/** - * Deletes all the records in a table and drops all associated auto-increment sequences - * - * @param mixed $table A string or model class representing the table to be truncated - * @param boolean $reset true for resetting the sequence, false to leave it as is. - * and if 1, sequences are not modified - * @return boolean SQL TRUNCATE TABLE statement, false if not applicable. - */ - public function truncate($table, $reset = false) { - $table = $this->fullTableName($table, false, false); - if (!isset($this->_sequenceMap[$table])) { - $cache = $this->cacheSources; - $this->cacheSources = false; - $this->describe($table); - $this->cacheSources = $cache; - } - if ($this->execute('DELETE FROM ' . $this->fullTableName($table))) { - $schema = $this->config['schema']; - if (isset($this->_sequenceMap[$table]) && $reset != true) { - foreach ($this->_sequenceMap[$table] as $field => $sequence) { - list($schema, $sequence) = explode('.', $sequence); - $this->_execute("ALTER SEQUENCE \"{$schema}\".\"{$sequence}\" RESTART WITH 1"); - } - } - return true; - } - return false; - } - -/** - * Prepares field names to be quoted by parent - * - * @param string $data - * @return string SQL field - */ - public function name($data) { - if (is_string($data)) { - $data = str_replace('"__"', '__', $data); - } - return parent::name($data); - } - -/** - * Generates the fields list of an SQL query. - * - * @param Model $model - * @param string $alias Alias table name - * @param mixed $fields - * @param boolean $quote - * @return array - */ - public function fields(Model $model, $alias = null, $fields = array(), $quote = true) { - if (empty($alias)) { - $alias = $model->alias; - } - $fields = parent::fields($model, $alias, $fields, false); - - if (!$quote) { - return $fields; - } - $count = count($fields); - - if ($count >= 1 && !preg_match('/^\s*COUNT\(\*/', $fields[0])) { - $result = array(); - for ($i = 0; $i < $count; $i++) { - if (!preg_match('/^.+\\(.*\\)/', $fields[$i]) && !preg_match('/\s+AS\s+/', $fields[$i])) { - if (substr($fields[$i], -1) == '*') { - if (strpos($fields[$i], '.') !== false && $fields[$i] != $alias . '.*') { - $build = explode('.', $fields[$i]); - $AssociatedModel = $model->{$build[0]}; - } else { - $AssociatedModel = $model; - } - - $_fields = $this->fields($AssociatedModel, $AssociatedModel->alias, array_keys($AssociatedModel->schema())); - $result = array_merge($result, $_fields); - continue; - } - - $prepend = ''; - if (strpos($fields[$i], 'DISTINCT') !== false) { - $prepend = 'DISTINCT '; - $fields[$i] = trim(str_replace('DISTINCT', '', $fields[$i])); - } - - if (strrpos($fields[$i], '.') === false) { - $fields[$i] = $prepend . $this->name($alias) . '.' . $this->name($fields[$i]) . ' AS ' . $this->name($alias . '__' . $fields[$i]); - } else { - $build = explode('.', $fields[$i]); - $fields[$i] = $prepend . $this->name($build[0]) . '.' . $this->name($build[1]) . ' AS ' . $this->name($build[0] . '__' . $build[1]); - } - } else { - $fields[$i] = preg_replace_callback('/\(([\s\.\w]+)\)/', array(&$this, '_quoteFunctionField'), $fields[$i]); - } - $result[] = $fields[$i]; - } - return $result; - } - return $fields; - } - -/** - * Auxiliary function to quote matched `(Model.fields)` from a preg_replace_callback call - * Quotes the fields in a function call. - * - * @param string $match matched string - * @return string quoted string - */ - protected function _quoteFunctionField($match) { - $prepend = ''; - if (strpos($match[1], 'DISTINCT') !== false) { - $prepend = 'DISTINCT '; - $match[1] = trim(str_replace('DISTINCT', '', $match[1])); - } - $constant = preg_match('/^\d+|NULL|FALSE|TRUE$/i', $match[1]); - - if (!$constant && strpos($match[1], '.') === false) { - $match[1] = $this->name($match[1]); - } elseif (!$constant) { - $parts = explode('.', $match[1]); - if (!Set::numeric($parts)) { - $match[1] = $this->name($match[1]); - } - } - return '(' . $prepend . $match[1] . ')'; - } - -/** - * Returns an array of the indexes in given datasource name. - * - * @param string $model Name of model to inspect - * @return array Fields in table. Keys are column and unique - */ - public function index($model) { - $index = array(); - $table = $this->fullTableName($model, false, false); - if ($table) { - $indexes = $this->query("SELECT c2.relname, i.indisprimary, i.indisunique, i.indisclustered, i.indisvalid, pg_catalog.pg_get_indexdef(i.indexrelid, 0, true) as statement, c2.reltablespace - FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, pg_catalog.pg_index i - WHERE c.oid = ( - SELECT c.oid - FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname ~ '^(" . $table . ")$' - AND pg_catalog.pg_table_is_visible(c.oid) - AND n.nspname ~ '^(" . $this->config['schema'] . ")$' - ) - AND c.oid = i.indrelid AND i.indexrelid = c2.oid - ORDER BY i.indisprimary DESC, i.indisunique DESC, c2.relname", false); - foreach ($indexes as $i => $info) { - $key = array_pop($info); - if ($key['indisprimary']) { - $key['relname'] = 'PRIMARY'; - } - preg_match('/\(([^\)]+)\)/', $key['statement'], $indexColumns); - $parsedColumn = $indexColumns[1]; - if (strpos($indexColumns[1], ',') !== false) { - $parsedColumn = explode(', ', $indexColumns[1]); - } - $index[$key['relname']]['unique'] = $key['indisunique']; - $index[$key['relname']]['column'] = $parsedColumn; - } - } - return $index; - } - -/** - * Alter the Schema of a table. - * - * @param array $compare Results of CakeSchema::compare() - * @param string $table name of the table - * @return array - */ - public function alterSchema($compare, $table = null) { - if (!is_array($compare)) { - return false; - } - $out = ''; - $colList = array(); - foreach ($compare as $curTable => $types) { - $indexes = $colList = array(); - if (!$table || $table == $curTable) { - $out .= 'ALTER TABLE ' . $this->fullTableName($curTable) . " \n"; - foreach ($types as $type => $column) { - if (isset($column['indexes'])) { - $indexes[$type] = $column['indexes']; - unset($column['indexes']); - } - switch ($type) { - case 'add': - foreach ($column as $field => $col) { - $col['name'] = $field; - $colList[] = 'ADD COLUMN ' . $this->buildColumn($col); - } - break; - case 'drop': - foreach ($column as $field => $col) { - $col['name'] = $field; - $colList[] = 'DROP COLUMN ' . $this->name($field); - } - break; - case 'change': - foreach ($column as $field => $col) { - if (!isset($col['name'])) { - $col['name'] = $field; - } - $fieldName = $this->name($field); - - $default = isset($col['default']) ? $col['default'] : null; - $nullable = isset($col['null']) ? $col['null'] : null; - unset($col['default'], $col['null']); - $colList[] = 'ALTER COLUMN ' . $fieldName . ' TYPE ' . str_replace(array($fieldName, 'NOT NULL'), '', $this->buildColumn($col)); - if (isset($nullable)) { - $nullable = ($nullable) ? 'DROP NOT NULL' : 'SET NOT NULL'; - $colList[] = 'ALTER COLUMN ' . $fieldName . ' ' . $nullable; - } - - if (isset($default)) { - $colList[] = 'ALTER COLUMN ' . $fieldName . ' SET DEFAULT ' . $this->value($default, $col['type']); - } else { - $colList[] = 'ALTER COLUMN ' . $fieldName . ' DROP DEFAULT'; - } - - } - break; - } - } - if (isset($indexes['drop']['PRIMARY'])) { - $colList[] = 'DROP CONSTRAINT ' . $curTable . '_pkey'; - } - if (isset($indexes['add']['PRIMARY'])) { - $cols = $indexes['add']['PRIMARY']['column']; - if (is_array($cols)) { - $cols = implode(', ', $cols); - } - $colList[] = 'ADD PRIMARY KEY (' . $cols . ')'; - } - - if (!empty($colList)) { - $out .= "\t" . implode(",\n\t", $colList) . ";\n\n"; - } else { - $out = ''; - } - $out .= implode(";\n\t", $this->_alterIndexes($curTable, $indexes)); - } - } - return $out; - } - -/** - * Generate PostgreSQL index alteration statements for a table. - * - * @param string $table Table to alter indexes for - * @param array $indexes Indexes to add and drop - * @return array Index alteration statements - */ - protected function _alterIndexes($table, $indexes) { - $alter = array(); - if (isset($indexes['drop'])) { - foreach ($indexes['drop'] as $name => $value) { - $out = 'DROP '; - if ($name == 'PRIMARY') { - continue; - } else { - $out .= 'INDEX ' . $name; - } - $alter[] = $out; - } - } - if (isset($indexes['add'])) { - foreach ($indexes['add'] as $name => $value) { - $out = 'CREATE '; - if ($name == 'PRIMARY') { - continue; - } else { - if (!empty($value['unique'])) { - $out .= 'UNIQUE '; - } - $out .= 'INDEX '; - } - if (is_array($value['column'])) { - $out .= $name . ' ON ' . $table . ' (' . implode(', ', array_map(array(&$this, 'name'), $value['column'])) . ')'; - } else { - $out .= $name . ' ON ' . $table . ' (' . $this->name($value['column']) . ')'; - } - $alter[] = $out; - } - } - return $alter; - } - -/** - * Returns a limit statement in the correct format for the particular database. - * - * @param integer $limit Limit of results returned - * @param integer $offset Offset from which to start results - * @return string SQL limit/offset statement - */ - public function limit($limit, $offset = null) { - if ($limit) { - $rt = ''; - if (!strpos(strtolower($limit), 'limit') || strpos(strtolower($limit), 'limit') === 0) { - $rt = ' LIMIT'; - } - - $rt .= ' ' . $limit; - if ($offset) { - $rt .= ' OFFSET ' . $offset; - } - - return $rt; - } - return null; - } - -/** - * Converts database-layer column types to basic types - * - * @param string $real Real database-layer column type (i.e. "varchar(255)") - * @return string Abstract column type (i.e. "string") - */ - public function column($real) { - if (is_array($real)) { - $col = $real['name']; - if (isset($real['limit'])) { - $col .= '(' . $real['limit'] . ')'; - } - return $col; - } - - $col = str_replace(')', '', $real); - $limit = null; - - if (strpos($col, '(') !== false) { - list($col, $limit) = explode('(', $col); - } - - $floats = array( - 'float', 'float4', 'float8', 'double', 'double precision', 'decimal', 'real', 'numeric' - ); - - switch (true) { - case (in_array($col, array('date', 'time', 'inet', 'boolean'))): - return $col; - case (strpos($col, 'timestamp') !== false): - return 'datetime'; - case (strpos($col, 'time') === 0): - return 'time'; - case (strpos($col, 'int') !== false && $col != 'interval'): - return 'integer'; - case (strpos($col, 'char') !== false || $col == 'uuid'): - return 'string'; - case (strpos($col, 'text') !== false): - return 'text'; - case (strpos($col, 'bytea') !== false): - return 'binary'; - case (in_array($col, $floats)): - return 'float'; - default: - return 'text'; - break; - } - } - -/** - * Gets the length of a database-native column description, or null if no length - * - * @param string $real Real database-layer column type (i.e. "varchar(255)") - * @return integer An integer representing the length of the column - */ - public function length($real) { - $col = str_replace(array(')', 'unsigned'), '', $real); - $limit = null; - - if (strpos($col, '(') !== false) { - list($col, $limit) = explode('(', $col); - } - if ($col == 'uuid') { - return 36; - } - if ($limit != null) { - return intval($limit); - } - return null; - } - -/** - * resultSet method - * - * @param array $results - * @return void - */ - public function resultSet(&$results) { - $this->map = array(); - $numFields = $results->columnCount(); - $index = 0; - $j = 0; - - while ($j < $numFields) { - $column = $results->getColumnMeta($j); - if (strpos($column['name'], '__')) { - list($table, $name) = explode('__', $column['name']); - $this->map[$index++] = array($table, $name, $column['native_type']); - } else { - $this->map[$index++] = array(0, $column['name'], $column['native_type']); - } - $j++; - } - } - -/** - * Fetches the next row from the current result set - * - * @return array - */ - public function fetchResult() { - if ($row = $this->_result->fetch(PDO::FETCH_NUM)) { - $resultRow = array(); - - foreach ($this->map as $index => $meta) { - list($table, $column, $type) = $meta; - - switch ($type) { - case 'bool': - $resultRow[$table][$column] = is_null($row[$index]) ? null : $this->boolean($row[$index]); - break; - case 'binary': - case 'bytea': - $resultRow[$table][$column] = is_null($row[$index]) ? null : stream_get_contents($row[$index]); - break; - default: - $resultRow[$table][$column] = $row[$index]; - break; - } - } - return $resultRow; - } else { - $this->_result->closeCursor(); - return false; - } - } - -/** - * Translates between PHP boolean values and PostgreSQL boolean values - * - * @param mixed $data Value to be translated - * @param boolean $quote true to quote a boolean to be used in a query, false to return the boolean value - * @return boolean Converted boolean value - */ - public function boolean($data, $quote = false) { - switch (true) { - case ($data === true || $data === false): - $result = $data; - break; - case ($data === 't' || $data === 'f'): - $result = ($data === 't'); - break; - case ($data === 'true' || $data === 'false'): - $result = ($data === 'true'); - break; - case ($data === 'TRUE' || $data === 'FALSE'): - $result = ($data === 'TRUE'); - break; - default: - $result = (bool)$data; - break; - } - - if ($quote) { - return ($result) ? 'TRUE' : 'FALSE'; - } - return (bool)$result; - } - -/** - * Sets the database encoding - * - * @param mixed $enc Database encoding - * @return boolean True on success, false on failure - */ - public function setEncoding($enc) { - return $this->_execute('SET NAMES ' . $this->value($enc)) !== false; - } - -/** - * Gets the database encoding - * - * @return string The database encoding - */ - public function getEncoding() { - $result = $this->_execute('SHOW client_encoding')->fetch(); - if ($result === false) { - return false; - } - return (isset($result['client_encoding'])) ? $result['client_encoding'] : false; - } - -/** - * Generate a Postgres-native column schema string - * - * @param array $column An array structured like the following: - * array('name'=>'value', 'type'=>'value'[, options]), - * where options can be 'default', 'length', or 'key'. - * @return string - */ - public function buildColumn($column) { - $col = $this->columns[$column['type']]; - if (!isset($col['length']) && !isset($col['limit'])) { - unset($column['length']); - } - $out = preg_replace('/integer\([0-9]+\)/', 'integer', parent::buildColumn($column)); - $out = str_replace('integer serial', 'serial', $out); - if (strpos($out, 'timestamp DEFAULT')) { - if (isset($column['null']) && $column['null']) { - $out = str_replace('DEFAULT NULL', '', $out); - } else { - $out = str_replace('DEFAULT NOT NULL', '', $out); - } - } - if (strpos($out, 'DEFAULT DEFAULT')) { - if (isset($column['null']) && $column['null']) { - $out = str_replace('DEFAULT DEFAULT', 'DEFAULT NULL', $out); - } elseif (in_array($column['type'], array('integer', 'float'))) { - $out = str_replace('DEFAULT DEFAULT', 'DEFAULT 0', $out); - } elseif ($column['type'] == 'boolean') { - $out = str_replace('DEFAULT DEFAULT', 'DEFAULT FALSE', $out); - } - } - return $out; - } - -/** - * Format indexes for create table - * - * @param array $indexes - * @param string $table - * @return string - */ - public function buildIndex($indexes, $table = null) { - $join = array(); - if (!is_array($indexes)) { - return array(); - } - foreach ($indexes as $name => $value) { - if ($name == 'PRIMARY') { - $out = 'PRIMARY KEY (' . $this->name($value['column']) . ')'; - } else { - $out = 'CREATE '; - if (!empty($value['unique'])) { - $out .= 'UNIQUE '; - } - if (is_array($value['column'])) { - $value['column'] = implode(', ', array_map(array(&$this, 'name'), $value['column'])); - } else { - $value['column'] = $this->name($value['column']); - } - $out .= "INDEX {$name} ON {$table}({$value['column']});"; - } - $join[] = $out; - } - return $join; - } - -/** - * Overrides DboSource::renderStatement to handle schema generation with Postgres-style indexes - * - * @param string $type - * @param array $data - * @return string - */ - public function renderStatement($type, $data) { - switch (strtolower($type)) { - case 'schema': - extract($data); - - foreach ($indexes as $i => $index) { - if (preg_match('/PRIMARY KEY/', $index)) { - unset($indexes[$i]); - $columns[] = $index; - break; - } - } - $join = array('columns' => ",\n\t", 'indexes' => "\n"); - - foreach (array('columns', 'indexes') as $var) { - if (is_array(${$var})) { - ${$var} = implode($join[$var], array_filter(${$var})); - } - } - return "CREATE TABLE {$table} (\n\t{$columns}\n);\n{$indexes}"; - break; - default: - return parent::renderStatement($type, $data); - break; - } - } - -/** - * Gets the schema name - * - * @return string The schema name - */ - public function getSchemaName() { - return $this->config['schema']; - } - -} diff --git a/lib/Cake/Model/Datasource/Database/Sqlite.php b/lib/Cake/Model/Datasource/Database/Sqlite.php deleted file mode 100644 index 59db71dcf48..00000000000 --- a/lib/Cake/Model/Datasource/Database/Sqlite.php +++ /dev/null @@ -1,562 +0,0 @@ - false, - 'database' => null - ); - -/** - * SQLite3 column definition - * - * @var array - */ - public $columns = array( - 'primary_key' => array('name' => 'integer primary key autoincrement'), - 'string' => array('name' => 'varchar', 'limit' => '255'), - 'text' => array('name' => 'text'), - 'integer' => array('name' => 'integer', 'limit' => null, 'formatter' => 'intval'), - 'float' => array('name' => 'float', 'formatter' => 'floatval'), - 'datetime' => array('name' => 'datetime', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'timestamp' => array('name' => 'timestamp', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'time' => array('name' => 'time', 'format' => 'H:i:s', 'formatter' => 'date'), - 'date' => array('name' => 'date', 'format' => 'Y-m-d', 'formatter' => 'date'), - 'binary' => array('name' => 'blob'), - 'boolean' => array('name' => 'boolean') - ); - -/** - * List of engine specific additional field parameters used on table creating - * - * @var array - */ - public $fieldParameters = array( - 'collate' => array( - 'value' => 'COLLATE', - 'quote' => false, - 'join' => ' ', - 'column' => 'Collate', - 'position' => 'afterDefault', - 'options' => array( - 'BINARY', 'NOCASE', 'RTRIM' - ) - ), - ); - -/** - * Connects to the database using config['database'] as a filename. - * - * @return boolean - * @throws MissingConnectionException - */ - public function connect() { - $config = $this->config; - $flags = array( - PDO::ATTR_PERSISTENT => $config['persistent'], - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION - ); - try { - $this->_connection = new PDO('sqlite:' . $config['database'], null, null, $flags); - $this->connected = true; - } catch(PDOException $e) { - throw new MissingConnectionException(array('class' => $e->getMessage())); - } - return $this->connected; - } - -/** - * Check whether the SQLite extension is installed/loaded - * - * @return boolean - */ - public function enabled() { - return in_array('sqlite', PDO::getAvailableDrivers()); - } - -/** - * Returns an array of tables in the database. If there are no tables, an error is raised and the application exits. - * - * @param mixed $data - * @return array Array of table names in the database - */ - public function listSources($data = null) { - $cache = parent::listSources(); - if ($cache != null) { - return $cache; - } - - $result = $this->fetchAll("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;", false); - - if (!$result || empty($result)) { - return array(); - } else { - $tables = array(); - foreach ($result as $table) { - $tables[] = $table[0]['name']; - } - parent::listSources($tables); - return $tables; - } - } - -/** - * Returns an array of the fields in given table name. - * - * @param Model|string $model Either the model or table name you want described. - * @return array Fields in table. Keys are name and type - */ - public function describe($model) { - $table = $this->fullTableName($model, false, false); - $cache = parent::describe($table); - if ($cache != null) { - return $cache; - } - $fields = array(); - $result = $this->_execute( - 'PRAGMA table_info(' . $this->value($table, 'string') . ')' - ); - - foreach ($result as $column) { - $column = (array)$column; - $default = ($column['dflt_value'] === 'NULL') ? null : trim($column['dflt_value'], "'"); - - $fields[$column['name']] = array( - 'type' => $this->column($column['type']), - 'null' => !$column['notnull'], - 'default' => $default, - 'length' => $this->length($column['type']) - ); - if ($column['pk'] == 1) { - $fields[$column['name']]['key'] = $this->index['PRI']; - $fields[$column['name']]['null'] = false; - if (empty($fields[$column['name']]['length'])) { - $fields[$column['name']]['length'] = 11; - } - } - } - - $result->closeCursor(); - $this->_cacheDescription($table, $fields); - return $fields; - } - -/** - * Generates and executes an SQL UPDATE statement for given model, fields, and values. - * - * @param Model $model - * @param array $fields - * @param array $values - * @param mixed $conditions - * @return array - */ - public function update(Model $model, $fields = array(), $values = null, $conditions = null) { - if (empty($values) && !empty($fields)) { - foreach ($fields as $field => $value) { - if (strpos($field, $model->alias . '.') !== false) { - unset($fields[$field]); - $field = str_replace($model->alias . '.', "", $field); - $field = str_replace($model->alias . '.', "", $field); - $fields[$field] = $value; - } - } - } - return parent::update($model, $fields, $values, $conditions); - } - -/** - * Deletes all the records in a table and resets the count of the auto-incrementing - * primary key, where applicable. - * - * @param mixed $table A string or model class representing the table to be truncated - * @return boolean SQL TRUNCATE TABLE statement, false if not applicable. - */ - public function truncate($table) { - $this->_execute('DELETE FROM sqlite_sequence where name=' . $this->startQuote . $this->fullTableName($table, false, false) . $this->endQuote); - return $this->execute('DELETE FROM ' . $this->fullTableName($table)); - } - -/** - * Converts database-layer column types to basic types - * - * @param string $real Real database-layer column type (i.e. "varchar(255)") - * @return string Abstract column type (i.e. "string") - */ - public function column($real) { - if (is_array($real)) { - $col = $real['name']; - if (isset($real['limit'])) { - $col .= '(' . $real['limit'] . ')'; - } - return $col; - } - - $col = strtolower(str_replace(')', '', $real)); - $limit = null; - @list($col, $limit) = explode('(', $col); - - if (in_array($col, array('text', 'integer', 'float', 'boolean', 'timestamp', 'date', 'datetime', 'time'))) { - return $col; - } - if (strpos($col, 'char') !== false) { - return 'string'; - } - if (in_array($col, array('blob', 'clob'))) { - return 'binary'; - } - if (strpos($col, 'numeric') !== false || strpos($col, 'decimal') !== false) { - return 'float'; - } - return 'text'; - } - -/** - * Generate ResultSet - * - * @param mixed $results - * @return void - */ - public function resultSet($results) { - $this->results = $results; - $this->map = array(); - $numFields = $results->columnCount(); - $index = 0; - $j = 0; - - //PDO::getColumnMeta is experimental and does not work with sqlite3, - // so try to figure it out based on the querystring - $querystring = $results->queryString; - if (stripos($querystring, 'SELECT') === 0) { - $last = strripos($querystring, 'FROM'); - if ($last !== false) { - $selectpart = substr($querystring, 7, $last - 8); - $selects = String::tokenize($selectpart, ',', '(', ')'); - } - } elseif (strpos($querystring, 'PRAGMA table_info') === 0) { - $selects = array('cid', 'name', 'type', 'notnull', 'dflt_value', 'pk'); - } elseif (strpos($querystring, 'PRAGMA index_list') === 0) { - $selects = array('seq', 'name', 'unique'); - } elseif (strpos($querystring, 'PRAGMA index_info') === 0) { - $selects = array('seqno', 'cid', 'name'); - } - while ($j < $numFields) { - if (!isset($selects[$j])) { - $j++; - continue; - } - if (preg_match('/\bAS\s+(.*)/i', $selects[$j], $matches)) { - $columnName = trim($matches[1], '"'); - } else { - $columnName = trim(str_replace('"', '', $selects[$j])); - } - - if (strpos($selects[$j], 'DISTINCT') === 0) { - $columnName = str_ireplace('DISTINCT', '', $columnName); - } - - $metaType = false; - try { - $metaData = (array)$results->getColumnMeta($j); - if (!empty($metaData['sqlite:decl_type'])) { - $metaType = trim($metaData['sqlite:decl_type']); - } - } catch (Exception $e) { - } - - if (strpos($columnName, '.')) { - $parts = explode('.', $columnName); - $this->map[$index++] = array(trim($parts[0]), trim($parts[1]), $metaType); - } else { - $this->map[$index++] = array(0, $columnName, $metaType); - } - $j++; - } - } - -/** - * Fetches the next row from the current result set - * - * @return mixed array with results fetched and mapped to column names or false if there is no results left to fetch - */ - public function fetchResult() { - if ($row = $this->_result->fetch(PDO::FETCH_NUM)) { - $resultRow = array(); - foreach ($this->map as $col => $meta) { - list($table, $column, $type) = $meta; - $resultRow[$table][$column] = $row[$col]; - if ($type == 'boolean' && !is_null($row[$col])) { - $resultRow[$table][$column] = $this->boolean($resultRow[$table][$column]); - } - } - return $resultRow; - } else { - $this->_result->closeCursor(); - return false; - } - } - -/** - * Returns a limit statement in the correct format for the particular database. - * - * @param integer $limit Limit of results returned - * @param integer $offset Offset from which to start results - * @return string SQL limit/offset statement - */ - public function limit($limit, $offset = null) { - if ($limit) { - $rt = ''; - if (!strpos(strtolower($limit), 'limit') || strpos(strtolower($limit), 'limit') === 0) { - $rt = ' LIMIT'; - } - $rt .= ' ' . $limit; - if ($offset) { - $rt .= ' OFFSET ' . $offset; - } - return $rt; - } - return null; - } - -/** - * Generate a database-native column schema string - * - * @param array $column An array structured like the following: array('name'=>'value', 'type'=>'value'[, options]), - * where options can be 'default', 'length', or 'key'. - * @return string - */ - public function buildColumn($column) { - $name = $type = null; - $column = array_merge(array('null' => true), $column); - extract($column); - - if (empty($name) || empty($type)) { - trigger_error(__d('cake_dev', 'Column name or type not defined in schema'), E_USER_WARNING); - return null; - } - - if (!isset($this->columns[$type])) { - trigger_error(__d('cake_dev', 'Column type %s does not exist', $type), E_USER_WARNING); - return null; - } - - if (isset($column['key']) && $column['key'] == 'primary' && $type == 'integer') { - return $this->name($name) . ' ' . $this->columns['primary_key']['name']; - } - return parent::buildColumn($column); - } - -/** - * Sets the database encoding - * - * @param string $enc Database encoding - * @return boolean - */ - public function setEncoding($enc) { - if (!in_array($enc, array("UTF-8", "UTF-16", "UTF-16le", "UTF-16be"))) { - return false; - } - return $this->_execute("PRAGMA encoding = \"{$enc}\"") !== false; - } - -/** - * Gets the database encoding - * - * @return string The database encoding - */ - public function getEncoding() { - return $this->fetchRow('PRAGMA encoding'); - } - -/** - * Removes redundant primary key indexes, as they are handled in the column def of the key. - * - * @param array $indexes - * @param string $table - * @return string - */ - public function buildIndex($indexes, $table = null) { - $join = array(); - - $table = str_replace('"', '', $table); - list($dbname, $table) = explode('.', $table); - $dbname = $this->name($dbname); - - foreach ($indexes as $name => $value) { - - if ($name == 'PRIMARY') { - continue; - } - $out = 'CREATE '; - - if (!empty($value['unique'])) { - $out .= 'UNIQUE '; - } - if (is_array($value['column'])) { - $value['column'] = join(', ', array_map(array(&$this, 'name'), $value['column'])); - } else { - $value['column'] = $this->name($value['column']); - } - $t = trim($table, '"'); - $indexname = $this->name($t . '_' . $name); - $table = $this->name($table); - $out .= "INDEX {$dbname}.{$indexname} ON {$table}({$value['column']});"; - $join[] = $out; - } - return $join; - } - -/** - * Overrides DboSource::index to handle SQLite index introspection - * Returns an array of the indexes in given table name. - * - * @param string $model Name of model to inspect - * @return array Fields in table. Keys are column and unique - */ - public function index($model) { - $index = array(); - $table = $this->fullTableName($model, false, false); - if ($table) { - $indexes = $this->query('PRAGMA index_list(' . $table . ')'); - - if (is_bool($indexes)) { - return array(); - } - foreach ($indexes as $i => $info) { - $key = array_pop($info); - $keyInfo = $this->query('PRAGMA index_info("' . $key['name'] . '")'); - foreach ($keyInfo as $keyCol) { - if (!isset($index[$key['name']])) { - $col = array(); - if (preg_match('/autoindex/', $key['name'])) { - $key['name'] = 'PRIMARY'; - } - $index[$key['name']]['column'] = $keyCol[0]['name']; - $index[$key['name']]['unique'] = intval($key['unique'] == 1); - } else { - if (!is_array($index[$key['name']]['column'])) { - $col[] = $index[$key['name']]['column']; - } - $col[] = $keyCol[0]['name']; - $index[$key['name']]['column'] = $col; - } - } - } - } - return $index; - } - -/** - * Overrides DboSource::renderStatement to handle schema generation with SQLite-style indexes - * - * @param string $type - * @param array $data - * @return string - */ - public function renderStatement($type, $data) { - switch (strtolower($type)) { - case 'schema': - extract($data); - if (is_array($columns)) { - $columns = "\t" . join(",\n\t", array_filter($columns)); - } - if (is_array($indexes)) { - $indexes = "\t" . join("\n\t", array_filter($indexes)); - } - return "CREATE TABLE {$table} (\n{$columns});\n{$indexes}"; - break; - default: - return parent::renderStatement($type, $data); - break; - } - } - -/** - * PDO deals in objects, not resources, so overload accordingly. - * - * @return boolean - */ - public function hasResult() { - return is_object($this->_result); - } - -/** - * Generate a "drop table" statement for the given Schema object - * - * @param CakeSchema $schema An instance of a subclass of CakeSchema - * @param string $table Optional. If specified only the table name given will be generated. - * Otherwise, all tables defined in the schema are generated. - * @return string - */ - public function dropSchema(CakeSchema $schema, $table = null) { - $out = ''; - foreach ($schema->tables as $curTable => $columns) { - if (!$table || $table == $curTable) { - $out .= 'DROP TABLE IF EXISTS ' . $this->fullTableName($curTable) . ";\n"; - } - } - return $out; - } - -/** - * Gets the schema name - * - * @return string The schema name - */ - public function getSchemaName() { - return "main"; // Sqlite Datasource does not support multidb - } - -} diff --git a/lib/Cake/Model/Datasource/Database/Sqlserver.php b/lib/Cake/Model/Datasource/Database/Sqlserver.php deleted file mode 100644 index a72373fb38d..00000000000 --- a/lib/Cake/Model/Datasource/Database/Sqlserver.php +++ /dev/null @@ -1,800 +0,0 @@ - true, - 'host' => 'localhost\SQLEXPRESS', - 'login' => '', - 'password' => '', - 'database' => 'cake', - 'schema' => '', - ); - -/** - * MS SQL column definition - * - * @var array - */ - public $columns = array( - 'primary_key' => array('name' => 'IDENTITY (1, 1) NOT NULL'), - 'string' => array('name' => 'nvarchar', 'limit' => '255'), - 'text' => array('name' => 'nvarchar', 'limit' => 'MAX'), - 'integer' => array('name' => 'int', 'formatter' => 'intval'), - 'float' => array('name' => 'numeric', 'formatter' => 'floatval'), - 'datetime' => array('name' => 'datetime', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'timestamp' => array('name' => 'timestamp', 'format' => 'Y-m-d H:i:s', 'formatter' => 'date'), - 'time' => array('name' => 'datetime', 'format' => 'H:i:s', 'formatter' => 'date'), - 'date' => array('name' => 'datetime', 'format' => 'Y-m-d', 'formatter' => 'date'), - 'binary' => array('name' => 'varbinary'), - 'boolean' => array('name' => 'bit') - ); - -/** - * Index of basic SQL commands - * - * @var array - */ - protected $_commands = array( - 'begin' => 'BEGIN TRANSACTION', - 'commit' => 'COMMIT', - 'rollback' => 'ROLLBACK' - ); - -/** - * Magic column name used to provide pagination support for SQLServer 2008 - * which lacks proper limit/offset support. - */ - const ROW_COUNTER = '_cake_page_rownum_'; - -/** - * The version of SQLServer being used. If greater than 11 - * Normal limit offset statements will be used - * - * @var string - */ - protected $_version; - -/** - * Connects to the database using options in the given configuration array. - * - * @return boolean True if the database could be connected, else false - * @throws MissingConnectionException - */ - public function connect() { - $config = $this->config; - $this->connected = false; - try { - $flags = array( - PDO::ATTR_PERSISTENT => $config['persistent'], - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION - ); - if (!empty($config['encoding'])) { - $flags[PDO::SQLSRV_ATTR_ENCODING] = $config['encoding']; - } - $this->_connection = new PDO( - "sqlsrv:server={$config['host']};Database={$config['database']}", - $config['login'], - $config['password'], - $flags - ); - $this->connected = true; - } catch (PDOException $e) { - throw new MissingConnectionException(array('class' => $e->getMessage())); - } - - $this->_version = $this->_connection->getAttribute(PDO::ATTR_SERVER_VERSION); - return $this->connected; - } - -/** - * Check that PDO SQL Server is installed/loaded - * - * @return boolean - */ - public function enabled() { - return in_array('sqlsrv', PDO::getAvailableDrivers()); - } - -/** - * Returns an array of sources (tables) in the database. - * - * @param mixed $data - * @return array Array of table names in the database - */ - public function listSources($data = null) { - $cache = parent::listSources(); - if ($cache !== null) { - return $cache; - } - $result = $this->_execute("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES"); - - if (!$result) { - $result->closeCursor(); - return array(); - } else { - $tables = array(); - - while ($line = $result->fetch(PDO::FETCH_NUM)) { - $tables[] = $line[0]; - } - - $result->closeCursor(); - parent::listSources($tables); - return $tables; - } - } - -/** - * Returns an array of the fields in given table name. - * - * @param Model|string $model Model object to describe, or a string table name. - * @return array Fields in table. Keys are name and type - * @throws CakeException - */ - public function describe($model) { - $table = $this->fullTableName($model, false); - $cache = parent::describe($table); - if ($cache != null) { - return $cache; - } - $fields = array(); - $table = $this->fullTableName($model, false); - $cols = $this->_execute( - "SELECT - COLUMN_NAME as Field, - DATA_TYPE as Type, - COL_LENGTH('" . $table . "', COLUMN_NAME) as Length, - IS_NULLABLE As [Null], - COLUMN_DEFAULT as [Default], - COLUMNPROPERTY(OBJECT_ID('" . $table . "'), COLUMN_NAME, 'IsIdentity') as [Key], - NUMERIC_SCALE as Size - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = '" . $table . "'" - ); - if (!$cols) { - throw new CakeException(__d('cake_dev', 'Could not describe table for %s', $table)); - } - - while ($column = $cols->fetch(PDO::FETCH_OBJ)) { - $field = $column->Field; - $fields[$field] = array( - 'type' => $this->column($column), - 'null' => ($column->Null === 'YES' ? true : false), - 'default' => preg_replace("/^[(]{1,2}'?([^')]*)?'?[)]{1,2}$/", "$1", $column->Default), - 'length' => $this->length($column), - 'key' => ($column->Key == '1') ? 'primary' : false - ); - - if ($fields[$field]['default'] === 'null') { - $fields[$field]['default'] = null; - } else { - $this->value($fields[$field]['default'], $fields[$field]['type']); - } - - if ($fields[$field]['key'] !== false && $fields[$field]['type'] == 'integer') { - $fields[$field]['length'] = 11; - } elseif ($fields[$field]['key'] === false) { - unset($fields[$field]['key']); - } - if (in_array($fields[$field]['type'], array('date', 'time', 'datetime', 'timestamp'))) { - $fields[$field]['length'] = null; - } - if ($fields[$field]['type'] == 'float' && !empty($column->Size)) { - $fields[$field]['length'] = $fields[$field]['length'] . ',' . $column->Size; - } - } - $this->_cacheDescription($table, $fields); - $cols->closeCursor(); - return $fields; - } - -/** - * Generates the fields list of an SQL query. - * - * @param Model $model - * @param string $alias Alias table name - * @param array $fields - * @param boolean $quote - * @return array - */ - public function fields(Model $model, $alias = null, $fields = array(), $quote = true) { - if (empty($alias)) { - $alias = $model->alias; - } - $fields = parent::fields($model, $alias, $fields, false); - $count = count($fields); - - if ($count >= 1 && strpos($fields[0], 'COUNT(*)') === false) { - $result = array(); - for ($i = 0; $i < $count; $i++) { - $prepend = ''; - - if (strpos($fields[$i], 'DISTINCT') !== false) { - $prepend = 'DISTINCT '; - $fields[$i] = trim(str_replace('DISTINCT', '', $fields[$i])); - } - - if (!preg_match('/\s+AS\s+/i', $fields[$i])) { - if (substr($fields[$i], -1) == '*') { - if (strpos($fields[$i], '.') !== false && $fields[$i] != $alias . '.*') { - $build = explode('.', $fields[$i]); - $AssociatedModel = $model->{$build[0]}; - } else { - $AssociatedModel = $model; - } - - $_fields = $this->fields($AssociatedModel, $AssociatedModel->alias, array_keys($AssociatedModel->schema())); - $result = array_merge($result, $_fields); - continue; - } - - if (strpos($fields[$i], '.') === false) { - $this->_fieldMappings[$alias . '__' . $fields[$i]] = $alias . '.' . $fields[$i]; - $fieldName = $this->name($alias . '.' . $fields[$i]); - $fieldAlias = $this->name($alias . '__' . $fields[$i]); - } else { - $build = explode('.', $fields[$i]); - $this->_fieldMappings[$build[0] . '__' . $build[1]] = $fields[$i]; - $fieldName = $this->name($build[0] . '.' . $build[1]); - $fieldAlias = $this->name(preg_replace("/^\[(.+)\]$/", "$1", $build[0]) . '__' . $build[1]); - } - if ($model->getColumnType($fields[$i]) == 'datetime') { - $fieldName = "CONVERT(VARCHAR(20), {$fieldName}, 20)"; - } - $fields[$i] = "{$fieldName} AS {$fieldAlias}"; - } - $result[] = $prepend . $fields[$i]; - } - return $result; - } else { - return $fields; - } - } - -/** - * Generates and executes an SQL INSERT statement for given model, fields, and values. - * Removes Identity (primary key) column from update data before returning to parent, if - * value is empty. - * - * @param Model $model - * @param array $fields - * @param array $values - * @return array - */ - public function create(Model $model, $fields = null, $values = null) { - if (!empty($values)) { - $fields = array_combine($fields, $values); - } - $primaryKey = $this->_getPrimaryKey($model); - - if (array_key_exists($primaryKey, $fields)) { - if (empty($fields[$primaryKey])) { - unset($fields[$primaryKey]); - } else { - $this->_execute('SET IDENTITY_INSERT ' . $this->fullTableName($model) . ' ON'); - } - } - $result = parent::create($model, array_keys($fields), array_values($fields)); - if (array_key_exists($primaryKey, $fields) && !empty($fields[$primaryKey])) { - $this->_execute('SET IDENTITY_INSERT ' . $this->fullTableName($model) . ' OFF'); - } - return $result; - } - -/** - * Generates and executes an SQL UPDATE statement for given model, fields, and values. - * Removes Identity (primary key) column from update data before returning to parent. - * - * @param Model $model - * @param array $fields - * @param array $values - * @param mixed $conditions - * @return array - */ - public function update(Model $model, $fields = array(), $values = null, $conditions = null) { - if (!empty($values)) { - $fields = array_combine($fields, $values); - } - if (isset($fields[$model->primaryKey])) { - unset($fields[$model->primaryKey]); - } - if (empty($fields)) { - return true; - } - return parent::update($model, array_keys($fields), array_values($fields), $conditions); - } - -/** - * Returns a limit statement in the correct format for the particular database. - * - * @param integer $limit Limit of results returned - * @param integer $offset Offset from which to start results - * @return string SQL limit/offset statement - */ - public function limit($limit, $offset = null) { - if ($limit) { - $rt = ''; - if (!strpos(strtolower($limit), 'top') || strpos(strtolower($limit), 'top') === 0) { - $rt = ' TOP'; - } - $rt .= ' ' . $limit; - if (is_int($offset) && $offset > 0) { - $rt = ' OFFSET ' . intval($offset) . ' ROWS FETCH FIRST ' . intval($limit) . ' ROWS ONLY'; - } - return $rt; - } - return null; - } - -/** - * Converts database-layer column types to basic types - * - * @param mixed $real Either the string value of the fields type. - * or the Result object from Sqlserver::describe() - * @return string Abstract column type (i.e. "string") - */ - public function column($real) { - $limit = null; - $col = $real; - if (is_object($real) && isset($real->Field)) { - $limit = $real->Length; - $col = $real->Type; - } - - if ($col == 'datetime2') { - return 'datetime'; - } - if (in_array($col, array('date', 'time', 'datetime', 'timestamp'))) { - return $col; - } - if ($col == 'bit') { - return 'boolean'; - } - if (strpos($col, 'int') !== false) { - return 'integer'; - } - if (strpos($col, 'char') !== false && $limit == -1) { - return 'text'; - } - if (strpos($col, 'char') !== false) { - return 'string'; - } - if (strpos($col, 'text') !== false) { - return 'text'; - } - if (strpos($col, 'binary') !== false || $col == 'image') { - return 'binary'; - } - if (in_array($col, array('float', 'real', 'decimal', 'numeric'))) { - return 'float'; - } - return 'text'; - } - -/** - * Handle SQLServer specific length properties. - * SQLServer handles text types as nvarchar/varchar with a length of -1. - * - * @param mixed $length Either the length as a string, or a Column descriptor object. - * @return mixed null|integer with length of column. - */ - public function length($length) { - if (is_object($length) && isset($length->Length)) { - if ($length->Length == -1 && strpos($length->Type, 'char') !== false) { - return null; - } - if (in_array($length->Type, array('nchar', 'nvarchar'))) { - return floor($length->Length / 2); - } - return $length->Length; - } - return parent::length($length); - } - -/** - * Builds a map of the columns contained in a result - * - * @param PDOStatement $results - * @return void - */ - public function resultSet($results) { - $this->map = array(); - $numFields = $results->columnCount(); - $index = 0; - - while ($numFields-- > 0) { - $column = $results->getColumnMeta($index); - $name = $column['name']; - - if (strpos($name, '__')) { - if (isset($this->_fieldMappings[$name]) && strpos($this->_fieldMappings[$name], '.')) { - $map = explode('.', $this->_fieldMappings[$name]); - } elseif (isset($this->_fieldMappings[$name])) { - $map = array(0, $this->_fieldMappings[$name]); - } else { - $map = array(0, $name); - } - } else { - $map = array(0, $name); - } - $map[] = ($column['sqlsrv:decl_type'] == 'bit') ? 'boolean' : $column['native_type']; - $this->map[$index++] = $map; - } - } - -/** - * Builds final SQL statement - * - * @param string $type Query type - * @param array $data Query data - * @return string - */ - public function renderStatement($type, $data) { - switch (strtolower($type)) { - case 'select': - extract($data); - $fields = trim($fields); - - if (strpos($limit, 'TOP') !== false && strpos($fields, 'DISTINCT ') === 0) { - $limit = 'DISTINCT ' . trim($limit); - $fields = substr($fields, 9); - } - - // hack order as SQLServer requires an order if there is a limit. - if ($limit && !$order) { - $order = 'ORDER BY (SELECT NULL)'; - } - - // For older versions use the subquery version of pagination. - if (version_compare($this->_version, '11', '<') && preg_match('/FETCH\sFIRST\s+([0-9]+)/i', $limit, $offset)) { - preg_match('/OFFSET\s*(\d+)\s*.*?(\d+)\s*ROWS/', $limit, $limitOffset); - - $limit = 'TOP ' . intval($limitOffset[2]); - $page = intval($limitOffset[1] / $limitOffset[2]); - $offset = intval($limitOffset[2] * $page); - - $rowCounter = self::ROW_COUNTER; - return " - SELECT {$limit} * FROM ( - SELECT {$fields}, ROW_NUMBER() OVER ({$order}) AS {$rowCounter} - FROM {$table} {$alias} {$joins} {$conditions} {$group} - ) AS _cake_paging_ - WHERE _cake_paging_.{$rowCounter} > {$offset} - ORDER BY _cake_paging_.{$rowCounter} - "; - } elseif (strpos($limit, 'FETCH') !== false) { - return "SELECT {$fields} FROM {$table} {$alias} {$joins} {$conditions} {$group} {$order} {$limit}"; - } else { - return "SELECT {$limit} {$fields} FROM {$table} {$alias} {$joins} {$conditions} {$group} {$order}"; - } - break; - case "schema": - extract($data); - - foreach ($indexes as $i => $index) { - if (preg_match('/PRIMARY KEY/', $index)) { - unset($indexes[$i]); - break; - } - } - - foreach (array('columns', 'indexes') as $var) { - if (is_array(${$var})) { - ${$var} = "\t" . implode(",\n\t", array_filter(${$var})); - } - } - return "CREATE TABLE {$table} (\n{$columns});\n{$indexes}"; - break; - default: - return parent::renderStatement($type, $data); - break; - } - } - -/** - * Returns a quoted and escaped string of $data for use in an SQL statement. - * - * @param string $data String to be prepared for use in an SQL statement - * @param string $column The column into which this data will be inserted - * @return string Quoted and escaped data - */ - public function value($data, $column = null) { - if (is_array($data) || is_object($data)) { - return parent::value($data, $column); - } elseif (in_array($data, array('{$__cakeID__$}', '{$__cakeForeignKey__$}'), true)) { - return $data; - } - - if (empty($column)) { - $column = $this->introspectType($data); - } - - switch ($column) { - case 'string': - case 'text': - return 'N' . $this->_connection->quote($data, PDO::PARAM_STR); - default: - return parent::value($data, $column); - } - } - -/** - * Returns an array of all result rows for a given SQL query. - * Returns false if no rows matched. - * - * @param Model $model - * @param array $queryData - * @param integer $recursive - * @return array Array of resultset rows, or false if no rows matched - */ - public function read(Model $model, $queryData = array(), $recursive = null) { - $results = parent::read($model, $queryData, $recursive); - $this->_fieldMappings = array(); - return $results; - } - -/** - * Fetches the next row from the current result set. - * Eats the magic ROW_COUNTER variable. - * - * @return mixed - */ - public function fetchResult() { - if ($row = $this->_result->fetch(PDO::FETCH_NUM)) { - $resultRow = array(); - foreach ($this->map as $col => $meta) { - list($table, $column, $type) = $meta; - if ($table === 0 && $column === self::ROW_COUNTER) { - continue; - } - $resultRow[$table][$column] = $row[$col]; - if ($type === 'boolean' && !is_null($row[$col])) { - $resultRow[$table][$column] = $this->boolean($resultRow[$table][$column]); - } - } - return $resultRow; - } - $this->_result->closeCursor(); - return false; - } - -/** - * Inserts multiple values into a table - * - * @param string $table - * @param string $fields - * @param array $values - * @return void - */ - public function insertMulti($table, $fields, $values) { - $primaryKey = $this->_getPrimaryKey($table); - $hasPrimaryKey = $primaryKey != null && ( - (is_array($fields) && in_array($primaryKey, $fields) - || (is_string($fields) && strpos($fields, $this->startQuote . $primaryKey . $this->endQuote) !== false)) - ); - - if ($hasPrimaryKey) { - $this->_execute('SET IDENTITY_INSERT ' . $this->fullTableName($table) . ' ON'); - } - - parent::insertMulti($table, $fields, $values); - - if ($hasPrimaryKey) { - $this->_execute('SET IDENTITY_INSERT ' . $this->fullTableName($table) . ' OFF'); - } - } - -/** - * Generate a database-native column schema string - * - * @param array $column An array structured like the following: array('name'=>'value', 'type'=>'value'[, options]), - * where options can be 'default', 'length', or 'key'. - * @return string - */ - public function buildColumn($column) { - $result = preg_replace('/(int|integer)\([0-9]+\)/i', '$1', parent::buildColumn($column)); - if (strpos($result, 'DEFAULT NULL') !== false) { - if (isset($column['default']) && $column['default'] === '') { - $result = str_replace('DEFAULT NULL', "DEFAULT ''", $result); - } else { - $result = str_replace('DEFAULT NULL', 'NULL', $result); - } - } elseif (array_keys($column) == array('type', 'name')) { - $result .= ' NULL'; - } elseif (strpos($result, "DEFAULT N'")) { - $result = str_replace("DEFAULT N'", "DEFAULT '", $result); - } - return $result; - } - -/** - * Format indexes for create table - * - * @param array $indexes - * @param string $table - * @return string - */ - public function buildIndex($indexes, $table = null) { - $join = array(); - - foreach ($indexes as $name => $value) { - if ($name == 'PRIMARY') { - $join[] = 'PRIMARY KEY (' . $this->name($value['column']) . ')'; - } elseif (isset($value['unique']) && $value['unique']) { - $out = "ALTER TABLE {$table} ADD CONSTRAINT {$name} UNIQUE"; - - if (is_array($value['column'])) { - $value['column'] = implode(', ', array_map(array(&$this, 'name'), $value['column'])); - } else { - $value['column'] = $this->name($value['column']); - } - $out .= "({$value['column']});"; - $join[] = $out; - } - } - return $join; - } - -/** - * Makes sure it will return the primary key - * - * @param mixed $model Model instance of table name - * @return string - */ - protected function _getPrimaryKey($model) { - $schema = $this->describe($model); - foreach ($schema as $field => $props) { - if (isset($props['key']) && $props['key'] == 'primary') { - return $field; - } - } - return null; - } - -/** - * Returns number of affected rows in previous database operation. If no previous operation exists, - * this returns false. - * - * @param mixed $source - * @return integer Number of affected rows - */ - public function lastAffected($source = null) { - $affected = parent::lastAffected(); - if ($affected === null && $this->_lastAffected !== false) { - return $this->_lastAffected; - } - return $affected; - } - -/** - * Executes given SQL statement. - * - * @param string $sql SQL statement - * @param array $params list of params to be bound to query (supported only in select) - * @param array $prepareOptions Options to be used in the prepare statement - * @return mixed PDOStatement if query executes with no problem, true as the result of a successful, false on error - * query returning no rows, such as a CREATE statement, false otherwise - * @throws PDOException - */ - protected function _execute($sql, $params = array(), $prepareOptions = array()) { - $this->_lastAffected = false; - if (strncasecmp($sql, 'SELECT', 6) == 0 || preg_match('/^EXEC(?:UTE)?\s/mi', $sql) > 0) { - $prepareOptions += array(PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL); - return parent::_execute($sql, $params, $prepareOptions); - } - try { - $this->_lastAffected = $this->_connection->exec($sql); - if ($this->_lastAffected === false) { - $this->_results = null; - $error = $this->_connection->errorInfo(); - $this->error = $error[2]; - return false; - } - return true; - } catch (PDOException $e) { - if (isset($query->queryString)) { - $e->queryString = $query->queryString; - } else { - $e->queryString = $sql; - } - throw $e; - } - } - -/** - * Generate a "drop table" statement for the given Schema object - * - * @param CakeSchema $schema An instance of a subclass of CakeSchema - * @param string $table Optional. If specified only the table name given will be generated. - * Otherwise, all tables defined in the schema are generated. - * @return string - */ - public function dropSchema(CakeSchema $schema, $table = null) { - $out = ''; - foreach ($schema->tables as $curTable => $columns) { - if (!$table || $table == $curTable) { - $out .= "IF OBJECT_ID('" . $this->fullTableName($curTable, false) . "', 'U') IS NOT NULL DROP TABLE " . $this->fullTableName($curTable) . ";\n"; - } - } - return $out; - } - -/** - * Gets the schema name - * - * @return string The schema name - */ - public function getSchemaName() { - return $this->config['schema']; - } - -} diff --git a/lib/Cake/Model/Datasource/DboSource.php b/lib/Cake/Model/Datasource/DboSource.php deleted file mode 100644 index d6eef163266..00000000000 --- a/lib/Cake/Model/Datasource/DboSource.php +++ /dev/null @@ -1,3155 +0,0 @@ - 'primary', 'MUL' => 'index', 'UNI' => 'unique'); - -/** - * Database keyword used to assign aliases to identifiers. - * - * @var string - */ - public $alias = 'AS '; - -/** - * Caches result from query parsing operations. Cached results for both DboSource::name() and - * DboSource::conditions() will be stored here. Method caching uses `crc32()` which is - * fast but can collisions more easily than other hashing algorithms. If you have problems - * with collisions, set DboSource::$cacheMethods to false. - * - * @var array - */ - public static $methodCache = array(); - -/** - * Whether or not to cache the results of DboSource::name() and DboSource::conditions() - * into the memory cache. Set to false to disable the use of the memory cache. - * - * @var boolean. - */ - public $cacheMethods = true; - -/** - * Print full query debug info? - * - * @var boolean - */ - public $fullDebug = false; - -/** - * String to hold how many rows were affected by the last SQL operation. - * - * @var string - */ - public $affected = null; - -/** - * Number of rows in current resultset - * - * @var integer - */ - public $numRows = null; - -/** - * Time the last query took - * - * @var integer - */ - public $took = null; - -/** - * Result - * - * @var array - */ - protected $_result = null; - -/** - * Queries count. - * - * @var integer - */ - protected $_queriesCnt = 0; - -/** - * Total duration of all queries. - * - * @var integer - */ - protected $_queriesTime = null; - -/** - * Log of queries executed by this DataSource - * - * @var array - */ - protected $_queriesLog = array(); - -/** - * Maximum number of items in query log - * - * This is to prevent query log taking over too much memory. - * - * @var integer Maximum number of queries in the queries log. - */ - protected $_queriesLogMax = 200; - -/** - * Caches serialized results of executed queries - * - * @var array Maximum number of queries in the queries log. - */ - protected $_queryCache = array(); - -/** - * A reference to the physical connection of this DataSource - * - * @var array - */ - protected $_connection = null; - -/** - * The DataSource configuration key name - * - * @var string - */ - public $configKeyName = null; - -/** - * The starting character that this DataSource uses for quoted identifiers. - * - * @var string - */ - public $startQuote = null; - -/** - * The ending character that this DataSource uses for quoted identifiers. - * - * @var string - */ - public $endQuote = null; - -/** - * The set of valid SQL operations usable in a WHERE statement - * - * @var array - */ - protected $_sqlOps = array('like', 'ilike', 'or', 'not', 'in', 'between', 'regexp', 'similar to'); - -/** - * Indicates the level of nested transactions - * - * @var integer - */ - protected $_transactionNesting = 0; - -/** - * Index of basic SQL commands - * - * @var array - */ - protected $_commands = array( - 'begin' => 'BEGIN', - 'commit' => 'COMMIT', - 'rollback' => 'ROLLBACK' - ); - -/** - * Separator string for virtualField composition - * - * @var string - */ - public $virtualFieldSeparator = '__'; - -/** - * List of table engine specific parameters used on table creating - * - * @var array - */ - public $tableParameters = array(); - -/** - * List of engine specific additional field parameters used on table creating - * - * @var array - */ - public $fieldParameters = array(); - -/** - * Indicates whether there was a change on the cached results on the methods of this class - * This will be used for storing in a more persistent cache - * - * @var boolean - */ - protected $_methodCacheChange = false; - -/** - * Constructor - * - * @param array $config Array of configuration information for the Datasource. - * @param boolean $autoConnect Whether or not the datasource should automatically connect. - * @throws MissingConnectionException when a connection cannot be made. - */ - public function __construct($config = null, $autoConnect = true) { - if (!isset($config['prefix'])) { - $config['prefix'] = ''; - } - parent::__construct($config); - $this->fullDebug = Configure::read('debug') > 1; - if (!$this->enabled()) { - throw new MissingConnectionException(array( - 'class' => get_class($this) - )); - } - if ($autoConnect) { - $this->connect(); - } - } - -/** - * Reconnects to database server with optional new settings - * - * @param array $config An array defining the new configuration settings - * @return boolean True on success, false on failure - */ - public function reconnect($config = array()) { - $this->disconnect(); - $this->setConfig($config); - $this->_sources = null; - - return $this->connect(); - } - -/** - * Disconnects from database. - * - * @return boolean True if the database could be disconnected, else false - */ - public function disconnect() { - if ($this->_result instanceof PDOStatement) { - $this->_result->closeCursor(); - } - unset($this->_connection); - $this->connected = false; - return true; - } - -/** - * Get the underlying connection object. - * - * @return PDOConnection - */ - public function getConnection() { - return $this->_connection; - } - -/** - * Returns a quoted and escaped string of $data for use in an SQL statement. - * - * @param string $data String to be prepared for use in an SQL statement - * @param string $column The column into which this data will be inserted - * @return string Quoted and escaped data - */ - public function value($data, $column = null) { - if (is_array($data) && !empty($data)) { - return array_map( - array(&$this, 'value'), - $data, array_fill(0, count($data), $column) - ); - } elseif (is_object($data) && isset($data->type, $data->value)) { - if ($data->type == 'identifier') { - return $this->name($data->value); - } elseif ($data->type == 'expression') { - return $data->value; - } - } elseif (in_array($data, array('{$__cakeID__$}', '{$__cakeForeignKey__$}'), true)) { - return $data; - } - - if ($data === null || (is_array($data) && empty($data))) { - return 'NULL'; - } - - if (empty($column)) { - $column = $this->introspectType($data); - } - - switch ($column) { - case 'binary': - return $this->_connection->quote($data, PDO::PARAM_LOB); - break; - case 'boolean': - return $this->_connection->quote($this->boolean($data, true), PDO::PARAM_BOOL); - break; - case 'string': - case 'text': - return $this->_connection->quote($data, PDO::PARAM_STR); - default: - if ($data === '') { - return 'NULL'; - } - if (is_float($data)) { - return str_replace(',', '.', strval($data)); - } - if ((is_int($data) || $data === '0') || ( - is_numeric($data) && strpos($data, ',') === false && - $data[0] != '0' && strpos($data, 'e') === false) - ) { - return $data; - } - return $this->_connection->quote($data); - break; - } - } - -/** - * Returns an object to represent a database identifier in a query. Expression objects - * are not sanitized or escaped. - * - * @param string $identifier A SQL expression to be used as an identifier - * @return stdClass An object representing a database identifier to be used in a query - */ - public function identifier($identifier) { - $obj = new stdClass(); - $obj->type = 'identifier'; - $obj->value = $identifier; - return $obj; - } - -/** - * Returns an object to represent a database expression in a query. Expression objects - * are not sanitized or escaped. - * - * @param string $expression An arbitrary SQL expression to be inserted into a query. - * @return stdClass An object representing a database expression to be used in a query - */ - public function expression($expression) { - $obj = new stdClass(); - $obj->type = 'expression'; - $obj->value = $expression; - return $obj; - } - -/** - * Executes given SQL statement. - * - * @param string $sql SQL statement - * @param array $params Additional options for the query. - * @return boolean - */ - public function rawQuery($sql, $params = array()) { - $this->took = $this->numRows = false; - return $this->execute($sql, $params); - } - -/** - * Queries the database with given SQL statement, and obtains some metadata about the result - * (rows affected, timing, any errors, number of rows in resultset). The query is also logged. - * If Configure::read('debug') is set, the log is shown all the time, else it is only shown on errors. - * - * ### Options - * - * - log - Whether or not the query should be logged to the memory log. - * - * @param string $sql SQL statement - * @param array $options - * @param array $params values to be bound to the query - * @return mixed Resource or object representing the result set, or false on failure - */ - public function execute($sql, $options = array(), $params = array()) { - $options += array('log' => $this->fullDebug); - - $t = microtime(true); - $this->_result = $this->_execute($sql, $params); - - if ($options['log']) { - $this->took = round((microtime(true) - $t) * 1000, 0); - $this->numRows = $this->affected = $this->lastAffected(); - $this->logQuery($sql, $params); - } - - return $this->_result; - } - -/** - * Executes given SQL statement. - * - * @param string $sql SQL statement - * @param array $params list of params to be bound to query - * @param array $prepareOptions Options to be used in the prepare statement - * @return mixed PDOStatement if query executes with no problem, true as the result of a successful, false on error - * query returning no rows, such as a CREATE statement, false otherwise - * @throws PDOException - */ - protected function _execute($sql, $params = array(), $prepareOptions = array()) { - $sql = trim($sql); - if (preg_match('/^(?:CREATE|ALTER|DROP)/i', $sql)) { - $statements = array_filter(explode(';', $sql)); - if (count($statements) > 1) { - $result = array_map(array($this, '_execute'), $statements); - return array_search(false, $result) === false; - } - } - - try { - $query = $this->_connection->prepare($sql, $prepareOptions); - $query->setFetchMode(PDO::FETCH_LAZY); - if (!$query->execute($params)) { - $this->_results = $query; - $query->closeCursor(); - return false; - } - if (!$query->columnCount()) { - $query->closeCursor(); - if (!$query->rowCount()) { - return true; - } - } - return $query; - } catch (PDOException $e) { - if (isset($query->queryString)) { - $e->queryString = $query->queryString; - } else { - $e->queryString = $sql; - } - throw $e; - } - } - -/** - * Returns a formatted error message from previous database operation. - * - * @param PDOStatement $query the query to extract the error from if any - * @return string Error message with error number - */ - public function lastError(PDOStatement $query = null) { - if ($query) { - $error = $query->errorInfo(); - } else { - $error = $this->_connection->errorInfo(); - } - if (empty($error[2])) { - return null; - } - return $error[1] . ': ' . $error[2]; - } - -/** - * Returns number of affected rows in previous database operation. If no previous operation exists, - * this returns false. - * - * @param mixed $source - * @return integer Number of affected rows - */ - public function lastAffected($source = null) { - if ($this->hasResult()) { - return $this->_result->rowCount(); - } - return 0; - } - -/** - * Returns number of rows in previous resultset. If no previous resultset exists, - * this returns false. - * - * @param mixed $source Not used - * @return integer Number of rows in resultset - */ - public function lastNumRows($source = null) { - return $this->lastAffected(); - } - -/** - * DataSource Query abstraction - * - * @return resource Result resource identifier. - */ - public function query() { - $args = func_get_args(); - $fields = null; - $order = null; - $limit = null; - $page = null; - $recursive = null; - - if (count($args) === 1) { - return $this->fetchAll($args[0]); - } elseif (count($args) > 1 && (strpos($args[0], 'findBy') === 0 || strpos($args[0], 'findAllBy') === 0)) { - $params = $args[1]; - - if (substr($args[0], 0, 6) === 'findBy') { - $all = false; - $field = Inflector::underscore(substr($args[0], 6)); - } else { - $all = true; - $field = Inflector::underscore(substr($args[0], 9)); - } - - $or = (strpos($field, '_or_') !== false); - if ($or) { - $field = explode('_or_', $field); - } else { - $field = explode('_and_', $field); - } - $off = count($field) - 1; - - if (isset($params[1 + $off])) { - $fields = $params[1 + $off]; - } - - if (isset($params[2 + $off])) { - $order = $params[2 + $off]; - } - - if (!array_key_exists(0, $params)) { - return false; - } - - $c = 0; - $conditions = array(); - - foreach ($field as $f) { - $conditions[$args[2]->alias . '.' . $f] = $params[$c++]; - } - - if ($or) { - $conditions = array('OR' => $conditions); - } - - if ($all) { - if (isset($params[3 + $off])) { - $limit = $params[3 + $off]; - } - - if (isset($params[4 + $off])) { - $page = $params[4 + $off]; - } - - if (isset($params[5 + $off])) { - $recursive = $params[5 + $off]; - } - return $args[2]->find('all', compact('conditions', 'fields', 'order', 'limit', 'page', 'recursive')); - } else { - if (isset($params[3 + $off])) { - $recursive = $params[3 + $off]; - } - return $args[2]->find('first', compact('conditions', 'fields', 'order', 'recursive')); - } - } else { - if (isset($args[1]) && $args[1] === true) { - return $this->fetchAll($args[0], true); - } elseif (isset($args[1]) && !is_array($args[1]) ) { - return $this->fetchAll($args[0], false); - } elseif (isset($args[1]) && is_array($args[1])) { - if (isset($args[2])) { - $cache = $args[2]; - } else { - $cache = true; - } - return $this->fetchAll($args[0], $args[1], array('cache' => $cache)); - } - } - } - -/** - * Returns a row from current resultset as an array - * - * @param string $sql Some SQL to be executed. - * @return array The fetched row as an array - */ - public function fetchRow($sql = null) { - if (is_string($sql) && strlen($sql) > 5 && !$this->execute($sql)) { - return null; - } - - if ($this->hasResult()) { - $this->resultSet($this->_result); - $resultRow = $this->fetchResult(); - if (isset($resultRow[0])) { - $this->fetchVirtualField($resultRow); - } - return $resultRow; - } else { - return null; - } - } - -/** - * Returns an array of all result rows for a given SQL query. - * Returns false if no rows matched. - * - * - * ### Options - * - * - `cache` - Returns the cached version of the query, if exists and stores the result in cache. - * This is a non-persistent cache, and only lasts for a single request. This option - * defaults to true. If you are directly calling this method, you can disable caching - * by setting $options to `false` - * - * @param string $sql SQL statement - * @param array $params parameters to be bound as values for the SQL statement - * @param array $options additional options for the query. - * @return array Array of resultset rows, or false if no rows matched - */ - public function fetchAll($sql, $params = array(), $options = array()) { - if (is_string($options)) { - $options = array('modelName' => $options); - } - if (is_bool($params)) { - $options['cache'] = $params; - $params = array(); - } - $options += array('cache' => true); - $cache = $options['cache']; - if ($cache && ($cached = $this->getQueryCache($sql, $params)) !== false) { - return $cached; - } - if ($result = $this->execute($sql, array(), $params)) { - $out = array(); - - if ($this->hasResult()) { - $first = $this->fetchRow(); - if ($first != null) { - $out[] = $first; - } - while ($item = $this->fetchResult()) { - if (isset($item[0])) { - $this->fetchVirtualField($item); - } - $out[] = $item; - } - } - - if (!is_bool($result) && $cache) { - $this->_writeQueryCache($sql, $out, $params); - } - - if (empty($out) && is_bool($this->_result)) { - return $this->_result; - } - return $out; - } - return false; - } - -/** - * Fetches the next row from the current result set - * - * @return boolean - */ - public function fetchResult() { - return false; - } - -/** - * Modifies $result array to place virtual fields in model entry where they belongs to - * - * @param array $result Reference to the fetched row - * @return void - */ - public function fetchVirtualField(&$result) { - if (isset($result[0]) && is_array($result[0])) { - foreach ($result[0] as $field => $value) { - if (strpos($field, $this->virtualFieldSeparator) === false) { - continue; - } - list($alias, $virtual) = explode($this->virtualFieldSeparator, $field); - - if (!ClassRegistry::isKeySet($alias)) { - return; - } - $model = ClassRegistry::getObject($alias); - if ($model->isVirtualField($virtual)) { - $result[$alias][$virtual] = $value; - unset($result[0][$field]); - } - } - if (empty($result[0])) { - unset($result[0]); - } - } - } - -/** - * Returns a single field of the first of query results for a given SQL query, or false if empty. - * - * @param string $name Name of the field - * @param string $sql SQL query - * @return mixed Value of field read. - */ - public function field($name, $sql) { - $data = $this->fetchRow($sql); - if (empty($data[$name])) { - return false; - } - return $data[$name]; - } - -/** - * Empties the method caches. - * These caches are used by DboSource::name() and DboSource::conditions() - * - * @return void - */ - public function flushMethodCache() { - $this->_methodCacheChange = true; - self::$methodCache = array(); - } - -/** - * Cache a value into the methodCaches. Will respect the value of DboSource::$cacheMethods. - * Will retrieve a value from the cache if $value is null. - * - * If caching is disabled and a write is attempted, the $value will be returned. - * A read will either return the value or null. - * - * @param string $method Name of the method being cached. - * @param string $key The key name for the cache operation. - * @param mixed $value The value to cache into memory. - * @return mixed Either null on failure, or the value if its set. - */ - public function cacheMethod($method, $key, $value = null) { - if ($this->cacheMethods === false) { - return $value; - } - if (empty(self::$methodCache)) { - self::$methodCache = Cache::read('method_cache', '_cake_core_'); - } - if ($value === null) { - return (isset(self::$methodCache[$method][$key])) ? self::$methodCache[$method][$key] : null; - } - $this->_methodCacheChange = true; - return self::$methodCache[$method][$key] = $value; - } - -/** - * Returns a quoted name of $data for use in an SQL statement. - * Strips fields out of SQL functions before quoting. - * - * Results of this method are stored in a memory cache. This improves performance, but - * because the method uses a simple hashing algorithm it can infrequently have collisions. - * Setting DboSource::$cacheMethods to false will disable the memory cache. - * - * @param mixed $data Either a string with a column to quote. An array of columns to quote or an - * object from DboSource::expression() or DboSource::identifier() - * @return string SQL field - */ - public function name($data) { - if (is_object($data) && isset($data->type)) { - return $data->value; - } - if ($data === '*') { - return '*'; - } - if (is_array($data)) { - foreach ($data as $i => $dataItem) { - $data[$i] = $this->name($dataItem); - } - return $data; - } - $cacheKey = crc32($this->startQuote . $data . $this->endQuote); - if ($return = $this->cacheMethod(__FUNCTION__, $cacheKey)) { - return $return; - } - $data = trim($data); - if (preg_match('/^[\w-]+(?:\.[^ \*]*)*$/', $data)) { // string, string.string - if (strpos($data, '.') === false) { // string - return $this->cacheMethod(__FUNCTION__, $cacheKey, $this->startQuote . $data . $this->endQuote); - } - $items = explode('.', $data); - return $this->cacheMethod(__FUNCTION__, $cacheKey, - $this->startQuote . implode($this->endQuote . '.' . $this->startQuote, $items) . $this->endQuote - ); - } - if (preg_match('/^[\w-]+\.\*$/', $data)) { // string.* - return $this->cacheMethod(__FUNCTION__, $cacheKey, - $this->startQuote . str_replace('.*', $this->endQuote . '.*', $data) - ); - } - if (preg_match('/^([\w-]+)\((.*)\)$/', $data, $matches)) { // Functions - return $this->cacheMethod(__FUNCTION__, $cacheKey, - $matches[1] . '(' . $this->name($matches[2]) . ')' - ); - } - if ( - preg_match('/^([\w-]+(\.[\w-]+|\(.*\))*)\s+' . preg_quote($this->alias) . '\s*([\w-]+)$/i', $data, $matches - )) { - return $this->cacheMethod( - __FUNCTION__, $cacheKey, - preg_replace( - '/\s{2,}/', ' ', $this->name($matches[1]) . ' ' . $this->alias . ' ' . $this->name($matches[3]) - ) - ); - } - if (preg_match('/^[\w-_\s]*[\w-_]+/', $data)) { - return $this->cacheMethod(__FUNCTION__, $cacheKey, $this->startQuote . $data . $this->endQuote); - } - return $this->cacheMethod(__FUNCTION__, $cacheKey, $data); - } - -/** - * Checks if the source is connected to the database. - * - * @return boolean True if the database is connected, else false - */ - public function isConnected() { - return $this->connected; - } - -/** - * Checks if the result is valid - * - * @return boolean True if the result is valid else false - */ - public function hasResult() { - return is_a($this->_result, 'PDOStatement'); - } - -/** - * Get the query log as an array. - * - * @param boolean $sorted Get the queries sorted by time taken, defaults to false. - * @param boolean $clear If True the existing log will cleared. - * @return array Array of queries run as an array - */ - public function getLog($sorted = false, $clear = true) { - if ($sorted) { - $log = sortByKey($this->_queriesLog, 'took', 'desc', SORT_NUMERIC); - } else { - $log = $this->_queriesLog; - } - if ($clear) { - $this->_queriesLog = array(); - } - return array('log' => $log, 'count' => $this->_queriesCnt, 'time' => $this->_queriesTime); - } - -/** - * Outputs the contents of the queries log. If in a non-CLI environment the sql_log element - * will be rendered and output. If in a CLI environment, a plain text log is generated. - * - * @param boolean $sorted Get the queries sorted by time taken, defaults to false. - * @return void - */ - public function showLog($sorted = false) { - $log = $this->getLog($sorted, false); - if (empty($log['log'])) { - return; - } - if (PHP_SAPI != 'cli') { - $controller = null; - $View = new View($controller, false); - $View->set('logs', array($this->configKeyName => $log)); - echo $View->element('sql_dump', array('_forced_from_dbo_' => true)); - } else { - foreach ($log['log'] as $k => $i) { - print (($k + 1) . ". {$i['query']}\n"); - } - } - } - -/** - * Log given SQL query. - * - * @param string $sql SQL statement - * @param array $params Values binded to the query (prepared statements) - * @return void - */ - public function logQuery($sql, $params = array()) { - $this->_queriesCnt++; - $this->_queriesTime += $this->took; - $this->_queriesLog[] = array( - 'query' => $sql, - 'params' => $params, - 'affected' => $this->affected, - 'numRows' => $this->numRows, - 'took' => $this->took - ); - if (count($this->_queriesLog) > $this->_queriesLogMax) { - array_pop($this->_queriesLog); - } - } - -/** - * Gets full table name including prefix - * - * @param mixed $model Either a Model object or a string table name. - * @param boolean $quote Whether you want the table name quoted. - * @param boolean $schema Whether you want the schema name included. - * @return string Full quoted table name - */ - public function fullTableName($model, $quote = true, $schema = true) { - if (is_object($model)) { - $schemaName = $model->schemaName; - $table = $model->tablePrefix . $model->table; - } elseif (!empty($this->config['prefix']) && strpos($model, $this->config['prefix']) === false) { - $table = $this->config['prefix'] . strval($model); - } else { - $table = strval($model); - } - if ($schema && !isset($schemaName)) { - $schemaName = $this->getSchemaName(); - } - - if ($quote) { - if ($schema && !empty($schemaName)) { - if (false == strstr($table, '.')) { - return $this->name($schemaName) . '.' . $this->name($table); - } - } - return $this->name($table); - } - if ($schema && !empty($schemaName)) { - if (false == strstr($table, '.')) { - return $schemaName . '.' . $table; - } - } - return $table; - } - -/** - * The "C" in CRUD - * - * Creates new records in the database. - * - * @param Model $model Model object that the record is for. - * @param array $fields An array of field names to insert. If null, $model->data will be - * used to generate field names. - * @param array $values An array of values with keys matching the fields. If null, $model->data will - * be used to generate values. - * @return boolean Success - */ - public function create(Model $model, $fields = null, $values = null) { - $id = null; - - if ($fields == null) { - unset($fields, $values); - $fields = array_keys($model->data); - $values = array_values($model->data); - } - $count = count($fields); - - for ($i = 0; $i < $count; $i++) { - $valueInsert[] = $this->value($values[$i], $model->getColumnType($fields[$i])); - $fieldInsert[] = $this->name($fields[$i]); - if ($fields[$i] == $model->primaryKey) { - $id = $values[$i]; - } - } - $query = array( - 'table' => $this->fullTableName($model), - 'fields' => implode(', ', $fieldInsert), - 'values' => implode(', ', $valueInsert) - ); - - if ($this->execute($this->renderStatement('create', $query))) { - if (empty($id)) { - $id = $this->lastInsertId($this->fullTableName($model, false, false), $model->primaryKey); - } - $model->setInsertID($id); - $model->id = $id; - return true; - } - $model->onError(); - return false; - } - -/** - * The "R" in CRUD - * - * Reads record(s) from the database. - * - * @param Model $model A Model object that the query is for. - * @param array $queryData An array of queryData information containing keys similar to Model::find() - * @param integer $recursive Number of levels of association - * @return mixed boolean false on error/failure. An array of results on success. - */ - public function read(Model $model, $queryData = array(), $recursive = null) { - $queryData = $this->_scrubQueryData($queryData); - - $null = null; - $array = array('callbacks' => $queryData['callbacks']); - $linkedModels = array(); - $bypass = false; - - if ($recursive === null && isset($queryData['recursive'])) { - $recursive = $queryData['recursive']; - } - - if (!is_null($recursive)) { - $_recursive = $model->recursive; - $model->recursive = $recursive; - } - - if (!empty($queryData['fields'])) { - $bypass = true; - $queryData['fields'] = $this->fields($model, null, $queryData['fields']); - } else { - $queryData['fields'] = $this->fields($model); - } - - $_associations = $model->associations(); - - if ($model->recursive == -1) { - $_associations = array(); - } elseif ($model->recursive == 0) { - unset($_associations[2], $_associations[3]); - } - - foreach ($_associations as $type) { - foreach ($model->{$type} as $assoc => $assocData) { - $linkModel = $model->{$assoc}; - $external = isset($assocData['external']); - - $linkModel->getDataSource(); - if ($model->useDbConfig === $linkModel->useDbConfig) { - if ($bypass) { - $assocData['fields'] = false; - } - if (true === $this->generateAssociationQuery($model, $linkModel, $type, $assoc, $assocData, $queryData, $external, $null)) { - $linkedModels[$type . '/' . $assoc] = true; - } - } - } - } - - $query = trim($this->generateAssociationQuery($model, null, null, null, null, $queryData, false, $null)); - - $resultSet = $this->fetchAll($query, $model->cacheQueries); - if ($resultSet === false) { - $model->onError(); - return false; - } - - $filtered = array(); - - if ($queryData['callbacks'] === true || $queryData['callbacks'] === 'after') { - $filtered = $this->_filterResults($resultSet, $model); - } - - if ($model->recursive > -1) { - foreach ($_associations as $type) { - foreach ($model->{$type} as $assoc => $assocData) { - $linkModel = $model->{$assoc}; - - if (!isset($linkedModels[$type . '/' . $assoc])) { - if ($model->useDbConfig === $linkModel->useDbConfig) { - $db = $this; - } else { - $db = ConnectionManager::getDataSource($linkModel->useDbConfig); - } - } elseif ($model->recursive > 1 && ($type === 'belongsTo' || $type === 'hasOne')) { - $db = $this; - } - - if (isset($db) && method_exists($db, 'queryAssociation')) { - $stack = array($assoc); - $db->queryAssociation($model, $linkModel, $type, $assoc, $assocData, $array, true, $resultSet, $model->recursive - 1, $stack); - unset($db); - - if ($type === 'hasMany') { - $filtered[] = $assoc; - } - } - } - } - if ($queryData['callbacks'] === true || $queryData['callbacks'] === 'after') { - $this->_filterResults($resultSet, $model, $filtered); - } - } - - if (!is_null($recursive)) { - $model->recursive = $_recursive; - } - return $resultSet; - } - -/** - * Passes association results thru afterFind filters of corresponding model - * - * @param array $results Reference of resultset to be filtered - * @param Model $model Instance of model to operate against - * @param array $filtered List of classes already filtered, to be skipped - * @return array Array of results that have been filtered through $model->afterFind - */ - protected function _filterResults(&$results, Model $model, $filtered = array()) { - $current = current($results); - if (!is_array($current)) { - return array(); - } - $keys = array_diff(array_keys($current), $filtered, array($model->alias)); - $filtering = array(); - foreach ($keys as $className) { - if (!isset($model->{$className}) || !is_object($model->{$className})) { - continue; - } - $linkedModel = $model->{$className}; - $filtering[] = $className; - foreach ($results as &$result) { - $data = $linkedModel->afterFind(array(array($className => $result[$className])), false); - if (isset($data[0][$className])) { - $result[$className] = $data[0][$className]; - } - } - } - return $filtering; - } - -/** - * Queries associations. Used to fetch results on recursive models. - * - * @param Model $model Primary Model object - * @param Model $linkModel Linked model that - * @param string $type Association type, one of the model association types ie. hasMany - * @param string $association - * @param array $assocData - * @param array $queryData - * @param boolean $external Whether or not the association query is on an external datasource. - * @param array $resultSet Existing results - * @param integer $recursive Number of levels of association - * @param array $stack - * @return mixed - * @throws CakeException when results cannot be created. - */ - public function queryAssociation(Model $model, &$linkModel, $type, $association, $assocData, &$queryData, $external = false, &$resultSet, $recursive, $stack) { - if ($query = $this->generateAssociationQuery($model, $linkModel, $type, $association, $assocData, $queryData, $external, $resultSet)) { - if (!is_array($resultSet)) { - throw new CakeException(__d('cake_dev', 'Error in Model %s', get_class($model))); - } - if ($type === 'hasMany' && empty($assocData['limit']) && !empty($assocData['foreignKey'])) { - $ins = $fetch = array(); - foreach ($resultSet as &$result) { - if ($in = $this->insertQueryData('{$__cakeID__$}', $result, $association, $assocData, $model, $linkModel, $stack)) { - $ins[] = $in; - } - } - - if (!empty($ins)) { - $ins = array_unique($ins); - $fetch = $this->fetchAssociated($model, $query, $ins); - } - - if (!empty($fetch) && is_array($fetch)) { - if ($recursive > 0) { - foreach ($linkModel->associations() as $type1) { - foreach ($linkModel->{$type1} as $assoc1 => $assocData1) { - $deepModel = $linkModel->{$assoc1}; - $tmpStack = $stack; - $tmpStack[] = $assoc1; - - if ($linkModel->useDbConfig === $deepModel->useDbConfig) { - $db = $this; - } else { - $db = ConnectionManager::getDataSource($deepModel->useDbConfig); - } - $db->queryAssociation($linkModel, $deepModel, $type1, $assoc1, $assocData1, $queryData, true, $fetch, $recursive - 1, $tmpStack); - } - } - } - } - if ($queryData['callbacks'] === true || $queryData['callbacks'] === 'after') { - $this->_filterResults($fetch, $model); - } - return $this->_mergeHasMany($resultSet, $fetch, $association, $model, $linkModel); - } elseif ($type === 'hasAndBelongsToMany') { - $ins = $fetch = array(); - foreach ($resultSet as &$result) { - if ($in = $this->insertQueryData('{$__cakeID__$}', $result, $association, $assocData, $model, $linkModel, $stack)) { - $ins[] = $in; - } - } - if (!empty($ins)) { - $ins = array_unique($ins); - if (count($ins) > 1) { - $query = str_replace('{$__cakeID__$}', '(' . implode(', ', $ins) . ')', $query); - $query = str_replace('= (', 'IN (', $query); - } else { - $query = str_replace('{$__cakeID__$}', $ins[0], $query); - } - $query = str_replace(' WHERE 1 = 1', '', $query); - } - - $foreignKey = $model->hasAndBelongsToMany[$association]['foreignKey']; - $joinKeys = array($foreignKey, $model->hasAndBelongsToMany[$association]['associationForeignKey']); - list($with, $habtmFields) = $model->joinModel($model->hasAndBelongsToMany[$association]['with'], $joinKeys); - $habtmFieldsCount = count($habtmFields); - $q = $this->insertQueryData($query, null, $association, $assocData, $model, $linkModel, $stack); - - if ($q !== false) { - $fetch = $this->fetchAll($q, $model->cacheQueries); - } else { - $fetch = null; - } - } - - $modelAlias = $model->alias; - $modelPK = $model->primaryKey; - foreach ($resultSet as &$row) { - if ($type !== 'hasAndBelongsToMany') { - $q = $this->insertQueryData($query, $row, $association, $assocData, $model, $linkModel, $stack); - if ($q !== false) { - $fetch = $this->fetchAll($q, $model->cacheQueries); - } else { - $fetch = null; - } - } - $selfJoin = $linkModel->name === $model->name; - - if (!empty($fetch) && is_array($fetch)) { - if ($recursive > 0) { - foreach ($linkModel->associations() as $type1) { - foreach ($linkModel->{$type1} as $assoc1 => $assocData1) { - $deepModel = $linkModel->{$assoc1}; - - if ($type1 === 'belongsTo' || ($deepModel->alias === $modelAlias && $type === 'belongsTo') || ($deepModel->alias !== $modelAlias)) { - $tmpStack = $stack; - $tmpStack[] = $assoc1; - if ($linkModel->useDbConfig == $deepModel->useDbConfig) { - $db = $this; - } else { - $db = ConnectionManager::getDataSource($deepModel->useDbConfig); - } - $db->queryAssociation($linkModel, $deepModel, $type1, $assoc1, $assocData1, $queryData, true, $fetch, $recursive - 1, $tmpStack); - } - } - } - } - if ($type === 'hasAndBelongsToMany') { - $uniqueIds = $merge = array(); - - foreach ($fetch as $j => $data) { - if (isset($data[$with]) && $data[$with][$foreignKey] === $row[$modelAlias][$modelPK]) { - if ($habtmFieldsCount <= 2) { - unset($data[$with]); - } - $merge[] = $data; - } - } - if (empty($merge) && !isset($row[$association])) { - $row[$association] = $merge; - } else { - $this->_mergeAssociation($row, $merge, $association, $type); - } - } else { - $this->_mergeAssociation($row, $fetch, $association, $type, $selfJoin); - } - if (isset($row[$association])) { - $row[$association] = $linkModel->afterFind($row[$association], false); - } - } else { - $tempArray[0][$association] = false; - $this->_mergeAssociation($row, $tempArray, $association, $type, $selfJoin); - } - } - } - } - -/** - * A more efficient way to fetch associations. Woohoo! - * - * @param Model $model Primary model object - * @param string $query Association query - * @param array $ids Array of IDs of associated records - * @return array Association results - */ - public function fetchAssociated(Model $model, $query, $ids) { - $query = str_replace('{$__cakeID__$}', implode(', ', $ids), $query); - if (count($ids) > 1) { - $query = str_replace('= (', 'IN (', $query); - } - return $this->fetchAll($query, $model->cacheQueries); - } - -/** - * mergeHasMany - Merge the results of hasMany relations. - * - * - * @param array $resultSet Data to merge into - * @param array $merge Data to merge - * @param string $association Name of Model being Merged - * @param Model $model Model being merged onto - * @param Model $linkModel Model being merged - * @return void - */ - protected function _mergeHasMany(&$resultSet, $merge, $association, $model, $linkModel) { - $modelAlias = $model->alias; - $modelPK = $model->primaryKey; - $modelFK = $model->hasMany[$association]['foreignKey']; - foreach ($resultSet as &$result) { - if (!isset($result[$modelAlias])) { - continue; - } - $merged = array(); - foreach ($merge as $data) { - if ($result[$modelAlias][$modelPK] === $data[$association][$modelFK]) { - if (count($data) > 1) { - $data = array_merge($data[$association], $data); - unset($data[$association]); - foreach ($data as $key => $name) { - if (is_numeric($key)) { - $data[$association][] = $name; - unset($data[$key]); - } - } - $merged[] = $data; - } else { - $merged[] = $data[$association]; - } - } - } - $result = Set::pushDiff($result, array($association => $merged)); - } - } - -/** - * Merge association of merge into data - * - * @param array $data - * @param array $merge - * @param string $association - * @param string $type - * @param boolean $selfJoin - * @return void - */ - protected function _mergeAssociation(&$data, &$merge, $association, $type, $selfJoin = false) { - if (isset($merge[0]) && !isset($merge[0][$association])) { - $association = Inflector::pluralize($association); - } - - if ($type === 'belongsTo' || $type === 'hasOne') { - if (isset($merge[$association])) { - $data[$association] = $merge[$association][0]; - } else { - if (count($merge[0][$association]) > 1) { - foreach ($merge[0] as $assoc => $data2) { - if ($assoc !== $association) { - $merge[0][$association][$assoc] = $data2; - } - } - } - if (!isset($data[$association])) { - if ($merge[0][$association] != null) { - $data[$association] = $merge[0][$association]; - } else { - $data[$association] = array(); - } - } else { - if (is_array($merge[0][$association])) { - foreach ($data[$association] as $k => $v) { - if (!is_array($v)) { - $dataAssocTmp[$k] = $v; - } - } - - foreach ($merge[0][$association] as $k => $v) { - if (!is_array($v)) { - $mergeAssocTmp[$k] = $v; - } - } - $dataKeys = array_keys($data); - $mergeKeys = array_keys($merge[0]); - - if ($mergeKeys[0] === $dataKeys[0] || $mergeKeys === $dataKeys) { - $data[$association][$association] = $merge[0][$association]; - } else { - $diff = Set::diff($dataAssocTmp, $mergeAssocTmp); - $data[$association] = array_merge($merge[0][$association], $diff); - } - } elseif ($selfJoin && array_key_exists($association, $merge[0])) { - $data[$association] = array_merge($data[$association], array($association => array())); - } - } - } - } else { - if (isset($merge[0][$association]) && $merge[0][$association] === false) { - if (!isset($data[$association])) { - $data[$association] = array(); - } - } else { - foreach ($merge as $i => $row) { - $insert = array(); - if (count($row) === 1) { - $insert = $row[$association]; - } elseif (isset($row[$association])) { - $insert = array_merge($row[$association], $row); - unset($insert[$association]); - } - - if (empty($data[$association]) || (isset($data[$association]) && !in_array($insert, $data[$association], true))) { - $data[$association][] = $insert; - } - } - } - } - } - -/** - * Generates an array representing a query or part of a query from a single model or two associated models - * - * @param Model $model - * @param Model $linkModel - * @param string $type - * @param string $association - * @param array $assocData - * @param array $queryData - * @param boolean $external - * @param array $resultSet - * @return mixed - */ - public function generateAssociationQuery(Model $model, $linkModel, $type, $association = null, $assocData = array(), &$queryData, $external = false, &$resultSet) { - $queryData = $this->_scrubQueryData($queryData); - $assocData = $this->_scrubQueryData($assocData); - $modelAlias = $model->alias; - - if (empty($queryData['fields'])) { - $queryData['fields'] = $this->fields($model, $modelAlias); - } elseif (!empty($model->hasMany) && $model->recursive > -1) { - $assocFields = $this->fields($model, $modelAlias, array("{$modelAlias}.{$model->primaryKey}")); - $passedFields = $queryData['fields']; - if (count($passedFields) === 1) { - if (strpos($passedFields[0], $assocFields[0]) === false && !preg_match('/^[a-z]+\(/i', $passedFields[0])) { - $queryData['fields'] = array_merge($passedFields, $assocFields); - } else { - $queryData['fields'] = $passedFields; - } - } else { - $queryData['fields'] = array_merge($passedFields, $assocFields); - } - unset($assocFields, $passedFields); - } - - if ($linkModel === null) { - return $this->buildStatement( - array( - 'fields' => array_unique($queryData['fields']), - 'table' => $this->fullTableName($model), - 'alias' => $modelAlias, - 'limit' => $queryData['limit'], - 'offset' => $queryData['offset'], - 'joins' => $queryData['joins'], - 'conditions' => $queryData['conditions'], - 'order' => $queryData['order'], - 'group' => $queryData['group'] - ), - $model - ); - } - if ($external && !empty($assocData['finderQuery'])) { - return $assocData['finderQuery']; - } - - $self = $model->name === $linkModel->name; - $fields = array(); - - if ($external || (in_array($type, array('hasOne', 'belongsTo')) && $assocData['fields'] !== false)) { - $fields = $this->fields($linkModel, $association, $assocData['fields']); - } - if (empty($assocData['offset']) && !empty($assocData['page'])) { - $assocData['offset'] = ($assocData['page'] - 1) * $assocData['limit']; - } - $assocData['limit'] = $this->limit($assocData['limit'], $assocData['offset']); - - switch ($type) { - case 'hasOne': - case 'belongsTo': - $conditions = $this->_mergeConditions( - $assocData['conditions'], - $this->getConstraint($type, $model, $linkModel, $association, array_merge($assocData, compact('external', 'self'))) - ); - - if (!$self && $external) { - foreach ($conditions as $key => $condition) { - if (is_numeric($key) && strpos($condition, $modelAlias . '.') !== false) { - unset($conditions[$key]); - } - } - } - - if ($external) { - $query = array_merge($assocData, array( - 'conditions' => $conditions, - 'table' => $this->fullTableName($linkModel), - 'fields' => $fields, - 'alias' => $association, - 'group' => null - )); - $query += array('order' => $assocData['order'], 'limit' => $assocData['limit']); - } else { - $join = array( - 'table' => $linkModel, - 'alias' => $association, - 'type' => isset($assocData['type']) ? $assocData['type'] : 'LEFT', - 'conditions' => trim($this->conditions($conditions, true, false, $model)) - ); - $queryData['fields'] = array_merge($queryData['fields'], $fields); - - if (!empty($assocData['order'])) { - $queryData['order'][] = $assocData['order']; - } - if (!in_array($join, $queryData['joins'])) { - $queryData['joins'][] = $join; - } - return true; - } - break; - case 'hasMany': - $assocData['fields'] = $this->fields($linkModel, $association, $assocData['fields']); - if (!empty($assocData['foreignKey'])) { - $assocData['fields'] = array_merge($assocData['fields'], $this->fields($linkModel, $association, array("{$association}.{$assocData['foreignKey']}"))); - } - $query = array( - 'conditions' => $this->_mergeConditions($this->getConstraint('hasMany', $model, $linkModel, $association, $assocData), $assocData['conditions']), - 'fields' => array_unique($assocData['fields']), - 'table' => $this->fullTableName($linkModel), - 'alias' => $association, - 'order' => $assocData['order'], - 'limit' => $assocData['limit'], - 'group' => null - ); - break; - case 'hasAndBelongsToMany': - $joinFields = array(); - $joinAssoc = null; - - if (isset($assocData['with']) && !empty($assocData['with'])) { - $joinKeys = array($assocData['foreignKey'], $assocData['associationForeignKey']); - list($with, $joinFields) = $model->joinModel($assocData['with'], $joinKeys); - - $joinTbl = $model->{$with}; - $joinAlias = $joinTbl; - - if (is_array($joinFields) && !empty($joinFields)) { - $joinAssoc = $joinAlias = $model->{$with}->alias; - $joinFields = $this->fields($model->{$with}, $joinAlias, $joinFields); - } else { - $joinFields = array(); - } - } else { - $joinTbl = $assocData['joinTable']; - $joinAlias = $this->fullTableName($assocData['joinTable']); - } - $query = array( - 'conditions' => $assocData['conditions'], - 'limit' => $assocData['limit'], - 'table' => $this->fullTableName($linkModel), - 'alias' => $association, - 'fields' => array_merge($this->fields($linkModel, $association, $assocData['fields']), $joinFields), - 'order' => $assocData['order'], - 'group' => null, - 'joins' => array(array( - 'table' => $joinTbl, - 'alias' => $joinAssoc, - 'conditions' => $this->getConstraint('hasAndBelongsToMany', $model, $linkModel, $joinAlias, $assocData, $association) - )) - ); - break; - } - if (isset($query)) { - return $this->buildStatement($query, $model); - } - return null; - } - -/** - * Returns a conditions array for the constraint between two models - * - * @param string $type Association type - * @param Model $model Model object - * @param string $linkModel - * @param string $alias - * @param array $assoc - * @param string $alias2 - * @return array Conditions array defining the constraint between $model and $association - */ - public function getConstraint($type, $model, $linkModel, $alias, $assoc, $alias2 = null) { - $assoc += array('external' => false, 'self' => false); - - if (empty($assoc['foreignKey'])) { - return array(); - } - - switch (true) { - case ($assoc['external'] && $type === 'hasOne'): - return array("{$alias}.{$assoc['foreignKey']}" => '{$__cakeID__$}'); - case ($assoc['external'] && $type === 'belongsTo'): - return array("{$alias}.{$linkModel->primaryKey}" => '{$__cakeForeignKey__$}'); - case (!$assoc['external'] && $type === 'hasOne'): - return array("{$alias}.{$assoc['foreignKey']}" => $this->identifier("{$model->alias}.{$model->primaryKey}")); - case (!$assoc['external'] && $type === 'belongsTo'): - return array("{$model->alias}.{$assoc['foreignKey']}" => $this->identifier("{$alias}.{$linkModel->primaryKey}")); - case ($type === 'hasMany'): - return array("{$alias}.{$assoc['foreignKey']}" => array('{$__cakeID__$}')); - case ($type === 'hasAndBelongsToMany'): - return array( - array("{$alias}.{$assoc['foreignKey']}" => '{$__cakeID__$}'), - array("{$alias}.{$assoc['associationForeignKey']}" => $this->identifier("{$alias2}.{$linkModel->primaryKey}")) - ); - } - return array(); - } - -/** - * Builds and generates a JOIN statement from an array. Handles final clean-up before conversion. - * - * @param array $join An array defining a JOIN statement in a query - * @return string An SQL JOIN statement to be used in a query - * @see DboSource::renderJoinStatement() - * @see DboSource::buildStatement() - */ - public function buildJoinStatement($join) { - $data = array_merge(array( - 'type' => null, - 'alias' => null, - 'table' => 'join_table', - 'conditions' => array() - ), $join); - - if (!empty($data['alias'])) { - $data['alias'] = $this->alias . $this->name($data['alias']); - } - if (!empty($data['conditions'])) { - $data['conditions'] = trim($this->conditions($data['conditions'], true, false)); - } - if (!empty($data['table'])) { - $data['table'] = $this->fullTableName($data['table']); - } - return $this->renderJoinStatement($data); - } - -/** - * Builds and generates an SQL statement from an array. Handles final clean-up before conversion. - * - * @param array $query An array defining an SQL query - * @param Model $model The model object which initiated the query - * @return string An executable SQL statement - * @see DboSource::renderStatement() - */ - public function buildStatement($query, $model) { - $query = array_merge(array('offset' => null, 'joins' => array()), $query); - if (!empty($query['joins'])) { - $count = count($query['joins']); - for ($i = 0; $i < $count; $i++) { - if (is_array($query['joins'][$i])) { - $query['joins'][$i] = $this->buildJoinStatement($query['joins'][$i]); - } - } - } - return $this->renderStatement('select', array( - 'conditions' => $this->conditions($query['conditions'], true, true, $model), - 'fields' => implode(', ', $query['fields']), - 'table' => $query['table'], - 'alias' => $this->alias . $this->name($query['alias']), - 'order' => $this->order($query['order'], 'ASC', $model), - 'limit' => $this->limit($query['limit'], $query['offset']), - 'joins' => implode(' ', $query['joins']), - 'group' => $this->group($query['group'], $model) - )); - } - -/** - * Renders a final SQL JOIN statement - * - * @param array $data - * @return string - */ - public function renderJoinStatement($data) { - extract($data); - return trim("{$type} JOIN {$table} {$alias} ON ({$conditions})"); - } - -/** - * Renders a final SQL statement by putting together the component parts in the correct order - * - * @param string $type type of query being run. e.g select, create, update, delete, schema, alter. - * @param array $data Array of data to insert into the query. - * @return string Rendered SQL expression to be run. - */ - public function renderStatement($type, $data) { - extract($data); - $aliases = null; - - switch (strtolower($type)) { - case 'select': - return "SELECT {$fields} FROM {$table} {$alias} {$joins} {$conditions} {$group} {$order} {$limit}"; - case 'create': - return "INSERT INTO {$table} ({$fields}) VALUES ({$values})"; - case 'update': - if (!empty($alias)) { - $aliases = "{$this->alias}{$alias} {$joins} "; - } - return "UPDATE {$table} {$aliases}SET {$fields} {$conditions}"; - case 'delete': - if (!empty($alias)) { - $aliases = "{$this->alias}{$alias} {$joins} "; - } - return "DELETE {$alias} FROM {$table} {$aliases}{$conditions}"; - case 'schema': - foreach (array('columns', 'indexes', 'tableParameters') as $var) { - if (is_array(${$var})) { - ${$var} = "\t" . join(",\n\t", array_filter(${$var})); - } else { - ${$var} = ''; - } - } - if (trim($indexes) !== '') { - $columns .= ','; - } - return "CREATE TABLE {$table} (\n{$columns}{$indexes}) {$tableParameters};"; - case 'alter': - return; - } - } - -/** - * Merges a mixed set of string/array conditions - * - * @param mixed $query - * @param mixed $assoc - * @return array - */ - protected function _mergeConditions($query, $assoc) { - if (empty($assoc)) { - return $query; - } - - if (is_array($query)) { - return array_merge((array)$assoc, $query); - } - - if (!empty($query)) { - $query = array($query); - if (is_array($assoc)) { - $query = array_merge($query, $assoc); - } else { - $query[] = $assoc; - } - return $query; - } - - return $assoc; - } - -/** - * Generates and executes an SQL UPDATE statement for given model, fields, and values. - * For databases that do not support aliases in UPDATE queries. - * - * @param Model $model - * @param array $fields - * @param array $values - * @param mixed $conditions - * @return boolean Success - */ - public function update(Model $model, $fields = array(), $values = null, $conditions = null) { - if ($values == null) { - $combined = $fields; - } else { - $combined = array_combine($fields, $values); - } - - $fields = implode(', ', $this->_prepareUpdateFields($model, $combined, empty($conditions))); - - $alias = $joins = null; - $table = $this->fullTableName($model); - $conditions = $this->_matchRecords($model, $conditions); - - if ($conditions === false) { - return false; - } - $query = compact('table', 'alias', 'joins', 'fields', 'conditions'); - - if (!$this->execute($this->renderStatement('update', $query))) { - $model->onError(); - return false; - } - return true; - } - -/** - * Quotes and prepares fields and values for an SQL UPDATE statement - * - * @param Model $model - * @param array $fields - * @param boolean $quoteValues If values should be quoted, or treated as SQL snippets - * @param boolean $alias Include the model alias in the field name - * @return array Fields and values, quoted and prepared - */ - protected function _prepareUpdateFields(Model $model, $fields, $quoteValues = true, $alias = false) { - $quotedAlias = $this->startQuote . $model->alias . $this->endQuote; - - $updates = array(); - foreach ($fields as $field => $value) { - if ($alias && strpos($field, '.') === false) { - $quoted = $model->escapeField($field); - } elseif (!$alias && strpos($field, '.') !== false) { - $quoted = $this->name(str_replace($quotedAlias . '.', '', str_replace( - $model->alias . '.', '', $field - ))); - } else { - $quoted = $this->name($field); - } - - if ($value === null) { - $updates[] = $quoted . ' = NULL'; - continue; - } - $update = $quoted . ' = '; - - if ($quoteValues) { - $update .= $this->value($value, $model->getColumnType($field)); - } elseif ($model->getColumnType($field) == 'boolean' && (is_int($value) || is_bool($value))) { - $update .= $this->boolean($value, true); - } elseif (!$alias) { - $update .= str_replace($quotedAlias . '.', '', str_replace( - $model->alias . '.', '', $value - )); - } else { - $update .= $value; - } - $updates[] = $update; - } - return $updates; - } - -/** - * Generates and executes an SQL DELETE statement. - * For databases that do not support aliases in UPDATE queries. - * - * @param Model $model - * @param mixed $conditions - * @return boolean Success - */ - public function delete(Model $model, $conditions = null) { - $alias = $joins = null; - $table = $this->fullTableName($model); - $conditions = $this->_matchRecords($model, $conditions); - - if ($conditions === false) { - return false; - } - - if ($this->execute($this->renderStatement('delete', compact('alias', 'table', 'joins', 'conditions'))) === false) { - $model->onError(); - return false; - } - return true; - } - -/** - * Gets a list of record IDs for the given conditions. Used for multi-record updates and deletes - * in databases that do not support aliases in UPDATE/DELETE queries. - * - * @param Model $model - * @param mixed $conditions - * @return array List of record IDs - */ - protected function _matchRecords(Model $model, $conditions = null) { - if ($conditions === true) { - $conditions = $this->conditions(true); - } elseif ($conditions === null) { - $conditions = $this->conditions($this->defaultConditions($model, $conditions, false), true, true, $model); - } else { - $noJoin = true; - foreach ($conditions as $field => $value) { - $originalField = $field; - if (strpos($field, '.') !== false) { - list($alias, $field) = explode('.', $field); - $field = ltrim($field, $this->startQuote); - $field = rtrim($field, $this->endQuote); - } - if (!$model->hasField($field)) { - $noJoin = false; - break; - } - if ($field !== $originalField) { - $conditions[$field] = $value; - unset($conditions[$originalField]); - } - } - if ($noJoin === true) { - return $this->conditions($conditions); - } - $idList = $model->find('all', array( - 'fields' => "{$model->alias}.{$model->primaryKey}", - 'conditions' => $conditions - )); - - if (empty($idList)) { - return false; - } - $conditions = $this->conditions(array( - $model->primaryKey => Set::extract($idList, "{n}.{$model->alias}.{$model->primaryKey}") - )); - } - return $conditions; - } - -/** - * Returns an array of SQL JOIN fragments from a model's associations - * - * @param Model $model - * @return array - */ - protected function _getJoins(Model $model) { - $join = array(); - $joins = array_merge($model->getAssociated('hasOne'), $model->getAssociated('belongsTo')); - - foreach ($joins as $assoc) { - if (isset($model->{$assoc}) && $model->useDbConfig == $model->{$assoc}->useDbConfig && $model->{$assoc}->getDataSource()) { - $assocData = $model->getAssociated($assoc); - $join[] = $this->buildJoinStatement(array( - 'table' => $model->{$assoc}, - 'alias' => $assoc, - 'type' => isset($assocData['type']) ? $assocData['type'] : 'LEFT', - 'conditions' => trim($this->conditions( - $this->_mergeConditions($assocData['conditions'], $this->getConstraint($assocData['association'], $model, $model->{$assoc}, $assoc, $assocData)), - true, false, $model - )) - )); - } - } - return $join; - } - -/** - * Returns an SQL calculation, i.e. COUNT() or MAX() - * - * @param Model $model - * @param string $func Lowercase name of SQL function, i.e. 'count' or 'max' - * @param array $params Function parameters (any values must be quoted manually) - * @return string An SQL calculation function - */ - public function calculate(Model $model, $func, $params = array()) { - $params = (array)$params; - - switch (strtolower($func)) { - case 'count': - if (!isset($params[0])) { - $params[0] = '*'; - } - if (!isset($params[1])) { - $params[1] = 'count'; - } - if (is_object($model) && $model->isVirtualField($params[0])) { - $arg = $this->_quoteFields($model->getVirtualField($params[0])); - } else { - $arg = $this->name($params[0]); - } - return 'COUNT(' . $arg . ') AS ' . $this->name($params[1]); - case 'max': - case 'min': - if (!isset($params[1])) { - $params[1] = $params[0]; - } - if (is_object($model) && $model->isVirtualField($params[0])) { - $arg = $this->_quoteFields($model->getVirtualField($params[0])); - } else { - $arg = $this->name($params[0]); - } - return strtoupper($func) . '(' . $arg . ') AS ' . $this->name($params[1]); - break; - } - } - -/** - * Deletes all the records in a table and resets the count of the auto-incrementing - * primary key, where applicable. - * - * @param mixed $table A string or model class representing the table to be truncated - * @return boolean SQL TRUNCATE TABLE statement, false if not applicable. - */ - public function truncate($table) { - return $this->execute('TRUNCATE TABLE ' . $this->fullTableName($table)); - } - -/** - * Begin a transaction - * - * @return boolean True on success, false on fail - * (i.e. if the database/model does not support transactions, - * or a transaction has not started). - */ - public function begin() { - if ($this->_transactionStarted || $this->_connection->beginTransaction()) { - if ($this->fullDebug && empty($this->_transactionNesting)) { - $this->logQuery('BEGIN'); - } - $this->_transactionStarted = true; - $this->_transactionNesting++; - return true; - } - return false; - } - -/** - * Commit a transaction - * - * @return boolean True on success, false on fail - * (i.e. if the database/model does not support transactions, - * or a transaction has not started). - */ - public function commit() { - if ($this->_transactionStarted) { - $this->_transactionNesting--; - if ($this->_transactionNesting <= 0) { - $this->_transactionStarted = false; - $this->_transactionNesting = 0; - if ($this->fullDebug) { - $this->logQuery('COMMIT'); - } - return $this->_connection->commit(); - } - return true; - } - return false; - } - -/** - * Rollback a transaction - * - * @return boolean True on success, false on fail - * (i.e. if the database/model does not support transactions, - * or a transaction has not started). - */ - public function rollback() { - if ($this->_transactionStarted && $this->_connection->rollBack()) { - if ($this->fullDebug) { - $this->logQuery('ROLLBACK'); - } - $this->_transactionStarted = false; - $this->_transactionNesting = 0; - return true; - } - return false; - } - -/** - * Returns the ID generated from the previous INSERT operation. - * - * @param mixed $source - * @return mixed - */ - public function lastInsertId($source = null) { - return $this->_connection->lastInsertId(); - } - -/** - * Creates a default set of conditions from the model if $conditions is null/empty. - * If conditions are supplied then they will be returned. If a model doesn't exist and no conditions - * were provided either null or false will be returned based on what was input. - * - * @param Model $model - * @param mixed $conditions Array of conditions, conditions string, null or false. If an array of conditions, - * or string conditions those conditions will be returned. With other values the model's existence will be checked. - * If the model doesn't exist a null or false will be returned depending on the input value. - * @param boolean $useAlias Use model aliases rather than table names when generating conditions - * @return mixed Either null, false, $conditions or an array of default conditions to use. - * @see DboSource::update() - * @see DboSource::conditions() - */ - public function defaultConditions(Model $model, $conditions, $useAlias = true) { - if (!empty($conditions)) { - return $conditions; - } - $exists = $model->exists(); - if (!$exists && $conditions !== null) { - return false; - } elseif (!$exists) { - return null; - } - $alias = $model->alias; - - if (!$useAlias) { - $alias = $this->fullTableName($model, false); - } - return array("{$alias}.{$model->primaryKey}" => $model->getID()); - } - -/** - * Returns a key formatted like a string Model.fieldname(i.e. Post.title, or Country.name) - * - * @param Model $model - * @param string $key - * @param string $assoc - * @return string - */ - public function resolveKey(Model $model, $key, $assoc = null) { - if (strpos('.', $key) !== false) { - return $this->name($model->alias) . '.' . $this->name($key); - } - return $key; - } - -/** - * Private helper method to remove query metadata in given data array. - * - * @param array $data - * @return array - */ - protected function _scrubQueryData($data) { - static $base = null; - if ($base === null) { - $base = array_fill_keys(array('conditions', 'fields', 'joins', 'order', 'limit', 'offset', 'group'), array()); - $base['callbacks'] = null; - } - return (array)$data + $base; - } - -/** - * Converts model virtual fields into sql expressions to be fetched later - * - * @param Model $model - * @param string $alias Alias table name - * @param mixed $fields virtual fields to be used on query - * @return array - */ - protected function _constructVirtualFields(Model $model, $alias, $fields) { - $virtual = array(); - foreach ($fields as $field) { - $virtualField = $this->name($alias . $this->virtualFieldSeparator . $field); - $expression = $this->_quoteFields($model->getVirtualField($field)); - $virtual[] = '(' . $expression . ") {$this->alias} {$virtualField}"; - } - return $virtual; - } - -/** - * Generates the fields list of an SQL query. - * - * @param Model $model - * @param string $alias Alias table name - * @param mixed $fields - * @param boolean $quote If false, returns fields array unquoted - * @return array - */ - public function fields(Model $model, $alias = null, $fields = array(), $quote = true) { - if (empty($alias)) { - $alias = $model->alias; - } - $virtualFields = $model->getVirtualField(); - $cacheKey = array( - $alias, - get_class($model), - $model->alias, - $virtualFields, - $fields, - $quote - ); - $cacheKey = md5(serialize($cacheKey)); - if ($return = $this->cacheMethod(__FUNCTION__, $cacheKey)) { - return $return; - } - $allFields = empty($fields); - if ($allFields) { - $fields = array_keys($model->schema()); - } elseif (!is_array($fields)) { - $fields = String::tokenize($fields); - } - $fields = array_values(array_filter($fields)); - $allFields = $allFields || in_array('*', $fields) || in_array($model->alias . '.*', $fields); - - $virtual = array(); - if (!empty($virtualFields)) { - $virtualKeys = array_keys($virtualFields); - foreach ($virtualKeys as $field) { - $virtualKeys[] = $model->alias . '.' . $field; - } - $virtual = ($allFields) ? $virtualKeys : array_intersect($virtualKeys, $fields); - foreach ($virtual as $i => $field) { - if (strpos($field, '.') !== false) { - $virtual[$i] = str_replace($model->alias . '.', '', $field); - } - $fields = array_diff($fields, array($field)); - } - $fields = array_values($fields); - } - - if (!$quote) { - if (!empty($virtual)) { - $fields = array_merge($fields, $this->_constructVirtualFields($model, $alias, $virtual)); - } - return $fields; - } - $count = count($fields); - - if ($count >= 1 && !in_array($fields[0], array('*', 'COUNT(*)'))) { - for ($i = 0; $i < $count; $i++) { - if (is_string($fields[$i]) && in_array($fields[$i], $virtual)) { - unset($fields[$i]); - continue; - } - if (is_object($fields[$i]) && isset($fields[$i]->type) && $fields[$i]->type === 'expression') { - $fields[$i] = $fields[$i]->value; - } elseif (preg_match('/^\(.*\)\s' . $this->alias . '.*/i', $fields[$i])) { - continue; - } elseif (!preg_match('/^.+\\(.*\\)/', $fields[$i])) { - $prepend = ''; - - if (strpos($fields[$i], 'DISTINCT') !== false) { - $prepend = 'DISTINCT '; - $fields[$i] = trim(str_replace('DISTINCT', '', $fields[$i])); - } - $dot = strpos($fields[$i], '.'); - - if ($dot === false) { - $prefix = !( - strpos($fields[$i], ' ') !== false || - strpos($fields[$i], '(') !== false - ); - $fields[$i] = $this->name(($prefix ? $alias . '.' : '') . $fields[$i]); - } else { - if (strpos($fields[$i], ',') === false) { - $build = explode('.', $fields[$i]); - if (!Set::numeric($build)) { - $fields[$i] = $this->name(implode('.', $build)); - } - } - } - $fields[$i] = $prepend . $fields[$i]; - } elseif (preg_match('/\(([\.\w]+)\)/', $fields[$i], $field)) { - if (isset($field[1])) { - if (strpos($field[1], '.') === false) { - $field[1] = $this->name($alias . '.' . $field[1]); - } else { - $field[0] = explode('.', $field[1]); - if (!Set::numeric($field[0])) { - $field[0] = implode('.', array_map(array(&$this, 'name'), $field[0])); - $fields[$i] = preg_replace('/\(' . $field[1] . '\)/', '(' . $field[0] . ')', $fields[$i], 1); - } - } - } - } - } - } - if (!empty($virtual)) { - $fields = array_merge($fields, $this->_constructVirtualFields($model, $alias, $virtual)); - } - return $this->cacheMethod(__FUNCTION__, $cacheKey, array_unique($fields)); - } - -/** - * Creates a WHERE clause by parsing given conditions data. If an array or string - * conditions are provided those conditions will be parsed and quoted. If a boolean - * is given it will be integer cast as condition. Null will return 1 = 1. - * - * Results of this method are stored in a memory cache. This improves performance, but - * because the method uses a simple hashing algorithm it can infrequently have collisions. - * Setting DboSource::$cacheMethods to false will disable the memory cache. - * - * @param mixed $conditions Array or string of conditions, or any value. - * @param boolean $quoteValues If true, values should be quoted - * @param boolean $where If true, "WHERE " will be prepended to the return value - * @param Model $model A reference to the Model instance making the query - * @return string SQL fragment - */ - public function conditions($conditions, $quoteValues = true, $where = true, $model = null) { - $clause = $out = ''; - - if ($where) { - $clause = ' WHERE '; - } - - if (is_array($conditions) && !empty($conditions)) { - $out = $this->conditionKeysToString($conditions, $quoteValues, $model); - - if (empty($out)) { - return $clause . ' 1 = 1'; - } - return $clause . implode(' AND ', $out); - } - if (is_bool($conditions)) { - return $clause . (int)$conditions . ' = 1'; - } - - if (empty($conditions) || trim($conditions) === '') { - return $clause . '1 = 1'; - } - $clauses = '/^WHERE\\x20|^GROUP\\x20BY\\x20|^HAVING\\x20|^ORDER\\x20BY\\x20/i'; - - if (preg_match($clauses, $conditions, $match)) { - $clause = ''; - } - $conditions = $this->_quoteFields($conditions); - return $clause . $conditions; - } - -/** - * Creates a WHERE clause by parsing given conditions array. Used by DboSource::conditions(). - * - * @param array $conditions Array or string of conditions - * @param boolean $quoteValues If true, values should be quoted - * @param Model $model A reference to the Model instance making the query - * @return string SQL fragment - */ - public function conditionKeysToString($conditions, $quoteValues = true, $model = null) { - $out = array(); - $data = $columnType = null; - $bool = array('and', 'or', 'not', 'and not', 'or not', 'xor', '||', '&&'); - - foreach ($conditions as $key => $value) { - $join = ' AND '; - $not = null; - - if (is_array($value)) { - $valueInsert = ( - !empty($value) && - (substr_count($key, '?') === count($value) || substr_count($key, ':') === count($value)) - ); - } - - if (is_numeric($key) && empty($value)) { - continue; - } elseif (is_numeric($key) && is_string($value)) { - $out[] = $not . $this->_quoteFields($value); - } elseif ((is_numeric($key) && is_array($value)) || in_array(strtolower(trim($key)), $bool)) { - if (in_array(strtolower(trim($key)), $bool)) { - $join = ' ' . strtoupper($key) . ' '; - } else { - $key = $join; - } - $value = $this->conditionKeysToString($value, $quoteValues, $model); - - if (strpos($join, 'NOT') !== false) { - if (strtoupper(trim($key)) === 'NOT') { - $key = 'AND ' . trim($key); - } - $not = 'NOT '; - } - - if (empty($value[1])) { - if ($not) { - $out[] = $not . '(' . $value[0] . ')'; - } else { - $out[] = $value[0]; - } - } else { - $out[] = '(' . $not . '(' . implode(') ' . strtoupper($key) . ' (', $value) . '))'; - } - } else { - if (is_object($value) && isset($value->type)) { - if ($value->type === 'identifier') { - $data .= $this->name($key) . ' = ' . $this->name($value->value); - } elseif ($value->type === 'expression') { - if (is_numeric($key)) { - $data .= $value->value; - } else { - $data .= $this->name($key) . ' = ' . $value->value; - } - } - } elseif (is_array($value) && !empty($value) && !$valueInsert) { - $keys = array_keys($value); - if ($keys === array_values($keys)) { - $count = count($value); - if ($count === 1 && !preg_match("/\s+NOT$/", $key)) { - $data = $this->_quoteFields($key) . ' = ('; - } else { - $data = $this->_quoteFields($key) . ' IN ('; - } - if ($quoteValues) { - if (is_object($model)) { - $columnType = $model->getColumnType($key); - } - $data .= implode(', ', $this->value($value, $columnType)); - } - $data .= ')'; - } else { - $ret = $this->conditionKeysToString($value, $quoteValues, $model); - if (count($ret) > 1) { - $data = '(' . implode(') AND (', $ret) . ')'; - } elseif (isset($ret[0])) { - $data = $ret[0]; - } - } - } elseif (is_numeric($key) && !empty($value)) { - $data = $this->_quoteFields($value); - } else { - $data = $this->_parseKey($model, trim($key), $value); - } - - if ($data != null) { - $out[] = $data; - $data = null; - } - } - } - return $out; - } - -/** - * Extracts a Model.field identifier and an SQL condition operator from a string, formats - * and inserts values, and composes them into an SQL snippet. - * - * @param Model $model Model object initiating the query - * @param string $key An SQL key snippet containing a field and optional SQL operator - * @param mixed $value The value(s) to be inserted in the string - * @return string - */ - protected function _parseKey($model, $key, $value) { - $operatorMatch = '/^(((' . implode(')|(', $this->_sqlOps); - $operatorMatch .= ')\\x20?)|<[>=]?(?![^>]+>)\\x20?|[>=!]{1,3}(?!<)\\x20?)/is'; - $bound = (strpos($key, '?') !== false || (is_array($value) && strpos($key, ':') !== false)); - - if (strpos($key, ' ') === false) { - $operator = '='; - } else { - list($key, $operator) = explode(' ', trim($key), 2); - - if (!preg_match($operatorMatch, trim($operator)) && strpos($operator, ' ') !== false) { - $key = $key . ' ' . $operator; - $split = strrpos($key, ' '); - $operator = substr($key, $split); - $key = substr($key, 0, $split); - } - } - - $virtual = false; - if (is_object($model) && $model->isVirtualField($key)) { - $key = $this->_quoteFields($model->getVirtualField($key)); - $virtual = true; - } - - $type = is_object($model) ? $model->getColumnType($key) : null; - $null = $value === null || (is_array($value) && empty($value)); - - if (strtolower($operator) === 'not') { - $data = $this->conditionKeysToString( - array($operator => array($key => $value)), true, $model - ); - return $data[0]; - } - - $value = $this->value($value, $type); - - if (!$virtual && $key !== '?') { - $isKey = (strpos($key, '(') !== false || strpos($key, ')') !== false); - $key = $isKey ? $this->_quoteFields($key) : $this->name($key); - } - - if ($bound) { - return String::insert($key . ' ' . trim($operator), $value); - } - - if (!preg_match($operatorMatch, trim($operator))) { - $operator .= ' ='; - } - $operator = trim($operator); - - if (is_array($value)) { - $value = implode(', ', $value); - - switch ($operator) { - case '=': - $operator = 'IN'; - break; - case '!=': - case '<>': - $operator = 'NOT IN'; - break; - } - $value = "({$value})"; - } elseif ($null || $value === 'NULL') { - switch ($operator) { - case '=': - $operator = 'IS'; - break; - case '!=': - case '<>': - $operator = 'IS NOT'; - break; - } - } - if ($virtual) { - return "({$key}) {$operator} {$value}"; - } - return "{$key} {$operator} {$value}"; - } - -/** - * Quotes Model.fields - * - * @param string $conditions - * @return string or false if no match - */ - protected function _quoteFields($conditions) { - $start = $end = null; - $original = $conditions; - - if (!empty($this->startQuote)) { - $start = preg_quote($this->startQuote); - } - if (!empty($this->endQuote)) { - $end = preg_quote($this->endQuote); - } - $conditions = str_replace(array($start, $end), '', $conditions); - $conditions = preg_replace_callback('/(?:[\'\"][^\'\"\\\]*(?:\\\.[^\'\"\\\]*)*[\'\"])|([a-z0-9_' . $start . $end . ']*\\.[a-z0-9_' . $start . $end . ']*)/i', array(&$this, '_quoteMatchedField'), $conditions); - - if ($conditions !== null) { - return $conditions; - } - return $original; - } - -/** - * Auxiliary function to quote matches `Model.fields` from a preg_replace_callback call - * - * @param string $match matched string - * @return string quoted string - */ - protected function _quoteMatchedField($match) { - if (is_numeric($match[0])) { - return $match[0]; - } - return $this->name($match[0]); - } - -/** - * Returns a limit statement in the correct format for the particular database. - * - * @param integer $limit Limit of results returned - * @param integer $offset Offset from which to start results - * @return string SQL limit/offset statement - */ - public function limit($limit, $offset = null) { - if ($limit) { - $rt = ''; - if (!strpos(strtolower($limit), 'limit')) { - $rt = ' LIMIT'; - } - - if ($offset) { - $rt .= ' ' . $offset . ','; - } - - $rt .= ' ' . $limit; - return $rt; - } - return null; - } - -/** - * Returns an ORDER BY clause as a string. - * - * @param array|string $keys Field reference, as a key (i.e. Post.title) - * @param string $direction Direction (ASC or DESC) - * @param Model $model model reference (used to look for virtual field) - * @return string ORDER BY clause - */ - public function order($keys, $direction = 'ASC', $model = null) { - if (!is_array($keys)) { - $keys = array($keys); - } - $keys = array_filter($keys); - $result = array(); - while (!empty($keys)) { - list($key, $dir) = each($keys); - array_shift($keys); - - if (is_numeric($key)) { - $key = $dir; - $dir = $direction; - } - - if (is_string($key) && strpos($key, ',') !== false && !preg_match('/\(.+\,.+\)/', $key)) { - $key = array_map('trim', explode(',', $key)); - } - if (is_array($key)) { - //Flatten the array - $key = array_reverse($key, true); - foreach ($key as $k => $v) { - if (is_numeric($k)) { - array_unshift($keys, $v); - } else { - $keys = array($k => $v) + $keys; - } - } - continue; - } elseif (is_object($key) && isset($key->type) && $key->type === 'expression') { - $result[] = $key->value; - continue; - } - - if (preg_match('/\\x20(ASC|DESC).*/i', $key, $_dir)) { - $dir = $_dir[0]; - $key = preg_replace('/\\x20(ASC|DESC).*/i', '', $key); - } - - $key = trim($key); - - if (is_object($model) && $model->isVirtualField($key)) { - $key = '(' . $this->_quoteFields($model->getVirtualField($key)) . ')'; - } - list($alias, $field) = pluginSplit($key); - if (is_object($model) && $alias !== $model->alias && is_object($model->{$alias}) && $model->{$alias}->isVirtualField($key)) { - $key = '(' . $this->_quoteFields($model->{$alias}->getVirtualField($key)) . ')'; - } - - if (strpos($key, '.')) { - $key = preg_replace_callback('/([a-zA-Z0-9_-]{1,})\\.([a-zA-Z0-9_-]{1,})/', array(&$this, '_quoteMatchedField'), $key); - } - if (!preg_match('/\s/', $key) && strpos($key, '.') === false) { - $key = $this->name($key); - } - $key .= ' ' . trim($dir); - $result[] = $key; - } - if (!empty($result)) { - return ' ORDER BY ' . implode(', ', $result); - } - return ''; - } - -/** - * Create a GROUP BY SQL clause - * - * @param string $group Group By Condition - * @param Model $model - * @return string string condition or null - */ - public function group($group, $model = null) { - if ($group) { - if (!is_array($group)) { - $group = array($group); - } - foreach ($group as $index => $key) { - if (is_object($model) && $model->isVirtualField($key)) { - $group[$index] = '(' . $model->getVirtualField($key) . ')'; - } - } - $group = implode(', ', $group); - return ' GROUP BY ' . $this->_quoteFields($group); - } - return null; - } - -/** - * Disconnects database, kills the connection and says the connection is closed. - * - * @return void - */ - public function close() { - $this->disconnect(); - } - -/** - * Checks if the specified table contains any record matching specified SQL - * - * @param Model $Model Model to search - * @param string $sql SQL WHERE clause (condition only, not the "WHERE" part) - * @return boolean True if the table has a matching record, else false - */ - public function hasAny(Model $Model, $sql) { - $sql = $this->conditions($sql); - $table = $this->fullTableName($Model); - $alias = $this->alias . $this->name($Model->alias); - $where = $sql ? "{$sql}" : ' WHERE 1 = 1'; - $id = $Model->escapeField(); - - $out = $this->fetchRow("SELECT COUNT({$id}) {$this->alias}count FROM {$table} {$alias}{$where}"); - - if (is_array($out)) { - return $out[0]['count']; - } - return false; - } - -/** - * Gets the length of a database-native column description, or null if no length - * - * @param string $real Real database-layer column type (i.e. "varchar(255)") - * @return mixed An integer or string representing the length of the column, or null for unknown length. - */ - public function length($real) { - if (!preg_match_all('/([\w\s]+)(?:\((\d+)(?:,(\d+))?\))?(\sunsigned)?(\szerofill)?/', $real, $result)) { - $col = str_replace(array(')', 'unsigned'), '', $real); - $limit = null; - - if (strpos($col, '(') !== false) { - list($col, $limit) = explode('(', $col); - } - if ($limit !== null) { - return intval($limit); - } - return null; - } - - $types = array( - 'int' => 1, 'tinyint' => 1, 'smallint' => 1, 'mediumint' => 1, 'integer' => 1, 'bigint' => 1 - ); - - list($real, $type, $length, $offset, $sign, $zerofill) = $result; - $typeArr = $type; - $type = $type[0]; - $length = $length[0]; - $offset = $offset[0]; - - $isFloat = in_array($type, array('dec', 'decimal', 'float', 'numeric', 'double')); - if ($isFloat && $offset) { - return $length . ',' . $offset; - } - - if (($real[0] == $type) && (count($real) === 1)) { - return null; - } - - if (isset($types[$type])) { - $length += $types[$type]; - if (!empty($sign)) { - $length--; - } - } elseif (in_array($type, array('enum', 'set'))) { - $length = 0; - foreach ($typeArr as $key => $enumValue) { - if ($key === 0) { - continue; - } - $tmpLength = strlen($enumValue); - if ($tmpLength > $length) { - $length = $tmpLength; - } - } - } - return intval($length); - } - -/** - * Translates between PHP boolean values and Database (faked) boolean values - * - * @param mixed $data Value to be translated - * @param boolean $quote - * @return string|boolean Converted boolean value - */ - public function boolean($data, $quote = false) { - if ($quote) { - return !empty($data) ? '1' : '0'; - } - return !empty($data); - } - -/** - * Inserts multiple values into a table - * - * @param string $table The table being inserted into. - * @param array $fields The array of field/column names being inserted. - * @param array $values The array of values to insert. The values should - * be an array of rows. Each row should have values keyed by the column name. - * Each row must have the values in the same order as $fields. - * @return boolean - */ - public function insertMulti($table, $fields, $values) { - $table = $this->fullTableName($table); - $holder = implode(',', array_fill(0, count($fields), '?')); - $fields = implode(', ', array_map(array(&$this, 'name'), $fields)); - - $pdoMap = array( - 'integer' => PDO::PARAM_INT, - 'float' => PDO::PARAM_STR, - 'boolean' => PDO::PARAM_BOOL, - 'string' => PDO::PARAM_STR, - 'text' => PDO::PARAM_STR - ); - $columnMap = array(); - - $sql = "INSERT INTO {$table} ({$fields}) VALUES ({$holder})"; - $statement = $this->_connection->prepare($sql); - $this->begin(); - - foreach ($values[key($values)] as $key => $val) { - $type = $this->introspectType($val); - $columnMap[$key] = $pdoMap[$type]; - } - - foreach ($values as $row => $value) { - $i = 1; - foreach ($value as $col => $val) { - $statement->bindValue($i, $val, $columnMap[$col]); - $i += 1; - } - $statement->execute(); - $statement->closeCursor(); - } - return $this->commit(); - } - -/** - * Returns an array of the indexes in given datasource name. - * - * @param string $model Name of model to inspect - * @return array Fields in table. Keys are column and unique - */ - public function index($model) { - return false; - } - -/** - * Generate a database-native schema for the given Schema object - * - * @param Model $schema An instance of a subclass of CakeSchema - * @param string $tableName Optional. If specified only the table name given will be generated. - * Otherwise, all tables defined in the schema are generated. - * @return string - */ - public function createSchema($schema, $tableName = null) { - if (!is_a($schema, 'CakeSchema')) { - trigger_error(__d('cake_dev', 'Invalid schema object'), E_USER_WARNING); - return null; - } - $out = ''; - - foreach ($schema->tables as $curTable => $columns) { - if (!$tableName || $tableName == $curTable) { - $cols = $colList = $indexes = $tableParameters = array(); - $primary = null; - $table = $this->fullTableName($curTable); - - foreach ($columns as $name => $col) { - if (is_string($col)) { - $col = array('type' => $col); - } - if (isset($col['key']) && $col['key'] === 'primary') { - $primary = $name; - } - if ($name !== 'indexes' && $name !== 'tableParameters') { - $col['name'] = $name; - if (!isset($col['type'])) { - $col['type'] = 'string'; - } - $cols[] = $this->buildColumn($col); - } elseif ($name === 'indexes') { - $indexes = array_merge($indexes, $this->buildIndex($col, $table)); - } elseif ($name === 'tableParameters') { - $tableParameters = array_merge($tableParameters, $this->buildTableParameters($col, $table)); - } - } - if (empty($indexes) && !empty($primary)) { - $col = array('PRIMARY' => array('column' => $primary, 'unique' => 1)); - $indexes = array_merge($indexes, $this->buildIndex($col, $table)); - } - $columns = $cols; - $out .= $this->renderStatement('schema', compact('table', 'columns', 'indexes', 'tableParameters')) . "\n\n"; - } - } - return $out; - } - -/** - * Generate a alter syntax from CakeSchema::compare() - * - * @param mixed $compare - * @param string $table - * @return boolean - */ - public function alterSchema($compare, $table = null) { - return false; - } - -/** - * Generate a "drop table" statement for the given Schema object - * - * @param CakeSchema $schema An instance of a subclass of CakeSchema - * @param string $table Optional. If specified only the table name given will be generated. - * Otherwise, all tables defined in the schema are generated. - * @return string - */ - public function dropSchema(CakeSchema $schema, $table = null) { - $out = ''; - - foreach ($schema->tables as $curTable => $columns) { - if (!$table || $table == $curTable) { - $out .= 'DROP TABLE ' . $this->fullTableName($curTable) . ";\n"; - } - } - return $out; - } - -/** - * Generate a database-native column schema string - * - * @param array $column An array structured like the following: array('name' => 'value', 'type' => 'value'[, options]), - * where options can be 'default', 'length', or 'key'. - * @return string - */ - public function buildColumn($column) { - $name = $type = null; - extract(array_merge(array('null' => true), $column)); - - if (empty($name) || empty($type)) { - trigger_error(__d('cake_dev', 'Column name or type not defined in schema'), E_USER_WARNING); - return null; - } - - if (!isset($this->columns[$type])) { - trigger_error(__d('cake_dev', 'Column type %s does not exist', $type), E_USER_WARNING); - return null; - } - - $real = $this->columns[$type]; - $out = $this->name($name) . ' ' . $real['name']; - - if (isset($column['length'])) { - $length = $column['length']; - } elseif (isset($column['limit'])) { - $length = $column['limit']; - } elseif (isset($real['length'])) { - $length = $real['length']; - } elseif (isset($real['limit'])) { - $length = $real['limit']; - } - if (isset($length)) { - $out .= '(' . $length . ')'; - } - - if (($column['type'] === 'integer' || $column['type'] === 'float') && isset($column['default']) && $column['default'] === '') { - $column['default'] = null; - } - $out = $this->_buildFieldParameters($out, $column, 'beforeDefault'); - - if (isset($column['key']) && $column['key'] === 'primary' && $type === 'integer') { - $out .= ' ' . $this->columns['primary_key']['name']; - } elseif (isset($column['key']) && $column['key'] === 'primary') { - $out .= ' NOT NULL'; - } elseif (isset($column['default']) && isset($column['null']) && $column['null'] === false) { - $out .= ' DEFAULT ' . $this->value($column['default'], $type) . ' NOT NULL'; - } elseif (isset($column['default'])) { - $out .= ' DEFAULT ' . $this->value($column['default'], $type); - } elseif ($type !== 'timestamp' && !empty($column['null'])) { - $out .= ' DEFAULT NULL'; - } elseif ($type === 'timestamp' && !empty($column['null'])) { - $out .= ' NULL'; - } elseif (isset($column['null']) && $column['null'] === false) { - $out .= ' NOT NULL'; - } - if ($type === 'timestamp' && isset($column['default']) && strtolower($column['default']) === 'current_timestamp') { - $out = str_replace(array("'CURRENT_TIMESTAMP'", "'current_timestamp'"), 'CURRENT_TIMESTAMP', $out); - } - return $this->_buildFieldParameters($out, $column, 'afterDefault'); - } - -/** - * Build the field parameters, in a position - * - * @param string $columnString The partially built column string - * @param array $columnData The array of column data. - * @param string $position The position type to use. 'beforeDefault' or 'afterDefault' are common - * @return string a built column with the field parameters added. - */ - protected function _buildFieldParameters($columnString, $columnData, $position) { - foreach ($this->fieldParameters as $paramName => $value) { - if (isset($columnData[$paramName]) && $value['position'] == $position) { - if (isset($value['options']) && !in_array($columnData[$paramName], $value['options'])) { - continue; - } - $val = $columnData[$paramName]; - if ($value['quote']) { - $val = $this->value($val); - } - $columnString .= ' ' . $value['value'] . $value['join'] . $val; - } - } - return $columnString; - } - -/** - * Format indexes for create table - * - * @param array $indexes - * @param string $table - * @return array - */ - public function buildIndex($indexes, $table = null) { - $join = array(); - foreach ($indexes as $name => $value) { - $out = ''; - if ($name === 'PRIMARY') { - $out .= 'PRIMARY '; - $name = null; - } else { - if (!empty($value['unique'])) { - $out .= 'UNIQUE '; - } - $name = $this->startQuote . $name . $this->endQuote; - } - if (is_array($value['column'])) { - $out .= 'KEY ' . $name . ' (' . implode(', ', array_map(array(&$this, 'name'), $value['column'])) . ')'; - } else { - $out .= 'KEY ' . $name . ' (' . $this->name($value['column']) . ')'; - } - $join[] = $out; - } - return $join; - } - -/** - * Read additional table parameters - * - * @param string $name - * @return array - */ - public function readTableParameters($name) { - $parameters = array(); - if (method_exists($this, 'listDetailedSources')) { - $currentTableDetails = $this->listDetailedSources($name); - foreach ($this->tableParameters as $paramName => $parameter) { - if (!empty($parameter['column']) && !empty($currentTableDetails[$parameter['column']])) { - $parameters[$paramName] = $currentTableDetails[$parameter['column']]; - } - } - } - return $parameters; - } - -/** - * Format parameters for create table - * - * @param array $parameters - * @param string $table - * @return array - */ - public function buildTableParameters($parameters, $table = null) { - $result = array(); - foreach ($parameters as $name => $value) { - if (isset($this->tableParameters[$name])) { - if ($this->tableParameters[$name]['quote']) { - $value = $this->value($value); - } - $result[] = $this->tableParameters[$name]['value'] . $this->tableParameters[$name]['join'] . $value; - } - } - return $result; - } - -/** - * Guesses the data type of an array - * - * @param string $value - * @return void - */ - public function introspectType($value) { - if (!is_array($value)) { - if (is_bool($value)) { - return 'boolean'; - } - if (is_float($value) && floatval($value) === $value) { - return 'float'; - } - if (is_int($value) && intval($value) === $value) { - return 'integer'; - } - if (is_string($value) && strlen($value) > 255) { - return 'text'; - } - return 'string'; - } - - $isAllFloat = $isAllInt = true; - $containsFloat = $containsInt = $containsString = false; - foreach ($value as $key => $valElement) { - $valElement = trim($valElement); - if (!is_float($valElement) && !preg_match('/^[\d]+\.[\d]+$/', $valElement)) { - $isAllFloat = false; - } else { - $containsFloat = true; - continue; - } - if (!is_int($valElement) && !preg_match('/^[\d]+$/', $valElement)) { - $isAllInt = false; - } else { - $containsInt = true; - continue; - } - $containsString = true; - } - - if ($isAllFloat) { - return 'float'; - } - if ($isAllInt) { - return 'integer'; - } - - if ($containsInt && !$containsString) { - return 'integer'; - } - return 'string'; - } - -/** - * Writes a new key for the in memory sql query cache - * - * @param string $sql SQL query - * @param mixed $data result of $sql query - * @param array $params query params bound as values - * @return void - */ - protected function _writeQueryCache($sql, $data, $params = array()) { - if (preg_match('/^\s*select/i', $sql)) { - $this->_queryCache[$sql][serialize($params)] = $data; - } - } - -/** - * Returns the result for a sql query if it is already cached - * - * @param string $sql SQL query - * @param array $params query params bound as values - * @return mixed results for query if it is cached, false otherwise - */ - public function getQueryCache($sql, $params = array()) { - if (isset($this->_queryCache[$sql]) && preg_match('/^\s*select/i', $sql)) { - $serialized = serialize($params); - if (isset($this->_queryCache[$sql][$serialized])) { - return $this->_queryCache[$sql][$serialized]; - } - } - return false; - } - -/** - * Used for storing in cache the results of the in-memory methodCache - * - */ - public function __destruct() { - if ($this->_methodCacheChange) { - Cache::write('method_cache', self::$methodCache, '_cake_core_'); - } - } - -} diff --git a/lib/Cake/Model/Datasource/Session/CacheSession.php b/lib/Cake/Model/Datasource/Session/CacheSession.php deleted file mode 100644 index 711b01bab71..00000000000 --- a/lib/Cake/Model/Datasource/Session/CacheSession.php +++ /dev/null @@ -1,103 +0,0 @@ - 'Session', - 'alias' => 'Session', - 'table' => 'cake_sessions', - ); - } else { - $settings = array( - 'class' => $modelName, - 'alias' => 'Session', - ); - } - $this->_model = ClassRegistry::init($settings); - $this->_timeout = Configure::read('Session.timeout') * 60; - } - -/** - * Method called on open of a database session. - * - * @return boolean Success - */ - public function open() { - return true; - } - -/** - * Method called on close of a database session. - * - * @return boolean Success - */ - public function close() { - $probability = mt_rand(1, 150); - if ($probability <= 3) { - $this->gc(); - } - return true; - } - -/** - * Method used to read from a database session. - * - * @param mixed $id The key of the value to read - * @return mixed The value of the key or false if it does not exist - */ - public function read($id) { - $row = $this->_model->find('first', array( - 'conditions' => array($this->_model->primaryKey => $id) - )); - - if (empty($row[$this->_model->alias]['data'])) { - return false; - } - - return $row[$this->_model->alias]['data']; - } - -/** - * Helper function called on write for database sessions. - * - * @param integer $id ID that uniquely identifies session in database - * @param mixed $data The value of the data to be saved. - * @return boolean True for successful write, false otherwise. - */ - public function write($id, $data) { - if (!$id) { - return false; - } - $expires = time() + $this->_timeout; - $record = compact('id', 'data', 'expires'); - $record[$this->_model->primaryKey] = $id; - return $this->_model->save($record); - } - -/** - * Method called on the destruction of a database session. - * - * @param integer $id ID that uniquely identifies session in database - * @return boolean True for successful delete, false otherwise. - */ - public function destroy($id) { - return $this->_model->delete($id); - } - -/** - * Helper function called on gc for database sessions. - * - * @param integer $expires Timestamp (defaults to current time) - * @return boolean Success - */ - public function gc($expires = null) { - if (!$expires) { - $expires = time(); - } - return $this->_model->deleteAll(array($this->_model->alias . ".expires <" => $expires), false, false); - } - -/** - * Closes the session before the objects handling it become unavailable - * - * @return void - */ - public function __destruct() { - try { - session_write_close(); - } catch (Exception $e) { - } - } - -} diff --git a/lib/Cake/Model/I18nModel.php b/lib/Cake/Model/I18nModel.php deleted file mode 100644 index 9b185064b18..00000000000 --- a/lib/Cake/Model/I18nModel.php +++ /dev/null @@ -1,44 +0,0 @@ - table 'users'; class 'Man' => table 'men') - * The table is required to have at least 'id auto_increment' primary key. - * - * @package Cake.Model - * @link http://book.cakephp.org/2.0/en/models.html - */ -class Model extends Object implements CakeEventListener { - -/** - * The name of the DataSource connection that this Model uses - * - * The value must be an attribute name that you defined in `app/Config/database.php` - * or created using `ConnectionManager::create()`. - * - * @var string - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#usedbconfig - */ - public $useDbConfig = 'default'; - -/** - * Custom database table name, or null/false if no table association is desired. - * - * @var string - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#useTable - */ - public $useTable = null; - -/** - * Custom display field name. Display fields are used by Scaffold, in SELECT boxes' OPTION elements. - * - * This field is also used in `find('list')` when called with no extra parameters in the fields list - * - * @var string - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#displayField - */ - public $displayField = null; - -/** - * Value of the primary key ID of the record that this model is currently pointing to. - * Automatically set after database insertions. - * - * @var mixed - */ - public $id = false; - -/** - * Container for the data that this model gets from persistent storage (usually, a database). - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#data - */ - public $data = array(); - -/** - * Holds physical schema/database name for this model. Automatically set during Model creation. - * - * @var string - * @access public - */ - public $schemaName = null; - -/** - * Table name for this Model. - * - * @var string - */ - public $table = false; - -/** - * The name of the primary key field for this model. - * - * @var string - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#primaryKey - */ - public $primaryKey = null; - -/** - * Field-by-field table metadata. - * - * @var array - */ - protected $_schema = null; - -/** - * List of validation rules. It must be an array with the field name as key and using - * as value one of the following possibilities - * - * ### Validating using regular expressions - * - * {{{ - * public $validate = array( - * 'name' => '/^[a-z].+$/i' - * ); - * }}} - * - * ### Validating using methods (no parameters) - * - * {{{ - * public $validate = array( - * 'name' => 'notEmpty' - * ); - * }}} - * - * ### Validating using methods (with parameters) - * - * {{{ - * public $validate = array( - * 'age' => array( - * 'rule' => array('between', 5, 25) - * ) - * ); - * }}} - * - * ### Validating using custom method - * - * {{{ - * public $validate = array( - * 'password' => array( - * 'rule' => array('customValidation') - * ) - * ); - * public function customValidation($data) { - * // $data will contain array('password' => 'value') - * if (isset($this->data[$this->alias]['password2'])) { - * return $this->data[$this->alias]['password2'] === current($data); - * } - * return true; - * } - * }}} - * - * ### Validations with messages - * - * The messages will be used in Model::$validationErrors and can be used in the FormHelper - * - * {{{ - * public $validate = array( - * 'age' => array( - * 'rule' => array('between', 5, 25), - * 'message' => array('The age must be between %d and %d.') - * ) - * ); - * }}} - * - * ### Multiple validations to the same field - * - * {{{ - * public $validate = array( - * 'login' => array( - * array( - * 'rule' => 'alphaNumeric', - * 'message' => 'Only alphabets and numbers allowed', - * 'last' => true - * ), - * array( - * 'rule' => array('minLength', 8), - * 'message' => array('Minimum length of %d characters') - * ) - * ) - * ); - * }}} - * - * ### Valid keys in validations - * - * - `rule`: String with method name, regular expression (started by slash) or array with method and parameters - * - `message`: String with the message or array if have multiple parameters. See http://php.net/sprintf - * - `last`: Boolean value to indicate if continue validating the others rules if the current fail [Default: true] - * - `required`: Boolean value to indicate if the field must be present on save - * - `allowEmpty`: Boolean value to indicate if the field can be empty - * - `on`: Possible values: `update`, `create`. Indicate to apply this rule only on update or create - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#validate - * @link http://book.cakephp.org/2.0/en/models/data-validation.html - */ - public $validate = array(); - -/** - * List of validation errors. - * - * @var array - */ - public $validationErrors = array(); - -/** - * Name of the validation string domain to use when translating validation errors. - * - * @var string - */ - public $validationDomain = null; - -/** - * Database table prefix for tables in model. - * - * @var string - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#tableprefix - */ - public $tablePrefix = null; - -/** - * Name of the model. - * - * @var string - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#name - */ - public $name = null; - -/** - * Alias name for model. - * - * @var string - */ - public $alias = null; - -/** - * List of table names included in the model description. Used for associations. - * - * @var array - */ - public $tableToModel = array(); - -/** - * Whether or not to cache queries for this model. This enables in-memory - * caching only, the results are not stored beyond the current request. - * - * @var boolean - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#cacheQueries - */ - public $cacheQueries = false; - -/** - * Detailed list of belongsTo associations. - * - * ### Basic usage - * - * `public $belongsTo = array('Group', 'Department');` - * - * ### Detailed configuration - * - * {{{ - * public $belongsTo = array( - * 'Group', - * 'Department' => array( - * 'className' => 'Department', - * 'foreignKey' => 'department_id' - * ) - * ); - * }}} - * - * ### Possible keys in association - * - * - `className`: the classname of the model being associated to the current model. - * If you're defining a 'Profile belongsTo User' relationship, the className key should equal 'User.' - * - `foreignKey`: the name of the foreign key found in the current model. This is - * especially handy if you need to define multiple belongsTo relationships. The default - * value for this key is the underscored, singular name of the other model, suffixed with '_id'. - * - `conditions`: An SQL fragment used to filter related model records. It's good - * practice to use model names in SQL fragments: 'User.active = 1' is always - * better than just 'active = 1.' - * - `type`: the type of the join to use in the SQL query, default is LEFT which - * may not fit your needs in all situations, INNER may be helpful when you want - * everything from your main and associated models or nothing at all!(effective - * when used with some conditions of course). (NB: type value is in lower case - i.e. left, inner) - * - `fields`: A list of fields to be retrieved when the associated model data is - * fetched. Returns all fields by default. - * - `order`: An SQL fragment that defines the sorting order for the returned associated rows. - * - `counterCache`: If set to true the associated Model will automatically increase or - * decrease the "[singular_model_name]_count" field in the foreign table whenever you do - * a save() or delete(). If its a string then its the field name to use. The value in the - * counter field represents the number of related rows. - * - `counterScope`: Optional conditions array to use for updating counter cache field. - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#belongsto - */ - public $belongsTo = array(); - -/** - * Detailed list of hasOne associations. - * - * ### Basic usage - * - * `public $hasOne = array('Profile', 'Address');` - * - * ### Detailed configuration - * - * {{{ - * public $hasOne = array( - * 'Profile', - * 'Address' => array( - * 'className' => 'Address', - * 'foreignKey' => 'user_id' - * ) - * ); - * }}} - * - * ### Possible keys in association - * - * - `className`: the classname of the model being associated to the current model. - * If you're defining a 'User hasOne Profile' relationship, the className key should equal 'Profile.' - * - `foreignKey`: the name of the foreign key found in the other model. This is - * especially handy if you need to define multiple hasOne relationships. - * The default value for this key is the underscored, singular name of the - * current model, suffixed with '_id'. In the example above it would default to 'user_id'. - * - `conditions`: An SQL fragment used to filter related model records. It's good - * practice to use model names in SQL fragments: "Profile.approved = 1" is - * always better than just "approved = 1." - * - `fields`: A list of fields to be retrieved when the associated model data is - * fetched. Returns all fields by default. - * - `order`: An SQL fragment that defines the sorting order for the returned associated rows. - * - `dependent`: When the dependent key is set to true, and the model's delete() - * method is called with the cascade parameter set to true, associated model - * records are also deleted. In this case we set it true so that deleting a - * User will also delete her associated Profile. - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#hasone - */ - public $hasOne = array(); - -/** - * Detailed list of hasMany associations. - * - * ### Basic usage - * - * `public $hasMany = array('Comment', 'Task');` - * - * ### Detailed configuration - * - * {{{ - * public $hasMany = array( - * 'Comment', - * 'Task' => array( - * 'className' => 'Task', - * 'foreignKey' => 'user_id' - * ) - * ); - * }}} - * - * ### Possible keys in association - * - * - `className`: the classname of the model being associated to the current model. - * If you're defining a 'User hasMany Comment' relationship, the className key should equal 'Comment.' - * - `foreignKey`: the name of the foreign key found in the other model. This is - * especially handy if you need to define multiple hasMany relationships. The default - * value for this key is the underscored, singular name of the actual model, suffixed with '_id'. - * - `conditions`: An SQL fragment used to filter related model records. It's good - * practice to use model names in SQL fragments: "Comment.status = 1" is always - * better than just "status = 1." - * - `fields`: A list of fields to be retrieved when the associated model data is - * fetched. Returns all fields by default. - * - `order`: An SQL fragment that defines the sorting order for the returned associated rows. - * - `limit`: The maximum number of associated rows you want returned. - * - `offset`: The number of associated rows to skip over (given the current - * conditions and order) before fetching and associating. - * - `dependent`: When dependent is set to true, recursive model deletion is - * possible. In this example, Comment records will be deleted when their - * associated User record has been deleted. - * - `exclusive`: When exclusive is set to true, recursive model deletion does - * the delete with a deleteAll() call, instead of deleting each entity separately. - * This greatly improves performance, but may not be ideal for all circumstances. - * - `finderQuery`: A complete SQL query CakePHP can use to fetch associated model - * records. This should be used in situations that require very custom results. - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#hasmany - */ - public $hasMany = array(); - -/** - * Detailed list of hasAndBelongsToMany associations. - * - * ### Basic usage - * - * `public $hasAndBelongsToMany = array('Role', 'Address');` - * - * ### Detailed configuration - * - * {{{ - * public $hasAndBelongsToMany = array( - * 'Role', - * 'Address' => array( - * 'className' => 'Address', - * 'foreignKey' => 'user_id', - * 'associationForeignKey' => 'address_id', - * 'joinTable' => 'addresses_users' - * ) - * ); - * }}} - * - * ### Possible keys in association - * - * - `className`: the classname of the model being associated to the current model. - * If you're defining a 'Recipe HABTM Tag' relationship, the className key should equal 'Tag.' - * - `joinTable`: The name of the join table used in this association (if the - * current table doesn't adhere to the naming convention for HABTM join tables). - * - `with`: Defines the name of the model for the join table. By default CakePHP - * will auto-create a model for you. Using the example above it would be called - * RecipesTag. By using this key you can override this default name. The join - * table model can be used just like any "regular" model to access the join table directly. - * - `foreignKey`: the name of the foreign key found in the current model. - * This is especially handy if you need to define multiple HABTM relationships. - * The default value for this key is the underscored, singular name of the - * current model, suffixed with '_id'. - * - `associationForeignKey`: the name of the foreign key found in the other model. - * This is especially handy if you need to define multiple HABTM relationships. - * The default value for this key is the underscored, singular name of the other - * model, suffixed with '_id'. - * - `unique`: If true (default value) cake will first delete existing relationship - * records in the foreign keys table before inserting new ones, when updating a - * record. So existing associations need to be passed again when updating. - * To prevent deletion of existing relationship records, set this key to a string 'keepExisting'. - * - `conditions`: An SQL fragment used to filter related model records. It's good - * practice to use model names in SQL fragments: "Comment.status = 1" is always - * better than just "status = 1." - * - `fields`: A list of fields to be retrieved when the associated model data is - * fetched. Returns all fields by default. - * - `order`: An SQL fragment that defines the sorting order for the returned associated rows. - * - `limit`: The maximum number of associated rows you want returned. - * - `offset`: The number of associated rows to skip over (given the current - * conditions and order) before fetching and associating. - * - `finderQuery`, `deleteQuery`, `insertQuery`: A complete SQL query CakePHP - * can use to fetch, delete, or create new associated model records. This should - * be used in situations that require very custom results. - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#hasandbelongstomany-habtm - */ - public $hasAndBelongsToMany = array(); - -/** - * List of behaviors to load when the model object is initialized. Settings can be - * passed to behaviors by using the behavior name as index. Eg: - * - * public $actsAs = array('Translate', 'MyBehavior' => array('setting1' => 'value1')) - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/behaviors.html#using-behaviors - */ - public $actsAs = null; - -/** - * Holds the Behavior objects currently bound to this model. - * - * @var BehaviorCollection - */ - public $Behaviors = null; - -/** - * Whitelist of fields allowed to be saved. - * - * @var array - */ - public $whitelist = array(); - -/** - * Whether or not to cache sources for this model. - * - * @var boolean - */ - public $cacheSources = true; - -/** - * Type of find query currently executing. - * - * @var string - */ - public $findQueryType = null; - -/** - * Number of associations to recurse through during find calls. Fetches only - * the first level by default. - * - * @var integer - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#recursive - */ - public $recursive = 1; - -/** - * The column name(s) and direction(s) to order find results by default. - * - * public $order = "Post.created DESC"; - * public $order = array("Post.view_count DESC", "Post.rating DESC"); - * - * @var string - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#order - */ - public $order = null; - -/** - * Array of virtual fields this model has. Virtual fields are aliased - * SQL expressions. Fields added to this property will be read as other fields in a model - * but will not be saveable. - * - * `public $virtualFields = array('two' => '1 + 1');` - * - * Is a simplistic example of how to set virtualFields - * - * @var array - * @link http://book.cakephp.org/2.0/en/models/model-attributes.html#virtualfields - */ - public $virtualFields = array(); - -/** - * Default list of association keys. - * - * @var array - */ - protected $_associationKeys = array( - 'belongsTo' => array('className', 'foreignKey', 'conditions', 'fields', 'order', 'counterCache'), - 'hasOne' => array('className', 'foreignKey', 'conditions', 'fields', 'order', 'dependent'), - 'hasMany' => array('className', 'foreignKey', 'conditions', 'fields', 'order', 'limit', 'offset', 'dependent', 'exclusive', 'finderQuery', 'counterQuery'), - 'hasAndBelongsToMany' => array('className', 'joinTable', 'with', 'foreignKey', 'associationForeignKey', 'conditions', 'fields', 'order', 'limit', 'offset', 'unique', 'finderQuery', 'deleteQuery', 'insertQuery') - ); - -/** - * Holds provided/generated association key names and other data for all associations. - * - * @var array - */ - protected $_associations = array('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany'); - -/** - * Holds model associations temporarily to allow for dynamic (un)binding. - * - * @var array - */ - public $__backAssociation = array(); - -/** - * Back inner association - * - * @var array - */ - public $__backInnerAssociation = array(); - -/** - * Back original association - * - * @var array - */ - public $__backOriginalAssociation = array(); - -/** - * Back containable association - * - * @var array - */ - public $__backContainableAssociation = array(); - -/** - * The ID of the model record that was last inserted. - * - * @var integer - */ - protected $_insertID = null; - -/** - * Has the datasource been configured. - * - * @var boolean - * @see Model::getDataSource - */ - protected $_sourceConfigured = false; - -/** - * List of valid finder method options, supplied as the first parameter to find(). - * - * @var array - */ - public $findMethods = array( - 'all' => true, 'first' => true, 'count' => true, - 'neighbors' => true, 'list' => true, 'threaded' => true - ); - -/** - * Instance of the CakeEventManager this model is using - * to dispatch inner events. - * - * @var CakeEventManager - */ - protected $_eventManager = null; - -/** - * Constructor. Binds the model's database table to the object. - * - * If `$id` is an array it can be used to pass several options into the model. - * - * - id - The id to start the model on. - * - table - The table to use for this model. - * - ds - The connection name this model is connected to. - * - name - The name of the model eg. Post. - * - alias - The alias of the model, this is used for registering the instance in the `ClassRegistry`. - * eg. `ParentThread` - * - * ### Overriding Model's __construct method. - * - * When overriding Model::__construct() be careful to include and pass in all 3 of the - * arguments to `parent::__construct($id, $table, $ds);` - * - * ### Dynamically creating models - * - * You can dynamically create model instances using the $id array syntax. - * - * {{{ - * $Post = new Model(array('table' => 'posts', 'name' => 'Post', 'ds' => 'connection2')); - * }}} - * - * Would create a model attached to the posts table on connection2. Dynamic model creation is useful - * when you want a model object that contains no associations or attached behaviors. - * - * @param mixed $id Set this ID for this model on startup, can also be an array of options, see above. - * @param string $table Name of database table to use. - * @param string $ds DataSource connection name. - */ - public function __construct($id = false, $table = null, $ds = null) { - parent::__construct(); - - if (is_array($id)) { - extract(array_merge( - array( - 'id' => $this->id, 'table' => $this->useTable, 'ds' => $this->useDbConfig, - 'name' => $this->name, 'alias' => $this->alias - ), - $id - )); - } - - if ($this->name === null) { - $this->name = (isset($name) ? $name : get_class($this)); - } - - if ($this->alias === null) { - $this->alias = (isset($alias) ? $alias : $this->name); - } - - if ($this->primaryKey === null) { - $this->primaryKey = 'id'; - } - - ClassRegistry::addObject($this->alias, $this); - - $this->id = $id; - unset($id); - - if ($table === false) { - $this->useTable = false; - } elseif ($table) { - $this->useTable = $table; - } - - if ($ds !== null) { - $this->useDbConfig = $ds; - } - - if (is_subclass_of($this, 'AppModel')) { - $merge = array('actsAs', 'findMethods'); - $parentClass = get_parent_class($this); - if ($parentClass !== 'AppModel') { - $this->_mergeVars($merge, $parentClass); - } - $this->_mergeVars($merge, 'AppModel'); - } - $this->_mergeVars(array('findMethods'), 'Model'); - - $this->Behaviors = new BehaviorCollection(); - - if ($this->useTable !== false) { - - if ($this->useTable === null) { - $this->useTable = Inflector::tableize($this->name); - } - - if ($this->displayField == null) { - unset($this->displayField); - } - $this->table = $this->useTable; - $this->tableToModel[$this->table] = $this->alias; - } elseif ($this->table === false) { - $this->table = Inflector::tableize($this->name); - } - - if ($this->tablePrefix === null) { - unset($this->tablePrefix); - } - - $this->_createLinks(); - $this->Behaviors->init($this->alias, $this->actsAs); - } - -/** - * Returns a list of all events that will fire in the model during it's lifecycle. - * You can override this function to add you own listener callbacks - * - * @return array - */ - public function implementedEvents() { - return array( - 'Model.beforeFind' => array('callable' => 'beforeFind', 'passParams' => true), - 'Model.afterFind' => array('callable' => 'afterFind', 'passParams' => true), - 'Model.beforeValidate' => array('callable' => 'beforeValidate', 'passParams' => true), - 'Model.beforeSave' => array('callable' => 'beforeSave', 'passParams' => true), - 'Model.afterSave' => array('callable' => 'afterSave', 'passParams' => true), - 'Model.beforeDelete' => array('callable' => 'beforeDelete', 'passParams' => true), - 'Model.afterDelete' => array('callable' => 'afterDelete'), - ); - } - -/** - * Returns the CakeEventManager manager instance that is handling any callbacks. - * You can use this instance to register any new listeners or callbacks to the - * model events, or create your own events and trigger them at will. - * - * @return CakeEventManager - */ - public function getEventManager() { - if (empty($this->_eventManager)) { - $this->_eventManager = new CakeEventManager(); - $this->_eventManager->attach($this->Behaviors); - $this->_eventManager->attach($this); - } - return $this->_eventManager; - } - -/** - * Handles custom method calls, like findBy for DB models, - * and custom RPC calls for remote data sources. - * - * @param string $method Name of method to call. - * @param array $params Parameters for the method. - * @return mixed Whatever is returned by called method - */ - public function __call($method, $params) { - $result = $this->Behaviors->dispatchMethod($this, $method, $params); - if ($result !== array('unhandled')) { - return $result; - } - $return = $this->getDataSource()->query($method, $params, $this); - return $return; - } - -/** - * Handles the lazy loading of model associations by looking in the association arrays for the requested variable - * - * @param string $name variable tested for existence in class - * @return boolean true if the variable exists (if is a not loaded model association it will be created), false otherwise - */ - public function __isset($name) { - $className = false; - - foreach ($this->_associations as $type) { - if (isset($name, $this->{$type}[$name])) { - $className = empty($this->{$type}[$name]['className']) ? $name : $this->{$type}[$name]['className']; - break; - } elseif (isset($name, $this->__backAssociation[$type][$name])) { - $className = empty($this->__backAssociation[$type][$name]['className']) ? - $name : $this->__backAssociation[$type][$name]['className']; - break; - } elseif ($type == 'hasAndBelongsToMany') { - foreach ($this->{$type} as $k => $relation) { - if (empty($relation['with'])) { - continue; - } - if (is_array($relation['with'])) { - if (key($relation['with']) === $name) { - $className = $name; - } - } else { - list($plugin, $class) = pluginSplit($relation['with']); - if ($class === $name) { - $className = $relation['with']; - } - } - if ($className) { - $assocKey = $k; - $dynamic = !empty($relation['dynamicWith']); - break(2); - } - } - } - } - - if (!$className) { - return false; - } - - list($plugin, $className) = pluginSplit($className); - - if (!ClassRegistry::isKeySet($className) && !empty($dynamic)) { - $this->{$className} = new AppModel(array( - 'name' => $className, - 'table' => $this->hasAndBelongsToMany[$assocKey]['joinTable'], - 'ds' => $this->useDbConfig - )); - } else { - $this->_constructLinkedModel($name, $className, $plugin); - } - - if (!empty($assocKey)) { - $this->hasAndBelongsToMany[$assocKey]['joinTable'] = $this->{$name}->table; - if (count($this->{$name}->schema()) <= 2 && $this->{$name}->primaryKey !== false) { - $this->{$name}->primaryKey = $this->hasAndBelongsToMany[$assocKey]['foreignKey']; - } - } - - return true; - } - -/** - * Returns the value of the requested variable if it can be set by __isset() - * - * @param string $name variable requested for it's value or reference - * @return mixed value of requested variable if it is set - */ - public function __get($name) { - if ($name === 'displayField') { - return $this->displayField = $this->hasField(array('title', 'name', $this->primaryKey)); - } - if ($name === 'tablePrefix') { - $this->setDataSource(); - if (property_exists($this, 'tablePrefix') && !empty($this->tablePrefix)) { - return $this->tablePrefix; - } - return $this->tablePrefix = null; - } - if (isset($this->{$name})) { - return $this->{$name}; - } - } - -/** - * Bind model associations on the fly. - * - * If `$reset` is false, association will not be reset - * to the originals defined in the model - * - * Example: Add a new hasOne binding to the Profile model not - * defined in the model source code: - * - * `$this->User->bindModel( array('hasOne' => array('Profile')) );` - * - * Bindings that are not made permanent will be reset by the next Model::find() call on this - * model. - * - * @param array $params Set of bindings (indexed by binding type) - * @param boolean $reset Set to false to make the binding permanent - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#creating-and-destroying-associations-on-the-fly - */ - public function bindModel($params, $reset = true) { - foreach ($params as $assoc => $model) { - if ($reset === true && !isset($this->__backAssociation[$assoc])) { - $this->__backAssociation[$assoc] = $this->{$assoc}; - } - foreach ($model as $key => $value) { - $assocName = $key; - - if (is_numeric($key)) { - $assocName = $value; - $value = array(); - } - $this->{$assoc}[$assocName] = $value; - if (property_exists($this, $assocName)) { - unset($this->{$assocName}); - } - if ($reset === false && isset($this->__backAssociation[$assoc])) { - $this->__backAssociation[$assoc][$assocName] = $value; - } - } - } - $this->_createLinks(); - return true; - } - -/** - * Turn off associations on the fly. - * - * If $reset is false, association will not be reset - * to the originals defined in the model - * - * Example: Turn off the associated Model Support request, - * to temporarily lighten the User model: - * - * `$this->User->unbindModel( array('hasMany' => array('Supportrequest')) );` - * - * unbound models that are not made permanent will reset with the next call to Model::find() - * - * @param array $params Set of bindings to unbind (indexed by binding type) - * @param boolean $reset Set to false to make the unbinding permanent - * @return boolean Success - * @link http://book.cakephp.org/2.0/en/models/associations-linking-models-together.html#creating-and-destroying-associations-on-the-fly - */ - public function unbindModel($params, $reset = true) { - foreach ($params as $assoc => $models) { - if ($reset === true && !isset($this->__backAssociation[$assoc])) { - $this->__backAssociation[$assoc] = $this->{$assoc}; - } - foreach ($models as $model) { - if ($reset === false && isset($this->__backAssociation[$assoc][$model])) { - unset($this->__backAssociation[$assoc][$model]); - } - unset($this->{$assoc}[$model]); - } - } - return true; - } - -/** - * Create a set of associations. - * - * @return void - */ - protected function _createLinks() { - foreach ($this->_associations as $type) { - if (!is_array($this->{$type})) { - $this->{$type} = explode(',', $this->{$type}); - - foreach ($this->{$type} as $i => $className) { - $className = trim($className); - unset ($this->{$type}[$i]); - $this->{$type}[$className] = array(); - } - } - - if (!empty($this->{$type})) { - foreach ($this->{$type} as $assoc => $value) { - $plugin = null; - - if (is_numeric($assoc)) { - unset ($this->{$type}[$assoc]); - $assoc = $value; - $value = array(); - - if (strpos($assoc, '.') !== false) { - list($plugin, $assoc) = pluginSplit($assoc); - $this->{$type}[$assoc] = array('className' => $plugin . '.' . $assoc); - } else { - $this->{$type}[$assoc] = $value; - } - } - $this->_generateAssociation($type, $assoc); - } - } - } - } - -/** - * Protected helper method to create associated models of a given class. - * - * @param string $assoc Association name - * @param string $className Class name - * @param string $plugin name of the plugin where $className is located - * examples: public $hasMany = array('Assoc' => array('className' => 'ModelName')); - * usage: $this->Assoc->modelMethods(); - * - * public $hasMany = array('ModelName'); - * usage: $this->ModelName->modelMethods(); - * @return void - */ - protected function _constructLinkedModel($assoc, $className = null, $plugin = null) { - if (empty($className)) { - $className = $assoc; - } - - if (!isset($this->{$assoc}) || $this->{$assoc}->name !== $className) { - if ($plugin) { - $plugin .= '.'; - } - $model = array('class' => $plugin . $className, 'alias' => $assoc); - $this->{$assoc} = ClassRegistry::init($model); - if ($plugin) { - ClassRegistry::addObject($plugin . $className, $this->{$assoc}); - } - if ($assoc) { - $this->tableToModel[$this->{$assoc}->table] = $assoc; - } - } - } - -/** - * Build an array-based association from string. - * - * @param string $type 'belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany' - * @param string $assocKey - * @return void - */ - protected function _generateAssociation($type, $assocKey) { - $class = $assocKey; - $dynamicWith = false; - - foreach ($this->_associationKeys[$type] as $key) { - - if (!isset($this->{$type}[$assocKey][$key]) || $this->{$type}[$assocKey][$key] === null) { - $data = ''; - - switch ($key) { - case 'fields': - $data = ''; - break; - - case 'foreignKey': - $data = (($type == 'belongsTo') ? Inflector::underscore($assocKey) : Inflector::singularize($this->table)) . '_id'; - break; - - case 'associationForeignKey': - $data = Inflector::singularize($this->{$class}->table) . '_id'; - break; - - case 'with': - $data = Inflector::camelize(Inflector::singularize($this->{$type}[$assocKey]['joinTable'])); - $dynamicWith = true; - break; - - case 'joinTable': - $tables = array($this->table, $this->{$class}->table); - sort ($tables); - $data = $tables[0] . '_' . $tables[1]; - break; - - case 'className': - $data = $class; - break; - - case 'unique': - $data = true; - break; - } - $this->{$type}[$assocKey][$key] = $data; - } - - if ($dynamicWith) { - $this->{$type}[$assocKey]['dynamicWith'] = true; - } - - } - } - -/** - * Sets a custom table for your controller class. Used by your controller to select a database table. - * - * @param string $tableName Name of the custom table - * @throws MissingTableException when database table $tableName is not found on data source - * @return void - */ - public function setSource($tableName) { - $this->setDataSource($this->useDbConfig); - $db = ConnectionManager::getDataSource($this->useDbConfig); - $db->cacheSources = ($this->cacheSources && $db->cacheSources); - - if (method_exists($db, 'listSources')) { - $sources = $db->listSources(); - if (is_array($sources) && !in_array(strtolower($this->tablePrefix . $tableName), array_map('strtolower', $sources))) { - throw new MissingTableException(array( - 'table' => $this->tablePrefix . $tableName, - 'class' => $this->alias, - 'ds' => $this->useDbConfig, - )); - } - $this->_schema = null; - } - $this->table = $this->useTable = $tableName; - $this->tableToModel[$this->table] = $this->alias; - } - -/** - * This function does two things: - * - * 1. it scans the array $one for the primary key, - * and if that's found, it sets the current id to the value of $one[id]. - * For all other keys than 'id' the keys and values of $one are copied to the 'data' property of this object. - * 2. Returns an array with all of $one's keys and values. - * (Alternative indata: two strings, which are mangled to - * a one-item, two-dimensional array using $one for a key and $two as its value.) - * - * @param mixed $one Array or string of data - * @param string $two Value string for the alternative indata method - * @return array Data with all of $one's keys and values - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html - */ - public function set($one, $two = null) { - if (!$one) { - return; - } - if (is_object($one)) { - if ($one instanceof SimpleXMLElement || $one instanceof DOMNode) { - $one = $this->_normalizeXmlData(Xml::toArray($one)); - } else { - $one = Set::reverse($one); - } - } - - if (is_array($one)) { - $data = $one; - if (empty($one[$this->alias])) { - if ($this->getAssociated(key($one)) === null) { - $data = array($this->alias => $one); - } - } - } else { - $data = array($this->alias => array($one => $two)); - } - - foreach ($data as $modelName => $fieldSet) { - if (is_array($fieldSet)) { - - foreach ($fieldSet as $fieldName => $fieldValue) { - if (isset($this->validationErrors[$fieldName])) { - unset ($this->validationErrors[$fieldName]); - } - - if ($modelName === $this->alias) { - if ($fieldName === $this->primaryKey) { - $this->id = $fieldValue; - } - } - if (is_array($fieldValue) || is_object($fieldValue)) { - $fieldValue = $this->deconstruct($fieldName, $fieldValue); - } - $this->data[$modelName][$fieldName] = $fieldValue; - } - } - } - return $data; - } - -/** - * Normalize Xml::toArray() to use in Model::save() - * - * @param array $xml XML as array - * @return array - */ - protected function _normalizeXmlData(array $xml) { - $return = array(); - foreach ($xml as $key => $value) { - if (is_array($value)) { - $return[Inflector::camelize($key)] = $this->_normalizeXmlData($value); - } elseif ($key[0] === '@') { - $return[substr($key, 1)] = $value; - } else { - $return[$key] = $value; - } - } - return $return; - } - -/** - * Deconstructs a complex data type (array or object) into a single field value. - * - * @param string $field The name of the field to be deconstructed - * @param mixed $data An array or object to be deconstructed into a field - * @return mixed The resulting data that should be assigned to a field - */ - public function deconstruct($field, $data) { - if (!is_array($data)) { - return $data; - } - - $type = $this->getColumnType($field); - - if (in_array($type, array('datetime', 'timestamp', 'date', 'time'))) { - $useNewDate = (isset($data['year']) || isset($data['month']) || - isset($data['day']) || isset($data['hour']) || isset($data['minute'])); - - $dateFields = array('Y' => 'year', 'm' => 'month', 'd' => 'day', 'H' => 'hour', 'i' => 'min', 's' => 'sec'); - $timeFields = array('H' => 'hour', 'i' => 'min', 's' => 'sec'); - $date = array(); - - if (isset($data['meridian']) && empty($data['meridian'])) { - return null; - } - - if ( - isset($data['hour']) && - isset($data['meridian']) && - !empty($data['hour']) && - $data['hour'] != 12 && - 'pm' == $data['meridian'] - ) { - $data['hour'] = $data['hour'] + 12; - } - if (isset($data['hour']) && isset($data['meridian']) && $data['hour'] == 12 && 'am' == $data['meridian']) { - $data['hour'] = '00'; - } - if ($type == 'time') { - foreach ($timeFields as $key => $val) { - if (!isset($data[$val]) || $data[$val] === '0' || $data[$val] === '00') { - $data[$val] = '00'; - } elseif ($data[$val] !== '') { - $data[$val] = sprintf('%02d', $data[$val]); - } - if (!empty($data[$val])) { - $date[$key] = $data[$val]; - } else { - return null; - } - } - } - - if ($type == 'datetime' || $type == 'timestamp' || $type == 'date') { - foreach ($dateFields as $key => $val) { - if ($val == 'hour' || $val == 'min' || $val == 'sec') { - if (!isset($data[$val]) || $data[$val] === '0' || $data[$val] === '00') { - $data[$val] = '00'; - } else { - $data[$val] = sprintf('%02d', $data[$val]); - } - } - if (!isset($data[$val]) || isset($data[$val]) && (empty($data[$val]) || $data[$val][0] === '-')) { - return null; - } - if (isset($data[$val]) && !empty($data[$val])) { - $date[$key] = $data[$val]; - } - } - } - - if ($useNewDate && !empty($date)) { - $format = $this->getDataSource()->columns[$type]['format']; - foreach (array('m', 'd', 'H', 'i', 's') as $index) { - if (isset($date[$index])) { - $date[$index] = sprintf('%02d', $date[$index]); - } - } - return str_replace(array_keys($date), array_values($date), $format); - } - } - return $data; - } - -/** - * Returns an array of table metadata (column names and types) from the database. - * $field => keys(type, null, default, key, length, extra) - * - * @param mixed $field Set to true to reload schema, or a string to return a specific field - * @return array Array of table metadata - */ - public function schema($field = false) { - if ($this->useTable !== false && (!is_array($this->_schema) || $field === true)) { - $db = $this->getDataSource(); - $db->cacheSources = ($this->cacheSources && $db->cacheSources); - if (method_exists($db, 'describe') && $this->useTable !== false) { - $this->_schema = $db->describe($this); - } elseif ($this->useTable === false) { - $this->_schema = array(); - } - } - if (is_string($field)) { - if (isset($this->_schema[$field])) { - return $this->_schema[$field]; - } else { - return null; - } - } - return $this->_schema; - } - -/** - * Returns an associative array of field names and column types. - * - * @return array Field types indexed by field name - */ - public function getColumnTypes() { - $columns = $this->schema(); - if (empty($columns)) { - trigger_error(__d('cake_dev', '(Model::getColumnTypes) Unable to build model field data. If you are using a model without a database table, try implementing schema()'), E_USER_WARNING); - } - $cols = array(); - foreach ($columns as $field => $values) { - $cols[$field] = $values['type']; - } - return $cols; - } - -/** - * Returns the column type of a column in the model. - * - * @param string $column The name of the model column - * @return string Column type - */ - public function getColumnType($column) { - $db = $this->getDataSource(); - $cols = $this->schema(); - $model = null; - - $column = str_replace(array($db->startQuote, $db->endQuote), '', $column); - - if (strpos($column, '.')) { - list($model, $column) = explode('.', $column); - } - if ($model != $this->alias && isset($this->{$model})) { - return $this->{$model}->getColumnType($column); - } - if (isset($cols[$column]) && isset($cols[$column]['type'])) { - return $cols[$column]['type']; - } - return null; - } - -/** - * Returns true if the supplied field exists in the model's database table. - * - * @param mixed $name Name of field to look for, or an array of names - * @param boolean $checkVirtual checks if the field is declared as virtual - * @return mixed If $name is a string, returns a boolean indicating whether the field exists. - * If $name is an array of field names, returns the first field that exists, - * or false if none exist. - */ - public function hasField($name, $checkVirtual = false) { - if (is_array($name)) { - foreach ($name as $n) { - if ($this->hasField($n, $checkVirtual)) { - return $n; - } - } - return false; - } - - if ($checkVirtual && !empty($this->virtualFields)) { - if ($this->isVirtualField($name)) { - return true; - } - } - - if (empty($this->_schema)) { - $this->schema(); - } - - if ($this->_schema != null) { - return isset($this->_schema[$name]); - } - return false; - } - -/** - * Check that a method is callable on a model. This will check both the model's own methods, its - * inherited methods and methods that could be callable through behaviors. - * - * @param string $method The method to be called. - * @return boolean True on method being callable. - */ - public function hasMethod($method) { - if (method_exists($this, $method)) { - return true; - } - if ($this->Behaviors->hasMethod($method)) { - return true; - } - return false; - } - -/** - * Returns true if the supplied field is a model Virtual Field - * - * @param string $field Name of field to look for - * @return boolean indicating whether the field exists as a model virtual field. - */ - public function isVirtualField($field) { - if (empty($this->virtualFields) || !is_string($field)) { - return false; - } - if (isset($this->virtualFields[$field])) { - return true; - } - if (strpos($field, '.') !== false) { - list($model, $field) = explode('.', $field); - if ($model == $this->alias && isset($this->virtualFields[$field])) { - return true; - } - } - return false; - } - -/** - * Returns the expression for a model virtual field - * - * @param string $field Name of field to look for - * @return mixed If $field is string expression bound to virtual field $field - * If $field is null, returns an array of all model virtual fields - * or false if none $field exist. - */ - public function getVirtualField($field = null) { - if ($field == null) { - return empty($this->virtualFields) ? false : $this->virtualFields; - } - if ($this->isVirtualField($field)) { - if (strpos($field, '.') !== false) { - list($model, $field) = explode('.', $field); - } - return $this->virtualFields[$field]; - } - return false; - } - -/** - * Initializes the model for writing a new record, loading the default values - * for those fields that are not defined in $data, and clearing previous validation errors. - * Especially helpful for saving data in loops. - * - * @param mixed $data Optional data array to assign to the model after it is created. If null or false, - * schema data defaults are not merged. - * @param boolean $filterKey If true, overwrites any primary key input with an empty value - * @return array The current Model::data; after merging $data and/or defaults from database - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-create-array-data-array - */ - public function create($data = array(), $filterKey = false) { - $defaults = array(); - $this->id = false; - $this->data = array(); - $this->validationErrors = array(); - - if ($data !== null && $data !== false) { - foreach ($this->schema() as $field => $properties) { - if ($this->primaryKey !== $field && isset($properties['default']) && $properties['default'] !== '') { - $defaults[$field] = $properties['default']; - } - } - $this->set($defaults); - $this->set($data); - } - if ($filterKey) { - $this->set($this->primaryKey, false); - } - return $this->data; - } - -/** - * Returns a list of fields from the database, and sets the current model - * data (Model::$data) with the record found. - * - * @param mixed $fields String of single field name, or an array of field names. - * @param mixed $id The ID of the record to read - * @return array Array of database fields, or false if not found - * @link http://book.cakephp.org/2.0/en/models/retrieving-your-data.html#model-read - */ - public function read($fields = null, $id = null) { - $this->validationErrors = array(); - - if ($id != null) { - $this->id = $id; - } - - $id = $this->id; - - if (is_array($this->id)) { - $id = $this->id[0]; - } - - if ($id !== null && $id !== false) { - $this->data = $this->find('first', array( - 'conditions' => array($this->alias . '.' . $this->primaryKey => $id), - 'fields' => $fields - )); - return $this->data; - } else { - return false; - } - } - -/** - * Returns the contents of a single field given the supplied conditions, in the - * supplied order. - * - * @param string $name Name of field to get - * @param array $conditions SQL conditions (defaults to NULL) - * @param string $order SQL ORDER BY fragment - * @return string field contents, or false if not found - * @link http://book.cakephp.org/2.0/en/models/retrieving-your-data.html#model-field - */ - public function field($name, $conditions = null, $order = null) { - if ($conditions === null && $this->id !== false) { - $conditions = array($this->alias . '.' . $this->primaryKey => $this->id); - } - if ($this->recursive >= 1) { - $recursive = -1; - } else { - $recursive = $this->recursive; - } - $fields = $name; - if ($data = $this->find('first', compact('conditions', 'fields', 'order', 'recursive'))) { - if (strpos($name, '.') === false) { - if (isset($data[$this->alias][$name])) { - return $data[$this->alias][$name]; - } - } else { - $name = explode('.', $name); - if (isset($data[$name[0]][$name[1]])) { - return $data[$name[0]][$name[1]]; - } - } - if (isset($data[0]) && count($data[0]) > 0) { - return array_shift($data[0]); - } - } else { - return false; - } - } - -/** - * Saves the value of a single field to the database, based on the current - * model ID. - * - * @param string $name Name of the table field - * @param mixed $value Value of the field - * @param array $validate See $options param in Model::save(). Does not respect 'fieldList' key if passed - * @return boolean See Model::save() - * @see Model::save() - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-savefield-string-fieldname-string-fieldvalue-validate-false - */ - public function saveField($name, $value, $validate = false) { - $id = $this->id; - $this->create(false); - - if (is_array($validate)) { - $options = array_merge(array('validate' => false, 'fieldList' => array($name)), $validate); - } else { - $options = array('validate' => $validate, 'fieldList' => array($name)); - } - return $this->save(array($this->alias => array($this->primaryKey => $id, $name => $value)), $options); - } - -/** - * Saves model data (based on white-list, if supplied) to the database. By - * default, validation occurs before save. - * - * @param array $data Data to save. - * @param mixed $validate Either a boolean, or an array. - * If a boolean, indicates whether or not to validate before saving. - * If an array, allows control of validate, callbacks, and fieldList - * @param array $fieldList List of fields to allow to be written - * @return mixed On success Model::$data if its not empty or true, false on failure - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html - */ - public function save($data = null, $validate = true, $fieldList = array()) { - $defaults = array('validate' => true, 'fieldList' => array(), 'callbacks' => true); - $_whitelist = $this->whitelist; - $fields = array(); - - if (!is_array($validate)) { - $options = array_merge($defaults, compact('validate', 'fieldList', 'callbacks')); - } else { - $options = array_merge($defaults, $validate); - } - - if (!empty($options['fieldList'])) { - if (!empty($options['fieldList'][$this->alias]) && is_array($options['fieldList'][$this->alias])) { - $this->whitelist = $options['fieldList'][$this->alias]; - } else { - $this->whitelist = $options['fieldList']; - } - } elseif ($options['fieldList'] === null) { - $this->whitelist = array(); - } - $this->set($data); - - if (empty($this->data) && !$this->hasField(array('created', 'updated', 'modified'))) { - return false; - } - - foreach (array('created', 'updated', 'modified') as $field) { - $keyPresentAndEmpty = ( - isset($this->data[$this->alias]) && - array_key_exists($field, $this->data[$this->alias]) && - $this->data[$this->alias][$field] === null - ); - if ($keyPresentAndEmpty) { - unset($this->data[$this->alias][$field]); - } - } - - $exists = $this->exists(); - $dateFields = array('modified', 'updated'); - - if (!$exists) { - $dateFields[] = 'created'; - } - if (isset($this->data[$this->alias])) { - $fields = array_keys($this->data[$this->alias]); - } - if ($options['validate'] && !$this->validates($options)) { - $this->whitelist = $_whitelist; - return false; - } - - $db = $this->getDataSource(); - - foreach ($dateFields as $updateCol) { - if ($this->hasField($updateCol) && !in_array($updateCol, $fields)) { - $default = array('formatter' => 'date'); - $colType = array_merge($default, $db->columns[$this->getColumnType($updateCol)]); - if (!array_key_exists('format', $colType)) { - $time = strtotime('now'); - } else { - $time = $colType['formatter']($colType['format']); - } - if (!empty($this->whitelist)) { - $this->whitelist[] = $updateCol; - } - $this->set($updateCol, $time); - } - } - - if ($options['callbacks'] === true || $options['callbacks'] === 'before') { - $event = new CakeEvent('Model.beforeSave', $this, array($options)); - list($event->break, $event->breakOn) = array(true, array(false, null)); - $this->getEventManager()->dispatch($event); - if (!$event->result) { - $this->whitelist = $_whitelist; - return false; - } - } - - if (empty($this->data[$this->alias][$this->primaryKey])) { - unset($this->data[$this->alias][$this->primaryKey]); - } - $fields = $values = array(); - - foreach ($this->data as $n => $v) { - if (isset($this->hasAndBelongsToMany[$n])) { - if (isset($v[$n])) { - $v = $v[$n]; - } - $joined[$n] = $v; - } else { - if ($n === $this->alias) { - foreach (array('created', 'updated', 'modified') as $field) { - if (array_key_exists($field, $v) && empty($v[$field])) { - unset($v[$field]); - } - } - - foreach ($v as $x => $y) { - if ($this->hasField($x) && (empty($this->whitelist) || in_array($x, $this->whitelist))) { - list($fields[], $values[]) = array($x, $y); - } - } - } - } - } - $count = count($fields); - - if (!$exists && $count > 0) { - $this->id = false; - } - $success = true; - $created = false; - - if ($count > 0) { - $cache = $this->_prepareUpdateFields(array_combine($fields, $values)); - - if (!empty($this->id)) { - $success = (bool)$db->update($this, $fields, $values); - } else { - $fInfo = $this->schema($this->primaryKey); - $isUUID = ($fInfo['length'] == 36 && - ($fInfo['type'] === 'string' || $fInfo['type'] === 'binary') - ); - if (empty($this->data[$this->alias][$this->primaryKey]) && $isUUID) { - if (array_key_exists($this->primaryKey, $this->data[$this->alias])) { - $j = array_search($this->primaryKey, $fields); - $values[$j] = String::uuid(); - } else { - list($fields[], $values[]) = array($this->primaryKey, String::uuid()); - } - } - - if (!$db->create($this, $fields, $values)) { - $success = $created = false; - } else { - $created = true; - } - } - - if ($success && !empty($this->belongsTo)) { - $this->updateCounterCache($cache, $created); - } - } - - if (!empty($joined) && $success === true) { - $this->_saveMulti($joined, $this->id, $db); - } - - if ($success && $count > 0) { - if (!empty($this->data)) { - $success = $this->data; - if ($created) { - $this->data[$this->alias][$this->primaryKey] = $this->id; - } - } - if ($options['callbacks'] === true || $options['callbacks'] === 'after') { - $event = new CakeEvent('Model.afterSave', $this, array($created, $options)); - $this->getEventManager()->dispatch($event); - } - if (!empty($this->data)) { - $success = Set::merge($success, $this->data); - } - $this->data = false; - $this->_clearCache(); - $this->validationErrors = array(); - } - $this->whitelist = $_whitelist; - return $success; - } - -/** - * Saves model hasAndBelongsToMany data to the database. - * - * @param array $joined Data to save - * @param mixed $id ID of record in this model - * @param DataSource $db - * @return void - */ - protected function _saveMulti($joined, $id, $db) { - foreach ($joined as $assoc => $data) { - - if (isset($this->hasAndBelongsToMany[$assoc])) { - list($join) = $this->joinModel($this->hasAndBelongsToMany[$assoc]['with']); - - $keyInfo = $this->{$join}->schema($this->{$join}->primaryKey); - if ($with = $this->hasAndBelongsToMany[$assoc]['with']) { - $withModel = is_array($with) ? key($with) : $with; - list($pluginName, $withModel) = pluginSplit($withModel); - $dbMulti = $this->{$withModel}->getDataSource(); - } else { - $dbMulti = $db; - } - - $isUUID = !empty($this->{$join}->primaryKey) && ( - $keyInfo['length'] == 36 && ( - $keyInfo['type'] === 'string' || - $keyInfo['type'] === 'binary' - ) - ); - - $newData = $newValues = $newJoins = array(); - $primaryAdded = false; - - $fields = array( - $dbMulti->name($this->hasAndBelongsToMany[$assoc]['foreignKey']), - $dbMulti->name($this->hasAndBelongsToMany[$assoc]['associationForeignKey']) - ); - - $idField = $db->name($this->{$join}->primaryKey); - if ($isUUID && !in_array($idField, $fields)) { - $fields[] = $idField; - $primaryAdded = true; - } - - foreach ((array)$data as $row) { - if ((is_string($row) && (strlen($row) == 36 || strlen($row) == 16)) || is_numeric($row)) { - $newJoins[] = $row; - $values = array($id, $row); - if ($isUUID && $primaryAdded) { - $values[] = String::uuid(); - } - $newValues[$row] = $values; - unset($values); - } elseif (isset($row[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])) { - if (!empty($row[$this->{$join}->primaryKey])) { - $newJoins[] = $row[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']]; - } - $newData[] = $row; - } elseif (isset($row[$join]) && isset($row[$join][$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])) { - if (!empty($row[$join][$this->{$join}->primaryKey])) { - $newJoins[] = $row[$join][$this->hasAndBelongsToMany[$assoc]['associationForeignKey']]; - } - $newData[] = $row[$join]; - } - } - - $keepExisting = $this->hasAndBelongsToMany[$assoc]['unique'] === 'keepExisting'; - if ($this->hasAndBelongsToMany[$assoc]['unique']) { - $conditions = array( - $join . '.' . $this->hasAndBelongsToMany[$assoc]['foreignKey'] => $id - ); - if (!empty($this->hasAndBelongsToMany[$assoc]['conditions'])) { - $conditions = array_merge($conditions, (array)$this->hasAndBelongsToMany[$assoc]['conditions']); - } - $associationForeignKey = $this->{$join}->alias . '.' . $this->hasAndBelongsToMany[$assoc]['associationForeignKey']; - $links = $this->{$join}->find('all', array( - 'conditions' => $conditions, - 'recursive' => empty($this->hasAndBelongsToMany[$assoc]['conditions']) ? -1 : 0, - 'fields' => $associationForeignKey, - )); - - $oldLinks = Set::extract($links, "{n}.{$associationForeignKey}"); - if (!empty($oldLinks)) { - if ($keepExisting && !empty($newJoins)) { - $conditions[$associationForeignKey] = array_diff($oldLinks, $newJoins); - } else { - $conditions[$associationForeignKey] = $oldLinks; - } - $dbMulti->delete($this->{$join}, $conditions); - } - } - - if (!empty($newData)) { - foreach ($newData as $data) { - $data[$this->hasAndBelongsToMany[$assoc]['foreignKey']] = $id; - if (empty($data[$this->{$join}->primaryKey])) { - $this->{$join}->create(); - } - $this->{$join}->save($data); - } - } - - if (!empty($newValues)) { - if ($keepExisting && !empty($links)) { - foreach ($links as $link) { - $oldJoin = $link[$join][$this->hasAndBelongsToMany[$assoc]['associationForeignKey']]; - if (! in_array($oldJoin, $newJoins) ) { - $conditions[$associationForeignKey] = $oldJoin; - $db->delete($this->{$join}, $conditions); - } else { - unset($newValues[$oldJoin]); - } - } - $newValues = array_values($newValues); - } - if (!empty($newValues)) { - $dbMulti->insertMulti($this->{$join}, $fields, $newValues); - } - } - } - } - } - -/** - * Updates the counter cache of belongsTo associations after a save or delete operation - * - * @param array $keys Optional foreign key data, defaults to the information $this->data - * @param boolean $created True if a new record was created, otherwise only associations with - * 'counterScope' defined get updated - * @return void - */ - public function updateCounterCache($keys = array(), $created = false) { - $keys = empty($keys) ? $this->data[$this->alias] : $keys; - $keys['old'] = isset($keys['old']) ? $keys['old'] : array(); - - foreach ($this->belongsTo as $parent => $assoc) { - if (!empty($assoc['counterCache'])) { - if (!is_array($assoc['counterCache'])) { - if (isset($assoc['counterScope'])) { - $assoc['counterCache'] = array($assoc['counterCache'] => $assoc['counterScope']); - } else { - $assoc['counterCache'] = array($assoc['counterCache'] => array()); - } - } - - $foreignKey = $assoc['foreignKey']; - $fkQuoted = $this->escapeField($assoc['foreignKey']); - - foreach ($assoc['counterCache'] as $field => $conditions) { - if (!is_string($field)) { - $field = Inflector::underscore($this->alias) . '_count'; - } - if (!$this->{$parent}->hasField($field)) { - continue; - } - if ($conditions === true) { - $conditions = array(); - } else { - $conditions = (array)$conditions; - } - - if (!array_key_exists($foreignKey, $keys)) { - $keys[$foreignKey] = $this->field($foreignKey); - } - $recursive = (empty($conditions) ? -1 : 0); - - if (isset($keys['old'][$foreignKey])) { - if ($keys['old'][$foreignKey] != $keys[$foreignKey]) { - $conditions[$fkQuoted] = $keys['old'][$foreignKey]; - $count = intval($this->find('count', compact('conditions', 'recursive'))); - - $this->{$parent}->updateAll( - array($field => $count), - array($this->{$parent}->escapeField() => $keys['old'][$foreignKey]) - ); - } - } - $conditions[$fkQuoted] = $keys[$foreignKey]; - - if ($recursive === 0) { - $conditions = array_merge($conditions, (array)$conditions); - } - $count = intval($this->find('count', compact('conditions', 'recursive'))); - - $this->{$parent}->updateAll( - array($field => $count), - array($this->{$parent}->escapeField() => $keys[$foreignKey]) - ); - } - } - } - } - -/** - * Helper method for Model::updateCounterCache(). Checks the fields to be updated for - * - * @param array $data The fields of the record that will be updated - * @return array Returns updated foreign key values, along with an 'old' key containing the old - * values, or empty if no foreign keys are updated. - */ - protected function _prepareUpdateFields($data) { - $foreignKeys = array(); - foreach ($this->belongsTo as $assoc => $info) { - if ($info['counterCache']) { - $foreignKeys[$assoc] = $info['foreignKey']; - } - } - $included = array_intersect($foreignKeys, array_keys($data)); - - if (empty($included) || empty($this->id)) { - return array(); - } - $old = $this->find('first', array( - 'conditions' => array($this->alias . '.' . $this->primaryKey => $this->id), - 'fields' => array_values($included), - 'recursive' => -1 - )); - return array_merge($data, array('old' => $old[$this->alias])); - } - -/** - * Backwards compatible passthrough method for: - * saveMany(), validateMany(), saveAssociated() and validateAssociated() - * - * Saves multiple individual records for a single model; Also works with a single record, as well as - * all its associated records. - * - * #### Options - * - * - validate: Set to false to disable validation, true to validate each record before saving, - * 'first' to validate *all* records before any are saved (default), - * or 'only' to only validate the records, but not save them. - * - atomic: If true (default), will attempt to save all records in a single transaction. - * Should be set to false if database/table does not support transactions. - * - fieldList: Equivalent to the $fieldList parameter in Model::save(). - * It should be an associate array with model name as key and array of fields as value. Eg. - * {{{ - * array( - * 'SomeModel' => array('field'), - * 'AssociatedModel' => array('field', 'otherfield') - * ) - * }}} - * - deep: see saveMany/saveAssociated - * - * @param array $data Record data to save. This can be either a numerically-indexed array (for saving multiple - * records of the same type), or an array indexed by association name. - * @param array $options Options to use when saving record data, See $options above. - * @return mixed If atomic: True on success, or false on failure. - * Otherwise: array similar to the $data array passed, but values are set to true/false - * depending on whether each record saved successfully. - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-saveassociated-array-data-null-array-options-array - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-saveall-array-data-null-array-options-array - */ - public function saveAll($data = null, $options = array()) { - $options = array_merge(array('validate' => 'first'), $options); - if (Set::numeric(array_keys($data))) { - if ($options['validate'] === 'only') { - return $this->validateMany($data, $options); - } - return $this->saveMany($data, $options); - } - if ($options['validate'] === 'only') { - return $this->validateAssociated($data, $options); - } - return $this->saveAssociated($data, $options); - } - -/** - * Saves multiple individual records for a single model - * - * #### Options - * - * - validate: Set to false to disable validation, true to validate each record before saving, - * 'first' to validate *all* records before any are saved (default), - * - atomic: If true (default), will attempt to save all records in a single transaction. - * Should be set to false if database/table does not support transactions. - * - fieldList: Equivalent to the $fieldList parameter in Model::save() - * - deep: If set to true, all associated data will be saved as well. - * - * @param array $data Record data to save. This should be a numerically-indexed array - * @param array $options Options to use when saving record data, See $options above. - * @return mixed If atomic: True on success, or false on failure. - * Otherwise: array similar to the $data array passed, but values are set to true/false - * depending on whether each record saved successfully. - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-savemany-array-data-null-array-options-array - */ - public function saveMany($data = null, $options = array()) { - if (empty($data)) { - $data = $this->data; - } - - $options = array_merge(array('validate' => 'first', 'atomic' => true, 'deep' => false), $options); - $this->validationErrors = $validationErrors = array(); - - if (empty($data) && $options['validate'] !== false) { - $result = $this->save($data, $options); - return !empty($result); - } - - if ($options['validate'] === 'first') { - $validates = $this->validateMany($data, $options); - if ((!$validates && $options['atomic']) || (!$options['atomic'] && in_array(false, $validates, true))) { - return $validates; - } - $options['validate'] = true; - } - - if ($options['atomic']) { - $db = $this->getDataSource(); - $transactionBegun = $db->begin(); - } - $return = array(); - foreach ($data as $key => $record) { - $validates = $this->create(null) !== null; - $saved = false; - if ($validates) { - if ($options['deep']) { - $saved = $this->saveAssociated($record, array_merge($options, array('atomic' => false))); - } else { - $saved = $this->save($record, $options); - } - } - $validates = ($validates && ($saved === true || (is_array($saved) && !in_array(false, $saved, true)))); - if (!$validates) { - $validationErrors[$key] = $this->validationErrors; - } - if (!$options['atomic']) { - $return[$key] = $validates; - } elseif (!$validates) { - break; - } - } - $this->validationErrors = $validationErrors; - - if (!$options['atomic']) { - return $return; - } - if ($validates) { - if ($transactionBegun) { - return $db->commit() !== false; - } else { - return true; - } - } - $db->rollback(); - return false; - } - -/** - * Validates multiple individual records for a single model - * - * #### Options - * - * - atomic: If true (default), returns boolean. If false returns array. - * - fieldList: Equivalent to the $fieldList parameter in Model::save() - * - deep: If set to true, all associated data will be validated as well. - * - * @param array $data Record data to validate. This should be a numerically-indexed array - * @param array $options Options to use when validating record data (see above), See also $options of validates(). - * @return boolean True on success, or false on failure. - * @return mixed If atomic: True on success, or false on failure. - * Otherwise: array similar to the $data array passed, but values are set to true/false - * depending on whether each record validated successfully. - */ - public function validateMany($data, $options = array()) { - $options = array_merge(array('atomic' => true, 'deep' => false), $options); - $this->validationErrors = $validationErrors = $return = array(); - foreach ($data as $key => $record) { - if ($options['deep']) { - $validates = $this->validateAssociated($record, $options); - } else { - $validates = $this->create($record) && $this->validates($options); - } - if ($validates === false || (is_array($validates) && in_array(false, $validates, true))) { - $validationErrors[$key] = $this->validationErrors; - $validates = false; - } else { - $validates = true; - } - $return[$key] = $validates; - } - $this->validationErrors = $validationErrors; - if (!$options['atomic']) { - return $return; - } - if (empty($this->validationErrors)) { - return true; - } - return false; - } - -/** - * Saves a single record, as well as all its directly associated records. - * - * #### Options - * - * - `validate` Set to `false` to disable validation, `true` to validate each record before saving, - * 'first' to validate *all* records before any are saved(default), - * - `atomic` If true (default), will attempt to save all records in a single transaction. - * Should be set to false if database/table does not support transactions. - * - fieldList: Equivalent to the $fieldList parameter in Model::save(). - * It should be an associate array with model name as key and array of fields as value. Eg. - * {{{ - * array( - * 'SomeModel' => array('field'), - * 'AssociatedModel' => array('field', 'otherfield') - * ) - * }}} - * - deep: If set to true, not only directly associated data is saved, but deeper nested associated data as well. - * - * @param array $data Record data to save. This should be an array indexed by association name. - * @param array $options Options to use when saving record data, See $options above. - * @return mixed If atomic: True on success, or false on failure. - * Otherwise: array similar to the $data array passed, but values are set to true/false - * depending on whether each record saved successfully. - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-saveassociated-array-data-null-array-options-array - */ - public function saveAssociated($data = null, $options = array()) { - if (empty($data)) { - $data = $this->data; - } - - $options = array_merge(array('validate' => 'first', 'atomic' => true, 'deep' => false), $options); - $this->validationErrors = $validationErrors = array(); - - if (empty($data) && $options['validate'] !== false) { - $result = $this->save($data, $options); - return !empty($result); - } - - if ($options['validate'] === 'first') { - $validates = $this->validateAssociated($data, $options); - if ((!$validates && $options['atomic']) || (!$options['atomic'] && in_array(false, $validates, true))) { - return $validates; - } - $options['validate'] = true; - } - if ($options['atomic']) { - $db = $this->getDataSource(); - $transactionBegun = $db->begin(); - } - $associations = $this->getAssociated(); - $return = array(); - $validates = true; - foreach ($data as $association => $values) { - if (isset($associations[$association]) && $associations[$association] === 'belongsTo') { - $validates = $this->{$association}->create(null) !== null; - $saved = false; - if ($validates) { - if ($options['deep']) { - $saved = $this->{$association}->saveAssociated($values, array_merge($options, array('atomic' => false))); - } else { - $saved = $this->{$association}->save($values, array_merge($options, array('atomic' => false))); - } - $validates = ($saved === true || (is_array($saved) && !in_array(false, $saved, true))); - } - if ($validates) { - $key = $this->belongsTo[$association]['foreignKey']; - if (isset($data[$this->alias])) { - $data[$this->alias][$key] = $this->{$association}->id; - } else { - $data = array_merge(array($key => $this->{$association}->id), $data, array($key => $this->{$association}->id)); - } - } else { - $validationErrors[$association] = $this->{$association}->validationErrors; - } - $return[$association] = $validates; - } - } - if ($validates && !($this->create(null) !== null && $this->save($data, $options))) { - $validationErrors[$this->alias] = $this->validationErrors; - $validates = false; - } - $return[$this->alias] = $validates; - - foreach ($data as $association => $values) { - if (!$validates) { - break; - } - if (isset($associations[$association])) { - $type = $associations[$association]; - $key = $this->{$type}[$association]['foreignKey']; - switch ($type) { - case 'hasOne': - if (isset($values[$association])) { - $values[$association][$key] = $this->id; - } else { - $values = array_merge(array($key => $this->id), $values, array($key => $this->id)); - } - $validates = $this->{$association}->create(null) !== null; - $saved = false; - if ($validates) { - if ($options['deep']) { - $saved = $this->{$association}->saveAssociated($values, array_merge($options, array('atomic' => false))); - } else { - $saved = $this->{$association}->save($values, $options); - } - } - $validates = ($validates && ($saved === true || (is_array($saved) && !in_array(false, $saved, true)))); - if (!$validates) { - $validationErrors[$association] = $this->{$association}->validationErrors; - } - $return[$association] = $validates; - break; - case 'hasMany': - foreach ($values as $i => $value) { - if (isset($values[$i][$association])) { - $values[$i][$association][$key] = $this->id; - } else { - $values[$i] = array_merge(array($key => $this->id), $value, array($key => $this->id)); - } - } - $_return = $this->{$association}->saveMany($values, array_merge($options, array('atomic' => false))); - if (in_array(false, $_return, true)) { - $validationErrors[$association] = $this->{$association}->validationErrors; - $validates = false; - } - $return[$association] = $_return; - break; - } - } - } - $this->validationErrors = $validationErrors; - - if (isset($validationErrors[$this->alias])) { - $this->validationErrors = $validationErrors[$this->alias]; - } - - if (!$options['atomic']) { - return $return; - } - if ($validates) { - if ($transactionBegun) { - return $db->commit() !== false; - } else { - return true; - } - } - $db->rollback(); - return false; - } - -/** - * Validates a single record, as well as all its directly associated records. - * - * #### Options - * - * - atomic: If true (default), returns boolean. If false returns array. - * - fieldList: Equivalent to the $fieldList parameter in Model::save() - * - deep: If set to true, not only directly associated data , but deeper nested associated data is validated as well. - * - * @param array $data Record data to validate. This should be an array indexed by association name. - * @param array $options Options to use when validating record data (see above), See also $options of validates(). - * @return array|boolean If atomic: True on success, or false on failure. - * Otherwise: array similar to the $data array passed, but values are set to true/false - * depending on whether each record validated successfully. - */ - public function validateAssociated($data, $options = array()) { - $options = array_merge(array('atomic' => true, 'deep' => false), $options); - $this->validationErrors = $validationErrors = $return = array(); - if (!($this->create($data) && $this->validates($options))) { - $validationErrors[$this->alias] = $this->validationErrors; - $return[$this->alias] = false; - } else { - $return[$this->alias] = true; - } - $associations = $this->getAssociated(); - foreach ($data as $association => $values) { - $validates = true; - if (isset($associations[$association])) { - if (in_array($associations[$association], array('belongsTo', 'hasOne'))) { - if ($options['deep']) { - $validates = $this->{$association}->validateAssociated($values, $options); - } else { - $validates = $this->{$association}->create($values) !== null && $this->{$association}->validates($options); - } - if (is_array($validates)) { - if (in_array(false, $validates, true)) { - $validates = false; - } else { - $validates = true; - } - } - $return[$association] = $validates; - } elseif ($associations[$association] === 'hasMany') { - $validates = $this->{$association}->validateMany($values, $options); - $return[$association] = $validates; - } - if (!$validates || (is_array($validates) && in_array(false, $validates, true))) { - $validationErrors[$association] = $this->{$association}->validationErrors; - } - } - } - - $this->validationErrors = $validationErrors; - if (isset($validationErrors[$this->alias])) { - $this->validationErrors = $validationErrors[$this->alias]; - } - if (!$options['atomic']) { - return $return; - } - if ($return[$this->alias] === false || !empty($this->validationErrors)) { - return false; - } - return true; - } - -/** - * Updates multiple model records based on a set of conditions. - * - * @param array $fields Set of fields and values, indexed by fields. - * Fields are treated as SQL snippets, to insert literal values manually escape your data. - * @param mixed $conditions Conditions to match, true for all records - * @return boolean True on success, false on failure - * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html#model-updateall-array-fields-array-conditions - */ - public function updateAll($fields, $conditions = true) { - return $this->getDataSource()->update($this, $fields, null, $conditions); - } - -/** - * Removes record for given ID. If no ID is given, the current ID is used. Returns true on success. - * - * @param mixed $id ID of record to delete - * @param boolean $cascade Set to true to delete records that depend on this record - * @return boolean True on success - * @link http://book.cakephp.org/2.0/en/models/deleting-data.html - */ - public function delete($id = null, $cascade = true) { - if (!empty($id)) { - $this->id = $id; - } - $id = $this->id; - - $event = new CakeEvent('Model.beforeDelete', $this, array($cascade)); - list($event->break, $event->breakOn) = array(true, array(false, null)); - $this->getEventManager()->dispatch($event); - if (!$event->isStopped()) { - if (!$this->exists()) { - return false; - } - $db = $this->getDataSource(); - - $this->_deleteDependent($id, $cascade); - $this->_deleteLinks($id); - $this->id = $id; - - $updateCounterCache = false; - if (!empty($this->belongsTo)) { - foreach ($this->belongsTo as $parent => $assoc) { - if (!empty($assoc['counterCache'])) { - $updateCounterCache = true; - break; - } - } - - $keys = $this->find('first', array( - 'fields' => $this->_collectForeignKeys(), - 'conditions' => array($this->alias . '.' . $this->primaryKey => $id), - 'recursive' => -1, - 'callbacks' => false - )); - } - - if ($db->delete($this, array($this->alias . '.' . $this->primaryKey => $id))) { - if ($updateCounterCache) { - $this->updateCounterCache($keys[$this->alias]); - } - $this->getEventManager()->dispatch(new CakeEvent('Model.afterDelete', $this)); - $this->_clearCache(); - $this->id = false; - return true; - } - } - return false; - } - -/** - * Cascades model deletes through associated hasMany and hasOne child records. - * - * @param string $id ID of record that was deleted - * @param boolean $cascade Set to true to delete records that depend on this record - * @return void - */ - protected function _deleteDependent($id, $cascade) { - if (!empty($this->__backAssociation)) { - $savedAssociatons = $this->__backAssociation; - $this->__backAssociation = array(); - } - if ($cascade === true) { - foreach (array_merge($this->hasMany, $this->hasOne) as $assoc => $data) { - if ($data['dependent'] === true) { - - $model = $this->{$assoc}; - - if ($data['foreignKey'] === false && $data['conditions'] && in_array($this->name, $model->getAssociated('belongsTo'))) { - $model->recursive = 0; - $conditions = array($this->escapeField(null, $this->name) => $id); - } else { - $model->recursive = -1; - $conditions = array($model->escapeField($data['foreignKey']) => $id); - if ($data['conditions']) { - $conditions = array_merge((array)$data['conditions'], $conditions); - } - } - - if (isset($data['exclusive']) && $data['exclusive']) { - $model->deleteAll($conditions); - } else { - $records = $model->find('all', array( - 'conditions' => $conditions, 'fields' => $model->primaryKey - )); - - if (!empty($records)) { - foreach ($records as $record) { - $model->delete($record[$model->alias][$model->primaryKey]); - } - } - } - } - } - } - if (isset($savedAssociatons)) { - $this->__backAssociation = $savedAssociatons; - } - } - -/** - * Cascades model deletes through HABTM join keys. - * - * @param string $id ID of record that was deleted - * @return void - */ - protected function _deleteLinks($id) { - foreach ($this->hasAndBelongsToMany as $assoc => $data) { - list($plugin, $joinModel) = pluginSplit($data['with']); - $records = $this->{$joinModel}->find('all', array( - 'conditions' => array($this->{$joinModel}->escapeField($data['foreignKey']) => $id), - 'fields' => $this->{$joinModel}->primaryKey, - 'recursive' => -1, - 'callbacks' => false - )); - if (!empty($records)) { - foreach ($records as $record) { - $this->{$joinModel}->delete($record[$this->{$joinModel}->alias][$this->{$joinModel}->primaryKey]); - } - } - } - } - -/** - * Deletes multiple model records based on a set of conditions. - * - * @param mixed $conditions Conditions to match - * @param boolean $cascade Set to true to delete records that depend on this record - * @param boolean $callbacks Run callbacks - * @return boolean True on success, false on failure - * @link http://book.cakephp.org/2.0/en/models/deleting-data.html#deleteall - */ - public function deleteAll($conditions, $cascade = true, $callbacks = false) { - if (empty($conditions)) { - return false; - } - $db = $this->getDataSource(); - - if (!$cascade && !$callbacks) { - return $db->delete($this, $conditions); - } else { - $ids = $this->find('all', array_merge(array( - 'fields' => "{$this->alias}.{$this->primaryKey}", - 'recursive' => 0), compact('conditions')) - ); - if ($ids === false) { - return false; - } - - $ids = Set::extract($ids, "{n}.{$this->alias}.{$this->primaryKey}"); - if (empty($ids)) { - return true; - } - - if ($callbacks) { - $_id = $this->id; - $result = true; - foreach ($ids as $id) { - $result = ($result && $this->delete($id, $cascade)); - } - $this->id = $_id; - return $result; - } else { - foreach ($ids as $id) { - $this->_deleteLinks($id); - if ($cascade) { - $this->_deleteDependent($id, $cascade); - } - } - return $db->delete($this, array($this->alias . '.' . $this->primaryKey => $ids)); - } - } - } - -/** - * Collects foreign keys from associations. - * - * @param string $type - * @return array - */ - protected function _collectForeignKeys($type = 'belongsTo') { - $result = array(); - - foreach ($this->{$type} as $assoc => $data) { - if (isset($data['foreignKey']) && is_string($data['foreignKey'])) { - $result[$assoc] = $data['foreignKey']; - } - } - return $result; - } - -/** - * Returns true if a record with particular ID exists. - * - * If $id is not passed it calls Model::getID() to obtain the current record ID, - * and then performs a Model::find('count') on the currently configured datasource - * to ascertain the existence of the record in persistent storage. - * - * @param mixed $id ID of record to check for existence - * @return boolean True if such a record exists - */ - public function exists($id = null) { - if ($id === null) { - $id = $this->getID(); - } - if ($id === false) { - return false; - } - $conditions = array($this->alias . '.' . $this->primaryKey => $id); - $query = array('conditions' => $conditions, 'recursive' => -1, 'callbacks' => false); - return ($this->find('count', $query) > 0); - } - -/** - * Returns true if a record that meets given conditions exists. - * - * @param array $conditions SQL conditions array - * @return boolean True if such a record exists - */ - public function hasAny($conditions = null) { - return ($this->find('count', array('conditions' => $conditions, 'recursive' => -1)) != false); - } - -/** - * Queries the datasource and returns a result set array. - * - * Also used to perform notation finds, where the first argument is type of find operation to perform - * (all / first / count / neighbors / list / threaded), - * second parameter options for finding ( indexed array, including: 'conditions', 'limit', - * 'recursive', 'page', 'fields', 'offset', 'order') - * - * Eg: - * {{{ - * find('all', array( - * 'conditions' => array('name' => 'Thomas Anderson'), - * 'fields' => array('name', 'email'), - * 'order' => 'field3 DESC', - * 'recursive' => 2, - * 'group' => 'type' - * )); - * }}} - * - * In addition to the standard query keys above, you can provide Datasource, and behavior specific - * keys. For example, when using a SQL based datasource you can use the joins key to specify additional - * joins that should be part of the query. - * - * {{{ - * find('all', array( - * 'conditions' => array('name' => 'Thomas Anderson'), - * 'joins' => array( - * array( - * 'alias' => 'Thought', - * 'table' => 'thoughts', - * 'type' => 'LEFT', - * 'conditions' => '`Thought`.`person_id` = `Person`.`id`' - * ) - * ) - * )); - * }}} - * - * Behaviors and find types can also define custom finder keys which are passed into find(). - * - * Specifying 'fields' for notation 'list': - * - * - If no fields are specified, then 'id' is used for key and 'model->displayField' is used for value. - * - If a single field is specified, 'id' is used for key and specified field is used for value. - * - If three fields are specified, they are used (in order) for key, value and group. - * - Otherwise, first and second fields are used for key and value. - * - * Note: find(list) + database views have issues with MySQL 5.0. Try upgrading to MySQL 5.1 if you - * have issues with database views. - * @param string $type Type of find operation (all / first / count / neighbors / list / threaded) - * @param array $query Option fields (conditions / fields / joins / limit / offset / order / page / group / callbacks) - * @return array Array of records - * @link http://book.cakephp.org/2.0/en/models/deleting-data.html#deleteall - */ - public function find($type = 'first', $query = array()) { - $this->findQueryType = $type; - $this->id = $this->getID(); - - $query = $this->buildQuery($type, $query); - if (is_null($query)) { - return null; - } - - $results = $this->getDataSource()->read($this, $query); - $this->resetAssociations(); - - if ($query['callbacks'] === true || $query['callbacks'] === 'after') { - $results = $this->_filterResults($results); - } - - $this->findQueryType = null; - - if ($type === 'all') { - return $results; - } else { - if ($this->findMethods[$type] === true) { - return $this->{'_find' . ucfirst($type)}('after', $query, $results); - } - } - } - -/** - * Builds the query array that is used by the data source to generate the query to fetch the data. - * - * @param string $type Type of find operation (all / first / count / neighbors / list / threaded) - * @param array $query Option fields (conditions / fields / joins / limit / offset / order / page / group / callbacks) - * @return array Query array or null if it could not be build for some reasons - * @see Model::find() - */ - public function buildQuery($type = 'first', $query = array()) { - $query = array_merge( - array( - 'conditions' => null, 'fields' => null, 'joins' => array(), 'limit' => null, - 'offset' => null, 'order' => null, 'page' => 1, 'group' => null, 'callbacks' => true, - ), - (array)$query - ); - - if ($type !== 'all') { - if ($this->findMethods[$type] === true) { - $query = $this->{'_find' . ucfirst($type)}('before', $query); - } - } - - if (!is_numeric($query['page']) || intval($query['page']) < 1) { - $query['page'] = 1; - } - if ($query['page'] > 1 && !empty($query['limit'])) { - $query['offset'] = ($query['page'] - 1) * $query['limit']; - } - if ($query['order'] === null && $this->order !== null) { - $query['order'] = $this->order; - } - $query['order'] = array($query['order']); - - if ($query['callbacks'] === true || $query['callbacks'] === 'before') { - $event = new CakeEvent('Model.beforeFind', $this, array($query)); - list($event->break, $event->breakOn, $event->modParams) = array(true, array(false, null), 0); - $this->getEventManager()->dispatch($event); - if ($event->isStopped()) { - return null; - } - $query = $event->result === true ? $event->data[0] : $event->result; - } - - return $query; - } - -/** - * Handles the before/after filter logic for find('first') operations. Only called by Model::find(). - * - * @param string $state Either "before" or "after" - * @param array $query - * @param array $results - * @return array - * @see Model::find() - */ - protected function _findFirst($state, $query, $results = array()) { - if ($state === 'before') { - $query['limit'] = 1; - return $query; - } elseif ($state === 'after') { - if (empty($results[0])) { - return false; - } - return $results[0]; - } - } - -/** - * Handles the before/after filter logic for find('count') operations. Only called by Model::find(). - * - * @param string $state Either "before" or "after" - * @param array $query - * @param array $results - * @return integer The number of records found, or false - * @see Model::find() - */ - protected function _findCount($state, $query, $results = array()) { - if ($state === 'before') { - $db = $this->getDataSource(); - $query['order'] = false; - if (!method_exists($db, 'calculate') || !method_exists($db, 'expression')) { - return $query; - } - if (empty($query['fields'])) { - $query['fields'] = $db->calculate($this, 'count'); - } elseif (is_string($query['fields']) && !preg_match('/count/i', $query['fields'])) { - $query['fields'] = $db->calculate($this, 'count', array( - $db->expression($query['fields']), 'count' - )); - } - return $query; - } elseif ($state === 'after') { - foreach (array(0, $this->alias) as $key) { - if (isset($results[0][$key]['count'])) { - if (($count = count($results)) > 1) { - return $count; - } else { - return intval($results[0][$key]['count']); - } - } - } - return false; - } - } - -/** - * Handles the before/after filter logic for find('list') operations. Only called by Model::find(). - * - * @param string $state Either "before" or "after" - * @param array $query - * @param array $results - * @return array Key/value pairs of primary keys/display field values of all records found - * @see Model::find() - */ - protected function _findList($state, $query, $results = array()) { - if ($state === 'before') { - if (empty($query['fields'])) { - $query['fields'] = array("{$this->alias}.{$this->primaryKey}", "{$this->alias}.{$this->displayField}"); - $list = array("{n}.{$this->alias}.{$this->primaryKey}", "{n}.{$this->alias}.{$this->displayField}", null); - } else { - if (!is_array($query['fields'])) { - $query['fields'] = String::tokenize($query['fields']); - } - - if (count($query['fields']) === 1) { - if (strpos($query['fields'][0], '.') === false) { - $query['fields'][0] = $this->alias . '.' . $query['fields'][0]; - } - - $list = array("{n}.{$this->alias}.{$this->primaryKey}", '{n}.' . $query['fields'][0], null); - $query['fields'] = array("{$this->alias}.{$this->primaryKey}", $query['fields'][0]); - } elseif (count($query['fields']) === 3) { - for ($i = 0; $i < 3; $i++) { - if (strpos($query['fields'][$i], '.') === false) { - $query['fields'][$i] = $this->alias . '.' . $query['fields'][$i]; - } - } - - $list = array('{n}.' . $query['fields'][0], '{n}.' . $query['fields'][1], '{n}.' . $query['fields'][2]); - } else { - for ($i = 0; $i < 2; $i++) { - if (strpos($query['fields'][$i], '.') === false) { - $query['fields'][$i] = $this->alias . '.' . $query['fields'][$i]; - } - } - - $list = array('{n}.' . $query['fields'][0], '{n}.' . $query['fields'][1], null); - } - } - if (!isset($query['recursive']) || $query['recursive'] === null) { - $query['recursive'] = -1; - } - list($query['list']['keyPath'], $query['list']['valuePath'], $query['list']['groupPath']) = $list; - return $query; - } elseif ($state === 'after') { - if (empty($results)) { - return array(); - } - $lst = $query['list']; - return Set::combine($results, $lst['keyPath'], $lst['valuePath'], $lst['groupPath']); - } - } - -/** - * Detects the previous field's value, then uses logic to find the 'wrapping' - * rows and return them. - * - * @param string $state Either "before" or "after" - * @param mixed $query - * @param array $results - * @return array - */ - protected function _findNeighbors($state, $query, $results = array()) { - if ($state === 'before') { - extract($query); - $conditions = (array)$conditions; - if (isset($field) && isset($value)) { - if (strpos($field, '.') === false) { - $field = $this->alias . '.' . $field; - } - } else { - $field = $this->alias . '.' . $this->primaryKey; - $value = $this->id; - } - $query['conditions'] = array_merge($conditions, array($field . ' <' => $value)); - $query['order'] = $field . ' DESC'; - $query['limit'] = 1; - $query['field'] = $field; - $query['value'] = $value; - return $query; - } elseif ($state === 'after') { - extract($query); - unset($query['conditions'][$field . ' <']); - $return = array(); - if (isset($results[0])) { - $prevVal = Set::extract('/' . str_replace('.', '/', $field), $results[0]); - $query['conditions'][$field . ' >='] = $prevVal[0]; - $query['conditions'][$field . ' !='] = $value; - $query['limit'] = 2; - } else { - $return['prev'] = null; - $query['conditions'][$field . ' >'] = $value; - $query['limit'] = 1; - } - $query['order'] = $field . ' ASC'; - $return2 = $this->find('all', $query); - if (!array_key_exists('prev', $return)) { - $return['prev'] = $return2[0]; - } - if (count($return2) === 2) { - $return['next'] = $return2[1]; - } elseif (count($return2) === 1 && !$return['prev']) { - $return['next'] = $return2[0]; - } else { - $return['next'] = null; - } - return $return; - } - } - -/** - * In the event of ambiguous results returned (multiple top level results, with different parent_ids) - * top level results with different parent_ids to the first result will be dropped - * - * @param mixed $state - * @param mixed $query - * @param array $results - * @return array Threaded results - */ - protected function _findThreaded($state, $query, $results = array()) { - if ($state === 'before') { - return $query; - } elseif ($state === 'after') { - $parent = 'parent_id'; - if (isset($query['parent'])) { - $parent = $query['parent']; - } - return Set::nest($results, array( - 'idPath' => '/' . $this->alias . '/' . $this->primaryKey, - 'parentPath' => '/' . $this->alias . '/' . $parent - )); - } - } - -/** - * Passes query results through model and behavior afterFilter() methods. - * - * @param array $results Results to filter - * @param boolean $primary If this is the primary model results (results from model where the find operation was performed) - * @return array Set of filtered results - */ - protected function _filterResults($results, $primary = true) { - $event = new CakeEvent('Model.afterFind', $this, array($results, $primary)); - $event->modParams = 0; - $this->getEventManager()->dispatch($event); - return $event->result; - } - -/** - * This resets the association arrays for the model back - * to those originally defined in the model. Normally called at the end - * of each call to Model::find() - * - * @return boolean Success - */ - public function resetAssociations() { - if (!empty($this->__backAssociation)) { - foreach ($this->_associations as $type) { - if (isset($this->__backAssociation[$type])) { - $this->{$type} = $this->__backAssociation[$type]; - } - } - $this->__backAssociation = array(); - } - - foreach ($this->_associations as $type) { - foreach ($this->{$type} as $key => $name) { - if (property_exists($this, $key) && !empty($this->{$key}->__backAssociation)) { - $this->{$key}->resetAssociations(); - } - } - } - $this->__backAssociation = array(); - return true; - } - -/** - * Returns false if any fields passed match any (by default, all if $or = false) of their matching values. - * - * @param array $fields Field/value pairs to search (if no values specified, they are pulled from $this->data) - * @param boolean $or If false, all fields specified must match in order for a false return value - * @return boolean False if any records matching any fields are found - */ - public function isUnique($fields, $or = true) { - if (!is_array($fields)) { - $fields = func_get_args(); - if (is_bool($fields[count($fields) - 1])) { - $or = $fields[count($fields) - 1]; - unset($fields[count($fields) - 1]); - } - } - - foreach ($fields as $field => $value) { - if (is_numeric($field)) { - unset($fields[$field]); - - $field = $value; - if (isset($this->data[$this->alias][$field])) { - $value = $this->data[$this->alias][$field]; - } else { - $value = null; - } - } - - if (strpos($field, '.') === false) { - unset($fields[$field]); - $fields[$this->alias . '.' . $field] = $value; - } - } - if ($or) { - $fields = array('or' => $fields); - } - if (!empty($this->id)) { - $fields[$this->alias . '.' . $this->primaryKey . ' !='] = $this->id; - } - return ($this->find('count', array('conditions' => $fields, 'recursive' => -1)) == 0); - } - -/** - * Returns a resultset for a given SQL statement. Custom SQL queries should be performed with this method. - * - * @param string $sql,... SQL statement - * @return array Resultset - * @link http://book.cakephp.org/2.0/en/models/retrieving-your-data.html#model-query - */ - public function query($sql) { - $params = func_get_args(); - $db = $this->getDataSource(); - return call_user_func_array(array(&$db, 'query'), $params); - } - -/** - * Returns true if all fields pass validation. Will validate hasAndBelongsToMany associations - * that use the 'with' key as well. Since _saveMulti is incapable of exiting a save operation. - * - * Will validate the currently set data. Use Model::set() or Model::create() to set the active data. - * - * @param array $options An optional array of custom options to be made available in the beforeValidate callback - * @return boolean True if there are no errors - */ - public function validates($options = array()) { - $errors = $this->invalidFields($options); - if (empty($errors) && $errors !== false) { - $errors = $this->_validateWithModels($options); - } - if (is_array($errors)) { - return count($errors) === 0; - } - return $errors; - } - -/** - * Returns an array of fields that have failed validation. On the current model. - * - * @param string $options An optional array of custom options to be made available in the beforeValidate callback - * @return array Array of invalid fields - * @see Model::validates() - */ - public function invalidFields($options = array()) { - $event = new CakeEvent('Model.beforeValidate', $this, array($options)); - list($event->break, $event->breakOn) = array(true, false); - $this->getEventManager()->dispatch($event); - if ($event->isStopped()) { - return false; - } - - if (!isset($this->validate) || empty($this->validate)) { - return $this->validationErrors; - } - - $data = $this->data; - $methods = array_map('strtolower', get_class_methods($this)); - $behaviorMethods = array_keys($this->Behaviors->methods()); - - if (isset($data[$this->alias])) { - $data = $data[$this->alias]; - } elseif (!is_array($data)) { - $data = array(); - } - - $exists = null; - - $_validate = $this->validate; - $whitelist = $this->whitelist; - - if (!empty($options['fieldList'])) { - if (!empty($options['fieldList'][$this->alias]) && is_array($options['fieldList'][$this->alias])) { - $whitelist = $options['fieldList'][$this->alias]; - } else { - $whitelist = $options['fieldList']; - } - } - - if (!empty($whitelist)) { - $validate = array(); - foreach ((array)$whitelist as $f) { - if (!empty($this->validate[$f])) { - $validate[$f] = $this->validate[$f]; - } - } - $this->validate = $validate; - } - - $validationDomain = $this->validationDomain; - if (empty($validationDomain)) { - $validationDomain = 'default'; - } - - foreach ($this->validate as $fieldName => $ruleSet) { - if (!is_array($ruleSet) || (is_array($ruleSet) && isset($ruleSet['rule']))) { - $ruleSet = array($ruleSet); - } - $default = array( - 'allowEmpty' => null, - 'required' => null, - 'rule' => 'blank', - 'last' => true, - 'on' => null - ); - - foreach ($ruleSet as $index => $validator) { - if (!is_array($validator)) { - $validator = array('rule' => $validator); - } - $validator = array_merge($default, $validator); - - if (!empty($validator['on']) || in_array($validator['required'], array('create', 'update'), true)) { - if ($exists === null) { - $exists = $this->exists(); - } - if ($validator['on'] == 'create' && $exists || $validator['on'] == 'update' && !$exists) { - continue; - } - if ($validator['required'] === 'create' && !$exists || $validator['required'] === 'update' && $exists) { - $validator['required'] = true; - } - } - - $valid = true; - $requiredFail = ( - (!isset($data[$fieldName]) && $validator['required'] === true) || - ( - isset($data[$fieldName]) && (empty($data[$fieldName]) && - !is_numeric($data[$fieldName])) && $validator['allowEmpty'] === false - ) - ); - - if (!$requiredFail && array_key_exists($fieldName, $data)) { - if (empty($data[$fieldName]) && $data[$fieldName] != '0' && $validator['allowEmpty'] === true) { - break; - } - if (is_array($validator['rule'])) { - $rule = $validator['rule'][0]; - unset($validator['rule'][0]); - $ruleParams = array_merge(array($data[$fieldName]), array_values($validator['rule'])); - } else { - $rule = $validator['rule']; - $ruleParams = array($data[$fieldName]); - } - - if (in_array(strtolower($rule), $methods)) { - $ruleParams[] = $validator; - $ruleParams[0] = array($fieldName => $ruleParams[0]); - $valid = $this->dispatchMethod($rule, $ruleParams); - } elseif (in_array($rule, $behaviorMethods) || in_array(strtolower($rule), $behaviorMethods)) { - $ruleParams[] = $validator; - $ruleParams[0] = array($fieldName => $ruleParams[0]); - $valid = $this->Behaviors->dispatchMethod($this, $rule, $ruleParams); - } elseif (method_exists('Validation', $rule)) { - $valid = call_user_func_array(array('Validation', $rule), $ruleParams); - } elseif (!is_array($validator['rule'])) { - $valid = preg_match($rule, $data[$fieldName]); - } elseif (Configure::read('debug') > 0) { - trigger_error(__d('cake_dev', 'Could not find validation handler %s for %s', $rule, $fieldName), E_USER_WARNING); - } - } - - if ($requiredFail || !$valid || (is_string($valid) && strlen($valid) > 0)) { - if (is_string($valid)) { - $message = $valid; - } elseif (isset($validator['message'])) { - $args = null; - if (is_array($validator['message'])) { - $message = $validator['message'][0]; - $args = array_slice($validator['message'], 1); - } else { - $message = $validator['message']; - } - if (is_array($validator['rule']) && $args === null) { - $args = array_slice($ruleSet[$index]['rule'], 1); - } - $message = __d($validationDomain, $message, $args); - } elseif (is_string($index)) { - if (is_array($validator['rule'])) { - $args = array_slice($ruleSet[$index]['rule'], 1); - $message = __d($validationDomain, $index, $args); - } else { - $message = __d($validationDomain, $index); - } - } elseif (!$requiredFail && is_numeric($index) && count($ruleSet) > 1) { - $message = $index + 1; - } else { - $message = __d('cake_dev', 'This field cannot be left blank'); - } - - $this->invalidate($fieldName, $message); - if ($validator['last']) { - break; - } - } - } - } - $this->validate = $_validate; - return $this->validationErrors; - } - -/** - * Runs validation for hasAndBelongsToMany associations that have 'with' keys - * set. And data in the set() data set. - * - * @param array $options Array of options to use on Validation of with models - * @return boolean Failure of validation on with models. - * @see Model::validates() - */ - protected function _validateWithModels($options) { - $valid = true; - foreach ($this->hasAndBelongsToMany as $assoc => $association) { - if (empty($association['with']) || !isset($this->data[$assoc])) { - continue; - } - list($join) = $this->joinModel($this->hasAndBelongsToMany[$assoc]['with']); - $data = $this->data[$assoc]; - - $newData = array(); - foreach ((array)$data as $row) { - if (isset($row[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])) { - $newData[] = $row; - } elseif (isset($row[$join]) && isset($row[$join][$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])) { - $newData[] = $row[$join]; - } - } - if (empty($newData)) { - continue; - } - foreach ($newData as $data) { - $data[$this->hasAndBelongsToMany[$assoc]['foreignKey']] = $this->id; - $this->{$join}->create($data); - $valid = ($valid && $this->{$join}->validates($options)); - } - } - return $valid; - } - -/** - * Marks a field as invalid, optionally setting the name of validation - * rule (in case of multiple validation for field) that was broken. - * - * @param string $field The name of the field to invalidate - * @param mixed $value Name of validation rule that was not failed, or validation message to - * be returned. If no validation key is provided, defaults to true. - * @return void - */ - public function invalidate($field, $value = true) { - if (!is_array($this->validationErrors)) { - $this->validationErrors = array(); - } - $this->validationErrors[$field][] = $value; - } - -/** - * Returns true if given field name is a foreign key in this model. - * - * @param string $field Returns true if the input string ends in "_id" - * @return boolean True if the field is a foreign key listed in the belongsTo array. - */ - public function isForeignKey($field) { - $foreignKeys = array(); - if (!empty($this->belongsTo)) { - foreach ($this->belongsTo as $assoc => $data) { - $foreignKeys[] = $data['foreignKey']; - } - } - return in_array($field, $foreignKeys); - } - -/** - * Escapes the field name and prepends the model name. Escaping is done according to the - * current database driver's rules. - * - * @param string $field Field to escape (e.g: id) - * @param string $alias Alias for the model (e.g: Post) - * @return string The name of the escaped field for this Model (i.e. id becomes `Post`.`id`). - */ - public function escapeField($field = null, $alias = null) { - if (empty($alias)) { - $alias = $this->alias; - } - if (empty($field)) { - $field = $this->primaryKey; - } - $db = $this->getDataSource(); - if (strpos($field, $db->name($alias) . '.') === 0) { - return $field; - } - return $db->name($alias . '.' . $field); - } - -/** - * Returns the current record's ID - * - * @param integer $list Index on which the composed ID is located - * @return mixed The ID of the current record, false if no ID - */ - public function getID($list = 0) { - if (empty($this->id) || (is_array($this->id) && isset($this->id[0]) && empty($this->id[0]))) { - return false; - } - - if (!is_array($this->id)) { - return $this->id; - } - - if (isset($this->id[$list]) && !empty($this->id[$list])) { - return $this->id[$list]; - } elseif (isset($this->id[$list])) { - return false; - } - - return current($this->id); - } - -/** - * Returns the ID of the last record this model inserted. - * - * @return mixed Last inserted ID - */ - public function getLastInsertID() { - return $this->getInsertID(); - } - -/** - * Returns the ID of the last record this model inserted. - * - * @return mixed Last inserted ID - */ - public function getInsertID() { - return $this->_insertID; - } - -/** - * Sets the ID of the last record this model inserted - * - * @param mixed $id Last inserted ID - * @return void - */ - public function setInsertID($id) { - $this->_insertID = $id; - } - -/** - * Returns the number of rows returned from the last query. - * - * @return integer Number of rows - */ - public function getNumRows() { - return $this->getDataSource()->lastNumRows(); - } - -/** - * Returns the number of rows affected by the last query. - * - * @return integer Number of rows - */ - public function getAffectedRows() { - return $this->getDataSource()->lastAffected(); - } - -/** - * Sets the DataSource to which this model is bound. - * - * @param string $dataSource The name of the DataSource, as defined in app/Config/database.php - * @return boolean True on success - * @throws MissingConnectionException - */ - public function setDataSource($dataSource = null) { - $oldConfig = $this->useDbConfig; - - if ($dataSource != null) { - $this->useDbConfig = $dataSource; - } - $db = ConnectionManager::getDataSource($this->useDbConfig); - if (!empty($oldConfig) && isset($db->config['prefix'])) { - $oldDb = ConnectionManager::getDataSource($oldConfig); - - if (!isset($this->tablePrefix) || (!isset($oldDb->config['prefix']) || $this->tablePrefix == $oldDb->config['prefix'])) { - $this->tablePrefix = $db->config['prefix']; - } - } elseif (isset($db->config['prefix'])) { - $this->tablePrefix = $db->config['prefix']; - } - - $this->schemaName = $db->getSchemaName(); - - if (empty($db) || !is_object($db)) { - throw new MissingConnectionException(array('class' => $this->name)); - } - } - -/** - * Gets the DataSource to which this model is bound. - * - * @return DataSource A DataSource object - */ - public function getDataSource() { - if (!$this->_sourceConfigured && $this->useTable !== false) { - $this->_sourceConfigured = true; - $this->setSource($this->useTable); - } - return ConnectionManager::getDataSource($this->useDbConfig); - } - -/** - * Get associations - * - * @return array - */ - public function associations() { - return $this->_associations; - } - -/** - * Gets all the models with which this model is associated. - * - * @param string $type Only result associations of this type - * @return array Associations - */ - public function getAssociated($type = null) { - if ($type == null) { - $associated = array(); - foreach ($this->_associations as $assoc) { - if (!empty($this->{$assoc})) { - $models = array_keys($this->{$assoc}); - foreach ($models as $m) { - $associated[$m] = $assoc; - } - } - } - return $associated; - } elseif (in_array($type, $this->_associations)) { - if (empty($this->{$type})) { - return array(); - } - return array_keys($this->{$type}); - } else { - $assoc = array_merge( - $this->hasOne, - $this->hasMany, - $this->belongsTo, - $this->hasAndBelongsToMany - ); - if (array_key_exists($type, $assoc)) { - foreach ($this->_associations as $a) { - if (isset($this->{$a}[$type])) { - $assoc[$type]['association'] = $a; - break; - } - } - return $assoc[$type]; - } - return null; - } - } - -/** - * Gets the name and fields to be used by a join model. This allows specifying join fields - * in the association definition. - * - * @param string|array $assoc The model to be joined - * @param array $keys Any join keys which must be merged with the keys queried - * @return array - */ - public function joinModel($assoc, $keys = array()) { - if (is_string($assoc)) { - list(, $assoc) = pluginSplit($assoc); - return array($assoc, array_keys($this->{$assoc}->schema())); - } elseif (is_array($assoc)) { - $with = key($assoc); - return array($with, array_unique(array_merge($assoc[$with], $keys))); - } - trigger_error( - __d('cake_dev', 'Invalid join model settings in %s', $model->alias), - E_USER_WARNING - ); - } - -/** - * Called before each find operation. Return false if you want to halt the find - * call, otherwise return the (modified) query data. - * - * @param array $queryData Data used to execute this query, i.e. conditions, order, etc. - * @return mixed true if the operation should continue, false if it should abort; or, modified - * $queryData to continue with new $queryData - * @link http://book.cakephp.org/view/1048/Callback-Methods#beforeFind-1049 - */ - public function beforeFind($queryData) { - return true; - } - -/** - * Called after each find operation. Can be used to modify any results returned by find(). - * Return value should be the (modified) results. - * - * @param mixed $results The results of the find operation - * @param boolean $primary Whether this model is being queried directly (vs. being queried as an association) - * @return mixed Result of the find operation - * @link http://book.cakephp.org/view/1048/Callback-Methods#afterFind-1050 - */ - public function afterFind($results, $primary = false) { - return $results; - } - -/** - * Called before each save operation, after validation. Return a non-true result - * to halt the save. - * - * @param array $options - * @return boolean True if the operation should continue, false if it should abort - * @link http://book.cakephp.org/view/1048/Callback-Methods#beforeSave-1052 - */ - public function beforeSave($options = array()) { - return true; - } - -/** - * Called after each successful save operation. - * - * @param boolean $created True if this save created a new record - * @return void - * @link http://book.cakephp.org/view/1048/Callback-Methods#afterSave-1053 - */ - public function afterSave($created) { - } - -/** - * Called before every deletion operation. - * - * @param boolean $cascade If true records that depend on this record will also be deleted - * @return boolean True if the operation should continue, false if it should abort - * @link http://book.cakephp.org/view/1048/Callback-Methods#beforeDelete-1054 - */ - public function beforeDelete($cascade = true) { - return true; - } - -/** - * Called after every deletion operation. - * - * @return void - * @link http://book.cakephp.org/view/1048/Callback-Methods#afterDelete-1055 - */ - public function afterDelete() { - } - -/** - * Called during validation operations, before validation. Please note that custom - * validation rules can be defined in $validate. - * - * @param array $options Options passed from model::save(), see $options of model::save(). - * @return boolean True if validate operation should continue, false to abort - * @link http://book.cakephp.org/view/1048/Callback-Methods#beforeValidate-1051 - */ - public function beforeValidate($options = array()) { - return true; - } - -/** - * Called when a DataSource-level error occurs. - * - * @return void - * @link http://book.cakephp.org/view/1048/Callback-Methods#onError-1056 - */ - public function onError() { - } - -/** - * Clears cache for this model. - * - * @param string $type If null this deletes cached views if Cache.check is true - * Will be used to allow deleting query cache also - * @return boolean true on delete - */ - protected function _clearCache($type = null) { - if ($type === null) { - if (Configure::read('Cache.check') === true) { - $assoc[] = strtolower(Inflector::pluralize($this->alias)); - $assoc[] = strtolower(Inflector::underscore(Inflector::pluralize($this->alias))); - foreach ($this->_associations as $key => $association) { - foreach ($this->$association as $key => $className) { - $check = strtolower(Inflector::pluralize($className['className'])); - if (!in_array($check, $assoc)) { - $assoc[] = strtolower(Inflector::pluralize($className['className'])); - $assoc[] = strtolower(Inflector::underscore(Inflector::pluralize($className['className']))); - } - } - } - clearCache($assoc); - return true; - } - } else { - //Will use for query cache deleting - } - } - -} diff --git a/lib/Cake/Model/ModelBehavior.php b/lib/Cake/Model/ModelBehavior.php deleted file mode 100644 index 0ad9a4da9d4..00000000000 --- a/lib/Cake/Model/ModelBehavior.php +++ /dev/null @@ -1,225 +0,0 @@ -Model->doSomething($arg1, $arg2);`. - * - * ### Mapped methods - * - * Behaviors can also define mapped methods. Mapped methods use pattern matching for method invocation. This - * allows you to create methods similar to Model::findAllByXXX methods on your behaviors. Mapped methods need to - * be declared in your behaviors `$mapMethods` array. The method signature for a mapped method is slightly different - * than a normal behavior mixin method. - * - * {{{ - * public $mapMethods = array('/do(\w+)/' => 'doSomething'); - * - * function doSomething(Model $model, $method, $arg1, $arg2) { - * //do something - * } - * }}} - * - * The above will map every doXXX() method call to the behavior. As you can see, the model is - * still the first parameter, but the called method name will be the 2nd parameter. This allows - * you to munge the method name for additional information, much like Model::findAllByXX. - * - * @package Cake.Model - * @see Model::$actsAs - * @see BehaviorCollection::load() - */ -class ModelBehavior extends Object { - -/** - * Contains configuration settings for use with individual model objects. This - * is used because if multiple models use this Behavior, each will use the same - * object instance. Individual model settings should be stored as an - * associative array, keyed off of the model name. - * - * @var array - * @see Model::$alias - */ - public $settings = array(); - -/** - * Allows the mapping of preg-compatible regular expressions to public or - * private methods in this class, where the array key is a /-delimited regular - * expression, and the value is a class method. Similar to the functionality of - * the findBy* / findAllBy* magic methods. - * - * @var array - */ - public $mapMethods = array(); - -/** - * Setup this behavior with the specified configuration settings. - * - * @param Model $model Model using this behavior - * @param array $config Configuration settings for $model - * @return void - */ - public function setup(Model $model, $config = array()) { - } - -/** - * Clean up any initialization this behavior has done on a model. Called when a behavior is dynamically - * detached from a model using Model::detach(). - * - * @param Model $model Model using this behavior - * @return void - * @see BehaviorCollection::detach() - */ - public function cleanup(Model $model) { - if (isset($this->settings[$model->alias])) { - unset($this->settings[$model->alias]); - } - } - -/** - * beforeFind can be used to cancel find operations, or modify the query that will be executed. - * By returning null/false you can abort a find. By returning an array you can modify/replace the query - * that is going to be run. - * - * @param Model $model Model using this behavior - * @param array $query Data used to execute this query, i.e. conditions, order, etc. - * @return boolean|array False or null will abort the operation. You can return an array to replace the - * $query that will be eventually run. - */ - public function beforeFind(Model $model, $query) { - return true; - } - -/** - * After find callback. Can be used to modify any results returned by find. - * - * @param Model $model Model using this behavior - * @param mixed $results The results of the find operation - * @param boolean $primary Whether this model is being queried directly (vs. being queried as an association) - * @return mixed An array value will replace the value of $results - any other value will be ignored. - */ - public function afterFind(Model $model, $results, $primary) { - } - -/** - * beforeValidate is called before a model is validated, you can use this callback to - * add behavior validation rules into a models validate array. Returning false - * will allow you to make the validation fail. - * - * @param Model $model Model using this behavior - * @return mixed False or null will abort the operation. Any other result will continue. - */ - public function beforeValidate(Model $model) { - return true; - } - -/** - * beforeSave is called before a model is saved. Returning false from a beforeSave callback - * will abort the save operation. - * - * @param Model $model Model using this behavior - * @return mixed False if the operation should abort. Any other result will continue. - */ - public function beforeSave(Model $model) { - return true; - } - -/** - * afterSave is called after a model is saved. - * - * @param Model $model Model using this behavior - * @param boolean $created True if this save created a new record - * @return boolean - */ - public function afterSave(Model $model, $created) { - return true; - } - -/** - * Before delete is called before any delete occurs on the attached model, but after the model's - * beforeDelete is called. Returning false from a beforeDelete will abort the delete. - * - * @param Model $model Model using this behavior - * @param boolean $cascade If true records that depend on this record will also be deleted - * @return mixed False if the operation should abort. Any other result will continue. - */ - public function beforeDelete(Model $model, $cascade = true) { - return true; - } - -/** - * After delete is called after any delete occurs on the attached model. - * - * @param Model $model Model using this behavior - * @return void - */ - public function afterDelete(Model $model) { - } - -/** - * DataSource error callback - * - * @param Model $model Model using this behavior - * @param string $error Error generated in DataSource - * @return void - */ - public function onError(Model $model, $error) { - } - -/** - * If $model's whitelist property is non-empty, $field will be added to it. - * Note: this method should *only* be used in beforeValidate or beforeSave to ensure - * that it only modifies the whitelist for the current save operation. Also make sure - * you explicitly set the value of the field which you are allowing. - * - * @param Model $model Model using this behavior - * @param string $field Field to be added to $model's whitelist - * @return void - */ - protected function _addToWhitelist(Model $model, $field) { - if (is_array($field)) { - foreach ($field as $f) { - $this->_addToWhitelist($model, $f); - } - return; - } - if (!empty($model->whitelist) && !in_array($field, $model->whitelist)) { - $model->whitelist[] = $field; - } - } - -} - diff --git a/lib/Cake/Model/Permission.php b/lib/Cake/Model/Permission.php deleted file mode 100644 index 86f1f02ff0d..00000000000 --- a/lib/Cake/Model/Permission.php +++ /dev/null @@ -1,75 +0,0 @@ -useDbConfig = $config; - } - parent::__construct(); - } - -} diff --git a/lib/Cake/Network/CakeRequest.php b/lib/Cake/Network/CakeRequest.php deleted file mode 100644 index 911a15a1bbe..00000000000 --- a/lib/Cake/Network/CakeRequest.php +++ /dev/null @@ -1,852 +0,0 @@ -controller`. - * - * @package Cake.Network - */ -class CakeRequest implements ArrayAccess { - -/** - * Array of parameters parsed from the url. - * - * @var array - */ - public $params = array( - 'plugin' => null, - 'controller' => null, - 'action' => null, - ); - -/** - * Array of POST data. Will contain form data as well as uploaded files. - * Inputs prefixed with 'data' will have the data prefix removed. If there is - * overlap between an input prefixed with data and one without, the 'data' prefixed - * value will take precedence. - * - * @var array - */ - public $data = array(); - -/** - * Array of querystring arguments - * - * @var array - */ - public $query = array(); - -/** - * The url string used for the request. - * - * @var string - */ - public $url; - -/** - * Base url path. - * - * @var string - */ - public $base = false; - -/** - * webroot path segment for the request. - * - * @var string - */ - public $webroot = '/'; - -/** - * The full address to the current request - * - * @var string - */ - public $here = null; - -/** - * The built in detectors used with `is()` can be modified with `addDetector()`. - * - * There are several ways to specify a detector, see CakeRequest::addDetector() for the - * various formats and ways to define detectors. - * - * @var array - */ - protected $_detectors = array( - 'get' => array('env' => 'REQUEST_METHOD', 'value' => 'GET'), - 'post' => array('env' => 'REQUEST_METHOD', 'value' => 'POST'), - 'put' => array('env' => 'REQUEST_METHOD', 'value' => 'PUT'), - 'delete' => array('env' => 'REQUEST_METHOD', 'value' => 'DELETE'), - 'head' => array('env' => 'REQUEST_METHOD', 'value' => 'HEAD'), - 'options' => array('env' => 'REQUEST_METHOD', 'value' => 'OPTIONS'), - 'ssl' => array('env' => 'HTTPS', 'value' => 1), - 'ajax' => array('env' => 'HTTP_X_REQUESTED_WITH', 'value' => 'XMLHttpRequest'), - 'flash' => array('env' => 'HTTP_USER_AGENT', 'pattern' => '/^(Shockwave|Adobe) Flash/'), - 'mobile' => array('env' => 'HTTP_USER_AGENT', 'options' => array( - 'Android', 'AvantGo', 'BlackBerry', 'DoCoMo', 'Fennec', 'iPod', 'iPhone', 'iPad', - 'J2ME', 'MIDP', 'NetFront', 'Nokia', 'Opera Mini', 'Opera Mobi', 'PalmOS', 'PalmSource', - 'portalmmm', 'Plucker', 'ReqwirelessWeb', 'SonyEricsson', 'Symbian', 'UP\\.Browser', - 'webOS', 'Windows CE', 'Windows Phone OS', 'Xiino' - )), - 'requested' => array('param' => 'requested', 'value' => 1) - ); - -/** - * Copy of php://input. Since this stream can only be read once in most SAPI's - * keep a copy of it so users don't need to know about that detail. - * - * @var string - */ - protected $_input = ''; - -/** - * Constructor - * - * @param string $url Trimmed url string to use. Should not contain the application base path. - * @param boolean $parseEnvironment Set to false to not auto parse the environment. ie. GET, POST and FILES. - */ - public function __construct($url = null, $parseEnvironment = true) { - $this->_base(); - if (empty($url)) { - $url = $this->_url(); - } - if ($url[0] == '/') { - $url = substr($url, 1); - } - $this->url = $url; - - if ($parseEnvironment) { - $this->_processPost(); - $this->_processGet(); - $this->_processFiles(); - } - $this->here = $this->base . '/' . $this->url; - } - -/** - * process the post data and set what is there into the object. - * processed data is available at `$this->data` - * - * Will merge POST vars prefixed with `data`, and ones without - * into a single array. Variables prefixed with `data` will overwrite those without. - * - * If you have mixed POST values be careful not to make any top level keys numeric - * containing arrays. Set::merge() is used to merge data, and it has possibly - * unexpected behavior in this situation. - * - * @return void - */ - protected function _processPost() { - $this->data = $_POST; - if (ini_get('magic_quotes_gpc') === '1') { - $this->data = stripslashes_deep($this->data); - } - if (env('HTTP_X_HTTP_METHOD_OVERRIDE')) { - $this->data['_method'] = env('HTTP_X_HTTP_METHOD_OVERRIDE'); - } - if (isset($this->data['_method'])) { - if (!empty($_SERVER)) { - $_SERVER['REQUEST_METHOD'] = $this->data['_method']; - } else { - $_ENV['REQUEST_METHOD'] = $this->data['_method']; - } - unset($this->data['_method']); - } - - if (isset($this->data['data'])) { - $data = $this->data['data']; - if (count($this->data) <= 1) { - $this->data = $data; - } else { - unset($this->data['data']); - $this->data = Set::merge($this->data, $data); - } - } - } - -/** - * Process the GET parameters and move things into the object. - * - * @return void - */ - protected function _processGet() { - if (ini_get('magic_quotes_gpc') === '1') { - $query = stripslashes_deep($_GET); - } else { - $query = $_GET; - } - - unset($query['/' . str_replace('.', '_', urldecode($this->url))]); - if (strpos($this->url, '?') !== false) { - list(, $querystr) = explode('?', $this->url); - parse_str($querystr, $queryArgs); - $query += $queryArgs; - } - if (isset($this->params['url'])) { - $query = array_merge($this->params['url'], $query); - } - $this->query = $query; - } - -/** - * Get the request uri. Looks in PATH_INFO first, as this is the exact value we need prepared - * by PHP. Following that, REQUEST_URI, PHP_SELF, HTTP_X_REWRITE_URL and argv are checked in that order. - * Each of these server variables have the base path, and query strings stripped off - * - * @return string URI The CakePHP request path that is being accessed. - */ - protected function _url() { - if (!empty($_SERVER['PATH_INFO'])) { - return $_SERVER['PATH_INFO']; - } elseif (isset($_SERVER['REQUEST_URI'])) { - $uri = $_SERVER['REQUEST_URI']; - } elseif (isset($_SERVER['PHP_SELF']) && isset($_SERVER['SCRIPT_NAME'])) { - $uri = str_replace($_SERVER['SCRIPT_NAME'], '', $_SERVER['PHP_SELF']); - } elseif (isset($_SERVER['HTTP_X_REWRITE_URL'])) { - $uri = $_SERVER['HTTP_X_REWRITE_URL']; - } elseif ($var = env('argv')) { - $uri = $var[0]; - } - - $base = $this->base; - - if (strlen($base) > 0 && strpos($uri, $base) === 0) { - $uri = substr($uri, strlen($base)); - } - if (strpos($uri, '?') !== false) { - list($uri) = explode('?', $uri, 2); - } - if (empty($uri) || $uri == '/' || $uri == '//') { - return '/'; - } - return $uri; - } - -/** - * Returns a base URL and sets the proper webroot - * - * @return string Base URL - */ - protected function _base() { - $dir = $webroot = null; - $config = Configure::read('App'); - extract($config); - - if (!isset($base)) { - $base = $this->base; - } - if ($base !== false) { - $this->webroot = $base . '/'; - return $this->base = $base; - } - - if (!$baseUrl) { - $base = dirname(env('PHP_SELF')); - - if ($webroot === 'webroot' && $webroot === basename($base)) { - $base = dirname($base); - } - if ($dir === 'app' && $dir === basename($base)) { - $base = dirname($base); - } - - if ($base === DS || $base === '.') { - $base = ''; - } - - $this->webroot = $base . '/'; - return $this->base = $base; - } - - $file = '/' . basename($baseUrl); - $base = dirname($baseUrl); - - if ($base === DS || $base === '.') { - $base = ''; - } - $this->webroot = $base . '/'; - - $docRoot = env('DOCUMENT_ROOT'); - $docRootContainsWebroot = strpos($docRoot, $dir . '/' . $webroot); - - if (!empty($base) || !$docRootContainsWebroot) { - if (strpos($this->webroot, '/' . $dir . '/') === false) { - $this->webroot .= $dir . '/'; - } - if (strpos($this->webroot, '/' . $webroot . '/') === false) { - $this->webroot .= $webroot . '/'; - } - } - return $this->base = $base . $file; - } - -/** - * Process $_FILES and move things into the object. - * - * @return void - */ - protected function _processFiles() { - if (isset($_FILES) && is_array($_FILES)) { - foreach ($_FILES as $name => $data) { - if ($name != 'data') { - $this->params['form'][$name] = $data; - } - } - } - - if (isset($_FILES['data'])) { - foreach ($_FILES['data'] as $key => $data) { - foreach ($data as $model => $fields) { - if (is_array($fields)) { - foreach ($fields as $field => $value) { - if (is_array($value)) { - foreach ($value as $k => $v) { - $this->data[$model][$field][$k][$key] = $v; - } - } else { - $this->data[$model][$field][$key] = $value; - } - } - } else { - $this->data[$model][$key] = $fields; - } - } - } - } - } - -/** - * Get the IP the client is using, or says they are using. - * - * @param boolean $safe Use safe = false when you think the user might manipulate their HTTP_CLIENT_IP - * header. Setting $safe = false will will also look at HTTP_X_FORWARDED_FOR - * @return string The client IP. - */ - public function clientIp($safe = true) { - if (!$safe && env('HTTP_X_FORWARDED_FOR') != null) { - $ipaddr = preg_replace('/(?:,.*)/', '', env('HTTP_X_FORWARDED_FOR')); - } else { - if (env('HTTP_CLIENT_IP') != null) { - $ipaddr = env('HTTP_CLIENT_IP'); - } else { - $ipaddr = env('REMOTE_ADDR'); - } - } - - if (env('HTTP_CLIENTADDRESS') != null) { - $tmpipaddr = env('HTTP_CLIENTADDRESS'); - - if (!empty($tmpipaddr)) { - $ipaddr = preg_replace('/(?:,.*)/', '', $tmpipaddr); - } - } - return trim($ipaddr); - } - -/** - * Returns the referer that referred this request. - * - * @param boolean $local Attempt to return a local address. Local addresses do not contain hostnames. - * @return string The referring address for this request. - */ - public function referer($local = false) { - $ref = env('HTTP_REFERER'); - $forwarded = env('HTTP_X_FORWARDED_HOST'); - if ($forwarded) { - $ref = $forwarded; - } - - $base = ''; - if (defined('FULL_BASE_URL')) { - $base = FULL_BASE_URL . $this->webroot; - } - if (!empty($ref) && !empty($base)) { - if ($local && strpos($ref, $base) === 0) { - $ref = substr($ref, strlen($base)); - if ($ref[0] != '/') { - $ref = '/' . $ref; - } - return $ref; - } elseif (!$local) { - return $ref; - } - } - return '/'; - } - -/** - * Missing method handler, handles wrapping older style isAjax() type methods - * - * @param string $name The method called - * @param array $params Array of parameters for the method call - * @return mixed - * @throws CakeException when an invalid method is called. - */ - public function __call($name, $params) { - if (strpos($name, 'is') === 0) { - $type = strtolower(substr($name, 2)); - return $this->is($type); - } - throw new CakeException(__d('cake_dev', '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. - */ - 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 - */ - public function __isset($name) { - return isset($this->params[$name]); - } - -/** - * Check whether or not a Request is a certain type. Uses the built in detection rules - * as well as additional rules defined with CakeRequest::addDetector(). Any detector can be called - * as `is($type)` or `is$Type()`. - * - * @param string $type The type of request you want to check. - * @return boolean Whether or not the request is the type you are checking. - */ - public function is($type) { - $type = strtolower($type); - if (!isset($this->_detectors[$type])) { - return false; - } - $detect = $this->_detectors[$type]; - if (isset($detect['env'])) { - if (isset($detect['value'])) { - return env($detect['env']) == $detect['value']; - } - if (isset($detect['pattern'])) { - return (bool)preg_match($detect['pattern'], env($detect['env'])); - } - if (isset($detect['options'])) { - $pattern = '/' . implode('|', $detect['options']) . '/i'; - return (bool)preg_match($pattern, env($detect['env'])); - } - } - if (isset($detect['param'])) { - $key = $detect['param']; - $value = $detect['value']; - return isset($this->params[$key]) ? $this->params[$key] == $value : false; - } - if (isset($detect['callback']) && is_callable($detect['callback'])) { - return call_user_func($detect['callback'], $this); - } - return false; - } - -/** - * 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. - * - * ### Environment value comparison - * - * 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', array('env' => 'REQUEST_METHOD', 'value' => 'POST'))` - * - * ### Pattern value comparison - * - * Pattern value comparison allows you to compare a value fetched from `env()` to a regular expression. - * - * e.g `addDetector('iphone', array('env' => 'HTTP_USER_AGENT', 'pattern' => '/iPhone/i'));` - * - * ### Option based comparison - * - * Option based comparisons use a list of options to create a regular expression. Subsequent calls - * to add an already defined options detector will merge the options. - * - * e.g `addDetector('mobile', array('env' => 'HTTP_USER_AGENT', 'options' => array('Fennec')));` - * - * ### Callback detectors - * - * Callback detectors allow you to provide a 'callback' type to handle the check. The callback will - * receive the request object as its only parameter. - * - * e.g `addDetector('custom', array('callback' => array('SomeClass', 'somemethod')));` - * - * ### Request parameter detectors - * - * Allows for custom detectors on the request parameters. - * - * e.g `addDetector('post', array('param' => 'requested', 'value' => 1)` - * - * @param string $name The name of the detector. - * @param array $options The options for the detector definition. See above. - * @return void - */ - public function addDetector($name, $options) { - $name = strtolower($name); - if (isset($this->_detectors[$name]) && isset($options['options'])) { - $options = Set::merge($this->_detectors[$name], $options); - } - $this->_detectors[$name] = $options; - } - -/** - * Add parameters to the request's parsed parameter set. This will overwrite any existing parameters. - * This modifies the parameters available through `$request->params`. - * - * @param array $params Array of parameters to merge in - * @return The current object, you can chain this method. - */ - public function addParams($params) { - $this->params = array_merge($this->params, (array)$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 CakeRequest the current object, you can chain this method. - */ - public function addPaths($paths) { - foreach (array('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 named parameters and querystring arguments. - * - * @param boolean $base Include the base path, set to false to trim the base path off. - * @return string the current request url including query string args. - */ - public function here($base = true) { - $url = $this->here; - if (!empty($this->query)) { - $url .= '?' . http_build_query($this->query, null, '&'); - } - if (!$base) { - $url = preg_replace('/^' . preg_quote($this->base, '/') . '/', '', $url, 1); - } - return $url; - } - -/** - * Read an HTTP header from the Request information. - * - * @param string $name Name of the header you want. - * @return mixed Either false on no header being set or the value of the header. - */ - public static function header($name) { - $name = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); - if (!empty($_SERVER[$name])) { - return $_SERVER[$name]; - } - return false; - } - -/** - * 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 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. - * - * @return string The name of the HTTP method used. - */ - public function method() { - return env('REQUEST_METHOD'); - } - -/** - * Get the host that the request was handled on. - * - * @return void - */ - public function host() { - return env('HTTP_HOST'); - } - -/** - * Get the domain name and include $tldLength segments of the tld. - * - * @param integer $tldLength Number of segments your tld contains. For example: `example.com` contains 1 tld. - * While `example.co.uk` contains 2. - * @return string Domain name without subdomains. - */ - public function domain($tldLength = 1) { - $segments = explode('.', $this->host()); - $domain = array_slice($segments, -1 * ($tldLength + 1)); - return implode('.', $domain); - } - -/** - * Get the subdomains for a host. - * - * @param integer $tldLength Number of segments your tld contains. For example: `example.com` contains 1 tld. - * While `example.co.uk` contains 2. - * @return array of subdomains. - */ - public function subdomains($tldLength = 1) { - $segments = explode('.', $this->host()); - return array_slice($segments, 0, -1 * ($tldLength + 1)); - } - -/** - * Find out which content types the client accepts or check if they accept a - * particular type of content. - * - * #### Get all types: - * - * `$this->request->accepts();` - * - * #### Check for a single type: - * - * `$this->request->accepts('json');` - * - * This method will order the returned content types by the preference values indicated - * by the client. - * - * @param string $type The content type to check for. Leave null to get all types a client accepts. - * @return mixed Either an array of all the types the client accepts or a boolean if they accept the - * provided type. - */ - public function accepts($type = null) { - $raw = $this->parseAccept(); - $accept = array(); - foreach ($raw as $value => $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 CakeRequest::accept() to get a simple list - * of the accepted content types. - * - * @return array An array of prefValue => array(content/types) - */ - public function parseAccept() { - $accept = array(); - $header = explode(',', $this->header('accept')); - foreach (array_filter($header) as $value) { - $prefPos = strpos($value, ';'); - if ($prefPos !== false) { - $prefValue = substr($value, strpos($value, '=') + 1); - $value = trim(substr($value, 0, $prefPos)); - } else { - $prefValue = '1.0'; - $value = trim($value); - } - if (!isset($accept[$prefValue])) { - $accept[$prefValue] = array(); - } - if ($prefValue) { - $accept[$prefValue][] = $value; - } - } - krsort($accept); - return $accept; - } - -/** - * Get the languages accepted by the client, or check if a specific language is accepted. - * - * Get the list of accepted languages: - * - * {{{ CakeRequest::acceptLanguage(); }}} - * - * Check if a specific language is accepted: - * - * {{{ CakeRequest::acceptLanguage('es-es'); }}} - * - * @param string $language The language to test. - * @return If a $language is provided, a boolean. Otherwise the array of accepted languages. - */ - public static function acceptLanguage($language = null) { - $accepts = preg_split('/[;,]/', self::header('Accept-Language')); - foreach ($accepts as &$accept) { - $accept = strtolower($accept); - if (strpos($accept, '_') !== false) { - $accept = str_replace('_', '-', $accept); - } - } - if ($language === null) { - return $accepts; - } - return in_array($language, $accepts); - } - -/** - * Provides a read/write accessor for `$this->data`. Allows you - * to use a syntax similar to `CakeSession` 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 $name,... Dot separated name of the value to read/write - * @return mixed Either the value being read, or this so you can chain consecutive writes. - */ - public function data($name) { - $args = func_get_args(); - if (count($args) == 2) { - $this->data = Set::insert($this->data, $name, $args[1]); - return $this; - } - return Set::classicExtract($this->data, $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', array('return' => 'DOMDocument'));` - * - * Any additional parameters are applied to the callback in the order they are given. - * - * @param string $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. - * @return The decoded/processed request data. - */ - public function input($callback = null) { - $input = $this->_readInput(); - $args = func_get_args(); - if (!empty($args)) { - $callback = array_shift($args); - array_unshift($args, $input); - return call_user_func_array($callback, $args); - } - return $input; - } - -/** - * Read data from php://input, mocked in tests. - * - * @return string contents of php://input - */ - protected function _readInput() { - if (empty($this->_input)) { - $fh = fopen('php://input', 'r'); - $content = stream_get_contents($fh); - fclose($fh); - $this->_input = $content; - } - return $this->_input; - } - -/** - * Array access read implementation - * - * @param string $name Name of the key being accessed. - * @return mixed - */ - 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 - * - * @param string $name Name of the key being written - * @param mixed $value The value being written. - * @return void - */ - public function offsetSet($name, $value) { - $this->params[$name] = $value; - } - -/** - * Array access isset() implementation - * - * @param string $name thing to check. - * @return boolean - */ - public function offsetExists($name) { - return isset($this->params[$name]); - } - -/** - * Array access unset() implementation - * - * @param string $name Name to unset. - * @return void - */ - public function offsetUnset($name) { - unset($this->params[$name]); - } - -} diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php deleted file mode 100644 index 591ae64214c..00000000000 --- a/lib/Cake/Network/CakeResponse.php +++ /dev/null @@ -1,1154 +0,0 @@ - 'Continue', - 101 => 'Switching Protocols', - 200 => 'OK', - 201 => 'Created', - 202 => 'Accepted', - 203 => 'Non-Authoritative Information', - 204 => 'No Content', - 205 => 'Reset Content', - 206 => 'Partial Content', - 300 => 'Multiple Choices', - 301 => 'Moved Permanently', - 302 => 'Found', - 303 => 'See Other', - 304 => 'Not Modified', - 305 => 'Use Proxy', - 307 => 'Temporary Redirect', - 400 => 'Bad Request', - 401 => 'Unauthorized', - 402 => 'Payment Required', - 403 => 'Forbidden', - 404 => 'Not Found', - 405 => 'Method Not Allowed', - 406 => 'Not Acceptable', - 407 => 'Proxy Authentication Required', - 408 => 'Request Time-out', - 409 => 'Conflict', - 410 => 'Gone', - 411 => 'Length Required', - 412 => 'Precondition Failed', - 413 => 'Request Entity Too Large', - 414 => 'Request-URI Too Large', - 415 => 'Unsupported Media Type', - 416 => 'Requested range not satisfiable', - 417 => 'Expectation Failed', - 500 => 'Internal Server Error', - 501 => 'Not Implemented', - 502 => 'Bad Gateway', - 503 => 'Service Unavailable', - 504 => 'Gateway Time-out' - ); - -/** - * Holds known mime type mappings - * - * @var array - */ - protected $_mimeTypes = array( - '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' => array('text/csv', 'application/vnd.ms-excel', 'text/plain'), - 'dcr' => 'application/x-director', - 'dir' => 'application/x-director', - 'dms' => 'application/octet-stream', - 'doc' => 'application/msword', - '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/vnd.microsoft.icon', - 'ips' => 'application/x-ipscript', - 'ipx' => 'application/x-ipix', - 'js' => 'text/javascript', - '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/mspowerpoint', - 'pps' => 'application/mspowerpoint', - 'ppt' => 'application/mspowerpoint', - 'ppz' => 'application/mspowerpoint', - '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', - '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', - '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', - 'html' => array('text/html', '*/*'), - 'htm' => array('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', - 'xml' => array('application/xml', 'text/xml'), - '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', - 'gif' => 'image/gif', - 'ief' => 'image/ief', - 'jpe' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'jpg' => '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', - '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' => 'text/javascript', - 'json' => 'application/json', - 'form' => 'application/x-www-form-urlencoded', - 'file' => 'multipart/form-data', - 'xhtml' => array('application/xhtml+xml', 'application/xhtml', 'text/xhtml'), - 'xhtml-mobile' => 'application/vnd.wap.xhtml+xml', - 'rss' => 'application/rss+xml', - 'atom' => 'application/atom+xml', - 'amf' => 'application/x-amf', - 'wap' => array('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', - ); - -/** - * Protocol header to send to the client - * - * @var string - */ - protected $_protocol = 'HTTP/1.1'; - -/** - * Status code to send to the client - * - * @var integer - */ - 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 integer - */ - protected $_contentType = 'text/html'; - -/** - * Buffer list of headers - * - * @var array - */ - protected $_headers = array(); - -/** - * Buffer string for response message - * - * @var string - */ - protected $_body = null; - -/** - * The charset the response body is encoded with - * - * @var string - */ - protected $_charset = 'UTF-8'; - -/** - * Holds all the cache directives that will be converted - * into headers when sending the request - * - * @var string - */ - protected $_cacheDirectives = array(); - -/** - * Holds cookies to be sent to the client - * - * @var array - */ - protected $_cookies = array(); - -/** - * Class constructor - * - * @param array $options list of parameters to setup the response. Possible values are: - * - body: the response text that should be sent to the client - * - 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 - */ - public function __construct(array $options = array()) { - if (isset($options['body'])) { - $this->body($options['body']); - } - if (isset($options['status'])) { - $this->statusCode($options['status']); - } - if (isset($options['type'])) { - $this->type($options['type']); - } - if (isset($options['charset'])) { - $this->charset($options['charset']); - } - } - -/** - * Sends the complete response to the client including headers and message body. - * Will echo out the content in the response body. - * - * @return void - */ - public function send() { - if (isset($this->_headers['Location']) && $this->_status === 200) { - $this->statusCode(302); - } - - $codeMessage = $this->_statusCodes[$this->_status]; - $this->_setCookies(); - $this->_sendHeader("{$this->_protocol} {$this->_status} {$codeMessage}"); - $this->_setContent(); - $this->_setContentLength(); - $this->_setContentType(); - foreach ($this->_headers as $header => $value) { - $this->_sendHeader($header, $value); - } - $this->_sendContent($this->_body); - } - -/** - * Sets the cookies that have been added via static method CakeResponse::addCookie() - * before any other output is sent to the client. - * Will set the cookies in the order they have been set. - * - * @return void - */ - protected function _setCookies() { - foreach ($this->_cookies as $name => $c) { - setcookie( - $name, $c['value'], $c['expire'], $c['path'], - $c['domain'], $c['secure'], $c['httpOnly'] - ); - } - } - -/** - * 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/* - * - * @return void - */ - protected function _setContentType() { - if (in_array($this->_status, array(304, 204))) { - return; - } - if (strpos($this->_contentType, 'text/') === 0) { - $this->header('Content-Type', "{$this->_contentType}; charset={$this->_charset}"); - } else { - $this->header('Content-Type', "{$this->_contentType}"); - } - } - -/** - * Sets the response body to an empty text if the status code is 204 or 304 - * - * @return void - */ - protected function _setContent() { - if (in_array($this->_status, array(304, 204))) { - $this->body(''); - } - } - -/** - * Calculates the correct Content-Length and sets it as a header in the response - * Will not set the value if already set or if the output is compressed. - * - * @return void - */ - protected function _setContentLength() { - $shouldSetLength = !isset($this->_headers['Content-Length']) && !in_array($this->_status, range(301, 307)); - if (isset($this->_headers['Content-Length']) && $this->_headers['Content-Length'] === false) { - unset($this->_headers['Content-Length']); - return; - } - if ($shouldSetLength && !$this->outputCompressed()) { - $offset = ob_get_level() ? ob_get_length() : 0; - if (ini_get('mbstring.func_overload') & 2 && function_exists('mb_strlen')) { - $this->length($offset + mb_strlen($this->_body, '8bit')); - } else { - $this->length($this->_headers['Content-Length'] = $offset + strlen($this->_body)); - } - } - } - -/** - * Sends a header to the client. - * - * @param string $name the header name - * @param string $value the header value - * @return void - */ - protected function _sendHeader($name, $value = null) { - if (!headers_sent()) { - if (is_null($value)) { - header($name); - } else { - header("{$name}: {$value}"); - } - } - } - -/** - * Sends a content string to the client. - * - * @param string $content string to send as response body - * @return void - */ - protected function _sendContent($content) { - echo $content; - } - -/** - * Buffers a header string to be sent - * Returns the complete list of buffered headers - * - * ### Single header - * e.g `header('Location', 'http://example.com');` - * - * ### Multiple headers - * e.g `header(array('Location' => 'http://example.com', 'X-Extra' => 'My header'));` - * - * ### String header - * e.g `header('WWW-Authenticate: Negotiate');` - * - * ### Array of string headers - * e.g `header(array('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 - * e.g `header('WWW-Authenticate: Negotiate'); header('WWW-Authenticate: Not-Negotiate');` - * will have the same effect as only doing `header('WWW-Authenticate: Not-Negotiate');` - * - * @param mixed $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 mixed $value. The header value. - * @return array list of headers to be sent - */ - public function header($header = null, $value = null) { - if (is_null($header)) { - return $this->_headers; - } - if (is_array($header)) { - foreach ($header as $h => $v) { - if (is_numeric($h)) { - $this->header($v); - continue; - } - $this->_headers[$h] = trim($v); - } - return $this->_headers; - } - - if (!is_null($value)) { - $this->_headers[$header] = $value; - return $this->_headers; - } - - list($header, $value) = explode(':', $header, 2); - $this->_headers[$header] = trim($value); - return $this->_headers; - } - -/** - * Buffers the response message to be sent - * if $content is null the current buffer is returned - * - * @param string $content the string message to be sent - * @return string current message buffer if $content param is passed as null - */ - public function body($content = null) { - if (is_null($content)) { - return $this->_body; - } - return $this->_body = $content; - } - -/** - * Sets the HTTP status code to be sent - * if $code is null the current code is returned - * - * @param integer $code - * @return integer current status code - * @throws CakeException When an unknown status code is reached. - */ - public function statusCode($code = null) { - if (is_null($code)) { - return $this->_status; - } - if (!isset($this->_statusCodes[$code])) { - throw new CakeException(__d('cake_dev', 'Unknown status code')); - } - return $this->_status = $code; - } - -/** - * Queries & sets valid HTTP response codes & messages. - * - * @param mixed $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 'code' and 'message' keys of each nested array are added to the default - * HTTP codes. Example: - * - * httpCodes(404); // returns array(404 => 'Not Found') - * - * httpCodes(array( - * 701 => 'Unicorn Moved', - * 800 => 'Unexpected Minotaur' - * )); // sets these new values, and returns true - * - * @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. - */ - public function httpCodes($code = null) { - if (empty($code)) { - return $this->_statusCodes; - } - - if (is_array($code)) { - $this->_statusCodes = $code + $this->_statusCodes; - return true; - } - - if (!isset($this->_statusCodes[$code])) { - return null; - } - return array($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, it will be stored as a content type definition - * - * ### Setting the content type - * - * e.g `type('jpg');` - * - * ### Returning the current content type - * - * e.g `type();` - * - * ### Storing a content type definition - * - * e.g `type(array('keynote' => 'application/keynote'));` - * - * ### Replacing a content type definition - * - * e.g `type(array('jpg' => 'text/plain'));` - * - * @param string $contentType - * @return mixed current content type or false if supplied an invalid content type - */ - public function type($contentType = null) { - if (is_null($contentType)) { - return $this->_contentType; - } - if (is_array($contentType)) { - $type = key($contentType); - $defitition = current($contentType); - $this->_mimeTypes[$type] = $defitition; - return $this->_contentType; - } - if (isset($this->_mimeTypes[$contentType])) { - $contentType = $this->_mimeTypes[$contentType]; - $contentType = is_array($contentType) ? current($contentType) : $contentType; - } - if (strpos($contentType, '/') === false) { - return false; - } - return $this->_contentType = $contentType; - } - -/** - * Returns the mime type definition for an alias - * - * 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 - */ - public function getMimeType($alias) { - if (isset($this->_mimeTypes[$alias])) { - return $this->_mimeTypes[$alias]; - } - return false; - } - -/** - * Maps a content-type back to an alias - * - * e.g `mapType('application/pdf'); // returns 'pdf'` - * - * @param mixed $ctype Either a string content type to map, or an array of types. - * @return mixed Aliases for the types provided. - */ - public function mapType($ctype) { - if (is_array($ctype)) { - return array_map(array($this, 'mapType'), $ctype); - } - - foreach ($this->_mimeTypes as $alias => $types) { - if (is_array($types) && in_array($ctype, $types)) { - return $alias; - } elseif (is_string($types) && $types == $ctype) { - return $alias; - } - } - return null; - } - -/** - * Sets the response charset - * if $charset is null the current charset is returned - * - * @param string $charset - * @return string current charset - */ - public function charset($charset = null) { - if (is_null($charset)) { - return $this->_charset; - } - return $this->_charset = $charset; - } - -/** - * Sets the correct headers to instruct the client to not cache the response - * - * @return void - */ - public function disableCache() { - $this->header(array( - 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', - 'Last-Modified' => gmdate("D, d M Y H:i:s") . " GMT", - '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 - */ - public function cache($since, $time = '+1 day') { - if (!is_integer($time)) { - $time = strtotime($time); - } - $this->header(array( - 'Date' => gmdate("D, j M Y G:i:s ", time()) . 'GMT' - )); - $this->modified($since); - $this->expires($time); - $this->sharable(true); - $this->maxAge($time - time()); - } - -/** - * 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 boolean $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 $time time in seconds after which the response should no longer be considered fresh - * @return boolean - */ - 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; - } - $sharable = $public || ! ($private || $noCache); - return $sharable; - } - if ($public) { - $this->_cacheDirectives['public'] = true; - unset($this->_cacheDirectives['private']); - $this->sharedMaxAge($time); - } else { - $this->_cacheDirectives['private'] = true; - unset($this->_cacheDirectives['public']); - $this->maxAge($time); - } - if ($time == null) { - $this->_setCacheControl(); - } - return (bool)$public; - } - -/** - * 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 $seconds if null, the method will return the current s-maxage value - * @return int - */ - 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; - } - -/** - * 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 $seconds if null, the method will return the current max-age value - * @return int - */ - 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; - } - -/** - * Sets the Cache-Control must-revalidate directive. - * must-revalidate indicates that the response should not be served - * stale by a cache under any cirumstance without first revalidating - * with the origin. - * If called with no parameters, this function will return wheter must-revalidate is present. - * - * @param int $seconds if null, the method will return the current - * must-revalidate value - * @return boolean - */ - 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); - } - -/** - * Helper method to generate a valid Cache-Control header from the options set - * in other methods - * - * @return void - */ - protected function _setCacheControl() { - $control = ''; - foreach ($this->_cacheDirectives as $key => $val) { - $control .= $val === true ? $key : sprintf('%s=%s', $key, $val); - $control .= ', '; - } - $control = rtrim($control, ', '); - $this->header('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 $time - * @return string - */ - public function expires($time = null) { - if ($time !== null) { - $date = $this->_getUTCDate($time); - $this->_headers['Expires'] = $date->format('D, j M Y H:i:s') . ' GMT'; - } - if (isset($this->_headers['Expires'])) { - return $this->_headers['Expires']; - } - return null; - } - -/** - * Sets the Last-Modified header for the response by taking an 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 $time - * @return string - */ - public function modified($time = null) { - if ($time !== null) { - $date = $this->_getUTCDate($time); - $this->_headers['Last-Modified'] = $date->format('D, j M Y H:i:s') . ' GMT'; - } - if (isset($this->_headers['Last-Modified'])) { - return $this->_headers['Last-Modified']; - } - return null; - } - -/** - * 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 - **/ - public function notModified() { - $this->statusCode(304); - $this->body(''); - $remove = array( - 'Allow', - 'Content-Encoding', - 'Content-Language', - 'Content-Length', - 'Content-MD5', - 'Content-Type', - 'Last-Modified' - ); - foreach ($remove as $header) { - unset($this->_headers[$header]); - } - } - -/** - * 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 $cacheVariances a single Vary string or a array - * containig the list for variances. - * @return array - **/ - public function vary($cacheVariances = null) { - if ($cacheVariances !== null) { - $cacheVariances = (array)$cacheVariances; - $this->_headers['Vary'] = implode(', ', $cacheVariances); - } - if (isset($this->_headers['Vary'])) { - return explode(', ', $this->_headers['Vary']); - } - return null; - } - -/** - * Sets the response Etag, Etags are a strong indicative that a response - * can be cached by a HTTP client. A bad way of generaing 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 instuct clients that the content has - * changed, but sematicallly, 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 $hash the unique has that identifies this resposnse - * @param boolean $weak whether the response is semantically the same as - * other with th same hash or not - * @return string - **/ - public function etag($tag = null, $weak = false) { - if ($tag !== null) { - $this->_headers['Etag'] = sprintf('%s"%s"', ($weak) ? 'W/' : null, $tag); - } - if (isset($this->_headers['Etag'])) { - return $this->_headers['Etag']; - } - return null; - } - -/** - * Returns a DateTime object initialized at the $time param and using UTC - * as timezone - * - * @param string|int|DateTime $time - * @return DateTime - */ - protected function _getUTCDate($time = null) { - if ($time instanceof DateTime) { - $result = clone $time; - } elseif (is_integer($time)) { - $result = new DateTime(date('Y-m-d H:i:s', $time)); - } else { - $result = new DateTime($time); - } - $result->setTimeZone(new DateTimeZone('UTC')); - return $result; - } - -/** - * Sets the correct output buffering handler to send a compressed response. Responses will - * be compressed with zlib, if the extension is available. - * - * @return boolean false if client does not accept compressed responses or no handler is available, true otherwise - */ - public function compress() { - $compressionEnabled = ini_get("zlib.output_compression") !== '1' && - extension_loaded("zlib") && - (strpos(env('HTTP_ACCEPT_ENCODING'), 'gzip') !== false); - return $compressionEnabled && ob_start('ob_gzhandler'); - } - -/** - * Returns whether the resulting output will be compressed by PHP - * - * @return boolean - */ - public function outputCompressed() { - return strpos(env('HTTP_ACCEPT_ENCODING'), 'gzip') !== false - && (ini_get("zlib.output_compression") === '1' || in_array('ob_gzhandler', ob_list_handlers())); - } - -/** - * Sets the correct headers to instruct the browser to download the response as a file. - * - * @param string $filename the name of the file as the browser will download the response - * @return void - */ - public function download($filename) { - $this->header('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 - * - * @return string protocol to be used for sending response - */ - public function protocol($protocol = null) { - if ($protocol !== null) { - $this->_protocol = $protocol; - } - return $this->_protocol; - } - -/** - * Sets the Content-Length header for the response - * If called with no arguments returns the last Content-Length set - * - * @return int - */ - public function length($bytes = null) { - if ($bytes !== null ) { - $this->_headers['Content-Length'] = $bytes; - } - if (isset($this->_headers['Content-Length'])) { - return $this->_headers['Content-Length']; - } - return null; - } - -/** - * Checks whether a response has not been modified according to the 'If-None-Match' - * (Etags) and 'If-Modified-Since' (last modification date) request - * headers headers. If the response is detected to be not modified, it - * is marked as so accordingly so the client can be informed of that. - * - * In order to mark a response as not modified, you need to set at least - * the Last-Modified response header or a response etag to be compared - * with the request itself - * - * @return boolean whether the response was marked as not modified or - * not - **/ - public function checkNotModified(CakeRequest $request) { - $etags = preg_split('/\s*,\s*/', $request->header('If-None-Match'), null, PREG_SPLIT_NO_EMPTY); - $modifiedSince = $request->header('If-Modified-Since'); - if ($responseTag = $this->etag()) { - $etagMatches = in_array('*', $etags) || in_array($responseTag, $etags); - } - if ($modifiedSince) { - $timeMatches = strtotime($this->modified()) == strtotime($modifiedSince); - } - $checks = compact('etagMatches', 'timeMatches'); - if (empty($checks)) { - return false; - } - $notModified = !in_array(false, $checks, true); - if ($notModified) { - $this->notModified(); - } - return $notModified; - } - -/** - * String conversion. Fetches the response body as a string. - * Does *not* send headers. - * - * @return string - */ - public function __toString() { - return (string)$this->_body; - } - -/** - * 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. - * - * @param $options Either null to get all cookies, string for a specific cookie - * or array to set cookie. - * - * ### 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)` - * - * @return mixed - */ - public function cookie($options = null) { - if ($options === null) { - return $this->_cookies; - } - - if (is_string($options)) { - if (!isset($this->_cookies[$options])) { - return null; - } - return $this->_cookies[$options]; - } - - $defaults = array( - 'name' => 'CakeCookie[default]', - 'value' => '', - 'expire' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false - ); - $options += $defaults; - - $this->_cookies[$options['name']] = $options; - } - -} diff --git a/lib/Cake/Network/CakeSocket.php b/lib/Cake/Network/CakeSocket.php deleted file mode 100644 index aa0eb4fbd43..00000000000 --- a/lib/Cake/Network/CakeSocket.php +++ /dev/null @@ -1,280 +0,0 @@ - false, - 'host' => 'localhost', - 'protocol' => 'tcp', - 'port' => 80, - 'timeout' => 30 - ); - -/** - * Configuration settings for the socket connection - * - * @var array - */ - public $config = array(); - -/** - * Reference to socket connection resource - * - * @var resource - */ - public $connection = null; - -/** - * This boolean contains the current state of the CakeSocket class - * - * @var boolean - */ - public $connected = false; - -/** - * This variable contains an array with the last error number (num) and string (str) - * - * @var array - */ - public $lastError = array(); - -/** - * Constructor. - * - * @param array $config Socket configuration, which will be merged with the base configuration - * @see CakeSocket::$_baseConfig - */ - public function __construct($config = array()) { - $this->config = array_merge($this->_baseConfig, $config); - if (!is_numeric($this->config['protocol'])) { - $this->config['protocol'] = getprotobyname($this->config['protocol']); - } - } - -/** - * Connect the socket to the given host and port. - * - * @return boolean Success - * @throws SocketException - */ - public function connect() { - if ($this->connection != null) { - $this->disconnect(); - } - - $scheme = null; - if (isset($this->config['request']) && $this->config['request']['uri']['scheme'] == 'https') { - $scheme = 'ssl://'; - } - - if ($this->config['persistent'] == true) { - $this->connection = @pfsockopen($scheme . $this->config['host'], $this->config['port'], $errNum, $errStr, $this->config['timeout']); - } else { - $this->connection = @fsockopen($scheme . $this->config['host'], $this->config['port'], $errNum, $errStr, $this->config['timeout']); - } - - if (!empty($errNum) || !empty($errStr)) { - $this->setLastError($errNum, $errStr); - throw new SocketException($errStr, $errNum); - } - - $this->connected = is_resource($this->connection); - if ($this->connected) { - stream_set_timeout($this->connection, $this->config['timeout']); - } - return $this->connected; - } - -/** - * Get the host name of the current connection. - * - * @return string Host name - */ - public function host() { - if (Validation::ip($this->config['host'])) { - return gethostbyaddr($this->config['host']); - } - return gethostbyaddr($this->address()); - } - -/** - * Get the IP address of the current connection. - * - * @return string IP address - */ - public function address() { - if (Validation::ip($this->config['host'])) { - return $this->config['host']; - } - return gethostbyname($this->config['host']); - } - -/** - * Get all IP addresses associated with the current connection. - * - * @return array IP addresses - */ - public function addresses() { - if (Validation::ip($this->config['host'])) { - return array($this->config['host']); - } - return gethostbynamel($this->config['host']); - } - -/** - * Get the last error as a string. - * - * @return string Last error - */ - public function lastError() { - if (!empty($this->lastError)) { - return $this->lastError['num'] . ': ' . $this->lastError['str']; - } - return null; - } - -/** - * Set the last error. - * - * @param integer $errNum Error code - * @param string $errStr Error string - * @return void - */ - public function setLastError($errNum, $errStr) { - $this->lastError = array('num' => $errNum, 'str' => $errStr); - } - -/** - * Write data to the socket. - * - * @param string $data The data to write to the socket - * @return boolean Success - */ - public function write($data) { - if (!$this->connected) { - if (!$this->connect()) { - return false; - } - } - $totalBytes = strlen($data); - for ($written = 0, $rv = 0; $written < $totalBytes; $written += $rv) { - $rv = fwrite($this->connection, substr($data, $written)); - if ($rv === false || $rv === 0) { - return $written; - } - } - return $written; - } - -/** - * Read data from the socket. Returns false if no data is available or no connection could be - * established. - * - * @param integer $length Optional buffer length to read; defaults to 1024 - * @return mixed Socket data - */ - public function read($length = 1024) { - if (!$this->connected) { - if (!$this->connect()) { - return false; - } - } - - if (!feof($this->connection)) { - $buffer = fread($this->connection, $length); - $info = stream_get_meta_data($this->connection); - if ($info['timed_out']) { - $this->setLastError(E_WARNING, __d('cake_dev', 'Connection timed out')); - return false; - } - return $buffer; - } - return false; - } - -/** - * Disconnect the socket from the current connection. - * - * @return boolean Success - */ - public function disconnect() { - if (!is_resource($this->connection)) { - $this->connected = false; - return true; - } - $this->connected = !fclose($this->connection); - - if (!$this->connected) { - $this->connection = null; - } - return !$this->connected; - } - -/** - * Destructor, used to disconnect from current connection. - * - */ - public function __destruct() { - $this->disconnect(); - } - -/** - * Resets the state of this Socket instance to it's initial state (before Object::__construct got executed) - * - * @param array $state Array with key and values to reset - * @return boolean True on success - */ - public function reset($state = null) { - if (empty($state)) { - static $initalState = array(); - if (empty($initalState)) { - $initalState = get_class_vars(__CLASS__); - } - $state = $initalState; - } - - foreach ($state as $property => $value) { - $this->{$property} = $value; - } - return true; - } - -} diff --git a/lib/Cake/Network/Email/AbstractTransport.php b/lib/Cake/Network/Email/AbstractTransport.php deleted file mode 100644 index 401d51152eb..00000000000 --- a/lib/Cake/Network/Email/AbstractTransport.php +++ /dev/null @@ -1,76 +0,0 @@ -_config = $config; - } - return $this->_config; - } - -/** - * Help to convert headers in string - * - * @param array $headers Headers in format key => value - * @param string $eol - * @return string - */ - protected function _headersToString($headers, $eol = "\r\n") { - $out = ''; - foreach ($headers as $key => $value) { - if ($value === false || $value === null || $value === '') { - continue; - } - $out .= $key . ': ' . $value . $eol; - } - if (!empty($out)) { - $out = substr($out, 0, -1 * strlen($eol)); - } - return $out; - } - -} diff --git a/lib/Cake/Network/Email/CakeEmail.php b/lib/Cake/Network/Email/CakeEmail.php deleted file mode 100644 index be21205ad2c..00000000000 --- a/lib/Cake/Network/Email/CakeEmail.php +++ /dev/null @@ -1,1478 +0,0 @@ -_appCharset = Configure::read('App.encoding'); - if ($this->_appCharset !== null) { - $this->charset = $this->_appCharset; - } - if ($config) { - $this->config($config); - } - if (empty($this->headerCharset)) { - $this->headerCharset = $this->charset; - } - } - -/** - * From - * - * @param mixed $email - * @param string $name - * @return mixed - * @throws SocketException - */ - public function from($email = null, $name = null) { - if ($email === null) { - return $this->_from; - } - return $this->_setEmailSingle('_from', $email, $name, __d('cake_dev', 'From requires only 1 email address.')); - } - -/** - * Sender - * - * @param mixed $email - * @param string $name - * @return mixed - * @throws SocketException - */ - public function sender($email = null, $name = null) { - if ($email === null) { - return $this->_sender; - } - return $this->_setEmailSingle('_sender', $email, $name, __d('cake_dev', 'Sender requires only 1 email address.')); - } - -/** - * Reply-To - * - * @param mixed $email - * @param string $name - * @return mixed - * @throws SocketException - */ - public function replyTo($email = null, $name = null) { - if ($email === null) { - return $this->_replyTo; - } - return $this->_setEmailSingle('_replyTo', $email, $name, __d('cake_dev', 'Reply-To requires only 1 email address.')); - } - -/** - * Read Receipt (Disposition-Notification-To header) - * - * @param mixed $email - * @param string $name - * @return mixed - * @throws SocketException - */ - public function readReceipt($email = null, $name = null) { - if ($email === null) { - return $this->_readReceipt; - } - return $this->_setEmailSingle('_readReceipt', $email, $name, __d('cake_dev', 'Disposition-Notification-To requires only 1 email address.')); - } - -/** - * Return Path - * - * @param mixed $email - * @param string $name - * @return mixed - * @throws SocketException - */ - public function returnPath($email = null, $name = null) { - if ($email === null) { - return $this->_returnPath; - } - return $this->_setEmailSingle('_returnPath', $email, $name, __d('cake_dev', 'Return-Path requires only 1 email address.')); - } - -/** - * To - * - * @param mixed $email Null to get, String with email, Array with email as key, name as value or email as value (without name) - * @param string $name - * @return mixed - */ - public function to($email = null, $name = null) { - if ($email === null) { - return $this->_to; - } - return $this->_setEmail('_to', $email, $name); - } - -/** - * Add To - * - * @param mixed $email String with email, Array with email as key, name as value or email as value (without name) - * @param string $name - * @return CakeEmail $this - */ - public function addTo($email, $name = null) { - return $this->_addEmail('_to', $email, $name); - } - -/** - * Cc - * - * @param mixed $email String with email, Array with email as key, name as value or email as value (without name) - * @param string $name - * @return mixed - */ - public function cc($email = null, $name = null) { - if ($email === null) { - return $this->_cc; - } - return $this->_setEmail('_cc', $email, $name); - } - -/** - * Add Cc - * - * @param mixed $email String with email, Array with email as key, name as value or email as value (without name) - * @param string $name - * @return CakeEmail $this - */ - public function addCc($email, $name = null) { - return $this->_addEmail('_cc', $email, $name); - } - -/** - * Bcc - * - * @param mixed $email String with email, Array with email as key, name as value or email as value (without name) - * @param string $name - * @return mixed - */ - public function bcc($email = null, $name = null) { - if ($email === null) { - return $this->_bcc; - } - return $this->_setEmail('_bcc', $email, $name); - } - -/** - * Add Bcc - * - * @param mixed $email String with email, Array with email as key, name as value or email as value (without name) - * @param string $name - * @return CakeEmail $this - */ - public function addBcc($email, $name = null) { - return $this->_addEmail('_bcc', $email, $name); - } - -/** - * Set email - * - * @param string $varName - * @param mixed $email - * @param mixed $name - * @return CakeEmail $this - * @throws SocketException - */ - protected function _setEmail($varName, $email, $name) { - if (!is_array($email)) { - if (!Validation::email($email)) { - throw new SocketException(__d('cake_dev', 'Invalid email: "%s"', $email)); - } - if ($name === null) { - $name = $email; - } - $this->{$varName} = array($email => $name); - return $this; - } - $list = array(); - foreach ($email as $key => $value) { - if (is_int($key)) { - $key = $value; - } - if (!Validation::email($key)) { - throw new SocketException(__d('cake_dev', 'Invalid email: "%s"', $key)); - } - $list[$key] = $value; - } - $this->{$varName} = $list; - return $this; - } - -/** - * Set only 1 email - * - * @param string $varName - * @param mixed $email - * @param string $name - * @param string $throwMessage - * @return CakeEmail $this - * @throws SocketException - */ - 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 SocketException($throwMessage); - } - return $this; - } - -/** - * Add email - * - * @param string $varName - * @param mixed $email - * @param mixed $name - * @return CakeEmail $this - * @throws SocketException - */ - protected function _addEmail($varName, $email, $name) { - if (!is_array($email)) { - if (!Validation::email($email)) { - throw new SocketException(__d('cake_dev', 'Invalid email: "%s"', $email)); - } - if ($name === null) { - $name = $email; - } - $this->{$varName}[$email] = $name; - return $this; - } - $list = array(); - foreach ($email as $key => $value) { - if (is_int($key)) { - $key = $value; - } - if (!Validation::email($key)) { - throw new SocketException(__d('cake_dev', 'Invalid email: "%s"', $key)); - } - $list[$key] = $value; - } - $this->{$varName} = array_merge($this->{$varName}, $list); - return $this; - } - -/** - * Get/Set Subject. - * - * @param null|string $subject - * @return mixed - */ - public function subject($subject = null) { - if ($subject === null) { - return $this->_subject; - } - $this->_subject = $this->_encode((string)$subject); - return $this; - } - -/** - * Sets headers for the message - * - * @param array $headers Associative array containing headers to be set. - * @return CakeEmail $this - * @throws SocketException - */ - public function setHeaders($headers) { - if (!is_array($headers)) { - throw new SocketException(__d('cake_dev', '$headers should be an array.')); - } - $this->_headers = $headers; - return $this; - } - -/** - * Add header for the message - * - * @param array $headers - * @return object $this - * @throws SocketException - */ - public function addHeaders($headers) { - if (!is_array($headers)) { - throw new SocketException(__d('cake_dev', '$headers should be an array.')); - } - $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 - * @return array - */ - public function getHeaders($include = array()) { - if ($include == array_values($include)) { - $include = array_fill_keys($include, true); - } - $defaults = array_fill_keys(array('from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'bcc', 'subject'), false); - $include += $defaults; - - $headers = array(); - $relation = array( - '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 (array('to', 'cc', 'bcc') as $var) { - if ($include[$var]) { - $classVar = '_' . $var; - $headers[ucfirst($var)] = implode(', ', $this->_formatAddress($this->{$classVar})); - } - } - - $headers += $this->_headers; - if (!isset($headers['X-Mailer'])) { - $headers['X-Mailer'] = self::EMAIL_CLIENT; - } - if (!isset($headers['Date'])) { - $headers['Date'] = date(DATE_RFC2822); - } - if ($this->_messageId !== false) { - if ($this->_messageId === true) { - $headers['Message-ID'] = '<' . str_replace('-', '', String::UUID()) . '@' . env('HTTP_HOST') . '>'; - } else { - $headers['Message-ID'] = $this->_messageId; - } - } - - if ($include['subject']) { - $headers['Subject'] = $this->_subject; - } - - $headers['MIME-Version'] = '1.0'; - if (!empty($this->_attachments) || $this->_emailFormat === 'both') { - $headers['Content-Type'] = 'multipart/mixed; boundary="' . $this->_boundary . '"'; - } elseif ($this->_emailFormat === 'text') { - $headers['Content-Type'] = 'text/plain; charset=' . $this->charset; - } elseif ($this->_emailFormat === 'html') { - $headers['Content-Type'] = 'text/html; charset=' . $this->charset; - } - $headers['Content-Transfer-Encoding'] = $this->_getContentTransferEncoding(); - - return $headers; - } - -/** - * Format addresses - * - * @param array $address - * @return array - */ - protected function _formatAddress($address) { - $return = array(); - foreach ($address as $email => $alias) { - if ($email === $alias) { - $return[] = $email; - } else { - if (strpos($alias, ',') !== false) { - $alias = '"' . $alias . '"'; - } - $return[] = sprintf('%s <%s>', $this->_encode($alias), $email); - } - } - return $return; - } - -/** - * Template and layout - * - * @param mixed $template Template name or null to not use - * @param mixed $layout Layout name or null to not use - * @return mixed - */ - public function template($template = false, $layout = false) { - if ($template === false) { - return array( - 'template' => $this->_template, - 'layout' => $this->_layout - ); - } - $this->_template = $template; - if ($layout !== false) { - $this->_layout = $layout; - } - return $this; - } - -/** - * View class for render - * - * @param string $viewClass - * @return mixed - */ - public function viewRender($viewClass = null) { - if ($viewClass === null) { - return $this->_viewRender; - } - $this->_viewRender = $viewClass; - return $this; - } - -/** - * Variables to be set on render - * - * @param array $viewVars - * @return mixed - */ - public function viewVars($viewVars = null) { - if ($viewVars === null) { - return $this->_viewVars; - } - $this->_viewVars = array_merge($this->_viewVars, (array)$viewVars); - return $this; - } - -/** - * Helpers to be used in render - * - * @param array $helpers - * @return mixed - */ - public function helpers($helpers = null) { - if ($helpers === null) { - return $this->_helpers; - } - $this->_helpers = (array)$helpers; - return $this; - } - -/** - * Email format - * - * @param string $format - * @return mixed - * @throws SocketException - */ - public function emailFormat($format = null) { - if ($format === null) { - return $this->_emailFormat; - } - if (!in_array($format, $this->_emailFormatAvailable)) { - throw new SocketException(__d('cake_dev', 'Format not available.')); - } - $this->_emailFormat = $format; - return $this; - } - -/** - * Transport name - * - * @param string $name - * @return mixed - */ - public function transport($name = null) { - if ($name === null) { - return $this->_transportName; - } - $this->_transportName = (string)$name; - $this->_transportClass = null; - return $this; - } - -/** - * Return the transport class - * - * @return CakeEmail - * @throws SocketException - */ - public function transportClass() { - if ($this->_transportClass) { - return $this->_transportClass; - } - list($plugin, $transportClassname) = pluginSplit($this->_transportName, true); - $transportClassname .= 'Transport'; - App::uses($transportClassname, $plugin . 'Network/Email'); - if (!class_exists($transportClassname)) { - throw new SocketException(__d('cake_dev', 'Class "%s" not found.', $transportClassname)); - } elseif (!method_exists($transportClassname, 'send')) { - throw new SocketException(__d('cake_dev', 'The "%s" do not have send method.', $transportClassname)); - } - - return $this->_transportClass = new $transportClassname(); - } - -/** - * Message-ID - * - * @param mixed $message True to generate a new Message-ID, False to ignore (not send in email), String to set as Message-ID - * @return mixed - * @throws SocketException - */ - public function messageId($message = null) { - if ($message === null) { - return $this->_messageId; - } - if (is_bool($message)) { - $this->_messageId = $message; - } else { - if (!preg_match('/^\<.+@.+\>$/', $message)) { - throw new SocketException(__d('cake_dev', 'Invalid format for Message-ID. The text should be something like ""')); - } - $this->_messageId = $message; - } - return $this; - } - -/** - * 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(array('custom_name.txt' => 'path/to/file.txt')); - * }}} - * - * Attach a file and specify additional properties: - * - * {{{ - * $email->attachments(array('custom_name.png' => array( - * 'file' => 'path/to/file', - * 'mimetype' => 'image/png', - * 'contentId' => 'abc123' - * )); - * }}} - * - * The `contentId` key allows you to specify an inline attachment. In your email text, you - * can use `` to display the image inline. - * - * @param mixed $attachments String with the filename or array with filenames - * @return mixed Either the array of attachments when getting or $this when setting. - * @throws SocketException - */ - public function attachments($attachments = null) { - if ($attachments === null) { - return $this->_attachments; - } - $attach = array(); - foreach ((array)$attachments as $name => $fileInfo) { - if (!is_array($fileInfo)) { - $fileInfo = array('file' => $fileInfo); - } - if (!isset($fileInfo['file'])) { - throw new SocketException(__d('cake_dev', 'File not specified.')); - } - $fileInfo['file'] = realpath($fileInfo['file']); - if ($fileInfo['file'] === false || !file_exists($fileInfo['file'])) { - throw new SocketException(__d('cake_dev', 'File not found: "%s"', $fileInfo['file'])); - } - if (is_int($name)) { - $name = basename($fileInfo['file']); - } - if (!isset($fileInfo['mimetype'])) { - $fileInfo['mimetype'] = 'application/octet-stream'; - } - $attach[$name] = $fileInfo; - } - $this->_attachments = $attach; - return $this; - } - -/** - * Add attachments - * - * @param mixed $attachments String with the filename or array with filenames - * @return CakeEmail $this - * @throws SocketException - */ - public function addAttachments($attachments) { - $current = $this->_attachments; - $this->attachments($attachments); - $this->_attachments = array_merge($current, $this->_attachments); - return $this; - } - -/** - * Get generated message (used by transport classes) - * - * @param mixed $type Use MESSAGE_* constants or null to return the full message as array - * @return mixed String if have type, array if type is null - */ - public function message($type = null) { - switch ($type) { - case self::MESSAGE_HTML: - return $this->_htmlMessage; - case self::MESSAGE_TEXT: - return $this->_textMessage; - } - return $this->_message; - } - -/** - * Configuration to use when send email - * - * @param mixed $config String with configuration name (from email.php), array with config or null to return current config - * @return mixed - */ - public function config($config = null) { - if ($config === null) { - return $this->_config; - } - if (!is_array($config)) { - $config = (string)$config; - } - - $this->_applyConfig($config); - return $this; - } - -/** - * Send an email using the specified content, template and layout - * - * @param mixed $content String with message or array with messages - * @return array - * @throws SocketException - */ - public function send($content = null) { - if (empty($this->_from)) { - throw new SocketException(__d('cake_dev', 'From is not specified.')); - } - if (empty($this->_to) && empty($this->_cc) && empty($this->_bcc)) { - throw new SocketException(__d('cake_dev', 'You need to specify at least one destination for to, cc or bcc.')); - } - - if (is_array($content)) { - $content = implode("\n", $content) . "\n"; - } - - $this->_textMessage = $this->_htmlMessage = ''; - $this->_createBoundary(); - $this->_message = $this->_render($this->_wrap($content)); - - $contents = $this->transportClass()->send($this); - if (!empty($this->_config['log'])) { - $level = LOG_DEBUG; - if ($this->_config['log'] !== true) { - $level = $this->_config['log']; - } - CakeLog::write($level, PHP_EOL . $contents['headers'] . PHP_EOL . $contents['message']); - } - return $contents; - } - -/** - * Static method to fast create an instance of CakeEmail - * - * @param mixed $to Address to send (see CakeEmail::to()). If null, will try to use 'to' from transport config - * @param mixed $subject String of subject or null to use 'subject' from transport config - * @param mixed $message String with message or array with variables to be used in render - * @param mixed $transportConfig String to use config from EmailConfig or array with configs - * @param boolean $send Send the email or just return the instance pre-configured - * @return CakeEmail Instance of CakeEmail - * @throws SocketException - */ - public static function deliver($to = null, $subject = null, $message = null, $transportConfig = 'fast', $send = true) { - $class = __CLASS__; - $instance = new $class($transportConfig); - if ($to !== null) { - $instance->to($to); - } - if ($subject !== null) { - $instance->subject($subject); - } - if (is_array($message)) { - $instance->viewVars($message); - $message = null; - } elseif ($message === null && array_key_exists('message', $config = $instance->config())) { - $message = $config['message']; - } - - if ($send === true) { - $instance->send($message); - } - - return $instance; - } - -/** - * Apply the config to an instance - * - * @param CakeEmail $obj CakeEmail - * @param array $config - * @return void - * @throws ConfigureException When configuration file cannot be found, or is missing - * the named config. - */ - protected function _applyConfig($config) { - if (is_string($config)) { - if (!class_exists('EmailConfig') && !config('email')) { - throw new ConfigureException(__d('cake_dev', '%s not found.', APP . 'Config' . DS . 'email.php')); - } - $configs = new EmailConfig(); - if (!isset($configs->{$config})) { - throw new ConfigureException(__d('cake_dev', 'Unknown email configuration "%s".', $config)); - } - $config = $configs->{$config}; - } - $this->_config += $config; - if (!empty($config['charset'])) { - $this->charset = $config['charset']; - } - if (!empty($config['headerCharset'])) { - $this->headerCharset = $config['headerCharset']; - } - if (empty($this->headerCharset)) { - $this->headerCharset = $this->charset; - } - $simpleMethods = array( - 'from', 'sender', 'to', 'replyTo', 'readReceipt', 'returnPath', 'cc', 'bcc', - 'messageId', 'subject', 'viewRender', 'viewVars', 'attachments', - 'transport', 'emailFormat' - ); - foreach ($simpleMethods as $method) { - if (isset($config[$method])) { - $this->$method($config[$method]); - unset($config[$method]); - } - } - if (isset($config['headers'])) { - $this->setHeaders($config['headers']); - unset($config['headers']); - } - if (array_key_exists('template', $config)) { - $layout = false; - if (array_key_exists('layout', $config)) { - $layout = $config['layout']; - unset($config['layout']); - } - $this->template($config['template'], $layout); - unset($config['template']); - } - $this->transportClass()->config($config); - } - -/** - * Reset all EmailComponent internal variables to be able to send out a new email. - * - * @return CakeEmail $this - */ - public function reset() { - $this->_to = array(); - $this->_from = array(); - $this->_sender = array(); - $this->_replyTo = array(); - $this->_readReceipt = array(); - $this->_returnPath = array(); - $this->_cc = array(); - $this->_bcc = array(); - $this->_messageId = true; - $this->_subject = ''; - $this->_headers = array(); - $this->_layout = 'default'; - $this->_template = ''; - $this->_viewRender = 'View'; - $this->_viewVars = array(); - $this->_helpers = array('Html'); - $this->_textMessage = ''; - $this->_htmlMessage = ''; - $this->_message = ''; - $this->_emailFormat = 'text'; - $this->_transportName = 'Mail'; - $this->_transportClass = null; - $this->_attachments = array(); - $this->_config = array(); - return $this; - } - -/** - * Encode the specified string using the current charset - * - * @param string $text String to encode - * @return string Encoded string - */ - protected function _encode($text) { - $internalEncoding = function_exists('mb_internal_encoding'); - if ($internalEncoding) { - $restore = mb_internal_encoding(); - mb_internal_encoding($this->_appCharset); - } - $return = mb_encode_mimeheader($text, $this->headerCharset, 'B'); - if ($internalEncoding) { - 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 || !function_exists('mb_convert_encoding')) { - 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 - * @return array Wrapped message - */ - protected function _wrap($message) { - $message = str_replace(array("\r\n", "\r"), "\n", $message); - $lines = explode("\n", $message); - $formatted = array(); - - foreach ($lines as $line) { - if (empty($line)) { - $formatted[] = ''; - continue; - } - if ($line[0] === '.') { - $line = '.' . $line; - } - if (!preg_match('/\<[a-z]/i', $line)) { - $formatted = array_merge($formatted, explode("\n", wordwrap($line, self::LINE_LENGTH_SHOULD, "\n"))); - 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 < self::LINE_LENGTH_SHOULD) { - $tmpLine .= $tag; - $tmpLineLength += $tagLength; - } else { - if ($tmpLineLength > 0) { - $formatted[] = trim($tmpLine); - $tmpLine = ''; - $tmpLineLength = 0; - } - if ($tagLength > self::LINE_LENGTH_SHOULD) { - $formatted[] = $tag; - } else { - $tmpLine = $tag; - $tmpLineLength = $tagLength; - } - } - $tag = ''; - $tagOpen = false; - } - continue; - } - if ($char === '<') { - $tagOpen = true; - $tag = '<'; - continue; - } - if ($char === ' ' && $tmpLineLength >= self::LINE_LENGTH_SHOULD) { - $formatted[] = $tmpLine; - $tmpLineLength = 0; - continue; - } - $tmpLine .= $char; - $tmpLineLength++; - if ($tmpLineLength === self::LINE_LENGTH_SHOULD) { - $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 (!empty($this->_attachments) || $this->_emailFormat === 'both') { - $this->_boundary = md5(uniqid(time())); - } - } - -/** - * Attach non-embedded files by adding file contents inside boundaries. - * - * @param string $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 = array(); - foreach ($this->_attachments as $filename => $fileInfo) { - if (!empty($fileInfo['contentId'])) { - continue; - } - $data = $this->_readFile($fileInfo['file']); - - $msg[] = '--' . $boundary; - $msg[] = 'Content-Type: ' . $fileInfo['mimetype']; - $msg[] = 'Content-Transfer-Encoding: base64'; - $msg[] = 'Content-Disposition: attachment; filename="' . $filename . '"'; - $msg[] = ''; - $msg[] = $data; - $msg[] = ''; - } - return $msg; - } - -/** - * Read the file contents and return a base64 version of the file contents. - * - * @param string $file The file to read. - * @return string File contents in base64 encoding - */ - protected function _readFile($file) { - $handle = fopen($file, 'rb'); - $data = fread($handle, filesize($file)); - $data = chunk_split(base64_encode($data)); - fclose($handle); - return $data; - } - -/** - * Attach inline/embedded files to the message. - * - * @param string $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 = array(); - foreach ($this->_attachments as $filename => $fileInfo) { - if (empty($fileInfo['contentId'])) { - continue; - } - $data = $this->_readFile($fileInfo['file']); - - $msg[] = '--' . $boundary; - $msg[] = 'Content-Type: ' . $fileInfo['mimetype']; - $msg[] = 'Content-Transfer-Encoding: base64'; - $msg[] = 'Content-ID: <' . $fileInfo['contentId'] . '>'; - $msg[] = 'Content-Disposition: inline; filename="' . $filename . '"'; - $msg[] = ''; - $msg[] = $data; - $msg[] = ''; - } - return $msg; - } - -/** - * Render the body of the email. - * - * @param string $content Content to render - * @return array Email body ready to be sent - */ - protected function _render($content) { - $content = implode("\n", $content); - $rendered = $this->_renderTemplates($content); - - $msg = array(); - - $contentIds = array_filter((array)Set::classicExtract($this->_attachments, '{s}.contentId')); - $hasInlineAttachments = count($contentIds) > 0; - $hasAttachments = !empty($this->_attachments); - $hasMultipleTypes = count($rendered) > 1; - - $boundary = $relBoundary = $textBoundary = $this->_boundary; - - if ($hasInlineAttachments) { - $msg[] = '--' . $boundary; - $msg[] = 'Content-Type: multipart/related; boundary="rel-' . $boundary . '"'; - $msg[] = ''; - $relBoundary = $textBoundary = 'rel-' . $boundary; - } - - if ($hasMultipleTypes) { - $msg[] = '--' . $relBoundary; - $msg[] = 'Content-Type: multipart/alternative; boundary="alt-' . $boundary . '"'; - $msg[] = ''; - $textBoundary = 'alt-' . $boundary; - } - - if (isset($rendered['text'])) { - if ($textBoundary !== $boundary || $hasAttachments) { - $msg[] = '--' . $textBoundary; - $msg[] = 'Content-Type: text/plain; charset=' . $this->charset; - $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 ($textBoundary !== $boundary || $hasAttachments) { - $msg[] = '--' . $textBoundary; - $msg[] = 'Content-Type: text/html; charset=' . $this->charset; - $msg[] = 'Content-Transfer-Encoding: ' . $this->_getContentTransferEncoding(); - $msg[] = ''; - } - $this->_htmlMessage = $rendered['html']; - $content = explode("\n", $this->_htmlMessage); - $msg = array_merge($msg, $content); - $msg[] = ''; - } - - if ($hasMultipleTypes) { - $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 = array($this->_emailFormat); - if ($this->_emailFormat == 'both') { - $types = array('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 = array(); - if (empty($this->_template)) { - foreach ($types as $type) { - $rendered[$type] = $this->_encodeString($content, $this->charset); - } - return $rendered; - } - $viewClass = $this->_viewRender; - if ($viewClass !== 'View') { - list($plugin, $viewClass) = pluginSplit($viewClass, true); - $viewClass .= 'View'; - App::uses($viewClass, $plugin . 'View'); - } - - $View = new $viewClass(null); - $View->viewVars = $this->_viewVars; - $View->helpers = $this->_helpers; - - list($templatePlugin, $template) = pluginSplit($this->_template); - list($layoutPlugin, $layout) = pluginSplit($this->_layout); - if ($templatePlugin) { - $View->plugin = $templatePlugin; - } elseif ($layoutPlugin) { - $View->plugin = $layoutPlugin; - } - - foreach ($types as $type) { - $View->set('content', $content); - $View->hasRendered = false; - $View->viewPath = $View->layoutPath = 'Emails' . DS . $type; - - $render = $View->render($template, $layout); - $render = str_replace(array("\r\n", "\r"), "\n", $render); - $rendered[$type] = $this->_encodeString($render, $this->charset); - } - return $rendered; - } - -/** - * Return the Content-Transfer Encoding value based on the set charset - * - * @return void - */ - protected function _getContentTransferEncoding() { - $charset = strtoupper($this->charset); - if (in_array($charset, $this->_charset8bit)) { - return '8bit'; - } - return '7bit'; - } - -} diff --git a/lib/Cake/Network/Email/DebugTransport.php b/lib/Cake/Network/Email/DebugTransport.php deleted file mode 100644 index e1f31f1454e..00000000000 --- a/lib/Cake/Network/Email/DebugTransport.php +++ /dev/null @@ -1,41 +0,0 @@ -getHeaders(array('from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'subject')); - $headers = $this->_headersToString($headers); - $message = implode("\r\n", (array)$email->message()); - return array('headers' => $headers, 'message' => $message); - } - -} diff --git a/lib/Cake/Network/Email/MailTransport.php b/lib/Cake/Network/Email/MailTransport.php deleted file mode 100644 index ae63b7612ff..00000000000 --- a/lib/Cake/Network/Email/MailTransport.php +++ /dev/null @@ -1,54 +0,0 @@ -_config['eol'])) { - $eol = $this->_config['eol']; - } - $headers = $email->getHeaders(array('from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'bcc')); - $to = $headers['To']; - unset($headers['To']); - $headers = $this->_headersToString($headers, $eol); - $message = implode($eol, $email->message()); - if (ini_get('safe_mode') || !isset($this->_config['additionalParameters'])) { - if (!@mail($to, $email->subject(), $message, $headers)) { - throw new SocketException(__d('cake_dev', 'Could not send email.')); - } - } elseif (!@mail($to, $email->subject(), $message, $headers, $this->_config['additionalParameters'])) { - throw new SocketException(__d('cake_dev', 'Could not send email.')); - } - return array('headers' => $headers, 'message' => $message); - } - -} diff --git a/lib/Cake/Network/Email/SmtpTransport.php b/lib/Cake/Network/Email/SmtpTransport.php deleted file mode 100644 index e89b21557fd..00000000000 --- a/lib/Cake/Network/Email/SmtpTransport.php +++ /dev/null @@ -1,231 +0,0 @@ -_cakeEmail = $email; - - $this->_connect(); - $this->_auth(); - $this->_sendRcpt(); - $this->_sendData(); - $this->_disconnect(); - - return $this->_content; - } - -/** - * Set the configuration - * - * @param array $config - * @return void - */ - public function config($config = array()) { - $default = array( - 'host' => 'localhost', - 'port' => 25, - 'timeout' => 30, - 'username' => null, - 'password' => null, - 'client' => null - ); - $this->_config = $config + $default; - } - -/** - * Connect to SMTP Server - * - * @return void - * @throws SocketException - */ - protected function _connect() { - $this->_generateSocket(); - if (!$this->_socket->connect()) { - throw new SocketException(__d('cake_dev', 'Unable to connect to SMTP server.')); - } - $this->_smtpSend(null, '220'); - - if (isset($this->_config['client'])) { - $host = $this->_config['client']; - } elseif ($httpHost = env('HTTP_HOST')) { - list($host) = explode(':', $httpHost); - } else { - $host = 'localhost'; - } - - try { - $this->_smtpSend("EHLO {$host}", '250'); - } catch (SocketException $e) { - try { - $this->_smtpSend("HELO {$host}", '250'); - } catch (SocketException $e2) { - throw new SocketException(__d('cake_dev', 'SMTP server did not accept the connection.')); - } - } - } - -/** - * Send authentication - * - * @return void - * @throws SocketException - */ - protected function _auth() { - if (isset($this->_config['username']) && isset($this->_config['password'])) { - $authRequired = $this->_smtpSend('AUTH LOGIN', '334|503'); - if ($authRequired == '334') { - if (!$this->_smtpSend(base64_encode($this->_config['username']), '334')) { - throw new SocketException(__d('cake_dev', 'SMTP server did not accept the username.')); - } - if (!$this->_smtpSend(base64_encode($this->_config['password']), '235')) { - throw new SocketException(__d('cake_dev', 'SMTP server did not accept the password.')); - } - } elseif ($authRequired != '503') { - throw new SocketException(__d('cake_dev', 'SMTP does not require authentication.')); - } - } - } - -/** - * Send emails - * - * @return void - * @throws SocketException - */ - protected function _sendRcpt() { - $from = $this->_cakeEmail->from(); - $this->_smtpSend('MAIL FROM:<' . key($from) . '>'); - - $to = $this->_cakeEmail->to(); - $cc = $this->_cakeEmail->cc(); - $bcc = $this->_cakeEmail->bcc(); - $emails = array_merge(array_keys($to), array_keys($cc), array_keys($bcc)); - foreach ($emails as $email) { - $this->_smtpSend('RCPT TO:<' . $email . '>'); - } - } - -/** - * Send Data - * - * @return void - * @throws SocketException - */ - protected function _sendData() { - $this->_smtpSend('DATA', '354'); - - $headers = $this->_cakeEmail->getHeaders(array('from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'subject')); - $headers = $this->_headersToString($headers); - $message = implode("\r\n", $this->_cakeEmail->message()); - $this->_smtpSend($headers . "\r\n\r\n" . $message . "\r\n\r\n\r\n."); - $this->_content = array('headers' => $headers, 'message' => $message); - } - -/** - * Disconnect - * - * @return void - * @throws SocketException - */ - protected function _disconnect() { - $this->_smtpSend('QUIT', false); - $this->_socket->disconnect(); - } - -/** - * Helper method to generate socket - * - * @return void - * @throws SocketException - */ - protected function _generateSocket() { - $this->_socket = new CakeSocket($this->_config); - } - -/** - * Protected method for sending data to SMTP connection - * - * @param string $data data to be sent to SMTP server - * @param mixed $checkCode code to check for in server response, false to skip - * @return void - * @throws SocketException - */ - protected function _smtpSend($data, $checkCode = '250') { - if (!is_null($data)) { - $this->_socket->write($data . "\r\n"); - } - while ($checkCode !== false) { - $response = ''; - $startTime = time(); - while (substr($response, -2) !== "\r\n" && ((time() - $startTime) < $this->_config['timeout'])) { - $response .= $this->_socket->read(); - } - if (substr($response, -2) !== "\r\n") { - throw new SocketException(__d('cake_dev', 'SMTP timeout.')); - } - $responseLines = explode("\r\n", rtrim($response, "\r\n")); - $response = end($responseLines); - - if (preg_match('/^(' . $checkCode . ')(.)/', $response, $code)) { - if ($code[2] === '-') { - continue; - } - return $code[1]; - } - throw new SocketException(__d('cake_dev', 'SMTP Error: %s', $response)); - } - } - -} diff --git a/lib/Cake/Network/Http/BasicAuthentication.php b/lib/Cake/Network/Http/BasicAuthentication.php deleted file mode 100644 index c39b2eea52f..00000000000 --- a/lib/Cake/Network/Http/BasicAuthentication.php +++ /dev/null @@ -1,66 +0,0 @@ -request['header']['Authorization'] = self::_generateHeader($authInfo['user'], $authInfo['pass']); - } - } - -/** - * Proxy Authentication - * - * @param HttpSocket $http - * @param array $proxyInfo - * @return void - * @see http://www.ietf.org/rfc/rfc2617.txt - */ - public static function proxyAuthentication(HttpSocket $http, &$proxyInfo) { - if (isset($proxyInfo['user'], $proxyInfo['pass'])) { - $http->request['header']['Proxy-Authorization'] = self::_generateHeader($proxyInfo['user'], $proxyInfo['pass']); - } - } - -/** - * Generate basic [proxy] authentication header - * - * @param string $user - * @param string $pass - * @return string - */ - protected static function _generateHeader($user, $pass) { - return 'Basic ' . base64_encode($user . ':' . $pass); - } - -} diff --git a/lib/Cake/Network/Http/DigestAuthentication.php b/lib/Cake/Network/Http/DigestAuthentication.php deleted file mode 100644 index 5518ce94837..00000000000 --- a/lib/Cake/Network/Http/DigestAuthentication.php +++ /dev/null @@ -1,105 +0,0 @@ -request['header']['Authorization'] = self::_generateHeader($http, $authInfo); - } - } - -/** - * Retrieve information about the authentication - * - * @param HttpSocket $http - * @param array $authInfo - * @return boolean - */ - protected static function _getServerInformation(HttpSocket $http, &$authInfo) { - $originalRequest = $http->request; - $http->configAuth(false); - $http->request($http->request); - $http->request = $originalRequest; - $http->configAuth('Digest', $authInfo); - - if (empty($http->response['header']['WWW-Authenticate'])) { - return false; - } - preg_match_all('@(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))@', $http->response['header']['WWW-Authenticate'], $matches, PREG_SET_ORDER); - foreach ($matches as $match) { - $authInfo[$match[1]] = $match[2]; - } - if (!empty($authInfo['qop']) && empty($authInfo['nc'])) { - $authInfo['nc'] = 1; - } - return true; - } - -/** - * Generate the header Authorization - * - * @param HttpSocket $http - * @param array $authInfo - * @return string - */ - protected static function _generateHeader(HttpSocket $http, &$authInfo) { - $a1 = md5($authInfo['user'] . ':' . $authInfo['realm'] . ':' . $authInfo['pass']); - $a2 = md5($http->request['method'] . ':' . $http->request['uri']['path']); - - if (empty($authInfo['qop'])) { - $response = md5($a1 . ':' . $authInfo['nonce'] . ':' . $a2); - } else { - $authInfo['cnonce'] = uniqid(); - $nc = sprintf('%08x', $authInfo['nc']++); - $response = md5($a1 . ':' . $authInfo['nonce'] . ':' . $nc . ':' . $authInfo['cnonce'] . ':auth:' . $a2); - } - - $authHeader = 'Digest '; - $authHeader .= 'username="' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $authInfo['user']) . '", '; - $authHeader .= 'realm="' . $authInfo['realm'] . '", '; - $authHeader .= 'nonce="' . $authInfo['nonce'] . '", '; - $authHeader .= 'uri="' . $http->request['uri']['path'] . '", '; - $authHeader .= 'response="' . $response . '"'; - if (!empty($authInfo['opaque'])) { - $authHeader .= ', opaque="' . $authInfo['opaque'] . '"'; - } - if (!empty($authInfo['qop'])) { - $authHeader .= ', qop="auth", nc=' . $nc . ', cnonce="' . $authInfo['cnonce'] . '"'; - } - return $authHeader; - } - -} diff --git a/lib/Cake/Network/Http/HttpResponse.php b/lib/Cake/Network/Http/HttpResponse.php deleted file mode 100644 index 139e3cfc4fb..00000000000 --- a/lib/Cake/Network/Http/HttpResponse.php +++ /dev/null @@ -1,448 +0,0 @@ -parseResponse($message); - } - } - -/** - * Body content - * - * @return string - */ - public function body() { - return (string)$this->body; - } - -/** - * Get header in case insensitive - * - * @param string $name Header name - * @param array $headers - * @return mixed String if header exists or null - */ - public function getHeader($name, $headers = null) { - if (!is_array($headers)) { - $headers =& $this->headers; - } - if (isset($headers[$name])) { - return $headers[$name]; - } - foreach ($headers as $key => $value) { - if (strcasecmp($key, $name) == 0) { - return $value; - } - } - return null; - } - -/** - * If return is 200 (OK) - * - * @return boolean - */ - public function isOk() { - return $this->code == 200; - } - -/** - * If return is a valid 3xx (Redirection) - * - * @return boolean - */ - public function isRedirect() { - return in_array($this->code, array(301, 302, 303, 307)) && !is_null($this->getHeader('Location')); - } - -/** - * Parses the given message and breaks it down in parts. - * - * @param string $message Message to parse - * @return void - * @throws SocketException - */ - public function parseResponse($message) { - if (!is_string($message)) { - throw new SocketException(__d('cake_dev', 'Invalid response.')); - } - - if (!preg_match("/^(.+\r\n)(.*)(?<=\r\n)\r\n/Us", $message, $match)) { - throw new SocketException(__d('cake_dev', 'Invalid HTTP response.')); - } - - list(, $statusLine, $header) = $match; - $this->raw = $message; - $this->body = (string)substr($message, strlen($match[0])); - - if (preg_match("/(.+) ([0-9]{3}) (.+)\r\n/DU", $statusLine, $match)) { - $this->httpVersion = $match[1]; - $this->code = $match[2]; - $this->reasonPhrase = $match[3]; - } - - $this->headers = $this->_parseHeader($header); - $transferEncoding = $this->getHeader('Transfer-Encoding'); - $decoded = $this->_decodeBody($this->body, $transferEncoding); - $this->body = $decoded['body']; - - if (!empty($decoded['header'])) { - $this->headers = $this->_parseHeader($this->_buildHeader($this->headers) . $this->_buildHeader($decoded['header'])); - } - - if (!empty($this->headers)) { - $this->cookies = $this->parseCookies($this->headers); - } - } - -/** - * Generic function to decode a $body with a given $encoding. Returns either an array with the keys - * 'body' and 'header' or false on failure. - * - * @param string $body A string containing the body to decode. - * @param mixed $encoding Can be false in case no encoding is being used, or a string representing the encoding. - * @return mixed Array of response headers and body or false. - */ - protected function _decodeBody($body, $encoding = 'chunked') { - if (!is_string($body)) { - return false; - } - if (empty($encoding)) { - return array('body' => $body, 'header' => false); - } - $decodeMethod = '_decode' . Inflector::camelize(str_replace('-', '_', $encoding)) . 'Body'; - - if (!is_callable(array(&$this, $decodeMethod))) { - return array('body' => $body, 'header' => false); - } - return $this->{$decodeMethod}($body); - } - -/** - * Decodes a chunked message $body and returns either an array with the keys 'body' and 'header' or false as - * a result. - * - * @param string $body A string containing the chunked body to decode. - * @return mixed Array of response headers and body or false. - * @throws SocketException - */ - protected function _decodeChunkedBody($body) { - if (!is_string($body)) { - return false; - } - - $decodedBody = null; - $chunkLength = null; - - while ($chunkLength !== 0) { - if (!preg_match('/^([0-9a-f]+) *(?:;(.+)=(.+))?(?:\r\n|\n)/iU', $body, $match)) { - throw new SocketException(__d('cake_dev', 'HttpSocket::_decodeChunkedBody - Could not parse malformed chunk.')); - } - - $chunkSize = 0; - $hexLength = 0; - $chunkExtensionName = ''; - $chunkExtensionValue = ''; - if (isset($match[0])) { - $chunkSize = $match[0]; - } - if (isset($match[1])) { - $hexLength = $match[1]; - } - if (isset($match[2])) { - $chunkExtensionName = $match[2]; - } - if (isset($match[3])) { - $chunkExtensionValue = $match[3]; - } - - $body = substr($body, strlen($chunkSize)); - $chunkLength = hexdec($hexLength); - $chunk = substr($body, 0, $chunkLength); - if (!empty($chunkExtensionName)) { - // @todo See if there are popular chunk extensions we should implement - } - $decodedBody .= $chunk; - if ($chunkLength !== 0) { - $body = substr($body, $chunkLength + strlen("\r\n")); - } - } - - $entityHeader = false; - if (!empty($body)) { - $entityHeader = $this->_parseHeader($body); - } - return array('body' => $decodedBody, 'header' => $entityHeader); - } - -/** - * Parses an array based header. - * - * @param array $header Header as an indexed array (field => value) - * @return array Parsed header - */ - protected function _parseHeader($header) { - if (is_array($header)) { - return $header; - } elseif (!is_string($header)) { - return false; - } - - preg_match_all("/(.+):(.+)(?:(?_unescapeToken($field); - - if (!isset($header[$field])) { - $header[$field] = $value; - } else { - $header[$field] = array_merge((array)$header[$field], (array)$value); - } - } - return $header; - } - -/** - * Parses cookies in response headers. - * - * @param array $header Header array containing one ore more 'Set-Cookie' headers. - * @return mixed Either false on no cookies, or an array of cookies received. - * @todo Make this 100% RFC 2965 confirm - */ - public function parseCookies($header) { - $cookieHeader = $this->getHeader('Set-Cookie', $header); - if (!$cookieHeader) { - return false; - } - - $cookies = array(); - foreach ((array)$cookieHeader as $cookie) { - if (strpos($cookie, '";"') !== false) { - $cookie = str_replace('";"', "{__cookie_replace__}", $cookie); - $parts = str_replace("{__cookie_replace__}", '";"', explode(';', $cookie)); - } else { - $parts = preg_split('/\;[ \t]*/', $cookie); - } - - list($name, $value) = explode('=', array_shift($parts), 2); - $cookies[$name] = compact('value'); - - foreach ($parts as $part) { - if (strpos($part, '=') !== false) { - list($key, $value) = explode('=', $part); - } else { - $key = $part; - $value = true; - } - - $key = strtolower($key); - if (!isset($cookies[$name][$key])) { - $cookies[$name][$key] = $value; - } - } - } - return $cookies; - } - -/** - * Unescapes a given $token according to RFC 2616 (HTTP 1.1 specs) - * - * @param string $token Token to unescape - * @param array $chars - * @return string Unescaped token - * @todo Test $chars parameter - */ - protected function _unescapeToken($token, $chars = null) { - $regex = '/"([' . implode('', $this->_tokenEscapeChars(true, $chars)) . '])"/'; - $token = preg_replace($regex, '\\1', $token); - return $token; - } - -/** - * Gets escape chars according to RFC 2616 (HTTP 1.1 specs). - * - * @param boolean $hex true to get them as HEX values, false otherwise - * @param array $chars - * @return array Escape chars - * @todo Test $chars parameter - */ - protected function _tokenEscapeChars($hex = true, $chars = null) { - if (!empty($chars)) { - $escape = $chars; - } else { - $escape = array('"', "(", ")", "<", ">", "@", ",", ";", ":", "\\", "/", "[", "]", "?", "=", "{", "}", " "); - for ($i = 0; $i <= 31; $i++) { - $escape[] = chr($i); - } - $escape[] = chr(127); - } - - if ($hex == false) { - return $escape; - } - foreach ($escape as $key => $char) { - $escape[$key] = '\\x' . str_pad(dechex(ord($char)), 2, '0', STR_PAD_LEFT); - } - return $escape; - } - -/** - * ArrayAccess - Offset Exists - * - * @param mixed $offset - * @return boolean - */ - public function offsetExists($offset) { - return in_array($offset, array('raw', 'status', 'header', 'body', 'cookies')); - } - -/** - * ArrayAccess - Offset Get - * - * @param mixed $offset - * @return mixed - */ - public function offsetGet($offset) { - switch ($offset) { - case 'raw': - $firstLineLength = strpos($this->raw, "\r\n") + 2; - if ($this->raw[$firstLineLength] === "\r") { - $header = null; - } else { - $header = substr($this->raw, $firstLineLength, strpos($this->raw, "\r\n\r\n") - $firstLineLength) . "\r\n"; - } - return array( - 'status-line' => $this->httpVersion . ' ' . $this->code . ' ' . $this->reasonPhrase . "\r\n", - 'header' => $header, - 'body' => $this->body, - 'response' => $this->raw - ); - case 'status': - return array( - 'http-version' => $this->httpVersion, - 'code' => $this->code, - 'reason-phrase' => $this->reasonPhrase - ); - case 'header': - return $this->headers; - case 'body': - return $this->body; - case 'cookies': - return $this->cookies; - } - return null; - } - -/** - * ArrayAccess - Offset Set - * - * @param mixed $offset - * @param mixed $value - * @return void - */ - public function offsetSet($offset, $value) { - } - -/** - * ArrayAccess - Offset Unset - * - * @param mixed $offset - * @return void - */ - public function offsetUnset($offset) { - } - -/** - * Instance as string - * - * @return string - */ - public function __toString() { - return $this->body(); - } - -} diff --git a/lib/Cake/Network/Http/HttpSocket.php b/lib/Cake/Network/Http/HttpSocket.php deleted file mode 100644 index 9ff65977f3f..00000000000 --- a/lib/Cake/Network/Http/HttpSocket.php +++ /dev/null @@ -1,981 +0,0 @@ - 'GET', - 'uri' => array( - 'scheme' => 'http', - 'host' => null, - 'port' => 80, - 'user' => null, - 'pass' => null, - 'path' => null, - 'query' => null, - 'fragment' => null - ), - 'version' => '1.1', - 'body' => '', - 'line' => null, - 'header' => array( - 'Connection' => 'close', - 'User-Agent' => 'CakePHP' - ), - 'raw' => null, - 'redirect' => false, - 'cookies' => array() - ); - -/** - * Contain information about the last response (read only) - * - * @var array - */ - public $response = null; - -/** - * Response classname - * - * @var string - */ - public $responseClass = 'HttpResponse'; - -/** - * Configuration settings for the HttpSocket and the requests - * - * @var array - */ - public $config = array( - 'persistent' => false, - 'host' => 'localhost', - 'protocol' => 'tcp', - 'port' => 80, - 'timeout' => 30, - 'request' => array( - 'uri' => array( - 'scheme' => array('http', 'https'), - 'host' => 'localhost', - 'port' => array(80, 443) - ), - 'redirect' => false, - 'cookies' => array() - ) - ); - -/** - * Authentication settings - * - * @var array - */ - protected $_auth = array(); - -/** - * Proxy settings - * - * @var array - */ - protected $_proxy = array(); - -/** - * Resource to receive the content of request - * - * @var mixed - */ - protected $_contentResource = null; - -/** - * Build an HTTP Socket using the specified configuration. - * - * You can use a url string to set the url and use default configurations for - * all other options: - * - * `$http = new HttpSocket('http://cakephp.org/');` - * - * Or use an array to configure multiple options: - * - * {{{ - * $http = new HttpSocket(array( - * 'host' => 'cakephp.org', - * 'timeout' => 20 - * )); - * }}} - * - * See HttpSocket::$config for options that can be used. - * - * @param mixed $config Configuration information, either a string url or an array of options. - */ - public function __construct($config = array()) { - if (is_string($config)) { - $this->_configUri($config); - } elseif (is_array($config)) { - if (isset($config['request']['uri']) && is_string($config['request']['uri'])) { - $this->_configUri($config['request']['uri']); - unset($config['request']['uri']); - } - $this->config = Set::merge($this->config, $config); - } - parent::__construct($this->config); - } - -/** - * Set authentication settings. - * - * Accepts two forms of parameters. If all you need is a username + password, as with - * Basic authentication you can do the following: - * - * {{{ - * $http->configAuth('Basic', 'mark', 'secret'); - * }}} - * - * If you are using an authentication strategy that requires more inputs, like Digest authentication - * you can call `configAuth()` with an array of user information. - * - * {{{ - * $http->configAuth('Digest', array( - * 'user' => 'mark', - * 'pass' => 'secret', - * 'realm' => 'my-realm', - * 'nonce' => 1235 - * )); - * }}} - * - * To remove any set authentication strategy, call `configAuth()` with no parameters: - * - * `$http->configAuth();` - * - * @param string $method Authentication method (ie. Basic, Digest). If empty, disable authentication - * @param mixed $user Username for authentication. Can be an array with settings to authentication class - * @param string $pass Password for authentication - * @return void - */ - public function configAuth($method, $user = null, $pass = null) { - if (empty($method)) { - $this->_auth = array(); - return; - } - if (is_array($user)) { - $this->_auth = array($method => $user); - return; - } - $this->_auth = array($method => compact('user', 'pass')); - } - -/** - * Set proxy settings - * - * @param mixed $host Proxy host. Can be an array with settings to authentication class - * @param integer $port Port. Default 3128. - * @param string $method Proxy method (ie, Basic, Digest). If empty, disable proxy authentication - * @param string $user Username if your proxy need authentication - * @param string $pass Password to proxy authentication - * @return void - */ - public function configProxy($host, $port = 3128, $method = null, $user = null, $pass = null) { - if (empty($host)) { - $this->_proxy = array(); - return; - } - if (is_array($host)) { - $this->_proxy = $host + array('host' => null); - return; - } - $this->_proxy = compact('host', 'port', 'method', 'user', 'pass'); - } - -/** - * Set the resource to receive the request content. This resource must support fwrite. - * - * @param mixed $resource Resource or false to disable the resource use - * @return void - * @throws SocketException - */ - public function setContentResource($resource) { - if ($resource === false) { - $this->_contentResource = null; - return; - } - if (!is_resource($resource)) { - throw new SocketException(__d('cake_dev', 'Invalid resource.')); - } - $this->_contentResource = $resource; - } - -/** - * Issue the specified request. HttpSocket::get() and HttpSocket::post() wrap this - * method and provide a more granular interface. - * - * @param mixed $request Either an URI string, or an array defining host/uri - * @return mixed false on error, HttpResponse on success - * @throws SocketException - */ - public function request($request = array()) { - $this->reset(false); - - if (is_string($request)) { - $request = array('uri' => $request); - } elseif (!is_array($request)) { - return false; - } - - if (!isset($request['uri'])) { - $request['uri'] = null; - } - $uri = $this->_parseUri($request['uri']); - if (!isset($uri['host'])) { - $host = $this->config['host']; - } - if (isset($request['host'])) { - $host = $request['host']; - unset($request['host']); - } - $request['uri'] = $this->url($request['uri']); - $request['uri'] = $this->_parseUri($request['uri'], true); - $this->request = Set::merge($this->request, array_diff_key($this->config['request'], array('cookies' => true)), $request); - - $this->_configUri($this->request['uri']); - - $Host = $this->request['uri']['host']; - if (!empty($this->config['request']['cookies'][$Host])) { - if (!isset($this->request['cookies'])) { - $this->request['cookies'] = array(); - } - if (!isset($request['cookies'])) { - $request['cookies'] = array(); - } - $this->request['cookies'] = array_merge($this->request['cookies'], $this->config['request']['cookies'][$Host], $request['cookies']); - } - - if (isset($host)) { - $this->config['host'] = $host; - } - $this->_setProxy(); - $this->request['proxy'] = $this->_proxy; - - $cookies = null; - - if (is_array($this->request['header'])) { - if (!empty($this->request['cookies'])) { - $cookies = $this->buildCookies($this->request['cookies']); - } - $scheme = ''; - $port = 0; - if (isset($this->request['uri']['scheme'])) { - $scheme = $this->request['uri']['scheme']; - } - if (isset($this->request['uri']['port'])) { - $port = $this->request['uri']['port']; - } - if ( - ($scheme === 'http' && $port != 80) || - ($scheme === 'https' && $port != 443) || - ($port != 80 && $port != 443) - ) { - $Host .= ':' . $port; - } - $this->request['header'] = array_merge(compact('Host'), $this->request['header']); - } - - if (isset($this->request['uri']['user'], $this->request['uri']['pass'])) { - $this->configAuth('Basic', $this->request['uri']['user'], $this->request['uri']['pass']); - } - $this->_setAuth(); - $this->request['auth'] = $this->_auth; - - if (is_array($this->request['body'])) { - $this->request['body'] = http_build_query($this->request['body']); - } - - if (!empty($this->request['body']) && !isset($this->request['header']['Content-Type'])) { - $this->request['header']['Content-Type'] = 'application/x-www-form-urlencoded'; - } - - if (!empty($this->request['body']) && !isset($this->request['header']['Content-Length'])) { - $this->request['header']['Content-Length'] = strlen($this->request['body']); - } - - $connectionType = null; - if (isset($this->request['header']['Connection'])) { - $connectionType = $this->request['header']['Connection']; - } - $this->request['header'] = $this->_buildHeader($this->request['header']) . $cookies; - - if (empty($this->request['line'])) { - $this->request['line'] = $this->_buildRequestLine($this->request); - } - - if ($this->quirksMode === false && $this->request['line'] === false) { - return false; - } - - $this->request['raw'] = ''; - if ($this->request['line'] !== false) { - $this->request['raw'] = $this->request['line']; - } - - if ($this->request['header'] !== false) { - $this->request['raw'] .= $this->request['header']; - } - - $this->request['raw'] .= "\r\n"; - $this->request['raw'] .= $this->request['body']; - $this->write($this->request['raw']); - - $response = null; - $inHeader = true; - while ($data = $this->read()) { - if ($this->_contentResource) { - if ($inHeader) { - $response .= $data; - $pos = strpos($response, "\r\n\r\n"); - if ($pos !== false) { - $pos += 4; - $data = substr($response, $pos); - fwrite($this->_contentResource, $data); - - $response = substr($response, 0, $pos); - $inHeader = false; - } - } else { - fwrite($this->_contentResource, $data); - fflush($this->_contentResource); - } - } else { - $response .= $data; - } - } - - if ($connectionType === 'close') { - $this->disconnect(); - } - - list($plugin, $responseClass) = pluginSplit($this->responseClass, true); - App::uses($this->responseClass, $plugin . 'Network/Http'); - if (!class_exists($responseClass)) { - throw new SocketException(__d('cake_dev', 'Class %s not found.', $this->responseClass)); - } - $responseClass = $this->responseClass; - $this->response = new $responseClass($response); - if (!empty($this->response->cookies)) { - if (!isset($this->config['request']['cookies'][$Host])) { - $this->config['request']['cookies'][$Host] = array(); - } - $this->config['request']['cookies'][$Host] = array_merge($this->config['request']['cookies'][$Host], $this->response->cookies); - } - - if ($this->request['redirect'] && $this->response->isRedirect()) { - $request['uri'] = $this->response->getHeader('Location'); - $request['redirect'] = is_int($this->request['redirect']) ? $this->request['redirect'] - 1 : $this->request['redirect']; - $this->response = $this->request($request); - } - - return $this->response; - } - -/** - * Issues a GET request to the specified URI, query, and request. - * - * Using a string uri and an array of query string parameters: - * - * `$response = $http->get('http://google.com/search', array('q' => 'cakephp', 'client' => 'safari'));` - * - * Would do a GET request to `http://google.com/search?q=cakephp&client=safari` - * - * You could express the same thing using a uri array and query string parameters: - * - * {{{ - * $response = $http->get( - * array('host' => 'google.com', 'path' => '/search'), - * array('q' => 'cakephp', 'client' => 'safari') - * ); - * }}} - * - * @param mixed $uri URI to request. Either a string uri, or a uri array, see HttpSocket::_parseUri() - * @param array $query Querystring parameters to append to URI - * @param array $request An indexed array with indexes such as 'method' or uri - * @return mixed Result of request, either false on failure or the response to the request. - */ - public function get($uri = null, $query = array(), $request = array()) { - if (!empty($query)) { - $uri = $this->_parseUri($uri, $this->config['request']['uri']); - if (isset($uri['query'])) { - $uri['query'] = array_merge($uri['query'], $query); - } else { - $uri['query'] = $query; - } - $uri = $this->_buildUri($uri); - } - - $request = Set::merge(array('method' => 'GET', 'uri' => $uri), $request); - return $this->request($request); - } - -/** - * Issues a POST request to the specified URI, query, and request. - * - * `post()` can be used to post simple data arrays to a url: - * - * {{{ - * $response = $http->post('http://example.com', array( - * 'username' => 'batman', - * 'password' => 'bruce_w4yne' - * )); - * }}} - * - * @param mixed $uri URI to request. See HttpSocket::_parseUri() - * @param array $data Array of POST data keys and values. - * @param array $request An indexed array with indexes such as 'method' or uri - * @return mixed Result of request, either false on failure or the response to the request. - */ - public function post($uri = null, $data = array(), $request = array()) { - $request = Set::merge(array('method' => 'POST', 'uri' => $uri, 'body' => $data), $request); - return $this->request($request); - } - -/** - * Issues a PUT request to the specified URI, query, and request. - * - * @param mixed $uri URI to request, See HttpSocket::_parseUri() - * @param array $data Array of PUT data keys and values. - * @param array $request An indexed array with indexes such as 'method' or uri - * @return mixed Result of request - */ - public function put($uri = null, $data = array(), $request = array()) { - $request = Set::merge(array('method' => 'PUT', 'uri' => $uri, 'body' => $data), $request); - return $this->request($request); - } - -/** - * Issues a DELETE request to the specified URI, query, and request. - * - * @param mixed $uri URI to request (see {@link _parseUri()}) - * @param array $data Query to append to URI - * @param array $request An indexed array with indexes such as 'method' or uri - * @return mixed Result of request - */ - public function delete($uri = null, $data = array(), $request = array()) { - $request = Set::merge(array('method' => 'DELETE', 'uri' => $uri, 'body' => $data), $request); - return $this->request($request); - } - -/** - * Normalizes urls into a $uriTemplate. If no template is provided - * a default one will be used. Will generate the url using the - * current config information. - * - * ### Usage: - * - * After configuring part of the request parameters, you can use url() to generate - * urls. - * - * {{{ - * $http = new HttpSocket('http://www.cakephp.org'); - * $url = $http->url('/search?q=bar'); - * }}} - * - * Would return `http://www.cakephp.org/search?q=bar` - * - * url() can also be used with custom templates: - * - * `$url = $http->url('http://www.cakephp/search?q=socket', '/%path?%query');` - * - * Would return `/search?q=socket`. - * - * @param mixed $url Either a string or array of url options to create a url with. - * @param string $uriTemplate A template string to use for url formatting. - * @return mixed Either false on failure or a string containing the composed url. - */ - public function url($url = null, $uriTemplate = null) { - if (is_null($url)) { - $url = '/'; - } - if (is_string($url)) { - $scheme = $this->config['request']['uri']['scheme']; - if (is_array($scheme)) { - $scheme = $scheme[0]; - } - $port = $this->config['request']['uri']['port']; - if (is_array($port)) { - $port = $port[0]; - } - if ($url{0} == '/') { - $url = $this->config['request']['uri']['host'] . ':' . $port . $url; - } - if (!preg_match('/^.+:\/\/|\*|^\//', $url)) { - $url = $scheme . '://' . $url; - } - } elseif (!is_array($url) && !empty($url)) { - return false; - } - - $base = array_merge($this->config['request']['uri'], array('scheme' => array('http', 'https'), 'port' => array(80, 443))); - $url = $this->_parseUri($url, $base); - - if (empty($url)) { - $url = $this->config['request']['uri']; - } - - if (!empty($uriTemplate)) { - return $this->_buildUri($url, $uriTemplate); - } - return $this->_buildUri($url); - } - -/** - * Set authentication in request - * - * @return void - * @throws SocketException - */ - protected function _setAuth() { - if (empty($this->_auth)) { - return; - } - $method = key($this->_auth); - list($plugin, $authClass) = pluginSplit($method, true); - $authClass = Inflector::camelize($authClass) . 'Authentication'; - App::uses($authClass, $plugin . 'Network/Http'); - - if (!class_exists($authClass)) { - throw new SocketException(__d('cake_dev', 'Unknown authentication method.')); - } - if (!method_exists($authClass, 'authentication')) { - throw new SocketException(sprintf(__d('cake_dev', 'The %s do not support authentication.'), $authClass)); - } - call_user_func_array("$authClass::authentication", array($this, &$this->_auth[$method])); - } - -/** - * Set the proxy configuration and authentication - * - * @return void - * @throws SocketException - */ - protected function _setProxy() { - if (empty($this->_proxy) || !isset($this->_proxy['host'], $this->_proxy['port'])) { - return; - } - $this->config['host'] = $this->_proxy['host']; - $this->config['port'] = $this->_proxy['port']; - - if (empty($this->_proxy['method']) || !isset($this->_proxy['user'], $this->_proxy['pass'])) { - return; - } - list($plugin, $authClass) = pluginSplit($this->_proxy['method'], true); - $authClass = Inflector::camelize($authClass) . 'Authentication'; - App::uses($authClass, $plugin . 'Network/Http'); - - if (!class_exists($authClass)) { - throw new SocketException(__d('cake_dev', 'Unknown authentication method for proxy.')); - } - if (!method_exists($authClass, 'proxyAuthentication')) { - throw new SocketException(sprintf(__d('cake_dev', 'The %s do not support proxy authentication.'), $authClass)); - } - call_user_func_array("$authClass::proxyAuthentication", array($this, &$this->_proxy)); - } - -/** - * Parses and sets the specified URI into current request configuration. - * - * @param mixed $uri URI, See HttpSocket::_parseUri() - * @return boolean If uri has merged in config - */ - protected function _configUri($uri = null) { - if (empty($uri)) { - return false; - } - - if (is_array($uri)) { - $uri = $this->_parseUri($uri); - } else { - $uri = $this->_parseUri($uri, true); - } - - if (!isset($uri['host'])) { - return false; - } - $config = array( - 'request' => array( - 'uri' => array_intersect_key($uri, $this->config['request']['uri']) - ) - ); - $this->config = Set::merge($this->config, $config); - $this->config = Set::merge($this->config, array_intersect_key($this->config['request']['uri'], $this->config)); - return true; - } - -/** - * Takes a $uri array and turns it into a fully qualified URL string - * - * @param mixed $uri Either A $uri array, or a request string. Will use $this->config if left empty. - * @param string $uriTemplate The Uri template/format to use. - * @return mixed A fully qualified URL formatted according to $uriTemplate, or false on failure - */ - protected function _buildUri($uri = array(), $uriTemplate = '%scheme://%user:%pass@%host:%port/%path?%query#%fragment') { - if (is_string($uri)) { - $uri = array('host' => $uri); - } - $uri = $this->_parseUri($uri, true); - - if (!is_array($uri) || empty($uri)) { - return false; - } - - $uri['path'] = preg_replace('/^\//', null, $uri['path']); - $uri['query'] = http_build_query($uri['query']); - $uri['query'] = rtrim($uri['query'], '='); - $stripIfEmpty = array( - 'query' => '?%query', - 'fragment' => '#%fragment', - 'user' => '%user:%pass@', - 'host' => '%host:%port/' - ); - - foreach ($stripIfEmpty as $key => $strip) { - if (empty($uri[$key])) { - $uriTemplate = str_replace($strip, null, $uriTemplate); - } - } - - $defaultPorts = array('http' => 80, 'https' => 443); - if (array_key_exists($uri['scheme'], $defaultPorts) && $defaultPorts[$uri['scheme']] == $uri['port']) { - $uriTemplate = str_replace(':%port', null, $uriTemplate); - } - foreach ($uri as $property => $value) { - $uriTemplate = str_replace('%' . $property, $value, $uriTemplate); - } - - if ($uriTemplate === '/*') { - $uriTemplate = '*'; - } - return $uriTemplate; - } - -/** - * Parses the given URI and breaks it down into pieces as an indexed array with elements - * such as 'scheme', 'port', 'query'. - * - * @param string $uri URI to parse - * @param mixed $base If true use default URI config, otherwise indexed array to set 'scheme', 'host', 'port', etc. - * @return array Parsed URI - */ - protected function _parseUri($uri = null, $base = array()) { - $uriBase = array( - 'scheme' => array('http', 'https'), - 'host' => null, - 'port' => array(80, 443), - 'user' => null, - 'pass' => null, - 'path' => '/', - 'query' => null, - 'fragment' => null - ); - - if (is_string($uri)) { - $uri = parse_url($uri); - } - if (!is_array($uri) || empty($uri)) { - return false; - } - if ($base === true) { - $base = $uriBase; - } - - if (isset($base['port'], $base['scheme']) && is_array($base['port']) && is_array($base['scheme'])) { - if (isset($uri['scheme']) && !isset($uri['port'])) { - $base['port'] = $base['port'][array_search($uri['scheme'], $base['scheme'])]; - } elseif (isset($uri['port']) && !isset($uri['scheme'])) { - $base['scheme'] = $base['scheme'][array_search($uri['port'], $base['port'])]; - } - } - - if (is_array($base) && !empty($base)) { - $uri = array_merge($base, $uri); - } - - if (isset($uri['scheme']) && is_array($uri['scheme'])) { - $uri['scheme'] = array_shift($uri['scheme']); - } - if (isset($uri['port']) && is_array($uri['port'])) { - $uri['port'] = array_shift($uri['port']); - } - - if (array_key_exists('query', $uri)) { - $uri['query'] = $this->_parseQuery($uri['query']); - } - - if (!array_intersect_key($uriBase, $uri)) { - return false; - } - return $uri; - } - -/** - * This function can be thought of as a reverse to PHP5's http_build_query(). It takes a given query string and turns it into an array and - * supports nesting by using the php bracket syntax. So this means you can parse queries like: - * - * - ?key[subKey]=value - * - ?key[]=value1&key[]=value2 - * - * A leading '?' mark in $query is optional and does not effect the outcome of this function. - * For the complete capabilities of this implementation take a look at HttpSocketTest::testparseQuery() - * - * @param mixed $query A query string to parse into an array or an array to return directly "as is" - * @return array The $query parsed into a possibly multi-level array. If an empty $query is - * given, an empty array is returned. - */ - protected function _parseQuery($query) { - if (is_array($query)) { - return $query; - } - - if (is_array($query)) { - return $query; - } - $parsedQuery = array(); - - if (is_string($query) && !empty($query)) { - $query = preg_replace('/^\?/', '', $query); - $items = explode('&', $query); - - foreach ($items as $item) { - if (strpos($item, '=') !== false) { - list($key, $value) = explode('=', $item, 2); - } else { - $key = $item; - $value = null; - } - - $key = urldecode($key); - $value = urldecode($value); - - if (preg_match_all('/\[([^\[\]]*)\]/iUs', $key, $matches)) { - $subKeys = $matches[1]; - $rootKey = substr($key, 0, strpos($key, '[')); - if (!empty($rootKey)) { - array_unshift($subKeys, $rootKey); - } - $queryNode =& $parsedQuery; - - foreach ($subKeys as $subKey) { - if (!is_array($queryNode)) { - $queryNode = array(); - } - - if ($subKey === '') { - $queryNode[] = array(); - end($queryNode); - $subKey = key($queryNode); - } - $queryNode =& $queryNode[$subKey]; - } - $queryNode = $value; - continue; - } - if (!isset($parsedQuery[$key])) { - $parsedQuery[$key] = $value; - } else { - $parsedQuery[$key] = (array)$parsedQuery[$key]; - $parsedQuery[$key][] = $value; - } - } - } - return $parsedQuery; - } - -/** - * Builds a request line according to HTTP/1.1 specs. Activate quirks mode to work outside specs. - * - * @param array $request Needs to contain a 'uri' key. Should also contain a 'method' key, otherwise defaults to GET. - * @param string $versionToken The version token to use, defaults to HTTP/1.1 - * @return string Request line - * @throws SocketException - */ - protected function _buildRequestLine($request = array(), $versionToken = 'HTTP/1.1') { - $asteriskMethods = array('OPTIONS'); - - if (is_string($request)) { - $isValid = preg_match("/(.+) (.+) (.+)\r\n/U", $request, $match); - if (!$this->quirksMode && (!$isValid || ($match[2] == '*' && !in_array($match[3], $asteriskMethods)))) { - throw new SocketException(__d('cake_dev', 'HttpSocket::_buildRequestLine - Passed an invalid request line string. Activate quirks mode to do this.')); - } - return $request; - } elseif (!is_array($request)) { - return false; - } elseif (!array_key_exists('uri', $request)) { - return false; - } - - $request['uri'] = $this->_parseUri($request['uri']); - $request = array_merge(array('method' => 'GET'), $request); - if (!empty($this->_proxy['host'])) { - $request['uri'] = $this->_buildUri($request['uri'], '%scheme://%host:%port/%path?%query'); - } else { - $request['uri'] = $this->_buildUri($request['uri'], '/%path?%query'); - } - - if (!$this->quirksMode && $request['uri'] === '*' && !in_array($request['method'], $asteriskMethods)) { - throw new SocketException(__d('cake_dev', 'HttpSocket::_buildRequestLine - The "*" asterisk character is only allowed for the following methods: %s. Activate quirks mode to work outside of HTTP/1.1 specs.', implode(',', $asteriskMethods))); - } - return $request['method'] . ' ' . $request['uri'] . ' ' . $versionToken . "\r\n"; - } - -/** - * Builds the header. - * - * @param array $header Header to build - * @param string $mode - * @return string Header built from array - */ - protected function _buildHeader($header, $mode = 'standard') { - if (is_string($header)) { - return $header; - } elseif (!is_array($header)) { - return false; - } - - $fieldsInHeader = array(); - foreach ($header as $key => $value) { - $lowKey = strtolower($key); - if (array_key_exists($lowKey, $fieldsInHeader)) { - $header[$fieldsInHeader[$lowKey]] = $value; - unset($header[$key]); - } else { - $fieldsInHeader[$lowKey] = $key; - } - } - - $returnHeader = ''; - foreach ($header as $field => $contents) { - if (is_array($contents) && $mode == 'standard') { - $contents = implode(',', $contents); - } - foreach ((array)$contents as $content) { - $contents = preg_replace("/\r\n(?![\t ])/", "\r\n ", $content); - $field = $this->_escapeToken($field); - - $returnHeader .= $field . ': ' . $contents . "\r\n"; - } - } - return $returnHeader; - } - -/** - * Builds cookie headers for a request. - * - * @param array $cookies Array of cookies to send with the request. - * @return string Cookie header string to be sent with the request. - * @todo Refactor token escape mechanism to be configurable - */ - public function buildCookies($cookies) { - $header = array(); - foreach ($cookies as $name => $cookie) { - $header[] = $name . '=' . $this->_escapeToken($cookie['value'], array(';')); - } - return $this->_buildHeader(array('Cookie' => implode('; ', $header)), 'pragmatic'); - } - -/** - * Escapes a given $token according to RFC 2616 (HTTP 1.1 specs) - * - * @param string $token Token to escape - * @param array $chars - * @return string Escaped token - * @todo Test $chars parameter - */ - protected function _escapeToken($token, $chars = null) { - $regex = '/([' . implode('', $this->_tokenEscapeChars(true, $chars)) . '])/'; - $token = preg_replace($regex, '"\\1"', $token); - return $token; - } - -/** - * Gets escape chars according to RFC 2616 (HTTP 1.1 specs). - * - * @param boolean $hex true to get them as HEX values, false otherwise - * @param array $chars - * @return array Escape chars - * @todo Test $chars parameter - */ - protected function _tokenEscapeChars($hex = true, $chars = null) { - if (!empty($chars)) { - $escape = $chars; - } else { - $escape = array('"', "(", ")", "<", ">", "@", ",", ";", ":", "\\", "/", "[", "]", "?", "=", "{", "}", " "); - for ($i = 0; $i <= 31; $i++) { - $escape[] = chr($i); - } - $escape[] = chr(127); - } - - if ($hex == false) { - return $escape; - } - foreach ($escape as $key => $char) { - $escape[$key] = '\\x' . str_pad(dechex(ord($char)), 2, '0', STR_PAD_LEFT); - } - return $escape; - } - -/** - * Resets the state of this HttpSocket instance to it's initial state (before Object::__construct got executed) or does - * the same thing partially for the request and the response property only. - * - * @param boolean $full If set to false only HttpSocket::response and HttpSocket::request are reseted - * @return boolean True on success - */ - public function reset($full = true) { - static $initalState = array(); - if (empty($initalState)) { - $initalState = get_class_vars(__CLASS__); - } - if (!$full) { - $this->request = $initalState['request']; - $this->response = $initalState['response']; - return true; - } - parent::reset($initalState); - return true; - } - -} diff --git a/lib/Cake/Routing/Dispatcher.php b/lib/Cake/Routing/Dispatcher.php deleted file mode 100644 index cab5675ac1e..00000000000 --- a/lib/Cake/Routing/Dispatcher.php +++ /dev/null @@ -1,328 +0,0 @@ -asset($request->url, $response) || $this->cached($request->here())) { - return; - } - - Router::setRequestInfo($request); - $request = $this->parseParams($request, $additionalParams); - $controller = $this->_getController($request, $response); - - if (!($controller instanceof Controller)) { - throw new MissingControllerException(array( - 'class' => Inflector::camelize($request->params['controller']) . 'Controller', - 'plugin' => empty($request->params['plugin']) ? null : Inflector::camelize($request->params['plugin']) - )); - } - - return $this->_invoke($controller, $request, $response); - } - -/** - * Initializes the components and models a controller will be using. - * Triggers the controller action, and invokes the rendering if Controller::$autoRender is true and echo's the output. - * Otherwise the return value of the controller action are returned. - * - * @param Controller $controller Controller to invoke - * @param CakeRequest $request The request object to invoke the controller for. - * @param CakeResponse $response The response object to receive the output - * @return void - */ - protected function _invoke(Controller $controller, CakeRequest $request, CakeResponse $response) { - $controller->constructClasses(); - $controller->startupProcess(); - - $render = true; - $result = $controller->invokeAction($request); - if ($result instanceof CakeResponse) { - $render = false; - $response = $result; - } - - if ($render && $controller->autoRender) { - $response = $controller->render(); - } elseif ($response->body() === null) { - $response->body($result); - } - $controller->shutdownProcess(); - - if (isset($request->params['return'])) { - return $response->body(); - } - $response->send(); - } - -/** - * Applies Routing and additionalParameters to the request to be dispatched. - * If Routes have not been loaded they will be loaded, and app/Config/routes.php will be run. - * - * @param CakeRequest $request CakeRequest object to mine for parameter information. - * @param array $additionalParams An array of additional parameters to set to the request. - * Useful when Object::requestAction() is involved - * @return CakeRequest The request object with routing params set. - */ - public function parseParams(CakeRequest $request, $additionalParams = array()) { - if (count(Router::$routes) == 0) { - $namedExpressions = Router::getNamedExpressions(); - extract($namedExpressions); - $this->_loadRoutes(); - } - - $params = Router::parse($request->url); - $request->addParams($params); - - if (!empty($additionalParams)) { - $request->addParams($additionalParams); - } - return $request; - } - -/** - * Get controller to use, either plugin controller or application controller - * - * @param CakeRequest $request Request object - * @param CakeResponse $response Response for the controller. - * @return mixed name of controller if not loaded, or object if loaded - */ - protected function _getController($request, $response) { - $ctrlClass = $this->_loadController($request); - if (!$ctrlClass) { - return false; - } - $reflection = new ReflectionClass($ctrlClass); - if ($reflection->isAbstract() || $reflection->isInterface()) { - return false; - } - return $reflection->newInstance($request, $response); - } - -/** - * Load controller and return controller classname - * - * @param CakeRequest $request - * @return string|bool Name of controller class name - */ - protected function _loadController($request) { - $pluginName = $pluginPath = $controller = null; - if (!empty($request->params['plugin'])) { - $pluginName = $controller = Inflector::camelize($request->params['plugin']); - $pluginPath = $pluginName . '.'; - } - if (!empty($request->params['controller'])) { - $controller = Inflector::camelize($request->params['controller']); - } - if ($pluginPath . $controller) { - $class = $controller . 'Controller'; - App::uses('AppController', 'Controller'); - App::uses($pluginName . 'AppController', $pluginPath . 'Controller'); - App::uses($class, $pluginPath . 'Controller'); - if (class_exists($class)) { - return $class; - } - } - return false; - } - -/** - * Loads route configuration - * - * @return void - */ - protected function _loadRoutes() { - include APP . 'Config' . DS . 'routes.php'; - } - -/** - * Outputs cached dispatch view cache - * - * @param string $path Requested URL path with any query string parameters - * @return string|boolean False if is not cached or output - */ - public function cached($path) { - if (Configure::read('Cache.check') === true) { - if ($path == '/') { - $path = 'home'; - } - $path = strtolower(Inflector::slug($path)); - - $filename = CACHE . 'views' . DS . $path . '.php'; - - if (!file_exists($filename)) { - $filename = CACHE . 'views' . DS . $path . '_index.php'; - } - if (file_exists($filename)) { - $controller = null; - $view = new View($controller); - return $view->renderCache($filename, microtime(true)); - } - } - return false; - } - -/** - * Checks if a requested asset exists and sends it to the browser - * - * @param string $url Requested URL - * @param CakeResponse $response The response object to put the file contents in. - * @return boolean True on success if the asset file was found and sent - */ - public function asset($url, CakeResponse $response) { - if (strpos($url, '..') !== false || strpos($url, '.') === false) { - return false; - } - $filters = Configure::read('Asset.filter'); - $isCss = ( - strpos($url, 'ccss/') === 0 || - preg_match('#^(theme/([^/]+)/ccss/)|(([^/]+)(?statusCode(404); - $response->send(); - return true; - } elseif ($isCss) { - include WWW_ROOT . DS . $filters['css']; - return true; - } elseif ($isJs) { - include WWW_ROOT . DS . $filters['js']; - return true; - } - $pathSegments = explode('.', $url); - $ext = array_pop($pathSegments); - $parts = explode('/', $url); - $assetFile = null; - - if ($parts[0] === 'theme') { - $themeName = $parts[1]; - unset($parts[0], $parts[1]); - $fileFragment = urldecode(implode(DS, $parts)); - $path = App::themePath($themeName) . 'webroot' . DS; - if (file_exists($path . $fileFragment)) { - $assetFile = $path . $fileFragment; - } - } else { - $plugin = Inflector::camelize($parts[0]); - if (CakePlugin::loaded($plugin)) { - unset($parts[0]); - $fileFragment = urldecode(implode(DS, $parts)); - $pluginWebroot = CakePlugin::path($plugin) . 'webroot' . DS; - if (file_exists($pluginWebroot . $fileFragment)) { - $assetFile = $pluginWebroot . $fileFragment; - } - } - } - - if ($assetFile !== null) { - $this->_deliverAsset($response, $assetFile, $ext); - return true; - } - return false; - } - -/** - * Sends an asset file to the client - * - * @param CakeResponse $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 void - */ - protected function _deliverAsset(CakeResponse $response, $assetFile, $ext) { - ob_start(); - $compressionEnabled = Configure::read('Asset.compress') && $response->compress(); - if ($response->type($ext) == $ext) { - $contentType = 'application/octet-stream'; - $agent = env('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)); - $response->send(); - ob_clean(); - if ($ext === 'css' || $ext === 'js') { - include $assetFile; - } else { - readfile($assetFile); - } - - if ($compressionEnabled) { - ob_end_flush(); - } - } - -} diff --git a/lib/Cake/Routing/Route/CakeRoute.php b/lib/Cake/Routing/Route/CakeRoute.php deleted file mode 100644 index 2c2343b71a3..00000000000 --- a/lib/Cake/Routing/Route/CakeRoute.php +++ /dev/null @@ -1,529 +0,0 @@ - 'content_type', - 'method' => 'request_method', - 'server' => 'server_name' - ); - -/** - * Constructor for a Route - * - * @param string $template Template string with parameter placeholders - * @param array $defaults Array of defaults for the route. - * @param array $options Array of additional options for the Route - */ - public function __construct($template, $defaults = array(), $options = array()) { - $this->template = $template; - $this->defaults = (array)$defaults; - $this->options = (array)$options; - } - -/** - * Check if a Route has been compiled into a regular expression. - * - * @return boolean - */ - public function compiled() { - return !empty($this->_compiledRoute); - } - -/** - * Compiles the route's regular expression. Modifies defaults property so all necessary keys are set - * and populates $this->names with the named routing elements. - * - * @return array Returns a string regular expression of the compiled route. - */ - public function compile() { - if ($this->compiled()) { - return $this->_compiledRoute; - } - $this->_writeRoute(); - return $this->_compiledRoute; - } - -/** - * Builds a route regular expression. Uses the template, defaults and options - * properties to compile a regular expression that can be used to parse request strings. - * - * @return void - */ - protected function _writeRoute() { - if (empty($this->template) || ($this->template === '/')) { - $this->_compiledRoute = '#^/*$#'; - $this->keys = array(); - return; - } - $route = $this->template; - $names = $routeParams = array(); - $parsed = preg_quote($this->template, '#'); - - preg_match_all('#:([A-Za-z0-9_-]+[A-Z0-9a-z])#', $route, $namedElements); - foreach ($namedElements[1] as $i => $name) { - $search = '\\' . $namedElements[0][$i]; - if (isset($this->options[$name])) { - $option = null; - 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; - } else { - $routeParams[$search] = '(?:(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option; - } - } else { - $routeParams[$search] = '(?:(?P<' . $name . '>[^/]+))'; - } - $names[] = $name; - } - if (preg_match('#\/\*\*$#', $route)) { - $parsed = preg_replace('#/\\\\\*\\\\\*$#', '(?:/(?P<_trailing_>.*))?', $parsed); - $this->_greedy = true; - } elseif (preg_match('#\/\*$#', $route)) { - $parsed = preg_replace('#/\\\\\*$#', '(?:/(?P<_args_>.*))?', $parsed); - $this->_greedy = true; - } - krsort($routeParams); - $parsed = str_replace(array_keys($routeParams), array_values($routeParams), $parsed); - $this->_compiledRoute = '#^' . $parsed . '[/]*$#'; - $this->keys = $names; - - //remove defaults that are also keys. They can cause match failures - foreach ($this->keys as $key) { - unset($this->defaults[$key]); - } - } - -/** - * 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. - * - * @param string $url The url to attempt to parse. - * @return mixed Boolean false on failure, otherwise an array or parameters - */ - public function parse($url) { - if (!$this->compiled()) { - $this->compile(); - } - if (!preg_match($this->_compiledRoute, $url, $route)) { - return false; - } - foreach ($this->defaults as $key => $val) { - $key = (string)$key; - if ($key[0] === '[' && preg_match('/^\[(\w+)\]$/', $key, $header)) { - if (isset($this->_headerMap[$header[1]])) { - $header = $this->_headerMap[$header[1]]; - } else { - $header = 'http_' . $header[1]; - } - $header = strtoupper($header); - - $val = (array)$val; - $h = false; - - foreach ($val as $v) { - if (env($header) === $v) { - $h = true; - } - } - if (!$h) { - return false; - } - } - } - array_shift($route); - $count = count($this->keys); - for ($i = 0; $i <= $count; $i++) { - unset($route[$i]); - } - $route['pass'] = $route['named'] = array(); - - // Assign defaults, set passed args to pass - foreach ($this->defaults as $key => $value) { - if (isset($route[$key])) { - continue; - } - if (is_integer($key)) { - $route['pass'][] = $value; - continue; - } - $route[$key] = $value; - } - - foreach ($this->keys as $key) { - if (isset($route[$key])) { - $route[$key] = rawurldecode($route[$key]); - } - } - - if (isset($route['_args_'])) { - list($pass, $named) = $this->_parseArgs($route['_args_'], $route); - $route['pass'] = array_merge($route['pass'], $pass); - $route['named'] = $named; - unset($route['_args_']); - } - - if (isset($route['_trailing_'])) { - $route['pass'][] = rawurldecode($route['_trailing_']); - unset($route['_trailing_']); - } - - // restructure 'pass' key route params - if (isset($this->options['pass'])) { - $j = count($this->options['pass']); - while ($j--) { - if (isset($route[$this->options['pass'][$j]])) { - array_unshift($route['pass'], $route[$this->options['pass'][$j]]); - } - } - } - return $route; - } - -/** - * Parse passed and Named parameters into a list of passed args, and a hash of named parameters. - * The local and global configuration for named parameters will be used. - * - * @param string $args A string with the passed & named params. eg. /1/page:2 - * @param string $context The current route context, which should contain controller/action keys. - * @return array Array of ($pass, $named) - */ - protected function _parseArgs($args, $context) { - $pass = $named = array(); - $args = explode('/', $args); - - $namedConfig = Router::namedConfig(); - $greedy = $namedConfig['greedyNamed']; - $rules = $namedConfig['rules']; - if (!empty($this->options['named'])) { - $greedy = isset($this->options['greedyNamed']) && $this->options['greedyNamed'] === true; - foreach ((array)$this->options['named'] as $key => $val) { - if (is_numeric($key)) { - $rules[$val] = true; - continue; - } - $rules[$key] = $val; - } - } - - foreach ($args as $param) { - if (empty($param) && $param !== '0' && $param !== 0) { - continue; - } - - $separatorIsPresent = strpos($param, $namedConfig['separator']) !== false; - if ((!isset($this->options['named']) || !empty($this->options['named'])) && $separatorIsPresent) { - list($key, $val) = explode($namedConfig['separator'], $param, 2); - $key = rawurldecode($key); - $val = rawurldecode($val); - $hasRule = isset($rules[$key]); - $passIt = (!$hasRule && !$greedy) || ($hasRule && !$this->_matchNamed($val, $rules[$key], $context)); - if ($passIt) { - $pass[] = rawurldecode($param); - } else { - 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 = $val; - foreach ($matches as $match) { - if (empty($match[1])) { - $arr = array($arr); - } else { - $arr = array( - $match[1] => $arr - ); - } - } - $val = $arr; - } - $named = array_merge_recursive($named, array($key => $val)); - } - } else { - $pass[] = rawurldecode($param); - } - } - return array($pass, $named); - } - -/** - * Return true if a given named $param's $val matches a given $rule depending on $context. Currently implemented - * rule types are controller, action and match that can be combined with each other. - * - * @param string $val The value of the named parameter - * @param array $rule The rule(s) to apply, can also be a match string - * @param string $context An array with additional context information (controller / action) - * @return boolean - */ - protected function _matchNamed($val, $rule, $context) { - if ($rule === true || $rule === false) { - return $rule; - } - if (is_string($rule)) { - $rule = array('match' => $rule); - } - if (!is_array($rule)) { - return false; - } - - $controllerMatches = ( - !isset($rule['controller'], $context['controller']) || - in_array($context['controller'], (array)$rule['controller']) - ); - if (!$controllerMatches) { - return false; - } - $actionMatches = ( - !isset($rule['action'], $context['action']) || - in_array($context['action'], (array)$rule['action']) - ); - if (!$actionMatches) { - return false; - } - return (!isset($rule['match']) || preg_match('/' . $rule['match'] . '/', $val)); - } - -/** - * Apply persistent parameters to a url array. Persistent parameters are a special - * key used during route creation to force route parameters to persist when omitted from - * a url array. - * - * @param array $url The array to apply persistent parameters to. - * @param array $params An array of persistent values to replace persistent ones. - * @return array An array with persistent parameters applied. - */ - public function persistParams($url, $params) { - foreach ($this->options['persist'] as $persistKey) { - if (array_key_exists($persistKey, $params) && !isset($url[$persistKey])) { - $url[$persistKey] = $params[$persistKey]; - } - } - return $url; - } - -/** - * Attempt to match a url array. If the url matches the route parameters and settings, then - * return a generated string url. If the url doesn't match the route parameters, false will be returned. - * This method handles the reverse routing or conversion of url arrays into string urls. - * - * @param array $url An array of parameters to check matching with. - * @return mixed Either a string url for the parameters if they match or false. - */ - public function match($url) { - if (!$this->compiled()) { - $this->compile(); - } - $defaults = $this->defaults; - - if (isset($defaults['prefix'])) { - $url['prefix'] = $defaults['prefix']; - } - - //check that all the key names are in the url - $keyNames = array_flip($this->keys); - if (array_intersect_key($keyNames, $url) !== $keyNames) { - return false; - } - - // Missing defaults is a fail. - if (array_diff_key($defaults, $url) !== array()) { - return false; - } - - $namedConfig = Router::namedConfig(); - $prefixes = Router::prefixes(); - $greedyNamed = $namedConfig['greedyNamed']; - $allowedNamedParams = $namedConfig['rules']; - - $named = $pass = array(); - - 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 ($defaultExists && $defaults[$key] != $value) { - return false; - } elseif ($defaultExists) { - continue; - } - - // If the key is a routed key, its not different yet. - if (array_key_exists($key, $keyNames)) { - continue; - } - - // pull out passed args - $numeric = is_numeric($key); - if ($numeric && isset($defaults[$key]) && $defaults[$key] == $value) { - continue; - } elseif ($numeric) { - $pass[] = $value; - unset($url[$key]); - continue; - } - - // pull out named params if named params are greedy or a rule exists. - if ( - ($greedyNamed || isset($allowedNamedParams[$key])) && - ($value !== false && $value !== null) && - (!in_array($key, $prefixes)) - ) { - $named[$key] = $value; - continue; - } - - // keys that don't exist are different. - if (!$defaultExists && !empty($value)) { - return false; - } - } - - //if a not a greedy route, no extra params are allowed. - if (!$this->_greedy && (!empty($pass) || !empty($named))) { - return false; - } - - //check patterns for routed params - if (!empty($this->options)) { - foreach ($this->options as $key => $pattern) { - if (array_key_exists($key, $url) && !preg_match('#^' . $pattern . '$#', $url[$key])) { - return false; - } - } - } - return $this->_writeUrl(array_merge($url, compact('pass', 'named'))); - } - -/** - * Converts a matching route array into a url string. Composes the string url using the template - * used to create the route. - * - * @param array $params The params to convert to a string url. - * @return string Composed route string. - */ - protected function _writeUrl($params) { - if (isset($params['prefix'], $params['action'])) { - $params['action'] = str_replace($params['prefix'] . '_', '', $params['action']); - unset($params['prefix']); - } - - if (is_array($params['pass'])) { - $params['pass'] = implode('/', array_map('rawurlencode', $params['pass'])); - } - - $namedConfig = Router::namedConfig(); - $separator = $namedConfig['separator']; - - if (!empty($params['named']) && is_array($params['named'])) { - $named = array(); - foreach ($params['named'] as $key => $value) { - if (is_array($value)) { - $flat = Set::flatten($value, ']['); - foreach ($flat as $namedKey => $namedValue) { - $named[] = $key . "[$namedKey]" . $separator . rawurlencode($namedValue); - } - } else { - $named[] = $key . $separator . rawurlencode($value); - } - } - $params['pass'] = $params['pass'] . '/' . implode('/', $named); - } - $out = $this->template; - - $search = $replace = array(); - foreach ($this->keys as $key) { - $string = null; - if (isset($params[$key])) { - $string = $params[$key]; - } elseif (strpos($out, $key) != strlen($out) - strlen($key)) { - $key .= '/'; - } - $search[] = ':' . $key; - $replace[] = $string; - } - $out = str_replace($search, $replace, $out); - - if (strpos($this->template, '*')) { - $out = str_replace('*', $params['pass'], $out); - } - $out = str_replace('//', '/', $out); - return $out; - } - -} diff --git a/lib/Cake/Routing/Route/PluginShortRoute.php b/lib/Cake/Routing/Route/PluginShortRoute.php deleted file mode 100644 index 2edd944e4ed..00000000000 --- a/lib/Cake/Routing/Route/PluginShortRoute.php +++ /dev/null @@ -1,58 +0,0 @@ -defaults['controller'] = $url['controller']; - $result = parent::match($url); - unset($this->defaults['controller']); - return $result; - } - -} diff --git a/lib/Cake/Routing/Route/RedirectRoute.php b/lib/Cake/Routing/Route/RedirectRoute.php deleted file mode 100644 index bfb0f06c380..00000000000 --- a/lib/Cake/Routing/Route/RedirectRoute.php +++ /dev/null @@ -1,117 +0,0 @@ -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 - * @return boolean False on failure - */ - public function parse($url) { - $params = parent::parse($url); - if (!$params) { - return false; - } - if (!$this->response) { - $this->response = new CakeResponse(); - } - $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 += array('named' => $params['named'], 'pass' => $params['pass'], 'url' => array()); - $redirect = Router::reverse($redirect); - } - $status = 301; - if (isset($this->options['status']) && ($this->options['status'] >= 300 && $this->options['status'] < 400)) { - $status = $this->options['status']; - } - $this->response->header(array('Location' => Router::url($redirect, true))); - $this->response->statusCode($status); - $this->response->send(); - $this->_stop(); - } - -/** - * There is no reverse routing redirection routes - * - * @param array $url Array of parameters to convert to a string. - * @return mixed either false or a string url. - */ - public function match($url) { - return false; - } - -/** - * Stop execution of the current script. Wraps exit() making - * testing easier. - * - * @param integer|string $status see http://php.net/exit for values - * @return void - */ - protected function _stop($code = 0) { - if ($this->stop) { - exit($code); - } - } - -} diff --git a/lib/Cake/Routing/Router.php b/lib/Cake/Routing/Router.php deleted file mode 100644 index a7bad556366..00000000000 --- a/lib/Cake/Routing/Router.php +++ /dev/null @@ -1,1112 +0,0 @@ - Router::ACTION, - 'Year' => Router::YEAR, - 'Month' => Router::MONTH, - 'Day' => Router::DAY, - 'ID' => Router::ID, - 'UUID' => Router::UUID - ); - -/** - * Stores all information necessary to decide what named arguments are parsed under what conditions. - * - * @var string - */ - protected static $_namedConfig = array( - 'default' => array('page', 'fields', 'order', 'limit', 'recursive', 'sort', 'direction', 'step'), - 'greedyNamed' => true, - 'separator' => ':', - 'rules' => false, - ); - -/** - * The route matching the URL of the current request - * - * @var array - */ - protected static $_currentRoute = array(); - -/** - * Default HTTP request method => controller action map. - * - * @var array - */ - protected static $_resourceMap = array( - array('action' => 'index', 'method' => 'GET', 'id' => false), - array('action' => 'view', 'method' => 'GET', 'id' => true), - array('action' => 'add', 'method' => 'POST', 'id' => false), - array('action' => 'edit', 'method' => 'PUT', 'id' => true), - array('action' => 'delete', 'method' => 'DELETE', 'id' => true), - array('action' => 'edit', 'method' => 'POST', 'id' => true) - ); - -/** - * List of resource-mapped controllers - * - * @var array - */ - protected static $_resourceMapped = array(); - -/** - * Maintains the request object stack for the current request. - * This will contain more than one request object when requestAction is used. - * - * @var array - */ - protected static $_requests = array(); - -/** - * Initial state is populated the first time reload() is called which is at the bottom - * of this file. This is a cheat as get_class_vars() returns the value of static vars even if they - * have changed. - * - * @var array - */ - protected static $_initialState = array(); - -/** - * Default route class to use - * - * @var string - */ - protected static $_routeClass = 'CakeRoute'; - -/** - * Set the default route class to use or return the current one - * - * @param string $routeClass to set as default - * @return mixed void|string - * @throws RouterException - */ - public static function defaultRouteClass($routeClass = null) { - if (is_null($routeClass)) { - return self::$_routeClass; - } - - self::$_routeClass = self::_validateRouteClass($routeClass); - } - -/** - * Validates that the passed route class exists and is a subclass of CakeRoute - * - * @param $routeClass - * @return string - * @throws RouterException - */ - protected static function _validateRouteClass($routeClass) { - if (!class_exists($routeClass) || !is_subclass_of($routeClass, 'CakeRoute')) { - throw new RouterException(__d('cake_dev', 'Route classes must extend CakeRoute')); - } - return $routeClass; - } - -/** - * Sets the Routing prefixes. - * - * @return void - */ - protected static function _setPrefixes() { - $routing = Configure::read('Routing'); - if (!empty($routing['prefixes'])) { - self::$_prefixes = array_merge(self::$_prefixes, (array)$routing['prefixes']); - } - } - -/** - * Gets the named route elements for use in app/Config/routes.php - * - * @return array Named route elements - * @see Router::$_namedExpressions - */ - public static function getNamedExpressions() { - return self::$_namedExpressions; - } - -/** - * Resource map getter & setter. - * - * @param array $resourceMap Resource map - * @return mixed - * @see Router::$_resourceMap - */ - public static function resourceMap($resourceMap = null) { - if ($resourceMap === null) { - return self::$_resourceMap; - } - self::$_resourceMap = $resourceMap; - } - -/** - * Connects a new Route in the router. - * - * Routes are a way of connecting request urls to objects in your application. At their core routes - * are a set or regular expressions that are used to match requests to destinations. - * - * Examples: - * - * `Router::connect('/:controller/:action/*');` - * - * The first parameter will be used as a controller name while the second is used as the action name. - * the '/*' syntax makes this route greedy in that it will match requests like `/posts/index` as well as requests - * like `/posts/edit/1/foo/bar`. - * - * `Router::connect('/home-page', array('controller' => 'pages', 'action' => 'display', 'home'));` - * - * The above shows the use of route parameter defaults. And providing routing parameters for a static route. - * - * {{{ - * Router::connect( - * '/:lang/:controller/:action/:id', - * array(), - * array('id' => '[0-9]+', 'lang' => '[a-z]{3}') - * ); - * }}} - * - * Shows connecting a route with custom route parameters as well as providing patterns for those parameters. - * Patterns for routing parameters do not need capturing groups, as one will be added for each route params. - * - * $options offers four 'special' keys. `pass`, `named`, `persist` and `routeClass` - * have special meaning in the $options array. - * - * `pass` is used to define which of the routed parameters should be shifted into the pass array. Adding a - * parameter to pass will remove it from the regular route array. Ex. `'pass' => array('slug')` - * - * `persist` is used 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 parameter to `false`. Ex. `'persist' => array('lang')` - * - * `routeClass` is used to extend and change how individual routes parse requests and handle reverse routing, - * via a custom routing class. Ex. `'routeClass' => 'SlugRoute'` - * - * `named` is used to configure named parameters at the route level. This key uses the same options - * as Router::connectNamed() - * - * @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. - * @see routes - * @return array Array of routes - * @throws RouterException - */ - public static function connect($route, $defaults = array(), $options = array()) { - foreach (self::$_prefixes as $prefix) { - if (isset($defaults[$prefix])) { - if ($defaults[$prefix]) { - $defaults['prefix'] = $prefix; - } else { - unset($defaults[$prefix]); - } - break; - } - } - if (isset($defaults['prefix'])) { - self::$_prefixes[] = $defaults['prefix']; - self::$_prefixes = array_keys(array_flip(self::$_prefixes)); - } - $defaults += array('plugin' => null); - if (empty($options['action'])) { - $defaults += array('action' => 'index'); - } - $routeClass = self::$_routeClass; - if (isset($options['routeClass'])) { - $routeClass = self::_validateRouteClass($options['routeClass']); - unset($options['routeClass']); - } - if ($routeClass == 'RedirectRoute' && isset($defaults['redirect'])) { - $defaults = $defaults['redirect']; - } - self::$routes[] = new $routeClass($route, $defaults, $options); - return self::$routes; - } - -/** - * Connects a new redirection Route in the router. - * - * Redirection routes are different from normal routes as they perform an actual - * header redirection if a match is found. The redirection can occur within your - * application or redirect to an outside location. - * - * Examples: - * - * `Router::redirect('/home/*', array('controller' => 'posts', 'action' => 'view', array('persist' => true));` - * - * Redirects /home/* to /posts/view and passes the parameters to /posts/view. Using an array as the - * redirect destination allows you to use other routes to define where a url string should be redirected to. - * - * `Router::redirect('/posts/*', 'http://google.com', array('status' => 302));` - * - * Redirects /posts/* to http://google.com with a HTTP status of 302 - * - * ### Options: - * - * - `status` Sets the HTTP status (default 301) - * - `persist` Passes the params to the redirected route, if it can. This is useful with greedy routes, - * routes that end in `*` are greedy. As you can remap urls and not loose any passed/named args. - * - * @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. - * @see routes - * @return array Array of routes - */ - public static function redirect($route, $url, $options = array()) { - App::uses('RedirectRoute', 'Routing/Route'); - $options['routeClass'] = 'RedirectRoute'; - if (is_string($url)) { - $url = array('redirect' => $url); - } - return self::connect($route, $url, $options); - } - -/** - * Specifies what named parameters CakePHP should be parsing out of incoming urls. By default - * CakePHP will parse every named parameter out of incoming URLs. However, if you want to take more - * control over how named parameters are parsed you can use one of the following setups: - * - * Do not parse any named parameters: - * - * {{{ Router::connectNamed(false); }}} - * - * Parse only default parameters used for CakePHP's pagination: - * - * {{{ Router::connectNamed(false, array('default' => true)); }}} - * - * Parse only the page parameter if its value is a number: - * - * {{{ Router::connectNamed(array('page' => '[\d]+'), array('default' => false, 'greedy' => false)); }}} - * - * Parse only the page parameter no matter what. - * - * {{{ Router::connectNamed(array('page'), array('default' => false, 'greedy' => false)); }}} - * - * Parse only the page parameter if the current action is 'index'. - * - * {{{ - * Router::connectNamed( - * array('page' => array('action' => 'index')), - * array('default' => false, 'greedy' => false) - * ); - * }}} - * - * Parse only the page parameter if the current action is 'index' and the controller is 'pages'. - * - * {{{ - * Router::connectNamed( - * array('page' => array('action' => 'index', 'controller' => 'pages')), - * array('default' => false, 'greedy' => false) - * ); - * }}} - * - * ### Options - * - * - `greedy` Setting this to true will make Router parse all named params. Setting it to false will - * parse only the connected named params. - * - `default` Set this to true to merge in the default set of named parameters. - * - `reset` Set to true to clear existing rules and start fresh. - * - `separator` Change the string used to separate the key & value in a named parameter. Defaults to `:` - * - * @param array $named A list of named parameters. Key value pairs are accepted where values are - * either regex strings to match, or arrays as seen above. - * @param array $options Allows to control all settings: separator, greedy, reset, default - * @return array - */ - public static function connectNamed($named, $options = array()) { - if (isset($options['separator'])) { - self::$_namedConfig['separator'] = $options['separator']; - unset($options['separator']); - } - - if ($named === true || $named === false) { - $options = array_merge(array('default' => $named, 'reset' => true, 'greedy' => $named), $options); - $named = array(); - } else { - $options = array_merge(array('default' => false, 'reset' => false, 'greedy' => true), $options); - } - - if ($options['reset'] == true || self::$_namedConfig['rules'] === false) { - self::$_namedConfig['rules'] = array(); - } - - if ($options['default']) { - $named = array_merge($named, self::$_namedConfig['default']); - } - - foreach ($named as $key => $val) { - if (is_numeric($key)) { - self::$_namedConfig['rules'][$val] = true; - } else { - self::$_namedConfig['rules'][$key] = $val; - } - } - self::$_namedConfig['greedyNamed'] = $options['greedy']; - return self::$_namedConfig; - } - -/** - * Gets the current named parameter configuration values. - * - * @return array - * @see Router::$_namedConfig - */ - public static function namedConfig() { - return self::$_namedConfig; - } - -/** - * Creates REST resource routes for the given controller(s). When creating resource routes - * for a plugin, by default the prefix will be changed to the lower_underscore version of the plugin - * name. By providing a prefix you can override this behavior. - * - * ### Options: - * - * - 'id' - The regular expression fragment to use when matching IDs. By default, matches - * integer values and UUIDs. - * - 'prefix' - URL prefix to use for the generated routes. Defaults to '/'. - * - * @param mixed $controller A controller name or array of controller names (i.e. "Posts" or "ListItems") - * @param array $options Options to use when generating REST routes - * @return array Array of mapped resources - */ - public static function mapResources($controller, $options = array()) { - $hasPrefix = isset($options['prefix']); - $options = array_merge(array( - 'prefix' => '/', - 'id' => self::ID . '|' . self::UUID - ), $options); - - $prefix = $options['prefix']; - - foreach ((array)$controller as $name) { - list($plugin, $name) = pluginSplit($name); - $urlName = Inflector::underscore($name); - $plugin = Inflector::underscore($plugin); - if ($plugin && !$hasPrefix) { - $prefix = '/' . $plugin . '/'; - } - - foreach (self::$_resourceMap as $params) { - $url = $prefix . $urlName . (($params['id']) ? '/:id' : ''); - - Router::connect($url, - array( - 'plugin' => $plugin, - 'controller' => $urlName, - 'action' => $params['action'], - '[method]' => $params['method'] - ), - array('id' => $options['id'], 'pass' => array('id')) - ); - } - self::$_resourceMapped[] = $urlName; - } - return self::$_resourceMapped; - } - -/** - * Returns the list of prefixes used in connected routes - * - * @return array A list of prefixes used in connected routes - */ - public static function prefixes() { - return self::$_prefixes; - } - -/** - * Parses given URL string. Returns 'routing' parameters for that url. - * - * @param string $url URL to be parsed - * @return array Parsed elements from URL - */ - public static function parse($url) { - $ext = null; - $out = array(); - - if ($url && strpos($url, '/') !== 0) { - $url = '/' . $url; - } - if (strpos($url, '?') !== false) { - $url = substr($url, 0, strpos($url, '?')); - } - - extract(self::_parseExtension($url)); - - for ($i = 0, $len = count(self::$routes); $i < $len; $i++) { - $route =& self::$routes[$i]; - - if (($r = $route->parse($url)) !== false) { - self::$_currentRoute[] =& $route; - $out = $r; - break; - } - } - if (isset($out['prefix'])) { - $out['action'] = $out['prefix'] . '_' . $out['action']; - } - - if (!empty($ext) && !isset($out['ext'])) { - $out['ext'] = $ext; - } - return $out; - } - -/** - * Parses a file extension out of a URL, if Router::parseExtensions() is enabled. - * - * @param string $url - * @return array Returns an array containing the altered URL and the parsed extension. - */ - protected static function _parseExtension($url) { - $ext = null; - - if (self::$_parseExtensions) { - if (preg_match('/\.[0-9a-zA-Z]*$/', $url, $match) === 1) { - $match = substr($match[0], 1); - if (empty(self::$_validExtensions)) { - $url = substr($url, 0, strpos($url, '.' . $match)); - $ext = $match; - } else { - foreach (self::$_validExtensions as $name) { - if (strcasecmp($name, $match) === 0) { - $url = substr($url, 0, strpos($url, '.' . $name)); - $ext = $match; - break; - } - } - } - } - } - return compact('ext', 'url'); - } - -/** - * 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 CakeRequest object or an array of arrays. Support for - * accepting arrays may be removed in the future. - * - * @param CakeRequest|array $request Parameters and path information or a CakeRequest object. - * @return void - */ - public static function setRequestInfo($request) { - if ($request instanceof CakeRequest) { - self::$_requests[] = $request; - } else { - $requestObj = new CakeRequest(); - $request += array(array(), array()); - $request[0] += array('controller' => false, 'action' => false, 'plugin' => null); - $requestObj->addParams($request[0])->addPaths($request[1]); - self::$_requests[] = $requestObj; - } - } - -/** - * Pops a request off of the request stack. Used when doing requestAction - * - * @return CakeRequest The request removed from the stack. - * @see Router::setRequestInfo() - * @see Object::requestAction() - */ - public static function popRequest() { - return array_pop(self::$_requests); - } - -/** - * Get the either the current request object, or the first one. - * - * @param boolean $current Whether you want the request from the top of the stack or the first one. - * @return CakeRequest or null. - */ - public static function getRequest($current = false) { - if ($current) { - return self::$_requests[count(self::$_requests) - 1]; - } - return isset(self::$_requests[0]) ? self::$_requests[0] : null; - } - -/** - * Gets parameter information - * - * @param boolean $current Get current request parameter, useful when using requestAction - * @return array Parameter information - */ - public static function getParams($current = false) { - if ($current) { - return self::$_requests[count(self::$_requests) - 1]->params; - } - if (isset(self::$_requests[0])) { - return self::$_requests[0]->params; - } - return array(); - } - -/** - * Gets URL parameter by name - * - * @param string $name Parameter name - * @param boolean $current Current parameter, useful when using requestAction - * @return string Parameter value - */ - public static function getParam($name = 'controller', $current = false) { - $params = Router::getParams($current); - if (isset($params[$name])) { - return $params[$name]; - } - return null; - } - -/** - * Gets path information - * - * @param boolean $current Current parameter, useful when using requestAction - * @return array - */ - public static function getPaths($current = false) { - if ($current) { - return self::$_requests[count(self::$_requests) - 1]; - } - if (!isset(self::$_requests[0])) { - return array('base' => null); - } - return array('base' => self::$_requests[0]->base); - } - -/** - * Reloads default Router settings. Resets all class variables and - * removes all connected routes. - * - * @return void - */ - public static function reload() { - if (empty(self::$_initialState)) { - self::$_initialState = get_class_vars('Router'); - self::_setPrefixes(); - return; - } - foreach (self::$_initialState as $key => $val) { - if ($key != '_initialState') { - self::${$key} = $val; - } - } - self::_setPrefixes(); - } - -/** - * Promote a route (by default, the last one added) to the beginning of the list - * - * @param integer $which A zero-based array index representing the route to move. For example, - * if 3 routes have been added, the last route would be 2. - * @return boolean Returns false if no route exists at the position specified by $which. - */ - public static function promote($which = null) { - if ($which === null) { - $which = count(self::$routes) - 1; - } - if (!isset(self::$routes[$which])) { - return false; - } - $route =& self::$routes[$which]; - unset(self::$routes[$which]); - array_unshift(self::$routes, $route); - return true; - } - -/** - * Finds URL for specified action. - * - * Returns an URL pointing to a combination of controller and action. Param - * $url can be: - * - * - Empty - the method will find address to actual controller/action. - * - '/' - the method will find base URL of application. - * - A combination of controller/action - the method will find url for it. - * - * 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. - * - `?` - Takes an array of query string parameters - * - `#` - Allows you to set url hash fragments. - * - `full_base` - If true the `FULL_BASE_URL` constant will be prepended to generated urls. - * - * @param mixed $url Cake-relative URL, like "/products/edit/92" or "/presidents/elect/4" - * or an array specifying any of the following: 'controller', 'action', - * and/or 'plugin', in addition to named arguments (keyed array elements), - * and standard URL arguments (indexed array elements) - * @param mixed $full If (bool) true, the full base URL will be prepended to the result. - * If an array accepts the following keys - * - escape - used when making urls embedded in html escapes query string '&' - * - full - if true the full base URL will be prepended. - * @return string Full translated URL with base path. - */ - public static function url($url = null, $full = false) { - $params = array('plugin' => null, 'controller' => null, 'action' => 'index'); - - if (is_bool($full)) { - $escape = false; - } else { - extract($full + array('escape' => false, 'full' => false)); - } - - $path = array('base' => null); - if (!empty(self::$_requests)) { - $request = self::$_requests[count(self::$_requests) - 1]; - $params = $request->params; - $path = array('base' => $request->base, 'here' => $request->here); - } - - $base = $path['base']; - $extension = $output = $q = $frag = null; - - if (empty($url)) { - $output = isset($path['here']) ? $path['here'] : '/'; - if ($full && defined('FULL_BASE_URL')) { - $output = FULL_BASE_URL . $output; - } - return $output; - } elseif (is_array($url)) { - if (isset($url['base']) && $url['base'] === false) { - $base = null; - unset($url['base']); - } - if (isset($url['full_base']) && $url['full_base'] === true) { - $full = true; - unset($url['full_base']); - } - if (isset($url['?'])) { - $q = $url['?']; - unset($url['?']); - } - if (isset($url['#'])) { - $frag = '#' . urlencode($url['#']); - unset($url['#']); - } - if (isset($url['ext'])) { - $extension = '.' . $url['ext']; - unset($url['ext']); - } - if (empty($url['action'])) { - if (empty($url['controller']) || $params['controller'] === $url['controller']) { - $url['action'] = $params['action']; - } else { - $url['action'] = 'index'; - } - } - - $prefixExists = (array_intersect_key($url, array_flip(self::$_prefixes))); - foreach (self::$_prefixes as $prefix) { - if (!empty($params[$prefix]) && !$prefixExists) { - $url[$prefix] = true; - } elseif (isset($url[$prefix]) && !$url[$prefix]) { - unset($url[$prefix]); - } - if (isset($url[$prefix]) && strpos($url['action'], $prefix . '_') === 0) { - $url['action'] = substr($url['action'], strlen($prefix) + 1); - } - } - - $url += array('controller' => $params['controller'], 'plugin' => $params['plugin']); - - $match = false; - - for ($i = 0, $len = count(self::$routes); $i < $len; $i++) { - $originalUrl = $url; - - if (isset(self::$routes[$i]->options['persist'], $params)) { - $url = self::$routes[$i]->persistParams($url, $params); - } - - if ($match = self::$routes[$i]->match($url)) { - $output = trim($match, '/'); - break; - } - $url = $originalUrl; - } - if ($match === false) { - $output = self::_handleNoRoute($url); - } - } else { - if ( - (strpos($url, '://') !== false || - (strpos($url, 'javascript:') === 0) || - (strpos($url, 'mailto:') === 0)) || - (!strncmp($url, '#', 1)) - ) { - return $url; - } - if (substr($url, 0, 1) === '/') { - $output = substr($url, 1); - } else { - foreach (self::$_prefixes as $prefix) { - if (isset($params[$prefix])) { - $output .= $prefix . '/'; - break; - } - } - if (!empty($params['plugin']) && $params['plugin'] !== $params['controller']) { - $output .= Inflector::underscore($params['plugin']) . '/'; - } - $output .= Inflector::underscore($params['controller']) . '/' . $url; - } - } - $protocol = preg_match('#^[a-z][a-z0-9+-.]*\://#i', $output); - if ($protocol === 0) { - $output = str_replace('//', '/', $base . '/' . $output); - - if ($full && defined('FULL_BASE_URL')) { - $output = FULL_BASE_URL . $output; - } - if (!empty($extension)) { - $output = rtrim($output, '/'); - } - } - return $output . $extension . self::queryString($q, array(), $escape) . $frag; - } - -/** - * A special fallback method that handles url arrays that cannot match - * any defined routes. - * - * @param array $url A url that didn't match any routes - * @return string A generated url for the array - * @see Router::url() - */ - protected static function _handleNoRoute($url) { - $named = $args = array(); - $skip = array_merge( - array('bare', 'action', 'controller', 'plugin', 'prefix'), - self::$_prefixes - ); - - $keys = array_values(array_diff(array_keys($url), $skip)); - $count = count($keys); - - // Remove this once parsed URL parameters can be inserted into 'pass' - for ($i = 0; $i < $count; $i++) { - $key = $keys[$i]; - if (is_numeric($keys[$i])) { - $args[] = $url[$key]; - } else { - $named[$key] = $url[$key]; - } - } - - list($args, $named) = array(Set::filter($args, true), Set::filter($named, true)); - foreach (self::$_prefixes as $prefix) { - if (!empty($url[$prefix])) { - $url['action'] = str_replace($prefix . '_', '', $url['action']); - break; - } - } - - if (empty($named) && empty($args) && (!isset($url['action']) || $url['action'] === 'index')) { - $url['action'] = null; - } - - $urlOut = array_filter(array($url['controller'], $url['action'])); - - if (isset($url['plugin'])) { - array_unshift($urlOut, $url['plugin']); - } - - foreach (self::$_prefixes as $prefix) { - if (isset($url[$prefix])) { - array_unshift($urlOut, $prefix); - break; - } - } - $output = implode('/', $urlOut); - - if (!empty($args)) { - $output .= '/' . implode('/', array_map('rawurlencode', $args)); - } - - if (!empty($named)) { - foreach ($named as $name => $value) { - if (is_array($value)) { - $flattend = Set::flatten($value, ']['); - foreach ($flattend as $namedKey => $namedValue) { - $output .= '/' . $name . "[$namedKey]" . self::$_namedConfig['separator'] . rawurlencode($namedValue); - } - } else { - $output .= '/' . $name . self::$_namedConfig['separator'] . rawurlencode($value); - } - } - } - return $output; - } - -/** - * Generates a well-formed querystring from $q - * - * @param string|array $q Query string Either a string of already compiled query string arguments or - * an array of arguments to convert into a query string. - * @param array $extra Extra querystring parameters. - * @param boolean $escape Whether or not to use escaped & - * @return array - */ - public static function queryString($q, $extra = array(), $escape = false) { - if (empty($q) && empty($extra)) { - return null; - } - $join = '&'; - if ($escape === true) { - $join = '&'; - } - $out = ''; - - if (is_array($q)) { - $q = array_merge($extra, $q); - } else { - $out = $q; - $q = $extra; - } - $out .= http_build_query($q, null, $join); - if (isset($out[0]) && $out[0] != '?') { - $out = '?' . $out; - } - return $out; - } - -/** - * Reverses a parsed parameter array into a string. Works similarly to Router::url(), but - * Since parsed URL's contain additional 'pass' and 'named' as well as 'url.url' keys. - * 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 CakeRequest|array $params The params array or CakeRequest object that needs to be reversed. - * @param boolean $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) { - if ($params instanceof CakeRequest) { - $url = $params->query; - $params = $params->params; - } else { - $url = $params['url']; - } - $pass = isset($params['pass']) ? $params['pass'] : array(); - $named = isset($params['named']) ? $params['named'] : array(); - - unset( - $params['pass'], $params['named'], $params['paging'], $params['models'], $params['url'], $url['url'], - $params['autoRender'], $params['bare'], $params['requested'], $params['return'], - $params['_Token'] - ); - $params = array_merge($params, $pass, $named); - if (!empty($url)) { - $params['?'] = $url; - } - return Router::url($params, $full); - } - -/** - * Normalizes a URL for purposes of comparison. Will strip the base path off - * and replace any double /'s. It will not unify the casing and underscoring - * of the input value. - * - * @param mixed $url URL to normalize Either an array or a string url. - * @return string Normalized URL - */ - public static function normalize($url = '/') { - if (is_array($url)) { - $url = Router::url($url); - } elseif (preg_match('/^[a-z\-]+:\/\//', $url)) { - return $url; - } - $request = Router::getRequest(); - - if (!empty($request->base) && stristr($url, $request->base)) { - $url = preg_replace('/^' . preg_quote($request->base, '/') . '/', '', $url, 1); - } - $url = '/' . $url; - - while (strpos($url, '//') !== false) { - $url = str_replace('//', '/', $url); - } - $url = preg_replace('/(?:(\/$))/', '', $url); - - if (empty($url)) { - return '/'; - } - return $url; - } - -/** - * Returns the route matching the current request URL. - * - * @return CakeRoute Matching route object. - */ - public static function &requestRoute() { - return self::$_currentRoute[0]; - } - -/** - * Returns the route matching the current request (useful for requestAction traces) - * - * @return CakeRoute Matching route object. - */ - public static function ¤tRoute() { - return self::$_currentRoute[count(self::$_currentRoute) - 1]; - } - -/** - * Removes the plugin name from the base URL. - * - * @param string $base Base URL - * @param string $plugin Plugin name - * @return string base url with plugin name removed if present - */ - public static function stripPlugin($base, $plugin = null) { - if ($plugin != null) { - $base = preg_replace('/(?:' . $plugin . ')/', '', $base); - $base = str_replace('//', '', $base); - $pos1 = strrpos($base, '/'); - $char = strlen($base) - 1; - - if ($pos1 === $char) { - $base = substr($base, 0, $char); - } - } - return $base; - } - -/** - * Instructs the router to parse out file extensions from the URL. For example, - * http://example.com/posts.rss would yield an file extension of "rss". - * The file extension itself is made available in the controller as - * `$this->params['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 layouts and helpers - * requires that the chosen extension has a defined mime type in `CakeResponse` - * - * A list of valid extension can be passed to this method, i.e. Router::parseExtensions('rss', 'xml'); - * If no parameters are given, anything after the first . (dot) after the last / in the URL will be - * parsed, excluding querystring parameters (i.e. ?q=...). - * - * @return void - * @see RequestHandler::startup() - */ - public static function parseExtensions() { - self::$_parseExtensions = true; - if (func_num_args() > 0) { - self::$_validExtensions = func_get_args(); - } - } - -/** - * Get the list of extensions that can be parsed by Router. To add more - * extensions use Router::parseExtensions() - * - * @return array Array of extensions Router is configured to parse. - */ - public static function extensions() { - return self::$_validExtensions; - } - -} - -//Save the initial state -Router::reload(); diff --git a/lib/Cake/Test/Case/AllBehaviorsTest.php b/lib/Cake/Test/Case/AllBehaviorsTest.php deleted file mode 100644 index c2f30af55a5..00000000000 --- a/lib/Cake/Test/Case/AllBehaviorsTest.php +++ /dev/null @@ -1,42 +0,0 @@ -addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'BehaviorCollectionTest.php'); - - $suite->addTestDirectory($path); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllCacheTest.php b/lib/Cake/Test/Case/AllCacheTest.php deleted file mode 100644 index e9c8a3eb862..00000000000 --- a/lib/Cake/Test/Case/AllCacheTest.php +++ /dev/null @@ -1,40 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'Cache'); - $suite->addTestDirectory(CORE_TEST_CASES . DS . 'Cache' . DS . 'Engine'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllComponentsTest.php b/lib/Cake/Test/Case/AllComponentsTest.php deleted file mode 100644 index 25cf1703631..00000000000 --- a/lib/Cake/Test/Case/AllComponentsTest.php +++ /dev/null @@ -1,42 +0,0 @@ -addTestFile(CORE_TEST_CASES . DS . 'Controller' . DS . 'ComponentTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Controller' . DS . 'ComponentCollectionTest.php'); - $suite->addTestDirectoryRecursive(CORE_TEST_CASES . DS . 'Controller' . DS . 'Component'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllConfigureTest.php b/lib/Cake/Test/Case/AllConfigureTest.php deleted file mode 100644 index ff56ee6b98b..00000000000 --- a/lib/Cake/Test/Case/AllConfigureTest.php +++ /dev/null @@ -1,40 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'Configure'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllConsoleTest.php b/lib/Cake/Test/Case/AllConsoleTest.php deleted file mode 100644 index 820d622b2ce..00000000000 --- a/lib/Cake/Test/Case/AllConsoleTest.php +++ /dev/null @@ -1,44 +0,0 @@ -addTestFile($path . 'AllConsoleLibsTest.php'); - $suite->addTestFile($path . 'AllTasksTest.php'); - $suite->addTestFile($path . 'AllShellsTest.php'); - return $suite; - } -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/AllControllerTest.php b/lib/Cake/Test/Case/AllControllerTest.php deleted file mode 100644 index 0d3a316493c..00000000000 --- a/lib/Cake/Test/Case/AllControllerTest.php +++ /dev/null @@ -1,44 +0,0 @@ -addTestFile(CORE_TEST_CASES . DS . 'Controller' . DS . 'ControllerTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Controller' . DS . 'ScaffoldTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Controller' . DS . 'PagesControllerTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Controller' . DS . 'ComponentTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Controller' . DS . 'ControllerMergeVarsTest.php'); - return $suite; - } -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/AllCoreTest.php b/lib/Cake/Test/Case/AllCoreTest.php deleted file mode 100644 index 7964fe12fca..00000000000 --- a/lib/Cake/Test/Case/AllCoreTest.php +++ /dev/null @@ -1,41 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'Core'); - return $suite; - } -} - diff --git a/lib/Cake/Test/Case/AllDatabaseTest.php b/lib/Cake/Test/Case/AllDatabaseTest.php deleted file mode 100644 index 6357ab9b1f7..00000000000 --- a/lib/Cake/Test/Case/AllDatabaseTest.php +++ /dev/null @@ -1,56 +0,0 @@ -addTestFile($path . $task . 'Test.php'); - } - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllErrorTest.php b/lib/Cake/Test/Case/AllErrorTest.php deleted file mode 100644 index 0777ed50537..00000000000 --- a/lib/Cake/Test/Case/AllErrorTest.php +++ /dev/null @@ -1,42 +0,0 @@ -addTestDirectory($libs . 'Error'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllEventTest.php b/lib/Cake/Test/Case/AllEventTest.php deleted file mode 100644 index 9f8ab2941c9..00000000000 --- a/lib/Cake/Test/Case/AllEventTest.php +++ /dev/null @@ -1,40 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'Event'); - return $suite; - } -} - diff --git a/lib/Cake/Test/Case/AllHelpersTest.php b/lib/Cake/Test/Case/AllHelpersTest.php deleted file mode 100644 index 9c40ad6f40f..00000000000 --- a/lib/Cake/Test/Case/AllHelpersTest.php +++ /dev/null @@ -1,42 +0,0 @@ -addTestFile(CORE_TEST_CASES . DS . 'View' . DS . 'HelperTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'View' . DS . 'HelperCollectionTest.php'); - $suite->addTestDirectory(CORE_TEST_CASES . DS . 'View' . DS . 'Helper' . DS); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllI18nTest.php b/lib/Cake/Test/Case/AllI18nTest.php deleted file mode 100644 index b4bfb8b2756..00000000000 --- a/lib/Cake/Test/Case/AllI18nTest.php +++ /dev/null @@ -1,40 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'I18n'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllLogTest.php b/lib/Cake/Test/Case/AllLogTest.php deleted file mode 100644 index e6dca95b611..00000000000 --- a/lib/Cake/Test/Case/AllLogTest.php +++ /dev/null @@ -1,41 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'Log'); - $suite->addTestDirectory(CORE_TEST_CASES . DS . 'Log' . DS . 'Engine'); - return $suite; - } -} - diff --git a/lib/Cake/Test/Case/AllNetworkTest.php b/lib/Cake/Test/Case/AllNetworkTest.php deleted file mode 100644 index 22809e067f1..00000000000 --- a/lib/Cake/Test/Case/AllNetworkTest.php +++ /dev/null @@ -1,42 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'Network'); - $suite->addTestDirectory(CORE_TEST_CASES . DS . 'Network' . DS . 'Email'); - $suite->addTestDirectory(CORE_TEST_CASES . DS . 'Network' . DS . 'Http'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllRoutingTest.php b/lib/Cake/Test/Case/AllRoutingTest.php deleted file mode 100644 index 0564439a5d6..00000000000 --- a/lib/Cake/Test/Case/AllRoutingTest.php +++ /dev/null @@ -1,43 +0,0 @@ -addTestDirectory($libs . 'Routing'); - $suite->addTestDirectory($libs . 'Routing' . DS . 'Route'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllTestSuiteTest.php b/lib/Cake/Test/Case/AllTestSuiteTest.php deleted file mode 100644 index 1ead825dd1a..00000000000 --- a/lib/Cake/Test/Case/AllTestSuiteTest.php +++ /dev/null @@ -1,40 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'TestSuite'); - return $suite; - } -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/AllTestsTest.php b/lib/Cake/Test/Case/AllTestsTest.php deleted file mode 100644 index 89903596d03..00000000000 --- a/lib/Cake/Test/Case/AllTestsTest.php +++ /dev/null @@ -1,60 +0,0 @@ -addTestFile($path . 'BasicsTest.php'); - $suite->addTestFile($path . 'AllConsoleTest.php'); - $suite->addTestFile($path . 'AllBehaviorsTest.php'); - $suite->addTestFile($path . 'AllCacheTest.php'); - $suite->addTestFile($path . 'AllComponentsTest.php'); - $suite->addTestFile($path . 'AllConfigureTest.php'); - $suite->addTestFile($path . 'AllCoreTest.php'); - $suite->addTestFile($path . 'AllControllerTest.php'); - $suite->addTestFile($path . 'AllDatabaseTest.php'); - $suite->addTestFile($path . 'AllErrorTest.php'); - $suite->addTestFile($path . 'AllEventTest.php'); - $suite->addTestFile($path . 'AllHelpersTest.php'); - $suite->addTestFile($path . 'AllLogTest.php'); - $suite->addTestFile($path . 'Model' . DS . 'ModelTest.php'); - $suite->addTestFile($path . 'AllRoutingTest.php'); - $suite->addTestFile($path . 'AllNetworkTest.php'); - $suite->addTestFile($path . 'AllTestSuiteTest.php'); - $suite->addTestFile($path . 'AllUtilityTest.php'); - $suite->addTestFile($path . 'AllViewTest.php'); - $suite->addTestFile($path . 'AllI18nTest.php'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllUtilityTest.php b/lib/Cake/Test/Case/AllUtilityTest.php deleted file mode 100644 index 8b41d8dae04..00000000000 --- a/lib/Cake/Test/Case/AllUtilityTest.php +++ /dev/null @@ -1,39 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'Utility'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/AllViewTest.php b/lib/Cake/Test/Case/AllViewTest.php deleted file mode 100644 index 3a9bcd3d0d2..00000000000 --- a/lib/Cake/Test/Case/AllViewTest.php +++ /dev/null @@ -1,40 +0,0 @@ -addTestDirectory(CORE_TEST_CASES . DS . 'View'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/BasicsTest.php b/lib/Cake/Test/Case/BasicsTest.php deleted file mode 100644 index fd2dbba6502..00000000000 --- a/lib/Cake/Test/Case/BasicsTest.php +++ /dev/null @@ -1,942 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -require_once CAKE . 'basics.php'; -App::uses('Folder', 'Utility'); -App::uses('CakeResponse', 'Network'); - -/** - * BasicsTest class - * - * @package Cake.Test.Case - */ -class BasicsTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - App::build(array( - 'Locale' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Locale' . DS) - )); - $this->_language = Configure::read('Config.language'); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - App::build(); - Configure::write('Config.language', $this->_language); - } - -/** - * test the array_diff_key compatibility function. - * - * @return void - */ - public function testArrayDiffKey() { - $one = array('one' => 1, 'two' => 2, 'three' => 3); - $two = array('one' => 'one', 'two' => 'two'); - $result = array_diff_key($one, $two); - $expected = array('three' => 3); - $this->assertEquals($expected, $result); - - $one = array('one' => array('value', 'value-two'), 'two' => 2, 'three' => 3); - $two = array('two' => 'two'); - $result = array_diff_key($one, $two); - $expected = array('one' => array('value', 'value-two'), 'three' => 3); - $this->assertEquals($expected, $result); - - $one = array('one' => null, 'two' => 2, 'three' => '', 'four' => 0); - $two = array('two' => 'two'); - $result = array_diff_key($one, $two); - $expected = array('one' => null, 'three' => '', 'four' => 0); - $this->assertEquals($expected, $result); - - $one = array('minYear' => null, 'maxYear' => null, 'separator' => '-', 'interval' => 1, 'monthNames' => true); - $two = array('minYear' => null, 'maxYear' => null, 'separator' => '-', 'interval' => 1, 'monthNames' => true); - $result = array_diff_key($one, $two); - $this->assertEquals(array(), $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['HTTP_HOST'] = 'localhost'; - $this->assertEquals(env('HTTP_BASE'), '.localhost'); - - $_SERVER['HTTP_HOST'] = 'com.ar'; - $this->assertEquals(env('HTTP_BASE'), '.com.ar'); - - $_SERVER['HTTP_HOST'] = 'example.ar'; - $this->assertEquals(env('HTTP_BASE'), '.example.ar'); - - $_SERVER['HTTP_HOST'] = 'example.com'; - $this->assertEquals(env('HTTP_BASE'), '.example.com'); - - $_SERVER['HTTP_HOST'] = 'www.example.com'; - $this->assertEquals(env('HTTP_BASE'), '.example.com'); - - $_SERVER['HTTP_HOST'] = 'subdomain.example.com'; - $this->assertEquals(env('HTTP_BASE'), '.example.com'); - - $_SERVER['HTTP_HOST'] = 'example.com.ar'; - $this->assertEquals(env('HTTP_BASE'), '.example.com.ar'); - - $_SERVER['HTTP_HOST'] = 'www.example.com.ar'; - $this->assertEquals(env('HTTP_BASE'), '.example.com.ar'); - - $_SERVER['HTTP_HOST'] = 'subdomain.example.com.ar'; - $this->assertEquals(env('HTTP_BASE'), '.example.com.ar'); - - $_SERVER['HTTP_HOST'] = 'double.subdomain.example.com'; - $this->assertEquals(env('HTTP_BASE'), '.subdomain.example.com'); - - $_SERVER['HTTP_HOST'] = 'double.subdomain.example.com.ar'; - $this->assertEquals(env('HTTP_BASE'), '.subdomain.example.com.ar'); - - $_SERVER = $_ENV = array(); - - $_SERVER['SCRIPT_NAME'] = '/a/test/test.php'; - $this->assertEquals(env('SCRIPT_NAME'), '/a/test/test.php'); - - $_SERVER = $_ENV = array(); - - $_ENV['CGI_MODE'] = 'BINARY'; - $_ENV['SCRIPT_URL'] = '/a/test/test.php'; - $this->assertEquals(env('SCRIPT_NAME'), '/a/test/test.php'); - - $_SERVER = $_ENV = array(); - - $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 = array(); - - $_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 = array(); - - $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 = array('this & that', '

Which one

'); - $result = h($in); - $expected = array('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); - - $arr = array('', ' '); - $result = h($arr); - $expected = array( - '<foo>', - '&nbsp;' - ); - $this->assertEquals($expected, $result); - - $arr = array('', ' '); - $result = h($arr, false); - $expected = array( - '<foo>', - ' ' - ); - $this->assertEquals($expected, $result); - - $arr = array('f' => '', 'n' => ' '); - $result = h($arr, false); - $expected = array( - 'f' => '<foo>', - 'n' => ' ' - ); - $this->assertEquals($expected, $result); - - $obj = new stdClass(); - $result = h($obj); - $this->assertEquals('(object)stdClass', $result); - - $obj = new CakeResponse(array('body' => 'Body content')); - $result = h($obj); - $this->assertEquals('Body content', $result); - } - -/** - * Test am() - * - * @return void - */ - public function testAm() { - $result = am(array('one', 'two'), 2, 3, 4); - $expected = array('one', 'two', 2, 3, 4); - $this->assertEquals($expected, $result); - - $result = am(array('one' => array(2, 3), 'two' => array('foo')), array('one' => array(4, 5))); - $expected = array('one' => array(4, 5), 'two' => array('foo')); - $this->assertEquals($expected, $result); - } - -/** - * test cache() - * - * @return void - */ - public function testCache() { - $_cacheDisable = Configure::read('Cache.disable'); - $this->skipIf($_cacheDisable, 'Cache is disabled, skipping cache() tests.'); - - Configure::write('Cache.disable', true); - $result = cache('basics_test', 'simple cache write'); - $this->assertNull($result); - - $result = cache('basics_test'); - $this->assertNull($result); - - Configure::write('Cache.disable', false); - $result = cache('basics_test', 'simple cache write'); - $this->assertTrue((boolean)$result); - $this->assertTrue(file_exists(CACHE . 'basics_test')); - - $result = cache('basics_test'); - $this->assertEquals('simple cache write', $result); - @unlink(CACHE . 'basics_test'); - - cache('basics_test', 'expired', '+1 second'); - sleep(2); - $result = cache('basics_test', null, '+1 second'); - $this->assertNull($result); - - Configure::write('Cache.disable', $_cacheDisable); - } - -/** - * test clearCache() - * - * @return void - */ - public function testClearCache() { - $cacheOff = Configure::read('Cache.disable'); - $this->skipIf($cacheOff, 'Cache is disabled, skipping clearCache() tests.'); - - cache('views' . DS . 'basics_test.cache', 'simple cache write'); - $this->assertTrue(file_exists(CACHE . 'views' . DS . 'basics_test.cache')); - - cache('views' . DS . 'basics_test_2.cache', 'simple cache write 2'); - $this->assertTrue(file_exists(CACHE . 'views' . DS . 'basics_test_2.cache')); - - cache('views' . DS . 'basics_test_3.cache', 'simple cache write 3'); - $this->assertTrue(file_exists(CACHE . 'views' . DS . 'basics_test_3.cache')); - - $result = clearCache(array('basics_test', 'basics_test_2'), 'views', '.cache'); - $this->assertTrue($result); - $this->assertFalse(file_exists(CACHE . 'views' . DS . 'basics_test.cache')); - $this->assertFalse(file_exists(CACHE . 'views' . DS . 'basics_test.cache')); - $this->assertTrue(file_exists(CACHE . 'views' . DS . 'basics_test_3.cache')); - - $result = clearCache(null, 'views', '.cache'); - $this->assertTrue($result); - $this->assertFalse(file_exists(CACHE . 'views' . DS . 'basics_test_3.cache')); - - // Different path from views and with prefix - cache('models' . DS . 'basics_test.cache', 'simple cache write'); - $this->assertTrue(file_exists(CACHE . 'models' . DS . 'basics_test.cache')); - - cache('models' . DS . 'basics_test_2.cache', 'simple cache write 2'); - $this->assertTrue(file_exists(CACHE . 'models' . DS . 'basics_test_2.cache')); - - cache('models' . DS . 'basics_test_3.cache', 'simple cache write 3'); - $this->assertTrue(file_exists(CACHE . 'models' . DS . 'basics_test_3.cache')); - - $result = clearCache('basics', 'models', '.cache'); - $this->assertTrue($result); - $this->assertFalse(file_exists(CACHE . 'models' . DS . 'basics_test.cache')); - $this->assertFalse(file_exists(CACHE . 'models' . DS . 'basics_test_2.cache')); - $this->assertFalse(file_exists(CACHE . 'models' . DS . 'basics_test_3.cache')); - - // checking if empty files were not removed - $emptyExists = file_exists(CACHE . 'views' . DS . 'empty'); - if (!$emptyExists) { - cache('views' . DS . 'empty', ''); - } - cache('views' . DS . 'basics_test.php', 'simple cache write'); - $this->assertTrue(file_exists(CACHE . 'views' . DS . 'basics_test.php')); - $this->assertTrue(file_exists(CACHE . 'views' . DS . 'empty')); - - $result = clearCache(); - $this->assertTrue($result); - $this->assertTrue(file_exists(CACHE . 'views' . DS . 'empty')); - $this->assertFalse(file_exists(CACHE . 'views' . DS . 'basics_test.php')); - if (!$emptyExists) { - unlink(CACHE . 'views' . DS . 'empty'); - } - } - -/** - * test __() - * - * @return void - */ - public function testTranslate() { - Configure::write('Config.language', 'rule_1_po'); - - $result = __('Plural Rule 1'); - $expected = 'Plural Rule 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __('Plural Rule 1 (from core)'); - $expected = 'Plural Rule 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __('Some string with %s', 'arguments'); - $expected = 'Some string with arguments'; - $this->assertEquals($expected, $result); - - $result = __('Some string with %s %s', 'multiple', 'arguments'); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - - $result = __('Some string with %s %s', array('multiple', 'arguments')); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - - $result = __('Testing %2$s %1$s', 'order', 'different'); - $expected = 'Testing different order'; - $this->assertEquals($expected, $result); - - $result = __('Testing %2$s %1$s', array('order', 'different')); - $expected = 'Testing different order'; - $this->assertEquals($expected, $result); - - $result = __('Testing %.2f number', 1.2345); - $expected = 'Testing 1.23 number'; - $this->assertEquals($expected, $result); - } - -/** - * test __n() - * - * @return void - */ - public function testTranslatePlural() { - Configure::write('Config.language', 'rule_1_po'); - - $result = __n('%d = 1', '%d = 0 or > 1', 0); - $expected = '%d = 0 or > 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __n('%d = 1', '%d = 0 or > 1', 1); - $expected = '%d = 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __n('%d = 1 (from core)', '%d = 0 or > 1 (from core)', 2); - $expected = '%d = 0 or > 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __n('%d item.', '%d items.', 1, 1); - $expected = '1 item.'; - $this->assertEquals($expected, $result); - - $result = __n('%d item for id %s', '%d items for id %s', 2, 2, '1234'); - $expected = '2 items for id 1234'; - $this->assertEquals($expected, $result); - - $result = __n('%d item for id %s', '%d items for id %s', 2, array(2, '1234')); - $expected = '2 items for id 1234'; - $this->assertEquals($expected, $result); - } - -/** - * test __d() - * - * @return void - */ - public function testTranslateDomain() { - Configure::write('Config.language', 'rule_1_po'); - - $result = __d('default', 'Plural Rule 1'); - $expected = 'Plural Rule 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __d('core', 'Plural Rule 1'); - $expected = 'Plural Rule 1'; - $this->assertEquals($expected, $result); - - $result = __d('core', 'Plural Rule 1 (from core)'); - $expected = 'Plural Rule 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __d('core', 'Some string with %s', 'arguments'); - $expected = 'Some string with arguments'; - $this->assertEquals($expected, $result); - - $result = __d('core', 'Some string with %s %s', 'multiple', 'arguments'); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - - $result = __d('core', 'Some string with %s %s', array('multiple', 'arguments')); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - } - -/** - * test __dn() - * - * @return void - */ - public function testTranslateDomainPlural() { - Configure::write('Config.language', 'rule_1_po'); - - $result = __dn('default', '%d = 1', '%d = 0 or > 1', 0); - $expected = '%d = 0 or > 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __dn('core', '%d = 1', '%d = 0 or > 1', 0); - $expected = '%d = 0 or > 1'; - $this->assertEquals($expected, $result); - - $result = __dn('core', '%d = 1 (from core)', '%d = 0 or > 1 (from core)', 0); - $expected = '%d = 0 or > 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __dn('default', '%d = 1', '%d = 0 or > 1', 1); - $expected = '%d = 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __dn('core', '%d item.', '%d items.', 1, 1); - $expected = '1 item.'; - $this->assertEquals($expected, $result); - - $result = __dn('core', '%d item for id %s', '%d items for id %s', 2, 2, '1234'); - $expected = '2 items for id 1234'; - $this->assertEquals($expected, $result); - - $result = __dn('core', '%d item for id %s', '%d items for id %s', 2, array(2, '1234')); - $expected = '2 items for id 1234'; - $this->assertEquals($expected, $result); - } - -/** - * test __c() - * - * @return void - */ - public function testTranslateCategory() { - Configure::write('Config.language', 'rule_1_po'); - - $result = __c('Plural Rule 1', 6); - $expected = 'Plural Rule 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __c('Plural Rule 1 (from core)', 6); - $expected = 'Plural Rule 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __c('Some string with %s', 6, 'arguments'); - $expected = 'Some string with arguments'; - $this->assertEquals($expected, $result); - - $result = __c('Some string with %s %s', 6, 'multiple', 'arguments'); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - - $result = __c('Some string with %s %s', 6, array('multiple', 'arguments')); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - } - -/** - * test __dc() - * - * @return void - */ - public function testTranslateDomainCategory() { - Configure::write('Config.language', 'rule_1_po'); - - $result = __dc('default', 'Plural Rule 1', 6); - $expected = 'Plural Rule 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __dc('default', 'Plural Rule 1 (from core)', 6); - $expected = 'Plural Rule 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __dc('core', 'Plural Rule 1', 6); - $expected = 'Plural Rule 1'; - $this->assertEquals($expected, $result); - - $result = __dc('core', 'Plural Rule 1 (from core)', 6); - $expected = 'Plural Rule 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __dc('core', 'Some string with %s', 6, 'arguments'); - $expected = 'Some string with arguments'; - $this->assertEquals($expected, $result); - - $result = __dc('core', 'Some string with %s %s', 6, 'multiple', 'arguments'); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - - $result = __dc('core', 'Some string with %s %s', 6, array('multiple', 'arguments')); - $expected = 'Some string with multiple arguments'; - $this->assertEquals($expected, $result); - } - -/** - * test __dcn() - * - * @return void - */ - public function testTranslateDomainCategoryPlural() { - Configure::write('Config.language', 'rule_1_po'); - - $result = __dcn('default', '%d = 1', '%d = 0 or > 1', 0, 6); - $expected = '%d = 0 or > 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __dcn('default', '%d = 1 (from core)', '%d = 0 or > 1 (from core)', 1, 6); - $expected = '%d = 1 (from core translated)'; - $this->assertEquals($expected, $result); - - $result = __dcn('core', '%d = 1', '%d = 0 or > 1', 0, 6); - $expected = '%d = 0 or > 1'; - $this->assertEquals($expected, $result); - - $result = __dcn('core', '%d item.', '%d items.', 1, 6, 1); - $expected = '1 item.'; - $this->assertEquals($expected, $result); - - $result = __dcn('core', '%d item for id %s', '%d items for id %s', 2, 6, 2, '1234'); - $expected = '2 items for id 1234'; - $this->assertEquals($expected, $result); - - $result = __dcn('core', '%d item for id %s', '%d items for id %s', 2, 6, array(2, '1234')); - $expected = '2 items for id 1234'; - $this->assertEquals($expected, $result); - } - -/** - * test LogError() - * - * @return void - */ - public function testLogError() { - @unlink(LOGS . 'error.log'); - - LogError('Testing LogError() basic function'); - LogError("Testing with\nmulti-line\nstring"); - - $result = file_get_contents(LOGS . 'error.log'); - $this->assertRegExp('/Error: Testing LogError\(\) basic function/', $result); - $this->assertNotRegExp("/Error: Testing with\nmulti-line\nstring/", $result); - $this->assertRegExp('/Error: Testing with multi-line string/', $result); - } - -/** - * test fileExistsInPath() - * - * @return void - */ - public function testFileExistsInPath() { - if (!function_exists('ini_set')) { - $this->markTestSkipped('%s ini_set function not available'); - } - - $_includePath = ini_get('include_path'); - - $path = TMP . 'basics_test'; - $folder1 = $path . DS . 'folder1'; - $folder2 = $path . DS . 'folder2'; - $file1 = $path . DS . 'file1.php'; - $file2 = $folder1 . DS . 'file2.php'; - $file3 = $folder1 . DS . 'file3.php'; - $file4 = $folder2 . DS . 'file4.php'; - - new Folder($path, true); - new Folder($folder1, true); - new Folder($folder2, true); - touch($file1); - touch($file2); - touch($file3); - touch($file4); - - ini_set('include_path', $path . PATH_SEPARATOR . $folder1); - - $this->assertEquals(fileExistsInPath('file1.php'), $file1); - $this->assertEquals(fileExistsInPath('file2.php'), $file2); - $this->assertEquals(fileExistsInPath('folder1' . DS . 'file2.php'), $file2); - $this->assertEquals(fileExistsInPath($file2), $file2); - $this->assertEquals(fileExistsInPath('file3.php'), $file3); - $this->assertEquals(fileExistsInPath($file4), $file4); - - $this->assertFalse(fileExistsInPath('file1')); - $this->assertFalse(fileExistsInPath('file4.php')); - - $Folder = new Folder($path); - $Folder->delete(); - - ini_set('include_path', $_includePath); - } - -/** - * test convertSlash() - * - * @return void - */ - public function testConvertSlash() { - $result = convertSlash('\path\to\location\\'); - $expected = '\path\to\location\\'; - $this->assertEquals($expected, $result); - - $result = convertSlash('/path/to/location/'); - $expected = 'path_to_location'; - $this->assertEquals($expected, $result); - } - -/** - * test debug() - * - * @return void - */ - public function testDebug() { - ob_start(); - debug('this-is-a-test', false); - $result = ob_get_clean(); - $expectedText = <<assertEquals($expected, $result); - - ob_start(); - debug('
this-is-a-test
', true); - $result = ob_get_clean(); - $expectedHtml = << -%s (line %d) -
-'<div>this-is-a-test</div>'
-
- -EXPECTED; - $expected = sprintf($expectedHtml, substr(__FILE__, strlen(ROOT)), __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, substr(__FILE__, strlen(ROOT)), __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, substr(__FILE__, strlen(ROOT)), __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_name() == 'cli') { - $expected = sprintf($expectedText, substr(__FILE__, strlen(ROOT)), __LINE__ - 17); - } else { - $expected = sprintf($expectedHtml, substr(__FILE__, strlen(ROOT)), __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_name() == 'cli') { - $expected = sprintf($expectedText, substr(__FILE__, strlen(ROOT)), __LINE__ - 17); - } else { - $expected = sprintf($expectedHtml, substr(__FILE__, strlen(ROOT)), __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, substr(__FILE__, strlen(ROOT)), __LINE__ - 8); - $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, substr(__FILE__, strlen(ROOT)), __LINE__ - 8); - $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, substr(__FILE__, strlen(ROOT)), __LINE__ - 8); - $this->assertEquals($expected, $result); - } - -/** - * test pr() - * - * @return void - */ - public function testPr() { - ob_start(); - pr('this is a test'); - $result = ob_get_clean(); - $expected = "
this is a test
"; - $this->assertEquals($expected, $result); - - ob_start(); - pr(array('this' => 'is', 'a' => 'test')); - $result = ob_get_clean(); - $expected = "
Array\n(\n    [this] => is\n    [a] => test\n)\n
"; - $this->assertEquals($expected, $result); - } - -/** - * test stripslashes_deep() - * - * @return void - */ - public function testStripslashesDeep() { - $this->skipIf(ini_get('magic_quotes_sybase') === '1', 'magic_quotes_sybase is on.'); - - $this->assertEquals(stripslashes_deep("tes\'t"), "tes't"); - $this->assertEquals(stripslashes_deep('tes\\' . chr(0) . 't'), 'tes' . chr(0) . 't'); - $this->assertEquals(stripslashes_deep('tes\"t'), 'tes"t'); - $this->assertEquals(stripslashes_deep("tes\'t"), "tes't"); - $this->assertEquals(stripslashes_deep('te\\st'), 'test'); - - $nested = array( - 'a' => "tes\'t", - 'b' => 'tes\\' . chr(0) . 't', - 'c' => array( - 'd' => 'tes\"t', - 'e' => "te\'s\'t", - array('f' => "tes\'t") - ), - 'g' => 'te\\st' - ); - $expected = array( - 'a' => "tes't", - 'b' => 'tes' . chr(0) . 't', - 'c' => array( - 'd' => 'tes"t', - 'e' => "te's't", - array('f' => "tes't") - ), - 'g' => 'test' - ); - $this->assertEquals($expected, stripslashes_deep($nested)); - } - -/** - * test stripslashes_deep() with magic_quotes_sybase on - * - * @return void - */ - public function testStripslashesDeepSybase() { - if (!(ini_get('magic_quotes_sybase') === '1')) { - $this->markTestSkipped('magic_quotes_sybase is off'); - } - - $this->assertEquals(stripslashes_deep("tes\'t"), "tes\'t"); - - $nested = array( - 'a' => "tes't", - 'b' => "tes''t", - 'c' => array( - 'd' => "tes'''t", - 'e' => "tes''''t", - array('f' => "tes''t") - ), - 'g' => "te'''''st" - ); - $expected = array( - 'a' => "tes't", - 'b' => "tes't", - 'c' => array( - 'd' => "tes''t", - 'e' => "tes''t", - array('f' => "tes't") - ), - 'g' => "te'''st" - ); - $this->assertEquals($expected, stripslashes_deep($nested)); - } - -/** - * test pluginSplit - * - * @return void - */ - public function testPluginSplit() { - $result = pluginSplit('Something.else'); - $this->assertEquals(array('Something', 'else'), $result); - - $result = pluginSplit('Something.else.more.dots'); - $this->assertEquals(array('Something', 'else.more.dots'), $result); - - $result = pluginSplit('Somethingelse'); - $this->assertEquals(array(null, 'Somethingelse'), $result); - - $result = pluginSplit('Something.else', true); - $this->assertEquals(array('Something.', 'else'), $result); - - $result = pluginSplit('Something.else.more.dots', true); - $this->assertEquals(array('Something.', 'else.more.dots'), $result); - - $result = pluginSplit('Post', false, 'Blog'); - $this->assertEquals(array('Blog', 'Post'), $result); - - $result = pluginSplit('Blog.Post', false, 'Ultimate'); - $this->assertEquals(array('Blog', 'Post'), $result); - } -} diff --git a/lib/Cake/Test/Case/Cache/CacheTest.php b/lib/Cake/Test/Case/Cache/CacheTest.php deleted file mode 100644 index a5360a4e2a6..00000000000 --- a/lib/Cake/Test/Case/Cache/CacheTest.php +++ /dev/null @@ -1,409 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Cache - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Cache', 'Cache'); - -/** - * CacheTest class - * - * @package Cake.Test.Case.Cache - */ -class CacheTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->_cacheDisable = Configure::read('Cache.disable'); - Configure::write('Cache.disable', false); - - $this->_defaultCacheConfig = Cache::config('default'); - Cache::config('default', array('engine' => 'File', 'path' => TMP . 'tests')); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - Configure::write('Cache.disable', $this->_cacheDisable); - Cache::config('default', $this->_defaultCacheConfig['settings']); - } - -/** - * testConfig method - * - * @return void - */ - public function testConfig() { - $settings = array('engine' => 'File', 'path' => TMP . 'tests', 'prefix' => 'cake_test_'); - $results = Cache::config('new', $settings); - $this->assertEquals(Cache::config('new'), $results); - $this->assertTrue(isset($results['engine'])); - $this->assertTrue(isset($results['settings'])); - } - -/** - * Check that no fatal errors are issued doing normal things when Cache.disable is true. - * - * @return void - */ - public function testNonFatalErrorsWithCachedisable() { - Configure::write('Cache.disable', true); - Cache::config('test', array('engine' => 'File', 'path' => TMP, 'prefix' => 'error_test_')); - - Cache::write('no_save', 'Noooo!', 'test'); - Cache::read('no_save', 'test'); - Cache::delete('no_save', 'test'); - Cache::set('duration', '+10 minutes'); - - Configure::write('Cache.disable', false); - } - -/** - * test configuring CacheEngines in App/libs - * - * @return void - */ - public function testConfigWithLibAndPluginEngines() { - App::build(array( - 'Lib' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Lib' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - - $settings = array('engine' => 'TestAppCache', 'path' => TMP, 'prefix' => 'cake_test_'); - $result = Cache::config('libEngine', $settings); - $this->assertEquals(Cache::config('libEngine'), $result); - - $settings = array('engine' => 'TestPlugin.TestPluginCache', 'path' => TMP, 'prefix' => 'cake_test_'); - $result = Cache::config('pluginLibEngine', $settings); - $this->assertEquals(Cache::config('pluginLibEngine'), $result); - - Cache::drop('libEngine'); - Cache::drop('pluginLibEngine'); - - App::build(); - CakePlugin::unload(); - } - -/** - * testInvalidConfig method - * - * Test that the cache class doesn't cause fatal errors with a partial path - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testInvalidConfig() { - Cache::config('invalid', array( - 'engine' => 'File', - 'duration' => '+1 year', - 'prefix' => 'testing_invalid_', - 'path' => 'data/', - 'serialize' => true, - 'random' => 'wii' - )); - $read = Cache::read('Test', 'invalid'); - } - -/** - * Test reading from a config that is undefined. - * - * @return void - */ - public function testReadNonExistingConfig() { - $this->assertFalse(Cache::read('key', 'totally fake')); - $this->assertFalse(Cache::write('key', 'value', 'totally fake')); - $this->assertFalse(Cache::increment('key', 1, 'totally fake')); - $this->assertFalse(Cache::decrement('key', 1, 'totally fake')); - } - -/** - * test that trying to configure classes that don't extend CacheEngine fail. - * - * @expectedException CacheException - * @return void - */ - public function testAttemptingToConfigureANonCacheEngineClass() { - $this->getMock('StdClass', array(), array(), 'RubbishEngine'); - Cache::config('Garbage', array( - 'engine' => 'Rubbish' - )); - } - -/** - * testConfigChange method - * - * @return void - */ - public function testConfigChange() { - $_cacheConfigSessions = Cache::config('sessions'); - $_cacheConfigTests = Cache::config('tests'); - - $result = Cache::config('sessions', array('engine' => 'File', 'path' => TMP . 'sessions')); - $this->assertEquals(Cache::settings('sessions'), $result['settings']); - - $result = Cache::config('tests', array('engine' => 'File', 'path' => TMP . 'tests')); - $this->assertEquals(Cache::settings('tests'), $result['settings']); - - Cache::config('sessions', $_cacheConfigSessions['settings']); - Cache::config('tests', $_cacheConfigTests['settings']); - } - -/** - * test that calling config() sets the 'default' configuration up. - * - * @return void - */ - public function testConfigSettingDefaultConfigKey() { - Cache::config('test_name', array('engine' => 'File', 'prefix' => 'test_name_')); - - Cache::write('value_one', 'I am cached', 'test_name'); - $result = Cache::read('value_one', 'test_name'); - $this->assertEquals('I am cached', $result); - - $result = Cache::read('value_one'); - $this->assertEquals(null, $result); - - Cache::write('value_one', 'I am in default config!'); - $result = Cache::read('value_one'); - $this->assertEquals('I am in default config!', $result); - - $result = Cache::read('value_one', 'test_name'); - $this->assertEquals('I am cached', $result); - - Cache::delete('value_one', 'test_name'); - Cache::delete('value_one', 'default'); - } - -/** - * testWritingWithConfig method - * - * @return void - */ - public function testWritingWithConfig() { - $_cacheConfigSessions = Cache::config('sessions'); - - Cache::write('test_something', 'this is the test data', 'tests'); - - $expected = array( - 'path' => TMP . 'sessions' . DS, - 'prefix' => 'cake_', - 'lock' => true, - 'serialize' => true, - 'duration' => 3600, - 'probability' => 100, - 'engine' => 'File', - 'isWindows' => DIRECTORY_SEPARATOR == '\\', - 'mask' => 0664 - ); - $this->assertEquals($expected, Cache::settings('sessions')); - - Cache::config('sessions', $_cacheConfigSessions['settings']); - } - -/** - * test that configured returns an array of the currently configured cache - * settings - * - * @return void - */ - public function testConfigured() { - $result = Cache::configured(); - $this->assertTrue(in_array('_cake_core_', $result)); - $this->assertTrue(in_array('default', $result)); - } - -/** - * testInitSettings method - * - * @return void - */ - public function testInitSettings() { - $initial = Cache::settings(); - $override = array('engine' => 'File', 'path' => TMP . 'tests'); - Cache::config('for_test', $override); - - $settings = Cache::settings(); - $expecting = $override + $initial; - $this->assertEquals($settings, $expecting); - } - -/** - * test that drop removes cache configs, and that further attempts to use that config - * do not work. - * - * @return void - */ - public function testDrop() { - App::build(array( - 'Lib' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Lib' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - - $result = Cache::drop('some_config_that_does_not_exist'); - $this->assertFalse($result); - - $_testsConfig = Cache::config('tests'); - $result = Cache::drop('tests'); - $this->assertTrue($result); - - Cache::config('unconfigTest', array( - 'engine' => 'TestAppCache' - )); - $this->assertTrue(Cache::isInitialized('unconfigTest')); - - $this->assertTrue(Cache::drop('unconfigTest')); - $this->assertFalse(Cache::isInitialized('TestAppCache')); - - Cache::config('tests', $_testsConfig); - App::build(); - } - -/** - * testWriteEmptyValues method - * - * @return void - */ - public function testWriteEmptyValues() { - Cache::write('App.falseTest', false); - $this->assertSame(Cache::read('App.falseTest'), false); - - Cache::write('App.trueTest', true); - $this->assertSame(Cache::read('App.trueTest'), true); - - Cache::write('App.nullTest', null); - $this->assertSame(Cache::read('App.nullTest'), null); - - Cache::write('App.zeroTest', 0); - $this->assertSame(Cache::read('App.zeroTest'), 0); - - Cache::write('App.zeroTest2', '0'); - $this->assertSame(Cache::read('App.zeroTest2'), '0'); - } - -/** - * Test that failed writes cause errors to be triggered. - * - * @return void - */ - public function testWriteTriggerError() { - App::build(array( - 'Lib' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Lib' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - - Cache::config('test_trigger', array('engine' => 'TestAppCache', 'prefix' => '')); - try { - Cache::write('fail', 'value', 'test_trigger'); - $this->fail('No exception thrown'); - } catch (PHPUnit_Framework_Error $e) { - $this->assertTrue(true); - } - Cache::drop('test_trigger'); - App::build(); - } - -/** - * testCacheDisable method - * - * 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() { - Configure::write('Cache.disable', false); - Cache::config('test_cache_disable_1', array('engine' => 'File', 'path' => TMP . 'tests')); - - $this->assertTrue(Cache::write('key_1', 'hello', 'test_cache_disable_1')); - $this->assertSame(Cache::read('key_1', 'test_cache_disable_1'), 'hello'); - - Configure::write('Cache.disable', true); - - $this->assertFalse(Cache::write('key_2', 'hello', 'test_cache_disable_1')); - $this->assertFalse(Cache::read('key_2', 'test_cache_disable_1')); - - Configure::write('Cache.disable', false); - - $this->assertTrue(Cache::write('key_3', 'hello', 'test_cache_disable_1')); - $this->assertSame(Cache::read('key_3', 'test_cache_disable_1'), 'hello'); - - Configure::write('Cache.disable', true); - Cache::config('test_cache_disable_2', array('engine' => 'File', 'path' => TMP . 'tests')); - - $this->assertFalse(Cache::write('key_4', 'hello', 'test_cache_disable_2')); - $this->assertFalse(Cache::read('key_4', 'test_cache_disable_2')); - - Configure::write('Cache.disable', false); - - $this->assertTrue(Cache::write('key_5', 'hello', 'test_cache_disable_2')); - $this->assertSame(Cache::read('key_5', 'test_cache_disable_2'), 'hello'); - - Configure::write('Cache.disable', true); - - $this->assertFalse(Cache::write('key_6', 'hello', 'test_cache_disable_2')); - $this->assertFalse(Cache::read('key_6', 'test_cache_disable_2')); - } - -/** - * testSet method - * - * @return void - */ - public function testSet() { - $_cacheSet = Cache::set(); - - Cache::set(array('duration' => '+1 year')); - $data = Cache::read('test_cache'); - $this->assertFalse($data); - - $data = 'this is just a simple test of the cache system'; - $write = Cache::write('test_cache', $data); - $this->assertTrue($write); - - Cache::set(array('duration' => '+1 year')); - $data = Cache::read('test_cache'); - $this->assertEquals('this is just a simple test of the cache system', $data); - - Cache::delete('test_cache'); - - $global = Cache::settings(); - - Cache::set($_cacheSet); - } - -/** - * test set() parameter handling for user cache configs. - * - * @return void - */ - public function testSetOnAlternateConfigs() { - Cache::config('file_config', array('engine' => 'File', 'prefix' => 'test_file_')); - Cache::set(array('duration' => '+1 year'), 'file_config'); - $settings = Cache::settings('file_config'); - - $this->assertEquals('test_file_', $settings['prefix']); - $this->assertEquals(strtotime('+1 year') - time(), $settings['duration']); - } -} diff --git a/lib/Cake/Test/Case/Cache/Engine/ApcEngineTest.php b/lib/Cake/Test/Case/Cache/Engine/ApcEngineTest.php deleted file mode 100644 index 62ac9b69251..00000000000 --- a/lib/Cake/Test/Case/Cache/Engine/ApcEngineTest.php +++ /dev/null @@ -1,201 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Cache.Engine - * @since CakePHP(tm) v 1.2.0.5434 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Cache', 'Cache'); - -/** - * ApcEngineTest class - * - * @package Cake.Test.Case.Cache.Engine - */ -class ApcEngineTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->skipIf(!function_exists('apc_store'), 'Apc is not installed or configured properly.'); - - $this->_cacheDisable = Configure::read('Cache.disable'); - Configure::write('Cache.disable', false); - Cache::config('apc', array('engine' => 'Apc', 'prefix' => 'cake_')); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - Configure::write('Cache.disable', $this->_cacheDisable); - Cache::drop('apc'); - Cache::config('default'); - } - -/** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() { - Cache::set(array('duration' => 1), 'apc'); - - $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::config('apc', array('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() { - Cache::set(array('duration' => 1), 'apc'); - - $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); - - Cache::set(array('duration' => 1), 'apc'); - - $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); - - 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() { - $this->skipIf(!function_exists('apc_dec'), 'No apc_dec() function, cannot test decrement().'); - - $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() { - $this->skipIf(!function_exists('apc_inc'), 'No apc_inc() function, cannot test increment().'); - - $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() { - apc_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', apc_fetch('not_cake')); - apc_delete('not_cake'); - } -} diff --git a/lib/Cake/Test/Case/Cache/Engine/FileEngineTest.php b/lib/Cake/Test/Case/Cache/Engine/FileEngineTest.php deleted file mode 100644 index b59b3029ae9..00000000000 --- a/lib/Cake/Test/Case/Cache/Engine/FileEngineTest.php +++ /dev/null @@ -1,396 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Cache.Engine - * @since CakePHP(tm) v 1.2.0.5434 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Cache', 'Cache'); - -/** - * FileEngineTest class - * - * @package Cake.Test.Case.Cache.Engine - */ -class FileEngineTest extends CakeTestCase { - -/** - * config property - * - * @var array - */ - public $config = array(); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - Configure::write('Cache.disable', false); - Cache::config('file_test', array('engine' => 'File', 'path' => CACHE)); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - Cache::clear(false, 'file_test'); - Cache::drop('file_test'); - } - -/** - * testCacheDirChange method - * - * @return void - */ - public function testCacheDirChange() { - $result = Cache::config('sessions', array('engine' => 'File', 'path' => TMP . 'sessions')); - $this->assertEquals(Cache::settings('sessions'), $result['settings']); - - $result = Cache::config('sessions', array('engine' => 'File', 'path' => TMP . 'tests')); - $this->assertEquals(Cache::settings('sessions'), $result['settings']); - $this->assertNotEquals(Cache::settings('default'), $result['settings']); - } - -/** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() { - Cache::config('default'); - - $result = Cache::write(null, 'here', 'file_test'); - $this->assertFalse($result); - - Cache::set(array('duration' => 1), 'file_test'); - - $result = Cache::read('test', 'file_test'); - $expecting = ''; - $this->assertEquals($expecting, $result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('test', $data, 'file_test'); - $this->assertTrue(file_exists(CACHE . 'cake_test')); - - $result = Cache::read('test', 'file_test'); - $expecting = $data; - $this->assertEquals($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() { - Cache::write('rw', 'first write', 'file_test'); - $result = Cache::read('rw', 'file_test'); - - Cache::write('rw', 'second write', 'file_test'); - $result2 = Cache::read('rw', 'file_test'); - - Cache::delete('rw', 'file_test'); - $this->assertEquals('first write', $result); - $this->assertEquals('second write', $result2); - } - -/** - * testExpiry method - * - * @return void - */ - public function testExpiry() { - Cache::set(array('duration' => 1), 'file_test'); - - $result = Cache::read('test', 'file_test'); - $this->assertFalse($result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'file_test'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'file_test'); - $this->assertFalse($result); - - Cache::set(array('duration' => "+1 second"), 'file_test'); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'file_test'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'file_test'); - $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, 'file_test'); - $this->assertTrue($result); - - $result = Cache::delete('delete_test', 'file_test'); - $this->assertTrue($result); - $this->assertFalse(file_exists(TMP . 'tests' . DS . 'delete_test')); - - $result = Cache::delete('delete_test', 'file_test'); - $this->assertFalse($result); - } - -/** - * testSerialize method - * - * @return void - */ - public function testSerialize() { - Cache::config('file_test', array('engine' => 'File', 'serialize' => true)); - $data = 'this is a test of the emergency broadcasting system'; - $write = Cache::write('serialize_test', $data, 'file_test'); - $this->assertTrue($write); - - Cache::config('file_test', array('serialize' => false)); - $read = Cache::read('serialize_test', 'file_test'); - - $newread = Cache::read('serialize_test', 'file_test'); - - $delete = Cache::delete('serialize_test', 'file_test'); - - $this->assertSame($read, serialize($data)); - - $this->assertSame(unserialize($newread), $data); - } - -/** - * testClear method - * - * @return void - */ - public function testClear() { - Cache::config('file_test', array('engine' => 'File', 'duration' => 1)); - - $data = 'this is a test of the emergency broadcasting system'; - $write = Cache::write('serialize_test1', $data, 'file_test'); - $write = Cache::write('serialize_test2', $data, 'file_test'); - $write = Cache::write('serialize_test3', $data, 'file_test'); - $this->assertTrue(file_exists(CACHE . 'cake_serialize_test1')); - $this->assertTrue(file_exists(CACHE . 'cake_serialize_test2')); - $this->assertTrue(file_exists(CACHE . 'cake_serialize_test3')); - sleep(2); - $result = Cache::clear(true, 'file_test'); - $this->assertTrue($result); - $this->assertFalse(file_exists(CACHE . 'cake_serialize_test1')); - $this->assertFalse(file_exists(CACHE . 'cake_serialize_test2')); - $this->assertFalse(file_exists(CACHE . 'cake_serialize_test3')); - - $data = 'this is a test of the emergency broadcasting system'; - $write = Cache::write('serialize_test1', $data, 'file_test'); - $write = Cache::write('serialize_test2', $data, 'file_test'); - $write = Cache::write('serialize_test3', $data, 'file_test'); - $this->assertTrue(file_exists(CACHE . 'cake_serialize_test1')); - $this->assertTrue(file_exists(CACHE . 'cake_serialize_test2')); - $this->assertTrue(file_exists(CACHE . 'cake_serialize_test3')); - - $result = Cache::clear(false, 'file_test'); - $this->assertTrue($result); - $this->assertFalse(file_exists(CACHE . 'cake_serialize_test1')); - $this->assertFalse(file_exists(CACHE . 'cake_serialize_test2')); - $this->assertFalse(file_exists(CACHE . 'cake_serialize_test3')); - } - -/** - * test that clear() doesn't wipe files not in the current engine's prefix. - * - * @return void - */ - public function testClearWithPrefixes() { - $FileOne = new FileEngine(); - $FileOne->init(array( - 'prefix' => 'prefix_one_', - 'duration' => DAY - )); - $FileTwo = new FileEngine(); - $FileTwo->init(array( - 'prefix' => 'prefix_two_', - 'duration' => DAY - )); - - $data1 = $data2 = $expected = 'content to cache'; - $FileOne->write('prefix_one_key_one', $data1, DAY); - $FileTwo->write('prefix_two_key_two', $data2, DAY); - - $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); - } - -/** - * testKeyPath method - * - * @return void - */ - public function testKeyPath() { - $result = Cache::write('views.countries.something', 'here', 'file_test'); - $this->assertTrue($result); - $this->assertTrue(file_exists(CACHE . 'cake_views_countries_something')); - - $result = Cache::read('views.countries.something', 'file_test'); - $this->assertEquals('here', $result); - - $result = Cache::clear(false, 'file_test'); - $this->assertTrue($result); - } - -/** - * testRemoveWindowsSlashesFromCache method - * - * @return void - */ - public function testRemoveWindowsSlashesFromCache() { - Cache::config('windows_test', array('engine' => 'File', 'isWindows' => true, 'prefix' => null, 'path' => TMP)); - - $expected = array( - 'C:\dev\prj2\sites\cake\libs' => array( - 0 => 'C:\dev\prj2\sites\cake\libs', 1 => 'C:\dev\prj2\sites\cake\libs\view', - 2 => 'C:\dev\prj2\sites\cake\libs\view\scaffolds', 3 => 'C:\dev\prj2\sites\cake\libs\view\pages', - 4 => 'C:\dev\prj2\sites\cake\libs\view\layouts', 5 => 'C:\dev\prj2\sites\cake\libs\view\layouts\xml', - 6 => 'C:\dev\prj2\sites\cake\libs\view\layouts\rss', 7 => 'C:\dev\prj2\sites\cake\libs\view\layouts\js', - 8 => 'C:\dev\prj2\sites\cake\libs\view\layouts\email', 9 => 'C:\dev\prj2\sites\cake\libs\view\layouts\email\text', - 10 => 'C:\dev\prj2\sites\cake\libs\view\layouts\email\html', 11 => 'C:\dev\prj2\sites\cake\libs\view\helpers', - 12 => 'C:\dev\prj2\sites\cake\libs\view\errors', 13 => 'C:\dev\prj2\sites\cake\libs\view\elements', - 14 => 'C:\dev\prj2\sites\cake\libs\view\elements\email', 15 => 'C:\dev\prj2\sites\cake\libs\view\elements\email\text', - 16 => 'C:\dev\prj2\sites\cake\libs\view\elements\email\html', 17 => 'C:\dev\prj2\sites\cake\libs\model', - 18 => 'C:\dev\prj2\sites\cake\libs\model\datasources', 19 => 'C:\dev\prj2\sites\cake\libs\model\datasources\dbo', - 20 => 'C:\dev\prj2\sites\cake\libs\model\behaviors', 21 => 'C:\dev\prj2\sites\cake\libs\controller', - 22 => 'C:\dev\prj2\sites\cake\libs\controller\components', 23 => 'C:\dev\prj2\sites\cake\libs\cache'), - 'C:\dev\prj2\sites\main_site\vendors' => array( - 0 => 'C:\dev\prj2\sites\main_site\vendors', 1 => 'C:\dev\prj2\sites\main_site\vendors\shells', - 2 => 'C:\dev\prj2\sites\main_site\vendors\shells\templates', 3 => 'C:\dev\prj2\sites\main_site\vendors\shells\templates\cdc_project', - 4 => 'C:\dev\prj2\sites\main_site\vendors\shells\tasks', 5 => 'C:\dev\prj2\sites\main_site\vendors\js', - 6 => 'C:\dev\prj2\sites\main_site\vendors\css'), - 'C:\dev\prj2\sites\vendors' => array( - 0 => 'C:\dev\prj2\sites\vendors', 1 => 'C:\dev\prj2\sites\vendors\simpletest', - 2 => 'C:\dev\prj2\sites\vendors\simpletest\test', 3 => 'C:\dev\prj2\sites\vendors\simpletest\test\support', - 4 => 'C:\dev\prj2\sites\vendors\simpletest\test\support\collector', 5 => 'C:\dev\prj2\sites\vendors\simpletest\extensions', - 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' => array( - 0 => 'C:\dev\prj2\sites\main_site\views\helpers') - ); - - Cache::write('test_dir_map', $expected, 'windows_test'); - $data = Cache::read('test_dir_map', 'windows_test'); - Cache::delete('test_dir_map', 'windows_test'); - $this->assertEquals($expected, $data); - - Cache::drop('windows_test'); - } - -/** - * testWriteQuotedString method - * - * @return void - */ - public function testWriteQuotedString() { - Cache::config('file_test', array('engine' => 'File', 'path' => TMP . 'tests')); - Cache::write('App.doubleQuoteTest', '"this is a quoted string"', 'file_test'); - $this->assertSame(Cache::read('App.doubleQuoteTest', 'file_test'), '"this is a quoted string"'); - Cache::write('App.singleQuoteTest', "'this is a quoted string'", 'file_test'); - $this->assertSame(Cache::read('App.singleQuoteTest', 'file_test'), "'this is a quoted string'"); - - Cache::config('file_test', array('isWindows' => true, 'path' => TMP . 'tests')); - $this->assertSame(Cache::read('App.doubleQuoteTest', 'file_test'), '"this is a quoted string"'); - Cache::write('App.singleQuoteTest', "'this is a quoted string'", 'file_test'); - $this->assertSame(Cache::read('App.singleQuoteTest', 'file_test'), "'this is a quoted string'"); - Cache::delete('App.singleQuoteTest', 'file_test'); - Cache::delete('App.doubleQuoteTest', 'file_test'); - } - -/** - * check that FileEngine generates an error when a configured Path does not exist. - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testErrorWhenPathDoesNotExist() { - $this->skipIf(is_dir(TMP . 'tests' . DS . 'file_failure'), 'Cannot run test directory exists.'); - - Cache::config('failure', array( - 'engine' => 'File', - 'path' => TMP . 'tests' . DS . 'file_failure' - )); - - Cache::drop('failure'); - } - -/** - * Testing the mask setting in FileEngine - * - * @return void - */ - public function testMaskSetting() { - if (DS === '\\') { - $this->markTestSkipped('File permission testing does not work on Windows.'); - } - Cache::config('mask_test', array('engine' => 'File', 'path' => TMP . 'tests')); - $data = 'This is some test content'; - $write = Cache::write('masking_test', $data, 'mask_test'); - $result = substr(sprintf('%o',fileperms(TMP . 'tests' . DS . 'cake_masking_test')), -4); - $expected = '0664'; - $this->assertEquals($expected, $result); - Cache::delete('masking_test', 'mask_test'); - Cache::drop('mask_test'); - - Cache::config('mask_test', array('engine' => 'File', 'mask' => 0666, 'path' => TMP . 'tests')); - $write = Cache::write('masking_test', $data, 'mask_test'); - $result = substr(sprintf('%o',fileperms(TMP . 'tests' . DS . 'cake_masking_test')), -4); - $expected = '0666'; - $this->assertEquals($expected, $result); - Cache::delete('masking_test', 'mask_test'); - Cache::drop('mask_test'); - - Cache::config('mask_test', array('engine' => 'File', 'mask' => 0644, 'path' => TMP . 'tests')); - $write = Cache::write('masking_test', $data, 'mask_test'); - $result = substr(sprintf('%o',fileperms(TMP . 'tests' . DS . 'cake_masking_test')), -4); - $expected = '0644'; - $this->assertEquals($expected, $result); - Cache::delete('masking_test', 'mask_test'); - Cache::drop('mask_test'); - - Cache::config('mask_test', array('engine' => 'File', 'mask' => 0640, 'path' => TMP . 'tests')); - $write = Cache::write('masking_test', $data, 'mask_test'); - $result = substr(sprintf('%o',fileperms(TMP . 'tests' . DS . 'cake_masking_test')), -4); - $expected = '0640'; - $this->assertEquals($expected, $result); - Cache::delete('masking_test', 'mask_test'); - Cache::drop('mask_test'); - } - -} diff --git a/lib/Cake/Test/Case/Cache/Engine/MemcacheEngineTest.php b/lib/Cake/Test/Case/Cache/Engine/MemcacheEngineTest.php deleted file mode 100644 index c0430bd0446..00000000000 --- a/lib/Cake/Test/Case/Cache/Engine/MemcacheEngineTest.php +++ /dev/null @@ -1,403 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Cache.Engine - * @since CakePHP(tm) v 1.2.0.5434 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Cache', 'Cache'); -App::uses('MemcacheEngine', 'Cache/Engine'); - -class TestMemcacheEngine extends MemcacheEngine { - -/** - * public accessor to _parseServerString - * - * @param string $server - * @return array - */ - public function parseServerString($server) { - return $this->_parseServerString($server); - } - - public function setMemcache($memcache) { - $this->_Memcache = $memcache; - } - -} - -/** - * MemcacheEngineTest class - * - * @package Cake.Test.Case.Cache.Engine - */ -class MemcacheEngineTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->skipIf(!class_exists('Memcache'), 'Memcache is not installed or configured properly.'); - - $this->_cacheDisable = Configure::read('Cache.disable'); - Configure::write('Cache.disable', false); - Cache::config('memcache', array( - 'engine' => 'Memcache', - 'prefix' => 'cake_', - 'duration' => 3600 - )); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - Configure::write('Cache.disable', $this->_cacheDisable); - Cache::drop('memcache'); - Cache::config('default'); - } - -/** - * testSettings method - * - * @return void - */ - public function testSettings() { - $settings = Cache::settings('memcache'); - unset($settings['serialize'], $settings['path']); - $expecting = array( - 'prefix' => 'cake_', - 'duration' => 3600, - 'probability' => 100, - 'servers' => array('127.0.0.1'), - 'persistent' => true, - 'compress' => false, - 'engine' => 'Memcache', - 'persistent' => true, - ); - $this->assertEquals($expecting, $settings); - } - -/** - * testSettings method - * - * @return void - */ - public function testMultipleServers() { - $servers = array('127.0.0.1:11211', '127.0.0.1:11222'); - $available = true; - $Memcache = new Memcache(); - - foreach ($servers as $server) { - list($host, $port) = explode(':', $server); - if (!@$Memcache->connect($host, $port)) { - $available = false; - } - } - - $this->skipIf(!$available, 'Need memcache servers at ' . implode(', ', $servers) . ' to run this test.'); - - $Memcache = new MemcacheEngine(); - $Memcache->init(array('engine' => 'Memcache', 'servers' => $servers)); - - $servers = array_keys($Memcache->__Memcache->getExtendedStats()); - $settings = $Memcache->settings(); - $this->assertEquals($settings['servers'], $servers); - Cache::drop('dual_server'); - } - -/** - * testConnect method - * - * @return void - */ - public function testConnect() { - $Memcache = new MemcacheEngine(); - $Memcache->init(Cache::settings('memcache')); - $result = $Memcache->connect('127.0.0.1'); - $this->assertTrue($result); - } - -/** - * test connecting to an ipv6 server. - * - * @return void - */ - public function testConnectIpv6() { - $Memcache = new MemcacheEngine(); - $result = $Memcache->init(array( - 'prefix' => 'cake_', - 'duration' => 200, - 'engine' => 'Memcache', - 'servers' => array( - '[::1]:11211' - ) - )); - $this->assertTrue($result); - } - -/** - * test non latin domains. - * - * @return void - */ - public function testParseServerStringNonLatin() { - $Memcache = new TestMemcacheEngine(); - $result = $Memcache->parseServerString('schülervz.net:13211'); - $this->assertEquals(array('schülervz.net', '13211'), $result); - - $result = $Memcache->parseServerString('sülül:1111'); - $this->assertEquals(array('sülül', '1111'), $result); - } - -/** - * test unix sockets. - * - * @return void - */ - public function testParseServerStringUnix() { - $Memcache = new TestMemcacheEngine(); - $result = $Memcache->parseServerString('unix:///path/to/memcached.sock'); - $this->assertEquals(array('unix:///path/to/memcached.sock', 0), $result); - } - -/** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() { - Cache::set(array('duration' => 1), null, 'memcache'); - - $result = Cache::read('test', 'memcache'); - $expecting = ''; - $this->assertEquals($expecting, $result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('test', $data, 'memcache'); - $this->assertTrue($result); - - $result = Cache::read('test', 'memcache'); - $expecting = $data; - $this->assertEquals($expecting, $result); - - Cache::delete('test', 'memcache'); - } - -/** - * testExpiry method - * - * @return void - */ - public function testExpiry() { - Cache::set(array('duration' => 1), 'memcache'); - - $result = Cache::read('test', 'memcache'); - $this->assertFalse($result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'memcache'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'memcache'); - $this->assertFalse($result); - - Cache::set(array('duration' => "+1 second"), 'memcache'); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'memcache'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'memcache'); - $this->assertFalse($result); - - Cache::config('memcache', array('duration' => '+1 second')); - sleep(2); - - $result = Cache::read('other_test', 'memcache'); - $this->assertFalse($result); - - Cache::config('memcache', array('duration' => '+29 days')); - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('long_expiry_test', $data, 'memcache'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('long_expiry_test', 'memcache'); - $expecting = $data; - $this->assertEquals($expecting, $result); - - Cache::config('memcache', array('duration' => 3600)); - } - -/** - * testDeleteCache method - * - * @return void - */ - public function testDeleteCache() { - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('delete_test', $data, 'memcache'); - $this->assertTrue($result); - - $result = Cache::delete('delete_test', 'memcache'); - $this->assertTrue($result); - } - -/** - * testDecrement method - * - * @return void - */ - public function testDecrement() { - $result = Cache::write('test_decrement', 5, 'memcache'); - $this->assertTrue($result); - - $result = Cache::decrement('test_decrement', 1, 'memcache'); - $this->assertEquals(4, $result); - - $result = Cache::read('test_decrement', 'memcache'); - $this->assertEquals(4, $result); - - $result = Cache::decrement('test_decrement', 2, 'memcache'); - $this->assertEquals(2, $result); - - $result = Cache::read('test_decrement', 'memcache'); - $this->assertEquals(2, $result); - } - -/** - * testIncrement method - * - * @return void - */ - public function testIncrement() { - $result = Cache::write('test_increment', 5, 'memcache'); - $this->assertTrue($result); - - $result = Cache::increment('test_increment', 1, 'memcache'); - $this->assertEquals(6, $result); - - $result = Cache::read('test_increment', 'memcache'); - $this->assertEquals(6, $result); - - $result = Cache::increment('test_increment', 2, 'memcache'); - $this->assertEquals(8, $result); - - $result = Cache::read('test_increment', 'memcache'); - $this->assertEquals(8, $result); - } - -/** - * test that configurations don't conflict, when a file engine is declared after a memcache one. - * - * @return void - */ - public function testConfigurationConflict() { - Cache::config('long_memcache', array( - 'engine' => 'Memcache', - 'duration' => '+2 seconds', - 'servers' => array('127.0.0.1:11211'), - )); - Cache::config('short_memcache', array( - 'engine' => 'Memcache', - 'duration' => '+1 seconds', - 'servers' => array('127.0.0.1:11211'), - )); - Cache::config('some_file', array('engine' => 'File')); - - $this->assertTrue(Cache::write('duration_test', 'yay', 'long_memcache')); - $this->assertTrue(Cache::write('short_duration_test', 'boo', 'short_memcache')); - - $this->assertEquals('yay', Cache::read('duration_test', 'long_memcache'), 'Value was not read %s'); - $this->assertEquals('boo', Cache::read('short_duration_test', 'short_memcache'), 'Value was not read %s'); - - sleep(1); - $this->assertEquals('yay', Cache::read('duration_test', 'long_memcache'), 'Value was not read %s'); - - sleep(2); - $this->assertFalse(Cache::read('short_duration_test', 'short_memcache'), 'Cache was not invalidated %s'); - $this->assertFalse(Cache::read('duration_test', 'long_memcache'), 'Value did not expire %s'); - - Cache::delete('duration_test', 'long_memcache'); - Cache::delete('short_duration_test', 'short_memcache'); - } - -/** - * test clearing memcache. - * - * @return void - */ - public function testClear() { - Cache::config('memcache2', array( - 'engine' => 'Memcache', - 'prefix' => 'cake2_', - 'duration' => 3600 - )); - - Cache::write('some_value', 'cache1', 'memcache'); - $result = Cache::clear(true, 'memcache'); - $this->assertTrue($result); - $this->assertEquals('cache1', Cache::read('some_value', 'memcache')); - - Cache::write('some_value', 'cache2', 'memcache2'); - $result = Cache::clear(false, 'memcache'); - $this->assertTrue($result); - $this->assertFalse(Cache::read('some_value', 'memcache')); - $this->assertEquals('cache2', Cache::read('some_value', 'memcache2')); - - Cache::clear(false, 'memcache2'); - } - -/** - * test that a 0 duration can successfully write. - * - * @return void - */ - public function testZeroDuration() { - Cache::config('memcache', array('duration' => 0)); - $result = Cache::write('test_key', 'written!', 'memcache'); - - $this->assertTrue('Could not write with duration 0', $result); - $result = Cache::read('test_key', 'memcache'); - $this->assertEquals('written!', $result); - } - -/** - * test that durations greater than 30 days never expire - * - * @return void - */ - public function testLongDurationEqualToZero() { - $memcache = new TestMemcacheEngine(); - $memcache->settings['compress'] = false; - - $mock = $this->getMock('Memcache'); - $memcache->setMemcache($mock); - $mock->expects($this->once()) - ->method('set') - ->with('key', 'value', false, 0); - - $value = 'value'; - $memcache->write('key', $value, 50 * DAY); - } - -} diff --git a/lib/Cake/Test/Case/Cache/Engine/WincacheEngineTest.php b/lib/Cake/Test/Case/Cache/Engine/WincacheEngineTest.php deleted file mode 100644 index c4da341fada..00000000000 --- a/lib/Cake/Test/Case/Cache/Engine/WincacheEngineTest.php +++ /dev/null @@ -1,191 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Cache.Engine - * @since CakePHP(tm) v 1.2.0.5434 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Cache', 'Cache'); - -/** - * WincacheEngineTest class - * - * @package Cake.Test.Case.Cache.Engine - */ -class WincacheEngineTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->skipIf(!function_exists('wincache_ucache_set'), 'Wincache is not installed or configured properly.'); - $this->_cacheDisable = Configure::read('Cache.disable'); - Configure::write('Cache.disable', false); - Cache::config('wincache', array('engine' => 'Wincache', 'prefix' => 'cake_')); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - Configure::write('Cache.disable', $this->_cacheDisable); - Cache::drop('wincache'); - Cache::config('default'); - } - -/** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() { - Cache::set(array('duration' => 1), 'wincache'); - - $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() { - Cache::set(array('duration' => 1), 'wincache'); - - $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); - - Cache::set(array('duration' => 1), 'wincache'); - - $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); - - 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')); - } -} diff --git a/lib/Cake/Test/Case/Cache/Engine/XcacheEngineTest.php b/lib/Cake/Test/Case/Cache/Engine/XcacheEngineTest.php deleted file mode 100644 index 9f6b091412a..00000000000 --- a/lib/Cake/Test/Case/Cache/Engine/XcacheEngineTest.php +++ /dev/null @@ -1,199 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Cache.Engine - * @since CakePHP(tm) v 1.2.0.5434 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Cache', 'Cache'); - -/** - * XcacheEngineTest class - * - * @package Cake.Test.Case.Cache.Engine - */ -class XcacheEngineTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - if (!function_exists('xcache_set')) { - $this->markTestSkipped('Xcache is not installed or configured properly'); - } - $this->_cacheDisable = Configure::read('Cache.disable'); - Configure::write('Cache.disable', false); - Cache::config('xcache', array('engine' => 'Xcache', 'prefix' => 'cake_')); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - Configure::write('Cache.disable', $this->_cacheDisable); - Cache::config('default'); - } - -/** - * testSettings method - * - * @return void - */ - public function testSettings() { - $settings = Cache::settings(); - $expecting = array( - 'prefix' => 'cake_', - 'duration' => 3600, - 'probability' => 100, - 'engine' => 'Xcache', - ); - $this->assertTrue(isset($settings['PHP_AUTH_USER'])); - $this->assertTrue(isset($settings['PHP_AUTH_PW'])); - - unset($settings['PHP_AUTH_USER'], $settings['PHP_AUTH_PW']); - $this->assertEquals($settings, $expecting); - } - -/** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() { - Cache::set(array('duration' => 1)); - - $result = Cache::read('test'); - $expecting = ''; - $this->assertEquals($expecting, $result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('test', $data); - $this->assertTrue($result); - - $result = Cache::read('test'); - $expecting = $data; - $this->assertEquals($expecting, $result); - - Cache::delete('test'); - } - -/** - * testExpiry method - * - * @return void - */ - public function testExpiry() { - Cache::set(array('duration' => 1)); - $result = Cache::read('test'); - $this->assertFalse($result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test'); - $this->assertFalse($result); - - Cache::set(array('duration' => "+1 second")); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test'); - $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); - $this->assertTrue($result); - - $result = Cache::delete('delete_test'); - $this->assertTrue($result); - } - -/** - * testClearCache method - * - * @return void - */ - public function testClearCache() { - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('clear_test_1', $data); - $this->assertTrue($result); - - $result = Cache::write('clear_test_2', $data); - $this->assertTrue($result); - - $result = Cache::clear(); - $this->assertTrue($result); - } - -/** - * testDecrement method - * - * @return void - */ - public function testDecrement() { - $result = Cache::write('test_decrement', 5); - $this->assertTrue($result); - - $result = Cache::decrement('test_decrement'); - $this->assertEquals(4, $result); - - $result = Cache::read('test_decrement'); - $this->assertEquals(4, $result); - - $result = Cache::decrement('test_decrement', 2); - $this->assertEquals(2, $result); - - $result = Cache::read('test_decrement'); - $this->assertEquals(2, $result); - } - -/** - * testIncrement method - * - * @return void - */ - public function testIncrement() { - $result = Cache::write('test_increment', 5); - $this->assertTrue($result); - - $result = Cache::increment('test_increment'); - $this->assertEquals(6, $result); - - $result = Cache::read('test_increment'); - $this->assertEquals(6, $result); - - $result = Cache::increment('test_increment', 2); - $this->assertEquals(8, $result); - - $result = Cache::read('test_increment'); - $this->assertEquals(8, $result); - } -} diff --git a/lib/Cake/Test/Case/Configure/IniReaderTest.php b/lib/Cake/Test/Case/Configure/IniReaderTest.php deleted file mode 100644 index 43ad3ce7244..00000000000 --- a/lib/Cake/Test/Case/Configure/IniReaderTest.php +++ /dev/null @@ -1,128 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Configure - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('IniReader', 'Configure'); - -class IniReaderTest extends CakeTestCase { - -/** - * The test file that will be read. - * - * @var string - */ - public $file; - -/** - * setup - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->path = CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS; - } - -/** - * test construct - * - * @return void - */ - public function testConstruct() { - $reader = new IniReader($this->path); - $config = $reader->read('acl.ini.php'); - - $this->assertTrue(isset($config['admin'])); - $this->assertTrue(isset($config['paul']['groups'])); - $this->assertEquals('ads', $config['admin']['deny']); - } - -/** - * no other sections should exist. - * - * @return void - */ - public function testReadingOnlyOneSection() { - $reader = new IniReader($this->path, 'admin'); - $config = $reader->read('acl.ini.php'); - - $this->assertTrue(isset($config['groups'])); - $this->assertEquals('administrators', $config['groups']); - } - -/** - * test without section - * - * @return void - */ - public function testReadingWithoutSection() { - $reader = new IniReader($this->path); - $config = $reader->read('no_section.ini'); - - $expected = array( - 'some_key' => 'some_value', - 'bool_key' => true - ); - $this->assertEquals($expected, $config); - } - -/** - * test that names with .'s get exploded into arrays. - * - * @return void - */ - public function testReadingValuesWithDots() { - $reader = new IniReader($this->path); - $config = $reader->read('nested.ini'); - - $this->assertTrue(isset($config['database']['db']['username'])); - $this->assertEquals('mark', $config['database']['db']['username']); - $this->assertEquals(3, $config['nesting']['one']['two']['three']); - } - -/** - * test boolean reading - * - * @return void - */ - public function testBooleanReading() { - $reader = new IniReader($this->path); - $config = $reader->read('nested.ini'); - - $this->assertTrue($config['bools']['test_on']); - $this->assertFalse($config['bools']['test_off']); - - $this->assertTrue($config['bools']['test_yes']); - $this->assertFalse($config['bools']['test_no']); - - $this->assertTrue($config['bools']['test_true']); - $this->assertFalse($config['bools']['test_false']); - - $this->assertFalse($config['bools']['test_null']); - } - -/** - * test read file without extension - * - * @return void - */ - public function testReadingWithoutExtension() { - $reader = new IniReader($this->path); - $config = $reader->read('nested'); - $this->assertTrue($config['bools']['test_on']); - } -} diff --git a/lib/Cake/Test/Case/Configure/PhpReaderTest.php b/lib/Cake/Test/Case/Configure/PhpReaderTest.php deleted file mode 100644 index ea34d8b3e38..00000000000 --- a/lib/Cake/Test/Case/Configure/PhpReaderTest.php +++ /dev/null @@ -1,99 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Configure - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('PhpReader', 'Configure'); - -class PhpReaderTest extends CakeTestCase { - -/** - * setup - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->path = CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS; - } - -/** - * test reading files - * - * @return void - */ - public function testRead() { - $reader = new PhpReader($this->path); - $values = $reader->read('var_test'); - $this->assertEquals('value', $values['Read']); - $this->assertEquals('buried', $values['Deep']['Deeper']['Deepest']); - - $values = $reader->read('var_test.php'); - $this->assertEquals('value', $values['Read']); - } - -/** - * Test an exception is thrown by reading files that don't exist. - * - * @expectedException ConfigureException - * @return void - */ - public function testReadWithNonExistantFile() { - $reader = new PhpReader($this->path); - $reader->read('fake_values'); - } - -/** - * test reading an empty file. - * - * @expectedException RuntimeException - * @return void - */ - public function testReadEmptyFile() { - $reader = new PhpReader($this->path); - $reader->read('empty'); - } - -/** - * test reading keys with ../ doesn't work - * - * @expectedException ConfigureException - * @return void - */ - public function testReadWithDots() { - $reader = new PhpReader($this->path); - $reader->read('../empty'); - } - -/** - * test reading from plugins - * - * @return void - */ - public function testReadPluginValue() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - $reader = new PhpReader($this->path); - $result = $reader->read('TestPlugin.load'); - $this->assertTrue(isset($result['plugin_load'])); - - $result = $reader->read('TestPlugin.load.php'); - $this->assertTrue(isset($result['plugin_load'])); - CakePlugin::unload(); - } -} diff --git a/lib/Cake/Test/Case/Console/AllConsoleLibsTest.php b/lib/Cake/Test/Case/Console/AllConsoleLibsTest.php deleted file mode 100644 index 4d13c5df1e0..00000000000 --- a/lib/Cake/Test/Case/Console/AllConsoleLibsTest.php +++ /dev/null @@ -1,45 +0,0 @@ -isFile() || strpos($file, 'All') === 0) { - continue; - } - $suite->addTestFile($file->getRealPath()); - } - return $suite; - } -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/Console/AllConsoleTest.php b/lib/Cake/Test/Case/Console/AllConsoleTest.php deleted file mode 100644 index e2844798d3b..00000000000 --- a/lib/Cake/Test/Case/Console/AllConsoleTest.php +++ /dev/null @@ -1,44 +0,0 @@ -addTestFile($path . 'AllConsoleLibsTest.php'); - $suite->addTestFile($path . 'AllTasksTest.php'); - $suite->addTestFile($path . 'AllShellsTest.php'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/Console/AllShellsTest.php b/lib/Cake/Test/Case/Console/AllShellsTest.php deleted file mode 100644 index 8842d61f5f9..00000000000 --- a/lib/Cake/Test/Case/Console/AllShellsTest.php +++ /dev/null @@ -1,42 +0,0 @@ -addTestDirectory($path); - return $suite; - } -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/Console/AllTasksTest.php b/lib/Cake/Test/Case/Console/AllTasksTest.php deleted file mode 100644 index dcdce737b58..00000000000 --- a/lib/Cake/Test/Case/Console/AllTasksTest.php +++ /dev/null @@ -1,42 +0,0 @@ -addTestDirectory($path); - return $suite; - } -} - diff --git a/lib/Cake/Test/Case/Console/Command/AclShellTest.php b/lib/Cake/Test/Case/Console/Command/AclShellTest.php deleted file mode 100644 index 44361d86a24..00000000000 --- a/lib/Cake/Test/Case/Console/Command/AclShellTest.php +++ /dev/null @@ -1,309 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock( - 'AclShell', - array('in', 'out', 'hr', 'createFile', 'error', 'err', 'clear', 'dispatchShell'), - array($out, $out, $in) - ); - $collection = new ComponentCollection(); - $this->Task->Acl = new AclComponent($collection); - $this->Task->params['datasource'] = 'test'; - } - -/** - * test that model.foreign_key output works when looking at acl rows - * - * @return void - */ - public function testViewWithModelForeignKeyOutput() { - $this->Task->command = 'view'; - $this->Task->startup(); - $data = array( - 'parent_id' => null, - 'model' => 'MyModel', - 'foreign_key' => 2, - ); - $this->Task->Acl->Aro->create($data); - $this->Task->Acl->Aro->save(); - $this->Task->args[0] = 'aro'; - - $this->Task->expects($this->at(0))->method('out')->with('Aro tree:'); - $this->Task->expects($this->at(2))->method('out') - ->with($this->stringContains('[1] ROOT')); - - $this->Task->expects($this->at(4))->method('out') - ->with($this->stringContains('[3] Gandalf')); - - $this->Task->expects($this->at(6))->method('out') - ->with($this->stringContains('[5] MyModel.2')); - - $this->Task->view(); - } - -/** - * test view with an argument - * - * @return void - */ - public function testViewWithArgument() { - $this->Task->args = array('aro', 'admins'); - - $this->Task->expects($this->at(0))->method('out')->with('Aro tree:'); - $this->Task->expects($this->at(2))->method('out')->with(' [2] admins'); - $this->Task->expects($this->at(3))->method('out')->with(' [3] Gandalf'); - $this->Task->expects($this->at(4))->method('out')->with(' [4] Elrond'); - - $this->Task->view(); - } - -/** - * test the method that splits model.foreign key. and that it returns an array. - * - * @return void - */ - public function testParsingModelAndForeignKey() { - $result = $this->Task->parseIdentifier('Model.foreignKey'); - $expected = array('model' => 'Model', 'foreign_key' => 'foreignKey'); - - $result = $this->Task->parseIdentifier('mySuperUser'); - $this->assertEquals('mySuperUser', $result); - - $result = $this->Task->parseIdentifier('111234'); - $this->assertEquals('111234', $result); - } - -/** - * test creating aro/aco nodes - * - * @return void - */ - public function testCreate() { - $this->Task->args = array('aro', 'root', 'User.1'); - $this->Task->expects($this->at(0))->method('out')->with("New Aro 'User.1' created.", 2); - $this->Task->expects($this->at(1))->method('out')->with("New Aro 'User.3' created.", 2); - $this->Task->expects($this->at(2))->method('out')->with("New Aro 'somealias' created.", 2); - - $this->Task->create(); - - $Aro = ClassRegistry::init('Aro'); - $Aro->cacheQueries = false; - $result = $Aro->read(); - $this->assertEquals('User', $result['Aro']['model']); - $this->assertEquals(1, $result['Aro']['foreign_key']); - $this->assertEquals(null, $result['Aro']['parent_id']); - $id = $result['Aro']['id']; - - $this->Task->args = array('aro', 'User.1', 'User.3'); - $this->Task->create(); - - $Aro = ClassRegistry::init('Aro'); - $result = $Aro->read(); - $this->assertEquals('User', $result['Aro']['model']); - $this->assertEquals(3, $result['Aro']['foreign_key']); - $this->assertEquals($id, $result['Aro']['parent_id']); - - $this->Task->args = array('aro', 'root', 'somealias'); - $this->Task->create(); - - $Aro = ClassRegistry::init('Aro'); - $result = $Aro->read(); - $this->assertEquals('somealias', $result['Aro']['alias']); - $this->assertEquals(null, $result['Aro']['model']); - $this->assertEquals(null, $result['Aro']['foreign_key']); - $this->assertEquals(null, $result['Aro']['parent_id']); - } - -/** - * test the delete method with different node types. - * - * @return void - */ - public function testDelete() { - $this->Task->args = array('aro', 'AuthUser.1'); - $this->Task->expects($this->at(0))->method('out') - ->with("Aro deleted.", 2); - $this->Task->delete(); - - $Aro = ClassRegistry::init('Aro'); - $result = $Aro->findById(3); - $this->assertFalse($result); - } - -/** - * test setParent method. - * - * @return void - */ - public function testSetParent() { - $this->Task->args = array('aro', 'AuthUser.2', 'root'); - $this->Task->setParent(); - - $Aro = ClassRegistry::init('Aro'); - $result = $Aro->read(null, 4); - $this->assertEquals(null, $result['Aro']['parent_id']); - } - -/** - * test grant - * - * @return void - */ - public function testGrant() { - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', 'create'); - $this->Task->expects($this->at(0))->method('out') - ->with($this->matchesRegularExpression('/granted/'), true); - $this->Task->grant(); - $node = $this->Task->Acl->Aro->node(array('model' => 'AuthUser', 'foreign_key' => 2)); - $node = $this->Task->Acl->Aro->read(null, $node[0]['Aro']['id']); - - $this->assertFalse(empty($node['Aco'][0])); - $this->assertEquals(1, $node['Aco'][0]['Permission']['_create']); - } - -/** - * test deny - * - * @return void - */ - public function testDeny() { - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', 'create'); - $this->Task->expects($this->at(0))->method('out') - ->with($this->stringContains('Permission denied'), true); - - $this->Task->deny(); - - $node = $this->Task->Acl->Aro->node(array('model' => 'AuthUser', 'foreign_key' => 2)); - $node = $this->Task->Acl->Aro->read(null, $node[0]['Aro']['id']); - $this->assertFalse(empty($node['Aco'][0])); - $this->assertEquals(-1, $node['Aco'][0]['Permission']['_create']); - } - -/** - * test checking allowed and denied perms - * - * @return void - */ - public function testCheck() { - $this->Task->expects($this->at(0))->method('out') - ->with($this->matchesRegularExpression('/not allowed/'), true); - $this->Task->expects($this->at(1))->method('out') - ->with($this->matchesRegularExpression('/granted/'), true); - $this->Task->expects($this->at(2))->method('out') - ->with($this->matchesRegularExpression('/is.*allowed/'), true); - $this->Task->expects($this->at(3))->method('out') - ->with($this->matchesRegularExpression('/not.*allowed/'), true); - - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', '*'); - $this->Task->check(); - - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', 'create'); - $this->Task->grant(); - - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', 'create'); - $this->Task->check(); - - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', '*'); - $this->Task->check(); - } - -/** - * test inherit and that it 0's the permission fields. - * - * @return void - */ - public function testInherit() { - $this->Task->expects($this->at(0))->method('out') - ->with($this->matchesRegularExpression('/Permission .*granted/'), true); - $this->Task->expects($this->at(1))->method('out') - ->with($this->matchesRegularExpression('/Permission .*inherited/'), true); - - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', 'create'); - $this->Task->grant(); - - $this->Task->args = array('AuthUser.2', 'ROOT/Controller1', 'all'); - $this->Task->inherit(); - - $node = $this->Task->Acl->Aro->node(array('model' => 'AuthUser', 'foreign_key' => 2)); - $node = $this->Task->Acl->Aro->read(null, $node[0]['Aro']['id']); - $this->assertFalse(empty($node['Aco'][0])); - $this->assertEquals(0, $node['Aco'][0]['Permission']['_create']); - } - -/** - * test getting the path for an aro/aco - * - * @return void - */ - public function testGetPath() { - $this->Task->args = array('aro', 'AuthUser.2'); - $node = $this->Task->Acl->Aro->node(array('model' => 'AuthUser', 'foreign_key' => 2)); - $first = $node[0]['Aro']['id']; - $second = $node[1]['Aro']['id']; - $last = $node[2]['Aro']['id']; - $this->Task->expects($this->at(2))->method('out')->with('[' . $last . '] ROOT'); - $this->Task->expects($this->at(3))->method('out')->with(' [' . $second . '] admins'); - $this->Task->expects($this->at(4))->method('out')->with(' [' . $first . '] Elrond'); - $this->Task->getPath(); - } - -/** - * test that initdb makes the correct call. - * - * @return void - */ - public function testInitDb() { - $this->Task->expects($this->once())->method('dispatchShell') - ->with('schema create DbAcl'); - - $this->Task->initdb(); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/ApiShellTest.php b/lib/Cake/Test/Case/Console/Command/ApiShellTest.php deleted file mode 100644 index 3908a2a2c12..00000000000 --- a/lib/Cake/Test/Case/Console/Command/ApiShellTest.php +++ /dev/null @@ -1,94 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Shell = $this->getMock( - 'ApiShell', - array('in', 'out', 'createFile', 'hr', '_stop'), - array( $out, $out, $in) - ); - } - -/** - * Test that method names are detected properly including those with no arguments. - * - * @return void - */ - public function testMethodNameDetection() { - $this->Shell->expects($this->any())->method('in')->will($this->returnValue('q')); - $this->Shell->expects($this->at(0))->method('out')->with('Controller'); - - $expected = array( - '1. afterFilter()', - '2. afterScaffoldSave($method)', - '3. afterScaffoldSaveError($method)', - '4. beforeFilter()', - '5. beforeRedirect($url, $status = NULL, $exit = true)', - '6. beforeRender()', - '7. beforeScaffold($method)', - '8. constructClasses()', - '9. disableCache()', - '10. flash($message, $url, $pause = 1, $layout = \'flash\')', - '11. getEventManager()', - '12. header($status)', - '13. httpCodes($code = NULL)', - '14. implementedEvents()', - '15. invokeAction($request)', - '16. loadModel($modelClass = NULL, $id = NULL)', - '17. paginate($object = NULL, $scope = array (), $whitelist = array ())', - '18. postConditions($data = array (), $op = NULL, $bool = \'AND\', $exclusive = false)', - '19. redirect($url, $status = NULL, $exit = true)', - '20. referer($default = NULL, $local = false)', - '21. render($view = NULL, $layout = NULL)', - '22. scaffoldError($method)', - '23. set($one, $two = NULL)', - '24. setAction($action)', - '25. setRequest($request)', - '26. shutdownProcess()', - '27. startupProcess()', - '28. validate()', - '29. validateErrors()' - ); - $this->Shell->expects($this->at(2))->method('out')->with($expected); - - $this->Shell->args = array('controller'); - $this->Shell->paths['controller'] = CAKE . 'Controller' . DS; - $this->Shell->main(); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/BakeShellTest.php b/lib/Cake/Test/Case/Console/Command/BakeShellTest.php deleted file mode 100644 index fe52adb02e8..00000000000 --- a/lib/Cake/Test/Case/Console/Command/BakeShellTest.php +++ /dev/null @@ -1,122 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Shell = $this->getMock( - 'BakeShell', - array('in', 'out', 'hr', 'err', 'createFile', '_stop', '_checkUnitTest'), - array($out, $out, $in) - ); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Dispatch, $this->Shell); - } - -/** - * test bake all - * - * @return void - */ - public function testAllWithModelName() { - App::uses('User', 'Model'); - $userExists = class_exists('User'); - $this->skipIf($userExists, 'User class exists, cannot test `bake all [param]`.'); - - $this->Shell->Model = $this->getMock('ModelTask', array(), array(&$this->Dispatcher)); - $this->Shell->Controller = $this->getMock('ControllerTask', array(), array(&$this->Dispatcher)); - $this->Shell->View = $this->getMock('ModelTask', array(), array(&$this->Dispatcher)); - $this->Shell->DbConfig = $this->getMock('DbConfigTask', array(), array(&$this->Dispatcher)); - - $this->Shell->DbConfig->expects($this->once()) - ->method('getConfig') - ->will($this->returnValue('test')); - - $this->Shell->Model->expects($this->never()) - ->method('getName'); - - $this->Shell->Model->expects($this->once()) - ->method('bake') - ->will($this->returnValue(true)); - - $this->Shell->Controller->expects($this->once()) - ->method('bake') - ->will($this->returnValue(true)); - - $this->Shell->View->expects($this->once()) - ->method('execute'); - - $this->Shell->expects($this->once())->method('_stop'); - $this->Shell->expects($this->at(0)) - ->method('out') - ->with('Bake All'); - - $this->Shell->expects($this->at(5)) - ->method('out') - ->with('Bake All complete'); - - $this->Shell->connection = ''; - $this->Shell->params = array(); - $this->Shell->args = array('User'); - $this->Shell->all(); - - $this->assertEquals('User', $this->Shell->View->args[0]); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/CommandListShellTest.php b/lib/Cake/Test/Case/Console/Command/CommandListShellTest.php deleted file mode 100644 index f67712b840a..00000000000 --- a/lib/Cake/Test/Case/Console/Command/CommandListShellTest.php +++ /dev/null @@ -1,160 +0,0 @@ -output .= $message; - } - -} - -class CommandListShellTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - App::build(array( - 'Plugin' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS - ), - 'Console/Command' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Console' . DS . 'Command' . DS - ) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - $out = new TestStringOutput(); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Shell = $this->getMock( - 'CommandListShell', - array('in', '_stop', 'clear'), - array($out, $out, $in) - ); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Shell); - CakePlugin::unload(); - } - -/** - * test that main finds core shells. - * - * @return void - */ - public function testMain() { - $this->Shell->main(); - $output = $this->Shell->stdout->output; - - $expected = "/example \[.*TestPlugin, TestPluginTwo.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/welcome \[.*TestPluginTwo.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/acl \[.*CORE.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/api \[.*CORE.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/bake \[.*CORE.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/console \[.*CORE.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/i18n \[.*CORE.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/schema \[.*CORE.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/testsuite \[.*CORE.*\]/"; - $this->assertRegExp($expected, $output); - - $expected = "/sample \[.*app.*\]/"; - $this->assertRegExp($expected, $output); - } - -/** - * Test the sort param - * - * @return void - */ - public function testSortPlugin() { - $this->Shell->params['sort'] = true; - $this->Shell->main(); - - $output = $this->Shell->stdout->output; - - $expected = "/\[.*App.*\]\\v*[ ]+sample/"; - $this->assertRegExp($expected, $output); - - $expected = "/\[.*TestPluginTwo.*\]\\v*[ ]+example, welcome/"; - $this->assertRegExp($expected, $output); - - $expected = "/\[.*TestPlugin.*\]\\v*[ ]+example/"; - $this->assertRegExp($expected, $output); - - $expected = "/\[.*Core.*\]\\v*[ ]+acl, api, bake, command_list, console, i18n, schema, test, testsuite/"; - $this->assertRegExp($expected, $output); - } - -/** - * test xml output. - * - * @return void - */ - public function testMainXml() { - $this->Shell->params['xml'] = true; - $this->Shell->main(); - - $output = $this->Shell->stdout->output; - - $find = ''; - $this->assertContains($find, $output); - - $find = ''; - $this->assertContains($find, $output); - - $find = ''; - $this->assertContains($find, $output); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/SchemaShellTest.php b/lib/Cake/Test/Case/Console/Command/SchemaShellTest.php deleted file mode 100644 index 6abd0cbd7cd..00000000000 --- a/lib/Cake/Test/Case/Console/Command/SchemaShellTest.php +++ /dev/null @@ -1,492 +0,0 @@ - array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'post_id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'user_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => false, 'length' => 100), - 'comment' => array('type' => 'text', 'null' => false, 'default' => null), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'N', 'length' => 1), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - ); - -/** - * posts property - * - * @var array - */ - public $articles = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'user_id' => array('type' => 'integer', 'null' => true, 'default' => ''), - 'title' => array('type' => 'string', 'null' => false, 'default' => 'Title'), - 'body' => array('type' => 'text', 'null' => true, 'default' => null), - 'summary' => array('type' => 'text', 'null' => true), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'Y', 'length' => 1), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - ); -} - -/** - * SchemaShellTest class - * - * @package Cake.Test.Case.Console.Command - */ -class SchemaShellTest extends CakeTestCase { - -/** - * Fixtures - * - * @var array - */ - public $fixtures = array('core.article', 'core.user', 'core.post', 'core.auth_user', 'core.author', - 'core.comment', 'core.test_plugin_comment' - ); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Shell = $this->getMock( - 'SchemaShell', - array('in', 'out', 'hr', 'createFile', 'error', 'err', '_stop'), - array($out, $out, $in) - ); - } - -/** - * endTest method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - if (!empty($this->file) && $this->file instanceof File) { - $this->file->delete(); - unset($this->file); - } - } - -/** - * test startup method - * - * @return void - */ - public function testStartup() { - $this->Shell->startup(); - $this->assertTrue(isset($this->Shell->Schema)); - $this->assertTrue(is_a($this->Shell->Schema, 'CakeSchema')); - $this->assertEquals(strtolower(APP_DIR), strtolower($this->Shell->Schema->name)); - $this->assertEquals('schema.php', $this->Shell->Schema->file); - - $this->Shell->Schema = null; - $this->Shell->params = array( - 'name' => 'TestSchema' - ); - $this->Shell->startup(); - $this->assertEquals('TestSchema', $this->Shell->Schema->name); - $this->assertEquals('test_schema.php', $this->Shell->Schema->file); - $this->assertEquals('default', $this->Shell->Schema->connection); - $this->assertEquals(APP . 'Config' . DS . 'Schema', $this->Shell->Schema->path); - - $this->Shell->Schema = null; - $this->Shell->params = array( - 'file' => 'other_file.php', - 'connection' => 'test', - 'path' => '/test/path' - ); - $this->Shell->startup(); - $this->assertEquals(strtolower(APP_DIR), strtolower($this->Shell->Schema->name)); - $this->assertEquals('other_file.php', $this->Shell->Schema->file); - $this->assertEquals('test', $this->Shell->Schema->connection); - $this->assertEquals('/test/path', $this->Shell->Schema->path); - } - -/** - * Test View - and that it dumps the schema file to stdout - * - * @return void - */ - public function testView() { - $this->Shell->startup(); - $this->Shell->Schema->path = APP . 'Config' . DS . 'Schema'; - $this->Shell->params['file'] = 'i18n.php'; - $this->Shell->expects($this->once())->method('_stop'); - $this->Shell->expects($this->once())->method('out'); - $this->Shell->view(); - } - -/** - * test that view() can find plugin schema files. - * - * @return void - */ - public function testViewWithPlugins() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - $this->Shell->args = array('TestPlugin.schema'); - $this->Shell->startup(); - $this->Shell->expects($this->exactly(2))->method('_stop'); - $this->Shell->expects($this->atLeastOnce())->method('out'); - $this->Shell->view(); - - $this->Shell->args = array(); - $this->Shell->params = array('plugin' => 'TestPlugin'); - $this->Shell->startup(); - $this->Shell->view(); - - App::build(); - CakePlugin::unload(); - } - -/** - * test dump() with sql file generation - * - * @return void - */ - public function testDumpWithFileWriting() { - $this->Shell->params = array( - 'name' => 'i18n', - 'connection' => 'test', - 'write' => TMP . 'tests' . DS . 'i18n.sql' - ); - $this->Shell->expects($this->once())->method('_stop'); - $this->Shell->startup(); - $this->Shell->dump(); - - $this->file = new File(TMP . 'tests' . DS . 'i18n.sql'); - $contents = $this->file->read(); - $this->assertRegExp('/DROP TABLE/', $contents); - $this->assertRegExp('/CREATE TABLE.*?i18n/', $contents); - $this->assertRegExp('/id/', $contents); - $this->assertRegExp('/model/', $contents); - $this->assertRegExp('/field/', $contents); - $this->assertRegExp('/locale/', $contents); - $this->assertRegExp('/foreign_key/', $contents); - $this->assertRegExp('/content/', $contents); - } - -/** - * test that dump() can find and work with plugin schema files. - * - * @return void - */ - public function testDumpFileWritingWithPlugins() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - $this->Shell->args = array('TestPlugin.TestPluginApp'); - $this->Shell->params = array( - 'connection' => 'test', - 'write' => TMP . 'tests' . DS . 'dump_test.sql' - ); - $this->Shell->startup(); - $this->Shell->expects($this->once())->method('_stop'); - $this->Shell->dump(); - - $this->file = new File(TMP . 'tests' . DS . 'dump_test.sql'); - $contents = $this->file->read(); - - $this->assertRegExp('/CREATE TABLE.*?test_plugin_acos/', $contents); - $this->assertRegExp('/id/', $contents); - $this->assertRegExp('/model/', $contents); - - $this->file->delete(); - App::build(); - CakePlugin::unload(); - } - -/** - * test generate with snapshot generation - * - * @return void - */ - public function testGenerateSnapshot() { - $this->Shell->path = TMP; - $this->Shell->params['file'] = 'schema.php'; - $this->Shell->params['force'] = false; - $this->Shell->args = array('snapshot'); - $this->Shell->Schema = $this->getMock('CakeSchema'); - $this->Shell->Schema->expects($this->at(0))->method('read')->will($this->returnValue(array('schema data'))); - $this->Shell->Schema->expects($this->at(0))->method('write')->will($this->returnValue(true)); - - $this->Shell->Schema->expects($this->at(1))->method('read'); - $this->Shell->Schema->expects($this->at(1))->method('write')->with(array('schema data', 'file' => 'schema_0.php')); - - $this->Shell->generate(); - } - -/** - * test generate without a snapshot. - * - * @return void - */ - public function testGenerateNoOverwrite() { - touch(TMP . 'schema.php'); - $this->Shell->params['file'] = 'schema.php'; - $this->Shell->params['force'] = false; - $this->Shell->args = array(); - - $this->Shell->expects($this->once())->method('in')->will($this->returnValue('q')); - $this->Shell->Schema = $this->getMock('CakeSchema'); - $this->Shell->Schema->path = TMP; - $this->Shell->Schema->expects($this->never())->method('read'); - - $result = $this->Shell->generate(); - unlink(TMP . 'schema.php'); - } - -/** - * test generate with overwriting of the schema files. - * - * @return void - */ - public function testGenerateOverwrite() { - touch(TMP . 'schema.php'); - $this->Shell->params['file'] = 'schema.php'; - $this->Shell->params['force'] = false; - $this->Shell->args = array(); - - $this->Shell->expects($this->once())->method('in')->will($this->returnValue('o')); - - $this->Shell->expects($this->at(2))->method('out') - ->with(new PHPUnit_Framework_Constraint_PCREMatch('/Schema file:\s[a-z\.]+\sgenerated/')); - - $this->Shell->Schema = $this->getMock('CakeSchema'); - $this->Shell->Schema->path = TMP; - $this->Shell->Schema->expects($this->once())->method('read')->will($this->returnValue(array('schema data'))); - $this->Shell->Schema->expects($this->once())->method('write')->will($this->returnValue(true)); - - $this->Shell->Schema->expects($this->once())->method('read'); - $this->Shell->Schema->expects($this->once())->method('write') - ->with(array('schema data', 'file' => 'schema.php')); - - $this->Shell->generate(); - unlink(TMP . 'schema.php'); - } - -/** - * test that generate() can read plugin dirs and generate schema files for the models - * in a plugin. - * - * @return void - */ - public function testGenerateWithPlugins() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - - $this->db->cacheSources = false; - $this->Shell->params = array( - 'plugin' => 'TestPlugin', - 'connection' => 'test', - 'force' => false - ); - $this->Shell->startup(); - $this->Shell->Schema->path = TMP . 'tests' . DS; - - $this->Shell->generate(); - $this->file = new File(TMP . 'tests' . DS . 'schema.php'); - $contents = $this->file->read(); - - $this->assertRegExp('/class TestPluginSchema/', $contents); - $this->assertRegExp('/public \$posts/', $contents); - $this->assertRegExp('/public \$auth_users/', $contents); - $this->assertRegExp('/public \$authors/', $contents); - $this->assertRegExp('/public \$test_plugin_comments/', $contents); - $this->assertNotRegExp('/public \$users/', $contents); - $this->assertNotRegExp('/public \$articles/', $contents); - CakePlugin::unload(); - } - -/** - * Test schema run create with no table args. - * - * @return void - */ - public function testCreateNoArgs() { - $this->Shell->params = array( - 'connection' => 'test' - ); - $this->Shell->args = array('i18n'); - $this->Shell->startup(); - $this->Shell->expects($this->any())->method('in')->will($this->returnValue('y')); - $this->Shell->create(); - - $db = ConnectionManager::getDataSource('test'); - - $db->cacheSources = false; - $sources = $db->listSources(); - $this->assertTrue(in_array($db->config['prefix'] . 'i18n', $sources)); - - $schema = new i18nSchema(); - $db->execute($db->dropSchema($schema)); - } - -/** - * Test schema run create with no table args. - * - * @return void - */ - public function testCreateWithTableArgs() { - $db = ConnectionManager::getDataSource('test'); - $sources = $db->listSources(); - if (in_array('acos', $sources)) { - $this->markTestSkipped('acos table already exists, cannot try to create it again.'); - } - $this->Shell->params = array( - 'connection' => 'test', - 'name' => 'DbAcl', - 'path' => APP . 'Config' . DS . 'Schema' - ); - $this->Shell->args = array('DbAcl', 'acos'); - $this->Shell->startup(); - $this->Shell->expects($this->any())->method('in')->will($this->returnValue('y')); - $this->Shell->create(); - - $db = ConnectionManager::getDataSource('test'); - $db->cacheSources = false; - $sources = $db->listSources(); - $this->assertTrue(in_array($db->config['prefix'] . 'acos', $sources), 'acos should be present.'); - $this->assertFalse(in_array($db->config['prefix'] . 'aros', $sources), 'aros should not be found.'); - $this->assertFalse(in_array('aros_acos', $sources), 'aros_acos should not be found.'); - - $schema = new DbAclSchema(); - $db->execute($db->dropSchema($schema, 'acos')); - } - -/** - * test run update with a table arg. - * - * @return void - */ - public function testUpdateWithTable() { - $this->Shell = $this->getMock( - 'SchemaShell', - array('in', 'out', 'hr', 'createFile', 'error', 'err', '_stop', '_run'), - array(&$this->Dispatcher) - ); - - $this->Shell->params = array( - 'connection' => 'test', - 'force' => true - ); - $this->Shell->args = array('SchemaShellTest', 'articles'); - $this->Shell->startup(); - $this->Shell->expects($this->any())->method('in')->will($this->returnValue('y')); - $this->Shell->expects($this->once())->method('_run') - ->with($this->arrayHasKey('articles'), 'update', $this->isInstanceOf('CakeSchema')); - - $this->Shell->update(); - } - -/** - * test that the plugin param creates the correct path in the schema object. - * - * @return void - */ - public function testPluginParam() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - $this->Shell->params = array( - 'plugin' => 'TestPlugin', - 'connection' => 'test' - ); - $this->Shell->startup(); - $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS . 'Config' . DS . 'Schema'; - $this->assertEquals($expected, $this->Shell->Schema->path); - CakePlugin::unload(); - } - -/** - * test that using Plugin.name with write. - * - * @return void - */ - public function testPluginDotSyntaxWithCreate() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - $this->Shell->params = array( - 'connection' => 'test' - ); - $this->Shell->args = array('TestPlugin.TestPluginApp'); - $this->Shell->startup(); - $this->Shell->expects($this->any())->method('in')->will($this->returnValue('y')); - $this->Shell->create(); - - $db = ConnectionManager::getDataSource('test'); - $sources = $db->listSources(); - $this->assertTrue(in_array($db->config['prefix'] . 'test_plugin_acos', $sources)); - - $schema = new TestPluginAppSchema(); - $db->execute($db->dropSchema($schema, 'test_plugin_acos')); - CakePlugin::unload(); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/ControllerTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/ControllerTaskTest.php deleted file mode 100644 index 6c4b4b1e73d..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/ControllerTaskTest.php +++ /dev/null @@ -1,650 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ControllerTask', - array('in', 'out', 'err', 'hr', 'createFile', '_stop', '_checkUnitTest'), - array($out, $out, $in) - ); - $this->Task->name = 'Controller'; - $this->Task->Template = new TemplateTask($out, $out, $in); - $this->Task->Template->params['theme'] = 'default'; - - $this->Task->Model = $this->getMock('ModelTask', - array('in', 'out', 'err', 'createFile', '_stop', '_checkUnitTest'), - array($out, $out, $in) - ); - $this->Task->Project = $this->getMock('ProjectTask', - array('in', 'out', 'err', 'createFile', '_stop', '_checkUnitTest', 'getPrefix'), - array($out, $out, $in) - ); - $this->Task->Test = $this->getMock('TestTask', array(), array($out, $out, $in)); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - unset($this->Task); - ClassRegistry::flush(); - App::build(); - parent::tearDown(); - } - -/** - * test ListAll - * - * @return void - */ - public function testListAll() { - $count = count($this->Task->listAll('test')); - if ($count != count($this->fixtures)) { - $this->markTestSkipped('Additional tables detected.'); - } - - $this->Task->connection = 'test'; - $this->Task->interactive = true; - $this->Task->expects($this->at(1))->method('out')->with('1. BakeArticles'); - $this->Task->expects($this->at(2))->method('out')->with('2. BakeArticlesBakeTags'); - $this->Task->expects($this->at(3))->method('out')->with('3. BakeComments'); - $this->Task->expects($this->at(4))->method('out')->with('4. BakeTags'); - - $expected = array('BakeArticles', 'BakeArticlesBakeTags', 'BakeComments', 'BakeTags'); - $result = $this->Task->listAll('test'); - $this->assertEquals($expected, $result); - - $this->Task->interactive = false; - $result = $this->Task->listAll(); - - $expected = array('bake_articles', 'bake_articles_bake_tags', 'bake_comments', 'bake_tags'); - $this->assertEquals($expected, $result); - } - -/** - * Test that getName interacts with the user and returns the controller name. - * - * @return void - */ - public function testGetNameValidIndex() { - $count = count($this->Task->listAll('test')); - if ($count != count($this->fixtures)) { - $this->markTestSkipped('Additional tables detected.'); - } - $this->Task->interactive = true; - $this->Task->expects($this->any())->method('in')->will( - $this->onConsecutiveCalls(3, 1) - ); - - $result = $this->Task->getName('test'); - $expected = 'BakeComments'; - $this->assertEquals($expected, $result); - - $result = $this->Task->getName('test'); - $expected = 'BakeArticles'; - $this->assertEquals($expected, $result); - } - -/** - * test getting invalid indexes. - * - * @return void - */ - public function testGetNameInvalidIndex() { - $this->Task->interactive = true; - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls(50, 'q')); - - $this->Task->expects($this->once())->method('err'); - $this->Task->expects($this->once())->method('_stop'); - - $this->Task->getName('test'); - } - -/** - * test helper interactions - * - * @return void - */ - public function testDoHelpersNo() { - $this->Task->expects($this->any())->method('in')->will($this->returnValue('n')); - $result = $this->Task->doHelpers(); - $this->assertEquals(array(), $result); - } - -/** - * test getting helper values - * - * @return void - */ - public function testDoHelpersTrailingSpace() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue(' Javascript, Ajax, CustomOne ')); - $result = $this->Task->doHelpers(); - $expected = array('Javascript', 'Ajax', 'CustomOne'); - $this->assertEquals($expected, $result); - } - -/** - * test doHelpers with extra commas - * - * @return void - */ - public function testDoHelpersTrailingCommas() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue(' Javascript, Ajax, CustomOne, , ')); - $result = $this->Task->doHelpers(); - $expected = array('Javascript', 'Ajax', 'CustomOne'); - $this->assertEquals($expected, $result); - } - -/** - * test component interactions - * - * @return void - */ - public function testDoComponentsNo() { - $this->Task->expects($this->any())->method('in')->will($this->returnValue('n')); - $result = $this->Task->doComponents(); - $this->assertEquals(array(), $result); - } - -/** - * test components with spaces - * - * @return void - */ - public function testDoComponentsTrailingSpaces() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue(' RequestHandler, Security ')); - - $result = $this->Task->doComponents(); - $expected = array('RequestHandler', 'Security'); - $this->assertEquals($expected, $result); - } - -/** - * test components with commas - * - * @return void - */ - public function testDoComponentsTrailingCommas() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue(' RequestHandler, Security, , ')); - - $result = $this->Task->doComponents(); - $expected = array('RequestHandler', 'Security'); - $this->assertEquals($expected, $result); - } - -/** - * test Confirming controller user interaction - * - * @return void - */ - public function testConfirmController() { - $controller = 'Posts'; - $scaffold = false; - $helpers = array('Ajax', 'Time'); - $components = array('Acl', 'Auth'); - - $this->Task->expects($this->at(4))->method('out')->with("Controller Name:\n\t$controller"); - $this->Task->expects($this->at(5))->method('out')->with("Helpers:\n\tAjax, Time"); - $this->Task->expects($this->at(6))->method('out')->with("Components:\n\tAcl, Auth"); - $this->Task->confirmController($controller, $scaffold, $helpers, $components); - } - -/** - * test the bake method - * - * @return void - */ - public function testBake() { - $helpers = array('Ajax', 'Time'); - $components = array('Acl', 'Auth'); - $this->Task->expects($this->any())->method('createFile')->will($this->returnValue(true)); - - $result = $this->Task->bake('Articles', '--actions--', $helpers, $components); - $this->assertContains(' * @property Article $Article', $result); - $this->assertContains(' * @property AclComponent $Acl', $result); - $this->assertContains(' * @property AuthComponent $Auth', $result); - $this->assertContains('class ArticlesController extends AppController', $result); - $this->assertContains("public \$components = array('Acl', 'Auth')", $result); - $this->assertContains("public \$helpers = array('Ajax', 'Time')", $result); - $this->assertContains("--actions--", $result); - - $result = $this->Task->bake('Articles', 'scaffold', $helpers, $components); - $this->assertContains("class ArticlesController extends AppController", $result); - $this->assertContains("public \$scaffold", $result); - $this->assertNotContains('@property', $result); - $this->assertNotContains('helpers', $result); - $this->assertNotContains('components', $result); - - $result = $this->Task->bake('Articles', '--actions--', array(), array()); - $this->assertContains('class ArticlesController extends AppController', $result); - $this->assertSame(substr_count($result, '@property'), 1); - $this->assertNotContains('components', $result); - $this->assertNotContains('helpers', $result); - $this->assertContains('--actions--', $result); - } - -/** - * test bake() with a -plugin param - * - * @return void - */ - public function testBakeWithPlugin() { - $this->Task->plugin = 'ControllerTest'; - - //fake plugin path - CakePlugin::load('ControllerTest', array('path' => APP . 'Plugin' . DS . 'ControllerTest' . DS)); - $path = APP . 'Plugin' . DS . 'ControllerTest' . DS . 'Controller' . DS . 'ArticlesController.php'; - - $this->Task->expects($this->at(1))->method('createFile')->with( - $path, - new PHPUnit_Framework_Constraint_IsAnything() - ); - $this->Task->expects($this->at(3))->method('createFile')->with( - $path, - $this->stringContains('ArticlesController extends ControllerTestAppController') - )->will($this->returnValue(true)); - - $this->Task->bake('Articles', '--actions--', array(), array(), array()); - - $this->Task->plugin = 'ControllerTest'; - $path = APP . 'Plugin' . DS . 'ControllerTest' . DS . 'Controller' . DS . 'ArticlesController.php'; - $result = $this->Task->bake('Articles', '--actions--', array(), array(), array()); - - $this->assertContains("App::uses('ControllerTestAppController', 'ControllerTest.Controller');", $result); - $this->assertEquals('ControllerTest', $this->Task->Template->templateVars['plugin']); - $this->assertEquals('ControllerTest.', $this->Task->Template->templateVars['pluginPath']); - - CakePlugin::unload(); - } - -/** - * test that bakeActions is creating the correct controller Code. (Using sessions) - * - * @return void - */ - public function testBakeActionsUsingSessions() { - $this->skipIf(!defined('ARTICLE_MODEL_CREATED'), 'Testing bakeActions requires Article, Comment & Tag Model to be undefined.'); - - $result = $this->Task->bakeActions('BakeArticles', null, true); - - $this->assertContains('function index() {', $result); - $this->assertContains('$this->BakeArticle->recursive = 0;', $result); - $this->assertContains("\$this->set('bakeArticles', \$this->paginate());", $result); - - $this->assertContains('function view($id = null)', $result); - $this->assertContains("throw new NotFoundException(__('Invalid bake article'));", $result); - $this->assertContains("\$this->set('bakeArticle', \$this->BakeArticle->read(null, \$id)", $result); - - $this->assertContains('function add()', $result); - $this->assertContains("if (\$this->request->is('post'))", $result); - $this->assertContains('if ($this->BakeArticle->save($this->request->data))', $result); - $this->assertContains("\$this->Session->setFlash(__('The bake article has been saved'));", $result); - - $this->assertContains('function edit($id = null)', $result); - $this->assertContains("\$this->Session->setFlash(__('The bake article could not be saved. Please, try again.'));", $result); - - $this->assertContains('function delete($id = null)', $result); - $this->assertContains('if ($this->BakeArticle->delete())', $result); - $this->assertContains("\$this->Session->setFlash(__('Bake article deleted'));", $result); - - $result = $this->Task->bakeActions('BakeArticles', 'admin_', true); - - $this->assertContains('function admin_index() {', $result); - $this->assertContains('function admin_add()', $result); - $this->assertContains('function admin_view($id = null)', $result); - $this->assertContains('function admin_edit($id = null)', $result); - $this->assertContains('function admin_delete($id = null)', $result); - } - -/** - * Test baking with Controller::flash() or no sessions. - * - * @return void - */ - public function testBakeActionsWithNoSessions() { - $this->skipIf(!defined('ARTICLE_MODEL_CREATED'), 'Testing bakeActions requires Article, Tag, Comment Models to be undefined.'); - - $result = $this->Task->bakeActions('BakeArticles', null, false); - - $this->assertContains('function index() {', $result); - $this->assertContains('$this->BakeArticle->recursive = 0;', $result); - $this->assertContains("\$this->set('bakeArticles', \$this->paginate());", $result); - - $this->assertContains('function view($id = null)', $result); - $this->assertContains("throw new NotFoundException(__('Invalid bake article'));", $result); - $this->assertContains("\$this->set('bakeArticle', \$this->BakeArticle->read(null, \$id)", $result); - - $this->assertContains('function add()', $result); - $this->assertContains("if (\$this->request->is('post'))", $result); - $this->assertContains('if ($this->BakeArticle->save($this->request->data))', $result); - - $this->assertContains("\$this->flash(__('The bake article has been saved.'), array('action' => 'index'))", $result); - - $this->assertContains('function edit($id = null)', $result); - $this->assertContains("\$this->BakeArticle->BakeTag->find('list')", $result); - $this->assertContains("\$this->set(compact('bakeTags'))", $result); - - $this->assertContains('function delete($id = null)', $result); - $this->assertContains('if ($this->BakeArticle->delete())', $result); - $this->assertContains("\$this->flash(__('Bake article deleted'), array('action' => 'index'))", $result); - } - -/** - * test baking a test - * - * @return void - */ - public function testBakeTest() { - $this->Task->plugin = 'ControllerTest'; - $this->Task->connection = 'test'; - $this->Task->interactive = false; - - $this->Task->Test->expects($this->once())->method('bake')->with('Controller', 'BakeArticles'); - $this->Task->bakeTest('BakeArticles'); - - $this->assertEquals($this->Task->plugin, $this->Task->Test->plugin); - $this->assertEquals($this->Task->connection, $this->Task->Test->connection); - $this->assertEquals($this->Task->interactive, $this->Task->Test->interactive); - } - -/** - * test Interactive mode. - * - * @return void - */ - public function testInteractive() { - $count = count($this->Task->listAll('test')); - if ($count != count($this->fixtures)) { - $this->markTestSkipped('Additional tables detected.'); - } - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls( - '1', - 'y', // build interactive - 'n', // build no scaffolds - 'y', // build normal methods - 'n', // build admin methods - 'n', // helpers? - 'n', // components? - 'y', // sessions ? - 'y' // looks good? - )); - - $filename = '/my/path/BakeArticlesController.php'; - $this->Task->expects($this->once())->method('createFile')->with( - $filename, - $this->stringContains('class BakeArticlesController') - ); - $this->Task->execute(); - } - -/** - * test Interactive mode. - * - * @return void - */ - public function testInteractiveAdminMethodsNotInteractive() { - $count = count($this->Task->listAll('test')); - if ($count != count($this->fixtures)) { - $this->markTestSkipped('Additional tables detected.'); - } - - $this->Task->connection = 'test'; - $this->Task->interactive = true; - $this->Task->path = '/my/path/'; - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls( - '1', - 'y', // build interactive - 'n', // build no scaffolds - 'y', // build normal methods - 'y', // build admin methods - 'n', // helpers? - 'n', // components? - 'y', // sessions ? - 'y' // looks good? - )); - - $this->Task->Project->expects($this->any()) - ->method('getPrefix') - ->will($this->returnValue('admin_')); - - $filename = '/my/path/BakeArticlesController.php'; - $this->Task->expects($this->once())->method('createFile')->with( - $filename, - $this->stringContains('class BakeArticlesController') - )->will($this->returnValue(true)); - - $result = $this->Task->execute(); - $this->assertRegExp('/admin_index/', $result); - } - -/** - * test that execute runs all when the first arg == all - * - * @return void - */ - public function testExecuteIntoAll() { - $count = count($this->Task->listAll('test')); - if ($count != count($this->fixtures)) { - $this->markTestSkipped('Additional tables detected.'); - } - if (!defined('ARTICLE_MODEL_CREATED')) { - $this->markTestSkipped('Execute into all could not be run as an Article, Tag or Comment model was already loaded.'); - } - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('all'); - - $this->Task->expects($this->any())->method('_checkUnitTest')->will($this->returnValue(true)); - $this->Task->Test->expects($this->once())->method('bake'); - - $filename = '/my/path/BakeArticlesController.php'; - $this->Task->expects($this->once())->method('createFile')->with( - $filename, - $this->stringContains('class BakeArticlesController') - )->will($this->returnValue(true)); - - $this->Task->execute(); - } - -/** - * test that `cake bake controller foos` works. - * - * @return void - */ - public function testExecuteWithController() { - if (!defined('ARTICLE_MODEL_CREATED')) { - $this->markTestSkipped('Execute with scaffold param requires no Article, Tag or Comment model to be defined'); - } - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeArticles'); - - $filename = '/my/path/BakeArticlesController.php'; - $this->Task->expects($this->once())->method('createFile')->with( - $filename, - $this->stringContains('$scaffold') - ); - - $this->Task->execute(); - } - -/** - * data provider for testExecuteWithControllerNameVariations - * - * @return void - */ - public static function nameVariations() { - return array( - array('BakeArticles'), array('BakeArticle'), array('bake_article'), array('bake_articles') - ); - } - -/** - * test that both plural and singular forms work for controller baking. - * - * @dataProvider nameVariations - * @return void - */ - public function testExecuteWithControllerNameVariations($name) { - if (!defined('ARTICLE_MODEL_CREATED')) { - $this->markTestSkipped('Execute with scaffold param requires no Article, Tag or Comment model to be defined.'); - } - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array($name); - - $filename = '/my/path/BakeArticlesController.php'; - $this->Task->expects($this->once())->method('createFile')->with( - $filename, $this->stringContains('$scaffold') - ); - $this->Task->execute(); - } - -/** - * test that `cake bake controller foo scaffold` works. - * - * @return void - */ - public function testExecuteWithPublicParam() { - if (!defined('ARTICLE_MODEL_CREATED')) { - $this->markTestSkipped('Execute with public param requires no Article, Tag or Comment model to be defined.'); - } - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeArticles'); - $this->Task->params = array('public' => true); - - $filename = '/my/path/BakeArticlesController.php'; - $expected = new PHPUnit_Framework_Constraint_Not($this->stringContains('$scaffold')); - $this->Task->expects($this->once())->method('createFile')->with( - $filename, $expected - ); - $this->Task->execute(); - } - -/** - * test that `cake bake controller foos both` works. - * - * @return void - */ - public function testExecuteWithControllerAndBoth() { - if (!defined('ARTICLE_MODEL_CREATED')) { - $this->markTestSkipped('Execute with controller and both requires no Article, Tag or Comment model to be defined.'); - } - $this->Task->Project->expects($this->any())->method('getPrefix')->will($this->returnValue('admin_')); - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeArticles'); - $this->Task->params = array('public' => true, 'admin' => true); - - $filename = '/my/path/BakeArticlesController.php'; - $this->Task->expects($this->once())->method('createFile')->with( - $filename, $this->stringContains('admin_index') - ); - $this->Task->execute(); - } - -/** - * test that `cake bake controller foos admin` works. - * - * @return void - */ - public function testExecuteWithControllerAndAdmin() { - if (!defined('ARTICLE_MODEL_CREATED')) { - $this->markTestSkipped('Execute with controller and admin requires no Article, Tag or Comment model to be defined.'); - } - $this->Task->Project->expects($this->any())->method('getPrefix')->will($this->returnValue('admin_')); - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeArticles'); - $this->Task->params = array('admin' => true); - - $filename = '/my/path/BakeArticlesController.php'; - $this->Task->expects($this->once())->method('createFile')->with( - $filename, $this->stringContains('admin_index') - ); - $this->Task->execute(); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/DbConfigTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/DbConfigTaskTest.php deleted file mode 100644 index 558dd3c3d4a..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/DbConfigTaskTest.php +++ /dev/null @@ -1,133 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('DbConfigTask', - array('in', 'out', 'err', 'hr', 'createFile', '_stop', '_checkUnitTest', '_verify'), - array($out, $out, $in) - ); - - $this->Task->path = APP . 'Config' . DS; - } - -/** - * endTest method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Task); - } - -/** - * Test the getConfig method. - * - * @return void - */ - public function testGetConfig() { - $this->Task->expects($this->any()) - ->method('in') - ->will($this->returnValue('test')); - - $result = $this->Task->getConfig(); - $this->assertEquals('test', $result); - } - -/** - * test that initialize sets the path up. - * - * @return void - */ - public function testInitialize() { - $this->Task->initialize(); - $this->assertFalse(empty($this->Task->path)); - $this->assertEquals(APP . 'Config' . DS, $this->Task->path); - } - -/** - * test execute and by extension _interactive - * - * @return void - */ - public function testExecuteIntoInteractive() { - $this->Task->initialize(); - - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock( - 'DbConfigTask', - array('in', '_stop', 'createFile', 'bake'), array($out, $out, $in) - ); - - $this->Task->expects($this->once())->method('_stop'); - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('default')); //name - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue('mysql')); //db type - $this->Task->expects($this->at(2))->method('in')->will($this->returnValue('n')); //persistant - $this->Task->expects($this->at(3))->method('in')->will($this->returnValue('localhost')); //server - $this->Task->expects($this->at(4))->method('in')->will($this->returnValue('n')); //port - $this->Task->expects($this->at(5))->method('in')->will($this->returnValue('root')); //user - $this->Task->expects($this->at(6))->method('in')->will($this->returnValue('password')); //password - $this->Task->expects($this->at(10))->method('in')->will($this->returnValue('cake_test')); //db - $this->Task->expects($this->at(11))->method('in')->will($this->returnValue('n')); //prefix - $this->Task->expects($this->at(12))->method('in')->will($this->returnValue('n')); //encoding - $this->Task->expects($this->at(13))->method('in')->will($this->returnValue('y')); //looks good - $this->Task->expects($this->at(14))->method('in')->will($this->returnValue('n')); //another - $this->Task->expects($this->at(15))->method('bake') - ->with(array( - array( - 'name' => 'default', - 'datasource' => 'mysql', - 'persistent' => 'false', - 'host' => 'localhost', - 'login' => 'root', - 'password' => 'password', - 'database' => 'cake_test', - 'prefix' => null, - 'encoding' => null, - 'port' => '', - 'schema' => null - ) - )); - - $result = $this->Task->execute(); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/ExtractTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/ExtractTaskTest.php deleted file mode 100644 index b144e37b99a..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/ExtractTaskTest.php +++ /dev/null @@ -1,402 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock( - 'ExtractTask', - array('in', 'out', 'err', '_stop'), - array($out, $out, $in) - ); - $this->path = TMP . 'tests' . DS . 'extract_task_test'; - $Folder = new Folder($this->path . DS . 'locale', true); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Task); - - $Folder = new Folder($this->path); - $Folder->delete(); - CakePlugin::unload(); - } - -/** - * testExecute method - * - * @return void - */ - public function testExecute() { - $this->Task->interactive = false; - - $this->Task->params['paths'] = CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS . 'Pages'; - $this->Task->params['output'] = $this->path . DS; - $this->Task->expects($this->never())->method('err'); - $this->Task->expects($this->any())->method('in') - ->will($this->returnValue('y')); - $this->Task->expects($this->never())->method('_stop'); - - $this->Task->execute(); - $this->assertTrue(file_exists($this->path . DS . 'default.pot')); - $result = file_get_contents($this->path . DS . 'default.pot'); - - $pattern = '/"Content-Type\: text\/plain; charset\=utf-8/'; - $this->assertRegExp($pattern, $result); - $pattern = '/"Content-Transfer-Encoding\: 8bit/'; - $this->assertRegExp($pattern, $result); - $pattern = '/"Plural-Forms\: nplurals\=INTEGER; plural\=EXPRESSION;/'; - $this->assertRegExp($pattern, $result); - - // home.ctp - $pattern = '/msgid "Your tmp directory is writable."\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Your tmp directory is NOT writable."\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "The %s is being used for caching. To change the config edit '; - $pattern .= 'APP\/config\/core.php "\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Your cache is NOT working. Please check '; - $pattern .= 'the settings in APP\/config\/core.php"\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Your database configuration file is present."\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Your database configuration file is NOT present."\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Rename config\/database.php.default to '; - $pattern .= 'config\/database.php"\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Cake is able to connect to the database."\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Cake is NOT able to connect to the database."\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "Editing this Page"\nmsgstr ""\n/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "To change the content of this page, create: APP\/views\/pages\/home\.ctp/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/To change its layout, create: APP\/views\/layouts\/default\.ctp\./s'; - $this->assertRegExp($pattern, $result); - - // extract.ctp - $pattern = '/\#: (\\\\|\/)extract\.ctp:15;6\n'; - $pattern .= 'msgid "You have %d new message."\nmsgid_plural "You have %d new messages."/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/msgid "You have %d new message."\nmsgstr ""/'; - $this->assertNotRegExp($pattern, $result, 'No duplicate msgid'); - - $pattern = '/\#: (\\\\|\/)extract\.ctp:7\n'; - $pattern .= 'msgid "You deleted %d message."\nmsgid_plural "You deleted %d messages."/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/\#: (\\\\|\/)extract\.ctp:14\n'; - $pattern .= '\#: (\\\\|\/)home\.ctp:99\n'; - $pattern .= 'msgid "Editing this Page"\nmsgstr ""/'; - $this->assertRegExp($pattern, $result); - - $pattern = '/\#: (\\\\|\/)extract\.ctp:18\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->assertRegExp($pattern, $result); - - // extract.ctp - 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->assertNotRegExp($pattern, $result); - $pattern = '/msgid "You deleted %d message."\nmsgid_plural "You deleted %d messages."/'; - $this->assertNotRegExp($pattern, $result); - - $pattern = '/msgid "You have %d new message \(domain\)."\nmsgid_plural "You have %d new messages \(domain\)."/'; - $this->assertRegExp($pattern, $result); - $pattern = '/msgid "You deleted %d message \(domain\)."\nmsgid_plural "You deleted %d messages \(domain\)."/'; - $this->assertRegExp($pattern, $result); - } - -/** - * test exclusions - * - * @return void - */ - public function testExtractWithExclude() { - $this->Task->interactive = false; - - $this->Task->params['paths'] = CAKE . 'Test' . DS . 'test_app' . DS . 'View'; - $this->Task->params['output'] = $this->path . DS; - $this->Task->params['exclude'] = 'Pages,Layouts'; - - $this->Task->expects($this->any())->method('in') - ->will($this->returnValue('y')); - - $this->Task->execute(); - $this->assertTrue(file_exists($this->path . DS . 'default.pot')); - $result = file_get_contents($this->path . DS . 'default.pot'); - - $pattern = '/\#: .*extract\.ctp:6\n/'; - $this->assertNotRegExp($pattern, $result); - - $pattern = '/\#: .*default\.ctp:26\n/'; - $this->assertNotRegExp($pattern, $result); - } - -/** - * test extract can read more than one path. - * - * @return void - */ - public function testExtractMultiplePaths() { - $this->Task->interactive = false; - - $this->Task->params['paths'] = - CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS . 'Pages,' . - CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS . 'Posts'; - - $this->Task->params['output'] = $this->path . DS; - $this->Task->expects($this->never())->method('err'); - $this->Task->expects($this->never())->method('_stop'); - $this->Task->execute(); - - $result = file_get_contents($this->path . DS . 'default.pot'); - - $pattern = '/msgid "Add User"/'; - $this->assertRegExp($pattern, $result); - } - -/** - * Tests that it is possible to exclude plugin paths by enabling the param option for the ExtractTask - * - * @return void - */ - public function testExtractExcludePlugins() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - $this->out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $this->in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ExtractTask', - array('_isExtractingApp', '_extractValidationMessages', 'in', 'out', 'err', 'clear', '_stop'), - array($this->out, $this->out, $this->in) - ); - $this->Task->expects($this->exactly(2))->method('_isExtractingApp')->will($this->returnValue(true)); - - $this->Task->params['paths'] = CAKE . 'Test' . DS . 'test_app' . DS; - $this->Task->params['output'] = $this->path . DS; - $this->Task->params['exclude-plugins'] = true; - - $this->Task->execute(); - $result = file_get_contents($this->path . DS . 'default.pot'); - $this->assertNotRegExp('#TestPlugin#', $result); - } - -/** - * Test that is possible to extract messages form a single plugin - * - * @return void - */ - public function testExtractPlugin() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - - $this->out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $this->in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ExtractTask', - array('_isExtractingApp', '_extractValidationMessages', 'in', 'out', 'err', 'clear', '_stop'), - array($this->out, $this->out, $this->in) - ); - - $this->Task->params['output'] = $this->path . DS; - $this->Task->params['plugin'] = 'TestPlugin'; - - $this->Task->execute(); - $result = file_get_contents($this->path . DS . 'default.pot'); - $this->assertNotRegExp('#Pages#', $result); - $this->assertContains('translate.ctp:1', $result); - $this->assertContains('This is a translatable string', $result); - } - -/** - * Tests that the task will inspect application models and extract the validation messages from them - * - * @return void - */ - public function testExtractModelValidation() { - App::build(array( - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - $this->out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $this->in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ExtractTask', - array('_isExtractingApp', 'in', 'out', 'err', 'clear', '_stop'), - array($this->out, $this->out, $this->in) - ); - $this->Task->expects($this->exactly(2))->method('_isExtractingApp')->will($this->returnValue(true)); - - $this->Task->params['paths'] = CAKE . 'Test' . DS . 'test_app' . DS; - $this->Task->params['output'] = $this->path . DS; - $this->Task->params['exclude-plugins'] = true; - $this->Task->params['ignore-model-validation'] = false; - - $this->Task->execute(); - $result = file_get_contents($this->path . DS . 'default.pot'); - - $pattern = preg_quote('#Model' . DS . 'PersisterOne.php:validation for field title#', '\\'); - $this->assertRegExp($pattern, $result); - - $pattern = preg_quote('#Model' . DS . 'PersisterOne.php:validation for field body#', '\\'); - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post title is required"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "You may enter up to %s chars \(minimum is %s chars\)"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post body is required"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post body is super required"#'; - $this->assertRegExp($pattern, $result); - } - -/** - * Tests that the task will inspect application models and extract the validation messages from them - * while using a custom validation domain for the messages set on the model itself - * - * @return void - */ - public function testExtractModelValidationWithDomainInModel() { - App::build(array( - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS . 'Model' . DS) - )); - $this->out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $this->in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ExtractTask', - array('_isExtractingApp', 'in', 'out', 'err', 'clear', '_stop'), - array($this->out, $this->out, $this->in) - ); - $this->Task->expects($this->exactly(2))->method('_isExtractingApp')->will($this->returnValue(true)); - - $this->Task->params['paths'] = CAKE . 'Test' . DS . 'test_app' . DS; - $this->Task->params['output'] = $this->path . DS; - $this->Task->params['exclude-plugins'] = true; - $this->Task->params['ignore-model-validation'] = false; - - $this->Task->execute(); - $result = file_get_contents($this->path . DS . 'test_plugin.pot'); - - $pattern = preg_quote('#Plugin' . DS . 'TestPlugin' . DS . 'Model' . DS . 'TestPluginPost.php:validation for field title#', '\\'); - $this->assertRegExp($pattern, $result); - - $pattern = preg_quote('#Plugin' . DS . 'TestPlugin' . DS . 'Model' . DS . 'TestPluginPost.php:validation for field body#', '\\'); - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post title is required"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post body is required"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post body is super required"#'; - $this->assertRegExp($pattern, $result); - } - -/** - * Test that the extract shell can obtain validation messages from models inside a specific plugin - * - * @return void - */ - public function testExtractModelValidationInPlugin() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - $this->out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $this->in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ExtractTask', - array('_isExtractingApp', 'in', 'out', 'err', 'clear', '_stop'), - array($this->out, $this->out, $this->in) - ); - - $this->Task->params['output'] = $this->path . DS; - $this->Task->params['ignore-model-validation'] = false; - $this->Task->params['plugin'] = 'TestPlugin'; - - $this->Task->execute(); - $result = file_get_contents($this->path . DS . 'test_plugin.pot'); - - $pattern = preg_quote('#Model' . DS . 'TestPluginPost.php:validation for field title#', '\\'); - $this->assertRegExp($pattern, $result); - - $pattern = preg_quote('#Model' . DS . 'TestPluginPost.php:validation for field body#', '\\'); - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post title is required"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post body is required"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#msgid "Post body is super required"#'; - $this->assertRegExp($pattern, $result); - - $pattern = '#Plugin/TestPlugin/Model/TestPluginPost.php:validation for field title#'; - $this->assertNotRegExp($pattern, $result); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/FixtureTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/FixtureTaskTest.php deleted file mode 100644 index 9f2cbd0bd17..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/FixtureTaskTest.php +++ /dev/null @@ -1,388 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('FixtureTask', - array('in', 'err', 'createFile', '_stop', 'clear'), - array($out, $out, $in) - ); - $this->Task->Model = $this->getMock('ModelTask', - array('in', 'out', 'err', 'createFile', 'getName', 'getTable', 'listAll'), - array($out, $out, $in) - ); - $this->Task->Template = new TemplateTask($out, $out, $in); - $this->Task->DbConfig = $this->getMock('DbConfigTask', array(), array($out, $out, $in)); - $this->Task->Template->initialize(); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Task); - } - -/** - * test that initialize sets the path - * - * @return void - */ - public function testConstruct() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $Task = new FixtureTask($out, $out, $in); - $this->assertEquals(APP . 'Test' . DS . 'Fixture' . DS, $Task->path); - } - -/** - * test import option array generation - * - * @return void - */ - public function testImportOptionsSchemaRecords() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue('y')); - - $result = $this->Task->importOptions('Article'); - $expected = array('schema' => 'Article', 'records' => true); - $this->assertEquals($expected, $result); - } - -/** - * test importOptions choosing nothing. - * - * @return void - */ - public function testImportOptionsNothing() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('n')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue('n')); - $this->Task->expects($this->at(2))->method('in')->will($this->returnValue('n')); - - $result = $this->Task->importOptions('Article'); - $expected = array(); - $this->assertEquals($expected, $result); - } - -/** - * test importOptions choosing from Table. - * - * @return void - */ - public function testImportOptionsTable() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('n')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue('n')); - $this->Task->expects($this->at(2))->method('in')->will($this->returnValue('y')); - $result = $this->Task->importOptions('Article'); - $expected = array('fromTable' => true); - $this->assertEquals($expected, $result); - } - -/** - * test generating a fixture with database conditions. - * - * @return void - */ - public function testImportRecordsFromDatabaseWithConditionsPoo() { - $this->Task->interactive = true; - $this->Task->expects($this->at(0))->method('in') - ->will($this->returnValue('WHERE 1=1')); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - - $result = $this->Task->bake('Article', false, array( - 'fromTable' => true, 'schema' => 'Article', 'records' => false - )); - - $this->assertContains('class ArticleFixture extends CakeTestFixture', $result); - $this->assertContains('public $records', $result); - $this->assertContains('public $import', $result); - $this->assertContains("'title' => 'First Article'", $result, 'Missing import data %s'); - $this->assertContains('Second Article', $result, 'Missing import data %s'); - $this->assertContains('Third Article', $result, 'Missing import data %s'); - } - -/** - * test that connection gets set to the import options when a different connection is used. - * - * @return void - */ - public function testImportOptionsAlternateConnection() { - $this->Task->connection = 'test'; - $result = $this->Task->bake('Article', false, array('schema' => 'Article')); - $this->assertContains("'connection' => 'test'", $result); - } - -/** - * Ensure that fixture data doesn't get overly escaped. - * - * @return void - */ - public function testImportRecordsNoEscaping() { - $db = ConnectionManager::getDataSource('test'); - if ($db instanceof Sqlserver) { - $this->markTestSkipped('This test does not run on SQLServer'); - } - - $Article = ClassRegistry::init('Article'); - $Article->updateAll(array('body' => "'Body \"value\"'")); - - $this->Task->interactive = true; - $this->Task->expects($this->at(0)) - ->method('in') - ->will($this->returnValue('WHERE 1=1 LIMIT 10')); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $result = $this->Task->bake('Article', false, array( - 'fromTable' => true, - 'schema' => 'Article', - 'records' => false - )); - $this->assertContains("'body' => 'Body \"value\"'", $result, 'Data has bad escaping'); - } - -/** - * test that execute passes runs bake depending with named model. - * - * - * @return void - */ - public function testExecuteWithNamedModel() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('article'); - $filename = '/my/path/ArticleFixture.php'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with($filename, $this->stringContains('class ArticleFixture')); - - $this->Task->execute(); - } - -/** - * test that execute runs all() when args[0] = all - * - * @return void - */ - public function testExecuteIntoAll() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('all'); - $this->Task->Model->expects($this->any()) - ->method('listAll') - ->will($this->returnValue(array('articles', 'comments'))); - - $filename = '/my/path/ArticleFixture.php'; - $this->Task->expects($this->at(0)) - ->method('createFile') - ->with($filename, $this->stringContains('class ArticleFixture')); - - $filename = '/my/path/CommentFixture.php'; - $this->Task->expects($this->at(1)) - ->method('createFile') - ->with($filename, $this->stringContains('class CommentFixture')); - - $this->Task->execute(); - } - -/** - * test using all() with -count and -records - * - * @return void - */ - public function testAllWithCountAndRecordsFlags() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('all'); - $this->Task->params = array('count' => 10, 'records' => true); - - $this->Task->Model->expects($this->any())->method('listAll') - ->will($this->returnValue(array('Articles', 'comments'))); - - $filename = '/my/path/ArticleFixture.php'; - $this->Task->expects($this->at(0))->method('createFile') - ->with($filename, $this->stringContains("'title' => 'Third Article'")); - - $filename = '/my/path/CommentFixture.php'; - $this->Task->expects($this->at(1))->method('createFile') - ->with($filename, $this->stringContains("'comment' => 'First Comment for First Article'")); - $this->Task->expects($this->exactly(2))->method('createFile'); - - $this->Task->all(); - } - -/** - * test interactive mode of execute - * - * @return void - */ - public function testExecuteInteractive() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - - $this->Task->expects($this->any())->method('in')->will($this->returnValue('y')); - $this->Task->Model->expects($this->any())->method('getName')->will($this->returnValue('Article')); - $this->Task->Model->expects($this->any())->method('getTable') - ->with('Article') - ->will($this->returnValue('articles')); - - $filename = '/my/path/ArticleFixture.php'; - $this->Task->expects($this->once())->method('createFile') - ->with($filename, $this->stringContains('class ArticleFixture')); - - $this->Task->execute(); - } - -/** - * Test that bake works - * - * @return void - */ - public function testBake() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - - $result = $this->Task->bake('Article'); - $this->assertContains('class ArticleFixture extends CakeTestFixture', $result); - $this->assertContains('public $fields', $result); - $this->assertContains('public $records', $result); - $this->assertNotContains('public $import', $result); - - $result = $this->Task->bake('Article', 'comments'); - $this->assertContains('class ArticleFixture extends CakeTestFixture', $result); - $this->assertContains('public $table = \'comments\';', $result); - $this->assertContains('public $fields = array(', $result); - - $result = $this->Task->bake('Article', 'comments', array('records' => true)); - $this->assertContains("public \$import = array('records' => true, 'connection' => 'test');", $result); - $this->assertNotContains('public $records', $result); - - $result = $this->Task->bake('Article', 'comments', array('schema' => 'Article')); - $this->assertContains("public \$import = array('model' => 'Article', 'connection' => 'test');", $result); - $this->assertNotContains('public $fields', $result); - - $result = $this->Task->bake('Article', 'comments', array('schema' => 'Article', 'records' => true)); - $this->assertContains("public \$import = array('model' => 'Article', 'records' => true, 'connection' => 'test');", $result); - $this->assertNotContains('public $fields', $result); - $this->assertNotContains('public $records', $result); - } - -/** - * test record generation with float and binary types - * - * @return void - */ - public function testRecordGenerationForBinaryAndFloat() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - - $result = $this->Task->bake('Article', 'datatypes'); - $this->assertContains("'float_field' => 1", $result); - $this->assertContains("'bool' => 1", $result); - - $result = $this->Task->bake('Article', 'binary_tests'); - $this->assertContains("'data' => 'Lorem ipsum dolor sit amet'", $result); - } - -/** - * Test that file generation includes headers and correct path for plugins. - * - * @return void - */ - public function testGenerateFixtureFile() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $filename = '/my/path/ArticleFixture.php'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with($filename, $this->stringContains('ArticleFixture')); - - $this->Task->expects($this->at(1))->method('createFile') - ->with($filename, $this->stringContains('Task->generateFixtureFile('Article', array()); - - $result = $this->Task->generateFixtureFile('Article', array()); - } - -/** - * test generating files into plugins. - * - * @return void - */ - public function testGeneratePluginFixtureFile() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->plugin = 'TestFixture'; - $filename = APP . 'Plugin' . DS . 'TestFixture' . DS . 'Test' . DS . 'Fixture' . DS . 'ArticleFixture.php'; - - //fake plugin path - CakePlugin::load('TestFixture', array('path' => APP . 'Plugin' . DS . 'TestFixture' . DS)); - $this->Task->expects($this->at(0))->method('createFile') - ->with($filename, $this->stringContains('class Article')); - - $result = $this->Task->generateFixtureFile('Article', array()); - CakePlugin::unload(); - } - -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/ModelTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/ModelTaskTest.php deleted file mode 100644 index b181848afc5..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/ModelTaskTest.php +++ /dev/null @@ -1,1189 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('ModelTask', - array('in', 'err', 'createFile', '_stop', '_checkUnitTest'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - } - -/** - * Setup a mock that has out mocked. Normally this is not used as it makes $this->at() really tricky. - * - * @return void - */ - protected function _useMockedOut() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('ModelTask', - array('in', 'out', 'err', 'hr', 'createFile', '_stop', '_checkUnitTest'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - } - -/** - * sets up the rest of the dependencies for Model Task - * - * @return void - */ - protected function _setupOtherMocks() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task->Fixture = $this->getMock('FixtureTask', array(), array($out, $out, $in)); - $this->Task->Test = $this->getMock('FixtureTask', array(), array($out, $out, $in)); - $this->Task->Template = new TemplateTask($out, $out, $in); - - $this->Task->name = 'Model'; - $this->Task->interactive = true; - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Task); - } - -/** - * Test that listAll scans the database connection and lists all the tables in it.s - * - * @return void - */ - public function testListAllArgument() { - $this->_useMockedOut(); - - $result = $this->Task->listAll('test'); - $this->assertContains('bake_articles', $result); - $this->assertContains('bake_articles_bake_tags', $result); - $this->assertContains('bake_tags', $result); - $this->assertContains('bake_comments', $result); - $this->assertContains('category_threads', $result); - } - -/** - * Test that listAll uses the connection property - * - * @return void - */ - public function testListAllConnection() { - $this->_useMockedOut(); - - $this->Task->connection = 'test'; - $result = $this->Task->listAll(); - $this->assertContains('bake_articles', $result); - $this->assertContains('bake_articles_bake_tags', $result); - $this->assertContains('bake_tags', $result); - $this->assertContains('bake_comments', $result); - $this->assertContains('category_threads', $result); - } - -/** - * Test that getName interacts with the user and returns the model name. - * - * @return void - */ - public function testGetNameQuit() { - $this->Task->expects($this->once())->method('in')->will($this->returnValue('q')); - $this->Task->expects($this->once())->method('_stop'); - $this->Task->getName('test'); - } - -/** - * test getName with a valid option. - * - * @return void - */ - public function testGetNameValidOption() { - $listing = $this->Task->listAll('test'); - $this->Task->expects($this->any())->method('in')->will($this->onConsecutiveCalls(1, 4)); - - $result = $this->Task->getName('test'); - $this->assertEquals(Inflector::classify($listing[0]), $result); - - $result = $this->Task->getName('test'); - $this->assertEquals(Inflector::classify($listing[3]), $result); - } - -/** - * test that an out of bounds option causes an error. - * - * @return void - */ - public function testGetNameWithOutOfBoundsOption() { - $this->Task->expects($this->any())->method('in')->will($this->onConsecutiveCalls(99, 1)); - $this->Task->expects($this->once())->method('err'); - - $result = $this->Task->getName('test'); - } - -/** - * Test table name interactions - * - * @return void - */ - public function testGetTableName() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $result = $this->Task->getTable('BakeArticle', 'test'); - $expected = 'bake_articles'; - $this->assertEquals($expected, $result); - } - -/** - * test gettting a custom table name. - * - * @return void - */ - public function testGetTableNameCustom() { - $this->Task->expects($this->any())->method('in')->will($this->onConsecutiveCalls('n', 'my_table')); - $result = $this->Task->getTable('BakeArticle', 'test'); - $expected = 'my_table'; - $this->assertEquals($expected, $result); - } - -/** - * test getTable with non-conventional tablenames - * - * @return void - */ - public function testGetTableOddTableInteractive() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ModelTask', - array('in', 'err', '_stop', '_checkUnitTest', 'getAllTables'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->interactive = true; - - $this->Task->expects($this->once())->method('getAllTables')->will($this->returnValue(array('articles', 'bake_odd'))); - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls( - 2 // bake_odd - )); - - $result = $this->Task->getName(); - $expected = 'BakeOdd'; - $this->assertEquals($expected, $result); - - $result = $this->Task->getTable($result); - $expected = 'bake_odd'; - $this->assertEquals($expected, $result); - } - -/** - * test getTable with non-conventional tablenames - * - * @return void - */ - public function testGetTableOddTable() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ModelTask', - array('in', 'err', '_stop', '_checkUnitTest', 'getAllTables'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->interactive = false; - $this->Task->args = array('BakeOdd'); - - $this->Task->expects($this->once())->method('getAllTables')->will($this->returnValue(array('articles', 'bake_odd'))); - - $this->Task->listAll(); - - $result = $this->Task->getTable('BakeOdd'); - $expected = 'bake_odd'; - $this->assertEquals($expected, $result); - } - -/** - * test that initializing the validations works. - * - * @return void - */ - public function testInitValidations() { - $result = $this->Task->initValidations(); - $this->assertTrue(in_array('notempty', $result)); - } - -/** - * test that individual field validation works, with interactive = false - * tests the guessing features of validation - * - * @return void - */ - public function testFieldValidationGuessing() { - $this->Task->interactive = false; - $this->Task->initValidations(); - - $result = $this->Task->fieldValidation('text', array('type' => 'string', 'length' => 10, 'null' => false)); - $expected = array('notempty' => 'notempty'); - $this->assertEquals($expected, $result); - - $result = $this->Task->fieldValidation('text', array('type' => 'date', 'length' => 10, 'null' => false)); - $expected = array('date' => 'date'); - $this->assertEquals($expected, $result); - - $result = $this->Task->fieldValidation('text', array('type' => 'time', 'length' => 10, 'null' => false)); - $expected = array('time' => 'time'); - $this->assertEquals($expected, $result); - - $result = $this->Task->fieldValidation('email', array('type' => 'string', 'length' => 10, 'null' => false)); - $expected = array('email' => 'email'); - $this->assertEquals($expected, $result); - - $result = $this->Task->fieldValidation('test', array('type' => 'integer', 'length' => 10, 'null' => false)); - $expected = array('numeric' => 'numeric'); - $this->assertEquals($expected, $result); - - $result = $this->Task->fieldValidation('test', array('type' => 'boolean', 'length' => 10, 'null' => false)); - $expected = array('boolean' => 'boolean'); - $this->assertEquals($expected, $result); - } - -/** - * test that interactive field validation works and returns multiple validators. - * - * @return void - */ - public function testInteractiveFieldValidation() { - $this->Task->initValidations(); - $this->Task->interactive = true; - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('21', 'y', '17', 'n')); - - $result = $this->Task->fieldValidation('text', array('type' => 'string', 'length' => 10, 'null' => false)); - $expected = array('notempty' => 'notempty', 'maxlength' => 'maxlength'); - $this->assertEquals($expected, $result); - } - -/** - * test that a bogus response doesn't cause errors to bubble up. - * - * @return void - */ - public function testInteractiveFieldValidationWithBogusResponse() { - $this->_useMockedOut(); - $this->Task->initValidations(); - $this->Task->interactive = true; - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('999999', '21', 'n')); - - $this->Task->expects($this->at(7))->method('out') - ->with($this->stringContains('make a valid')); - - $result = $this->Task->fieldValidation('text', array('type' => 'string', 'length' => 10, 'null' => false)); - $expected = array('notempty' => 'notempty'); - $this->assertEquals($expected, $result); - } - -/** - * test that a regular expression can be used for validation. - * - * @return void - */ - public function testInteractiveFieldValidationWithRegexp() { - $this->Task->initValidations(); - $this->Task->interactive = true; - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('/^[a-z]{0,9}$/', 'n')); - - $result = $this->Task->fieldValidation('text', array('type' => 'string', 'length' => 10, 'null' => false)); - $expected = array('a_z_0_9' => '/^[a-z]{0,9}$/'); - $this->assertEquals($expected, $result); - } - -/** - * test the validation Generation routine - * - * @return void - */ - public function testNonInteractiveDoValidation() { - $Model = $this->getMock('Model'); - $Model->primaryKey = 'id'; - $Model->expects($this->any())->method('schema')->will($this->returnValue(array( - 'id' => array( - 'type' => 'integer', - 'length' => 11, - 'null' => false, - 'key' => 'primary', - ), - 'name' => array( - 'type' => 'string', - 'length' => 20, - 'null' => false, - ), - 'email' => array( - 'type' => 'string', - 'length' => 255, - 'null' => false, - ), - 'some_date' => array( - 'type' => 'date', - 'length' => '', - 'null' => false, - ), - 'some_time' => array( - 'type' => 'time', - 'length' => '', - 'null' => false, - ), - 'created' => array( - 'type' => 'datetime', - 'length' => '', - 'null' => false, - ) - ))); - $this->Task->interactive = false; - - $result = $this->Task->doValidation($Model); - $expected = array( - 'name' => array( - 'notempty' => 'notempty' - ), - 'email' => array( - 'email' => 'email', - ), - 'some_date' => array( - 'date' => 'date' - ), - 'some_time' => array( - 'time' => 'time' - ), - ); - $this->assertEquals($expected, $result); - } - -/** - * test that finding primary key works - * - * @return void - */ - public function testFindPrimaryKey() { - $fields = array( - 'one' => array(), - 'two' => array(), - 'key' => array('key' => 'primary') - ); - $anything = new PHPUnit_Framework_Constraint_IsAnything(); - $this->Task->expects($this->once())->method('in') - ->with($anything, null, 'key') - ->will($this->returnValue('my_field')); - - $result = $this->Task->findPrimaryKey($fields); - $expected = 'my_field'; - $this->assertEquals($expected, $result); - } - -/** - * test finding Display field - * - * @return void - */ - public function testFindDisplayFieldNone() { - $fields = array( - 'id' => array(), 'tagname' => array(), 'body' => array(), - 'created' => array(), 'modified' => array() - ); - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('n')); - $result = $this->Task->findDisplayField($fields); - $this->assertFalse($result); - } - -/** - * Test finding a displayname from user input - * - * @return void - */ - public function testFindDisplayName() { - $fields = array( - 'id' => array(), 'tagname' => array(), 'body' => array(), - 'created' => array(), 'modified' => array() - ); - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('y', 2)); - - $result = $this->Task->findDisplayField($fields); - $this->assertEquals('tagname', $result); - } - -/** - * test that belongsTo generation works. - * - * @return void - */ - public function testBelongsToGeneration() { - $model = new Model(array('ds' => 'test', 'name' => 'BakeComment')); - $result = $this->Task->findBelongsTo($model, array()); - $expected = array( - 'belongsTo' => array( - array( - 'alias' => 'BakeArticle', - 'className' => 'BakeArticle', - 'foreignKey' => 'bake_article_id', - ), - array( - 'alias' => 'BakeUser', - 'className' => 'BakeUser', - 'foreignKey' => 'bake_user_id', - ), - ) - ); - $this->assertEquals($expected, $result); - - $model = new Model(array('ds' => 'test', 'name' => 'CategoryThread')); - $result = $this->Task->findBelongsTo($model, array()); - $expected = array( - 'belongsTo' => array( - array( - 'alias' => 'ParentCategoryThread', - 'className' => 'CategoryThread', - 'foreignKey' => 'parent_id', - ), - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * test that hasOne and/or hasMany relations are generated properly. - * - * @return void - */ - public function testHasManyHasOneGeneration() { - $model = new Model(array('ds' => 'test', 'name' => 'BakeArticle')); - $this->Task->connection = 'test'; - $this->Task->listAll(); - $result = $this->Task->findHasOneAndMany($model, array()); - $expected = array( - 'hasMany' => array( - array( - 'alias' => 'BakeComment', - 'className' => 'BakeComment', - 'foreignKey' => 'bake_article_id', - ), - ), - 'hasOne' => array( - array( - 'alias' => 'BakeComment', - 'className' => 'BakeComment', - 'foreignKey' => 'bake_article_id', - ), - ), - ); - $this->assertEquals($expected, $result); - - $model = new Model(array('ds' => 'test', 'name' => 'CategoryThread')); - $result = $this->Task->findHasOneAndMany($model, array()); - $expected = array( - 'hasOne' => array( - array( - 'alias' => 'ChildCategoryThread', - 'className' => 'CategoryThread', - 'foreignKey' => 'parent_id', - ), - ), - 'hasMany' => array( - array( - 'alias' => 'ChildCategoryThread', - 'className' => 'CategoryThread', - 'foreignKey' => 'parent_id', - ), - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * Test that HABTM generation works - * - * @return void - */ - public function testHasAndBelongsToManyGeneration() { - $model = new Model(array('ds' => 'test', 'name' => 'BakeArticle')); - $this->Task->connection = 'test'; - $this->Task->listAll(); - $result = $this->Task->findHasAndBelongsToMany($model, array()); - $expected = array( - 'hasAndBelongsToMany' => array( - array( - 'alias' => 'BakeTag', - 'className' => 'BakeTag', - 'foreignKey' => 'bake_article_id', - 'joinTable' => 'bake_articles_bake_tags', - 'associationForeignKey' => 'bake_tag_id', - ), - ), - ); - $this->assertEquals($expected, $result); - } - -/** - * test non interactive doAssociations - * - * @return void - */ - public function testDoAssociationsNonInteractive() { - $this->Task->connection = 'test'; - $this->Task->interactive = false; - $model = new Model(array('ds' => 'test', 'name' => 'BakeArticle')); - $result = $this->Task->doAssociations($model); - $expected = array( - 'belongsTo' => array( - array( - 'alias' => 'BakeUser', - 'className' => 'BakeUser', - 'foreignKey' => 'bake_user_id', - ), - ), - 'hasMany' => array( - array( - 'alias' => 'BakeComment', - 'className' => 'BakeComment', - 'foreignKey' => 'bake_article_id', - ), - ), - 'hasAndBelongsToMany' => array( - array( - 'alias' => 'BakeTag', - 'className' => 'BakeTag', - 'foreignKey' => 'bake_article_id', - 'joinTable' => 'bake_articles_bake_tags', - 'associationForeignKey' => 'bake_tag_id', - ), - ), - ); - $this->assertEquals($expected, $result); - } - -/** - * Ensure that the fixture object is correctly called. - * - * @return void - */ - public function testBakeFixture() { - $this->Task->plugin = 'TestPlugin'; - $this->Task->interactive = true; - $this->Task->Fixture->expects($this->at(0))->method('bake')->with('BakeArticle', 'bake_articles'); - $this->Task->bakeFixture('BakeArticle', 'bake_articles'); - - $this->assertEquals($this->Task->plugin, $this->Task->Fixture->plugin); - $this->assertEquals($this->Task->connection, $this->Task->Fixture->connection); - $this->assertEquals($this->Task->interactive, $this->Task->Fixture->interactive); - } - -/** - * Ensure that the test object is correctly called. - * - * @return void - */ - public function testBakeTest() { - $this->Task->plugin = 'TestPlugin'; - $this->Task->interactive = true; - $this->Task->Test->expects($this->at(0))->method('bake')->with('Model', 'BakeArticle'); - $this->Task->bakeTest('BakeArticle'); - - $this->assertEquals($this->Task->plugin, $this->Task->Test->plugin); - $this->assertEquals($this->Task->connection, $this->Task->Test->connection); - $this->assertEquals($this->Task->interactive, $this->Task->Test->interactive); - } - -/** - * test confirming of associations, and that when an association is hasMany - * a question for the hasOne is also not asked. - * - * @return void - */ - public function testConfirmAssociations() { - $associations = array( - 'hasOne' => array( - array( - 'alias' => 'ChildCategoryThread', - 'className' => 'CategoryThread', - 'foreignKey' => 'parent_id', - ), - ), - 'hasMany' => array( - array( - 'alias' => 'ChildCategoryThread', - 'className' => 'CategoryThread', - 'foreignKey' => 'parent_id', - ), - ), - 'belongsTo' => array( - array( - 'alias' => 'User', - 'className' => 'User', - 'foreignKey' => 'user_id', - ), - ) - ); - $model = new Model(array('ds' => 'test', 'name' => 'CategoryThread')); - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('n', 'y', 'n', 'n', 'n')); - - $result = $this->Task->confirmAssociations($model, $associations); - $this->assertTrue(empty($result['hasOne'])); - - $result = $this->Task->confirmAssociations($model, $associations); - $this->assertTrue(empty($result['hasMany'])); - $this->assertTrue(empty($result['hasOne'])); - } - -/** - * test that inOptions generates questions and only accepts a valid answer - * - * @return void - */ - public function testInOptions() { - $this->_useMockedOut(); - - $options = array('one', 'two', 'three'); - $this->Task->expects($this->at(0))->method('out')->with('1. one'); - $this->Task->expects($this->at(1))->method('out')->with('2. two'); - $this->Task->expects($this->at(2))->method('out')->with('3. three'); - $this->Task->expects($this->at(3))->method('in')->will($this->returnValue(10)); - - $this->Task->expects($this->at(4))->method('out')->with('1. one'); - $this->Task->expects($this->at(5))->method('out')->with('2. two'); - $this->Task->expects($this->at(6))->method('out')->with('3. three'); - $this->Task->expects($this->at(7))->method('in')->will($this->returnValue(2)); - $result = $this->Task->inOptions($options, 'Pick a number'); - $this->assertEquals(1, $result); - } - -/** - * test baking validation - * - * @return void - */ - public function testBakeValidation() { - $validate = array( - 'name' => array( - 'notempty' => 'notempty' - ), - 'email' => array( - 'email' => 'email', - ), - 'some_date' => array( - 'date' => 'date' - ), - 'some_time' => array( - 'time' => 'time' - ) - ); - $result = $this->Task->bake('BakeArticle', compact('validate')); - $this->assertRegExp('/class BakeArticle extends AppModel \{/', $result); - $this->assertRegExp('/\$validate \= array\(/', $result); - $expected = <<< STRINGEND -array( - 'notempty' => array( - 'rule' => array('notempty'), - //'message' => 'Your custom message here', - //'allowEmpty' => false, - //'required' => false, - //'last' => false, // Stop validation after this rule - //'on' => 'create', // Limit validation to 'create' or 'update' operations - ), -STRINGEND; - $this->assertRegExp('/' . preg_quote(str_replace("\r\n", "\n", $expected), '/') . '/', $result); - } - -/** - * test baking relations - * - * @return void - */ - public function testBakeRelations() { - $associations = array( - 'belongsTo' => array( - array( - 'alias' => 'SomethingElse', - 'className' => 'SomethingElse', - 'foreignKey' => 'something_else_id', - ), - array( - 'alias' => 'BakeUser', - 'className' => 'BakeUser', - 'foreignKey' => 'bake_user_id', - ), - ), - 'hasOne' => array( - array( - 'alias' => 'OtherModel', - 'className' => 'OtherModel', - 'foreignKey' => 'other_model_id', - ), - ), - 'hasMany' => array( - array( - 'alias' => 'BakeComment', - 'className' => 'BakeComment', - 'foreignKey' => 'parent_id', - ), - ), - 'hasAndBelongsToMany' => array( - array( - 'alias' => 'BakeTag', - 'className' => 'BakeTag', - 'foreignKey' => 'bake_article_id', - 'joinTable' => 'bake_articles_bake_tags', - 'associationForeignKey' => 'bake_tag_id', - ), - ) - ); - $result = $this->Task->bake('BakeArticle', compact('associations')); - $this->assertContains(' * @property BakeUser $BakeUser', $result); - $this->assertContains(' * @property OtherModel $OtherModel', $result); - $this->assertContains(' * @property BakeComment $BakeComment', $result); - $this->assertContains(' * @property BakeTag $BakeTag', $result); - $this->assertRegExp('/\$hasAndBelongsToMany \= array\(/', $result); - $this->assertRegExp('/\$hasMany \= array\(/', $result); - $this->assertRegExp('/\$belongsTo \= array\(/', $result); - $this->assertRegExp('/\$hasOne \= array\(/', $result); - $this->assertRegExp('/BakeTag/', $result); - $this->assertRegExp('/OtherModel/', $result); - $this->assertRegExp('/SomethingElse/', $result); - $this->assertRegExp('/BakeComment/', $result); - } - -/** - * test bake() with a -plugin param - * - * @return void - */ - public function testBakeWithPlugin() { - $this->Task->plugin = 'ControllerTest'; - - //fake plugin path - CakePlugin::load('ControllerTest', array('path' => APP . 'Plugin' . DS . 'ControllerTest' . DS)); - $path = APP . 'Plugin' . DS . 'ControllerTest' . DS . 'Model' . DS . 'BakeArticle.php'; - $this->Task->expects($this->once())->method('createFile') - ->with($path, $this->stringContains('BakeArticle extends ControllerTestAppModel')); - - $result = $this->Task->bake('BakeArticle', array(), array()); - $this->assertContains("App::uses('ControllerTestAppModel', 'ControllerTest.Model');", $result); - - $this->assertEquals(count(ClassRegistry::keys()), 0); - $this->assertEquals(count(ClassRegistry::mapKeys()), 0); - } - -/** - * test that execute passes runs bake depending with named model. - * - * @return void - */ - public function testExecuteWithNamedModel() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeArticle'); - $filename = '/my/path/BakeArticle.php'; - - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(1)); - $this->Task->expects($this->once())->method('createFile') - ->with($filename, $this->stringContains('class BakeArticle extends AppModel')); - - $this->Task->execute(); - - $this->assertEquals(count(ClassRegistry::keys()), 0); - $this->assertEquals(count(ClassRegistry::mapKeys()), 0); - } - -/** - * data provider for testExecuteWithNamedModelVariations - * - * @return void - */ - public static function nameVariations() { - return array( - array('BakeArticles'), array('BakeArticle'), array('bake_article'), array('bake_articles') - ); - } - -/** - * test that execute passes with different inflections of the same name. - * - * @dataProvider nameVariations - * @return void - */ - public function testExecuteWithNamedModelVariations($name) { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(1)); - - $this->Task->args = array($name); - $filename = '/my/path/BakeArticle.php'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with($filename, $this->stringContains('class BakeArticle extends AppModel')); - $this->Task->execute(); - } - -/** - * test that execute with a model name picks up hasMany associations. - * - * @return void - */ - public function testExecuteWithNamedModelHasManyCreated() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeArticle'); - $filename = '/my/path/BakeArticle.php'; - - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(1)); - $this->Task->expects($this->at(0))->method('createFile') - ->with($filename, $this->stringContains("'BakeComment' => array(")); - - $this->Task->execute(); - } - -/** - * test that execute runs all() when args[0] = all - * - * @return void - */ - public function testExecuteIntoAll() { - $count = count($this->Task->listAll('test')); - if ($count != count($this->fixtures)) { - $this->markTestSkipped('Additional tables detected.'); - } - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('all'); - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(true)); - - $this->Task->Fixture->expects($this->exactly(5))->method('bake'); - $this->Task->Test->expects($this->exactly(5))->method('bake'); - - $filename = '/my/path/BakeArticle.php'; - $this->Task->expects($this->at(1))->method('createFile') - ->with($filename, $this->stringContains('class BakeArticle')); - - $filename = '/my/path/BakeArticlesBakeTag.php'; - $this->Task->expects($this->at(2))->method('createFile') - ->with($filename, $this->stringContains('class BakeArticlesBakeTag')); - - $filename = '/my/path/BakeComment.php'; - $this->Task->expects($this->at(3))->method('createFile') - ->with($filename, $this->stringContains('class BakeComment')); - - $filename = '/my/path/BakeComment.php'; - $this->Task->expects($this->at(3))->method('createFile') - ->with($filename, $this->stringContains('public $primaryKey = \'otherid\';')); - - $filename = '/my/path/BakeTag.php'; - $this->Task->expects($this->at(4))->method('createFile') - ->with($filename, $this->stringContains('class BakeTag')); - - $filename = '/my/path/BakeTag.php'; - $this->Task->expects($this->at(4))->method('createFile') - ->with($filename, $this->logicalNot($this->stringContains('public $primaryKey'))); - - $filename = '/my/path/CategoryThread.php'; - $this->Task->expects($this->at(5))->method('createFile') - ->with($filename, $this->stringContains('class CategoryThread')); - - $this->Task->execute(); - - $this->assertEquals(count(ClassRegistry::keys()), 0); - $this->assertEquals(count(ClassRegistry::mapKeys()), 0); - } - -/** - * test that odd tablenames arent inflected back from modelname - * - * @return void - */ - public function testExecuteIntoAllOddTables() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ModelTask', - array('in', 'err', '_stop', '_checkUnitTest', 'getAllTables', '_getModelObject', 'bake', 'bakeFixture'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('all'); - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('getAllTables')->will($this->returnValue(array('bake_odd'))); - $object = new Model(array('name' => 'BakeOdd', 'table' => 'bake_odd', 'ds' => 'test')); - $this->Task->expects($this->once())->method('_getModelObject')->with('BakeOdd', 'bake_odd')->will($this->returnValue($object)); - $this->Task->expects($this->at(3))->method('bake')->with($object, false)->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('bakeFixture')->with('BakeOdd', 'bake_odd'); - - $this->Task->execute(); - - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ModelTask', - array('in', 'err', '_stop', '_checkUnitTest', 'getAllTables', '_getModelObject', 'doAssociations', 'doValidation', 'createFile'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('all'); - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('getAllTables')->will($this->returnValue(array('bake_odd'))); - $object = new Model(array('name' => 'BakeOdd', 'table' => 'bake_odd', 'ds' => 'test')); - $this->Task->expects($this->once())->method('_getModelObject')->will($this->returnValue($object)); - $this->Task->expects($this->once())->method('doAssociations')->will($this->returnValue(array())); - $this->Task->expects($this->once())->method('doValidation')->will($this->returnValue(array())); - - $filename = '/my/path/BakeOdd.php'; - $this->Task->expects($this->once())->method('createFile') - ->with($filename, $this->stringContains('class BakeOdd')); - - $filename = '/my/path/BakeOdd.php'; - $this->Task->expects($this->once())->method('createFile') - ->with($filename, $this->stringContains('public $useTable = \'bake_odd\'')); - - $this->Task->execute(); - } - -/** - * test that odd tablenames arent inflected back from modelname - * - * @return void - */ - public function testExecuteIntoBakeOddTables() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ModelTask', - array('in', 'err', '_stop', '_checkUnitTest', 'getAllTables', '_getModelObject', 'bake', 'bakeFixture'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeOdd'); - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('getAllTables')->will($this->returnValue(array('articles', 'bake_odd'))); - $object = new Model(array('name' => 'BakeOdd', 'table' => 'bake_odd', 'ds' => 'test')); - $this->Task->expects($this->once())->method('_getModelObject')->with('BakeOdd', 'bake_odd')->will($this->returnValue($object)); - $this->Task->expects($this->once())->method('bake')->with($object, false)->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('bakeFixture')->with('BakeOdd', 'bake_odd'); - - $this->Task->execute(); - - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Task = $this->getMock('ModelTask', - array('in', 'err', '_stop', '_checkUnitTest', 'getAllTables', '_getModelObject', 'doAssociations', 'doValidation', 'createFile'), - array($out, $out, $in) - ); - $this->_setupOtherMocks(); - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('BakeOdd'); - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('getAllTables')->will($this->returnValue(array('articles', 'bake_odd'))); - $object = new Model(array('name' => 'BakeOdd', 'table' => 'bake_odd', 'ds' => 'test')); - $this->Task->expects($this->once())->method('_getModelObject')->will($this->returnValue($object)); - $this->Task->expects($this->once())->method('doAssociations')->will($this->returnValue(array())); - $this->Task->expects($this->once())->method('doValidation')->will($this->returnValue(array())); - - $filename = '/my/path/BakeOdd.php'; - $this->Task->expects($this->once())->method('createFile') - ->with($filename, $this->stringContains('class BakeOdd')); - - $filename = '/my/path/BakeOdd.php'; - $this->Task->expects($this->once())->method('createFile') - ->with($filename, $this->stringContains('public $useTable = \'bake_odd\'')); - - $this->Task->execute(); - } - -/** - * test that skipTables changes how all() works. - * - * @return void - */ - public function testSkipTablesAndAll() { - $count = count($this->Task->listAll('test')); - if ($count != count($this->fixtures)) { - $this->markTestSkipped('Additional tables detected.'); - } - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->args = array('all'); - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(true)); - $this->Task->skipTables = array('bake_tags'); - - $this->Task->Fixture->expects($this->exactly(4))->method('bake'); - $this->Task->Test->expects($this->exactly(4))->method('bake'); - - $filename = '/my/path/BakeArticle.php'; - $this->Task->expects($this->at(1))->method('createFile') - ->with($filename, $this->stringContains('class BakeArticle')); - - $filename = '/my/path/BakeArticlesBakeTag.php'; - $this->Task->expects($this->at(2))->method('createFile') - ->with($filename, $this->stringContains('class BakeArticlesBakeTag')); - - $filename = '/my/path/BakeComment.php'; - $this->Task->expects($this->at(3))->method('createFile') - ->with($filename, $this->stringContains('class BakeComment')); - - $filename = '/my/path/CategoryThread.php'; - $this->Task->expects($this->at(4))->method('createFile') - ->with($filename, $this->stringContains('class CategoryThread')); - - $this->Task->execute(); - } - -/** - * test the interactive side of bake. - * - * @return void - */ - public function testExecuteIntoInteractive() { - $tables = $this->Task->listAll('test'); - $article = array_search('bake_articles', $tables) + 1; - - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - $this->Task->interactive = true; - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls( - $article, // article - 'n', // no validation - 'y', // associations - 'y', // comment relation - 'y', // user relation - 'y', // tag relation - 'n', // additional assocs - 'y' // looks good? - )); - $this->Task->expects($this->once())->method('_checkUnitTest')->will($this->returnValue(true)); - - $this->Task->Test->expects($this->once())->method('bake'); - $this->Task->Fixture->expects($this->once())->method('bake'); - - $filename = '/my/path/BakeArticle.php'; - - $this->Task->expects($this->once())->method('createFile') - ->with($filename, $this->stringContains('class BakeArticle')); - - $this->Task->execute(); - - $this->assertEquals(count(ClassRegistry::keys()), 0); - $this->assertEquals(count(ClassRegistry::mapKeys()), 0); - } - -/** - * test using bake interactively with a table that does not exist. - * - * @return void - */ - public function testExecuteWithNonExistantTableName() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls( - 'Foobar', // Or type in the name of the model - 'y', // Do you want to use this table - 'n' // Doesn't exist, continue anyway? - )); - - $this->Task->execute(); - } - -/** - * test using bake interactively with a table that does not exist. - * - * @return void - */ - public function testForcedExecuteWithNonExistantTableName() { - $this->Task->connection = 'test'; - $this->Task->path = '/my/path/'; - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls( - 'Foobar', // Or type in the name of the model - 'y', // Do you want to use this table - 'y', // Doesn't exist, continue anyway? - 'id', // Primary key - 'y' // Looks good? - )); - - $this->Task->execute(); - } - -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/PluginTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/PluginTaskTest.php deleted file mode 100644 index 3a02a04792d..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/PluginTaskTest.php +++ /dev/null @@ -1,193 +0,0 @@ -out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $this->in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('PluginTask', - array('in', 'err', 'createFile', '_stop', 'clear'), - array($this->out, $this->out, $this->in) - ); - $this->Task->path = TMP . 'tests' . DS; - - $this->_paths = $paths = App::path('plugins'); - foreach ($paths as $i => $p) { - if (!is_dir($p)) { - array_splice($paths, $i, 1); - } - } - $this->_testPath = array_push($paths, TMP . 'tests' . DS); - App::build(array('plugins' => $paths)); - } - -/** - * test bake() - * - * @return void - */ - public function testBakeFoldersAndFiles() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue($this->_testPath)); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue('y')); - - $path = $this->Task->path . 'BakeTestPlugin'; - - $file = $path . DS . 'Controller' . DS . 'BakeTestPluginAppController.php'; - $this->Task->expects($this->at(2))->method('createFile') - ->with($file, new PHPUnit_Framework_Constraint_IsAnything()); - - $file = $path . DS . 'Model' . DS . 'BakeTestPluginAppModel.php'; - $this->Task->expects($this->at(3))->method('createFile') - ->with($file, new PHPUnit_Framework_Constraint_IsAnything()); - - $this->Task->bake('BakeTestPlugin'); - - $path = $this->Task->path . 'BakeTestPlugin'; - $this->assertTrue(is_dir($path), 'No plugin dir %s'); - - $directories = array( - 'Config' . DS . 'Schema', - 'Model' . DS . 'Behavior', - 'Model' . DS . 'Datasource', - 'Console' . DS . 'Command' . DS . 'Task', - 'Controller' . DS . 'Component', - 'Lib', - 'View' . DS . 'Helper', - 'Test' . DS . 'Case' . DS . 'Controller' . DS . 'Component', - 'Test' . DS . 'Case' . DS . 'View' . DS . 'Helper', - 'Test' . DS . 'Case' . DS . 'Model' . DS . 'Behavior', - 'Test' . DS . 'Fixture', - 'Vendor', - 'webroot' - ); - foreach ($directories as $dir) { - $this->assertTrue(is_dir($path . DS . $dir), 'Missing directory for ' . $dir); - } - - $Folder = new Folder($this->Task->path . 'BakeTestPlugin'); - $Folder->delete(); - } - -/** - * test execute with no args, flowing into interactive, - * - * @return void - */ - public function testExecuteWithNoArgs() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('TestPlugin')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue($this->_testPath)); - $this->Task->expects($this->at(2))->method('in')->will($this->returnValue('y')); - - $path = $this->Task->path . 'TestPlugin'; - $file = $path . DS . 'Controller' . DS . 'TestPluginAppController.php'; - - $this->Task->expects($this->at(3))->method('createFile') - ->with($file, new PHPUnit_Framework_Constraint_IsAnything()); - - $file = $path . DS . 'Model' . DS . 'TestPluginAppModel.php'; - $this->Task->expects($this->at(4))->method('createFile') - ->with($file, new PHPUnit_Framework_Constraint_IsAnything()); - - $this->Task->args = array(); - $this->Task->execute(); - - $Folder = new Folder($path); - $Folder->delete(); - } - -/** - * Test Execute - * - * @return void - */ - public function testExecuteWithOneArg() { - $this->Task->expects($this->at(0))->method('in') - ->will($this->returnValue($this->_testPath)); - $this->Task->expects($this->at(1))->method('in') - ->will($this->returnValue('y')); - - $path = $this->Task->path . 'BakeTestPlugin'; - $file = $path . DS . 'Controller' . DS . 'BakeTestPluginAppController.php'; - $this->Task->expects($this->at(2))->method('createFile') - ->with($file, new PHPUnit_Framework_Constraint_IsAnything()); - - $path = $this->Task->path . 'BakeTestPlugin'; - $file = $path . DS . 'Model' . DS . 'BakeTestPluginAppModel.php'; - $this->Task->expects($this->at(3))->method('createFile') - ->with($file, new PHPUnit_Framework_Constraint_IsAnything()); - - $this->Task->args = array('BakeTestPlugin'); - - $this->Task->execute(); - - $Folder = new Folder($this->Task->path . 'BakeTestPlugin'); - $Folder->delete(); - } - -/** - * Test that findPath ignores paths that don't exist. - * - * @return void - */ - public function testFindPathNonExistant() { - $paths = App::path('plugins'); - $last = count($paths); - $paths[] = '/fake/path'; - - $this->Task = $this->getMock('PluginTask', - array('in', 'out', 'err', 'createFile', '_stop'), - array($this->out, $this->out, $this->in) - ); - $this->Task->path = TMP . 'tests' . DS; - - // Make sure the added path is filtered out. - $this->Task->expects($this->exactly($last)) - ->method('out'); - - $this->Task->expects($this->once()) - ->method('in') - ->will($this->returnValue($last)); - - $this->Task->findPath($paths); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/ProjectTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/ProjectTaskTest.php deleted file mode 100644 index 8fd1b33f081..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/ProjectTaskTest.php +++ /dev/null @@ -1,369 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('ProjectTask', - array('in', 'err', 'createFile', '_stop'), - array($out, $out, $in) - ); - $this->Task->path = TMP . 'tests' . DS; - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - - $Folder = new Folder($this->Task->path . 'bake_test_app'); - $Folder->delete(); - unset($this->Task); - } - -/** - * creates a test project that is used for testing project task. - * - * @return void - */ - protected function _setupTestProject() { - $skel = CAKE . 'Console' . DS . 'Templates' . DS . 'skel'; - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->bake($this->Task->path . 'bake_test_app', $skel); - } - -/** - * test bake() method and directory creation. - * - * @return void - */ - public function testBake() { - $this->_setupTestProject(); - $path = $this->Task->path . 'bake_test_app'; - - $this->assertTrue(is_dir($path), 'No project dir %s'); - $dirs = array( - 'Config', - 'Config' . DS . 'Schema', - 'Console', - 'Console' . DS . 'Command', - 'Console' . DS . 'Templates', - 'Console' . DS . 'Command' . DS . 'Task', - 'Controller', - 'Controller' . DS . 'Component', - 'Locale', - 'Model', - 'Model' . DS . 'Behavior', - 'Model' . DS . 'Datasource', - 'Plugin', - 'Test', - 'Test' . DS . 'Case', - 'Test' . DS . 'Case' . DS . 'Controller', - 'Test' . DS . 'Case' . DS . 'Controller' . DS . 'Component', - 'Test' . DS . 'Case' . DS . 'Model', - 'Test' . DS . 'Case' . DS . 'Model' . DS . 'Behavior', - 'Test' . DS . 'Fixture', - 'Vendor', - 'View', - 'View' . DS . 'Helper', - 'tmp', - 'tmp' . DS . 'cache', - 'tmp' . DS . 'cache' . DS . 'models', - 'tmp' . DS . 'cache' . DS . 'persistent', - 'tmp' . DS . 'cache' . DS . 'views', - 'tmp' . DS . 'logs', - 'tmp' . DS . 'sessions', - 'tmp' . DS . 'tests', - 'webroot', - 'webroot' . DS . 'css', - 'webroot' . DS . 'files', - 'webroot' . DS . 'img', - 'webroot' . DS . 'js', - - ); - foreach ($dirs as $dir) { - $this->assertTrue(is_dir($path . DS . $dir), 'Missing ' . $dir); - } - } - -/** - * test bake with an absolute path. - * - * @return void - */ - public function testExecuteWithAbsolutePath() { - $path = $this->Task->args[0] = TMP . 'tests' . DS . 'bake_test_app'; - $this->Task->params['skel'] = CAKE . 'Console' . DS . 'Templates' . DS . 'skel'; - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->execute(); - - $this->assertTrue(is_dir($this->Task->args[0]), 'No project dir'); - $File = new File($path . DS . 'webroot' . DS . 'index.php'); - $contents = $File->read(); - $this->assertRegExp('/define\(\'CAKE_CORE_INCLUDE_PATH\', .*?DS/', $contents); - $File = new File($path . DS . 'webroot' . DS . 'test.php'); - $contents = $File->read(); - $this->assertRegExp('/define\(\'CAKE_CORE_INCLUDE_PATH\', .*?DS/', $contents); - } - -/** - * test bake with CakePHP on the include path. The constants should remain commented out. - * - * @return void - */ - public function testExecuteWithCakeOnIncludePath() { - if (!function_exists('ini_set')) { - $this->markTestAsSkipped('Not access to ini_set, cannot proceed.'); - } - $restore = ini_get('include_path'); - ini_set('include_path', CAKE_CORE_INCLUDE_PATH . PATH_SEPARATOR . $restore); - - $path = $this->Task->args[0] = TMP . 'tests' . DS . 'bake_test_app'; - $this->Task->params['skel'] = CAKE . 'Console' . DS . 'Templates' . DS . 'skel'; - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->execute(); - - $this->assertTrue(is_dir($this->Task->args[0]), 'No project dir'); - $contents = file_get_contents($path . DS . 'webroot' . DS . 'index.php'); - $this->assertRegExp('#//define\(\'CAKE_CORE_INCLUDE_PATH#', $contents); - - $contents = file_get_contents($path . DS . 'webroot' . DS . 'test.php'); - $this->assertRegExp('#//define\(\'CAKE_CORE_INCLUDE_PATH#', $contents); - - ini_set('include_path', $restore); - } - -/** - * test bake() method with -empty flag, directory creation and empty files. - * - * @return void - */ - public function testBakeEmptyFlag() { - $this->Task->params['empty'] = true; - $this->_setupTestProject(); - $path = $this->Task->path . 'bake_test_app'; - - $empty = array( - 'Console' . DS . 'Command' . DS . 'Task' => 'empty', - 'Controller' . DS . 'Component' => 'empty', - 'Lib' => 'empty', - 'Model' . DS . 'Behavior' => 'empty', - 'Model' . DS . 'Datasource' => 'empty', - 'Plugin' => 'empty', - 'Test' . DS . 'Case' . DS . 'Model' . DS . 'Behavior' => 'empty', - 'Test' . DS . 'Case' . DS . 'Controller' . DS . 'Component' => 'empty', - 'Test' . DS . 'Case' . DS . 'View' . DS . 'Helper' => 'empty', - 'Test' . DS . 'Fixture' => 'empty', - 'Vendor' => 'empty', - 'View' . DS . 'Elements' => 'empty', - 'View' . DS . 'Scaffolds' => 'empty', - 'tmp' . DS . 'cache' . DS . 'models' => 'empty', - 'tmp' . DS . 'cache' . DS . 'persistent' => 'empty', - 'tmp' . DS . 'cache' . DS . 'views' => 'empty', - 'tmp' . DS . 'logs' => 'empty', - 'tmp' . DS . 'sessions' => 'empty', - 'tmp' . DS . 'tests' => 'empty', - 'webroot' . DS . 'js' => 'empty', - 'webroot' . DS . 'files' => 'empty' - ); - - foreach ($empty as $dir => $file) { - $this->assertTrue(is_file($path . DS . $dir . DS . $file), sprintf('Missing %s file in %s', $file, $dir)); - } - } - -/** - * test generation of Security.salt - * - * @return void - */ - public function testSecuritySaltGeneration() { - $this->_setupTestProject(); - - $path = $this->Task->path . 'bake_test_app' . DS; - $result = $this->Task->securitySalt($path); - $this->assertTrue($result); - - $File = new File($path . 'Config' . DS . 'core.php'); - $contents = $File->read(); - $this->assertNotRegExp('/DYhG93b0qyJfIxfs2guVoUubWwvniR2G0FgaC9mi/', $contents, 'Default Salt left behind. %s'); - } - -/** - * test generation of Security.cipherSeed - * - * @return void - */ - public function testSecurityCipherSeedGeneration() { - $this->_setupTestProject(); - - $path = $this->Task->path . 'bake_test_app' . DS; - $result = $this->Task->securityCipherSeed($path); - $this->assertTrue($result); - - $File = new File($path . 'Config' . DS . 'core.php'); - $contents = $File->read(); - $this->assertNotRegExp('/76859309657453542496749683645/', $contents, 'Default CipherSeed left behind. %s'); - } - -/** - * Test that index.php is generated correctly. - * - * @return void - */ - public function testIndexPhpGeneration() { - $this->_setupTestProject(); - - $path = $this->Task->path . 'bake_test_app' . DS; - $this->Task->corePath($path); - - $File = new File($path . 'webroot' . DS . 'index.php'); - $contents = $File->read(); - $this->assertNotRegExp('/define\(\'CAKE_CORE_INCLUDE_PATH\', ROOT/', $contents); - $File = new File($path . 'webroot' . DS . 'test.php'); - $contents = $File->read(); - $this->assertNotRegExp('/define\(\'CAKE_CORE_INCLUDE_PATH\', ROOT/', $contents); - } - -/** - * test getPrefix method, and that it returns Routing.prefix or writes to config file. - * - * @return void - */ - public function testGetPrefix() { - Configure::write('Routing.prefixes', array('admin')); - $result = $this->Task->getPrefix(); - $this->assertEquals('admin_', $result); - - Configure::write('Routing.prefixes', null); - $this->_setupTestProject(); - $this->Task->configPath = $this->Task->path . 'bake_test_app' . DS . 'Config' . DS; - $this->Task->expects($this->once())->method('in')->will($this->returnValue('super_duper_admin')); - - $result = $this->Task->getPrefix(); - $this->assertEquals('super_duper_admin_', $result); - - $File = new File($this->Task->configPath . 'core.php'); - $File->delete(); - } - -/** - * test cakeAdmin() writing core.php - * - * @return void - */ - public function testCakeAdmin() { - $File = new File(APP . 'Config' . DS . 'core.php'); - $contents = $File->read(); - $File = new File(TMP . 'tests' . DS . 'core.php'); - $File->write($contents); - - Configure::write('Routing.prefixes', null); - $this->Task->configPath = TMP . 'tests' . DS; - $result = $this->Task->cakeAdmin('my_prefix'); - $this->assertTrue($result); - - $this->assertEquals(Configure::read('Routing.prefixes'), array('my_prefix')); - $File->delete(); - } - -/** - * test getting the prefix with more than one prefix setup - * - * @return void - */ - public function testGetPrefixWithMultiplePrefixes() { - Configure::write('Routing.prefixes', array('admin', 'ninja', 'shinobi')); - $this->_setupTestProject(); - $this->Task->configPath = $this->Task->path . 'bake_test_app' . DS . 'Config' . DS; - $this->Task->expects($this->once())->method('in')->will($this->returnValue(2)); - - $result = $this->Task->getPrefix(); - $this->assertEquals('ninja_', $result); - } - -/** - * Test execute method with one param to destination folder. - * - * @return void - */ - public function testExecute() { - $this->Task->params['skel'] = CAKE . 'Console' . DS . 'Templates' . DS . 'skel'; - $this->Task->params['working'] = TMP . 'tests' . DS; - - $path = $this->Task->path . 'bake_test_app'; - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue($path)); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue('y')); - - $this->Task->execute(); - $this->assertTrue(is_dir($path), 'No project dir'); - $this->assertTrue(is_dir($path . DS . 'Controller'), 'No controllers dir '); - $this->assertTrue(is_dir($path . DS . 'Controller' . DS . 'Component'), 'No components dir '); - $this->assertTrue(is_dir($path . DS . 'Model'), 'No models dir'); - $this->assertTrue(is_dir($path . DS . 'View'), 'No views dir'); - $this->assertTrue(is_dir($path . DS . 'View' . DS . 'Helper'), 'No helpers dir'); - $this->assertTrue(is_dir($path . DS . 'Test'), 'No tests dir'); - $this->assertTrue(is_dir($path . DS . 'Test' . DS . 'Case'), 'No cases dir'); - $this->assertTrue(is_dir($path . DS . 'Test' . DS . 'Fixture'), 'No fixtures dir'); - } - -/** - * test console path - * - * @return void - */ - public function testConsolePath() { - $this->_setupTestProject(); - - $path = $this->Task->path . 'bake_test_app' . DS; - $result = $this->Task->consolePath($path); - $this->assertTrue($result); - - $File = new File($path . 'Console' . DS . 'cake.php'); - $contents = $File->read(); - $this->assertNotRegExp('/__CAKE_PATH__/', $contents, 'Console path placeholder left behind.'); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/TemplateTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/TemplateTaskTest.php deleted file mode 100644 index ad458936019..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/TemplateTaskTest.php +++ /dev/null @@ -1,165 +0,0 @@ -getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('TemplateTask', - array('in', 'err', 'createFile', '_stop', 'clear'), - array($out, $out, $in) - ); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Task); - } - -/** - * test that set sets variables - * - * @return void - */ - public function testSet() { - $this->Task->set('one', 'two'); - $this->assertTrue(isset($this->Task->templateVars['one'])); - $this->assertEquals('two', $this->Task->templateVars['one']); - - $this->Task->set(array('one' => 'three', 'four' => 'five')); - $this->assertTrue(isset($this->Task->templateVars['one'])); - $this->assertEquals('three', $this->Task->templateVars['one']); - $this->assertTrue(isset($this->Task->templateVars['four'])); - $this->assertEquals('five', $this->Task->templateVars['four']); - - $this->Task->templateVars = array(); - $this->Task->set(array(3 => 'three', 4 => 'four')); - $this->Task->set(array(1 => 'one', 2 => 'two')); - $expected = array(3 => 'three', 4 => 'four', 1 => 'one', 2 => 'two'); - $this->assertEquals($expected, $this->Task->templateVars); - } - -/** - * test finding themes installed in - * - * @return void - */ - public function testFindingInstalledThemesForBake() { - $consoleLibs = CAKE . 'Console' . DS; - $this->Task->initialize(); - $this->assertEquals($this->Task->templatePaths['default'], $consoleLibs . 'Templates' . DS . 'default' . DS); - } - -/** - * test getting the correct theme name. Ensure that with only one theme, or a theme param - * that the user is not bugged. If there are more, find and return the correct theme name - * - * @return void - */ - public function testGetThemePath() { - $defaultTheme = CAKE . 'Console' . DS . 'Templates' . DS . 'default' . DS; - $this->Task->templatePaths = array('default' => $defaultTheme); - - $this->Task->expects($this->exactly(1))->method('in')->will($this->returnValue('1')); - - $result = $this->Task->getThemePath(); - $this->assertEquals($defaultTheme, $result); - - $this->Task->templatePaths = array('default' => $defaultTheme, 'other' => '/some/path'); - $this->Task->params['theme'] = 'other'; - $result = $this->Task->getThemePath(); - $this->assertEquals('/some/path', $result); - - $this->Task->params = array(); - $result = $this->Task->getThemePath(); - $this->assertEquals($defaultTheme, $result); - $this->assertEquals('default', $this->Task->params['theme']); - } - -/** - * test generate - * - * @return void - */ - public function testGenerate() { - App::build(array( - 'Console' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Console' . DS - ) - )); - $this->Task->initialize(); - $this->Task->expects($this->any())->method('in')->will($this->returnValue(1)); - - $result = $this->Task->generate('classes', 'test_object', array('test' => 'foo')); - $expected = "I got rendered\nfoo"; - $this->assertEquals($expected, $result); - } - -/** - * test generate with a missing template in the chosen theme. - * ensure fallback to default works. - * - * @return void - */ - public function testGenerateWithTemplateFallbacks() { - App::build(array( - 'Console' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Console' . DS, - CAKE_CORE_INCLUDE_PATH . DS . 'console' . DS - ) - )); - $this->Task->initialize(); - $this->Task->params['theme'] = 'test'; - $this->Task->set(array( - 'model' => 'Article', - 'table' => 'articles', - 'import' => false, - 'records' => false, - 'schema' => '' - )); - $result = $this->Task->generate('classes', 'fixture'); - $this->assertRegExp('/ArticleFixture extends CakeTestFixture/', $result); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/TestTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/TestTaskTest.php deleted file mode 100644 index 767fc4e3522..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/TestTaskTest.php +++ /dev/null @@ -1,727 +0,0 @@ - array( - 'className' => 'TestTask.TestTaskComment', - 'foreignKey' => 'article_id', - ) - ); - -/** - * Has and Belongs To Many Associations - * - * @var array - */ - public $hasAndBelongsToMany = array( - 'Tag' => array( - 'className' => 'TestTaskTag', - 'joinTable' => 'articles_tags', - 'foreignKey' => 'article_id', - 'associationForeignKey' => 'tag_id' - ) - ); - -/** - * Example public method - * - * @return void - */ - public function doSomething() { - } - -/** - * Example Secondary public method - * - * @return void - */ - public function doSomethingElse() { - } - -/** - * Example protected method - * - * @return void - */ - protected function _innerMethod() { - } - -} - -/** - * Tag Testing Model - * - * @package Cake.Test.Case.Console.Command.Task - * @package Cake.Test.Case.Console.Command.Task - */ -class TestTaskTag extends Model { - -/** - * Model name - * - * @var string - */ - public $name = 'TestTaskTag'; - -/** - * Table name - * - * @var string - */ - public $useTable = 'tags'; - -/** - * Has and Belongs To Many Associations - * - * @var array - */ - public $hasAndBelongsToMany = array( - 'Article' => array( - 'className' => 'TestTaskArticle', - 'joinTable' => 'articles_tags', - 'foreignKey' => 'tag_id', - 'associationForeignKey' => 'article_id' - ) - ); -} - -/** - * Simulated plugin - * - * @package Cake.Test.Case.Console.Command.Task - * @package Cake.Test.Case.Console.Command.Task - */ -class TestTaskAppModel extends Model { -} - -/** - * Testing AppMode (TaskComment) - * - * @package Cake.Test.Case.Console.Command.Task - * @package Cake.Test.Case.Console.Command.Task - */ -class TestTaskComment extends TestTaskAppModel { - -/** - * Model name - * - * @var string - */ - public $name = 'TestTaskComment'; - -/** - * Table name - * - * @var string - */ - public $useTable = 'comments'; - -/** - * Belongs To Associations - * - * @var array - */ - public $belongsTo = array( - 'Article' => array( - 'className' => 'TestTaskArticle', - 'foreignKey' => 'article_id', - ) - ); -} - -/** - * Test Task Comments Controller - * - * @package Cake.Test.Case.Console.Command.Task - * @package Cake.Test.Case.Console.Command.Task - */ -class TestTaskCommentsController extends Controller { - -/** - * Controller Name - * - * @var string - */ - public $name = 'TestTaskComments'; - -/** - * Models to use - * - * @var array - */ - public $uses = array('TestTaskComment', 'TestTaskTag'); -} - -/** - * TestTaskTest class - * - * @package Cake.Test.Case.Console.Command.Task - */ -class TestTaskTest extends CakeTestCase { - -/** - * Fixtures - * - * @var string - */ - public $fixtures = array('core.article', 'core.comment', 'core.articles_tag', 'core.tag'); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('TestTask', - array('in', 'err', 'createFile', '_stop', 'isLoadableClass'), - array($out, $out, $in) - ); - $this->Task->name = 'Test'; - $this->Task->Template = new TemplateTask($out, $out, $in); - } - -/** - * endTest method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Task); - CakePlugin::unload(); - } - -/** - * Test that file path generation doesn't continuously append paths. - * - * @return void - */ - public function testFilePathGenerationModelRepeated() { - $this->Task->expects($this->never())->method('err'); - $this->Task->expects($this->never())->method('_stop'); - - $file = TESTS . 'Case' . DS . 'Model' . DS . 'MyClassTest.php'; - - $this->Task->expects($this->at(1))->method('createFile') - ->with($file, $this->anything()); - - $this->Task->expects($this->at(3))->method('createFile') - ->with($file, $this->anything()); - - $file = TESTS . 'Case' . DS . 'Controller' . DS . 'CommentsControllerTest.php'; - $this->Task->expects($this->at(5))->method('createFile') - ->with($file, $this->anything()); - - $this->Task->bake('Model', 'MyClass'); - $this->Task->bake('Model', 'MyClass'); - $this->Task->bake('Controller', 'Comments'); - } - -/** - * Test that method introspection pulls all relevant non parent class - * methods into the test case. - * - * @return void - */ - public function testMethodIntrospection() { - $result = $this->Task->getTestableMethods('TestTaskArticle'); - $expected = array('dosomething', 'dosomethingelse'); - $this->assertEquals($expected, array_map('strtolower', $result)); - } - -/** - * test that the generation of fixtures works correctly. - * - * @return void - */ - public function testFixtureArrayGenerationFromModel() { - $subject = ClassRegistry::init('TestTaskArticle'); - $result = $this->Task->generateFixtureList($subject); - $expected = array('plugin.test_task.test_task_comment', 'app.articles_tags', - 'app.test_task_article', 'app.test_task_tag'); - - $this->assertEquals(sort($expected), sort($result)); - } - -/** - * test that the generation of fixtures works correctly. - * - * @return void - */ - public function testFixtureArrayGenerationFromController() { - $subject = new TestTaskCommentsController(); - $result = $this->Task->generateFixtureList($subject); - $expected = array('plugin.test_task.test_task_comment', 'app.articles_tags', - 'app.test_task_article', 'app.test_task_tag'); - - $this->assertEquals(sort($expected), sort($result)); - } - -/** - * test user interaction to get object type - * - * @return void - */ - public function testGetObjectType() { - $this->Task->expects($this->once())->method('_stop'); - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('q')); - $this->Task->expects($this->at(2))->method('in')->will($this->returnValue(2)); - - $this->Task->getObjectType(); - - $result = $this->Task->getObjectType(); - $this->assertEquals($this->Task->classTypes['Controller'], $result); - } - -/** - * creating test subjects should clear the registry so the registry is always fresh - * - * @return void - */ - public function testRegistryClearWhenBuildingTestObjects() { - ClassRegistry::flush(); - $model = ClassRegistry::init('TestTaskComment'); - $model->bindModel(array( - 'belongsTo' => array( - 'Random' => array( - 'className' => 'TestTaskArticle', - 'foreignKey' => 'article_id', - ) - ) - )); - $keys = ClassRegistry::keys(); - $this->assertTrue(in_array('test_task_comment', $keys)); - $object = $this->Task->buildTestSubject('Model', 'TestTaskComment'); - - $keys = ClassRegistry::keys(); - $this->assertFalse(in_array('random', $keys)); - } - -/** - * test that getClassName returns the user choice as a classname. - * - * @return void - */ - public function testGetClassName() { - $objects = App::objects('model'); - $this->skipIf(empty($objects), 'No models in app.'); - - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('MyCustomClass')); - $this->Task->expects($this->at(1))->method('in')->will($this->returnValue(1)); - - $result = $this->Task->getClassName('Model'); - $this->assertEquals('MyCustomClass', $result); - - $result = $this->Task->getClassName('Model'); - $options = App::objects('model'); - $this->assertEquals($options[0], $result); - } - -/** - * Test the user interaction for defining additional fixtures. - * - * @return void - */ - public function testGetUserFixtures() { - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('y')); - $this->Task->expects($this->at(1))->method('in') - ->will($this->returnValue('app.pizza, app.topping, app.side_dish')); - - $result = $this->Task->getUserFixtures(); - $expected = array('app.pizza', 'app.topping', 'app.side_dish'); - $this->assertEquals($expected, $result); - } - -/** - * test that resolving classnames works - * - * @return void - */ - public function testGetRealClassname() { - $result = $this->Task->getRealClassname('Model', 'Post'); - $this->assertEquals('Post', $result); - - $result = $this->Task->getRealClassname('Controller', 'Posts'); - $this->assertEquals('PostsController', $result); - - $result = $this->Task->getRealClassname('Controller', 'PostsController'); - $this->assertEquals('PostsController', $result); - - $result = $this->Task->getRealClassname('Controller', 'AlertTypes'); - $this->assertEquals('AlertTypesController', $result); - - $result = $this->Task->getRealClassname('Helper', 'Form'); - $this->assertEquals('FormHelper', $result); - - $result = $this->Task->getRealClassname('Helper', 'FormHelper'); - $this->assertEquals('FormHelper', $result); - - $result = $this->Task->getRealClassname('Behavior', 'Containable'); - $this->assertEquals('ContainableBehavior', $result); - - $result = $this->Task->getRealClassname('Behavior', 'ContainableBehavior'); - $this->assertEquals('ContainableBehavior', $result); - - $result = $this->Task->getRealClassname('Component', 'Auth'); - $this->assertEquals('AuthComponent', $result); - } - -/** - * test baking files. The conditionally run tests are known to fail in PHP4 - * as PHP4 classnames are all lower case, breaking the plugin path inflection. - * - * @return void - */ - public function testBakeModelTest() { - $this->Task->expects($this->once())->method('createFile')->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('isLoadableClass')->will($this->returnValue(true)); - - $result = $this->Task->bake('Model', 'TestTaskArticle'); - - $this->assertContains("App::uses('TestTaskArticle', 'Model')", $result); - $this->assertContains('class TestTaskArticleTestCase extends CakeTestCase', $result); - - $this->assertContains('function setUp()', $result); - $this->assertContains("\$this->TestTaskArticle = ClassRegistry::init('TestTaskArticle')", $result); - - $this->assertContains('function tearDown()', $result); - $this->assertContains('unset($this->TestTaskArticle)', $result); - - $this->assertContains('function testDoSomething()', $result); - $this->assertContains('function testDoSomethingElse()', $result); - - $this->assertContains("'app.test_task_article'", $result); - $this->assertContains("'plugin.test_task.test_task_comment'", $result); - $this->assertContains("'app.test_task_tag'", $result); - $this->assertContains("'app.articles_tag'", $result); - } - -/** - * test baking controller test files, ensure that the stub class is generated. - * Conditional assertion is known to fail on PHP4 as classnames are all lower case - * causing issues with inflection of path name from classname. - * - * @return void - */ - public function testBakeControllerTest() { - $this->Task->expects($this->once())->method('createFile')->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('isLoadableClass')->will($this->returnValue(true)); - - $result = $this->Task->bake('Controller', 'TestTaskComments'); - - $this->assertContains("App::uses('TestTaskCommentsController', 'Controller')", $result); - $this->assertContains('class TestTaskCommentsControllerTestCase extends CakeTestCase', $result); - - $this->assertContains('class TestTestTaskCommentsController extends TestTaskCommentsController', $result); - $this->assertContains('public $autoRender = false', $result); - $this->assertContains('function redirect($url, $status = null, $exit = true)', $result); - - $this->assertContains('function setUp()', $result); - $this->assertContains("\$this->TestTaskComments = new TestTestTaskCommentsController()", $result); - $this->assertContains("\$this->TestTaskComments->constructClasses()", $result); - - $this->assertContains('function tearDown()', $result); - $this->assertContains('unset($this->TestTaskComments)', $result); - - $this->assertContains("'app.test_task_article'", $result); - $this->assertContains("'plugin.test_task.test_task_comment'", $result); - $this->assertContains("'app.test_task_tag'", $result); - $this->assertContains("'app.articles_tag'", $result); - } - -/** - * test Constructor generation ensure that constructClasses is called for controllers - * - * @return void - */ - public function testGenerateConstructor() { - $result = $this->Task->generateConstructor('controller', 'PostsController'); - $expected = array('', "new TestPostsController();\n", "\$this->Posts->constructClasses();\n"); - $this->assertEquals($expected, $result); - - $result = $this->Task->generateConstructor('model', 'Post'); - $expected = array('', "ClassRegistry::init('Post');\n", ''); - $this->assertEquals($expected, $result); - - $result = $this->Task->generateConstructor('helper', 'FormHelper'); - $expected = array("\$View = new View();\n", "new FormHelper(\$View);\n", ''); - $this->assertEquals($expected, $result); - } - -/** - * Test generateUses() - */ - public function testGenerateUses() { - $result = $this->Task->generateUses('model', 'Model', 'Post'); - $expected = array( - array('Post', 'Model') - ); - $this->assertEquals($expected, $result); - - $result = $this->Task->generateUses('controller', 'Controller', 'PostsController'); - $expected = array( - array('PostsController', 'Controller') - ); - $this->assertEquals($expected, $result); - - $result = $this->Task->generateUses('helper', 'View/Helper', 'FormHelper'); - $expected = array( - array('View', 'View'), - array('Helper', 'View'), - array('FormHelper', 'View/Helper'), - ); - $this->assertEquals($expected, $result); - - $result = $this->Task->generateUses('component', 'Controller/Component', 'AuthComponent'); - $expected = array( - array('ComponentCollection', 'Controller'), - array('Component', 'Controller'), - array('AuthComponent', 'Controller/Component') - ); - $this->assertEquals($expected, $result); - } - -/** - * Test that mock class generation works for the appropriate classes - * - * @return void - */ - public function testMockClassGeneration() { - $result = $this->Task->hasMockClass('controller'); - $this->assertTrue($result); - } - -/** - * test bake() with a -plugin param - * - * @return void - */ - public function testBakeWithPlugin() { - $this->Task->plugin = 'TestTest'; - - //fake plugin path - CakePlugin::load('TestTest', array('path' => APP . 'Plugin' . DS . 'TestTest' . DS)); - $path = APP . 'Plugin' . DS . 'TestTest' . DS . 'Test' . DS . 'Case' . DS . 'View' . DS . 'Helper' . DS . 'FormHelperTest.php'; - $this->Task->expects($this->once())->method('createFile') - ->with($path, $this->anything()); - - $this->Task->bake('Helper', 'Form'); - CakePlugin::unload(); - } - -/** - * test interactive with plugins lists from the plugin - * - * @return void - */ - public function testInteractiveWithPlugin() { - $testApp = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS; - App::build(array( - 'Plugin' => array($testApp) - ), App::RESET); - CakePlugin::load('TestPlugin'); - - $this->Task->plugin = 'TestPlugin'; - $path = $testApp . 'TestPlugin' . DS . 'Test' . DS . 'Case' . DS . 'View' . DS . 'Helper' . DS . 'OtherHelperTest.php'; - $this->Task->expects($this->any()) - ->method('in') - ->will($this->onConsecutiveCalls( - 5, //helper - 1 //OtherHelper - )); - - $this->Task->expects($this->once()) - ->method('createFile') - ->with($path, $this->anything()); - - $this->Task->stdout->expects($this->at(21)) - ->method('write') - ->with('1. OtherHelperHelper'); - - $this->Task->execute(); - } - - public static function caseFileNameProvider() { - return array( - array('Model', 'Post', 'Case' . DS . 'Model' . DS . 'PostTest.php'), - array('Helper', 'Form', 'Case' . DS . 'View' . DS . 'Helper' . DS . 'FormHelperTest.php'), - array('Controller', 'Posts', 'Case' . DS . 'Controller' . DS . 'PostsControllerTest.php'), - array('Behavior', 'Containable', 'Case' . DS . 'Model' . DS . 'Behavior' . DS . 'ContainableBehaviorTest.php'), - array('Component', 'Auth', 'Case' . DS . 'Controller' . DS . 'Component' . DS . 'AuthComponentTest.php'), - array('model', 'Post', 'Case' . DS . 'Model' . DS . 'PostTest.php'), - array('helper', 'Form', 'Case' . DS . 'View' . DS . 'Helper' . DS . 'FormHelperTest.php'), - array('controller', 'Posts', 'Case' . DS . 'Controller' . DS . 'PostsControllerTest.php'), - array('behavior', 'Containable', 'Case' . DS . 'Model' . DS . 'Behavior' . DS . 'ContainableBehaviorTest.php'), - array('component', 'Auth', 'Case' . DS . 'Controller' . DS . 'Component' . DS . 'AuthComponentTest.php'), - ); - } - -/** - * Test filename generation for each type + plugins - * - * @dataProvider caseFileNameProvider - * @return void - */ - public function testTestCaseFileName($type, $class, $expected) { - $this->Task->path = DS . 'my' . DS . 'path' . DS . 'tests' . DS; - - $result = $this->Task->testCaseFileName($type, $class); - $expected = $this->Task->path . $expected; - $this->assertEquals($expected, $result); - } - -/** - * Test filename generation for plugins. - * - * @return void - */ - public function testTestCaseFileNamePlugin() { - $this->Task->path = DS . 'my' . DS . 'path' . DS . 'tests' . DS; - - CakePlugin::load('TestTest', array('path' => APP . 'Plugin' . DS . 'TestTest' . DS )); - $this->Task->plugin = 'TestTest'; - $result = $this->Task->testCaseFileName('Model', 'Post'); - $expected = APP . 'Plugin' . DS . 'TestTest' . DS . 'Test' . DS . 'Case' . DS . 'Model' . DS . 'PostTest.php'; - $this->assertEquals($expected, $result); - } - -/** - * test execute with a type defined - * - * @return void - */ - public function testExecuteWithOneArg() { - $this->Task->args[0] = 'Model'; - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('TestTaskTag')); - $this->Task->expects($this->once())->method('isLoadableClass')->will($this->returnValue(true)); - $this->Task->expects($this->once())->method('createFile') - ->with( - $this->anything(), - $this->stringContains('class TestTaskTagTestCase extends CakeTestCase') - ); - $this->Task->execute(); - } - -/** - * test execute with type and class name defined - * - * @return void - */ - public function testExecuteWithTwoArgs() { - $this->Task->args = array('Model', 'TestTaskTag'); - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('TestTaskTag')); - $this->Task->expects($this->once())->method('createFile') - ->with( - $this->anything(), - $this->stringContains('class TestTaskTagTestCase extends CakeTestCase') - ); - $this->Task->expects($this->any())->method('isLoadableClass')->will($this->returnValue(true)); - $this->Task->execute(); - } - -/** - * test execute with type and class name defined and lower case. - * - * @return void - */ - public function testExecuteWithTwoArgsLowerCase() { - $this->Task->args = array('model', 'TestTaskTag'); - $this->Task->expects($this->at(0))->method('in')->will($this->returnValue('TestTaskTag')); - $this->Task->expects($this->once())->method('createFile') - ->with( - $this->anything(), - $this->stringContains('class TestTaskTagTestCase extends CakeTestCase') - ); - $this->Task->expects($this->any())->method('isLoadableClass')->will($this->returnValue(true)); - $this->Task->execute(); - } - -/** - * Data provider for mapType() tests. - * - * @return array - */ - public static function mapTypeProvider() { - return array( - array('controller', null, 'Controller'), - array('Controller', null, 'Controller'), - array('component', null, 'Controller/Component'), - array('Component', null, 'Controller/Component'), - array('model', null, 'Model'), - array('Model', null, 'Model'), - array('behavior', null, 'Model/Behavior'), - array('Behavior', null, 'Model/Behavior'), - array('helper', null, 'View/Helper'), - array('Helper', null, 'View/Helper'), - array('Helper', 'DebugKit', 'DebugKit.View/Helper'), - ); - } - -/** - * Test that mapType returns the correct package names. - * - * @dataProvider mapTypeProvider - * @return void - */ - public function testMapType($original, $plugin, $expected) { - $this->assertEquals($expected, $this->Task->mapType($original, $plugin)); - } -} diff --git a/lib/Cake/Test/Case/Console/Command/Task/ViewTaskTest.php b/lib/Cake/Test/Case/Console/Command/Task/ViewTaskTest.php deleted file mode 100644 index 486544320d4..00000000000 --- a/lib/Cake/Test/Case/Console/Command/Task/ViewTaskTest.php +++ /dev/null @@ -1,731 +0,0 @@ - array( - 'className' => 'TestTest.ViewTaskArticle', - 'foreignKey' => 'article_id' - ) - ); -} - -/** - * Test View Task Article Model - * - * @package Cake.Test.Case.Console.Command.Task - * @package Cake.Test.Case.Console.Command.Task - */ -class ViewTaskArticle extends Model { - -/** - * Model name - * - * @var string - */ - public $name = 'ViewTaskArticle'; - -/** - * Table name - * - * @var string - */ - public $useTable = 'articles'; -} - -/** - * Test View Task Comments Controller - * - * @package Cake.Test.Case.Console.Command.Task - * @package Cake.Test.Case.Console.Command.Task - */ -class ViewTaskCommentsController extends Controller { - -/** - * Controller name - * - * @var string - */ - public $name = 'ViewTaskComments'; - -/** - * Testing public controller action - * - * @return void - */ - public function index() { - } - -/** - * Testing public controller action - * - * @return void - */ - public function add() { - } - -} - -/** - * Test View Task Articles Controller - * - * @package Cake.Test.Case.Console.Command.Task - * @package Cake.Test.Case.Console.Command.Task - */ -class ViewTaskArticlesController extends Controller { - -/** - * Controller name - * - * @var string - */ - public $name = 'ViewTaskArticles'; - -/** - * Test public controller action - * - * @return void - */ - public function index() { - } - -/** - * Test public controller action - * - * @return void - */ - public function add() { - } - -/** - * Test admin prefixed controller action - * - * @return void - */ - public function admin_index() { - } - -/** - * Test admin prefixed controller action - * - * @return void - */ - public function admin_add() { - } - -/** - * Test admin prefixed controller action - * - * @return void - */ - public function admin_view() { - } - -/** - * Test admin prefixed controller action - * - * @return void - */ - public function admin_edit() { - } - -/** - * Test admin prefixed controller action - * - * @return void - */ - public function admin_delete() { - } - -} - -/** - * ViewTaskTest class - * - * @package Cake.Test.Case.Console.Command.Task - */ -class ViewTaskTest extends CakeTestCase { - -/** - * Fixtures - * - * @var array - */ - public $fixtures = array('core.article', 'core.comment', 'core.articles_tag', 'core.tag'); - -/** - * setUp method - * - * Ensure that the default theme is used - * - * @return void - */ - public function setUp() { - parent::setUp(); - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Task = $this->getMock('ViewTask', - array('in', 'err', 'createFile', '_stop'), - array($out, $out, $in) - ); - $this->Task->Template = new TemplateTask($out, $out, $in); - $this->Task->Controller = $this->getMock('ControllerTask', array(), array($out, $out, $in)); - $this->Task->Project = $this->getMock('ProjectTask', array(), array($out, $out, $in)); - $this->Task->DbConfig = $this->getMock('DbConfigTask', array(), array($out, $out, $in)); - - $this->Task->path = TMP; - $this->Task->Template->params['theme'] = 'default'; - $this->Task->Template->templatePaths = array('default' => CAKE . 'Console' . DS . 'Templates' . DS . 'default' . DS); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Task, $this->Dispatch); - } - -/** - * Test getContent and parsing of Templates. - * - * @return void - */ - public function testGetContent() { - $vars = array( - 'modelClass' => 'TestViewModel', - 'schema' => array(), - 'primaryKey' => 'id', - 'displayField' => 'name', - 'singularVar' => 'testViewModel', - 'pluralVar' => 'testViewModels', - 'singularHumanName' => 'Test View Model', - 'pluralHumanName' => 'Test View Models', - 'fields' => array('id', 'name', 'body'), - 'associations' => array() - ); - $result = $this->Task->getContent('view', $vars); - - $this->assertRegExp('/Delete Test View Model/', $result); - $this->assertRegExp('/Edit Test View Model/', $result); - $this->assertRegExp('/List Test View Models/', $result); - $this->assertRegExp('/New Test View Model/', $result); - - $this->assertRegExp('/testViewModel\[\'TestViewModel\'\]\[\'id\'\]/', $result); - $this->assertRegExp('/testViewModel\[\'TestViewModel\'\]\[\'name\'\]/', $result); - $this->assertRegExp('/testViewModel\[\'TestViewModel\'\]\[\'body\'\]/', $result); - } - -/** - * test getContent() using an admin_prefixed action. - * - * @return void - */ - public function testGetContentWithAdminAction() { - $_back = Configure::read('Routing'); - Configure::write('Routing.prefixes', array('admin')); - $vars = array( - 'modelClass' => 'TestViewModel', - 'schema' => array(), - 'primaryKey' => 'id', - 'displayField' => 'name', - 'singularVar' => 'testViewModel', - 'pluralVar' => 'testViewModels', - 'singularHumanName' => 'Test View Model', - 'pluralHumanName' => 'Test View Models', - 'fields' => array('id', 'name', 'body'), - 'associations' => array() - ); - $result = $this->Task->getContent('admin_view', $vars); - - $this->assertRegExp('/Delete Test View Model/', $result); - $this->assertRegExp('/Edit Test View Model/', $result); - $this->assertRegExp('/List Test View Models/', $result); - $this->assertRegExp('/New Test View Model/', $result); - - $this->assertRegExp('/testViewModel\[\'TestViewModel\'\]\[\'id\'\]/', $result); - $this->assertRegExp('/testViewModel\[\'TestViewModel\'\]\[\'name\'\]/', $result); - $this->assertRegExp('/testViewModel\[\'TestViewModel\'\]\[\'body\'\]/', $result); - - $result = $this->Task->getContent('admin_add', $vars); - $this->assertRegExp("/input\('name'\)/", $result); - $this->assertRegExp("/input\('body'\)/", $result); - $this->assertRegExp('/List Test View Models/', $result); - - Configure::write('Routing', $_back); - } - -/** - * test Bake method - * - * @return void - */ - public function testBakeView() { - $this->Task->controllerName = 'ViewTaskComments'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'view.ctp', - $this->stringContains('View Task Articles') - ); - - $this->Task->bake('view', true); - } - -/** - * test baking an edit file - * - * @return void - */ - public function testBakeEdit() { - $this->Task->controllerName = 'ViewTaskComments'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'edit.ctp', - new PHPUnit_Framework_Constraint_IsAnything() - ); - $this->Task->bake('edit', true); - } - -/** - * test baking an index - * - * @return void - */ - public function testBakeIndex() { - $this->Task->controllerName = 'ViewTaskComments'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'index.ctp', - $this->stringContains("\$viewTaskComment['Article']['title']") - ); - $this->Task->bake('index', true); - } - -/** - * test that baking a view with no template doesn't make a file. - * - * @return void - */ - public function testBakeWithNoTemplate() { - $this->Task->controllerName = 'ViewTaskComments'; - - $this->Task->expects($this->never())->method('createFile'); - $this->Task->bake('delete', true); - } - -/** - * test bake() with a -plugin param - * - * @return void - */ - public function testBakeWithPlugin() { - $this->Task->controllerName = 'ViewTaskComments'; - $this->Task->plugin = 'TestTest'; - $this->Task->name = 'View'; - - //fake plugin path - CakePlugin::load('TestTest', array('path' => APP . 'Plugin' . DS . 'TestTest' . DS)); - $path = APP . 'Plugin' . DS . 'TestTest' . DS . 'View' . DS . 'ViewTaskComments' . DS . 'view.ctp'; - - $result = $this->Task->getContent('index'); - $this->assertNotContains('List Test Test.view Task Articles', $result); - - $this->Task->expects($this->once()) - ->method('createFile') - ->with($path, $this->anything()); - - $this->Task->bake('view', true); - CakePlugin::unload(); - } - -/** - * test bake actions baking multiple actions. - * - * @return void - */ - public function testBakeActions() { - $this->Task->controllerName = 'ViewTaskComments'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'view.ctp', - $this->stringContains('View Task Comments') - ); - $this->Task->expects($this->at(1))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'edit.ctp', - $this->stringContains('Edit View Task Comment') - ); - $this->Task->expects($this->at(2))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'index.ctp', - $this->stringContains('ViewTaskComment') - ); - - $this->Task->bakeActions(array('view', 'edit', 'index'), array()); - } - -/** - * test baking a customAction (non crud) - * - * @return void - */ - public function testCustomAction() { - $this->Task->controllerName = 'ViewTaskComments'; - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('', 'my_action', 'y')); - - $this->Task->expects($this->once())->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'my_action.ctp', - $this->anything() - ); - - $this->Task->customAction(); - } - -/** - * Test all() - * - * @return void - */ - public function testExecuteIntoAll() { - $this->Task->args[0] = 'all'; - - $this->Task->Controller->expects($this->once())->method('listAll') - ->will($this->returnValue(array('view_task_comments'))); - - $this->Task->expects($this->at(0))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'index.ctp', - $this->anything() - ); - $this->Task->expects($this->at(1))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'add.ctp', - $this->anything() - ); - $this->Task->expects($this->exactly(2))->method('createFile'); - - $this->Task->execute(); - } - -/** - * Test all() with action parameter - * - * @return void - */ - public function testExecuteIntoAllWithActionName() { - $this->Task->args = array('all', 'index'); - - $this->Task->Controller->expects($this->once())->method('listAll') - ->will($this->returnValue(array('view_task_comments'))); - - $this->Task->expects($this->once())->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'index.ctp', - $this->anything() - ); - - $this->Task->execute(); - } - -/** - * test `cake bake view $controller view` - * - * @return void - */ - public function testExecuteWithActionParam() { - $this->Task->args[0] = 'ViewTaskComments'; - $this->Task->args[1] = 'view'; - - $this->Task->expects($this->once())->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'view.ctp', - $this->anything() - ); - $this->Task->execute(); - } - -/** - * test `cake bake view $controller` - * Ensure that views are only baked for actions that exist in the controller. - * - * @return void - */ - public function testExecuteWithController() { - $this->Task->args[0] = 'ViewTaskComments'; - - $this->Task->expects($this->at(0))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'index.ctp', - $this->anything() - ); - $this->Task->expects($this->at(1))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'add.ctp', - $this->anything() - ); - $this->Task->expects($this->exactly(2))->method('createFile'); - - $this->Task->execute(); - } - -/** - * static dataprovider for test cases - * - * @return void - */ - public static function nameVariations() { - return array(array('ViewTaskComments'), array('ViewTaskComment'), array('view_task_comment')); - } - -/** - * test that both plural and singular forms can be used for baking views. - * - * @dataProvider nameVariations - * @return void - */ - public function testExecuteWithControllerVariations($name) { - $this->Task->args = array($name); - - $this->Task->expects($this->at(0))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'index.ctp', - $this->anything() - ); - $this->Task->expects($this->at(1))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'add.ctp', - $this->anything() - ); - $this->Task->execute(); - } - -/** - * test `cake bake view $controller --admin` - * Which only bakes admin methods, not non-admin methods. - * - * @return void - */ - public function testExecuteWithControllerAndAdminFlag() { - $_back = Configure::read('Routing'); - Configure::write('Routing.prefixes', array('admin')); - $this->Task->args[0] = 'ViewTaskArticles'; - $this->Task->params['admin'] = 1; - - $this->Task->Project->expects($this->any())->method('getPrefix')->will($this->returnValue('admin_')); - - $this->Task->expects($this->exactly(4))->method('createFile'); - - $views = array('admin_index.ctp', 'admin_add.ctp', 'admin_view.ctp', 'admin_edit.ctp'); - foreach ($views as $i => $view) { - $this->Task->expects($this->at($i))->method('createFile') - ->with( - TMP . 'ViewTaskArticles' . DS . $view, - $this->anything() - ); - } - $this->Task->execute(); - Configure::write('Routing', $_back); - } - -/** - * test execute into interactive. - * - * @return void - */ - public function testExecuteInteractive() { - $this->Task->connection = 'test'; - $this->Task->args = array(); - $this->Task->params = array(); - - $this->Task->Controller->expects($this->once())->method('getName') - ->will($this->returnValue('ViewTaskComments')); - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('y', 'y', 'n')); - - $this->Task->expects($this->at(3))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'index.ctp', - $this->stringContains('ViewTaskComment') - ); - - $this->Task->expects($this->at(4))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'view.ctp', - $this->stringContains('ViewTaskComment') - ); - - $this->Task->expects($this->at(5))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'add.ctp', - $this->stringContains('Add View Task Comment') - ); - - $this->Task->expects($this->at(6))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'edit.ctp', - $this->stringContains('Edit View Task Comment') - ); - - $this->Task->expects($this->exactly(4))->method('createFile'); - $this->Task->execute(); - } - -/** - * test `cake bake view posts index list` - * - * @return void - */ - public function testExecuteWithAlternateTemplates() { - $this->Task->connection = 'test'; - $this->Task->args = array('ViewTaskComments', 'index', 'list'); - $this->Task->params = array(); - - $this->Task->expects($this->once())->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'list.ctp', - $this->stringContains('ViewTaskComment') - ); - $this->Task->execute(); - } - -/** - * test execute into interactive() with admin methods. - * - * @return void - */ - public function testExecuteInteractiveWithAdmin() { - Configure::write('Routing.prefixes', array('admin')); - $this->Task->connection = 'test'; - $this->Task->args = array(); - - $this->Task->Controller->expects($this->once())->method('getName') - ->will($this->returnValue('ViewTaskComments')); - - $this->Task->Project->expects($this->once())->method('getPrefix') - ->will($this->returnValue('admin_')); - - $this->Task->expects($this->any())->method('in') - ->will($this->onConsecutiveCalls('y', 'n', 'y')); - - $this->Task->expects($this->at(3))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'admin_index.ctp', - $this->stringContains('ViewTaskComment') - ); - - $this->Task->expects($this->at(4))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'admin_view.ctp', - $this->stringContains('ViewTaskComment') - ); - - $this->Task->expects($this->at(5))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'admin_add.ctp', - $this->stringContains('Add View Task Comment') - ); - - $this->Task->expects($this->at(6))->method('createFile') - ->with( - TMP . 'ViewTaskComments' . DS . 'admin_edit.ctp', - $this->stringContains('Edit View Task Comment') - ); - - $this->Task->expects($this->exactly(4))->method('createFile'); - $this->Task->execute(); - } - -/** - * test getting templates, make sure noTemplateActions works and prefixed template is used before generic one. - * - * @return void - */ - public function testGetTemplate() { - $result = $this->Task->getTemplate('delete'); - $this->assertFalse($result); - - $result = $this->Task->getTemplate('add'); - $this->assertEquals('form', $result); - - Configure::write('Routing.prefixes', array('admin')); - - $result = $this->Task->getTemplate('admin_add'); - $this->assertEquals('form', $result); - - $this->Task->Template->templatePaths = array( - 'test' => CAKE . 'Test' . DS . 'test_app' . DS . 'Console' . DS . 'Templates' . DS . 'test' . DS - ); - $this->Task->Template->params['theme'] = 'test'; - - $result = $this->Task->getTemplate('admin_edit'); - $this->assertEquals('admin_edit', $result); - } - -} diff --git a/lib/Cake/Test/Case/Console/Command/TestShellTest.php b/lib/Cake/Test/Case/Console/Command/TestShellTest.php deleted file mode 100644 index 8fc619bde77..00000000000 --- a/lib/Cake/Test/Case/Console/Command/TestShellTest.php +++ /dev/null @@ -1,333 +0,0 @@ -_mapFileToCase($file, $category, $throwOnMissingFile); - } - - public function mapFileToCategory($file) { - return $this->_mapFileToCategory($file); - } - -} - -class TestShellTest extends CakeTestCase { - -/** - * setUp test case - * - * @return void - */ - public function setUp() { - $out = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - - $this->Shell = $this->getMock( - 'TestTestShell', - array('in', 'out', 'hr', 'help', 'error', 'err', '_stop', 'initialize', '_run', 'clear'), - array($out, $out, $in) - ); - $this->Shell->OptionParser = $this->getMock('ConsoleOptionParser', array(), array(null, false)); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - unset($this->Dispatch, $this->Shell); - } - -/** - * testMapCoreFileToCategory - * - * @return void - */ - public function testMapCoreFileToCategory() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCategory('lib/Cake/basics.php'); - $this->assertSame('core', $return); - - $return = $this->Shell->mapFileToCategory('lib/Cake/Core/App.php'); - $this->assertSame('core', $return); - - $return = $this->Shell->mapFileToCategory('lib/Cake/Some/Deeply/Nested/Structure.php'); - $this->assertSame('core', $return); - } - -/** - * testMapCoreFileToCase - * - * basics.php is a slightly special case - it's the only file in the core with a test that isn't Capitalized - * - * @return void - */ - public function testMapCoreFileToCase() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCase('lib/Cake/basics.php', 'core'); - $this->assertSame('Basics', $return); - - $return = $this->Shell->mapFileToCase('lib/Cake/Core/App.php', 'core'); - $this->assertSame('Core/App', $return); - - $return = $this->Shell->mapFileToCase('lib/Cake/Some/Deeply/Nested/Structure.php', 'core', false); - $this->assertSame('Some/Deeply/Nested/Structure', $return); - } - -/** - * testMapAppFileToCategory - * - * @return void - */ - public function testMapAppFileToCategory() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCategory(APP . 'Controller/ExampleController.php'); - $this->assertSame('app', $return); - - $return = $this->Shell->mapFileToCategory(APP . 'My/File/Is/Here.php'); - $this->assertSame('app', $return); - } - -/** - * testMapAppFileToCase - * - * @return void - */ - public function testMapAppFileToCase() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCase(APP . 'Controller/ExampleController.php', 'app', false); - $this->assertSame('Controller/ExampleController', $return); - - $return = $this->Shell->mapFileToCase(APP . 'My/File/Is/Here.php', 'app', false); - $this->assertSame('My/File/Is/Here', $return); - } - -/** - * testMapPluginFileToCategory - * - * @return void - */ - public function testMapPluginFileToCategory() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCategory(APP . 'Plugin/awesome/Controller/ExampleController.php'); - $this->assertSame('awesome', $return); - - $return = $this->Shell->mapFileToCategory(dirname(CAKE) . 'plugins/awesome/Controller/ExampleController.php'); - $this->assertSame('awesome', $return); - } - -/** - * testMapPluginFileToCase - * - * @return void - */ - public function testMapPluginFileToCase() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCase(APP . 'Plugin/awesome/Controller/ExampleController.php', 'awesome', false); - $this->assertSame('Controller/ExampleController', $return); - - $return = $this->Shell->mapFileToCase(dirname(CAKE) . 'plugins/awesome/Controller/ExampleController.php', 'awesome', false); - $this->assertSame('Controller/ExampleController', $return); - } - -/** - * testMapCoreTestToCategory - * - * @return void - */ - public function testMapCoreTestToCategory() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCategory('lib/Cake/Test/Case/BasicsTest.php'); - $this->assertSame('core', $return); - - $return = $this->Shell->mapFileToCategory('lib/Cake/Test/Case/BasicsTest.php'); - $this->assertSame('core', $return); - - $return = $this->Shell->mapFileToCategory('lib/Cake/Test/Case/Some/Deeply/Nested/StructureTest.php'); - $this->assertSame('core', $return); - } - -/** - * testMapCoreTestToCase - * - * basics.php is a slightly special case - it's the only file in the core with a test that isn't Capitalized - * - * @return void - */ - public function testMapCoreTestToCase() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCase('lib/Cake/Test/Case/BasicsTest.php', 'core'); - $this->assertSame('Basics', $return); - - $return = $this->Shell->mapFileToCase('lib/Cake/Test/Case/Core/AppTest.php', 'core'); - $this->assertSame('Core/App', $return); - - $return = $this->Shell->mapFileToCase('lib/Cake/Test/Case/Some/Deeply/Nested/StructureTest.php', 'core', false); - $this->assertSame('Some/Deeply/Nested/Structure', $return); - } - -/** - * testMapAppTestToCategory - * - * @return void - */ - public function testMapAppTestToCategory() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCategory(APP . 'Test/Case/Controller/ExampleControllerTest.php'); - $this->assertSame('app', $return); - - $return = $this->Shell->mapFileToCategory(APP . 'Test/Case/My/File/Is/HereTest.php'); - $this->assertSame('app', $return); - } - -/** - * testMapAppTestToCase - * - * @return void - */ - public function testMapAppTestToCase() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCase(APP . 'Test/Case/Controller/ExampleControllerTest.php', 'app', false); - $this->assertSame('Controller/ExampleController', $return); - - $return = $this->Shell->mapFileToCase(APP . 'Test/Case/My/File/Is/HereTest.php', 'app', false); - $this->assertSame('My/File/Is/Here', $return); - } - -/** - * testMapPluginTestToCategory - * - * @return void - */ - public function testMapPluginTestToCategory() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCategory(APP . 'Plugin/awesome/Test/Case/Controller/ExampleControllerTest.php'); - $this->assertSame('awesome', $return); - - $return = $this->Shell->mapFileToCategory(dirname(CAKE) . 'plugins/awesome/Test/Case/Controller/ExampleControllerTest.php'); - $this->assertSame('awesome', $return); - } - -/** - * testMapPluginTestToCase - * - * @return void - */ - public function testMapPluginTestToCase() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCase(APP . 'Plugin/awesome/Test/Case/Controller/ExampleControllerTest.php', 'awesome', false); - $this->assertSame('Controller/ExampleController', $return); - - $return = $this->Shell->mapFileToCase(dirname(CAKE) . 'plugins/awesome/Test/Case/Controller/ExampleControllerTest.php', 'awesome', false); - $this->assertSame('Controller/ExampleController', $return); - } - -/** - * testMapNotTestToNothing - * - * @return void - */ - public function testMapNotTestToNothing() { - $this->Shell->startup(); - - $return = $this->Shell->mapFileToCategory(APP . 'Test/Case/NotATestFile.php'); - $this->assertSame('app', $return); - - $return = $this->Shell->mapFileToCase(APP . 'Test/Case/NotATestFile.php', false, false); - $this->assertFalse($return); - - $return = $this->Shell->mapFileToCategory(APP . 'Test/Fixture/SomeTest.php'); - $this->assertSame('app', $return); - - $return = $this->Shell->mapFileToCase(APP . 'Test/Fixture/SomeTest.php', false, false); - $this->assertFalse($return); - } - -/** - * test available list of test cases for an empty category - * - * @return void - */ - public function testAvailableWithEmptyList() { - $this->Shell->startup(); - $this->Shell->args = array('unexistant-category'); - $this->Shell->expects($this->at(0))->method('out')->with(__d('cake_console', "No test cases available \n\n")); - $this->Shell->OptionParser->expects($this->once())->method('help'); - $this->Shell->available(); - } - -/** - * test available list of test cases for core category - * - * @return void - */ - public function testAvailableCoreCategory() { - $this->Shell->startup(); - $this->Shell->args = array('core'); - $this->Shell->expects($this->at(0))->method('out')->with('Core Test Cases:'); - $this->Shell->expects($this->at(1))->method('out') - ->with($this->stringContains('[1]')); - $this->Shell->expects($this->at(2))->method('out') - ->with($this->stringContains('[2]')); - - $this->Shell->expects($this->once())->method('in') - ->with(__d('cake_console', 'What test case would you like to run?'), null, 'q') - ->will($this->returnValue('1')); - - $this->Shell->expects($this->once())->method('_run'); - $this->Shell->available(); - $this->assertEquals(array('core', 'AllBehaviors'), $this->Shell->args); - } - -/** - * Tests that correct option for test runner are passed - * - * @return void - */ - public function testRunnerOptions() { - $this->Shell->startup(); - $this->Shell->args = array('core', 'Basics'); - $this->Shell->params = array('filter' => 'myFilter', 'colors' => true, 'verbose' => true); - - $this->Shell->expects($this->once())->method('_run') - ->with( - array('app' => false, 'plugin' => null, 'core' => true, 'output' => 'text', 'case' => 'Basics'), - array('--filter', 'myFilter', '--colors', '--verbose') - ); - $this->Shell->main(); - } -} diff --git a/lib/Cake/Test/Case/Console/ConsoleErrorHandlerTest.php b/lib/Cake/Test/Case/Console/ConsoleErrorHandlerTest.php deleted file mode 100644 index ef24dcbcfce..00000000000 --- a/lib/Cake/Test/Case/Console/ConsoleErrorHandlerTest.php +++ /dev/null @@ -1,134 +0,0 @@ -Error = $this->getMock('ConsoleErrorHandler', array('_stop')); - ConsoleErrorHandler::$stderr = $this->getMock('ConsoleOutput', array(), array(), '', false); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - unset($this->Error); - parent::tearDown(); - } - -/** - * test that the console error handler can deal with CakeExceptions. - * - * @return void - */ - public function testHandleError() { - $content = "Notice Error: This is a notice error in [/some/file, line 275]\n"; - ConsoleErrorHandler::$stderr->expects($this->once())->method('write') - ->with($content); - - $this->Error->handleError(E_NOTICE, 'This is a notice error', '/some/file', 275); - } - -/** - * test that the console error handler can deal with CakeExceptions. - * - * @return void - */ - public function testCakeErrors() { - $exception = new MissingActionException('Missing action'); - ConsoleErrorHandler::$stderr->expects($this->once())->method('write') - ->with($this->stringContains('Missing action')); - - $this->Error->expects($this->once()) - ->method('_stop') - ->with(404); - - $this->Error->handleException($exception); - } - -/** - * test a non CakeException exception. - * - * @return void - */ - public function testNonCakeExceptions() { - $exception = new InvalidArgumentException('Too many parameters.'); - - ConsoleErrorHandler::$stderr->expects($this->once())->method('write') - ->with($this->stringContains('Too many parameters.')); - - $this->Error->expects($this->once()) - ->method('_stop') - ->with(1); - - $this->Error->handleException($exception); - } - -/** - * test a Error404 exception. - * - * @return void - */ - public function testError404Exception() { - $exception = new NotFoundException('dont use me in cli.'); - - ConsoleErrorHandler::$stderr->expects($this->once())->method('write') - ->with($this->stringContains('dont use me in cli.')); - - $this->Error->expects($this->once()) - ->method('_stop') - ->with(404); - - $this->Error->handleException($exception); - } - -/** - * test a Error500 exception. - * - * @return void - */ - public function testError500Exception() { - $exception = new InternalErrorException('dont use me in cli.'); - - ConsoleErrorHandler::$stderr->expects($this->once())->method('write') - ->with($this->stringContains('dont use me in cli.')); - - $this->Error->expects($this->once()) - ->method('_stop') - ->with(500); - - $this->Error->handleException($exception); - } - -} diff --git a/lib/Cake/Test/Case/Console/ConsoleOptionParserTest.php b/lib/Cake/Test/Case/Console/ConsoleOptionParserTest.php deleted file mode 100644 index a2787ffc2fb..00000000000 --- a/lib/Cake/Test/Case/Console/ConsoleOptionParserTest.php +++ /dev/null @@ -1,594 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Console - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('ConsoleOptionParser', 'Console'); - -class ConsoleOptionParserTest extends CakeTestCase { - -/** - * test setting the console description - * - * @return void - */ - public function testDescription() { - $parser = new ConsoleOptionParser('test', false); - $result = $parser->description('A test'); - - $this->assertEquals($parser, $result, 'Setting description is not chainable'); - $this->assertEquals('A test', $parser->description(), 'getting value is wrong.'); - - $result = $parser->description(array('A test', 'something')); - $this->assertEquals("A test\nsomething", $parser->description(), 'getting value is wrong.'); - } - -/** - * test setting the console epilog - * - * @return void - */ - public function testEpilog() { - $parser = new ConsoleOptionParser('test', false); - $result = $parser->epilog('A test'); - - $this->assertEquals($parser, $result, 'Setting epilog is not chainable'); - $this->assertEquals('A test', $parser->epilog(), 'getting value is wrong.'); - - $result = $parser->epilog(array('A test', 'something')); - $this->assertEquals("A test\nsomething", $parser->epilog(), 'getting value is wrong.'); - } - -/** - * test adding an option returns self. - * - * @return void - */ - public function testAddOptionReturnSelf() { - $parser = new ConsoleOptionParser('test', false); - $result = $parser->addOption('test'); - $this->assertEquals($parser, $result, 'Did not return $this from addOption'); - } - -/** - * test adding an option and using the long value for parsing. - * - * @return void - */ - public function testAddOptionLong() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array( - 'short' => 't' - )); - $result = $parser->parse(array('--test', 'value')); - $this->assertEquals(array('test' => 'value', 'help' => false), $result[0], 'Long parameter did not parse out'); - } - -/** - * test addOption with an object. - * - * @return void - */ - public function testAddOptionObject() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption(new ConsoleInputOption('test', 't')); - $result = $parser->parse(array('--test=value')); - $this->assertEquals(array('test' => 'value', 'help' => false), $result[0], 'Long parameter did not parse out'); - } - -/** - * test adding an option and using the long value for parsing. - * - * @return void - */ - public function testAddOptionLongEquals() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array( - 'short' => 't' - )); - $result = $parser->parse(array('--test=value')); - $this->assertEquals(array('test' => 'value', 'help' => false), $result[0], 'Long parameter did not parse out'); - } - -/** - * test adding an option and using the default. - * - * @return void - */ - public function testAddOptionDefault() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array( - 'default' => 'default value', - )); - $result = $parser->parse(array('--test')); - $this->assertEquals(array('test' => 'default value', 'help' => false), $result[0], 'Default value did not parse out'); - - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array( - 'default' => 'default value', - )); - $result = $parser->parse(array()); - $this->assertEquals(array('test' => 'default value', 'help' => false), $result[0], 'Default value did not parse out'); - } - -/** - * test adding an option and using the short value for parsing. - * - * @return void - */ - public function testAddOptionShort() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array( - 'short' => 't' - )); - $result = $parser->parse(array('-t', 'value')); - $this->assertEquals(array('test' => 'value', 'help' => false), $result[0], 'Short parameter did not parse out'); - } - -/** - * Test that adding an option using a two letter short value causes an exception. - * As they will not parse correctly. - * - * @expectedException ConsoleException - * @return void - */ - public function testAddOptionShortOneLetter() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array('short' => 'te')); - } - -/** - * test adding and using boolean options. - * - * @return void - */ - public function testAddOptionBoolean() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array( - 'boolean' => true, - )); - - $result = $parser->parse(array('--test', 'value')); - $expected = array(array('test' => true, 'help' => false), array('value')); - $this->assertEquals($expected, $result); - - $result = $parser->parse(array('value')); - $expected = array(array('test' => false, 'help' => false), array('value')); - $this->assertEquals($expected, $result); - } - -/** - * test adding an multiple shorts. - * - * @return void - */ - public function testAddOptionMultipleShort() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test', array('short' => 't', 'boolean' => true)) - ->addOption('file', array('short' => 'f', 'boolean' => true)) - ->addOption('output', array('short' => 'o', 'boolean' => true)); - - $result = $parser->parse(array('-o', '-t', '-f')); - $expected = array('file' => true, 'test' => true, 'output' => true, 'help' => false); - $this->assertEquals($expected, $result[0], 'Short parameter did not parse out'); - - $result = $parser->parse(array('-otf')); - $this->assertEquals($expected, $result[0], 'Short parameter did not parse out'); - } - -/** - * test multiple options at once. - * - * @return void - */ - public function testMultipleOptions() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('test') - ->addOption('connection') - ->addOption('table', array('short' => 't', 'default' => true)); - - $result = $parser->parse(array('--test', 'value', '-t', '--connection', 'postgres')); - $expected = array('test' => 'value', 'table' => true, 'connection' => 'postgres', 'help' => false); - $this->assertEquals($expected, $result[0], 'multiple options did not parse'); - } - -/** - * Test adding multiple options. - * - * @return void - */ - public function testAddOptions() { - $parser = new ConsoleOptionParser('something', false); - $result = $parser->addOptions(array( - 'name' => array('help' => 'The name'), - 'other' => array('help' => 'The other arg') - )); - $this->assertEquals($parser, $result, 'addOptions is not chainable.'); - - $result = $parser->options(); - $this->assertEquals(3, count($result), 'Not enough options'); - } - -/** - * test that boolean options work - * - * @return void - */ - public function testOptionWithBooleanParam() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('no-commit', array('boolean' => true)) - ->addOption('table', array('short' => 't')); - - $result = $parser->parse(array('--table', 'posts', '--no-commit', 'arg1', 'arg2')); - $expected = array(array('table' => 'posts', 'no-commit' => true, 'help' => false), array('arg1', 'arg2')); - $this->assertEquals($expected, $result, 'Boolean option did not parse correctly.'); - } - -/** - * test parsing options that do not exist. - * - * @expectedException ConsoleException - */ - public function testOptionThatDoesNotExist() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('no-commit', array('boolean' => true)); - - $result = $parser->parse(array('--fail', 'other')); - } - -/** - * test parsing short options that do not exist. - * - * @expectedException ConsoleException - */ - public function testShortOptionThatDoesNotExist() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('no-commit', array('boolean' => true)); - - $result = $parser->parse(array('-f')); - } - -/** - * test that options with choices enforce them. - * - * @expectedException ConsoleException - * @return void - */ - public function testOptionWithChoices() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('name', array('choices' => array('mark', 'jose'))); - - $result = $parser->parse(array('--name', 'mark')); - $expected = array('name' => 'mark', 'help' => false); - $this->assertEquals($expected, $result[0], 'Got the correct value.'); - - $result = $parser->parse(array('--name', 'jimmy')); - } - -/** - * Ensure that option values can start with - - * - * @return void - */ - public function testOptionWithValueStartingWithMinus() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('name') - ->addOption('age'); - - $result = $parser->parse(array('--name', '-foo', '--age', 'old')); - $expected = array('name' => '-foo', 'age' => 'old', 'help' => false); - $this->assertEquals($expected, $result[0], 'Option values starting with "-" are broken.'); - } - -/** - * test positional argument parsing. - * - * @return void - */ - public function testPositionalArgument() { - $parser = new ConsoleOptionParser('test', false); - $result = $parser->addArgument('name', array('help' => 'An argument')); - $this->assertEquals($parser, $result, 'Should return this'); - } - -/** - * test addOption with an object. - * - * @return void - */ - public function testAddArgumentObject() { - $parser = new ConsoleOptionParser('test', false); - $parser->addArgument(new ConsoleInputArgument('test')); - $result = $parser->arguments(); - $this->assertEquals(1, count($result)); - $this->assertEquals('test', $result[0]->name()); - } - -/** - * test overwriting positional arguments. - * - * @return void - */ - public function testPositionalArgOverwrite() { - $parser = new ConsoleOptionParser('test', false); - $parser->addArgument('name', array('help' => 'An argument')) - ->addArgument('other', array('index' => 0)); - - $result = $parser->arguments(); - $this->assertEquals(1, count($result), 'Overwrite did not occur'); - } - -/** - * test parsing arguments. - * - * @expectedException ConsoleException - * @return void - */ - public function testParseArgumentTooMany() { - $parser = new ConsoleOptionParser('test', false); - $parser->addArgument('name', array('help' => 'An argument')) - ->addArgument('other'); - - $expected = array('one', 'two'); - $result = $parser->parse($expected); - $this->assertEquals($expected, $result[1], 'Arguments are not as expected'); - - $result = $parser->parse(array('one', 'two', 'three')); - } - -/** - * test parsing arguments with 0 value. - * - * @return void - */ - public function testParseArgumentZero() { - $parser = new ConsoleOptionParser('test', false); - - $expected = array('one', 'two', 0, 'after', 'zero'); - $result = $parser->parse($expected); - $this->assertEquals($expected, $result[1], 'Arguments are not as expected'); - } - -/** - * test that when there are not enough arguments an exception is raised - * - * @expectedException ConsoleException - * @return void - */ - public function testPositionalArgNotEnough() { - $parser = new ConsoleOptionParser('test', false); - $parser->addArgument('name', array('required' => true)) - ->addArgument('other', array('required' => true)); - - $parser->parse(array('one')); - } - -/** - * test that arguments with choices enforce them. - * - * @expectedException ConsoleException - * @return void - */ - public function testPositionalArgWithChoices() { - $parser = new ConsoleOptionParser('test', false); - $parser->addArgument('name', array('choices' => array('mark', 'jose'))) - ->addArgument('alias', array('choices' => array('cowboy', 'samurai'))) - ->addArgument('weapon', array('choices' => array('gun', 'sword'))); - - $result = $parser->parse(array('mark', 'samurai', 'sword')); - $expected = array('mark', 'samurai', 'sword'); - $this->assertEquals($expected, $result[1], 'Got the correct value.'); - - $result = $parser->parse(array('jose', 'coder')); - } - -/** - * Test adding multiple arguments. - * - * @return void - */ - public function testAddArguments() { - $parser = new ConsoleOptionParser('test', false); - $result = $parser->addArguments(array( - 'name' => array('help' => 'The name'), - 'other' => array('help' => 'The other arg') - )); - $this->assertEquals($parser, $result, 'addArguments is not chainable.'); - - $result = $parser->arguments(); - $this->assertEquals(2, count($result), 'Not enough arguments'); - } - -/** - * test setting a subcommand up. - * - * @return void - */ - public function testSubcommand() { - $parser = new ConsoleOptionParser('test', false); - $result = $parser->addSubcommand('initdb', array( - 'help' => 'Initialize the database' - )); - $this->assertEquals($parser, $result, 'Adding a subcommand is not chainable'); - } - -/** - * test addSubcommand with an object. - * - * @return void - */ - public function testAddSubcommandObject() { - $parser = new ConsoleOptionParser('test', false); - $parser->addSubcommand(new ConsoleInputSubcommand('test')); - $result = $parser->subcommands(); - $this->assertEquals(1, count($result)); - $this->assertEquals('test', $result['test']->name()); - } - -/** - * test adding multiple subcommands - * - * @return void - */ - public function testAddSubcommands() { - $parser = new ConsoleOptionParser('test', false); - $result = $parser->addSubcommands(array( - 'initdb' => array('help' => 'Initialize the database'), - 'create' => array('help' => 'Create something') - )); - $this->assertEquals($parser, $result, 'Adding a subcommands is not chainable'); - $result = $parser->subcommands(); - $this->assertEquals(2, count($result), 'Not enough subcommands'); - } - -/** - * test that no exception is triggered when help is being generated - * - * @return void - */ - public function testHelpNoExceptionWhenGettingHelp() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)); - - $result = $parser->parse(array('--help')); - $this->assertTrue($result[0]['help']); - } - -/** - * test that help() with a command param shows the help for a subcommand - * - * @return void - */ - public function testHelpSubcommandHelp() { - $subParser = new ConsoleOptionParser('method', false); - $subParser->addOption('connection', array('help' => 'Db connection.')); - - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addSubcommand('method', array( - 'help' => 'This is another command', - 'parser' => $subParser - )) - ->addOption('test', array('help' => 'A test option.')); - - $result = $parser->help('method'); - $expected = <<Usage: -cake mycommand method [-h] [--connection] - -Options: - ---help, -h Display this help. ---connection Db connection. - -TEXT; - $this->assertEquals($expected, $result, 'Help is not correct.'); - } - -/** - * test building a parser from an array. - * - * @return void - */ - public function testBuildFromArray() { - $spec = array( - 'command' => 'test', - 'arguments' => array( - 'name' => array('help' => 'The name'), - 'other' => array('help' => 'The other arg') - ), - 'options' => array( - 'name' => array('help' => 'The name'), - 'other' => array('help' => 'The other arg') - ), - 'subcommands' => array( - 'initdb' => array('help' => 'make database') - ), - 'description' => 'description text', - 'epilog' => 'epilog text' - ); - $parser = ConsoleOptionParser::buildFromArray($spec); - - $this->assertEquals($spec['description'], $parser->description()); - $this->assertEquals($spec['epilog'], $parser->epilog()); - - $options = $parser->options(); - $this->assertTrue(isset($options['name'])); - $this->assertTrue(isset($options['other'])); - - $args = $parser->arguments(); - $this->assertEquals(2, count($args)); - - $commands = $parser->subcommands(); - $this->assertEquals(1, count($commands)); - } - -/** - * test that create() returns instances - * - * @return void - */ - public function testCreateFactory() { - $parser = ConsoleOptionParser::create('factory', false); - $this->assertInstanceOf('ConsoleOptionParser', $parser); - $this->assertEquals('factory', $parser->command()); - } - -/** - * test that command() inflects the command name. - * - * @return void - */ - public function testCommandInflection() { - $parser = new ConsoleOptionParser('CommandLine'); - $this->assertEquals('command_line', $parser->command()); - } - -/** - * test that parse() takes a subcommand argument, and that the subcommand parser - * is used. - * - * @return void - */ - public function testParsingWithSubParser() { - $parser = new ConsoleOptionParser('test', false); - $parser->addOption('primary') - ->addArgument('one', array('required' => true, 'choices' => array('a', 'b'))) - ->addArgument('two', array('required' => true)) - ->addSubcommand('sub', array( - 'parser' => array( - 'options' => array( - 'secondary' => array('boolean' => true), - 'fourth' => array('help' => 'fourth option') - ), - 'arguments' => array( - 'sub_arg' => array('choices' => array('c', 'd')) - ) - ) - )); - - $result = $parser->parse(array('--secondary', '--fourth', '4', 'c'), 'sub'); - $expected = array(array( - 'secondary' => true, - 'fourth' => '4', - 'help' => false, - 'verbose' => false, - 'quiet' => false), array('c')); - $this->assertEquals($expected, $result, 'Sub parser did not parse request.'); - } - -} diff --git a/lib/Cake/Test/Case/Console/ConsoleOutputTest.php b/lib/Cake/Test/Case/Console/ConsoleOutputTest.php deleted file mode 100644 index 0f7e106a3da..00000000000 --- a/lib/Cake/Test/Case/Console/ConsoleOutputTest.php +++ /dev/null @@ -1,241 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Console - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('ConsoleOutput', 'Console'); - -class ConsoleOutputTest extends CakeTestCase { - -/** - * setup - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->output = $this->getMock('ConsoleOutput', array('_write')); - $this->output->outputAs(ConsoleOutput::COLOR); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - unset($this->output); - } - -/** - * test writing with no new line - * - * @return void - */ - public function testWriteNoNewLine() { - $this->output->expects($this->once())->method('_write') - ->with('Some output'); - - $this->output->write('Some output', false); - } - -/** - * test writing with no new line - * - * @return void - */ - public function testWriteNewLine() { - $this->output->expects($this->once())->method('_write') - ->with('Some output' . PHP_EOL); - - $this->output->write('Some output'); - } - -/** - * test write() with multiple new lines - * - * @return void - */ - public function testWriteMultipleNewLines() { - $this->output->expects($this->once())->method('_write') - ->with('Some output' . PHP_EOL . PHP_EOL . PHP_EOL . PHP_EOL); - - $this->output->write('Some output', 4); - } - -/** - * test writing an array of messages. - * - * @return void - */ - public function testWriteArray() { - $this->output->expects($this->once())->method('_write') - ->with('Line' . PHP_EOL . 'Line' . PHP_EOL . 'Line' . PHP_EOL); - - $this->output->write(array('Line', 'Line', 'Line')); - } - -/** - * test getting a style. - * - * @return void - */ - public function testStylesGet() { - $result = $this->output->styles('error'); - $expected = array('text' => 'red', 'underline' => true); - $this->assertEquals($expected, $result); - - $this->assertNull($this->output->styles('made_up_goop')); - - $result = $this->output->styles(); - $this->assertNotEmpty($result, 'error', 'Error is missing'); - $this->assertNotEmpty($result, 'warning', 'Warning is missing'); - } - -/** - * test adding a style. - * - * @return void - */ - public function testStylesAdding() { - $this->output->styles('test', array('text' => 'red', 'background' => 'black')); - $result = $this->output->styles('test'); - $expected = array('text' => 'red', 'background' => 'black'); - $this->assertEquals($expected, $result); - - $this->assertTrue($this->output->styles('test', false), 'Removing a style should return true.'); - $this->assertNull($this->output->styles('test'), 'Removed styles should be null.'); - } - -/** - * test formatting text with styles. - * - * @return void - */ - public function testFormattingSimple() { - $this->output->expects($this->once())->method('_write') - ->with("\033[31;4mError:\033[0m Something bad"); - - $this->output->write('Error: Something bad', false); - } - -/** - * test that formatting doesn't eat tags it doesn't know about. - * - * @return void - */ - public function testFormattingNotEatingTags() { - $this->output->expects($this->once())->method('_write') - ->with(" Something bad"); - - $this->output->write(' Something bad', false); - } - -/** - * test formatting with custom styles. - * - * @return void - */ - public function testFormattingCustom() { - $this->output->styles('annoying', array( - 'text' => 'magenta', - 'background' => 'cyan', - 'blink' => true, - 'underline' => true - )); - - $this->output->expects($this->once())->method('_write') - ->with("\033[35;46;5;4mAnnoy:\033[0m Something bad"); - - $this->output->write('Annoy: Something bad', false); - } - -/** - * test formatting text with missing styles. - * - * @return void - */ - public function testFormattingMissingStyleName() { - $this->output->expects($this->once())->method('_write') - ->with("Error: Something bad"); - - $this->output->write('Error: Something bad', false); - } - -/** - * test formatting text with multiple styles. - * - * @return void - */ - public function testFormattingMultipleStylesName() { - $this->output->expects($this->once())->method('_write') - ->with("\033[31;4mBad\033[0m \033[33mWarning\033[0m Regular"); - - $this->output->write('Bad Warning Regular', false); - } - -/** - * test that multiple tags of the same name work in one string. - * - * @return void - */ - public function testFormattingMultipleSameTags() { - $this->output->expects($this->once())->method('_write') - ->with("\033[31;4mBad\033[0m \033[31;4mWarning\033[0m Regular"); - - $this->output->write('Bad Warning Regular', false); - } - -/** - * test raw output not getting tags replaced. - * - * @return void - */ - public function testOutputAsRaw() { - $this->output->outputAs(ConsoleOutput::RAW); - $this->output->expects($this->once())->method('_write') - ->with('Bad Regular'); - - $this->output->write('Bad Regular', false); - } - -/** - * test plain output. - * - * @return void - */ - public function testOutputAsPlain() { - $this->output->outputAs(ConsoleOutput::PLAIN); - $this->output->expects($this->once())->method('_write') - ->with('Bad Regular'); - - $this->output->write('Bad Regular', false); - } - -/** - * test plain output only strips tags used for formatting. - * - * @return void - */ - public function testOutputAsPlainSelectiveTagRemoval() { - $this->output->outputAs(ConsoleOutput::PLAIN); - $this->output->expects($this->once())->method('_write') - ->with('Bad Regular Left behind '); - - $this->output->write('Bad Regular Left behind ', false); - } -} diff --git a/lib/Cake/Test/Case/Console/HelpFormatterTest.php b/lib/Cake/Test/Case/Console/HelpFormatterTest.php deleted file mode 100644 index 1b4d953171e..00000000000 --- a/lib/Cake/Test/Case/Console/HelpFormatterTest.php +++ /dev/null @@ -1,505 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Console - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('ConsoleOptionParser', 'Console'); -App::uses('HelpFormatter', 'Console'); - -class HelpFormatterTest extends CakeTestCase { - -/** - * test that the console max width is respected when generating help. - * - * @return void - */ - public function testWidthFormatting() { - $parser = new ConsoleOptionParser('test', false); - $parser->description('This is fifteen This is fifteen This is fifteen') - ->addOption('four', array('help' => 'this is help text this is help text')) - ->addArgument('four', array('help' => 'this is help text this is help text')) - ->addSubcommand('four', array('help' => 'this is help text this is help text')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(30); - $expected = <<Usage: -cake test [subcommand] [-h] [--four] [] - -Subcommands: - -four this is help text this - is help text - -To see help on a subcommand use `cake test [subcommand] --help` - -Options: - ---help, -h Display this help. ---four this is help text - this is help text - -Arguments: - -four this is help text this - is help text - (optional) - -TEXT; - $this->assertEquals($expected, $result, 'Generated help is too wide'); - } - -/** - * test help() with options and arguments that have choices. - * - * @return void - */ - public function testHelpWithChoices() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.', 'choices' => array('one', 'two'))) - ->addArgument('type', array( - 'help' => 'Resource type.', - 'choices' => array('aco', 'aro'), - 'required' => true - )) - ->addArgument('other_longer', array('help' => 'Another argument.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(); - $expected = <<Usage: -cake mycommand [-h] [--test one|two] [] - -Options: - ---help, -h Display this help. ---test A test option. (choices: one|two) - -Arguments: - -type Resource type. (choices: aco|aro) -other_longer Another argument. (optional) - -TEXT; - $this->assertEquals($expected, $result, 'Help does not match'); - } - -/** - * test description and epilog in the help - * - * @return void - */ - public function testHelpDescriptionAndEpilog() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->description('Description text') - ->epilog('epilog text') - ->addOption('test', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(); - $expected = <<Usage: -cake mycommand [-h] [--test] - -Options: - ---help, -h Display this help. ---test A test option. - -Arguments: - -model The model to make. - -epilog text - -TEXT; - $this->assertEquals($expected, $result, 'Help is wrong.'); - } - -/** - * test that help() outputs subcommands. - * - * @return void - */ - public function testHelpSubcommand() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addSubcommand('method', array('help' => 'This is another command')) - ->addOption('test', array('help' => 'A test option.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(); - $expected = <<Usage: -cake mycommand [subcommand] [-h] [--test] - -Subcommands: - -method This is another command - -To see help on a subcommand use `cake mycommand [subcommand] --help` - -Options: - ---help, -h Display this help. ---test A test option. - -TEXT; - $this->assertEquals($expected, $result, 'Help is not correct.'); - } - -/** - * test getting help with defined options. - * - * @return void - */ - public function testHelpWithOptions() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.')) - ->addOption('connection', array( - 'short' => 'c', 'help' => 'The connection to use.', 'default' => 'default' - )); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(); - $expected = <<Usage: -cake mycommand [-h] [--test] [-c default] - -Options: - ---help, -h Display this help. ---test A test option. ---connection, -c The connection to use. (default: - default) - -TEXT; - $this->assertEquals($expected, $result, 'Help does not match'); - } - -/** - * test getting help with defined options. - * - * @return void - */ - public function testHelpWithOptionsAndArguments() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)) - ->addArgument('other_longer', array('help' => 'Another argument.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(); - $expected = <<Usage: -cake mycommand [-h] [--test] [] - -Options: - ---help, -h Display this help. ---test A test option. - -Arguments: - -model The model to make. -other_longer Another argument. (optional) - -TEXT; - $this->assertEquals($expected, $result, 'Help does not match'); - } - -/** - * Test that a long set of options doesn't make useless output. - * - * @return void - */ - public function testHelpWithLotsOfOptions() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser - ->addOption('test', array('help' => 'A test option.')) - ->addOption('test2', array('help' => 'A test option.')) - ->addOption('test3', array('help' => 'A test option.')) - ->addOption('test4', array('help' => 'A test option.')) - ->addOption('test5', array('help' => 'A test option.')) - ->addOption('test6', array('help' => 'A test option.')) - ->addOption('test7', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)) - ->addArgument('other_longer', array('help' => 'Another argument.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(); - $expected = 'cake mycommand [options] []'; - $this->assertContains($expected, $result); - } - -/** - * Test that a long set of arguments doesn't make useless output. - * - * @return void - */ - public function testHelpWithLotsOfArguments() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser - ->addArgument('test', array('help' => 'A test option.')) - ->addArgument('test2', array('help' => 'A test option.')) - ->addArgument('test3', array('help' => 'A test option.')) - ->addArgument('test4', array('help' => 'A test option.')) - ->addArgument('test5', array('help' => 'A test option.')) - ->addArgument('test6', array('help' => 'A test option.')) - ->addArgument('test7', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)) - ->addArgument('other_longer', array('help' => 'Another argument.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->text(); - $expected = 'cake mycommand [-h] [arguments]'; - $this->assertContains($expected, $result); - } - -/** - * test help() with options and arguments that have choices. - * - * @return void - */ - public function testXmlHelpWithChoices() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.', 'choices' => array('one', 'two'))) - ->addArgument('type', array( - 'help' => 'Resource type.', - 'choices' => array('aco', 'aro'), - 'required' => true - )) - ->addArgument('other_longer', array('help' => 'Another argument.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->xml(); - $expected = << - -mycommand -Description text - - - - - - - - - aco - aro - - - -epilog text - -TEXT; - $this->assertEquals(new DomDocument($expected), new DomDocument($result), 'Help does not match'); - } - -/** - * test description and epilog in the help - * - * @return void - */ - public function testXmlHelpDescriptionAndEpilog() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->description('Description text') - ->epilog('epilog text') - ->addOption('test', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)); - - $formatter = new HelpFormatter($parser); - $result = $formatter->xml(); - $expected = << - -mycommand -Description text - - - - - - - - - - -epilog text - -TEXT; - $this->assertEquals(new DomDocument($expected), new DomDocument($result), 'Help does not match'); - } - -/** - * test that help() outputs subcommands. - * - * @return void - */ - public function testXmlHelpSubcommand() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addSubcommand('method', array('help' => 'This is another command')) - ->addOption('test', array('help' => 'A test option.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->xml(); - $expected = << - -mycommand - - - - - - - - - - - -TEXT; - $this->assertEquals(new DomDocument($expected), new DomDocument($result), 'Help does not match'); - } - -/** - * test getting help with defined options. - * - * @return void - */ - public function testXmlHelpWithOptions() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.')) - ->addOption('connection', array( - 'short' => 'c', 'help' => 'The connection to use.', 'default' => 'default' - )); - - $formatter = new HelpFormatter($parser); - $result = $formatter->xml(); - $expected = << - -mycommand - - - - - - - - - - -TEXT; - $this->assertEquals(new DomDocument($expected), new DomDocument($result), 'Help does not match'); - } - -/** - * test getting help with defined options. - * - * @return void - */ - public function testXmlHelpWithOptionsAndArguments() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)) - ->addArgument('other_longer', array('help' => 'Another argument.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->xml(); - $expected = << - - mycommand - - - - - - - - - - - - - - - - -TEXT; - $this->assertEquals(new DomDocument($expected), new DomDocument($result), 'Help does not match'); - } - -/** - * Test xml help as object - * - * @return void - */ - public function testXmlHelpAsObject() { - $parser = new ConsoleOptionParser('mycommand', false); - $parser->addOption('test', array('help' => 'A test option.')) - ->addArgument('model', array('help' => 'The model to make.', 'required' => true)) - ->addArgument('other_longer', array('help' => 'Another argument.')); - - $formatter = new HelpFormatter($parser); - $result = $formatter->xml(false); - $this->assertInstanceOf('SimpleXmlElement', $result); - } -} diff --git a/lib/Cake/Test/Case/Console/ShellDispatcherTest.php b/lib/Cake/Test/Case/Console/ShellDispatcherTest.php deleted file mode 100644 index 1b3adfabd64..00000000000 --- a/lib/Cake/Test/Case/Console/ShellDispatcherTest.php +++ /dev/null @@ -1,576 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Console - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('ShellDispatcher', 'Console'); - -/** - * TestShellDispatcher class - * - * @package Cake.Test.Case.Console - */ -class TestShellDispatcher extends ShellDispatcher { - -/** - * params property - * - * @var array - */ - public $params = array(); - -/** - * stopped property - * - * @var string - */ - public $stopped = null; - -/** - * TestShell - * - * @var mixed - */ - public $TestShell; - -/** - * _initEnvironment method - * - * @return void - */ - protected function _initEnvironment() { - } - -/** - * clear method - * - * @return void - */ - public function clear() { - } - -/** - * _stop method - * - * @return void - */ - protected function _stop($status = 0) { - $this->stopped = 'Stopped with status: ' . $status; - return $status; - } - -/** - * getShell - * - * @param mixed $shell - * @return mixed - */ - public function getShell($shell) { - return $this->_getShell($shell); - } - -/** - * _getShell - * - * @param mixed $plugin - * @return mixed - */ - protected function _getShell($shell) { - if (isset($this->TestShell)) { - return $this->TestShell; - } - return parent::_getShell($shell); - } - -} - -/** - * ShellDispatcherTest - * - * @package Cake.Test.Case.Console - */ -class ShellDispatcherTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - App::build(array( - 'Plugin' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS - ), - 'Console/Command' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Console' . DS . 'Command' . DS - ) - ), App::RESET); - CakePlugin::load('TestPlugin'); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - CakePlugin::unload(); - } - -/** - * testParseParams method - * - * @return void - */ - public function testParseParams() { - $Dispatcher = new TestShellDispatcher(); - - $params = array( - '/cake/1.2.x.x/cake/console/cake.php', - 'bake', - '-app', - 'new', - '-working', - '/var/www/htdocs' - ); - $expected = array( - 'app' => 'new', - 'webroot' => 'webroot', - 'working' => str_replace('/', DS, '/var/www/htdocs/new'), - 'root' => str_replace('/', DS,'/var/www/htdocs') - ); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array('cake.php'); - $expected = array( - 'app' => 'app', - 'webroot' => 'webroot', - 'working' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH) . DS . 'app'), - 'root' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH)), - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - 'cake.php', - '-app', - 'new', - ); - $expected = array( - 'app' => 'new', - 'webroot' => 'webroot', - 'working' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH) . DS . 'new'), - 'root' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH)) - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - './cake.php', - 'bake', - '-app', - 'new', - '-working', - '/cake/1.2.x.x/cake/console' - ); - - $expected = array( - 'app' => 'new', - 'webroot' => 'webroot', - 'working' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH) . DS . 'new'), - 'root' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH)) - ); - - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - './console/cake.php', - 'bake', - '-app', - 'new', - '-working', - '/cake/1.2.x.x/cake' - ); - $expected = array( - 'app' => 'new', - 'webroot' => 'webroot', - 'working' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH) . DS . 'new'), - 'root' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH)) - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - './console/cake.php', - 'bake', - '-app', - 'new', - '-dry', - '-working', - '/cake/1.2.x.x/cake' - ); - $expected = array( - 'app' => 'new', - 'working' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH) . DS . 'new'), - 'root' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH)), - 'webroot' => 'webroot' - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - './console/cake.php', - '-working', - '/cake/1.2.x.x/cake', - 'schema', - 'run', - 'create', - '-dry', - '-f', - '-name', - 'DbAcl' - ); - $expected = array( - 'app' => 'app', - 'webroot' => 'webroot', - 'working' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH) . DS . 'app'), - 'root' => str_replace('\\', DS, dirname(CAKE_CORE_INCLUDE_PATH)), - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $expected = array( - './console/cake.php', 'schema', 'run', 'create', '-dry', '-f', '-name', 'DbAcl' - ); - $this->assertEquals($expected, $Dispatcher->args); - - $params = array( - '/cake/1.2.x.x/cake/console/cake.php', - '-working', - '/cake/1.2.x.x/app', - 'schema', - 'run', - 'create', - '-dry', - '-name', - 'DbAcl' - ); - $expected = array( - 'app' => 'app', - 'webroot' => 'webroot', - 'working' => str_replace('/', DS, '/cake/1.2.x.x/app'), - 'root' => str_replace('/', DS, '/cake/1.2.x.x'), - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - 'cake.php', - '-working', - 'C:/wamp/www/cake/app', - 'bake', - '-app', - 'C:/wamp/www/apps/cake/app', - ); - $expected = array( - 'app' => 'app', - 'webroot' => 'webroot', - 'working' => 'C:\wamp\www\apps\cake\app', - 'root' => 'C:\wamp\www\apps\cake' - ); - - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - 'cake.php', - '-working', - 'C:\wamp\www\cake\app', - 'bake', - '-app', - 'C:\wamp\www\apps\cake\app', - ); - $expected = array( - 'app' => 'app', - 'webroot' => 'webroot', - 'working' => 'C:\wamp\www\apps\cake\app', - 'root' => 'C:\wamp\www\apps\cake' - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - 'cake.php', - '-working', - 'C:\wamp\www\apps', - 'bake', - '-app', - 'cake\app', - '-url', - 'http://example.com/some/url/with/a/path' - ); - $expected = array( - 'app' => 'app', - 'webroot' => 'webroot', - 'working' => 'C:\wamp\www\apps\cake\app', - 'root' => 'C:\wamp\www\apps\cake', - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - '/home/amelo/dev/cake-common/cake/console/cake.php', - '-root', - '/home/amelo/dev/lsbu-vacancy', - '-working', - '/home/amelo/dev/lsbu-vacancy', - '-app', - 'app', - ); - $expected = array( - 'app' => 'app', - 'webroot' => 'webroot', - 'working' => '/home/amelo/dev/lsbu-vacancy/app', - 'root' => '/home/amelo/dev/lsbu-vacancy', - ); - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - $params = array( - '/cake/1.2.x.x/cake/console/cake.php', - 'bake', - '-app', - 'new', - '-app', - 'old', - '-working', - '/var/www/htdocs' - ); - $expected = array( - 'app' => 'old', - 'webroot' => 'webroot', - 'working' => str_replace('/', DS, '/var/www/htdocs/old'), - 'root' => str_replace('/', DS,'/var/www/htdocs') - ); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - - if (DS === '\\') { - $params = array( - 'cake.php', - '-working', - 'D:\www', - 'bake', - 'my_app', - ); - $expected = array( - 'working' => 'D:\\\\www', - 'app' => 'www', - 'root' => 'D:\\', - 'webroot' => 'webroot' - ); - - $Dispatcher->params = $Dispatcher->args = array(); - $Dispatcher->parseParams($params); - $this->assertEquals($expected, $Dispatcher->params); - } - } - -/** - * Verify loading of (plugin-) shells - * - * @return void - */ - public function testGetShell() { - $this->skipIf(class_exists('SampleShell'), 'SampleShell Class already loaded.'); - $this->skipIf(class_exists('ExampleShell'), 'ExampleShell Class already loaded.'); - - $Dispatcher = new TestShellDispatcher(); - - $result = $Dispatcher->getShell('sample'); - $this->assertInstanceOf('SampleShell', $result); - - $Dispatcher = new TestShellDispatcher(); - $result = $Dispatcher->getShell('test_plugin.example'); - $this->assertInstanceOf('ExampleShell', $result); - $this->assertEquals('TestPlugin', $result->plugin); - $this->assertEquals('Example', $result->name); - - $Dispatcher = new TestShellDispatcher(); - $result = $Dispatcher->getShell('TestPlugin.example'); - $this->assertInstanceOf('ExampleShell', $result); - } - -/** - * Verify correct dispatch of Shell subclasses with a main method - * - * @return void - */ - public function testDispatchShellWithMain() { - $Dispatcher = new TestShellDispatcher(); - $Mock = $this->getMock('Shell', array(), array(&$Dispatcher), 'MockWithMainShell'); - - $Mock->expects($this->once())->method('initialize'); - $Mock->expects($this->once())->method('loadTasks'); - $Mock->expects($this->once())->method('runCommand') - ->with(null, array()) - ->will($this->returnValue(true)); - - $Dispatcher->TestShell = $Mock; - - $Dispatcher->args = array('mock_with_main'); - $result = $Dispatcher->dispatch(); - $this->assertTrue($result); - $this->assertEquals(array(), $Dispatcher->args); - } - -/** - * Verify correct dispatch of Shell subclasses without a main method - * - * @return void - */ - public function testDispatchShellWithoutMain() { - $Dispatcher = new TestShellDispatcher(); - $Shell = $this->getMock('Shell', array(), array(&$Dispatcher), 'MockWithoutMainShell'); - - $Shell = new MockWithoutMainShell($Dispatcher); - $this->mockObjects[] = $Shell; - - $Shell->expects($this->once())->method('initialize'); - $Shell->expects($this->once())->method('loadTasks'); - $Shell->expects($this->once())->method('runCommand') - ->with('initdb', array('initdb')) - ->will($this->returnValue(true)); - - $Dispatcher->TestShell = $Shell; - - $Dispatcher->args = array('mock_without_main', 'initdb'); - $result = $Dispatcher->dispatch(); - $this->assertTrue($result); - } - -/** - * Verify correct dispatch of custom classes with a main method - * - * @return void - */ - public function testDispatchNotAShellWithMain() { - $Dispatcher = new TestShellDispatcher(); - $methods = get_class_methods('Object'); - array_push($methods, 'main', 'initdb', 'initialize', 'loadTasks', 'startup', '_secret'); - $Shell = $this->getMock('Object', $methods, array(), 'MockWithMainNotAShell'); - - $Shell->expects($this->never())->method('initialize'); - $Shell->expects($this->never())->method('loadTasks'); - $Shell->expects($this->once())->method('startup'); - $Shell->expects($this->once())->method('main')->will($this->returnValue(true)); - $Dispatcher->TestShell = $Shell; - - $Dispatcher->args = array('mock_with_main_not_a'); - $result = $Dispatcher->dispatch(); - $this->assertTrue($result); - $this->assertEquals(array(), $Dispatcher->args); - - $Shell = new MockWithMainNotAShell($Dispatcher); - $this->mockObjects[] = $Shell; - $Shell->expects($this->once())->method('initdb')->will($this->returnValue(true)); - $Shell->expects($this->once())->method('startup'); - $Dispatcher->TestShell = $Shell; - - $Dispatcher->args = array('mock_with_main_not_a', 'initdb'); - $result = $Dispatcher->dispatch(); - $this->assertTrue($result); - } - -/** - * Verify correct dispatch of custom classes without a main method - * - * @return void - */ - public function testDispatchNotAShellWithoutMain() { - $Dispatcher = new TestShellDispatcher(); - $methods = get_class_methods('Object'); - array_push($methods, 'main', 'initdb', 'initialize', 'loadTasks', 'startup', '_secret'); - $Shell = $this->getMock('Object', $methods, array(&$Dispatcher), 'MockWithoutMainNotAShell'); - - $Shell->expects($this->never())->method('initialize'); - $Shell->expects($this->never())->method('loadTasks'); - $Shell->expects($this->once())->method('startup'); - $Shell->expects($this->once())->method('main')->will($this->returnValue(true)); - $Dispatcher->TestShell = $Shell; - - $Dispatcher->args = array('mock_without_main_not_a'); - $result = $Dispatcher->dispatch(); - $this->assertTrue($result); - $this->assertEquals(array(), $Dispatcher->args); - - $Shell = new MockWithoutMainNotAShell($Dispatcher); - $this->mockObjects[] = $Shell; - $Shell->expects($this->once())->method('initdb')->will($this->returnValue(true)); - $Shell->expects($this->once())->method('startup'); - $Dispatcher->TestShell = $Shell; - - $Dispatcher->args = array('mock_without_main_not_a', 'initdb'); - $result = $Dispatcher->dispatch(); - $this->assertTrue($result); - } - -/** - * Verify shifting of arguments - * - * @return void - */ - public function testShiftArgs() { - $Dispatcher = new TestShellDispatcher(); - - $Dispatcher->args = array('a', 'b', 'c'); - $this->assertEquals('a', $Dispatcher->shiftArgs()); - $this->assertSame($Dispatcher->args, array('b', 'c')); - - $Dispatcher->args = array('a' => 'b', 'c', 'd'); - $this->assertEquals('b', $Dispatcher->shiftArgs()); - $this->assertSame($Dispatcher->args, array('c', 'd')); - - $Dispatcher->args = array('a', 'b' => 'c', 'd'); - $this->assertEquals('a', $Dispatcher->shiftArgs()); - $this->assertSame($Dispatcher->args, array('b' => 'c', 'd')); - - $Dispatcher->args = array(0 => 'a', 2 => 'b', 30 => 'c'); - $this->assertEquals('a', $Dispatcher->shiftArgs()); - $this->assertSame($Dispatcher->args, array(0 => 'b', 1 => 'c')); - - $Dispatcher->args = array(); - $this->assertNull($Dispatcher->shiftArgs()); - $this->assertSame(array(), $Dispatcher->args); - } - -} diff --git a/lib/Cake/Test/Case/Console/ShellTest.php b/lib/Cake/Test/Case/Console/ShellTest.php deleted file mode 100644 index 4e75b29b96d..00000000000 --- a/lib/Cake/Test/Case/Console/ShellTest.php +++ /dev/null @@ -1,871 +0,0 @@ -stopped = $status; - } - - public function do_something() { - } - - protected function _secret() { - } - - protected function no_access() { - } - - public function mergeVars($properties, $class, $normalize = true) { - return $this->_mergeVars($properties, $class, $normalize); - } - -} - -/** - * Class for testing merging vars - * - * @package Cake.Test.Case.Console.Command - */ -class TestMergeShell extends Shell { - - public $tasks = array('DbConfig', 'Fixture'); - - public $uses = array('Comment'); - -} - -/** - * TestAppleTask class - * - * @package Cake.Test.Case.Console.Command - */ -class TestAppleTask extends Shell { -} - -/** - * TestBananaTask class - * - * @package Cake.Test.Case.Console.Command - */ -class TestBananaTask extends Shell { -} - -/** - * ShellTest class - * - * @package Cake.Test.Case.Console.Command - */ -class ShellTest extends CakeTestCase { - -/** - * Fixtures used in this test case - * - * @var array - */ - public $fixtures = array( - 'core.post', 'core.comment', 'core.article', 'core.user', - 'core.tag', 'core.articles_tag', 'core.attachment' - ); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - - $output = $this->getMock('ConsoleOutput', array(), array(), '', false); - $error = $this->getMock('ConsoleOutput', array(), array(), '', false); - $in = $this->getMock('ConsoleInput', array(), array(), '', false); - $this->Shell = new ShellTestShell($output, $error, $in); - - if (is_dir(TMP . 'shell_test')) { - $Folder = new Folder(TMP . 'shell_test'); - $Folder->delete(); - } - } - -/** - * testConstruct method - * - * @return void - */ - public function testConstruct() { - $this->assertEquals('ShellTestShell', $this->Shell->name); - $this->assertInstanceOf('ConsoleInput', $this->Shell->stdin); - $this->assertInstanceOf('ConsoleOutput', $this->Shell->stdout); - $this->assertInstanceOf('ConsoleOutput', $this->Shell->stderr); - } - -/** - * test merging vars - * - * @return void - */ - public function testMergeVars() { - $this->Shell->tasks = array('DbConfig' => array('one', 'two')); - $this->Shell->uses = array('Posts'); - $this->Shell->mergeVars(array('tasks'), 'TestMergeShell'); - $this->Shell->mergeVars(array('uses'), 'TestMergeShell', false); - - $expected = array('DbConfig' => null, 'Fixture' => null, 'DbConfig' => array('one', 'two')); - $this->assertEquals($expected, $this->Shell->tasks); - - $expected = array('Fixture' => null, 'DbConfig' => array('one', 'two')); - $this->assertEquals($expected, Set::normalize($this->Shell->tasks), 'Normalized results are wrong.'); - $this->assertEquals(array('Comment', 'Posts'), $this->Shell->uses, 'Merged models are wrong.'); - } - -/** - * testInitialize method - * - * @return void - */ - public function testInitialize() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS) - ), App::RESET); - - CakePlugin::load('TestPlugin'); - $this->Shell->uses = array('TestPlugin.TestPluginPost'); - $this->Shell->initialize(); - - $this->assertTrue(isset($this->Shell->TestPluginPost)); - $this->assertInstanceOf('TestPluginPost', $this->Shell->TestPluginPost); - $this->assertEquals('TestPluginPost', $this->Shell->modelClass); - CakePlugin::unload('TestPlugin'); - - $this->Shell->uses = array('Comment'); - $this->Shell->initialize(); - $this->assertTrue(isset($this->Shell->Comment)); - $this->assertInstanceOf('Comment', $this->Shell->Comment); - $this->assertEquals('Comment', $this->Shell->modelClass); - - App::build(); - } - -/** - * testIn method - * - * @return void - */ - public function testIn() { - $this->Shell->stdin->expects($this->at(0)) - ->method('read') - ->will($this->returnValue('n')); - - $this->Shell->stdin->expects($this->at(1)) - ->method('read') - ->will($this->returnValue('Y')); - - $this->Shell->stdin->expects($this->at(2)) - ->method('read') - ->will($this->returnValue('y')); - - $this->Shell->stdin->expects($this->at(3)) - ->method('read') - ->will($this->returnValue('y')); - - $this->Shell->stdin->expects($this->at(4)) - ->method('read') - ->will($this->returnValue('y')); - - $this->Shell->stdin->expects($this->at(5)) - ->method('read') - ->will($this->returnValue('0')); - - $result = $this->Shell->in('Just a test?', array('y', 'n'), 'n'); - $this->assertEquals('n', $result); - - $result = $this->Shell->in('Just a test?', array('y', 'n'), 'n'); - $this->assertEquals('Y', $result); - - $result = $this->Shell->in('Just a test?', 'y,n', 'n'); - $this->assertEquals('y', $result); - - $result = $this->Shell->in('Just a test?', 'y/n', 'n'); - $this->assertEquals('y', $result); - - $result = $this->Shell->in('Just a test?', 'y', 'y'); - $this->assertEquals('y', $result); - - $result = $this->Shell->in('Just a test?', array(0, 1, 2), '0'); - $this->assertEquals('0', $result); - } - -/** - * Test in() when not interactive. - * - * @return void - */ - public function testInNonInteractive() { - $this->Shell->interactive = false; - - $result = $this->Shell->in('Just a test?', 'y/n', 'n'); - $this->assertEquals('n', $result); - } - -/** - * testOut method - * - * @return void - */ - public function testOut() { - $this->Shell->stdout->expects($this->at(0)) - ->method('write') - ->with("Just a test", 1); - - $this->Shell->stdout->expects($this->at(1)) - ->method('write') - ->with(array('Just', 'a', 'test'), 1); - - $this->Shell->stdout->expects($this->at(2)) - ->method('write') - ->with(array('Just', 'a', 'test'), 2); - - $this->Shell->stdout->expects($this->at(3)) - ->method('write') - ->with('', 1); - - $this->Shell->out('Just a test'); - - $this->Shell->out(array('Just', 'a', 'test')); - - $this->Shell->out(array('Just', 'a', 'test'), 2); - - $this->Shell->out(); - } - -/** - * test that verbose and quiet output levels work - * - * @return void - */ - public function testVerboseOutput() { - $this->Shell->stdout->expects($this->at(0))->method('write') - ->with('Verbose', 1); - $this->Shell->stdout->expects($this->at(1))->method('write') - ->with('Normal', 1); - $this->Shell->stdout->expects($this->at(2))->method('write') - ->with('Quiet', 1); - - $this->Shell->params['verbose'] = true; - $this->Shell->params['quiet'] = false; - - $this->Shell->out('Verbose', 1, Shell::VERBOSE); - $this->Shell->out('Normal', 1, Shell::NORMAL); - $this->Shell->out('Quiet', 1, Shell::QUIET); - } - -/** - * test that verbose and quiet output levels work - * - * @return void - */ - public function testQuietOutput() { - $this->Shell->stdout->expects($this->once())->method('write') - ->with('Quiet', 1); - - $this->Shell->params['verbose'] = false; - $this->Shell->params['quiet'] = true; - - $this->Shell->out('Verbose', 1, Shell::VERBOSE); - $this->Shell->out('Normal', 1, Shell::NORMAL); - $this->Shell->out('Quiet', 1, Shell::QUIET); - } - -/** - * testErr method - * - * @return void - */ - public function testErr() { - $this->Shell->stderr->expects($this->at(0)) - ->method('write') - ->with("Just a test", 1); - - $this->Shell->stderr->expects($this->at(1)) - ->method('write') - ->with(array('Just', 'a', 'test'), 1); - - $this->Shell->stderr->expects($this->at(2)) - ->method('write') - ->with(array('Just', 'a', 'test'), 2); - - $this->Shell->stderr->expects($this->at(3)) - ->method('write') - ->with('', 1); - - $this->Shell->err('Just a test'); - - $this->Shell->err(array('Just', 'a', 'test')); - - $this->Shell->err(array('Just', 'a', 'test'), 2); - - $this->Shell->err(); - } - -/** - * testNl - * - * @return void - */ - public function testNl() { - $newLine = "\n"; - if (DS === '\\') { - $newLine = "\r\n"; - } - $this->assertEquals($this->Shell->nl(), $newLine); - $this->assertEquals($this->Shell->nl(true), $newLine); - $this->assertEquals("", $this->Shell->nl(false)); - $this->assertEquals($this->Shell->nl(2), $newLine . $newLine); - $this->assertEquals($this->Shell->nl(1), $newLine); - } - -/** - * testHr - * - * @return void - */ - public function testHr() { - $bar = '---------------------------------------------------------------'; - - $this->Shell->stdout->expects($this->at(0))->method('write')->with('', 0); - $this->Shell->stdout->expects($this->at(1))->method('write')->with($bar, 1); - $this->Shell->stdout->expects($this->at(2))->method('write')->with('', 0); - - $this->Shell->stdout->expects($this->at(3))->method('write')->with("", true); - $this->Shell->stdout->expects($this->at(4))->method('write')->with($bar, 1); - $this->Shell->stdout->expects($this->at(5))->method('write')->with("", true); - - $this->Shell->stdout->expects($this->at(6))->method('write')->with("", 2); - $this->Shell->stdout->expects($this->at(7))->method('write')->with($bar, 1); - $this->Shell->stdout->expects($this->at(8))->method('write')->with("", 2); - - $this->Shell->hr(); - - $this->Shell->hr(true); - - $this->Shell->hr(2); - } - -/** - * testError - * - * @return void - */ - public function testError() { - $this->Shell->stderr->expects($this->at(0)) - ->method('write') - ->with("Error: Foo Not Found", 1); - - $this->Shell->stderr->expects($this->at(1)) - ->method('write') - ->with("Error: Foo Not Found", 1); - - $this->Shell->stderr->expects($this->at(2)) - ->method('write') - ->with("Searched all...", 1); - - $this->Shell->error('Foo Not Found'); - $this->assertSame($this->Shell->stopped, 1); - - $this->Shell->stopped = null; - - $this->Shell->error('Foo Not Found', 'Searched all...'); - $this->assertSame($this->Shell->stopped, 1); - } - -/** - * testLoadTasks method - * - * @return void - */ - public function testLoadTasks() { - $this->assertTrue($this->Shell->loadTasks()); - - $this->Shell->tasks = null; - $this->assertTrue($this->Shell->loadTasks()); - - $this->Shell->tasks = false; - $this->assertTrue($this->Shell->loadTasks()); - - $this->Shell->tasks = true; - $this->assertTrue($this->Shell->loadTasks()); - - $this->Shell->tasks = array(); - $this->assertTrue($this->Shell->loadTasks()); - - $this->Shell->tasks = array('TestApple'); - $this->assertTrue($this->Shell->loadTasks()); - $this->assertInstanceOf('TestAppleTask', $this->Shell->TestApple); - - $this->Shell->tasks = 'TestBanana'; - $this->assertTrue($this->Shell->loadTasks()); - $this->assertInstanceOf('TestAppleTask', $this->Shell->TestApple); - $this->assertInstanceOf('TestBananaTask', $this->Shell->TestBanana); - - unset($this->Shell->ShellTestApple, $this->Shell->TestBanana); - - $this->Shell->tasks = array('TestApple', 'TestBanana'); - $this->assertTrue($this->Shell->loadTasks()); - $this->assertInstanceOf('TestAppleTask', $this->Shell->TestApple); - $this->assertInstanceOf('TestBananaTask', $this->Shell->TestBanana); - } - -/** - * test that __get() makes args and params references - * - * @return void - */ - public function testMagicGetArgAndParamReferences() { - $this->Shell->tasks = array('TestApple'); - $this->Shell->args = array('one'); - $this->Shell->params = array('help' => false); - $this->Shell->loadTasks(); - $result = $this->Shell->TestApple; - - $this->Shell->args = array('one', 'two'); - - $this->assertSame($this->Shell->args, $result->args); - $this->assertSame($this->Shell->params, $result->params); - } - -/** - * testShortPath method - * - * @return void - */ - public function testShortPath() { - $path = $expected = DS . 'tmp' . DS . 'ab' . DS . 'cd'; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - - $path = $expected = DS . 'tmp' . DS . 'ab' . DS . 'cd' . DS; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - - $path = $expected = DS . 'tmp' . DS . 'ab' . DS . 'index.php'; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - - $path = DS . 'tmp' . DS . 'ab' . DS . DS . 'cd'; - $expected = DS . 'tmp' . DS . 'ab' . DS . 'cd'; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - - $path = 'tmp' . DS . 'ab'; - $expected = 'tmp' . DS . 'ab'; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - - $path = 'tmp' . DS . 'ab'; - $expected = 'tmp' . DS . 'ab'; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - - $path = APP; - $expected = DS . basename(APP) . DS; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - - $path = APP . 'index.php'; - $expected = DS . basename(APP) . DS . 'index.php'; - $this->assertEquals($expected, $this->Shell->shortPath($path)); - } - -/** - * testCreateFile method - * - * @return void - */ - public function testCreateFileNonInteractive() { - $this->skipIf(DIRECTORY_SEPARATOR === '\\', 'Not supported on Windows.'); - - $path = TMP . 'shell_test'; - $file = $path . DS . 'file1.php'; - - $Folder = new Folder($path, true); - - $this->Shell->interactive = false; - - $contents = "Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals(file_get_contents($file), $contents); - - $contents = "Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals(file_get_contents($file), $contents); - } - -/** - * test createFile when the shell is interactive. - * - * @return void - */ - public function testCreateFileInteractive() { - $this->skipIf(DIRECTORY_SEPARATOR === '\\', 'Not supported on Windows.'); - - $path = TMP . 'shell_test'; - $file = $path . DS . 'file1.php'; - $Folder = new Folder($path, true); - - $this->Shell->interactive = true; - - $this->Shell->stdin->expects($this->at(0)) - ->method('read') - ->will($this->returnValue('n')); - - $this->Shell->stdin->expects($this->at(1)) - ->method('read') - ->will($this->returnValue('y')); - - $contents = "Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals(file_get_contents($file), $contents); - - // no overwrite - $contents = 'new contents'; - $result = $this->Shell->createFile($file, $contents); - $this->assertFalse($result); - $this->assertTrue(file_exists($file)); - $this->assertNotEquals($contents, file_get_contents($file)); - - // overwrite - $contents = 'more new contents'; - $result = $this->Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals($contents, file_get_contents($file)); - } - -/** - * Test that you can't create files that aren't writable. - * - * @return void - */ - public function testCreateFileNoPermissions() { - $path = TMP . 'shell_test'; - $file = $path . DS . 'no_perms'; - - mkdir($path); - chmod($path, 0444); - - $this->Shell->createFile($file, 'testing'); - $this->assertFalse(file_exists($file)); - - chmod($path, 0744); - rmdir($path); - } - -/** - * testCreateFileWindows method - * - * @return void - */ - public function testCreateFileWindowsNonInteractive() { - $this->skipIf(DIRECTORY_SEPARATOR === '/', 'testCreateFileWindowsNonInteractive supported on Windows only.'); - - $path = TMP . 'shell_test'; - $file = $path . DS . 'file1.php'; - - $Folder = new Folder($path, true); - - $this->Shell->interactive = false; - - $contents = "Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals(file_get_contents($file), $contents); - - $contents = "Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals(file_get_contents($file), $contents); - - $Folder = new Folder($path); - $Folder->delete(); - } - -/** - * test createFile on windows with interactive on. - * - * @return void - */ - public function testCreateFileWindowsInteractive() { - $this->skipIf(DIRECTORY_SEPARATOR === '/', 'testCreateFileWindowsInteractive supported on Windows only.'); - $path = TMP . 'shell_test'; - $file = $path . DS . 'file1.php'; - $Folder = new Folder($path, true); - - $this->Shell->interactive = true; - - $this->Shell->stdin->expects($this->at(0)) - ->method('read') - ->will($this->returnValue('n')); - - $this->Shell->stdin->expects($this->at(1)) - ->method('read') - ->will($this->returnValue('y')); - - $contents = "Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals(file_get_contents($file), $contents); - - // no overwrite - $contents = 'new contents'; - $result = $this->Shell->createFile($file, $contents); - $this->assertFalse($result); - $this->assertTrue(file_exists($file)); - $this->assertNotEquals($contents, file_get_contents($file)); - - // overwrite - $contents = 'more new contents'; - $result = $this->Shell->createFile($file, $contents); - $this->assertTrue($result); - $this->assertTrue(file_exists($file)); - $this->assertEquals($contents, file_get_contents($file)); - - $Folder->delete(); - } - -/** - * test hasTask method - * - * @return void - */ - public function testHasTask() { - $this->Shell->tasks = array('Extract', 'DbConfig'); - $this->Shell->loadTasks(); - - $this->assertTrue($this->Shell->hasTask('extract')); - $this->assertTrue($this->Shell->hasTask('Extract')); - $this->assertFalse($this->Shell->hasTask('random')); - - $this->assertTrue($this->Shell->hasTask('db_config')); - $this->assertTrue($this->Shell->hasTask('DbConfig')); - } - -/** - * test the hasMethod - * - * @return void - */ - public function testHasMethod() { - $this->assertTrue($this->Shell->hasMethod('do_something')); - $this->assertFalse($this->Shell->hasMethod('hr'), 'hr is callable'); - $this->assertFalse($this->Shell->hasMethod('_secret'), '_secret is callable'); - $this->assertFalse($this->Shell->hasMethod('no_access'), 'no_access is callable'); - } - -/** - * test run command calling main. - * - * @return void - */ - public function testRunCommandMain() { - $methods = get_class_methods('Shell'); - $Mock = $this->getMock('Shell', array('main', 'startup'), array(), '', false); - - $Mock->expects($this->once())->method('main')->will($this->returnValue(true)); - $result = $Mock->runCommand(null, array()); - $this->assertTrue($result); - } - -/** - * test run command calling a legit method. - * - * @return void - */ - public function testRunCommandWithMethod() { - $methods = get_class_methods('Shell'); - $Mock = $this->getMock('Shell', array('hit_me', 'startup'), array(), '', false); - - $Mock->expects($this->once())->method('hit_me')->will($this->returnValue(true)); - $result = $Mock->runCommand('hit_me', array()); - $this->assertTrue($result); - } - -/** - * test run command causing exception on Shell method. - * - * @return void - */ - public function testRunCommandBaseclassMethod() { - $Mock = $this->getMock('Shell', array('startup', 'getOptionParser', 'out'), array(), '', false); - $Parser = $this->getMock('ConsoleOptionParser', array(), array(), '', false); - - $Parser->expects($this->once())->method('help'); - $Mock->expects($this->once())->method('getOptionParser') - ->will($this->returnValue($Parser)); - $Mock->expects($this->never())->method('hr'); - $Mock->expects($this->once())->method('out'); - - $result = $Mock->runCommand('hr', array()); - } - -/** - * test run command causing exception on Shell method. - * - * @return void - */ - public function testRunCommandMissingMethod() { - $methods = get_class_methods('Shell'); - $Mock = $this->getMock('Shell', array('startup', 'getOptionParser', 'out'), array(), '', false); - $Parser = $this->getMock('ConsoleOptionParser', array(), array(), '', false); - - $Parser->expects($this->once())->method('help'); - $Mock->expects($this->never())->method('idontexist'); - $Mock->expects($this->once())->method('getOptionParser') - ->will($this->returnValue($Parser)); - $Mock->expects($this->once())->method('out'); - - $result = $Mock->runCommand('idontexist', array()); - $this->assertFalse($result); - } - -/** - * test that a --help causes help to show. - * - * @return void - */ - public function testRunCommandTriggeringHelp() { - $Parser = $this->getMock('ConsoleOptionParser', array(), array(), '', false); - $Parser->expects($this->once())->method('parse') - ->with(array('--help')) - ->will($this->returnValue(array(array('help' => true), array()))); - $Parser->expects($this->once())->method('help'); - - $Shell = $this->getMock('Shell', array('getOptionParser', 'out', 'startup', '_welcome'), array(), '', false); - $Shell->expects($this->once())->method('getOptionParser') - ->will($this->returnValue($Parser)); - $Shell->expects($this->once())->method('out'); - - $Shell->runCommand(null, array('--help')); - } - -/** - * test that runCommand will call runCommand on the task. - * - * @return void - */ - public function testRunCommandHittingTask() { - $Shell = $this->getMock('Shell', array('hasTask', 'startup'), array(), '', false); - $task = $this->getMock('Shell', array('execute', 'runCommand'), array(), '', false); - $task->expects($this->any()) - ->method('runCommand') - ->with('execute', array('one', 'value')); - - $Shell->expects($this->once())->method('startup'); - $Shell->expects($this->any()) - ->method('hasTask') - ->will($this->returnValue(true)); - - $Shell->RunCommand = $task; - - $result = $Shell->runCommand('run_command', array('run_command', 'one', 'value')); - } - -/** - * test wrapBlock wrapping text. - * - * @return void - */ - public function testWrapText() { - $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; - $result = $this->Shell->wrapText($text, 33); - $expected = <<assertEquals($expected, $result, 'Text not wrapped.'); - - $result = $this->Shell->wrapText($text, array('indent' => ' ', 'width' => 33)); - $expected = <<assertEquals($expected, $result, 'Text not wrapped.'); - } - -/** - * Testing camel cased naming of tasks - * - * @return void - */ - public function testShellNaming() { - $this->Shell->tasks = array('TestApple'); - $this->Shell->loadTasks(); - $expected = 'TestApple'; - $this->assertEquals($expected, $this->Shell->TestApple->name); - } - -/** - * Test that option parsers are created with the correct name/command. - * - * @return void - */ - public function testGetOptionParser() { - $this->Shell->name = 'test'; - $this->Shell->plugin = 'plugin'; - $parser = $this->Shell->getOptionParser(); - - $this->assertEquals('plugin.test', $parser->command()); - } - -} diff --git a/lib/Cake/Test/Case/Console/TaskCollectionTest.php b/lib/Cake/Test/Case/Console/TaskCollectionTest.php deleted file mode 100644 index a681b490b9d..00000000000 --- a/lib/Cake/Test/Case/Console/TaskCollectionTest.php +++ /dev/null @@ -1,124 +0,0 @@ -getMock('Shell', array(), array(), '', false); - $dispatcher = $this->getMock('ShellDispatcher', array(), array(), '', false); - $this->Tasks = new TaskCollection($shell, $dispatcher); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - unset($this->Tasks); - parent::tearDown(); - } - -/** - * test triggering callbacks on loaded tasks - * - * @return void - */ - public function testLoad() { - $result = $this->Tasks->load('DbConfig'); - $this->assertInstanceOf('DbConfigTask', $result); - $this->assertInstanceOf('DbConfigTask', $this->Tasks->DbConfig); - - $result = $this->Tasks->attached(); - $this->assertEquals(array('DbConfig'), $result, 'attached() results are wrong.'); - } - -/** - * test load and enable = false - * - * @return void - */ - public function testLoadWithEnableFalse() { - $result = $this->Tasks->load('DbConfig', array('enabled' => false)); - $this->assertInstanceOf('DbConfigTask', $result); - $this->assertInstanceOf('DbConfigTask', $this->Tasks->DbConfig); - - $this->assertFalse($this->Tasks->enabled('DbConfig'), 'DbConfigTask should be disabled'); - } - -/** - * test missingtask exception - * - * @expectedException MissingTaskException - * @return void - */ - public function testLoadMissingTask() { - $result = $this->Tasks->load('ThisTaskShouldAlwaysBeMissing'); - } - -/** - * test loading a plugin helper. - * - * @return void - */ - public function testLoadPluginTask() { - $dispatcher = $this->getMock('ShellDispatcher', array(), array(), '', false); - $shell = $this->getMock('Shell', array(), array(), '', false); - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - $this->Tasks = new TaskCollection($shell, $dispatcher); - - $result = $this->Tasks->load('TestPlugin.OtherTask'); - $this->assertInstanceOf('OtherTaskTask', $result, 'Task class is wrong.'); - $this->assertInstanceOf('OtherTaskTask', $this->Tasks->OtherTask, 'Class is wrong'); - CakePlugin::unload(); - } - -/** - * test unload() - * - * @return void - */ - public function testUnload() { - $this->Tasks->load('Extract'); - $this->Tasks->load('DbConfig'); - - $result = $this->Tasks->attached(); - $this->assertEquals(array('Extract', 'DbConfig'), $result, 'loaded tasks is wrong'); - - $this->Tasks->unload('DbConfig'); - $this->assertFalse(isset($this->Tasks->DbConfig)); - $this->assertTrue(isset($this->Tasks->Extract)); - - $result = $this->Tasks->attached(); - $this->assertEquals(array('Extract'), $result, 'loaded tasks is wrong'); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/Acl/DbAclTest.php b/lib/Cake/Test/Case/Controller/Component/Acl/DbAclTest.php deleted file mode 100644 index 4137e648da4..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Acl/DbAclTest.php +++ /dev/null @@ -1,537 +0,0 @@ - array('with' => 'PermissionTwoTest')); -} - -/** - * AcoTwoTest class - * - * @package Cake.Test.Case.Controller.Component.Acl - */ -class AcoTwoTest extends AclNodeTwoTestBase { - -/** - * name property - * - * @var string 'AcoTwoTest' - */ - public $name = 'AcoTwoTest'; - -/** - * useTable property - * - * @var string 'aco_twos' - */ - public $useTable = 'aco_twos'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('AroTwoTest' => array('with' => 'PermissionTwoTest')); -} - -/** - * PermissionTwoTest class - * - * @package Cake.Test.Case.Controller.Component.Acl - */ -class PermissionTwoTest extends CakeTestModel { - -/** - * name property - * - * @var string 'PermissionTwoTest' - */ - public $name = 'PermissionTwoTest'; - -/** - * useTable property - * - * @var string 'aros_aco_twos' - */ - public $useTable = 'aros_aco_twos'; - -/** - * cacheQueries property - * - * @var bool false - */ - public $cacheQueries = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('AroTwoTest' => array('foreignKey' => 'aro_id'), 'AcoTwoTest' => array('foreignKey' => 'aco_id')); - -/** - * actsAs property - * - * @var mixed null - */ - public $actsAs = null; -} - -/** - * DbAclTwoTest class - * - * @package Cake.Test.Case.Controller.Component.Acl - */ -class DbAclTwoTest extends DbAcl { - -/** - * construct method - * - * @return void - */ - public function __construct() { - $this->Aro = new AroTwoTest(); - $this->Aro->Permission = new PermissionTwoTest(); - $this->Aco = new AcoTwoTest(); - $this->Aro->Permission = new PermissionTwoTest(); - } - -} - -/** - * Test case for AclComponent using the DbAcl implementation. - * - * @package Cake.Test.Case.Controller.Component.Acl - */ -class DbAclTest extends CakeTestCase { - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.aro_two', 'core.aco_two', 'core.aros_aco_two'); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - Configure::write('Acl.classname', 'DbAclTwoTest'); - Configure::write('Acl.database', 'test'); - $Collection = new ComponentCollection(); - $this->Acl = new AclComponent($Collection); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Acl); - } - -/** - * testAclCreate method - * - * @return void - */ - public function testCreate() { - $this->Acl->Aro->create(array('alias' => 'Chotchkey')); - $this->assertTrue((bool)$this->Acl->Aro->save()); - - $parent = $this->Acl->Aro->id; - - $this->Acl->Aro->create(array('parent_id' => $parent, 'alias' => 'Joanna')); - $this->assertTrue((bool)$this->Acl->Aro->save()); - - $this->Acl->Aro->create(array('parent_id' => $parent, 'alias' => 'Stapler')); - $this->assertTrue((bool)$this->Acl->Aro->save()); - - $root = $this->Acl->Aco->node('ROOT'); - $parent = $root[0]['AcoTwoTest']['id']; - - $this->Acl->Aco->create(array('parent_id' => $parent, 'alias' => 'Drinks')); - $this->assertTrue((bool)$this->Acl->Aco->save()); - - $this->Acl->Aco->create(array('parent_id' => $parent, 'alias' => 'PiecesOfFlair')); - $this->assertTrue((bool)$this->Acl->Aco->save()); - } - -/** - * testAclCreateWithParent method - * - * @return void - */ - public function testCreateWithParent() { - $parent = $this->Acl->Aro->findByAlias('Peter', null, null, -1); - $this->Acl->Aro->create(); - $this->Acl->Aro->save(array( - 'alias' => 'Subordinate', - 'model' => 'User', - 'foreign_key' => 7, - 'parent_id' => $parent['AroTwoTest']['id'] - )); - $result = $this->Acl->Aro->findByAlias('Subordinate', null, null, -1); - $this->assertEquals(16, $result['AroTwoTest']['lft']); - $this->assertEquals(17, $result['AroTwoTest']['rght']); - } - -/** - * testDbAclAllow method - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testAllow() { - $this->assertFalse($this->Acl->check('Micheal', 'tpsReports', 'read')); - $this->assertTrue($this->Acl->allow('Micheal', 'tpsReports', array('read', 'delete', 'update'))); - $this->assertTrue($this->Acl->check('Micheal', 'tpsReports', 'update')); - $this->assertTrue($this->Acl->check('Micheal', 'tpsReports', 'read')); - $this->assertTrue($this->Acl->check('Micheal', 'tpsReports', 'delete')); - - $this->assertFalse($this->Acl->check('Micheal', 'tpsReports', 'create')); - $this->assertTrue($this->Acl->allow('Micheal', 'ROOT/tpsReports', 'create')); - $this->assertTrue($this->Acl->check('Micheal', 'tpsReports', 'create')); - $this->assertTrue($this->Acl->check('Micheal', 'tpsReports', 'delete')); - $this->assertTrue($this->Acl->allow('Micheal', 'printers', 'create')); - // Michael no longer has his delete permission for tpsReports! - $this->assertTrue($this->Acl->check('Micheal', 'tpsReports', 'delete')); - $this->assertTrue($this->Acl->check('Micheal', 'printers', 'create')); - - $this->assertFalse($this->Acl->check('root/users/Samir', 'ROOT/tpsReports/view')); - $this->assertTrue($this->Acl->allow('root/users/Samir', 'ROOT/tpsReports/view', '*')); - $this->assertTrue($this->Acl->check('Samir', 'view', 'read')); - $this->assertTrue($this->Acl->check('root/users/Samir', 'ROOT/tpsReports/view', 'update')); - - $this->assertFalse($this->Acl->check('root/users/Samir', 'ROOT/tpsReports/update','*')); - $this->assertTrue($this->Acl->allow('root/users/Samir', 'ROOT/tpsReports/update', '*')); - $this->assertTrue($this->Acl->check('Samir', 'update', 'read')); - $this->assertTrue($this->Acl->check('root/users/Samir', 'ROOT/tpsReports/update', 'update')); - // Samir should still have his tpsReports/view permissions, but does not - $this->assertTrue($this->Acl->check('root/users/Samir', 'ROOT/tpsReports/view', 'update')); - - $this->assertFalse($this->Acl->allow('Lumbergh', 'ROOT/tpsReports/DoesNotExist', 'create')); - } - -/** - * testAllowInvalidNode method - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testAllowInvalidNode() { - $this->Acl->allow('Homer', 'tpsReports', 'create'); - } - -/** - * testDbAclCheck method - * - * @return void - */ - public function testCheck() { - $this->assertTrue($this->Acl->check('Samir', 'print', 'read')); - $this->assertTrue($this->Acl->check('Lumbergh', 'current', 'read')); - $this->assertFalse($this->Acl->check('Milton', 'smash', 'read')); - $this->assertFalse($this->Acl->check('Milton', 'current', 'update')); - - $this->assertFalse($this->Acl->check(null, 'printers', 'create')); - $this->assertFalse($this->Acl->check('managers', null, 'read')); - - $this->assertTrue($this->Acl->check('Bobs', 'ROOT/tpsReports/view/current', 'read')); - $this->assertFalse($this->Acl->check('Samir', 'ROOT/tpsReports/update', 'read')); - - $this->assertFalse($this->Acl->check('root/users/Milton', 'smash', 'delete')); - } - -/** - * testCheckInvalidNode method - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testCheckInvalidNode() { - $this->assertFalse($this->Acl->check('WRONG', 'tpsReports', 'read')); - } - -/** - * testCheckInvalidPermission method - * - * @expectedException PHPUnit_Framework_Error_Notice - * @return void - */ - public function testCheckInvalidPermission() { - $this->Acl->check('Lumbergh', 'smash', 'foobar'); - } - -/** - * testCheckMissingPermission method - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testCheckMissingPermission() { - $this->Acl->check('users', 'NonExistant', 'read'); - } - -/** - * testDbAclCascadingDeny function - * - * Setup the acl permissions such that Bobs inherits from admin. - * deny Admin delete access to a specific resource, check the permisssions are inherited. - * - * @return void - */ - public function testAclCascadingDeny() { - $this->Acl->inherit('Bobs', 'ROOT', '*'); - $this->assertTrue($this->Acl->check('admin', 'tpsReports', 'delete')); - $this->assertTrue($this->Acl->check('Bobs', 'tpsReports', 'delete')); - $this->Acl->deny('admin', 'tpsReports', 'delete'); - $this->assertFalse($this->Acl->check('admin', 'tpsReports', 'delete')); - $this->assertFalse($this->Acl->check('Bobs', 'tpsReports', 'delete')); - } - -/** - * testDbAclDeny method - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testDeny() { - $this->assertTrue($this->Acl->check('Micheal', 'smash', 'delete')); - $this->Acl->deny('Micheal', 'smash', 'delete'); - $this->assertFalse($this->Acl->check('Micheal', 'smash', 'delete')); - $this->assertTrue($this->Acl->check('Micheal', 'smash', 'read')); - $this->assertTrue($this->Acl->check('Micheal', 'smash', 'create')); - $this->assertTrue($this->Acl->check('Micheal', 'smash', 'update')); - $this->assertFalse($this->Acl->check('Micheal', 'smash', '*')); - - $this->assertTrue($this->Acl->check('Samir', 'refill', '*')); - $this->Acl->deny('Samir', 'refill', '*'); - $this->assertFalse($this->Acl->check('Samir', 'refill', 'create')); - $this->assertFalse($this->Acl->check('Samir', 'refill', 'update')); - $this->assertFalse($this->Acl->check('Samir', 'refill', 'read')); - $this->assertFalse($this->Acl->check('Samir', 'refill', 'delete')); - - $result = $this->Acl->Aro->Permission->find('all', array('conditions' => array('AroTwoTest.alias' => 'Samir'))); - $expected = '-1'; - $this->assertEquals($expected, $result[0]['PermissionTwoTest']['_delete']); - - $this->assertFalse($this->Acl->deny('Lumbergh', 'ROOT/tpsReports/DoesNotExist', 'create')); - } - -/** - * testAclNodeLookup method - * - * @return void - */ - public function testAclNodeLookup() { - $result = $this->Acl->Aro->node('root/users/Samir'); - $expected = array( - array('AroTwoTest' => array('id' => '7', 'parent_id' => '4', 'model' => 'User', 'foreign_key' => 3, 'alias' => 'Samir')), - array('AroTwoTest' => array('id' => '4', 'parent_id' => '1', 'model' => 'Group', 'foreign_key' => 3, 'alias' => 'users')), - array('AroTwoTest' => array('id' => '1', 'parent_id' => null, 'model' => null, 'foreign_key' => null, 'alias' => 'root')) - ); - $this->assertEquals($expected, $result); - - $result = $this->Acl->Aco->node('ROOT/tpsReports/view/current'); - $expected = array( - array('AcoTwoTest' => array('id' => '4', 'parent_id' => '3', 'model' => null, 'foreign_key' => null, 'alias' => 'current')), - array('AcoTwoTest' => array('id' => '3', 'parent_id' => '2', 'model' => null, 'foreign_key' => null, 'alias' => 'view')), - array('AcoTwoTest' => array('id' => '2', 'parent_id' => '1', 'model' => null, 'foreign_key' => null, 'alias' => 'tpsReports')), - array('AcoTwoTest' => array('id' => '1', 'parent_id' => null, 'model' => null, 'foreign_key' => null, 'alias' => 'ROOT')), - ); - $this->assertEquals($expected, $result); - } - -/** - * testDbInherit method - * - * @return void - */ - public function testInherit() { - //parent doesn't have access inherit should still deny - $this->assertFalse($this->Acl->check('Milton', 'smash', 'delete')); - $this->Acl->inherit('Milton', 'smash', 'delete'); - $this->assertFalse($this->Acl->check('Milton', 'smash', 'delete')); - - //inherit parent - $this->assertFalse($this->Acl->check('Milton', 'smash', 'read')); - $this->Acl->inherit('Milton', 'smash', 'read'); - $this->assertTrue($this->Acl->check('Milton', 'smash', 'read')); - } - -/** - * testDbGrant method - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testGrant() { - $this->assertFalse($this->Acl->check('Samir', 'tpsReports', 'create')); - $this->Acl->allow('Samir', 'tpsReports', 'create'); - $this->assertTrue($this->Acl->check('Samir', 'tpsReports', 'create')); - - $this->assertFalse($this->Acl->check('Micheal', 'view', 'read')); - $this->Acl->allow('Micheal', 'view', array('read', 'create', 'update')); - $this->assertTrue($this->Acl->check('Micheal', 'view', 'read')); - $this->assertTrue($this->Acl->check('Micheal', 'view', 'create')); - $this->assertTrue($this->Acl->check('Micheal', 'view', 'update')); - $this->assertFalse($this->Acl->check('Micheal', 'view', 'delete')); - - $this->assertFalse($this->Acl->allow('Peter', 'ROOT/tpsReports/DoesNotExist', 'create')); - } - -/** - * testDbRevoke method - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testRevoke() { - $this->assertTrue($this->Acl->check('Bobs', 'tpsReports', 'read')); - $this->Acl->deny('Bobs', 'tpsReports', 'read'); - $this->assertFalse($this->Acl->check('Bobs', 'tpsReports', 'read')); - - $this->assertTrue($this->Acl->check('users', 'printers', 'read')); - $this->Acl->deny('users', 'printers', 'read'); - $this->assertFalse($this->Acl->check('users', 'printers', 'read')); - $this->assertFalse($this->Acl->check('Samir', 'printers', 'read')); - $this->assertFalse($this->Acl->check('Peter', 'printers', 'read')); - - $this->Acl->deny('Bobs', 'ROOT/printers/DoesNotExist', 'create'); - } - -/** - * debug function - to help editing/creating test cases for the ACL component - * - * To check the overal ACL status at any time call $this->__debug(); - * Generates a list of the current aro and aco structures and a grid dump of the permissions that are defined - * Only designed to work with the db based ACL - * - * @param bool $treesToo - * @return void - */ - protected function __debug($printTreesToo = false) { - $this->Acl->Aro->displayField = 'alias'; - $this->Acl->Aco->displayField = 'alias'; - $aros = $this->Acl->Aro->find('list', array('order' => 'lft')); - $acos = $this->Acl->Aco->find('list', array('order' => 'lft')); - $rights = array('*', 'create', 'read', 'update', 'delete'); - $permissions['Aros v Acos >'] = $acos; - foreach ($aros as $aro) { - $row = array(); - foreach ($acos as $aco) { - $perms = ''; - foreach ($rights as $right) { - if ($this->Acl->check($aro, $aco, $right)) { - if ($right == '*') { - $perms .= '****'; - break; - } - $perms .= $right[0]; - } elseif ($right != '*') { - $perms .= ' '; - } - } - $row[] = $perms; - } - $permissions[$aro] = $row; - } - foreach ($permissions as $key => $values) { - array_unshift($values, $key); - $values = array_map(array(&$this, '__pad'), $values); - $permissions[$key] = implode (' ', $values); - } - $permisssions = array_map(array(&$this, '__pad'), $permissions); - array_unshift($permissions, 'Current Permissions :'); - if ($printTreesToo) { - debug(array('aros' => $this->Acl->Aro->generateTreeList(), 'acos' => $this->Acl->Aco->generateTreeList())); - } - debug(implode("\r\n", $permissions)); - } - -/** - * pad function - * Used by debug to format strings used in the data dump - * - * @param string $string - * @param int $len - * @return void - */ - protected function __pad($string = '', $len = 14) { - return str_pad($string, $len); - } -} diff --git a/lib/Cake/Test/Case/Controller/Component/Acl/IniAclTest.php b/lib/Cake/Test/Case/Controller/Component/Acl/IniAclTest.php deleted file mode 100644 index de552b1f872..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Acl/IniAclTest.php +++ /dev/null @@ -1,69 +0,0 @@ -config = $Ini->readConfigFile($iniFile); - - $this->assertFalse($Ini->check('admin', 'ads')); - $this->assertTrue($Ini->check('admin', 'posts')); - - $this->assertTrue($Ini->check('jenny', 'posts')); - $this->assertTrue($Ini->check('jenny', 'ads')); - - $this->assertTrue($Ini->check('paul', 'posts')); - $this->assertFalse($Ini->check('paul', 'ads')); - - $this->assertFalse($Ini->check('nobody', 'comments')); - } - -/** - * check should accept a user array. - * - * @return void - */ - public function testCheckArray() { - $iniFile = CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS . 'acl.ini.php'; - - $Ini = new IniAcl(); - $Ini->config = $Ini->readConfigFile($iniFile); - $Ini->userPath = 'User.username'; - - $user = array( - 'User' => array('username' => 'admin') - ); - $this->assertTrue($Ini->check($user, 'posts')); - } -} - diff --git a/lib/Cake/Test/Case/Controller/Component/Acl/PhpAclTest.php b/lib/Cake/Test/Case/Controller/Component/Acl/PhpAclTest.php deleted file mode 100644 index 3cf18997206..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Acl/PhpAclTest.php +++ /dev/null @@ -1,336 +0,0 @@ -PhpAcl = new PhpAcl(); - $this->Acl = new AclComponent($Collection, array( - 'adapter' => array( - 'config' => CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS . 'acl.php', - ), - )); - } - - public function testRoleInheritance() { - $roles = $this->Acl->Aro->roles('User/peter'); - $this->assertEquals(array('Role/accounting'), $roles[0]); - $this->assertEquals(array('User/peter'), $roles[1]); - - $roles = $this->Acl->Aro->roles('hardy'); - $this->assertEquals(array('Role/database_manager', 'Role/data_acquirer'), $roles[0]); - $this->assertEquals(array('Role/accounting', 'Role/data_analyst'), $roles[1]); - $this->assertEquals(array('Role/accounting_manager', 'Role/reports'), $roles[2]); - $this->assertEquals(array('User/hardy'), $roles[3]); - } - - public function testAddRole() { - $this->assertEquals(array(array(PhpAro::DEFAULT_ROLE)), $this->Acl->Aro->roles('foobar')); - $this->Acl->Aro->addRole(array('User/foobar' => 'Role/accounting')); - $this->assertEquals(array(array('Role/accounting'), array('User/foobar')), $this->Acl->Aro->roles('foobar')); - } - - public function testAroResolve() { - $map = $this->Acl->Aro->map; - $this->Acl->Aro->map = array( - 'User' => 'FooModel/nickname', - 'Role' => 'FooModel/role', - ); - - $this->assertEquals('Role/default', $this->Acl->Aro->resolve('Foo.bar')); - $this->assertEquals('User/hardy', $this->Acl->Aro->resolve('FooModel/hardy')); - $this->assertEquals('User/hardy', $this->Acl->Aro->resolve('hardy')); - $this->assertEquals('User/hardy', $this->Acl->Aro->resolve(array('FooModel' => array('nickname' => 'hardy')))); - $this->assertEquals('Role/admin', $this->Acl->Aro->resolve(array('FooModel' => array('role' => 'admin')))); - $this->assertEquals('Role/admin', $this->Acl->Aro->resolve('Role/admin')); - - $this->assertEquals('Role/admin', $this->Acl->Aro->resolve('admin')); - $this->assertEquals('Role/admin', $this->Acl->Aro->resolve('FooModel/admin')); - $this->assertEquals('Role/accounting', $this->Acl->Aro->resolve('accounting')); - - $this->assertEquals(PhpAro::DEFAULT_ROLE, $this->Acl->Aro->resolve('bla')); - $this->assertEquals(PhpAro::DEFAULT_ROLE, $this->Acl->Aro->resolve(array('FooModel' => array('role' => 'hardy')))); - } - -/** - * test correct resolution of defined aliases - */ - public function testAroAliases() { - $this->Acl->Aro->map = array( - 'User' => 'User/username', - 'Role' => 'User/group_id', - ); - - $this->Acl->Aro->aliases = array( - 'Role/1' => 'Role/admin', - 'Role/24' => 'Role/accounting', - ); - - $user = array( - 'User' => array( - 'username' => 'unknown_user', - 'group_id' => '1', - ), - ); - // group/1 - $this->assertEquals('Role/admin', $this->Acl->Aro->resolve($user)); - // group/24 - $this->assertEquals('Role/accounting', $this->Acl->Aro->resolve('Role/24')); - $this->assertEquals('Role/accounting', $this->Acl->Aro->resolve('24')); - - // check department - $user = array( - 'User' => array( - 'username' => 'foo', - 'group_id' => '25', - ), - ); - - $this->Acl->Aro->addRole(array('Role/IT' => null)); - $this->Acl->Aro->addAlias(array('Role/25' => 'Role/IT')); - $this->Acl->allow('Role/IT', '/rules/debugging/*'); - - $this->assertEquals(array(array('Role/IT', )), $this->Acl->Aro->roles($user)); - $this->assertTrue($this->Acl->check($user, '/rules/debugging/stats/pageload')); - $this->assertTrue($this->Acl->check($user, '/rules/debugging/sql/queries')); - // Role/default is allowed users dashboard, but not Role/IT - $this->assertFalse($this->Acl->check($user, '/controllers/users/dashboard')); - - $this->assertFalse($this->Acl->check($user, '/controllers/invoices/send')); - // wee add an more specific entry for user foo to also inherit from Role/accounting - $this->Acl->Aro->addRole(array('User/foo' => 'Role/IT, Role/accounting')); - $this->assertTrue($this->Acl->check($user, '/controllers/invoices/send')); - } - -/** - * test check method - * - * @return void - */ - public function testCheck() { - $this->assertTrue($this->Acl->check('jan', '/controllers/users/Dashboard')); - $this->assertTrue($this->Acl->check('some_unknown_role', '/controllers/users/Dashboard')); - $this->assertTrue($this->Acl->check('Role/admin', 'foo/bar')); - $this->assertTrue($this->Acl->check('role/admin', '/foo/bar')); - $this->assertTrue($this->Acl->check('jan', 'foo/bar')); - $this->assertTrue($this->Acl->check('user/jan', 'foo/bar')); - $this->assertTrue($this->Acl->check('Role/admin', 'controllers/bar')); - $this->assertTrue($this->Acl->check(array('User' => array('username' => 'jan')), '/controllers/bar/bll')); - $this->assertTrue($this->Acl->check('Role/database_manager', 'controllers/db/create')); - $this->assertTrue($this->Acl->check('User/db_manager_2', 'controllers/db/create')); - $this->assertFalse($this->Acl->check('db_manager_2', '/controllers/users/Dashboard')); - - // inheritance: hardy -> reports -> data_analyst -> database_manager - $this->assertTrue($this->Acl->check('User/hardy', 'controllers/db/create')); - $this->assertFalse($this->Acl->check('User/jeff', 'controllers/db/create')); - - $this->assertTrue($this->Acl->check('Role/database_manager', 'controllers/db/select')); - $this->assertTrue($this->Acl->check('User/db_manager_2', 'controllers/db/select')); - $this->assertFalse($this->Acl->check('User/jeff', 'controllers/db/select')); - - $this->assertTrue($this->Acl->check('Role/database_manager', 'controllers/db/drop')); - $this->assertTrue($this->Acl->check('User/db_manager_1', 'controllers/db/drop')); - $this->assertFalse($this->Acl->check('db_manager_2', 'controllers/db/drop')); - - $this->assertTrue($this->Acl->check('db_manager_2', 'controllers/invoices/edit')); - $this->assertFalse($this->Acl->check('database_manager', 'controllers/invoices/edit')); - $this->assertFalse($this->Acl->check('db_manager_1', 'controllers/invoices/edit')); - - // Role/manager is allowed /controllers/*/*_manager - $this->assertTrue($this->Acl->check('stan', 'controllers/invoices/manager_edit')); - $this->assertTrue($this->Acl->check('Role/manager', 'controllers/baz/manager_foo')); - $this->assertFalse($this->Acl->check('User/stan', 'custom/foo/manager_edit')); - $this->assertFalse($this->Acl->check('stan', 'bar/baz/manager_foo')); - $this->assertFalse($this->Acl->check('Role/accounting', 'bar/baz/manager_foo')); - $this->assertFalse($this->Acl->check('accounting', 'controllers/baz/manager_foo')); - - $this->assertTrue($this->Acl->check('User/stan', 'controllers/articles/edit')); - $this->assertTrue($this->Acl->check('stan', 'controllers/articles/add')); - $this->assertTrue($this->Acl->check('stan', 'controllers/articles/publish')); - $this->assertFalse($this->Acl->check('User/stan', 'controllers/articles/delete')); - $this->assertFalse($this->Acl->check('accounting', 'controllers/articles/edit')); - $this->assertFalse($this->Acl->check('accounting', 'controllers/articles/add')); - $this->assertFalse($this->Acl->check('role/accounting', 'controllers/articles/publish')); - } - -/** - * lhs of defined rules are case insensitive - */ - public function testCheckIsCaseInsensitive() { - $this->assertTrue($this->Acl->check('hardy', 'controllers/forms/new')); - $this->assertTrue($this->Acl->check('Role/data_acquirer', 'controllers/forms/new')); - $this->assertTrue($this->Acl->check('hardy', 'controllers/FORMS/NEW')); - $this->assertTrue($this->Acl->check('Role/data_acquirer', 'controllers/FORMS/NEW')); - } - -/** - * allow should work in-memory - */ - public function testAllow() { - $this->assertFalse($this->Acl->check('jeff', 'foo/bar')); - - $this->Acl->allow('jeff', 'foo/bar'); - - $this->assertTrue($this->Acl->check('jeff', 'foo/bar')); - $this->assertFalse($this->Acl->check('peter', 'foo/bar')); - $this->assertFalse($this->Acl->check('hardy', 'foo/bar')); - - $this->Acl->allow('Role/accounting', 'foo/bar'); - - $this->assertTrue($this->Acl->check('peter', 'foo/bar')); - $this->assertTrue($this->Acl->check('hardy', 'foo/bar')); - - $this->assertFalse($this->Acl->check('Role/reports', 'foo/bar')); - } - -/** - * deny should work in-memory - */ - public function testDeny() { - $this->assertTrue($this->Acl->check('stan', 'controllers/baz/manager_foo')); - - $this->Acl->deny('stan', 'controllers/baz/manager_foo'); - - $this->assertFalse($this->Acl->check('stan', 'controllers/baz/manager_foo')); - $this->assertTrue($this->Acl->check('Role/manager', 'controllers/baz/manager_foo')); - $this->assertTrue($this->Acl->check('stan', 'controllers/baz/manager_bar')); - $this->assertTrue($this->Acl->check('stan', 'controllers/baz/manager_foooooo')); - } - -/** - * test that a deny rule wins over an equally specific allow rule - */ - public function testDenyRuleIsStrongerThanAllowRule() { - $this->assertFalse($this->Acl->check('peter', 'baz/bam')); - $this->Acl->allow('peter', 'baz/bam'); - $this->assertTrue($this->Acl->check('peter', 'baz/bam')); - $this->Acl->deny('peter', 'baz/bam'); - $this->assertFalse($this->Acl->check('peter', 'baz/bam')); - - $this->assertTrue($this->Acl->check('stan', 'controllers/reports/foo')); - // stan is denied as he's sales and sales is denied /controllers/*/delete - $this->assertFalse($this->Acl->check('stan', 'controllers/reports/delete')); - $this->Acl->allow('stan', 'controllers/reports/delete'); - $this->assertFalse($this->Acl->check('Role/sales', 'controllers/reports/delete')); - $this->assertTrue($this->Acl->check('stan', 'controllers/reports/delete')); - $this->Acl->deny('stan', 'controllers/reports/delete'); - $this->assertFalse($this->Acl->check('stan', 'controllers/reports/delete')); - - // there is already an equally specific deny rule that will win - $this->Acl->allow('stan', 'controllers/reports/delete'); - $this->assertFalse($this->Acl->check('stan', 'controllers/reports/delete')); - } - -/** - * test that an invalid configuration throws exception - */ - public function testInvalidConfigWithAroMissing() { - $this->setExpectedException( - 'AclException', - '"roles" section not found in configuration' - ); - $config = array('aco' => array('allow' => array('foo' => ''))); - $this->PhpAcl->build($config); - } - - public function testInvalidConfigWithAcosMissing() { - $this->setExpectedException( - 'AclException', - 'Neither "allow" nor "deny" rules were provided in configuration.' - ); - - $config = array( - 'roles' => array('Role/foo' => null), - ); - - $this->PhpAcl->build($config); - } - -/** - * test resolving of ACOs - */ - public function testAcoResolve() { - $this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('foo/bar')); - $this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('foo/bar')); - $this->assertEquals(array('foo', 'bar', 'baz'), $this->Acl->Aco->resolve('foo/bar/baz')); - $this->assertEquals(array('foo', '*-bar', '?-baz'), $this->Acl->Aco->resolve('foo/*-bar/?-baz')); - - $this->assertEquals(array('foo', 'bar', '[a-f0-9]{24}', '*_bla', 'bla'), $this->Acl->Aco->resolve('foo/bar/[a-f0-9]{24}/*_bla/bla')); - - // multiple slashes will be squashed to a single, trimmed and then exploded - $this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('foo//bar')); - $this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('//foo///bar/')); - $this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('/foo//bar//')); - $this->assertEquals(array('foo', 'bar'), $this->Acl->Aco->resolve('/foo // bar')); - $this->assertEquals(array(), $this->Acl->Aco->resolve('/////')); - } - -/** - * test that declaring cyclic dependencies should give an error when building the tree - */ - public function testAroDeclarationContainsCycles() { - $config = array( - 'roles' => array( - 'Role/a' => null, - 'Role/b' => 'User/b', - 'User/a' => 'Role/a, Role/b', - 'User/b' => 'User/a', - - ), - 'rules' => array( - 'allow' => array( - '*' => 'Role/a', - ), - ), - ); - - $this->expectError('PHPUnit_Framework_Error', 'cycle detected' /* ... */); - $this->PhpAcl->build($config); - } - -/** - * test that with policy allow, only denies count - */ - public function testPolicy() { - // allow by default - $this->Acl->settings['adapter']['policy'] = PhpAcl::ALLOW; - $this->Acl->adapter($this->PhpAcl); - - $this->assertTrue($this->Acl->check('Role/sales', 'foo')); - $this->assertTrue($this->Acl->check('Role/sales', 'controllers/bla/create')); - $this->assertTrue($this->Acl->check('Role/default', 'foo')); - // undefined user, undefined aco - $this->assertTrue($this->Acl->check('foobar', 'foo/bar')); - - // deny rule: Role.sales -> controllers.*.delete - $this->assertFalse($this->Acl->check('Role/sales', 'controllers/bar/delete')); - $this->assertFalse($this->Acl->check('Role/sales', 'controllers/bar', 'delete')); - } -} diff --git a/lib/Cake/Test/Case/Controller/Component/AclComponentTest.php b/lib/Cake/Test/Case/Controller/Component/AclComponentTest.php deleted file mode 100644 index 7261a8222de..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/AclComponentTest.php +++ /dev/null @@ -1,91 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 1.2.0.5435 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('AclComponent', 'Controller/Component'); -class_exists('AclComponent'); - -/** - * Test Case for AclComponent - * - * @package Cake.Test.Case.Controller.Component - */ -class AclComponentTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - if (!class_exists('MockAclImplementation', false)) { - $this->getMock('AclInterface', array(), array(), 'MockAclImplementation'); - } - Configure::write('Acl.classname', 'MockAclImplementation'); - $Collection = new ComponentCollection(); - $this->Acl = new AclComponent($Collection); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Acl); - } - -/** - * test that constructor throws an exception when Acl.classname is a - * non-existent class - * - * @expectedException CakeException - * @return void - */ - public function testConstrutorException() { - Configure::write('Acl.classname', 'AclClassNameThatDoesNotExist'); - $Collection = new ComponentCollection(); - $acl = new AclComponent($Collection); - } - -/** - * test that adapter() allows control of the internal implementation AclComponent uses. - * - * @return void - */ - public function testAdapter() { - $implementation = new MockAclImplementation(); - $implementation->expects($this->once())->method('initialize')->with($this->Acl); - $this->assertNull($this->Acl->adapter($implementation)); - - $this->assertEquals($this->Acl->adapter(), $implementation, 'Returned object is different %s'); - } - -/** - * test that adapter() whines when the class is not an AclBase - * - * @expectedException CakeException - * @return void - */ - public function testAdapterException() { - $thing = new StdClass(); - $this->Acl->adapter($thing); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/Auth/ActionsAuthorizeTest.php b/lib/Cake/Test/Case/Controller/Component/Auth/ActionsAuthorizeTest.php deleted file mode 100644 index 6b06b37b006..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Auth/ActionsAuthorizeTest.php +++ /dev/null @@ -1,192 +0,0 @@ -controller = $this->getMock('Controller', array(), array(), '', false); - $this->Acl = $this->getMock('AclComponent', array(), array(), '', false); - $this->Collection = $this->getMock('ComponentCollection'); - - $this->auth = new ActionsAuthorize($this->Collection); - $this->auth->settings['actionPath'] = '/controllers'; - } - -/** - * setup the mock acl. - * - * @return void - */ - protected function _mockAcl() { - $this->Collection->expects($this->any()) - ->method('load') - ->with('Acl') - ->will($this->returnValue($this->Acl)); - } - -/** - * test failure - * - * @return void - */ - public function testAuthorizeFailure() { - $user = array( - 'User' => array( - 'id' => 1, - 'user' => 'mariano' - ) - ); - $request = new CakeRequest('/posts/index', false); - $request->addParams(array( - 'plugin' => null, - 'controller' => 'posts', - 'action' => 'index' - )); - - $this->_mockAcl(); - - $this->Acl->expects($this->once()) - ->method('check') - ->with($user, 'controllers/Posts/index') - ->will($this->returnValue(false)); - - $this->assertFalse($this->auth->authorize($user['User'], $request)); - } - -/** - * test isAuthorized working. - * - * @return void - */ - public function testAuthorizeSuccess() { - $user = array( - 'User' => array( - 'id' => 1, - 'user' => 'mariano' - ) - ); - $request = new CakeRequest('/posts/index', false); - $request->addParams(array( - 'plugin' => null, - 'controller' => 'posts', - 'action' => 'index' - )); - - $this->_mockAcl(); - - $this->Acl->expects($this->once()) - ->method('check') - ->with($user, 'controllers/Posts/index') - ->will($this->returnValue(true)); - - $this->assertTrue($this->auth->authorize($user['User'], $request)); - } - -/** - * testAuthorizeSettings - * - * @return void - */ - public function testAuthorizeSettings() { - $request = new CakeRequest('/posts/index', false); - $request->addParams(array( - 'plugin' => null, - 'controller' => 'posts', - 'action' => 'index' - )); - - $this->_mockAcl(); - - $this->auth->settings['userModel'] = 'TestPlugin.TestPluginAuthUser'; - $user = array( - 'id' => 1, - 'user' => 'mariano' - ); - - $expected = array('TestPlugin.TestPluginAuthUser' => array('id' => 1, 'user' => 'mariano')); - $this->Acl->expects($this->once()) - ->method('check') - ->with($expected, 'controllers/Posts/index') - ->will($this->returnValue(true)); - - $this->assertTrue($this->auth->authorize($user, $request)); - } - -/** - * test action() - * - * @return void - */ - public function testActionMethod() { - $request = new CakeRequest('/posts/index', false); - $request->addParams(array( - 'plugin' => null, - 'controller' => 'posts', - 'action' => 'index' - )); - - $result = $this->auth->action($request); - $this->assertEquals('controllers/Posts/index', $result); - } - -/** - * Make sure that action() doesn't create double slashes anywhere. - * - * @return void - */ - public function testActionNoDoubleSlash() { - $this->auth->settings['actionPath'] = '/controllers/'; - $request = array( - 'plugin' => null, - 'controller' => 'posts', - 'action' => 'index' - ); - $result = $this->auth->action($request); - $this->assertEquals('controllers/Posts/index', $result); - } - -/** - * test action() and plugins - * - * @return void - */ - public function testActionWithPlugin() { - $request = new CakeRequest('/debug_kit/posts/index', false); - $request->addParams(array( - 'plugin' => 'debug_kit', - 'controller' => 'posts', - 'action' => 'index' - )); - - $result = $this->auth->action($request); - $this->assertEquals('controllers/DebugKit/Posts/index', $result); - } -} diff --git a/lib/Cake/Test/Case/Controller/Component/Auth/BasicAuthenticateTest.php b/lib/Cake/Test/Case/Controller/Component/Auth/BasicAuthenticateTest.php deleted file mode 100644 index 44bf9ba6cb5..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Auth/BasicAuthenticateTest.php +++ /dev/null @@ -1,217 +0,0 @@ -Collection = $this->getMock('ComponentCollection'); - $this->auth = new BasicAuthenticate($this->Collection, array( - 'fields' => array('username' => 'user', 'password' => 'password'), - 'userModel' => 'User', - 'realm' => 'localhost', - 'recursive' => 0 - )); - - $password = Security::hash('password', null, true); - $User = ClassRegistry::init('User'); - $User->updateAll(array('password' => $User->getDataSource()->value($password))); - $this->server = $_SERVER; - $this->response = $this->getMock('CakeResponse'); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - $_SERVER = $this->server; - } - -/** - * test applying settings in the constructor - * - * @return void - */ - public function testConstructor() { - $object = new BasicAuthenticate($this->Collection, array( - 'userModel' => 'AuthUser', - 'fields' => array('username' => 'user', 'password' => 'password') - )); - $this->assertEquals('AuthUser', $object->settings['userModel']); - $this->assertEquals(array('username' => 'user', 'password' => 'password'), $object->settings['fields']); - $this->assertEquals(env('SERVER_NAME'), $object->settings['realm']); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoData() { - $request = new CakeRequest('posts/index', false); - - $this->response->expects($this->once()) - ->method('header') - ->with('WWW-Authenticate: Basic realm="localhost"'); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoUsername() { - $request = new CakeRequest('posts/index', false); - $_SERVER['PHP_AUTH_PW'] = 'foobar'; - - $this->response->expects($this->once()) - ->method('header') - ->with('WWW-Authenticate: Basic realm="localhost"'); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoPassword() { - $request = new CakeRequest('posts/index', false); - $_SERVER['PHP_AUTH_USER'] = 'mariano'; - $_SERVER['PHP_AUTH_PW'] = null; - - $this->response->expects($this->once()) - ->method('header') - ->with('WWW-Authenticate: Basic realm="localhost"'); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateInjection() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $_SERVER['PHP_AUTH_USER'] = '> 1'; - $_SERVER['PHP_AUTH_PW'] = "' OR 1 = 1"; - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test that challenge headers are sent when no credentials are found. - * - * @return void - */ - public function testAuthenticateChallenge() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $this->response->expects($this->at(0)) - ->method('header') - ->with('WWW-Authenticate: Basic realm="localhost"'); - - $this->response->expects($this->at(1)) - ->method('send'); - - $result = $this->auth->authenticate($request, $this->response); - $this->assertFalse($result); - } - -/** - * test authenticate sucesss - * - * @return void - */ - public function testAuthenticateSuccess() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $_SERVER['PHP_AUTH_USER'] = 'mariano'; - $_SERVER['PHP_AUTH_PW'] = 'password'; - - $result = $this->auth->authenticate($request, $this->response); - $expected = array( - 'id' => 1, - 'user' => 'mariano', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ); - $this->assertEquals($expected, $result); - } - -/** - * test scope failure. - * - * @return void - */ - public function testAuthenticateFailReChallenge() { - $this->auth->settings['scope'] = array('user' => 'nate'); - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $_SERVER['PHP_AUTH_USER'] = 'mariano'; - $_SERVER['PHP_AUTH_PW'] = 'password'; - - $this->response->expects($this->at(0)) - ->method('header') - ->with('WWW-Authenticate: Basic realm="localhost"'); - - $this->response->expects($this->at(1)) - ->method('statusCode') - ->with(401); - - $this->response->expects($this->at(2)) - ->method('send'); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/Auth/ControllerAuthorizeTest.php b/lib/Cake/Test/Case/Controller/Component/Auth/ControllerAuthorizeTest.php deleted file mode 100644 index a1dae910854..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Auth/ControllerAuthorizeTest.php +++ /dev/null @@ -1,84 +0,0 @@ -controller = $this->getMock('Controller', array('isAuthorized'), array(), '', false); - $this->components = $this->getMock('ComponentCollection'); - $this->components->expects($this->any()) - ->method('getController') - ->will($this->returnValue($this->controller)); - - $this->auth = new ControllerAuthorize($this->components); - } - -/** - * @expectedException PHPUnit_Framework_Error - */ - public function testControllerTypeError() { - $this->auth->controller(new StdClass()); - } - -/** - * @expectedException CakeException - */ - public function testControllerErrorOnMissingMethod() { - $this->auth->controller(new Controller()); - } - -/** - * test failure - * - * @return void - */ - public function testAuthorizeFailure() { - $user = array(); - $request = new CakeRequest('/posts/index', false); - $this->assertFalse($this->auth->authorize($user, $request)); - } - -/** - * test isAuthorized working. - * - * @return void - */ - public function testAuthorizeSuccess() { - $user = array('User' => array('username' => 'mark')); - $request = new CakeRequest('/posts/index', false); - - $this->controller->expects($this->once()) - ->method('isAuthorized') - ->with($user) - ->will($this->returnValue(true)); - - $this->assertTrue($this->auth->authorize($user, $request)); - } -} diff --git a/lib/Cake/Test/Case/Controller/Component/Auth/CrudAuthorizeTest.php b/lib/Cake/Test/Case/Controller/Component/Auth/CrudAuthorizeTest.php deleted file mode 100644 index 1f4464571a7..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Auth/CrudAuthorizeTest.php +++ /dev/null @@ -1,186 +0,0 @@ -Acl = $this->getMock('AclComponent', array(), array(), '', false); - $this->Components = $this->getMock('ComponentCollection'); - - $this->auth = new CrudAuthorize($this->Components); - } - -/** - * setup the mock acl. - * - * @return void - */ - protected function _mockAcl() { - $this->Components->expects($this->any()) - ->method('load') - ->with('Acl') - ->will($this->returnValue($this->Acl)); - } - -/** - * test authorize() without a mapped action, ensure an error is generated. - * - * @expectedException PHPUnit_Framework_Error_Warning - * @return void - */ - public function testAuthorizeNoMappedAction() { - $request = new CakeRequest('/posts/foobar', false); - $request->addParams(array( - 'controller' => 'posts', - 'action' => 'foobar' - )); - $user = array('User' => array('user' => 'mark')); - - $this->auth->authorize($user, $request); - } - -/** - * test check() passing - * - * @return void - */ - public function testAuthorizeCheckSuccess() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array( - 'controller' => 'posts', - 'action' => 'index' - )); - $user = array('User' => array('user' => 'mark')); - - $this->_mockAcl(); - $this->Acl->expects($this->once()) - ->method('check') - ->with($user, 'Posts', 'read') - ->will($this->returnValue(true)); - - $this->assertTrue($this->auth->authorize($user['User'], $request)); - } - -/** - * test check() failing - * - * @return void - */ - public function testAuthorizeCheckFailure() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array( - 'controller' => 'posts', - 'action' => 'index' - )); - $user = array('User' => array('user' => 'mark')); - - $this->_mockAcl(); - $this->Acl->expects($this->once()) - ->method('check') - ->with($user, 'Posts', 'read') - ->will($this->returnValue(false)); - - $this->assertFalse($this->auth->authorize($user['User'], $request)); - } - -/** - * test getting actionMap - * - * @return void - */ - public function testMapActionsGet() { - $result = $this->auth->mapActions(); - $expected = array( - 'create' => 'create', - 'read' => 'read', - 'update' => 'update', - 'delete' => 'delete', - 'index' => 'read', - 'add' => 'create', - 'edit' => 'update', - 'view' => 'read', - 'remove' => 'delete' - ); - $this->assertEquals($expected, $result); - } - -/** - * test adding into mapActions - * - * @return void - */ - public function testMapActionsSet() { - $map = array( - 'create' => array('generate'), - 'read' => array('listing', 'show'), - 'update' => array('update'), - 'random' => 'custom' - ); - $result = $this->auth->mapActions($map); - $this->assertNull($result); - - $result = $this->auth->mapActions(); - $expected = array( - 'add' => 'create', - 'create' => 'create', - 'read' => 'read', - 'index' => 'read', - 'add' => 'create', - 'edit' => 'update', - 'view' => 'read', - 'delete' => 'delete', - 'remove' => 'delete', - 'generate' => 'create', - 'listing' => 'read', - 'show' => 'read', - 'update' => 'update', - 'random' => 'custom', - ); - $this->assertEquals($expected, $result); - } - -/** - * test prefix routes getting auto mapped. - * - * @return void - */ - public function testAutoPrefixMapActions() { - Configure::write('Routing.prefixes', array('admin', 'manager')); - Router::reload(); - - $auth = new CrudAuthorize($this->Components); - $this->assertTrue(isset($auth->settings['actionMap']['admin_index'])); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/Auth/DigestAuthenticateTest.php b/lib/Cake/Test/Case/Controller/Component/Auth/DigestAuthenticateTest.php deleted file mode 100644 index f9ca8316b2c..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Auth/DigestAuthenticateTest.php +++ /dev/null @@ -1,306 +0,0 @@ -Collection = $this->getMock('ComponentCollection'); - $this->server = $_SERVER; - $this->auth = new DigestAuthenticate($this->Collection, array( - 'fields' => array('username' => 'user', 'password' => 'password'), - 'userModel' => 'User', - 'realm' => 'localhost', - 'nonce' => 123, - 'opaque' => '123abc' - )); - - $password = DigestAuthenticate::password('mariano', 'cake', 'localhost'); - $User = ClassRegistry::init('User'); - $User->updateAll(array('password' => $User->getDataSource()->value($password))); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $this->response = $this->getMock('CakeResponse'); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - $_SERVER = $this->server; - } - -/** - * test applying settings in the constructor - * - * @return void - */ - public function testConstructor() { - $object = new DigestAuthenticate($this->Collection, array( - 'userModel' => 'AuthUser', - 'fields' => array('username' => 'user', 'password' => 'password'), - 'nonce' => 123456 - )); - $this->assertEquals('AuthUser', $object->settings['userModel']); - $this->assertEquals(array('username' => 'user', 'password' => 'password'), $object->settings['fields']); - $this->assertEquals(123456, $object->settings['nonce']); - $this->assertEquals(env('SERVER_NAME'), $object->settings['realm']); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoData() { - $request = new CakeRequest('posts/index', false); - - $this->response->expects($this->once()) - ->method('header') - ->with('WWW-Authenticate: Digest realm="localhost",qop="auth",nonce="123",opaque="123abc"'); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateWrongUsername() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $_SERVER['PHP_AUTH_DIGEST'] = <<response->expects($this->at(0)) - ->method('header') - ->with('WWW-Authenticate: Digest realm="localhost",qop="auth",nonce="123",opaque="123abc"'); - - $this->response->expects($this->at(1)) - ->method('statusCode') - ->with(401); - - $this->response->expects($this->at(2)) - ->method('send'); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test that challenge headers are sent when no credentials are found. - * - * @return void - */ - public function testAuthenticateChallenge() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $this->response->expects($this->at(0)) - ->method('header') - ->with('WWW-Authenticate: Digest realm="localhost",qop="auth",nonce="123",opaque="123abc"'); - - $this->response->expects($this->at(1)) - ->method('statusCode') - ->with(401); - - $this->response->expects($this->at(2)) - ->method('send'); - - $result = $this->auth->authenticate($request, $this->response); - $this->assertFalse($result); - } - -/** - * test authenticate success - * - * @return void - */ - public function testAuthenticateSuccess() { - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $_SERVER['PHP_AUTH_DIGEST'] = <<auth->authenticate($request, $this->response); - $expected = array( - 'id' => 1, - 'user' => 'mariano', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ); - $this->assertEquals($expected, $result); - } - -/** - * test scope failure. - * - * @return void - */ - public function testAuthenticateFailReChallenge() { - $this->auth->settings['scope'] = array('user' => 'nate'); - $request = new CakeRequest('posts/index', false); - $request->addParams(array('pass' => array(), 'named' => array())); - - $_SERVER['PHP_AUTH_DIGEST'] = <<response->expects($this->at(0)) - ->method('header') - ->with('WWW-Authenticate: Digest realm="localhost",qop="auth",nonce="123",opaque="123abc"'); - - $this->response->expects($this->at(1)) - ->method('statusCode') - ->with(401); - - $this->response->expects($this->at(2)) - ->method('send'); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * testParseDigestAuthData method - * - * @return void - */ - public function testParseAuthData() { - $digest = << 'Mufasa', - '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); - - $result = $this->auth->parseAuthData(''); - $this->assertNull($result); - } - -/** - * 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); - } -} diff --git a/lib/Cake/Test/Case/Controller/Component/Auth/FormAuthenticateTest.php b/lib/Cake/Test/Case/Controller/Component/Auth/FormAuthenticateTest.php deleted file mode 100644 index 09097d4b7de..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/Auth/FormAuthenticateTest.php +++ /dev/null @@ -1,194 +0,0 @@ -Collection = $this->getMock('ComponentCollection'); - $this->auth = new FormAuthenticate($this->Collection, array( - 'fields' => array('username' => 'user', 'password' => 'password'), - 'userModel' => 'User' - )); - $password = Security::hash('password', null, true); - $User = ClassRegistry::init('User'); - $User->updateAll(array('password' => $User->getDataSource()->value($password))); - $this->response = $this->getMock('CakeResponse'); - } - -/** - * test applying settings in the constructor - * - * @return void - */ - public function testConstructor() { - $object = new FormAuthenticate($this->Collection, array( - 'userModel' => 'AuthUser', - 'fields' => array('username' => 'user', 'password' => 'password') - )); - $this->assertEquals('AuthUser', $object->settings['userModel']); - $this->assertEquals(array('username' => 'user', 'password' => 'password'), $object->settings['fields']); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoData() { - $request = new CakeRequest('posts/index', false); - $request->data = array(); - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoUsername() { - $request = new CakeRequest('posts/index', false); - $request->data = array('User' => array('password' => 'foobar')); - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoPassword() { - $request = new CakeRequest('posts/index', false); - $request->data = array('User' => array('user' => 'mariano')); - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateInjection() { - $request = new CakeRequest('posts/index', false); - $request->data = array( - 'User' => array( - 'user' => '> 1', - 'password' => "' OR 1 = 1" - )); - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test authenticate success - * - * @return void - */ - public function testAuthenticateSuccess() { - $request = new CakeRequest('posts/index', false); - $request->data = array('User' => array( - 'user' => 'mariano', - 'password' => 'password' - )); - $result = $this->auth->authenticate($request, $this->response); - $expected = array( - 'id' => 1, - 'user' => 'mariano', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ); - $this->assertEquals($expected, $result); - } - -/** - * test scope failure. - * - * @return void - */ - public function testAuthenticateScopeFail() { - $this->auth->settings['scope'] = array('user' => 'nate'); - $request = new CakeRequest('posts/index', false); - $request->data = array('User' => array( - 'user' => 'mariano', - 'password' => 'password' - )); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - -/** - * test a model in a plugin. - * - * @return void - */ - public function testPluginModel() { - Cache::delete('object_map', '_cake_core_'); - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - ), App::RESET); - CakePlugin::load('TestPlugin'); - - $ts = date('Y-m-d H:i:s'); - $PluginModel = ClassRegistry::init('TestPlugin.TestPluginAuthUser'); - $user['id'] = 1; - $user['username'] = 'gwoo'; - $user['password'] = Security::hash(Configure::read('Security.salt') . 'cake'); - $PluginModel->save($user, false); - - $this->auth->settings['userModel'] = 'TestPlugin.TestPluginAuthUser'; - $this->auth->settings['fields']['username'] = 'username'; - - $request = new CakeRequest('posts/index', false); - $request->data = array('TestPluginAuthUser' => array( - 'username' => 'gwoo', - 'password' => 'cake' - )); - - $result = $this->auth->authenticate($request, $this->response); - $expected = array( - 'id' => 1, - 'username' => 'gwoo', - 'created' => '2007-03-17 01:16:23' - ); - $this->assertTrue($result['updated'] >= $ts); - unset($result['updated']); - $this->assertEquals($expected, $result); - CakePlugin::unload(); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/AuthComponentTest.php b/lib/Cake/Test/Case/Controller/Component/AuthComponentTest.php deleted file mode 100644 index 1fb5e3e325d..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/AuthComponentTest.php +++ /dev/null @@ -1,1229 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 1.2.0.5347 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Controller', 'Controller'); -App::uses('AuthComponent', 'Controller/Component'); -App::uses('AclComponent', 'Controller/Component'); -App::uses('FormAuthenticate', 'Controller/Component/Auth'); - -/** - * TestAuthComponent class - * - * @package Cake.Test.Case.Controller.Component - * @package Cake.Test.Case.Controller.Component - */ -class TestAuthComponent extends AuthComponent { - -/** - * testStop property - * - * @var bool false - */ - public $testStop = false; - -/** - * stop method - * - * @return void - */ - protected function _stop($status = 0) { - $this->testStop = true; - } - - public static function clearUser() { - self::$_user = array(); - } - -} - -/** - * AuthUser class - * - * @package Cake.Test.Case.Controller.Component - * @package Cake.Test.Case.Controller.Component - */ -class AuthUser extends CakeTestModel { - -/** - * name property - * - * @var string 'AuthUser' - */ - public $name = 'AuthUser'; - -/** - * useDbConfig property - * - * @var string 'test' - */ - public $useDbConfig = 'test'; - -} - -/** - * AuthTestController class - * - * @package Cake.Test.Case.Controller.Component - * @package Cake.Test.Case.Controller.Component - */ -class AuthTestController extends Controller { - -/** - * name property - * - * @var string 'AuthTest' - */ - public $name = 'AuthTest'; - -/** - * uses property - * - * @var array - */ - public $uses = array('AuthUser'); - -/** - * components property - * - * @var array - */ - public $components = array('Session', 'Auth'); - -/** - * testUrl property - * - * @var mixed null - */ - public $testUrl = null; - -/** - * construct method - * - * @return void - */ - public function __construct($request, $response) { - $request->addParams(Router::parse('/auth_test')); - $request->here = '/auth_test'; - $request->webroot = '/'; - Router::setRequestInfo($request); - parent::__construct($request, $response); - } - -/** - * login method - * - * @return void - */ - public function login() { - } - -/** - * admin_login method - * - * @return void - */ - public function admin_login() { - } - -/** - * admin_add method - * - * @return void - */ - public function admin_add() { - } - -/** - * logout method - * - * @return void - */ - public function logout() { - } - -/** - * add method - * - * @return void - */ - public function add() { - echo "add"; - } - -/** - * add method - * - * @return void - */ - public function camelCase() { - echo "camelCase"; - } - -/** - * redirect method - * - * @param mixed $url - * @param mixed $status - * @param mixed $exit - * @return void - */ - public function redirect($url, $status = null, $exit = true) { - $this->testUrl = Router::url($url); - return false; - } - -/** - * isAuthorized method - * - * @return void - */ - public function isAuthorized() { - } - -} - -/** - * AjaxAuthController class - * - * @package Cake.Test.Case.Controller.Component - */ -class AjaxAuthController extends Controller { - -/** - * name property - * - * @var string 'AjaxAuth' - */ - public $name = 'AjaxAuth'; - -/** - * components property - * - * @var array - */ - public $components = array('Session', 'TestAuth'); - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * testUrl property - * - * @var mixed null - */ - public $testUrl = null; - -/** - * beforeFilter method - * - * @return void - */ - public function beforeFilter() { - $this->TestAuth->ajaxLogin = 'test_element'; - $this->TestAuth->userModel = 'AuthUser'; - $this->TestAuth->RequestHandler->ajaxLayout = 'ajax2'; - } - -/** - * add method - * - * @return void - */ - public function add() { - if ($this->TestAuth->testStop !== true) { - echo 'Added Record'; - } - } - -/** - * redirect method - * - * @param mixed $url - * @param mixed $status - * @param mixed $exit - * @return void - */ - public function redirect($url, $status = null, $exit = true) { - $this->testUrl = Router::url($url); - return false; - } - -} - -/** - * AuthComponentTest class - * - * @package Cake.Test.Case.Controller.Component - * @package Cake.Test.Case.Controller.Component - */ -class AuthComponentTest extends CakeTestCase { - -/** - * name property - * - * @var string 'Auth' - */ - public $name = 'Auth'; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.auth_user'); - -/** - * initialized property - * - * @var bool false - */ - public $initialized = false; - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->_server = $_SERVER; - $this->_env = $_ENV; - - Configure::write('Security.salt', 'YJfIxfs2guVoUubWDYhG93b0qyJfIxfs2guwvniR2G0FgaC9mi'); - Configure::write('Security.cipherSeed', 770011223369876); - - $request = new CakeRequest(null, false); - - $this->Controller = new AuthTestController($request, $this->getMock('CakeResponse')); - - $collection = new ComponentCollection(); - $collection->init($this->Controller); - $this->Auth = new TestAuthComponent($collection); - $this->Auth->request = $request; - $this->Auth->response = $this->getMock('CakeResponse'); - - $this->Controller->Components->init($this->Controller); - - $this->initialized = true; - Router::reload(); - Router::connect('/:controller/:action/*'); - - $User = ClassRegistry::init('AuthUser'); - $User->updateAll(array('password' => $User->getDataSource()->value(Security::hash('cake', null, true)))); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - $_SERVER = $this->_server; - $_ENV = $this->_env; - - TestAuthComponent::clearUser(); - $this->Auth->Session->delete('Auth'); - $this->Auth->Session->delete('Message.auth'); - unset($this->Controller, $this->Auth); - } - -/** - * testNoAuth method - * - * @return void - */ - public function testNoAuth() { - $this->assertFalse($this->Auth->isAuthorized()); - } - -/** - * testIsErrorOrTests - * - * @return void - */ - public function testIsErrorOrTests() { - $this->Controller->Auth->initialize($this->Controller); - - $this->Controller->name = 'CakeError'; - $this->assertTrue($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->name = 'Post'; - $this->Controller->request['action'] = 'thisdoesnotexist'; - $this->assertTrue($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->scaffold = null; - $this->Controller->request['action'] = 'index'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - } - -/** - * testLogin method - * - * @return void - */ - public function testLogin() { - $this->getMock('FormAuthenticate', array(), array(), 'AuthLoginFormAuthenticate', false); - $this->Auth->authenticate = array( - 'AuthLoginForm' => array( - 'userModel' => 'AuthUser' - ) - ); - $this->Auth->Session = $this->getMock('SessionComponent', array('renew'), array(), '', false); - - $mocks = $this->Auth->constructAuthenticate(); - $this->mockObjects[] = $mocks[0]; - - $this->Auth->request->data = array( - 'AuthUser' => array( - 'username' => 'mark', - 'password' => Security::hash('cake', null, true) - ) - ); - - $user = array( - 'id' => 1, - 'username' => 'mark' - ); - - $mocks[0]->expects($this->once()) - ->method('authenticate') - ->with($this->Auth->request) - ->will($this->returnValue($user)); - - $this->Auth->Session->expects($this->once()) - ->method('renew'); - - $result = $this->Auth->login(); - $this->assertTrue($result); - - $this->assertTrue($this->Auth->loggedIn()); - $this->assertEquals($user, $this->Auth->user()); - } - -/** - * test that being redirected to the login page, with no post data does - * not set the session value. Saving the session value in this circumstance - * can cause the user to be redirected to an already public page. - * - * @return void - */ - public function testLoginActionNotSettingAuthRedirect() { - $_SERVER['HTTP_REFERER'] = '/pages/display/about'; - - $this->Controller->data = array(); - $this->Controller->request->addParams(Router::parse('auth_test/login')); - $this->Controller->request->url = 'auth_test/login'; - $this->Auth->Session->delete('Auth'); - - $this->Auth->loginRedirect = '/users/dashboard'; - $this->Auth->loginAction = 'auth_test/login'; - $this->Auth->userModel = 'AuthUser'; - - $this->Auth->startup($this->Controller); - $redirect = $this->Auth->Session->read('Auth.redirect'); - $this->assertNull($redirect); - } - -/** - * testAuthorizeFalse method - * - * @return void - */ - public function testAuthorizeFalse() { - $this->AuthUser = new AuthUser(); - $user = $this->AuthUser->find(); - $this->Auth->Session->write('Auth.User', $user['AuthUser']); - $this->Controller->Auth->userModel = 'AuthUser'; - $this->Controller->Auth->authorize = false; - $this->Controller->request->addParams(Router::parse('auth_test/add')); - $result = $this->Controller->Auth->startup($this->Controller); - $this->assertTrue($result); - - $this->Auth->Session->delete('Auth'); - $result = $this->Controller->Auth->startup($this->Controller); - $this->assertFalse($result); - $this->assertTrue($this->Auth->Session->check('Message.auth')); - - $this->Controller->request->addParams(Router::parse('auth_test/camelCase')); - $result = $this->Controller->Auth->startup($this->Controller); - $this->assertFalse($result); - } - -/** - * @expectedException CakeException - * @return void - */ - public function testIsAuthorizedMissingFile() { - $this->Controller->Auth->authorize = 'Missing'; - $this->Controller->Auth->isAuthorized(array('User' => array('id' => 1))); - } - -/** - * test that isAuthorized calls methods correctly - * - * @return void - */ - public function testIsAuthorizedDelegation() { - $this->getMock('BaseAuthorize', array('authorize'), array(), 'AuthMockOneAuthorize', false); - $this->getMock('BaseAuthorize', array('authorize'), array(), 'AuthMockTwoAuthorize', false); - $this->getMock('BaseAuthorize', array('authorize'), array(), 'AuthMockThreeAuthorize', false); - - $this->Auth->authorize = array( - 'AuthMockOne', - 'AuthMockTwo', - 'AuthMockThree' - ); - $mocks = $this->Auth->constructAuthorize(); - $request = $this->Auth->request; - - $this->assertEquals(3, count($mocks)); - $mocks[0]->expects($this->once()) - ->method('authorize') - ->with(array('User'), $request) - ->will($this->returnValue(false)); - - $mocks[1]->expects($this->once()) - ->method('authorize') - ->with(array('User'), $request) - ->will($this->returnValue(true)); - - $mocks[2]->expects($this->never()) - ->method('authorize'); - - $this->assertTrue($this->Auth->isAuthorized(array('User'), $request)); - } - -/** - * test that isAuthorized will use the session user if none is given. - * - * @return void - */ - public function testIsAuthorizedUsingUserInSession() { - $this->getMock('BaseAuthorize', array('authorize'), array(), 'AuthMockFourAuthorize', false); - $this->Auth->authorize = array('AuthMockFour'); - - $user = array('user' => 'mark'); - $this->Auth->Session->write('Auth.User', $user); - $mocks = $this->Auth->constructAuthorize(); - $request = $this->Controller->request; - - $mocks[0]->expects($this->once()) - ->method('authorize') - ->with($user, $request) - ->will($this->returnValue(true)); - - $this->assertTrue($this->Auth->isAuthorized(null, $request)); - } - -/** - * test that loadAuthorize resets the loaded objects each time. - * - * @return void - */ - public function testLoadAuthorizeResets() { - $this->Controller->Auth->authorize = array( - 'Controller' - ); - $result = $this->Controller->Auth->constructAuthorize(); - $this->assertEquals(1, count($result)); - - $result = $this->Controller->Auth->constructAuthorize(); - $this->assertEquals(1, count($result)); - } - -/** - * @expectedException CakeException - * @return void - */ - public function testLoadAuthenticateNoFile() { - $this->Controller->Auth->authenticate = 'Missing'; - $this->Controller->Auth->identify($this->Controller->request, $this->Controller->response); - } - -/** - * test the * key with authenticate - * - * @return void - */ - public function testAllConfigWithAuthorize() { - $this->Controller->Auth->authorize = array( - AuthComponent::ALL => array('actionPath' => 'controllers/'), - 'Actions' - ); - $objects = $this->Controller->Auth->constructAuthorize(); - $result = $objects[0]; - $this->assertEquals('controllers/', $result->settings['actionPath']); - } - -/** - * test that loadAuthorize resets the loaded objects each time. - * - * @return void - */ - public function testLoadAuthenticateResets() { - $this->Controller->Auth->authenticate = array( - 'Form' - ); - $result = $this->Controller->Auth->constructAuthenticate(); - $this->assertEquals(1, count($result)); - - $result = $this->Controller->Auth->constructAuthenticate(); - $this->assertEquals(1, count($result)); - } - -/** - * test the * key with authenticate - * - * @return void - */ - public function testAllConfigWithAuthenticate() { - $this->Controller->Auth->authenticate = array( - AuthComponent::ALL => array('userModel' => 'AuthUser'), - 'Form' - ); - $objects = $this->Controller->Auth->constructAuthenticate(); - $result = $objects[0]; - $this->assertEquals('AuthUser', $result->settings['userModel']); - } - -/** - * Tests that deny always takes precedence over allow - * - * @return void - */ - public function testAllowDenyAll() { - $this->Controller->Auth->initialize($this->Controller); - - $this->Controller->Auth->allow(); - $this->Controller->Auth->deny('add', 'camelCase'); - - $this->Controller->request['action'] = 'delete'; - $this->assertTrue($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->request['action'] = 'add'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->request['action'] = 'camelCase'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->Auth->allow(); - $this->Controller->Auth->deny(array('add', 'camelCase')); - - $this->Controller->request['action'] = 'delete'; - $this->assertTrue($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->request['action'] = 'camelCase'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->Auth->allow('*'); - $this->Controller->Auth->deny(); - - $this->Controller->request['action'] = 'camelCase'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->request['action'] = 'add'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->Auth->allow('camelCase'); - $this->Controller->Auth->deny(); - - $this->Controller->request['action'] = 'camelCase'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->request['action'] = 'login'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->Auth->deny(); - $this->Controller->Auth->allow(null); - - $this->Controller->request['action'] = 'camelCase'; - $this->assertTrue($this->Controller->Auth->startup($this->Controller)); - - $this->Controller->Auth->allow(); - $this->Controller->Auth->deny(null); - - $this->Controller->request['action'] = 'camelCase'; - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - } - -/** - * test that deny() converts camel case inputs to lowercase. - * - * @return void - */ - public function testDenyWithCamelCaseMethods() { - $this->Controller->Auth->initialize($this->Controller); - $this->Controller->Auth->allow(); - $this->Controller->Auth->deny('add', 'camelCase'); - - $url = '/auth_test/camelCase'; - $this->Controller->request->addParams(Router::parse($url)); - $this->Controller->request->query['url'] = Router::normalize($url); - - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - - $url = '/auth_test/CamelCase'; - $this->Controller->request->addParams(Router::parse($url)); - $this->Controller->request->query['url'] = Router::normalize($url); - $this->assertFalse($this->Controller->Auth->startup($this->Controller)); - } - -/** - * test that allow() and allowedActions work with camelCase method names. - * - * @return void - */ - public function testAllowedActionsWithCamelCaseMethods() { - $url = '/auth_test/camelCase'; - $this->Controller->request->addParams(Router::parse($url)); - $this->Controller->request->query['url'] = Router::normalize($url); - $this->Controller->Auth->initialize($this->Controller); - $this->Controller->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Controller->Auth->userModel = 'AuthUser'; - $this->Controller->Auth->allow(); - $result = $this->Controller->Auth->startup($this->Controller); - $this->assertTrue($result, 'startup() should return true, as action is allowed. %s'); - - $url = '/auth_test/camelCase'; - $this->Controller->request->addParams(Router::parse($url)); - $this->Controller->request->query['url'] = Router::normalize($url); - $this->Controller->Auth->initialize($this->Controller); - $this->Controller->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Controller->Auth->userModel = 'AuthUser'; - $this->Controller->Auth->allowedActions = array('delete', 'camelCase', 'add'); - $result = $this->Controller->Auth->startup($this->Controller); - $this->assertTrue($result, 'startup() should return true, as action is allowed. %s'); - - $this->Controller->Auth->allowedActions = array('delete', 'add'); - $result = $this->Controller->Auth->startup($this->Controller); - $this->assertFalse($result, 'startup() should return false, as action is not allowed. %s'); - - $url = '/auth_test/delete'; - $this->Controller->request->addParams(Router::parse($url)); - $this->Controller->request->query['url'] = Router::normalize($url); - $this->Controller->Auth->initialize($this->Controller); - $this->Controller->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Controller->Auth->userModel = 'AuthUser'; - - $this->Controller->Auth->allow(array('delete', 'add')); - $result = $this->Controller->Auth->startup($this->Controller); - $this->assertTrue($result, 'startup() should return true, as action is allowed. %s'); - } - - public function testAllowedActionsSetWithAllowMethod() { - $url = '/auth_test/action_name'; - $this->Controller->request->addParams(Router::parse($url)); - $this->Controller->request->query['url'] = Router::normalize($url); - $this->Controller->Auth->initialize($this->Controller); - $this->Controller->Auth->allow('action_name', 'anotherAction'); - $this->assertEquals(array('action_name', 'anotherAction'), $this->Controller->Auth->allowedActions); - } - -/** - * testLoginRedirect method - * - * @return void - */ - public function testLoginRedirect() { - $_SERVER['HTTP_REFERER'] = false; - $_ENV['HTTP_REFERER'] = false; - putenv('HTTP_REFERER='); - - $this->Auth->Session->write('Auth', array( - 'AuthUser' => array('id' => '1', 'username' => 'nate') - )); - - $this->Auth->request->addParams(Router::parse('users/login')); - $this->Auth->request->url = 'users/login'; - $this->Auth->initialize($this->Controller); - - $this->Auth->loginRedirect = array( - 'controller' => 'pages', 'action' => 'display', 'welcome' - ); - $this->Auth->startup($this->Controller); - $expected = Router::normalize($this->Auth->loginRedirect); - $this->assertEquals($expected, $this->Auth->redirect()); - - $this->Auth->Session->delete('Auth'); - - //empty referer no session - $_SERVER['HTTP_REFERER'] = false; - $_ENV['HTTP_REFERER'] = false; - putenv('HTTP_REFERER='); - $url = '/posts/view/1'; - - $this->Auth->Session->write('Auth', array( - 'AuthUser' => array('id' => '1', 'username' => 'nate')) - ); - $this->Controller->testUrl = null; - $this->Auth->request->addParams(Router::parse($url)); - array_push($this->Controller->methods, 'view', 'edit', 'index'); - - $this->Auth->initialize($this->Controller); - $this->Auth->authorize = 'controller'; - - $this->Auth->loginAction = array( - 'controller' => 'AuthTest', 'action' => 'login' - ); - $this->Auth->startup($this->Controller); - $expected = Router::normalize('/AuthTest/login'); - $this->assertEquals($expected, $this->Controller->testUrl); - - $this->Auth->Session->delete('Auth'); - $_SERVER['HTTP_REFERER'] = $_ENV['HTTP_REFERER'] = Router::url('/admin', true); - $this->Auth->Session->write('Auth', array( - 'AuthUser' => array('id' => '1', 'username' => 'nate') - )); - $this->Auth->request->params['action'] = 'login'; - $this->Auth->request->url = 'auth_test/login'; - $this->Auth->initialize($this->Controller); - $this->Auth->loginAction = 'auth_test/login'; - $this->Auth->loginRedirect = false; - $this->Auth->startup($this->Controller); - $expected = Router::normalize('/admin'); - $this->assertEquals($expected, $this->Auth->redirect()); - - //Ticket #4750 - //named params - $this->Controller->request = $this->Auth->request; - $this->Auth->Session->delete('Auth'); - $url = '/posts/index/year:2008/month:feb'; - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->request->url = $this->Auth->request->here = Router::normalize($url); - $this->Auth->initialize($this->Controller); - $this->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Auth->startup($this->Controller); - $expected = Router::normalize('posts/index/year:2008/month:feb'); - $this->assertEquals($expected, $this->Auth->Session->read('Auth.redirect')); - - //passed args - $this->Auth->Session->delete('Auth'); - $url = '/posts/view/1'; - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->request->url = $this->Auth->request->here = Router::normalize($url); - $this->Auth->initialize($this->Controller); - $this->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Auth->startup($this->Controller); - $expected = Router::normalize('posts/view/1'); - $this->assertEquals($expected, $this->Auth->Session->read('Auth.redirect')); - - // QueryString parameters - $_back = $_GET; - $_GET = array( - 'print' => 'true', - 'refer' => 'menu' - ); - $this->Auth->Session->delete('Auth'); - $url = '/posts/index/29'; - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->request->url = $this->Auth->request->here = Router::normalize($url); - $this->Auth->request->query = $_GET; - - $this->Auth->initialize($this->Controller); - $this->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Auth->startup($this->Controller); - $expected = Router::normalize('posts/index/29?print=true&refer=menu'); - $this->assertEquals($expected, $this->Auth->Session->read('Auth.redirect')); - - $_GET = $_back; - - //external authed action - $_SERVER['HTTP_REFERER'] = 'http://webmail.example.com/view/message'; - $this->Auth->Session->delete('Auth'); - $url = '/posts/edit/1'; - $request = new CakeRequest($url); - $request->query = array(); - $this->Auth->request = $this->Controller->request = $request; - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->request->url = $this->Auth->request->here = Router::normalize($url); - $this->Auth->initialize($this->Controller); - $this->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Auth->startup($this->Controller); - $expected = Router::normalize('/posts/edit/1'); - $this->assertEquals($expected, $this->Auth->Session->read('Auth.redirect')); - - //external direct login link - $_SERVER['HTTP_REFERER'] = 'http://webmail.example.com/view/message'; - $this->Auth->Session->delete('Auth'); - $url = '/AuthTest/login'; - $this->Auth->request = $this->Controller->request = new CakeRequest($url); - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->request->url = Router::normalize($url); - $this->Auth->initialize($this->Controller); - $this->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Auth->startup($this->Controller); - $expected = Router::normalize('/'); - $this->assertEquals($expected, $this->Auth->Session->read('Auth.redirect')); - - $this->Auth->Session->delete('Auth'); - } - -/** - * test that no redirects or authorization tests occur on the loginAction - * - * @return void - */ - public function testNoRedirectOnLoginAction() { - $controller = $this->getMock('Controller'); - $controller->methods = array('login'); - - $url = '/AuthTest/login'; - $this->Auth->request = $controller->request = new CakeRequest($url); - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->loginAction = array('controller' => 'AuthTest', 'action' => 'login'); - $this->Auth->authorize = array('Controller'); - - $controller->expects($this->never()) - ->method('redirect'); - - $this->Auth->startup($controller); - } - -/** - * Ensure that no redirect is performed when a 404 is reached - * And the user doesn't have a session. - * - * @return void - */ - public function testNoRedirectOn404() { - $this->Auth->Session->delete('Auth'); - $this->Auth->initialize($this->Controller); - $this->Auth->request->addParams(Router::parse('auth_test/something_totally_wrong')); - $result = $this->Auth->startup($this->Controller); - $this->assertTrue($result, 'Auth redirected a missing action %s'); - } - -/** - * testAdminRoute method - * - * @return void - */ - public function testAdminRoute() { - $pref = Configure::read('Routing.prefixes'); - Configure::write('Routing.prefixes', array('admin')); - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - - $url = '/admin/auth_test/add'; - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->request->query['url'] = ltrim($url, '/'); - $this->Auth->request->base = ''; - - Router::setRequestInfo($this->Auth->request); - $this->Auth->initialize($this->Controller); - - $this->Auth->loginAction = array( - 'admin' => true, 'controller' => 'auth_test', 'action' => 'login' - ); - - $this->Auth->startup($this->Controller); - $this->assertEquals('/admin/auth_test/login', $this->Controller->testUrl); - - Configure::write('Routing.prefixes', $pref); - } - -/** - * testAjaxLogin method - * - * @return void - */ - public function testAjaxLogin() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - $_SERVER['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"; - - App::uses('Dispatcher', 'Routing'); - - ob_start(); - $Dispatcher = new Dispatcher(); - $Dispatcher->dispatch(new CakeRequest('/ajax_auth/add'), new CakeResponse(), array('return' => 1)); - $result = ob_get_clean(); - - $this->assertEquals("Ajax!\nthis is the test element", str_replace("\r\n", "\n", $result)); - unset($_SERVER['HTTP_X_REQUESTED_WITH']); - } - -/** - * testLoginActionRedirect method - * - * @return void - */ - public function testLoginActionRedirect() { - $admin = Configure::read('Routing.prefixes'); - Configure::write('Routing.prefixes', array('admin')); - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - - $url = '/admin/auth_test/login'; - $this->Auth->request->addParams(Router::parse($url)); - $this->Auth->request->url = ltrim($url, '/'); - Router::setRequestInfo(array( - array( - 'pass' => array(), 'action' => 'admin_login', 'plugin' => null, 'controller' => 'auth_test', - 'admin' => true, - ), - array( - 'base' => null, 'here' => $url, - 'webroot' => '/', 'passedArgs' => array(), - ) - )); - - $this->Auth->initialize($this->Controller); - $this->Auth->loginAction = array('admin' => true, 'controller' => 'auth_test', 'action' => 'login'); - $this->Auth->startup($this->Controller); - - $this->assertNull($this->Controller->testUrl); - - Configure::write('Routing.prefixes', $admin); - } - -/** - * Stateless auth methods like Basic should populate data that can be - * accessed by $this->user(). - * - * @return void - */ - public function testStatelessAuthWorksWithUser() { - $_SERVER['PHP_AUTH_USER'] = 'mariano'; - $_SERVER['PHP_AUTH_PW'] = 'cake'; - $url = '/auth_test/add'; - $this->Auth->request->addParams(Router::parse($url)); - - $this->Auth->authenticate = array( - 'Basic' => array('userModel' => 'AuthUser') - ); - $this->Auth->startup($this->Controller); - - $result = $this->Auth->user(); - $this->assertEquals('mariano', $result['username']); - - $result = $this->Auth->user('username'); - $this->assertEquals('mariano', $result); - } - -/** - * Tests that shutdown destroys the redirect session var - * - * @return void - */ - public function testShutDown() { - $this->Auth->Session->write('Auth.User', 'not empty'); - $this->Auth->Session->write('Auth.redirect', 'foo'); - $this->Controller->Auth->loggedIn(true); - - $this->Controller->Auth->shutdown($this->Controller); - $this->assertNull($this->Auth->Session->read('Auth.redirect')); - } - -/** - * test $settings in Controller::$components - * - * @return void - */ - public function testComponentSettings() { - $request = new CakeRequest(null, false); - $this->Controller = new AuthTestController($request, $this->getMock('CakeResponse')); - - $this->Controller->components = array( - 'Auth' => array( - 'loginAction' => array('controller' => 'people', 'action' => 'login'), - 'logoutRedirect' => array('controller' => 'people', 'action' => 'login'), - ), - 'Session' - ); - $this->Controller->Components->init($this->Controller); - $this->Controller->Components->trigger('initialize', array(&$this->Controller)); - Router::reload(); - - $expected = array( - 'loginAction' => array('controller' => 'people', 'action' => 'login'), - 'logoutRedirect' => array('controller' => 'people', 'action' => 'login'), - ); - $this->assertEquals($expected['loginAction'], $this->Controller->Auth->loginAction); - $this->assertEquals($expected['logoutRedirect'], $this->Controller->Auth->logoutRedirect); - } - -/** - * test that logout deletes the session variables. and returns the correct url - * - * @return void - */ - public function testLogout() { - $this->Auth->Session->write('Auth.User.id', '1'); - $this->Auth->Session->write('Auth.redirect', '/users/login'); - $this->Auth->logoutRedirect = '/'; - $result = $this->Auth->logout(); - - $this->assertEquals('/', $result); - $this->assertNull($this->Auth->Session->read('Auth.AuthUser')); - $this->assertNull($this->Auth->Session->read('Auth.redirect')); - } - -/** - * Logout should trigger a logout method on authentication objects. - * - * @return void - */ - public function testLogoutTrigger() { - $this->getMock('BaseAuthenticate', array('authenticate', 'logout'), array(), 'LogoutTriggerMockAuthenticate', false); - - $this->Auth->authenticate = array('LogoutTriggerMock'); - $mock = $this->Auth->constructAuthenticate(); - $mock[0]->expects($this->once()) - ->method('logout'); - - $this->Auth->logout(); - } - -/** - * test mapActions loading and delegating to authorize objects. - * - * @return void - */ - public function testMapActionsDelegation() { - $this->getMock('BaseAuthorize', array('authorize'), array(), 'MapActionMockAuthorize', false); - $this->Auth->authorize = array('MapActionMock'); - $mock = $this->Auth->constructAuthorize(); - $mock[0]->expects($this->once()) - ->method('mapActions') - ->with(array('create' => array('my_action'))); - - $this->Auth->mapActions(array('create' => array('my_action'))); - } - -/** - * test logging in with a request. - * - * @return void - */ - public function testLoginWithRequestData() { - $this->getMock('FormAuthenticate', array(), array(), 'RequestLoginMockAuthenticate', false); - $request = new CakeRequest('users/login', false); - $user = array('username' => 'mark', 'role' => 'admin'); - - $this->Auth->request = $request; - $this->Auth->authenticate = array('RequestLoginMock'); - $mock = $this->Auth->constructAuthenticate(); - $mock[0]->expects($this->once()) - ->method('authenticate') - ->with($request) - ->will($this->returnValue($user)); - - $this->assertTrue($this->Auth->login()); - $this->assertEquals($user['username'], $this->Auth->user('username')); - } - -/** - * test login() with user data - * - * @return void - */ - public function testLoginWithUserData() { - $this->assertFalse($this->Auth->loggedIn()); - - $user = array( - 'username' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ); - $this->assertTrue($this->Auth->login($user)); - $this->assertTrue($this->Auth->loggedIn()); - $this->assertEquals($user['username'], $this->Auth->user('username')); - } - -/** - * test flash settings. - * - * @return void - */ - public function testFlashSettings() { - $this->Auth->Session = $this->getMock('SessionComponent', array(), array(), '', false); - $this->Auth->Session->expects($this->once()) - ->method('setFlash') - ->with('Auth failure', 'custom', array(1), 'auth-key'); - - $this->Auth->flash = array( - 'element' => 'custom', - 'params' => array(1), - 'key' => 'auth-key' - ); - $this->Auth->flash('Auth failure'); - } - -/** - * test the various states of Auth::redirect() - * - * @return void - */ - public function testRedirectSet() { - $value = array('controller' => 'users', 'action' => 'home'); - $result = $this->Auth->redirect($value); - $this->assertEquals('/users/home', $result); - $this->assertEquals($value, $this->Auth->Session->read('Auth.redirect')); - } - -/** - * test redirect using Auth.redirect from the session. - * - * @return void - */ - public function testRedirectSessionRead() { - $this->Auth->loginAction = array('controller' => 'users', 'action' => 'login'); - $this->Auth->Session->write('Auth.redirect', '/users/home'); - - $result = $this->Auth->redirect(); - $this->assertEquals('/users/home', $result); - $this->assertFalse($this->Auth->Session->check('Auth.redirect')); - } - -/** - * test that redirect does not return loginAction if that is what's stored in Auth.redirect. - * instead loginRedirect should be used. - * - * @return void - */ - public function testRedirectSessionReadEqualToLoginAction() { - $this->Auth->loginAction = array('controller' => 'users', 'action' => 'login'); - $this->Auth->loginRedirect = array('controller' => 'users', 'action' => 'home'); - $this->Auth->Session->write('Auth.redirect', array('controller' => 'users', 'action' => 'login')); - - $result = $this->Auth->redirect(); - $this->assertEquals('/users/home', $result); - $this->assertFalse($this->Auth->Session->check('Auth.redirect')); - } - -/** - * test password hashing - * - * @return void - */ - public function testPassword() { - $result = $this->Auth->password('password'); - $expected = Security::hash('password', null, true); - $this->assertEquals($expected, $result); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/CookieComponentTest.php b/lib/Cake/Test/Case/Controller/Component/CookieComponentTest.php deleted file mode 100644 index 7924876441b..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/CookieComponentTest.php +++ /dev/null @@ -1,602 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 1.2.0.5435 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Component', 'Controller'); -App::uses('Controller', 'Controller'); -App::uses('CookieComponent', 'Controller/Component'); - - -/** - * CookieComponentTestController class - * - * @package Cake.Test.Case.Controller.Component - */ -class CookieComponentTestController extends Controller { - -/** - * components property - * - * @var array - */ - public $components = array('Cookie'); - -/** - * beforeFilter method - * - * @return void - */ - public function beforeFilter() { - $this->Cookie->name = 'CakeTestCookie'; - $this->Cookie->time = 10; - $this->Cookie->path = '/'; - $this->Cookie->domain = ''; - $this->Cookie->secure = false; - $this->Cookie->key = 'somerandomhaskey'; - } - -} - -/** - * CookieComponentTest class - * - * @package Cake.Test.Case.Controller.Component - */ -class CookieComponentTest extends CakeTestCase { - -/** - * Controller property - * - * @var CookieComponentTestController - */ - public $Controller; - -/** - * start - * - * @return void - */ - public function setUp() { - $_COOKIE = array(); - $this->Controller = new CookieComponentTestController(new CakeRequest(), new CakeResponse()); - $this->Controller->constructClasses(); - $this->Cookie = $this->Controller->Cookie; - - $this->Cookie->name = 'CakeTestCookie'; - $this->Cookie->time = 10; - $this->Cookie->path = '/'; - $this->Cookie->domain = ''; - $this->Cookie->secure = false; - $this->Cookie->key = 'somerandomhaskey'; - - $this->Cookie->startup($this->Controller); - } - -/** - * end - * - * @return void - */ - public function tearDown() { - $this->Cookie->destroy(); - } - -/** - * sets up some default cookie data. - * - * @return void - */ - protected function _setCookieData() { - $this->Cookie->write(array('Encrytped_array' => array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'))); - $this->Cookie->write(array('Encrytped_multi_cookies.name' => 'CakePHP')); - $this->Cookie->write(array('Encrytped_multi_cookies.version' => '1.2.0.x')); - $this->Cookie->write(array('Encrytped_multi_cookies.tag' => 'CakePHP Rocks!')); - - $this->Cookie->write(array('Plain_array' => array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!')), null, false); - $this->Cookie->write(array('Plain_multi_cookies.name' => 'CakePHP'), null, false); - $this->Cookie->write(array('Plain_multi_cookies.version' => '1.2.0.x'), null, false); - $this->Cookie->write(array('Plain_multi_cookies.tag' => 'CakePHP Rocks!'), null, false); - } - -/** - * test that initialize sets settings from components array - * - * @return void - */ - public function testSettings() { - $settings = array( - 'time' => '5 days', - 'path' => '/' - ); - $Cookie = new CookieComponent(new ComponentCollection(), $settings); - $this->assertEquals($Cookie->time, $settings['time']); - $this->assertEquals($Cookie->path, $settings['path']); - } - -/** - * testCookieName - * - * @return void - */ - public function testCookieName() { - $this->assertEquals('CakeTestCookie', $this->Cookie->name); - } - -/** - * testReadEncryptedCookieData - * - * @return void - */ - public function testReadEncryptedCookieData() { - $this->_setCookieData(); - $data = $this->Cookie->read('Encrytped_array'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_multi_cookies'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - } - -/** - * testReadPlainCookieData - * - * @return void - */ - public function testReadPlainCookieData() { - $this->_setCookieData(); - $data = $this->Cookie->read('Plain_array'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_multi_cookies'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - } - -/** - * test read() after switching the cookie name. - * - * @return void - */ - public function testReadWithNameSwitch() { - $_COOKIE = array( - 'CakeTestCookie' => array( - 'key' => 'value' - ), - 'OtherTestCookie' => array( - 'key' => 'other value' - ) - ); - $this->assertEquals('value', $this->Cookie->read('key')); - - $this->Cookie->name = 'OtherTestCookie'; - $this->assertEquals('other value', $this->Cookie->read('key')); - } - -/** - * test a simple write() - * - * @return void - */ - public function testWriteSimple() { - $this->Cookie->write('Testing', 'value'); - $result = $this->Cookie->read('Testing'); - - $this->assertEquals('value', $result); - } - -/** - * test write with httpOnly cookies - * - * @return void - */ - public function testWriteHttpOnly() { - $this->Cookie->httpOnly = true; - $this->Cookie->secure = false; - $this->Cookie->write('Testing', 'value', false); - $expected = array( - 'name' => $this->Cookie->name . '[Testing]', - 'value' => 'value', - 'expire' => time() + 10, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => true); - $result = $this->Controller->response->cookie($this->Cookie->name . '[Testing]'); - $this->assertEquals($expected, $result); - } - -/** - * test delete with httpOnly - * - * @return void - */ - public function testDeleteHttpOnly() { - $this->Cookie->httpOnly = true; - $this->Cookie->secure = false; - $this->Cookie->delete('Testing', false); - $expected = array( - 'name' => $this->Cookie->name . '[Testing]', - 'value' => '', - 'expire' => time() - 42000, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => true); - $result = $this->Controller->response->cookie($this->Cookie->name . '[Testing]'); - $this->assertEquals($expected, $result); - } - -/** - * testWritePlainCookieArray - * - * @return void - */ - public function testWritePlainCookieArray() { - $this->Cookie->write(array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'), null, false); - - $this->assertEquals('CakePHP', $this->Cookie->read('name')); - $this->assertEquals('1.2.0.x', $this->Cookie->read('version')); - $this->assertEquals('CakePHP Rocks!', $this->Cookie->read('tag')); - - $this->Cookie->delete('name'); - $this->Cookie->delete('version'); - $this->Cookie->delete('tag'); - } - -/** - * test writing values that are not scalars - * - * @return void - */ - public function testWriteArrayValues() { - $this->Cookie->secure = false; - $this->Cookie->write('Testing', array(1, 2, 3), false); - $expected = array( - 'name' => $this->Cookie->name . '[Testing]', - 'value' => '[1,2,3]', - 'expire' => time() + 10, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false); - $result = $this->Controller->response->cookie($this->Cookie->name . '[Testing]'); - $this->assertEquals($expected, $result); - } - -/** - * testReadingCookieValue - * - * @return void - */ - public function testReadingCookieValue() { - $this->_setCookieData(); - $data = $this->Cookie->read(); - $expected = array( - 'Encrytped_array' => array( - 'name' => 'CakePHP', - 'version' => '1.2.0.x', - 'tag' => 'CakePHP Rocks!'), - 'Encrytped_multi_cookies' => array( - 'name' => 'CakePHP', - 'version' => '1.2.0.x', - 'tag' => 'CakePHP Rocks!'), - 'Plain_array' => array( - 'name' => 'CakePHP', - 'version' => '1.2.0.x', - 'tag' => 'CakePHP Rocks!'), - 'Plain_multi_cookies' => array( - 'name' => 'CakePHP', - 'version' => '1.2.0.x', - 'tag' => 'CakePHP Rocks!')); - $this->assertEquals($expected, $data); - } - -/** - * testDeleteCookieValue - * - * @return void - */ - public function testDeleteCookieValue() { - $this->_setCookieData(); - $this->Cookie->delete('Encrytped_multi_cookies.name'); - $data = $this->Cookie->read('Encrytped_multi_cookies'); - $expected = array('version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $this->Cookie->delete('Encrytped_array'); - $data = $this->Cookie->read('Encrytped_array'); - $this->assertNull($data); - - $this->Cookie->delete('Plain_multi_cookies.name'); - $data = $this->Cookie->read('Plain_multi_cookies'); - $expected = array('version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $this->Cookie->delete('Plain_array'); - $data = $this->Cookie->read('Plain_array'); - $this->assertNull($data); - } - -/** - * testReadingCookieArray - * - * @return void - */ - public function testReadingCookieArray() { - $this->_setCookieData(); - - $data = $this->Cookie->read('Encrytped_array.name'); - $expected = 'CakePHP'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_array.version'); - $expected = '1.2.0.x'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_array.tag'); - $expected = 'CakePHP Rocks!'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_multi_cookies.name'); - $expected = 'CakePHP'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_multi_cookies.version'); - $expected = '1.2.0.x'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_multi_cookies.tag'); - $expected = 'CakePHP Rocks!'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_array.name'); - $expected = 'CakePHP'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_array.version'); - $expected = '1.2.0.x'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_array.tag'); - $expected = 'CakePHP Rocks!'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_multi_cookies.name'); - $expected = 'CakePHP'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_multi_cookies.version'); - $expected = '1.2.0.x'; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_multi_cookies.tag'); - $expected = 'CakePHP Rocks!'; - $this->assertEquals($expected, $data); - } - -/** - * testReadingCookieDataOnStartup - * - * @return void - */ - public function testReadingCookieDataOnStartup() { - $data = $this->Cookie->read('Encrytped_array'); - $this->assertNull($data); - - $data = $this->Cookie->read('Encrytped_multi_cookies'); - $this->assertNull($data); - - $data = $this->Cookie->read('Plain_array'); - $this->assertNull($data); - - $data = $this->Cookie->read('Plain_multi_cookies'); - $this->assertNull($data); - - $_COOKIE['CakeTestCookie'] = array( - 'Encrytped_array' => $this->__encrypt(array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!')), - 'Encrytped_multi_cookies' => array( - 'name' => $this->__encrypt('CakePHP'), - 'version' => $this->__encrypt('1.2.0.x'), - 'tag' => $this->__encrypt('CakePHP Rocks!')), - 'Plain_array' => '{"name":"CakePHP","version":"1.2.0.x","tag":"CakePHP Rocks!"}', - 'Plain_multi_cookies' => array( - 'name' => 'CakePHP', - 'version' => '1.2.0.x', - 'tag' => 'CakePHP Rocks!')); - - $this->Cookie->startup(new CookieComponentTestController()); - - $data = $this->Cookie->read('Encrytped_array'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_multi_cookies'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_array'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_multi_cookies'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - $this->Cookie->destroy(); - unset($_COOKIE['CakeTestCookie']); - } - -/** - * testReadingCookieDataWithoutStartup - * - * @return void - */ - public function testReadingCookieDataWithoutStartup() { - $data = $this->Cookie->read('Encrytped_array'); - $expected = null; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_multi_cookies'); - $expected = null; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_array'); - $expected = null; - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_multi_cookies'); - $expected = null; - $this->assertEquals($expected, $data); - - $_COOKIE['CakeTestCookie'] = array( - 'Encrytped_array' => $this->__encrypt(array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!')), - 'Encrytped_multi_cookies' => array( - 'name' => $this->__encrypt('CakePHP'), - 'version' => $this->__encrypt('1.2.0.x'), - 'tag' => $this->__encrypt('CakePHP Rocks!')), - 'Plain_array' => '{"name":"CakePHP","version":"1.2.0.x","tag":"CakePHP Rocks!"}', - 'Plain_multi_cookies' => array( - 'name' => 'CakePHP', - 'version' => '1.2.0.x', - 'tag' => 'CakePHP Rocks!')); - - $data = $this->Cookie->read('Encrytped_array'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Encrytped_multi_cookies'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_array'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - - $data = $this->Cookie->read('Plain_multi_cookies'); - $expected = array('name' => 'CakePHP', 'version' => '1.2.0.x', 'tag' => 'CakePHP Rocks!'); - $this->assertEquals($expected, $data); - $this->Cookie->destroy(); - unset($_COOKIE['CakeTestCookie']); - } - -/** - * Test Reading legacy cookie values. - * - * @return void - */ - public function testReadLegacyCookieValue() { - $_COOKIE['CakeTestCookie'] = array( - 'Legacy' => array('value' => $this->_oldImplode(array(1, 2, 3))) - ); - $result = $this->Cookie->read('Legacy.value'); - $expected = array(1, 2, 3); - $this->assertEquals($expected, $result); - } - -/** - * Test reading empty values. - */ - public function testReadEmpty() { - $_COOKIE['CakeTestCookie'] = array( - 'JSON' => '{"name":"value"}', - 'Empty' => '', - 'String' => '{"somewhat:"broken"}' - ); - $this->assertEqual(array('name' => 'value'), $this->Cookie->read('JSON')); - $this->assertEqual('value', $this->Cookie->read('JSON.name')); - $this->assertEqual('', $this->Cookie->read('Empty')); - $this->assertEqual('{"somewhat:"broken"}', $this->Cookie->read('String')); - } - -/** - * test that no error is issued for non array data. - * - * @return void - */ - public function testNoErrorOnNonArrayData() { - $this->Cookie->destroy(); - $_COOKIE['CakeTestCookie'] = 'kaboom'; - - $this->assertNull($this->Cookie->read('value')); - } - -/** - * test that deleting a top level keys kills the child elements too. - * - * @return void - */ - public function testDeleteRemovesChildren() { - $_COOKIE['CakeTestCookie'] = array( - 'User' => array('email' => 'example@example.com', 'name' => 'mark'), - 'other' => 'value' - ); - $this->assertEquals('mark', $this->Cookie->read('User.name')); - - $this->Cookie->delete('User'); - $this->assertNull($this->Cookie->read('User.email')); - $this->Cookie->destroy(); - } - -/** - * Test deleting recursively with keys that don't exist. - * - * @return void - */ - public function testDeleteChildrenNotExist() { - $this->assertNull($this->Cookie->delete('NotFound')); - $this->assertNull($this->Cookie->delete('Not.Found')); - } - -/** - * Helper method for generating old style encoded cookie values. - * - * @return string. - */ - protected function _oldImplode(array $array) { - $string = ''; - foreach ($array as $key => $value) { - $string .= ',' . $key . '|' . $value; - } - return substr($string, 1); - } - -/** - * Implode method to keep keys are multidimensional arrays - * - * @param array $array Map of key and values - * @return string String in the form key1|value1,key2|value2 - */ - protected function _implode(array $array) { - return json_encode($array); - } - -/** - * encrypt method - * - * @param mixed $value - * @return string - */ - protected function __encrypt($value) { - if (is_array($value)) { - $value = $this->_implode($value); - } - return "Q2FrZQ==." . base64_encode(Security::cipher($value, $this->Cookie->key)); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/EmailComponentTest.php b/lib/Cake/Test/Case/Controller/Component/EmailComponentTest.php deleted file mode 100644 index b1a272b8215..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/EmailComponentTest.php +++ /dev/null @@ -1,882 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 1.2.0.5347 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Controller', 'Controller'); -App::uses('EmailComponent', 'Controller/Component'); -App::uses('AbstractTransport', 'Network/Email'); - -/** - * EmailTestComponent class - * - * @package Cake.Test.Case.Controller.Component - */ -class EmailTestComponent extends EmailComponent { - -/** - * Convenience method for testing. - * - * @return string - */ - public function strip($content, $message = false) { - return parent::_strip($content, $message); - } - -} - -/** - * DebugCompTransport class - * - * @package Cake.Test.Case.Controller.Component - */ -class DebugCompTransport extends AbstractTransport { - -/** - * Last email - * - * @var string - */ - public static $lastEmail = null; - -/** - * Send mail - * - * @params object $email CakeEmail - * @return boolean - */ - public function send(CakeEmail $email) { - $email->addHeaders(array('Date' => EmailComponentTest::$sentDate)); - $headers = $email->getHeaders(array_fill_keys(array('from', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'bcc', 'subject'), true)); - $to = $headers['To']; - $subject = $headers['Subject']; - unset($headers['To'], $headers['Subject']); - - $message = implode("\n", $email->message()); - - $last = '
';
-		$last .= sprintf("%s %s\n", 'To:', $to);
-		$last .= sprintf("%s %s\n", 'From:', $headers['From']);
-		$last .= sprintf("%s %s\n", 'Subject:', $subject);
-		$last .= sprintf("%s\n\n%s", 'Header:', $this->_headersToString($headers, "\n"));
-		$last .= sprintf("%s\n\n%s", 'Message:', $message);
-		$last .= '
'; - - self::$lastEmail = $last; - - return true; - } - -} - -/** - * EmailTestController class - * - * @package Cake.Test.Case.Controller.Component - */ -class EmailTestController extends Controller { - -/** - * name property - * - * @var string 'EmailTest' - */ - public $name = 'EmailTest'; - -/** - * uses property - * - * @var mixed null - */ - public $uses = null; - -/** - * components property - * - * @var array - */ - public $components = array('Session', 'EmailTest'); - -} - -/** - * EmailTest class - * - * @package Cake.Test.Case.Controller.Component - */ -class EmailComponentTest extends CakeTestCase { - -/** - * Controller property - * - * @var EmailTestController - */ - public $Controller; - -/** - * name property - * - * @var string 'Email' - */ - public $name = 'Email'; - -/** - * sentDate - * - * @var string - */ - public static $sentDate = null; - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->_appEncoding = Configure::read('App.encoding'); - Configure::write('App.encoding', 'UTF-8'); - - $this->Controller = new EmailTestController(); - - $this->Controller->Components->init($this->Controller); - - $this->Controller->EmailTest->initialize($this->Controller, array()); - - self::$sentDate = date(DATE_RFC2822); - - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - Configure::write('App.encoding', $this->_appEncoding); - App::build(); - ClassRegistry::flush(); - } - -/** - * testSendFormats method - * - * @return void - */ - public function testSendFormats() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake SMTP test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->Controller->EmailTest->messageId = false; - - $date = self::$sentDate; - $message = <<To: postmaster@example.com -From: noreply@example.com -Subject: Cake SMTP test -Header: - -From: noreply@example.com -Reply-To: noreply@example.com -X-Mailer: CakePHP Email Component -Date: $date -MIME-Version: 1.0 -Content-Type: {CONTENTTYPE} -Content-Transfer-Encoding: 8bitMessage: - -This is the body of the message - - -MSGBLOC; - - $this->Controller->EmailTest->sendAs = 'text'; - $expect = str_replace('{CONTENTTYPE}', 'text/plain; charset=UTF-8', $message); - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertTextEquals(DebugCompTransport::$lastEmail, $expect); - - $this->Controller->EmailTest->sendAs = 'html'; - $expect = str_replace('{CONTENTTYPE}', 'text/html; charset=UTF-8', $message); - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertTextEquals(DebugCompTransport::$lastEmail, $expect); - } - -/** - * testTemplates method - * - * @return void - */ - public function testTemplates() { - ClassRegistry::flush(); - - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake SMTP test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->Controller->EmailTest->messageId = false; - - $date = self::$sentDate; - $header = <<Controller->EmailTest->layout = 'default'; - $this->Controller->EmailTest->template = 'default'; - $this->Controller->set('title_for_layout', 'Email Test'); - - $text = << - - - - Email Test - - - -

This is the body of the message

-

This email was sent using the CakePHP Framework

- - -HTMLBLOC; - - $this->Controller->EmailTest->sendAs = 'text'; - $expect = '
' . str_replace('{CONTENTTYPE}', 'text/plain; charset=UTF-8', $header) . $text . "\n" . '
'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertTextEquals(DebugCompTransport::$lastEmail, $expect); - - $this->Controller->EmailTest->sendAs = 'html'; - $expect = '
' . str_replace('{CONTENTTYPE}', 'text/html; charset=UTF-8', $header) . $html . "\n" . '
'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertTextEquals(DebugCompTransport::$lastEmail, $expect); - - $this->Controller->EmailTest->sendAs = 'both'; - $expect = str_replace('{CONTENTTYPE}', 'multipart/mixed; boundary="{boundary}"', $header); - $expect .= "--{boundary}\n" . - 'Content-Type: multipart/alternative; boundary="alt-{boundary}"' . "\n\n" . - '--alt-{boundary}' . "\n" . - 'Content-Type: text/plain; charset=UTF-8' . "\n" . - 'Content-Transfer-Encoding: 8bit' . "\n\n" . - $text . - "\n\n" . - '--alt-{boundary}' . "\n" . - 'Content-Type: text/html; charset=UTF-8' . "\n" . - 'Content-Transfer-Encoding: 8bit' . "\n\n" . - $html . - "\n\n" . - '--alt-{boundary}--' . "\n\n\n" . - '--{boundary}--' . "\n"; - - $expect = '
' . $expect . '
'; - - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertTextEquals( - $expect, - preg_replace('/[a-z0-9]{32}/i', '{boundary}', DebugCompTransport::$lastEmail) - ); - - $html = << - - - - Email Test - - - -

This is the body of the message

-

This email was sent using the CakePHP Framework

- - - -HTMLBLOC; - - $this->Controller->EmailTest->sendAs = 'html'; - $expect = '
' . str_replace('{CONTENTTYPE}', 'text/html; charset=UTF-8', $header) . $html . '
'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message', 'default', 'thin')); - $this->assertTextEquals(DebugCompTransport::$lastEmail, $expect); - } - -/** - * test that elements used in email templates get helpers. - * - * @return void - */ - public function testTemplateNestedElements() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake SMTP test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->Controller->EmailTest->messageId = false; - $this->Controller->EmailTest->layout = 'default'; - $this->Controller->EmailTest->template = 'nested_element'; - $this->Controller->EmailTest->sendAs = 'html'; - $this->Controller->helpers = array('Html'); - - $this->Controller->EmailTest->send(); - $result = DebugCompTransport::$lastEmail; - $this->assertRegExp('/Test/', $result); - $this->assertRegExp('/http\:\/\/example\.com/', $result); - } - -/** - * testSendDebug method - * - * @return void - */ - public function testSendDebug() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->cc = 'cc@example.com'; - $this->Controller->EmailTest->bcc = 'bcc@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $result = DebugCompTransport::$lastEmail; - - $this->assertRegExp('/To: postmaster@example.com\n/', $result); - $this->assertRegExp('/Subject: Cake Debug Test\n/', $result); - $this->assertRegExp('/Reply-To: noreply@example.com\n/', $result); - $this->assertRegExp('/From: noreply@example.com\n/', $result); - $this->assertRegExp('/Cc: cc@example.com\n/', $result); - $this->assertRegExp('/Bcc: bcc@example.com\n/', $result); - $this->assertRegExp('/Date: ' . preg_quote(self::$sentDate) . '\n/', $result); - $this->assertRegExp('/X-Mailer: CakePHP Email Component\n/', $result); - $this->assertRegExp('/Content-Type: text\/plain; charset=UTF-8\n/', $result); - $this->assertRegExp('/Content-Transfer-Encoding: 8bitMessage:\n/', $result); - $this->assertRegExp('/This is the body of the message/', $result); - } - -/** - * test send with delivery = debug and not using sessions. - * - * @return void - */ - public function testSendDebugWithNoSessions() { - $session = $this->Controller->Session; - unset($this->Controller->Session); - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->Controller->EmailTest->send('This is the body of the message'); - $result = DebugCompTransport::$lastEmail; - - $this->assertRegExp('/To: postmaster@example.com\n/', $result); - $this->assertRegExp('/Subject: Cake Debug Test\n/', $result); - $this->assertRegExp('/Reply-To: noreply@example.com\n/', $result); - $this->assertRegExp('/From: noreply@example.com\n/', $result); - $this->assertRegExp('/Date: ' . preg_quote(self::$sentDate) . '\n/', $result); - $this->assertRegExp('/X-Mailer: CakePHP Email Component\n/', $result); - $this->assertRegExp('/Content-Type: text\/plain; charset=UTF-8\n/', $result); - $this->assertRegExp('/Content-Transfer-Encoding: 8bitMessage:\n/', $result); - $this->assertRegExp('/This is the body of the message/', $result); - $this->Controller->Session = $session; - } - -/** - * testMessageRetrievalWithoutTemplate method - * - * @return void - */ - public function testMessageRetrievalWithoutTemplate() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->layout = 'default'; - $this->Controller->EmailTest->template = null; - - $this->Controller->EmailTest->delivery = 'DebugComp'; - - $text = $html = "This is the body of the message\n"; - - $this->Controller->EmailTest->sendAs = 'both'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertTextEquals($this->Controller->EmailTest->textMessage, $text); - $this->assertTextEquals($this->Controller->EmailTest->htmlMessage, $html); - - $this->Controller->EmailTest->sendAs = 'text'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertTextEquals($this->Controller->EmailTest->textMessage, $text); - $this->assertNull($this->Controller->EmailTest->htmlMessage); - - $this->Controller->EmailTest->sendAs = 'html'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $this->assertNull($this->Controller->EmailTest->textMessage); - $this->assertTextEquals($this->Controller->EmailTest->htmlMessage, $html); - } - -/** - * testMessageRetrievalWithTemplate method - * - * @return void - */ - public function testMessageRetrievalWithTemplate() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - - $this->Controller->set('value', 22091985); - $this->Controller->set('title_for_layout', 'EmailTest'); - - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->layout = 'default'; - $this->Controller->EmailTest->template = 'custom'; - - $this->Controller->EmailTest->delivery = 'DebugComp'; - - $text = << - - - - EmailTest - - - -

Here is your value: 22091985

- -

This email was sent using the CakePHP Framework

- - -HTMLBLOC; - - $this->Controller->EmailTest->sendAs = 'both'; - $this->assertTrue($this->Controller->EmailTest->send()); - $this->assertTextEquals($this->Controller->EmailTest->textMessage, $text); - $this->assertTextEquals($this->Controller->EmailTest->htmlMessage, $html); - - $this->Controller->EmailTest->sendAs = 'text'; - $this->assertTrue($this->Controller->EmailTest->send()); - $this->assertTextEquals($this->Controller->EmailTest->textMessage, $text); - $this->assertNull($this->Controller->EmailTest->htmlMessage); - - $this->Controller->EmailTest->sendAs = 'html'; - $this->assertTrue($this->Controller->EmailTest->send()); - $this->assertNull($this->Controller->EmailTest->textMessage); - $this->assertTextEquals($this->Controller->EmailTest->htmlMessage, $html); - } - -/** - * testMessageRetrievalWithHelper method - * - * @return void - */ - public function testMessageRetrievalWithHelper() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - - $timestamp = time(); - $this->Controller->set('time', $timestamp); - $this->Controller->set('title_for_layout', 'EmailTest'); - $this->Controller->helpers = array('Time'); - - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->layout = 'default'; - $this->Controller->EmailTest->template = 'custom_helper'; - $this->Controller->EmailTest->sendAs = 'text'; - $this->Controller->EmailTest->delivery = 'DebugComp'; - - $this->assertTrue($this->Controller->EmailTest->send()); - $this->assertTrue((bool)strpos($this->Controller->EmailTest->textMessage, 'Right now: ' . date('Y-m-d\TH:i:s\Z', $timestamp))); - } - -/** - * testContentArray method - * - * @return void - */ - public function testSendContentArray() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - - $content = array('First line', 'Second line', 'Third line'); - $this->assertTrue($this->Controller->EmailTest->send($content)); - $result = DebugCompTransport::$lastEmail; - - $this->assertRegExp('/To: postmaster@example.com\n/', $result); - $this->assertRegExp('/Subject: Cake Debug Test\n/', $result); - $this->assertRegExp('/Reply-To: noreply@example.com\n/', $result); - $this->assertRegExp('/From: noreply@example.com\n/', $result); - $this->assertRegExp('/X-Mailer: CakePHP Email Component\n/', $result); - $this->assertRegExp('/Content-Type: text\/plain; charset=UTF-8\n/', $result); - $this->assertRegExp('/Content-Transfer-Encoding: 8bitMessage:\n/', $result); - $this->assertRegExp('/First line\n/', $result); - $this->assertRegExp('/Second line\n/', $result); - $this->assertRegExp('/Third line\n/', $result); - } - -/** - * test setting a custom date. - * - * @return void - */ - public function testDateProperty() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->date = self::$sentDate = 'Today!'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - - $this->assertTrue($this->Controller->EmailTest->send('test message')); - $result = DebugCompTransport::$lastEmail; - $this->assertRegExp('/Date: Today!\n/', $result); - } - -/** - * testContentStripping method - * - * @return void - */ - public function testContentStripping() { - $content = "Previous content\n--alt-\nContent-TypeContent-Type:: text/html; charsetcharset==utf-8\nContent-Transfer-Encoding: 8bit"; - $content .= "\n\n

My own html content

"; - - $result = $this->Controller->EmailTest->strip($content, true); - $expected = "Previous content\n--alt-\n text/html; utf-8\n 8bit\n\n

My own html content

"; - $this->assertEquals($expected, $result); - - $content = '

Some HTML content with an email link'; - $result = $this->Controller->EmailTest->strip($content, true); - $expected = $content; - $this->assertEquals($expected, $result); - - $content = '

Some HTML content with an '; - $content .= 'email link'; - $result = $this->Controller->EmailTest->strip($content, true); - $expected = $content; - $this->assertEquals($expected, $result); - } - -/** - * test that the _encode() will set mb_internal_encoding. - * - * @return void - */ - public function testEncodeSettingInternalCharset() { - $this->skipIf(!function_exists('mb_internal_encoding'), 'Missing mb_* functions, cannot run test.'); - - $restore = mb_internal_encoding(); - mb_internal_encoding('ISO-8859-1'); - - $this->Controller->charset = 'UTF-8'; - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'هذه رسالة بعنوان طويل مرسل للمستلم'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - - $this->Controller->EmailTest->sendAs = 'text'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - - $subject = '=?UTF-8?B?2YfYsNmHINix2LPYp9mE2Kkg2KjYudmG2YjYp9mGINi32YjZitmEINmF2LE=?=' . "\r\n" . ' =?UTF-8?B?2LPZhCDZhNmE2YXYs9iq2YTZhQ==?='; - - preg_match('/Subject: (.*)Header:/s', DebugCompTransport::$lastEmail, $matches); - $this->assertEquals(trim($matches[1]), $subject); - - $result = mb_internal_encoding(); - $this->assertEquals('ISO-8859-1', $result); - - mb_internal_encoding($restore); - } - -/** - * testMultibyte method - * - * @return void - */ - public function testMultibyte() { - $this->Controller->charset = 'UTF-8'; - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'هذه رسالة بعنوان طويل مرسل للمستلم'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - - $subject = '=?UTF-8?B?2YfYsNmHINix2LPYp9mE2Kkg2KjYudmG2YjYp9mGINi32YjZitmEINmF2LE=?=' . "\r\n" . ' =?UTF-8?B?2LPZhCDZhNmE2YXYs9iq2YTZhQ==?='; - - $this->Controller->EmailTest->sendAs = 'text'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - preg_match('/Subject: (.*)Header:/s', DebugCompTransport::$lastEmail, $matches); - $this->assertEquals(trim($matches[1]), $subject); - - $this->Controller->EmailTest->sendAs = 'html'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - preg_match('/Subject: (.*)Header:/s', DebugCompTransport::$lastEmail, $matches); - $this->assertEquals(trim($matches[1]), $subject); - - $this->Controller->EmailTest->sendAs = 'both'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - preg_match('/Subject: (.*)Header:/s', DebugCompTransport::$lastEmail, $matches); - $this->assertEquals(trim($matches[1]), $subject); - } - -/** - * undocumented function - * - * @return void - */ - public function testSendWithAttachments() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Attachment Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->Controller->EmailTest->attachments = array( - __FILE__, - 'some-name.php' => __FILE__ - ); - $body = '

This is the body of the message

'; - - $this->Controller->EmailTest->sendAs = 'text'; - $this->assertTrue($this->Controller->EmailTest->send($body)); - $msg = DebugCompTransport::$lastEmail; - $this->assertRegExp('/' . preg_quote('Content-Disposition: attachment; filename="EmailComponentTest.php"') . '/', $msg); - $this->assertRegExp('/' . preg_quote('Content-Disposition: attachment; filename="some-name.php"') . '/', $msg); - } - -/** - * testSendAsIsNotIgnoredIfAttachmentsPresent method - * - * @return void - */ - public function testSendAsIsNotIgnoredIfAttachmentsPresent() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Attachment Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->Controller->EmailTest->attachments = array(__FILE__); - $body = '

This is the body of the message

'; - - $this->Controller->EmailTest->sendAs = 'html'; - $this->assertTrue($this->Controller->EmailTest->send($body)); - $msg = DebugCompTransport::$lastEmail; - $this->assertNotRegExp('/text\/plain/', $msg); - $this->assertRegExp('/text\/html/', $msg); - - $this->Controller->EmailTest->sendAs = 'text'; - $this->assertTrue($this->Controller->EmailTest->send($body)); - $msg = DebugCompTransport::$lastEmail; - $this->assertRegExp('/text\/plain/', $msg); - $this->assertNotRegExp('/text\/html/', $msg); - - $this->Controller->EmailTest->sendAs = 'both'; - $this->assertTrue($this->Controller->EmailTest->send($body)); - $msg = DebugCompTransport::$lastEmail; - - $this->assertRegExp('/text\/plain/', $msg); - $this->assertRegExp('/text\/html/', $msg); - $this->assertRegExp('/multipart\/alternative/', $msg); - } - -/** - * testNoDoubleNewlinesInHeaders function - * - * @return void - */ - public function testNoDoubleNewlinesInHeaders() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Attachment Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - $this->Controller->EmailTest->delivery = 'DebugComp'; - $body = '

This is the body of the message

'; - - $this->Controller->EmailTest->sendAs = 'both'; - $this->assertTrue($this->Controller->EmailTest->send($body)); - $msg = DebugCompTransport::$lastEmail; - - $this->assertNotRegExp('/\n\nContent-Transfer-Encoding/', $msg); - $this->assertRegExp('/\nContent-Transfer-Encoding/', $msg); - } - -/** - * testReset method - * - * @return void - */ - public function testReset() { - $this->Controller->EmailTest->template = 'default'; - $this->Controller->EmailTest->to = 'test.recipient@example.com'; - $this->Controller->EmailTest->from = 'test.sender@example.com'; - $this->Controller->EmailTest->replyTo = 'test.replyto@example.com'; - $this->Controller->EmailTest->return = 'test.return@example.com'; - $this->Controller->EmailTest->cc = array('cc1@example.com', 'cc2@example.com'); - $this->Controller->EmailTest->bcc = array('bcc1@example.com', 'bcc2@example.com'); - $this->Controller->EmailTest->date = 'Today!'; - $this->Controller->EmailTest->subject = 'Test subject'; - $this->Controller->EmailTest->additionalParams = 'X-additional-header'; - $this->Controller->EmailTest->delivery = 'smtp'; - $this->Controller->EmailTest->smtpOptions['host'] = 'blah'; - $this->Controller->EmailTest->smtpOptions['timeout'] = 0.2; - $this->Controller->EmailTest->attachments = array('attachment1', 'attachment2'); - $this->Controller->EmailTest->textMessage = 'This is the body of the message'; - $this->Controller->EmailTest->htmlMessage = 'This is the body of the message'; - $this->Controller->EmailTest->messageId = false; - - try { - $this->Controller->EmailTest->send('Should not work'); - $this->fail('No exception'); - } catch (SocketException $e) { - $this->assertTrue(true, 'SocketException raised'); - } - - $this->Controller->EmailTest->reset(); - - $this->assertNull($this->Controller->EmailTest->template); - $this->assertSame($this->Controller->EmailTest->to, array()); - $this->assertNull($this->Controller->EmailTest->from); - $this->assertNull($this->Controller->EmailTest->replyTo); - $this->assertNull($this->Controller->EmailTest->return); - $this->assertSame($this->Controller->EmailTest->cc, array()); - $this->assertSame($this->Controller->EmailTest->bcc, array()); - $this->assertNull($this->Controller->EmailTest->date); - $this->assertNull($this->Controller->EmailTest->subject); - $this->assertNull($this->Controller->EmailTest->additionalParams); - $this->assertNull($this->Controller->EmailTest->smtpError); - $this->assertSame($this->Controller->EmailTest->attachments, array()); - $this->assertNull($this->Controller->EmailTest->textMessage); - $this->assertTrue($this->Controller->EmailTest->messageId); - $this->assertEquals('mail', $this->Controller->EmailTest->delivery); - } - - public function testPluginCustomViewClass() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - - $this->Controller->view = 'TestPlugin.Email'; - - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'CustomViewClass test'; - $this->Controller->EmailTest->delivery = 'DebugComp'; - $body = 'Body of message'; - - $this->assertTrue($this->Controller->EmailTest->send($body)); - $result = DebugCompTransport::$lastEmail; - - $this->assertRegExp('/Body of message/', $result); - } - -/** - * testStartup method - * - * @return void - */ - public function testStartup() { - $this->assertNull($this->Controller->EmailTest->startup($this->Controller)); - } - -/** - * testMessageId method - * - * @return void - */ - public function testMessageId() { - $this->Controller->EmailTest->to = 'postmaster@example.com'; - $this->Controller->EmailTest->from = 'noreply@example.com'; - $this->Controller->EmailTest->subject = 'Cake Debug Test'; - $this->Controller->EmailTest->replyTo = 'noreply@example.com'; - $this->Controller->EmailTest->template = null; - - $this->Controller->EmailTest->delivery = 'DebugComp'; - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $result = DebugCompTransport::$lastEmail; - - $this->assertRegExp('/Message-ID: \<[a-f0-9]{8}[a-f0-9]{4}[a-f0-9]{4}[a-f0-9]{4}[a-f0-9]{12}@' . env('HTTP_HOST') . '\>\n/', $result); - - $this->Controller->EmailTest->messageId = '<22091985.998877@example.com>'; - - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $result = DebugCompTransport::$lastEmail; - - $this->assertRegExp('/Message-ID: <22091985.998877@example.com>\n/', $result); - - $this->Controller->EmailTest->messageId = false; - - $this->assertTrue($this->Controller->EmailTest->send('This is the body of the message')); - $result = DebugCompTransport::$lastEmail; - - $this->assertNotRegExp('/Message-ID:/', $result); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/PaginatorComponentTest.php b/lib/Cake/Test/Case/Controller/Component/PaginatorComponentTest.php deleted file mode 100644 index e5ba32c2a72..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/PaginatorComponentTest.php +++ /dev/null @@ -1,884 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Controller', 'Controller'); -App::uses('PaginatorComponent', 'Controller/Component'); -App::uses('CakeRequest', 'Network'); -App::uses('CakeResponse', 'Network'); - -/** - * PaginatorTestController class - * - * @package Cake.Test.Case.Controller.Component - */ -class PaginatorTestController extends Controller { - -/** - * name property - * - * @var string 'PaginatorTest' - */ - public $name = 'PaginatorTest'; - -/** - * components property - * - * @var array - */ - public $components = array('Paginator'); -} - -/** - * PaginatorControllerPost class - * - * @package Cake.Test.Case.Controller.Component - */ -class PaginatorControllerPost extends CakeTestModel { - -/** - * name property - * - * @var string 'PaginatorControllerPost' - */ - public $name = 'PaginatorControllerPost'; - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'posts'; - -/** - * invalidFields property - * - * @var array - */ - public $invalidFields = array('name' => 'error_msg'); - -/** - * lastQueries property - * - * @var array - */ - public $lastQueries = array(); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('PaginatorAuthor' => array('foreignKey' => 'author_id')); - -/** - * beforeFind method - * - * @param mixed $query - * @return void - */ - public function beforeFind($query) { - array_unshift($this->lastQueries, $query); - } - -/** - * find method - * - * @param mixed $type - * @param array $options - * @return void - */ - public function find($conditions = null, $fields = array(), $order = null, $recursive = null) { - if ($conditions == 'popular') { - $conditions = array($this->name . '.' . $this->primaryKey . ' > ' => '1'); - $options = Set::merge($fields, compact('conditions')); - return parent::find('all', $options); - } - return parent::find($conditions, $fields); - } - -} - -/** - * ControllerPaginateModel class - * - * @package Cake.Test.Case.Controller.Component - */ -class ControllerPaginateModel extends CakeTestModel { - -/** - * name property - * - * @var string 'ControllerPaginateModel' - */ - public $name = 'ControllerPaginateModel'; - -/** - * useTable property - * - * @var string 'comments' - */ - public $useTable = 'comments'; - -/** - * paginate method - * - * @return void - */ - public function paginate($conditions, $fields, $order, $limit, $page, $recursive, $extra) { - $this->extra = $extra; - } - -/** - * paginateCount - * - * @return void - */ - public function paginateCount($conditions, $recursive, $extra) { - $this->extraCount = $extra; - } - -} - -/** - * PaginatorControllerComment class - * - * @package Cake.Test.Case.Controller.Component - */ -class PaginatorControllerComment extends CakeTestModel { - -/** - * name property - * - * @var string 'Comment' - */ - public $name = 'Comment'; - -/** - * useTable property - * - * @var string 'comments' - */ - public $useTable = 'comments'; - -/** - * alias property - * - * @var string 'PaginatorControllerComment' - */ - public $alias = 'PaginatorControllerComment'; -} - -/** - * PaginatorAuthor class - * - * @package Cake.Test.Case.Controller.Component - */ -class PaginatorAuthor extends CakeTestModel { - -/** - * name property - * - * @var string 'PaginatorAuthor' - */ - public $name = 'PaginatorAuthor'; - -/** - * useTable property - * - * @var string 'authors' - */ - public $useTable = 'authors'; - -/** - * alias property - * - * @var string 'PaginatorAuthor' - */ - public $alias = 'PaginatorAuthor'; - -/** - * alias property - * - * @var string 'PaginatorAuthor' - */ - public $virtualFields = array( - 'joined_offset' => 'PaginatorAuthor.id + 1' - ); - -} - -class PaginatorComponentTest extends CakeTestCase { - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.post', 'core.comment', 'core.author'); - -/** - * setup - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->request = new CakeRequest('controller_posts/index'); - $this->request->params['pass'] = $this->request->params['named'] = array(); - $this->Controller = new Controller($this->request); - $this->Paginator = new PaginatorComponent($this->getMock('ComponentCollection'), array()); - $this->Paginator->Controller = $this->Controller; - $this->Controller->Post = $this->getMock('Model'); - $this->Controller->Post->alias = 'Post'; - } - -/** - * testPaginate method - * - * @return void - */ - public function testPaginate() { - $Controller = new PaginatorTestController($this->request); - $Controller->uses = array('PaginatorControllerPost', 'PaginatorControllerComment'); - $Controller->request->params['pass'] = array('1'); - $Controller->request->query = array(); - $Controller->constructClasses(); - - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(array(1, 2, 3), $results); - - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerComment'), '{n}.PaginatorControllerComment.id'); - $this->assertEquals(array(1, 2, 3, 4, 5, 6), $results); - - $Controller->modelClass = null; - - $Controller->uses[0] = 'Plugin.PaginatorControllerPost'; - $results = Set::extract($Controller->Paginator->paginate(), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(array(1, 2, 3), $results); - - $Controller->request->params['named'] = array('page' => '-1'); - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertEquals(array(1, 2, 3), $results); - - $Controller->request->params['named'] = array('sort' => 'PaginatorControllerPost.id', 'direction' => 'asc'); - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertEquals(array(1, 2, 3), $results); - - $Controller->request->params['named'] = array('sort' => 'PaginatorControllerPost.id', 'direction' => 'desc'); - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertEquals(array(3, 2, 1), $results); - - $Controller->request->params['named'] = array('sort' => 'id', 'direction' => 'desc'); - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertEquals(array(3, 2, 1), $results); - - $Controller->request->params['named'] = array('sort' => 'NotExisting.field', 'direction' => 'desc'); - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(1, $Controller->params['paging']['PaginatorControllerPost']['page'], 'Invalid field in query %s'); - $this->assertEquals(array(1, 2, 3), $results); - - $Controller->request->params['named'] = array( - 'sort' => 'PaginatorControllerPost.author_id', 'direction' => 'allYourBase' - ); - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals(array('PaginatorControllerPost.author_id' => 'asc'), $Controller->PaginatorControllerPost->lastQueries[1]['order'][0]); - $this->assertEquals(array(1, 3, 2), $results); - - $Controller->request->params['named'] = array(); - $Controller->Paginator->settings = array('limit' => 0, 'maxLimit' => 10, 'paramType' => 'named'); - $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertSame(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['pageCount'], 3); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['prevPage'], false); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['nextPage'], true); - - $Controller->request->params['named'] = array(); - $Controller->Paginator->settings = array('limit' => 'garbage!', 'maxLimit' => 10, 'paramType' => 'named'); - $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertSame(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['pageCount'], 3); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['prevPage'], false); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['nextPage'], true); - - $Controller->request->params['named'] = array(); - $Controller->Paginator->settings = array('limit' => '-1', 'maxLimit' => 10, 'paramType' => 'named'); - $Controller->Paginator->paginate('PaginatorControllerPost'); - - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['limit'], 1); - $this->assertSame(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['pageCount'], 3); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['prevPage'], false); - $this->assertSame($Controller->params['paging']['PaginatorControllerPost']['nextPage'], true); - } - -/** - * Test that non-numeric values are rejected for page, and limit - * - * @return void - */ - public function testPageParamCasting() { - $this->Controller->Post->expects($this->at(0)) - ->method('hasMethod') - ->with('paginate') - ->will($this->returnValue(false)); - - $this->Controller->Post->expects($this->at(1)) - ->method('find') - ->will($this->returnValue(array('stuff'))); - - $this->Controller->Post->expects($this->at(2)) - ->method('hasMethod') - ->with('paginateCount') - ->will($this->returnValue(false)); - - $this->Controller->Post->expects($this->at(3)) - ->method('find') - ->will($this->returnValue(2)); - - $this->request->params['named'] = array('page' => '1 " onclick="alert(\'xss\');">'); - $this->Paginator->settings = array('limit' => 1, 'maxLimit' => 10, 'paramType' => 'named'); - $this->Paginator->paginate('Post'); - $this->assertSame(1, $this->request->params['paging']['Post']['page'], 'XSS exploit opened'); - } - -/** - * testPaginateExtraParams method - * - * @return void - */ - public function testPaginateExtraParams() { - $Controller = new PaginatorTestController($this->request); - - $Controller->uses = array('PaginatorControllerPost', 'PaginatorControllerComment'); - $Controller->request->params['pass'] = array('1'); - $Controller->params['url'] = array(); - $Controller->constructClasses(); - - $Controller->request->params['named'] = array('page' => '-1', 'contain' => array('PaginatorControllerComment')); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertEquals(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertEquals(array(1, 2, 3), Set::extract($result, '{n}.PaginatorControllerPost.id')); - $this->assertTrue(!isset($Controller->PaginatorControllerPost->lastQueries[1]['contain'])); - - $Controller->request->params['named'] = array('page' => '-1'); - $Controller->Paginator->settings = array( - 'PaginatorControllerPost' => array( - 'contain' => array('PaginatorControllerComment'), - 'maxLimit' => 10, - 'paramType' => 'named' - ), - ); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertEquals(1, $Controller->params['paging']['PaginatorControllerPost']['page']); - $this->assertEquals(array(1, 2, 3), Set::extract($result, '{n}.PaginatorControllerPost.id')); - $this->assertTrue(isset($Controller->PaginatorControllerPost->lastQueries[1]['contain'])); - - $Controller->Paginator->settings = array( - 'PaginatorControllerPost' => array( - 'popular', 'fields' => array('id', 'title'), 'maxLimit' => 10, 'paramType' => 'named' - ), - ); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertEquals(array(2, 3), Set::extract($result, '{n}.PaginatorControllerPost.id')); - $this->assertEquals(array('PaginatorControllerPost.id > ' => '1'), $Controller->PaginatorControllerPost->lastQueries[1]['conditions']); - - $Controller->request->params['named'] = array('limit' => 12); - $Controller->Paginator->settings = array('limit' => 30, 'maxLimit' => 100, 'paramType' => 'named'); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $paging = $Controller->params['paging']['PaginatorControllerPost']; - - $this->assertEquals(12, $Controller->PaginatorControllerPost->lastQueries[1]['limit']); - $this->assertEquals(12, $paging['options']['limit']); - - $Controller = new PaginatorTestController($this->request); - $Controller->uses = array('ControllerPaginateModel'); - $Controller->request->query = array(); - $Controller->constructClasses(); - $Controller->Paginator->settings = array( - 'ControllerPaginateModel' => array( - 'contain' => array('ControllerPaginateModel'), - 'group' => 'Comment.author_id', - 'maxLimit' => 10, - 'paramType' => 'named' - ) - ); - $result = $Controller->Paginator->paginate('ControllerPaginateModel'); - $expected = array( - 'contain' => array('ControllerPaginateModel'), - 'group' => 'Comment.author_id', - 'maxLimit' => 10, - 'paramType' => 'named' - ); - $this->assertEquals($expected, $Controller->ControllerPaginateModel->extra); - $this->assertEquals($expected, $Controller->ControllerPaginateModel->extraCount); - - $Controller->Paginator->settings = array( - 'ControllerPaginateModel' => array( - 'foo', 'contain' => array('ControllerPaginateModel'), - 'group' => 'Comment.author_id', - 'maxLimit' => 10, - 'paramType' => 'named' - ) - ); - $Controller->Paginator->paginate('ControllerPaginateModel'); - $expected = array( - 'contain' => array('ControllerPaginateModel'), - 'group' => 'Comment.author_id', - 'type' => 'foo', - 'maxLimit' => 10, - 'paramType' => 'named' - ); - $this->assertEquals($expected, $Controller->ControllerPaginateModel->extra); - $this->assertEquals($expected, $Controller->ControllerPaginateModel->extraCount); - } - -/** - * Test that special paginate types are called and that the type param doesn't leak out into defaults or options. - * - * @return void - */ - public function testPaginateSpecialType() { - $Controller = new PaginatorTestController($this->request); - $Controller->uses = array('PaginatorControllerPost', 'PaginatorControllerComment'); - $Controller->passedArgs[] = '1'; - $Controller->params['url'] = array(); - $Controller->constructClasses(); - - $Controller->Paginator->settings = array( - 'PaginatorControllerPost' => array( - 'popular', - 'fields' => array('id', 'title'), - 'maxLimit' => 10, - 'paramType' => 'named' - ) - ); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - - $this->assertEquals(array(2, 3), Set::extract($result, '{n}.PaginatorControllerPost.id')); - $this->assertEquals( - $Controller->PaginatorControllerPost->lastQueries[1]['conditions'], - array('PaginatorControllerPost.id > ' => '1') - ); - $this->assertFalse(isset($Controller->params['paging']['PaginatorControllerPost']['options'][0])); - } - -/** - * testDefaultPaginateParams method - * - * @return void - */ - public function testDefaultPaginateParams() { - $Controller = new PaginatorTestController($this->request); - $Controller->modelClass = 'PaginatorControllerPost'; - $Controller->params['url'] = array(); - $Controller->constructClasses(); - $Controller->Paginator->settings = array( - 'order' => 'PaginatorControllerPost.id DESC', - 'maxLimit' => 10, - 'paramType' => 'named' - ); - $results = Set::extract($Controller->Paginator->paginate('PaginatorControllerPost'), '{n}.PaginatorControllerPost.id'); - $this->assertEquals('PaginatorControllerPost.id DESC', $Controller->params['paging']['PaginatorControllerPost']['order']); - $this->assertEquals(array(3, 2, 1), $results); - } - -/** - * test paginate() and virtualField interactions - * - * @return void - */ - public function testPaginateOrderVirtualField() { - $Controller = new PaginatorTestController($this->request); - $Controller->uses = array('PaginatorControllerPost', 'PaginatorControllerComment'); - $Controller->params['url'] = array(); - $Controller->constructClasses(); - $Controller->PaginatorControllerPost->virtualFields = array( - 'offset_test' => 'PaginatorControllerPost.id + 1' - ); - - $Controller->Paginator->settings = array( - 'fields' => array('id', 'title', 'offset_test'), - 'order' => array('offset_test' => 'DESC'), - 'maxLimit' => 10, - 'paramType' => 'named' - ); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertEquals(array(4, 3, 2), Set::extract($result, '{n}.PaginatorControllerPost.offset_test')); - - $Controller->request->params['named'] = array('sort' => 'offset_test', 'direction' => 'asc'); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertEquals(array(2, 3, 4), Set::extract($result, '{n}.PaginatorControllerPost.offset_test')); - } - -/** - * test paginate() and virtualField on joined model - * - * @return void - */ - public function testPaginateOrderVirtualFieldJoinedModel() { - $Controller = new PaginatorTestController($this->request); - $Controller->uses = array('PaginatorControllerPost'); - $Controller->params['url'] = array(); - $Controller->constructClasses(); - $Controller->PaginatorControllerPost->recursive = 0; - $Controller->Paginator->settings = array( - 'order' => array('PaginatorAuthor.joined_offset' => 'DESC'), - 'maxLimit' => 10, - 'paramType' => 'named' - ); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertEquals(array(4, 2, 2), Set::extract($result, '{n}.PaginatorAuthor.joined_offset')); - - $Controller->request->params['named'] = array('sort' => 'PaginatorAuthor.joined_offset', 'direction' => 'asc'); - $result = $Controller->Paginator->paginate('PaginatorControllerPost'); - $this->assertEquals(array(2, 2, 4), Set::extract($result, '{n}.PaginatorAuthor.joined_offset')); - } - -/** - * Tests for missing models - * - * @expectedException MissingModelException - */ - public function testPaginateMissingModel() { - $Controller = new PaginatorTestController($this->request); - $Controller->constructClasses(); - $Controller->Paginator->paginate('MissingModel'); - } - -/** - * test that option merging prefers specific models - * - * @return void - */ - public function testMergeOptionsModelSpecific() { - $this->Paginator->settings = array( - 'page' => 1, - 'limit' => 20, - 'maxLimit' => 100, - 'paramType' => 'named', - 'Post' => array( - 'page' => 1, - 'limit' => 10, - 'maxLimit' => 50, - 'paramType' => 'named', - ) - ); - $result = $this->Paginator->mergeOptions('Silly'); - $this->assertEquals($this->Paginator->settings, $result); - - $result = $this->Paginator->mergeOptions('Post'); - $expected = array('page' => 1, 'limit' => 10, 'paramType' => 'named', 'maxLimit' => 50); - $this->assertEquals($expected, $result); - } - -/** - * test mergeOptions with named params. - * - * @return void - */ - public function testMergeOptionsNamedParams() { - $this->request->params['named'] = array( - 'page' => 10, - 'limit' => 10 - ); - $this->Paginator->settings = array( - 'page' => 1, - 'limit' => 20, - 'maxLimit' => 100, - 'paramType' => 'named', - ); - $result = $this->Paginator->mergeOptions('Post'); - $expected = array('page' => 10, 'limit' => 10, 'maxLimit' => 100, 'paramType' => 'named'); - $this->assertEquals($expected, $result); - } - -/** - * test merging options from the querystring. - * - * @return void - */ - public function testMergeOptionsQueryString() { - $this->request->params['named'] = array( - 'page' => 10, - 'limit' => 10 - ); - $this->request->query = array( - 'page' => 99, - 'limit' => 75 - ); - $this->Paginator->settings = array( - 'page' => 1, - 'limit' => 20, - 'maxLimit' => 100, - 'paramType' => 'querystring', - ); - $result = $this->Paginator->mergeOptions('Post'); - $expected = array('page' => 99, 'limit' => 75, 'maxLimit' => 100, 'paramType' => 'querystring'); - $this->assertEquals($expected, $result); - } - -/** - * test that the default whitelist doesn't let people screw with things they should not be allowed to. - * - * @return void - */ - public function testMergeOptionsDefaultWhiteList() { - $this->request->params['named'] = array( - 'page' => 10, - 'limit' => 10, - 'fields' => array('bad.stuff'), - 'recursive' => 1000, - 'conditions' => array('bad.stuff'), - 'contain' => array('bad') - ); - $this->Paginator->settings = array( - 'page' => 1, - 'limit' => 20, - 'maxLimit' => 100, - 'paramType' => 'named', - ); - $result = $this->Paginator->mergeOptions('Post'); - $expected = array('page' => 10, 'limit' => 10, 'maxLimit' => 100, 'paramType' => 'named'); - $this->assertEquals($expected, $result); - } - -/** - * test that modifying the whitelist works. - * - * @return void - */ - public function testMergeOptionsExtraWhitelist() { - $this->request->params['named'] = array( - 'page' => 10, - 'limit' => 10, - 'fields' => array('bad.stuff'), - 'recursive' => 1000, - 'conditions' => array('bad.stuff'), - 'contain' => array('bad') - ); - $this->Paginator->settings = array( - 'page' => 1, - 'limit' => 20, - 'maxLimit' => 100, - 'paramType' => 'named', - ); - $this->Paginator->whitelist[] = 'fields'; - $result = $this->Paginator->mergeOptions('Post'); - $expected = array( - 'page' => 10, 'limit' => 10, 'maxLimit' => 100, 'paramType' => 'named', 'fields' => array('bad.stuff') - ); - $this->assertEquals($expected, $result); - } - -/** - * test that invalid directions are ignored. - * - * @return void - */ - public function testValidateSortInvalidDirection() { - $model = $this->getMock('Model'); - $model->alias = 'model'; - $model->expects($this->any())->method('hasField')->will($this->returnValue(true)); - - $options = array('sort' => 'something', 'direction' => 'boogers'); - $result = $this->Paginator->validateSort($model, $options); - - $this->assertEquals('asc', $result['order']['model.something']); - } - -/** - * test that fields not in whitelist won't be part of order conditions. - * - * @return void - */ - public function testValidateSortWhitelistFailure() { - $model = $this->getMock('Model'); - $model->alias = 'model'; - $model->expects($this->any())->method('hasField')->will($this->returnValue(true)); - - $options = array('sort' => 'body', 'direction' => 'asc'); - $result = $this->Paginator->validateSort($model, $options, array('title', 'id')); - - $this->assertNull($result['order']); - } - -/** - * test that virtual fields work. - * - * @return void - */ - public function testValidateSortVirtualField() { - $model = $this->getMock('Model'); - $model->alias = 'model'; - - $model->expects($this->at(0)) - ->method('hasField') - ->with('something') - ->will($this->returnValue(false)); - - $model->expects($this->at(1)) - ->method('hasField') - ->with('something', true) - ->will($this->returnValue(true)); - - $options = array('sort' => 'something', 'direction' => 'desc'); - $result = $this->Paginator->validateSort($model, $options); - - $this->assertEquals('desc', $result['order']['something']); - } - -/** - * test that multiple sort works. - * - * @return void - */ - public function testValidateSortMultiple() { - $model = $this->getMock('Model'); - $model->alias = 'model'; - $model->expects($this->any())->method('hasField')->will($this->returnValue(true)); - - $options = array('order' => array( - 'author_id' => 'asc', - 'title' => 'asc' - )); - $result = $this->Paginator->validateSort($model, $options); - $expected = array( - 'model.author_id' => 'asc', - 'model.title' => 'asc' - ); - - $this->assertEquals($expected, $result['order']); - } - -/** - * Test that no sort doesn't trigger an error. - * - * @return void - */ - public function testValidateSortNoSort() { - $model = $this->getMock('Model'); - $model->alias = 'model'; - $model->expects($this->any())->method('hasField')->will($this->returnValue(true)); - - $options = array('direction' => 'asc'); - $result = $this->Paginator->validateSort($model, $options, array('title', 'id')); - $this->assertFalse(isset($result['order'])); - - $options = array('order' => 'invalid desc'); - $result = $this->Paginator->validateSort($model, $options, array('title', 'id')); - - $this->assertEquals($options['order'], $result['order']); - } - -/** - * test that maxLimit is respected - * - * @return void - */ - public function testCheckLimit() { - $result = $this->Paginator->checkLimit(array('limit' => 1000000, 'maxLimit' => 100)); - $this->assertEquals(100, $result['limit']); - - $result = $this->Paginator->checkLimit(array('limit' => 'sheep!', 'maxLimit' => 100)); - $this->assertEquals(1, $result['limit']); - - $result = $this->Paginator->checkLimit(array('limit' => '-1', 'maxLimit' => 100)); - $this->assertEquals(1, $result['limit']); - - $result = $this->Paginator->checkLimit(array('limit' => null, 'maxLimit' => 100)); - $this->assertEquals(1, $result['limit']); - - $result = $this->Paginator->checkLimit(array('limit' => 0, 'maxLimit' => 100)); - $this->assertEquals(1, $result['limit']); - } - -/** - * testPaginateMaxLimit - * - * @return void - */ - public function testPaginateMaxLimit() { - $Controller = new Controller($this->request); - - $Controller->uses = array('PaginatorControllerPost', 'ControllerComment'); - $Controller->passedArgs[] = '1'; - $Controller->constructClasses(); - - $Controller->request->params['named'] = array( - 'contain' => array('ControllerComment'), 'limit' => '1000' - ); - $result = $Controller->paginate('PaginatorControllerPost'); - $this->assertEquals(100, $Controller->params['paging']['PaginatorControllerPost']['options']['limit']); - - $Controller->request->params['named'] = array( - 'contain' => array('ControllerComment'), 'limit' => '1000', 'maxLimit' => 1000 - ); - $result = $Controller->paginate('PaginatorControllerPost'); - $this->assertEquals(100, $Controller->params['paging']['PaginatorControllerPost']['options']['limit']); - - $Controller->request->params['named'] = array('contain' => array('ControllerComment'), 'limit' => '10'); - $result = $Controller->paginate('PaginatorControllerPost'); - $this->assertEquals(10, $Controller->params['paging']['PaginatorControllerPost']['options']['limit']); - - $Controller->request->params['named'] = array('contain' => array('ControllerComment'), 'limit' => '1000'); - $Controller->paginate = array('maxLimit' => 2000, 'paramType' => 'named'); - $result = $Controller->paginate('PaginatorControllerPost'); - $this->assertEquals(1000, $Controller->params['paging']['PaginatorControllerPost']['options']['limit']); - - $Controller->request->params['named'] = array('contain' => array('ControllerComment'), 'limit' => '5000'); - $result = $Controller->paginate('PaginatorControllerPost'); - $this->assertEquals(2000, $Controller->params['paging']['PaginatorControllerPost']['options']['limit']); - } - -/** - * test paginate() and virtualField overlapping with real fields. - * - * @return void - */ - public function testPaginateOrderVirtualFieldSharedWithRealField() { - $Controller = new Controller($this->request); - $Controller->uses = array('PaginatorControllerPost', 'PaginatorControllerComment'); - $Controller->constructClasses(); - $Controller->PaginatorControllerComment->virtualFields = array( - 'title' => 'PaginatorControllerComment.comment' - ); - $Controller->PaginatorControllerComment->bindModel(array( - 'belongsTo' => array( - 'PaginatorControllerPost' => array( - 'className' => 'PaginatorControllerPost', - 'foreignKey' => 'article_id' - ) - ) - ), false); - - $Controller->paginate = array( - 'fields' => array('PaginatorControllerComment.id', 'title', 'PaginatorControllerPost.title'), - ); - $Controller->passedArgs = array('sort' => 'PaginatorControllerPost.title', 'dir' => 'asc'); - $result = $Controller->paginate('PaginatorControllerComment'); - $this->assertEquals(array(1, 2, 3, 4, 5, 6), Set::extract($result, '{n}.PaginatorControllerComment.id')); - } - -} diff --git a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php deleted file mode 100644 index 2dc35d44f9c..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php +++ /dev/null @@ -1,881 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 1.2.0.5435 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Controller', 'Controller'); -App::uses('RequestHandlerComponent', 'Controller/Component'); -App::uses('CakeRequest', 'Network'); -App::uses('CakeResponse', 'Network'); -App::uses('Router', 'Routing'); - -/** - * RequestHandlerTestController class - * - * @package Cake.Test.Case.Controller.Component - */ -class RequestHandlerTestController extends Controller { - -/** - * uses property - * - * @var mixed null - */ - public $uses = null; - -/** - * test method for ajax redirection - * - * @return void - */ - public function destination() { - $this->viewPath = 'Posts'; - $this->render('index'); - } - -/** - * test method for ajax redirection + parameter parsing - * - * @return void - */ - public function param_method($one = null, $two = null) { - echo "one: $one two: $two"; - $this->autoRender = false; - } - -/** - * test method for testing layout rendering when isAjax() - * - * @return void - */ - public function ajax2_layout() { - if ($this->autoLayout) { - $this->layout = 'ajax2'; - } - $this->destination(); - } - -} - - -/** - * RequestHandlerComponentTest class - * - * @package Cake.Test.Case.Controller.Component - */ -class RequestHandlerComponentTest extends CakeTestCase { - -/** - * Controller property - * - * @var RequestHandlerTestController - */ - public $Controller; - -/** - * RequestHandler property - * - * @var RequestHandlerComponent - */ - public $RequestHandler; - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->_server = $_SERVER; - $this->_init(); - } - -/** - * init method - * - * @return void - */ - protected function _init() { - $request = new CakeRequest('controller_posts/index'); - $response = new CakeResponse(); - $this->Controller = new RequestHandlerTestController($request, $response); - $this->Controller->constructClasses(); - $this->RequestHandler = new RequestHandlerComponent($this->Controller->Components); - $this->_extensions = Router::extensions(); - } - -/** - * endTest method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->RequestHandler, $this->Controller); - if (!headers_sent()) { - header('Content-type: text/html'); //reset content type. - } - $_SERVER = $this->_server; - call_user_func_array('Router::parseExtensions', $this->_extensions); - } - -/** - * Test that the constructor sets the settings. - * - * @return void - */ - public function testConstructorSettings() { - $settings = array( - 'ajaxLayout' => 'test_ajax' - ); - $Collection = new ComponentCollection(); - $Collection->init($this->Controller); - $RequestHandler = new RequestHandlerComponent($Collection, $settings); - $this->assertEquals('test_ajax', $RequestHandler->ajaxLayout); - } - -/** - * testInitializeCallback method - * - * @return void - */ - public function testInitializeCallback() { - $this->assertNull($this->RequestHandler->ext); - $this->Controller->request->params['ext'] = 'rss'; - $this->RequestHandler->initialize($this->Controller); - $this->assertEquals('rss', $this->RequestHandler->ext); - } - -/** - * test that a mapped Accept-type header will set $this->ext correctly. - * - * @return void - */ - public function testInitializeContentTypeSettingExt() { - $this->assertNull($this->RequestHandler->ext); - - $_SERVER['HTTP_ACCEPT'] = 'application/json'; - Router::parseExtensions('json'); - - $this->RequestHandler->initialize($this->Controller); - $this->assertEquals('json', $this->RequestHandler->ext); - } - -/** - * Test that RequestHandler sets $this->ext when jQuery sends its wonky-ish headers. - * - * @return void - */ - public function testInitializeContentTypeWithjQueryAccept() { - $_SERVER['HTTP_ACCEPT'] = 'application/json, text/javascript, */*; q=0.01'; - $this->assertNull($this->RequestHandler->ext); - Router::parseExtensions('json'); - - $this->RequestHandler->initialize($this->Controller); - $this->assertEquals('json', $this->RequestHandler->ext); - } - -/** - * Test that RequestHandler sets $this->ext when jQuery sends its wonky-ish headers - * and the application is configured to handle multiple extensions - * - * @return void - */ - public function testInitializeContentTypeWithjQueryAcceptAndMultiplesExtensions() { - $_SERVER['HTTP_ACCEPT'] = 'application/json, text/javascript, */*; q=0.01'; - $this->assertNull($this->RequestHandler->ext); - Router::parseExtensions('rss', 'json'); - - $this->RequestHandler->initialize($this->Controller); - $this->assertEquals('json', $this->RequestHandler->ext); - } - -/** - * Test that RequestHandler does not set $this->ext when multiple accepts are sent. - * - * @return void - */ - public function testInitializeNoContentTypeWithSingleAccept() { - $_SERVER['HTTP_ACCEPT'] = 'application/json, text/html, */*; q=0.01'; - $this->assertNull($this->RequestHandler->ext); - Router::parseExtensions('json'); - - $this->RequestHandler->initialize($this->Controller); - $this->assertNull($this->RequestHandler->ext); - } - -/** - * Test that ext is not set with multiple accepted content types. - * - * @return void - */ - public function testInitializeNoContentTypeWithMultipleAcceptedTypes() { - $_SERVER['HTTP_ACCEPT'] = 'application/json, text/javascript, application/xml, */*; q=0.01'; - $this->assertNull($this->RequestHandler->ext); - Router::parseExtensions('xml', 'json'); - - $this->RequestHandler->initialize($this->Controller); - $this->assertNull($this->RequestHandler->ext); - } - -/** - * Test that ext is not set with confusing android accepts headers. - * - * @return void - */ - public function testInitializeAmbiguousAndroidAccepts() { - $_SERVER['HTTP_ACCEPT'] = 'application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5'; - $this->assertNull($this->RequestHandler->ext); - Router::parseExtensions('html', 'xml'); - - $this->RequestHandler->initialize($this->Controller); - $this->assertNull($this->RequestHandler->ext); - } - -/** - * Test that a type mismatch doesn't incorrectly set the ext - * - * @return void - */ - public function testInitializeContentTypeAndExtensionMismatch() { - $this->assertNull($this->RequestHandler->ext); - $extensions = Router::extensions(); - Router::parseExtensions('xml'); - - $this->Controller->request = $this->getMock('CakeRequest'); - $this->Controller->request->expects($this->any()) - ->method('accepts') - ->will($this->returnValue(array('application/json'))); - - $this->RequestHandler->initialize($this->Controller); - $this->assertNull($this->RequestHandler->ext); - - call_user_func_array(array('Router', 'parseExtensions'), $extensions); - } - -/** - * testDisabling method - * - * @return void - */ - public function testDisabling() { - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; - $this->_init(); - $this->RequestHandler->initialize($this->Controller); - $this->Controller->beforeFilter(); - $this->RequestHandler->startup($this->Controller); - $this->assertEquals(true, $this->Controller->params['isAjax']); - } - -/** - * testAutoResponseType method - * - * @return void - */ - public function testAutoResponseType() { - $this->Controller->ext = '.thtml'; - $this->Controller->request->params['ext'] = 'rss'; - $this->RequestHandler->initialize($this->Controller); - $this->RequestHandler->startup($this->Controller); - $this->assertEquals('.ctp', $this->Controller->ext); - } - -/** - * testAutoAjaxLayout method - * - * @return void - */ - public function testAutoAjaxLayout() { - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; - $this->RequestHandler->startup($this->Controller); - $this->assertEquals($this->Controller->layout, $this->RequestHandler->ajaxLayout); - - $this->_init(); - $this->Controller->request->params['ext'] = 'js'; - $this->RequestHandler->initialize($this->Controller); - $this->RequestHandler->startup($this->Controller); - $this->assertNotEquals('ajax', $this->Controller->layout); - - unset($_SERVER['HTTP_X_REQUESTED_WITH']); - } - -/** - * testStartupCallback method - * - * @return void - */ - public function testStartupCallback() { - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $_SERVER['CONTENT_TYPE'] = 'application/xml'; - $this->Controller->request = $this->getMock('CakeRequest', array('_readInput')); - $this->RequestHandler->startup($this->Controller); - $this->assertTrue(is_array($this->Controller->data)); - $this->assertFalse(is_object($this->Controller->data)); - } - -/** - * testStartupCallback with charset. - * - * @return void - */ - public function testStartupCallbackCharset() { - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $_SERVER['CONTENT_TYPE'] = 'application/xml; charset=UTF-8'; - $this->Controller->request = $this->getMock('CakeRequest', array('_readInput')); - $this->RequestHandler->startup($this->Controller); - $this->assertTrue(is_array($this->Controller->data)); - $this->assertFalse(is_object($this->Controller->data)); - } - -/** - * Test mapping a new type and having startup process it. - * - * @return void - */ - public function testStartupCustomTypeProcess() { - if (!function_exists('str_getcsv')) { - $this->markTestSkipped('Need "str_getcsv" for this test.'); - } - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_SERVER['CONTENT_TYPE'] = 'text/csv'; - $this->Controller->request = $this->getMock('CakeRequest', array('_readInput')); - $this->Controller->request->expects($this->once()) - ->method('_readInput') - ->will($this->returnValue('"A","csv","string"')); - $this->RequestHandler->addInputType('csv', array('str_getcsv')); - $this->RequestHandler->startup($this->Controller); - $expected = array( - 'A', 'csv', 'string' - ); - $this->assertEquals($expected, $this->Controller->request->data); - } - -/** - * testNonAjaxRedirect method - * - * @return void - */ - public function testNonAjaxRedirect() { - $this->RequestHandler->initialize($this->Controller); - $this->RequestHandler->startup($this->Controller); - $this->assertNull($this->RequestHandler->beforeRedirect($this->Controller, '/')); - } - -/** - * testRenderAs method - * - * @return void - */ - public function testRenderAs() { - $this->assertFalse(in_array('Rss', $this->Controller->helpers)); - $this->RequestHandler->renderAs($this->Controller, 'rss'); - $this->assertTrue(in_array('Rss', $this->Controller->helpers)); - - $this->Controller->viewPath = 'request_handler_test\\rss'; - $this->RequestHandler->renderAs($this->Controller, 'js'); - $this->assertEquals('request_handler_test' . DS . 'js', $this->Controller->viewPath); - } - -/** - * test that attachment headers work with renderAs - * - * @return void - */ - public function testRenderAsWithAttachment() { - $this->RequestHandler->request = $this->getMock('CakeRequest'); - $this->RequestHandler->request->expects($this->any()) - ->method('parseAccept') - ->will($this->returnValue(array('1.0' => array('application/xml')))); - - $this->RequestHandler->response = $this->getMock('CakeResponse', array('type', 'download', 'charset')); - $this->RequestHandler->response->expects($this->at(0)) - ->method('type') - ->with('application/xml'); - $this->RequestHandler->response->expects($this->at(1)) - ->method('charset') - ->with('UTF-8'); - $this->RequestHandler->response->expects($this->at(2)) - ->method('download') - ->with('myfile.xml'); - - $this->RequestHandler->renderAs($this->Controller, 'xml', array('attachment' => 'myfile.xml')); - - $this->assertEquals('Xml', $this->Controller->viewClass); - } - -/** - * test that respondAs works as expected. - * - * @return void - */ - public function testRespondAs() { - $this->RequestHandler->response = $this->getMock('CakeResponse', array('type')); - $this->RequestHandler->response->expects($this->at(0))->method('type') - ->with('application/json'); - $this->RequestHandler->response->expects($this->at(1))->method('type') - ->with('text/xml'); - - $result = $this->RequestHandler->respondAs('json'); - $this->assertTrue($result); - $result = $this->RequestHandler->respondAs('text/xml'); - $this->assertTrue($result); - } - -/** - * test that attachment headers work with respondAs - * - * @return void - */ - public function testRespondAsWithAttachment() { - $this->RequestHandler = $this->getMock( - 'RequestHandlerComponent', - array('_header'), - array(&$this->Controller->Components) - ); - $this->RequestHandler->response = $this->getMock('CakeResponse', array('type', 'download')); - $this->RequestHandler->request = $this->getMock('CakeRequest'); - - $this->RequestHandler->request->expects($this->once()) - ->method('parseAccept') - ->will($this->returnValue(array('1.0' => array('application/xml')))); - - $this->RequestHandler->response->expects($this->once())->method('download') - ->with('myfile.xml'); - $this->RequestHandler->response->expects($this->once())->method('type') - ->with('application/xml'); - - $result = $this->RequestHandler->respondAs('xml', array('attachment' => 'myfile.xml')); - $this->assertTrue($result); - } - -/** - * test that calling renderAs() more than once continues to work. - * - * @link #6466 - * @return void - */ - public function testRenderAsCalledTwice() { - $this->RequestHandler->renderAs($this->Controller, 'print'); - $this->assertEquals('RequestHandlerTest' . DS . 'print', $this->Controller->viewPath); - $this->assertEquals('print', $this->Controller->layoutPath); - - $this->RequestHandler->renderAs($this->Controller, 'js'); - $this->assertEquals('RequestHandlerTest' . DS . 'js', $this->Controller->viewPath); - $this->assertEquals('js', $this->Controller->layoutPath); - $this->assertTrue(in_array('Js', $this->Controller->helpers)); - } - -/** - * testRequestClientTypes method - * - * @return void - */ - public function testRequestClientTypes() { - $_SERVER['HTTP_X_PROTOTYPE_VERSION'] = '1.5'; - $this->assertEquals('1.5', $this->RequestHandler->getAjaxVersion()); - - unset($_SERVER['HTTP_X_REQUESTED_WITH'], $_SERVER['HTTP_X_PROTOTYPE_VERSION']); - $this->assertFalse($this->RequestHandler->getAjaxVersion()); - } - -/** - * Tests the detection of various Flash versions - * - * @return void - */ - public function testFlashDetection() { - $request = $this->getMock('CakeRequest'); - $request->expects($this->once())->method('is') - ->with('flash') - ->will($this->returnValue(true)); - - $this->RequestHandler->request = $request; - $this->assertTrue($this->RequestHandler->isFlash()); - } - -/** - * testRequestContentTypes method - * - * @return void - */ - public function testRequestContentTypes() { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $this->assertNull($this->RequestHandler->requestedWith()); - - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_SERVER['CONTENT_TYPE'] = 'application/json'; - $this->assertEquals('json', $this->RequestHandler->requestedWith()); - - $result = $this->RequestHandler->requestedWith(array('json', 'xml')); - $this->assertEquals('json', $result); - - $result = $this->RequestHandler->requestedWith(array('rss', 'atom')); - $this->assertFalse($result); - - $_SERVER['HTTP_ACCEPT'] = 'text/xml,application/xml,application/xhtml+xml,text/html,text/plain,image/png,*/*'; - $this->assertTrue($this->RequestHandler->isXml()); - $this->assertFalse($this->RequestHandler->isAtom()); - $this->assertFalse($this->RequestHandler->isRSS()); - - $_SERVER['HTTP_ACCEPT'] = 'application/atom+xml,text/xml,application/xml,application/xhtml+xml,text/html,text/plain,image/png,*/*'; - $this->assertTrue($this->RequestHandler->isAtom()); - $this->assertFalse($this->RequestHandler->isRSS()); - - $_SERVER['HTTP_ACCEPT'] = 'application/rss+xml,text/xml,application/xml,application/xhtml+xml,text/html,text/plain,image/png,*/*'; - $this->assertFalse($this->RequestHandler->isAtom()); - $this->assertTrue($this->RequestHandler->isRSS()); - - $this->assertFalse($this->RequestHandler->isWap()); - $_SERVER['HTTP_ACCEPT'] = 'text/vnd.wap.wml,text/html,text/plain,image/png,*/*'; - $this->assertTrue($this->RequestHandler->isWap()); - - $_SERVER['HTTP_ACCEPT'] = 'application/rss+xml,text/xml,application/xml,application/xhtml+xml,text/html,text/plain,image/png,*/*'; - } - -/** - * testResponseContentType method - * - * @return void - */ - public function testResponseContentType() { - $this->assertEquals('html', $this->RequestHandler->responseType()); - $this->assertTrue($this->RequestHandler->respondAs('atom')); - $this->assertEquals('atom', $this->RequestHandler->responseType()); - } - -/** - * testMobileDeviceDetection method - * - * @return void - */ - public function testMobileDeviceDetection() { - $request = $this->getMock('CakeRequest'); - $request->expects($this->once())->method('is') - ->with('mobile') - ->will($this->returnValue(true)); - - $this->RequestHandler->request = $request; - $this->assertTrue($this->RequestHandler->isMobile()); - } - -/** - * testRequestProperties method - * - * @return void - */ - public function testRequestProperties() { - $request = $this->getMock('CakeRequest'); - $request->expects($this->once())->method('is') - ->with('ssl') - ->will($this->returnValue(true)); - - $this->RequestHandler->request = $request; - $this->assertTrue($this->RequestHandler->isSsl()); - } - -/** - * testRequestMethod method - * - * @return void - */ - public function testRequestMethod() { - $request = $this->getMock('CakeRequest'); - $request->expects($this->at(0))->method('is') - ->with('get') - ->will($this->returnValue(true)); - - $request->expects($this->at(1))->method('is') - ->with('post') - ->will($this->returnValue(false)); - - $request->expects($this->at(2))->method('is') - ->with('delete') - ->will($this->returnValue(true)); - - $request->expects($this->at(3))->method('is') - ->with('put') - ->will($this->returnValue(false)); - - $this->RequestHandler->request = $request; - $this->assertTrue($this->RequestHandler->isGet()); - $this->assertFalse($this->RequestHandler->isPost()); - $this->assertTrue($this->RequestHandler->isDelete()); - $this->assertFalse($this->RequestHandler->isPut()); - } - -/** - * test that map alias converts aliases to content types. - * - * @return void - */ - public function testMapAlias() { - $result = $this->RequestHandler->mapAlias('xml'); - $this->assertEquals('application/xml', $result); - - $result = $this->RequestHandler->mapAlias('text/html'); - $this->assertNull($result); - - $result = $this->RequestHandler->mapAlias('wap'); - $this->assertEquals('text/vnd.wap.wml', $result); - - $result = $this->RequestHandler->mapAlias(array('xml', 'js', 'json')); - $expected = array('application/xml', 'text/javascript', 'application/json'); - $this->assertEquals($expected, $result); - } - -/** - * test accepts() on the component - * - * @return void - */ - public function testAccepts() { - $_SERVER['HTTP_ACCEPT'] = 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5'; - $this->assertTrue($this->RequestHandler->accepts(array('js', 'xml', 'html'))); - $this->assertFalse($this->RequestHandler->accepts(array('gif', 'jpeg', 'foo'))); - - $_SERVER['HTTP_ACCEPT'] = '*/*;q=0.5'; - $this->assertFalse($this->RequestHandler->accepts('rss')); - } - -/** - * test accepts and prefers methods. - * - * @return void - */ - public function testPrefers() { - $_SERVER['HTTP_ACCEPT'] = 'text/xml,application/xml,application/xhtml+xml,text/html,text/plain,image/png,*/*'; - $this->assertNotEquals('rss', $this->RequestHandler->prefers()); - $this->RequestHandler->ext = 'rss'; - $this->assertEquals('rss', $this->RequestHandler->prefers()); - $this->assertFalse($this->RequestHandler->prefers('xml')); - $this->assertEquals('xml', $this->RequestHandler->prefers(array('js', 'xml', 'xhtml'))); - $this->assertFalse($this->RequestHandler->prefers(array('red', 'blue'))); - $this->assertEquals('xhtml', $this->RequestHandler->prefers(array('js', 'json', 'xhtml'))); - $this->assertTrue($this->RequestHandler->prefers(array('rss')), 'Should return true if input matches ext.'); - $this->assertFalse($this->RequestHandler->prefers(array('html')), 'No match with ext, return false.'); - - $_SERVER['HTTP_ACCEPT'] = 'text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5'; - $this->_init(); - $this->assertEquals('xml', $this->RequestHandler->prefers()); - - $_SERVER['HTTP_ACCEPT'] = '*/*;q=0.5'; - $this->assertEquals('html', $this->RequestHandler->prefers()); - $this->assertFalse($this->RequestHandler->prefers('rss')); - } - -/** - * testCustomContent method - * - * @return void - */ - public function testCustomContent() { - $_SERVER['HTTP_ACCEPT'] = 'text/x-mobile,text/html;q=0.9,text/plain;q=0.8,*/*;q=0.5'; - $this->RequestHandler->setContent('mobile', 'text/x-mobile'); - $this->RequestHandler->startup($this->Controller); - $this->assertEquals('mobile', $this->RequestHandler->prefers()); - } - -/** - * testClientProperties method - * - * @return void - */ - public function testClientProperties() { - $request = $this->getMock('CakeRequest'); - $request->expects($this->once())->method('referer'); - $request->expects($this->once())->method('clientIp')->will($this->returnValue(false)); - - $this->RequestHandler->request = $request; - - $this->RequestHandler->getReferer(); - $this->RequestHandler->getClientIP(false); - } - -/** - * test that ajax requests involving redirects trigger requestAction instead. - * - * @return void - */ - public function testAjaxRedirectAsRequestAction() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - ), App::RESET); - - $this->Controller->RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $this->Controller->request = $this->getMock('CakeRequest'); - $this->Controller->response = $this->getMock('CakeResponse', array('_sendHeader')); - $this->Controller->RequestHandler->request = $this->Controller->request; - $this->Controller->RequestHandler->response = $this->Controller->response; - $this->Controller->request->expects($this->any())->method('is')->will($this->returnValue(true)); - $this->Controller->RequestHandler->expects($this->once())->method('_stop'); - - ob_start(); - $this->Controller->RequestHandler->beforeRedirect( - $this->Controller, array('controller' => 'request_handler_test', 'action' => 'destination') - ); - $result = ob_get_clean(); - $this->assertRegExp('/posts index/', $result, 'RequestAction redirect failed.'); - - App::build(); - } - -/** - * test that ajax requests involving redirects don't force no layout - * this would cause the ajax layout to not be rendered. - * - * @return void - */ - public function testAjaxRedirectAsRequestActionStillRenderingLayout() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - ), App::RESET); - - $this->Controller->RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $this->Controller->request = $this->getMock('CakeRequest'); - $this->Controller->response = $this->getMock('CakeResponse', array('_sendHeader')); - $this->Controller->RequestHandler->request = $this->Controller->request; - $this->Controller->RequestHandler->response = $this->Controller->response; - $this->Controller->request->expects($this->any())->method('is')->will($this->returnValue(true)); - $this->Controller->RequestHandler->expects($this->once())->method('_stop'); - - ob_start(); - $this->Controller->RequestHandler->beforeRedirect( - $this->Controller, array('controller' => 'request_handler_test', 'action' => 'ajax2_layout') - ); - $result = ob_get_clean(); - $this->assertRegExp('/posts index/', $result, 'RequestAction redirect failed.'); - $this->assertRegExp('/Ajax!/', $result, 'Layout was not rendered.'); - - App::build(); - } - -/** - * test that the beforeRedirect callback properly converts - * array urls into their correct string ones, and adds base => false so - * the correct urls are generated. - * - * @link http://cakephp.lighthouseapp.com/projects/42648-cakephp-1x/tickets/276 - * @return void - */ - public function testBeforeRedirectCallbackWithArrayUrl() { - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; - - Router::setRequestInfo(array( - array('plugin' => null, 'controller' => 'accounts', 'action' => 'index', 'pass' => array(), 'named' => array(), 'form' => array(), 'url' => array('url' => 'accounts/')), - array('base' => '/officespace', 'here' => '/officespace/accounts/', 'webroot' => '/officespace/') - )); - - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('_sendHeader')); - $RequestHandler->request = new CakeRequest('posts/index'); - $RequestHandler->response = $this->getMock('CakeResponse', array('_sendHeader')); - - ob_start(); - $RequestHandler->beforeRedirect( - $this->Controller, - array('controller' => 'request_handler_test', 'action' => 'param_method', 'first', 'second') - ); - $result = ob_get_clean(); - $this->assertEquals('one: first two: second', $result); - } - -/** - * assure that beforeRedirect with a status code will correctly set the status header - * - * @return void - */ - public function testBeforeRedirectCallingHeader() { - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; - - $controller = $this->getMock('Controller', array('header')); - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('_sendHeader','statusCode')); - $RequestHandler->request = $this->getMock('CakeRequest'); - $RequestHandler->request->expects($this->once())->method('is') - ->with('ajax') - ->will($this->returnValue(true)); - - $RequestHandler->response->expects($this->once())->method('statusCode')->with(403); - - ob_start(); - $RequestHandler->beforeRedirect($controller, 'request_handler_test/param_method/first/second', 403); - $result = ob_get_clean(); - } - -/** - * @expectedException CakeException - * @return void - */ - public function testAddInputTypeException() { - $this->RequestHandler->addInputType('csv', array('I am not callable')); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagStar() { - $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something'); - $RequestHandler->response->expects($this->once())->method('notModified'); - $this->assertFalse($RequestHandler->beforeRender($this->Controller)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagExact() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something', true); - $RequestHandler->response->expects($this->once())->method('notModified'); - $this->assertFalse($RequestHandler->beforeRender($this->Controller)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagAndTime() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something', true); - $RequestHandler->response->modified('2012-01-01 00:00:00'); - $RequestHandler->response->expects($this->once())->method('notModified'); - $this->assertFalse($RequestHandler->beforeRender($this->Controller)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedNoInfo() { - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->expects($this->never())->method('notModified'); - $this->assertNull($RequestHandler->beforeRender($this->Controller)); - } -} diff --git a/lib/Cake/Test/Case/Controller/Component/SecurityComponentTest.php b/lib/Cake/Test/Case/Controller/Component/SecurityComponentTest.php deleted file mode 100644 index c97626df5fb..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/SecurityComponentTest.php +++ /dev/null @@ -1,1342 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 1.2.0.5435 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('SecurityComponent', 'Controller/Component'); -App::uses('Controller', 'Controller'); - -/** - * TestSecurityComponent - * - * @package Cake.Test.Case.Controller.Component - */ -class TestSecurityComponent extends SecurityComponent { - -/** - * validatePost method - * - * @param Controller $controller - * @return boolean - */ - public function validatePost(Controller $controller) { - return $this->_validatePost($controller); - } - -} - -/** - * SecurityTestController - * - * @package Cake.Test.Case.Controller.Component - */ -class SecurityTestController extends Controller { - -/** - * name property - * - * @var string 'SecurityTest' - */ - public $name = 'SecurityTest'; - -/** - * components property - * - * @var array - */ - public $components = array('Session', 'TestSecurity'); - -/** - * failed property - * - * @var boolean false - */ - public $failed = false; - -/** - * Used for keeping track of headers in test - * - * @var array - */ - public $testHeaders = array(); - -/** - * fail method - * - * @return void - */ - public function fail() { - $this->failed = true; - } - -/** - * redirect method - * - * @param mixed $option - * @param mixed $code - * @param mixed $exit - * @return void - */ - public function redirect($url, $status = null, $exit = true) { - return $status; - } - -/** - * Convenience method for header() - * - * @param string $status - * @return void - */ - public function header($status) { - $this->testHeaders[] = $status; - } - -} - -/** - * SecurityComponentTest class - * - * @package Cake.Test.Case.Controller.Component - */ -class SecurityComponentTest extends CakeTestCase { - -/** - * Controller property - * - * @var SecurityTestController - */ - public $Controller; - -/** - * oldSalt property - * - * @var string - */ - public $oldSalt; - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - - $request = new CakeRequest('posts/index', false); - $request->addParams(array('controller' => 'posts', 'action' => 'index')); - $this->Controller = new SecurityTestController($request); - $this->Controller->Components->init($this->Controller); - $this->Controller->Security = $this->Controller->TestSecurity; - $this->Controller->Security->blackHoleCallback = 'fail'; - $this->Security = $this->Controller->Security; - $this->Security->csrfCheck = false; - - Configure::write('Security.salt', 'foo!'); - } - -/** - * Tear-down method. Resets environment state. - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - $this->Controller->Session->delete('_Token'); - unset($this->Controller->Security); - unset($this->Controller->Component); - unset($this->Controller); - } - -/** - * test that initialize can set properties. - * - * @return void - */ - public function testConstructorSettingProperties() { - $settings = array( - 'requirePost' => array('edit', 'update'), - 'requireSecure' => array('update_account'), - 'requireGet' => array('index'), - 'validatePost' => false, - ); - $Security = new SecurityComponent($this->Controller->Components, $settings); - $this->Controller->Security->initialize($this->Controller, $settings); - $this->assertEquals($Security->requirePost, $settings['requirePost']); - $this->assertEquals($Security->requireSecure, $settings['requireSecure']); - $this->assertEquals($Security->requireGet, $settings['requireGet']); - $this->assertEquals($Security->validatePost, $settings['validatePost']); - } - -/** - * testStartup method - * - * @return void - */ - public function testStartup() { - $this->Controller->Security->startup($this->Controller); - $result = $this->Controller->params['_Token']['key']; - $this->assertNotNull($result); - $this->assertTrue($this->Controller->Session->check('_Token')); - } - -/** - * testRequirePostFail method - * - * @return void - */ - public function testRequirePostFail() { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requirePost(array('posted')); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - } - -/** - * testRequirePostSucceed method - * - * @return void - */ - public function testRequirePostSucceed() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requirePost('posted'); - $this->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequireSecureFail method - * - * @return void - */ - public function testRequireSecureFail() { - $_SERVER['HTTPS'] = 'off'; - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requireSecure(array('posted')); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - } - -/** - * testRequireSecureSucceed method - * - * @return void - */ - public function testRequireSecureSucceed() { - $_SERVER['REQUEST_METHOD'] = 'Secure'; - $this->Controller->request['action'] = 'posted'; - $_SERVER['HTTPS'] = 'on'; - $this->Controller->Security->requireSecure('posted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequireAuthFail method - * - * @return void - */ - public function testRequireAuthFail() { - $_SERVER['REQUEST_METHOD'] = 'AUTH'; - $this->Controller->request['action'] = 'posted'; - $this->Controller->request->data = array('username' => 'willy', 'password' => 'somePass'); - $this->Controller->Security->requireAuth(array('posted')); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - - $this->Controller->Session->write('_Token', array('allowedControllers' => array())); - $this->Controller->request->data = array('username' => 'willy', 'password' => 'somePass'); - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requireAuth('posted'); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - - $this->Controller->Session->write('_Token', array( - 'allowedControllers' => array('SecurityTest'), 'allowedActions' => array('posted2') - )); - $this->Controller->request->data = array('username' => 'willy', 'password' => 'somePass'); - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requireAuth('posted'); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - } - -/** - * testRequireAuthSucceed method - * - * @return void - */ - public function testRequireAuthSucceed() { - $_SERVER['REQUEST_METHOD'] = 'AUTH'; - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requireAuth('posted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - - $this->Controller->Security->Session->write('_Token', array( - 'allowedControllers' => array('SecurityTest'), 'allowedActions' => array('posted') - )); - $this->Controller->request['controller'] = 'SecurityTest'; - $this->Controller->request['action'] = 'posted'; - - $this->Controller->request->data = array( - 'username' => 'willy', 'password' => 'somePass', '_Token' => '' - ); - $this->Controller->action = 'posted'; - $this->Controller->Security->requireAuth('posted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequirePostSucceedWrongMethod method - * - * @return void - */ - public function testRequirePostSucceedWrongMethod() { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $this->Controller->request['action'] = 'getted'; - $this->Controller->Security->requirePost('posted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequireGetFail method - * - * @return void - */ - public function testRequireGetFail() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'getted'; - $this->Controller->Security->requireGet(array('getted')); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - } - -/** - * testRequireGetSucceed method - * - * @return void - */ - public function testRequireGetSucceed() { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $this->Controller->request['action'] = 'getted'; - $this->Controller->Security->requireGet('getted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequireGetSucceedWrongMethod method - * - * @return void - */ - public function testRequireGetSucceedWrongMethod() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'posted'; - $this->Security->requireGet('getted'); - $this->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequirePutFail method - * - * @return void - */ - public function testRequirePutFail() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'putted'; - $this->Controller->Security->requirePut(array('putted')); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - } - -/** - * testRequirePutSucceed method - * - * @return void - */ - public function testRequirePutSucceed() { - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $this->Controller->request['action'] = 'putted'; - $this->Controller->Security->requirePut('putted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequirePutSucceedWrongMethod method - * - * @return void - */ - public function testRequirePutSucceedWrongMethod() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requirePut('putted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequireDeleteFail method - * - * @return void - */ - public function testRequireDeleteFail() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'deleted'; - $this->Controller->Security->requireDelete(array('deleted', 'other_method')); - $this->Controller->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed); - } - -/** - * testRequireDeleteSucceed method - * - * @return void - */ - public function testRequireDeleteSucceed() { - $_SERVER['REQUEST_METHOD'] = 'DELETE'; - $this->Controller->request['action'] = 'deleted'; - $this->Controller->Security->requireDelete('deleted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * testRequireDeleteSucceedWrongMethod method - * - * @return void - */ - public function testRequireDeleteSucceedWrongMethod() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->Controller->request['action'] = 'posted'; - $this->Controller->Security->requireDelete('deleted'); - $this->Controller->Security->startup($this->Controller); - $this->assertFalse($this->Controller->failed); - } - -/** - * Simple hash validation test - * - * @return void - */ - public function testValidatePost() { - $this->Controller->Security->startup($this->Controller); - - $key = $this->Controller->request->params['_Token']['key']; - $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877%3AModel.valid'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array('username' => 'nate', 'password' => 'foo', 'valid' => '0'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $this->assertTrue($this->Controller->Security->validatePost($this->Controller)); - } - -/** - * Test that validatePost fails if you are missing the session information. - * - * @return void - */ - public function testValidatePostNoSession() { - $this->Controller->Security->startup($this->Controller); - $this->Controller->Session->delete('_Token'); - - $key = $this->Controller->params['_Token']['key']; - $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877%3AModel.valid'; - - $this->Controller->data = array( - 'Model' => array('username' => 'nate', 'password' => 'foo', 'valid' => '0'), - '_Token' => compact('key', 'fields') - ); - $this->assertFalse($this->Controller->Security->validatePost($this->Controller)); - } - -/** - * test that validatePost fails if any of its required fields are missing. - * - * @return void - */ - public function testValidatePostFormHacking() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->params['_Token']['key']; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array('username' => 'nate', 'password' => 'foo', 'valid' => '0'), - '_Token' => compact('key', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertFalse($result, 'validatePost passed when fields were missing. %s'); - } - -/** - * Test that objects can't be passed into the serialized string. This was a vector for RFI and LFI - * attacks. Thanks to Felix Wilhelm - * - * @return void - */ - public function testValidatePostObjectDeserialize() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877'; - $unlocked = ''; - - // a corrupted serialized object, so we can see if it ever gets to deserialize - $attack = 'O:3:"App":1:{s:5:"__map";a:1:{s:3:"foo";s:7:"Hacked!";s:1:"fail"}}'; - $fields .= urlencode(':' . str_rot13($attack)); - - $this->Controller->request->data = array( - 'Model' => array('username' => 'mark', 'password' => 'foo', 'valid' => '0'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertFalse($result, 'validatePost passed when key was missing. %s'); - } - -/** - * Tests validation of checkbox arrays - * - * @return void - */ - public function testValidatePostArray() { - $this->Controller->Security->startup($this->Controller); - - $key = $this->Controller->request->params['_Token']['key']; - $fields = 'f7d573650a295b94e0938d32b323fde775e5f32b%3A'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array('multi_field' => array('1', '3')), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $this->assertTrue($this->Controller->Security->validatePost($this->Controller)); - } - -/** - * testValidatePostNoModel method - * - * @return void - */ - public function testValidatePostNoModel() { - $this->Controller->Security->startup($this->Controller); - - $key = $this->Controller->request->params['_Token']['key']; - $fields = '540ac9c60d323c22bafe997b72c0790f39a8bdef%3A'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'anything' => 'some_data', - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testValidatePostSimple method - * - * @return void - */ - public function testValidatePostSimple() { - $this->Controller->Security->startup($this->Controller); - - $key = $this->Controller->request->params['_Token']['key']; - $fields = '69f493434187b867ea14b901fdf58b55d27c935d%3A'; - $unlocked = ''; - - $this->Controller->request->data = $data = array( - 'Model' => array('username' => '', 'password' => ''), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * Tests hash validation for multiple records, including locked fields - * - * @return void - */ - public function testValidatePostComplex() { - $this->Controller->Security->startup($this->Controller); - - $key = $this->Controller->request->params['_Token']['key']; - $fields = 'c9118120e680a7201b543f562e5301006ccfcbe2%3AAddresses.0.id%7CAddresses.1.id'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Addresses' => array( - '0' => array( - 'id' => '123456', 'title' => '', 'first_name' => '', 'last_name' => '', - 'address' => '', 'city' => '', 'phone' => '', 'primary' => '' - ), - '1' => array( - 'id' => '654321', 'title' => '', 'first_name' => '', 'last_name' => '', - 'address' => '', 'city' => '', 'phone' => '', 'primary' => '' - ) - ), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * test ValidatePost with multiple select elements. - * - * @return void - */ - public function testValidatePostMultipleSelect() { - $this->Controller->Security->startup($this->Controller); - - $key = $this->Controller->request->params['_Token']['key']; - $fields = '422cde416475abc171568be690a98cad20e66079%3A'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Tag' => array('Tag' => array(1, 2)), - '_Token' => compact('key', 'fields', 'unlocked'), - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - - $this->Controller->request->data = array( - 'Tag' => array('Tag' => array(1, 2, 3)), - '_Token' => compact('key', 'fields', 'unlocked'), - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - - $this->Controller->request->data = array( - 'Tag' => array('Tag' => array(1, 2, 3, 4)), - '_Token' => compact('key', 'fields', 'unlocked'), - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - - $fields = '19464422eafe977ee729c59222af07f983010c5f%3A'; - $this->Controller->request->data = array( - 'User.password' => 'bar', 'User.name' => 'foo', 'User.is_valid' => '1', - 'Tag' => array('Tag' => array(1)), - '_Token' => compact('key', 'fields', 'unlocked'), - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testValidatePostCheckbox method - * - * First block tests un-checked checkbox - * Second block tests checked checkbox - * - * @return void - */ - public function testValidatePostCheckbox() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877%3AModel.valid'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array('username' => '', 'password' => '', 'valid' => '0'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - - $fields = '874439ca69f89b4c4a5f50fb9c36ff56a28f5d42%3A'; - - $this->Controller->request->data = array( - 'Model' => array('username' => '', 'password' => '', 'valid' => '0'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - - $this->Controller->request->data = array(); - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - - $this->Controller->request->data = $data = array( - 'Model' => array('username' => '', 'password' => '', 'valid' => '0'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testValidatePostHidden method - * - * @return void - */ - public function testValidatePostHidden() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = '51ccd8cb0997c7b3d4523ecde5a109318405ef8c%3AModel.hidden%7CModel.other_hidden'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array( - 'username' => '', 'password' => '', 'hidden' => '0', - 'other_hidden' => 'some hidden value' - ), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testValidatePostWithDisabledFields method - * - * @return void - */ - public function testValidatePostWithDisabledFields() { - $this->Controller->Security->disabledFields = array('Model.username', 'Model.password'); - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = 'ef1082968c449397bcd849f963636864383278b1%3AModel.hidden'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array( - 'username' => '', 'password' => '', 'hidden' => '0' - ), - '_Token' => compact('fields', 'key', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * test validating post data with posted unlocked fields. - * - * @return void - */ - public function testValidatePostDisabledFieldsInData() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $unlocked = 'Model.username'; - $fields = array('Model.hidden', 'Model.password'); - $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Configure::read('Security.salt'))); - - $this->Controller->request->data = array( - 'Model' => array( - 'username' => 'mark', - 'password' => 'sekret', - 'hidden' => '0' - ), - '_Token' => compact('fields', 'key', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * test that missing 'unlocked' input causes failure - * - * @return void - */ - public function testValidatePostFailNoDisabled() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = array('Model.hidden', 'Model.password', 'Model.username'); - $fields = urlencode(Security::hash(serialize($fields) . Configure::read('Security.salt'))); - - $this->Controller->request->data = array( - 'Model' => array( - 'username' => 'mark', - 'password' => 'sekret', - 'hidden' => '0' - ), - '_Token' => compact('fields', 'key') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertFalse($result); - } - -/** - * Test that validatePost fails when unlocked fields are changed. - * - * @return - */ - public function testValidatePostFailDisabledFieldTampering() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $unlocked = 'Model.username'; - $fields = array('Model.hidden', 'Model.password'); - $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Configure::read('Security.salt'))); - - // Tamper the values. - $unlocked = 'Model.username|Model.password'; - - $this->Controller->request->data = array( - 'Model' => array( - 'username' => 'mark', - 'password' => 'sekret', - 'hidden' => '0' - ), - '_Token' => compact('fields', 'key', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertFalse($result); - } - -/** - * testValidateHiddenMultipleModel method - * - * @return void - */ - public function testValidateHiddenMultipleModel() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = 'a2d01072dc4660eea9d15007025f35a7a5b58e18%3AModel.valid%7CModel2.valid%7CModel3.valid'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array('username' => '', 'password' => '', 'valid' => '0'), - 'Model2' => array('valid' => '0'), - 'Model3' => array('valid' => '0'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testValidateHasManyModel method - * - * @return void - */ - public function testValidateHasManyModel() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = '51e3b55a6edd82020b3f29c9ae200e14bbeb7ee5%3AModel.0.hidden%7CModel.0.valid'; - $fields .= '%7CModel.1.hidden%7CModel.1.valid'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Model' => array( - array( - 'username' => 'username', 'password' => 'password', - 'hidden' => 'value', 'valid' => '0' - ), - array( - 'username' => 'username', 'password' => 'password', - 'hidden' => 'value', 'valid' => '0' - ) - ), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testValidateHasManyRecordsPass method - * - * @return void - */ - public function testValidateHasManyRecordsPass() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = '7a203edb3d345bbf38fe0dccae960da8842e11d7%3AAddress.0.id%7CAddress.0.primary%7C'; - $fields .= 'Address.1.id%7CAddress.1.primary'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Address' => array( - 0 => array( - 'id' => '123', - 'title' => 'home', - 'first_name' => 'Bilbo', - 'last_name' => 'Baggins', - 'address' => '23 Bag end way', - 'city' => 'the shire', - 'phone' => 'N/A', - 'primary' => '1', - ), - 1 => array( - 'id' => '124', - 'title' => 'home', - 'first_name' => 'Frodo', - 'last_name' => 'Baggins', - 'address' => '50 Bag end way', - 'city' => 'the shire', - 'phone' => 'N/A', - 'primary' => '1' - ) - ), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * Test that values like Foo.0.1 - * - * @return void - */ - public function testValidateNestedNumericSets() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $unlocked = ''; - $hashFields = array('TaxonomyData'); - $fields = urlencode(Security::hash(serialize($hashFields) . $unlocked . Configure::read('Security.salt'))); - - $this->Controller->request->data = array( - 'TaxonomyData' => array( - 1 => array(array(2)), - 2 => array(array(3)) - ), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testValidateHasManyRecords method - * - * validatePost should fail, hidden fields have been changed. - * - * @return void - */ - public function testValidateHasManyRecordsFail() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = '7a203edb3d345bbf38fe0dccae960da8842e11d7%3AAddress.0.id%7CAddress.0.primary%7C'; - $fields .= 'Address.1.id%7CAddress.1.primary'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'Address' => array( - 0 => array( - 'id' => '123', - 'title' => 'home', - 'first_name' => 'Bilbo', - 'last_name' => 'Baggins', - 'address' => '23 Bag end way', - 'city' => 'the shire', - 'phone' => 'N/A', - 'primary' => '5', - ), - 1 => array( - 'id' => '124', - 'title' => 'home', - 'first_name' => 'Frodo', - 'last_name' => 'Baggins', - 'address' => '50 Bag end way', - 'city' => 'the shire', - 'phone' => 'N/A', - 'primary' => '1' - ) - ), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertFalse($result); - } - -/** - * testFormDisabledFields method - * - * @return void - */ - public function testFormDisabledFields() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = '11842060341b9d0fc3808b90ba29fdea7054d6ad%3An%3A0%3A%7B%7D'; - $unlocked = ''; - - $this->Controller->request->data = array( - 'MyModel' => array('name' => 'some data'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertFalse($result); - - $this->Controller->Security->startup($this->Controller); - $this->Controller->Security->disabledFields = array('MyModel.name'); - $key = $this->Controller->request->params['_Token']['key']; - - $this->Controller->request->data = array( - 'MyModel' => array('name' => 'some data'), - '_Token' => compact('key', 'fields', 'unlocked') - ); - - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * testRadio method - * - * @return void - */ - public function testRadio() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - $fields = '575ef54ca4fc8cab468d6d898e9acd3a9671c17e%3An%3A0%3A%7B%7D'; - $unlocked = ''; - - $this->Controller->request->data = array( - '_Token' => compact('key', 'fields', 'unlocked') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertFalse($result); - - $this->Controller->request->data = array( - '_Token' => compact('key', 'fields', 'unlocked'), - 'Test' => array('test' => '') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - - $this->Controller->request->data = array( - '_Token' => compact('key', 'fields', 'unlocked'), - 'Test' => array('test' => '1') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - - $this->Controller->request->data = array( - '_Token' => compact('key', 'fields', 'unlocked'), - 'Test' => array('test' => '2') - ); - $result = $this->Controller->Security->validatePost($this->Controller); - $this->assertTrue($result); - } - -/** - * test that a requestAction's controller will have the _Token appended to - * the params. - * - * @return void - * @see http://cakephp.lighthouseapp.com/projects/42648/tickets/68 - */ - public function testSettingTokenForRequestAction() { - $this->Controller->Security->startup($this->Controller); - $key = $this->Controller->request->params['_Token']['key']; - - $this->Controller->params['requested'] = 1; - unset($this->Controller->request->params['_Token']); - - $this->Controller->Security->startup($this->Controller); - $this->assertEquals($this->Controller->request->params['_Token']['key'], $key); - } - -/** - * test that blackhole doesn't delete the _Token session key so repeat data submissions - * stay blackholed. - * - * @link http://cakephp.lighthouseapp.com/projects/42648/tickets/214 - * @return void - */ - public function testBlackHoleNotDeletingSessionInformation() { - $this->Controller->Security->startup($this->Controller); - - $this->Controller->Security->blackHole($this->Controller, 'auth'); - $this->assertTrue($this->Controller->Security->Session->check('_Token'), '_Token was deleted by blackHole %s'); - } - -/** - * test that csrf checks are skipped for request action. - * - * @return void - */ - public function testCsrfSkipRequestAction() { - $_SERVER['REQUEST_METHOD'] = 'POST'; - - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfExpires = '+10 minutes'; - $this->Controller->request->params['requested'] = 1; - $this->Security->startup($this->Controller); - - $this->assertFalse($this->Controller->failed, 'fail() was called.'); - } - -/** - * test setting - * - * @return void - */ - public function testCsrfSettings() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfExpires = '+10 minutes'; - $this->Security->startup($this->Controller); - - $token = $this->Security->Session->read('_Token'); - $this->assertEquals(1, count($token['csrfTokens']), 'Missing the csrf token.'); - $this->assertEquals(strtotime('+10 minutes'), current($token['csrfTokens']), 'Token expiry does not match'); - $this->assertEquals(array('key', 'unlockedFields'), array_keys($this->Controller->request->params['_Token']), 'Keys don not match'); - } - -/** - * Test setting multiple nonces, when startup() is called more than once, (ie more than one request.) - * - * @return void - */ - public function testCsrfSettingMultipleNonces() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfExpires = '+10 minutes'; - $csrfExpires = strtotime('+10 minutes'); - $this->Security->startup($this->Controller); - $this->Security->startup($this->Controller); - - $token = $this->Security->Session->read('_Token'); - $this->assertEquals(2, count($token['csrfTokens']), 'Missing the csrf token.'); - foreach ($token['csrfTokens'] as $key => $expires) { - $diff = $csrfExpires - $expires; - $this->assertTrue($diff === 0 || $diff === 1, 'Token expiry does not match'); - } - } - -/** - * test that nonces are consumed by form submits. - * - * @return void - */ - public function testCsrfNonceConsumption() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfExpires = '+10 minutes'; - - $this->Security->Session->write('_Token.csrfTokens', array('nonce1' => strtotime('+10 minutes'))); - - $this->Controller->request = $this->getMock('CakeRequest', array('is')); - $this->Controller->request->expects($this->once())->method('is') - ->with('post') - ->will($this->returnValue(true)); - - $this->Controller->request->params['action'] = 'index'; - $this->Controller->request->data = array( - '_Token' => array( - 'key' => 'nonce1' - ), - 'Post' => array( - 'title' => 'Woot' - ) - ); - $this->Security->startup($this->Controller); - $token = $this->Security->Session->read('_Token'); - $this->assertFalse(isset($token['csrfTokens']['nonce1']), 'Token was not consumed'); - } - -/** - * test that expired values in the csrfTokens are cleaned up. - * - * @return void - */ - public function testCsrfNonceVacuum() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfExpires = '+10 minutes'; - - $this->Security->Session->write('_Token.csrfTokens', array( - 'valid' => strtotime('+30 minutes'), - 'poof' => strtotime('-11 minutes'), - 'dust' => strtotime('-20 minutes') - )); - $this->Security->startup($this->Controller); - $tokens = $this->Security->Session->read('_Token.csrfTokens'); - $this->assertEquals(2, count($tokens), 'Too many tokens left behind'); - $this->assertNotEmpty('valid', $tokens, 'Valid token was removed.'); - } - -/** - * test that when the key is missing the request is blackHoled - * - * @return void - */ - public function testCsrfBlackHoleOnKeyMismatch() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfExpires = '+10 minutes'; - - $this->Security->Session->write('_Token.csrfTokens', array('nonce1' => strtotime('+10 minutes'))); - - $this->Controller->request = $this->getMock('CakeRequest', array('is')); - $this->Controller->request->expects($this->once())->method('is') - ->with('post') - ->will($this->returnValue(true)); - - $this->Controller->request->params['action'] = 'index'; - $this->Controller->request->data = array( - '_Token' => array( - 'key' => 'not the right value' - ), - 'Post' => array( - 'title' => 'Woot' - ) - ); - $this->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed, 'fail() was not called.'); - } - -/** - * test that when the key is missing the request is blackHoled - * - * @return void - */ - public function testCsrfBlackHoleOnExpiredKey() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfExpires = '+10 minutes'; - - $this->Security->Session->write('_Token.csrfTokens', array('nonce1' => strtotime('-5 minutes'))); - - $this->Controller->request = $this->getMock('CakeRequest', array('is')); - $this->Controller->request->expects($this->once())->method('is') - ->with('post') - ->will($this->returnValue(true)); - - $this->Controller->request->params['action'] = 'index'; - $this->Controller->request->data = array( - '_Token' => array( - 'key' => 'nonce1' - ), - 'Post' => array( - 'title' => 'Woot' - ) - ); - $this->Security->startup($this->Controller); - $this->assertTrue($this->Controller->failed, 'fail() was not called.'); - } - -/** - * test that csrfUseOnce = false works. - * - * @return void - */ - public function testCsrfNotUseOnce() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfUseOnce = false; - $this->Security->csrfExpires = '+10 minutes'; - - // Generate one token - $this->Security->startup($this->Controller); - $token = $this->Security->Session->read('_Token.csrfTokens'); - $this->assertEquals(1, count($token), 'Should only be one token.'); - - $this->Security->startup($this->Controller); - $tokenTwo = $this->Security->Session->read('_Token.csrfTokens'); - $this->assertEquals(1, count($tokenTwo), 'Should only be one token.'); - $this->assertEquals($token, $tokenTwo, 'Tokens should not be different.'); - - $key = $this->Controller->request->params['_Token']['key']; - $this->assertEquals(array($key), array_keys($token), '_Token.key and csrfToken do not match request will blackhole.'); - } - -/** - * ensure that longer session tokens are not consumed - * - * @return void - */ - public function testCsrfNotUseOnceValidationLeavingToken() { - $this->Security->validatePost = false; - $this->Security->csrfCheck = true; - $this->Security->csrfUseOnce = false; - $this->Security->csrfExpires = '+10 minutes'; - - $this->Security->Session->write('_Token.csrfTokens', array('nonce1' => strtotime('+10 minutes'))); - - $this->Controller->request = $this->getMock('CakeRequest', array('is')); - $this->Controller->request->expects($this->once())->method('is') - ->with('post') - ->will($this->returnValue(true)); - - $this->Controller->request->params['action'] = 'index'; - $this->Controller->request->data = array( - '_Token' => array( - 'key' => 'nonce1' - ), - 'Post' => array( - 'title' => 'Woot' - ) - ); - $this->Security->startup($this->Controller); - $token = $this->Security->Session->read('_Token'); - $this->assertTrue(isset($token['csrfTokens']['nonce1']), 'Token was consumed'); - } - -/** - * Test generateToken() - * - * @return void - */ - public function testGenerateToken() { - $request = $this->Controller->request; - $this->Security->generateToken($request); - - $this->assertNotEmpty($request->params['_Token']); - $this->assertTrue(isset($request->params['_Token']['unlockedFields'])); - $this->assertTrue(isset($request->params['_Token']['key'])); - } - -/** - * Test the limiting of CSRF tokens. - * - * @return void - */ - public function testCsrfLimit() { - $this->Security->csrfLimit = 3; - $time = strtotime('+10 minutes'); - $tokens = array( - '1' => $time, - '2' => $time, - '3' => $time, - '4' => $time, - '5' => $time, - ); - $this->Security->Session->write('_Token', array('csrfTokens' => $tokens)); - $this->Security->generateToken($this->Controller->request); - $result = $this->Security->Session->read('_Token.csrfTokens'); - - $this->assertFalse(isset($result['1'])); - $this->assertFalse(isset($result['2'])); - $this->assertFalse(isset($result['3'])); - $this->assertTrue(isset($result['4'])); - $this->assertTrue(isset($result['5'])); - } -} diff --git a/lib/Cake/Test/Case/Controller/Component/SessionComponentTest.php b/lib/Cake/Test/Case/Controller/Component/SessionComponentTest.php deleted file mode 100644 index 8e5af39aea8..00000000000 --- a/lib/Cake/Test/Case/Controller/Component/SessionComponentTest.php +++ /dev/null @@ -1,295 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller.Component - * @since CakePHP(tm) v 1.2.0.5436 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Controller', 'Controller'); -App::uses('SessionComponent', 'Controller/Component'); - -/** - * SessionTestController class - * - * @package Cake.Test.Case.Controller.Component - */ -class SessionTestController extends Controller { - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * sessionId method - * - * @return string - */ - public function sessionId() { - return $this->Session->id(); - } - -} - -/** - * OrangeSessionTestController class - * - * @package Cake.Test.Case.Controller.Component - */ -class OrangeSessionTestController extends Controller { - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * sessionId method - * - * @return string - */ - public function sessionId() { - return $this->Session->id(); - } - -} - -/** - * SessionComponentTest class - * - * @package Cake.Test.Case.Controller.Component - */ -class SessionComponentTest extends CakeTestCase { - - protected static $_sessionBackup; - -/** - * fixtures - * - * @var string - */ - public $fixtures = array('core.session'); - -/** - * test case startup - * - * @return void - */ - public static function setupBeforeClass() { - self::$_sessionBackup = Configure::read('Session'); - Configure::write('Session', array( - 'defaults' => 'php', - 'timeout' => 100, - 'cookie' => 'test' - )); - } - -/** - * cleanup after test case. - * - * @return void - */ - public static function teardownAfterClass() { - Configure::write('Session', self::$_sessionBackup); - } - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $_SESSION = null; - $this->ComponentCollection = new ComponentCollection(); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - CakeSession::destroy(); - } - -/** - * ensure that session ids don't change when request action is called. - * - * @return void - */ - public function testSessionIdConsistentAcrossRequestAction() { - $Session = new SessionComponent($this->ComponentCollection); - $Session->check('Test'); - $this->assertTrue(isset($_SESSION)); - - $Object = new Object(); - $Session = new SessionComponent($this->ComponentCollection); - $expected = $Session->id(); - - $result = $Object->requestAction('/session_test/sessionId'); - $this->assertEquals($expected, $result); - - $result = $Object->requestAction('/orange_session_test/sessionId'); - $this->assertEquals($expected, $result); - } - -/** - * testSessionValid method - * - * @return void - */ - public function testSessionValid() { - $Session = new SessionComponent($this->ComponentCollection); - - $this->assertTrue($Session->valid()); - - Configure::write('Session.checkAgent', true); - $Session->userAgent('rweerw'); - $this->assertFalse($Session->valid()); - - $Session = new SessionComponent($this->ComponentCollection); - $Session->time = $Session->read('Config.time') + 1; - $this->assertFalse($Session->valid()); - } - -/** - * testSessionError method - * - * @return void - */ - public function testSessionError() { - $Session = new SessionComponent($this->ComponentCollection); - $this->assertFalse($Session->error()); - } - -/** - * testSessionReadWrite method - * - * @return void - */ - public function testSessionReadWrite() { - $Session = new SessionComponent($this->ComponentCollection); - - $this->assertNull($Session->read('Test')); - - $this->assertTrue($Session->write('Test', 'some value')); - $this->assertEquals('some value', $Session->read('Test')); - $this->assertFalse($Session->write('Test.key', 'some value')); - $Session->delete('Test'); - - $this->assertTrue($Session->write('Test.key.path', 'some value')); - $this->assertEquals('some value', $Session->read('Test.key.path')); - $this->assertEquals(array('path' => 'some value'), $Session->read('Test.key')); - $this->assertTrue($Session->write('Test.key.path2', 'another value')); - $this->assertEquals(array('path' => 'some value', 'path2' => 'another value'), $Session->read('Test.key')); - $Session->delete('Test'); - - $array = array('key1' => 'val1', 'key2' => 'val2', 'key3' => 'val3'); - $this->assertTrue($Session->write('Test', $array)); - $this->assertEquals($Session->read('Test'), $array); - $Session->delete('Test'); - - $this->assertFalse($Session->write(array('Test'), 'some value')); - $this->assertTrue($Session->write(array('Test' => 'some value'))); - $this->assertEquals('some value', $Session->read('Test')); - $Session->delete('Test'); - } - -/** - * testSessionDelete method - * - * @return void - */ - public function testSessionDelete() { - $Session = new SessionComponent($this->ComponentCollection); - - $this->assertFalse($Session->delete('Test')); - - $Session->write('Test', 'some value'); - $this->assertTrue($Session->delete('Test')); - } - -/** - * testSessionCheck method - * - * @return void - */ - public function testSessionCheck() { - $Session = new SessionComponent($this->ComponentCollection); - - $this->assertFalse($Session->check('Test')); - - $Session->write('Test', 'some value'); - $this->assertTrue($Session->check('Test')); - $Session->delete('Test'); - } - -/** - * testSessionFlash method - * - * @return void - */ - public function testSessionFlash() { - $Session = new SessionComponent($this->ComponentCollection); - - $this->assertNull($Session->read('Message.flash')); - - $Session->setFlash('This is a test message'); - $this->assertEquals(array('message' => 'This is a test message', 'element' => 'default', 'params' => array()), $Session->read('Message.flash')); - - $Session->setFlash('This is a test message', 'test', array('name' => 'Joel Moss')); - $this->assertEquals(array('message' => 'This is a test message', 'element' => 'test', 'params' => array('name' => 'Joel Moss')), $Session->read('Message.flash')); - - $Session->setFlash('This is a test message', 'default', array(), 'myFlash'); - $this->assertEquals(array('message' => 'This is a test message', 'element' => 'default', 'params' => array()), $Session->read('Message.myFlash')); - - $Session->setFlash('This is a test message', 'non_existing_layout'); - $this->assertEquals(array('message' => 'This is a test message', 'element' => 'default', 'params' => array()), $Session->read('Message.myFlash')); - - $Session->delete('Message'); - } - -/** - * testSessionId method - * - * @return void - */ - public function testSessionId() { - unset($_SESSION); - $Session = new SessionComponent($this->ComponentCollection); - $Session->check('test'); - $this->assertEquals(session_id(), $Session->id()); - } - -/** - * testSessionDestroy method - * - * @return void - */ - public function testSessionDestroy() { - $Session = new SessionComponent($this->ComponentCollection); - - $Session->write('Test', 'some value'); - $this->assertEquals('some value', $Session->read('Test')); - $Session->destroy('Test'); - $this->assertNull($Session->read('Test')); - } - -} diff --git a/lib/Cake/Test/Case/Controller/ComponentCollectionTest.php b/lib/Cake/Test/Case/Controller/ComponentCollectionTest.php deleted file mode 100644 index e556263cc72..00000000000 --- a/lib/Cake/Test/Case/Controller/ComponentCollectionTest.php +++ /dev/null @@ -1,178 +0,0 @@ -Components = new ComponentCollection(); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - unset($this->Components); - parent::tearDown(); - } - -/** - * test triggering callbacks on loaded helpers - * - * @return void - */ - public function testLoad() { - $result = $this->Components->load('Cookie'); - $this->assertInstanceOf('CookieComponent', $result); - $this->assertInstanceOf('CookieComponent', $this->Components->Cookie); - - $result = $this->Components->attached(); - $this->assertEquals(array('Cookie'), $result, 'attached() results are wrong.'); - - $this->assertTrue($this->Components->enabled('Cookie')); - - $result = $this->Components->load('Cookie'); - $this->assertSame($result, $this->Components->Cookie); - } - -/** - * Tests loading as an alias - * - * @return void - */ - public function testLoadWithAlias() { - $result = $this->Components->load('Cookie', array('className' => 'CookieAlias', 'somesetting' => true)); - $this->assertInstanceOf('CookieAliasComponent', $result); - $this->assertInstanceOf('CookieAliasComponent', $this->Components->Cookie); - $this->assertTrue($this->Components->Cookie->settings['somesetting']); - - $result = $this->Components->attached(); - $this->assertEquals(array('Cookie'), $result, 'attached() results are wrong.'); - - $this->assertTrue($this->Components->enabled('Cookie')); - - $result = $this->Components->load('Cookie'); - $this->assertInstanceOf('CookieAliasComponent', $result); - - App::build(array('Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS))); - CakePlugin::load('TestPlugin'); - $result = $this->Components->load('SomeOther', array('className' => 'TestPlugin.Other')); - $this->assertInstanceOf('OtherComponent', $result); - $this->assertInstanceOf('OtherComponent', $this->Components->SomeOther); - - $result = $this->Components->attached(); - $this->assertEquals(array('Cookie', 'SomeOther'), $result, 'attached() results are wrong.'); - App::build(); - CakePlugin::unload(); - } - -/** - * test load and enable = false - * - * @return void - */ - public function testLoadWithEnableFalse() { - $result = $this->Components->load('Cookie', array('enabled' => false)); - $this->assertInstanceOf('CookieComponent', $result); - $this->assertInstanceOf('CookieComponent', $this->Components->Cookie); - - $this->assertFalse($this->Components->enabled('Cookie'), 'Cookie should be disabled'); - } - -/** - * test missingcomponent exception - * - * @expectedException MissingComponentException - * @return void - */ - public function testLoadMissingComponent() { - $this->Components->load('ThisComponentShouldAlwaysBeMissing'); - } - -/** - * test loading a plugin component. - * - * @return void - */ - public function testLoadPluginComponent() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - )); - CakePlugin::load('TestPlugin'); - $result = $this->Components->load('TestPlugin.Other'); - $this->assertInstanceOf('OtherComponent', $result, 'Component class is wrong.'); - $this->assertInstanceOf('OtherComponent', $this->Components->Other, 'Class is wrong'); - App::build(); - CakePlugin::unload(); - } - -/** - * test unload() - * - * @return void - */ - public function testUnload() { - $this->Components->load('Cookie'); - $this->Components->load('Security'); - - $result = $this->Components->attached(); - $this->assertEquals(array('Cookie', 'Security'), $result, 'loaded components is wrong'); - - $this->Components->unload('Cookie'); - $this->assertFalse(isset($this->Components->Cookie)); - $this->assertTrue(isset($this->Components->Security)); - - $result = $this->Components->attached(); - $this->assertEquals(array('Security'), $result, 'loaded components is wrong'); - - $result = $this->Components->enabled(); - $this->assertEquals(array('Security'), $result, 'enabled components is wrong'); - } - -/** - * test getting the controller out of the collection - * - * @return void - */ - public function testGetController() { - $controller = $this->getMock('Controller'); - $controller->components = array('Security'); - $this->Components->init($controller); - $result = $this->Components->getController(); - - $this->assertSame($controller, $result); - } -} diff --git a/lib/Cake/Test/Case/Controller/ComponentTest.php b/lib/Cake/Test/Case/Controller/ComponentTest.php deleted file mode 100644 index 482e304f7ba..00000000000 --- a/lib/Cake/Test/Case/Controller/ComponentTest.php +++ /dev/null @@ -1,314 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller - * @since CakePHP(tm) v 1.2.0.5436 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Controller', 'Controller'); -App::uses('Component', 'Controller'); - -/** - * ParamTestComponent - * - * @package Cake.Test.Case.Controller - */ -class ParamTestComponent extends Component { - -/** - * name property - * - * @var string 'ParamTest' - */ - public $name = 'ParamTest'; - -/** - * components property - * - * @var array - */ - public $components = array('Banana' => array('config' => 'value')); -} - -/** - * ComponentTestController class - * - * @package Cake.Test.Case.Controller - */ -class ComponentTestController extends Controller { - -/** - * name property - * - * @var string 'ComponentTest' - */ - public $name = 'ComponentTest'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -} - -/** - * AppleComponent class - * - * @package Cake.Test.Case.Controller - */ -class AppleComponent extends Component { - -/** - * components property - * - * @var array - */ - public $components = array('Orange'); - -/** - * testName property - * - * @var mixed null - */ - public $testName = null; - -/** - * startup method - * - * @param mixed $controller - * @return void - */ - public function startup(Controller $controller) { - $this->testName = $controller->name; - } - -} - -/** - * OrangeComponent class - * - * @package Cake.Test.Case.Controller - */ -class OrangeComponent extends Component { - -/** - * components property - * - * @var array - */ - public $components = array('Banana'); - -/** - * initialize method - * - * @param mixed $controller - * @return void - */ - public function initialize(Controller $controller) { - $this->Controller = $controller; - $this->Banana->testField = 'OrangeField'; - } - -/** - * startup method - * - * @param Controller $controller - * @return string - */ - public function startup(Controller $controller) { - $controller->foo = 'pass'; - } - -} - -/** - * BananaComponent class - * - * @package Cake.Test.Case.Controller - */ -class BananaComponent extends Component { - -/** - * testField property - * - * @var string 'BananaField' - */ - public $testField = 'BananaField'; - -/** - * startup method - * - * @param Controller $controller - * @return string - */ - public function startup(Controller $controller) { - $controller->bar = 'fail'; - } - -} - -/** - * MutuallyReferencingOneComponent class - * - * @package Cake.Test.Case.Controller - */ -class MutuallyReferencingOneComponent extends Component { - -/** - * components property - * - * @var array - */ - public $components = array('MutuallyReferencingTwo'); -} - -/** - * MutuallyReferencingTwoComponent class - * - * @package Cake.Test.Case.Controller - */ -class MutuallyReferencingTwoComponent extends Component { - -/** - * components property - * - * @var array - */ - public $components = array('MutuallyReferencingOne'); -} - -/** - * SomethingWithEmailComponent class - * - * @package Cake.Test.Case.Controller - */ -class SomethingWithEmailComponent extends Component { - -/** - * components property - * - * @var array - */ - public $components = array('Email'); -} - - -/** - * ComponentTest class - * - * @package Cake.Test.Case.Controller - */ -class ComponentTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->_pluginPaths = App::path('plugins'); - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - App::build(); - ClassRegistry::flush(); - } - -/** - * test accessing inner components. - * - * @return void - */ - public function testInnerComponentConstruction() { - $Collection = new ComponentCollection(); - $Component = new AppleComponent($Collection); - - $this->assertInstanceOf('OrangeComponent', $Component->Orange, 'class is wrong'); - } - -/** - * test component loading - * - * @return void - */ - public function testNestedComponentLoading() { - $Collection = new ComponentCollection(); - $Apple = new AppleComponent($Collection); - - $this->assertInstanceOf('OrangeComponent', $Apple->Orange, 'class is wrong'); - $this->assertInstanceOf('BananaComponent', $Apple->Orange->Banana, 'class is wrong'); - $this->assertTrue(empty($Apple->Session)); - $this->assertTrue(empty($Apple->Orange->Session)); - } - -/** - * test that component components are not enabled in the collection. - * - * @return void - */ - public function testInnerComponentsAreNotEnabled() { - $Collection = new ComponentCollection(); - $Apple = $Collection->load('Apple'); - - $this->assertInstanceOf('OrangeComponent', $Apple->Orange, 'class is wrong'); - $result = $Collection->enabled(); - $this->assertEquals(array('Apple'), $result, 'Too many components enabled.'); - } - -/** - * test a component being used more than once. - * - * @return void - */ - public function testMultipleComponentInitialize() { - $Collection = new ComponentCollection(); - $Banana = $Collection->load('Banana'); - $Orange = $Collection->load('Orange'); - - $this->assertSame($Banana, $Orange->Banana, 'Should be references'); - $Banana->testField = 'OrangeField'; - - $this->assertSame($Banana->testField, $Orange->Banana->testField, 'References are broken'); - } - -/** - * Test mutually referencing components. - * - * @return void - */ - public function testSomethingReferencingEmailComponent() { - $Controller = new ComponentTestController(); - $Controller->components = array('SomethingWithEmail'); - $Controller->uses = false; - $Controller->constructClasses(); - $Controller->Components->trigger('initialize', array(&$Controller)); - $Controller->beforeFilter(); - $Controller->Components->trigger('startup', array(&$Controller)); - - $this->assertInstanceOf('SomethingWithEmailComponent', $Controller->SomethingWithEmail); - $this->assertInstanceOf('EmailComponent', $Controller->SomethingWithEmail->Email); - } - -} diff --git a/lib/Cake/Test/Case/Controller/ControllerMergeVarsTest.php b/lib/Cake/Test/Case/Controller/ControllerMergeVarsTest.php deleted file mode 100644 index df96b2bee39..00000000000 --- a/lib/Cake/Test/Case/Controller/ControllerMergeVarsTest.php +++ /dev/null @@ -1,252 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller - * @since CakePHP(tm) v 1.2.3 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Controller', 'Controller'); - -/** - * Test case AppController - * - * @package Cake.Test.Case.Controller - * @package Cake.Test.Case.Controller - */ -class MergeVarsAppController extends Controller { - -/** - * components - * - * @var array - */ - public $components = array('MergeVar' => array('flag', 'otherFlag', 'redirect' => false)); - -/** - * helpers - * - * @var array - */ - public $helpers = array('MergeVar' => array('format' => 'html', 'terse')); -} - -/** - * MergeVar Component - * - * @package Cake.Test.Case.Controller - */ -class MergeVarComponent extends Object { - -} - -/** - * Additional controller for testing - * - * @package Cake.Test.Case.Controller - */ -class MergeVariablesController extends MergeVarsAppController { - -/** - * name - * - * @var string - */ - public $name = 'MergeVariables'; - -/** - * uses - * - * @var arrays - */ - public $uses = array(); - -/** - * parent for mergeVars - * - * @var string - */ - protected $_mergeParent = 'MergeVarsAppController'; -} - -/** - * MergeVarPlugin App Controller - * - * @package Cake.Test.Case.Controller - */ -class MergeVarPluginAppController extends MergeVarsAppController { - -/** - * components - * - * @var array - */ - public $components = array('Auth' => array('setting' => 'val', 'otherVal')); - -/** - * helpers - * - * @var array - */ - public $helpers = array('Javascript'); - -/** - * parent for mergeVars - * - * @var string - */ - protected $_mergeParent = 'MergeVarsAppController'; -} - -/** - * MergePostsController - * - * @package Cake.Test.Case.Controller - */ -class MergePostsController extends MergeVarPluginAppController { - -/** - * name - * - * @var string - */ - public $name = 'MergePosts'; - -/** - * uses - * - * @var array - */ - public $uses = array(); -} - - -/** - * Test Case for Controller Merging of Vars. - * - * @package Cake.Test.Case.Controller - */ -class ControllerMergeVarsTest extends CakeTestCase { - -/** - * test that component settings are not duplicated when merging component settings - * - * @return void - */ - public function testComponentParamMergingNoDuplication() { - $Controller = new MergeVariablesController(); - $Controller->constructClasses(); - - $expected = array('MergeVar' => array('flag', 'otherFlag', 'redirect' => false)); - $this->assertEquals($expected, $Controller->components, 'Duplication of settings occurred. %s'); - } - -/** - * test component merges with redeclared components - * - * @return void - */ - public function testComponentMergingWithRedeclarations() { - $Controller = new MergeVariablesController(); - $Controller->components['MergeVar'] = array('remote', 'redirect' => true); - $Controller->constructClasses(); - - $expected = array('MergeVar' => array('flag', 'otherFlag', 'redirect' => true, 'remote')); - $this->assertEquals($expected, $Controller->components, 'Merging of settings is wrong. %s'); - } - -/** - * test merging of helpers array, ensure no duplication occurs - * - * @return void - */ - public function testHelperSettingMergingNoDuplication() { - $Controller = new MergeVariablesController(); - $Controller->constructClasses(); - - $expected = array('MergeVar' => array('format' => 'html', 'terse')); - $this->assertEquals($expected, $Controller->helpers, 'Duplication of settings occurred. %s'); - } - -/** - * Test that helpers declared in appcontroller come before those in the subclass - * orderwise - * - * @return void - */ - public function testHelperOrderPrecedence() { - $Controller = new MergeVariablesController(); - $Controller->helpers = array('Custom', 'Foo' => array('something')); - $Controller->constructClasses(); - - $expected = array( - 'MergeVar' => array('format' => 'html', 'terse'), - 'Custom' => null, - 'Foo' => array('something') - ); - $this->assertSame($expected, $Controller->helpers, 'Order is incorrect.'); - } - -/** - * test merging of vars with plugin - * - * @return void - */ - public function testMergeVarsWithPlugin() { - $Controller = new MergePostsController(); - $Controller->components = array('Email' => array('ports' => 'open')); - $Controller->plugin = 'MergeVarPlugin'; - $Controller->constructClasses(); - - $expected = array( - 'MergeVar' => array('flag', 'otherFlag', 'redirect' => false), - 'Auth' => array('setting' => 'val', 'otherVal'), - 'Email' => array('ports' => 'open') - ); - $this->assertEquals($expected, $Controller->components, 'Components are unexpected.'); - - $expected = array( - 'MergeVar' => array('format' => 'html', 'terse'), - 'Javascript' => null - ); - $this->assertEquals($expected, $Controller->helpers, 'Helpers are unexpected.'); - - $Controller = new MergePostsController(); - $Controller->components = array(); - $Controller->plugin = 'MergeVarPlugin'; - $Controller->constructClasses(); - - $expected = array( - 'MergeVar' => array('flag', 'otherFlag', 'redirect' => false), - 'Auth' => array('setting' => 'val', 'otherVal'), - ); - $this->assertEquals($expected, $Controller->components, 'Components are unexpected.'); - } - -/** - * Ensure that _mergeControllerVars is not being greedy and merging with - * AppController when you make an instance of Controller - * - * @return void - */ - public function testMergeVarsNotGreedy() { - $Controller = new Controller(); - $Controller->components = array(); - $Controller->uses = array(); - $Controller->constructClasses(); - - $this->assertFalse(isset($Controller->Session)); - } -} diff --git a/lib/Cake/Test/Case/Controller/ControllerTest.php b/lib/Cake/Test/Case/Controller/ControllerTest.php deleted file mode 100644 index b2b0a1ba398..00000000000 --- a/lib/Cake/Test/Case/Controller/ControllerTest.php +++ /dev/null @@ -1,1409 +0,0 @@ - 'error_msg'); - -/** - * lastQuery property - * - * @var mixed null - */ - public $lastQuery = null; - -/** - * beforeFind method - * - * @param mixed $query - * @return void - */ - public function beforeFind($query) { - $this->lastQuery = $query; - } - -/** - * find method - * - * @param mixed $type - * @param array $options - * @return void - */ - public function find($type = 'first', $options = array()) { - if ($type == 'popular') { - $conditions = array($this->name . '.' . $this->primaryKey . ' > ' => '1'); - $options = Set::merge($options, compact('conditions')); - return parent::find('all', $options); - } - return parent::find($type, $options); - } - -} - -/** - * ControllerPostsController class - * - * @package Cake.Test.Case.Controller - */ -class ControllerCommentsController extends ControllerTestAppController { - -/** - * name property - * - * @var string 'ControllerPost' - */ - public $name = 'ControllerComments'; - - protected $_mergeParent = 'ControllerTestAppController'; -} - -/** - * ControllerComment class - * - * @package Cake.Test.Case.Controller - */ -class ControllerComment extends CakeTestModel { - -/** - * name property - * - * @var string 'ControllerComment' - */ - public $name = 'Comment'; - -/** - * useTable property - * - * @var string 'comments' - */ - public $useTable = 'comments'; - -/** - * data property - * - * @var array - */ - public $data = array('name' => 'Some Name'); - -/** - * alias property - * - * @var string 'ControllerComment' - */ - public $alias = 'ControllerComment'; -} - -/** - * ControllerAlias class - * - * @package Cake.Test.Case.Controller - */ -class ControllerAlias extends CakeTestModel { - -/** - * name property - * - * @var string 'ControllerAlias' - */ - public $name = 'ControllerAlias'; - -/** - * alias property - * - * @var string 'ControllerSomeAlias' - */ - public $alias = 'ControllerSomeAlias'; - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'posts'; -} - -/** - * NameTest class - * - * @package Cake.Test.Case.Controller - */ -class NameTest extends CakeTestModel { - -/** - * name property - * @var string 'Name' - */ - public $name = 'Name'; - -/** - * useTable property - * @var string 'names' - */ - public $useTable = 'comments'; - -/** - * alias property - * - * @var string 'ControllerComment' - */ - public $alias = 'Name'; -} - -/** - * TestController class - * - * @package Cake.Test.Case.Controller - */ -class TestController extends ControllerTestAppController { - -/** - * name property - * @var string 'Name' - */ - public $name = 'Test'; - -/** - * helpers property - * - * @var array - */ - public $helpers = array('Session'); - -/** - * components property - * - * @var array - */ - public $components = array('Security'); - -/** - * uses property - * - * @var array - */ - public $uses = array('ControllerComment', 'ControllerAlias'); - - protected $_mergeParent = 'ControllerTestAppController'; - -/** - * index method - * - * @param mixed $testId - * @param mixed $test2Id - * @return void - */ - public function index($testId, $testTwoId) { - $this->data = array( - 'testId' => $testId, - 'test2Id' => $testTwoId - ); - } - -/** - * view method - * - * @param mixed $testId - * @param mixed $test2Id - * @return void - */ - public function view($testId, $testTwoId) { - $this->data = array( - 'testId' => $testId, - 'test2Id' => $testTwoId - ); - } - - public function returner() { - return 'I am from the controller.'; - } - - protected function protected_m() { - } - - private function private_m() { - } - - public function _hidden() { - } - - public function admin_add() { - } - -} - -/** - * TestComponent class - * - * @package Cake.Test.Case.Controller - */ -class TestComponent extends Object { - -/** - * beforeRedirect method - * - * @return void - */ - public function beforeRedirect() { - } - -/** - * initialize method - * - * @return void - */ - public function initialize(Controller $controller) { - } - -/** - * startup method - * - * @return void - */ - public function startup(Controller $controller) { - } - -/** - * shutdown method - * - * @return void - */ - public function shutdown(Controller $controller) { - } - -/** - * beforeRender callback - * - * @return void - */ - public function beforeRender(Controller $controller) { - if ($this->viewclass) { - $controller->viewClass = $this->viewclass; - } - } - -} - -class Test2Component extends TestComponent { - - public function beforeRender(Controller $controller) { - return false; - } - -} - -/** - * AnotherTestController class - * - * @package Cake.Test.Case.Controller - */ -class AnotherTestController extends ControllerTestAppController { - -/** - * name property - * @var string 'Name' - */ - public $name = 'AnotherTest'; - -/** - * uses property - * - * @var array - */ - public $uses = false; - -/** - * merge parent - * - * @var string - */ - protected $_mergeParent = 'ControllerTestAppController'; -} - -/** - * ControllerTest class - * - * @package Cake.Test.Case.Controller - */ -class ControllerTest extends CakeTestCase { - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.post', 'core.comment', 'core.name'); - -/** - * reset environment. - * - * @return void - */ - public function setUp() { - parent::setUp(); - App::objects('plugin', null, false); - App::build(); - Router::reload(); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - CakePlugin::unload(); - App::build(); - parent::tearDown(); - } - -/** - * testLoadModel method - * - * @return void - */ - public function testLoadModel() { - $request = new CakeRequest('controller_posts/index'); - $response = $this->getMock('CakeResponse'); - $Controller = new Controller($request, $response); - - $this->assertFalse(isset($Controller->ControllerPost)); - - $result = $Controller->loadModel('ControllerPost'); - $this->assertTrue($result); - $this->assertTrue(is_a($Controller->ControllerPost, 'ControllerPost')); - $this->assertTrue(in_array('ControllerPost', $Controller->uses)); - - ClassRegistry::flush(); - unset($Controller); - } - -/** - * testLoadModel method from a plugin controller - * - * @return void - */ - public function testLoadModelInPlugins() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'Controller' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Controller' . DS), - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS) - )); - CakePlugin::load('TestPlugin'); - App::uses('TestPluginAppController', 'TestPlugin.Controller'); - App::uses('TestPluginController', 'TestPlugin.Controller'); - - $Controller = new TestPluginController(); - $Controller->plugin = 'TestPlugin'; - $Controller->uses = false; - - $this->assertFalse(isset($Controller->Comment)); - - $result = $Controller->loadModel('Comment'); - $this->assertTrue($result); - $this->assertInstanceOf('Comment', $Controller->Comment); - $this->assertTrue(in_array('Comment', $Controller->uses)); - - ClassRegistry::flush(); - unset($Controller); - } - -/** - * testConstructClasses method - * - * @return void - */ - public function testConstructClasses() { - $request = new CakeRequest('controller_posts/index'); - - $Controller = new Controller($request); - $Controller->uses = array('ControllerPost', 'ControllerComment'); - $Controller->constructClasses(); - $this->assertTrue(is_a($Controller->ControllerPost, 'ControllerPost')); - $this->assertTrue(is_a($Controller->ControllerComment, 'ControllerComment')); - - $this->assertEquals('Comment', $Controller->ControllerComment->name); - - unset($Controller); - - App::build(array('Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS))); - CakePlugin::load('TestPlugin'); - - $Controller = new Controller($request); - $Controller->uses = array('TestPlugin.TestPluginPost'); - $Controller->constructClasses(); - - $this->assertTrue(isset($Controller->TestPluginPost)); - $this->assertTrue(is_a($Controller->TestPluginPost, 'TestPluginPost')); - } - -/** - * testAliasName method - * - * @return void - */ - public function testAliasName() { - $request = new CakeRequest('controller_posts/index'); - $Controller = new Controller($request); - $Controller->uses = array('NameTest'); - $Controller->constructClasses(); - - $this->assertEquals('Name', $Controller->NameTest->name); - $this->assertEquals('Name', $Controller->NameTest->alias); - - unset($Controller); - } - -/** - * testFlash method - * - * @return void - */ - public function testFlash() { - $request = new CakeRequest('controller_posts/index'); - $request->webroot = '/'; - $request->base = '/'; - - $Controller = new Controller($request, $this->getMock('CakeResponse', array('_sendHeader'))); - $Controller->flash('this should work', '/flash'); - $result = $Controller->response->body(); - - $expected = ' - - - - this should work - - - -

this should work

- - '; - $result = str_replace(array("\t", "\r\n", "\n"), "", $result); - $expected = str_replace(array("\t", "\r\n", "\n"), "", $expected); - $this->assertEquals($expected, $result); - - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - $Controller = new Controller($request); - $Controller->response = $this->getMock('CakeResponse', array('_sendHeader')); - $Controller->flash('this should work', '/flash', 1, 'ajax2'); - $result = $Controller->response->body(); - $this->assertRegExp('/Ajax!/', $result); - App::build(); - } - -/** - * testControllerSet method - * - * @return void - */ - public function testControllerSet() { - $request = new CakeRequest('controller_posts/index'); - $Controller = new Controller($request); - - $Controller->set('variable_with_underscores', null); - $this->assertTrue(array_key_exists('variable_with_underscores', $Controller->viewVars)); - - $Controller->viewVars = array(); - $viewVars = array('ModelName' => array('id' => 1, 'name' => 'value')); - $Controller->set($viewVars); - $this->assertTrue(array_key_exists('ModelName', $Controller->viewVars)); - - $Controller->viewVars = array(); - $Controller->set('variable_with_underscores', 'value'); - $this->assertTrue(array_key_exists('variable_with_underscores', $Controller->viewVars)); - - $Controller->viewVars = array(); - $viewVars = array('ModelName' => 'name'); - $Controller->set($viewVars); - $this->assertTrue(array_key_exists('ModelName', $Controller->viewVars)); - - $Controller->set('title', 'someTitle'); - $this->assertSame($Controller->viewVars['title'], 'someTitle'); - $this->assertTrue(empty($Controller->pageTitle)); - - $Controller->viewVars = array(); - $expected = array('ModelName' => 'name', 'ModelName2' => 'name2'); - $Controller->set(array('ModelName', 'ModelName2'), array('name', 'name2')); - $this->assertSame($expected, $Controller->viewVars); - - $Controller->viewVars = array(); - $Controller->set(array(3 => 'three', 4 => 'four')); - $Controller->set(array(1 => 'one', 2 => 'two')); - $expected = array(3 => 'three', 4 => 'four', 1 => 'one', 2 => 'two'); - $this->assertEquals($expected, $Controller->viewVars); - } - -/** - * testRender method - * - * @return void - */ - public function testRender() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - ), App::RESET); - ClassRegistry::flush(); - $request = new CakeRequest('controller_posts/index'); - $request->params['action'] = 'index'; - - $Controller = new Controller($request, new CakeResponse()); - $Controller->viewPath = 'Posts'; - - $result = $Controller->render('index'); - $this->assertRegExp('/posts index/', (string)$result); - - $Controller->view = 'index'; - $result = $Controller->render(); - $this->assertRegExp('/posts index/', (string)$result); - - $result = $Controller->render('/Elements/test_element'); - $this->assertRegExp('/this is the test element/', (string)$result); - $Controller->view = null; - - $Controller = new TestController($request, new CakeResponse()); - $Controller->uses = array('ControllerAlias', 'TestPlugin.ControllerComment', 'ControllerPost'); - $Controller->helpers = array('Html'); - $Controller->constructClasses(); - $Controller->ControllerComment->validationErrors = array('title' => 'tooShort'); - $expected = $Controller->ControllerComment->validationErrors; - - $Controller->viewPath = 'Posts'; - $result = $Controller->render('index'); - $View = $Controller->View; - $this->assertTrue(isset($View->validationErrors['ControllerComment'])); - $this->assertEquals($expected, $View->validationErrors['ControllerComment']); - - $expectedModels = array( - 'ControllerAlias' => array('plugin' => null, 'className' => 'ControllerAlias'), - 'ControllerComment' => array('plugin' => 'TestPlugin', 'className' => 'ControllerComment'), - 'ControllerPost' => array('plugin' => null, 'className' => 'ControllerPost') - ); - $this->assertEquals($expectedModels, $Controller->request->params['models']); - - ClassRegistry::flush(); - App::build(); - } - -/** - * test that a component beforeRender can change the controller view class. - * - * @return void - */ - public function testComponentBeforeRenderChangingViewClass() { - App::build(array( - 'View' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS - ) - ), true); - $Controller = new Controller($this->getMock('CakeRequest'), new CakeResponse()); - $Controller->uses = array(); - $Controller->components = array('Test'); - $Controller->constructClasses(); - $Controller->Test->viewclass = 'Theme'; - $Controller->viewPath = 'Posts'; - $Controller->theme = 'TestTheme'; - $result = $Controller->render('index'); - $this->assertRegExp('/default test_theme layout/', (string)$result); - App::build(); - } - -/** - * test that a component beforeRender can change the controller view class. - * - * @return void - */ - public function testComponentCancelRender() { - $Controller = new Controller($this->getMock('CakeRequest'), new CakeResponse()); - $Controller->uses = array(); - $Controller->components = array('Test2'); - $Controller->constructClasses(); - $result = $Controller->render('index'); - $this->assertInstanceOf('CakeResponse', $result); - } - -/** - * testToBeInheritedGuardmethods method - * - * @return void - */ - public function testToBeInheritedGuardmethods() { - $request = new CakeRequest('controller_posts/index'); - - $Controller = new Controller($request, $this->getMock('CakeResponse')); - $this->assertTrue($Controller->beforeScaffold('')); - $this->assertTrue($Controller->afterScaffoldSave('')); - $this->assertTrue($Controller->afterScaffoldSaveError('')); - $this->assertFalse($Controller->scaffoldError('')); - } - -/** - * Generates status codes for redirect test. - * - * @return void - */ - public static function statusCodeProvider() { - return array( - array(300, "Multiple Choices"), - array(301, "Moved Permanently"), - array(302, "Found"), - array(303, "See Other"), - array(304, "Not Modified"), - array(305, "Use Proxy"), - array(307, "Temporary Redirect") - ); - } - -/** - * testRedirect method - * - * @dataProvider statusCodeProvider - * @return void - */ - public function testRedirectByCode($code, $msg) { - $Controller = new Controller(null); - $Controller->response = $this->getMock('CakeResponse', array('header', 'statusCode')); - - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $Controller->response->expects($this->once())->method('statusCode') - ->with($code); - $Controller->response->expects($this->once())->method('header') - ->with('Location', 'http://cakephp.org'); - - $Controller->redirect('http://cakephp.org', (int)$code, false); - $this->assertFalse($Controller->autoRender); - } - -/** - * test redirecting by message - * - * @dataProvider statusCodeProvider - * @return void - */ - public function testRedirectByMessage($code, $msg) { - $Controller = new Controller(null); - $Controller->response = $this->getMock('CakeResponse', array('header', 'statusCode')); - - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $Controller->response->expects($this->once())->method('statusCode') - ->with($code); - - $Controller->response->expects($this->once())->method('header') - ->with('Location', 'http://cakephp.org'); - - $Controller->redirect('http://cakephp.org', $msg, false); - $this->assertFalse($Controller->autoRender); - } - -/** - * test that redirect triggers methods on the components. - * - * @return void - */ - public function testRedirectTriggeringComponentsReturnNull() { - $Controller = new Controller(null); - $Controller->response = $this->getMock('CakeResponse', array('header', 'statusCode')); - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $Controller->Components->expects($this->once())->method('trigger') - ->will($this->returnValue(null)); - - $Controller->response->expects($this->once())->method('statusCode') - ->with(301); - - $Controller->response->expects($this->once())->method('header') - ->with('Location', 'http://cakephp.org'); - - $Controller->redirect('http://cakephp.org', 301, false); - } - -/** - * test that beforeRedirect callback returning null doesn't affect things. - * - * @return void - */ - public function testRedirectBeforeRedirectModifyingParams() { - $Controller = new Controller(null); - $Controller->response = $this->getMock('CakeResponse', array('header', 'statusCode')); - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $Controller->Components->expects($this->once())->method('trigger') - ->will($this->returnValue(array('http://book.cakephp.org'))); - - $Controller->response->expects($this->once())->method('statusCode') - ->with(301); - - $Controller->response->expects($this->once())->method('header') - ->with('Location', 'http://book.cakephp.org'); - - $Controller->redirect('http://cakephp.org', 301, false); - } - -/** - * test that beforeRedirect callback returning null doesn't affect things. - * - * @return void - */ - public function testRedirectBeforeRedirectModifyingParamsArrayReturn() { - $Controller = $this->getMock('Controller', array('header', '_stop')); - $Controller->response = $this->getMock('CakeResponse'); - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $return = array( - array( - 'url' => 'http://example.com/test/1', - 'exit' => false, - 'status' => 302 - ), - array( - 'url' => 'http://example.com/test/2', - ), - ); - $Controller->Components->expects($this->once())->method('trigger') - ->will($this->returnValue($return)); - - $Controller->response->expects($this->once())->method('header') - ->with('Location', 'http://example.com/test/2'); - - $Controller->response->expects($this->at(1))->method('statusCode') - ->with(302); - - $Controller->expects($this->never())->method('_stop'); - $Controller->redirect('http://cakephp.org', 301); - } - -/** - * test that beforeRedirect callback returning false in controller - * - * @return void - */ - public function testRedirectBeforeRedirectInController() { - $Controller = $this->getMock('Controller', array('_stop', 'beforeRedirect')); - $Controller->response = $this->getMock('CakeResponse', array('header')); - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $Controller->expects($this->once())->method('beforeRedirect') - ->with('http://cakephp.org') - ->will($this->returnValue(false)); - $Controller->response->expects($this->never())->method('header'); - $Controller->expects($this->never())->method('_stop'); - $Controller->redirect('http://cakephp.org'); - } - -/** - * testMergeVars method - * - * @return void - */ - public function testMergeVars() { - $request = new CakeRequest('controller_posts/index'); - - $TestController = new TestController($request); - $TestController->constructClasses(); - - $testVars = get_class_vars('TestController'); - $appVars = get_class_vars('ControllerTestAppController'); - - $components = is_array($appVars['components']) - ? array_merge($appVars['components'], $testVars['components']) - : $testVars['components']; - if (!in_array('Session', $components)) { - $components[] = 'Session'; - } - $helpers = is_array($appVars['helpers']) - ? array_merge($appVars['helpers'], $testVars['helpers']) - : $testVars['helpers']; - $uses = is_array($appVars['uses']) - ? array_merge($appVars['uses'], $testVars['uses']) - : $testVars['uses']; - - $this->assertEquals(0, count(array_diff_key($TestController->helpers, array_flip($helpers)))); - $this->assertEquals(0, count(array_diff($TestController->uses, $uses))); - $this->assertEquals(count(array_diff_assoc(Set::normalize($TestController->components), Set::normalize($components))), 0); - - $expected = array('ControllerComment', 'ControllerAlias', 'ControllerPost'); - $this->assertEquals($expected, $TestController->uses, '$uses was merged incorrectly, ControllerTestAppController models should be last.'); - - $TestController = new AnotherTestController($request); - $TestController->constructClasses(); - - $appVars = get_class_vars('ControllerTestAppController'); - $testVars = get_class_vars('AnotherTestController'); - - $this->assertTrue(in_array('ControllerPost', $appVars['uses'])); - $this->assertFalse($testVars['uses']); - - $this->assertFalse(property_exists($TestController, 'ControllerPost')); - - $TestController = new ControllerCommentsController($request); - $TestController->constructClasses(); - - $appVars = get_class_vars('ControllerTestAppController'); - $testVars = get_class_vars('ControllerCommentsController'); - - $this->assertTrue(in_array('ControllerPost', $appVars['uses'])); - $this->assertEquals(array('ControllerPost'), $testVars['uses']); - - $this->assertTrue(isset($TestController->ControllerPost)); - $this->assertTrue(isset($TestController->ControllerComment)); - } - -/** - * test that options from child classes replace those in the parent classes. - * - * @return void - */ - public function testChildComponentOptionsSupercedeParents() { - $request = new CakeRequest('controller_posts/index'); - - $TestController = new TestController($request); - - $expected = array('foo'); - $TestController->components = array('Cookie' => $expected); - $TestController->constructClasses(); - $this->assertEquals($expected, $TestController->components['Cookie']); - } - -/** - * Ensure that _mergeControllerVars is not being greedy and merging with - * ControllerTestAppController when you make an instance of Controller - * - * @return void - */ - public function testMergeVarsNotGreedy() { - $request = new CakeRequest('controller_posts/index'); - - $Controller = new Controller($request); - $Controller->components = array(); - $Controller->uses = array(); - $Controller->constructClasses(); - - $this->assertFalse(isset($Controller->Session)); - } - -/** - * testReferer method - * - * @return void - */ - public function testReferer() { - $request = $this->getMock('CakeRequest'); - - $request->expects($this->any())->method('referer') - ->with(true) - ->will($this->returnValue('/posts/index')); - - $Controller = new Controller($request); - $result = $Controller->referer(null, true); - $this->assertEquals('/posts/index', $result); - - $Controller = new Controller($request); - $request->setReturnValue('referer', '/', array(true)); - $result = $Controller->referer(array('controller' => 'posts', 'action' => 'index'), true); - $this->assertEquals('/posts/index', $result); - - $request = $this->getMock('CakeRequest'); - - $request->expects($this->any())->method('referer') - ->with(false) - ->will($this->returnValue('http://localhost/posts/index')); - - $Controller = new Controller($request); - $result = $Controller->referer(); - $this->assertEquals('http://localhost/posts/index', $result); - - $Controller = new Controller(null); - $result = $Controller->referer(); - $this->assertEquals('/', $result); - } - -/** - * testSetAction method - * - * @return void - */ - public function testSetAction() { - $request = new CakeRequest('controller_posts/index'); - - $TestController = new TestController($request); - $TestController->setAction('view', 1, 2); - $expected = array('testId' => 1, 'test2Id' => 2); - $this->assertSame($expected, $TestController->request->data); - $this->assertSame('view', $TestController->request->params['action']); - $this->assertSame('view', $TestController->view); - } - -/** - * testValidateErrors method - * - * @return void - */ - public function testValidateErrors() { - ClassRegistry::flush(); - $request = new CakeRequest('controller_posts/index'); - - $TestController = new TestController($request); - $TestController->constructClasses(); - $this->assertFalse($TestController->validateErrors()); - $this->assertEquals(0, $TestController->validate()); - - $TestController->ControllerComment->invalidate('some_field', 'error_message'); - $TestController->ControllerComment->invalidate('some_field2', 'error_message2'); - - $comment = new ControllerComment($request); - $comment->set('someVar', 'data'); - $result = $TestController->validateErrors($comment); - $expected = array('some_field' => array('error_message'), 'some_field2' => array('error_message2')); - $this->assertSame($expected, $result); - $this->assertEquals(2, $TestController->validate($comment)); - } - -/** - * test that validateErrors works with any old model. - * - * @return void - */ - public function testValidateErrorsOnArbitraryModels() { - $TestController = new TestController(); - - $Post = new ControllerPost(); - $Post->validate = array('title' => 'notEmpty'); - $Post->set('title', ''); - $result = $TestController->validateErrors($Post); - - $expected = array('title' => array('This field cannot be left blank')); - $this->assertEquals($expected, $result); - } - -/** - * testPostConditions method - * - * @return void - */ - public function testPostConditions() { - $request = new CakeRequest('controller_posts/index'); - - $Controller = new Controller($request); - - $data = array( - 'Model1' => array('field1' => '23'), - 'Model2' => array('field2' => 'string'), - 'Model3' => array('field3' => '23'), - ); - $expected = array( - 'Model1.field1' => '23', - 'Model2.field2' => 'string', - 'Model3.field3' => '23', - ); - $result = $Controller->postConditions($data); - $this->assertSame($expected, $result); - - $data = array(); - $Controller->data = array( - 'Model1' => array('field1' => '23'), - 'Model2' => array('field2' => 'string'), - 'Model3' => array('field3' => '23'), - ); - $expected = array( - 'Model1.field1' => '23', - 'Model2.field2' => 'string', - 'Model3.field3' => '23', - ); - $result = $Controller->postConditions($data); - $this->assertSame($expected, $result); - - $data = array(); - $Controller->data = array(); - $result = $Controller->postConditions($data); - $this->assertNull($result); - - $data = array(); - $Controller->data = array( - 'Model1' => array('field1' => '23'), - 'Model2' => array('field2' => 'string'), - 'Model3' => array('field3' => '23'), - ); - $ops = array( - 'Model1.field1' => '>', - 'Model2.field2' => 'LIKE', - 'Model3.field3' => '<=', - ); - $expected = array( - 'Model1.field1 >' => '23', - 'Model2.field2 LIKE' => "%string%", - 'Model3.field3 <=' => '23', - ); - $result = $Controller->postConditions($data, $ops); - $this->assertSame($expected, $result); - } - -/** - * testControllerHttpCodes method - * - * @return void - */ - public function testControllerHttpCodes() { - $response = $this->getMock('CakeResponse', array('httpCodes')); - $Controller = new Controller(null, $response); - $Controller->response->expects($this->at(0))->method('httpCodes')->with(null); - $Controller->response->expects($this->at(1))->method('httpCodes')->with(100); - $Controller->httpCodes(); - $Controller->httpCodes(100); - } - -/** - * Tests that the startup process calls the correct functions - * - * @return void - */ - public function testStartupProcess() { - $Controller = $this->getMock('Controller', array('getEventManager')); - - $eventManager = $this->getMock('CakeEventManager'); - $eventManager->expects($this->at(0))->method('dispatch') - ->with( - $this->logicalAnd( - $this->isInstanceOf('CakeEvent'), - $this->attributeEqualTo('_name', 'Controller.initialize'), - $this->attributeEqualTo('_subject', $Controller) - ) - ); - $eventManager->expects($this->at(1))->method('dispatch') - ->with( - $this->logicalAnd( - $this->isInstanceOf('CakeEvent'), - $this->attributeEqualTo('_name', 'Controller.startup'), - $this->attributeEqualTo('_subject', $Controller) - ) - ); - $Controller->expects($this->exactly(2))->method('getEventManager') - ->will($this->returnValue($eventManager)); - $Controller->startupProcess(); - } - -/** - * Tests that the shutdown process calls the correct functions - * - * @return void - */ - public function testStartupProcessIndirect() { - $Controller = $this->getMock('Controller', array('beforeFilter')); - - $Controller->components = array('MockShutdown'); - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $Controller->expects($this->once())->method('beforeFilter'); - $Controller->Components->expects($this->exactly(2))->method('trigger')->with($this->isInstanceOf('CakeEvent')); - - $Controller->startupProcess(); - } - -/** - * Tests that the shutdown process calls the correct functions - * - * @return void - */ - public function testShutdownProcess() { - $Controller = $this->getMock('Controller', array('getEventManager')); - - $eventManager = $this->getMock('CakeEventManager'); - $eventManager->expects($this->once())->method('dispatch') - ->with( - $this->logicalAnd( - $this->isInstanceOf('CakeEvent'), - $this->attributeEqualTo('_name', 'Controller.shutdown'), - $this->attributeEqualTo('_subject', $Controller) - ) - ); - $Controller->expects($this->once())->method('getEventManager') - ->will($this->returnValue($eventManager)); - $Controller->shutdownProcess(); - } - -/** - * Tests that the shutdown process calls the correct functions - * - * @return void - */ - public function testShutdownProcessIndirect() { - $Controller = $this->getMock('Controller', array('afterFilter')); - - $Controller->components = array('MockShutdown'); - $Controller->Components = $this->getMock('ComponentCollection', array('trigger')); - - $Controller->expects($this->once())->method('afterFilter'); - $Controller->Components->expects($this->exactly(1))->method('trigger')->with($this->isInstanceOf('CakeEvent')); - - $Controller->shutdownProcess(); - } - -/** - * test that BC works for attributes on the request object. - * - * @return void - */ - public function testPropertyBackwardsCompatibility() { - $request = new CakeRequest('posts/index', null); - $request->addParams(array('controller' => 'posts', 'action' => 'index')); - $request->data = array('Post' => array('id' => 1)); - $request->here = '/posts/index'; - $request->webroot = '/'; - - $Controller = new TestController($request); - $this->assertEquals($request->data, $Controller->data); - $this->assertEquals($request->webroot, $Controller->webroot); - $this->assertEquals($request->here, $Controller->here); - $this->assertEquals($request->action, $Controller->action); - - $this->assertFalse(empty($Controller->data)); - $this->assertTrue(isset($Controller->data)); - $this->assertTrue(empty($Controller->something)); - $this->assertFalse(isset($Controller->something)); - - $this->assertEquals($request, $Controller->params); - $this->assertEquals($request->params['controller'], $Controller->params['controller']); - } - -/** - * test that the BC wrapper doesn't interfere with models and components. - * - * @return void - */ - public function testPropertyCompatibilityAndModelsComponents() { - $request = new CakeRequest('controller_posts/index'); - - $Controller = new TestController($request); - $Controller->constructClasses(); - $this->assertInstanceOf('SecurityComponent', $Controller->Security); - $this->assertInstanceOf('ControllerComment', $Controller->ControllerComment); - } - -/** - * test that using Controller::paginate() falls back to PaginatorComponent - * - * @return void - */ - public function testPaginateBackwardsCompatibility() { - $request = new CakeRequest('controller_posts/index'); - $request->params['pass'] = $request->params['named'] = array(); - $response = $this->getMock('CakeResponse', array('httpCodes')); - - $Controller = new Controller($request, $response); - $Controller->uses = array('ControllerPost', 'ControllerComment'); - $Controller->passedArgs[] = '1'; - $Controller->params['url'] = array(); - $Controller->constructClasses(); - $expected = array('page' => 1, 'limit' => 20, 'maxLimit' => 100, 'paramType' => 'named'); - $this->assertEquals($expected, $Controller->paginate); - - $results = Set::extract($Controller->paginate('ControllerPost'), '{n}.ControllerPost.id'); - $this->assertEquals(array(1, 2, 3), $results); - - $Controller->passedArgs = array(); - $Controller->paginate = array('limit' => '-1'); - $this->assertEquals(array('limit' => '-1'), $Controller->paginate); - $Controller->paginate('ControllerPost'); - $this->assertSame($Controller->params['paging']['ControllerPost']['page'], 1); - $this->assertSame($Controller->params['paging']['ControllerPost']['pageCount'], 3); - $this->assertSame($Controller->params['paging']['ControllerPost']['prevPage'], false); - $this->assertSame($Controller->params['paging']['ControllerPost']['nextPage'], true); - } - -/** - * testMissingAction method - * - * @expectedException MissingActionException - * @expectedExceptionMessage Action TestController::missing() could not be found. - * @return void - */ - public function testInvokeActionMissingAction() { - $url = new CakeRequest('test/missing'); - $url->addParams(array('controller' => 'test_controller', 'action' => 'missing')); - $response = $this->getMock('CakeResponse'); - - $Controller = new TestController($url, $response); - $Controller->invokeAction($url); - } - -/** - * test invoking private methods. - * - * @expectedException PrivateActionException - * @expectedExceptionMessage Private Action TestController::private_m() is not directly accessible. - * @return void - */ - public function testInvokeActionPrivate() { - $url = new CakeRequest('test/private_m/'); - $url->addParams(array('controller' => 'test_controller', 'action' => 'private_m')); - $response = $this->getMock('CakeResponse'); - - $Controller = new TestController($url, $response); - $Controller->invokeAction($url); - } - -/** - * test invoking protected methods. - * - * @expectedException PrivateActionException - * @expectedExceptionMessage Private Action TestController::protected_m() is not directly accessible. - * @return void - */ - public function testInvokeActionProtected() { - $url = new CakeRequest('test/protected_m/'); - $url->addParams(array('controller' => 'test_controller', 'action' => 'protected_m')); - $response = $this->getMock('CakeResponse'); - - $Controller = new TestController($url, $response); - $Controller->invokeAction($url); - } - -/** - * test invoking hidden methods. - * - * @expectedException PrivateActionException - * @expectedExceptionMessage Private Action TestController::_hidden() is not directly accessible. - * @return void - */ - public function testInvokeActionHidden() { - $url = new CakeRequest('test/_hidden/'); - $url->addParams(array('controller' => 'test_controller', 'action' => '_hidden')); - $response = $this->getMock('CakeResponse'); - - $Controller = new TestController($url, $response); - $Controller->invokeAction($url); - } - -/** - * test invoking controller methods. - * - * @expectedException PrivateActionException - * @expectedExceptionMessage Private Action TestController::redirect() is not directly accessible. - * @return void - */ - public function testInvokeActionBaseMethods() { - $url = new CakeRequest('test/redirect/'); - $url->addParams(array('controller' => 'test_controller', 'action' => 'redirect')); - $response = $this->getMock('CakeResponse'); - - $Controller = new TestController($url, $response); - $Controller->invokeAction($url); - } - -/** - * test invoking controller methods. - * - * @expectedException PrivateActionException - * @expectedExceptionMessage Private Action TestController::admin_add() is not directly accessible. - * @return void - */ - public function testInvokeActionPrefixProtection() { - Router::reload(); - Router::connect('/admin/:controller/:action/*', array('prefix' => 'admin')); - - $url = new CakeRequest('test/admin_add/'); - $url->addParams(array('controller' => 'test_controller', 'action' => 'admin_add')); - $response = $this->getMock('CakeResponse'); - - $Controller = new TestController($url, $response); - $Controller->invokeAction($url); - } - -/** - * test invoking controller methods. - * - * @return void - */ - public function testInvokeActionReturnValue() { - $url = new CakeRequest('test/returner/'); - $url->addParams(array( - 'controller' => 'test_controller', - 'action' => 'returner', - 'pass' => array() - )); - $response = $this->getMock('CakeResponse'); - - $Controller = new TestController($url, $response); - $result = $Controller->invokeAction($url); - $this->assertEquals('I am from the controller.', $result); - } - -} diff --git a/lib/Cake/Test/Case/Controller/PagesControllerTest.php b/lib/Cake/Test/Case/Controller/PagesControllerTest.php deleted file mode 100644 index 829a940d963..00000000000 --- a/lib/Cake/Test/Case/Controller/PagesControllerTest.php +++ /dev/null @@ -1,53 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller - * @since CakePHP(tm) v 1.2.0.5436 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('PagesController', 'Controller'); - -/** - * PagesControllerTest class - * - * @package Cake.Test.Case.Controller - */ -class PagesControllerTest extends CakeTestCase { - -/** - * testDisplay method - * - * @return void - */ - public function testDisplay() { - App::build(array( - 'View' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS - ) - )); - $Pages = new PagesController(new CakeRequest(null, false), new CakeResponse()); - - $Pages->viewPath = 'Posts'; - $Pages->display('index'); - $this->assertRegExp('/posts index/', $Pages->response->body()); - $this->assertEquals('index', $Pages->viewVars['page']); - - $Pages->viewPath = 'Themed'; - $Pages->display('TestTheme', 'Posts', 'index'); - $this->assertRegExp('/posts index themed view/', $Pages->response->body()); - $this->assertEquals('TestTheme', $Pages->viewVars['page']); - $this->assertEquals('Posts', $Pages->viewVars['subpage']); - } -} diff --git a/lib/Cake/Test/Case/Controller/ScaffoldTest.php b/lib/Cake/Test/Case/Controller/ScaffoldTest.php deleted file mode 100644 index cd5ac89cb46..00000000000 --- a/lib/Cake/Test/Case/Controller/ScaffoldTest.php +++ /dev/null @@ -1,350 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Controller - * @since CakePHP(tm) v 1.2.0.5436 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Router', 'Routing'); -App::uses('Controller', 'Controller'); -App::uses('Scaffold', 'Controller'); -App::uses('ScaffoldView', 'View'); -App::uses('AppModel', 'Model'); - -require_once dirname(dirname(__FILE__)) . DS . 'Model' . DS . 'models.php'; - -/** - * ScaffoldMockController class - * - * @package Cake.Test.Case.Controller - */ -class ScaffoldMockController extends Controller { - -/** - * name property - * - * @var string 'ScaffoldMock' - */ - public $name = 'ScaffoldMock'; - -/** - * scaffold property - * - * @var mixed - */ - public $scaffold; -} - -/** - * ScaffoldMockControllerWithFields class - * - * @package Cake.Test.Case.Controller - */ -class ScaffoldMockControllerWithFields extends Controller { - -/** - * name property - * - * @var string 'ScaffoldMock' - */ - public $name = 'ScaffoldMock'; - -/** - * scaffold property - * - * @var mixed - */ - public $scaffold; - -/** - * function beforeScaffold - * - * @param string method - */ - public function beforeScaffold($method) { - $this->set('scaffoldFields', array('title')); - return true; - } - -} - -/** - * TestScaffoldMock class - * - * @package Cake.Test.Case.Controller - */ -class TestScaffoldMock extends Scaffold { - -/** - * Overload _scaffold - * - * @param unknown_type $params - */ - protected function _scaffold(CakeRequest $request) { - $this->_params = $request; - } - -/** - * Get Params from the Controller. - * - * @return unknown - */ - public function getParams() { - return $this->_params; - } - -} - -/** - * Scaffold Test class - * - * @package Cake.Test.Case.Controller - */ -class ScaffoldTest extends CakeTestCase { - -/** - * Controller property - * - * @var SecurityTestController - */ - public $Controller; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.article', 'core.user', 'core.comment', 'core.join_thing', 'core.tag'); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $request = new CakeRequest(null, false); - $this->Controller = new ScaffoldMockController($request); - $this->Controller->response = $this->getMock('CakeResponse', array('_sendHeader')); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Controller); - } - -/** - * Test the correct Generation of Scaffold Params. - * This ensures that the correct action and view will be generated - * - * @return void - */ - public function testScaffoldParams() { - $params = array( - 'plugin' => null, - 'pass' => array(), - 'form' => array(), - 'named' => array(), - 'url' => array('url' => 'admin/scaffold_mock/edit'), - 'controller' => 'scaffold_mock', - 'action' => 'admin_edit', - 'admin' => true, - ); - $this->Controller->request->base = ''; - $this->Controller->request->webroot = '/'; - $this->Controller->request->here = '/admin/scaffold_mock/edit'; - $this->Controller->request->addParams($params); - - //set router. - Router::setRequestInfo($this->Controller->request); - - $this->Controller->constructClasses(); - $Scaffold = new TestScaffoldMock($this->Controller, $this->Controller->request); - $result = $Scaffold->getParams(); - $this->assertEquals('admin_edit', $result['action']); - } - -/** - * test that the proper names and variable values are set by Scaffold - * - * @return void - */ - public function testScaffoldVariableSetting() { - $params = array( - 'plugin' => null, - 'pass' => array(), - 'form' => array(), - 'named' => array(), - 'url' => array('url' => 'admin/scaffold_mock/edit'), - 'controller' => 'scaffold_mock', - 'action' => 'admin_edit', - 'admin' => true, - ); - $this->Controller->request->base = ''; - $this->Controller->request->webroot = '/'; - $this->Controller->request->here = '/admin/scaffold_mock/edit'; - $this->Controller->request->addParams($params); - - //set router. - Router::setRequestInfo($this->Controller->request); - - $this->Controller->constructClasses(); - $Scaffold = new TestScaffoldMock($this->Controller, $this->Controller->request); - $result = $Scaffold->controller->viewVars; - - $this->assertEquals('Scaffold :: Admin Edit :: Scaffold Mock', $result['title_for_layout']); - $this->assertEquals('Scaffold Mock', $result['singularHumanName']); - $this->assertEquals('Scaffold Mock', $result['pluralHumanName']); - $this->assertEquals('ScaffoldMock', $result['modelClass']); - $this->assertEquals('id', $result['primaryKey']); - $this->assertEquals('title', $result['displayField']); - $this->assertEquals('scaffoldMock', $result['singularVar']); - $this->assertEquals('scaffoldMock', $result['pluralVar']); - $this->assertEquals(array('id', 'user_id', 'title', 'body', 'published', 'created', 'updated'), $result['scaffoldFields']); - } - -/** - * test that Scaffold overrides the view property even if its set to 'Theme' - * - * @return void - */ - public function testScaffoldChangingViewProperty() { - $this->Controller->action = 'edit'; - $this->Controller->theme = 'TestTheme'; - $this->Controller->viewClass = 'Theme'; - $this->Controller->constructClasses(); - $Scaffold = new TestScaffoldMock($this->Controller, $this->Controller->request); - - $this->assertEquals('Scaffold', $this->Controller->viewClass); - } - -/** - * test that scaffold outputs flash messages when sessions are unset. - * - * @return void - */ - public function testScaffoldFlashMessages() { - $params = array( - 'plugin' => null, - 'pass' => array(1), - 'form' => array(), - 'named' => array(), - 'url' => array('url' => 'scaffold_mock'), - 'controller' => 'scaffold_mock', - 'action' => 'edit', - ); - $this->Controller->request->base = ''; - $this->Controller->request->webroot = '/'; - $this->Controller->request->here = '/scaffold_mock/edit'; - $this->Controller->request->addParams($params); - - //set router. - Router::reload(); - Router::setRequestInfo($this->Controller->request); - $this->Controller->request->data = array( - 'ScaffoldMock' => array( - 'id' => 1, - 'title' => 'New title', - 'body' => 'new body' - ) - ); - $this->Controller->constructClasses(); - unset($this->Controller->Session); - - ob_start(); - new Scaffold($this->Controller, $this->Controller->request); - $this->Controller->response->send(); - $result = ob_get_clean(); - $this->assertRegExp('/Scaffold Mock has been updated/', $result); - } - -/** - * test that habtm relationship keys get added to scaffoldFields. - * - * @return void - */ - public function testHabtmFieldAdditionWithScaffoldForm() { - CakePlugin::unload(); - $params = array( - 'plugin' => null, - 'pass' => array(1), - 'form' => array(), - 'named' => array(), - 'url' => array('url' => 'scaffold_mock'), - 'controller' => 'scaffold_mock', - 'action' => 'edit', - ); - $this->Controller->request->base = ''; - $this->Controller->request->webroot = '/'; - $this->Controller->request->here = '/scaffold_mock/edit'; - $this->Controller->request->addParams($params); - - //set router. - Router::reload(); - Router::setRequestInfo($this->Controller->request); - - $this->Controller->constructClasses(); - ob_start(); - $Scaffold = new Scaffold($this->Controller, $this->Controller->request); - $this->Controller->response->send(); - $result = ob_get_clean(); - $this->assertRegExp('/name="data\[ScaffoldTag\]\[ScaffoldTag\]"/', $result); - - $result = $Scaffold->controller->viewVars; - $this->assertEquals(array('id', 'user_id', 'title', 'body', 'published', 'created', 'updated', 'ScaffoldTag'), $result['scaffoldFields']); - } - -/** - * test that the proper names and variable values are set by Scaffold - * - * @return void - */ - public function testEditScaffoldWithScaffoldFields() { - $request = new CakeRequest(null, false); - $this->Controller = new ScaffoldMockControllerWithFields($request); - $this->Controller->response = $this->getMock('CakeResponse', array('_sendHeader')); - - $params = array( - 'plugin' => null, - 'pass' => array(1), - 'form' => array(), - 'named' => array(), - 'url' => array('url' => 'scaffold_mock/edit'), - 'controller' => 'scaffold_mock', - 'action' => 'edit', - ); - $this->Controller->request->base = ''; - $this->Controller->request->webroot = '/'; - $this->Controller->request->here = '/scaffold_mock/edit'; - $this->Controller->request->addParams($params); - - //set router. - Router::reload(); - Router::setRequestInfo($this->Controller->request); - - $this->Controller->constructClasses(); - ob_start(); - new Scaffold($this->Controller, $this->Controller->request); - $this->Controller->response->send(); - $result = ob_get_clean(); - - $this->assertNotRegExp('/textarea name="data\[ScaffoldMock\]\[body\]" cols="30" rows="6" id="ScaffoldMockBody"/', $result); - } - -} diff --git a/lib/Cake/Test/Case/Core/AppTest.php b/lib/Cake/Test/Case/Core/AppTest.php deleted file mode 100644 index 029537ef27a..00000000000 --- a/lib/Cake/Test/Case/Core/AppTest.php +++ /dev/null @@ -1,850 +0,0 @@ -assertEquals($expected, $old); - - App::build(array('Model' => array('/path/to/models/'))); - $new = App::path('Model'); - $expected = array( - '/path/to/models/', - APP . 'Model' . DS - ); - $this->assertEquals($expected, $new); - - App::build(); - App::build(array('Model' => array('/path/to/models/')), App::PREPEND); - $new = App::path('Model'); - $expected = array( - '/path/to/models/', - APP . 'Model' . DS - ); - $this->assertEquals($expected, $new); - - App::build(); - App::build(array('Model' => array('/path/to/models/')), App::APPEND); - $new = App::path('Model'); - $expected = array( - APP . 'Model' . DS, - '/path/to/models/' - ); - $this->assertEquals($expected, $new); - - App::build(); - App::build(array( - 'Model' => array('/path/to/models/'), - 'Controller' => array('/path/to/controllers/'), - ), App::APPEND); - $new = App::path('Model'); - $expected = array( - APP . 'Model' . DS, - '/path/to/models/' - ); - $this->assertEquals($expected, $new); - $new = App::path('Controller'); - $expected = array( - APP . 'Controller' . DS, - '/path/to/controllers/' - ); - $this->assertEquals($expected, $new); - - App::build(); //reset defaults - $defaults = App::path('Model'); - $this->assertEquals($old, $defaults); - } - -/** - * tests that it is possible to set up paths using the cake 1.3 notation for them (models, behaviors, controllers...) - * - * @return void - */ - public function testCompatibleBuild() { - $old = App::path('models'); - $expected = array( - APP . 'Model' . DS - ); - $this->assertEquals($expected, $old); - - App::build(array('models' => array('/path/to/models/'))); - - $new = App::path('models'); - - $expected = array( - '/path/to/models/', - APP . 'Model' . DS - ); - $this->assertEquals($expected, $new); - $this->assertEquals($expected, App::path('Model')); - - App::build(array('datasources' => array('/path/to/datasources/'))); - $expected = array( - '/path/to/datasources/', - APP . 'Model' . DS . 'Datasource' . DS - ); - $result = App::path('datasources'); - $this->assertEquals($expected, $result); - $this->assertEquals($expected, App::path('Model/Datasource')); - - App::build(array('behaviors' => array('/path/to/behaviors/'))); - $expected = array( - '/path/to/behaviors/', - APP . 'Model' . DS . 'Behavior' . DS - ); - $result = App::path('behaviors'); - $this->assertEquals($expected, $result); - $this->assertEquals($expected, App::path('Model/Behavior')); - - App::build(array('controllers' => array('/path/to/controllers/'))); - $expected = array( - '/path/to/controllers/', - APP . 'Controller' . DS - ); - $result = App::path('controllers'); - $this->assertEquals($expected, $result); - $this->assertEquals($expected, App::path('Controller')); - - App::build(array('components' => array('/path/to/components/'))); - $expected = array( - '/path/to/components/', - APP . 'Controller' . DS . 'Component' . DS - ); - $result = App::path('components'); - $this->assertEquals($expected, $result); - $this->assertEquals($expected, App::path('Controller/Component')); - - App::build(array('views' => array('/path/to/views/'))); - $expected = array( - '/path/to/views/', - APP . 'View' . DS - ); - $result = App::path('views'); - $this->assertEquals($expected, $result); - $this->assertEquals($expected, App::path('View')); - - App::build(array('helpers' => array('/path/to/helpers/'))); - $expected = array( - '/path/to/helpers/', - APP . 'View' . DS . 'Helper' . DS - ); - $result = App::path('helpers'); - $this->assertEquals($expected, $result); - $this->assertEquals($expected, App::path('View/Helper')); - - App::build(array('shells' => array('/path/to/shells/'))); - $expected = array( - '/path/to/shells/', - APP . 'Console' . DS . 'Command' . DS - ); - $result = App::path('shells'); - $this->assertEquals($expected, $result); - $this->assertEquals($expected, App::path('Console/Command')); - - App::build(); //reset defaults - $defaults = App::path('Model'); - $this->assertEquals($old, $defaults); - } - -/** - * test package build() with App::REGISTER. - * - * @return void - */ - public function testBuildPackage() { - $pluginPaths = array( - '/foo/bar', - APP . 'Plugin' . DS, - dirname(dirname(CAKE)) . DS . 'plugins' . DS - ); - App::build(array( - 'Plugin' => array( - '/foo/bar' - ) - )); - $result = App::path('Plugin'); - $this->assertEquals($pluginPaths, $result); - - $paths = App::path('Service'); - $this->assertEquals(array(), $paths); - - App::build(array( - 'Service' => array( - '%s' . 'Service' . DS, - ), - ), App::REGISTER); - - $expected = array( - APP . 'Service' . DS, - ); - $result = App::path('Service'); - $this->assertEquals($expected, $result); - - //Ensure new paths registered for other packages are not affected - $result = App::path('Plugin'); - $this->assertEquals($pluginPaths, $result); - - App::build(); - $paths = App::path('Service'); - $this->assertEquals(array(), $paths); - } - -/** - * test path() with a plugin. - * - * @return void - */ - public function testPathWithPlugins() { - $basepath = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS; - App::build(array( - 'Plugin' => array($basepath), - )); - CakePlugin::load('TestPlugin'); - - $result = App::path('Vendor', 'TestPlugin'); - $this->assertEquals($basepath . 'TestPlugin' . DS . 'Vendor' . DS, $result[0]); - } - -/** - * testBuildWithReset method - * - * @return void - */ - public function testBuildWithReset() { - $old = App::path('Model'); - $expected = array( - APP . 'Model' . DS - ); - $this->assertEquals($expected, $old); - - App::build(array('Model' => array('/path/to/models/')), App::RESET); - - $new = App::path('Model'); - - $expected = array( - '/path/to/models/' - ); - $this->assertEquals($expected, $new); - - App::build(); //reset defaults - $defaults = App::path('Model'); - $this->assertEquals($old, $defaults); - } - -/** - * testCore method - * - * @return void - */ - public function testCore() { - $model = App::core('Model'); - $this->assertEquals(array(CAKE . 'Model' . DS), $model); - - $view = App::core('View'); - $this->assertEquals(array(CAKE . 'View' . DS), $view); - - $controller = App::core('Controller'); - $this->assertEquals(array(CAKE . 'Controller' . DS), $controller); - - $component = App::core('Controller/Component'); - $this->assertEquals(array(CAKE . 'Controller' . DS . 'Component' . DS), str_replace('/', DS, $component)); - - $auth = App::core('Controller/Component/Auth'); - $this->assertEquals(array(CAKE . 'Controller' . DS . 'Component' . DS . 'Auth' . DS), str_replace('/', DS, $auth)); - - $datasource = App::core('Model/Datasource'); - $this->assertEquals(array(CAKE . 'Model' . DS . 'Datasource' . DS), str_replace('/', DS, $datasource)); - } - -/** - * testListObjects method - * - * @return void - */ - public function testListObjects() { - $result = App::objects('class', CAKE . 'Routing', false); - $this->assertTrue(in_array('Dispatcher', $result)); - $this->assertTrue(in_array('Router', $result)); - - App::build(array( - 'Model/Behavior' => App::core('Model/Behavior'), - 'Controller' => App::core('Controller'), - 'Controller/Component' => App::core('Controller/Component'), - 'View' => App::core('View'), - 'Model' => App::core('Model'), - 'View/Helper' => App::core('View/Helper'), - ), App::RESET); - $result = App::objects('behavior', null, false); - $this->assertTrue(in_array('TreeBehavior', $result)); - $result = App::objects('Model/Behavior', null, false); - $this->assertTrue(in_array('TreeBehavior', $result)); - - $result = App::objects('component', null, false); - $this->assertTrue(in_array('AuthComponent', $result)); - $result = App::objects('Controller/Component', null, false); - $this->assertTrue(in_array('AuthComponent', $result)); - - $result = App::objects('view', null, false); - $this->assertTrue(in_array('MediaView', $result)); - $result = App::objects('View', null, false); - $this->assertTrue(in_array('MediaView', $result)); - - $result = App::objects('helper', null, false); - $this->assertTrue(in_array('HtmlHelper', $result)); - $result = App::objects('View/Helper', null, false); - $this->assertTrue(in_array('HtmlHelper', $result)); - - $result = App::objects('model', null, false); - $this->assertTrue(in_array('AcoAction', $result)); - $result = App::objects('Model', null, false); - $this->assertTrue(in_array('AcoAction', $result)); - - $result = App::objects('file'); - $this->assertFalse($result); - - $result = App::objects('file', 'non_existing_configure'); - $expected = array(); - $this->assertEquals($expected, $result); - - $result = App::objects('NonExistingType'); - $this->assertEquals(array(), $result); - - App::build(array( - 'plugins' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Lib' . DS - ) - )); - $result = App::objects('plugin', null, false); - $this->assertTrue(in_array('Cache', $result)); - $this->assertTrue(in_array('Log', $result)); - - App::build(); - } - -/** - * Make sure that .svn and friends are excluded from App::objects('plugin') - */ - public function testListObjectsIgnoreDotDirectories() { - $path = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS; - - $this->skipIf(!is_writable($path), $path . ' is not writable.'); - - App::build(array( - 'plugins' => array($path) - ), App::RESET); - mkdir($path . '.svn'); - $result = App::objects('plugin', null, false); - rmdir($path . '.svn'); - - $this->assertNotContains('.svn', $result); - } - -/** - * Tests listing objects within a plugin - * - * @return void - */ - public function testListObjectsInPlugin() { - App::build(array( - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS), - 'plugins' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - $result = App::objects('TestPlugin.model'); - $this->assertTrue(in_array('TestPluginPost', $result)); - $result = App::objects('TestPlugin.Model'); - $this->assertTrue(in_array('TestPluginPost', $result)); - - $result = App::objects('TestPlugin.behavior'); - $this->assertTrue(in_array('TestPluginPersisterOneBehavior', $result)); - $result = App::objects('TestPlugin.Model/Behavior'); - $this->assertTrue(in_array('TestPluginPersisterOneBehavior', $result)); - - $result = App::objects('TestPlugin.helper'); - $expected = array('OtherHelperHelper', 'PluggedHelperHelper', 'TestPluginAppHelper'); - $this->assertEquals($expected, $result); - $result = App::objects('TestPlugin.View/Helper'); - $expected = array('OtherHelperHelper', 'PluggedHelperHelper', 'TestPluginAppHelper'); - $this->assertEquals($expected, $result); - - $result = App::objects('TestPlugin.component'); - $this->assertTrue(in_array('OtherComponent', $result)); - $result = App::objects('TestPlugin.Controller/Component'); - $this->assertTrue(in_array('OtherComponent', $result)); - - $result = App::objects('TestPluginTwo.behavior'); - $this->assertEquals(array(), $result); - $result = App::objects('TestPluginTwo.Model/Behavior'); - $this->assertEquals(array(), $result); - - $result = App::objects('model', null, false); - $this->assertTrue(in_array('Comment', $result)); - $this->assertTrue(in_array('Post', $result)); - - $result = App::objects('Model', null, false); - $this->assertTrue(in_array('Comment', $result)); - $this->assertTrue(in_array('Post', $result)); - - App::build(); - } - -/** - * test that pluginPath can find paths for plugins. - * - * @return void - */ - public function testPluginPath() { - App::build(array( - 'plugins' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - $path = App::pluginPath('TestPlugin'); - $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS; - $this->assertEquals($expected, $path); - - $path = App::pluginPath('TestPluginTwo'); - $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS; - $this->assertEquals($expected, $path); - App::build(); - } - -/** - * test that themePath can find paths for themes. - * - * @return void - */ - public function testThemePath() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - $path = App::themePath('test_theme'); - $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS . 'Themed' . DS . 'TestTheme' . DS; - $this->assertEquals($expected, $path); - - $path = App::themePath('TestTheme'); - $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS . 'Themed' . DS . 'TestTheme' . DS; - $this->assertEquals($expected, $path); - - App::build(); - } - -/** - * testClassLoading method - * - * @return void - */ - public function testClassLoading() { - $file = App::import('Model', 'Model', false); - $this->assertTrue($file); - $this->assertTrue(class_exists('Model')); - - $file = App::import('Controller', 'Controller', false); - $this->assertTrue($file); - $this->assertTrue(class_exists('Controller')); - - $file = App::import('Component', 'Auth', false); - $this->assertTrue($file); - $this->assertTrue(class_exists('AuthComponent')); - - $file = App::import('Shell', 'Shell', false); - $this->assertTrue($file); - $this->assertTrue(class_exists('Shell')); - - $file = App::import('Configure', 'PhpReader'); - $this->assertTrue($file); - $this->assertTrue(class_exists('PhpReader')); - - $file = App::import('Model', 'SomeRandomModelThatDoesNotExist', false); - $this->assertFalse($file); - - $file = App::import('Model', 'AppModel', false); - $this->assertTrue($file); - $this->assertTrue(class_exists('AppModel')); - - $file = App::import('WrongType', null, true, array(), ''); - $this->assertFalse($file); - - $file = App::import('Model', 'NonExistingPlugin.NonExistingModel', false); - $this->assertFalse($file); - - $file = App::import('Model', array('NonExistingPlugin.NonExistingModel'), false); - $this->assertFalse($file); - - if (!class_exists('AppController', false)) { - $classes = array_flip(get_declared_classes()); - - $this->assertFalse(isset($classes['PagesController'])); - $this->assertFalse(isset($classes['AppController'])); - - $file = App::import('Controller', 'Pages'); - $this->assertTrue($file); - $this->assertTrue(class_exists('PagesController')); - - $classes = array_flip(get_declared_classes()); - - $this->assertTrue(isset($classes['PagesController'])); - $this->assertTrue(isset($classes['AppController'])); - - $file = App::import('Behavior', 'Containable'); - $this->assertTrue($file); - $this->assertTrue(class_exists('ContainableBehavior')); - - $file = App::import('Component', 'RequestHandler'); - $this->assertTrue($file); - $this->assertTrue(class_exists('RequestHandlerComponent')); - - $file = App::import('Helper', 'Form'); - $this->assertTrue($file); - $this->assertTrue(class_exists('FormHelper')); - - $file = App::import('Model', 'NonExistingModel'); - $this->assertFalse($file); - - $file = App::import('Datasource', 'DboSource'); - $this->assertTrue($file); - $this->assertTrue(class_exists('DboSource')); - } - App::build(); - } - -/** - * test import() with plugins - * - * @return void - */ - public function testPluginImporting() { - App::build(array( - 'Lib' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Lib' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - $result = App::import('Controller', 'TestPlugin.Tests'); - $this->assertTrue($result); - $this->assertTrue(class_exists('TestPluginAppController')); - $this->assertTrue(class_exists('TestsController')); - - $result = App::import('Lib', 'TestPlugin.TestPluginLibrary'); - $this->assertTrue($result); - $this->assertTrue(class_exists('TestPluginLibrary')); - - $result = App::import('Lib', 'Library'); - $this->assertTrue($result); - $this->assertTrue(class_exists('Library')); - - $result = App::import('Helper', 'TestPlugin.OtherHelper'); - $this->assertTrue($result); - $this->assertTrue(class_exists('OtherHelperHelper')); - - $result = App::import('Helper', 'TestPlugin.TestPluginApp'); - $this->assertTrue($result); - $this->assertTrue(class_exists('TestPluginAppHelper')); - - $result = App::import('Datasource', 'TestPlugin.TestSource'); - $this->assertTrue($result); - $this->assertTrue(class_exists('TestSource')); - - App::uses('ExampleExample', 'TestPlugin.Vendor/Example'); - $this->assertTrue(class_exists('ExampleExample')); - - App::build(); - } - -/** - * test that building helper paths actually works. - * - * @return void - * @link http://cakephp.lighthouseapp.com/projects/42648/tickets/410 - */ - public function testImportingHelpersFromAlternatePaths() { - $this->assertFalse(class_exists('BananaHelper', false), 'BananaHelper exists, cannot test importing it.'); - App::build(array( - 'View/Helper' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS . 'Helper' . DS - ) - )); - $this->assertFalse(class_exists('BananaHelper', false), 'BananaHelper exists, cannot test importing it.'); - App::import('Helper', 'Banana'); - $this->assertTrue(class_exists('BananaHelper', false), 'BananaHelper was not loaded.'); - - App::build(); - } - -/** - * testFileLoading method - * - * @return void - */ - public function testFileLoading() { - $file = App::import('File', 'RealFile', false, array(), CAKE . 'Config' . DS . 'config.php'); - $this->assertTrue($file); - - $file = App::import('File', 'NoFile', false, array(), CAKE . 'Config' . DS . 'cake' . DS . 'config.php'); - $this->assertFalse($file); - } - -/** - * testFileLoadingWithArray method - * - * @return void - */ - public function testFileLoadingWithArray() { - $type = array( - 'type' => 'File', - 'name' => 'SomeName', - 'parent' => false, - 'file' => CAKE . DS . 'Config' . DS . 'config.php' - ); - $file = App::import($type); - $this->assertTrue($file); - - $type = array( - 'type' => 'File', - 'name' => 'NoFile', - 'parent' => false, - 'file' => CAKE . 'Config' . DS . 'cake' . DS . 'config.php' - ); - $file = App::import($type); - $this->assertFalse($file); - } - -/** - * testFileLoadingReturnValue method - * - * @return void - */ - public function testFileLoadingReturnValue() { - $file = App::import('File', 'Name', false, array(), CAKE . 'Config' . DS . 'config.php', true); - $this->assertTrue(!empty($file)); - - $this->assertTrue(isset($file['Cake.version'])); - - $type = array( - 'type' => 'File', - 'name' => 'OtherName', - 'parent' => false, - 'file' => CAKE . 'Config' . DS . 'config.php', 'return' => true - ); - $file = App::import($type); - $this->assertTrue(!empty($file)); - - $this->assertTrue(isset($file['Cake.version'])); - } - -/** - * testLoadingWithSearch method - * - * @return void - */ - public function testLoadingWithSearch() { - $file = App::import('File', 'NewName', false, array(CAKE . 'Config' . DS), 'config.php'); - $this->assertTrue($file); - - $file = App::import('File', 'AnotherNewName', false, array(CAKE), 'config.php'); - $this->assertFalse($file); - } - -/** - * testLoadingWithSearchArray method - * - * @return void - */ - public function testLoadingWithSearchArray() { - $type = array( - 'type' => 'File', - 'name' => 'RandomName', - 'parent' => false, - 'file' => 'config.php', - 'search' => array(CAKE . 'Config' . DS) - ); - $file = App::import($type); - $this->assertTrue($file); - - $type = array( - 'type' => 'File', - 'name' => 'AnotherRandomName', - 'parent' => false, - 'file' => 'config.php', - 'search' => array(CAKE) - ); - $file = App::import($type); - $this->assertFalse($file); - } - -/** - * testMultipleLoading method - * - * @return void - */ - public function testMultipleLoading() { - if (class_exists('PersisterOne', false) || class_exists('PersisterTwo', false)) { - $this->markTestSkipped('Cannot test loading of classes that exist.'); - } - App::build(array( - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS) - )); - $toLoad = array('PersisterOne', 'PersisterTwo'); - $load = App::import('Model', $toLoad); - $this->assertTrue($load); - - $classes = array_flip(get_declared_classes()); - - $this->assertTrue(isset($classes['PersisterOne'])); - $this->assertTrue(isset($classes['PersisterTwo'])); - - $load = App::import('Model', array('PersisterOne', 'SomeNotFoundClass', 'PersisterTwo')); - $this->assertFalse($load); - } - - public function testLoadingVendor() { - App::build(array( - 'plugins' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'vendors' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Vendor' . DS), - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - ob_start(); - $result = App::import('Vendor', 'css/TestAsset', array('ext' => 'css')); - $text = ob_get_clean(); - $this->assertTrue($result); - $this->assertEquals('this is the test asset css file', $text); - - $result = App::import('Vendor', 'TestPlugin.sample/SamplePlugin'); - $this->assertTrue($result); - $this->assertTrue(class_exists('SamplePluginClassTestName')); - - $result = App::import('Vendor', 'sample/ConfigureTestVendorSample'); - $this->assertTrue($result); - $this->assertTrue(class_exists('ConfigureTestVendorSample')); - - ob_start(); - $result = App::import('Vendor', 'SomeNameInSubfolder', array('file' => 'somename/some.name.php')); - $text = ob_get_clean(); - $this->assertTrue($result); - $this->assertEquals('This is a file with dot in file name', $text); - - ob_start(); - $result = App::import('Vendor', 'TestHello', array('file' => 'Test' . DS . 'hello.php')); - $text = ob_get_clean(); - $this->assertTrue($result); - $this->assertEquals('This is the hello.php file in Test directory', $text); - - ob_start(); - $result = App::import('Vendor', 'MyTest', array('file' => 'Test' . DS . 'MyTest.php')); - $text = ob_get_clean(); - $this->assertTrue($result); - $this->assertEquals('This is the MyTest.php file', $text); - - ob_start(); - $result = App::import('Vendor', 'Welcome'); - $text = ob_get_clean(); - $this->assertTrue($result); - $this->assertEquals('This is the welcome.php file in vendors directory', $text); - - ob_start(); - $result = App::import('Vendor', 'TestPlugin.Welcome'); - $text = ob_get_clean(); - $this->assertTrue($result); - $this->assertEquals('This is the welcome.php file in test_plugin/vendors directory', $text); - } - -/** - * Tests that the automatic class loader will also find in "libs" folder for both - * app and plugins if it does not find the class in other configured paths - * - */ - public function testLoadClassInLibs() { - App::build(array( - 'libs' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Lib' . DS), - 'plugins' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - $this->assertFalse(class_exists('CustomLibClass', false)); - App::uses('CustomLibClass', 'TestPlugin.Custom/Package'); - $this->assertTrue(class_exists('CustomLibClass')); - - $this->assertFalse(class_exists('TestUtilityClass', false)); - App::uses('TestUtilityClass', 'Utility'); - $this->assertTrue(class_exists('CustomLibClass')); - } - -/** - * Tests that App::location() returns the defined path for a class - * - * @return void - */ - public function testClassLocation() { - App::uses('MyCustomClass', 'MyPackage/Name'); - $this->assertEquals('MyPackage/Name', App::location('MyCustomClass')); - } - -/** - * Test that paths() works. - * - * @return void - */ - public function testPaths() { - $result = App::paths(); - $this->assertArrayHasKey('Plugin', $result); - $this->assertArrayHasKey('Controller', $result); - $this->assertArrayHasKey('Controller/Component', $result); - } - -/** - * Proves that it is possible to load plugin libraries in top - * level Lib dir for plugins - * - * @return void - */ - public function testPluginLibClasses() { - App::build(array( - 'plugins' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - $this->assertFalse(class_exists('TestPluginOtherLibrary', false)); - App::uses('TestPluginOtherLibrary', 'TestPlugin.Lib'); - $this->assertTrue(class_exists('TestPluginOtherLibrary')); - } -} diff --git a/lib/Cake/Test/Case/Core/CakePluginTest.php b/lib/Cake/Test/Case/Core/CakePluginTest.php deleted file mode 100644 index 87926a20983..00000000000 --- a/lib/Cake/Test/Case/Core/CakePluginTest.php +++ /dev/null @@ -1,268 +0,0 @@ - array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - App::objects('plugins', null, false); - } - -/** - * Reverts the changes done to the environment while testing - * - * @return void - */ - public function tearDown() { - App::build(); - CakePlugin::unload(); - Configure::delete('CakePluginTest'); - } - -/** - * Tests loading a single plugin - * - * @return void - */ - public function testLoadSingle() { - CakePlugin::unload(); - CakePlugin::load('TestPlugin'); - $expected = array('TestPlugin'); - $this->assertEquals($expected, CakePlugin::loaded()); - } - -/** - * Tests unloading plugins - * - * @return void - */ - public function testUnload() { - CakePlugin::load('TestPlugin'); - $expected = array('TestPlugin'); - $this->assertEquals($expected, CakePlugin::loaded()); - - CakePlugin::unload('TestPlugin'); - $this->assertEquals(array(), CakePlugin::loaded()); - - CakePlugin::load('TestPlugin'); - $expected = array('TestPlugin'); - $this->assertEquals($expected, CakePlugin::loaded()); - - CakePlugin::unload('TestFakePlugin'); - $this->assertEquals($expected, CakePlugin::loaded()); - } - -/** - * Tests loading a plugin and its bootstrap file - * - * @return void - */ - public function testLoadSingleWithBootstrap() { - CakePlugin::load('TestPlugin', array('bootstrap' => true)); - $this->assertTrue(CakePlugin::loaded('TestPlugin')); - $this->assertEquals('loaded plugin bootstrap', Configure::read('CakePluginTest.test_plugin.bootstrap')); - } - -/** - * Tests loading a plugin with bootstrap file and routes file - * - * @return void - */ - public function testLoadSingleWithBootstrapAndRoutes() { - CakePlugin::load('TestPlugin', array('bootstrap' => true, 'routes' => true)); - $this->assertTrue(CakePlugin::loaded('TestPlugin')); - $this->assertEquals('loaded plugin bootstrap', Configure::read('CakePluginTest.test_plugin.bootstrap')); - - CakePlugin::routes(); - $this->assertEquals('loaded plugin routes', Configure::read('CakePluginTest.test_plugin.routes')); - } - -/** - * Tests loading multiple plugins at once - * - * @return void - */ - public function testLoadMultiple() { - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - $expected = array('TestPlugin', 'TestPluginTwo'); - $this->assertEquals($expected, CakePlugin::loaded()); - } - -/** - * Tests loading multiple plugins and their bootstrap files - * - * @return void - */ - public function testLoadMultipleWithDefaults() { - CakePlugin::load(array('TestPlugin', 'TestPluginTwo'), array('bootstrap' => true, 'routes' => false)); - $expected = array('TestPlugin', 'TestPluginTwo'); - $this->assertEquals($expected, CakePlugin::loaded()); - $this->assertEquals('loaded plugin bootstrap', Configure::read('CakePluginTest.test_plugin.bootstrap')); - $this->assertEquals('loaded plugin two bootstrap', Configure::read('CakePluginTest.test_plugin_two.bootstrap')); - } - -/** - * Tests loading multiple plugins with default loading params and some overrides - * - * @return void - */ - public function testLoadMultipleWithDefaultsAndOverride() { - CakePlugin::load( - array('TestPlugin', 'TestPluginTwo' => array('routes' => false)), - array('bootstrap' => true, 'routes' => true) - ); - $expected = array('TestPlugin', 'TestPluginTwo'); - $this->assertEquals($expected, CakePlugin::loaded()); - $this->assertEquals('loaded plugin bootstrap', Configure::read('CakePluginTest.test_plugin.bootstrap')); - $this->assertEquals(null, Configure::read('CakePluginTest.test_plugin_two.bootstrap')); - } - -/** - * Tests that it is possible to load multiple bootstrap files at once - * - * @return void - */ - public function testMultipleBootstrapFiles() { - CakePlugin::load('TestPlugin', array('bootstrap' => array('bootstrap', 'custom_config'))); - $this->assertTrue(CakePlugin::loaded('TestPlugin')); - $this->assertEquals('loaded plugin bootstrap', Configure::read('CakePluginTest.test_plugin.bootstrap')); - } - -/** - * Tests that it is possible to load plugin bootstrap by calling a callback function - * - * @return void - */ - public function testCallbackBootstrap() { - CakePlugin::load('TestPlugin', array('bootstrap' => array($this, 'pluginBootstrap'))); - $this->assertTrue(CakePlugin::loaded('TestPlugin')); - $this->assertEquals('called plugin bootstrap callback', Configure::read('CakePluginTest.test_plugin.bootstrap')); - } - -/** - * Tests that loading a missing routes file throws a warning - * - * @return void - * @expectedException PHPUNIT_FRAMEWORK_ERROR_WARNING - */ - public function testLoadMultipleWithDefaultsMissingFile() { - CakePlugin::load(array('TestPlugin', 'TestPluginTwo'), array('bootstrap' => true, 'routes' => true)); - CakePlugin::routes(); - } - -/** - * Tests that CakePlugin::load() throws an exception on unknown plugin - * - * @return void - * @expectedException MissingPluginException - */ - public function testLoadNotFound() { - CakePlugin::load('MissingPlugin'); - } - -/** - * Tests that CakePlugin::path() returns the correct path for the loaded plugins - * - * @return void - */ - public function testPath() { - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS; - $this->assertEquals(CakePlugin::path('TestPlugin'), $expected); - - $expected = CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS; - $this->assertEquals(CakePlugin::path('TestPluginTwo'), $expected); - } - -/** - * Tests that CakePlugin::path() throws an exception on unknown plugin - * - * @return void - * @expectedException MissingPluginException - */ - public function testPathNotFound() { - CakePlugin::path('TestPlugin'); - } - -/** - * Tests that CakePlugin::loadAll() will load all plugins in the configured folder - * - * @return void - */ - public function testLoadAll() { - CakePlugin::loadAll(); - $expected = array('PluginJs', 'TestPlugin', 'TestPluginTwo'); - $this->assertEquals($expected, CakePlugin::loaded()); - } - -/** - * Tests that CakePlugin::loadAll() will load all plugins in the configured folder with bootstrap loading - * - * @return void - */ - public function testLoadAllWithDefaults() { - $defaults = array('bootstrap' => true); - CakePlugin::loadAll(array($defaults)); - $expected = array('PluginJs', 'TestPlugin', 'TestPluginTwo'); - $this->assertEquals($expected, CakePlugin::loaded()); - $this->assertEquals('loaded js plugin bootstrap', Configure::read('CakePluginTest.js_plugin.bootstrap')); - $this->assertEquals('loaded plugin bootstrap', Configure::read('CakePluginTest.test_plugin.bootstrap')); - $this->assertEquals('loaded plugin two bootstrap', Configure::read('CakePluginTest.test_plugin_two.bootstrap')); - } - -/** - * Tests that CakePlugin::loadAll() will load all plugins in the configured folder wit defaults - * and overrides for a plugin - * - * @return void - */ - public function testLoadAllWithDefaultsAndOverride() { - CakePlugin::loadAll(array(array('bootstrap' => true), 'TestPlugin' => array('routes' => true))); - CakePlugin::routes(); - - $expected = array('PluginJs', 'TestPlugin', 'TestPluginTwo'); - $this->assertEquals($expected, CakePlugin::loaded()); - $this->assertEquals('loaded js plugin bootstrap', Configure::read('CakePluginTest.js_plugin.bootstrap')); - $this->assertEquals('loaded plugin routes', Configure::read('CakePluginTest.test_plugin.routes')); - $this->assertEquals(null, Configure::read('CakePluginTest.test_plugin.bootstrap')); - $this->assertEquals('loaded plugin two bootstrap', Configure::read('CakePluginTest.test_plugin_two.bootstrap')); - } - -/** - * Auxiliary function to test plugin bootstrap callbacks - * - * @return void - */ - public function pluginBootstrap() { - Configure::write('CakePluginTest.test_plugin.bootstrap', 'called plugin bootstrap callback'); - } -} diff --git a/lib/Cake/Test/Case/Core/ConfigureTest.php b/lib/Cake/Test/Case/Core/ConfigureTest.php deleted file mode 100644 index 9a73a1e7c5a..00000000000 --- a/lib/Cake/Test/Case/Core/ConfigureTest.php +++ /dev/null @@ -1,357 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Core - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('PhpReader', 'Configure'); - -/** - * ConfigureTest - * - * @package Cake.Test.Case.Core - */ -class ConfigureTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->_cacheDisable = Configure::read('Cache.disable'); - $this->_debug = Configure::read('debug'); - - Configure::write('Cache.disable', true); - App::build(); - App::objects('plugin', null, true); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - if (file_exists(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_core_paths')) { - unlink(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_core_paths'); - } - if (file_exists(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_dir_map')) { - unlink(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_dir_map'); - } - if (file_exists(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_file_map')) { - unlink(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_file_map'); - } - if (file_exists(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_object_map')) { - unlink(TMP . 'cache' . DS . 'persistent' . DS . 'cake_core_object_map'); - } - if (file_exists(TMP . 'cache' . DS . 'persistent' . DS . 'test.config.php')) { - unlink(TMP . 'cache' . DS . 'persistent' . DS . 'test.config.php'); - } - if (file_exists(TMP . 'cache' . DS . 'persistent' . DS . 'test.php')) { - unlink(TMP . 'cache' . DS . 'persistent' . DS . 'test.php'); - } - Configure::write('debug', $this->_debug); - Configure::write('Cache.disable', $this->_cacheDisable); - Configure::drop('test'); - } - -/** - * testRead method - * - * @return void - */ - public function testRead() { - $expected = 'ok'; - Configure::write('level1.level2.level3_1', $expected); - Configure::write('level1.level2.level3_2', 'something_else'); - $result = Configure::read('level1.level2.level3_1'); - $this->assertEquals($expected, $result); - - $result = Configure::read('level1.level2.level3_2'); - $this->assertEquals('something_else', $result); - - $result = Configure::read('debug'); - $this->assertTrue($result >= 0); - - $result = Configure::read(); - $this->assertTrue(is_array($result)); - $this->assertTrue(isset($result['debug'])); - $this->assertTrue(isset($result['level1'])); - - $result = Configure::read('something_I_just_made_up_now'); - $this->assertEquals(null, $result, 'Missing key should return null.'); - } - -/** - * testWrite method - * - * @return void - */ - public function testWrite() { - $writeResult = Configure::write('SomeName.someKey', 'myvalue'); - $this->assertTrue($writeResult); - $result = Configure::read('SomeName.someKey'); - $this->assertEquals('myvalue', $result); - - $writeResult = Configure::write('SomeName.someKey', null); - $this->assertTrue($writeResult); - $result = Configure::read('SomeName.someKey'); - $this->assertEquals(null, $result); - - $expected = array('One' => array('Two' => array('Three' => array('Four' => array('Five' => 'cool'))))); - $writeResult = Configure::write('Key', $expected); - $this->assertTrue($writeResult); - - $result = Configure::read('Key'); - $this->assertEquals($expected, $result); - - $result = Configure::read('Key.One'); - $this->assertEquals($expected['One'], $result); - - $result = Configure::read('Key.One.Two'); - $this->assertEquals($expected['One']['Two'], $result); - - $result = Configure::read('Key.One.Two.Three.Four.Five'); - $this->assertEquals('cool', $result); - - Configure::write('one.two.three.four', '4'); - $result = Configure::read('one.two.three.four'); - $this->assertEquals('4', $result); - } - -/** - * test setting display_errors with debug. - * - * @return void - */ - public function testDebugSettingDisplayErrors() { - Configure::write('debug', 0); - $result = ini_get('display_errors'); - $this->assertEquals(0, $result); - - Configure::write('debug', 2); - $result = ini_get('display_errors'); - $this->assertEquals(1, $result); - } - -/** - * testDelete method - * - * @return void - */ - public function testDelete() { - Configure::write('SomeName.someKey', 'myvalue'); - $result = Configure::read('SomeName.someKey'); - $this->assertEquals('myvalue', $result); - - Configure::delete('SomeName.someKey'); - $result = Configure::read('SomeName.someKey'); - $this->assertTrue($result === null); - - Configure::write('SomeName', array('someKey' => 'myvalue', 'otherKey' => 'otherValue')); - - $result = Configure::read('SomeName.someKey'); - $this->assertEquals('myvalue', $result); - - $result = Configure::read('SomeName.otherKey'); - $this->assertEquals('otherValue', $result); - - Configure::delete('SomeName'); - - $result = Configure::read('SomeName.someKey'); - $this->assertTrue($result === null); - - $result = Configure::read('SomeName.otherKey'); - $this->assertTrue($result === null); - } - -/** - * testLoad method - * - * @expectedException RuntimeException - * @return void - */ - public function testLoadExceptionOnNonExistantFile() { - Configure::config('test', new PhpReader()); - $result = Configure::load('non_existing_configuration_file', 'test'); - } - -/** - * test load method for default config creation - * - * @return void - */ - public function testLoadDefaultConfig() { - try { - Configure::load('non_existing_configuration_file'); - } catch (Exception $e) { - $result = Configure::configured('default'); - $this->assertTrue($result); - } - } - -/** - * test load with merging - * - * @return void - */ - public function testLoadWithMerge() { - Configure::config('test', new PhpReader(CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS)); - - $result = Configure::load('var_test', 'test'); - $this->assertTrue($result); - - $this->assertEquals('value', Configure::read('Read')); - - $result = Configure::load('var_test2', 'test', true); - $this->assertTrue($result); - - $this->assertEquals('value2', Configure::read('Read')); - $this->assertEquals('buried2', Configure::read('Deep.Second.SecondDeepest')); - $this->assertEquals('buried', Configure::read('Deep.Deeper.Deepest')); - $this->assertEquals('Overwrite', Configure::read('TestAcl.classname')); - $this->assertEquals('one', Configure::read('TestAcl.custom')); - } - -/** - * test loading with overwrite - * - * @return void - */ - public function testLoadNoMerge() { - Configure::config('test', new PhpReader(CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS)); - - $result = Configure::load('var_test', 'test'); - $this->assertTrue($result); - - $this->assertEquals('value', Configure::read('Read')); - - $result = Configure::load('var_test2', 'test', false); - $this->assertTrue($result); - - $this->assertEquals('value2', Configure::read('Read')); - $this->assertEquals('buried2', Configure::read('Deep.Second.SecondDeepest')); - $this->assertNull(Configure::read('Deep.Deeper.Deepest')); - } - -/** - * testLoad method - * - * @return void - */ - public function testLoadPlugin() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - Configure::config('test', new PhpReader()); - CakePlugin::load('TestPlugin'); - $result = Configure::load('TestPlugin.load', 'test'); - $this->assertTrue($result); - $expected = '/test_app/plugins/test_plugin/config/load.php'; - $config = Configure::read('plugin_load'); - $this->assertEquals($expected, $config); - - $result = Configure::load('TestPlugin.more.load', 'test'); - $this->assertTrue($result); - $expected = '/test_app/plugins/test_plugin/config/more.load.php'; - $config = Configure::read('plugin_more_load'); - $this->assertEquals($expected, $config); - CakePlugin::unload(); - } - -/** - * testStore method - * - * @return void - */ - public function testStoreAndRestore() { - Configure::write('Cache.disable', false); - - Configure::write('Testing', 'yummy'); - $this->assertTrue(Configure::store('store_test', 'default')); - - Configure::delete('Testing'); - $this->assertNull(Configure::read('Testing')); - - Configure::restore('store_test', 'default'); - $this->assertEquals('yummy', Configure::read('Testing')); - - Cache::delete('store_test', 'default'); - } - -/** - * test that store and restore only store/restore the provided data. - * - * @return void - */ - public function testStoreAndRestoreWithData() { - Configure::write('Cache.disable', false); - - Configure::write('testing', 'value'); - Configure::store('store_test', 'default', array('store_test' => 'one')); - Configure::delete('testing'); - $this->assertNull(Configure::read('store_test'), 'Calling store with data shouldn\'t modify runtime.'); - - Configure::restore('store_test', 'default'); - $this->assertEquals('one', Configure::read('store_test')); - $this->assertNull(Configure::read('testing'), 'Values that were not stored are not restored.'); - - Cache::delete('store_test', 'default'); - } - -/** - * testVersion method - * - * @return void - */ - public function testVersion() { - $result = Configure::version(); - $this->assertTrue(version_compare($result, '1.2', '>=')); - } - -/** - * test adding new readers. - * - * @return void - */ - public function testReaderSetup() { - $reader = new PhpReader(); - Configure::config('test', $reader); - $configured = Configure::configured(); - - $this->assertTrue(in_array('test', $configured)); - - $this->assertTrue(Configure::configured('test')); - $this->assertFalse(Configure::configured('fake_garbage')); - - $this->assertTrue(Configure::drop('test')); - $this->assertFalse(Configure::drop('test'), 'dropping things that do not exist should return false.'); - } - -/** - * test reader() throwing exceptions on missing interface. - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testReaderExceptionOnIncorrectClass() { - $reader = new StdClass(); - Configure::config('test', $reader); - } - -} diff --git a/lib/Cake/Test/Case/Core/ObjectTest.php b/lib/Cake/Test/Case/Core/ObjectTest.php deleted file mode 100644 index 0258d040265..00000000000 --- a/lib/Cake/Test/Case/Core/ObjectTest.php +++ /dev/null @@ -1,672 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Core - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Object', 'Core'); -App::uses('Router', 'Routing'); -App::uses('Controller', 'Controller'); -App::uses('Model', 'Model'); - -/** - * RequestActionPost class - * - * @package Cake.Test.Case.Core - */ -class RequestActionPost extends CakeTestModel { - -/** - * name property - * - * @var string 'ControllerPost' - */ - public $name = 'RequestActionPost'; - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'posts'; -} - -/** - * RequestActionController class - * - * @package Cake.Test.Case.Core - */ -class RequestActionController extends Controller { - -/** - * uses property - * - * @var array - * @access public - */ - public $uses = array('RequestActionPost'); - -/** - * test_request_action method - * - * @access public - * @return void - */ - public function test_request_action() { - return 'This is a test'; - } - -/** - * another_ra_test method - * - * @param mixed $id - * @param mixed $other - * @access public - * @return void - */ - public function another_ra_test($id, $other) { - return $id + $other; - } - -/** - * normal_request_action method - * - * @return void - */ - public function normal_request_action() { - return 'Hello World'; - } - -/** - * returns $this->here - * - * @return void - */ - public function return_here() { - return $this->here; - } - -/** - * paginate_request_action method - * - * @return void - */ - public function paginate_request_action() { - $data = $this->paginate(); - return true; - } - -/** - * post pass, testing post passing - * - * @return array - */ - public function post_pass() { - return $this->request->data; - } - -/** - * test param passing and parsing. - * - * @return array - */ - public function params_pass() { - return $this->request; - } - - public function param_check() { - $this->autoRender = false; - $content = ''; - if (isset($this->request->params[0])) { - $content = 'return found'; - } - $this->response->body($content); - } - -} - -/** - * TestObject class - * - * @package Cake.Test.Case.Core - */ -class TestObject extends Object { - -/** - * firstName property - * - * @var string 'Joel' - */ - public $firstName = 'Joel'; - -/** - * lastName property - * - * @var string 'Moss' - */ - public $lastName = 'Moss'; - -/** - * methodCalls property - * - * @var array - */ - public $methodCalls = array(); - -/** - * emptyMethod method - * - * @return void - */ - public function emptyMethod() { - $this->methodCalls[] = 'emptyMethod'; - } - -/** - * oneParamMethod method - * - * @param mixed $param - * @return void - */ - public function oneParamMethod($param) { - $this->methodCalls[] = array('oneParamMethod' => array($param)); - } - -/** - * twoParamMethod method - * - * @param mixed $param - * @param mixed $paramTwo - * @return void - */ - public function twoParamMethod($param, $paramTwo) { - $this->methodCalls[] = array('twoParamMethod' => array($param, $paramTwo)); - } - -/** - * threeParamMethod method - * - * @param mixed $param - * @param mixed $paramTwo - * @param mixed $paramThree - * @return void - */ - public function threeParamMethod($param, $paramTwo, $paramThree) { - $this->methodCalls[] = array('threeParamMethod' => array($param, $paramTwo, $paramThree)); - } - -/** - * fourParamMethod method - * - * @param mixed $param - * @param mixed $paramTwo - * @param mixed $paramThree - * @param mixed $paramFour - * @return void - */ - public function fourParamMethod($param, $paramTwo, $paramThree, $paramFour) { - $this->methodCalls[] = array('fourParamMethod' => array($param, $paramTwo, $paramThree, $paramFour)); - } - -/** - * fiveParamMethod method - * - * @param mixed $param - * @param mixed $paramTwo - * @param mixed $paramThree - * @param mixed $paramFour - * @param mixed $paramFive - * @return void - */ - public function fiveParamMethod($param, $paramTwo, $paramThree, $paramFour, $paramFive) { - $this->methodCalls[] = array('fiveParamMethod' => array($param, $paramTwo, $paramThree, $paramFour, $paramFive)); - } - -/** - * crazyMethod method - * - * @param mixed $param - * @param mixed $paramTwo - * @param mixed $paramThree - * @param mixed $paramFour - * @param mixed $paramFive - * @param mixed $paramSix - * @param mixed $paramSeven - * @return void - */ - public function crazyMethod($param, $paramTwo, $paramThree, $paramFour, $paramFive, $paramSix, $paramSeven = null) { - $this->methodCalls[] = array('crazyMethod' => array($param, $paramTwo, $paramThree, $paramFour, $paramFive, $paramSix, $paramSeven)); - } - -/** - * methodWithOptionalParam method - * - * @param mixed $param - * @return void - */ - public function methodWithOptionalParam($param = null) { - $this->methodCalls[] = array('methodWithOptionalParam' => array($param)); - } - -/** - * undocumented function - * - * @return void - */ - public function set($properties = array()) { - return parent::_set($properties); - } - -} - -/** - * ObjectTestModel class - * - * @package Cake.Test.Case.Core - */ -class ObjectTestModel extends CakeTestModel { - - public $useTable = false; - - public $name = 'ObjectTestModel'; - -} - -/** - * Object Test class - * - * @package Cake.Test.Case.Core - */ -class ObjectTest extends CakeTestCase { - -/** - * fixtures - * - * @var string - */ - public $fixtures = array('core.post', 'core.test_plugin_comment', 'core.comment'); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->object = new TestObject(); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - App::build(); - CakePlugin::unload(); - unset($this->object); - } - -/** - * testLog method - * - * @return void - */ - public function testLog() { - if (file_exists(LOGS . 'error.log')) { - unlink(LOGS . 'error.log'); - } - $this->assertTrue($this->object->log('Test warning 1')); - $this->assertTrue($this->object->log(array('Test' => 'warning 2'))); - $result = file(LOGS . 'error.log'); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Error: Test warning 1$/', $result[0]); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Error: Array$/', $result[1]); - $this->assertRegExp('/^\($/', $result[2]); - $this->assertRegExp('/\[Test\] => warning 2$/', $result[3]); - $this->assertRegExp('/^\)$/', $result[4]); - unlink(LOGS . 'error.log'); - - $this->assertTrue($this->object->log('Test warning 1', LOG_WARNING)); - $this->assertTrue($this->object->log(array('Test' => 'warning 2'), LOG_WARNING)); - $result = file(LOGS . 'error.log'); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Warning: Test warning 1$/', $result[0]); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Warning: Array$/', $result[1]); - $this->assertRegExp('/^\($/', $result[2]); - $this->assertRegExp('/\[Test\] => warning 2$/', $result[3]); - $this->assertRegExp('/^\)$/', $result[4]); - unlink(LOGS . 'error.log'); - } - -/** - * testSet method - * - * @return void - */ - public function testSet() { - $this->object->set('a string'); - $this->assertEquals('Joel', $this->object->firstName); - - $this->object->set(array('firstName')); - $this->assertEquals('Joel', $this->object->firstName); - - $this->object->set(array('firstName' => 'Ashley')); - $this->assertEquals('Ashley', $this->object->firstName); - - $this->object->set(array('firstName' => 'Joel', 'lastName' => 'Moose')); - $this->assertEquals('Joel', $this->object->firstName); - $this->assertEquals('Moose', $this->object->lastName); - } - -/** - * testToString method - * - * @return void - */ - public function testToString() { - $result = strtolower($this->object->toString()); - $this->assertEquals('testobject', $result); - } - -/** - * testMethodDispatching method - * - * @return void - */ - public function testMethodDispatching() { - $this->object->emptyMethod(); - $expected = array('emptyMethod'); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->oneParamMethod('Hello'); - $expected[] = array('oneParamMethod' => array('Hello')); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->twoParamMethod(true, false); - $expected[] = array('twoParamMethod' => array(true, false)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->threeParamMethod(true, false, null); - $expected[] = array('threeParamMethod' => array(true, false, null)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->crazyMethod(1, 2, 3, 4, 5, 6, 7); - $expected[] = array('crazyMethod' => array(1, 2, 3, 4, 5, 6, 7)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object = new TestObject(); - $this->assertSame($this->object->methodCalls, array()); - - $this->object->dispatchMethod('emptyMethod'); - $expected = array('emptyMethod'); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('oneParamMethod', array('Hello')); - $expected[] = array('oneParamMethod' => array('Hello')); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('twoParamMethod', array(true, false)); - $expected[] = array('twoParamMethod' => array(true, false)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('threeParamMethod', array(true, false, null)); - $expected[] = array('threeParamMethod' => array(true, false, null)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('fourParamMethod', array(1, 2, 3, 4)); - $expected[] = array('fourParamMethod' => array(1, 2, 3, 4)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('fiveParamMethod', array(1, 2, 3, 4, 5)); - $expected[] = array('fiveParamMethod' => array(1, 2, 3, 4, 5)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('crazyMethod', array(1, 2, 3, 4, 5, 6, 7)); - $expected[] = array('crazyMethod' => array(1, 2, 3, 4, 5, 6, 7)); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('methodWithOptionalParam', array('Hello')); - $expected[] = array('methodWithOptionalParam' => array("Hello")); - $this->assertSame($this->object->methodCalls, $expected); - - $this->object->dispatchMethod('methodWithOptionalParam'); - $expected[] = array('methodWithOptionalParam' => array(null)); - $this->assertSame($this->object->methodCalls, $expected); - } - -/** - * testRequestAction method - * - * @return void - */ - public function testRequestAction() { - App::build(array( - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS), - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS), - 'Controller' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Controller' . DS) - ), App::RESET); - $this->assertNull(Router::getRequest(), 'request stack should be empty.'); - - $result = $this->object->requestAction(''); - $this->assertFalse($result); - - $result = $this->object->requestAction('/request_action/test_request_action'); - $expected = 'This is a test'; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction(FULL_BASE_URL . '/request_action/test_request_action'); - $expected = 'This is a test'; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction('/request_action/another_ra_test/2/5'); - $expected = 7; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction('/tests_apps/index', array('return')); - $expected = 'This is the TestsAppsController index view '; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction('/tests_apps/some_method'); - $expected = 5; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction('/request_action/paginate_request_action'); - $this->assertTrue($result); - - $result = $this->object->requestAction('/request_action/normal_request_action'); - $expected = 'Hello World'; - $this->assertEquals($expected, $result); - - $this->assertNull(Router::getRequest(), 'requests were not popped off the stack, this will break url generation'); - } - -/** - * test requestAction() and plugins. - * - * @return void - */ - public function testRequestActionPlugins() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - ), App::RESET); - CakePlugin::load('TestPlugin'); - Router::reload(); - - $result = $this->object->requestAction('/test_plugin/tests/index', array('return')); - $expected = 'test plugin index'; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction('/test_plugin/tests/index/some_param', array('return')); - $expected = 'test plugin index'; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction( - array('controller' => 'tests', 'action' => 'index', 'plugin' => 'test_plugin'), array('return') - ); - $expected = 'test plugin index'; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction('/test_plugin/tests/some_method'); - $expected = 25; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction( - array('controller' => 'tests', 'action' => 'some_method', 'plugin' => 'test_plugin') - ); - $expected = 25; - $this->assertEquals($expected, $result); - } - -/** - * test requestAction() with arrays. - * - * @return void - */ - public function testRequestActionArray() { - App::build(array( - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS), - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS), - 'Controller' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Controller' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin')); - - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'test_request_action') - ); - $expected = 'This is a test'; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'another_ra_test'), - array('pass' => array('5', '7')) - ); - $expected = 12; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction( - array('controller' => 'tests_apps', 'action' => 'index'), array('return') - ); - $expected = 'This is the TestsAppsController index view '; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction(array('controller' => 'tests_apps', 'action' => 'some_method')); - $expected = 5; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'normal_request_action') - ); - $expected = 'Hello World'; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'paginate_request_action') - ); - $this->assertTrue($result); - - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'paginate_request_action'), - array('pass' => array(5), 'named' => array('param' => 'value')) - ); - $this->assertTrue($result); - } - -/** - * Test that requestAction() does not forward the 0 => return value. - * - * @return void - */ - public function testRequestActionRemoveReturnParam() { - $result = $this->object->requestAction( - '/request_action/param_check', array('return') - ); - $this->assertEquals('', $result, 'Return key was found'); - } - -/** - * Test that requestAction() is populating $this->params properly - * - * @return void - */ - public function testRequestActionParamParseAndPass() { - $result = $this->object->requestAction('/request_action/params_pass'); - $this->assertEquals('request_action/params_pass', $result->url); - $this->assertEquals('request_action', $result['controller']); - $this->assertEquals('params_pass', $result['action']); - $this->assertEquals(null, $result['plugin']); - - $result = $this->object->requestAction('/request_action/params_pass/sort:desc/limit:5'); - $expected = array('sort' => 'desc', 'limit' => 5,); - $this->assertEquals($expected, $result['named']); - - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'params_pass'), - array('named' => array('sort' => 'desc', 'limit' => 5)) - ); - $this->assertEquals($expected, $result['named']); - } - -/** - * test that requestAction does not fish data out of the POST - * superglobal. - * - * @return void - */ - public function testRequestActionNoPostPassing() { - $_tmp = $_POST; - - $_POST = array('data' => array( - 'item' => 'value' - )); - $result = $this->object->requestAction(array('controller' => 'request_action', 'action' => 'post_pass')); - $expected = null; - $this->assertEmpty($result); - - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'post_pass'), - array('data' => $_POST['data']) - ); - $expected = $_POST['data']; - $this->assertEquals($expected, $result); - - $result = $this->object->requestAction('/request_action/post_pass'); - $expected = $_POST['data']; - $this->assertEquals($expected, $result); - - $_POST = $_tmp; - } - -/** - * Test requestAction with post data. - * - * @return void - */ - public function testRequestActionPostWithData() { - $data = array( - 'Post' => array('id' => 2) - ); - $result = $this->object->requestAction( - array('controller' => 'request_action', 'action' => 'post_pass'), - array('data' => $data) - ); - $this->assertEquals($data, $result); - - $result = $this->object->requestAction( - '/request_action/post_pass', - array('data' => $data) - ); - $this->assertEquals($data, $result); - } -} diff --git a/lib/Cake/Test/Case/Error/ErrorHandlerTest.php b/lib/Cake/Test/Case/Error/ErrorHandlerTest.php deleted file mode 100644 index 0385361eacb..00000000000 --- a/lib/Cake/Test/Case/Error/ErrorHandlerTest.php +++ /dev/null @@ -1,246 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Error - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('ErrorHandler', 'Error'); -App::uses('Controller', 'Controller'); -App::uses('Router', 'Routing'); - -/** - * ErrorHandlerTest class - * - * @package Cake.Test.Case.Error - */ -class ErrorHandlerTest extends CakeTestCase { - - protected $_restoreError = false; - -/** - * setup create a request object to get out of router later. - * - * @return void - */ - public function setUp() { - parent::setUp(); - App::build(array( - 'View' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS - ) - ), App::RESET); - Router::reload(); - - $request = new CakeRequest(null, false); - $request->base = ''; - Router::setRequestInfo($request); - $this->_debug = Configure::read('debug'); - $this->_error = Configure::read('Error'); - Configure::write('debug', 2); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - Configure::write('debug', $this->_debug); - Configure::write('Error', $this->_error); - App::build(); - if ($this->_restoreError) { - restore_error_handler(); - } - parent::tearDown(); - } - -/** - * test error handling when debug is on, an error should be printed from Debugger. - * - * @return void - */ - public function testHandleErrorDebugOn() { - set_error_handler('ErrorHandler::handleError'); - $this->_restoreError = true; - - ob_start(); - $wrong .= ''; - $result = ob_get_clean(); - - $this->assertRegExp('/
/', $result);
-		$this->assertRegExp('/Notice<\/b>/', $result);
-		$this->assertRegExp('/variable:\s+wrong/', $result);
-	}
-
-/**
- * provides errors for mapping tests.
- *
- * @return void
- */
-	public static function errorProvider() {
-		return array(
-			array(E_USER_NOTICE, 'Notice'),
-			array(E_USER_WARNING, 'Warning'),
-			array(E_USER_ERROR, 'Fatal Error'),
-		);
-	}
-
-/**
- * test error mappings
- *
- * @dataProvider errorProvider
- * @return void
- */
-	public function testErrorMapping($error, $expected) {
-		set_error_handler('ErrorHandler::handleError');
-		$this->_restoreError = true;
-
-		ob_start();
-		trigger_error('Test error', $error);
-
-		$result = ob_get_clean();
-		$this->assertRegExp('/' . $expected . '<\/b>/', $result);
-	}
-
-/**
- * test error prepended by @
- *
- * @return void
- */
-	public function testErrorSuppressed() {
-		set_error_handler('ErrorHandler::handleError');
-		$this->_restoreError = true;
-
-		ob_start();
-		@include 'invalid.file';
-		$result = ob_get_clean();
-		$this->assertTrue(empty($result));
-	}
-
-/**
- * Test that errors go into CakeLog when debug = 0.
- *
- * @return void
- */
-	public function testHandleErrorDebugOff() {
-		Configure::write('debug', 0);
-		Configure::write('Error.trace', false);
-		if (file_exists(LOGS . 'debug.log')) {
-			@unlink(LOGS . 'debug.log');
-		}
-
-		set_error_handler('ErrorHandler::handleError');
-		$this->_restoreError = true;
-
-		$out .= '';
-
-		$result = file(LOGS . 'debug.log');
-		$this->assertEquals(1, count($result));
-		$this->assertRegExp(
-			'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (Notice|Debug): Notice \(8\): Undefined variable:\s+out in \[.+ line \d+\]$/',
-			$result[0]
-		);
-		@unlink(LOGS . 'debug.log');
-	}
-
-/**
- * Test that errors going into CakeLog include traces.
- *
- * @return void
- */
-	public function testHandleErrorLoggingTrace() {
-		Configure::write('debug', 0);
-		Configure::write('Error.trace', true);
-		if (file_exists(LOGS . 'debug.log')) {
-			@unlink(LOGS . 'debug.log');
-		}
-
-		set_error_handler('ErrorHandler::handleError');
-		$this->_restoreError = true;
-
-		$out .= '';
-
-		$result = file(LOGS . 'debug.log');
-		$this->assertRegExp(
-			'/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (Notice|Debug): Notice \(8\): Undefined variable:\s+out in \[.+ line \d+\]$/',
-			$result[0]
-		);
-		$this->assertRegExp('/^Trace:/', $result[1]);
-		$this->assertRegExp('/^ErrorHandlerTest\:\:testHandleErrorLoggingTrace\(\)/', $result[2]);
-		@unlink(LOGS . 'debug.log');
-	}
-
-/**
- * test handleException generating a page.
- *
- * @return void
- */
-	public function testHandleException() {
-		$this->skipIf(file_exists(APP . 'app_error.php'), 'App error exists cannot run.');
-
-		$error = new NotFoundException('Kaboom!');
-		ob_start();
-		ErrorHandler::handleException($error);
-		$result = ob_get_clean();
-		$this->assertRegExp('/Kaboom!/', $result, 'message missing.');
-	}
-
-/**
- * test handleException generating a page.
- *
- * @return void
- */
-	public function testHandleExceptionLog() {
-		$this->skipIf(file_exists(APP . 'app_error.php'), 'App error exists cannot run.');
-
-		if (file_exists(LOGS . 'error.log')) {
-			unlink(LOGS . 'error.log');
-		}
-		Configure::write('Exception.log', true);
-		$error = new NotFoundException('Kaboom!');
-
-		ob_start();
-		ErrorHandler::handleException($error);
-		$result = ob_get_clean();
-		$this->assertRegExp('/Kaboom!/', $result, 'message missing.');
-
-		$log = file(LOGS . 'error.log');
-		$this->assertRegExp('/\[NotFoundException\] Kaboom!/', $log[0], 'message missing.');
-		$this->assertRegExp('/\#0.*ErrorHandlerTest->testHandleExceptionLog/', $log[1], 'Stack trace missing.');
-	}
-
-/**
- * tests it is possible to load a plugin exception renderer
- *
- * @return void
- */
-	public function testLoadPluginHanlder() {
-		App::build(array(
-			'Plugin' => array(
-				CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS
-			)
-		), App::RESET);
-		CakePlugin::load('TestPlugin');
-		Configure::write('Exception.renderer', 'TestPlugin.TestPluginExceptionRenderer');
-		$error = new NotFoundException('Kaboom!');
-		ob_start();
-		ErrorHandler::handleException($error);
-		$result = ob_get_clean();
-		$this->assertEquals('Rendered by test plugin', $result);
-		CakePlugin::unload();
-	}
-
-}
diff --git a/lib/Cake/Test/Case/Error/ExceptionRendererTest.php b/lib/Cake/Test/Case/Error/ExceptionRendererTest.php
deleted file mode 100644
index 42c5dfc6571..00000000000
--- a/lib/Cake/Test/Case/Error/ExceptionRendererTest.php
+++ /dev/null
@@ -1,742 +0,0 @@
-
- * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice
- *
- * @copyright     Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests
- * @package       Cake.Test.Case.Error
- * @since         CakePHP(tm) v 2.0
- * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
- */
-
-App::uses('ExceptionRenderer', 'Error');
-App::uses('Controller', 'Controller');
-App::uses('AppController', 'Controller');
-App::uses('Component', 'Controller');
-App::uses('Router', 'Routing');
-
-/**
- * Short description for class.
- *
- * @package       Cake.Test.Case.Error
- */
-class AuthBlueberryUser extends CakeTestModel {
-
-/**
- * name property
- *
- * @var string 'AuthBlueberryUser'
- */
-	public $name = 'AuthBlueberryUser';
-
-/**
- * useTable property
- *
- * @var string
- */
-	public $useTable = false;
-}
-
-/**
- * BlueberryComponent class
- *
- * @package       Cake.Test.Case.Error
- */
-class BlueberryComponent extends Component {
-
-/**
- * testName property
- *
- * @return void
- */
-	public $testName = null;
-
-/**
- * initialize method
- *
- * @return void
- */
-	public function initialize(Controller $controller) {
-		$this->testName = 'BlueberryComponent';
-	}
-
-}
-
-/**
- * TestErrorController class
- *
- * @package       Cake.Test.Case.Error
- */
-class TestErrorController extends Controller {
-
-/**
- * uses property
- *
- * @var array
- */
-	public $uses = array();
-
-/**
- * components property
- *
- * @return void
- */
-	public $components = array('Blueberry');
-
-/**
- * beforeRender method
- *
- * @return void
- */
-	public function beforeRender() {
-		echo $this->Blueberry->testName;
-	}
-
-/**
- * index method
- *
- * @return void
- */
-	public function index() {
-		$this->autoRender = false;
-		return 'what up';
-	}
-
-}
-
-/**
- * MyCustomExceptionRenderer class
- *
- * @package       Cake.Test.Case.Error
- */
-class MyCustomExceptionRenderer extends ExceptionRenderer {
-
-/**
- * custom error message type.
- *
- * @return void
- */
-	public function missingWidgetThing() {
-		echo 'widget thing is missing';
-	}
-
-}
-
-/**
- * Exception class for testing app error handlers and custom errors.
- *
- * @package       Cake.Test.Case.Error
- */
-class MissingWidgetThingException extends NotFoundException {
-}
-
-
-/**
- * ExceptionRendererTest class
- *
- * @package       Cake.Test.Case.Error
- */
-class ExceptionRendererTest extends CakeTestCase {
-
-	protected $_restoreError = false;
-
-/**
- * setup create a request object to get out of router later.
- *
- * @return void
- */
-	public function setUp() {
-		parent::setUp();
-		App::build(array(
-			'View' => array(
-				CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS
-			)
-		), App::RESET);
-		Router::reload();
-
-		$request = new CakeRequest(null, false);
-		$request->base = '';
-		Router::setRequestInfo($request);
-		$this->_debug = Configure::read('debug');
-		$this->_error = Configure::read('Error');
-		Configure::write('debug', 2);
-	}
-
-/**
- * tearDown
- *
- * @return void
- */
-	public function tearDown() {
-		Configure::write('debug', $this->_debug);
-		Configure::write('Error', $this->_error);
-		App::build();
-		if ($this->_restoreError) {
-			restore_error_handler();
-		}
-		parent::tearDown();
-	}
-
-/**
- * Mocks out the response on the ExceptionRenderer object so headers aren't modified.
- *
- * @return void
- */
-	protected function _mockResponse($error) {
-		$error->controller->response = $this->getMock('CakeResponse', array('_sendHeader'));
-		return $error;
-	}
-
-/**
- * test that methods declared in an ExceptionRenderer subclass are not converted
- * into error400 when debug > 0
- *
- * @return void
- */
-	public function testSubclassMethodsNotBeingConvertedToError() {
-		Configure::write('debug', 2);
-
-		$exception = new MissingWidgetThingException('Widget not found');
-		$ExceptionRenderer = $this->_mockResponse(new MyCustomExceptionRenderer($exception));
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertEquals('widget thing is missing', $result);
-	}
-
-/**
- * test that subclass methods are not converted when debug = 0
- *
- * @return void
- */
-	public function testSubclassMethodsNotBeingConvertedDebug0() {
-		Configure::write('debug', 0);
-		$exception = new MissingWidgetThingException('Widget not found');
-		$ExceptionRenderer = $this->_mockResponse(new MyCustomExceptionRenderer($exception));
-
-		$this->assertEquals('missingWidgetThing', $ExceptionRenderer->method);
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertEquals('widget thing is missing', $result, 'Method declared in subclass converted to error400');
-	}
-
-/**
- * test that ExceptionRenderer subclasses properly convert framework errors.
- *
- * @return void
- */
-	public function testSubclassConvertingFrameworkErrors() {
-		Configure::write('debug', 0);
-
-		$exception = new MissingControllerException('PostsController');
-		$ExceptionRenderer = $this->_mockResponse(new MyCustomExceptionRenderer($exception));
-
-		$this->assertEquals('error400', $ExceptionRenderer->method);
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertRegExp('/Not Found/', $result, 'Method declared in error handler not converted to error400. %s');
-	}
-
-/**
- * test things in the constructor.
- *
- * @return void
- */
-	public function testConstruction() {
-		$exception = new NotFoundException('Page not found');
-		$ExceptionRenderer = new ExceptionRenderer($exception);
-
-		$this->assertInstanceOf('CakeErrorController', $ExceptionRenderer->controller);
-		$this->assertEquals('error400', $ExceptionRenderer->method);
-		$this->assertEquals($exception, $ExceptionRenderer->error);
-	}
-
-/**
- * test that method gets coerced when debug = 0
- *
- * @return void
- */
-	public function testErrorMethodCoercion() {
-		Configure::write('debug', 0);
-		$exception = new MissingActionException('Page not found');
-		$ExceptionRenderer = new ExceptionRenderer($exception);
-
-		$this->assertInstanceOf('CakeErrorController', $ExceptionRenderer->controller);
-		$this->assertEquals('error400', $ExceptionRenderer->method);
-		$this->assertEquals($exception, $ExceptionRenderer->error);
-	}
-
-/**
- * test that unknown exception types with valid status codes are treated correctly.
- *
- * @return void
- */
-	public function testUnknownExceptionTypeWithExceptionThatHasA400Code() {
-		$exception = new MissingWidgetThingException('coding fail.');
-		$ExceptionRenderer = new ExceptionRenderer($exception);
-		$ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader'));
-		$ExceptionRenderer->controller->response->expects($this->once())->method('statusCode')->with(404);
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertFalse(method_exists($ExceptionRenderer, 'missingWidgetThing'), 'no method should exist.');
-		$this->assertEquals('error400', $ExceptionRenderer->method, 'incorrect method coercion.');
-		$this->assertContains('coding fail', $result, 'Text should show up.');
-	}
-
-/**
- * test that unknown exception types with valid status codes are treated correctly.
- *
- * @return void
- */
-	public function testUnknownExceptionTypeWithNoCodeIsA500() {
-		$exception = new OutOfBoundsException('foul ball.');
-		$ExceptionRenderer = new ExceptionRenderer($exception);
-		$ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader'));
-		$ExceptionRenderer->controller->response->expects($this->once())
-			->method('statusCode')
-			->with(500);
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertEquals('error500', $ExceptionRenderer->method, 'incorrect method coercion.');
-		$this->assertContains('foul ball.', $result, 'Text should show up as its debug mode.');
-	}
-
-/**
- * test that unknown exceptions have messages ignored.
- *
- * @return void
- */
-	public function testUnknownExceptionInProduction() {
-		Configure::write('debug', 0);
-
-		$exception = new OutOfBoundsException('foul ball.');
-		$ExceptionRenderer = new ExceptionRenderer($exception);
-		$ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader'));
-		$ExceptionRenderer->controller->response->expects($this->once())
-			->method('statusCode')
-			->with(500);
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertEquals('error500', $ExceptionRenderer->method, 'incorrect method coercion.');
-		$this->assertNotContains('foul ball.', $result, 'Text should no show up.');
-		$this->assertContains('Internal Error', $result, 'Generic message only.');
-	}
-
-/**
- * test that unknown exception types with valid status codes are treated correctly.
- *
- * @return void
- */
-	public function testUnknownExceptionTypeWithCodeHigherThan500() {
-		$exception = new OutOfBoundsException('foul ball.', 501);
-		$ExceptionRenderer = new ExceptionRenderer($exception);
-		$ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader'));
-		$ExceptionRenderer->controller->response->expects($this->once())->method('statusCode')->with(501);
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertEquals('error500', $ExceptionRenderer->method, 'incorrect method coercion.');
-		$this->assertContains('foul ball.', $result, 'Text should show up as its debug mode.');
-	}
-
-/**
- * testerror400 method
- *
- * @return void
- */
-	public function testError400() {
-		Router::reload();
-
-		$request = new CakeRequest('posts/view/1000', false);
-		Router::setRequestInfo($request);
-
-		$exception = new NotFoundException('Custom message');
-		$ExceptionRenderer = new ExceptionRenderer($exception);
-		$ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader'));
-		$ExceptionRenderer->controller->response->expects($this->once())->method('statusCode')->with(404);
-
-		ob_start();
-		$ExceptionRenderer->render();
-		$result = ob_get_clean();
-
-		$this->assertRegExp('/

Custom message<\/h2>/', $result); - $this->assertRegExp("/'.*?\/posts\/view\/1000'<\/strong>/", $result); - } - -/** - * test that error400 only modifies the messages on CakeExceptions. - * - * @return void - */ - public function testerror400OnlyChangingCakeException() { - Configure::write('debug', 0); - - $exception = new NotFoundException('Custom message'); - $ExceptionRenderer = $this->_mockResponse(new ExceptionRenderer($exception)); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - $this->assertContains('Custom message', $result); - - $exception = new MissingActionException(array('controller' => 'PostsController', 'action' => 'index')); - $ExceptionRenderer = $this->_mockResponse(new ExceptionRenderer($exception)); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - $this->assertContains('Not Found', $result); - } - -/** - * test that error400 doesn't expose XSS - * - * @return void - */ - public function testError400NoInjection() { - Router::reload(); - - $request = new CakeRequest('pages/pink', false); - Router::setRequestInfo($request); - - $exception = new NotFoundException('Custom message'); - $ExceptionRenderer = $this->_mockResponse(new ExceptionRenderer($exception)); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - - $this->assertNotRegExp('##', $result); - } - -/** - * testError500 method - * - * @return void - */ - public function testError500Message() { - $exception = new InternalErrorException('An Internal Error Has Occurred'); - $ExceptionRenderer = new ExceptionRenderer($exception); - $ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader')); - $ExceptionRenderer->controller->response->expects($this->once())->method('statusCode')->with(500); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - - $this->assertRegExp('/

An Internal Error Has Occurred<\/h2>/', $result); - } - -/** - * testMissingController method - * - * @return void - */ - public function testMissingController() { - $exception = new MissingControllerException(array('class' => 'PostsController')); - $ExceptionRenderer = $this->_mockResponse(new ExceptionRenderer($exception)); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - - $this->assertRegExp('/

Missing Controller<\/h2>/', $result); - $this->assertRegExp('/PostsController<\/em>/', $result); - } - -/** - * Returns an array of tests to run for the various CakeException classes. - * - * @return void - */ - public static function testProvider() { - return array( - array( - new MissingActionException(array('controller' => 'PostsController', 'action' => 'index')), - array( - '/

Missing Method in PostsController<\/h2>/', - '/PostsController::<\/em>index\(\)<\/em>/' - ), - 404 - ), - array( - new PrivateActionException(array('controller' => 'PostsController' , 'action' => '_secretSauce')), - array( - '/

Private Method in PostsController<\/h2>/', - '/PostsController::<\/em>_secretSauce\(\)<\/em>/' - ), - 404 - ), - array( - new MissingTableException(array('table' => 'articles', 'class' => 'Article', 'ds' => 'test')), - array( - '/

Missing Database Table<\/h2>/', - '/Table articles<\/em> for model Article<\/em> was not found in datasource test<\/em>/' - ), - 500 - ), - array( - new MissingDatabaseException(array('connection' => 'default')), - array( - '/

Missing Database Connection<\/h2>/', - '/Confirm you have created the file/' - ), - 500 - ), - array( - new MissingViewException(array('file' => '/posts/about.ctp')), - array( - "/posts\/about.ctp/" - ), - 500 - ), - array( - new MissingLayoutException(array('file' => 'layouts/my_layout.ctp')), - array( - "/Missing Layout/", - "/layouts\/my_layout.ctp/" - ), - 500 - ), - array( - new MissingConnectionException(array('class' => 'Article')), - array( - '/

Missing Database Connection<\/h2>/', - '/Article requires a database connection/' - ), - 500 - ), - array( - new MissingDatasourceConfigException(array('config' => 'default')), - array( - '/

Missing Datasource Configuration<\/h2>/', - '/The datasource configuration default<\/em> was not found in database.php/' - ), - 500 - ), - array( - new MissingDatasourceException(array('class' => 'MyDatasource', 'plugin' => 'MyPlugin')), - array( - '/

Missing Datasource<\/h2>/', - '/Datasource class MyPlugin.MyDatasource<\/em> could not be found/' - ), - 500 - ), - array( - new MissingHelperException(array('class' => 'MyCustomHelper')), - array( - '/

Missing Helper<\/h2>/', - '/MyCustomHelper<\/em> could not be found./', - '/Create the class MyCustomHelper<\/em> below in file:/', - '/(\/|\\\)MyCustomHelper.php/' - ), - 500 - ), - array( - new MissingBehaviorException(array('class' => 'MyCustomBehavior')), - array( - '/

Missing Behavior<\/h2>/', - '/Create the class MyCustomBehavior<\/em> below in file:/', - '/(\/|\\\)MyCustomBehavior.php/' - ), - 500 - ), - array( - new MissingComponentException(array('class' => 'SideboxComponent')), - array( - '/

Missing Component<\/h2>/', - '/Create the class SideboxComponent<\/em> below in file:/', - '/(\/|\\\)SideboxComponent.php/' - ), - 500 - ), - array( - new Exception('boom'), - array( - '/Internal Error/' - ), - 500 - ), - array( - new RuntimeException('another boom'), - array( - '/Internal Error/' - ), - 500 - ), - array( - new CakeException('base class'), - array('/Internal Error/'), - 500 - ), - array( - new ConfigureException('No file'), - array('/Internal Error/'), - 500 - ) - ); - } - -/** - * Test the various CakeException sub classes - * - * @dataProvider testProvider - * @return void - */ - public function testCakeExceptionHandling($exception, $patterns, $code) { - $ExceptionRenderer = new ExceptionRenderer($exception); - $ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader')); - $ExceptionRenderer->controller->response->expects($this->once()) - ->method('statusCode') - ->with($code); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - - foreach ($patterns as $pattern) { - $this->assertRegExp($pattern, $result); - } - } - -/** - * Test exceptions being raised when helpers are missing. - * - * @return void - */ - public function testMissingRenderSafe() { - $exception = new MissingHelperException(array('class' => 'Fail')); - $ExceptionRenderer = new ExceptionRenderer($exception); - - $ExceptionRenderer->controller = $this->getMock('Controller'); - $ExceptionRenderer->controller->helpers = array('Fail', 'Boom'); - $ExceptionRenderer->controller->request = $this->getMock('CakeRequest'); - $ExceptionRenderer->controller->expects($this->at(2)) - ->method('render') - ->with('missingHelper') - ->will($this->throwException($exception)); - - $ExceptionRenderer->controller->expects($this->at(4)) - ->method('render') - ->with('error500') - ->will($this->returnValue(true)); - - $ExceptionRenderer->controller->response = $this->getMock('CakeResponse'); - $ExceptionRenderer->render(); - sort($ExceptionRenderer->controller->helpers); - $this->assertEquals(array('Form', 'Html', 'Session'), $ExceptionRenderer->controller->helpers); - } - -/** - * Test that missing subDir/layoutPath don't cause other fatal errors. - * - * @return void - */ - public function testMissingSubdirRenderSafe() { - $exception = new NotFoundException(); - $ExceptionRenderer = new ExceptionRenderer($exception); - - $ExceptionRenderer->controller = $this->getMock('Controller'); - $ExceptionRenderer->controller->helpers = array('Fail', 'Boom'); - $ExceptionRenderer->controller->layoutPath = 'json'; - $ExceptionRenderer->controller->subDir = 'json'; - $ExceptionRenderer->controller->viewClass = 'Json'; - $ExceptionRenderer->controller->request = $this->getMock('CakeRequest'); - - $ExceptionRenderer->controller->expects($this->at(1)) - ->method('render') - ->with('error400') - ->will($this->throwException($exception)); - - $ExceptionRenderer->controller->expects($this->at(3)) - ->method('render') - ->with('error500') - ->will($this->returnValue(true)); - - $ExceptionRenderer->controller->response = $this->getMock('CakeResponse'); - $ExceptionRenderer->controller->response->expects($this->once()) - ->method('type') - ->with('html'); - - $ExceptionRenderer->render(); - $this->assertEquals('', $ExceptionRenderer->controller->layoutPath); - $this->assertEquals('', $ExceptionRenderer->controller->subDir); - $this->assertEquals('View', $ExceptionRenderer->controller->viewClass); - $this->assertEquals('Errors/', $ExceptionRenderer->controller->viewPath); - } - -/** - * Test that exceptions can be rendered when an request hasn't been registered - * with Router - * - * @return void - */ - public function testRenderWithNoRequest() { - Router::reload(); - $this->assertNull(Router::getRequest(false)); - - $exception = new Exception('Terrible'); - $ExceptionRenderer = new ExceptionRenderer($exception); - $ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader')); - $ExceptionRenderer->controller->response->expects($this->once()) - ->method('statusCode') - ->with(500); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - - $this->assertContains('Internal Error', $result); - } - -/** - * Tests the output of rendering a PDOException - * - * @return void - */ - public function testPDOException() { - $exception = new PDOException('There was an error in the SQL query'); - $exception->queryString = 'SELECT * from poo_query < 5 and :seven'; - $exception->params = array('seven' => 7); - $ExceptionRenderer = new ExceptionRenderer($exception); - $ExceptionRenderer->controller->response = $this->getMock('CakeResponse', array('statusCode', '_sendHeader')); - $ExceptionRenderer->controller->response->expects($this->once())->method('statusCode')->with(500); - - ob_start(); - $ExceptionRenderer->render(); - $result = ob_get_clean(); - - $this->assertContains('

Database Error

', $result); - $this->assertContains('There was an error in the SQL query', $result); - $this->assertContains('SELECT * from poo_query < 5 and :seven', $result); - $this->assertContains("'seven' => (int) 7", $result); - } -} diff --git a/lib/Cake/Test/Case/Event/CakeEventManagerTest.php b/lib/Cake/Test/Case/Event/CakeEventManagerTest.php deleted file mode 100644 index 04a358ab4a8..00000000000 --- a/lib/Cake/Test/Case/Event/CakeEventManagerTest.php +++ /dev/null @@ -1,406 +0,0 @@ -callStack[] = __FUNCTION__; - } - -/** - * Test function to be used in event dispatching - * - * @return void - */ - public function secondListenerFunction() { - $this->callStack[] = __FUNCTION__; - } - -/** - * Auxiliary function to help in stopPropagation testing - * - * @param CakeEvent $event - * @return void - */ - public function stopListener($event) { - $event->stopPropagation(); - } - -} - -/** - * Mock used for testing the subscriber objects - * - * @package Cake.Test.Case.Event - */ -class CustomTestEventListerner extends CakeEventTestListener implements CakeEventListener { - - public function implementedEvents() { - return array( - 'fake.event' => 'listenerFunction', - 'another.event' => array('callable' => 'secondListenerFunction', 'passParams' => true), - 'multiple.handlers' => array( - array('callable' => 'listenerFunction'), - array('callable' => 'thirdListenerFunction') - ) - ); - } - -/** - * Test function to be used in event dispatching - * - * @return void - */ - public function thirdListenerFunction() { - $this->callStack[] = __FUNCTION__; - } - -} - -/** - * Tests the CakeEventManager class functionality - * - */ -class CakeEventManagerTest extends CakeTestCase { - -/** - * Tests the attach() method for a single event key in multiple queues - * - * @return void - */ - public function testAttachListeners() { - $manager = new CakeEventManager; - $manager->attach('fakeFunction', 'fake.event'); - $expected = array( - array('callable' => 'fakeFunction', 'passParams' => false) - ); - $this->assertEquals($expected, $manager->listeners('fake.event')); - - $manager->attach('fakeFunction2', 'fake.event'); - $expected[] = array('callable' => 'fakeFunction2', 'passParams' => false); - $this->assertEquals($expected, $manager->listeners('fake.event')); - - $manager->attach('inQ5', 'fake.event', array('priority' => 5)); - $manager->attach('inQ1', 'fake.event', array('priority' => 1)); - $manager->attach('otherInQ5', 'fake.event', array('priority' => 5)); - - $expected = array_merge( - array( - array('callable' => 'inQ1', 'passParams' => false), - array('callable' => 'inQ5', 'passParams' => false), - array('callable' => 'otherInQ5', 'passParams' => false) - ), - $expected - ); - $this->assertEquals($expected, $manager->listeners('fake.event')); - } - -/** - * Tests the attach() method for multiple event key in multiple queues - * - * @return void - */ - public function testAttachMultipleEventKeys() { - $manager = new CakeEventManager; - $manager->attach('fakeFunction', 'fake.event'); - $manager->attach('fakeFunction2', 'another.event'); - $manager->attach('fakeFunction3', 'another.event', array('priority' => 1, 'passParams' => true)); - $expected = array( - array('callable' => 'fakeFunction', 'passParams' => false) - ); - $this->assertEquals($expected, $manager->listeners('fake.event')); - - $expected = array( - array('callable' => 'fakeFunction3', 'passParams' => true), - array('callable' => 'fakeFunction2', 'passParams' => false) - ); - $this->assertEquals($expected, $manager->listeners('another.event')); - } - -/** - * Tests detaching an event from a event key queue - * - * @return void - */ - public function testDetach() { - $manager = new CakeEventManager; - $manager->attach(array('AClass', 'aMethod'), 'fake.event'); - $manager->attach(array('AClass', 'anotherMethod'), 'another.event'); - $manager->attach('fakeFunction', 'another.event', array('priority' => 1)); - - $manager->detach(array('AClass', 'aMethod'), 'fake.event'); - $this->assertEquals(array(), $manager->listeners('fake.event')); - - $manager->detach(array('AClass', 'anotherMethod'), 'another.event'); - $expected = array( - array('callable' => 'fakeFunction', 'passParams' => false) - ); - $this->assertEquals($expected, $manager->listeners('another.event')); - - $manager->detach('fakeFunction', 'another.event'); - $this->assertEquals(array(), $manager->listeners('another.event')); - } - -/** - * Tests detaching an event from all event queues - * - * @return void - */ - public function testDetachFromAll() { - $manager = new CakeEventManager; - $manager->attach(array('AClass', 'aMethod'), 'fake.event'); - $manager->attach(array('AClass', 'aMethod'), 'another.event'); - $manager->attach('fakeFunction', 'another.event', array('priority' => 1)); - - $manager->detach(array('AClass', 'aMethod')); - $expected = array( - array('callable' => 'fakeFunction', 'passParams' => false) - ); - $this->assertEquals($expected, $manager->listeners('another.event')); - $this->assertEquals(array(), $manager->listeners('fake.event')); - } - -/** - * Tests event dispatching - * - * @return void - */ - public function testDispatch() { - $manager = new CakeEventManager; - $listener = $this->getMock('CakeEventTestListener'); - $anotherListener = $this->getMock('CakeEventTestListener'); - $manager->attach(array($listener, 'listenerFunction'), 'fake.event'); - $manager->attach(array($anotherListener, 'listenerFunction'), 'fake.event'); - $event = new CakeEvent('fake.event'); - - $listener->expects($this->once())->method('listenerFunction')->with($event); - $anotherListener->expects($this->once())->method('listenerFunction')->with($event); - $manager->dispatch($event); - } - -/** - * Tests event dispatching using event key name - * - * @return void - */ - public function testDispatchWithKeyName() { - $manager = new CakeEventManager; - $listener = new CakeEventTestListener; - $manager->attach(array($listener, 'listenerFunction'), 'fake.event'); - $event = 'fake.event'; - $manager->dispatch($event); - - $expected = array('listenerFunction'); - $this->assertEquals($expected, $listener->callStack); - } - -/** - * Tests event dispatching with a return value - * - * @return void - */ - public function testDispatchReturnValue() { - $manager = new CakeEventManager; - $listener = $this->getMock('CakeEventTestListener'); - $anotherListener = $this->getMock('CakeEventTestListener'); - $manager->attach(array($listener, 'listenerFunction'), 'fake.event'); - $manager->attach(array($anotherListener, 'listenerFunction'), 'fake.event'); - $event = new CakeEvent('fake.event'); - - $firstStep = clone $event; - $listener->expects($this->at(0))->method('listenerFunction') - ->with($firstStep) - ->will($this->returnValue('something special')); - $anotherListener->expects($this->at(0))->method('listenerFunction')->with($event); - $manager->dispatch($event); - $this->assertEquals('something special', $event->result); - } - -/** - * Tests that returning false in a callback stops the event - * - * @return void - */ - public function testDispatchFalseStopsEvent() { - $manager = new CakeEventManager; - $listener = $this->getMock('CakeEventTestListener'); - $anotherListener = $this->getMock('CakeEventTestListener'); - $manager->attach(array($listener, 'listenerFunction'), 'fake.event'); - $manager->attach(array($anotherListener, 'listenerFunction'), 'fake.event'); - $event = new CakeEvent('fake.event'); - - $originalEvent = clone $event; - $listener->expects($this->at(0))->method('listenerFunction') - ->with($originalEvent) - ->will($this->returnValue(false)); - $anotherListener->expects($this->never())->method('listenerFunction'); - $manager->dispatch($event); - $this->assertTrue($event->isStopped()); - } - -/** - * Tests event dispatching using priorities - * - * @return void - */ - public function testDispatchPrioritized() { - $manager = new CakeEventManager; - $listener = new CakeEventTestListener; - $manager->attach(array($listener, 'listenerFunction'), 'fake.event'); - $manager->attach(array($listener, 'secondListenerFunction'), 'fake.event', array('priority' => 5)); - $event = new CakeEvent('fake.event'); - $manager->dispatch($event); - - $expected = array('secondListenerFunction', 'listenerFunction'); - $this->assertEquals($expected, $listener->callStack); - } - -/** - * Tests event dispatching with passed params - * - * @return void - */ - public function testDispatchPassingParams() { - $manager = new CakeEventManager; - $listener = $this->getMock('CakeEventTestListener'); - $anotherListener = $this->getMock('CakeEventTestListener'); - $manager->attach(array($listener, 'listenerFunction'), 'fake.event'); - $manager->attach(array($anotherListener, 'secondListenerFunction'), 'fake.event', array('passParams' => true)); - $event = new CakeEvent('fake.event', $this, array('some' => 'data')); - - $listener->expects($this->once())->method('listenerFunction')->with($event); - $anotherListener->expects($this->once())->method('secondListenerFunction')->with('data'); - $manager->dispatch($event); - } - -/** - * Tests subscribing a listener object and firing the events it subscribed to - * - * @return void - */ - public function testAttachSubscriber() { - $manager = new CakeEventManager; - $listener = $this->getMock('CustomTestEventListerner', array('secondListenerFunction')); - $manager->attach($listener); - $event = new CakeEvent('fake.event'); - - $manager->dispatch($event); - - $expected = array('listenerFunction'); - $this->assertEquals($expected, $listener->callStack); - - $listener->expects($this->at(0))->method('secondListenerFunction')->with('data'); - $event = new CakeEvent('another.event', $this, array('some' => 'data')); - $manager->dispatch($event); - - $manager = new CakeEventManager; - $listener = $this->getMock('CustomTestEventListerner', array('listenerFunction', 'thirdListenerFunction')); - $manager->attach($listener); - $event = new CakeEvent('multiple.handlers'); - $listener->expects($this->once())->method('listenerFunction')->with($event); - $listener->expects($this->once())->method('thirdListenerFunction')->with($event); - $manager->dispatch($event); - } - -/** - * Tests subscribing a listener object and firing the events it subscribed to - * - * @return void - */ - public function testDetachSubscriber() { - $manager = new CakeEventManager; - $listener = $this->getMock('CustomTestEventListerner', array('secondListenerFunction')); - $manager->attach($listener); - $expected = array( - array('callable' => array($listener, 'secondListenerFunction'), 'passParams' => true) - ); - $this->assertEquals($expected, $manager->listeners('another.event')); - $expected = array( - array('callable' => array($listener, 'listenerFunction'), 'passParams' => false) - ); - $this->assertEquals($expected, $manager->listeners('fake.event')); - $manager->detach($listener); - $this->assertEquals(array(), $manager->listeners('fake.event')); - $this->assertEquals(array(), $manager->listeners('another.event')); - } - -/** - * Tests that it is possible to get/set the manager singleton - * - * @return void - */ - public function testGlobalDispatcherGetter() { - $this->assertInstanceOf('CakeEventManager', CakeEventManager::instance()); - $manager = new CakeEventManager; - - CakeEventManager::instance($manager); - $this->assertSame($manager, CakeEventManager::instance()); - } - -/** - * Tests that the global event manager gets the event too from any other manager - * - * @return void - */ - public function testDispatchWithGlobal() { - $generalManager = $this->getMock('CakeEventManager', array('dispatch')); - $manager = new CakeEventManager; - $event = new CakeEvent('fake.event'); - CakeEventManager::instance($generalManager); - - $generalManager->expects($this->once())->method('dispatch')->with($event); - $manager->dispatch($event); - } - -/** - * Tests that stopping an event will not notify the rest of the listeners - * - * @return void - */ - public function testStopPropagation() { - $manager = new CakeEventManager; - $listener = new CakeEventTestListener; - $manager->attach(array($listener, 'listenerFunction'), 'fake.event'); - $manager->attach(array($listener, 'stopListener'), 'fake.event', array('priority' => 8)); - $manager->attach(array($listener, 'secondListenerFunction'), 'fake.event', array('priority' => 5)); - $event = new CakeEvent('fake.event'); - $manager->dispatch($event); - - $expected = array('secondListenerFunction'); - $this->assertEquals($expected, $listener->callStack); - } -} diff --git a/lib/Cake/Test/Case/Event/CakeEventTest.php b/lib/Cake/Test/Case/Event/CakeEventTest.php deleted file mode 100644 index 592a93a82eb..00000000000 --- a/lib/Cake/Test/Case/Event/CakeEventTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertEquals('fake.event', $event->name()); - } - -/** - * Tests the subject() method - * - * @return void - */ - public function testSubject() { - $event = new CakeEvent('fake.event', $this); - $this->assertSame($this, $event->subject()); - - $event = new CakeEvent('fake.event'); - $this->assertNull($event->subject()); - } - -/** - * Tests the event propagation stopping property - * - * @return void - */ - public function testPropagation() { - $event = new CakeEvent('fake.event'); - $this->assertFalse($event->isStopped()); - $event->stopPropagation(); - $this->assertTrue($event->isStopped()); - } - -/** - * Tests that it is possible to get/set custom data in a event - * - * @return void - */ - public function testEventData() { - $event = new CakeEvent('fake.event', $this, array('some' => 'data')); - $this->assertEquals(array('some' => 'data'), $event->data); - } - -/** - * Tests that it is possible to get the name and subject directly - * - * @return void - */ - public function testEventDirectPropertyAccess() { - $event = new CakeEvent('fake.event', $this); - $this->assertEquals($this, $event->subject); - $this->assertEquals('fake.event', $event->name); - } -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/I18n/I18nTest.php b/lib/Cake/Test/Case/I18n/I18nTest.php deleted file mode 100644 index 3f6dff13d50..00000000000 --- a/lib/Cake/Test/Case/I18n/I18nTest.php +++ /dev/null @@ -1,1952 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.I18n - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('I18n', 'I18n'); - -/** - * I18nTest class - * - * @package Cake.Test.Case.I18n - */ -class I18nTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - Cache::delete('object_map', '_cake_core_'); - App::build(array( - 'Locale' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Locale' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin')); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - Cache::delete('object_map', '_cake_core_'); - App::build(); - CakePlugin::unload(); - } - -/** - * testTranslationCaching method - * - * @return void - */ - public function testTranslationCaching() { - Configure::write('Config.language', 'cache_test_po'); - - // reset internally stored entries - I18n::clear(); - - Cache::clear(false, '_cake_core_'); - $lang = Configure::read('Config.language'); - - Cache::config('_cake_core_', Cache::config('default')); - - // make some calls to translate using different domains - $this->assertEquals('Dom 1 Foo', I18n::translate('dom1.foo', false, 'dom1')); - $this->assertEquals('Dom 1 Bar', I18n::translate('dom1.bar', false, 'dom1')); - $domains = I18n::domains(); - $this->assertEquals('Dom 1 Foo', $domains['dom1']['cache_test_po']['LC_MESSAGES']['dom1.foo']); - - // reset internally stored entries - I18n::clear(); - - // now only dom1 should be in cache - $cachedDom1 = Cache::read('dom1_' . $lang, '_cake_core_'); - $this->assertEquals('Dom 1 Foo', $cachedDom1['LC_MESSAGES']['dom1.foo']); - $this->assertEquals('Dom 1 Bar', $cachedDom1['LC_MESSAGES']['dom1.bar']); - // dom2 not in cache - $this->assertFalse(Cache::read('dom2_' . $lang, '_cake_core_')); - - // translate a item of dom2 (adds dom2 to cache) - $this->assertEquals('Dom 2 Foo', I18n::translate('dom2.foo', false, 'dom2')); - - // verify dom2 was cached through manual read from cache - $cachedDom2 = Cache::read('dom2_' . $lang, '_cake_core_'); - $this->assertEquals('Dom 2 Foo', $cachedDom2['LC_MESSAGES']['dom2.foo']); - $this->assertEquals('Dom 2 Bar', $cachedDom2['LC_MESSAGES']['dom2.bar']); - - // modify cache entry manually to verify that dom1 entries now will be read from cache - $cachedDom1['LC_MESSAGES']['dom1.foo'] = 'FOO'; - Cache::write('dom1_' . $lang, $cachedDom1, '_cake_core_'); - $this->assertEquals('FOO', I18n::translate('dom1.foo', false, 'dom1')); - } - -/** - * testDefaultStrings method - * - * @return void - */ - public function testDefaultStrings() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 1', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('1 = 1', $plurals)); - $this->assertTrue(in_array('2 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('3 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('4 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('5 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('6 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('7 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('8 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('9 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('10 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('11 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('12 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('13 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('14 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('15 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('16 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('17 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('18 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('19 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('20 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('21 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('22 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('23 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('24 = 0 or > 1', $plurals)); - $this->assertTrue(in_array('25 = 0 or > 1', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 1 (from core)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('1 = 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('2 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('3 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('4 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('5 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('6 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('7 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('8 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('9 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('10 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('11 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('12 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('13 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('14 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('15 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('16 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('17 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('18 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('19 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('20 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('21 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('22 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('23 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('24 = 0 or > 1 (from core)', $corePlurals)); - $this->assertTrue(in_array('25 = 0 or > 1 (from core)', $corePlurals)); - } - -/** - * testPoRulesZero method - * - * @return void - */ - public function testPoRulesZero() { - Configure::write('Config.language', 'rule_0_po'); - $this->assertRulesZero(); - } - -/** - * testMoRulesZero method - * - * @return void - */ - public function testMoRulesZero() { - Configure::write('Config.language', 'rule_0_mo'); - $this->assertRulesZero(); - } - -/** - * Assertions for rules zero. - * - * @return - */ - public function assertRulesZero() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 0 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('1 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('2 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('3 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('4 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('5 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('6 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('7 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('8 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('9 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('10 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('11 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('12 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('13 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('14 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('15 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('16 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('17 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('18 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('19 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('20 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('21 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('22 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('23 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('24 ends with any # (translated)', $plurals)); - $this->assertTrue(in_array('25 ends with any # (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 0 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 ends with any # (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 ends with any # (from core translated)', $corePlurals)); - } - -/** - * testPoRulesOne method - * - * @return void - */ - public function testPoRulesOne() { - Configure::write('Config.language', 'rule_1_po'); - $this->assertRulesOne(); - } - -/** - * testMoRulesOne method - * - * @return void - */ - public function testMoRulesOne() { - Configure::write('Config.language', 'rule_1_mo'); - $this->assertRulesOne(); - } - -/** - * Assertions for plural rule one - * - * @return void - */ - public function assertRulesOne() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 1 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('1 = 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('3 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('4 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('5 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('6 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('7 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('8 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('9 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('10 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('11 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('12 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('13 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('14 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('15 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('16 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('17 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('18 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('19 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('20 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('21 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('22 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('23 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('24 = 0 or > 1 (translated)', $plurals)); - $this->assertTrue(in_array('25 = 0 or > 1 (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 1 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 = 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 = 0 or > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 = 0 or > 1 (from core translated)', $corePlurals)); - } - -/** - * testMoRulesTwo method - * - * @return void - */ - public function testMoRulesTwo() { - Configure::write('Config.language', 'rule_2_mo'); - $this->assertRulesTwo(); - } - -/** - * testPoRulesTwo method - * - * @return void - */ - public function testPoRulesTwo() { - Configure::write('Config.language', 'rule_2_po'); - $this->assertRulesTwo(); - } - -/** - * Assertions for rules Two - * - * @return void - */ - public function assertRulesTwo() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 2 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 = 0 or 1 (translated)', $plurals)); - $this->assertTrue(in_array('1 = 0 or 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('3 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('4 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('5 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('6 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('7 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('8 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('9 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('10 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('11 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('12 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('13 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('14 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('15 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('16 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('17 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('18 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('19 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('20 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('21 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('22 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('23 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('24 > 1 (translated)', $plurals)); - $this->assertTrue(in_array('25 > 1 (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 2 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 = 0 or 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 = 0 or 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 > 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 > 1 (from core translated)', $corePlurals)); - } - -/** - * testPoRulesThree method - * - * @return void - */ - public function testPoRulesThree() { - Configure::write('Config.language', 'rule_3_po'); - $this->assertRulesThree(); - } - -/** - * testMoRulesThree method - * - * @return void - */ - public function testMoRulesThree() { - Configure::write('Config.language', 'rule_3_mo'); - $this->assertRulesThree(); - } - -/** - * Assert rules for plural three. - * - * @return void - */ - public function assertRulesThree() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 3 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 = 0 (translated)', $plurals)); - $this->assertTrue(in_array('1 ends 1 but not 11 (translated)', $plurals)); - $this->assertTrue(in_array('2 everything else (translated)', $plurals)); - $this->assertTrue(in_array('3 everything else (translated)', $plurals)); - $this->assertTrue(in_array('4 everything else (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 ends 1 but not 11 (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 3 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 = 0 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 ends 1 but not 11 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 ends 1 but not 11 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesFour method - * - * @return void - */ - public function testPoRulesFour() { - Configure::write('Config.language', 'rule_4_po'); - $this->assertRulesFour(); - } - -/** - * testMoRulesFour method - * - * @return void - */ - public function testMoRulesFour() { - Configure::write('Config.language', 'rule_4_mo'); - $this->assertRulesFour(); - } - -/** - * Run the assertions for Rule 4 plurals. - * - * @return void - */ - public function assertRulesFour() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 4 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('1 = 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 = 2 (translated)', $plurals)); - $this->assertTrue(in_array('3 everything else (translated)', $plurals)); - $this->assertTrue(in_array('4 everything else (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 4 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 = 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 = 2 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesFive method - * - * @return void - */ - public function testPoRulesFive() { - Configure::write('Config.language', 'rule_5_po'); - $this->assertRulesFive(); - } - -/** - * testMoRulesFive method - * - * @return void - */ - public function testMoRulesFive() { - Configure::write('Config.language', 'rule_5_mo'); - $this->assertRulesFive(); - } - -/** - * Run the assertions for rule 5 plurals - * - * @return void - */ - public function assertRulesFive() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 5 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('0 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('1 = 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('3 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('4 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('5 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('6 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('7 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('8 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('9 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('10 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('11 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('12 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('13 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('14 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('15 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('16 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('17 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('18 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('19 = 0 or ends in 01-19 (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 5 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('0 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 = 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 = 0 or ends in 01-19 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesSix method - * - * @return void - */ - public function testPoRulesSix() { - Configure::write('Config.language', 'rule_6_po'); - $this->assertRulesSix(); - } - -/** - * testMoRulesSix method - * - * @return void - */ - public function testMoRulesSix() { - Configure::write('Config.language', 'rule_6_mo'); - $this->assertRulesSix(); - } - -/** - * Assertions for the sixth plural rules. - * - * @return void - */ - public function assertRulesSix() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 6 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('1 ends in 1, not 11 (translated)', $plurals)); - $this->assertTrue(in_array('2 everything else (translated)', $plurals)); - $this->assertTrue(in_array('3 everything else (translated)', $plurals)); - $this->assertTrue(in_array('4 everything else (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('11 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('12 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('13 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('14 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('15 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('16 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('17 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('18 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('19 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('20 ends in 0 or ends in 10-20 (translated)', $plurals)); - $this->assertTrue(in_array('21 ends in 1, not 11 (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 6 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 ends in 1, not 11 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 ends in 0 or ends in 10-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 ends in 1, not 11 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesSeven method - * - * @return void - */ - public function testPoRulesSeven() { - Configure::write('Config.language', 'rule_7_po'); - $this->assertRulesSeven(); - } - -/** - * testMoRulesSeven method - * - * @return void - */ - public function testMoRulesSeven() { - Configure::write('Config.language', 'rule_7_mo'); - $this->assertRulesSeven(); - } - -/** - * Run assertions for seventh plural rules - * - * @return void - */ - public function assertRulesSeven() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 7 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('1 ends in 1, not 11 (translated)', $plurals)); - $this->assertTrue(in_array('2 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('3 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('4 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 ends in 1, not 11 (translated)', $plurals)); - $this->assertTrue(in_array('22 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('23 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('24 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 7 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 ends in 1, not 11 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 ends in 1, not 11 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesEight method - * - * @return void - */ - public function testPoRulesEight() { - Configure::write('Config.language', 'rule_8_po'); - $this->assertRulesEight(); - } - -/** - * testMoRulesEight method - * - * @return void - */ - public function testMoRulesEight() { - Configure::write('Config.language', 'rule_8_mo'); - $this->assertRulesEight(); - } - -/** - * Run assertions for the eighth plural rule. - * - * @return void - */ - public function assertRulesEight() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 8 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('1 is 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 is 2-4 (translated)', $plurals)); - $this->assertTrue(in_array('3 is 2-4 (translated)', $plurals)); - $this->assertTrue(in_array('4 is 2-4 (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 8 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 is 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 is 2-4 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 is 2-4 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 is 2-4 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesNine method - * - * @return void - */ - public function testPoRulesNine() { - Configure::write('Config.language', 'rule_9_po'); - $this->assertRulesNine(); - } - -/** - * testMoRulesNine method - * - * @return void - */ - public function testMoRulesNine() { - Configure::write('Config.language', 'rule_9_mo'); - $this->assertRulesNine(); - } - -/** - * Assert plural rules nine - * - * @return void - */ - public function assertRulesNine() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 9 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('1 is 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('3 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('4 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('23 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('24 ends in 2-4, not 12-14 (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 9 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 is 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 ends in 2-4, not 12-14 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesTen method - * - * @return void - */ - public function testPoRulesTen() { - Configure::write('Config.language', 'rule_10_po'); - $this->assertRulesTen(); - } - -/** - * testMoRulesTen method - * - * @return void - */ - public function testMoRulesTen() { - Configure::write('Config.language', 'rule_10_mo'); - $this->assertRulesTen(); - } - -/** - * Assertions for plural rules 10 - * - * @return void - */ - public function assertRulesTen() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 10 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('1 ends in 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 ends in 2 (translated)', $plurals)); - $this->assertTrue(in_array('3 ends in 03-04 (translated)', $plurals)); - $this->assertTrue(in_array('4 ends in 03-04 (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 10 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 ends in 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 ends in 2 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 ends in 03-04 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 ends in 03-04 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesEleven method - * - * @return void - */ - public function testPoRulesEleven() { - Configure::write('Config.language', 'rule_11_po'); - $this->assertRulesEleven(); - } - -/** - * testMoRulesEleven method - * - * @return void - */ - public function testMoRulesEleven() { - Configure::write('Config.language', 'rule_11_mo'); - $this->assertRulesEleven(); - } - -/** - * Assertions for plural rules eleven - * - * @return void - */ - public function assertRulesEleven() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 11 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('1 is 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 is 2 (translated)', $plurals)); - $this->assertTrue(in_array('3 is 3-6 (translated)', $plurals)); - $this->assertTrue(in_array('4 is 3-6 (translated)', $plurals)); - $this->assertTrue(in_array('5 is 3-6 (translated)', $plurals)); - $this->assertTrue(in_array('6 is 3-6 (translated)', $plurals)); - $this->assertTrue(in_array('7 is 7-10 (translated)', $plurals)); - $this->assertTrue(in_array('8 is 7-10 (translated)', $plurals)); - $this->assertTrue(in_array('9 is 7-10 (translated)', $plurals)); - $this->assertTrue(in_array('10 is 7-10 (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 11 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 is 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 is 2 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 is 3-6 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 is 3-6 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 is 3-6 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 is 3-6 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 is 7-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 is 7-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 is 7-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 is 7-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPoRulesTwelve method - * - * @return void - */ - public function testPoRulesTwelve() { - Configure::write('Config.language', 'rule_12_po'); - $this->assertRulesTwelve(); - } - -/** - * testMoRulesTwelve method - * - * @return void - */ - public function testMoRulesTwelve() { - Configure::write('Config.language', 'rule_12_mo'); - $this->assertRulesTwelve(); - } - -/** - * Assertions for plural rules twelve - * - * @return void - */ - public function assertRulesTwelve() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 12 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('1 is 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 is 2 (translated)', $plurals)); - $this->assertTrue(in_array('3 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('4 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('5 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('6 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('7 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('8 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('9 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('10 is 0 or 3-10 (translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 12 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 is 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 is 2 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 is 0 or 3-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testMoRulesThirteen method - * - * @return void - */ - public function testmoRulesThirteen() { - Configure::write('Config.language', 'rule_13_mo'); - $this->assertRulesThirteen(); - } - -/** - * testPoRulesThirteen method - * - * @return void - */ - public function testPoRulesThirteen() { - Configure::write('Config.language', 'rule_13_po'); - $this->assertRulesThirteen(); - } - -/** - * Assertions for plural rules thirteen - * - * @return void - */ - public function assertRulesThirteen() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 13 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('1 is 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('3 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('4 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('5 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('6 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('7 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('8 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('9 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('10 is 0 or ends in 01-10 (translated)', $plurals)); - $this->assertTrue(in_array('11 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('12 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('13 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('14 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('15 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('16 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('17 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('18 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('19 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('20 ends in 11-20 (translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 13 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 is 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 is 0 or ends in 01-10 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 ends in 11-20 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testMoRulesFourteen method - * - * @return void - */ - public function testMoRulesFourteen() { - Configure::write('Config.language', 'rule_14_mo'); - $this->assertRulesFourteen(); - } - -/** - * testPoRulesFourteen method - * - * @return void - */ - public function testPoRulesFourteen() { - Configure::write('Config.language', 'rule_14_po'); - $this->assertRulesFourteen(); - } - -/** - * Assertions for plural rules fourteen - * - * @return void - */ - public function assertRulesFourteen() { - $singular = $this->__singular(); - $this->assertEquals('Plural Rule 14 (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (translated)', $plurals)); - $this->assertTrue(in_array('1 ends in 1 (translated)', $plurals)); - $this->assertTrue(in_array('2 ends in 2 (translated)', $plurals)); - $this->assertTrue(in_array('3 everything else (translated)', $plurals)); - $this->assertTrue(in_array('4 everything else (translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (translated)', $plurals)); - $this->assertTrue(in_array('11 ends in 1 (translated)', $plurals)); - $this->assertTrue(in_array('12 ends in 2 (translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (translated)', $plurals)); - $this->assertTrue(in_array('21 ends in 1 (translated)', $plurals)); - $this->assertTrue(in_array('22 ends in 2 (translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (translated)', $plurals)); - - $coreSingular = $this->__singularFromCore(); - $this->assertEquals('Plural Rule 14 (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertTrue(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('1 ends in 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('2 ends in 2 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('3 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('4 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('11 ends in 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('12 ends in 2 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('21 ends in 1 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('22 ends in 2 (from core translated)', $corePlurals)); - $this->assertTrue(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertTrue(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testSetLanguageWithSession method - * - * @return void - */ - public function testSetLanguageWithSession() { - $_SESSION['Config']['language'] = 'po'; - $singular = $this->__singular(); - $this->assertEquals('Po (translated)', $singular); - - $plurals = $this->__plural(); - $this->assertTrue(in_array('0 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('1 is 1 (po translated)', $plurals)); - $this->assertTrue(in_array('2 is 2-4 (po translated)', $plurals)); - $this->assertTrue(in_array('3 is 2-4 (po translated)', $plurals)); - $this->assertTrue(in_array('4 is 2-4 (po translated)', $plurals)); - $this->assertTrue(in_array('5 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('6 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('7 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('8 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('9 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('10 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('11 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('12 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('13 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('14 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('15 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('16 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('17 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('18 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('19 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('20 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('21 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('22 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('23 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('24 everything else (po translated)', $plurals)); - $this->assertTrue(in_array('25 everything else (po translated)', $plurals)); - unset($_SESSION['Config']['language']); - } - -/** - * testNoCoreTranslation method - * - * @return void - */ - public function testNoCoreTranslation() { - Configure::write('Config.language', 'po'); - $singular = $this->__singular(); - $this->assertEquals('Po (translated)', $singular); - - $coreSingular = $this->__singularFromCore(); - $this->assertNotEquals('Po (from core translated)', $coreSingular); - - $corePlurals = $this->__pluralFromCore(); - $this->assertFalse(in_array('0 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('1 is 1 (from core translated)', $corePlurals)); - $this->assertFalse(in_array('2 is 2-4 (from core translated)', $corePlurals)); - $this->assertFalse(in_array('3 is 2-4 (from core translated)', $corePlurals)); - $this->assertFalse(in_array('4 is 2-4 (from core translated)', $corePlurals)); - $this->assertFalse(in_array('5 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('6 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('7 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('8 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('9 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('10 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('11 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('12 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('13 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('14 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('15 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('16 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('17 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('18 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('19 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('20 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('21 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('22 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('23 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('24 everything else (from core translated)', $corePlurals)); - $this->assertFalse(in_array('25 everything else (from core translated)', $corePlurals)); - } - -/** - * testPluginTranslation method - * - * @return void - */ - public function testPluginTranslation() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - - Configure::write('Config.language', 'po'); - $singular = $this->__domainSingular(); - $this->assertEquals('Plural Rule 1 (from plugin)', $singular); - - $plurals = $this->__domainPlural(); - $this->assertTrue(in_array('0 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('1 = 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('2 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('3 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('4 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('5 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('6 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('7 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('8 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('9 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('10 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('11 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('12 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('13 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('14 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('15 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('16 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('17 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('18 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('19 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('20 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('21 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('22 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('23 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('24 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('25 = 0 or > 1 (from plugin)', $plurals)); - } - -/** - * testPoMultipleLineTranslation method - * - * @return void - */ - public function testPoMultipleLineTranslation() { - Configure::write('Config.language', 'po'); - - $string = "This is a multiline translation\n"; - $string .= "broken up over multiple lines.\n"; - $string .= "This is the third line.\n"; - $string .= "This is the forth line."; - $result = __($string); - - $expected = "This is a multiline translation\n"; - $expected .= "broken up over multiple lines.\n"; - $expected .= "This is the third line.\n"; - $expected .= "This is the forth line. (translated)"; - $this->assertEquals($expected, $result); - - // Windows Newline is \r\n - $string = "This is a multiline translation\r\n"; - $string .= "broken up over multiple lines.\r\n"; - $string .= "This is the third line.\r\n"; - $string .= "This is the forth line."; - $result = __($string); - $this->assertEquals($expected, $result); - - $singular = "valid\nsecond line"; - $plural = "valids\nsecond line"; - - $result = __n($singular, $plural, 1); - $expected = "v\nsecond line"; - $this->assertEquals($expected, $result); - - $result = __n($singular, $plural, 2); - $expected = "vs\nsecond line"; - $this->assertEquals($expected, $result); - - $string = "This is a multiline translation\n"; - $string .= "broken up over multiple lines.\n"; - $string .= "This is the third line.\n"; - $string .= "This is the forth line."; - - $singular = "%d = 1\n" . $string; - $plural = "%d = 0 or > 1\n" . $string; - - $result = __n($singular, $plural, 1); - $expected = "%d is 1\n" . $string; - $this->assertEquals($expected, $result); - - $result = __n($singular, $plural, 2); - $expected = "%d is 2-4\n" . $string; - $this->assertEquals($expected, $result); - - // Windows Newline is \r\n - $string = "This is a multiline translation\r\n"; - $string .= "broken up over multiple lines.\r\n"; - $string .= "This is the third line.\r\n"; - $string .= "This is the forth line."; - - $singular = "%d = 1\r\n" . $string; - $plural = "%d = 0 or > 1\r\n" . $string; - - $result = __n($singular, $plural, 1); - $expected = "%d is 1\n" . str_replace("\r\n", "\n", $string); - $this->assertEquals($expected, $result); - - $result = __n($singular, $plural, 2); - $expected = "%d is 2-4\n" . str_replace("\r\n", "\n", $string); - $this->assertEquals($expected, $result); - } - -/** - * testPoNoTranslationNeeded method - * - * @return void - */ - public function testPoNoTranslationNeeded() { - Configure::write('Config.language', 'po'); - $result = __('No Translation needed'); - $this->assertEquals('No Translation needed', $result); - } - -/** - * testPoQuotedString method - * - * @return void - */ - public function testPoQuotedString() { - Configure::write('Config.language', 'po'); - $expected = 'this is a "quoted string" (translated)'; - $this->assertEquals($expected, __('this is a "quoted string"')); - } - -/** - * testFloatValue method - * - * @return void - */ - public function testFloatValue() { - Configure::write('Config.language', 'rule_9_po'); - - $result = __n('%d = 1', '%d = 0 or > 1', (float)1); - $expected = '%d is 1 (translated)'; - $this->assertEquals($expected, $result); - - $result = __n('%d = 1', '%d = 0 or > 1', (float)2); - $expected = "%d ends in 2-4, not 12-14 (translated)"; - $this->assertEquals($expected, $result); - - $result = __n('%d = 1', '%d = 0 or > 1', (float)5); - $expected = "%d everything else (translated)"; - $this->assertEquals($expected, $result); - } - -/** - * testCategory method - * - * @return void - */ - public function testCategory() { - Configure::write('Config.language', 'po'); - $category = $this->__category(); - $this->assertEquals('Monetary Po (translated)', $category); - } - -/** - * testPluginCategory method - * - * @return void - */ - public function testPluginCategory() { - Configure::write('Config.language', 'po'); - - $singular = $this->__domainCategorySingular(); - $this->assertEquals('Monetary Plural Rule 1 (from plugin)', $singular); - - $plurals = $this->__domainCategoryPlural(); - $this->assertTrue(in_array('Monetary 0 = 0 or > 1 (from plugin)', $plurals)); - $this->assertTrue(in_array('Monetary 1 = 1 (from plugin)', $plurals)); - } - -/** - * testCategoryThenSingular method - * - * @return void - */ - public function testCategoryThenSingular() { - Configure::write('Config.language', 'po'); - $category = $this->__category(); - $this->assertEquals('Monetary Po (translated)', $category); - - $singular = $this->__singular(); - $this->assertEquals('Po (translated)', $singular); - } - -/** - * testTimeDefinition method - * - * @return void - */ - public function testTimeDefinition() { - Configure::write('Config.language', 'po'); - $result = __c('d_fmt', 5); - $expected = '%m/%d/%Y'; - $this->assertEquals($expected, $result); - - $result = __c('am_pm', 5); - $expected = array('AM', 'PM'); - $this->assertEquals($expected, $result); - - $result = __c('abmon', 5); - $expected = array('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'); - $this->assertEquals($expected, $result); - } - -/** - * testTimeDefinitionJapanese method - * - * @return void - */ - public function testTimeDefinitionJapanese() { - Configure::write('Config.language', 'ja_jp'); - $result = __c('d_fmt', 5); - - $expected = "%Y年%m月%d日"; - - $this->assertEquals($expected, $result); - - $result = __c('am_pm', 5); - $expected = array("午前", "午後"); - $this->assertEquals($expected, $result); - - $result = __c('abmon', 5); - $expected = array(" 1月", " 2月", " 3月", " 4月", " 5月", " 6月", " 7月", " 8月", " 9月", "10月", "11月", "12月"); - $this->assertEquals($expected, $result); - } - -/** - * testTranslateLanguageParam method - * - * @return void - */ - public function testTranslateLanguageParam() { - Configure::write('Config.language', 'rule_0_po'); - - $result = I18n::translate('Plural Rule 1', null, null, 6); - $expected = 'Plural Rule 0 (translated)'; - $this->assertEquals($expected, $result); - - $result = I18n::translate('Plural Rule 1', null, null, 6, null, 'rule_1_po'); - $expected = 'Plural Rule 1 (translated)'; - $this->assertEquals($expected, $result); - } - -/** - * Singular method - * - * @return void - */ - private function __domainCategorySingular($domain = 'test_plugin', $category = 3) { - $singular = __dc($domain, 'Plural Rule 1', $category); - return $singular; - } - -/** - * Plural method - * - * @return void - */ - private function __domainCategoryPlural($domain = 'test_plugin', $category = 3) { - $plurals = array(); - for ($number = 0; $number <= 25; $number++) { - $plurals[] = sprintf(__dcn($domain, '%d = 1', '%d = 0 or > 1', (float)$number, $category), (float)$number); - } - return $plurals; - } - -/** - * Singular method - * - * @return void - */ - private function __domainSingular($domain = 'test_plugin') { - $singular = __d($domain, 'Plural Rule 1'); - return $singular; - } - -/** - * Plural method - * - * @return void - */ - private function __domainPlural($domain = 'test_plugin') { - $plurals = array(); - for ($number = 0; $number <= 25; $number++) { - $plurals[] = sprintf(__dn($domain, '%d = 1', '%d = 0 or > 1', (float)$number), (float)$number ); - } - return $plurals; - } - -/** - * category method - * - * @return void - */ - private function __category($category = 3) { - $singular = __c('Plural Rule 1', $category); - return $singular; - } - -/** - * Singular method - * - * @return void - */ - private function __singular() { - $singular = __('Plural Rule 1'); - return $singular; - } - -/** - * Plural method - * - * @return void - */ - private function __plural() { - $plurals = array(); - for ($number = 0; $number <= 25; $number++) { - $plurals[] = sprintf(__n('%d = 1', '%d = 0 or > 1', (float)$number), (float)$number); - } - return $plurals; - } - -/** - * singularFromCore method - * - * @return void - */ - private function __singularFromCore() { - $singular = __('Plural Rule 1 (from core)'); - return $singular; - } - -/** - * pluralFromCore method - * - * @return void - */ - private function __pluralFromCore() { - $plurals = array(); - for ($number = 0; $number <= 25; $number++) { - $plurals[] = sprintf(__n('%d = 1 (from core)', '%d = 0 or > 1 (from core)', (float)$number), (float)$number ); - } - return $plurals; - } -} diff --git a/lib/Cake/Test/Case/I18n/L10nTest.php b/lib/Cake/Test/Case/I18n/L10nTest.php deleted file mode 100644 index 1e7fb5777af..00000000000 --- a/lib/Cake/Test/Case/I18n/L10nTest.php +++ /dev/null @@ -1,951 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.I18n - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('L10n', 'I18n'); - -/** - * L10nTest class - * - * @package Cake.Test.Case.I18n - */ -class L10nTest extends CakeTestCase { - -/** - * testGet method - * - * @return void - */ - public function testGet() { - $localize = new L10n(); - - // Catalog Entry - $localize->get('en'); - - $this->assertEquals('English', $localize->language); - $this->assertEquals(array('eng', 'eng'), $localize->languagePath); - $this->assertEquals('eng', $localize->locale); - - // Map Entry - $localize->get('eng'); - - $this->assertEquals('English', $localize->language); - $this->assertEquals(array('eng', 'eng'), $localize->languagePath); - $this->assertEquals('eng', $localize->locale); - - // Catalog Entry - $localize->get('en-ca'); - - $this->assertEquals('English (Canadian)', $localize->language); - $this->assertEquals(array('en_ca', 'eng'), $localize->languagePath); - $this->assertEquals('en_ca', $localize->locale); - - // Default Entry - define('DEFAULT_LANGUAGE', 'en-us'); - - $localize->get('use_default'); - - $this->assertEquals('English (United States)', $localize->language); - $this->assertEquals(array('en_us', 'eng'), $localize->languagePath); - $this->assertEquals('en_us', $localize->locale); - - $localize->get('es'); - $localize->get(''); - $this->assertEquals('en-us', $localize->lang); - - // Using $this->default - $localize = new L10n(); - - $localize->get('use_default'); - $this->assertEquals('English (United States)', $localize->language); - $this->assertEquals(array('en_us', 'eng', 'eng'), $localize->languagePath); - $this->assertEquals('en_us', $localize->locale); - } - -/** - * testGetAutoLanguage method - * - * @return void - */ - public function testGetAutoLanguage() { - $serverBackup = $_SERVER; - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'inexistent,en-ca'; - - $localize = new L10n(); - $localize->get(); - - $this->assertEquals('English (Canadian)', $localize->language); - $this->assertEquals(array('en_ca', 'eng', 'eng'), $localize->languagePath); - $this->assertEquals('en_ca', $localize->locale); - - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'es_mx'; - $localize->get(); - - $this->assertEquals('Spanish (Mexican)', $localize->language); - $this->assertEquals(array('es_mx', 'spa', 'eng'), $localize->languagePath); - $this->assertEquals('es_mx', $localize->locale); - - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'en_xy,en_ca'; - $localize->get(); - - $this->assertEquals('English', $localize->language); - $this->assertEquals(array('eng', 'eng', 'eng'), $localize->languagePath); - $this->assertEquals('eng', $localize->locale); - - $_SERVER = $serverBackup; - } - -/** - * testMap method - * - * @return void - */ - public function testMap() { - $localize = new L10n(); - - $result = $localize->map(array('afr', 'af')); - $expected = array('afr' => 'af', 'af' => 'afr'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('alb', 'sq')); - $expected = array('alb' => 'sq', 'sq' => 'alb'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ara', 'ar')); - $expected = array('ara' => 'ar', 'ar' => 'ara'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('hye', 'hy')); - $expected = array('hye' => 'hy', 'hy' => 'hye'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('baq', 'eu')); - $expected = array('baq' => 'eu', 'eu' => 'baq'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('baq', 'eu')); - $expected = array('baq' => 'eu', 'eu' => 'baq'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('bos', 'bs')); - $expected = array('bos' => 'bs', 'bs' => 'bos'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('bul', 'bg')); - $expected = array('bul' => 'bg', 'bg' => 'bul'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('bel', 'be')); - $expected = array('bel' => 'be', 'be' => 'bel'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('cat', 'ca')); - $expected = array('cat' => 'ca', 'ca' => 'cat'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('chi', 'zh')); - $expected = array('chi' => 'zh', 'zh' => 'chi'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('zho', 'zh')); - $expected = array('zho' => 'zh', 'zh' => 'chi'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('hrv', 'hr')); - $expected = array('hrv' => 'hr', 'hr' => 'hrv'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ces', 'cs')); - $expected = array('ces' => 'cs', 'cs' => 'cze'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('cze', 'cs')); - $expected = array('cze' => 'cs', 'cs' => 'cze'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('dan', 'da')); - $expected = array('dan' => 'da', 'da' => 'dan'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('dut', 'nl')); - $expected = array('dut' => 'nl', 'nl' => 'dut'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('nld', 'nl')); - $expected = array('nld' => 'nl', 'nl' => 'dut'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('nld')); - $expected = array('nld' => 'nl'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('eng', 'en')); - $expected = array('eng' => 'en', 'en' => 'eng'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('est', 'et')); - $expected = array('est' => 'et', 'et' => 'est'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('fao', 'fo')); - $expected = array('fao' => 'fo', 'fo' => 'fao'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('fas', 'fa')); - $expected = array('fas' => 'fa', 'fa' => 'fas'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('per', 'fa')); - $expected = array('per' => 'fa', 'fa' => 'fas'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('fin', 'fi')); - $expected = array('fin' => 'fi', 'fi' => 'fin'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('fra', 'fr')); - $expected = array('fra' => 'fr', 'fr' => 'fre'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('fre', 'fr')); - $expected = array('fre' => 'fr', 'fr' => 'fre'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('gla', 'gd')); - $expected = array('gla' => 'gd', 'gd' => 'gla'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('glg', 'gl')); - $expected = array('glg' => 'gl', 'gl' => 'glg'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('deu', 'de')); - $expected = array('deu' => 'de', 'de' => 'deu'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ger', 'de')); - $expected = array('ger' => 'de', 'de' => 'deu'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ell', 'el')); - $expected = array('ell' => 'el', 'el' => 'gre'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('gre', 'el')); - $expected = array('gre' => 'el', 'el' => 'gre'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('heb', 'he')); - $expected = array('heb' => 'he', 'he' => 'heb'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('hin', 'hi')); - $expected = array('hin' => 'hi', 'hi' => 'hin'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('hun', 'hu')); - $expected = array('hun' => 'hu', 'hu' => 'hun'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ice', 'is')); - $expected = array('ice' => 'is', 'is' => 'ice'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('isl', 'is')); - $expected = array('isl' => 'is', 'is' => 'ice'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ind', 'id')); - $expected = array('ind' => 'id', 'id' => 'ind'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('gle', 'ga')); - $expected = array('gle' => 'ga', 'ga' => 'gle'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ita', 'it')); - $expected = array('ita' => 'it', 'it' => 'ita'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('jpn', 'ja')); - $expected = array('jpn' => 'ja', 'ja' => 'jpn'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('kor', 'ko')); - $expected = array('kor' => 'ko', 'ko' => 'kor'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('lav', 'lv')); - $expected = array('lav' => 'lv', 'lv' => 'lav'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('lit', 'lt')); - $expected = array('lit' => 'lt', 'lt' => 'lit'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('mac', 'mk')); - $expected = array('mac' => 'mk', 'mk' => 'mac'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('mkd', 'mk')); - $expected = array('mkd' => 'mk', 'mk' => 'mac'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('may', 'ms')); - $expected = array('may' => 'ms', 'ms' => 'may'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('msa', 'ms')); - $expected = array('msa' => 'ms', 'ms' => 'may'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('mlt', 'mt')); - $expected = array('mlt' => 'mt', 'mt' => 'mlt'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('nor', 'no')); - $expected = array('nor' => 'no', 'no' => 'nor'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('nob', 'nb')); - $expected = array('nob' => 'nb', 'nb' => 'nob'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('nno', 'nn')); - $expected = array('nno' => 'nn', 'nn' => 'nno'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('pol', 'pl')); - $expected = array('pol' => 'pl', 'pl' => 'pol'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('por', 'pt')); - $expected = array('por' => 'pt', 'pt' => 'por'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('roh', 'rm')); - $expected = array('roh' => 'rm', 'rm' => 'roh'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ron', 'ro')); - $expected = array('ron' => 'ro', 'ro' => 'rum'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('rum', 'ro')); - $expected = array('rum' => 'ro', 'ro' => 'rum'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('rus', 'ru')); - $expected = array('rus' => 'ru', 'ru' => 'rus'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('smi', 'sz')); - $expected = array('smi' => 'sz', 'sz' => 'smi'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('scc', 'sr')); - $expected = array('scc' => 'sr', 'sr' => 'scc'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('srp', 'sr')); - $expected = array('srp' => 'sr', 'sr' => 'scc'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('slk', 'sk')); - $expected = array('slk' => 'sk', 'sk' => 'slo'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('slo', 'sk')); - $expected = array('slo' => 'sk', 'sk' => 'slo'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('slv', 'sl')); - $expected = array('slv' => 'sl', 'sl' => 'slv'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('wen', 'sb')); - $expected = array('wen' => 'sb', 'sb' => 'wen'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('spa', 'es')); - $expected = array('spa' => 'es', 'es' => 'spa'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('swe', 'sv')); - $expected = array('swe' => 'sv', 'sv' => 'swe'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('tha', 'th')); - $expected = array('tha' => 'th', 'th' => 'tha'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('tso', 'ts')); - $expected = array('tso' => 'ts', 'ts' => 'tso'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('tsn', 'tn')); - $expected = array('tsn' => 'tn', 'tn' => 'tsn'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('tur', 'tr')); - $expected = array('tur' => 'tr', 'tr' => 'tur'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ukr', 'uk')); - $expected = array('ukr' => 'uk', 'uk' => 'ukr'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('urd', 'ur')); - $expected = array('urd' => 'ur', 'ur' => 'urd'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('ven', 've')); - $expected = array('ven' => 've', 've' => 'ven'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('vie', 'vi')); - $expected = array('vie' => 'vi', 'vi' => 'vie'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('xho', 'xh')); - $expected = array('xho' => 'xh', 'xh' => 'xho'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('cy', 'cym')); - $expected = array('cym' => 'cy', 'cy' => 'cym'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('yid', 'yi')); - $expected = array('yid' => 'yi', 'yi' => 'yid'); - $this->assertEquals($expected, $result); - - $result = $localize->map(array('zul', 'zu')); - $expected = array('zul' => 'zu', 'zu' => 'zul'); - $this->assertEquals($expected, $result); - } - -/** - * testCatalog method - * - * @return void - */ - public function testCatalog() { - $localize = new L10n(); - - $result = $localize->catalog(array('af')); - $expected = array( - 'af' => array('language' => 'Afrikaans', 'locale' => 'afr', 'localeFallback' => 'afr', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ar', 'ar-ae', 'ar-bh', 'ar-dz', 'ar-eg', 'ar-iq', 'ar-jo', 'ar-kw', 'ar-lb', 'ar-ly', 'ar-ma', - 'ar-om', 'ar-qa', 'ar-sa', 'ar-sy', 'ar-tn', 'ar-ye')); - $expected = array( - 'ar' => array('language' => 'Arabic', 'locale' => 'ara', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ae' => array('language' => 'Arabic (U.A.E.)', 'locale' => 'ar_ae', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-bh' => array('language' => 'Arabic (Bahrain)', 'locale' => 'ar_bh', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-dz' => array('language' => 'Arabic (Algeria)', 'locale' => 'ar_dz', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-eg' => array('language' => 'Arabic (Egypt)', 'locale' => 'ar_eg', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-iq' => array('language' => 'Arabic (Iraq)', 'locale' => 'ar_iq', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-jo' => array('language' => 'Arabic (Jordan)', 'locale' => 'ar_jo', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-kw' => array('language' => 'Arabic (Kuwait)', 'locale' => 'ar_kw', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-lb' => array('language' => 'Arabic (Lebanon)', 'locale' => 'ar_lb', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ly' => array('language' => 'Arabic (Libya)', 'locale' => 'ar_ly', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ma' => array('language' => 'Arabic (Morocco)', 'locale' => 'ar_ma', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-om' => array('language' => 'Arabic (Oman)', 'locale' => 'ar_om', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-qa' => array('language' => 'Arabic (Qatar)', 'locale' => 'ar_qa', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-sa' => array('language' => 'Arabic (Saudi Arabia)', 'locale' => 'ar_sa', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-sy' => array('language' => 'Arabic (Syria)', 'locale' => 'ar_sy', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-tn' => array('language' => 'Arabic (Tunisia)', 'locale' => 'ar_tn', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'ar-ye' => array('language' => 'Arabic (Yemen)', 'locale' => 'ar_ye', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('be')); - $expected = array( - 'be' => array('language' => 'Byelorussian', 'locale' => 'bel', 'localeFallback' => 'bel', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('bg')); - $expected = array( - 'bg' => array('language' => 'Bulgarian', 'locale' => 'bul', 'localeFallback' => 'bul', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('bs')); - $expected = array( - 'bs' => array('language' => 'Bosnian', 'locale' => 'bos', 'localeFallback' => 'bos', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ca')); - $expected = array( - 'ca' => array('language' => 'Catalan', 'locale' => 'cat', 'localeFallback' => 'cat', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('cs')); - $expected = array( - 'cs' => array('language' => 'Czech', 'locale' => 'cze', 'localeFallback' => 'cze', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('da')); - $expected = array( - 'da' => array('language' => 'Danish', 'locale' => 'dan', 'localeFallback' => 'dan', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('de', 'de-at', 'de-ch', 'de-de', 'de-li', 'de-lu')); - $expected = array( - 'de' => array('language' => 'German (Standard)', 'locale' => 'deu', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-at' => array('language' => 'German (Austria)', 'locale' => 'de_at', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-ch' => array('language' => 'German (Swiss)', 'locale' => 'de_ch', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-de' => array('language' => 'German (Germany)', 'locale' => 'de_de', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-li' => array('language' => 'German (Liechtenstein)', 'locale' => 'de_li', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'de-lu' => array('language' => 'German (Luxembourg)', 'locale' => 'de_lu', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('e', 'el')); - $expected = array( - 'e' => array('language' => 'Greek', 'locale' => 'gre', 'localeFallback' => 'gre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'el' => array('language' => 'Greek', 'locale' => 'gre', 'localeFallback' => 'gre', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('en', 'en-au', 'en-bz', 'en-ca', 'en-gb', 'en-ie', 'en-jm', 'en-nz', 'en-tt', 'en-us', 'en-za')); - $expected = array( - 'en' => array('language' => 'English', 'locale' => 'eng', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-au' => array('language' => 'English (Australian)', 'locale' => 'en_au', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-bz' => array('language' => 'English (Belize)', 'locale' => 'en_bz', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-ca' => array('language' => 'English (Canadian)', 'locale' => 'en_ca', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-gb' => array('language' => 'English (British)', 'locale' => 'en_gb', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-ie' => array('language' => 'English (Ireland)', 'locale' => 'en_ie', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-jm' => array('language' => 'English (Jamaica)', 'locale' => 'en_jm', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-nz' => array('language' => 'English (New Zealand)', 'locale' => 'en_nz', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-tt' => array('language' => 'English (Trinidad)', 'locale' => 'en_tt', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-us' => array('language' => 'English (United States)', 'locale' => 'en_us', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'en-za' => array('language' => 'English (South Africa)', 'locale' => 'en_za', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('es', 'es-ar', 'es-bo', 'es-cl', 'es-co', 'es-cr', 'es-do', 'es-ec', 'es-es', 'es-gt', 'es-hn', - 'es-mx', 'es-ni', 'es-pa', 'es-pe', 'es-pr', 'es-py', 'es-sv', 'es-uy', 'es-ve')); - $expected = array( - 'es' => array('language' => 'Spanish (Spain - Traditional)', 'locale' => 'spa', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ar' => array('language' => 'Spanish (Argentina)', 'locale' => 'es_ar', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-bo' => array('language' => 'Spanish (Bolivia)', 'locale' => 'es_bo', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-cl' => array('language' => 'Spanish (Chile)', 'locale' => 'es_cl', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-co' => array('language' => 'Spanish (Colombia)', 'locale' => 'es_co', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-cr' => array('language' => 'Spanish (Costa Rica)', 'locale' => 'es_cr', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-do' => array('language' => 'Spanish (Dominican Republic)', 'locale' => 'es_do', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ec' => array('language' => 'Spanish (Ecuador)', 'locale' => 'es_ec', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-es' => array('language' => 'Spanish (Spain)', 'locale' => 'es_es', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-gt' => array('language' => 'Spanish (Guatemala)', 'locale' => 'es_gt', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-hn' => array('language' => 'Spanish (Honduras)', 'locale' => 'es_hn', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-mx' => array('language' => 'Spanish (Mexican)', 'locale' => 'es_mx', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ni' => array('language' => 'Spanish (Nicaragua)', 'locale' => 'es_ni', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-pa' => array('language' => 'Spanish (Panama)', 'locale' => 'es_pa', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-pe' => array('language' => 'Spanish (Peru)', 'locale' => 'es_pe', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-pr' => array('language' => 'Spanish (Puerto Rico)', 'locale' => 'es_pr', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-py' => array('language' => 'Spanish (Paraguay)', 'locale' => 'es_py', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-sv' => array('language' => 'Spanish (El Salvador)', 'locale' => 'es_sv', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-uy' => array('language' => 'Spanish (Uruguay)', 'locale' => 'es_uy', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-ve' => array('language' => 'Spanish (Venezuela)', 'locale' => 'es_ve', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('et')); - $expected = array( - 'et' => array('language' => 'Estonian', 'locale' => 'est', 'localeFallback' => 'est', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('eu')); - $expected = array( - 'eu' => array('language' => 'Basque', 'locale' => 'baq', 'localeFallback' => 'baq', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('fa')); - $expected = array( - 'fa' => array('language' => 'Farsi', 'locale' => 'per', 'localeFallback' => 'per', 'charset' => 'utf-8', 'direction' => 'rtl') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('fi')); - $expected = array( - 'fi' => array('language' => 'Finnish', 'locale' => 'fin', 'localeFallback' => 'fin', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('fo')); - $expected = array( - 'fo' => array('language' => 'Faeroese', 'locale' => 'fao', 'localeFallback' => 'fao', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('fr', 'fr-be', 'fr-ca', 'fr-ch', 'fr-fr', 'fr-lu')); - $expected = array( - 'fr' => array('language' => 'French (Standard)', 'locale' => 'fre', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-be' => array('language' => 'French (Belgium)', 'locale' => 'fr_be', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-ca' => array('language' => 'French (Canadian)', 'locale' => 'fr_ca', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-ch' => array('language' => 'French (Swiss)', 'locale' => 'fr_ch', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-fr' => array('language' => 'French (France)', 'locale' => 'fr_fr', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'fr-lu' => array('language' => 'French (Luxembourg)', 'locale' => 'fr_lu', 'localeFallback' => 'fre', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ga')); - $expected = array( - 'ga' => array('language' => 'Irish', 'locale' => 'gle', 'localeFallback' => 'gle', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('gd', 'gd-ie')); - $expected = array( - 'gd' => array('language' => 'Gaelic (Scots)', 'locale' => 'gla', 'localeFallback' => 'gla', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'gd-ie' => array('language' => 'Gaelic (Irish)', 'locale' => 'gd_ie', 'localeFallback' => 'gla', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('gl')); - $expected = array( - 'gl' => array('language' => 'Galician', 'locale' => 'glg', 'localeFallback' => 'glg', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('he')); - $expected = array( - 'he' => array('language' => 'Hebrew', 'locale' => 'heb', 'localeFallback' => 'heb', 'charset' => 'utf-8', 'direction' => 'rtl') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('hi')); - $expected = array( - 'hi' => array('language' => 'Hindi', 'locale' => 'hin', 'localeFallback' => 'hin', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('hr')); - $expected = array( - 'hr' => array('language' => 'Croatian', 'locale' => 'hrv', 'localeFallback' => 'hrv', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('hu')); - $expected = array( - 'hu' => array('language' => 'Hungarian', 'locale' => 'hun', 'localeFallback' => 'hun', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('hy')); - $expected = array( - 'hy' => array('language' => 'Armenian - Armenia', 'locale' => 'hye', 'localeFallback' => 'hye', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('id', 'in')); - $expected = array( - 'id' => array('language' => 'Indonesian', 'locale' => 'ind', 'localeFallback' => 'ind', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'in' => array('language' => 'Indonesian', 'locale' => 'ind', 'localeFallback' => 'ind', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('is')); - $expected = array( - 'is' => array('language' => 'Icelandic', 'locale' => 'ice', 'localeFallback' => 'ice', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('it', 'it-ch')); - $expected = array( - 'it' => array('language' => 'Italian', 'locale' => 'ita', 'localeFallback' => 'ita', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'it-ch' => array('language' => 'Italian (Swiss) ', 'locale' => 'it_ch', 'localeFallback' => 'ita', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ja')); - $expected = array( - 'ja' => array('language' => 'Japanese', 'locale' => 'jpn', 'localeFallback' => 'jpn', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ko', 'ko-kp', 'ko-kr')); - $expected = array( - 'ko' => array('language' => 'Korean', 'locale' => 'kor', 'localeFallback' => 'kor', 'charset' => 'kr', 'direction' => 'ltr'), - 'ko-kp' => array('language' => 'Korea (North)', 'locale' => 'ko_kp', 'localeFallback' => 'kor', 'charset' => 'kr', 'direction' => 'ltr'), - 'ko-kr' => array('language' => 'Korea (South)', 'locale' => 'ko_kr', 'localeFallback' => 'kor', 'charset' => 'kr', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('koi8-r', 'ru', 'ru-mo')); - $expected = array( - 'koi8-r' => array('language' => 'Russian', 'locale' => 'koi8_r', 'localeFallback' => 'rus', 'charset' => 'koi8-r', 'direction' => 'ltr'), - 'ru' => array('language' => 'Russian', 'locale' => 'rus', 'localeFallback' => 'rus', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ru-mo' => array('language' => 'Russian (Moldavia)', 'locale' => 'ru_mo', 'localeFallback' => 'rus', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('lt')); - $expected = array( - 'lt' => array('language' => 'Lithuanian', 'locale' => 'lit', 'localeFallback' => 'lit', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('lv')); - $expected = array( - 'lv' => array('language' => 'Latvian', 'locale' => 'lav', 'localeFallback' => 'lav', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('mk', 'mk-mk')); - $expected = array( - 'mk' => array('language' => 'FYRO Macedonian', 'locale' => 'mk', 'localeFallback' => 'mac', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'mk-mk' => array('language' => 'Macedonian', 'locale' => 'mk_mk', 'localeFallback' => 'mac', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ms')); - $expected = array( - 'ms' => array('language' => 'Malaysian', 'locale' => 'may', 'localeFallback' => 'may', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('mt')); - $expected = array( - 'mt' => array('language' => 'Maltese', 'locale' => 'mlt', 'localeFallback' => 'mlt', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('n', 'nl', 'nl-be')); - $expected = array( - 'n' => array('language' => 'Dutch (Standard)', 'locale' => 'dut', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'nl' => array('language' => 'Dutch (Standard)', 'locale' => 'dut', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'nl-be' => array('language' => 'Dutch (Belgium)', 'locale' => 'nl_be', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog('nl'); - $expected = array('language' => 'Dutch (Standard)', 'locale' => 'dut', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'); - $this->assertEquals($expected, $result); - - $result = $localize->catalog('nld'); - $expected = array('language' => 'Dutch (Standard)', 'locale' => 'dut', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'); - $this->assertEquals($expected, $result); - - $result = $localize->catalog('dut'); - $expected = array('language' => 'Dutch (Standard)', 'locale' => 'dut', 'localeFallback' => 'dut', 'charset' => 'utf-8', 'direction' => 'ltr'); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('nb')); - $expected = array( - 'nb' => array('language' => 'Norwegian Bokmal', 'locale' => 'nob', 'localeFallback' => 'nor', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('nn', 'no')); - $expected = array( - 'nn' => array('language' => 'Norwegian Nynorsk', 'locale' => 'nno', 'localeFallback' => 'nor', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'no' => array('language' => 'Norwegian', 'locale' => 'nor', 'localeFallback' => 'nor', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('p', 'pl')); - $expected = array( - 'p' => array('language' => 'Polish', 'locale' => 'pol', 'localeFallback' => 'pol', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'pl' => array('language' => 'Polish', 'locale' => 'pol', 'localeFallback' => 'pol', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('pt', 'pt-br')); - $expected = array( - 'pt' => array('language' => 'Portuguese (Portugal)', 'locale' => 'por', 'localeFallback' => 'por', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'pt-br' => array('language' => 'Portuguese (Brazil)', 'locale' => 'pt_br', 'localeFallback' => 'por', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('rm')); - $expected = array( - 'rm' => array('language' => 'Rhaeto-Romanic', 'locale' => 'roh', 'localeFallback' => 'roh', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ro', 'ro-mo')); - $expected = array( - 'ro' => array('language' => 'Romanian', 'locale' => 'rum', 'localeFallback' => 'rum', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ro-mo' => array('language' => 'Romanian (Moldavia)', 'locale' => 'ro_mo', 'localeFallback' => 'rum', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sb')); - $expected = array( - 'sb' => array('language' => 'Sorbian', 'locale' => 'wen', 'localeFallback' => 'wen', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sk')); - $expected = array( - 'sk' => array('language' => 'Slovak', 'locale' => 'slo', 'localeFallback' => 'slo', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sl')); - $expected = array( - 'sl' => array('language' => 'Slovenian', 'locale' => 'slv', 'localeFallback' => 'slv', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sq')); - $expected = array( - 'sq' => array('language' => 'Albanian', 'locale' => 'alb', 'localeFallback' => 'alb', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sr')); - $expected = array( - 'sr' => array('language' => 'Serbian', 'locale' => 'scc', 'localeFallback' => 'scc', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sv', 'sv-fi')); - $expected = array( - 'sv' => array('language' => 'Swedish', 'locale' => 'swe', 'localeFallback' => 'swe', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sv-fi' => array('language' => 'Swedish (Finland)', 'locale' => 'sv_fi', 'localeFallback' => 'swe', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sx')); - $expected = array( - 'sx' => array('language' => 'Sutu', 'locale' => 'sx', 'localeFallback' => 'sx', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('sz')); - $expected = array( - 'sz' => array('language' => 'Sami (Lappish)', 'locale' => 'smi', 'localeFallback' => 'smi', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('th')); - $expected = array( - 'th' => array('language' => 'Thai', 'locale' => 'tha', 'localeFallback' => 'tha', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('tn')); - $expected = array( - 'tn' => array('language' => 'Tswana', 'locale' => 'tsn', 'localeFallback' => 'tsn', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('tr')); - $expected = array( - 'tr' => array('language' => 'Turkish', 'locale' => 'tur', 'localeFallback' => 'tur', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ts')); - $expected = array( - 'ts' => array('language' => 'Tsonga', 'locale' => 'tso', 'localeFallback' => 'tso', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('uk')); - $expected = array( - 'uk' => array('language' => 'Ukrainian', 'locale' => 'ukr', 'localeFallback' => 'ukr', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ur')); - $expected = array( - 'ur' => array('language' => 'Urdu', 'locale' => 'urd', 'localeFallback' => 'urd', 'charset' => 'utf-8', 'direction' => 'rtl') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('ve')); - $expected = array( - 've' => array('language' => 'Venda', 'locale' => 'ven', 'localeFallback' => 'ven', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('vi')); - $expected = array( - 'vi' => array('language' => 'Vietnamese', 'locale' => 'vie', 'localeFallback' => 'vie', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('cy')); - $expected = array( - 'cy' => array('language' => 'Welsh', 'locale' => 'cym', 'localeFallback' => 'cym', 'charset' => 'utf-8', - 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('xh')); - $expected = array( - 'xh' => array('language' => 'Xhosa', 'locale' => 'xho', 'localeFallback' => 'xho', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('yi')); - $expected = array( - 'yi' => array('language' => 'Yiddish', 'locale' => 'yid', 'localeFallback' => 'yid', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('zh', 'zh-cn', 'zh-hk', 'zh-sg', 'zh-tw')); - $expected = array( - 'zh' => array('language' => 'Chinese', 'locale' => 'chi', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zh-cn' => array('language' => 'Chinese (PRC)', 'locale' => 'zh_cn', 'localeFallback' => 'chi', 'charset' => 'GB2312', 'direction' => 'ltr'), - 'zh-hk' => array('language' => 'Chinese (Hong Kong)', 'locale' => 'zh_hk', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zh-sg' => array('language' => 'Chinese (Singapore)', 'locale' => 'zh_sg', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zh-tw' => array('language' => 'Chinese (Taiwan)', 'locale' => 'zh_tw', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('zu')); - $expected = array( - 'zu' => array('language' => 'Zulu', 'locale' => 'zul', 'localeFallback' => 'zul', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('en-nz', 'es-do', 'sz', 'ar-lb', 'zh-hk', 'pt-br')); - $expected = array( - 'en-nz' => array('language' => 'English (New Zealand)', 'locale' => 'en_nz', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'es-do' => array('language' => 'Spanish (Dominican Republic)', 'locale' => 'es_do', 'localeFallback' => 'spa', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'sz' => array('language' => 'Sami (Lappish)', 'locale' => 'smi', 'localeFallback' => 'smi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'ar-lb' => array('language' => 'Arabic (Lebanon)', 'locale' => 'ar_lb', 'localeFallback' => 'ara', 'charset' => 'utf-8', 'direction' => 'rtl'), - 'zh-hk' => array('language' => 'Chinese (Hong Kong)', 'locale' => 'zh_hk', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'pt-br' => array('language' => 'Portuguese (Brazil)', 'locale' => 'pt_br', 'localeFallback' => 'por', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - - $result = $localize->catalog(array('eng', 'deu', 'zho', 'rum', 'zul', 'yid')); - $expected = array( - 'eng' => array('language' => 'English', 'locale' => 'eng', 'localeFallback' => 'eng', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'deu' => array('language' => 'German (Standard)', 'locale' => 'deu', 'localeFallback' => 'deu', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zho' => array('language' => 'Chinese', 'locale' => 'chi', 'localeFallback' => 'chi', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'rum' => array('language' => 'Romanian', 'locale' => 'rum', 'localeFallback' => 'rum', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'zul' => array('language' => 'Zulu', 'locale' => 'zul', 'localeFallback' => 'zul', 'charset' => 'utf-8', 'direction' => 'ltr'), - 'yid' => array('language' => 'Yiddish', 'locale' => 'yid', 'localeFallback' => 'yid', 'charset' => 'utf-8', 'direction' => 'ltr') - ); - $this->assertEquals($expected, $result); - } -} diff --git a/lib/Cake/Test/Case/I18n/MultibyteTest.php b/lib/Cake/Test/Case/I18n/MultibyteTest.php deleted file mode 100644 index d2bbe1664ca..00000000000 --- a/lib/Cake/Test/Case/I18n/MultibyteTest.php +++ /dev/null @@ -1,9228 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.I18n - * @since CakePHP(tm) v 1.2.0.6833 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Multibyte', 'I18n'); - -/** - * MultibyteTest class - * - * @package Cake.Test.Case.I18n - */ -class MultibyteTest extends CakeTestCase { - -/** - * testUtf8 method - * - * @return void - */ - public function testUtf8() { - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = Multibyte::utf8($string); - $expected = array(33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, - 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, - 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, - 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126); - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $result = Multibyte::utf8($string); - $expected = array(161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, - 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200); - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $result = Multibyte::utf8($string); - $expected = array(201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, - 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, - 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, - 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, - 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300); - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $result = Multibyte::utf8($string); - $expected = array(301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, - 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, - 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, - 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, - 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400); - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $result = Multibyte::utf8($string); - $expected = array(401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, - 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, - 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, - 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, - 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500); - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $result = Multibyte::utf8($string); - $expected = array(601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, - 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, - 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, - 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, - 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700); - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $result = Multibyte::utf8($string); - $expected = array(1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 1040, 1041, - 1042, 1043, 1044, 1045, 1046, 1047, 1048, 1049, 1050, 1051); - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $result = Multibyte::utf8($string); - $expected = array(1052, 1053, 1054, 1055, 1056, 1057, 1058, 1059, 1060, 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, - 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, - 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100); - $this->assertEquals($expected, $result); - - $string = 'չպջռսվտ'; - $result = Multibyte::utf8($string); - $expected = array(1401, 1402, 1403, 1404, 1405, 1406, 1407); - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $result = Multibyte::utf8($string); - $expected = array(1601, 1602, 1603, 1604, 1605, 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615); - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $result = Multibyte::utf8($string); - $expected = array(10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, - 10045, 10046, 10047, 10048, 10049, 10050, 10051, 10052, 10053, 10054, 10055, 10056, 10057, - 10058, 10059, 10060, 10061, 10062, 10063, 10064, 10065, 10066, 10067, 10068, 10069, 10070, - 10071, 10072, 10073, 10074, 10075, 10076, 10077, 10078); - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $result = Multibyte::utf8($string); - $expected = array(11904, 11905, 11906, 11907, 11908, 11909, 11910, 11911, 11912, 11913, 11914, 11915, 11916, 11917, 11918, 11919, - 11920, 11921, 11922, 11923, 11924, 11925, 11926, 11927, 11928, 11929, 11931, 11932, 11933, 11934, 11935, 11936, - 11937, 11938, 11939, 11940, 11941, 11942, 11943, 11944, 11945, 11946, 11947, 11948, 11949, 11950, 11951, 11952, - 11953, 11954, 11955, 11956, 11957, 11958, 11959, 11960, 11961, 11962, 11963, 11964, 11965, 11966, 11967, 11968, - 11969, 11970, 11971, 11972, 11973, 11974, 11975, 11976, 11977, 11978, 11979, 11980, 11981, 11982, 11983, 11984, - 11985, 11986, 11987, 11988, 11989, 11990, 11991, 11992, 11993, 11994, 11995, 11996, 11997, 11998, 11999, 12000); - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $result = Multibyte::utf8($string); - $expected = array(12101, 12102, 12103, 12104, 12105, 12106, 12107, 12108, 12109, 12110, 12111, 12112, 12113, 12114, 12115, 12116, - 12117, 12118, 12119, 12120, 12121, 12122, 12123, 12124, 12125, 12126, 12127, 12128, 12129, 12130, 12131, 12132, - 12133, 12134, 12135, 12136, 12137, 12138, 12139, 12140, 12141, 12142, 12143, 12144, 12145, 12146, 12147, 12148, - 12149, 12150, 12151, 12152, 12153, 12154, 12155, 12156, 12157, 12158, 12159); - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $result = Multibyte::utf8($string); - $expected = array(45601, 45602, 45603, 45604, 45605, 45606, 45607, 45608, 45609, 45610, 45611, 45612, 45613, 45614, 45615, 45616, - 45617, 45618, 45619, 45620, 45621, 45622, 45623, 45624, 45625, 45626, 45627, 45628, 45629, 45630, 45631, 45632, - 45633, 45634, 45635, 45636, 45637, 45638, 45639, 45640, 45641, 45642, 45643, 45644, 45645, 45646, 45647, 45648, - 45649, 45650, 45651, 45652, 45653, 45654, 45655, 45656, 45657, 45658, 45659, 45660, 45661, 45662, 45663, 45664, - 45665, 45666, 45667, 45668, 45669, 45670, 45671, 45672, 45673, 45674, 45675, 45676, 45677, 45678, 45679, 45680, - 45681, 45682, 45683, 45684, 45685, 45686, 45687, 45688, 45689, 45690, 45691, 45692, 45693, 45694, 45695, 45696, - 45697, 45698, 45699, 45700); - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $result = Multibyte::utf8($string); - $expected = array(65136, 65137, 65138, 65139, 65140, 65141, 65142, 65143, 65144, 65145, 65146, 65147, 65148, 65149, 65150, 65151, - 65152, 65153, 65154, 65155, 65156, 65157, 65158, 65159, 65160, 65161, 65162, 65163, 65164, 65165, 65166, 65167, - 65168, 65169, 65170, 65171, 65172, 65173, 65174, 65175, 65176, 65177, 65178, 65179, 65180, 65181, 65182, 65183, - 65184, 65185, 65186, 65187, 65188, 65189, 65190, 65191, 65192, 65193, 65194, 65195, 65196, 65197, 65198, 65199, - 65200); - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $result = Multibyte::utf8($string); - $expected = array(65201, 65202, 65203, 65204, 65205, 65206, 65207, 65208, 65209, 65210, 65211, 65212, 65213, 65214, 65215, 65216, - 65217, 65218, 65219, 65220, 65221, 65222, 65223, 65224, 65225, 65226, 65227, 65228, 65229, 65230, 65231, 65232, - 65233, 65234, 65235, 65236, 65237, 65238, 65239, 65240, 65241, 65242, 65243, 65244, 65245, 65246, 65247, 65248, - 65249, 65250, 65251, 65252, 65253, 65254, 65255, 65256, 65257, 65258, 65259, 65260, 65261, 65262, 65263, 65264, - 65265, 65266, 65267, 65268, 65269, 65270, 65271, 65272, 65273, 65274, 65275, 65276); - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $result = Multibyte::utf8($string); - $expected = array(65345, 65346, 65347, 65348, 65349, 65350, 65351, 65352, 65353, 65354, 65355, 65356, 65357, 65358, 65359, 65360, - 65361, 65362, 65363, 65364, 65365, 65366, 65367, 65368, 65369, 65370); - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $result = Multibyte::utf8($string); - $expected = array(65377, 65378, 65379, 65380, 65381, 65382, 65383, 65384, 65385, 65386, 65387, 65388, 65389, 65390, 65391, 65392, - 65393, 65394, 65395, 65396, 65397, 65398, 65399, 65400); - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $result = Multibyte::utf8($string); - $expected = array(65401, 65402, 65403, 65404, 65405, 65406, 65407, 65408, 65409, 65410, 65411, 65412, 65413, 65414, 65415, 65416, - 65417, 65418, 65419, 65420, 65421, 65422, 65423, 65424, 65425, 65426, 65427, 65428, 65429, 65430, 65431, 65432, - 65433, 65434, 65435, 65436, 65437, 65438); - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = Multibyte::utf8($string); - $expected = array(292, 275, 314, 316, 335, 44, 32, 372, 337, 345, 316, 271, 33); - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $result = Multibyte::utf8($string); - $expected = array(72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33); - $this->assertEquals($expected, $result); - - $string = '¨'; - $result = Multibyte::utf8($string); - $expected = array(168); - $this->assertEquals($expected, $result); - - $string = '¿'; - $result = Multibyte::utf8($string); - $expected = array(191); - $this->assertEquals($expected, $result); - - $string = 'čini'; - $result = Multibyte::utf8($string); - $expected = array(269, 105, 110, 105); - $this->assertEquals($expected, $result); - - $string = 'moći'; - $result = Multibyte::utf8($string); - $expected = array(109, 111, 263, 105); - $this->assertEquals($expected, $result); - - $string = 'državni'; - $result = Multibyte::utf8($string); - $expected = array(100, 114, 382, 97, 118, 110, 105); - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $result = Multibyte::utf8($string); - $expected = array(25226, 30334, 24230, 35774, 20026, 39318, 39029); - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = Multibyte::utf8($string); - $expected = array(19968, 20108, 19977, 21608, 27704, 40845); - $this->assertEquals($expected, $result); - - $string = 'ԀԂԄԆԈԊԌԎԐԒ'; - $result = Multibyte::utf8($string); - $expected = array(1280, 1282, 1284, 1286, 1288, 1290, 1292, 1294, 1296, 1298); - $this->assertEquals($expected, $result); - - $string = 'ԁԃԅԇԉԋԍԏԐԒ'; - $result = Multibyte::utf8($string); - $expected = array(1281, 1283, 1285, 1287, 1289, 1291, 1293, 1295, 1296, 1298); - $this->assertEquals($expected, $result); - - $string = 'ԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖև'; - $result = Multibyte::utf8($string); - $expected = array(1329, 1330, 1331, 1332, 1333, 1334, 1335, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 1343, 1344, 1345, 1346, - 1347, 1348, 1349, 1350, 1351, 1352, 1353, 1354, 1355, 1356, 1357, 1358, 1359, 1360, 1361, 1362, 1363, 1364, - 1365, 1366, 1415); - $this->assertEquals($expected, $result); - - $string = 'աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև'; - $result = Multibyte::utf8($string); - $expected = array(1377, 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1387, 1388, 1389, 1390, 1391, 1392, 1393, 1394, - 1395, 1396, 1397, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407, 1408, 1409, 1410, 1411, 1412, - 1413, 1414, 1415); - $this->assertEquals($expected, $result); - - $string = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $result = Multibyte::utf8($string); - $expected = array(4256, 4257, 4258, 4259, 4260, 4261, 4262, 4263, 4264, 4265, 4266, 4267, 4268, 4269, 4270, 4271, 4272, 4273, - 4274, 4275, 4276, 4277, 4278, 4279, 4280, 4281, 4282, 4283, 4284, 4285, 4286, 4287, 4288, 4289, 4290, 4291, - 4292, 4293); - $this->assertEquals($expected, $result); - - $string = 'ḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẖẗẘẙẚẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸ'; - $result = Multibyte::utf8($string); - $expected = array(7680, 7682, 7684, 7686, 7688, 7690, 7692, 7694, 7696, 7698, 7700, 7702, 7704, 7706, 7708, 7710, 7712, 7714, - 7716, 7718, 7720, 7722, 7724, 7726, 7728, 7730, 7732, 7734, 7736, 7738, 7740, 7742, 7744, 7746, 7748, 7750, - 7752, 7754, 7756, 7758, 7760, 7762, 7764, 7766, 7768, 7770, 7772, 7774, 7776, 7778, 7780, 7782, 7784, 7786, - 7788, 7790, 7792, 7794, 7796, 7798, 7800, 7802, 7804, 7806, 7808, 7810, 7812, 7814, 7816, 7818, 7820, 7822, - 7824, 7826, 7828, 7830, 7831, 7832, 7833, 7834, 7840, 7842, 7844, 7846, 7848, 7850, 7852, 7854, 7856, - 7858, 7860, 7862, 7864, 7866, 7868, 7870, 7872, 7874, 7876, 7878, 7880, 7882, 7884, 7886, 7888, 7890, 7892, - 7894, 7896, 7898, 7900, 7902, 7904, 7906, 7908, 7910, 7912, 7914, 7916, 7918, 7920, 7922, 7924, 7926, 7928); - $this->assertEquals($expected, $result); - - $string = 'ḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕẖẗẘẙẚạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ'; - $result = Multibyte::utf8($string); - $expected = array(7681, 7683, 7685, 7687, 7689, 7691, 7693, 7695, 7697, 7699, 7701, 7703, 7705, 7707, 7709, 7711, 7713, 7715, - 7717, 7719, 7721, 7723, 7725, 7727, 7729, 7731, 7733, 7735, 7737, 7739, 7741, 7743, 7745, 7747, 7749, 7751, - 7753, 7755, 7757, 7759, 7761, 7763, 7765, 7767, 7769, 7771, 7773, 7775, 7777, 7779, 7781, 7783, 7785, 7787, - 7789, 7791, 7793, 7795, 7797, 7799, 7801, 7803, 7805, 7807, 7809, 7811, 7813, 7815, 7817, 7819, 7821, 7823, - 7825, 7827, 7829, 7830, 7831, 7832, 7833, 7834, 7841, 7843, 7845, 7847, 7849, 7851, 7853, 7855, 7857, 7859, - 7861, 7863, 7865, 7867, 7869, 7871, 7873, 7875, 7877, 7879, 7881, 7883, 7885, 7887, 7889, 7891, 7893, 7895, - 7897, 7899, 7901, 7903, 7905, 7907, 7909, 7911, 7913, 7915, 7917, 7919, 7921, 7923, 7925, 7927, 7929); - $this->assertEquals($expected, $result); - - $string = 'ΩKÅℲ'; - $result = Multibyte::utf8($string); - $expected = array(8486, 8490, 8491, 8498); - $this->assertEquals($expected, $result); - - $string = 'ωkåⅎ'; - $result = Multibyte::utf8($string); - $expected = array(969, 107, 229, 8526); - $this->assertEquals($expected, $result); - - $string = 'ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯↃ'; - $result = Multibyte::utf8($string); - $expected = array(8544, 8545, 8546, 8547, 8548, 8549, 8550, 8551, 8552, 8553, 8554, 8555, 8556, 8557, 8558, 8559, 8579); - $this->assertEquals($expected, $result); - - $string = 'ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↄ'; - $result = Multibyte::utf8($string); - $expected = array(8560, 8561, 8562, 8563, 8564, 8565, 8566, 8567, 8568, 8569, 8570, 8571, 8572, 8573, 8574, 8575, 8580); - $this->assertEquals($expected, $result); - - $string = 'ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ'; - $result = Multibyte::utf8($string); - $expected = array(9398, 9399, 9400, 9401, 9402, 9403, 9404, 9405, 9406, 9407, 9408, 9409, 9410, 9411, 9412, 9413, 9414, - 9415, 9416, 9417, 9418, 9419, 9420, 9421, 9422, 9423); - $this->assertEquals($expected, $result); - - $string = 'ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ'; - $result = Multibyte::utf8($string); - $expected = array(9424, 9425, 9426, 9427, 9428, 9429, 9430, 9431, 9432, 9433, 9434, 9435, 9436, 9437, 9438, 9439, 9440, 9441, - 9442, 9443, 9444, 9445, 9446, 9447, 9448, 9449); - $this->assertEquals($expected, $result); - - $string = 'ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮ'; - $result = Multibyte::utf8($string); - $expected = array(11264, 11265, 11266, 11267, 11268, 11269, 11270, 11271, 11272, 11273, 11274, 11275, 11276, 11277, 11278, - 11279, 11280, 11281, 11282, 11283, 11284, 11285, 11286, 11287, 11288, 11289, 11290, 11291, 11292, 11293, - 11294, 11295, 11296, 11297, 11298, 11299, 11300, 11301, 11302, 11303, 11304, 11305, 11306, 11307, 11308, - 11309, 11310); - $this->assertEquals($expected, $result); - - $string = 'ⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞ'; - $result = Multibyte::utf8($string); - $expected = array(11312, 11313, 11314, 11315, 11316, 11317, 11318, 11319, 11320, 11321, 11322, 11323, 11324, 11325, 11326, 11327, - 11328, 11329, 11330, 11331, 11332, 11333, 11334, 11335, 11336, 11337, 11338, 11339, 11340, 11341, 11342, 11343, - 11344, 11345, 11346, 11347, 11348, 11349, 11350, 11351, 11352, 11353, 11354, 11355, 11356, 11357, 11358); - $this->assertEquals($expected, $result); - - $string = 'ⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢ'; - $result = Multibyte::utf8($string); - $expected = array(11392, 11394, 11396, 11398, 11400, 11402, 11404, 11406, 11408, 11410, 11412, 11414, 11416, 11418, 11420, - 11422, 11424, 11426, 11428, 11430, 11432, 11434, 11436, 11438, 11440, 11442, 11444, 11446, 11448, 11450, - 11452, 11454, 11456, 11458, 11460, 11462, 11464, 11466, 11468, 11470, 11472, 11474, 11476, 11478, 11480, - 11482, 11484, 11486, 11488, 11490); - $this->assertEquals($expected, $result); - - $string = 'ⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣ'; - $result = Multibyte::utf8($string); - $expected = array(11393, 11395, 11397, 11399, 11401, 11403, 11405, 11407, 11409, 11411, 11413, 11415, 11417, 11419, 11421, 11423, - 11425, 11427, 11429, 11431, 11433, 11435, 11437, 11439, 11441, 11443, 11445, 11447, 11449, 11451, 11453, 11455, - 11457, 11459, 11461, 11463, 11465, 11467, 11469, 11471, 11473, 11475, 11477, 11479, 11481, 11483, 11485, 11487, - 11489, 11491); - $this->assertEquals($expected, $result); - - $string = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $result = Multibyte::utf8($string); - $expected = array(64256, 64257, 64258, 64259, 64260, 64261, 64262, 64275, 64276, 64277, 64278, 64279); - $this->assertEquals($expected, $result); - } - -/** - * testAscii method - * - * @return void - */ - public function testAscii() { - $input = array(33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, - 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, - 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, - 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126); - $result = Multibyte::ascii($input); - - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $input = array(161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, - 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200); - $result = Multibyte::ascii($input); - - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $input = array(201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, - 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, - 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, - 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, - 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300); - $result = Multibyte::ascii($input); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $input = array(301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, - 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, - 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, - 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, - 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, - 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, - 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, - 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, - 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, - 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, - 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, - 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, - 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 1040, 1041, - 1042, 1043, 1044, 1045, 1046, 1047, 1048, 1049, 1050, 1051); - $expected = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(1052, 1053, 1054, 1055, 1056, 1057, 1058, 1059, 1060, 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, - 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, - 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100); - $expected = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(1401, 1402, 1403, 1404, 1405, 1406, 1407); - $expected = 'չպջռսվտ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(1601, 1602, 1603, 1604, 1605, 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615); - $expected = 'فقكلمنهوىيًٌٍَُ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, - 10045, 10046, 10047, 10048, 10049, 10050, 10051, 10052, 10053, 10054, 10055, 10056, 10057, - 10058, 10059, 10060, 10061, 10062, 10063, 10064, 10065, 10066, 10067, 10068, 10069, 10070, - 10071, 10072, 10073, 10074, 10075, 10076, 10077, 10078); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(11904, 11905, 11906, 11907, 11908, 11909, 11910, 11911, 11912, 11913, 11914, 11915, 11916, 11917, 11918, 11919, - 11920, 11921, 11922, 11923, 11924, 11925, 11926, 11927, 11928, 11929, 11931, 11932, 11933, 11934, 11935, 11936, - 11937, 11938, 11939, 11940, 11941, 11942, 11943, 11944, 11945, 11946, 11947, 11948, 11949, 11950, 11951, 11952, - 11953, 11954, 11955, 11956, 11957, 11958, 11959, 11960, 11961, 11962, 11963, 11964, 11965, 11966, 11967, 11968, - 11969, 11970, 11971, 11972, 11973, 11974, 11975, 11976, 11977, 11978, 11979, 11980, 11981, 11982, 11983, 11984, - 11985, 11986, 11987, 11988, 11989, 11990, 11991, 11992, 11993, 11994, 11995, 11996, 11997, 11998, 11999, 12000); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(12101, 12102, 12103, 12104, 12105, 12106, 12107, 12108, 12109, 12110, 12111, 12112, 12113, 12114, 12115, 12116, - 12117, 12118, 12119, 12120, 12121, 12122, 12123, 12124, 12125, 12126, 12127, 12128, 12129, 12130, 12131, 12132, - 12133, 12134, 12135, 12136, 12137, 12138, 12139, 12140, 12141, 12142, 12143, 12144, 12145, 12146, 12147, 12148, - 12149, 12150, 12151, 12152, 12153, 12154, 12155, 12156, 12157, 12158, 12159); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(45601, 45602, 45603, 45604, 45605, 45606, 45607, 45608, 45609, 45610, 45611, 45612, 45613, 45614, 45615, 45616, - 45617, 45618, 45619, 45620, 45621, 45622, 45623, 45624, 45625, 45626, 45627, 45628, 45629, 45630, 45631, 45632, - 45633, 45634, 45635, 45636, 45637, 45638, 45639, 45640, 45641, 45642, 45643, 45644, 45645, 45646, 45647, 45648, - 45649, 45650, 45651, 45652, 45653, 45654, 45655, 45656, 45657, 45658, 45659, 45660, 45661, 45662, 45663, 45664, - 45665, 45666, 45667, 45668, 45669, 45670, 45671, 45672, 45673, 45674, 45675, 45676, 45677, 45678, 45679, 45680, - 45681, 45682, 45683, 45684, 45685, 45686, 45687, 45688, 45689, 45690, 45691, 45692, 45693, 45694, 45695, 45696, - 45697, 45698, 45699, 45700); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(65136, 65137, 65138, 65139, 65140, 65141, 65142, 65143, 65144, 65145, 65146, 65147, 65148, 65149, 65150, 65151, - 65152, 65153, 65154, 65155, 65156, 65157, 65158, 65159, 65160, 65161, 65162, 65163, 65164, 65165, 65166, 65167, - 65168, 65169, 65170, 65171, 65172, 65173, 65174, 65175, 65176, 65177, 65178, 65179, 65180, 65181, 65182, 65183, - 65184, 65185, 65186, 65187, 65188, 65189, 65190, 65191, 65192, 65193, 65194, 65195, 65196, 65197, 65198, 65199, - 65200); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(65201, 65202, 65203, 65204, 65205, 65206, 65207, 65208, 65209, 65210, 65211, 65212, 65213, 65214, 65215, 65216, - 65217, 65218, 65219, 65220, 65221, 65222, 65223, 65224, 65225, 65226, 65227, 65228, 65229, 65230, 65231, 65232, - 65233, 65234, 65235, 65236, 65237, 65238, 65239, 65240, 65241, 65242, 65243, 65244, 65245, 65246, 65247, 65248, - 65249, 65250, 65251, 65252, 65253, 65254, 65255, 65256, 65257, 65258, 65259, 65260, 65261, 65262, 65263, 65264, - 65265, 65266, 65267, 65268, 65269, 65270, 65271, 65272, 65273, 65274, 65275, 65276); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(65345, 65346, 65347, 65348, 65349, 65350, 65351, 65352, 65353, 65354, 65355, 65356, 65357, 65358, 65359, 65360, - 65361, 65362, 65363, 65364, 65365, 65366, 65367, 65368, 65369, 65370); - $expected = 'abcdefghijklmnopqrstuvwxyz'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(65377, 65378, 65379, 65380, 65381, 65382, 65383, 65384, 65385, 65386, 65387, 65388, 65389, 65390, 65391, 65392, - 65393, 65394, 65395, 65396, 65397, 65398, 65399, 65400); - $expected = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(65401, 65402, 65403, 65404, 65405, 65406, 65407, 65408, 65409, 65410, 65411, 65412, 65413, 65414, 65415, 65416, - 65417, 65418, 65419, 65420, 65421, 65422, 65423, 65424, 65425, 65426, 65427, 65428, 65429, 65430, 65431, 65432, - 65433, 65434, 65435, 65436, 65437, 65438); - $expected = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(292, 275, 314, 316, 335, 44, 32, 372, 337, 345, 316, 271, 33); - $expected = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33); - $expected = 'Hello, World!'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(168); - $expected = '¨'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(191); - $expected = '¿'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(269, 105, 110, 105); - $expected = 'čini'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(109, 111, 263, 105); - $expected = 'moći'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(100, 114, 382, 97, 118, 110, 105); - $expected = 'državni'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(25226, 30334, 24230, 35774, 20026, 39318, 39029); - $expected = '把百度设为首页'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(19968, 20108, 19977, 21608, 27704, 40845); - $expected = '一二三周永龍'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(1280, 1282, 1284, 1286, 1288, 1290, 1292, 1294, 1296, 1298); - $expected = 'ԀԂԄԆԈԊԌԎԐԒ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(1281, 1283, 1285, 1287, 1289, 1291, 1293, 1295, 1296, 1298); - $expected = 'ԁԃԅԇԉԋԍԏԐԒ'; - $result = Multibyte::ascii($input); - $this->assertEquals($expected, $result); - - $input = array(1329, 1330, 1331, 1332, 1333, 1334, 1335, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 1343, 1344, 1345, 1346, 1347, - 1348, 1349, 1350, 1351, 1352, 1353, 1354, 1355, 1356, 1357, 1358, 1359, 1360, 1361, 1362, 1363, 1364, 1365, - 1366, 1415); - $result = Multibyte::ascii($input); - $expected = 'ԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖև'; - $this->assertEquals($expected, $result); - - $input = array(1377, 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1387, 1388, 1389, 1390, 1391, 1392, 1393, 1394, - 1395, 1396, 1397, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407, 1408, 1409, 1410, 1411, 1412, - 1413, 1414, 1415); - $result = Multibyte::ascii($input); - $expected = 'աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև'; - $this->assertEquals($expected, $result); - - $input = array(4256, 4257, 4258, 4259, 4260, 4261, 4262, 4263, 4264, 4265, 4266, 4267, 4268, 4269, 4270, 4271, 4272, 4273, 4274, - 4275, 4276, 4277, 4278, 4279, 4280, 4281, 4282, 4283, 4284, 4285, 4286, 4287, 4288, 4289, 4290, 4291, 4292, 4293); - $result = Multibyte::ascii($input); - $expected = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $this->assertEquals($expected, $result); - - $input = array(7680, 7682, 7684, 7686, 7688, 7690, 7692, 7694, 7696, 7698, 7700, 7702, 7704, 7706, 7708, 7710, 7712, 7714, - 7716, 7718, 7720, 7722, 7724, 7726, 7728, 7730, 7732, 7734, 7736, 7738, 7740, 7742, 7744, 7746, 7748, 7750, - 7752, 7754, 7756, 7758, 7760, 7762, 7764, 7766, 7768, 7770, 7772, 7774, 7776, 7778, 7780, 7782, 7784, 7786, - 7788, 7790, 7792, 7794, 7796, 7798, 7800, 7802, 7804, 7806, 7808, 7810, 7812, 7814, 7816, 7818, 7820, 7822, - 7824, 7826, 7828, 7830, 7831, 7832, 7833, 7834, 7840, 7842, 7844, 7846, 7848, 7850, 7852, 7854, 7856, - 7858, 7860, 7862, 7864, 7866, 7868, 7870, 7872, 7874, 7876, 7878, 7880, 7882, 7884, 7886, 7888, 7890, 7892, - 7894, 7896, 7898, 7900, 7902, 7904, 7906, 7908, 7910, 7912, 7914, 7916, 7918, 7920, 7922, 7924, 7926, 7928); - $result = Multibyte::ascii($input); - $expected = 'ḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẖẗẘẙẚẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸ'; - $this->assertEquals($expected, $result); - - $input = array(7681, 7683, 7685, 7687, 7689, 7691, 7693, 7695, 7697, 7699, 7701, 7703, 7705, 7707, 7709, 7711, 7713, 7715, - 7717, 7719, 7721, 7723, 7725, 7727, 7729, 7731, 7733, 7735, 7737, 7739, 7741, 7743, 7745, 7747, 7749, 7751, - 7753, 7755, 7757, 7759, 7761, 7763, 7765, 7767, 7769, 7771, 7773, 7775, 7777, 7779, 7781, 7783, 7785, 7787, - 7789, 7791, 7793, 7795, 7797, 7799, 7801, 7803, 7805, 7807, 7809, 7811, 7813, 7815, 7817, 7819, 7821, 7823, - 7825, 7827, 7829, 7830, 7831, 7832, 7833, 7834, 7841, 7843, 7845, 7847, 7849, 7851, 7853, 7855, 7857, 7859, - 7861, 7863, 7865, 7867, 7869, 7871, 7873, 7875, 7877, 7879, 7881, 7883, 7885, 7887, 7889, 7891, 7893, 7895, - 7897, 7899, 7901, 7903, 7905, 7907, 7909, 7911, 7913, 7915, 7917, 7919, 7921, 7923, 7925, 7927, 7929); - $result = Multibyte::ascii($input); - $expected = 'ḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕẖẗẘẙẚạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ'; - $this->assertEquals($expected, $result); - - $input = array(8486, 8490, 8491, 8498); - $result = Multibyte::ascii($input); - $expected = 'ΩKÅℲ'; - $this->assertEquals($expected, $result); - - $input = array(969, 107, 229, 8526); - $result = Multibyte::ascii($input); - $expected = 'ωkåⅎ'; - $this->assertEquals($expected, $result); - - $input = array(8544, 8545, 8546, 8547, 8548, 8549, 8550, 8551, 8552, 8553, 8554, 8555, 8556, 8557, 8558, 8559, 8579); - $result = Multibyte::ascii($input); - $expected = 'ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯↃ'; - $this->assertEquals($expected, $result); - - $input = array(8560, 8561, 8562, 8563, 8564, 8565, 8566, 8567, 8568, 8569, 8570, 8571, 8572, 8573, 8574, 8575, 8580); - $result = Multibyte::ascii($input); - $expected = 'ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↄ'; - $this->assertEquals($expected, $result); - - $input = array(9398, 9399, 9400, 9401, 9402, 9403, 9404, 9405, 9406, 9407, 9408, 9409, 9410, 9411, 9412, 9413, 9414, - 9415, 9416, 9417, 9418, 9419, 9420, 9421, 9422, 9423); - $result = Multibyte::ascii($input); - $expected = 'ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ'; - $this->assertEquals($expected, $result); - - $input = array(9424, 9425, 9426, 9427, 9428, 9429, 9430, 9431, 9432, 9433, 9434, 9435, 9436, 9437, 9438, 9439, 9440, 9441, - 9442, 9443, 9444, 9445, 9446, 9447, 9448, 9449); - $result = Multibyte::ascii($input); - $expected = 'ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ'; - $this->assertEquals($expected, $result); - - $input = array(11264, 11265, 11266, 11267, 11268, 11269, 11270, 11271, 11272, 11273, 11274, 11275, 11276, 11277, 11278, 11279, - 11280, 11281, 11282, 11283, 11284, 11285, 11286, 11287, 11288, 11289, 11290, 11291, 11292, 11293, 11294, 11295, - 11296, 11297, 11298, 11299, 11300, 11301, 11302, 11303, 11304, 11305, 11306, 11307, 11308, 11309, 11310); - $result = Multibyte::ascii($input); - $expected = 'ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮ'; - $this->assertEquals($expected, $result); - - $input = array(11312, 11313, 11314, 11315, 11316, 11317, 11318, 11319, 11320, 11321, 11322, 11323, 11324, 11325, 11326, 11327, - 11328, 11329, 11330, 11331, 11332, 11333, 11334, 11335, 11336, 11337, 11338, 11339, 11340, 11341, 11342, 11343, - 11344, 11345, 11346, 11347, 11348, 11349, 11350, 11351, 11352, 11353, 11354, 11355, 11356, 11357, 11358); - $result = Multibyte::ascii($input); - $expected = 'ⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞ'; - $this->assertEquals($expected, $result); - - $input = array(11392, 11394, 11396, 11398, 11400, 11402, 11404, 11406, 11408, 11410, 11412, 11414, 11416, 11418, 11420, - 11422, 11424, 11426, 11428, 11430, 11432, 11434, 11436, 11438, 11440, 11442, 11444, 11446, 11448, 11450, - 11452, 11454, 11456, 11458, 11460, 11462, 11464, 11466, 11468, 11470, 11472, 11474, 11476, 11478, 11480, - 11482, 11484, 11486, 11488, 11490); - $result = Multibyte::ascii($input); - $expected = 'ⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢ'; - $this->assertEquals($expected, $result); - - $input = array(11393, 11395, 11397, 11399, 11401, 11403, 11405, 11407, 11409, 11411, 11413, 11415, 11417, 11419, 11421, 11423, - 11425, 11427, 11429, 11431, 11433, 11435, 11437, 11439, 11441, 11443, 11445, 11447, 11449, 11451, 11453, 11455, - 11457, 11459, 11461, 11463, 11465, 11467, 11469, 11471, 11473, 11475, 11477, 11479, 11481, 11483, 11485, 11487, - 11489, 11491); - $result = Multibyte::ascii($input); - $expected = 'ⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣ'; - $this->assertEquals($expected, $result); - - $input = array(64256, 64257, 64258, 64259, 64260, 64261, 64262, 64275, 64276, 64277, 64278, 64279); - $result = Multibyte::ascii($input); - $expected = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStripos method - * - * @return void - */ - public function testUsingMbStripos() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'f'; - $result = mb_stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'f'; - $result = mb_stripos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'å'; - $result = mb_stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'å'; - $result = mb_stripos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = mb_stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = mb_stripos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'f'; - $result = mb_stripos($string, $find); - $expected = 37; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'Μ'; - $result = mb_stripos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'É'; - $result = mb_stripos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_stripos($string, $find); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_stripos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_stripos($string, $find, 40); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'Ʀ'; - $result = mb_stripos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'ї'; - $result = mb_stripos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_stripos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_stripos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_stripos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_stripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_stripos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_stripos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_stripos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_stripos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_stripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = mb_stripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_stripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_stripos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_stripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'Ő'; - $result = mb_stripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'O'; - $result = mb_stripos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_stripos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'N'; - $result = mb_stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'Ć'; - $result = mb_stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'Ž'; - $result = mb_stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_stripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_stripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'DŽ'; - $result = mb_stripos($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStripos method - * - * @return void - */ - public function testMultibyteStripos() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'f'; - $result = Multibyte::stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'f'; - $result = Multibyte::stripos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'å'; - $result = Multibyte::stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'å'; - $result = Multibyte::stripos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = Multibyte::stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = Multibyte::stripos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'f'; - $result = Multibyte::stripos($string, $find); - $expected = 37; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'Μ'; - $result = Multibyte::stripos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'É'; - $result = Multibyte::stripos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::stripos($string, $find); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::stripos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::stripos($string, $find, 40); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'Ʀ'; - $result = Multibyte::stripos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'ї'; - $result = Multibyte::stripos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::stripos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::stripos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::stripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::stripos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::stripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::stripos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::stripos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::stripos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::stripos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::stripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = Multibyte::stripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::stripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::stripos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::stripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'Ő'; - $result = Multibyte::stripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'O'; - $result = Multibyte::stripos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::stripos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'N'; - $result = Multibyte::stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'Ć'; - $result = Multibyte::stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'Ž'; - $result = Multibyte::stripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::stripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::stripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'DŽ'; - $result = Multibyte::stripos($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStristr method - * - * @return void - */ - public function testUsingMbStristr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'f'; - $result = mb_stristr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'f'; - $result = mb_stristr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'å'; - $result = mb_stristr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'å'; - $result = mb_stristr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = mb_stristr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = mb_stristr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'f'; - $result = mb_stristr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'f'; - $result = mb_stristr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDE'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'Μ'; - $result = mb_stristr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'Μ'; - $result = mb_stristr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'þ'; - $result = mb_stristr($string, $find); - $expected = 'Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'þ'; - $result = mb_stristr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_stristr($string, $find); - $expected = 'ŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_stristr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃń'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_stristr($string, $find); - $expected = 'ƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = mb_stristr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'Ʀ'; - $result = mb_stristr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'Ʀ'; - $result = mb_stristr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'ї'; - $result = mb_stristr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'ї'; - $result = mb_stristr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_stristr($string, $find); - $expected = 'РСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_stristr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_stristr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_stristr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_stristr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_stristr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_stristr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_stristr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_stristr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_stristr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_stristr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_stristr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_stristr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_stristr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_stristr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_stristr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = mb_stristr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = mb_stristr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_stristr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_stristr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_stristr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_stristr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'Ő'; - $result = mb_stristr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'Ő'; - $result = mb_stristr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ĺļ'; - $result = mb_stristr($string, $find, true); - $expected = 'Ĥē'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'O'; - $result = mb_stristr($string, $find); - $expected = 'o, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'O'; - $result = mb_stristr($string, $find, true); - $expected = 'Hell'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_stristr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_stristr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_stristr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_stristr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_stristr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_stristr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'N'; - $result = mb_stristr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'N'; - $result = mb_stristr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'Ć'; - $result = mb_stristr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'Ć'; - $result = mb_stristr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'Ž'; - $result = mb_stristr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'Ž'; - $result = mb_stristr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_stristr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_stristr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_stristr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_stristr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '二周'; - $result = mb_stristr($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStristr method - * - * @return void - */ - public function testMultibyteStristr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'f'; - $result = Multibyte::stristr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'f'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'å'; - $result = Multibyte::stristr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'å'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = Multibyte::stristr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'ċ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'f'; - $result = Multibyte::stristr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'f'; - $result = Multibyte::stristr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDE'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'Μ'; - $result = Multibyte::stristr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'Μ'; - $result = Multibyte::stristr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'þ'; - $result = Multibyte::stristr($string, $find); - $expected = 'Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'þ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::stristr($string, $find); - $expected = 'ŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃń'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::stristr($string, $find); - $expected = 'ƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'Ʀ'; - $result = Multibyte::stristr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'Ʀ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'ї'; - $result = Multibyte::stristr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'ї'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::stristr($string, $find); - $expected = 'РСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::stristr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::stristr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::stristr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::stristr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::stristr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::stristr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::stristr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::stristr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::stristr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::stristr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::stristr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = Multibyte::stristr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::stristr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::stristr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::stristr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'Ő'; - $result = Multibyte::stristr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'Ő'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ĺļ'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'Ĥē'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'O'; - $result = Multibyte::stristr($string, $find); - $expected = 'o, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'O'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'Hell'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::stristr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::stristr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::stristr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'N'; - $result = Multibyte::stristr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'N'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'Ć'; - $result = Multibyte::stristr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'Ć'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'Ž'; - $result = Multibyte::stristr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'Ž'; - $result = Multibyte::stristr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::stristr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::stristr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::stristr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::stristr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '二周'; - $result = Multibyte::stristr($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrlen method - * - * @return void - */ - public function testUsingMbStrlen() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $result = mb_strlen($string); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $result = mb_strlen($string); - $expected = 30; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $result = mb_strlen($string); - $expected = 61; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = mb_strlen($string); - $expected = 94; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $result = mb_strlen($string); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $result = mb_strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $result = mb_strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $result = mb_strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $result = mb_strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $result = mb_strlen($string); - $expected = 28; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $result = mb_strlen($string); - $expected = 49; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $result = mb_strlen($string); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $result = mb_strlen($string); - $expected = 47; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $result = mb_strlen($string); - $expected = 96; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $result = mb_strlen($string); - $expected = 59; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $result = mb_strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $result = mb_strlen($string); - $expected = 65; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $result = mb_strlen($string); - $expected = 76; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $result = mb_strlen($string); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $result = mb_strlen($string); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $result = mb_strlen($string); - $expected = 38; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = mb_strlen($string); - $expected = 13; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $result = mb_strlen($string); - $expected = 13; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $result = mb_strlen($string); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $result = mb_strlen($string); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $result = mb_strlen($string); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $result = mb_strlen($string); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = mb_strlen($string); - $expected = 6; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrlen method - * - * @return void - */ - public function testMultibyteStrlen() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $result = Multibyte::strlen($string); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $result = Multibyte::strlen($string); - $expected = 30; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $result = Multibyte::strlen($string); - $expected = 61; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = Multibyte::strlen($string); - $expected = 94; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $result = Multibyte::strlen($string); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $result = Multibyte::strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $result = Multibyte::strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $result = Multibyte::strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $result = Multibyte::strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $result = Multibyte::strlen($string); - $expected = 28; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $result = Multibyte::strlen($string); - $expected = 49; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $result = Multibyte::strlen($string); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $result = Multibyte::strlen($string); - $expected = 47; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $result = Multibyte::strlen($string); - $expected = 96; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $result = Multibyte::strlen($string); - $expected = 59; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $result = Multibyte::strlen($string); - $expected = 100; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $result = Multibyte::strlen($string); - $expected = 65; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $result = Multibyte::strlen($string); - $expected = 76; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $result = Multibyte::strlen($string); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $result = Multibyte::strlen($string); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $result = Multibyte::strlen($string); - $expected = 38; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = Multibyte::strlen($string); - $expected = 13; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $result = Multibyte::strlen($string); - $expected = 13; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $result = Multibyte::strlen($string); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $result = Multibyte::strlen($string); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $result = Multibyte::strlen($string); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $result = Multibyte::strlen($string); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = Multibyte::strlen($string); - $expected = 6; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrpos method - * - * @return void - */ - public function testUsingMbStrpos() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strpos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strpos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strpos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strpos($string, $find); - $expected = 37; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strpos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'é'; - $result = mb_strpos($string, $find); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strpos($string, $find); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_strpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = mb_strpos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strpos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strpos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'р'; - $result = mb_strpos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strpos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strpos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strpos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strpos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strpos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strpos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'őř'; - $result = mb_strpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strpos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strpos($string, $find, 5); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '一周'; - $result = mb_strpos($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrpos method - * - * @return void - */ - public function testMultibyteStrpos() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strpos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strpos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strpos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strpos($string, $find); - $expected = 37; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strpos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'é'; - $result = Multibyte::strpos($string, $find); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strpos($string, $find); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::strpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = Multibyte::strpos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strpos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strpos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'р'; - $result = Multibyte::strpos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strpos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strpos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strpos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strpos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strpos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strpos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'őř'; - $result = Multibyte::strpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strpos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strpos($string, $find, 5); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '一周'; - $result = Multibyte::strpos($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrrchr method - * - * @return void - */ - public function testUsingMbStrrchr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strrchr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strrchr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strrchr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strrchr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strrchr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strrchr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strrchr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strrchr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDE'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strrchr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strrchr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = mb_strrchr($string, $find); - $expected = 'Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = mb_strrchr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strrchr($string, $find); - $expected = 'ŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strrchr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃń'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_strrchr($string, $find); - $expected = 'ƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = mb_strrchr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strrchr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strrchr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strrchr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strrchr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strrchr($string, $find); - $expected = 'РСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strrchr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strrchr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strrchr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strrchr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strrchr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strrchr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strrchr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strrchr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strrchr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strrchr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strrchr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strrchr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strrchr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strrchr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strrchr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strrchr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strrchr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strrchr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strrchr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strrchr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strrchr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strrchr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strrchr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strrchr($string, $find); - $expected = 'orld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strrchr($string, $find, true); - $expected = 'Hello, W'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_strrchr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_strrchr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_strrchr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_strrchr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_strrchr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_strrchr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strrchr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strrchr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strrchr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strrchr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strrchr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strrchr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strrchr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strrchr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strrchr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strrchr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周龍'; - $result = mb_strrchr($string, $find, true); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrrchr method - * - * @return void - */ - public function testMultibyteStrrchr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strrchr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strrchr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDE'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃń'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strrchr($string, $find); - $expected = 'РСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strrchr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strrchr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strrchr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strrchr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strrchr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strrchr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strrchr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strrchr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strrchr($string, $find); - $expected = 'orld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'Hello, W'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::strrchr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::strrchr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::strrchr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strrchr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strrchr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strrchr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strrchr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strrchr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strrchr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周龍'; - $result = Multibyte::strrchr($string, $find, true); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrrichr method - * - * @return void - */ - public function testUsingMbStrrichr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strrichr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strrichr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strrichr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strrichr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strrichr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strrichr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strrichr($string, $find); - $expected = 'fghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strrichr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcde'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strrichr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strrichr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = mb_strrichr($string, $find); - $expected = 'þÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = mb_strrichr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüý'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strrichr($string, $find); - $expected = 'ņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strrichr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅ'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_strrichr($string, $find); - $expected = 'ƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = mb_strrichr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strrichr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strrichr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strrichr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strrichr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strrichr($string, $find); - $expected = 'рстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмноп'; - $find = 'Р'; - $result = mb_strrichr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strrichr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strrichr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strrichr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strrichr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strrichr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strrichr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strrichr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strrichr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strrichr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strrichr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strrichr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strrichr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strrichr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strrichr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strrichr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strrichr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strrichr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strrichr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strrichr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strrichr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strrichr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strrichr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strrichr($string, $find); - $expected = 'orld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strrichr($string, $find, true); - $expected = 'Hello, W'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_strrichr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_strrichr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_strrichr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_strrichr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_strrichr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_strrichr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strrichr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strrichr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strrichr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strrichr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strrichr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strrichr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strrichr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strrichr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strrichr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strrichr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '百设'; - $result = mb_strrichr($string, $find, true); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrrichr method - * - * @return void - */ - public function testMultibyteStrrichr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strrichr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strrichr($string, $find); - $expected = 'fghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcde'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'þÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüý'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅ'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strrichr($string, $find); - $expected = 'рстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмноп'; - $find = 'Р'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strrichr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strrichr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strrichr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strrichr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strrichr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strrichr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strrichr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strrichr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strrichr($string, $find); - $expected = 'orld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'Hello, W'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::strrichr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::strrichr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::strrichr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strrichr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strrichr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strrichr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strrichr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strrichr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strrichr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '百设'; - $result = Multibyte::strrichr($string, $find, true); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrripos method - * - * @return void - */ - public function testUsingMbStrripos() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strripos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strripos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'ÓÔ'; - $result = mb_strripos($string, $find); - $expected = 19; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strripos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strripos($string, $find); - $expected = 69; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strripos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'é'; - $result = mb_strripos($string, $find); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strripos($string, $find); - $expected = 25; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_strripos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = mb_strripos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strripos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strripos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strripos($string, $find); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'р'; - $result = mb_strripos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strripos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strripos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strripos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strripos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strripos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnoppqrstuvwxyz'; - $find = 'pp'; - $result = mb_strripos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strripos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strripos($string, $find, 5); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'dž'; - $result = mb_strripos($string, $find); - $this->assertFalse($result); - } - -/** - * testMultibyteStrripos method - * - * @return void - */ - public function testMultibyteStrripos() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strripos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strripos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'ÓÔ'; - $result = Multibyte::strripos($string, $find); - $expected = 19; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strripos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strripos($string, $find); - $expected = 69; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strripos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'é'; - $result = Multibyte::strripos($string, $find); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strripos($string, $find); - $expected = 25; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::strripos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = Multibyte::strripos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strripos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strripos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strripos($string, $find); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'р'; - $result = Multibyte::strripos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strripos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strripos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strripos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strripos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strripos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strripos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strripos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnoppqrstuvwxyz'; - $find = 'pp'; - $result = Multibyte::strripos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strripos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strripos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strripos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strripos($string, $find, 5); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strripos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strripos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'dž'; - $result = Multibyte::strripos($string, $find); - $expected = 0; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrrpos method - * - * @return void - */ - public function testUsingMbStrrpos() { - $this->skipIf(extension_loaded('mbstring') && version_compare(PHP_VERSION, '5.2.0', '<'), 'PHP version does not support $offset parameter in mb_strrpos().'); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strrpos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'ÙÚ'; - $result = mb_strrpos($string, $find); - $expected = 25; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strrpos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strrpos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strrpos($string, $find); - $expected = 37; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strrpos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'é'; - $result = mb_strrpos($string, $find); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strrpos($string, $find); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_strrpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = mb_strrpos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strrpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strrpos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strrpos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'р'; - $result = mb_strrpos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strrpos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strrpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strrpos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strrpos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strrpos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strrpos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strrpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strrpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnoppqrstuvwxyz'; - $find = 'pp'; - $result = mb_strrpos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strrpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strrpos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strrpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strrpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strrpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strrpos($string, $find, 5); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strrpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strrpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'H'; - $result = mb_strrpos($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrrpos method - * - * @return void - */ - public function testMultibyteStrrpos() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strrpos($string, $find, 6); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strrpos($string, $find, 6); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞ'; - $find = 'ÙÚ'; - $result = Multibyte::strrpos($string, $find); - $expected = 25; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strrpos($string, $find, 6); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strrpos($string, $find); - $expected = 37; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strrpos($string, $find); - $expected = 20; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'é'; - $result = Multibyte::strrpos($string, $find); - $expected = 32; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strrpos($string, $find); - $expected = 24; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::strrpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = Multibyte::strrpos($string, $find); - $expected = 40; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strrpos($string, $find); - $expected = 39; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strrpos($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strrpos($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'р'; - $result = Multibyte::strrpos($string, $find, 5); - $expected = 36; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strrpos($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strrpos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strrpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strrpos($string, $find); - $expected = 31; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strrpos($string, $find); - $expected = 26; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strrpos($string, $find); - $expected = 46; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strrpos($string, $find); - $expected = 45; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strrpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strrpos($string, $find); - $expected = 10; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnoppqrstuvwxyz'; - $find = 'pp'; - $result = Multibyte::strrpos($string, $find); - $expected = 15; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strrpos($string, $find); - $expected = 16; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strrpos($string, $find); - $expected = 17; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strrpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strrpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strrpos($string, $find); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strrpos($string, $find, 5); - $expected = 8; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strrpos($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strrpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strrpos($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'H'; - $result = Multibyte::strrpos($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrstr method - * - * @return void - */ - public function testUsingMbStrstr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strstr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_strstr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strstr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = mb_strstr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strstr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_strstr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strstr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = mb_strstr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDE'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strstr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = mb_strstr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = mb_strstr($string, $find); - $expected = 'Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = mb_strstr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strstr($string, $find); - $expected = 'ŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = mb_strstr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃń'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_strstr($string, $find); - $expected = 'ƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = mb_strstr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strstr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = mb_strstr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strstr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_strstr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strstr($string, $find); - $expected = 'РСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_strstr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strstr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = mb_strstr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strstr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_strstr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strstr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_strstr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strstr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_strstr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strstr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = mb_strstr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strstr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = mb_strstr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strstr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = mb_strstr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strstr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = mb_strstr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = mb_strstr($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strstr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_strstr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strstr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_strstr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strstr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_strstr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ĺļ'; - $result = mb_strstr($string, $find, true); - $expected = 'Ĥē'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strstr($string, $find); - $expected = 'o, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_strstr($string, $find, true); - $expected = 'Hell'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_strstr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = mb_strstr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_strstr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = mb_strstr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_strstr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = mb_strstr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strstr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_strstr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strstr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_strstr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strstr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_strstr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strstr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_strstr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strstr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_strstr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '二周'; - $result = mb_strstr($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrstr method - * - * @return void - */ - public function testMultibyteStrstr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strstr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ0123456789'; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ABCDE'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strstr($string, $find); - $expected = 'ÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $find = 'Å'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ÀÁÂÃÄ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strstr($string, $find); - $expected = 'ĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ĀĂĄĆĈ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strstr($string, $find); - $expected = 'FGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $find = 'F'; - $result = Multibyte::strstr($string, $find, true); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDE'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strstr($string, $find); - $expected = 'µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $find = 'µ'; - $result = Multibyte::strstr($string, $find, true); - $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = Multibyte::strstr($string, $find); - $expected = 'Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $find = 'Þ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strstr($string, $find); - $expected = 'ŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'Ņ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃń'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::strstr($string, $find); - $expected = 'ƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $find = 'Ƹ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strstr($string, $find); - $expected = 'ʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $find = 'ʀ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strstr($string, $find); - $expected = 'ЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ЀЁЂЃЄЅІ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strstr($string, $find); - $expected = 'РСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'МНОП'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strstr($string, $find); - $expected = 'نهوىيًٌٍَُ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'فقكلم'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strstr($string, $find); - $expected = '✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::strstr($string, $find, true); - $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strstr($string, $find); - $expected = '⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::strstr($string, $find, true); - $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strstr($string, $find); - $expected = '⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::strstr($string, $find, true); - $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strstr($string, $find); - $expected = '눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눻'; - $result = Multibyte::strstr($string, $find, true); - $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strstr($string, $find); - $expected = 'ﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strstr($string, $find); - $expected = 'ﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strstr($string, $find); - $expected = 'klmnopqrstuvwxyz'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'k'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'abcdefghij'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $find = 'K'; - $result = Multibyte::strstr($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strstr($string, $find); - $expected = 'アイウエオカキク'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::strstr($string, $find, true); - $expected = '。「」、・ヲァィゥェォャュョッー'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strstr($string, $find); - $expected = 'ハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'ケコサシスセソタチツテトナニヌネノ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strstr($string, $find); - $expected = 'őřļď!'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'Ĥēĺļŏ, Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ĺļ'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'Ĥē'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strstr($string, $find); - $expected = 'o, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'Hell'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::strstr($string, $find); - $expected = 'World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'Wo'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'Hello, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::strstr($string, $find); - $expected = 'llo, World!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'll'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'He'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::strstr($string, $find); - $expected = 'rld!'; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rld'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'Hello, Wo'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strstr($string, $find); - $expected = 'ni'; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'či'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strstr($string, $find); - $expected = 'ći'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'mo'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strstr($string, $find); - $expected = 'žavni'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::strstr($string, $find, true); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strstr($string, $find); - $expected = '设为首页'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::strstr($string, $find, true); - $expected = '把百度'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strstr($string, $find); - $expected = '周永龍'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::strstr($string, $find, true); - $expected = '一二三'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '二周'; - $result = Multibyte::strstr($string, $find); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrtolower method - * - * @return void - */ - public function testUsingMbStrtolower() { - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`ABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~'; - $result = mb_strtolower($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@abcdefghijklmnopqrstuvwxyz[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $result = mb_strtolower($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $this->assertEquals($expected, $result); - - $string = 'À'; - $result = mb_strtolower($string); - $expected = 'à'; - $this->assertEquals($expected, $result); - - $string = 'Á'; - $result = mb_strtolower($string); - $expected = 'á'; - $this->assertEquals($expected, $result); - - $string = 'Â'; - $result = mb_strtolower($string); - $expected = 'â'; - $this->assertEquals($expected, $result); - - $string = 'Ã'; - $result = mb_strtolower($string); - $expected = 'ã'; - $this->assertEquals($expected, $result); - - $string = 'Ä'; - $result = mb_strtolower($string); - $expected = 'ä'; - $this->assertEquals($expected, $result); - - $string = 'Å'; - $result = mb_strtolower($string); - $expected = 'å'; - $this->assertEquals($expected, $result); - - $string = 'Æ'; - $result = mb_strtolower($string); - $expected = 'æ'; - $this->assertEquals($expected, $result); - - $string = 'Ç'; - $result = mb_strtolower($string); - $expected = 'ç'; - $this->assertEquals($expected, $result); - - $string = 'È'; - $result = mb_strtolower($string); - $expected = 'è'; - $this->assertEquals($expected, $result); - - $string = 'É'; - $result = mb_strtolower($string); - $expected = 'é'; - $this->assertEquals($expected, $result); - - $string = 'Ê'; - $result = mb_strtolower($string); - $expected = 'ê'; - $this->assertEquals($expected, $result); - - $string = 'Ë'; - $result = mb_strtolower($string); - $expected = 'ë'; - $this->assertEquals($expected, $result); - - $string = 'Ì'; - $result = mb_strtolower($string); - $expected = 'ì'; - $this->assertEquals($expected, $result); - - $string = 'Í'; - $result = mb_strtolower($string); - $expected = 'í'; - $this->assertEquals($expected, $result); - - $string = 'Î'; - $result = mb_strtolower($string); - $expected = 'î'; - $this->assertEquals($expected, $result); - - $string = 'Ï'; - $result = mb_strtolower($string); - $expected = 'ï'; - $this->assertEquals($expected, $result); - - $string = 'Ð'; - $result = mb_strtolower($string); - $expected = 'ð'; - $this->assertEquals($expected, $result); - - $string = 'Ñ'; - $result = mb_strtolower($string); - $expected = 'ñ'; - $this->assertEquals($expected, $result); - - $string = 'Ò'; - $result = mb_strtolower($string); - $expected = 'ò'; - $this->assertEquals($expected, $result); - - $string = 'Ó'; - $result = mb_strtolower($string); - $expected = 'ó'; - $this->assertEquals($expected, $result); - - $string = 'Ô'; - $result = mb_strtolower($string); - $expected = 'ô'; - $this->assertEquals($expected, $result); - - $string = 'Õ'; - $result = mb_strtolower($string); - $expected = 'õ'; - $this->assertEquals($expected, $result); - - $string = 'Ö'; - $result = mb_strtolower($string); - $expected = 'ö'; - $this->assertEquals($expected, $result); - - $string = 'Ø'; - $result = mb_strtolower($string); - $expected = 'ø'; - $this->assertEquals($expected, $result); - - $string = 'Ù'; - $result = mb_strtolower($string); - $expected = 'ù'; - $this->assertEquals($expected, $result); - - $string = 'Ú'; - $result = mb_strtolower($string); - $expected = 'ú'; - $this->assertEquals($expected, $result); - - $string = 'Û'; - $result = mb_strtolower($string); - $expected = 'û'; - $this->assertEquals($expected, $result); - - $string = 'Ü'; - $result = mb_strtolower($string); - $expected = 'ü'; - $this->assertEquals($expected, $result); - - $string = 'Ý'; - $result = mb_strtolower($string); - $expected = 'ý'; - $this->assertEquals($expected, $result); - - $string = 'Þ'; - $result = mb_strtolower($string); - $expected = 'þ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $result = mb_strtolower($string); - $expected = 'àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþ'; - $this->assertEquals($expected, $result); - - $string = 'Ā'; - $result = mb_strtolower($string); - $expected = 'ā'; - $this->assertEquals($expected, $result); - - $string = 'Ă'; - $result = mb_strtolower($string); - $expected = 'ă'; - $this->assertEquals($expected, $result); - - $string = 'Ą'; - $result = mb_strtolower($string); - $expected = 'ą'; - $this->assertEquals($expected, $result); - - $string = 'Ć'; - $result = mb_strtolower($string); - $expected = 'ć'; - $this->assertEquals($expected, $result); - - $string = 'Ĉ'; - $result = mb_strtolower($string); - $expected = 'ĉ'; - $this->assertEquals($expected, $result); - - $string = 'Ċ'; - $result = mb_strtolower($string); - $expected = 'ċ'; - $this->assertEquals($expected, $result); - - $string = 'Č'; - $result = mb_strtolower($string); - $expected = 'č'; - $this->assertEquals($expected, $result); - - $string = 'Ď'; - $result = mb_strtolower($string); - $expected = 'ď'; - $this->assertEquals($expected, $result); - - $string = 'Đ'; - $result = mb_strtolower($string); - $expected = 'đ'; - $this->assertEquals($expected, $result); - - $string = 'Ē'; - $result = mb_strtolower($string); - $expected = 'ē'; - $this->assertEquals($expected, $result); - - $string = 'Ĕ'; - $result = mb_strtolower($string); - $expected = 'ĕ'; - $this->assertEquals($expected, $result); - - $string = 'Ė'; - $result = mb_strtolower($string); - $expected = 'ė'; - $this->assertEquals($expected, $result); - - $string = 'Ę'; - $result = mb_strtolower($string); - $expected = 'ę'; - $this->assertEquals($expected, $result); - - $string = 'Ě'; - $result = mb_strtolower($string); - $expected = 'ě'; - $this->assertEquals($expected, $result); - - $string = 'Ĝ'; - $result = mb_strtolower($string); - $expected = 'ĝ'; - $this->assertEquals($expected, $result); - - $string = 'Ğ'; - $result = mb_strtolower($string); - $expected = 'ğ'; - $this->assertEquals($expected, $result); - - $string = 'Ġ'; - $result = mb_strtolower($string); - $expected = 'ġ'; - $this->assertEquals($expected, $result); - - $string = 'Ģ'; - $result = mb_strtolower($string); - $expected = 'ģ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥ'; - $result = mb_strtolower($string); - $expected = 'ĥ'; - $this->assertEquals($expected, $result); - - $string = 'Ħ'; - $result = mb_strtolower($string); - $expected = 'ħ'; - $this->assertEquals($expected, $result); - - $string = 'Ĩ'; - $result = mb_strtolower($string); - $expected = 'ĩ'; - $this->assertEquals($expected, $result); - - $string = 'Ī'; - $result = mb_strtolower($string); - $expected = 'ī'; - $this->assertEquals($expected, $result); - - $string = 'Ĭ'; - $result = mb_strtolower($string); - $expected = 'ĭ'; - $this->assertEquals($expected, $result); - - $string = 'Į'; - $result = mb_strtolower($string); - $expected = 'į'; - $this->assertEquals($expected, $result); - - $string = 'IJ'; - $result = mb_strtolower($string); - $expected = 'ij'; - $this->assertEquals($expected, $result); - - $string = 'Ĵ'; - $result = mb_strtolower($string); - $expected = 'ĵ'; - $this->assertEquals($expected, $result); - - $string = 'Ķ'; - $result = mb_strtolower($string); - $expected = 'ķ'; - $this->assertEquals($expected, $result); - - $string = 'Ĺ'; - $result = mb_strtolower($string); - $expected = 'ĺ'; - $this->assertEquals($expected, $result); - - $string = 'Ļ'; - $result = mb_strtolower($string); - $expected = 'ļ'; - $this->assertEquals($expected, $result); - - $string = 'Ľ'; - $result = mb_strtolower($string); - $expected = 'ľ'; - $this->assertEquals($expected, $result); - - $string = 'Ŀ'; - $result = mb_strtolower($string); - $expected = 'ŀ'; - $this->assertEquals($expected, $result); - - $string = 'Ł'; - $result = mb_strtolower($string); - $expected = 'ł'; - $this->assertEquals($expected, $result); - - $string = 'Ń'; - $result = mb_strtolower($string); - $expected = 'ń'; - $this->assertEquals($expected, $result); - - $string = 'Ņ'; - $result = mb_strtolower($string); - $expected = 'ņ'; - $this->assertEquals($expected, $result); - - $string = 'Ň'; - $result = mb_strtolower($string); - $expected = 'ň'; - $this->assertEquals($expected, $result); - - $string = 'Ŋ'; - $result = mb_strtolower($string); - $expected = 'ŋ'; - $this->assertEquals($expected, $result); - - $string = 'Ō'; - $result = mb_strtolower($string); - $expected = 'ō'; - $this->assertEquals($expected, $result); - - $string = 'Ŏ'; - $result = mb_strtolower($string); - $expected = 'ŏ'; - $this->assertEquals($expected, $result); - - $string = 'Ő'; - $result = mb_strtolower($string); - $expected = 'ő'; - $this->assertEquals($expected, $result); - - $string = 'Œ'; - $result = mb_strtolower($string); - $expected = 'œ'; - $this->assertEquals($expected, $result); - - $string = 'Ŕ'; - $result = mb_strtolower($string); - $expected = 'ŕ'; - $this->assertEquals($expected, $result); - - $string = 'Ŗ'; - $result = mb_strtolower($string); - $expected = 'ŗ'; - $this->assertEquals($expected, $result); - - $string = 'Ř'; - $result = mb_strtolower($string); - $expected = 'ř'; - $this->assertEquals($expected, $result); - - $string = 'Ś'; - $result = mb_strtolower($string); - $expected = 'ś'; - $this->assertEquals($expected, $result); - - $string = 'Ŝ'; - $result = mb_strtolower($string); - $expected = 'ŝ'; - $this->assertEquals($expected, $result); - - $string = 'Ş'; - $result = mb_strtolower($string); - $expected = 'ş'; - $this->assertEquals($expected, $result); - - $string = 'Š'; - $result = mb_strtolower($string); - $expected = 'š'; - $this->assertEquals($expected, $result); - - $string = 'Ţ'; - $result = mb_strtolower($string); - $expected = 'ţ'; - $this->assertEquals($expected, $result); - - $string = 'Ť'; - $result = mb_strtolower($string); - $expected = 'ť'; - $this->assertEquals($expected, $result); - - $string = 'Ŧ'; - $result = mb_strtolower($string); - $expected = 'ŧ'; - $this->assertEquals($expected, $result); - - $string = 'Ũ'; - $result = mb_strtolower($string); - $expected = 'ũ'; - $this->assertEquals($expected, $result); - - $string = 'Ū'; - $result = mb_strtolower($string); - $expected = 'ū'; - $this->assertEquals($expected, $result); - - $string = 'Ŭ'; - $result = mb_strtolower($string); - $expected = 'ŭ'; - $this->assertEquals($expected, $result); - - $string = 'Ů'; - $result = mb_strtolower($string); - $expected = 'ů'; - $this->assertEquals($expected, $result); - - $string = 'Ű'; - $result = mb_strtolower($string); - $expected = 'ű'; - $this->assertEquals($expected, $result); - - $string = 'Ų'; - $result = mb_strtolower($string); - $expected = 'ų'; - $this->assertEquals($expected, $result); - - $string = 'Ŵ'; - $result = mb_strtolower($string); - $expected = 'ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Ŷ'; - $result = mb_strtolower($string); - $expected = 'ŷ'; - $this->assertEquals($expected, $result); - - $string = 'Ź'; - $result = mb_strtolower($string); - $expected = 'ź'; - $this->assertEquals($expected, $result); - - $string = 'Ż'; - $result = mb_strtolower($string); - $expected = 'ż'; - $this->assertEquals($expected, $result); - - $string = 'Ž'; - $result = mb_strtolower($string); - $expected = 'ž'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $result = mb_strtolower($string); - $expected = 'āăąćĉċčďđēĕėęěĝğġģĥħĩīĭįijĵķĺļľŀłńņňŋōŏőœŕŗřśŝşšţťŧũūŭůűųŵŷźżž'; - $this->assertEquals($expected, $result); - - $string = 'ĤĒĹĻŎ, ŴŐŘĻĎ!'; - $result = mb_strtolower($string); - $expected = 'ĥēĺļŏ, ŵőřļď!'; - $this->assertEquals($expected, $result); - - $string = 'ĥēĺļŏ, ŵőřļď!'; - $result = mb_strtolower($string); - $expected = 'ĥēĺļŏ, ŵőřļď!'; - $this->assertEquals($expected, $result); - - $string = 'ἈΙ'; - $result = mb_strtolower($string); - $expected = 'ἀι'; - $this->assertEquals($expected, $result); - - $string = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $result = mb_strtolower($string); - $expected = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrtolower method - * - * @return void - */ - public function testMultibyteStrtolower() { - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`ABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~'; - $result = Multibyte::strtolower($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@abcdefghijklmnopqrstuvwxyz[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $result = Multibyte::strtolower($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $this->assertEquals($expected, $result); - - $string = 'À'; - $result = Multibyte::strtolower($string); - $expected = 'à'; - $this->assertEquals($expected, $result); - - $string = 'Á'; - $result = Multibyte::strtolower($string); - $expected = 'á'; - $this->assertEquals($expected, $result); - - $string = 'Â'; - $result = Multibyte::strtolower($string); - $expected = 'â'; - $this->assertEquals($expected, $result); - - $string = 'Ã'; - $result = Multibyte::strtolower($string); - $expected = 'ã'; - $this->assertEquals($expected, $result); - - $string = 'Ä'; - $result = Multibyte::strtolower($string); - $expected = 'ä'; - $this->assertEquals($expected, $result); - - $string = 'Å'; - $result = Multibyte::strtolower($string); - $expected = 'å'; - $this->assertEquals($expected, $result); - - $string = 'Æ'; - $result = Multibyte::strtolower($string); - $expected = 'æ'; - $this->assertEquals($expected, $result); - - $string = 'Ç'; - $result = Multibyte::strtolower($string); - $expected = 'ç'; - $this->assertEquals($expected, $result); - - $string = 'È'; - $result = Multibyte::strtolower($string); - $expected = 'è'; - $this->assertEquals($expected, $result); - - $string = 'É'; - $result = Multibyte::strtolower($string); - $expected = 'é'; - $this->assertEquals($expected, $result); - - $string = 'Ê'; - $result = Multibyte::strtolower($string); - $expected = 'ê'; - $this->assertEquals($expected, $result); - - $string = 'Ë'; - $result = Multibyte::strtolower($string); - $expected = 'ë'; - $this->assertEquals($expected, $result); - - $string = 'Ì'; - $result = Multibyte::strtolower($string); - $expected = 'ì'; - $this->assertEquals($expected, $result); - - $string = 'Í'; - $result = Multibyte::strtolower($string); - $expected = 'í'; - $this->assertEquals($expected, $result); - - $string = 'Î'; - $result = Multibyte::strtolower($string); - $expected = 'î'; - $this->assertEquals($expected, $result); - - $string = 'Ï'; - $result = Multibyte::strtolower($string); - $expected = 'ï'; - $this->assertEquals($expected, $result); - - $string = 'Ð'; - $result = Multibyte::strtolower($string); - $expected = 'ð'; - $this->assertEquals($expected, $result); - - $string = 'Ñ'; - $result = Multibyte::strtolower($string); - $expected = 'ñ'; - $this->assertEquals($expected, $result); - - $string = 'Ò'; - $result = Multibyte::strtolower($string); - $expected = 'ò'; - $this->assertEquals($expected, $result); - - $string = 'Ó'; - $result = Multibyte::strtolower($string); - $expected = 'ó'; - $this->assertEquals($expected, $result); - - $string = 'Ô'; - $result = Multibyte::strtolower($string); - $expected = 'ô'; - $this->assertEquals($expected, $result); - - $string = 'Õ'; - $result = Multibyte::strtolower($string); - $expected = 'õ'; - $this->assertEquals($expected, $result); - - $string = 'Ö'; - $result = Multibyte::strtolower($string); - $expected = 'ö'; - $this->assertEquals($expected, $result); - - $string = 'Ø'; - $result = Multibyte::strtolower($string); - $expected = 'ø'; - $this->assertEquals($expected, $result); - - $string = 'Ù'; - $result = Multibyte::strtolower($string); - $expected = 'ù'; - $this->assertEquals($expected, $result); - - $string = 'Ú'; - $result = Multibyte::strtolower($string); - $expected = 'ú'; - $this->assertEquals($expected, $result); - - $string = 'Û'; - $result = Multibyte::strtolower($string); - $expected = 'û'; - $this->assertEquals($expected, $result); - - $string = 'Ü'; - $result = Multibyte::strtolower($string); - $expected = 'ü'; - $this->assertEquals($expected, $result); - - $string = 'Ý'; - $result = Multibyte::strtolower($string); - $expected = 'ý'; - $this->assertEquals($expected, $result); - - $string = 'Þ'; - $result = Multibyte::strtolower($string); - $expected = 'þ'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $result = Multibyte::strtolower($string); - $expected = 'àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþ'; - $this->assertEquals($expected, $result); - - $string = 'Ā'; - $result = Multibyte::strtolower($string); - $expected = 'ā'; - $this->assertEquals($expected, $result); - - $string = 'Ă'; - $result = Multibyte::strtolower($string); - $expected = 'ă'; - $this->assertEquals($expected, $result); - - $string = 'Ą'; - $result = Multibyte::strtolower($string); - $expected = 'ą'; - $this->assertEquals($expected, $result); - - $string = 'Ć'; - $result = Multibyte::strtolower($string); - $expected = 'ć'; - $this->assertEquals($expected, $result); - - $string = 'Ĉ'; - $result = Multibyte::strtolower($string); - $expected = 'ĉ'; - $this->assertEquals($expected, $result); - - $string = 'Ċ'; - $result = Multibyte::strtolower($string); - $expected = 'ċ'; - $this->assertEquals($expected, $result); - - $string = 'Č'; - $result = Multibyte::strtolower($string); - $expected = 'č'; - $this->assertEquals($expected, $result); - - $string = 'Ď'; - $result = Multibyte::strtolower($string); - $expected = 'ď'; - $this->assertEquals($expected, $result); - - $string = 'Đ'; - $result = Multibyte::strtolower($string); - $expected = 'đ'; - $this->assertEquals($expected, $result); - - $string = 'Ē'; - $result = Multibyte::strtolower($string); - $expected = 'ē'; - $this->assertEquals($expected, $result); - - $string = 'Ĕ'; - $result = Multibyte::strtolower($string); - $expected = 'ĕ'; - $this->assertEquals($expected, $result); - - $string = 'Ė'; - $result = Multibyte::strtolower($string); - $expected = 'ė'; - $this->assertEquals($expected, $result); - - $string = 'Ę'; - $result = Multibyte::strtolower($string); - $expected = 'ę'; - $this->assertEquals($expected, $result); - - $string = 'Ě'; - $result = Multibyte::strtolower($string); - $expected = 'ě'; - $this->assertEquals($expected, $result); - - $string = 'Ĝ'; - $result = Multibyte::strtolower($string); - $expected = 'ĝ'; - $this->assertEquals($expected, $result); - - $string = 'Ğ'; - $result = Multibyte::strtolower($string); - $expected = 'ğ'; - $this->assertEquals($expected, $result); - - $string = 'Ġ'; - $result = Multibyte::strtolower($string); - $expected = 'ġ'; - $this->assertEquals($expected, $result); - - $string = 'Ģ'; - $result = Multibyte::strtolower($string); - $expected = 'ģ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥ'; - $result = Multibyte::strtolower($string); - $expected = 'ĥ'; - $this->assertEquals($expected, $result); - - $string = 'Ħ'; - $result = Multibyte::strtolower($string); - $expected = 'ħ'; - $this->assertEquals($expected, $result); - - $string = 'Ĩ'; - $result = Multibyte::strtolower($string); - $expected = 'ĩ'; - $this->assertEquals($expected, $result); - - $string = 'Ī'; - $result = Multibyte::strtolower($string); - $expected = 'ī'; - $this->assertEquals($expected, $result); - - $string = 'Ĭ'; - $result = Multibyte::strtolower($string); - $expected = 'ĭ'; - $this->assertEquals($expected, $result); - - $string = 'Į'; - $result = Multibyte::strtolower($string); - $expected = 'į'; - $this->assertEquals($expected, $result); - - $string = 'IJ'; - $result = Multibyte::strtolower($string); - $expected = 'ij'; - $this->assertEquals($expected, $result); - - $string = 'Ĵ'; - $result = Multibyte::strtolower($string); - $expected = 'ĵ'; - $this->assertEquals($expected, $result); - - $string = 'Ķ'; - $result = Multibyte::strtolower($string); - $expected = 'ķ'; - $this->assertEquals($expected, $result); - - $string = 'Ĺ'; - $result = Multibyte::strtolower($string); - $expected = 'ĺ'; - $this->assertEquals($expected, $result); - - $string = 'Ļ'; - $result = Multibyte::strtolower($string); - $expected = 'ļ'; - $this->assertEquals($expected, $result); - - $string = 'Ľ'; - $result = Multibyte::strtolower($string); - $expected = 'ľ'; - $this->assertEquals($expected, $result); - - $string = 'Ŀ'; - $result = Multibyte::strtolower($string); - $expected = 'ŀ'; - $this->assertEquals($expected, $result); - - $string = 'Ł'; - $result = Multibyte::strtolower($string); - $expected = 'ł'; - $this->assertEquals($expected, $result); - - $string = 'Ń'; - $result = Multibyte::strtolower($string); - $expected = 'ń'; - $this->assertEquals($expected, $result); - - $string = 'Ņ'; - $result = Multibyte::strtolower($string); - $expected = 'ņ'; - $this->assertEquals($expected, $result); - - $string = 'Ň'; - $result = Multibyte::strtolower($string); - $expected = 'ň'; - $this->assertEquals($expected, $result); - - $string = 'Ŋ'; - $result = Multibyte::strtolower($string); - $expected = 'ŋ'; - $this->assertEquals($expected, $result); - - $string = 'Ō'; - $result = Multibyte::strtolower($string); - $expected = 'ō'; - $this->assertEquals($expected, $result); - - $string = 'Ŏ'; - $result = Multibyte::strtolower($string); - $expected = 'ŏ'; - $this->assertEquals($expected, $result); - - $string = 'Ő'; - $result = Multibyte::strtolower($string); - $expected = 'ő'; - $this->assertEquals($expected, $result); - - $string = 'Œ'; - $result = Multibyte::strtolower($string); - $expected = 'œ'; - $this->assertEquals($expected, $result); - - $string = 'Ŕ'; - $result = Multibyte::strtolower($string); - $expected = 'ŕ'; - $this->assertEquals($expected, $result); - - $string = 'Ŗ'; - $result = Multibyte::strtolower($string); - $expected = 'ŗ'; - $this->assertEquals($expected, $result); - - $string = 'Ř'; - $result = Multibyte::strtolower($string); - $expected = 'ř'; - $this->assertEquals($expected, $result); - - $string = 'Ś'; - $result = Multibyte::strtolower($string); - $expected = 'ś'; - $this->assertEquals($expected, $result); - - $string = 'Ŝ'; - $result = Multibyte::strtolower($string); - $expected = 'ŝ'; - $this->assertEquals($expected, $result); - - $string = 'Ş'; - $result = Multibyte::strtolower($string); - $expected = 'ş'; - $this->assertEquals($expected, $result); - - $string = 'Š'; - $result = Multibyte::strtolower($string); - $expected = 'š'; - $this->assertEquals($expected, $result); - - $string = 'Ţ'; - $result = Multibyte::strtolower($string); - $expected = 'ţ'; - $this->assertEquals($expected, $result); - - $string = 'Ť'; - $result = Multibyte::strtolower($string); - $expected = 'ť'; - $this->assertEquals($expected, $result); - - $string = 'Ŧ'; - $result = Multibyte::strtolower($string); - $expected = 'ŧ'; - $this->assertEquals($expected, $result); - - $string = 'Ũ'; - $result = Multibyte::strtolower($string); - $expected = 'ũ'; - $this->assertEquals($expected, $result); - - $string = 'Ū'; - $result = Multibyte::strtolower($string); - $expected = 'ū'; - $this->assertEquals($expected, $result); - - $string = 'Ŭ'; - $result = Multibyte::strtolower($string); - $expected = 'ŭ'; - $this->assertEquals($expected, $result); - - $string = 'Ů'; - $result = Multibyte::strtolower($string); - $expected = 'ů'; - $this->assertEquals($expected, $result); - - $string = 'Ű'; - $result = Multibyte::strtolower($string); - $expected = 'ű'; - $this->assertEquals($expected, $result); - - $string = 'Ų'; - $result = Multibyte::strtolower($string); - $expected = 'ų'; - $this->assertEquals($expected, $result); - - $string = 'Ŵ'; - $result = Multibyte::strtolower($string); - $expected = 'ŵ'; - $this->assertEquals($expected, $result); - - $string = 'Ŷ'; - $result = Multibyte::strtolower($string); - $expected = 'ŷ'; - $this->assertEquals($expected, $result); - - $string = 'Ź'; - $result = Multibyte::strtolower($string); - $expected = 'ź'; - $this->assertEquals($expected, $result); - - $string = 'Ż'; - $result = Multibyte::strtolower($string); - $expected = 'ż'; - $this->assertEquals($expected, $result); - - $string = 'Ž'; - $result = Multibyte::strtolower($string); - $expected = 'ž'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $result = Multibyte::strtolower($string); - $expected = 'āăąćĉċčďđēĕėęěĝğġģĥħĩīĭįijĵķĺļľŀłńņňŋōŏőœŕŗřśŝşšţťŧũūŭůűųŵŷźżž'; - $this->assertEquals($expected, $result); - - $string = 'ĤĒĹĻŎ, ŴŐŘĻĎ!'; - $result = Multibyte::strtolower($string); - $expected = 'ĥēĺļŏ, ŵőřļď!'; - $this->assertEquals($expected, $result); - - $string = 'ĥēĺļŏ, ŵőřļď!'; - $result = Multibyte::strtolower($string); - $expected = 'ĥēĺļŏ, ŵőřļď!'; - $this->assertEquals($expected, $result); - - $string = 'ἈΙ'; - $result = Multibyte::strtolower($string); - $expected = 'ἀι'; - $this->assertEquals($expected, $result); - - $string = 'ԀԂԄԆԈԊԌԎԐԒ'; - $result = Multibyte::strtolower($string); - $expected = 'ԁԃԅԇԉԋԍԏԑԓ'; - $this->assertEquals($expected, $result); - - $string = 'ԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖև'; - $result = Multibyte::strtolower($string); - $expected = 'աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև'; - $this->assertEquals($expected, $result); - - $string = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $result = Multibyte::strtolower($string); - $expected = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $this->assertEquals($expected, $result); - - $string = 'ḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẖẗẘẙẚẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸ'; - $result = Multibyte::strtolower($string); - $expected = 'ḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕẖẗẘẙẚạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ'; - $this->assertEquals($expected, $result); - - $string = 'ΩKÅℲ'; - $result = Multibyte::strtolower($string); - $expected = 'ωkåⅎ'; - $this->assertEquals($expected, $result); - - $string = 'ΩKÅ'; - $result = Multibyte::strtolower($string); - $expected = 'ωkå'; - $this->assertEquals($expected, $result); - - $string = 'ΩKÅ'; - $result = Multibyte::strtolower($string); - $expected = 'ωkå'; - $this->assertEquals($expected, $result); - - $string = 'ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯↃ'; - $result = Multibyte::strtolower($string); - $expected = 'ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↄ'; - $this->assertEquals($expected, $result); - - $string = 'ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ'; - $result = Multibyte::strtolower($string); - $expected = 'ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ'; - $this->assertEquals($expected, $result); - - $string = 'ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮ'; - $result = Multibyte::strtolower($string); - $expected = 'ⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞ'; - $this->assertEquals($expected, $result); - - $string = 'ⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢ'; - $result = Multibyte::strtolower($string); - $expected = 'ⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣ'; - $this->assertEquals($expected, $result); - - $string = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $result = Multibyte::strtolower($string); - $expected = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbStrtoupper method - * - * @return void - */ - public function testUsingMbStrtoupper() { - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = mb_strtoupper($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`ABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $result = mb_strtoupper($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $this->assertEquals($expected, $result); - - $string = 'à'; - $result = mb_strtoupper($string); - $expected = 'À'; - $this->assertEquals($expected, $result); - - $string = 'á'; - $result = mb_strtoupper($string); - $expected = 'Á'; - $this->assertEquals($expected, $result); - - $string = 'â'; - $result = mb_strtoupper($string); - $expected = 'Â'; - $this->assertEquals($expected, $result); - - $string = 'ã'; - $result = mb_strtoupper($string); - $expected = 'Ã'; - $this->assertEquals($expected, $result); - - $string = 'ä'; - $result = mb_strtoupper($string); - $expected = 'Ä'; - $this->assertEquals($expected, $result); - - $string = 'å'; - $result = mb_strtoupper($string); - $expected = 'Å'; - $this->assertEquals($expected, $result); - - $string = 'æ'; - $result = mb_strtoupper($string); - $expected = 'Æ'; - $this->assertEquals($expected, $result); - - $string = 'ç'; - $result = mb_strtoupper($string); - $expected = 'Ç'; - $this->assertEquals($expected, $result); - - $string = 'è'; - $result = mb_strtoupper($string); - $expected = 'È'; - $this->assertEquals($expected, $result); - - $string = 'é'; - $result = mb_strtoupper($string); - $expected = 'É'; - $this->assertEquals($expected, $result); - - $string = 'ê'; - $result = mb_strtoupper($string); - $expected = 'Ê'; - $this->assertEquals($expected, $result); - - $string = 'ë'; - $result = mb_strtoupper($string); - $expected = 'Ë'; - $this->assertEquals($expected, $result); - - $string = 'ì'; - $result = mb_strtoupper($string); - $expected = 'Ì'; - $this->assertEquals($expected, $result); - - $string = 'í'; - $result = mb_strtoupper($string); - $expected = 'Í'; - $this->assertEquals($expected, $result); - - $string = 'î'; - $result = mb_strtoupper($string); - $expected = 'Î'; - $this->assertEquals($expected, $result); - - $string = 'ï'; - $result = mb_strtoupper($string); - $expected = 'Ï'; - $this->assertEquals($expected, $result); - - $string = 'ð'; - $result = mb_strtoupper($string); - $expected = 'Ð'; - $this->assertEquals($expected, $result); - - $string = 'ñ'; - $result = mb_strtoupper($string); - $expected = 'Ñ'; - $this->assertEquals($expected, $result); - - $string = 'ò'; - $result = mb_strtoupper($string); - $expected = 'Ò'; - $this->assertEquals($expected, $result); - - $string = 'ó'; - $result = mb_strtoupper($string); - $expected = 'Ó'; - $this->assertEquals($expected, $result); - - $string = 'ô'; - $result = mb_strtoupper($string); - $expected = 'Ô'; - $this->assertEquals($expected, $result); - - $string = 'õ'; - $result = mb_strtoupper($string); - $expected = 'Õ'; - $this->assertEquals($expected, $result); - - $string = 'ö'; - $result = mb_strtoupper($string); - $expected = 'Ö'; - $this->assertEquals($expected, $result); - - $string = 'ø'; - $result = mb_strtoupper($string); - $expected = 'Ø'; - $this->assertEquals($expected, $result); - - $string = 'ù'; - $result = mb_strtoupper($string); - $expected = 'Ù'; - $this->assertEquals($expected, $result); - - $string = 'ú'; - $result = mb_strtoupper($string); - $expected = 'Ú'; - $this->assertEquals($expected, $result); - - $string = 'û'; - $result = mb_strtoupper($string); - $expected = 'Û'; - $this->assertEquals($expected, $result); - - $string = 'ü'; - $result = mb_strtoupper($string); - $expected = 'Ü'; - $this->assertEquals($expected, $result); - - $string = 'ý'; - $result = mb_strtoupper($string); - $expected = 'Ý'; - $this->assertEquals($expected, $result); - - $string = 'þ'; - $result = mb_strtoupper($string); - $expected = 'Þ'; - $this->assertEquals($expected, $result); - - $string = 'àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþ'; - $result = mb_strtoupper($string); - $expected = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ā'; - $result = mb_strtoupper($string); - $expected = 'Ā'; - $this->assertEquals($expected, $result); - - $string = 'ă'; - $result = mb_strtoupper($string); - $expected = 'Ă'; - $this->assertEquals($expected, $result); - - $string = 'ą'; - $result = mb_strtoupper($string); - $expected = 'Ą'; - $this->assertEquals($expected, $result); - - $string = 'ć'; - $result = mb_strtoupper($string); - $expected = 'Ć'; - $this->assertEquals($expected, $result); - - $string = 'ĉ'; - $result = mb_strtoupper($string); - $expected = 'Ĉ'; - $this->assertEquals($expected, $result); - - $string = 'ċ'; - $result = mb_strtoupper($string); - $expected = 'Ċ'; - $this->assertEquals($expected, $result); - - $string = 'č'; - $result = mb_strtoupper($string); - $expected = 'Č'; - $this->assertEquals($expected, $result); - - $string = 'ď'; - $result = mb_strtoupper($string); - $expected = 'Ď'; - $this->assertEquals($expected, $result); - - $string = 'đ'; - $result = mb_strtoupper($string); - $expected = 'Đ'; - $this->assertEquals($expected, $result); - - $string = 'ē'; - $result = mb_strtoupper($string); - $expected = 'Ē'; - $this->assertEquals($expected, $result); - - $string = 'ĕ'; - $result = mb_strtoupper($string); - $expected = 'Ĕ'; - $this->assertEquals($expected, $result); - - $string = 'ė'; - $result = mb_strtoupper($string); - $expected = 'Ė'; - $this->assertEquals($expected, $result); - - $string = 'ę'; - $result = mb_strtoupper($string); - $expected = 'Ę'; - $this->assertEquals($expected, $result); - - $string = 'ě'; - $result = mb_strtoupper($string); - $expected = 'Ě'; - $this->assertEquals($expected, $result); - - $string = 'ĝ'; - $result = mb_strtoupper($string); - $expected = 'Ĝ'; - $this->assertEquals($expected, $result); - - $string = 'ğ'; - $result = mb_strtoupper($string); - $expected = 'Ğ'; - $this->assertEquals($expected, $result); - - $string = 'ġ'; - $result = mb_strtoupper($string); - $expected = 'Ġ'; - $this->assertEquals($expected, $result); - - $string = 'ģ'; - $result = mb_strtoupper($string); - $expected = 'Ģ'; - $this->assertEquals($expected, $result); - - $string = 'ĥ'; - $result = mb_strtoupper($string); - $expected = 'Ĥ'; - $this->assertEquals($expected, $result); - - $string = 'ħ'; - $result = mb_strtoupper($string); - $expected = 'Ħ'; - $this->assertEquals($expected, $result); - - $string = 'ĩ'; - $result = mb_strtoupper($string); - $expected = 'Ĩ'; - $this->assertEquals($expected, $result); - - $string = 'ī'; - $result = mb_strtoupper($string); - $expected = 'Ī'; - $this->assertEquals($expected, $result); - - $string = 'ĭ'; - $result = mb_strtoupper($string); - $expected = 'Ĭ'; - $this->assertEquals($expected, $result); - - $string = 'į'; - $result = mb_strtoupper($string); - $expected = 'Į'; - $this->assertEquals($expected, $result); - - $string = 'ij'; - $result = mb_strtoupper($string); - $expected = 'IJ'; - $this->assertEquals($expected, $result); - - $string = 'ĵ'; - $result = mb_strtoupper($string); - $expected = 'Ĵ'; - $this->assertEquals($expected, $result); - - $string = 'ķ'; - $result = mb_strtoupper($string); - $expected = 'Ķ'; - $this->assertEquals($expected, $result); - - $string = 'ĺ'; - $result = mb_strtoupper($string); - $expected = 'Ĺ'; - $this->assertEquals($expected, $result); - - $string = 'ļ'; - $result = mb_strtoupper($string); - $expected = 'Ļ'; - $this->assertEquals($expected, $result); - - $string = 'ľ'; - $result = mb_strtoupper($string); - $expected = 'Ľ'; - $this->assertEquals($expected, $result); - - $string = 'ŀ'; - $result = mb_strtoupper($string); - $expected = 'Ŀ'; - $this->assertEquals($expected, $result); - - $string = 'ł'; - $result = mb_strtoupper($string); - $expected = 'Ł'; - $this->assertEquals($expected, $result); - - $string = 'ń'; - $result = mb_strtoupper($string); - $expected = 'Ń'; - $this->assertEquals($expected, $result); - - $string = 'ņ'; - $result = mb_strtoupper($string); - $expected = 'Ņ'; - $this->assertEquals($expected, $result); - - $string = 'ň'; - $result = mb_strtoupper($string); - $expected = 'Ň'; - $this->assertEquals($expected, $result); - - $string = 'ŋ'; - $result = mb_strtoupper($string); - $expected = 'Ŋ'; - $this->assertEquals($expected, $result); - - $string = 'ō'; - $result = mb_strtoupper($string); - $expected = 'Ō'; - $this->assertEquals($expected, $result); - - $string = 'ŏ'; - $result = mb_strtoupper($string); - $expected = 'Ŏ'; - $this->assertEquals($expected, $result); - - $string = 'ő'; - $result = mb_strtoupper($string); - $expected = 'Ő'; - $this->assertEquals($expected, $result); - - $string = 'œ'; - $result = mb_strtoupper($string); - $expected = 'Œ'; - $this->assertEquals($expected, $result); - - $string = 'ŕ'; - $result = mb_strtoupper($string); - $expected = 'Ŕ'; - $this->assertEquals($expected, $result); - - $string = 'ŗ'; - $result = mb_strtoupper($string); - $expected = 'Ŗ'; - $this->assertEquals($expected, $result); - - $string = 'ř'; - $result = mb_strtoupper($string); - $expected = 'Ř'; - $this->assertEquals($expected, $result); - - $string = 'ś'; - $result = mb_strtoupper($string); - $expected = 'Ś'; - $this->assertEquals($expected, $result); - - $string = 'ŝ'; - $result = mb_strtoupper($string); - $expected = 'Ŝ'; - $this->assertEquals($expected, $result); - - $string = 'ş'; - $result = mb_strtoupper($string); - $expected = 'Ş'; - $this->assertEquals($expected, $result); - - $string = 'š'; - $result = mb_strtoupper($string); - $expected = 'Š'; - $this->assertEquals($expected, $result); - - $string = 'ţ'; - $result = mb_strtoupper($string); - $expected = 'Ţ'; - $this->assertEquals($expected, $result); - - $string = 'ť'; - $result = mb_strtoupper($string); - $expected = 'Ť'; - $this->assertEquals($expected, $result); - - $string = 'ŧ'; - $result = mb_strtoupper($string); - $expected = 'Ŧ'; - $this->assertEquals($expected, $result); - - $string = 'ũ'; - $result = mb_strtoupper($string); - $expected = 'Ũ'; - $this->assertEquals($expected, $result); - - $string = 'ū'; - $result = mb_strtoupper($string); - $expected = 'Ū'; - $this->assertEquals($expected, $result); - - $string = 'ŭ'; - $result = mb_strtoupper($string); - $expected = 'Ŭ'; - $this->assertEquals($expected, $result); - - $string = 'ů'; - $result = mb_strtoupper($string); - $expected = 'Ů'; - $this->assertEquals($expected, $result); - - $string = 'ű'; - $result = mb_strtoupper($string); - $expected = 'Ű'; - $this->assertEquals($expected, $result); - - $string = 'ų'; - $result = mb_strtoupper($string); - $expected = 'Ų'; - $this->assertEquals($expected, $result); - - $string = 'ŵ'; - $result = mb_strtoupper($string); - $expected = 'Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'ŷ'; - $result = mb_strtoupper($string); - $expected = 'Ŷ'; - $this->assertEquals($expected, $result); - - $string = 'ź'; - $result = mb_strtoupper($string); - $expected = 'Ź'; - $this->assertEquals($expected, $result); - - $string = 'ż'; - $result = mb_strtoupper($string); - $expected = 'Ż'; - $this->assertEquals($expected, $result); - - $string = 'ž'; - $result = mb_strtoupper($string); - $expected = 'Ž'; - $this->assertEquals($expected, $result); - - $string = 'āăąćĉċčďđēĕėęěĝğġģĥħĩīĭįijĵķĺļľŀłńņňŋōŏőœŕŗřśŝşšţťŧũūŭůűųŵŷźżž'; - $result = mb_strtoupper($string); - $expected = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = mb_strtoupper($string); - $expected = 'ĤĒĹĻŎ, ŴŐŘĻĎ!'; - $this->assertEquals($expected, $result); - - $string = 'ἀι'; - $result = mb_strtoupper($string); - $expected = 'ἈΙ'; - $this->assertEquals($expected, $result); - - $string = 'ԁԃԅԇԉԋԍԏԐԒ'; - $result = mb_strtoupper($string); - $expected = 'ԀԂԄԆԈԊԌԎԐԒ'; - $this->assertEquals($expected, $result); - - $string = 'աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև'; - $result = mb_strtoupper($string); - $expected = 'ԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖև'; - $this->assertEquals($expected, $result); - - $string = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $result = mb_strtoupper($string); - $expected = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $this->assertEquals($expected, $result); - - $string = 'ḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕẖẗẘẙẚạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ'; - $result = mb_strtoupper($string); - $expected = 'ḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẖẗẘẙẚẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸ'; - $this->assertEquals($expected, $result); - - $string = 'ωkå'; - $result = mb_strtoupper($string); - $expected = 'ΩKÅ'; - $this->assertEquals($expected, $result); - - $string = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $result = mb_strtoupper($string); - $expected = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteStrtoupper method - * - * @return void - */ - public function testMultibyteStrtoupper() { - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = Multibyte::strtoupper($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`ABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $result = Multibyte::strtoupper($string); - $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@'; - $this->assertEquals($expected, $result); - - $string = 'à'; - $result = Multibyte::strtoupper($string); - $expected = 'À'; - $this->assertEquals($expected, $result); - - $string = 'á'; - $result = Multibyte::strtoupper($string); - $expected = 'Á'; - $this->assertEquals($expected, $result); - - $string = 'â'; - $result = Multibyte::strtoupper($string); - $expected = 'Â'; - $this->assertEquals($expected, $result); - - $string = 'ã'; - $result = Multibyte::strtoupper($string); - $expected = 'Ã'; - $this->assertEquals($expected, $result); - - $string = 'ä'; - $result = Multibyte::strtoupper($string); - $expected = 'Ä'; - $this->assertEquals($expected, $result); - - $string = 'å'; - $result = Multibyte::strtoupper($string); - $expected = 'Å'; - $this->assertEquals($expected, $result); - - $string = 'æ'; - $result = Multibyte::strtoupper($string); - $expected = 'Æ'; - $this->assertEquals($expected, $result); - - $string = 'ç'; - $result = Multibyte::strtoupper($string); - $expected = 'Ç'; - $this->assertEquals($expected, $result); - - $string = 'è'; - $result = Multibyte::strtoupper($string); - $expected = 'È'; - $this->assertEquals($expected, $result); - - $string = 'é'; - $result = Multibyte::strtoupper($string); - $expected = 'É'; - $this->assertEquals($expected, $result); - - $string = 'ê'; - $result = Multibyte::strtoupper($string); - $expected = 'Ê'; - $this->assertEquals($expected, $result); - - $string = 'ë'; - $result = Multibyte::strtoupper($string); - $expected = 'Ë'; - $this->assertEquals($expected, $result); - - $string = 'ì'; - $result = Multibyte::strtoupper($string); - $expected = 'Ì'; - $this->assertEquals($expected, $result); - - $string = 'í'; - $result = Multibyte::strtoupper($string); - $expected = 'Í'; - $this->assertEquals($expected, $result); - - $string = 'î'; - $result = Multibyte::strtoupper($string); - $expected = 'Î'; - $this->assertEquals($expected, $result); - - $string = 'ï'; - $result = Multibyte::strtoupper($string); - $expected = 'Ï'; - $this->assertEquals($expected, $result); - - $string = 'ð'; - $result = Multibyte::strtoupper($string); - $expected = 'Ð'; - $this->assertEquals($expected, $result); - - $string = 'ñ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ñ'; - $this->assertEquals($expected, $result); - - $string = 'ò'; - $result = Multibyte::strtoupper($string); - $expected = 'Ò'; - $this->assertEquals($expected, $result); - - $string = 'ó'; - $result = Multibyte::strtoupper($string); - $expected = 'Ó'; - $this->assertEquals($expected, $result); - - $string = 'ô'; - $result = Multibyte::strtoupper($string); - $expected = 'Ô'; - $this->assertEquals($expected, $result); - - $string = 'õ'; - $result = Multibyte::strtoupper($string); - $expected = 'Õ'; - $this->assertEquals($expected, $result); - - $string = 'ö'; - $result = Multibyte::strtoupper($string); - $expected = 'Ö'; - $this->assertEquals($expected, $result); - - $string = 'ø'; - $result = Multibyte::strtoupper($string); - $expected = 'Ø'; - $this->assertEquals($expected, $result); - - $string = 'ù'; - $result = Multibyte::strtoupper($string); - $expected = 'Ù'; - $this->assertEquals($expected, $result); - - $string = 'ú'; - $result = Multibyte::strtoupper($string); - $expected = 'Ú'; - $this->assertEquals($expected, $result); - - $string = 'û'; - $result = Multibyte::strtoupper($string); - $expected = 'Û'; - $this->assertEquals($expected, $result); - - $string = 'ü'; - $result = Multibyte::strtoupper($string); - $expected = 'Ü'; - $this->assertEquals($expected, $result); - - $string = 'ý'; - $result = Multibyte::strtoupper($string); - $expected = 'Ý'; - $this->assertEquals($expected, $result); - - $string = 'þ'; - $result = Multibyte::strtoupper($string); - $expected = 'Þ'; - $this->assertEquals($expected, $result); - - $string = 'àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþ'; - $result = Multibyte::strtoupper($string); - $expected = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $this->assertEquals($expected, $result); - - $string = 'ā'; - $result = Multibyte::strtoupper($string); - $expected = 'Ā'; - $this->assertEquals($expected, $result); - - $string = 'ă'; - $result = Multibyte::strtoupper($string); - $expected = 'Ă'; - $this->assertEquals($expected, $result); - - $string = 'ą'; - $result = Multibyte::strtoupper($string); - $expected = 'Ą'; - $this->assertEquals($expected, $result); - - $string = 'ć'; - $result = Multibyte::strtoupper($string); - $expected = 'Ć'; - $this->assertEquals($expected, $result); - - $string = 'ĉ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĉ'; - $this->assertEquals($expected, $result); - - $string = 'ċ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ċ'; - $this->assertEquals($expected, $result); - - $string = 'č'; - $result = Multibyte::strtoupper($string); - $expected = 'Č'; - $this->assertEquals($expected, $result); - - $string = 'ď'; - $result = Multibyte::strtoupper($string); - $expected = 'Ď'; - $this->assertEquals($expected, $result); - - $string = 'đ'; - $result = Multibyte::strtoupper($string); - $expected = 'Đ'; - $this->assertEquals($expected, $result); - - $string = 'ē'; - $result = Multibyte::strtoupper($string); - $expected = 'Ē'; - $this->assertEquals($expected, $result); - - $string = 'ĕ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĕ'; - $this->assertEquals($expected, $result); - - $string = 'ė'; - $result = Multibyte::strtoupper($string); - $expected = 'Ė'; - $this->assertEquals($expected, $result); - - $string = 'ę'; - $result = Multibyte::strtoupper($string); - $expected = 'Ę'; - $this->assertEquals($expected, $result); - - $string = 'ě'; - $result = Multibyte::strtoupper($string); - $expected = 'Ě'; - $this->assertEquals($expected, $result); - - $string = 'ĝ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĝ'; - $this->assertEquals($expected, $result); - - $string = 'ğ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ğ'; - $this->assertEquals($expected, $result); - - $string = 'ġ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ġ'; - $this->assertEquals($expected, $result); - - $string = 'ģ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ģ'; - $this->assertEquals($expected, $result); - - $string = 'ĥ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĥ'; - $this->assertEquals($expected, $result); - - $string = 'ħ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ħ'; - $this->assertEquals($expected, $result); - - $string = 'ĩ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĩ'; - $this->assertEquals($expected, $result); - - $string = 'ī'; - $result = Multibyte::strtoupper($string); - $expected = 'Ī'; - $this->assertEquals($expected, $result); - - $string = 'ĭ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĭ'; - $this->assertEquals($expected, $result); - - $string = 'į'; - $result = Multibyte::strtoupper($string); - $expected = 'Į'; - $this->assertEquals($expected, $result); - - $string = 'ij'; - $result = Multibyte::strtoupper($string); - $expected = 'IJ'; - $this->assertEquals($expected, $result); - - $string = 'ĵ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĵ'; - $this->assertEquals($expected, $result); - - $string = 'ķ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ķ'; - $this->assertEquals($expected, $result); - - $string = 'ĺ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ĺ'; - $this->assertEquals($expected, $result); - - $string = 'ļ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ļ'; - $this->assertEquals($expected, $result); - - $string = 'ľ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ľ'; - $this->assertEquals($expected, $result); - - $string = 'ŀ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŀ'; - $this->assertEquals($expected, $result); - - $string = 'ł'; - $result = Multibyte::strtoupper($string); - $expected = 'Ł'; - $this->assertEquals($expected, $result); - - $string = 'ń'; - $result = Multibyte::strtoupper($string); - $expected = 'Ń'; - $this->assertEquals($expected, $result); - - $string = 'ņ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ņ'; - $this->assertEquals($expected, $result); - - $string = 'ň'; - $result = Multibyte::strtoupper($string); - $expected = 'Ň'; - $this->assertEquals($expected, $result); - - $string = 'ŋ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŋ'; - $this->assertEquals($expected, $result); - - $string = 'ō'; - $result = Multibyte::strtoupper($string); - $expected = 'Ō'; - $this->assertEquals($expected, $result); - - $string = 'ŏ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŏ'; - $this->assertEquals($expected, $result); - - $string = 'ő'; - $result = Multibyte::strtoupper($string); - $expected = 'Ő'; - $this->assertEquals($expected, $result); - - $string = 'œ'; - $result = Multibyte::strtoupper($string); - $expected = 'Œ'; - $this->assertEquals($expected, $result); - - $string = 'ŕ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŕ'; - $this->assertEquals($expected, $result); - - $string = 'ŗ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŗ'; - $this->assertEquals($expected, $result); - - $string = 'ř'; - $result = Multibyte::strtoupper($string); - $expected = 'Ř'; - $this->assertEquals($expected, $result); - - $string = 'ś'; - $result = Multibyte::strtoupper($string); - $expected = 'Ś'; - $this->assertEquals($expected, $result); - - $string = 'ŝ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŝ'; - $this->assertEquals($expected, $result); - - $string = 'ş'; - $result = Multibyte::strtoupper($string); - $expected = 'Ş'; - $this->assertEquals($expected, $result); - - $string = 'š'; - $result = Multibyte::strtoupper($string); - $expected = 'Š'; - $this->assertEquals($expected, $result); - - $string = 'ţ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ţ'; - $this->assertEquals($expected, $result); - - $string = 'ť'; - $result = Multibyte::strtoupper($string); - $expected = 'Ť'; - $this->assertEquals($expected, $result); - - $string = 'ŧ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŧ'; - $this->assertEquals($expected, $result); - - $string = 'ũ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ũ'; - $this->assertEquals($expected, $result); - - $string = 'ū'; - $result = Multibyte::strtoupper($string); - $expected = 'Ū'; - $this->assertEquals($expected, $result); - - $string = 'ŭ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŭ'; - $this->assertEquals($expected, $result); - - $string = 'ů'; - $result = Multibyte::strtoupper($string); - $expected = 'Ů'; - $this->assertEquals($expected, $result); - - $string = 'ű'; - $result = Multibyte::strtoupper($string); - $expected = 'Ű'; - $this->assertEquals($expected, $result); - - $string = 'ų'; - $result = Multibyte::strtoupper($string); - $expected = 'Ų'; - $this->assertEquals($expected, $result); - - $string = 'ŵ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŵ'; - $this->assertEquals($expected, $result); - - $string = 'ŷ'; - $result = Multibyte::strtoupper($string); - $expected = 'Ŷ'; - $this->assertEquals($expected, $result); - - $string = 'ź'; - $result = Multibyte::strtoupper($string); - $expected = 'Ź'; - $this->assertEquals($expected, $result); - - $string = 'ż'; - $result = Multibyte::strtoupper($string); - $expected = 'Ż'; - $this->assertEquals($expected, $result); - - $string = 'ž'; - $result = Multibyte::strtoupper($string); - $expected = 'Ž'; - $this->assertEquals($expected, $result); - - $string = 'āăąćĉċčďđēĕėęěĝğġģĥħĩīĭįijĵķĺļľŀłńņňŋōŏőœŕŗřśŝşšţťŧũūŭůűųŵŷźżž'; - $result = Multibyte::strtoupper($string); - $expected = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = Multibyte::strtoupper($string); - $expected = 'ĤĒĹĻŎ, ŴŐŘĻĎ!'; - $this->assertEquals($expected, $result); - - $string = 'ἀι'; - $result = mb_strtoupper($string); - $expected = 'ἈΙ'; - $this->assertEquals($expected, $result); - - $string = 'ἀι'; - $result = Multibyte::strtoupper($string); - $expected = 'ἈΙ'; - $this->assertEquals($expected, $result); - - $string = 'ԁԃԅԇԉԋԍԏԐԒ'; - $result = Multibyte::strtoupper($string); - $expected = 'ԀԂԄԆԈԊԌԎԐԒ'; - $this->assertEquals($expected, $result); - - $string = 'աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև'; - $result = Multibyte::strtoupper($string); - $expected = 'ԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖև'; - $this->assertEquals($expected, $result); - - $string = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $result = Multibyte::strtoupper($string); - $expected = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; - $this->assertEquals($expected, $result); - - $string = 'ḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕẖẗẘẙẚạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ'; - $result = Multibyte::strtoupper($string); - $expected = 'ḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẖẗẘẙẚẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸ'; - $this->assertEquals($expected, $result); - - $string = 'ωkåⅎ'; - $result = Multibyte::strtoupper($string); - $expected = 'ΩKÅℲ'; - $this->assertEquals($expected, $result); - - $string = 'ωkå'; - $result = Multibyte::strtoupper($string); - $expected = 'ΩKÅ'; - $this->assertEquals($expected, $result); - - $string = 'ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↄ'; - $result = Multibyte::strtoupper($string); - $expected = 'ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯↃ'; - $this->assertEquals($expected, $result); - - $string = 'ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ'; - $result = Multibyte::strtoupper($string); - $expected = 'ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ'; - $this->assertEquals($expected, $result); - - $string = 'ⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞ'; - $result = Multibyte::strtoupper($string); - $expected = 'ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮ'; - $this->assertEquals($expected, $result); - - $string = 'ⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣ'; - $result = Multibyte::strtoupper($string); - $expected = 'ⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢ'; - $this->assertEquals($expected, $result); - - $string = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $result = Multibyte::strtoupper($string); - $expected = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbSubstrCount method - * - * @return void - */ - public function testUsingMbSubstrCount() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSFTUVWXYZ0F12345F6789'; - $find = 'F'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÅÊËÌÍÎÏÐÑÒÓÔÅÕÖØÅÙÚÛÅÜÝÞ'; - $find = 'Å'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÙÚÂÃÄÅÆÇÈÙÚÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞÙÚ'; - $find = 'ÙÚ'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊÅËÌÍÎÏÐÑÒÓÔÕÅÖØÅÙÚÅÛÜÅÝÞÅ'; - $find = 'Å'; - $result = mb_substr_count($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'ĊĀĂĄĆĈĊČĎĐĒĔĖĊĘĚĜĞĠĢĤĦĨĪĬĮĊIJĴĶĹĻĽĿŁŃŅŇŊŌĊŎŐŒŔŖŘŚŜŞŠŢĊŤŦŨŪŬŮŰĊŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_substr_count($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĊĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅĊŇŊŌŎŐŒŔŖĊŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./012F34567F89:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghiFjklmnopqFrstuvwFxyz{|}~'; - $find = 'F'; - $result = mb_substr_count($string, $find); - $expected = 6; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥µ¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁµÂõÄÅÆÇµÈ'; - $find = 'µ'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôÕÖõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉÕÖĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝÕÖĞğĠġĢģĤĥĦÕÖħĨĩĪīĬ'; - $find = 'ÕÖ'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōĵĶķĸĹŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšĵĶķĸĹŢţŤťŦŧŨũŪūŬŭŮůŰűŲųĵĶķĸĹŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'ĵĶķĸĹ'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƸƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJƸNjnjǍǎǏǐǑǒǓƸǔǕǖǗǘǙǚƸǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƹƠơƢƣƤƥƦƧƨƩƹƪƫƬƭƮƯưƱƲƳƴƹƵƶƷƸƹƺƻƼƽƾƿǀǁǂƹǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞʀɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʀʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʀʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʀʻʼ'; - $find = 'ʀ'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЇЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = mb_substr_count($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСРТУФХЦЧШЩЪЫЬРЭЮЯабРвгдежзийклРмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСрТУФХЦЧШЩЪЫрЬЭЮЯабвгдежзийклмнопррстуфхцчшщъыь'; - $find = 'р'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'فنقكلنمنهونىينًٌٍَُ'; - $find = 'ن'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✿✴✵✶✷✸✿✹✺✻✼✽✾✿❀❁❂❃❄❅❆✿❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺐⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺐⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⺐⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽤⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽤⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = mb_substr_count($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눺눻눼눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕눺눻눼뉖뉗뉘뉙뉚뉛뉜뉝눺눻눼뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눺눻눼'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ﺞﺟﺠﺡﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺞﺟﺠﺡﺆﺇﺞﺟﺠﺡﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞﺟﺠﺡ'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﻞﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻞﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻞﻸﻹﻺﻞﻻﻼ'; - $find = 'ﻞ'; - $result = mb_substr_count($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'abcdkefghijklmnopqrstuvwxkyz'; - $find = 'k'; - $result = mb_substr_count($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'abklmcdefghijklmnopqrstuvklmwxyz'; - $find = 'klm'; - $result = mb_substr_count($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'abcdppefghijklmnoppqrstuvwxyz'; - $find = 'ppe'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ĺļ'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = mb_substr_count($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rl'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'ničiničiini'; - $find = 'n'; - $result = mb_substr_count($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'moćimoćimoćmćioći'; - $find = 'ći'; - $result = mb_substr_count($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = mb_substr_count($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'H'; - $result = mb_substr_count($string, $find); - $expected = 0; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteSubstrCount method - * - * @return void - */ - public function testMultibyteSubstrCount() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $find = 'F'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'ABCDEFGHIJKLMNOPQFRSFTUVWXYZ0F12345F6789'; - $find = 'F'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÅÊËÌÍÎÏÐÑÒÓÔÅÕÖØÅÙÚÛÅÜÝÞ'; - $find = 'Å'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÙÚÂÃÄÅÆÇÈÙÚÉÊËÌÍÎÏÐÑÒÓÔÕÖØÅÙÚÛÜÝÞÙÚ'; - $find = 'ÙÚ'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊÅËÌÍÎÏÐÑÒÓÔÕÅÖØÅÙÚÅÛÜÅÝÞÅ'; - $find = 'Å'; - $result = Multibyte::substrCount($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'ĊĀĂĄĆĈĊČĎĐĒĔĖĊĘĚĜĞĠĢĤĦĨĪĬĮĊIJĴĶĹĻĽĿŁŃŅŇŊŌĊŎŐŒŔŖŘŚŜŞŠŢĊŤŦŨŪŬŮŰĊŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::substrCount($string, $find); - $expected = 7; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĊĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁĊŃŅĊŇŊŌŎŐŒŔŖĊŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $find = 'Ċ'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./012F34567F89:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghiFjklmnopqFrstuvwFxyz{|}~'; - $find = 'F'; - $result = Multibyte::substrCount($string, $find); - $expected = 6; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥µ¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁµÂõÄÅÆÇµÈ'; - $find = 'µ'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôÕÖõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉÕÖĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝÕÖĞğĠġĢģĤĥĦÕÖħĨĩĪīĬ'; - $find = 'ÕÖ'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōĵĶķĸĹŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšĵĶķĸĹŢţŤťŦŧŨũŪūŬŭŮůŰűŲųĵĶķĸĹŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $find = 'ĵĶķĸĹ'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƸƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJƸNjnjǍǎǏǐǑǒǓƸǔǕǖǗǘǙǚƸǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'Ƹ'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƹƠơƢƣƤƥƦƧƨƩƹƪƫƬƭƮƯưƱƲƳƴƹƵƶƷƸƹƺƻƼƽƾƿǀǁǂƹǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $find = 'ƹ'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞʀɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʀʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʀʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʀʻʼ'; - $find = 'ʀ'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЇЎЏАБВГДЕЖЗИЙКЛ'; - $find = 'Ї'; - $result = Multibyte::substrCount($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСРТУФХЦЧШЩЪЫЬРЭЮЯабРвгдежзийклРмнопрстуфхцчшщъыь'; - $find = 'Р'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСрТУФХЦЧШЩЪЫрЬЭЮЯабвгдежзийклмнопррстуфхцчшщъыь'; - $find = 'р'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'فنقكلنمنهونىينًٌٍَُ'; - $find = 'ن'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✿✴✵✶✷✸✿✹✺✻✼✽✾✿❀❁❂❃❄❅❆✿❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $find = '✿'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺐⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺐⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⺐⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $find = '⺐'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽤⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽤⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $find = '⽤'; - $result = Multibyte::substrCount($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눺눻눼눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕눺눻눼뉖뉗뉘뉙뉚뉛뉜뉝눺눻눼뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $find = '눺눻눼'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ﺞﺟﺠﺡﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺞﺟﺠﺡﺆﺇﺞﺟﺠﺡﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $find = 'ﺞﺟﺠﺡ'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﻞﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻞﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻞﻸﻹﻺﻞﻻﻼ'; - $find = 'ﻞ'; - $result = Multibyte::substrCount($string, $find); - $expected = 5; - $this->assertEquals($expected, $result); - - $string = 'abcdkefghijklmnopqrstuvwxkyz'; - $find = 'k'; - $result = Multibyte::substrCount($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'abklmcdefghijklmnopqrstuvklmwxyz'; - $find = 'klm'; - $result = Multibyte::substrCount($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'abcdppefghijklmnoppqrstuvwxyz'; - $find = 'ppe'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $find = 'ア'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $find = 'ハ'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ő'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'ĺļ'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'o'; - $result = Multibyte::substrCount($string, $find); - $expected = 2; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $find = 'rl'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $find = 'n'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'ničiničiini'; - $find = 'n'; - $result = Multibyte::substrCount($string, $find); - $expected = 3; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $find = 'ć'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'moćimoćimoćmćioći'; - $find = 'ći'; - $result = Multibyte::substrCount($string, $find); - $expected = 4; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $find = 'ž'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $find = '设'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $find = '周'; - $result = Multibyte::substrCount($string, $find); - $expected = 1; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $find = 'H'; - $result = Multibyte::substrCount($string, $find); - $expected = 0; - $this->assertEquals($expected, $result); - } - -/** - * testUsingMbSubstr method - * - * @return void - */ - public function testUsingMbSubstr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $result = mb_substr($string, 4, 7); - $expected = 'EFGHIJK'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $result = mb_substr($string, 4, 7); - $expected = 'ÄÅÆÇÈÉÊ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $result = mb_substr($string, 4, 7); - $expected = 'ĈĊČĎĐĒĔ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = mb_substr($string, 4, 7); - $expected = '%&\'()*+'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $result = mb_substr($string, 4); - $expected = '¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $result = mb_substr($string, 4, 7); - $expected = 'ÍÎÏÐÑÒÓ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $result = mb_substr($string, 4, 7); - $expected = 'ıIJijĴĵĶķ'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $result = mb_substr($string, 25); - $expected = 'ƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $result = mb_substr($string, 3); - $expected = 'ɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $result = mb_substr($string, 3); - $expected = 'ЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $result = mb_substr($string, 3, 16); - $expected = 'ПРСТУФХЦЧШЩЪЫЬЭЮ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $result = mb_substr($string, 3, 6); - $expected = 'لمنهوى'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $result = mb_substr($string, 6, 14); - $expected = '✶✷✸✹✺✻✼✽✾✿❀❁❂❃'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $result = mb_substr($string, 8, 13); - $expected = '⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $result = mb_substr($string, 12, 24); - $expected = '⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $result = mb_substr($string, 12, 24); - $expected = '눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $result = mb_substr($string, 12); - $expected = 'ﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $result = mb_substr($string, 24, 12); - $expected = 'ﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $result = mb_substr($string, 11, 2); - $expected = 'lm'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $result = mb_substr($string, 7, 11); - $expected = 'ィゥェォャュョッーアイ'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $result = mb_substr($string, 13, 13); - $expected = 'ニヌネノハヒフヘホマミムメ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = mb_substr($string, 3, 4); - $expected = 'ļŏ, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $result = mb_substr($string, 3, 4); - $expected = 'lo, '; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $result = mb_substr($string, 3); - $expected = 'i'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $result = mb_substr($string, 1); - $expected = 'oći'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $result = mb_substr($string, 0, 2); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $result = mb_substr($string, 3, 3); - $expected = '设为首'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = mb_substr($string, 0, 1); - $expected = '一'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = mb_substr($string, 6); - $expected = false; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = mb_substr($string, 0); - $expected = '一二三周永龍'; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteSubstr method - * - * @return void - */ - public function testMultibyteSubstr() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $result = Multibyte::substr($string, 4, 7); - $expected = 'EFGHIJK'; - $this->assertEquals($expected, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $result = Multibyte::substr($string, 4, 7); - $expected = 'ÄÅÆÇÈÉÊ'; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $result = Multibyte::substr($string, 4, 7); - $expected = 'ĈĊČĎĐĒĔ'; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = Multibyte::substr($string, 4, 7); - $expected = '%&\'()*+'; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $result = Multibyte::substr($string, 4); - $expected = '¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $result = Multibyte::substr($string, 4, 7); - $expected = 'ÍÎÏÐÑÒÓ'; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $result = Multibyte::substr($string, 4, 7); - $expected = 'ıIJijĴĵĶķ'; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $result = Multibyte::substr($string, 25); - $expected = 'ƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $result = Multibyte::substr($string, 3); - $expected = 'ɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $result = Multibyte::substr($string, 3); - $expected = 'ЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $result = Multibyte::substr($string, 3, 16); - $expected = 'ПРСТУФХЦЧШЩЪЫЬЭЮ'; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $result = Multibyte::substr($string, 3, 6); - $expected = 'لمنهوى'; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $result = Multibyte::substr($string, 6, 14); - $expected = '✶✷✸✹✺✻✼✽✾✿❀❁❂❃'; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $result = Multibyte::substr($string, 8, 13); - $expected = '⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔'; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $result = Multibyte::substr($string, 12, 24); - $expected = '⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨'; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $result = Multibyte::substr($string, 12, 24); - $expected = '눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄'; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $result = Multibyte::substr($string, 12); - $expected = 'ﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $result = Multibyte::substr($string, 24, 12); - $expected = 'ﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔ'; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $result = Multibyte::substr($string, 11, 2); - $expected = 'lm'; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $result = Multibyte::substr($string, 7, 11); - $expected = 'ィゥェォャュョッーアイ'; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $result = Multibyte::substr($string, 13, 13); - $expected = 'ニヌネノハヒフヘホマミムメ'; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = Multibyte::substr($string, 3, 4); - $expected = 'ļŏ, '; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $result = Multibyte::substr($string, 3, 4); - $expected = 'lo, '; - $this->assertEquals($expected, $result); - - $string = 'čini'; - $result = Multibyte::substr($string, 3); - $expected = 'i'; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $result = Multibyte::substr($string, 1); - $expected = 'oći'; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $result = Multibyte::substr($string, 0, 2); - $expected = 'dr'; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $result = Multibyte::substr($string, 3, 3); - $expected = '设为首'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = Multibyte::substr($string, 0, 1); - $expected = '一'; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = Multibyte::substr($string, 6); - $expected = false; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = Multibyte::substr($string, 0); - $expected = '一二三周永龍'; - $this->assertEquals($expected, $result); - } - -/** - * testMultibyteSubstr method - * - * @return void - */ - public function testMultibyteMimeEncode() { - $string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $result = Multibyte::mimeEncode($string); - $this->assertEquals($string, $result); - - $string = 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?w4DDgcOCw4PDhMOFw4bDh8OIw4nDisOLw4zDjcOOw4/DkMORw5LDk8OUw5U=?=' . "\r\n" . - ' =?UTF-8?B?w5bDmMOZw5rDm8Ocw53Dng==?='; - $this->assertEquals($expected, $result); - $result = Multibyte::mimeEncode($string, null, "\n"); - $expected = '=?UTF-8?B?w4DDgcOCw4PDhMOFw4bDh8OIw4nDisOLw4zDjcOOw4/DkMORw5LDk8OUw5U=?=' . "\n" . - ' =?UTF-8?B?w5bDmMOZw5rDm8Ocw53Dng==?='; - $this->assertEquals($expected, $result); - - $string = 'ĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŹŻŽ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?xIDEgsSExIbEiMSKxIzEjsSQxJLElMSWxJjEmsScxJ7EoMSixKTEpsSoxKo=?=' . "\r\n" . - ' =?UTF-8?B?xKzErsSyxLTEtsS5xLvEvcS/xYHFg8WFxYfFisWMxY7FkMWSxZTFlsWYxZo=?=' . "\r\n" . - ' =?UTF-8?B?xZzFnsWgxaLFpMWmxajFqsWsxa7FsMWyxbTFtsW5xbvFvQ==?='; - $this->assertEquals($expected, $result); - - $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xN?=' . "\r\n" . - ' =?UTF-8?B?Tk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6?=' . "\r\n" . - ' =?UTF-8?B?e3x9fg==?='; - $this->assertEquals($expected, $result); - - $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?wqHCosKjwqTCpcKmwqfCqMKpwqrCq8Kswq3CrsKvwrDCscKywrPCtMK1wrY=?=' . "\r\n" . - ' =?UTF-8?B?wrfCuMK5wrrCu8K8wr3CvsK/w4DDgcOCw4PDhMOFw4bDh8OI?='; - $this->assertEquals($expected, $result); - - $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?w4nDisOLw4zDjcOOw4/DkMORw5LDk8OUw5XDlsOXw5jDmcOaw5vDnMOdw54=?=' . "\r\n" . - ' =?UTF-8?B?w5/DoMOhw6LDo8Okw6XDpsOnw6jDqcOqw6vDrMOtw67Dr8Oww7HDssOzw7Q=?=' . "\r\n" . - ' =?UTF-8?B?w7XDtsO3w7jDucO6w7vDvMO9w77Dv8SAxIHEgsSDxITEhcSGxIfEiMSJxIo=?=' . "\r\n" . - ' =?UTF-8?B?xIvEjMSNxI7Ej8SQxJHEksSTxJTElcSWxJfEmMSZxJrEm8ScxJ3EnsSfxKA=?=' . "\r\n" . - ' =?UTF-8?B?xKHEosSjxKTEpcSmxKfEqMSpxKrEq8Ss?='; - $this->assertEquals($expected, $result); - - $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?xK3ErsSvxLDEscSyxLPEtMS1xLbEt8S4xLnEusS7xLzEvcS+xL/FgMWBxYI=?=' . "\r\n" . - ' =?UTF-8?B?xYPFhMWFxYbFh8WIxYnFisWLxYzFjcWOxY/FkMWRxZLFk8WUxZXFlsWXxZg=?=' . "\r\n" . - ' =?UTF-8?B?xZnFmsWbxZzFncWexZ/FoMWhxaLFo8WkxaXFpsWnxajFqcWqxavFrMWtxa4=?=' . "\r\n" . - ' =?UTF-8?B?xa/FsMWxxbLFs8W0xbXFtsW3xbjFucW6xbvFvMW9xb7Fv8aAxoHGgsaDxoQ=?=' . "\r\n" . - ' =?UTF-8?B?xoXGhsaHxojGicaKxovGjMaNxo7Gj8aQ?='; - $this->assertEquals($expected, $result); - - $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?xpHGksaTxpTGlcaWxpfGmMaZxprGm8acxp3GnsafxqDGocaixqPGpMalxqY=?=' . "\r\n" . - ' =?UTF-8?B?xqfGqMapxqrGq8asxq3GrsavxrDGscayxrPGtMa1xrbGt8a4xrnGusa7xrw=?=' . "\r\n" . - ' =?UTF-8?B?xr3Gvsa/x4DHgceCx4PHhMeFx4bHh8eIx4nHiseLx4zHjceOx4/HkMeRx5I=?=' . "\r\n" . - ' =?UTF-8?B?x5PHlMeVx5bHl8eYx5nHmsebx5zHnceex5/HoMehx6LHo8ekx6XHpsenx6g=?=' . "\r\n" . - ' =?UTF-8?B?x6nHqserx6zHrceux6/HsMexx7LHs8e0?='; - $this->assertEquals($expected, $result); - - $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?yZnJmsmbyZzJncmeyZ/JoMmhyaLJo8mkyaXJpsmnyajJqcmqyavJrMmtya4=?=' . "\r\n" . - ' =?UTF-8?B?ya/JsMmxybLJs8m0ybXJtsm3ybjJucm6ybvJvMm9yb7Jv8qAyoHKgsqDyoQ=?=' . "\r\n" . - ' =?UTF-8?B?yoXKhsqHyojKicqKyovKjMqNyo7Kj8qQypHKksqTypTKlcqWypfKmMqZypo=?=' . "\r\n" . - ' =?UTF-8?B?ypvKnMqdyp7Kn8qgyqHKosqjyqTKpcqmyqfKqMqpyqrKq8qsyq3KrsqvyrA=?=' . "\r\n" . - ' =?UTF-8?B?yrHKssqzyrTKtcq2yrfKuMq5yrrKu8q8?='; - $this->assertEquals($expected, $result); - - $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?0IDQgdCC0IPQhNCF0IbQh9CI0InQitCL0IzQjdCO0I/QkNCR0JLQk9CU0JU=?=' . "\r\n" . - ' =?UTF-8?B?0JbQl9CY0JnQmtCb?='; - $this->assertEquals($expected, $result); - - $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?0JzQndCe0J/QoNCh0KLQo9Ck0KXQptCn0KjQqdCq0KvQrNCt0K7Qr9Cw0LE=?=' . "\r\n" . - ' =?UTF-8?B?0LLQs9C00LXQttC30LjQudC60LvQvNC90L7Qv9GA0YHRgtGD0YTRhdGG0Yc=?=' . "\r\n" . - ' =?UTF-8?B?0YjRidGK0YvRjA==?='; - $this->assertEquals($expected, $result); - - $string = 'فقكلمنهوىيًٌٍَُ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?2YHZgtmD2YTZhdmG2YfZiNmJ2YrZi9mM2Y3ZjtmP?='; - $this->assertEquals($expected, $result); - - $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?4pyw4pyx4pyy4pyz4py04py14py24py34py44py54py64py74py84py94py+?=' . "\r\n" . - ' =?UTF-8?B?4py/4p2A4p2B4p2C4p2D4p2E4p2F4p2G4p2H4p2I4p2J4p2K4p2L4p2M4p2N?=' . "\r\n" . - ' =?UTF-8?B?4p2O4p2P4p2Q4p2R4p2S4p2T4p2U4p2V4p2W4p2X4p2Y4p2Z4p2a4p2b4p2c?=' . "\r\n" . - ' =?UTF-8?B?4p2d4p2e?='; - $this->assertEquals($expected, $result); - - $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?4rqA4rqB4rqC4rqD4rqE4rqF4rqG4rqH4rqI4rqJ4rqK4rqL4rqM4rqN4rqO?=' . "\r\n" . - ' =?UTF-8?B?4rqP4rqQ4rqR4rqS4rqT4rqU4rqV4rqW4rqX4rqY4rqZ4rqb4rqc4rqd4rqe?=' . "\r\n" . - ' =?UTF-8?B?4rqf4rqg4rqh4rqi4rqj4rqk4rql4rqm4rqn4rqo4rqp4rqq4rqr4rqs4rqt?=' . "\r\n" . - ' =?UTF-8?B?4rqu4rqv4rqw4rqx4rqy4rqz4rq04rq14rq24rq34rq44rq54rq64rq74rq8?=' . "\r\n" . - ' =?UTF-8?B?4rq94rq+4rq/4ruA4ruB4ruC4ruD4ruE4ruF4ruG4ruH4ruI4ruJ4ruK4ruL?=' . "\r\n" . - ' =?UTF-8?B?4ruM4ruN4ruO4ruP4ruQ4ruR4ruS4ruT4ruU4ruV4ruW4ruX4ruY4ruZ4rua?=' . "\r\n" . - ' =?UTF-8?B?4rub4ruc4rud4rue4ruf4rug?='; - $this->assertEquals($expected, $result); - - $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?4r2F4r2G4r2H4r2I4r2J4r2K4r2L4r2M4r2N4r2O4r2P4r2Q4r2R4r2S4r2T?=' . "\r\n" . - ' =?UTF-8?B?4r2U4r2V4r2W4r2X4r2Y4r2Z4r2a4r2b4r2c4r2d4r2e4r2f4r2g4r2h4r2i?=' . "\r\n" . - ' =?UTF-8?B?4r2j4r2k4r2l4r2m4r2n4r2o4r2p4r2q4r2r4r2s4r2t4r2u4r2v4r2w4r2x?=' . "\r\n" . - ' =?UTF-8?B?4r2y4r2z4r204r214r224r234r244r254r264r274r284r294r2+4r2/?='; - $this->assertEquals($expected, $result); - - $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?64ih64ii64ij64ik64il64im64in64io64ip64iq64ir64is64it64iu64iv?=' . "\r\n" . - ' =?UTF-8?B?64iw64ix64iy64iz64i064i164i264i364i464i564i664i764i864i964i+?=' . "\r\n" . - ' =?UTF-8?B?64i/64mA64mB64mC64mD64mE64mF64mG64mH64mI64mJ64mK64mL64mM64mN?=' . "\r\n" . - ' =?UTF-8?B?64mO64mP64mQ64mR64mS64mT64mU64mV64mW64mX64mY64mZ64ma64mb64mc?=' . "\r\n" . - ' =?UTF-8?B?64md64me64mf64mg64mh64mi64mj64mk64ml64mm64mn64mo64mp64mq64mr?=' . "\r\n" . - ' =?UTF-8?B?64ms64mt64mu64mv64mw64mx64my64mz64m064m164m264m364m464m564m6?=' . "\r\n" . - ' =?UTF-8?B?64m764m864m964m+64m/64qA64qB64qC64qD64qE?='; - $this->assertEquals($expected, $result); - - $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?77mw77mx77my77mz77m077m177m277m377m477m577m677m777m877m977m+?=' . "\r\n" . - ' =?UTF-8?B?77m/77qA77qB77qC77qD77qE77qF77qG77qH77qI77qJ77qK77qL77qM77qN?=' . "\r\n" . - ' =?UTF-8?B?77qO77qP77qQ77qR77qS77qT77qU77qV77qW77qX77qY77qZ77qa77qb77qc?=' . "\r\n" . - ' =?UTF-8?B?77qd77qe77qf77qg77qh77qi77qj77qk77ql77qm77qn77qo77qp77qq77qr?=' . "\r\n" . - ' =?UTF-8?B?77qs77qt77qu77qv77qw?='; - $this->assertEquals($expected, $result); - - $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?77qx77qy77qz77q077q177q277q377q477q577q677q777q877q977q+77q/?=' . "\r\n" . - ' =?UTF-8?B?77uA77uB77uC77uD77uE77uF77uG77uH77uI77uJ77uK77uL77uM77uN77uO?=' . "\r\n" . - ' =?UTF-8?B?77uP77uQ77uR77uS77uT77uU77uV77uW77uX77uY77uZ77ua77ub77uc77ud?=' . "\r\n" . - ' =?UTF-8?B?77ue77uf77ug77uh77ui77uj77uk77ul77um77un77uo77up77uq77ur77us?=' . "\r\n" . - ' =?UTF-8?B?77ut77uu77uv77uw77ux77uy77uz77u077u177u277u377u477u577u677u7?=' . "\r\n" . - ' =?UTF-8?B?77u8?='; - $this->assertEquals($expected, $result); - - $string = 'abcdefghijklmnopqrstuvwxyz'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?772B772C772D772E772F772G772H772I772J772K772L772M772N772O772P?=' . "\r\n" . - ' =?UTF-8?B?772Q772R772S772T772U772V772W772X772Y772Z772a?='; - $this->assertEquals($expected, $result); - - $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?772h772i772j772k772l772m772n772o772p772q772r772s772t772u772v?=' . "\r\n" . - ' =?UTF-8?B?772w772x772y772z77207721772277237724?='; - $this->assertEquals($expected, $result); - - $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?77257726772777287729772+772/776A776B776C776D776E776F776G776H?=' . "\r\n" . - ' =?UTF-8?B?776I776J776K776L776M776N776O776P776Q776R776S776T776U776V776W?=' . "\r\n" . - ' =?UTF-8?B?776X776Y776Z776a776b776c776d776e?='; - $this->assertEquals($expected, $result); - - $string = 'Ĥēĺļŏ, Ŵőřļď!'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?xKTEk8S6xLzFjywgxbTFkcWZxLzEjyE=?='; - $this->assertEquals($expected, $result); - - $string = 'Hello, World!'; - $result = Multibyte::mimeEncode($string); - $this->assertEquals($string, $result); - - $string = 'čini'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?xI1pbmk=?='; - $this->assertEquals($expected, $result); - - $string = 'moći'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?bW/Eh2k=?='; - $this->assertEquals($expected, $result); - - $string = 'državni'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?ZHLFvmF2bmk=?='; - $this->assertEquals($expected, $result); - - $string = '把百度设为首页'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?5oqK55m+5bqm6K6+5Li66aaW6aG1?='; - $this->assertEquals($expected, $result); - - $string = '一二三周永龍'; - $result = Multibyte::mimeEncode($string); - $expected = '=?UTF-8?B?5LiA5LqM5LiJ5ZGo5rC46b6N?='; - $this->assertEquals($expected, $result); - } -} diff --git a/lib/Cake/Test/Case/Log/CakeLogTest.php b/lib/Cake/Test/Case/Log/CakeLogTest.php deleted file mode 100644 index 89e3e3e8623..00000000000 --- a/lib/Cake/Test/Case/Log/CakeLogTest.php +++ /dev/null @@ -1,173 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Log - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('CakeLog', 'Log'); -App::uses('FileLog', 'Log/Engine'); - -/** - * CakeLogTest class - * - * @package Cake.Test.Case.Log - */ -class CakeLogTest extends CakeTestCase { - -/** - * Start test callback, clears all streams enabled. - * - * @return void - */ - public function setUp() { - parent::setUp(); - $streams = CakeLog::configured(); - foreach ($streams as $stream) { - CakeLog::drop($stream); - } - } - -/** - * test importing loggers from app/libs and plugins. - * - * @return void - */ - public function testImportingLoggers() { - App::build(array( - 'Lib' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Lib' . DS), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - - $result = CakeLog::config('libtest', array( - 'engine' => 'TestAppLog' - )); - $this->assertTrue($result); - $this->assertEquals(CakeLog::configured(), array('libtest')); - - $result = CakeLog::config('plugintest', array( - 'engine' => 'TestPlugin.TestPluginLog' - )); - $this->assertTrue($result); - $this->assertEquals(CakeLog::configured(), array('libtest', 'plugintest')); - - App::build(); - CakePlugin::unload(); - } - -/** - * test all the errors from failed logger imports - * - * @expectedException CakeLogException - * @return void - */ - public function testImportingLoggerFailure() { - CakeLog::config('fail', array()); - } - -/** - * test that loggers have to implement the correct interface. - * - * @expectedException CakeLogException - * @return void - */ - public function testNotImplementingInterface() { - CakeLog::config('fail', array('engine' => 'stdClass')); - } - -/** - * Test that CakeLog autoconfigures itself to use a FileLogger with the LOGS dir. - * When no streams are there. - * - * @return void - */ - public function testAutoConfig() { - if (file_exists(LOGS . 'error.log')) { - unlink(LOGS . 'error.log'); - } - CakeLog::write(LOG_WARNING, 'Test warning'); - $this->assertTrue(file_exists(LOGS . 'error.log')); - - $result = CakeLog::configured(); - $this->assertEquals(array('default'), $result); - unlink(LOGS . 'error.log'); - } - -/** - * test configuring log streams - * - * @return void - */ - public function testConfig() { - CakeLog::config('file', array( - 'engine' => 'FileLog', - 'path' => LOGS - )); - $result = CakeLog::configured(); - $this->assertEquals(array('file'), $result); - - if (file_exists(LOGS . 'error.log')) { - @unlink(LOGS . 'error.log'); - } - CakeLog::write(LOG_WARNING, 'Test warning'); - $this->assertTrue(file_exists(LOGS . 'error.log')); - - $result = file_get_contents(LOGS . 'error.log'); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Warning: Test warning/', $result); - unlink(LOGS . 'error.log'); - } - -/** - * explicit tests for drop() - * - * @return void - **/ - public function testDrop() { - CakeLog::config('file', array( - 'engine' => 'FileLog', - 'path' => LOGS - )); - $result = CakeLog::configured(); - $this->assertEquals(array('file'), $result); - - CakeLog::drop('file'); - $result = CakeLog::configured(); - $this->assertEquals(array(), $result); - } - -/** - * testLogFileWriting method - * - * @return void - */ - public function testLogFileWriting() { - if (file_exists(LOGS . 'error.log')) { - unlink(LOGS . 'error.log'); - } - $result = CakeLog::write(LOG_WARNING, 'Test warning'); - $this->assertTrue($result); - $this->assertTrue(file_exists(LOGS . 'error.log')); - unlink(LOGS . 'error.log'); - - CakeLog::write(LOG_WARNING, 'Test warning 1'); - CakeLog::write(LOG_WARNING, 'Test warning 2'); - $result = file_get_contents(LOGS . 'error.log'); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Warning: Test warning 1/', $result); - $this->assertRegExp('/2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Warning: Test warning 2$/', $result); - unlink(LOGS . 'error.log'); - } - -} diff --git a/lib/Cake/Test/Case/Log/Engine/FileLogTest.php b/lib/Cake/Test/Case/Log/Engine/FileLogTest.php deleted file mode 100644 index 2dc3153bf94..00000000000 --- a/lib/Cake/Test/Case/Log/Engine/FileLogTest.php +++ /dev/null @@ -1,83 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Log.Engine - * @since CakePHP(tm) v 1.3 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('FileLog', 'Log/Engine'); - -/** - * CakeLogTest class - * - * @package Cake.Test.Case.Log.Engine - */ -class FileLogTest extends CakeTestCase { - -/** - * testLogFileWriting method - * - * @return void - */ - public function testLogFileWriting() { - if (file_exists(LOGS . 'error.log')) { - unlink(LOGS . 'error.log'); - } - $log = new FileLog(); - $log->write('warning', 'Test warning'); - $this->assertTrue(file_exists(LOGS . 'error.log')); - - $result = file_get_contents(LOGS . 'error.log'); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Warning: Test warning/', $result); - unlink(LOGS . 'error.log'); - - if (file_exists(LOGS . 'debug.log')) { - unlink(LOGS . 'debug.log'); - } - $log->write('debug', 'Test warning'); - $this->assertTrue(file_exists(LOGS . 'debug.log')); - - $result = file_get_contents(LOGS . 'debug.log'); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Debug: Test warning/', $result); - unlink(LOGS . 'debug.log'); - - if (file_exists(LOGS . 'random.log')) { - unlink(LOGS . 'random.log'); - } - $log->write('random', 'Test warning'); - $this->assertTrue(file_exists(LOGS . 'random.log')); - - $result = file_get_contents(LOGS . 'random.log'); - $this->assertRegExp('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ Random: Test warning/', $result); - unlink(LOGS . 'random.log'); - } - -/** - * test using the path setting to write logs in other places. - * - * @return void - */ - public function testPathSetting() { - $path = TMP . 'tests' . DS; - if (file_exists(LOGS . 'error.log')) { - unlink(LOGS . 'error.log'); - } - - $log = new FileLog(compact('path')); - $log->write('warning', 'Test warning'); - $this->assertTrue(file_exists($path . 'error.log')); - unlink($path . 'error.log'); - } - -} diff --git a/lib/Cake/Test/Case/Model/AclNodeTest.php b/lib/Cake/Test/Case/Model/AclNodeTest.php deleted file mode 100644 index 234788a1ed3..00000000000 --- a/lib/Cake/Test/Case/Model/AclNodeTest.php +++ /dev/null @@ -1,387 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('DbAcl', 'Controller/Component/Acl'); -App::uses('AclNode', 'Model'); - -/** - * DB ACL wrapper test class - * - * @package Cake.Test.Case.Model - */ -class DbAclNodeTestBase extends AclNode { - -/** - * useDbConfig property - * - * @var string 'test' - */ - public $useDbConfig = 'test'; - -/** - * cacheSources property - * - * @var bool false - */ - public $cacheSources = false; -} - -/** - * Aro Test Wrapper - * - * @package Cake.Test.Case.Model - */ -class DbAroTest extends DbAclNodeTestBase { - -/** - * name property - * - * @var string 'DbAroTest' - */ - public $name = 'DbAroTest'; - -/** - * useTable property - * - * @var string 'aros' - */ - public $useTable = 'aros'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('DbAcoTest' => array('with' => 'DbPermissionTest')); -} - -/** - * Aco Test Wrapper - * - * @package Cake.Test.Case.Model - */ -class DbAcoTest extends DbAclNodeTestBase { - -/** - * name property - * - * @var string 'DbAcoTest' - */ - public $name = 'DbAcoTest'; - -/** - * useTable property - * - * @var string 'acos' - */ - public $useTable = 'acos'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('DbAroTest' => array('with' => 'DbPermissionTest')); -} - -/** - * Permission Test Wrapper - * - * @package Cake.Test.Case.Model - */ -class DbPermissionTest extends CakeTestModel { - -/** - * name property - * - * @var string 'DbPermissionTest' - */ - public $name = 'DbPermissionTest'; - -/** - * useTable property - * - * @var string 'aros_acos' - */ - public $useTable = 'aros_acos'; - -/** - * cacheQueries property - * - * @var bool false - */ - public $cacheQueries = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('DbAroTest' => array('foreignKey' => 'aro_id'), 'DbAcoTest' => array('foreignKey' => 'aco_id')); -} - -/** - * DboActionTest class - * - * @package Cake.Test.Case.Model - */ -class DbAcoActionTest extends CakeTestModel { - -/** - * name property - * - * @var string 'DbAcoActionTest' - */ - public $name = 'DbAcoActionTest'; - -/** - * useTable property - * - * @var string 'aco_actions' - */ - public $useTable = 'aco_actions'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('DbAcoTest' => array('foreignKey' => 'aco_id')); -} - -/** - * DbAroUserTest class - * - * @package Cake.Test.Case.Model - */ -class DbAroUserTest extends CakeTestModel { - -/** - * name property - * - * @var string 'AuthUser' - */ - public $name = 'AuthUser'; - -/** - * useTable property - * - * @var string 'auth_users' - */ - public $useTable = 'auth_users'; - -/** - * bindNode method - * - * @param mixed $ref - * @return void - */ - public function bindNode($ref = null) { - if (Configure::read('DbAclbindMode') == 'string') { - return 'ROOT/admins/Gandalf'; - } elseif (Configure::read('DbAclbindMode') == 'array') { - return array('DbAroTest' => array('DbAroTest.model' => 'AuthUser', 'DbAroTest.foreign_key' => 2)); - } - } - -} - -/** - * TestDbAcl class - * - * @package Cake.Test.Case.Model - */ -class TestDbAcl extends DbAcl { - -/** - * construct method - * - * @return void - */ - public function __construct() { - $this->Aro = new DbAroTest(); - $this->Aro->Permission = new DbPermissionTest(); - $this->Aco = new DbAcoTest(); - $this->Aro->Permission = new DbPermissionTest(); - } - -} - -/** - * AclNodeTest class - * - * @package Cake.Test.Case.Model - */ -class AclNodeTest extends CakeTestCase { - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.aro', 'core.aco', 'core.aros_aco', 'core.aco_action', 'core.auth_user'); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - Configure::write('Acl.classname', 'TestDbAcl'); - Configure::write('Acl.database', 'test'); - } - -/** - * testNode method - * - * @return void - */ - public function testNode() { - $Aco = new DbAcoTest(); - $result = Set::extract($Aco->node('Controller1'), '{n}.DbAcoTest.id'); - $expected = array(2, 1); - $this->assertEquals($expected, $result); - - $result = Set::extract($Aco->node('Controller1/action1'), '{n}.DbAcoTest.id'); - $expected = array(3, 2, 1); - $this->assertEquals($expected, $result); - - $result = Set::extract($Aco->node('Controller2/action1'), '{n}.DbAcoTest.id'); - $expected = array(7, 6, 1); - $this->assertEquals($expected, $result); - - $result = Set::extract($Aco->node('Controller1/action2'), '{n}.DbAcoTest.id'); - $expected = array(5, 2, 1); - $this->assertEquals($expected, $result); - - $result = Set::extract($Aco->node('Controller1/action1/record1'), '{n}.DbAcoTest.id'); - $expected = array(4, 3, 2, 1); - $this->assertEquals($expected, $result); - - $result = Set::extract($Aco->node('Controller2/action1/record1'), '{n}.DbAcoTest.id'); - $expected = array(8, 7, 6, 1); - $this->assertEquals($expected, $result); - - $result = Set::extract($Aco->node('Controller2/action3'), '{n}.DbAcoTest.id'); - $this->assertNull($result); - - $result = Set::extract($Aco->node('Controller2/action3/record5'), '{n}.DbAcoTest.id'); - $this->assertNull($result); - - $result = $Aco->node(''); - $this->assertEquals(null, $result); - } - -/** - * test that node() doesn't dig deeper than it should. - * - * @return void - */ - public function testNodeWithDuplicatePathSegments() { - $Aco = new DbAcoTest(); - $nodes = $Aco->node('ROOT/Users'); - $this->assertEquals(1, $nodes[0]['DbAcoTest']['parent_id'], 'Parent id does not point at ROOT. %s'); - } - -/** - * testNodeArrayFind method - * - * @return void - */ - public function testNodeArrayFind() { - $Aro = new DbAroTest(); - Configure::write('DbAclbindMode', 'string'); - $result = Set::extract($Aro->node(array('DbAroUserTest' => array('id' => '1', 'foreign_key' => '1'))), '{n}.DbAroTest.id'); - $expected = array(3, 2, 1); - $this->assertEquals($expected, $result); - - Configure::write('DbAclbindMode', 'array'); - $result = Set::extract($Aro->node(array('DbAroUserTest' => array('id' => 4, 'foreign_key' => 2))), '{n}.DbAroTest.id'); - $expected = array(4); - $this->assertEquals($expected, $result); - } - -/** - * testNodeObjectFind method - * - * @return void - */ - public function testNodeObjectFind() { - $Aro = new DbAroTest(); - $Model = new DbAroUserTest(); - $Model->id = 1; - $result = Set::extract($Aro->node($Model), '{n}.DbAroTest.id'); - $expected = array(3, 2, 1); - $this->assertEquals($expected, $result); - - $Model->id = 2; - $result = Set::extract($Aro->node($Model), '{n}.DbAroTest.id'); - $expected = array(4, 2, 1); - $this->assertEquals($expected, $result); - } - -/** - * testNodeAliasParenting method - * - * @return void - */ - public function testNodeAliasParenting() { - $Aco = ClassRegistry::init('DbAcoTest'); - $db = $Aco->getDataSource(); - $db->truncate($Aco); - - $Aco->create(array('model' => null, 'foreign_key' => null, 'parent_id' => null, 'alias' => 'Application')); - $Aco->save(); - - $Aco->create(array('model' => null, 'foreign_key' => null, 'parent_id' => $Aco->id, 'alias' => 'Pages')); - $Aco->save(); - - $result = $Aco->find('all'); - $expected = array( - array('DbAcoTest' => array('id' => '1', 'parent_id' => null, 'model' => null, 'foreign_key' => null, 'alias' => 'Application', 'lft' => '1', 'rght' => '4'), 'DbAroTest' => array()), - array('DbAcoTest' => array('id' => '2', 'parent_id' => '1', 'model' => null, 'foreign_key' => null, 'alias' => 'Pages', 'lft' => '2', 'rght' => '3'), 'DbAroTest' => array()) - ); - $this->assertEquals($expected, $result); - } - -/** - * testNodeActionAuthorize method - * - * @return void - */ - public function testNodeActionAuthorize() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - - $Aro = new DbAroTest(); - $Aro->create(); - $Aro->save(array('model' => 'TestPluginAuthUser', 'foreign_key' => 1)); - $result = $Aro->id; - $expected = 5; - $this->assertEquals($expected, $result); - - $node = $Aro->node(array('TestPlugin.TestPluginAuthUser' => array('id' => 1, 'user' => 'mariano'))); - $result = Set::extract($node, '0.DbAroTest.id'); - $expected = $Aro->id; - $this->assertEquals($expected, $result); - CakePlugin::unload('TestPlugin'); - } -} diff --git a/lib/Cake/Test/Case/Model/Behavior/AclBehaviorTest.php b/lib/Cake/Test/Case/Model/Behavior/AclBehaviorTest.php deleted file mode 100644 index aae48caa543..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/AclBehaviorTest.php +++ /dev/null @@ -1,490 +0,0 @@ - 'both'); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'Mother' => array( - 'className' => 'AclPerson', - 'foreignKey' => 'mother_id', - ) - ); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'Child' => array( - 'className' => 'AclPerson', - 'foreignKey' => 'mother_id' - ) - ); - -/** - * parentNode method - * - * @return void - */ - public function parentNode() { - if (!$this->id && empty($this->data)) { - return null; - } - if (isset($this->data['AclPerson']['mother_id'])) { - $motherId = $this->data['AclPerson']['mother_id']; - } else { - $motherId = $this->field('mother_id'); - } - if (!$motherId) { - return null; - } else { - return array('AclPerson' => array('id' => $motherId)); - } - } - -} - -/** - * AclUser class - * - * @package Cake.Test.Case.Model.Behavior - */ -class AclUser extends CakeTestModel { - -/** - * name property - * - * @var string - */ - public $name = 'User'; - -/** - * useTable property - * - * @var string - */ - public $useTable = 'users'; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Acl' => array('type' => 'requester')); - -/** - * parentNode - * - */ - public function parentNode() { - return null; - } - -} - -/** - * AclPost class - * - * @package Cake.Test.Case.Model.Behavior - */ -class AclPost extends CakeTestModel { - -/** - * name property - * - * @var string - */ - public $name = 'Post'; - -/** - * useTable property - * - * @var string - */ - public $useTable = 'posts'; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Acl' => array('type' => 'Controlled')); - -/** - * parentNode - * - */ - public function parentNode() { - return null; - } - -} - -/** - * AclBehaviorTest class - * - * @package Cake.Test.Case.Model.Behavior - */ -class AclBehaviorTest extends CakeTestCase { - -/** - * Aco property - * - * @var Aco - */ - public $Aco; - -/** - * Aro property - * - * @var Aro - */ - public $Aro; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.person', 'core.user', 'core.post', 'core.aco', 'core.aro', 'core.aros_aco'); - -/** - * Set up the test - * - * @return void - */ - public function setUp() { - Configure::write('Acl.database', 'test'); - - $this->Aco = new Aco(); - $this->Aro = new Aro(); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - ClassRegistry::flush(); - unset($this->Aro, $this->Aco); - } - -/** - * Test Setup of AclBehavior - * - * @return void - */ - public function testSetup() { - $User = new AclUser(); - $this->assertTrue(isset($User->Behaviors->Acl->settings['User'])); - $this->assertEquals('requester', $User->Behaviors->Acl->settings['User']['type']); - $this->assertTrue(is_object($User->Aro)); - - $Post = new AclPost(); - $this->assertTrue(isset($Post->Behaviors->Acl->settings['Post'])); - $this->assertEquals('controlled', $Post->Behaviors->Acl->settings['Post']['type']); - $this->assertTrue(is_object($Post->Aco)); - } - -/** - * Test Setup of AclBehavior as both requester and controlled - * - * @return void - */ - public function testSetupMulti() { - $User = new AclPerson(); - $this->assertTrue(isset($User->Behaviors->Acl->settings['AclPerson'])); - $this->assertEquals('both', $User->Behaviors->Acl->settings['AclPerson']['type']); - $this->assertTrue(is_object($User->Aro)); - $this->assertTrue(is_object($User->Aco)); - } - -/** - * test After Save - * - * @return void - */ - public function testAfterSave() { - $Post = new AclPost(); - $data = array( - 'Post' => array( - 'author_id' => 1, - 'title' => 'Acl Post', - 'body' => 'post body', - 'published' => 1 - ), - ); - $Post->save($data); - $result = $this->Aco->find('first', array( - 'conditions' => array('Aco.model' => 'Post', 'Aco.foreign_key' => $Post->id) - )); - $this->assertTrue(is_array($result)); - $this->assertEquals('Post', $result['Aco']['model']); - $this->assertEquals($Post->id, $result['Aco']['foreign_key']); - - $aroData = array( - 'Aro' => array( - 'model' => 'AclPerson', - 'foreign_key' => 2, - 'parent_id' => null - ) - ); - $this->Aro->save($aroData); - - $acoData = array( - 'Aco' => array( - 'model' => 'AclPerson', - 'foreign_key' => 2, - 'parent_id' => null - ) - ); - $this->Aco->save($acoData); - - $Person = new AclPerson(); - $data = array( - 'AclPerson' => array( - 'name' => 'Trent', - 'mother_id' => 2, - 'father_id' => 3, - ), - ); - $Person->save($data); - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => $Person->id) - )); - $this->assertTrue(is_array($result)); - $this->assertEquals(5, $result['Aro']['parent_id']); - - $node = $Person->node(array('model' => 'AclPerson', 'foreign_key' => 8), 'Aro'); - $this->assertEquals(2, count($node)); - $this->assertEquals(5, $node[0]['Aro']['parent_id']); - $this->assertEquals(null, $node[1]['Aro']['parent_id']); - - $aroData = array( - 'Aro' => array( - 'model' => 'AclPerson', - 'foreign_key' => 1, - 'parent_id' => null - ) - ); - $this->Aro->create(); - $this->Aro->save($aroData); - $acoData = array( - 'Aco' => array( - 'model' => 'AclPerson', - 'foreign_key' => 1, - 'parent_id' => null - )); - $this->Aco->create(); - $this->Aco->save($acoData); - $Person->read(null, 8); - $Person->set('mother_id', 1); - $Person->save(); - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => $Person->id) - )); - $this->assertTrue(is_array($result)); - $this->assertEquals(7, $result['Aro']['parent_id']); - - $node = $Person->node(array('model' => 'AclPerson', 'foreign_key' => 8), 'Aro'); - $this->assertEquals(2, count($node)); - $this->assertEquals(7, $node[0]['Aro']['parent_id']); - $this->assertEquals(null, $node[1]['Aro']['parent_id']); - } - -/** - * test that an afterSave on an update does not cause parent_id to become null. - * - * @return void - */ - public function testAfterSaveUpdateParentIdNotNull() { - $aroData = array( - 'Aro' => array( - 'model' => 'AclPerson', - 'foreign_key' => 2, - 'parent_id' => null - ) - ); - $this->Aro->save($aroData); - - $acoData = array( - 'Aco' => array( - 'model' => 'AclPerson', - 'foreign_key' => 2, - 'parent_id' => null - ) - ); - $this->Aco->save($acoData); - - $Person = new AclPerson(); - $data = array( - 'AclPerson' => array( - 'name' => 'Trent', - 'mother_id' => 2, - 'father_id' => 3, - ), - ); - $Person->save($data); - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => $Person->id) - )); - $this->assertTrue(is_array($result)); - $this->assertEquals(5, $result['Aro']['parent_id']); - - $Person->save(array('id' => $Person->id, 'name' => 'Bruce')); - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => $Person->id) - )); - $this->assertEquals(5, $result['Aro']['parent_id']); - } - -/** - * Test After Delete - * - * @return void - */ - public function testAfterDelete() { - $aroData = array( - 'Aro' => array( - 'model' => 'AclPerson', - 'foreign_key' => 2, - 'parent_id' => null - ) - ); - $this->Aro->save($aroData); - - $acoData = array( - 'Aco' => array( - 'model' => 'AclPerson', - 'foreign_key' => 2, - 'parent_id' => null - ) - ); - $this->Aco->save($acoData); - $Person = new AclPerson(); - - $data = array( - 'AclPerson' => array( - 'name' => 'Trent', - 'mother_id' => 2, - 'father_id' => 3, - ), - ); - $Person->save($data); - $id = $Person->id; - $node = $Person->node(null, 'Aro'); - $this->assertEquals(2, count($node)); - $this->assertEquals(5, $node[0]['Aro']['parent_id']); - $this->assertEquals(null, $node[1]['Aro']['parent_id']); - - $Person->delete($id); - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => $id) - )); - $this->assertTrue(empty($result)); - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => 2) - )); - $this->assertFalse(empty($result)); - - $data = array( - 'AclPerson' => array( - 'name' => 'Trent', - 'mother_id' => 2, - 'father_id' => 3, - ), - ); - $Person->save($data); - $id = $Person->id; - $Person->delete(2); - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => $id) - )); - $this->assertTrue(empty($result)); - - $result = $this->Aro->find('first', array( - 'conditions' => array('Aro.model' => 'AclPerson', 'Aro.foreign_key' => 2) - )); - $this->assertTrue(empty($result)); - } - -/** - * Test Node() - * - * @return void - */ - public function testNode() { - $Person = new AclPerson(); - $aroData = array( - 'Aro' => array( - 'model' => 'AclPerson', - 'foreign_key' => 2, - 'parent_id' => null - ) - ); - $this->Aro->save($aroData); - - $Person->id = 2; - $result = $Person->node(null, 'Aro'); - $this->assertTrue(is_array($result)); - $this->assertEquals(1, count($result)); - } -} diff --git a/lib/Cake/Test/Case/Model/Behavior/ContainableBehaviorTest.php b/lib/Cake/Test/Case/Model/Behavior/ContainableBehaviorTest.php deleted file mode 100644 index 1dbf1abac29..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/ContainableBehaviorTest.php +++ /dev/null @@ -1,3660 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Behavior - * @since CakePHP(tm) v 1.2.0.5669 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -require_once dirname(dirname(__FILE__)) . DS . 'models.php'; - -/** - * ContainableTest class - * - * @package Cake.Test.Case.Model.Behavior - */ -class ContainableBehaviorTest extends CakeTestCase { - -/** - * Fixtures associated with this test case - * - * @var array - */ - public $fixtures = array( - 'core.article', 'core.article_featured', 'core.article_featureds_tags', - 'core.articles_tag', 'core.attachment', 'core.category', - 'core.comment', 'core.featured', 'core.tag', 'core.user', - 'core.join_a', 'core.join_b', 'core.join_c', 'core.join_a_c', 'core.join_a_b' - ); - -/** - * Method executed before each test - * - */ - public function setUp() { - parent::setUp(); - $this->User = ClassRegistry::init('User'); - $this->Article = ClassRegistry::init('Article'); - $this->Tag = ClassRegistry::init('Tag'); - - $this->User->bindModel(array( - 'hasMany' => array('Article', 'ArticleFeatured', 'Comment') - ), false); - $this->User->ArticleFeatured->unbindModel(array('belongsTo' => array('Category')), false); - $this->User->ArticleFeatured->hasMany['Comment']['foreignKey'] = 'article_id'; - - $this->Tag->bindModel(array( - 'hasAndBelongsToMany' => array('Article') - ), false); - - $this->User->Behaviors->attach('Containable'); - $this->Article->Behaviors->attach('Containable'); - $this->Tag->Behaviors->attach('Containable'); - } - -/** - * Method executed after each test - * - */ - public function tearDown() { - unset($this->Article); - unset($this->User); - unset($this->Tag); - parent::tearDown(); - } - -/** - * testContainments method - * - * @return void - */ - public function testContainments() { - $r = $this->_containments($this->Article, array('Comment' => array('conditions' => array('Comment.user_id' => 2)))); - $this->assertTrue(Set::matches('/Article/keep/Comment/conditions[Comment.user_id=2]', $r)); - - $r = $this->_containments($this->User, array( - 'ArticleFeatured' => array( - 'Featured' => array( - 'id', - 'Category' => 'name' - ) - ))); - $this->assertEquals(array('id'), Set::extract('/ArticleFeatured/keep/Featured/fields', $r)); - - $r = $this->_containments($this->Article, array( - 'Comment' => array( - 'User', - 'conditions' => array('Comment' => array('user_id' => 2)), - ), - )); - $this->assertTrue(Set::matches('/User', $r)); - $this->assertTrue(Set::matches('/Comment', $r)); - $this->assertTrue(Set::matches('/Article/keep/Comment/conditions/Comment[user_id=2]', $r)); - - $r = $this->_containments($this->Article, array('Comment(comment, published)' => 'Attachment(attachment)', 'User(user)')); - $this->assertTrue(Set::matches('/Comment', $r)); - $this->assertTrue(Set::matches('/User', $r)); - $this->assertTrue(Set::matches('/Article/keep/Comment', $r)); - $this->assertTrue(Set::matches('/Article/keep/User', $r)); - $this->assertEquals(array('comment', 'published'), Set::extract('/Article/keep/Comment/fields', $r)); - $this->assertEquals(array('user'), Set::extract('/Article/keep/User/fields', $r)); - $this->assertTrue(Set::matches('/Comment/keep/Attachment', $r)); - $this->assertEquals(array('attachment'), Set::extract('/Comment/keep/Attachment/fields', $r)); - - $r = $this->_containments($this->Article, array('Comment' => array('limit' => 1))); - $this->assertEquals(array('Comment', 'Article'), array_keys($r)); - $result = Set::extract('/Comment/keep', $r); - $this->assertEquals(array('keep' => array()), array_shift($result)); - $this->assertTrue(Set::matches('/Article/keep/Comment', $r)); - $result = Set::extract('/Article/keep/Comment/.', $r); - $this->assertEquals(array('limit' => 1), array_shift($result)); - - $r = $this->_containments($this->Article, array('Comment.User')); - $this->assertEquals(array('User', 'Comment', 'Article'), array_keys($r)); - - $result = Set::extract('/User/keep', $r); - $this->assertEquals(array('keep' => array()), array_shift($result)); - - $result = Set::extract('/Comment/keep', $r); - $this->assertEquals(array('keep' => array('User' => array())), array_shift($result)); - - $result = Set::extract('/Article/keep', $r); - $this->assertEquals(array('keep' => array('Comment' => array())), array_shift($result)); - - $r = $this->_containments($this->Tag, array('Article' => array('User' => array('Comment' => array( - 'Attachment' => array('conditions' => array('Attachment.id >' => 1)) - ))))); - $this->assertTrue(Set::matches('/Attachment', $r)); - $this->assertTrue(Set::matches('/Comment/keep/Attachment/conditions', $r)); - $this->assertEquals(array('Attachment.id >' => 1), $r['Comment']['keep']['Attachment']['conditions']); - $this->assertTrue(Set::matches('/User/keep/Comment', $r)); - $this->assertTrue(Set::matches('/Article/keep/User', $r)); - $this->assertTrue(Set::matches('/Tag/keep/Article', $r)); - } - -/** - * testInvalidContainments method - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testInvalidContainments() { - $r = $this->_containments($this->Article, array('Comment', 'InvalidBinding')); - } - -/** - * testInvalidContainments method with suppressing error notices - * - * @return void - */ - public function testInvalidContainmentsNoNotices() { - $this->Article->Behaviors->attach('Containable', array('notices' => false)); - $r = $this->_containments($this->Article, array('Comment', 'InvalidBinding')); - } - -/** - * testBeforeFind method - * - * @return void - */ - public function testBeforeFind() { - $r = $this->Article->find('all', array('contain' => array('Comment'))); - $this->assertFalse(Set::matches('/User', $r)); - $this->assertTrue(Set::matches('/Comment', $r)); - $this->assertFalse(Set::matches('/Comment/User', $r)); - - $r = $this->Article->find('all', array('contain' => 'Comment.User')); - $this->assertTrue(Set::matches('/Comment/User', $r)); - $this->assertFalse(Set::matches('/Comment/Article', $r)); - - $r = $this->Article->find('all', array('contain' => array('Comment' => array('User', 'Article')))); - $this->assertTrue(Set::matches('/Comment/User', $r)); - $this->assertTrue(Set::matches('/Comment/Article', $r)); - - $r = $this->Article->find('all', array('contain' => array('Comment' => array('conditions' => array('Comment.user_id' => 2))))); - $this->assertFalse(Set::matches('/Comment[user_id!=2]', $r)); - $this->assertTrue(Set::matches('/Comment[user_id=2]', $r)); - - $r = $this->Article->find('all', array('contain' => array('Comment.user_id = 2'))); - $this->assertFalse(Set::matches('/Comment[user_id!=2]', $r)); - - $r = $this->Article->find('all', array('contain' => 'Comment.id DESC')); - $ids = $descIds = Set::extract('/Comment[1]/id', $r); - rsort($descIds); - $this->assertEquals($ids, $descIds); - - $r = $this->Article->find('all', array('contain' => 'Comment')); - $this->assertTrue(Set::matches('/Comment[user_id!=2]', $r)); - - $r = $this->Article->find('all', array('contain' => array('Comment' => array('fields' => 'comment')))); - $this->assertFalse(Set::matches('/Comment/created', $r)); - $this->assertTrue(Set::matches('/Comment/comment', $r)); - $this->assertFalse(Set::matches('/Comment/updated', $r)); - - $r = $this->Article->find('all', array('contain' => array('Comment' => array('fields' => array('comment', 'updated'))))); - $this->assertFalse(Set::matches('/Comment/created', $r)); - $this->assertTrue(Set::matches('/Comment/comment', $r)); - $this->assertTrue(Set::matches('/Comment/updated', $r)); - - $r = $this->Article->find('all', array('contain' => array('Comment' => array('comment', 'updated')))); - $this->assertFalse(Set::matches('/Comment/created', $r)); - $this->assertTrue(Set::matches('/Comment/comment', $r)); - $this->assertTrue(Set::matches('/Comment/updated', $r)); - - $r = $this->Article->find('all', array('contain' => array('Comment(comment,updated)'))); - $this->assertFalse(Set::matches('/Comment/created', $r)); - $this->assertTrue(Set::matches('/Comment/comment', $r)); - $this->assertTrue(Set::matches('/Comment/updated', $r)); - - $r = $this->Article->find('all', array('contain' => 'Comment.created')); - $this->assertTrue(Set::matches('/Comment/created', $r)); - $this->assertFalse(Set::matches('/Comment/comment', $r)); - - $r = $this->Article->find('all', array('contain' => array('User.Article(title)', 'Comment(comment)'))); - $this->assertFalse(Set::matches('/Comment/Article', $r)); - $this->assertFalse(Set::matches('/Comment/User', $r)); - $this->assertTrue(Set::matches('/Comment/comment', $r)); - $this->assertFalse(Set::matches('/Comment/created', $r)); - $this->assertTrue(Set::matches('/User/Article/title', $r)); - $this->assertFalse(Set::matches('/User/Article/created', $r)); - - $r = $this->Article->find('all', array('contain' => array())); - $this->assertFalse(Set::matches('/User', $r)); - $this->assertFalse(Set::matches('/Comment', $r)); - } - -/** - * testBeforeFindWithNonExistingBinding method - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testBeforeFindWithNonExistingBinding() { - $r = $this->Article->find('all', array('contain' => array('Comment' => 'NonExistingBinding'))); - } - -/** - * testContain method - * - * @return void - */ - public function testContain() { - $this->Article->contain('Comment.User'); - $r = $this->Article->find('all'); - $this->assertTrue(Set::matches('/Comment/User', $r)); - $this->assertFalse(Set::matches('/Comment/Article', $r)); - - $r = $this->Article->find('all'); - $this->assertFalse(Set::matches('/Comment/User', $r)); - } - -/** - * testFindEmbeddedNoBindings method - * - * @return void - */ - public function testFindEmbeddedNoBindings() { - $result = $this->Article->find('all', array('contain' => false)); - $expected = array( - array('Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - )), - array('Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - )), - array('Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - )) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindFirstLevel method - * - * @return void - */ - public function testFindFirstLevel() { - $this->Article->contain('User'); - $result = $this->Article->find('all', array('recursive' => 1)); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $this->Article->contain('User', 'Comment'); - $result = $this->Article->find('all', array('recursive' => 1)); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array( - array( - 'id' => 1, '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' - ), - array( - 'id' => 2, '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' - ), - array( - 'id' => 3, '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' - ), - array( - 'id' => 4, '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' - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => 5, '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' - ), - array( - 'id' => 6, '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' - ) - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindEmbeddedFirstLevel method - * - * @return void - */ - public function testFindEmbeddedFirstLevel() { - $result = $this->Article->find('all', array('contain' => array('User'))); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array('contain' => array('User', 'Comment'))); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array( - array( - 'id' => 1, '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' - ), - array( - 'id' => 2, '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' - ), - array( - 'id' => 3, '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' - ), - array( - 'id' => 4, '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' - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => 5, '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' - ), - array( - 'id' => 6, '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' - ) - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindSecondLevel method - * - * @return void - */ - public function testFindSecondLevel() { - $this->Article->contain(array('Comment' => 'User')); - $result = $this->Article->find('all', array('recursive' => 2)); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ) - ), - array( - 'id' => 2, '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', - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ) - ), - array( - 'id' => 3, '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', - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'id' => 4, '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', - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'id' => 6, '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', - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'Comment' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->Article->contain(array('User' => 'ArticleFeatured')); - $result = $this->Article->find('all', array('recursive' => 2)); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $this->Article->contain(array('User' => array('ArticleFeatured', 'Comment'))); - $result = $this->Article->find('all', array('recursive' => 2)); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'Comment' => array( - array( - 'id' => 3, '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' - ), - array( - 'id' => 4, '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' - ), - array( - 'id' => 5, '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' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ), - 'Comment' => array() - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'Comment' => array( - array( - 'id' => 3, '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' - ), - array( - 'id' => 4, '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' - ), - array( - 'id' => 5, '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' - ) - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $this->Article->contain(array('User' => array('ArticleFeatured')), 'Tag', array('Comment' => 'Attachment')); - $result = $this->Article->find('all', array('recursive' => 2)); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'Attachment' => array() - ), - array( - 'id' => 2, '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', - 'Attachment' => array() - ), - array( - 'id' => 3, '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', - 'Attachment' => array() - ), - array( - 'id' => 4, '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', - 'Attachment' => array() - ) - ), - 'Tag' => array( - array('id' => 1, 'tag' => 'tag1', 'created' => '2007-03-18 12:22:23', 'updated' => '2007-03-18 12:24:31'), - array('id' => 2, 'tag' => 'tag2', 'created' => '2007-03-18 12:24:23', 'updated' => '2007-03-18 12:26:31') - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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', - 'Attachment' => array() - ) - ), - 'Tag' => array( - array('id' => 1, 'tag' => 'tag1', 'created' => '2007-03-18 12:22:23', 'updated' => '2007-03-18 12:24:31'), - array('id' => 3, 'tag' => 'tag3', 'created' => '2007-03-18 12:26:23', 'updated' => '2007-03-18 12:28:31') - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ), - 'Comment' => array(), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindEmbeddedSecondLevel method - * - * @return void - */ - public function testFindEmbeddedSecondLevel() { - $result = $this->Article->find('all', array('contain' => array('Comment' => 'User'))); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ) - ), - array( - 'id' => 2, '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', - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ) - ), - array( - 'id' => 3, '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', - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'id' => 4, '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', - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'id' => 6, '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', - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'Comment' => array() - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array('contain' => array('User' => 'ArticleFeatured'))); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array('contain' => array('User' => array('ArticleFeatured', 'Comment')))); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'Comment' => array( - array( - 'id' => 3, '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' - ), - array( - 'id' => 4, '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' - ), - array( - 'id' => 5, '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' - ) - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ), - 'Comment' => array() - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'Comment' => array( - array( - 'id' => 3, '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' - ), - array( - 'id' => 4, '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' - ), - array( - 'id' => 5, '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' - ) - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array('contain' => array('User' => 'ArticleFeatured', 'Tag', 'Comment' => 'Attachment'))); - $expected = array( - array( - 'Article' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'Attachment' => array() - ), - array( - 'id' => 2, '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', - 'Attachment' => array() - ), - array( - 'id' => 3, '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', - 'Attachment' => array() - ), - array( - 'id' => 4, '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', - 'Attachment' => array() - ) - ), - 'Tag' => array( - array('id' => 1, 'tag' => 'tag1', 'created' => '2007-03-18 12:22:23', 'updated' => '2007-03-18 12:24:31'), - array('id' => 2, 'tag' => 'tag2', 'created' => '2007-03-18 12:24:23', 'updated' => '2007-03-18 12:26:31') - ) - ), - array( - 'Article' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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', - 'Attachment' => array() - ) - ), - 'Tag' => array( - array('id' => 1, 'tag' => 'tag1', 'created' => '2007-03-18 12:22:23', 'updated' => '2007-03-18 12:24:31'), - array('id' => 3, 'tag' => 'tag3', 'created' => '2007-03-18 12:26:23', 'updated' => '2007-03-18 12:28:31') - ) - ), - array( - 'Article' => array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ) - ), - 'Comment' => array(), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindThirdLevel method - * - * @return void - */ - public function testFindThirdLevel() { - $this->User->contain(array('ArticleFeatured' => array('Featured' => 'Category'))); - $result = $this->User->find('all', array('recursive' => 3)); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->User->contain(array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => array('Article', 'Attachment')))); - $result = $this->User->find('all', array('recursive' => 3)); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 2, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 3, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 4, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->User->contain(array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => 'Attachment'), 'Article')); - $result = $this->User->find('all', array('recursive' => 3)); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Article' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'Attachment' => array() - ), - array( - 'id' => 2, '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', - 'Attachment' => array() - ), - array( - 'id' => 3, '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', - 'Attachment' => array() - ), - array( - 'id' => 4, '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', - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'Article' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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', - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindEmbeddedThirdLevel method - * - * @return void - */ - public function testFindEmbeddedThirdLevel() { - $result = $this->User->find('all', array('contain' => array('ArticleFeatured' => array('Featured' => 'Category')))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->User->find('all', array('contain' => array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => array('Article', 'Attachment'))))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 2, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 3, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 4, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->User->find('all', array('contain' => array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => 'Attachment'), 'Article'))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Article' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'Attachment' => array() - ), - array( - 'id' => 2, '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', - 'Attachment' => array() - ), - array( - 'id' => 3, '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', - 'Attachment' => array() - ), - array( - 'id' => 4, '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', - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'Article' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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', - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSettingsThirdLevel method - * - * @return void - */ - public function testSettingsThirdLevel() { - $result = $this->User->find('all', array('contain' => array('ArticleFeatured' => array('Featured' => array('Category' => array('id', 'name')))))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'name' => 'Category 1' - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'name' => 'Category 1' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $r = $this->User->find('all', array('contain' => array( - 'ArticleFeatured' => array( - 'id', 'title', - 'Featured' => array( - 'id', 'category_id', - 'Category' => array('id', 'name') - ) - ) - ))); - - $this->assertTrue(Set::matches('/User[id=1]', $r)); - $this->assertFalse(Set::matches('/Article', $r) || Set::matches('/Comment', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured', $r)); - $this->assertFalse(Set::matches('/ArticleFeatured/User', $r) || Set::matches('/ArticleFeatured/Comment', $r) || Set::matches('/ArticleFeatured/Tag', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured', $r)); - $this->assertFalse(Set::matches('/ArticleFeatured/Featured/ArticleFeatured', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured/Category', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured[id=1]', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured[id=1]/Category[id=1]', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured[id=1]/Category[name=Category 1]', $r)); - - $r = $this->User->find('all', array('contain' => array( - 'ArticleFeatured' => array( - 'title', - 'Featured' => array( - 'id', - 'Category' => 'name' - ) - ) - ))); - - $this->assertTrue(Set::matches('/User[id=1]', $r)); - $this->assertFalse(Set::matches('/Article', $r) || Set::matches('/Comment', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured', $r)); - $this->assertFalse(Set::matches('/ArticleFeatured/User', $r) || Set::matches('/ArticleFeatured/Comment', $r) || Set::matches('/ArticleFeatured/Tag', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured', $r)); - $this->assertFalse(Set::matches('/ArticleFeatured/Featured/ArticleFeatured', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured/Category', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured[id=1]', $r)); - $this->assertTrue(Set::matches('/ArticleFeatured/Featured[id=1]/Category[name=Category 1]', $r)); - - $result = $this->User->find('all', array('contain' => array( - 'ArticleFeatured' => array( - 'title', - 'Featured' => array( - 'category_id', - 'Category' => 'name' - ) - ) - ))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'title' => 'First Article', 'id' => 1, 'user_id' => 1, - 'Featured' => array( - 'category_id' => 1, 'id' => 1, - 'Category' => array( - 'name' => 'Category 1' - ) - ) - ), - array( - 'title' => 'Third Article', 'id' => 3, 'user_id' => 1, - 'Featured' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'title' => 'Second Article', 'id' => 2, 'user_id' => 3, - 'Featured' => array( - 'category_id' => 1, 'id' => 2, - 'Category' => array( - 'name' => 'Category 1' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $orders = array( - 'title DESC', 'title DESC, published DESC', - array('title' => 'DESC'), array('title' => 'DESC', 'published' => 'DESC'), - ); - foreach ($orders as $order) { - $result = $this->User->find('all', array('contain' => array( - 'ArticleFeatured' => array( - 'title', 'order' => $order, - 'Featured' => array( - 'category_id', - 'Category' => 'name' - ) - ) - ))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'title' => 'Third Article', 'id' => 3, 'user_id' => 1, - 'Featured' => array() - ), - array( - 'title' => 'First Article', 'id' => 1, 'user_id' => 1, - 'Featured' => array( - 'category_id' => 1, 'id' => 1, - 'Category' => array( - 'name' => 'Category 1' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'title' => 'Second Article', 'id' => 2, 'user_id' => 3, - 'Featured' => array( - 'category_id' => 1, 'id' => 2, - 'Category' => array( - 'name' => 'Category 1' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - } - } - -/** - * testFindThirdLevelNonReset method - * - * @return void - */ - public function testFindThirdLevelNonReset() { - $this->User->contain(false, array('ArticleFeatured' => array('Featured' => 'Category'))); - $result = $this->User->find('all', array('recursive' => 3)); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->User->resetBindings(); - - $this->User->contain(false, array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => array('Article', 'Attachment')))); - $result = $this->User->find('all', array('recursive' => 3)); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 2, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 3, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 4, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->User->resetBindings(); - - $this->User->contain(false, array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => 'Attachment'), 'Article')); - $result = $this->User->find('all', array('recursive' => 3)); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Article' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'Attachment' => array() - ), - array( - 'id' => 2, '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', - 'Attachment' => array() - ), - array( - 'id' => 3, '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', - 'Attachment' => array() - ), - array( - 'id' => 4, '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', - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'Article' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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', - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindEmbeddedThirdLevelNonReset method - * - * @return void - */ - public function testFindEmbeddedThirdLevelNonReset() { - $result = $this->User->find('all', array('reset' => false, 'contain' => array('ArticleFeatured' => array('Featured' => 'Category')))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->_assertBindings($this->User, array('hasMany' => array('ArticleFeatured'))); - $this->_assertBindings($this->User->ArticleFeatured, array('hasOne' => array('Featured'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('Category'))); - - $this->User->resetBindings(); - - $this->_assertBindings($this->User, array('hasMany' => array('Article', 'ArticleFeatured', 'Comment'))); - $this->_assertBindings($this->User->ArticleFeatured, array('belongsTo' => array('User'), 'hasOne' => array('Featured'), 'hasMany' => array('Comment'), 'hasAndBelongsToMany' => array('Tag'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('ArticleFeatured', 'Category'))); - $this->_assertBindings($this->User->ArticleFeatured->Comment, array('belongsTo' => array('Article', 'User'), 'hasOne' => array('Attachment'))); - - $result = $this->User->find('all', array('reset' => false, 'contain' => array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => array('Article', 'Attachment'))))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 2, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 3, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 4, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->_assertBindings($this->User, array('hasMany' => array('ArticleFeatured'))); - $this->_assertBindings($this->User->ArticleFeatured, array('hasOne' => array('Featured'), 'hasMany' => array('Comment'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('Category'))); - $this->_assertBindings($this->User->ArticleFeatured->Comment, array('belongsTo' => array('Article'), 'hasOne' => array('Attachment'))); - - $this->User->resetBindings(); - $this->_assertBindings($this->User, array('hasMany' => array('Article', 'ArticleFeatured', 'Comment'))); - $this->_assertBindings($this->User->ArticleFeatured, array('belongsTo' => array('User'), 'hasOne' => array('Featured'), 'hasMany' => array('Comment'), 'hasAndBelongsToMany' => array('Tag'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('ArticleFeatured', 'Category'))); - $this->_assertBindings($this->User->ArticleFeatured->Comment, array('belongsTo' => array('Article', 'User'), 'hasOne' => array('Attachment'))); - - $result = $this->User->find('all', array('contain' => array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => array('Article', 'Attachment')), false))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 2, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 3, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ), - array( - 'id' => 4, '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' => array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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' => array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ), - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->_assertBindings($this->User, array('hasMany' => array('ArticleFeatured'))); - $this->_assertBindings($this->User->ArticleFeatured, array('hasOne' => array('Featured'), 'hasMany' => array('Comment'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('Category'))); - $this->_assertBindings($this->User->ArticleFeatured->Comment, array('belongsTo' => array('Article'), 'hasOne' => array('Attachment'))); - - $this->User->resetBindings(); - $this->_assertBindings($this->User, array('hasMany' => array('Article', 'ArticleFeatured', 'Comment'))); - $this->_assertBindings($this->User->ArticleFeatured, array('belongsTo' => array('User'), 'hasOne' => array('Featured'), 'hasMany' => array('Comment'), 'hasAndBelongsToMany' => array('Tag'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('ArticleFeatured', 'Category'))); - $this->_assertBindings($this->User->ArticleFeatured->Comment, array('belongsTo' => array('Article', 'User'), 'hasOne' => array('Attachment'))); - - $result = $this->User->find('all', array('reset' => false, 'contain' => array('ArticleFeatured' => array('Featured' => 'Category', 'Comment' => 'Attachment'), 'Article'))); - $expected = array( - array( - 'User' => array( - 'id' => 1, 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Article' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 1, 'user_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Featured' => array( - 'id' => 1, 'article_featured_id' => 1, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 1, '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', - 'Attachment' => array() - ), - array( - 'id' => 2, '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', - 'Attachment' => array() - ), - array( - 'id' => 3, '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', - 'Attachment' => array() - ), - array( - 'id' => 4, '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', - 'Attachment' => array() - ) - ) - ), - array( - 'id' => 3, 'user_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31', - 'Featured' => array(), - 'Comment' => array() - ) - ) - ), - array( - 'User' => array( - 'id' => 2, 'user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ), - array( - 'User' => array( - 'id' => 3, 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31' - ), - 'Article' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31' - ) - ), - 'ArticleFeatured' => array( - array( - 'id' => 2, 'user_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', - 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31', - 'Featured' => array( - 'id' => 2, 'article_featured_id' => 2, 'category_id' => 1, 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31', - 'Category' => array( - 'id' => 1, 'parent_id' => 0, 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31' - ) - ), - 'Comment' => array( - array( - 'id' => 5, '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', - 'Attachment' => array( - 'id' => 1, 'comment_id' => 5, 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => 6, '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', - 'Attachment' => array() - ) - ) - ) - ) - ), - array( - 'User' => array( - 'id' => 4, 'user' => 'garrett', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31' - ), - 'Article' => array(), - 'ArticleFeatured' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->_assertBindings($this->User, array('hasMany' => array('Article', 'ArticleFeatured'))); - $this->_assertBindings($this->User->Article); - $this->_assertBindings($this->User->ArticleFeatured, array('hasOne' => array('Featured'), 'hasMany' => array('Comment'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('Category'))); - $this->_assertBindings($this->User->ArticleFeatured->Comment, array('hasOne' => array('Attachment'))); - - $this->User->resetBindings(); - $this->_assertBindings($this->User, array('hasMany' => array('Article', 'ArticleFeatured', 'Comment'))); - $this->_assertBindings($this->User->Article, array('belongsTo' => array('User'), 'hasMany' => array('Comment'), 'hasAndBelongsToMany' => array('Tag'))); - $this->_assertBindings($this->User->ArticleFeatured, array('belongsTo' => array('User'), 'hasOne' => array('Featured'), 'hasMany' => array('Comment'), 'hasAndBelongsToMany' => array('Tag'))); - $this->_assertBindings($this->User->ArticleFeatured->Featured, array('belongsTo' => array('ArticleFeatured', 'Category'))); - $this->_assertBindings($this->User->ArticleFeatured->Comment, array('belongsTo' => array('Article', 'User'), 'hasOne' => array('Attachment'))); - } - -/** - * testEmbeddedFindFields method - * - * @return void - */ - public function testEmbeddedFindFields() { - $result = $this->Article->find('all', array( - 'contain' => array('User(user)'), - 'fields' => array('title'), - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array('title' => 'First Article'), 'User' => array('user' => 'mariano', 'id' => 1)), - array('Article' => array('title' => 'Second Article'), 'User' => array('user' => 'larry', 'id' => 3)), - array('Article' => array('title' => 'Third Article'), 'User' => array('user' => 'mariano', 'id' => 1)), - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array( - 'contain' => array('User(id, user)'), - 'fields' => array('title'), - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array('title' => 'First Article'), 'User' => array('user' => 'mariano', 'id' => 1)), - array('Article' => array('title' => 'Second Article'), 'User' => array('user' => 'larry', 'id' => 3)), - array('Article' => array('title' => 'Third Article'), 'User' => array('user' => 'mariano', 'id' => 1)), - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array( - 'contain' => array( - 'Comment(comment, published)' => 'Attachment(attachment)', 'User(user)' - ), - 'fields' => array('title'), - 'order' => array('Article.id' => 'ASC') - )); - if (!empty($result)) { - foreach ($result as $i => $article) { - foreach ($article['Comment'] as $j => $comment) { - $result[$i]['Comment'][$j] = array_diff_key($comment, array('id' => true)); - } - } - } - $expected = array( - array( - 'Article' => array('title' => 'First Article', 'id' => 1), - 'User' => array('user' => 'mariano', 'id' => 1), - 'Comment' => array( - array('comment' => 'First Comment for First Article', 'published' => 'Y', 'article_id' => 1, 'Attachment' => array()), - array('comment' => 'Second Comment for First Article', 'published' => 'Y', 'article_id' => 1, 'Attachment' => array()), - array('comment' => 'Third Comment for First Article', 'published' => 'Y', 'article_id' => 1, 'Attachment' => array()), - array('comment' => 'Fourth Comment for First Article', 'published' => 'N', 'article_id' => 1, 'Attachment' => array()), - ) - ), - array( - 'Article' => array('title' => 'Second Article', 'id' => 2), - 'User' => array('user' => 'larry', 'id' => 3), - 'Comment' => array( - array('comment' => 'First Comment for Second Article', 'published' => 'Y', 'article_id' => 2, 'Attachment' => array( - 'attachment' => 'attachment.zip', 'id' => 1 - )), - array('comment' => 'Second Comment for Second Article', 'published' => 'Y', 'article_id' => 2, 'Attachment' => array()) - ) - ), - array( - 'Article' => array('title' => 'Third Article', 'id' => 3), - 'User' => array('user' => 'mariano', 'id' => 1), - 'Comment' => array() - ), - ); - $this->assertEquals($expected, $result); - } - -/** - * test that hasOne and belongsTo fields act the same in a contain array. - * - * @return void - */ - public function testHasOneFieldsInContain() { - $this->Article->unbindModel(array( - 'hasMany' => array('Comment') - ), true); - unset($this->Article->Comment); - $this->Article->bindModel(array( - 'hasOne' => array('Comment') - )); - - $result = $this->Article->find('all', array( - 'fields' => array('title', 'body'), - 'contain' => array( - 'Comment' => array( - 'fields' => array('comment') - ), - 'User' => array( - 'fields' => array('user') - ) - ) - )); - $this->assertTrue(isset($result[0]['Article']['title']), 'title missing %s'); - $this->assertTrue(isset($result[0]['Article']['body']), 'body missing %s'); - $this->assertTrue(isset($result[0]['Comment']['comment']), 'comment missing %s'); - $this->assertTrue(isset($result[0]['User']['user']), 'body missing %s'); - $this->assertFalse(isset($result[0]['Comment']['published']), 'published found %s'); - $this->assertFalse(isset($result[0]['User']['password']), 'password found %s'); - } - -/** - * testFindConditionalBinding method - * - * @return void - */ - public function testFindConditionalBinding() { - $this->Article->contain(array( - 'User(user)', - 'Tag' => array( - 'fields' => array('tag', 'created'), - 'conditions' => array('created >=' => '2007-03-18 12:24') - ) - )); - $result = $this->Article->find('all', array('fields' => array('title'), 'order' => array('Article.id' => 'ASC'))); - $expected = array( - array( - 'Article' => array('id' => 1, 'title' => 'First Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array(array('tag' => 'tag2', 'created' => '2007-03-18 12:24:23')) - ), - array( - 'Article' => array('id' => 2, 'title' => 'Second Article'), - 'User' => array('id' => 3, 'user' => 'larry'), - 'Tag' => array(array('tag' => 'tag3', 'created' => '2007-03-18 12:26:23')) - ), - array( - 'Article' => array('id' => 3, 'title' => 'Third Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->Article->contain(array('User(id,user)', 'Tag' => array('fields' => array('tag', 'created')))); - $result = $this->Article->find('all', array('fields' => array('title'), 'order' => array('Article.id' => 'ASC'))); - $expected = array( - array( - 'Article' => array('id' => 1, 'title' => 'First Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array( - array('tag' => 'tag1', 'created' => '2007-03-18 12:22:23'), - array('tag' => 'tag2', 'created' => '2007-03-18 12:24:23') - ) - ), - array( - 'Article' => array('id' => 2, 'title' => 'Second Article'), - 'User' => array('id' => 3, 'user' => 'larry'), - 'Tag' => array( - array('tag' => 'tag1', 'created' => '2007-03-18 12:22:23'), - array('tag' => 'tag3', 'created' => '2007-03-18 12:26:23') - ) - ), - array( - 'Article' => array('id' => 3, 'title' => 'Third Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array( - 'fields' => array('title'), - 'contain' => array('User(id,user)', 'Tag' => array('fields' => array('tag', 'created'))), - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array( - 'Article' => array('id' => 1, 'title' => 'First Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array( - array('tag' => 'tag1', 'created' => '2007-03-18 12:22:23'), - array('tag' => 'tag2', 'created' => '2007-03-18 12:24:23') - ) - ), - array( - 'Article' => array('id' => 2, 'title' => 'Second Article'), - 'User' => array('id' => 3, 'user' => 'larry'), - 'Tag' => array( - array('tag' => 'tag1', 'created' => '2007-03-18 12:22:23'), - array('tag' => 'tag3', 'created' => '2007-03-18 12:26:23') - ) - ), - array( - 'Article' => array('id' => 3, 'title' => 'Third Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->Article->contain(array( - 'User(id,user)', - 'Tag' => array( - 'fields' => array('tag', 'created'), - 'conditions' => array('created >=' => '2007-03-18 12:24') - ) - )); - $result = $this->Article->find('all', array('fields' => array('title'), 'order' => array('Article.id' => 'ASC'))); - $expected = array( - array( - 'Article' => array('id' => 1, 'title' => 'First Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array(array('tag' => 'tag2', 'created' => '2007-03-18 12:24:23')) - ), - array( - 'Article' => array('id' => 2, 'title' => 'Second Article'), - 'User' => array('id' => 3, 'user' => 'larry'), - 'Tag' => array(array('tag' => 'tag3', 'created' => '2007-03-18 12:26:23')) - ), - array( - 'Article' => array('id' => 3, 'title' => 'Third Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - - $this->assertTrue(empty($this->User->Article->hasAndBelongsToMany['Tag']['conditions'])); - - $result = $this->User->find('all', array('contain' => array( - 'Article.Tag' => array('conditions' => array('created >=' => '2007-03-18 12:24')) - ))); - - $this->assertTrue(Set::matches('/User[id=1]', $result)); - $this->assertFalse(Set::matches('/Article[id=1]/Tag[id=1]', $result)); - $this->assertTrue(Set::matches('/Article[id=1]/Tag[id=2]', $result)); - $this->assertTrue(empty($this->User->Article->hasAndBelongsToMany['Tag']['conditions'])); - - $this->assertTrue(empty($this->User->Article->hasAndBelongsToMany['Tag']['order'])); - - $result = $this->User->find('all', array('contain' => array( - 'Article.Tag' => array('order' => 'created DESC') - ))); - - $this->assertTrue(Set::matches('/User[id=1]', $result)); - $this->assertTrue(Set::matches('/Article[id=1]/Tag[id=1]', $result)); - $this->assertTrue(Set::matches('/Article[id=1]/Tag[id=2]', $result)); - $this->assertTrue(empty($this->User->Article->hasAndBelongsToMany['Tag']['order'])); - } - -/** - * testOtherFinds method - * - * @return void - */ - public function testOtherFinds() { - $result = $this->Article->find('count'); - $expected = 3; - $this->assertEquals($expected, $result); - - $result = $this->Article->find('count', array('conditions' => array('Article.id >' => '1'))); - $expected = 2; - $this->assertEquals($expected, $result); - - $result = $this->Article->find('count', array('contain' => array())); - $expected = 3; - $this->assertEquals($expected, $result); - - $this->Article->contain(array('User(id,user)', 'Tag' => array('fields' => array('tag', 'created'), 'conditions' => array('created >=' => '2007-03-18 12:24')))); - $result = $this->Article->find('first', array('fields' => array('title'))); - $expected = array( - 'Article' => array('id' => 1, 'title' => 'First Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array(array('tag' => 'tag2', 'created' => '2007-03-18 12:24:23')) - ); - $this->assertEquals($expected, $result); - - $this->Article->contain(array('User(id,user)', 'Tag' => array('fields' => array('tag', 'created')))); - $result = $this->Article->find('first', array('fields' => array('title'))); - $expected = array( - 'Article' => array('id' => 1, 'title' => 'First Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array( - array('tag' => 'tag1', 'created' => '2007-03-18 12:22:23'), - array('tag' => 'tag2', 'created' => '2007-03-18 12:24:23') - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('first', array( - 'fields' => array('title'), - 'order' => 'Article.id DESC', - 'contain' => array('User(id,user)', 'Tag' => array('fields' => array('tag', 'created'))) - )); - $expected = array( - 'Article' => array('id' => 3, 'title' => 'Third Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Tag' => array() - ); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('list', array( - 'contain' => array('User(id,user)'), - 'fields' => array('Article.id', 'Article.title') - )); - $expected = array( - 1 => 'First Article', - 2 => 'Second Article', - 3 => 'Third Article' - ); - $this->assertEquals($expected, $result); - } - -/** - * testOriginalAssociations method - * - * @return void - */ - public function testOriginalAssociations() { - $this->Article->Comment->Behaviors->attach('Containable'); - - $options = array( - 'conditions' => array( - 'Comment.published' => 'Y', - ), - 'contain' => 'User', - 'recursive' => 1 - ); - - $firstResult = $this->Article->Comment->find('all', $options); - - $dummyResult = $this->Article->Comment->find('all', array( - 'conditions' => array( - 'User.user' => 'mariano' - ), - 'fields' => array('User.password'), - 'contain' => array('User.password'), - )); - - $result = $this->Article->Comment->find('all', $options); - $this->assertEquals($firstResult, $result); - - $this->Article->unbindModel(array('hasMany' => array('Comment'), 'belongsTo' => array('User'), 'hasAndBelongsToMany' => array('Tag')), false); - $this->Article->bindModel(array('hasMany' => array('Comment'), 'belongsTo' => array('User')), false); - - $r = $this->Article->find('all', array('contain' => array('Comment(comment)', 'User(user)'), 'fields' => array('title'))); - $this->assertTrue(Set::matches('/Article[id=1]', $r)); - $this->assertTrue(Set::matches('/User[id=1]', $r)); - $this->assertTrue(Set::matches('/Comment[article_id=1]', $r)); - $this->assertFalse(Set::matches('/Comment[id=1]', $r)); - - $r = $this->Article->find('all'); - $this->assertTrue(Set::matches('/Article[id=1]', $r)); - $this->assertTrue(Set::matches('/User[id=1]', $r)); - $this->assertTrue(Set::matches('/Comment[article_id=1]', $r)); - $this->assertTrue(Set::matches('/Comment[id=1]', $r)); - - $this->Article->bindModel(array('hasAndBelongsToMany' => array('Tag')), false); - - $this->Article->contain(false, array('User(id,user)', 'Comment' => array('fields' => array('comment'), 'conditions' => array('created >=' => '2007-03-18 10:49')))); - $result = $this->Article->find('all', array('fields' => array('title'), 'limit' => 1, 'page' => 1, 'order' => 'Article.id ASC')); - $expected = array(array( - 'Article' => array('id' => 1, 'title' => 'First Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Comment' => array( - array('comment' => 'Third Comment for First Article', 'article_id' => 1), - array('comment' => 'Fourth Comment for First Article', 'article_id' => 1) - ) - )); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array('fields' => array('title', 'User.id', 'User.user'), 'limit' => 1, 'page' => 2, 'order' => 'Article.id ASC')); - $expected = array(array( - 'Article' => array('id' => 2, 'title' => 'Second Article'), - 'User' => array('id' => 3, 'user' => 'larry'), - 'Comment' => array( - array('comment' => 'First Comment for Second Article', 'article_id' => 2), - array('comment' => 'Second Comment for Second Article', 'article_id' => 2) - ) - )); - $this->assertEquals($expected, $result); - - $result = $this->Article->find('all', array('fields' => array('title', 'User.id', 'User.user'), 'limit' => 1, 'page' => 3, 'order' => 'Article.id ASC')); - $expected = array(array( - 'Article' => array('id' => 3, 'title' => 'Third Article'), - 'User' => array('id' => 1, 'user' => 'mariano'), - 'Comment' => array() - )); - $this->assertEquals($expected, $result); - - $this->Article->contain(false, array('User' => array('fields' => 'user'), 'Comment')); - $result = $this->Article->find('all'); - $this->assertTrue(Set::matches('/Article[id=1]', $result)); - $this->assertTrue(Set::matches('/User[user=mariano]', $result)); - $this->assertTrue(Set::matches('/Comment[article_id=1]', $result)); - $this->Article->resetBindings(); - - $this->Article->contain(false, array('User' => array('fields' => array('user')), 'Comment')); - $result = $this->Article->find('all'); - $this->assertTrue(Set::matches('/Article[id=1]', $result)); - $this->assertTrue(Set::matches('/User[user=mariano]', $result)); - $this->assertTrue(Set::matches('/Comment[article_id=1]', $result)); - $this->Article->resetBindings(); - } - -/** - * testResetAddedAssociation method - * - */ - public function testResetAddedAssociation() { - $this->assertTrue(empty($this->Article->hasMany['ArticlesTag'])); - - $this->Article->bindModel(array( - 'hasMany' => array('ArticlesTag') - )); - $this->assertTrue(!empty($this->Article->hasMany['ArticlesTag'])); - - $result = $this->Article->find('first', array( - 'conditions' => array('Article.id' => 1), - 'contain' => array('ArticlesTag') - )); - - $expected = array('Article', 'ArticlesTag'); - $this->assertTrue(!empty($result)); - $this->assertEquals('First Article', $result['Article']['title']); - $this->assertTrue(!empty($result['ArticlesTag'])); - $this->assertEquals($expected, array_keys($result)); - - $this->assertTrue(empty($this->Article->hasMany['ArticlesTag'])); - - $this->JoinA = ClassRegistry::init('JoinA'); - $this->JoinB = ClassRegistry::init('JoinB'); - $this->JoinC = ClassRegistry::init('JoinC'); - - $this->JoinA->Behaviors->attach('Containable'); - $this->JoinB->Behaviors->attach('Containable'); - $this->JoinC->Behaviors->attach('Containable'); - - $this->JoinA->JoinB->find('all', array('contain' => array('JoinA'))); - $this->JoinA->bindModel(array('hasOne' => array('JoinAsJoinC' => array('joinTable' => 'as_cs'))), false); - $result = $this->JoinA->hasOne; - $this->JoinA->find('all'); - $resultAfter = $this->JoinA->hasOne; - $this->assertEquals($result, $resultAfter); - } - -/** - * testResetAssociation method - * - */ - public function testResetAssociation() { - $this->Article->Behaviors->attach('Containable'); - $this->Article->Comment->Behaviors->attach('Containable'); - $this->Article->User->Behaviors->attach('Containable'); - - $initialOptions = array( - 'conditions' => array( - 'Comment.published' => 'Y', - ), - 'contain' => 'User', - 'recursive' => 1, - ); - - $initialModels = $this->Article->Comment->find('all', $initialOptions); - - $findOptions = array( - 'conditions' => array( - 'User.user' => 'mariano', - ), - 'fields' => array('User.password'), - 'contain' => array('User.password') - ); - $result = $this->Article->Comment->find('all', $findOptions); - $result = $this->Article->Comment->find('all', $initialOptions); - $this->assertEquals($initialModels, $result); - } - -/** - * testResetDeeperHasOneAssociations method - * - */ - public function testResetDeeperHasOneAssociations() { - $this->Article->User->unbindModel(array( - 'hasMany' => array('ArticleFeatured', 'Comment') - ), false); - $userHasOne = array('hasOne' => array('ArticleFeatured', 'Comment')); - - $this->Article->User->bindModel($userHasOne, false); - $expected = $this->Article->User->hasOne; - $this->Article->find('all'); - $this->assertEquals($expected, $this->Article->User->hasOne); - - $this->Article->User->bindModel($userHasOne, false); - $expected = $this->Article->User->hasOne; - $this->Article->find('all', array( - 'contain' => array( - 'User' => array('ArticleFeatured', 'Comment') - ) - )); - $this->assertEquals($expected, $this->Article->User->hasOne); - - $this->Article->User->bindModel($userHasOne, false); - $expected = $this->Article->User->hasOne; - $this->Article->find('all', array( - 'contain' => array( - 'User' => array( - 'ArticleFeatured', - 'Comment' => array('fields' => array('created')) - ) - ) - )); - $this->assertEquals($expected, $this->Article->User->hasOne); - - $this->Article->User->bindModel($userHasOne, false); - $expected = $this->Article->User->hasOne; - $this->Article->find('all', array( - 'contain' => array( - 'User' => array( - 'Comment' => array('fields' => array('created')) - ) - ) - )); - $this->assertEquals($expected, $this->Article->User->hasOne); - - $this->Article->User->bindModel($userHasOne, false); - $expected = $this->Article->User->hasOne; - $this->Article->find('all', array( - 'contain' => array( - 'User.ArticleFeatured' => array( - 'conditions' => array('ArticleFeatured.published' => 'Y') - ), - 'User.Comment' - ) - )); - $this->assertEquals($expected, $this->Article->User->hasOne); - } - -/** - * testResetMultipleHabtmAssociations method - * - */ - public function testResetMultipleHabtmAssociations() { - $articleHabtm = array( - 'hasAndBelongsToMany' => array( - 'Tag' => array( - 'className' => 'Tag', - 'joinTable' => 'articles_tags', - 'foreignKey' => 'article_id', - 'associationForeignKey' => 'tag_id' - ), - 'ShortTag' => array( - 'className' => 'Tag', - 'joinTable' => 'articles_tags', - 'foreignKey' => 'article_id', - 'associationForeignKey' => 'tag_id', - // LENGHT function mysql-only, using LIKE does almost the same - 'conditions' => "ShortTag.tag LIKE '???'" - ) - ) - ); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all'); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => 'Tag.tag')); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => 'Tag')); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => array('Tag' => array('fields' => array(null))))); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => array('Tag' => array('fields' => array('Tag.tag'))))); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => array('Tag' => array('fields' => array('Tag.tag', 'Tag.created'))))); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => 'ShortTag.tag')); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => 'ShortTag')); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => array('ShortTag' => array('fields' => array(null))))); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => array('ShortTag' => array('fields' => array('ShortTag.tag'))))); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - - $this->Article->resetBindings(); - $this->Article->bindModel($articleHabtm, false); - $expected = $this->Article->hasAndBelongsToMany; - $this->Article->find('all', array('contain' => array('ShortTag' => array('fields' => array('ShortTag.tag', 'ShortTag.created'))))); - $this->assertEquals($expected, $this->Article->hasAndBelongsToMany); - } - -/** - * test that bindModel and unbindModel work with find() calls in between. - */ - public function testBindMultipleTimesWithFind() { - $binding = array( - 'hasOne' => array( - 'ArticlesTag' => array( - 'foreignKey' => false, - 'type' => 'INNER', - 'conditions' => array( - 'ArticlesTag.article_id = Article.id' - ) - ), - 'Tag' => array( - 'type' => 'INNER', - 'foreignKey' => false, - 'conditions' => array( - 'ArticlesTag.tag_id = Tag.id' - ) - ) - ) - ); - $this->Article->unbindModel(array('hasAndBelongsToMany' => array('Tag'))); - $this->Article->bindModel($binding); - $result = $this->Article->find('all', array('limit' => 1, 'contain' => array('ArticlesTag', 'Tag'))); - - $this->Article->unbindModel(array('hasAndBelongsToMany' => array('Tag'))); - $this->Article->bindModel($binding); - $result = $this->Article->find('all', array('limit' => 1, 'contain' => array('ArticlesTag', 'Tag'))); - - $associated = $this->Article->getAssociated(); - $this->assertEquals('hasAndBelongsToMany', $associated['Tag']); - $this->assertFalse(isset($associated['ArticleTag'])); - } - -/** - * test that autoFields doesn't splice in fields from other databases. - * - * @return void - */ - public function testAutoFieldsWithMultipleDatabases() { - $config = new DATABASE_CONFIG(); - - $this->skipIf( - !isset($config->test) || !isset($config->test2), - 'Primary and secondary test databases not configured, ' . - 'skipping cross-database join tests. ' . - ' To run these tests, you must define $test and $test2 ' . - 'in your database configuration.' - ); - - $db = ConnectionManager::getDataSource('test2'); - $this->fixtureManager->loadSingle('User', $db); - - $this->Article->User->setDataSource('test2'); - - $result = $this->Article->find('all', array( - 'fields' => array('Article.title'), - 'contain' => array('User') - )); - $this->assertTrue(isset($result[0]['Article'])); - $this->assertTrue(isset($result[0]['User'])); - } - -/** - * test that autoFields doesn't splice in columns that aren't part of the join. - * - * @return void - */ - public function testAutoFieldsWithRecursiveNegativeOne() { - $this->Article->recursive = -1; - $result = $this->Article->field('title', array('Article.title' => 'First Article')); - $this->assertNoErrors(); - $this->assertEquals('First Article', $result, 'Field is wrong'); - } - -/** - * test that find(all) doesn't return incorrect values when mixed with containable. - * - * @return void - */ - public function testFindAllReturn() { - $result = $this->Article->find('all', array( - 'conditions' => array('Article.id' => 999999999) - )); - $this->assertEmpty($result, 'Should be empty.'); - } - -/** - * testLazyLoad method - * - * @return void - */ - public function testLazyLoad() { - // Local set up - $this->User = ClassRegistry::init('User'); - $this->User->bindModel(array( - 'hasMany' => array('Article', 'ArticleFeatured', 'Comment') - ), false); - - try { - $this->User->find('first', array( - 'contain' => 'Comment', - 'lazyLoad' => true - )); - } catch (Exception $e) { - $exceptions = true; - } - $this->assertTrue(empty($exceptions)); - } - -/** - * _containments method - * - * @param Model $Model - * @param array $contain - * @return void - */ - protected function _containments($Model, $contain = array()) { - if (!is_array($Model)) { - $result = $Model->containments($contain); - return $this->_containments($result['models']); - } else { - $result = $Model; - foreach ($result as $i => $containment) { - $result[$i] = array_diff_key($containment, array('instance' => true)); - } - } - return $result; - } - -/** - * _assertBindings method - * - * @param Model $Model - * @param array $expected - * @return void - */ - protected function _assertBindings(Model $Model, $expected = array()) { - $expected = array_merge(array( - 'belongsTo' => array(), - 'hasOne' => array(), - 'hasMany' => array(), - 'hasAndBelongsToMany' => array() - ), $expected); - foreach ($expected as $binding => $expect) { - $this->assertEquals(array_keys($Model->$binding), $expect); - } - } -} diff --git a/lib/Cake/Test/Case/Model/Behavior/TranslateBehaviorTest.php b/lib/Cake/Test/Case/Model/Behavior/TranslateBehaviorTest.php deleted file mode 100644 index e6d6e8f0aa4..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/TranslateBehaviorTest.php +++ /dev/null @@ -1,900 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Behavior - * @since CakePHP(tm) v 1.2.0.5669 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -if (!defined('CAKEPHP_UNIT_TEST_EXECUTION')) { - define('CAKEPHP_UNIT_TEST_EXECUTION', 1); -} - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -require_once dirname(dirname(__FILE__)) . DS . 'models.php'; - -/** - * TranslateBehaviorTest class - * - * @package Cake.Test.Case.Model.Behavior - */ -class TranslateBehaviorTest extends CakeTestCase { - -/** - * autoFixtures property - * - * @var bool false - */ - public $autoFixtures = false; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array( - 'core.translated_item', 'core.translate', 'core.translate_table', - 'core.translated_article', 'core.translate_article', 'core.user', 'core.comment', 'core.tag', 'core.articles_tag', - 'core.translate_with_prefix' - ); - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - ClassRegistry::flush(); - } - -/** - * Test that count queries with conditions get the correct joins - * - * @return void - */ - public function testCountWithConditions() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $Model = new TranslatedItem(); - $Model->locale = 'eng'; - $result = $Model->find('count', array( - 'conditions' => array( - 'I18n__content.locale' => 'eng' - ) - )); - $this->assertEqual(3, $result); - } - -/** - * testTranslateModel method - * - * @return void - */ - public function testTranslateModel() { - $this->loadFixtures('TranslateTable', 'Tag', 'TranslatedItem', 'Translate', 'User', 'TranslatedArticle', 'TranslateArticle'); - $TestModel = new Tag(); - $TestModel->translateTable = 'another_i18n'; - $TestModel->Behaviors->attach('Translate', array('title')); - $translateModel = $TestModel->Behaviors->Translate->translateModel($TestModel); - $this->assertEquals('I18nModel', $translateModel->name); - $this->assertEquals('another_i18n', $translateModel->useTable); - - $TestModel = new User(); - $TestModel->Behaviors->attach('Translate', array('title')); - $translateModel = $TestModel->Behaviors->Translate->translateModel($TestModel); - $this->assertEquals('I18nModel', $translateModel->name); - $this->assertEquals('i18n', $translateModel->useTable); - - $TestModel = new TranslatedArticle(); - $translateModel = $TestModel->Behaviors->Translate->translateModel($TestModel); - $this->assertEquals('TranslateArticleModel', $translateModel->name); - $this->assertEquals('article_i18n', $translateModel->useTable); - - $TestModel = new TranslatedItem(); - $translateModel = $TestModel->Behaviors->Translate->translateModel($TestModel); - $this->assertEquals('TranslateTestModel', $translateModel->name); - $this->assertEquals('i18n', $translateModel->useTable); - } - -/** - * testLocaleFalsePlain method - * - * @return void - */ - public function testLocaleFalsePlain() { - $this->loadFixtures('Translate', 'TranslatedItem', 'User'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = false; - - $result = $TestModel->read(null, 1); - $expected = array('TranslatedItem' => array('id' => 1, 'slug' => 'first_translated')); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('fields' => array('slug'))); - $expected = array( - array('TranslatedItem' => array('slug' => 'first_translated')), - array('TranslatedItem' => array('slug' => 'second_translated')), - array('TranslatedItem' => array('slug' => 'third_translated')) - ); - $this->assertEquals($expected, $result); - } - -/** - * testLocaleFalseAssociations method - * - * @return void - */ - public function testLocaleFalseAssociations() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = false; - $TestModel->unbindTranslation(); - $translations = array('title' => 'Title', 'content' => 'Content'); - $TestModel->bindTranslation($translations, false); - - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedItem' => array('id' => 1, 'slug' => 'first_translated'), - 'Title' => array( - array('id' => 1, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Title #1'), - array('id' => 3, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titel #1'), - array('id' => 5, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titulek #1') - ), - 'Content' => array( - array('id' => 2, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Content #1'), - array('id' => 4, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Inhalt #1'), - array('id' => 6, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Obsah #1') - ) - ); - $this->assertEquals($expected, $result); - - $TestModel->hasMany['Title']['fields'] = $TestModel->hasMany['Content']['fields'] = array('content'); - $TestModel->hasMany['Title']['conditions']['locale'] = $TestModel->hasMany['Content']['conditions']['locale'] = 'eng'; - - $result = $TestModel->find('all', array('fields' => array('TranslatedItem.slug'))); - $expected = array( - array( - 'TranslatedItem' => array('id' => 1, 'slug' => 'first_translated'), - 'Title' => array(array('foreign_key' => 1, 'content' => 'Title #1')), - 'Content' => array(array('foreign_key' => 1, 'content' => 'Content #1')) - ), - array( - 'TranslatedItem' => array('id' => 2, 'slug' => 'second_translated'), - 'Title' => array(array('foreign_key' => 2, 'content' => 'Title #2')), - 'Content' => array(array('foreign_key' => 2, 'content' => 'Content #2')) - ), - array( - 'TranslatedItem' => array('id' => 3, 'slug' => 'third_translated'), - 'Title' => array(array('foreign_key' => 3, 'content' => 'Title #3')), - 'Content' => array(array('foreign_key' => 3, 'content' => 'Content #3')) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testLocaleSingle method - * - * @return void - */ - public function testLocaleSingle() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'eng'; - - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'eng', - 'title' => 'Title #1', - 'content' => 'Content #1' - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all'); - $expected = array( - array( - 'TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'eng', - 'title' => 'Title #1', - 'content' => 'Content #1' - ) - ), - array( - 'TranslatedItem' => array( - 'id' => 2, - 'slug' => 'second_translated', - 'locale' => 'eng', - 'title' => 'Title #2', - 'content' => 'Content #2' - ) - ), - array( - 'TranslatedItem' => array( - 'id' => 3, - 'slug' => 'third_translated', - 'locale' => 'eng', - 'title' => 'Title #3', - 'content' => 'Content #3' - ) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testLocaleSingleWithConditions method - * - * @return void - */ - public function testLocaleSingleWithConditions() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'eng'; - $result = $TestModel->find('all', array('conditions' => array('slug' => 'first_translated'))); - $expected = array( - array( - 'TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'eng', - 'title' => 'Title #1', - 'content' => 'Content #1' - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('conditions' => "TranslatedItem.slug = 'first_translated'")); - $expected = array( - array( - 'TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'eng', - 'title' => 'Title #1', - 'content' => 'Content #1' - ) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testLocaleSingleAssociations method - * - * @return void - */ - public function testLocaleSingleAssociations() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'eng'; - $TestModel->unbindTranslation(); - $translations = array('title' => 'Title', 'content' => 'Content'); - $TestModel->bindTranslation($translations, false); - - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'eng', - 'title' => 'Title #1', - 'content' => 'Content #1' - ), - 'Title' => array( - array('id' => 1, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Title #1'), - array('id' => 3, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titel #1'), - array('id' => 5, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titulek #1') - ), - 'Content' => array( - array('id' => 2, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Content #1'), - array('id' => 4, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Inhalt #1'), - array('id' => 6, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Obsah #1') - ) - ); - $this->assertEquals($expected, $result); - - $TestModel->hasMany['Title']['fields'] = $TestModel->hasMany['Content']['fields'] = array('content'); - $TestModel->hasMany['Title']['conditions']['locale'] = $TestModel->hasMany['Content']['conditions']['locale'] = 'eng'; - - $result = $TestModel->find('all', array('fields' => array('TranslatedItem.title'))); - $expected = array( - array( - 'TranslatedItem' => array('id' => 1, 'locale' => 'eng', 'title' => 'Title #1', 'slug' => 'first_translated'), - 'Title' => array(array('foreign_key' => 1, 'content' => 'Title #1')), - 'Content' => array(array('foreign_key' => 1, 'content' => 'Content #1')) - ), - array( - 'TranslatedItem' => array('id' => 2, 'locale' => 'eng', 'title' => 'Title #2', 'slug' => 'second_translated'), - 'Title' => array(array('foreign_key' => 2, 'content' => 'Title #2')), - 'Content' => array(array('foreign_key' => 2, 'content' => 'Content #2')) - ), - array( - 'TranslatedItem' => array('id' => 3, 'locale' => 'eng', 'title' => 'Title #3','slug' => 'third_translated'), - 'Title' => array(array('foreign_key' => 3, 'content' => 'Title #3')), - 'Content' => array(array('foreign_key' => 3, 'content' => 'Content #3')) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testLocaleMultiple method - * - * @return void - */ - public function testLocaleMultiple() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = array('deu', 'eng', 'cze'); - - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'deu', - 'title' => 'Titel #1', - 'content' => 'Inhalt #1' - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('fields' => array('slug', 'title', 'content'))); - $expected = array( - array( - 'TranslatedItem' => array( - 'slug' => 'first_translated', - 'locale' => 'deu', - 'content' => 'Inhalt #1', - 'title' => 'Titel #1' - ) - ), - array( - 'TranslatedItem' => array( - 'slug' => 'second_translated', - 'locale' => 'deu', - 'title' => 'Titel #2', - 'content' => 'Inhalt #2' - ) - ), - array( - 'TranslatedItem' => array( - 'slug' => 'third_translated', - 'locale' => 'deu', - 'title' => 'Titel #3', - 'content' => 'Inhalt #3' - ) - ) - ); - - $this->assertEquals($expected, $result); - } - -/** - * testMissingTranslation method - * - * @return void - */ - public function testMissingTranslation() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'rus'; - $result = $TestModel->read(null, 1); - $this->assertFalse($result); - - $TestModel->locale = array('rus'); - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'rus', - 'title' => '', - 'content' => '' - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testTranslatedFindList method - * - * @return void - */ - public function testTranslatedFindList() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'deu'; - $TestModel->displayField = 'title'; - $result = $TestModel->find('list', array('recursive' => 1)); - $expected = array(1 => 'Titel #1', 2 => 'Titel #2', 3 => 'Titel #3'); - $this->assertEquals($expected, $result); - - // SQL Server trigger an error and stops the page even if the debug = 0 - if ($this->db instanceof Sqlserver) { - $debug = Configure::read('debug'); - Configure::write('debug', 0); - - $result = $TestModel->find('list', array('recursive' => 1, 'callbacks' => false)); - $this->assertEquals(array(), $result); - - $result = $TestModel->find('list', array('recursive' => 1, 'callbacks' => 'after')); - $this->assertEquals(array(), $result); - Configure::write('debug', $debug); - } - - $result = $TestModel->find('list', array('recursive' => 1, 'callbacks' => 'before')); - $expected = array(1 => null, 2 => null, 3 => null); - $this->assertEquals($expected, $result); - } - -/** - * testReadSelectedFields method - * - * @return void - */ - public function testReadSelectedFields() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'eng'; - $result = $TestModel->find('all', array('fields' => array('slug', 'TranslatedItem.content'))); - $expected = array( - array('TranslatedItem' => array('slug' => 'first_translated', 'locale' => 'eng', 'content' => 'Content #1')), - array('TranslatedItem' => array('slug' => 'second_translated', 'locale' => 'eng', 'content' => 'Content #2')), - array('TranslatedItem' => array('slug' => 'third_translated', 'locale' => 'eng', 'content' => 'Content #3')) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('fields' => array('TranslatedItem.slug', 'content'))); - $this->assertEquals($expected, $result); - - $TestModel->locale = array('eng', 'deu', 'cze'); - $delete = array(array('locale' => 'deu'), array('field' => 'content', 'locale' => 'eng')); - $I18nModel = ClassRegistry::getObject('TranslateTestModel'); - $I18nModel->deleteAll(array('or' => $delete)); - - $result = $TestModel->find('all', array('fields' => array('title', 'content'))); - $expected = array( - array('TranslatedItem' => array('locale' => 'eng', 'title' => 'Title #1', 'content' => 'Obsah #1')), - array('TranslatedItem' => array('locale' => 'eng', 'title' => 'Title #2', 'content' => 'Obsah #2')), - array('TranslatedItem' => array('locale' => 'eng', 'title' => 'Title #3', 'content' => 'Obsah #3')) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveCreate method - * - * @return void - */ - public function testSaveCreate() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'spa'; - $data = array('slug' => 'fourth_translated', 'title' => 'Leyenda #4', 'content' => 'Contenido #4'); - $TestModel->create($data); - $TestModel->save(); - $result = $TestModel->read(); - $expected = array('TranslatedItem' => array_merge($data, array('id' => $TestModel->id, 'locale' => 'spa'))); - $this->assertEquals($expected, $result); - } - -/** - * testSaveUpdate method - * - * @return void - */ - public function testSaveUpdate() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'spa'; - $oldData = array('slug' => 'fourth_translated', 'title' => 'Leyenda #4'); - $TestModel->create($oldData); - $TestModel->save(); - $id = $TestModel->id; - $newData = array('id' => $id, 'content' => 'Contenido #4'); - $TestModel->create($newData); - $TestModel->save(); - $result = $TestModel->read(null, $id); - $expected = array('TranslatedItem' => array_merge($oldData, $newData, array('locale' => 'spa'))); - $this->assertEquals($expected, $result); - } - -/** - * testMultipleCreate method - * - * @return void - */ - public function testMultipleCreate() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'deu'; - $data = array( - 'slug' => 'new_translated', - 'title' => array('eng' => 'New title', 'spa' => 'Nuevo leyenda'), - 'content' => array('eng' => 'New content', 'spa' => 'Nuevo contenido') - ); - $TestModel->create($data); - $TestModel->save(); - - $TestModel->unbindTranslation(); - $translations = array('title' => 'Title', 'content' => 'Content'); - $TestModel->bindTranslation($translations, false); - $TestModel->locale = array('eng', 'spa'); - - $result = $TestModel->read(); - $expected = array( - 'TranslatedItem' => array('id' => 4, 'slug' => 'new_translated', 'locale' => 'eng', 'title' => 'New title', 'content' => 'New content'), - 'Title' => array( - array('id' => 21, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 4, 'field' => 'title', 'content' => 'New title'), - array('id' => 22, 'locale' => 'spa', 'model' => 'TranslatedItem', 'foreign_key' => 4, 'field' => 'title', 'content' => 'Nuevo leyenda') - ), - 'Content' => array( - array('id' => 19, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 4, 'field' => 'content', 'content' => 'New content'), - array('id' => 20, 'locale' => 'spa', 'model' => 'TranslatedItem', 'foreign_key' => 4, 'field' => 'content', 'content' => 'Nuevo contenido') - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testMultipleUpdate method - * - * @return void - */ - public function testMultipleUpdate() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'eng'; - $TestModel->validate['title'] = 'notEmpty'; - $data = array('TranslatedItem' => array( - 'id' => 1, - 'title' => array('eng' => 'New Title #1', 'deu' => 'Neue Titel #1', 'cze' => 'Novy Titulek #1'), - 'content' => array('eng' => 'New Content #1', 'deu' => 'Neue Inhalt #1', 'cze' => 'Novy Obsah #1') - )); - $TestModel->create(); - $TestModel->save($data); - - $TestModel->unbindTranslation(); - $translations = array('title' => 'Title', 'content' => 'Content'); - $TestModel->bindTranslation($translations, false); - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedItem' => array('id' => '1', 'slug' => 'first_translated', 'locale' => 'eng', 'title' => 'New Title #1', 'content' => 'New Content #1'), - 'Title' => array( - array('id' => 1, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'New Title #1'), - array('id' => 3, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Neue Titel #1'), - array('id' => 5, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Novy Titulek #1') - ), - 'Content' => array( - array('id' => 2, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'New Content #1'), - array('id' => 4, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Neue Inhalt #1'), - array('id' => 6, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Novy Obsah #1') - ) - ); - $this->assertEquals($expected, $result); - - $TestModel->unbindTranslation($translations); - $TestModel->bindTranslation(array('title', 'content'), false); - } - -/** - * testMixedCreateUpdateWithArrayLocale method - * - * @return void - */ - public function testMixedCreateUpdateWithArrayLocale() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = array('cze', 'deu'); - $data = array('TranslatedItem' => array( - 'id' => 1, - 'title' => array('eng' => 'Updated Title #1', 'spa' => 'Nuevo leyenda #1'), - 'content' => 'Upraveny obsah #1' - )); - $TestModel->create(); - $TestModel->save($data); - - $TestModel->unbindTranslation(); - $translations = array('title' => 'Title', 'content' => 'Content'); - $TestModel->bindTranslation($translations, false); - $result = $TestModel->read(null, 1); - $result['Title'] = Set::sort($result['Title'], '{n}.id', 'asc'); - $result['Content'] = Set::sort($result['Content'], '{n}.id', 'asc'); - $expected = array( - 'TranslatedItem' => array('id' => 1, 'slug' => 'first_translated', 'locale' => 'cze', 'title' => 'Titulek #1', 'content' => 'Upraveny obsah #1'), - 'Title' => array( - array('id' => 1, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Updated Title #1'), - array('id' => 3, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titel #1'), - array('id' => 5, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titulek #1'), - array('id' => 19, 'locale' => 'spa', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Nuevo leyenda #1') - ), - 'Content' => array( - array('id' => 2, 'locale' => 'eng', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Content #1'), - array('id' => 4, 'locale' => 'deu', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Inhalt #1'), - array('id' => 6, 'locale' => 'cze', 'model' => 'TranslatedItem', 'foreign_key' => 1, 'field' => 'content', 'content' => 'Upraveny obsah #1') - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testValidation method - * - * @return void - */ - public function testValidation() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->locale = 'eng'; - $TestModel->validate['title'] = '/Only this title/'; - $data = array('TranslatedItem' => array( - 'id' => 1, - 'title' => array('eng' => 'New Title #1', 'deu' => 'Neue Titel #1', 'cze' => 'Novy Titulek #1'), - 'content' => array('eng' => 'New Content #1', 'deu' => 'Neue Inhalt #1', 'cze' => 'Novy Obsah #1') - )); - $TestModel->create(); - $this->assertFalse($TestModel->save($data)); - $this->assertEquals(array('This field cannot be left blank'), $TestModel->validationErrors['title']); - - $TestModel->locale = 'eng'; - $TestModel->validate['title'] = '/Only this title/'; - $data = array('TranslatedItem' => array( - 'id' => 1, - 'title' => array('eng' => 'Only this title', 'deu' => 'Neue Titel #1', 'cze' => 'Novy Titulek #1'), - 'content' => array('eng' => 'New Content #1', 'deu' => 'Neue Inhalt #1', 'cze' => 'Novy Obsah #1') - )); - $TestModel->create(); - $result = $TestModel->save($data); - $this->assertFalse(empty($result)); - } - -/** - * testAttachDetach method - * - * @return void - */ - public function testAttachDetach() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - - $TestModel->unbindTranslation(); - $translations = array('title' => 'Title', 'content' => 'Content'); - $TestModel->bindTranslation($translations, false); - - $result = array_keys($TestModel->hasMany); - $expected = array('Title', 'Content'); - $this->assertEquals($expected, $result); - - $TestModel->Behaviors->detach('Translate'); - $result = array_keys($TestModel->hasMany); - $expected = array(); - $this->assertEquals($expected, $result); - - $result = isset($TestModel->Behaviors->Translate); - $this->assertFalse($result); - - $result = isset($Behavior->settings[$TestModel->alias]); - $this->assertFalse($result); - - $result = isset($Behavior->runtime[$TestModel->alias]); - $this->assertFalse($result); - - $TestModel->Behaviors->attach('Translate', array('title' => 'Title', 'content' => 'Content')); - $result = array_keys($TestModel->hasMany); - $expected = array('Title', 'Content'); - $this->assertEquals($expected, $result); - - $result = isset($TestModel->Behaviors->Translate); - $this->assertTrue($result); - - $Behavior = $TestModel->Behaviors->Translate; - - $result = isset($Behavior->settings[$TestModel->alias]); - $this->assertTrue($result); - - $result = isset($Behavior->runtime[$TestModel->alias]); - $this->assertTrue($result); - } - -/** - * testAnotherTranslateTable method - * - * @return void - */ - public function testAnotherTranslateTable() { - $this->loadFixtures('Translate', 'TranslatedItem', 'TranslateTable'); - - $TestModel = new TranslatedItemWithTable(); - $TestModel->locale = 'eng'; - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedItemWithTable' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'eng', - 'title' => 'Another Title #1', - 'content' => 'Another Content #1' - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testTranslateWithAssociations method - * - * @return void - */ - public function testTranslateWithAssociations() { - $this->loadFixtures('TranslateArticle', 'TranslatedArticle', 'User', 'Comment', 'ArticlesTag', 'Tag'); - - $TestModel = new TranslatedArticle(); - $TestModel->locale = 'eng'; - $recursive = $TestModel->recursive; - - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedArticle' => array( - 'id' => 1, - 'user_id' => 1, - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'locale' => 'eng', - 'title' => 'Title (eng) #1', - 'body' => 'Body (eng) #1' - ), - 'User' => array( - 'id' => 1, - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('recursive' => -1)); - $expected = array( - array( - 'TranslatedArticle' => array( - 'id' => 1, - 'user_id' => 1, - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'locale' => 'eng', - 'title' => 'Title (eng) #1', - 'body' => 'Body (eng) #1' - ) - ), - array( - 'TranslatedArticle' => array( - 'id' => 2, - 'user_id' => 3, - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31', - 'locale' => 'eng', - 'title' => 'Title (eng) #2', - 'body' => 'Body (eng) #2' - ) - ), - array( - 'TranslatedArticle' => array( - 'id' => 3, - 'user_id' => 1, - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31', - 'locale' => 'eng', - 'title' => 'Title (eng) #3', - 'body' => 'Body (eng) #3' - ) - ) - ); - $this->assertEquals($expected, $result); - $this->assertEquals($TestModel->recursive, $recursive); - - $TestModel->recursive = -1; - $result = $TestModel->read(null, 1); - $expected = array( - 'TranslatedArticle' => array( - 'id' => 1, - 'user_id' => 1, - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'locale' => 'eng', - 'title' => 'Title (eng) #1', - 'body' => 'Body (eng) #1' - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testTranslateTableWithPrefix method - * Tests that is possible to have a translation model with a custom tablePrefix - * - * @return void - */ - public function testTranslateTableWithPrefix() { - $this->loadFixtures('TranslateWithPrefix', 'TranslatedItem'); - $TestModel = new TranslatedItem2; - $TestModel->locale = 'eng'; - $result = $TestModel->read(null, 1); - $expected = array('TranslatedItem' => array( - 'id' => 1, - 'slug' => 'first_translated', - 'locale' => 'eng', - 'content' => 'Content #1', - 'title' => 'Title #1' - )); - $this->assertEquals($expected, $result); - } - -/** - * Test infinite loops not occurring with unbindTranslation() - * - * @return void - */ - public function testUnbindTranslationInfinteLoop() { - $this->loadFixtures('Translate', 'TranslatedItem'); - - $TestModel = new TranslatedItem(); - $TestModel->Behaviors->detach('Translate'); - $TestModel->actsAs = array(); - $TestModel->Behaviors->attach('Translate'); - $TestModel->bindTranslation(array('title', 'content'), true); - $result = $TestModel->unbindTranslation(); - - $this->assertFalse($result); - } - -/** - * Test that an exception is raised when you try to over-write the name attribute. - * - * @expectedException CakeException - * @return void - */ - public function testExceptionOnNameTranslation() { - $this->loadFixtures('Translate', 'TranslatedItem'); - $TestModel = new TranslatedItem(); - $TestModel->bindTranslation(array('name' => 'name')); - } -} diff --git a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorAfterTest.php b/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorAfterTest.php deleted file mode 100644 index 86077a92c7e..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorAfterTest.php +++ /dev/null @@ -1,77 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Behavior - * @since CakePHP(tm) v 1.2.0.5330 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -require_once dirname(dirname(__FILE__)) . DS . 'models.php'; - - -/** - * TreeBehaviorAfterTest class - * - * @package Cake.Test.Case.Model.Behavior - */ -class TreeBehaviorAfterTest extends CakeTestCase { - -/** - * Whether backup global state for each test method or not - * - * @var bool false - */ - public $backupGlobals = false; - -/** - * settings property - * - * @var array - */ - public $settings = array( - 'modelClass' => 'AfterTree', - 'leftField' => 'lft', - 'rightField' => 'rght', - 'parentField' => 'parent_id' - ); - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.after_tree'); - -/** - * Tests the afterSave callback in the model - * - * @return void - */ - public function testAftersaveCallback() { - $this->Tree = new AfterTree(); - - $expected = array('AfterTree' => array('name' => 'Six and One Half Changed in AfterTree::afterSave() but not in database', 'parent_id' => 6, 'lft' => 11, 'rght' => 12)); - $result = $this->Tree->save(array('AfterTree' => array('name' => 'Six and One Half', 'parent_id' => 6))); - $expected['AfterTree']['id'] = $this->Tree->id; - $this->assertEquals($expected, $result); - - $expected = array('AfterTree' => array('name' => 'Six and One Half', 'parent_id' => 6, 'lft' => 11, 'rght' => 12, 'id' => 8)); - $result = $this->Tree->find('all'); - $this->assertEquals($expected, $result[7]); - } -} - - diff --git a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorNumberTest.php b/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorNumberTest.php deleted file mode 100644 index ab78edf9e33..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorNumberTest.php +++ /dev/null @@ -1,1367 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Behavior - * @since CakePHP(tm) v 1.2.0.5330 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -require_once dirname(dirname(__FILE__)) . DS . 'models.php'; - -/** - * TreeBehaviorNumberTest class - * - * @package Cake.Test.Case.Model.Behavior - */ -class TreeBehaviorNumberTest extends CakeTestCase { - -/** - * Whether backup global state for each test method or not - * - * @var bool false - */ - public $backupGlobals = false; - -/** - * settings property - * - * @var array - */ - public $settings = array( - 'modelClass' => 'NumberTree', - 'leftField' => 'lft', - 'rightField' => 'rght', - 'parentField' => 'parent_id' - ); - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.number_tree', 'core.person'); - -/** - * testInitialize method - * - * @return void - */ - public function testInitialize() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->find('count'); - $this->assertEquals(7, $result); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testDetectInvalidLeft method - * - * @return void - */ - public function testDetectInvalidLeft() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->findByName('1.1'); - - $save[$modelClass]['id'] = $result[$modelClass]['id']; - $save[$modelClass][$leftField] = 0; - - $this->Tree->save($save); - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover(); - $this->assertSame($result, true); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - } - -/** - * testDetectInvalidRight method - * - * @return void - */ - public function testDetectInvalidRight() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->findByName('1.1'); - - $save[$modelClass]['id'] = $result[$modelClass]['id']; - $save[$modelClass][$rightField] = 0; - - $this->Tree->save($save); - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover(); - $this->assertSame($result, true); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - } - -/** - * testDetectInvalidParent method - * - * @return void - */ - public function testDetectInvalidParent() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->findByName('1.1'); - - // Bypass behavior and any other logic - $this->Tree->updateAll(array($parentField => null), array('id' => $result[$modelClass]['id'])); - - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover(); - $this->assertSame($result, true); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - } - -/** - * testDetectNoneExistentParent method - * - * @return void - */ - public function testDetectNoneExistentParent() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->findByName('1.1'); - $this->Tree->updateAll(array($parentField => 999999), array('id' => $result[$modelClass]['id'])); - - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover('MPTT'); - $this->assertSame($result, true); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - } - -/** - * testRecoverUsingParentMode method - * - * @return void - */ - public function testRecoverUsingParentMode() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->Behaviors->disable('Tree'); - - $this->Tree->save(array('parent_id' => null, 'name' => 'Main', $parentField => null, $leftField => 0, $rightField => 0)); - $node1 = $this->Tree->id; - - $this->Tree->create(); - $this->Tree->save(array('parent_id' => null, 'name' => 'About Us', $parentField => $node1, $leftField => 0, $rightField => 0)); - $node11 = $this->Tree->id; - $this->Tree->create(); - $this->Tree->save(array('parent_id' => null, 'name' => 'Programs', $parentField => $node1, $leftField => 0, $rightField => 0)); - $node12 = $this->Tree->id; - $this->Tree->create(); - $this->Tree->save(array('parent_id' => null, 'name' => 'Mission and History', $parentField => $node11, $leftField => 0, $rightField => 0)); - $this->Tree->create(); - $this->Tree->save(array('parent_id' => null, 'name' => 'Overview', $parentField => $node12, $leftField => 0, $rightField => 0)); - - $this->Tree->Behaviors->enable('Tree'); - - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover(); - $this->assertTrue($result); - - $result = $this->Tree->verify(); - $this->assertTrue($result); - - $result = $this->Tree->find('first', array( - 'fields' => array('name', $parentField, $leftField, $rightField), - 'conditions' => array('name' => 'Main'), - 'recursive' => -1 - )); - $expected = array( - $modelClass => array( - 'name' => 'Main', - $parentField => null, - $leftField => 1, - $rightField => 10 - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testRecoverFromMissingParent method - * - * @return void - */ - public function testRecoverFromMissingParent() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->findByName('1.1'); - $this->Tree->updateAll(array($parentField => 999999), array('id' => $result[$modelClass]['id'])); - - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover(); - $this->assertSame($result, true); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - } - -/** - * testDetectInvalidParents method - * - * @return void - */ - public function testDetectInvalidParents() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $this->Tree->updateAll(array($parentField => null)); - - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover(); - $this->assertSame($result, true); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - } - -/** - * testDetectInvalidLftsRghts method - * - * @return void - */ - public function testDetectInvalidLftsRghts() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $this->Tree->updateAll(array($leftField => 0, $rightField => 0)); - - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $this->Tree->recover(); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - } - -/** - * Reproduces a situation where a single node has lft= rght, and all other lft and rght fields follow sequentially - * - * @return void - */ - public function testDetectEqualLftsRghts() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(1, 3); - - $result = $this->Tree->findByName('1.1'); - $this->Tree->updateAll(array($rightField => $result[$modelClass][$leftField]), array('id' => $result[$modelClass]['id'])); - $this->Tree->updateAll(array($leftField => $this->Tree->escapeField($leftField) . ' -1'), - array($leftField . ' >' => $result[$modelClass][$leftField])); - $this->Tree->updateAll(array($rightField => $this->Tree->escapeField($rightField) . ' -1'), - array($rightField . ' >' => $result[$modelClass][$leftField])); - - $result = $this->Tree->verify(); - $this->assertNotSame($result, true); - - $result = $this->Tree->recover(); - $this->assertTrue($result); - - $result = $this->Tree->verify(); - $this->assertTrue($result); - } - -/** - * testAddOrphan method - * - * @return void - */ - public function testAddOrphan() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $this->Tree->save(array($modelClass => array('name' => 'testAddOrphan', $parentField => null))); - $result = $this->Tree->find('first', array('fields' => array('name', $parentField), 'order' => $modelClass . '.' . $leftField . ' desc')); - $expected = array($modelClass => array('name' => 'testAddOrphan', $parentField => null)); - $this->assertEquals($expected, $result); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testAddMiddle method - * - * @return void - */ - public function testAddMiddle() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1'))); - $initialCount = $this->Tree->find('count'); - - $this->Tree->create(); - $result = $this->Tree->save(array($modelClass => array('name' => 'testAddMiddle', $parentField => $data[$modelClass]['id']))); - $expected = array_merge(array($modelClass => array('name' => 'testAddMiddle', $parentField => '2')), $result); - $this->assertSame($expected, $result); - - $laterCount = $this->Tree->find('count'); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount + 1, $laterCount); - - $children = $this->Tree->children($data[$modelClass]['id'], true, array('name')); - $expects = array(array($modelClass => array('name' => '1.1.1')), - array($modelClass => array('name' => '1.1.2')), - array($modelClass => array('name' => 'testAddMiddle'))); - $this->assertSame($children, $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testAddInvalid method - * - * @return void - */ - public function testAddInvalid() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $initialCount = $this->Tree->find('count'); - //$this->expectError('Trying to save a node under a none-existant node in TreeBehavior::beforeSave'); - - $saveSuccess = $this->Tree->save(array($modelClass => array('name' => 'testAddInvalid', $parentField => 99999))); - $this->assertSame($saveSuccess, false); - - $laterCount = $this->Tree->find('count'); - $this->assertSame($initialCount, $laterCount); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testAddNotIndexedByModel method - * - * @return void - */ - public function testAddNotIndexedByModel() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $this->Tree->save(array('name' => 'testAddNotIndexed', $parentField => null)); - $result = $this->Tree->find('first', array('fields' => array('name', $parentField), 'order' => $modelClass . '.' . $leftField . ' desc')); - $expected = array($modelClass => array('name' => 'testAddNotIndexed', $parentField => null)); - $this->assertEquals($expected, $result); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMovePromote method - * - * @return void - */ - public function testMovePromote() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $parent = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $parentId = $parent[$modelClass]['id']; - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1.1'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->saveField($parentField, $parentId); - $direct = $this->Tree->children($parentId, true, array('id', 'name', $parentField, $leftField, $rightField)); - $expects = array(array($modelClass => array('id' => 2, 'name' => '1.1', $parentField => 1, $leftField => 2, $rightField => 5)), - array($modelClass => array('id' => 5, 'name' => '1.2', $parentField => 1, $leftField => 6, $rightField => 11)), - array($modelClass => array('id' => 3, 'name' => '1.1.1', $parentField => 1, $leftField => 12, $rightField => 13))); - $this->assertEquals($direct, $expects); - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMoveWithWhitelist method - * - * @return void - */ - public function testMoveWithWhitelist() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $parent = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $parentId = $parent[$modelClass]['id']; - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1.1'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->whitelist = array($parentField, 'name', 'description'); - $this->Tree->saveField($parentField, $parentId); - - $result = $this->Tree->children($parentId, true, array('id', 'name', $parentField, $leftField, $rightField)); - $expected = array(array($modelClass => array('id' => 2, 'name' => '1.1', $parentField => 1, $leftField => 2, $rightField => 5)), - array($modelClass => array('id' => 5, 'name' => '1.2', $parentField => 1, $leftField => 6, $rightField => 11)), - array($modelClass => array('id' => 3, 'name' => '1.1.1', $parentField => 1, $leftField => 12, $rightField => 13))); - $this->assertEquals($expected, $result); - $this->assertTrue($this->Tree->verify()); - } - -/** - * testInsertWithWhitelist method - * - * @return void - */ - public function testInsertWithWhitelist() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $this->Tree->whitelist = array('name', $parentField); - $this->Tree->save(array($modelClass => array('name' => 'testAddOrphan', $parentField => null))); - $result = $this->Tree->findByName('testAddOrphan', array('name', $parentField, $leftField, $rightField)); - $expected = array('name' => 'testAddOrphan', $parentField => null, $leftField => '15', $rightField => 16); - $this->assertEquals($expected, $result[$modelClass]); - $this->assertSame($this->Tree->verify(), true); - } - -/** - * testMoveBefore method - * - * @return void - */ - public function testMoveBefore() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $parent = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1.1'))); - $parentId = $parent[$modelClass]['id']; - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.2'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->saveField($parentField, $parentId); - - $result = $this->Tree->children($parentId, true, array('name')); - $expects = array(array($modelClass => array('name' => '1.1.1')), - array($modelClass => array('name' => '1.1.2')), - array($modelClass => array('name' => '1.2'))); - $this->assertEquals($expects, $result); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMoveAfter method - * - * @return void - */ - public function testMoveAfter() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $parent = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1.2'))); - $parentId = $parent[$modelClass]['id']; - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->saveField($parentField, $parentId); - - $result = $this->Tree->children($parentId, true, array('name')); - $expects = array(array($modelClass => array('name' => '1.2.1')), - array($modelClass => array('name' => '1.2.2')), - array($modelClass => array('name' => '1.1'))); - $this->assertEquals($expects, $result); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMoveDemoteInvalid method - * - * @return void - */ - public function testMoveDemoteInvalid() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $parent = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $parentId = $parent[$modelClass]['id']; - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1.1'))); - - $expects = $this->Tree->find('all'); - $before = $this->Tree->read(null, $data[$modelClass]['id']); - - $this->Tree->id = $parentId; - $this->Tree->saveField($parentField, $data[$modelClass]['id']); - - $results = $this->Tree->find('all'); - $after = $this->Tree->read(null, $data[$modelClass]['id']); - - $this->assertEquals($expects, $results); - $this->assertEquals($before, $after); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMoveInvalid method - * - * @return void - */ - public function testMoveInvalid() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $initialCount = $this->Tree->find('count'); - $data = $this->Tree->findByName('1.1'); - - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->saveField($parentField, 999999); - - $laterCount = $this->Tree->find('count'); - $this->assertSame($initialCount, $laterCount); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMoveSelfInvalid method - * - * @return void - */ - public function testMoveSelfInvalid() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $initialCount = $this->Tree->find('count'); - $data = $this->Tree->findByName('1.1'); - - $this->Tree->id = $data[$modelClass]['id']; - $saveSuccess = $this->Tree->saveField($parentField, $this->Tree->id); - - $this->assertSame($saveSuccess, false); - $laterCount = $this->Tree->find('count'); - $this->assertSame($initialCount, $laterCount); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMoveUpSuccess method - * - * @return void - */ - public function testMoveUpSuccess() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.2'))); - $this->Tree->moveUp($data[$modelClass]['id']); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array(array($modelClass => array('name' => '1.2', )), - array($modelClass => array('name' => '1.1', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveUpFail method - * - * @return void - */ - public function testMoveUpFail() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1.1'))); - - $this->Tree->moveUp($data[$modelClass]['id']); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array(array($modelClass => array('name' => '1.1', )), - array($modelClass => array('name' => '1.2', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveUp2 method - * - * @return void - */ - public function testMoveUp2() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(1, 10); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.5'))); - $this->Tree->moveUp($data[$modelClass]['id'], 2); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array( - array($modelClass => array('name' => '1.1', )), - array($modelClass => array('name' => '1.2', )), - array($modelClass => array('name' => '1.5', )), - array($modelClass => array('name' => '1.3', )), - array($modelClass => array('name' => '1.4', )), - array($modelClass => array('name' => '1.6', )), - array($modelClass => array('name' => '1.7', )), - array($modelClass => array('name' => '1.8', )), - array($modelClass => array('name' => '1.9', )), - array($modelClass => array('name' => '1.10', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveUpFirst method - * - * @return void - */ - public function testMoveUpFirst() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(1, 10); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.5'))); - $this->Tree->moveUp($data[$modelClass]['id'], true); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array( - array($modelClass => array('name' => '1.5', )), - array($modelClass => array('name' => '1.1', )), - array($modelClass => array('name' => '1.2', )), - array($modelClass => array('name' => '1.3', )), - array($modelClass => array('name' => '1.4', )), - array($modelClass => array('name' => '1.6', )), - array($modelClass => array('name' => '1.7', )), - array($modelClass => array('name' => '1.8', )), - array($modelClass => array('name' => '1.9', )), - array($modelClass => array('name' => '1.10', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveDownSuccess method - * - * @return void - */ - public function testMoveDownSuccess() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1'))); - $this->Tree->moveDown($data[$modelClass]['id']); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array(array($modelClass => array('name' => '1.2', )), - array($modelClass => array('name' => '1.1', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveDownFail method - * - * @return void - */ - public function testMoveDownFail() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1.2'))); - $this->Tree->moveDown($data[$modelClass]['id']); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array(array($modelClass => array('name' => '1.1', )), - array($modelClass => array('name' => '1.2', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveDownLast method - * - * @return void - */ - public function testMoveDownLast() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(1, 10); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.5'))); - $this->Tree->moveDown($data[$modelClass]['id'], true); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array( - array($modelClass => array('name' => '1.1', )), - array($modelClass => array('name' => '1.2', )), - array($modelClass => array('name' => '1.3', )), - array($modelClass => array('name' => '1.4', )), - array($modelClass => array('name' => '1.6', )), - array($modelClass => array('name' => '1.7', )), - array($modelClass => array('name' => '1.8', )), - array($modelClass => array('name' => '1.9', )), - array($modelClass => array('name' => '1.10', )), - array($modelClass => array('name' => '1.5', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveDown2 method - * - * @return void - */ - public function testMoveDown2() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(1, 10); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.5'))); - $this->Tree->moveDown($data[$modelClass]['id'], 2); - - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array( - array($modelClass => array('name' => '1.1', )), - array($modelClass => array('name' => '1.2', )), - array($modelClass => array('name' => '1.3', )), - array($modelClass => array('name' => '1.4', )), - array($modelClass => array('name' => '1.6', )), - array($modelClass => array('name' => '1.7', )), - array($modelClass => array('name' => '1.5', )), - array($modelClass => array('name' => '1.8', )), - array($modelClass => array('name' => '1.9', )), - array($modelClass => array('name' => '1.10', ))); - $this->assertSame($expected, $result); - } - -/** - * testSaveNoMove method - * - * @return void - */ - public function testSaveNoMove() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(1, 10); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.5'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->saveField('name', 'renamed'); - $parent = $this->Tree->findByName('1. Root', array('id')); - $this->Tree->id = $parent[$modelClass]['id']; - $result = $this->Tree->children(null, true, array('name')); - $expected = array( - array($modelClass => array('name' => '1.1', )), - array($modelClass => array('name' => '1.2', )), - array($modelClass => array('name' => '1.3', )), - array($modelClass => array('name' => '1.4', )), - array($modelClass => array('name' => 'renamed', )), - array($modelClass => array('name' => '1.6', )), - array($modelClass => array('name' => '1.7', )), - array($modelClass => array('name' => '1.8', )), - array($modelClass => array('name' => '1.9', )), - array($modelClass => array('name' => '1.10', ))); - $this->assertSame($expected, $result); - } - -/** - * testMoveToRootAndMoveUp method - * - * @return void - */ - public function testMoveToRootAndMoveUp() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(1, 1); - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->save(array($parentField => null)); - - $result = $this->Tree->verify(); - $this->assertSame($result, true); - - $this->Tree->moveUp(); - - $result = $this->Tree->find('all', array('fields' => 'name', 'order' => $modelClass . '.' . $leftField . ' ASC')); - $expected = array(array($modelClass => array('name' => '1.1')), - array($modelClass => array('name' => '1. Root'))); - $this->assertSame($expected, $result); - } - -/** - * testDelete method - * - * @return void - */ - public function testDelete() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $initialCount = $this->Tree->find('count'); - $result = $this->Tree->findByName('1.1.1'); - - $return = $this->Tree->delete($result[$modelClass]['id']); - $this->assertEquals(true, $return); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount - 1, $laterCount); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - - $initialCount = $this->Tree->find('count'); - $result = $this->Tree->findByName('1.1'); - - $return = $this->Tree->delete($result[$modelClass]['id']); - $this->assertEquals(true, $return); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount - 2, $laterCount); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testRemove method - * - * @return void - */ - public function testRemove() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $initialCount = $this->Tree->find('count'); - $result = $this->Tree->findByName('1.1'); - - $this->Tree->removeFromTree($result[$modelClass]['id']); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount, $laterCount); - - $children = $this->Tree->children($result[$modelClass][$parentField], true, array('name')); - $expects = array(array($modelClass => array('name' => '1.1.1')), - array($modelClass => array('name' => '1.1.2')), - array($modelClass => array('name' => '1.2'))); - $this->assertEquals($children, $expects); - - $topNodes = $this->Tree->children(false, true,array('name')); - $expects = array(array($modelClass => array('name' => '1. Root')), - array($modelClass => array('name' => '1.1'))); - $this->assertEquals($topNodes, $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testRemoveLastTopParent method - * - * @return void - */ - public function testRemoveLastTopParent() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $initialCount = $this->Tree->find('count'); - $initialTopNodes = $this->Tree->childCount(false); - - $result = $this->Tree->findByName('1. Root'); - $this->Tree->removeFromTree($result[$modelClass]['id']); - - $laterCount = $this->Tree->find('count'); - $laterTopNodes = $this->Tree->childCount(false); - - $this->assertEquals($initialCount, $laterCount); - $this->assertEquals($initialTopNodes, $laterTopNodes); - - $topNodes = $this->Tree->children(false, true,array('name')); - $expects = array(array($modelClass => array('name' => '1.1')), - array($modelClass => array('name' => '1.2')), - array($modelClass => array('name' => '1. Root'))); - - $this->assertEquals($topNodes, $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testRemoveNoChildren method - * - * @return void - */ - public function testRemoveNoChildren() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $initialCount = $this->Tree->find('count'); - - $result = $this->Tree->findByName('1.1.1'); - $this->Tree->removeFromTree($result[$modelClass]['id']); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount, $laterCount); - - $nodes = $this->Tree->find('list', array('order' => $leftField)); - $expects = array( - 1 => '1. Root', - 2 => '1.1', - 4 => '1.1.2', - 5 => '1.2', - 6 => '1.2.1', - 7 => '1.2.2', - 3 => '1.1.1', - ); - - $this->assertEquals($nodes, $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testRemoveAndDelete method - * - * @return void - */ - public function testRemoveAndDelete() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $initialCount = $this->Tree->find('count'); - $result = $this->Tree->findByName('1.1'); - - $this->Tree->removeFromTree($result[$modelClass]['id'], true); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount - 1, $laterCount); - - $children = $this->Tree->children($result[$modelClass][$parentField], true, array('name'), $leftField . ' asc'); - $expects = array( - array($modelClass => array('name' => '1.1.1')), - array($modelClass => array('name' => '1.1.2')), - array($modelClass => array('name' => '1.2')) - ); - $this->assertEquals($children, $expects); - - $topNodes = $this->Tree->children(false, true,array('name')); - $expects = array(array($modelClass => array('name' => '1. Root'))); - $this->assertEquals($topNodes, $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testRemoveAndDeleteNoChildren method - * - * @return void - */ - public function testRemoveAndDeleteNoChildren() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $initialCount = $this->Tree->find('count'); - - $result = $this->Tree->findByName('1.1.1'); - $this->Tree->removeFromTree($result[$modelClass]['id'], true); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount - 1, $laterCount); - - $nodes = $this->Tree->find('list', array('order' => $leftField)); - $expects = array( - 1 => '1. Root', - 2 => '1.1', - 4 => '1.1.2', - 5 => '1.2', - 6 => '1.2.1', - 7 => '1.2.2', - ); - $this->assertEquals($nodes, $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testChildren method - * - * @return void - */ - public function testChildren() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $this->Tree->id = $data[$modelClass]['id']; - - $direct = $this->Tree->children(null, true, array('id', 'name', $parentField, $leftField, $rightField)); - $expects = array(array($modelClass => array('id' => 2, 'name' => '1.1', $parentField => 1, $leftField => 2, $rightField => 7)), - array($modelClass => array('id' => 5, 'name' => '1.2', $parentField => 1, $leftField => 8, $rightField => 13))); - $this->assertEquals($direct, $expects); - - $total = $this->Tree->children(null, null, array('id', 'name', $parentField, $leftField, $rightField)); - $expects = array(array($modelClass => array('id' => 2, 'name' => '1.1', $parentField => 1, $leftField => 2, $rightField => 7)), - array($modelClass => array('id' => 3, 'name' => '1.1.1', $parentField => 2, $leftField => 3, $rightField => 4)), - array($modelClass => array('id' => 4, 'name' => '1.1.2', $parentField => 2, $leftField => 5, $rightField => 6)), - array($modelClass => array('id' => 5, 'name' => '1.2', $parentField => 1, $leftField => 8, $rightField => 13)), - array($modelClass => array('id' => 6, 'name' => '1.2.1', $parentField => 5, $leftField => 9, $rightField => 10)), - array($modelClass => array('id' => 7, 'name' => '1.2.2', $parentField => 5, $leftField => 11, $rightField => 12))); - $this->assertEquals($total, $expects); - - $this->assertEquals(array(), $this->Tree->children(10000)); - } - -/** - * testCountChildren method - * - * @return void - */ - public function testCountChildren() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $this->Tree->id = $data[$modelClass]['id']; - - $direct = $this->Tree->childCount(null, true); - $this->assertEquals(2, $direct); - - $total = $this->Tree->childCount(); - $this->assertEquals(6, $total); - - $this->Tree->read(null, $data[$modelClass]['id']); - $id = $this->Tree->field('id', array($modelClass . '.name' => '1.2')); - $total = $this->Tree->childCount($id); - $this->assertEquals(2, $total); - } - -/** - * testGetParentNode method - * - * @return void - */ - public function testGetParentNode() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1.2.2'))); - $this->Tree->id = $data[$modelClass]['id']; - - $result = $this->Tree->getParentNode(null, array('name')); - $expects = array($modelClass => array('name' => '1.2')); - $this->assertSame($expects, $result); - } - -/** - * testGetPath method - * - * @return void - */ - public function testGetPath() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1.2.2'))); - $this->Tree->id = $data[$modelClass]['id']; - - $result = $this->Tree->getPath(null, array('name')); - $expects = array(array($modelClass => array('name' => '1. Root')), - array($modelClass => array('name' => '1.2')), - array($modelClass => array('name' => '1.2.2'))); - $this->assertSame($expects, $result); - } - -/** - * testNoAmbiguousColumn method - * - * @return void - */ - public function testNoAmbiguousColumn() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->bindModel(array('belongsTo' => array('Dummy' => - array('className' => $modelClass, 'foreignKey' => $parentField, 'conditions' => array('Dummy.id' => null)))), false); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $this->Tree->id = $data[$modelClass]['id']; - - $direct = $this->Tree->children(null, true, array('id', 'name', $parentField, $leftField, $rightField)); - $expects = array(array($modelClass => array('id' => 2, 'name' => '1.1', $parentField => 1, $leftField => 2, $rightField => 7)), - array($modelClass => array('id' => 5, 'name' => '1.2', $parentField => 1, $leftField => 8, $rightField => 13))); - $this->assertEquals($direct, $expects); - - $total = $this->Tree->children(null, null, array('id', 'name', $parentField, $leftField, $rightField)); - $expects = array( - array($modelClass => array('id' => 2, 'name' => '1.1', $parentField => 1, $leftField => 2, $rightField => 7)), - array($modelClass => array('id' => 3, 'name' => '1.1.1', $parentField => 2, $leftField => 3, $rightField => 4)), - array($modelClass => array('id' => 4, 'name' => '1.1.2', $parentField => 2, $leftField => 5, $rightField => 6)), - array($modelClass => array('id' => 5, 'name' => '1.2', $parentField => 1, $leftField => 8, $rightField => 13)), - array($modelClass => array('id' => 6, 'name' => '1.2.1', $parentField => 5, $leftField => 9, $rightField => 10)), - array($modelClass => array('id' => 7, 'name' => '1.2.2', $parentField => 5, $leftField => 11, $rightField => 12)) - ); - $this->assertEquals($total, $expects); - } - -/** - * testReorderTree method - * - * @return void - */ - public function testReorderTree() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(3, 3); - $nodes = $this->Tree->find('list', array('order' => $leftField)); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1'))); - $this->Tree->moveDown($data[$modelClass]['id']); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.2.1'))); - $this->Tree->moveDown($data[$modelClass]['id']); - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.3.2.2'))); - $this->Tree->moveDown($data[$modelClass]['id']); - - $unsortedNodes = $this->Tree->find('list', array('order' => $leftField)); - $this->assertEquals($nodes, $unsortedNodes); - $this->assertNotEquals(array_keys($nodes), array_keys($unsortedNodes)); - - $this->Tree->reorder(); - $sortedNodes = $this->Tree->find('list', array('order' => $leftField)); - $this->assertSame($nodes, $sortedNodes); - } - -/** - * test reordering large-ish trees with cacheQueries = true. - * This caused infinite loops when moving down elements as stale data is returned - * from the memory cache - * - * @return void - */ - public function testReorderBigTreeWithQueryCaching() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 10); - - $original = $this->Tree->cacheQueries; - $this->Tree->cacheQueries = true; - $this->Tree->reorder(array('field' => 'name', 'direction' => 'DESC')); - $this->assertTrue($this->Tree->cacheQueries, 'cacheQueries was not restored after reorder(). %s'); - $this->Tree->cacheQueries = $original; - } - -/** - * testGenerateTreeListWithSelfJoin method - * - * @return void - */ - public function testGenerateTreeListWithSelfJoin() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->bindModel(array('belongsTo' => array('Dummy' => - array('className' => $modelClass, 'foreignKey' => $parentField, 'conditions' => array('Dummy.id' => null)))), false); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->generateTreeList(); - $expected = array(1 => '1. Root', 2 => '_1.1', 3 => '__1.1.1', 4 => '__1.1.2', 5 => '_1.2', 6 => '__1.2.1', 7 => '__1.2.2'); - $this->assertSame($expected, $result); - } - -/** - * testArraySyntax method - * - * @return void - */ - public function testArraySyntax() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(3, 3); - $this->assertSame($this->Tree->childCount(2), $this->Tree->childCount(array('id' => 2))); - $this->assertSame($this->Tree->getParentNode(2), $this->Tree->getParentNode(array('id' => 2))); - $this->assertSame($this->Tree->getPath(4), $this->Tree->getPath(array('id' => 4))); - } - -/** - * testFindThreaded method - * - * @return void - */ - public function testFindThreaded() { - $Model = new Person(); - $Model->recursive = -1; - $Model->Behaviors->attach('Tree', array('parent' => 'mother_id')); - - $result = $Model->find('threaded'); - $expected = array( - array( - 'Person' => array( - 'id' => '4', - 'name' => 'mother - grand mother', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array( - array( - 'Person' => array( - 'id' => '2', - 'name' => 'mother', - 'mother_id' => '4', - 'father_id' => '5' - ), - 'children' => array( - array( - 'Person' => array( - 'id' => '1', - 'name' => 'person', - 'mother_id' => '2', - 'father_id' => '3' - ), - 'children' => array() - ) - ) - ) - ) - ), - array( - 'Person' => array( - 'id' => '5', - 'name' => 'mother - grand father', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array() - ), - array( - 'Person' => array( - 'id' => '6', - 'name' => 'father - grand mother', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array( - array( - 'Person' => array( - 'id' => '3', - 'name' => 'father', - 'mother_id' => '6', - 'father_id' => '7' - ), - 'children' => array() - ) - ) - ), - array( - 'Person' => array( - 'id' => '7', - 'name' => 'father - grand father', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array() - ) - ); - $this->assertEquals($expected, $result); - } -} diff --git a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorScopedTest.php b/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorScopedTest.php deleted file mode 100644 index 6f9163a0498..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorScopedTest.php +++ /dev/null @@ -1,316 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Behavior - * @since CakePHP(tm) v 1.2.0.5330 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -require_once dirname(dirname(__FILE__)) . DS . 'models.php'; - -/** - * TreeBehaviorScopedTest class - * - * @package Cake.Test.Case.Model.Behavior - */ -class TreeBehaviorScopedTest extends CakeTestCase { - -/** - * Whether backup global state for each test method or not - * - * @var bool false - */ - public $backupGlobals = false; - -/** - * settings property - * - * @var array - */ - public $settings = array( - 'modelClass' => 'FlagTree', - 'leftField' => 'lft', - 'rightField' => 'rght', - 'parentField' => 'parent_id' - ); - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.flag_tree', 'core.ad', 'core.campaign', 'core.translate', 'core.number_tree_two'); - -/** - * testStringScope method - * - * @return void - */ - public function testStringScope() { - $this->Tree = new FlagTree(); - $this->Tree->initialize(2, 3); - - $this->Tree->id = 1; - $this->Tree->saveField('flag', 1); - $this->Tree->id = 2; - $this->Tree->saveField('flag', 1); - - $result = $this->Tree->children(); - $expected = array( - array('FlagTree' => array('id' => '3', 'name' => '1.1.1', 'parent_id' => '2', 'lft' => '3', 'rght' => '4', 'flag' => '0')), - array('FlagTree' => array('id' => '4', 'name' => '1.1.2', 'parent_id' => '2', 'lft' => '5', 'rght' => '6', 'flag' => '0')), - array('FlagTree' => array('id' => '5', 'name' => '1.1.3', 'parent_id' => '2', 'lft' => '7', 'rght' => '8', 'flag' => '0')) - ); - $this->assertEquals($expected, $result); - - $this->Tree->Behaviors->attach('Tree', array('scope' => 'FlagTree.flag = 1')); - $this->assertEquals(array(), $this->Tree->children()); - - $this->Tree->id = 1; - $this->Tree->Behaviors->attach('Tree', array('scope' => 'FlagTree.flag = 1')); - - $result = $this->Tree->children(); - $expected = array(array('FlagTree' => array('id' => '2', 'name' => '1.1', 'parent_id' => '1', 'lft' => '2', 'rght' => '9', 'flag' => '1'))); - $this->assertEquals($expected, $result); - - $this->assertTrue($this->Tree->delete()); - $this->assertEquals(11, $this->Tree->find('count')); - } - -/** - * testArrayScope method - * - * @return void - */ - public function testArrayScope() { - $this->Tree = new FlagTree(); - $this->Tree->initialize(2, 3); - - $this->Tree->id = 1; - $this->Tree->saveField('flag', 1); - $this->Tree->id = 2; - $this->Tree->saveField('flag', 1); - - $result = $this->Tree->children(); - $expected = array( - array('FlagTree' => array('id' => '3', 'name' => '1.1.1', 'parent_id' => '2', 'lft' => '3', 'rght' => '4', 'flag' => '0')), - array('FlagTree' => array('id' => '4', 'name' => '1.1.2', 'parent_id' => '2', 'lft' => '5', 'rght' => '6', 'flag' => '0')), - array('FlagTree' => array('id' => '5', 'name' => '1.1.3', 'parent_id' => '2', 'lft' => '7', 'rght' => '8', 'flag' => '0')) - ); - $this->assertEquals($expected, $result); - - $this->Tree->Behaviors->attach('Tree', array('scope' => array('FlagTree.flag' => 1))); - $this->assertEquals(array(), $this->Tree->children()); - - $this->Tree->id = 1; - $this->Tree->Behaviors->attach('Tree', array('scope' => array('FlagTree.flag' => 1))); - - $result = $this->Tree->children(); - $expected = array(array('FlagTree' => array('id' => '2', 'name' => '1.1', 'parent_id' => '1', 'lft' => '2', 'rght' => '9', 'flag' => '1'))); - $this->assertEquals($expected, $result); - - $this->assertTrue($this->Tree->delete()); - $this->assertEquals(11, $this->Tree->find('count')); - } - -/** - * testMoveUpWithScope method - * - * @return void - */ - public function testMoveUpWithScope() { - $this->Ad = new Ad(); - $this->Ad->Behaviors->attach('Tree', array('scope' => 'Campaign')); - $this->Ad->moveUp(6); - - $this->Ad->id = 4; - $result = $this->Ad->children(); - $this->assertEquals(array(6, 5), Set::extract('/Ad/id', $result)); - $this->assertEquals(array(2, 2), Set::extract('/Campaign/id', $result)); - } - -/** - * testMoveDownWithScope method - * - * @return void - */ - public function testMoveDownWithScope() { - $this->Ad = new Ad(); - $this->Ad->Behaviors->attach('Tree', array('scope' => 'Campaign')); - $this->Ad->moveDown(6); - - $this->Ad->id = 4; - $result = $this->Ad->children(); - $this->assertEquals(array(5, 6), Set::extract('/Ad/id', $result)); - $this->assertEquals(array(2, 2), Set::extract('/Campaign/id', $result)); - } - -/** - * Tests the interaction (non-interference) between TreeBehavior and other behaviors with respect - * to callback hooks - * - * @return void - */ - public function testTranslatingTree() { - $this->Tree = new FlagTree(); - $this->Tree->cacheQueries = false; - $this->Tree->Behaviors->attach('Translate', array('name')); - - //Save - $this->Tree->locale = 'eng'; - $data = array('FlagTree' => array( - 'name' => 'name #1', - 'locale' => 'eng', - 'parent_id' => null, - )); - $this->Tree->save($data); - $result = $this->Tree->find('all'); - $expected = array(array('FlagTree' => array( - 'id' => 1, - 'name' => 'name #1', - 'parent_id' => null, - 'lft' => 1, - 'rght' => 2, - 'flag' => 0, - 'locale' => 'eng', - ))); - $this->assertEquals($expected, $result); - - //update existing record, same locale - $this->Tree->create(); - $data['FlagTree']['name'] = 'Named 2'; - $this->Tree->id = 1; - $this->Tree->save($data); - $result = $this->Tree->find('all'); - $expected = array(array('FlagTree' => array( - 'id' => 1, - 'name' => 'Named 2', - 'parent_id' => null, - 'lft' => 1, - 'rght' => 2, - 'flag' => 0, - 'locale' => 'eng', - ))); - $this->assertEquals($expected, $result); - - //update different locale, same record - $this->Tree->create(); - $this->Tree->locale = 'deu'; - $this->Tree->id = 1; - $data = array('FlagTree' => array( - 'id' => 1, - 'parent_id' => null, - 'name' => 'namen #1', - 'locale' => 'deu', - )); - $this->Tree->save($data); - - $this->Tree->locale = 'deu'; - $result = $this->Tree->find('all'); - $expected = array(array('FlagTree' => array( - 'id' => 1, - 'name' => 'namen #1', - 'parent_id' => null, - 'lft' => 1, - 'rght' => 2, - 'flag' => 0, - 'locale' => 'deu', - ))); - $this->assertEquals($expected, $result); - - //Save with bindTranslation - $this->Tree->locale = 'eng'; - $data = array( - 'name' => array('eng' => 'New title', 'spa' => 'Nuevo leyenda'), - 'parent_id' => null - ); - $this->Tree->create($data); - $this->Tree->save(); - - $this->Tree->unbindTranslation(); - $translations = array('name' => 'Name'); - $this->Tree->bindTranslation($translations, false); - $this->Tree->locale = array('eng', 'spa'); - - $result = $this->Tree->read(); - $expected = array( - 'FlagTree' => array('id' => 2, 'parent_id' => null, 'locale' => 'eng', 'name' => 'New title', 'flag' => 0, 'lft' => 3, 'rght' => 4), - 'Name' => array( - array('id' => 21, 'locale' => 'eng', 'model' => 'FlagTree', 'foreign_key' => 2, 'field' => 'name', 'content' => 'New title'), - array('id' => 22, 'locale' => 'spa', 'model' => 'FlagTree', 'foreign_key' => 2, 'field' => 'name', 'content' => 'Nuevo leyenda') - ), - ); - $this->assertEquals($expected, $result); - } - -/** - * testGenerateTreeListWithSelfJoin method - * - * @return void - */ - public function testAliasesWithScopeInTwoTreeAssociations() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $this->TreeTwo = new NumberTreeTwo(); - - $record = $this->Tree->find('first'); - - $this->Tree->bindModel(array( - 'hasMany' => array( - 'SecondTree' => array( - 'className' => 'NumberTreeTwo', - 'foreignKey' => 'number_tree_id' - ) - ) - )); - $this->TreeTwo->bindModel(array( - 'belongsTo' => array( - 'FirstTree' => array( - 'className' => $modelClass, - 'foreignKey' => 'number_tree_id' - ) - ) - )); - $this->TreeTwo->Behaviors->attach('Tree', array( - 'scope' => 'FirstTree' - )); - - $data = array( - 'NumberTreeTwo' => array( - 'name' => 'First', - 'number_tree_id' => $record['FlagTree']['id'] - ) - ); - $this->TreeTwo->create(); - $result = $this->TreeTwo->save($data); - $this->assertFalse(empty($result)); - - $result = $this->TreeTwo->find('first'); - $expected = array('NumberTreeTwo' => array( - 'id' => 1, - 'name' => 'First', - 'number_tree_id' => $record['FlagTree']['id'], - 'parent_id' => null, - 'lft' => 1, - 'rght' => 2 - )); - $this->assertEquals($expected, $result); - } -} diff --git a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorTest.php b/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorTest.php deleted file mode 100644 index 4b55e7e353e..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorTest.php +++ /dev/null @@ -1,43 +0,0 @@ -addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'Behavior' . DS . 'TreeBehaviorNumberTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'Behavior' . DS . 'TreeBehaviorScopedTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'Behavior' . DS . 'TreeBehaviorAfterTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'Behavior' . DS . 'TreeBehaviorUuidTest.php'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorUuidTest.php b/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorUuidTest.php deleted file mode 100644 index 64d5c61cfb8..00000000000 --- a/lib/Cake/Test/Case/Model/Behavior/TreeBehaviorUuidTest.php +++ /dev/null @@ -1,255 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Behavior - * @since CakePHP(tm) v 1.2.0.5330 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -require_once dirname(dirname(__FILE__)) . DS . 'models.php'; - -/** - * TreeBehaviorUuidTest class - * - * @package Cake.Test.Case.Model.Behavior - */ -class TreeBehaviorUuidTest extends CakeTestCase { - -/** - * Whether backup global state for each test method or not - * - * @var bool false - */ - public $backupGlobals = false; - -/** - * settings property - * - * @var array - */ - public $settings = array( - 'modelClass' => 'UuidTree', - 'leftField' => 'lft', - 'rightField' => 'rght', - 'parentField' => 'parent_id' - ); - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.uuid_tree'); - -/** - * testMovePromote method - * - * @return void - */ - public function testMovePromote() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $parent = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $parentId = $parent[$modelClass]['id']; - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1.1'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->saveField($parentField, $parentId); - $direct = $this->Tree->children($parentId, true, array('name', $leftField, $rightField)); - $expects = array(array($modelClass => array('name' => '1.1', $leftField => 2, $rightField => 5)), - array($modelClass => array('name' => '1.2', $leftField => 6, $rightField => 11)), - array($modelClass => array('name' => '1.1.1', $leftField => 12, $rightField => 13))); - $this->assertEquals($direct, $expects); - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testMoveWithWhitelist method - * - * @return void - */ - public function testMoveWithWhitelist() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $this->Tree->id = null; - - $parent = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $parentId = $parent[$modelClass]['id']; - - $data = $this->Tree->find('first', array('fields' => array('id'), 'conditions' => array($modelClass . '.name' => '1.1.1'))); - $this->Tree->id = $data[$modelClass]['id']; - $this->Tree->whitelist = array($parentField, 'name', 'description'); - $this->Tree->saveField($parentField, $parentId); - - $result = $this->Tree->children($parentId, true, array('name', $leftField, $rightField)); - $expected = array(array($modelClass => array('name' => '1.1', $leftField => 2, $rightField => 5)), - array($modelClass => array('name' => '1.2', $leftField => 6, $rightField => 11)), - array($modelClass => array('name' => '1.1.1', $leftField => 12, $rightField => 13))); - $this->assertEquals($expected, $result); - $this->assertTrue($this->Tree->verify()); - } - -/** - * testRemoveNoChildren method - * - * @return void - */ - public function testRemoveNoChildren() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $initialCount = $this->Tree->find('count'); - - $result = $this->Tree->findByName('1.1.1'); - $this->Tree->removeFromTree($result[$modelClass]['id']); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount, $laterCount); - - $nodes = $this->Tree->find('list', array('order' => $leftField)); - $expects = array( - '1. Root', - '1.1', - '1.1.2', - '1.2', - '1.2.1', - '1.2.2', - '1.1.1', - ); - - $this->assertEquals(array_values($nodes), $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testRemoveAndDeleteNoChildren method - * - * @return void - */ - public function testRemoveAndDeleteNoChildren() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - $initialCount = $this->Tree->find('count'); - - $result = $this->Tree->findByName('1.1.1'); - $this->Tree->removeFromTree($result[$modelClass]['id'], true); - - $laterCount = $this->Tree->find('count'); - $this->assertEquals($initialCount - 1, $laterCount); - - $nodes = $this->Tree->find('list', array('order' => $leftField)); - $expects = array( - '1. Root', - '1.1', - '1.1.2', - '1.2', - '1.2.1', - '1.2.2', - ); - $this->assertEquals(array_values($nodes), $expects); - - $validTree = $this->Tree->verify(); - $this->assertSame($validTree, true); - } - -/** - * testChildren method - * - * @return void - */ - public function testChildren() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $this->Tree->id = $data[$modelClass]['id']; - - $direct = $this->Tree->children(null, true, array('name', $leftField, $rightField)); - $expects = array(array($modelClass => array('name' => '1.1', $leftField => 2, $rightField => 7)), - array($modelClass => array('name' => '1.2', $leftField => 8, $rightField => 13))); - $this->assertEquals($direct, $expects); - - $total = $this->Tree->children(null, null, array('name', $leftField, $rightField)); - $expects = array(array($modelClass => array('name' => '1.1', $leftField => 2, $rightField => 7)), - array($modelClass => array('name' => '1.1.1', $leftField => 3, $rightField => 4)), - array($modelClass => array('name' => '1.1.2', $leftField => 5, $rightField => 6)), - array($modelClass => array('name' => '1.2', $leftField => 8, $rightField => 13)), - array($modelClass => array('name' => '1.2.1', $leftField => 9, $rightField => 10)), - array($modelClass => array('name' => '1.2.2', $leftField => 11, $rightField => 12))); - $this->assertEquals($total, $expects); - } - -/** - * testNoAmbiguousColumn method - * - * @return void - */ - public function testNoAmbiguousColumn() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->initialize(2, 2); - - $this->Tree->bindModel(array('belongsTo' => array('Dummy' => - array('className' => $modelClass, 'foreignKey' => $parentField, 'conditions' => array('Dummy.id' => null)))), false); - - $data = $this->Tree->find('first', array('conditions' => array($modelClass . '.name' => '1. Root'))); - $this->Tree->id = $data[$modelClass]['id']; - - $direct = $this->Tree->children(null, true, array('name', $leftField, $rightField)); - $expects = array(array($modelClass => array('name' => '1.1', $leftField => 2, $rightField => 7)), - array($modelClass => array('name' => '1.2', $leftField => 8, $rightField => 13))); - $this->assertEquals($direct, $expects); - - $total = $this->Tree->children(null, null, array('name', $leftField, $rightField)); - $expects = array( - array($modelClass => array('name' => '1.1', $leftField => 2, $rightField => 7)), - array($modelClass => array('name' => '1.1.1', $leftField => 3, $rightField => 4)), - array($modelClass => array('name' => '1.1.2', $leftField => 5, $rightField => 6)), - array($modelClass => array('name' => '1.2', $leftField => 8, $rightField => 13)), - array($modelClass => array('name' => '1.2.1', $leftField => 9, $rightField => 10)), - array($modelClass => array('name' => '1.2.2', $leftField => 11, $rightField => 12)) - ); - $this->assertEquals($total, $expects); - } - -/** - * testGenerateTreeListWithSelfJoin method - * - * @return void - */ - public function testGenerateTreeListWithSelfJoin() { - extract($this->settings); - $this->Tree = new $modelClass(); - $this->Tree->bindModel(array('belongsTo' => array('Dummy' => - array('className' => $modelClass, 'foreignKey' => $parentField, 'conditions' => array('Dummy.id' => null)))), false); - $this->Tree->initialize(2, 2); - - $result = $this->Tree->generateTreeList(); - $expected = array('1. Root', '_1.1', '__1.1.1', '__1.1.2', '_1.2', '__1.2.1', '__1.2.2'); - $this->assertSame(array_values($result), $expected); - } -} diff --git a/lib/Cake/Test/Case/Model/BehaviorCollectionTest.php b/lib/Cake/Test/Case/Model/BehaviorCollectionTest.php deleted file mode 100644 index d52bcf96177..00000000000 --- a/lib/Cake/Test/Case/Model/BehaviorCollectionTest.php +++ /dev/null @@ -1,1141 +0,0 @@ - 'testMethod', '/look for\s+(.+)/' => 'speakEnglish'); - -/** - * setup method - * - * @param mixed $model - * @param array $config - * @return void - */ - public function setup(Model $model, $config = array()) { - parent::setup($model, $config); - if (isset($config['mangle'])) { - $config['mangle'] .= ' mangled'; - } - $this->settings[$model->alias] = array_merge(array('beforeFind' => 'on', 'afterFind' => 'off'), $config); - } - -/** - * beforeFind method - * - * @param mixed $model - * @param mixed $query - * @return void - */ - public function beforeFind(Model $model, $query) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['beforeFind']) || $settings['beforeFind'] == 'off') { - return parent::beforeFind($model, $query); - } - switch ($settings['beforeFind']) { - case 'on': - return false; - break; - case 'test': - return null; - break; - case 'modify': - $query['fields'] = array($model->alias . '.id', $model->alias . '.name', $model->alias . '.mytime'); - $query['recursive'] = -1; - return $query; - break; - } - } - -/** - * afterFind method - * - * @param mixed $model - * @param mixed $results - * @param mixed $primary - * @return void - */ - public function afterFind(Model $model, $results, $primary) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['afterFind']) || $settings['afterFind'] == 'off') { - return parent::afterFind($model, $results, $primary); - } - switch ($settings['afterFind']) { - case 'on': - return array(); - break; - case 'test': - return true; - break; - case 'test2': - return null; - break; - case 'modify': - return Set::extract($results, "{n}.{$model->alias}"); - break; - } - } - -/** - * beforeSave method - * - * @param mixed $model - * @return void - */ - public function beforeSave(Model $model) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['beforeSave']) || $settings['beforeSave'] == 'off') { - return parent::beforeSave($model); - } - switch ($settings['beforeSave']) { - case 'on': - return false; - break; - case 'test': - return true; - break; - case 'modify': - $model->data[$model->alias]['name'] .= ' modified before'; - return true; - break; - } - } - -/** - * afterSave method - * - * @param mixed $model - * @param mixed $created - * @return void - */ - public function afterSave(Model $model, $created) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['afterSave']) || $settings['afterSave'] == 'off') { - return parent::afterSave($model, $created); - } - $string = 'modified after'; - if ($created) { - $string .= ' on create'; - } - switch ($settings['afterSave']) { - case 'on': - $model->data[$model->alias]['aftersave'] = $string; - break; - case 'test': - unset($model->data[$model->alias]['name']); - break; - case 'test2': - return false; - break; - case 'modify': - $model->data[$model->alias]['name'] .= ' ' . $string; - break; - } - } - -/** - * beforeValidate method - * - * @param mixed $model - * @return void - */ - public function beforeValidate(Model $model) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['validate']) || $settings['validate'] == 'off') { - return parent::beforeValidate($model); - } - switch ($settings['validate']) { - case 'on': - $model->invalidate('name'); - return true; - break; - case 'test': - return null; - break; - case 'whitelist': - $this->_addToWhitelist($model, array('name')); - return true; - break; - case 'stop': - $model->invalidate('name'); - return false; - break; - } - } - -/** - * beforeDelete method - * - * @param mixed $model - * @param bool $cascade - * @return void - */ - public function beforeDelete(Model $model, $cascade = true) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['beforeDelete']) || $settings['beforeDelete'] == 'off') { - return parent::beforeDelete($model, $cascade); - } - switch ($settings['beforeDelete']) { - case 'on': - return false; - break; - case 'test': - return null; - break; - case 'test2': - echo 'beforeDelete success'; - if ($cascade) { - echo ' (cascading) '; - } - return true; - break; - } - } - -/** - * afterDelete method - * - * @param mixed $model - * @return void - */ - public function afterDelete(Model $model) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['afterDelete']) || $settings['afterDelete'] == 'off') { - return parent::afterDelete($model); - } - switch ($settings['afterDelete']) { - case 'on': - echo 'afterDelete success'; - break; - } - } - -/** - * onError method - * - * @param mixed $model - * @return void - */ - public function onError(Model $model, $error) { - $settings = $this->settings[$model->alias]; - if (!isset($settings['onError']) || $settings['onError'] == 'off') { - return parent::onError($model, $error); - } - echo "onError trigger success"; - } - -/** - * beforeTest method - * - * @param mixed $model - * @return void - */ - public function beforeTest(Model $model) { - if (!isset($model->beforeTestResult)) { - $model->beforeTestResult = array(); - } - $model->beforeTestResult[] = strtolower(get_class($this)); - return strtolower(get_class($this)); - } - -/** - * testMethod method - * - * @param mixed $model - * @param bool $param - * @return void - */ - public function testMethod(Model $model, $param = true) { - if ($param === true) { - return 'working'; - } - } - -/** - * testData method - * - * @param mixed $model - * @return void - */ - public function testData(Model $model) { - if (!isset($model->data['Apple']['field'])) { - return false; - } - $model->data['Apple']['field_2'] = true; - return true; - } - -/** - * validateField method - * - * @param mixed $model - * @param mixed $field - * @return void - */ - public function validateField(Model $model, $field) { - return current($field) === 'Orange'; - } - -/** - * speakEnglish method - * - * @param mixed $model - * @param mixed $method - * @param mixed $query - * @return void - */ - public function speakEnglish(Model $model, $method, $query) { - $method = preg_replace('/look for\s+/', 'Item.name = \'', $method); - $query = preg_replace('/^in\s+/', 'Location.name = \'', $query); - return $method . '\' AND ' . $query . '\''; - } - -} - -/** - * Test2Behavior class - * - * @package Cake.Test.Case.Model - */ -class Test2Behavior extends TestBehavior { - - public $mapMethods = array('/mappingRobot(\w+)/' => 'mapped'); - - public function resolveMethod(Model $model, $stuff) { - } - - public function mapped(Model $model, $method, $query) { - } - -} - -/** - * Test3Behavior class - * - * @package Cake.Test.Case.Model - */ -class Test3Behavior extends TestBehavior{ -} - -/** - * Test4Behavior class - * - * @package Cake.Test.Case.Model - */ -class Test4Behavior extends ModelBehavior{ - - public function setup(Model $model, $config = null) { - $model->bindModel( - array('hasMany' => array('Comment')) - ); - } - -} - -/** - * Test5Behavior class - * - * @package Cake.Test.Case.Model - */ -class Test5Behavior extends ModelBehavior{ - - public function setup(Model $model, $config = null) { - $model->bindModel( - array('belongsTo' => array('User')) - ); - } - -} - -/** - * Test6Behavior class - * - * @package Cake.Test.Case.Model - */ -class Test6Behavior extends ModelBehavior{ - - public function setup(Model $model, $config = null) { - $model->bindModel( - array('hasAndBelongsToMany' => array('Tag')) - ); - } - -} - -/** - * Test7Behavior class - * - * @package Cake.Test.Case.Model - */ -class Test7Behavior extends ModelBehavior{ - - public function setup(Model $model, $config = null) { - $model->bindModel( - array('hasOne' => array('Attachment')) - ); - } - -} - -/** - * Extended TestBehavior - */ -class TestAliasBehavior extends TestBehavior { -} - -/** - * BehaviorCollection class - * - * @package Cake.Test.Case.Model - */ -class BehaviorCollectionTest extends CakeTestCase { - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array( - 'core.apple', 'core.sample', 'core.article', 'core.user', 'core.comment', - 'core.attachment', 'core.tag', 'core.articles_tag', 'core.translate' - ); - -/** - * Test load() with enabled => false - * - */ - public function testLoadDisabled() { - $Apple = new Apple(); - $this->assertSame(array(), $Apple->Behaviors->attached()); - - $Apple->Behaviors->load('Translate', array('enabled' => false)); - $this->assertTrue($Apple->Behaviors->attached('Translate')); - $this->assertFalse($Apple->Behaviors->enabled('Translate')); - } - -/** - * Tests loading aliased behaviors - */ - public function testLoadAlias() { - $Apple = new Apple(); - $this->assertSame(array(), $Apple->Behaviors->attached()); - - $Apple->Behaviors->load('Test', array('className' => 'TestAlias', 'somesetting' => true)); - $this->assertSame(array('Test'), $Apple->Behaviors->attached()); - $this->assertInstanceOf('TestAliasBehavior', $Apple->Behaviors->Test); - $this->assertTrue($Apple->Behaviors->Test->settings['Apple']['somesetting']); - - $this->assertEquals('working', $Apple->Behaviors->Test->testMethod($Apple, true)); - $this->assertEquals('working', $Apple->testMethod(true)); - $this->assertEquals('working', $Apple->Behaviors->dispatchMethod($Apple, 'testMethod')); - - App::build(array('Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS))); - CakePlugin::load('TestPlugin'); - $this->assertTrue($Apple->Behaviors->load('SomeOther', array('className' => 'TestPlugin.TestPluginPersisterOne'))); - $this->assertInstanceOf('TestPluginPersisterOneBehavior', $Apple->Behaviors->SomeOther); - - $result = $Apple->Behaviors->attached(); - $this->assertEquals(array('Test', 'SomeOther'), $result, 'attached() results are wrong.'); - App::build(); - CakePlugin::unload(); - } - -/** - * testBehaviorBinding method - * - * @return void - */ - public function testBehaviorBinding() { - $Apple = new Apple(); - $this->assertSame(array(), $Apple->Behaviors->attached()); - - $Apple->Behaviors->attach('Test', array('key' => 'value')); - $this->assertSame(array('Test'), $Apple->Behaviors->attached()); - $this->assertEquals('testbehavior', strtolower(get_class($Apple->Behaviors->Test))); - $expected = array('beforeFind' => 'on', 'afterFind' => 'off', 'key' => 'value'); - $this->assertEquals($expected, $Apple->Behaviors->Test->settings['Apple']); - $this->assertEquals(array('Apple'), array_keys($Apple->Behaviors->Test->settings)); - - $this->assertSame($Apple->Sample->Behaviors->attached(), array()); - $Apple->Sample->Behaviors->attach('Test', array('key2' => 'value2')); - $this->assertSame($Apple->Sample->Behaviors->attached(), array('Test')); - $this->assertEquals(array('beforeFind' => 'on', 'afterFind' => 'off', 'key2' => 'value2'), $Apple->Sample->Behaviors->Test->settings['Sample']); - - $this->assertEquals(array('Apple', 'Sample'), array_keys($Apple->Behaviors->Test->settings)); - $this->assertSame( - $Apple->Sample->Behaviors->Test->settings, - $Apple->Behaviors->Test->settings - ); - $this->assertNotSame($Apple->Behaviors->Test->settings['Apple'], $Apple->Sample->Behaviors->Test->settings['Sample']); - - $Apple->Behaviors->attach('Test', array('key2' => 'value2', 'key3' => 'value3', 'beforeFind' => 'off')); - $Apple->Sample->Behaviors->attach('Test', array('key' => 'value', 'key3' => 'value3', 'beforeFind' => 'off')); - $this->assertEquals(array('beforeFind' => 'off', 'afterFind' => 'off', 'key' => 'value', 'key2' => 'value2', 'key3' => 'value3'), $Apple->Behaviors->Test->settings['Apple']); - $this->assertEquals($Apple->Behaviors->Test->settings['Apple'], $Apple->Sample->Behaviors->Test->settings['Sample']); - - $this->assertFalse(isset($Apple->Child->Behaviors->Test)); - $Apple->Child->Behaviors->attach('Test', array('key' => 'value', 'key2' => 'value2', 'key3' => 'value3', 'beforeFind' => 'off')); - $this->assertEquals($Apple->Child->Behaviors->Test->settings['Child'], $Apple->Sample->Behaviors->Test->settings['Sample']); - - $this->assertFalse(isset($Apple->Parent->Behaviors->Test)); - $Apple->Parent->Behaviors->attach('Test', array('key' => 'value', 'key2' => 'value2', 'key3' => 'value3', 'beforeFind' => 'off')); - $this->assertEquals($Apple->Parent->Behaviors->Test->settings['Parent'], $Apple->Sample->Behaviors->Test->settings['Sample']); - - $Apple->Parent->Behaviors->attach('Test', array('key' => 'value', 'key2' => 'value', 'key3' => 'value', 'beforeFind' => 'off')); - $this->assertNotEquals($Apple->Parent->Behaviors->Test->settings['Parent'], $Apple->Sample->Behaviors->Test->settings['Sample']); - - $Apple->Behaviors->attach('Plugin.Test', array('key' => 'new value')); - $expected = array( - 'beforeFind' => 'off', 'afterFind' => 'off', 'key' => 'new value', - 'key2' => 'value2', 'key3' => 'value3' - ); - $this->assertEquals($expected, $Apple->Behaviors->Test->settings['Apple']); - - $current = $Apple->Behaviors->Test->settings['Apple']; - $expected = array_merge($current, array('mangle' => 'trigger mangled')); - $Apple->Behaviors->attach('Test', array('mangle' => 'trigger')); - $this->assertEquals($expected, $Apple->Behaviors->Test->settings['Apple']); - - $Apple->Behaviors->attach('Test'); - $expected = array_merge($current, array('mangle' => 'trigger mangled mangled')); - - $this->assertEquals($expected, $Apple->Behaviors->Test->settings['Apple']); - $Apple->Behaviors->attach('Test', array('mangle' => 'trigger')); - $expected = array_merge($current, array('mangle' => 'trigger mangled')); - $this->assertEquals($expected, $Apple->Behaviors->Test->settings['Apple']); - } - -/** - * test that attach()/detach() works with plugin.banana - * - * @return void - */ - public function testDetachWithPluginNames() { - $Apple = new Apple(); - $Apple->Behaviors->attach('Plugin.Test'); - $this->assertTrue(isset($Apple->Behaviors->Test), 'Missing behavior'); - $this->assertEquals(array('Test'), $Apple->Behaviors->attached()); - - $Apple->Behaviors->detach('Plugin.Test'); - $this->assertEquals(array(), $Apple->Behaviors->attached()); - - $Apple->Behaviors->attach('Plugin.Test'); - $this->assertTrue(isset($Apple->Behaviors->Test), 'Missing behavior'); - $this->assertEquals(array('Test'), $Apple->Behaviors->attached()); - - $Apple->Behaviors->detach('Test'); - $this->assertEquals(array(), $Apple->Behaviors->attached()); - } - -/** - * test that attaching a non existent Behavior triggers a cake error. - * - * @expectedException MissingBehaviorException - * @return void - */ - public function testInvalidBehaviorCausingCakeError() { - $Apple = new Apple(); - $Apple->Behaviors->attach('NoSuchBehavior'); - } - -/** - * testBehaviorToggling method - * - * @return void - */ - public function testBehaviorToggling() { - $Apple = new Apple(); - $expected = $Apple->find('all'); - $this->assertSame($Apple->Behaviors->enabled(), array()); - - $Apple->Behaviors->init('Apple', array('Test' => array('key' => 'value'))); - $this->assertSame($Apple->Behaviors->enabled(), array('Test')); - - $Apple->Behaviors->disable('Test'); - $this->assertSame(array('Test'), $Apple->Behaviors->attached()); - $this->assertSame($Apple->Behaviors->enabled(), array()); - - $Apple->Sample->Behaviors->attach('Test'); - $this->assertSame($Apple->Sample->Behaviors->enabled('Test'), true); - $this->assertSame($Apple->Behaviors->enabled(), array()); - - $Apple->Behaviors->enable('Test'); - $this->assertSame($Apple->Behaviors->attached('Test'), true); - $this->assertSame($Apple->Behaviors->enabled(), array('Test')); - - $Apple->Behaviors->disable('Test'); - $this->assertSame($Apple->Behaviors->enabled(), array()); - $Apple->Behaviors->attach('Test', array('enabled' => true)); - $this->assertSame($Apple->Behaviors->enabled(), array('Test')); - $Apple->Behaviors->attach('Test', array('enabled' => false)); - $this->assertSame($Apple->Behaviors->enabled(), array()); - $Apple->Behaviors->detach('Test'); - $this->assertSame($Apple->Behaviors->enabled(), array()); - } - -/** - * testBehaviorFindCallbacks method - * - * @return void - */ - public function testBehaviorFindCallbacks() { - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $Apple = new Apple(); - $expected = $Apple->find('all'); - - $Apple->Behaviors->attach('Test'); - $this->assertSame($Apple->find('all'), null); - - $Apple->Behaviors->attach('Test', array('beforeFind' => 'off')); - $this->assertSame($expected, $Apple->find('all')); - - $Apple->Behaviors->attach('Test', array('beforeFind' => 'test')); - $this->assertSame($expected, $Apple->find('all')); - - $Apple->Behaviors->attach('Test', array('beforeFind' => 'modify')); - $expected2 = array( - array('Apple' => array('id' => '1', 'name' => 'Red Apple 1', 'mytime' => '22:57:17')), - array('Apple' => array('id' => '2', 'name' => 'Bright Red Apple', 'mytime' => '22:57:17')), - array('Apple' => array('id' => '3', 'name' => 'green blue', 'mytime' => '22:57:17')) - ); - $result = $Apple->find('all', array('conditions' => array('Apple.id <' => '4'))); - $this->assertEquals($expected2, $result); - - $Apple->Behaviors->disable('Test'); - $result = $Apple->find('all'); - $this->assertEquals($expected, $result); - - $Apple->Behaviors->attach('Test', array('beforeFind' => 'off', 'afterFind' => 'on')); - $this->assertSame($Apple->find('all'), array()); - - $Apple->Behaviors->attach('Test', array('afterFind' => 'off')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Behaviors->attach('Test', array('afterFind' => 'test')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Behaviors->attach('Test', array('afterFind' => 'test2')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Behaviors->attach('Test', array('afterFind' => 'modify')); - $expected = array( - array('id' => '1', 'apple_id' => '2', 'color' => 'Red 1', 'name' => 'Red Apple 1', 'created' => '2006-11-22 10:38:58', 'date' => '1951-01-04', 'modified' => '2006-12-01 13:31:26', 'mytime' => '22:57:17'), - array('id' => '2', 'apple_id' => '1', 'color' => 'Bright Red 1', 'name' => 'Bright Red Apple', 'created' => '2006-11-22 10:43:13', 'date' => '2014-01-01', 'modified' => '2006-11-30 18:38:10', 'mytime' => '22:57:17'), - array('id' => '3', 'apple_id' => '2', 'color' => 'blue green', 'name' => 'green blue', 'created' => '2006-12-25 05:13:36', 'date' => '2006-12-25', 'modified' => '2006-12-25 05:23:24', 'mytime' => '22:57:17'), - array('id' => '4', 'apple_id' => '2', 'color' => 'Blue Green', 'name' => 'Test Name', 'created' => '2006-12-25 05:23:36', 'date' => '2006-12-25', 'modified' => '2006-12-25 05:23:36', 'mytime' => '22:57:17'), - array('id' => '5', 'apple_id' => '5', 'color' => 'Green', 'name' => 'Blue Green', 'created' => '2006-12-25 05:24:06', 'date' => '2006-12-25', 'modified' => '2006-12-25 05:29:16', 'mytime' => '22:57:17'), - array('id' => '6', 'apple_id' => '4', 'color' => 'My new appleOrange', 'name' => 'My new apple', 'created' => '2006-12-25 05:29:39', 'date' => '2006-12-25', 'modified' => '2006-12-25 05:29:39', 'mytime' => '22:57:17'), - array('id' => '7', 'apple_id' => '6', 'color' => 'Some wierd color', 'name' => 'Some odd color', 'created' => '2006-12-25 05:34:21', 'date' => '2006-12-25', 'modified' => '2006-12-25 05:34:21', 'mytime' => '22:57:17') - ); - $this->assertEquals($expected, $Apple->find('all')); - } - -/** - * testBehaviorHasManyFindCallbacks method - * - * @return void - */ - public function testBehaviorHasManyFindCallbacks() { - $Apple = new Apple(); - $Apple->unbindModel(array('hasOne' => array('Sample'), 'belongsTo' => array('Parent')), false); - $expected = $Apple->find('all'); - - $Apple->unbindModel(array('hasMany' => array('Child'))); - $wellBehaved = $Apple->find('all'); - $Apple->Child->Behaviors->attach('Test', array('afterFind' => 'modify')); - $Apple->unbindModel(array('hasMany' => array('Child'))); - $this->assertSame($Apple->find('all'), $wellBehaved); - - $Apple->Child->Behaviors->attach('Test', array('before' => 'off')); - $this->assertSame($expected, $Apple->find('all')); - - $Apple->Child->Behaviors->attach('Test', array('before' => 'test')); - $this->assertSame($expected, $Apple->find('all')); - - $expected2 = array( - array( - 'Apple' => array('id' => 1), - 'Child' => array( - array('id' => 2, 'name' => 'Bright Red Apple', 'mytime' => '22:57:17'))), - array( - 'Apple' => array('id' => 2), - 'Child' => array( - array('id' => 1, 'name' => 'Red Apple 1', 'mytime' => '22:57:17'), - array('id' => 3, 'name' => 'green blue', 'mytime' => '22:57:17'), - array('id' => 4, 'name' => 'Test Name', 'mytime' => '22:57:17'))), - array( - 'Apple' => array('id' => 3), - 'Child' => array()) - ); - - $Apple->Child->Behaviors->attach('Test', array('before' => 'modify')); - $result = $Apple->find('all', array('fields' => array('Apple.id'), 'conditions' => array('Apple.id <' => '4'))); - - $Apple->Child->Behaviors->disable('Test'); - $result = $Apple->find('all'); - $this->assertEquals($expected, $result); - - $Apple->Child->Behaviors->attach('Test', array('before' => 'off', 'after' => 'on')); - - $Apple->Child->Behaviors->attach('Test', array('after' => 'off')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Child->Behaviors->attach('Test', array('after' => 'test')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Child->Behaviors->attach('Test', array('after' => 'test2')); - $this->assertEquals($expected, $Apple->find('all')); - } - -/** - * testBehaviorHasOneFindCallbacks method - * - * @return void - */ - public function testBehaviorHasOneFindCallbacks() { - $Apple = new Apple(); - $Apple->unbindModel(array('hasMany' => array('Child'), 'belongsTo' => array('Parent')), false); - $expected = $Apple->find('all'); - - $Apple->unbindModel(array('hasOne' => array('Sample'))); - $wellBehaved = $Apple->find('all'); - $Apple->Sample->Behaviors->attach('Test'); - $Apple->unbindModel(array('hasOne' => array('Sample'))); - $this->assertSame($Apple->find('all'), $wellBehaved); - - $Apple->Sample->Behaviors->attach('Test', array('before' => 'off')); - $this->assertSame($expected, $Apple->find('all')); - - $Apple->Sample->Behaviors->attach('Test', array('before' => 'test')); - $this->assertSame($expected, $Apple->find('all')); - - $Apple->Sample->Behaviors->disable('Test'); - $result = $Apple->find('all'); - $this->assertEquals($expected, $result); - - $Apple->Sample->Behaviors->attach('Test', array('after' => 'off')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Sample->Behaviors->attach('Test', array('after' => 'test')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Sample->Behaviors->attach('Test', array('after' => 'test2')); - $this->assertEquals($expected, $Apple->find('all')); - } - -/** - * testBehaviorBelongsToFindCallbacks method - * - * @return void - */ - public function testBehaviorBelongsToFindCallbacks() { - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $Apple = new Apple(); - $Apple->unbindModel(array('hasMany' => array('Child'), 'hasOne' => array('Sample')), false); - $expected = $Apple->find('all'); - - $Apple->unbindModel(array('belongsTo' => array('Parent'))); - $wellBehaved = $Apple->find('all'); - $Apple->Parent->Behaviors->attach('Test'); - $Apple->unbindModel(array('belongsTo' => array('Parent'))); - $this->assertSame($Apple->find('all'), $wellBehaved); - - $Apple->Parent->Behaviors->attach('Test', array('before' => 'off')); - $this->assertSame($expected, $Apple->find('all')); - - $Apple->Parent->Behaviors->attach('Test', array('before' => 'test')); - $this->assertSame($expected, $Apple->find('all')); - - $Apple->Parent->Behaviors->attach('Test', array('before' => 'modify')); - $expected2 = array( - array( - 'Apple' => array('id' => 1), - 'Parent' => array('id' => 2, 'name' => 'Bright Red Apple', 'mytime' => '22:57:17')), - array( - 'Apple' => array('id' => 2), - 'Parent' => array('id' => 1, 'name' => 'Red Apple 1', 'mytime' => '22:57:17')), - array( - 'Apple' => array('id' => 3), - 'Parent' => array('id' => 2, 'name' => 'Bright Red Apple', 'mytime' => '22:57:17')) - ); - $result2 = $Apple->find('all', array( - 'fields' => array('Apple.id', 'Parent.id', 'Parent.name', 'Parent.mytime'), - 'conditions' => array('Apple.id <' => '4') - )); - $this->assertEquals($expected2, $result2); - - $Apple->Parent->Behaviors->disable('Test'); - $result = $Apple->find('all'); - $this->assertEquals($expected, $result); - - $Apple->Parent->Behaviors->attach('Test', array('after' => 'off')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Parent->Behaviors->attach('Test', array('after' => 'test')); - $this->assertEquals($expected, $Apple->find('all')); - - $Apple->Parent->Behaviors->attach('Test', array('after' => 'test2')); - $this->assertEquals($expected, $Apple->find('all')); - } - -/** - * testBehaviorSaveCallbacks method - * - * @return void - */ - public function testBehaviorSaveCallbacks() { - $Sample = new Sample(); - $record = array('Sample' => array('apple_id' => 6, 'name' => 'sample99')); - - $Sample->Behaviors->attach('Test', array('beforeSave' => 'on')); - $Sample->create(); - $this->assertSame(false, $Sample->save($record)); - - $Sample->Behaviors->attach('Test', array('beforeSave' => 'off')); - $Sample->create(); - $result = $Sample->save($record); - $expected = $record; - $expected['Sample']['id'] = $Sample->id; - $this->assertSame($expected, $result); - - $Sample->Behaviors->attach('Test', array('beforeSave' => 'test')); - $Sample->create(); - $result = $Sample->save($record); - $expected = $record; - $expected['Sample']['id'] = $Sample->id; - $this->assertSame($expected, $result); - - $Sample->Behaviors->attach('Test', array('beforeSave' => 'modify')); - $expected = Set::insert($record, 'Sample.name', 'sample99 modified before'); - $Sample->create(); - $result = $Sample->save($record); - $expected['Sample']['id'] = $Sample->id; - $this->assertSame($expected, $result); - - $Sample->Behaviors->disable('Test'); - $this->assertSame($record, $Sample->save($record)); - - $Sample->Behaviors->attach('Test', array('beforeSave' => 'off', 'afterSave' => 'on')); - $expected = Set::merge($record, array('Sample' => array('aftersave' => 'modified after on create'))); - $Sample->create(); - $result = $Sample->save($record); - $expected['Sample']['id'] = $Sample->id; - $this->assertEquals($expected, $result); - - $Sample->Behaviors->attach('Test', array('beforeSave' => 'modify', 'afterSave' => 'modify')); - $expected = Set::merge($record, array('Sample' => array('name' => 'sample99 modified before modified after on create'))); - $Sample->create(); - $result = $Sample->save($record); - $expected['Sample']['id'] = $Sample->id; - $this->assertSame($expected, $result); - - $Sample->Behaviors->attach('Test', array('beforeSave' => 'off', 'afterSave' => 'test')); - $Sample->create(); - $expected = $record; - $result = $Sample->save($record); - $expected['Sample']['id'] = $Sample->id; - $this->assertSame($expected, $result); - - $Sample->Behaviors->attach('Test', array('afterSave' => 'test2')); - $Sample->create(); - $expected = $record; - $result = $Sample->save($record); - $expected['Sample']['id'] = $Sample->id; - $this->assertSame($expected, $result); - - $Sample->Behaviors->attach('Test', array('beforeFind' => 'off', 'afterFind' => 'off')); - $Sample->recursive = -1; - $record2 = $Sample->read(null, 1); - - $Sample->Behaviors->attach('Test', array('afterSave' => 'on')); - $expected = Set::merge($record2, array('Sample' => array('aftersave' => 'modified after'))); - $Sample->create(); - $this->assertSame($expected, $Sample->save($record2)); - - $Sample->Behaviors->attach('Test', array('afterSave' => 'modify')); - $expected = Set::merge($record2, array('Sample' => array('name' => 'sample1 modified after'))); - $Sample->create(); - $this->assertSame($expected, $Sample->save($record2)); - } - -/** - * testBehaviorDeleteCallbacks method - * - * @return void - */ - public function testBehaviorDeleteCallbacks() { - $Apple = new Apple(); - - $Apple->Behaviors->attach('Test', array('beforeFind' => 'off', 'beforeDelete' => 'off')); - $this->assertSame($Apple->delete(6), true); - - $Apple->Behaviors->attach('Test', array('beforeDelete' => 'on')); - $this->assertSame($Apple->delete(4), false); - - $Apple->Behaviors->attach('Test', array('beforeDelete' => 'test2')); - - ob_start(); - $results = $Apple->delete(4); - $this->assertSame(trim(ob_get_clean()), 'beforeDelete success (cascading)'); - $this->assertSame($results, true); - - ob_start(); - $results = $Apple->delete(3, false); - $this->assertSame(trim(ob_get_clean()), 'beforeDelete success'); - $this->assertSame($results, true); - - $Apple->Behaviors->attach('Test', array('beforeDelete' => 'off', 'afterDelete' => 'on')); - ob_start(); - $results = $Apple->delete(2, false); - $this->assertSame(trim(ob_get_clean()), 'afterDelete success'); - $this->assertSame($results, true); - } - -/** - * testBehaviorOnErrorCallback method - * - * @return void - */ - public function testBehaviorOnErrorCallback() { - $Apple = new Apple(); - - $Apple->Behaviors->attach('Test', array('beforeFind' => 'off', 'onError' => 'on')); - ob_start(); - $Apple->Behaviors->Test->onError($Apple, ''); - $this->assertSame(trim(ob_get_clean()), 'onError trigger success'); - } - -/** - * testBehaviorValidateCallback method - * - * @return void - */ - public function testBehaviorValidateCallback() { - $Apple = new Apple(); - - $Apple->Behaviors->attach('Test'); - $this->assertSame($Apple->validates(), true); - - $Apple->Behaviors->attach('Test', array('validate' => 'on')); - $this->assertSame($Apple->validates(), false); - $this->assertSame($Apple->validationErrors, array('name' => array(true))); - - $Apple->Behaviors->attach('Test', array('validate' => 'stop')); - $this->assertSame($Apple->validates(), false); - $this->assertSame($Apple->validationErrors, array('name' => array(true, true))); - - $Apple->Behaviors->attach('Test', array('validate' => 'whitelist')); - $Apple->validates(); - $this->assertSame($Apple->whitelist, array()); - - $Apple->whitelist = array('unknown'); - $Apple->validates(); - $this->assertSame($Apple->whitelist, array('unknown', 'name')); - } - -/** - * testBehaviorValidateMethods method - * - * @return void - */ - public function testBehaviorValidateMethods() { - $Apple = new Apple(); - $Apple->Behaviors->attach('Test'); - $Apple->validate['color'] = 'validateField'; - - $result = $Apple->save(array('name' => 'Genetically Modified Apple', 'color' => 'Orange')); - $this->assertEquals(array('name', 'color', 'modified', 'created', 'id'), array_keys($result['Apple'])); - - $Apple->create(); - $result = $Apple->save(array('name' => 'Regular Apple', 'color' => 'Red')); - $this->assertFalse($result); - } - -/** - * testBehaviorMethodDispatching method - * - * @return void - */ - public function testBehaviorMethodDispatching() { - $Apple = new Apple(); - $Apple->Behaviors->attach('Test'); - - $expected = 'working'; - $this->assertEquals($expected, $Apple->testMethod()); - $this->assertEquals($expected, $Apple->Behaviors->dispatchMethod($Apple, 'testMethod')); - - $result = $Apple->Behaviors->dispatchMethod($Apple, 'wtf'); - $this->assertEquals(array('unhandled'), $result); - - $result = $Apple->{'look for the remote'}('in the couch'); - $expected = "Item.name = 'the remote' AND Location.name = 'the couch'"; - $this->assertEquals($expected, $result); - - $result = $Apple->{'look for THE REMOTE'}('in the couch'); - $expected = "Item.name = 'THE REMOTE' AND Location.name = 'the couch'"; - $this->assertEquals($expected, $result, 'Mapped method was lowercased.'); - } - -/** - * testBehaviorMethodDispatchingWithData method - * - * @return void - */ - public function testBehaviorMethodDispatchingWithData() { - $Apple = new Apple(); - $Apple->Behaviors->attach('Test'); - - $Apple->set('field', 'value'); - $this->assertTrue($Apple->testData()); - $this->assertTrue($Apple->data['Apple']['field_2']); - - $this->assertTrue($Apple->testData('one', 'two', 'three', 'four', 'five', 'six')); - } - -/** - * undocumented function - * - * @return void - */ - public function testBindModelCallsInBehaviors() { - // hasMany - $Article = new Article(); - $Article->unbindModel(array('hasMany' => array('Comment'))); - $result = $Article->find('first'); - $this->assertFalse(array_key_exists('Comment', $result)); - - $Article->Behaviors->attach('Test4'); - $result = $Article->find('first'); - $this->assertTrue(array_key_exists('Comment', $result)); - - // belongsTo - $Article->unbindModel(array('belongsTo' => array('User'))); - $result = $Article->find('first'); - $this->assertFalse(array_key_exists('User', $result)); - - $Article->Behaviors->attach('Test5'); - $result = $Article->find('first'); - $this->assertTrue(array_key_exists('User', $result)); - - // hasAndBelongsToMany - $Article->unbindModel(array('hasAndBelongsToMany' => array('Tag'))); - $result = $Article->find('first'); - $this->assertFalse(array_key_exists('Tag', $result)); - - $Article->Behaviors->attach('Test6'); - $result = $Article->find('first'); - $this->assertTrue(array_key_exists('Comment', $result)); - - // hasOne - $Comment = new Comment(); - $Comment->unbindModel(array('hasOne' => array('Attachment'))); - $result = $Comment->find('first'); - $this->assertFalse(array_key_exists('Attachment', $result)); - - $Comment->Behaviors->attach('Test7'); - $result = $Comment->find('first'); - $this->assertTrue(array_key_exists('Attachment', $result)); - } - -/** - * Test attach and detaching - * - * @return void - */ - public function testBehaviorAttachAndDetach() { - $Sample = new Sample(); - $Sample->actsAs = array('Test3' => array('bar'), 'Test2' => array('foo', 'bar')); - $Sample->Behaviors->init($Sample->alias, $Sample->actsAs); - $Sample->Behaviors->attach('Test2'); - $Sample->Behaviors->detach('Test3'); - - $Sample->Behaviors->trigger('beforeTest', array(&$Sample)); - } - -/** - * test that hasMethod works with basic functions. - * - * @return void - */ - public function testHasMethodBasic() { - $Sample = new Sample(); - $Collection = new BehaviorCollection(); - $Collection->init('Sample', array('Test', 'Test2')); - - $this->assertTrue($Collection->hasMethod('testMethod')); - $this->assertTrue($Collection->hasMethod('resolveMethod')); - - $this->assertFalse($Collection->hasMethod('No method')); - } - -/** - * test that hasMethod works with mapped methods. - * - * @return void - */ - public function testHasMethodMappedMethods() { - $Sample = new Sample(); - $Collection = new BehaviorCollection(); - $Collection->init('Sample', array('Test', 'Test2')); - - $this->assertTrue($Collection->hasMethod('look for the remote in the couch')); - $this->assertTrue($Collection->hasMethod('mappingRobotOnTheRoof')); - } - -/** - * test hasMethod returning a 'callback' - * - * @return void - */ - public function testHasMethodAsCallback() { - $Sample = new Sample(); - $Collection = new BehaviorCollection(); - $Collection->init('Sample', array('Test', 'Test2')); - - $result = $Collection->hasMethod('testMethod', true); - $expected = array('Test', 'testMethod'); - $this->assertEquals($expected, $result); - - $result = $Collection->hasMethod('resolveMethod', true); - $expected = array('Test2', 'resolveMethod'); - $this->assertEquals($expected, $result); - - $result = $Collection->hasMethod('mappingRobotOnTheRoof', true); - $expected = array('Test2', 'mapped', 'mappingRobotOnTheRoof'); - $this->assertEquals($expected, $result); - } - -} diff --git a/lib/Cake/Test/Case/Model/CakeSchemaTest.php b/lib/Cake/Test/Case/Model/CakeSchemaTest.php deleted file mode 100644 index a6a640417bf..00000000000 --- a/lib/Cake/Test/Case/Model/CakeSchemaTest.php +++ /dev/null @@ -1,1079 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.5550 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('CakeSchema', 'Model'); -App::uses('CakeTestFixture', 'TestSuite/Fixture'); - -/** - * Test for Schema database management - * - * @package Cake.Test.Case.Model - */ -class MyAppSchema extends CakeSchema { - -/** - * name property - * - * @var string 'MyApp' - */ - public $name = 'MyApp'; - -/** - * connection property - * - * @var string 'test' - */ - public $connection = 'test'; - -/** - * comments property - * - * @var array - */ - public $comments = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'post_id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'user_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => false, 'length' => 100), - 'comment' => array('type' => 'text', 'null' => false, 'default' => null), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'N', 'length' => 1), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - ); - -/** - * posts property - * - * @var array - */ - public $posts = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => true, 'default' => ''), - 'title' => array('type' => 'string', 'null' => false, 'default' => 'Title'), - 'body' => array('type' => 'text', 'null' => true, 'default' => null), - 'summary' => array('type' => 'text', 'null' => true), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'Y', 'length' => 1), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - ); - -/** - * _foo property - * - * @var array - */ - protected $_foo = array('bar'); - -/** - * setup method - * - * @param mixed $version - * @return void - */ - public function setup($version) { - } - -/** - * teardown method - * - * @param mixed $version - * @return void - */ - public function teardown($version) { - } - -/** - * getVar method - * - * @param string $var Name of var - * @return mixed - */ - public function getVar($var) { - if (!isset($this->$var)) { - return null; - } - return $this->$var; - } - -} - -/** - * TestAppSchema class - * - * @package Cake.Test.Case.Model - */ -class TestAppSchema extends CakeSchema { - -/** - * name property - * - * @var string 'MyApp' - */ - public $name = 'MyApp'; - -/** - * comments property - * - * @var array - */ - public $comments = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0,'key' => 'primary'), - 'article_id' => array('type' => 'integer', 'null' => false), - 'user_id' => array('type' => 'integer', 'null' => false), - 'comment' => array('type' => 'text', 'null' => true, 'default' => null), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'N', 'length' => 1), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - 'tableParameters' => array(), - ); - -/** - * posts property - * - * @var array - */ - public $posts = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => false), - 'body' => array('type' => 'text', 'null' => true, 'default' => null), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'N', 'length' => 1), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - 'tableParameters' => array(), - ); - -/** - * posts_tags property - * - * @var array - */ - public $posts_tags = array( - 'post_id' => array('type' => 'integer', 'null' => false, 'key' => 'primary'), - 'tag_id' => array('type' => 'string', 'null' => false, 'key' => 'primary'), - 'indexes' => array('posts_tag' => array('column' => array('tag_id', 'post_id'), 'unique' => 1)), - 'tableParameters' => array() - ); - -/** - * tags property - * - * @var array - */ - public $tags = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'tag' => array('type' => 'string', 'null' => false), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - 'tableParameters' => array() - ); - -/** - * datatypes property - * - * @var array - */ - public $datatypes = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'float_field' => array('type' => 'float', 'null' => false, 'length' => '5,2', 'default' => ''), - 'bool' => array('type' => 'boolean', 'null' => false, 'default' => false), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - 'tableParameters' => array() - ); - -/** - * setup method - * - * @param mixed $version - * @return void - */ - public function setup($version) { - } - -/** - * teardown method - * - * @param mixed $version - * @return void - */ - public function teardown($version) { - } - -} - -/** - * SchemaPost class - * - * @package Cake.Test.Case.Model - */ -class SchemaPost extends CakeTestModel { - -/** - * name property - * - * @var string 'SchemaPost' - */ - public $name = 'SchemaPost'; - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'posts'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('SchemaComment'); - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('SchemaTag'); -} - -/** - * SchemaComment class - * - * @package Cake.Test.Case.Model - */ -class SchemaComment extends CakeTestModel { - -/** - * name property - * - * @var string 'SchemaComment' - */ - public $name = 'SchemaComment'; - -/** - * useTable property - * - * @var string 'comments' - */ - public $useTable = 'comments'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('SchemaPost'); -} - -/** - * SchemaTag class - * - * @package Cake.Test.Case.Model - */ -class SchemaTag extends CakeTestModel { - -/** - * name property - * - * @var string 'SchemaTag' - */ - public $name = 'SchemaTag'; - -/** - * useTable property - * - * @var string 'tags' - */ - public $useTable = 'tags'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('SchemaPost'); -} - -/** - * SchemaDatatype class - * - * @package Cake.Test.Case.Model - */ -class SchemaDatatype extends CakeTestModel { - -/** - * name property - * - * @var string 'SchemaDatatype' - */ - public $name = 'SchemaDatatype'; - -/** - * useTable property - * - * @var string 'datatypes' - */ - public $useTable = 'datatypes'; -} - -/** - * Testdescribe class - * - * This class is defined purely to inherit the cacheSources variable otherwise - * testSchemaCreateTable will fail if listSources has already been called and - * its source cache populated - I.e. if the test is run within a group - * - * @uses CakeTestModel - * @package - * @package Cake.Test.Case.Model - */ -class Testdescribe extends CakeTestModel { - -/** - * name property - * - * @var string 'Testdescribe' - */ - public $name = 'Testdescribe'; -} - -/** - * SchemaCrossDatabase class - * - * @package Cake.Test.Case.Model - */ -class SchemaCrossDatabase extends CakeTestModel { - -/** - * name property - * - * @var string 'SchemaCrossDatabase' - */ - public $name = 'SchemaCrossDatabase'; - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'cross_database'; - -/** - * useDbConfig property - * - * @var string 'test2' - */ - public $useDbConfig = 'test2'; -} - -/** - * SchemaCrossDatabaseFixture class - * - * @package Cake.Test.Case.Model - */ -class SchemaCrossDatabaseFixture extends CakeTestFixture { - -/** - * name property - * - * @var string 'CrossDatabase' - */ - public $name = 'CrossDatabase'; - -/** - * table property - * - */ - public $table = 'cross_database'; - -/** - * fields property - * - * @var array - */ - public $fields = array( - 'id' => array('type' => 'integer', 'key' => 'primary'), - 'name' => 'string' - ); - -/** - * records property - * - * @var array - */ - public $records = array( - array('id' => 1, 'name' => 'First'), - array('id' => 2, 'name' => 'Second'), - ); -} - -/** - * SchemaPrefixAuthUser class - * - * @package Cake.Test.Case.Model - */ -class SchemaPrefixAuthUser extends CakeTestModel { - -/** - * name property - * - * @var string - */ - public $name = 'SchemaPrefixAuthUser'; - -/** - * table prefix - * - * @var string - */ - public $tablePrefix = 'auth_'; - -/** - * useTable - * - * @var string - */ - public $useTable = 'users'; -} - -/** - * CakeSchemaTest - * - * @package Cake.Test.Case.Model - */ -class CakeSchemaTest extends CakeTestCase { - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array( - 'core.post', 'core.tag', 'core.posts_tag', 'core.test_plugin_comment', - 'core.datatype', 'core.auth_user', 'core.author', - 'core.test_plugin_article', 'core.user', 'core.comment', - 'core.prefix_test' - ); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - ConnectionManager::getDataSource('test')->cacheSources = false; - $this->Schema = new TestAppSchema(); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - if (file_exists(TMP . 'tests' . DS . 'schema.php')) { - unlink(TMP . 'tests' . DS . 'schema.php'); - } - unset($this->Schema); - CakePlugin::unload(); - } - -/** - * testSchemaName method - * - * @return void - */ - public function testSchemaName() { - $Schema = new CakeSchema(); - $this->assertEquals(strtolower(APP_DIR), strtolower($Schema->name)); - - Configure::write('App.dir', 'Some.name.with.dots'); - $Schema = new CakeSchema(); - $this->assertEquals('SomeNameWithDots', $Schema->name); - - Configure::write('App.dir', 'app'); - } - -/** - * testSchemaRead method - * - * @return void - */ - public function testSchemaRead() { - $read = $this->Schema->read(array( - 'connection' => 'test', - 'name' => 'TestApp', - 'models' => array('SchemaPost', 'SchemaComment', 'SchemaTag', 'SchemaDatatype') - )); - unset($read['tables']['missing']); - - $expected = array('comments', 'datatypes', 'posts', 'posts_tags', 'tags'); - foreach ($expected as $table) { - $this->assertTrue(isset($read['tables'][$table]), 'Missing table ' . $table); - } - foreach ($this->Schema->tables as $table => $fields) { - $this->assertEquals(array_keys($fields), array_keys($read['tables'][$table])); - } - - if (isset($read['tables']['datatypes']['float_field']['length'])) { - $this->assertEquals( - $read['tables']['datatypes']['float_field']['length'], - $this->Schema->tables['datatypes']['float_field']['length'] - ); - } - - $this->assertEquals( - $read['tables']['datatypes']['float_field']['type'], - $this->Schema->tables['datatypes']['float_field']['type'] - ); - - $this->assertEquals( - $read['tables']['datatypes']['float_field']['null'], - $this->Schema->tables['datatypes']['float_field']['null'] - ); - - $db = ConnectionManager::getDataSource('test'); - $config = $db->config; - $config['prefix'] = 'schema_test_prefix_'; - ConnectionManager::create('schema_prefix', $config); - $read = $this->Schema->read(array('connection' => 'schema_prefix', 'models' => false)); - $this->assertTrue(empty($read['tables'])); - - $read = $this->Schema->read(array( - 'connection' => 'test', - 'name' => 'TestApp', - 'models' => array('SchemaComment', 'SchemaTag', 'SchemaPost') - )); - $this->assertFalse(isset($read['tables']['missing']['posts_tags']), 'Join table marked as missing'); - } - -/** - * testSchemaReadWithAppModel method - * - * @access public - * @return void - */ - public function testSchemaReadWithAppModel() { - $connections = ConnectionManager::enumConnectionObjects(); - ConnectionManager::drop('default'); - ConnectionManager::create('default', $connections['test']); - try { - $read = $this->Schema->read(array( - 'connection' => 'default', - 'name' => 'TestApp', - 'models' => array('AppModel') - )); - } catch(MissingTableException $mte) { - ConnectionManager::drop('default'); - $this->fail($mte->getMessage()); - } - ConnectionManager::drop('default'); - } - -/** - * testSchemaReadWithOddTablePrefix method - * - * @return void - */ - public function testSchemaReadWithOddTablePrefix() { - $config = ConnectionManager::getDataSource('test')->config; - $this->skipIf(!empty($config['prefix']), 'This test can not be executed with datasource prefix set.'); - - $SchemaPost = ClassRegistry::init('SchemaPost'); - $SchemaPost->tablePrefix = 'po'; - $SchemaPost->useTable = 'sts'; - $read = $this->Schema->read(array( - 'connection' => 'test', - 'name' => 'TestApp', - 'models' => array('SchemaPost') - )); - - $this->assertFalse(isset($read['tables']['missing']['posts']), 'Posts table was not read from tablePrefix'); - } - -/** - * test read() with tablePrefix properties. - * - * @return void - */ - public function testSchemaReadWithTablePrefix() { - $config = ConnectionManager::getDataSource('test')->config; - $this->skipIf(!empty($config['prefix']), 'This test can not be executed with datasource prefix set.'); - - $model = new SchemaPrefixAuthUser(); - - $Schema = new CakeSchema(); - $read = $Schema->read(array( - 'connection' => 'test', - 'name' => 'TestApp', - 'models' => array('SchemaPrefixAuthUser') - )); - unset($read['tables']['missing']); - $this->assertTrue(isset($read['tables']['auth_users']), 'auth_users key missing %s'); - } - -/** - * test reading schema with config prefix. - * - * @return void - */ - public function testSchemaReadWithConfigPrefix() { - $this->skipIf($this->db instanceof Sqlite, 'Cannot open 2 connections to Sqlite'); - - $db = ConnectionManager::getDataSource('test'); - $config = $db->config; - $this->skipIf(!empty($config['prefix']), 'This test can not be executed with datasource prefix set.'); - - $config['prefix'] = 'schema_test_prefix_'; - ConnectionManager::create('schema_prefix', $config); - $read = $this->Schema->read(array('connection' => 'schema_prefix', 'models' => false)); - $this->assertTrue(empty($read['tables'])); - - $config['prefix'] = 'prefix_'; - ConnectionManager::create('schema_prefix2', $config); - $read = $this->Schema->read(array( - 'connection' => 'schema_prefix2', - 'name' => 'TestApp', - 'models' => false)); - $this->assertTrue(isset($read['tables']['prefix_tests'])); - } - -/** - * test reading schema from plugins. - * - * @return void - */ - public function testSchemaReadWithPlugins() { - App::objects('model', null, false); - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - - $Schema = new CakeSchema(); - $Schema->plugin = 'TestPlugin'; - $read = $Schema->read(array( - 'connection' => 'test', - 'name' => 'TestApp', - 'models' => true - )); - unset($read['tables']['missing']); - $this->assertTrue(isset($read['tables']['auth_users'])); - $this->assertTrue(isset($read['tables']['authors'])); - $this->assertTrue(isset($read['tables']['test_plugin_comments'])); - $this->assertTrue(isset($read['tables']['posts'])); - $this->assertTrue(count($read['tables']) >= 4); - - App::build(); - } - -/** - * test reading schema with tables from another database. - * - * @return void - */ - public function testSchemaReadWithCrossDatabase() { - $config = ConnectionManager::enumConnectionObjects(); - $this->skipIf( - !isset($config['test']) || !isset($config['test2']), - 'Primary and secondary test databases not configured, ' . - 'skipping cross-database join tests. ' . - 'To run these tests, you must define $test and $test2 in your database configuration.' - ); - - $db = ConnectionManager::getDataSource('test2'); - $fixture = new SchemaCrossDatabaseFixture(); - $fixture->create($db); - $fixture->insert($db); - - $read = $this->Schema->read(array( - 'connection' => 'test', - 'name' => 'TestApp', - 'models' => array('SchemaCrossDatabase', 'SchemaPost') - )); - $this->assertTrue(isset($read['tables']['posts'])); - $this->assertFalse(isset($read['tables']['cross_database']), 'Cross database should not appear'); - $this->assertFalse(isset($read['tables']['missing']['cross_database']), 'Cross database should not appear'); - - $read = $this->Schema->read(array( - 'connection' => 'test2', - 'name' => 'TestApp', - 'models' => array('SchemaCrossDatabase', 'SchemaPost') - )); - $this->assertFalse(isset($read['tables']['posts']), 'Posts should not appear'); - $this->assertFalse(isset($read['tables']['posts']), 'Posts should not appear'); - $this->assertTrue(isset($read['tables']['cross_database'])); - - $fixture->drop($db); - } - -/** - * test that tables are generated correctly - * - * @return void - */ - public function testGenerateTable() { - $posts = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => false), - 'body' => array('type' => 'text', 'null' => true, 'default' => null), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'N', 'length' => 1), - 'created' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'updated' => array('type' => 'datetime', 'null' => true, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => true)), - ); - $result = $this->Schema->generateTable('posts', $posts); - $this->assertRegExp('/public \$posts/', $result); - } - -/** - * testSchemaWrite method - * - * @return void - */ - public function testSchemaWrite() { - $write = $this->Schema->write(array( - 'name' => 'MyOtherApp', - 'tables' => $this->Schema->tables, - 'path' => TMP . 'tests' - )); - $file = file_get_contents(TMP . 'tests' . DS . 'schema.php'); - $this->assertEquals($write, $file); - - require_once TMP . 'tests' . DS . 'schema.php'; - $OtherSchema = new MyOtherAppSchema(); - $this->assertEquals($this->Schema->tables, $OtherSchema->tables); - } - -/** - * testSchemaComparison method - * - * @return void - */ - public function testSchemaComparison() { - $New = new MyAppSchema(); - $compare = $New->compare($this->Schema); - $expected = array( - 'comments' => array( - 'add' => array( - 'post_id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'after' => 'id'), - 'title' => array('type' => 'string', 'null' => false, 'length' => 100, 'after' => 'user_id'), - ), - 'drop' => array( - 'article_id' => array('type' => 'integer', 'null' => false), - 'tableParameters' => array(), - ), - 'change' => array( - 'comment' => array('type' => 'text', 'null' => false, 'default' => null), - ) - ), - 'posts' => array( - 'add' => array( - 'summary' => array('type' => 'text', 'null' => true, 'after' => 'body'), - ), - 'drop' => array( - 'tableParameters' => array(), - ), - 'change' => array( - 'author_id' => array('type' => 'integer', 'null' => true, 'default' => ''), - 'title' => array('type' => 'string', 'null' => false, 'default' => 'Title'), - 'published' => array('type' => 'string', 'null' => true, 'default' => 'Y', 'length' => 1) - ) - ), - ); - $this->assertEquals($expected, $compare); - $this->assertNull($New->getVar('comments')); - $this->assertEquals(array('bar'), $New->getVar('_foo')); - - $tables = array( - 'missing' => array( - 'categories' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), - 'created' => array('type' => 'datetime', 'null' => false, 'default' => null), - 'modified' => array('type' => 'datetime', 'null' => false, 'default' => null), - 'name' => array('type' => 'string', 'null' => false, 'default' => null, 'length' => 100), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)), - 'tableParameters' => array('charset' => 'latin1', 'collate' => 'latin1_swedish_ci', 'engine' => 'MyISAM') - ) - ), - 'ratings' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), - 'foreign_key' => array('type' => 'integer', 'null' => false, 'default' => null), - 'model' => array('type' => 'varchar', 'null' => false, 'default' => null), - 'value' => array('type' => 'float', 'null' => false, 'length' => '5,2', 'default' => null), - 'created' => array('type' => 'datetime', 'null' => false, 'default' => null), - 'modified' => array('type' => 'datetime', 'null' => false, 'default' => null), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)), - 'tableParameters' => array('charset' => 'latin1', 'collate' => 'latin1_swedish_ci', 'engine' => 'MyISAM') - ) - ); - $compare = $New->compare($this->Schema, $tables); - $expected = array( - 'ratings' => array( - 'add' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), - 'foreign_key' => array('type' => 'integer', 'null' => false, 'default' => null, 'after' => 'id'), - 'model' => array('type' => 'varchar', 'null' => false, 'default' => null, 'after' => 'foreign_key'), - 'value' => array('type' => 'float', 'null' => false, 'length' => '5,2', 'default' => null, 'after' => 'model'), - 'created' => array('type' => 'datetime', 'null' => false, 'default' => null, 'after' => 'value'), - 'modified' => array('type' => 'datetime', 'null' => false, 'default' => null, 'after' => 'created'), - 'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1)), - 'tableParameters' => array('charset' => 'latin1', 'collate' => 'latin1_swedish_ci', 'engine' => 'MyISAM') - ) - ) - ); - $this->assertEquals($expected, $compare); - } - -/** - * test comparing '' and null and making sure they are different. - * - * @return void - */ - public function testCompareEmptyStringAndNull() { - $One = new CakeSchema(array( - 'posts' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), - 'name' => array('type' => 'string', 'null' => false, 'default' => '') - ) - )); - $Two = new CakeSchema(array( - 'posts' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'key' => 'primary'), - 'name' => array('type' => 'string', 'null' => false, 'default' => null) - ) - )); - $compare = $One->compare($Two); - $expected = array( - 'posts' => array( - 'change' => array( - 'name' => array('type' => 'string', 'null' => false, 'default' => null) - ) - ) - ); - $this->assertEquals($expected, $compare); - } - -/** - * Test comparing tableParameters and indexes. - * - * @return void - */ - public function testTableParametersAndIndexComparison() { - $old = array( - 'posts' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => false), - 'indexes' => array( - 'PRIMARY' => array('column' => 'id', 'unique' => true) - ), - 'tableParameters' => array( - 'charset' => 'latin1', - 'collate' => 'latin1_general_ci' - ) - ), - 'comments' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'post_id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'comment' => array('type' => 'text'), - 'indexes' => array( - 'PRIMARY' => array('column' => 'id', 'unique' => true), - 'post_id' => array('column' => 'post_id'), - ), - 'tableParameters' => array( - 'engine' => 'InnoDB', - 'charset' => 'latin1', - 'collate' => 'latin1_general_ci' - ) - ) - ); - $new = array( - 'posts' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => false), - 'indexes' => array( - 'PRIMARY' => array('column' => 'id', 'unique' => true), - 'author_id' => array('column' => 'author_id'), - ), - 'tableParameters' => array( - 'charset' => 'utf8', - 'collate' => 'utf8_general_ci', - 'engine' => 'MyISAM' - ) - ), - 'comments' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'post_id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'comment' => array('type' => 'text'), - 'indexes' => array( - 'PRIMARY' => array('column' => 'id', 'unique' => true), - ), - 'tableParameters' => array( - 'charset' => 'utf8', - 'collate' => 'utf8_general_ci' - ) - ) - ); - $compare = $this->Schema->compare($old, $new); - $expected = array( - 'posts' => array( - 'add' => array( - 'indexes' => array('author_id' => array('column' => 'author_id')), - ), - 'change' => array( - 'tableParameters' => array( - 'charset' => 'utf8', - 'collate' => 'utf8_general_ci', - 'engine' => 'MyISAM' - ) - ) - ), - 'comments' => array( - 'drop' => array( - 'indexes' => array('post_id' => array('column' => 'post_id')), - ), - 'change' => array( - 'tableParameters' => array( - 'charset' => 'utf8', - 'collate' => 'utf8_general_ci', - ) - ) - ) - ); - $this->assertEquals($expected, $compare); - } - -/** - * Test comparing with field changed from VARCHAR to DATETIME - * - * @return void - */ - public function testCompareVarcharToDatetime() { - $old = array( - 'posts' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => true, 'length' => 45), - 'indexes' => array( - 'PRIMARY' => array('column' => 'id', 'unique' => true) - ), - 'tableParameters' => array( - 'charset' => 'latin1', - 'collate' => 'latin1_general_ci' - ) - ), - ); - $new = array( - 'posts' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'datetime', 'null' => false), - 'indexes' => array( - 'PRIMARY' => array('column' => 'id', 'unique' => true) - ), - 'tableParameters' => array( - 'charset' => 'latin1', - 'collate' => 'latin1_general_ci' - ) - ), - ); - $compare = $this->Schema->compare($old, $new); - $expected = array( - 'posts' => array( - 'change' => array( - 'title' => array( - 'type' => 'datetime', - 'null' => false, - ) - ) - ), - ); - $this->assertEquals($expected, $compare, 'Invalid SQL, datetime does not have length'); - } - -/** - * testSchemaLoading method - * - * @return void - */ - public function testSchemaLoading() { - $Other = $this->Schema->load(array('name' => 'MyOtherApp', 'path' => TMP . 'tests')); - $this->assertEquals('MyOtherApp', $Other->name); - $this->assertEquals($Other->tables, $this->Schema->tables); - } - -/** - * test loading schema files inside of plugins. - * - * @return void - */ - public function testSchemaLoadingFromPlugin() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - $Other = $this->Schema->load(array('name' => 'TestPluginApp', 'plugin' => 'TestPlugin')); - $this->assertEquals('TestPluginApp', $Other->name); - $this->assertEquals(array('test_plugin_acos'), array_keys($Other->tables)); - - App::build(); - } - -/** - * testSchemaCreateTable method - * - * @return void - */ - public function testSchemaCreateTable() { - $db = ConnectionManager::getDataSource('test'); - $db->cacheSources = false; - - $Schema = new CakeSchema(array( - 'connection' => 'test', - 'testdescribes' => array( - 'id' => array('type' => 'integer', 'key' => 'primary'), - 'int_null' => array('type' => 'integer', 'null' => true), - 'int_not_null' => array('type' => 'integer', 'null' => false), - ), - )); - $sql = $db->createSchema($Schema); - - $col = $Schema->tables['testdescribes']['int_null']; - $col['name'] = 'int_null'; - $column = $this->db->buildColumn($col); - $this->assertRegExp('/' . preg_quote($column, '/') . '/', $sql); - - $col = $Schema->tables['testdescribes']['int_not_null']; - $col['name'] = 'int_not_null'; - $column = $this->db->buildColumn($col); - $this->assertRegExp('/' . preg_quote($column, '/') . '/', $sql); - } -} diff --git a/lib/Cake/Test/Case/Model/ConnectionManagerTest.php b/lib/Cake/Test/Case/Model/ConnectionManagerTest.php deleted file mode 100644 index 3bb1bc0c3d4..00000000000 --- a/lib/Cake/Test/Case/Model/ConnectionManagerTest.php +++ /dev/null @@ -1,346 +0,0 @@ -assertTrue(count($sources) >= 1); - - $connections = array('default', 'test', 'test'); - $this->assertTrue(count(array_intersect(array_keys($sources), $connections)) >= 1); - } - -/** - * testGetDataSource method - * - * @return void - */ - public function testGetDataSource() { - App::build(array( - 'Model/Datasource' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS . 'Datasource' . DS - ) - )); - - $name = 'test_get_datasource'; - $config = array('datasource' => 'Test2Source'); - - $connection = ConnectionManager::create($name, $config); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertTrue((bool)(count(array_keys($connections) >= 1))); - - $source = ConnectionManager::getDataSource('test_get_datasource'); - $this->assertTrue(is_object($source)); - ConnectionManager::drop('test_get_datasource'); - } - -/** - * testGetDataSourceException() method - * - * @return void - * @expectedException MissingDatasourceConfigException - */ - public function testGetDataSourceException() { - ConnectionManager::getDataSource('non_existent_source'); - } - -/** - * testGetPluginDataSource method - * - * @return void - */ - public function testGetPluginDataSource() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - $name = 'test_source'; - $config = array('datasource' => 'TestPlugin.TestSource'); - $connection = ConnectionManager::create($name, $config); - - $this->assertTrue(class_exists('TestSource')); - $this->assertEquals($connection->configKeyName, $name); - $this->assertEquals($connection->config, $config); - - ConnectionManager::drop($name); - } - -/** - * testGetPluginDataSourceAndPluginDriver method - * - * @return void - */ - public function testGetPluginDataSourceAndPluginDriver() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - $name = 'test_plugin_source_and_driver'; - $config = array('datasource' => 'TestPlugin.Database/TestDriver'); - - $connection = ConnectionManager::create($name, $config); - - $this->assertTrue(class_exists('TestSource')); - $this->assertTrue(class_exists('TestDriver')); - $this->assertEquals($connection->configKeyName, $name); - $this->assertEquals($connection->config, $config); - - ConnectionManager::drop($name); - } - -/** - * testGetLocalDataSourceAndPluginDriver method - * - * @return void - */ - public function testGetLocalDataSourceAndPluginDriver() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - $name = 'test_local_source_and_plugin_driver'; - $config = array('datasource' => 'TestPlugin.Database/DboDummy'); - - $connection = ConnectionManager::create($name, $config); - - $this->assertTrue(class_exists('DboSource')); - $this->assertTrue(class_exists('DboDummy')); - $this->assertEquals($connection->configKeyName, $name); - - ConnectionManager::drop($name); - } - -/** - * testGetPluginDataSourceAndLocalDriver method - * - * @return void - */ - public function testGetPluginDataSourceAndLocalDriver() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'Model/Datasource/Database' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS . 'Datasource' . DS . 'Database' . DS - ) - )); - - $name = 'test_plugin_source_and_local_driver'; - $config = array('datasource' => 'Database/TestLocalDriver'); - - $connection = ConnectionManager::create($name, $config); - - $this->assertTrue(class_exists('TestSource')); - $this->assertTrue(class_exists('TestLocalDriver')); - $this->assertEquals($connection->configKeyName, $name); - $this->assertEquals($connection->config, $config); - ConnectionManager::drop($name); - } - -/** - * testSourceList method - * - * @return void - */ - public function testSourceList() { - ConnectionManager::getDataSource('test'); - $sources = ConnectionManager::sourceList(); - $this->assertTrue(count($sources) >= 1); - $this->assertTrue(in_array('test', array_keys($sources))); - } - -/** - * testGetSourceName method - * - * @return void - */ - public function testGetSourceName() { - $connections = ConnectionManager::enumConnectionObjects(); - $source = ConnectionManager::getDataSource('test'); - $result = ConnectionManager::getSourceName($source); - - $this->assertEquals('test', $result); - - $source = new StdClass(); - $result = ConnectionManager::getSourceName($source); - $this->assertNull($result); - } - -/** - * testLoadDataSource method - * - * @return void - */ - public function testLoadDataSource() { - $connections = array( - array('classname' => 'Mysql', 'filename' => 'Mysql', 'package' => 'Database'), - array('classname' => 'Postgres', 'filename' => 'Postgres', 'package' => 'Database'), - array('classname' => 'Sqlite', 'filename' => 'Sqlite', 'package' => 'Database'), - ); - - foreach ($connections as $connection) { - $exists = class_exists($connection['classname']); - $loaded = ConnectionManager::loadDataSource($connection); - $this->assertEquals($loaded, !$exists, "Failed loading the {$connection['classname']} datasource"); - } - } - -/** - * testLoadDataSourceException() method - * - * @return void - * @expectedException MissingDatasourceException - */ - public function testLoadDataSourceException() { - $connection = array('classname' => 'NonExistentDataSource', 'filename' => 'non_existent'); - $loaded = ConnectionManager::loadDataSource($connection); - } - -/** - * testCreateDataSource method - * - * @return void - */ - public function testCreateDataSourceWithIntegrationTests() { - $name = 'test_created_connection'; - - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertTrue((bool)(count(array_keys($connections) >= 1))); - - $source = ConnectionManager::getDataSource('test'); - $this->assertTrue(is_object($source)); - - $config = $source->config; - $connection = ConnectionManager::create($name, $config); - - $this->assertTrue(is_object($connection)); - $this->assertEquals($name, $connection->configKeyName); - $this->assertEquals($name, ConnectionManager::getSourceName($connection)); - - $source = ConnectionManager::create(null, array()); - $this->assertEquals(null, $source); - - $source = ConnectionManager::create('another_test', array()); - $this->assertEquals(null, $source); - - $config = array('classname' => 'DboMysql', 'filename' => 'dbo' . DS . 'dbo_mysql'); - $source = ConnectionManager::create(null, $config); - $this->assertEquals(null, $source); - } - -/** - * testConnectionData method - * - * @return void - */ - public function testConnectionData() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'Model/Datasource' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS . 'Datasource' . DS - ) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - $expected = array( - 'datasource' => 'Test2Source' - ); - - ConnectionManager::create('connection1', array('datasource' => 'Test2Source')); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertEquals($expected, $connections['connection1']); - ConnectionManager::drop('connection1'); - - ConnectionManager::create('connection2', array('datasource' => 'Test2Source')); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertEquals($expected, $connections['connection2']); - ConnectionManager::drop('connection2'); - - ConnectionManager::create('connection3', array('datasource' => 'TestPlugin.TestSource')); - $connections = ConnectionManager::enumConnectionObjects(); - $expected['datasource'] = 'TestPlugin.TestSource'; - $this->assertEquals($expected, $connections['connection3']); - ConnectionManager::drop('connection3'); - - ConnectionManager::create('connection4', array('datasource' => 'TestPlugin.TestSource')); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertEquals($expected, $connections['connection4']); - ConnectionManager::drop('connection4'); - - ConnectionManager::create('connection5', array('datasource' => 'Test2OtherSource')); - $connections = ConnectionManager::enumConnectionObjects(); - $expected['datasource'] = 'Test2OtherSource'; - $this->assertEquals($expected, $connections['connection5']); - ConnectionManager::drop('connection5'); - - ConnectionManager::create('connection6', array('datasource' => 'Test2OtherSource')); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertEquals($expected, $connections['connection6']); - ConnectionManager::drop('connection6'); - - ConnectionManager::create('connection7', array('datasource' => 'TestPlugin.TestOtherSource')); - $connections = ConnectionManager::enumConnectionObjects(); - $expected['datasource'] = 'TestPlugin.TestOtherSource'; - $this->assertEquals($expected, $connections['connection7']); - ConnectionManager::drop('connection7'); - - ConnectionManager::create('connection8', array('datasource' => 'TestPlugin.TestOtherSource')); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertEquals($expected, $connections['connection8']); - ConnectionManager::drop('connection8'); - } - -/** - * Tests that a connection configuration can be deleted in runtime - * - * @return void - */ - public function testDrop() { - App::build(array( - 'Model/Datasource' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS . 'Datasource' . DS - ) - )); - ConnectionManager::create('droppable', array('datasource' => 'Test2Source')); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertEquals(array('datasource' => 'Test2Source'), $connections['droppable']); - - $this->assertTrue(ConnectionManager::drop('droppable')); - $connections = ConnectionManager::enumConnectionObjects(); - $this->assertFalse(isset($connections['droppable'])); - } -} diff --git a/lib/Cake/Test/Case/Model/Datasource/CakeSessionTest.php b/lib/Cake/Test/Case/Model/Datasource/CakeSessionTest.php deleted file mode 100644 index 57d863aaeae..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/CakeSessionTest.php +++ /dev/null @@ -1,704 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Datasource - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('CakeSession', 'Model/Datasource'); - -class TestCakeSession extends CakeSession { - - public static function setUserAgent($value) { - self::$_userAgent = $value; - } - - public static function setHost($host) { - self::_setHost($host); - } - -} - -/** - * CakeSessionTest class - * - * @package Cake.Test.Case.Model.Datasource - */ -class CakeSessionTest extends CakeTestCase { - - protected static $_gcDivisor; - -/** - * Fixtures used in the SessionTest - * - * @var array - */ - public $fixtures = array('core.session'); - -/** - * setup before class. - * - * @return void - */ - public static function setupBeforeClass() { - // Make sure garbage colector will be called - self::$_gcDivisor = ini_get('session.gc_divisor'); - ini_set('session.gc_divisor', '1'); - } - -/** - * teardown after class - * - * @return void - */ - public static function teardownAfterClass() { - // Revert to the default setting - ini_set('session.gc_divisor', self::$_gcDivisor); - } - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - Configure::write('Session', array( - 'defaults' => 'php', - 'cookie' => 'cakephp', - 'timeout' => 120, - 'cookieTimeout' => 120, - 'ini' => array(), - )); - TestCakeSession::init(); - } - -/** - * tearDown method - * - * @return void - */ - public function teardown() { - if (TestCakeSession::started()) { - TestCakeSession::clear(); - } - unset($_SESSION); - parent::teardown(); - } - -/** - * test setting ini properties with Session configuration. - * - * @return void - */ - public function testSessionConfigIniSetting() { - $_SESSION = null; - - Configure::write('Session', array( - 'cookie' => 'test', - 'checkAgent' => false, - 'timeout' => 86400, - 'ini' => array( - 'session.referer_check' => 'example.com', - 'session.use_trans_sid' => false - ) - )); - TestCakeSession::start(); - $this->assertEquals('', ini_get('session.use_trans_sid'), 'Ini value is incorrect'); - $this->assertEquals('example.com', ini_get('session.referer_check'), 'Ini value is incorrect'); - $this->assertEquals('test', ini_get('session.name'), 'Ini value is incorrect'); - } - -/** - * testSessionPath - * - * @return void - */ - public function testSessionPath() { - TestCakeSession::init('/index.php'); - $this->assertEquals('/', TestCakeSession::$path); - - TestCakeSession::init('/sub_dir/index.php'); - $this->assertEquals('/sub_dir/', TestCakeSession::$path); - } - -/** - * testCakeSessionPathEmpty - * - * @return void - */ - public function testCakeSessionPathEmpty() { - TestCakeSession::init(''); - $this->assertEquals('/', TestCakeSession::$path, 'Session path is empty, with "" as $base needs to be /'); - } - -/** - * testCakeSessionPathContainsParams - * - * @return void - */ - public function testCakeSessionPathContainsQuestion() { - TestCakeSession::init('/index.php?'); - $this->assertEquals('/', TestCakeSession::$path); - } - -/** - * testSetHost - * - * @return void - */ - public function testSetHost() { - TestCakeSession::init(); - TestCakeSession::setHost('cakephp.org'); - $this->assertEquals('cakephp.org', TestCakeSession::$host); - } - -/** - * testSetHostWithPort - * - * @return void - */ - public function testSetHostWithPort() { - TestCakeSession::init(); - TestCakeSession::setHost('cakephp.org:443'); - $this->assertEquals('cakephp.org', TestCakeSession::$host); - } - -/** - * test valid with bogus user agent. - * - * @return void - */ - public function testValidBogusUserAgent() { - Configure::write('Session.checkAgent', true); - TestCakeSession::start(); - $this->assertTrue(TestCakeSession::valid(), 'Newly started session should be valid'); - - TestCakeSession::userAgent('bogus!'); - $this->assertFalse(TestCakeSession::valid(), 'user agent mismatch should fail.'); - } - -/** - * test valid with bogus user agent. - * - * @return void - */ - public function testValidTimeExpiry() { - Configure::write('Session.checkAgent', true); - TestCakeSession::start(); - $this->assertTrue(TestCakeSession::valid(), 'Newly started session should be valid'); - - TestCakeSession::$time = strtotime('next year'); - $this->assertFalse(TestCakeSession::valid(), 'time should cause failure.'); - } - -/** - * testCheck method - * - * @return void - */ - public function testCheck() { - TestCakeSession::write('SessionTestCase', 'value'); - $this->assertTrue(TestCakeSession::check('SessionTestCase')); - - $this->assertFalse(TestCakeSession::check('NotExistingSessionTestCase'), false); - } - -/** - * testSimpleRead method - * - * @return void - */ - public function testSimpleRead() { - TestCakeSession::write('testing', '1,2,3'); - $result = TestCakeSession::read('testing'); - $this->assertEquals('1,2,3', $result); - - TestCakeSession::write('testing', array('1' => 'one', '2' => 'two','3' => 'three')); - $result = TestCakeSession::read('testing.1'); - $this->assertEquals('one', $result); - - $result = TestCakeSession::read('testing'); - $this->assertEquals(array('1' => 'one', '2' => 'two', '3' => 'three'), $result); - - $result = TestCakeSession::read(); - $this->assertTrue(isset($result['testing'])); - $this->assertTrue(isset($result['Config'])); - $this->assertTrue(isset($result['Config']['userAgent'])); - - TestCakeSession::write('This.is.a.deep.array.my.friend', 'value'); - $result = TestCakeSession::read('This.is.a.deep.array.my.friend'); - $this->assertEquals('value', $result); - } - -/** - * testReadyEmpty - * - * @return void - */ - public function testReadyEmpty() { - $this->assertFalse(TestCakeSession::read('')); - } - -/** - * test writing a hash of values/ - * - * @return void - */ - public function testWriteArray() { - $result = TestCakeSession::write(array( - 'one' => 1, - 'two' => 2, - 'three' => array('something'), - 'null' => null - )); - $this->assertTrue($result); - $this->assertEquals(1, TestCakeSession::read('one')); - $this->assertEquals(array('something'), TestCakeSession::read('three')); - $this->assertEquals(null, TestCakeSession::read('null')); - } - -/** - * testWriteEmptyKey - * - * @return void - */ - public function testWriteEmptyKey() { - $this->assertFalse(TestCakeSession::write('', 'graham')); - $this->assertFalse(TestCakeSession::write('', '')); - $this->assertFalse(TestCakeSession::write('')); - } - -/** - * testId method - * - * @return void - */ - public function testId() { - TestCakeSession::destroy(); - - $result = TestCakeSession::id(); - $expected = session_id(); - $this->assertEquals($expected, $result); - - TestCakeSession::id('MySessionId'); - $result = TestCakeSession::id(); - $this->assertEquals('MySessionId', $result); - } - -/** - * testStarted method - * - * @return void - */ - public function testStarted() { - unset($_SESSION); - $_SESSION = null; - - $this->assertFalse(TestCakeSession::started()); - $this->assertTrue(TestCakeSession::start()); - $this->assertTrue(TestCakeSession::started()); - } - -/** - * testError method - * - * @return void - */ - public function testError() { - TestCakeSession::read('Does.not.exist'); - $result = TestCakeSession::error(); - $this->assertEquals("Does.not.exist doesn't exist", $result); - - TestCakeSession::delete('Failing.delete'); - $result = TestCakeSession::error(); - $this->assertEquals("Failing.delete doesn't exist", $result); - } - -/** - * testDel method - * - * @return void - */ - public function testDelete() { - $this->assertTrue(TestCakeSession::write('Delete.me', 'Clearing out')); - $this->assertTrue(TestCakeSession::delete('Delete.me')); - $this->assertFalse(TestCakeSession::check('Delete.me')); - $this->assertTrue(TestCakeSession::check('Delete')); - - $this->assertTrue(TestCakeSession::write('Clearing.sale', 'everything must go')); - $this->assertTrue(TestCakeSession::delete('Clearing')); - $this->assertFalse(TestCakeSession::check('Clearing.sale')); - $this->assertFalse(TestCakeSession::check('Clearing')); - } - -/** - * testDestroy method - * - * @return void - */ - public function testDestroy() { - TestCakeSession::write('bulletProof', 'invincible'); - $id = TestCakeSession::id(); - TestCakeSession::destroy(); - - $this->assertFalse(TestCakeSession::check('bulletProof')); - $this->assertNotEquals(TestCakeSession::id(), $id); - } - -/** - * testCheckingSavedEmpty method - * - * @return void - */ - public function testCheckingSavedEmpty() { - $this->assertTrue(TestCakeSession::write('SessionTestCase', 0)); - $this->assertTrue(TestCakeSession::check('SessionTestCase')); - - $this->assertTrue(TestCakeSession::write('SessionTestCase', '0')); - $this->assertTrue(TestCakeSession::check('SessionTestCase')); - - $this->assertTrue(TestCakeSession::write('SessionTestCase', false)); - $this->assertTrue(TestCakeSession::check('SessionTestCase')); - - $this->assertTrue(TestCakeSession::write('SessionTestCase', null)); - $this->assertFalse(TestCakeSession::check('SessionTestCase')); - } - -/** - * testCheckKeyWithSpaces method - * - * @return void - */ - public function testCheckKeyWithSpaces() { - $this->assertTrue(TestCakeSession::write('Session Test', "test")); - $this->assertTrue(TestCakeSession::check('Session Test')); - TestCakeSession::delete('Session Test'); - - $this->assertTrue(TestCakeSession::write('Session Test.Test Case', "test")); - $this->assertTrue(TestCakeSession::check('Session Test.Test Case')); - } - -/** - * testCheckEmpty - * - * @return void - */ - public function testCheckEmpty() { - $this->assertFalse(TestCakeSession::check()); - } - -/** - * test key exploitation - * - * @return void - */ - public function testKeyExploit() { - $key = "a'] = 1; phpinfo(); \$_SESSION['a"; - $result = TestCakeSession::write($key, 'haxored'); - $this->assertTrue($result); - - $result = TestCakeSession::read($key); - $this->assertEquals('haxored', $result); - } - -/** - * testReadingSavedEmpty method - * - * @return void - */ - public function testReadingSavedEmpty() { - TestCakeSession::write('SessionTestCase', 0); - $this->assertEquals(0, TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', '0'); - $this->assertEquals('0', TestCakeSession::read('SessionTestCase')); - $this->assertFalse(TestCakeSession::read('SessionTestCase') === 0); - - TestCakeSession::write('SessionTestCase', false); - $this->assertFalse(TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', null); - $this->assertEquals(null, TestCakeSession::read('SessionTestCase')); - } - -/** - * testCheckUserAgentFalse method - * - * @return void - */ - public function testCheckUserAgentFalse() { - Configure::write('Session.checkAgent', false); - TestCakeSession::setUserAgent(md5('http://randomdomainname.com' . Configure::read('Security.salt'))); - $this->assertTrue(TestCakeSession::valid()); - } - -/** - * testCheckUserAgentTrue method - * - * @return void - */ - public function testCheckUserAgentTrue() { - Configure::write('Session.checkAgent', true); - TestCakeSession::$error = false; - $agent = md5('http://randomdomainname.com' . Configure::read('Security.salt')); - - TestCakeSession::write('Config.userAgent', md5('Hacking you!')); - TestCakeSession::setUserAgent($agent); - $this->assertFalse(TestCakeSession::valid()); - } - -/** - * testReadAndWriteWithDatabaseStorage method - * - * @return void - */ - public function testReadAndWriteWithCakeStorage() { - Configure::write('Session.defaults', 'cake'); - - TestCakeSession::init(); - TestCakeSession::start(); - - TestCakeSession::write('SessionTestCase', 0); - $this->assertEquals(0, TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', '0'); - $this->assertEquals('0', TestCakeSession::read('SessionTestCase')); - $this->assertFalse(TestCakeSession::read('SessionTestCase') === 0); - - TestCakeSession::write('SessionTestCase', false); - $this->assertFalse(TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', null); - $this->assertEquals(null, TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', 'This is a Test'); - $this->assertEquals('This is a Test', TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', 'This is a Test'); - TestCakeSession::write('SessionTestCase', 'This was updated'); - $this->assertEquals('This was updated', TestCakeSession::read('SessionTestCase')); - - TestCakeSession::destroy(); - $this->assertNull(TestCakeSession::read('SessionTestCase')); - } - -/** - * test using a handler from app/Model/Datasource/Session. - * - * @return void - */ - public function testUsingAppLibsHandler() { - App::build(array( - 'Model/Datasource/Session' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS . 'Datasource' . DS . 'Session' . DS - ), - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - Configure::write('Session', array( - 'defaults' => 'cake', - 'handler' => array( - 'engine' => 'TestAppLibSession' - ) - )); - TestCakeSession::destroy(); - $this->assertTrue(TestCakeSession::started()); - - App::build(); - } - -/** - * test using a handler from a plugin. - * - * @return void - */ - public function testUsingPluginHandler() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load('TestPlugin'); - - Configure::write('Session', array( - 'defaults' => 'cake', - 'handler' => array( - 'engine' => 'TestPlugin.TestPluginSession' - ) - )); - - TestCakeSession::destroy(); - $this->assertTrue(TestCakeSession::started()); - - App::build(); - } - -/** - * testReadAndWriteWithDatabaseStorage method - * - * @return void - */ - public function testReadAndWriteWithCacheStorage() { - Configure::write('Session.defaults', 'cache'); - - TestCakeSession::init(); - TestCakeSession::destroy(); - - TestCakeSession::write('SessionTestCase', 0); - $this->assertEquals(0, TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', '0'); - $this->assertEquals('0', TestCakeSession::read('SessionTestCase')); - $this->assertFalse(TestCakeSession::read('SessionTestCase') === 0); - - TestCakeSession::write('SessionTestCase', false); - $this->assertFalse(TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', null); - $this->assertEquals(null, TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', 'This is a Test'); - $this->assertEquals('This is a Test', TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', 'This is a Test'); - TestCakeSession::write('SessionTestCase', 'This was updated'); - $this->assertEquals('This was updated', TestCakeSession::read('SessionTestCase')); - - TestCakeSession::destroy(); - $this->assertNull(TestCakeSession::read('SessionTestCase')); - } - -/** - * test that changing the config name of the cache config works. - * - * @return void - */ - public function testReadAndWriteWithCustomCacheConfig() { - Configure::write('Session.defaults', 'cache'); - Configure::write('Session.handler.config', 'session_test'); - - Cache::config('session_test', array( - 'engine' => 'File', - 'prefix' => 'session_test_', - )); - - TestCakeSession::init(); - TestCakeSession::start(); - - TestCakeSession::write('SessionTestCase', 'Some value'); - $this->assertEquals('Some value', TestCakeSession::read('SessionTestCase')); - $id = TestCakeSession::id(); - - Cache::delete($id, 'session_test'); - } - -/** - * testReadAndWriteWithDatabaseStorage method - * - * @return void - */ - public function testReadAndWriteWithDatabaseStorage() { - Configure::write('Session.defaults', 'database'); - Configure::write('Session.handler.table', 'sessions'); - Configure::write('Session.handler.model', 'Session'); - Configure::write('Session.handler.database', 'test'); - - TestCakeSession::init(); - TestCakeSession::start(); - - TestCakeSession::write('SessionTestCase', 0); - $this->assertEquals(0, TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', '0'); - $this->assertEquals('0', TestCakeSession::read('SessionTestCase')); - $this->assertFalse(TestCakeSession::read('SessionTestCase') === 0); - - TestCakeSession::write('SessionTestCase', false); - $this->assertFalse(TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', null); - $this->assertEquals(null, TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', 'This is a Test'); - $this->assertEquals('This is a Test', TestCakeSession::read('SessionTestCase')); - - TestCakeSession::write('SessionTestCase', 'Some additional data'); - $this->assertEquals('Some additional data', TestCakeSession::read('SessionTestCase')); - - TestCakeSession::destroy(); - $this->assertNull(TestCakeSession::read('SessionTestCase')); - - Configure::write('Session', array( - 'defaults' => 'php' - )); - TestCakeSession::init(); - } - -/** - * testSessionTimeout method - * - * @return void - */ - public function testSessionTimeout() { - Configure::write('debug', 2); - Configure::write('Session.autoRegenerate', false); - - $timeoutSeconds = Configure::read('Session.timeout') * 60; - - TestCakeSession::destroy(); - TestCakeSession::write('Test', 'some value'); - - $this->assertEquals(time() + $timeoutSeconds, CakeSession::$sessionTime); - $this->assertEquals(10, $_SESSION['Config']['countdown']); - $this->assertEquals(CakeSession::$sessionTime, $_SESSION['Config']['time']); - $this->assertEquals(time(), CakeSession::$time); - $this->assertEquals(time() + $timeoutSeconds, $_SESSION['Config']['time']); - - Configure::write('Session.harden', true); - TestCakeSession::destroy(); - - TestCakeSession::write('Test', 'some value'); - $this->assertEquals(time() + $timeoutSeconds, CakeSession::$sessionTime); - $this->assertEquals(10, $_SESSION['Config']['countdown']); - $this->assertEquals(CakeSession::$sessionTime, $_SESSION['Config']['time']); - $this->assertEquals(time(), CakeSession::$time); - $this->assertEquals(CakeSession::$time + $timeoutSeconds, $_SESSION['Config']['time']); - } - -/** - * Test that cookieTimeout matches timeout when unspecified. - * - * @return void - */ - public function testCookieTimeoutFallback() { - $_SESSION = null; - Configure::write('Session', array( - 'defaults' => 'php', - 'timeout' => 400, - )); - TestCakeSession::start(); - $this->assertEquals(400, Configure::read('Session.cookieTimeout')); - $this->assertEquals(400, Configure::read('Session.timeout')); - - $_SESSION = null; - Configure::write('Session', array( - 'defaults' => 'php', - 'timeout' => 400, - 'cookieTimeout' => 600 - )); - TestCakeSession::start(); - $this->assertEquals(600, Configure::read('Session.cookieTimeout')); - $this->assertEquals(400, Configure::read('Session.timeout')); - } - -} diff --git a/lib/Cake/Test/Case/Model/Datasource/Database/MysqlTest.php b/lib/Cake/Test/Case/Model/Datasource/Database/MysqlTest.php deleted file mode 100644 index 3024a060744..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/Database/MysqlTest.php +++ /dev/null @@ -1,3582 +0,0 @@ -Dbo = ConnectionManager::getDataSource('test'); - if (!($this->Dbo instanceof Mysql)) { - $this->markTestSkipped('The MySQL extension is not available.'); - } - $this->_debug = Configure::read('debug'); - Configure::write('debug', 1); - $this->model = ClassRegistry::init('MysqlTestModel'); - } - -/** - * Sets up a Dbo class instance for testing - * - */ - public function tearDown() { - unset($this->model); - ClassRegistry::flush(); - Configure::write('debug', $this->_debug); - } - -/** - * Test Dbo value method - * - * @group quoting - */ - public function testQuoting() { - $result = $this->Dbo->fields($this->model); - $expected = array( - '`MysqlTestModel`.`id`', - '`MysqlTestModel`.`client_id`', - '`MysqlTestModel`.`name`', - '`MysqlTestModel`.`login`', - '`MysqlTestModel`.`passwd`', - '`MysqlTestModel`.`addr_1`', - '`MysqlTestModel`.`addr_2`', - '`MysqlTestModel`.`zip_code`', - '`MysqlTestModel`.`city`', - '`MysqlTestModel`.`country`', - '`MysqlTestModel`.`phone`', - '`MysqlTestModel`.`fax`', - '`MysqlTestModel`.`url`', - '`MysqlTestModel`.`email`', - '`MysqlTestModel`.`comments`', - '`MysqlTestModel`.`last_login`', - '`MysqlTestModel`.`created`', - '`MysqlTestModel`.`updated`' - ); - $this->assertEquals($expected, $result); - - $expected = 1.2; - $result = $this->Dbo->value(1.2, 'float'); - $this->assertEquals($expected, $result); - - $expected = "'1,2'"; - $result = $this->Dbo->value('1,2', 'float'); - $this->assertEquals($expected, $result); - - $expected = "'4713e29446'"; - $result = $this->Dbo->value('4713e29446'); - - $this->assertEquals($expected, $result); - - $expected = 'NULL'; - $result = $this->Dbo->value('', 'integer'); - $this->assertEquals($expected, $result); - - $expected = "'0'"; - $result = $this->Dbo->value('', 'boolean'); - $this->assertEquals($expected, $result); - - $expected = 10010001; - $result = $this->Dbo->value(10010001); - $this->assertEquals($expected, $result); - - $expected = "'00010010001'"; - $result = $this->Dbo->value('00010010001'); - $this->assertEquals($expected, $result); - } - -/** - * test that localized floats don't cause trouble. - * - * @group quoting - * @return void - */ - public function testLocalizedFloats() { - $this->skipIf(DS === '\\', 'The locale is not supported in Windows and affect the others tests.'); - - $restore = setlocale(LC_ALL, 0); - setlocale(LC_ALL, 'de_DE'); - - $result = $this->Dbo->value(3.141593); - $this->assertEquals('3.141593', $result); - - $result = $this->db->value(3.141593, 'float'); - $this->assertEquals('3.141593', $result); - - $result = $this->db->value(1234567.11, 'float'); - $this->assertEquals('1234567.11', $result); - - $result = $this->db->value(123456.45464748, 'float'); - $this->assertContains('123456.454647', $result); - - $result = $this->db->value(0.987654321, 'float'); - $this->assertEquals('0.987654321', (string)$result); - - $result = $this->db->value(2.2E-54, 'float'); - $this->assertEquals('2.2E-54', (string)$result); - - $result = $this->db->value(2.2E-54); - $this->assertEquals('2.2E-54', (string)$result); - - setlocale(LC_ALL, $restore); - } - -/** - * test that scientific notations are working correctly - * - * @return void - */ - public function testScientificNotation() { - $result = $this->db->value(2.2E-54, 'float'); - $this->assertEquals('2.2E-54', (string)$result); - - $result = $this->db->value(2.2E-54); - $this->assertEquals('2.2E-54', (string)$result); - } - -/** - * testTinyintCasting method - * - * - * @return void - */ - public function testTinyintCasting() { - $this->Dbo->cacheSources = false; - $tableName = 'tinyint_' . uniqid(); - $this->Dbo->rawQuery('CREATE TABLE ' . $this->Dbo->fullTableName($tableName) . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id));'); - - $this->model = new CakeTestModel(array( - 'name' => 'Tinyint', 'table' => $tableName, 'ds' => 'test' - )); - - $result = $this->model->schema(); - $this->assertEquals('boolean', $result['bool']['type']); - $this->assertEquals('integer', $result['small_int']['type']); - - $this->assertTrue((bool)$this->model->save(array('bool' => 5, 'small_int' => 5))); - $result = $this->model->find('first'); - $this->assertSame($result['Tinyint']['bool'], true); - $this->assertSame($result['Tinyint']['small_int'], '5'); - $this->model->deleteAll(true); - - $this->assertTrue((bool)$this->model->save(array('bool' => 0, 'small_int' => 100))); - $result = $this->model->find('first'); - $this->assertSame($result['Tinyint']['bool'], false); - $this->assertSame($result['Tinyint']['small_int'], '100'); - $this->model->deleteAll(true); - - $this->assertTrue((bool)$this->model->save(array('bool' => true, 'small_int' => 0))); - $result = $this->model->find('first'); - $this->assertSame($result['Tinyint']['bool'], true); - $this->assertSame($result['Tinyint']['small_int'], '0'); - $this->model->deleteAll(true); - - $this->Dbo->rawQuery('DROP TABLE ' . $this->Dbo->fullTableName($tableName)); - } - -/** - * testLastAffected method - * - * - * @return void - */ - public function testLastAffected() { - $this->Dbo->cacheSources = false; - $tableName = 'tinyint_' . uniqid(); - $this->Dbo->rawQuery('CREATE TABLE ' . $this->Dbo->fullTableName($tableName) . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id));'); - - $this->model = new CakeTestModel(array( - 'name' => 'Tinyint', 'table' => $tableName, 'ds' => 'test' - )); - - $this->assertTrue((bool)$this->model->save(array('bool' => 5, 'small_int' => 5))); - $this->assertEquals(1, $this->model->find('count')); - $this->model->deleteAll(true); - $result = $this->Dbo->lastAffected(); - $this->assertEquals(1, $result); - $this->assertEquals(0, $this->model->find('count')); - - $this->Dbo->rawQuery('DROP TABLE ' . $this->Dbo->fullTableName($tableName)); - } - -/** - * testIndexDetection method - * - * @group indices - * @return void - */ - public function testIndexDetection() { - $this->Dbo->cacheSources = false; - - $name = $this->Dbo->fullTableName('simple'); - $this->Dbo->rawQuery('CREATE TABLE ' . $name . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id));'); - $expected = array('PRIMARY' => array('column' => 'id', 'unique' => 1)); - $result = $this->Dbo->index('simple', false); - $this->Dbo->rawQuery('DROP TABLE ' . $name); - $this->assertEquals($expected, $result); - - $name = $this->Dbo->fullTableName('with_a_key'); - $this->Dbo->rawQuery('CREATE TABLE ' . $name . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id), KEY `pointless_bool` ( `bool` ));'); - $expected = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'pointless_bool' => array('column' => 'bool', 'unique' => 0), - ); - $result = $this->Dbo->index('with_a_key', false); - $this->Dbo->rawQuery('DROP TABLE ' . $name); - $this->assertEquals($expected, $result); - - $name = $this->Dbo->fullTableName('with_two_keys'); - $this->Dbo->rawQuery('CREATE TABLE ' . $name . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id), KEY `pointless_bool` ( `bool` ), KEY `pointless_small_int` ( `small_int` ));'); - $expected = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'pointless_bool' => array('column' => 'bool', 'unique' => 0), - 'pointless_small_int' => array('column' => 'small_int', 'unique' => 0), - ); - $result = $this->Dbo->index('with_two_keys', false); - $this->Dbo->rawQuery('DROP TABLE ' . $name); - $this->assertEquals($expected, $result); - - $name = $this->Dbo->fullTableName('with_compound_keys'); - $this->Dbo->rawQuery('CREATE TABLE ' . $name . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id), KEY `pointless_bool` ( `bool` ), KEY `pointless_small_int` ( `small_int` ), KEY `one_way` ( `bool`, `small_int` ));'); - $expected = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'pointless_bool' => array('column' => 'bool', 'unique' => 0), - 'pointless_small_int' => array('column' => 'small_int', 'unique' => 0), - 'one_way' => array('column' => array('bool', 'small_int'), 'unique' => 0), - ); - $result = $this->Dbo->index('with_compound_keys', false); - $this->Dbo->rawQuery('DROP TABLE ' . $name); - $this->assertEquals($expected, $result); - - $name = $this->Dbo->fullTableName('with_multiple_compound_keys'); - $this->Dbo->rawQuery('CREATE TABLE ' . $name . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id), KEY `pointless_bool` ( `bool` ), KEY `pointless_small_int` ( `small_int` ), KEY `one_way` ( `bool`, `small_int` ), KEY `other_way` ( `small_int`, `bool` ));'); - $expected = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'pointless_bool' => array('column' => 'bool', 'unique' => 0), - 'pointless_small_int' => array('column' => 'small_int', 'unique' => 0), - 'one_way' => array('column' => array('bool', 'small_int'), 'unique' => 0), - 'other_way' => array('column' => array('small_int', 'bool'), 'unique' => 0), - ); - $result = $this->Dbo->index('with_multiple_compound_keys', false); - $this->Dbo->rawQuery('DROP TABLE ' . $name); - $this->assertEquals($expected, $result); - } - -/** - * testBuildColumn method - * - * @return void - */ - public function testBuildColumn() { - $restore = $this->Dbo->columns; - $this->Dbo->columns = array('varchar(255)' => 1); - $data = array( - 'name' => 'testName', - 'type' => 'varchar(255)', - 'default', - 'null' => true, - 'key', - 'comment' => 'test' - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`testName` DEFAULT NULL COMMENT \'test\''; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'testName', - 'type' => 'varchar(255)', - 'default', - 'null' => true, - 'key', - 'charset' => 'utf8', - 'collate' => 'utf8_unicode_ci' - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`testName` CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL'; - $this->assertEquals($expected, $result); - $this->Dbo->columns = $restore; - } - -/** - * MySQL 4.x returns index data in a different format, - * Using a mock ensure that MySQL 4.x output is properly parsed. - * - * @group indices - * @return void - */ - public function testIndexOnMySQL4Output() { - $name = $this->Dbo->fullTableName('simple'); - - $mockDbo = $this->getMock('Mysql', array('connect', '_execute', 'getVersion')); - $columnData = array( - array('0' => array( - 'Table' => 'with_compound_keys', - 'Non_unique' => '0', - 'Key_name' => 'PRIMARY', - 'Seq_in_index' => '1', - 'Column_name' => 'id', - 'Collation' => 'A', - 'Cardinality' => '0', - 'Sub_part' => null, - 'Packed' => null, - 'Null' => '', - 'Index_type' => 'BTREE', - 'Comment' => '' - )), - array('0' => array( - 'Table' => 'with_compound_keys', - 'Non_unique' => '1', - 'Key_name' => 'pointless_bool', - 'Seq_in_index' => '1', - 'Column_name' => 'bool', - 'Collation' => 'A', - 'Cardinality' => null, - 'Sub_part' => null, - 'Packed' => null, - 'Null' => 'YES', - 'Index_type' => 'BTREE', - 'Comment' => '' - )), - array('0' => array( - 'Table' => 'with_compound_keys', - 'Non_unique' => '1', - 'Key_name' => 'pointless_small_int', - 'Seq_in_index' => '1', - 'Column_name' => 'small_int', - 'Collation' => 'A', - 'Cardinality' => null, - 'Sub_part' => null, - 'Packed' => null, - 'Null' => 'YES', - 'Index_type' => 'BTREE', - 'Comment' => '' - )), - array('0' => array( - 'Table' => 'with_compound_keys', - 'Non_unique' => '1', - 'Key_name' => 'one_way', - 'Seq_in_index' => '1', - 'Column_name' => 'bool', - 'Collation' => 'A', - 'Cardinality' => null, - 'Sub_part' => null, - 'Packed' => null, - 'Null' => 'YES', - 'Index_type' => 'BTREE', - 'Comment' => '' - )), - array('0' => array( - 'Table' => 'with_compound_keys', - 'Non_unique' => '1', - 'Key_name' => 'one_way', - 'Seq_in_index' => '2', - 'Column_name' => 'small_int', - 'Collation' => 'A', - 'Cardinality' => null, - 'Sub_part' => null, - 'Packed' => null, - 'Null' => 'YES', - 'Index_type' => 'BTREE', - 'Comment' => '' - )) - ); - - $mockDbo->expects($this->once())->method('getVersion')->will($this->returnValue('4.1')); - $resultMock = $this->getMock('PDOStatement', array('fetch')); - $mockDbo->expects($this->once()) - ->method('_execute') - ->with('SHOW INDEX FROM ' . $name) - ->will($this->returnValue($resultMock)); - - foreach ($columnData as $i => $data) { - $resultMock->expects($this->at($i))->method('fetch')->will($this->returnValue((object)$data)); - } - - $result = $mockDbo->index($name, false); - $expected = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'pointless_bool' => array('column' => 'bool', 'unique' => 0), - 'pointless_small_int' => array('column' => 'small_int', 'unique' => 0), - 'one_way' => array('column' => array('bool', 'small_int'), 'unique' => 0), - ); - $this->assertEquals($expected, $result); - } - -/** - * testColumn method - * - * @return void - */ - public function testColumn() { - $result = $this->Dbo->column('varchar(50)'); - $expected = 'string'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('text'); - $expected = 'text'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('int(11)'); - $expected = 'integer'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('int(11) unsigned'); - $expected = 'integer'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('tinyint(1)'); - $expected = 'boolean'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('boolean'); - $expected = 'boolean'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('float'); - $expected = 'float'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('float unsigned'); - $expected = 'float'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('double unsigned'); - $expected = 'float'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->column('decimal(14,7) unsigned'); - $expected = 'float'; - $this->assertEquals($expected, $result); - } - -/** - * testAlterSchemaIndexes method - * - * @group indices - * @return void - */ - public function testAlterSchemaIndexes() { - $this->Dbo->cacheSources = $this->Dbo->testing = false; - $table = $this->Dbo->fullTableName('altertest'); - - $schemaA = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'group1' => array('type' => 'integer', 'null' => true), - 'group2' => array('type' => 'integer', 'null' => true) - ))); - $result = $this->Dbo->createSchema($schemaA); - $this->assertContains('`id` int(11) DEFAULT 0 NOT NULL,', $result); - $this->assertContains('`name` varchar(50) NOT NULL,', $result); - $this->assertContains('`group1` int(11) DEFAULT NULL', $result); - $this->assertContains('`group2` int(11) DEFAULT NULL', $result); - - //Test that the string is syntactically correct - $query = $this->Dbo->getConnection()->prepare($result); - $this->assertEquals($query->queryString, $result); - - $schemaB = new CakeSchema(array( - 'name' => 'AlterTest2', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'group1' => array('type' => 'integer', 'null' => true), - 'group2' => array('type' => 'integer', 'null' => true), - 'indexes' => array( - 'name_idx' => array('column' => 'name', 'unique' => 0), - 'group_idx' => array('column' => 'group1', 'unique' => 0), - 'compound_idx' => array('column' => array('group1', 'group2'), 'unique' => 0), - 'PRIMARY' => array('column' => 'id', 'unique' => 1)) - ))); - - $result = $this->Dbo->alterSchema($schemaB->compare($schemaA)); - $this->assertContains("ALTER TABLE $table", $result); - $this->assertContains('ADD KEY name_idx (`name`),', $result); - $this->assertContains('ADD KEY group_idx (`group1`),', $result); - $this->assertContains('ADD KEY compound_idx (`group1`, `group2`),', $result); - $this->assertContains('ADD PRIMARY KEY (`id`);', $result); - - //Test that the string is syntactically correct - $query = $this->Dbo->getConnection()->prepare($result); - $this->assertEquals($query->queryString, $result); - - // Change three indexes, delete one and add another one - $schemaC = new CakeSchema(array( - 'name' => 'AlterTest3', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'group1' => array('type' => 'integer', 'null' => true), - 'group2' => array('type' => 'integer', 'null' => true), - 'indexes' => array( - 'name_idx' => array('column' => 'name', 'unique' => 1), - 'group_idx' => array('column' => 'group2', 'unique' => 0), - 'compound_idx' => array('column' => array('group2', 'group1'), 'unique' => 0), - 'id_name_idx' => array('column' => array('id', 'name'), 'unique' => 0)) - ))); - - $result = $this->Dbo->alterSchema($schemaC->compare($schemaB)); - $this->assertContains("ALTER TABLE $table", $result); - $this->assertContains('DROP PRIMARY KEY,', $result); - $this->assertContains('DROP KEY name_idx,', $result); - $this->assertContains('DROP KEY group_idx,', $result); - $this->assertContains('DROP KEY compound_idx,', $result); - $this->assertContains('ADD KEY id_name_idx (`id`, `name`),', $result); - $this->assertContains('ADD UNIQUE KEY name_idx (`name`),', $result); - $this->assertContains('ADD KEY group_idx (`group2`),', $result); - $this->assertContains('ADD KEY compound_idx (`group2`, `group1`);', $result); - - $query = $this->Dbo->getConnection()->prepare($result); - $this->assertEquals($query->queryString, $result); - - // Compare us to ourself. - $this->assertEquals(array(), $schemaC->compare($schemaC)); - - // Drop the indexes - $result = $this->Dbo->alterSchema($schemaA->compare($schemaC)); - - $this->assertContains("ALTER TABLE $table", $result); - $this->assertContains('DROP KEY name_idx,', $result); - $this->assertContains('DROP KEY group_idx,', $result); - $this->assertContains('DROP KEY compound_idx,', $result); - $this->assertContains('DROP KEY id_name_idx;', $result); - - $query = $this->Dbo->getConnection()->prepare($result); - $this->assertEquals($query->queryString, $result); - } - -/** - * test saving and retrieval of blobs - * - * @return void - */ - public function testBlobSaving() { - $this->loadFixtures('BinaryTest'); - $this->Dbo->cacheSources = false; - $data = file_get_contents(CAKE . 'Test' . DS . 'test_app' . DS . 'webroot' . DS . 'img' . DS . 'cake.power.gif'); - - $model = new CakeTestModel(array('name' => 'BinaryTest', 'ds' => 'test')); - $model->save(compact('data')); - - $result = $model->find('first'); - $this->assertEquals($data, $result['BinaryTest']['data']); - } - -/** - * test altering the table settings with schema. - * - * @return void - */ - public function testAlteringTableParameters() { - $this->Dbo->cacheSources = $this->Dbo->testing = false; - - $schemaA = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'tableParameters' => array( - 'charset' => 'latin1', - 'collate' => 'latin1_general_ci', - 'engine' => 'MyISAM' - ) - ) - )); - $this->Dbo->rawQuery($this->Dbo->createSchema($schemaA)); - $schemaB = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'tableParameters' => array( - 'charset' => 'utf8', - 'collate' => 'utf8_general_ci', - 'engine' => 'InnoDB' - ) - ) - )); - $result = $this->Dbo->alterSchema($schemaB->compare($schemaA)); - $this->assertContains('DEFAULT CHARSET=utf8', $result); - $this->assertContains('ENGINE=InnoDB', $result); - $this->assertContains('COLLATE=utf8_general_ci', $result); - - $this->Dbo->rawQuery($result); - $result = $this->Dbo->listDetailedSources($this->Dbo->fullTableName('altertest', false, false)); - $this->assertEquals('utf8_general_ci', $result['Collation']); - $this->assertEquals('InnoDB', $result['Engine']); - $this->assertEquals('utf8', $result['charset']); - - $this->Dbo->rawQuery($this->Dbo->dropSchema($schemaA)); - } - -/** - * test alterSchema on two tables. - * - * @return void - */ - public function testAlteringTwoTables() { - $schema1 = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - ), - 'other_table' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - ) - )); - $schema2 = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'field_two' => array('type' => 'string', 'null' => false, 'length' => 50), - ), - 'other_table' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'field_two' => array('type' => 'string', 'null' => false, 'length' => 50), - ) - )); - $result = $this->Dbo->alterSchema($schema2->compare($schema1)); - $this->assertEquals(2, substr_count($result, 'field_two'), 'Too many fields'); - } - -/** - * testReadTableParameters method - * - * @return void - */ - public function testReadTableParameters() { - $this->Dbo->cacheSources = $this->Dbo->testing = false; - $tableName = 'tinyint_' . uniqid(); - $table = $this->Dbo->fullTableName($tableName); - $this->Dbo->rawQuery('CREATE TABLE ' . $table . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;'); - $result = $this->Dbo->readTableParameters($this->Dbo->fullTableName($tableName, false, false)); - $this->Dbo->rawQuery('DROP TABLE ' . $table); - $expected = array( - 'charset' => 'utf8', - 'collate' => 'utf8_unicode_ci', - 'engine' => 'InnoDB'); - $this->assertEquals($expected, $result); - - $table = $this->Dbo->fullTableName($tableName); - $this->Dbo->rawQuery('CREATE TABLE ' . $table . ' (id int(11) AUTO_INCREMENT, bool tinyint(1), small_int tinyint(2), primary key(id)) ENGINE=MyISAM DEFAULT CHARSET=cp1250 COLLATE=cp1250_general_ci;'); - $result = $this->Dbo->readTableParameters($this->Dbo->fullTableName($tableName, false, false)); - $this->Dbo->rawQuery('DROP TABLE ' . $table); - $expected = array( - 'charset' => 'cp1250', - 'collate' => 'cp1250_general_ci', - 'engine' => 'MyISAM'); - $this->assertEquals($expected, $result); - } - -/** - * testBuildTableParameters method - * - * @return void - */ - public function testBuildTableParameters() { - $this->Dbo->cacheSources = $this->Dbo->testing = false; - $data = array( - 'charset' => 'utf8', - 'collate' => 'utf8_unicode_ci', - 'engine' => 'InnoDB'); - $result = $this->Dbo->buildTableParameters($data); - $expected = array( - 'DEFAULT CHARSET=utf8', - 'COLLATE=utf8_unicode_ci', - 'ENGINE=InnoDB'); - $this->assertEquals($expected, $result); - } - -/** - * testBuildTableParameters method - * - * @return void - */ - public function testGetCharsetName() { - $this->Dbo->cacheSources = $this->Dbo->testing = false; - $result = $this->Dbo->getCharsetName('utf8_unicode_ci'); - $this->assertEquals('utf8', $result); - $result = $this->Dbo->getCharsetName('cp1250_general_ci'); - $this->assertEquals('cp1250', $result); - } - -/** - * test that changing the virtualFieldSeparator allows for __ fields. - * - * @return void - */ - public function testVirtualFieldSeparators() { - $this->loadFixtures('BinaryTest'); - $model = new CakeTestModel(array('table' => 'binary_tests', 'ds' => 'test', 'name' => 'BinaryTest')); - $model->virtualFields = array( - 'other__field' => 'SUM(id)' - ); - - $this->Dbo->virtualFieldSeparator = '_$_'; - $result = $this->Dbo->fields($model, null, array('data', 'other__field')); - - $expected = array('`BinaryTest`.`data`', '(SUM(id)) AS `BinaryTest_$_other__field`'); - $this->assertEquals($expected, $result); - } - -/** - * Test describe() on a fixture. - * - * @return void - */ - public function testDescribe() { - $this->loadFixtures('Apple'); - - $model = new Apple(); - $result = $this->Dbo->describe($model); - - $this->assertTrue(isset($result['id'])); - $this->assertTrue(isset($result['color'])); - - $result = $this->Dbo->describe($model->useTable); - - $this->assertTrue(isset($result['id'])); - $this->assertTrue(isset($result['color'])); - } - -/** - * test that a describe() gets additional fieldParameters - * - * @return void - */ - public function testDescribeGettingFieldParameters() { - $schema = new CakeSchema(array( - 'connection' => 'test', - 'testdescribes' => array( - 'id' => array('type' => 'integer', 'key' => 'primary'), - 'stringy' => array( - 'type' => 'string', - 'null' => true, - 'charset' => 'cp1250', - 'collate' => 'cp1250_general_ci', - ), - 'other_col' => array( - 'type' => 'string', - 'null' => false, - 'charset' => 'latin1', - 'comment' => 'Test Comment' - ) - ) - )); - - $this->Dbo->execute($this->Dbo->createSchema($schema)); - $model = new CakeTestModel(array('table' => 'testdescribes', 'name' => 'Testdescribes')); - $result = $model->getDataSource()->describe($model); - $this->Dbo->execute($this->Dbo->dropSchema($schema)); - - $this->assertEquals('cp1250_general_ci', $result['stringy']['collate']); - $this->assertEquals('cp1250', $result['stringy']['charset']); - $this->assertEquals('Test Comment', $result['other_col']['comment']); - } - -/** - * Tests that listSources method sends the correct query and parses the result accordingly - * @return void - */ - public function testListSources() { - $db = $this->getMock('Mysql', array('connect', '_execute')); - $queryResult = $this->getMock('PDOStatement'); - $db->expects($this->once()) - ->method('_execute') - ->with('SHOW TABLES FROM `cake`') - ->will($this->returnValue($queryResult)); - $queryResult->expects($this->at(0)) - ->method('fetch') - ->will($this->returnValue(array('cake_table'))); - $queryResult->expects($this->at(1)) - ->method('fetch') - ->will($this->returnValue(array('another_table'))); - $queryResult->expects($this->at(2)) - ->method('fetch') - ->will($this->returnValue(null)); - - $tables = $db->listSources(); - $this->assertEquals(array('cake_table', 'another_table'), $tables); - } - -/** - * test that listDetailedSources with a named table that doesn't exist. - * - * @return void - */ - public function testListDetailedSourcesNamed() { - $this->loadFixtures('Apple'); - - $result = $this->Dbo->listDetailedSources('imaginary'); - $this->assertEquals(array(), $result, 'Should be empty when table does not exist.'); - - $result = $this->Dbo->listDetailedSources(); - $tableName = $this->Dbo->fullTableName('apples', false, false); - $this->assertTrue(isset($result[$tableName]), 'Key should exist'); - } - -/** - * Tests that getVersion method sends the correct query for getting the mysql version - * @return void - */ - public function testGetVersion() { - $version = $this->Dbo->getVersion(); - $this->assertTrue(is_string($version)); - } - -/** - * Tests that getVersion method sends the correct query for getting the client encoding - * @return void - */ - public function testGetEncoding() { - $db = $this->getMock('Mysql', array('connect', '_execute')); - $queryResult = $this->getMock('PDOStatement'); - - $db->expects($this->once()) - ->method('_execute') - ->with('SHOW VARIABLES LIKE ?', array('character_set_client')) - ->will($this->returnValue($queryResult)); - $result = new StdClass; - $result->Value = 'utf-8'; - $queryResult->expects($this->once()) - ->method('fetchObject') - ->will($this->returnValue($result)); - - $encoding = $db->getEncoding(); - $this->assertEquals('utf-8', $encoding); - } - -/** - * testFieldDoubleEscaping method - * - * @return void - */ - public function testFieldDoubleEscaping() { - $db = $this->Dbo->config['database']; - $test = $this->getMock('Mysql', array('connect', '_execute', 'execute')); - $test->config['database'] = $db; - - $this->Model = $this->getMock('Article2', array('getDataSource')); - $this->Model->alias = 'Article'; - $this->Model->expects($this->any()) - ->method('getDataSource') - ->will($this->returnValue($test)); - - $this->assertEquals('`Article`.`id`', $this->Model->escapeField()); - $result = $test->fields($this->Model, null, $this->Model->escapeField()); - $this->assertEquals(array('`Article`.`id`'), $result); - - $test->expects($this->at(0))->method('execute') - ->with('SELECT `Article`.`id` FROM ' . $test->fullTableName('articles') . ' AS `Article` WHERE 1 = 1'); - - $result = $test->read($this->Model, array( - 'fields' => $this->Model->escapeField(), - 'conditions' => null, - 'recursive' => -1 - )); - - $test->startQuote = '['; - $test->endQuote = ']'; - $this->assertEquals('[Article].[id]', $this->Model->escapeField()); - - $result = $test->fields($this->Model, null, $this->Model->escapeField()); - $this->assertEquals(array('[Article].[id]'), $result); - - $test->expects($this->at(0))->method('execute') - ->with('SELECT [Article].[id] FROM ' . $test->fullTableName('articles') . ' AS [Article] WHERE 1 = 1'); - $result = $test->read($this->Model, array( - 'fields' => $this->Model->escapeField(), - 'conditions' => null, - 'recursive' => -1 - )); - } - -/** - * testGenerateAssociationQuerySelfJoin method - * - * @return void - */ - public function testGenerateAssociationQuerySelfJoin() { - $this->Dbo = $this->getMock('Mysql', array('connect', '_execute', 'execute')); - $this->startTime = microtime(true); - $this->Model = new Article2(); - $this->_buildRelatedModels($this->Model); - $this->_buildRelatedModels($this->Model->Category2); - $this->Model->Category2->ChildCat = new Category2(); - $this->Model->Category2->ParentCat = new Category2(); - - $queryData = array(); - - foreach ($this->Model->Category2->associations() as $type) { - foreach ($this->Model->Category2->{$type} as $assoc => $assocData) { - $linkModel = $this->Model->Category2->{$assoc}; - $external = isset($assocData['external']); - - if ($this->Model->Category2->alias == $linkModel->alias && $type != 'hasAndBelongsToMany' && $type != 'hasMany') { - $result = $this->Dbo->generateAssociationQuery($this->Model->Category2, $linkModel, $type, $assoc, $assocData, $queryData, $external, $null); - $this->assertFalse(empty($result)); - } else { - if ($this->Model->Category2->useDbConfig == $linkModel->useDbConfig) { - $result = $this->Dbo->generateAssociationQuery($this->Model->Category2, $linkModel, $type, $assoc, $assocData, $queryData, $external, $null); - $this->assertFalse(empty($result)); - } - } - } - } - - $query = $this->Dbo->generateAssociationQuery($this->Model->Category2, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+(.+)FROM(.+)`Category2`\.`group_id`\s+=\s+`Group`\.`id`\)\s+LEFT JOIN(.+)WHERE\s+1 = 1\s*$/', $query); - - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'belongsTo', 'model' => 'TestModel4Parent'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $_queryData = $queryData; - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $expected = array( - 'conditions' => array(), - 'fields' => array( - '`TestModel4`.`id`', - '`TestModel4`.`name`', - '`TestModel4`.`created`', - '`TestModel4`.`updated`', - '`TestModel4Parent`.`id`', - '`TestModel4Parent`.`name`', - '`TestModel4Parent`.`created`', - '`TestModel4Parent`.`updated`' - ), - 'joins' => array( - array( - 'table' => $this->Dbo->fullTableName($this->Model), - 'alias' => 'TestModel4Parent', - 'type' => 'LEFT', - 'conditions' => '`TestModel4`.`parent_id` = `TestModel4Parent`.`id`' - ) - ), - 'order' => array(), - 'limit' => array(), - 'offset' => array(), - 'group' => array(), - 'callbacks' => null - ); - $queryData['joins'][0]['table'] = $this->Dbo->fullTableName($queryData['joins'][0]['table']); - $this->assertEquals($expected, $queryData); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`, `TestModel4Parent`\.`id`, `TestModel4Parent`\.`name`, `TestModel4Parent`\.`created`, `TestModel4Parent`\.`updated`\s+/', $result); - $this->assertRegExp('/FROM\s+\S+`test_model4` AS `TestModel4`\s+LEFT JOIN\s+\S+`test_model4` AS `TestModel4Parent`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel4`.`parent_id` = `TestModel4Parent`.`id`\)\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+1 = 1\s+$/', $result); - - $params['assocData']['type'] = 'INNER'; - $this->Model->belongsTo['TestModel4Parent']['type'] = 'INNER'; - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $_queryData, $params['external'], $resultSet); - $this->assertTrue($result); - $this->assertEquals('INNER', $_queryData['joins'][0]['type']); - } - -/** - * buildRelatedModels method - * - * @param mixed $model - * @return void - */ - protected function _buildRelatedModels(Model $model) { - foreach ($model->associations() as $type) { - foreach ($model->{$type} as $assoc => $assocData) { - if (is_string($assocData)) { - $className = $assocData; - } elseif (isset($assocData['className'])) { - $className = $assocData['className']; - } - $model->$className = new $className(); - $model->$className->schema(); - } - } - } - -/** - * &_prepareAssociationQuery method - * - * @param mixed $model - * @param mixed $queryData - * @param mixed $binding - * @return void - */ - protected function &_prepareAssociationQuery(Model $model, &$queryData, $binding) { - $type = $binding['type']; - $assoc = $binding['model']; - $assocData = $model->{$type}[$assoc]; - $className = $assocData['className']; - - $linkModel = $model->{$className}; - $external = isset($assocData['external']); - $queryData = $this->_scrubQueryData($queryData); - - $result = array_merge(array('linkModel' => &$linkModel), compact('type', 'assoc', 'assocData', 'external')); - return $result; - } - -/** - * Helper method copied from DboSource::_scrubQueryData() - * - * @param array $data - * @return array - */ - protected function _scrubQueryData($data) { - static $base = null; - if ($base === null) { - $base = array_fill_keys(array('conditions', 'fields', 'joins', 'order', 'limit', 'offset', 'group'), array()); - $base['callbacks'] = null; - } - return (array)$data + $base; - } - -/** - * testGenerateInnerJoinAssociationQuery method - * - * @return void - */ - public function testGenerateInnerJoinAssociationQuery() { - $db = $this->Dbo->config['database']; - $test = $this->getMock('Mysql', array('connect', '_execute', 'execute')); - $test->config['database'] = $db; - - $this->Model = $this->getMock('TestModel9', array('getDataSource')); - $this->Model->expects($this->any()) - ->method('getDataSource') - ->will($this->returnValue($test)); - - $this->Model->TestModel8 = $this->getMock('TestModel8', array('getDataSource')); - $this->Model->TestModel8->expects($this->any()) - ->method('getDataSource') - ->will($this->returnValue($test)); - - $testModel8Table = $this->Model->TestModel8->getDataSource()->fullTableName($this->Model->TestModel8); - - $test->expects($this->at(0))->method('execute') - ->with($this->stringContains('`TestModel9` LEFT JOIN ' . $testModel8Table)); - - $test->expects($this->at(1))->method('execute') - ->with($this->stringContains('TestModel9` INNER JOIN ' . $testModel8Table)); - - $test->read($this->Model, array('recursive' => 1)); - $this->Model->belongsTo['TestModel8']['type'] = 'INNER'; - $test->read($this->Model, array('recursive' => 1)); - } - -/** - * testGenerateAssociationQuerySelfJoinWithConditionsInHasOneBinding method - * - * @return void - */ - public function testGenerateAssociationQuerySelfJoinWithConditionsInHasOneBinding() { - $this->Model = new TestModel8(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasOne', 'model' => 'TestModel9'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel8`\.`id`, `TestModel8`\.`test_model9_id`, `TestModel8`\.`name`, `TestModel8`\.`created`, `TestModel8`\.`updated`, `TestModel9`\.`id`, `TestModel9`\.`test_model8_id`, `TestModel9`\.`name`, `TestModel9`\.`created`, `TestModel9`\.`updated`\s+/', $result); - $this->assertRegExp('/FROM\s+\S+`test_model8` AS `TestModel8`\s+LEFT JOIN\s+\S+`test_model9` AS `TestModel9`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel9`\.`name` != \'mariano\'\s+AND\s+`TestModel9`.`test_model8_id` = `TestModel8`.`id`\)\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQuerySelfJoinWithConditionsInBelongsToBinding method - * - * @return void - */ - public function testGenerateAssociationQuerySelfJoinWithConditionsInBelongsToBinding() { - $this->Model = new TestModel9(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'belongsTo', 'model' => 'TestModel8'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel9`\.`id`, `TestModel9`\.`test_model8_id`, `TestModel9`\.`name`, `TestModel9`\.`created`, `TestModel9`\.`updated`, `TestModel8`\.`id`, `TestModel8`\.`test_model9_id`, `TestModel8`\.`name`, `TestModel8`\.`created`, `TestModel8`\.`updated`\s+/', $result); - $this->assertRegExp('/FROM\s+\S+`test_model9` AS `TestModel9`\s+LEFT JOIN\s+\S+`test_model8` AS `TestModel8`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel8`\.`name` != \'larry\'\s+AND\s+`TestModel9`.`test_model8_id` = `TestModel8`.`id`\)\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQuerySelfJoinWithConditions method - * - * @return void - */ - public function testGenerateAssociationQuerySelfJoinWithConditions() { - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'belongsTo', 'model' => 'TestModel4Parent'); - $queryData = array('conditions' => array('TestModel4Parent.name !=' => 'mariano')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`, `TestModel4Parent`\.`id`, `TestModel4Parent`\.`name`, `TestModel4Parent`\.`created`, `TestModel4Parent`\.`updated`\s+/', $result); - $this->assertRegExp('/FROM\s+\S+`test_model4` AS `TestModel4`\s+LEFT JOIN\s+\S+`test_model4` AS `TestModel4Parent`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel4`.`parent_id` = `TestModel4Parent`.`id`\)\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?`TestModel4Parent`.`name`\s+!=\s+\'mariano\'(?:\))?\s*$/', $result); - - $this->Featured2 = new Featured2(); - $this->Featured2->schema(); - - $this->Featured2->bindModel(array( - 'belongsTo' => array( - 'ArticleFeatured2' => array( - 'conditions' => 'ArticleFeatured2.published = \'Y\'', - 'fields' => 'id, title, user_id, published' - ) - ) - )); - - $this->_buildRelatedModels($this->Featured2); - - $binding = array('type' => 'belongsTo', 'model' => 'ArticleFeatured2'); - $queryData = array('conditions' => array()); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Featured2, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Featured2, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - $result = $this->Dbo->generateAssociationQuery($this->Featured2, $null, null, null, null, $queryData, false, $null); - - $this->assertRegExp( - '/^SELECT\s+`Featured2`\.`id`, `Featured2`\.`article_id`, `Featured2`\.`category_id`, `Featured2`\.`name`,\s+' . - '`ArticleFeatured2`\.`id`, `ArticleFeatured2`\.`title`, `ArticleFeatured2`\.`user_id`, `ArticleFeatured2`\.`published`\s+' . - 'FROM\s+\S+`featured2` AS `Featured2`\s+LEFT JOIN\s+\S+`article_featured` AS `ArticleFeatured2`' . - '\s+ON\s+\(`ArticleFeatured2`.`published` = \'Y\'\s+AND\s+`Featured2`\.`article_featured2_id` = `ArticleFeatured2`\.`id`\)' . - '\s+WHERE\s+1\s+=\s+1\s*$/', - $result - ); - } - -/** - * testGenerateAssociationQueryHasOne method - * - * @return void - */ - public function testGenerateAssociationQueryHasOne() { - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasOne', 'model' => 'TestModel5'); - - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $testModel5Table = $this->Dbo->fullTableName($this->Model->TestModel5); - $result = $this->Dbo->buildJoinStatement($queryData['joins'][0]); - $expected = ' LEFT JOIN ' . $testModel5Table . ' AS `TestModel5` ON (`TestModel5`.`test_model4_id` = `TestModel4`.`id`)'; - $this->assertEquals(trim($expected), trim($result)); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`, `TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model4` AS `TestModel4`\s+LEFT JOIN\s+/', $result); - $this->assertRegExp('/`test_model5` AS `TestModel5`\s+ON\s+\(`TestModel5`.`test_model4_id` = `TestModel4`.`id`\)\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?\s*1 = 1\s*(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQueryHasOneWithConditions method - * - * @return void - */ - public function testGenerateAssociationQueryHasOneWithConditions() { - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasOne', 'model' => 'TestModel5'); - - $queryData = array('conditions' => array('TestModel5.name !=' => 'mariano')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`, `TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model4` AS `TestModel4`\s+LEFT JOIN\s+\S+`test_model5` AS `TestModel5`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel5`.`test_model4_id`\s+=\s+`TestModel4`.`id`\)\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?\s*`TestModel5`.`name`\s+!=\s+\'mariano\'\s*(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQueryBelongsTo method - * - * @return void - */ - public function testGenerateAssociationQueryBelongsTo() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'belongsTo', 'model' => 'TestModel4'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $testModel4Table = $this->Dbo->fullTableName($this->Model->TestModel4, true, true); - $result = $this->Dbo->buildJoinStatement($queryData['joins'][0]); - $expected = ' LEFT JOIN ' . $testModel4Table . ' AS `TestModel4` ON (`TestModel5`.`test_model4_id` = `TestModel4`.`id`)'; - $this->assertEquals(trim($expected), trim($result)); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`, `TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+LEFT JOIN\s+\S+`test_model4` AS `TestModel4`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel5`.`test_model4_id` = `TestModel4`.`id`\)\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?\s*1 = 1\s*(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQueryBelongsToWithConditions method - * - * @return void - */ - public function testGenerateAssociationQueryBelongsToWithConditions() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'belongsTo', 'model' => 'TestModel4'); - $queryData = array('conditions' => array('TestModel5.name !=' => 'mariano')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertTrue($result); - - $testModel4Table = $this->Dbo->fullTableName($this->Model->TestModel4, true, true); - $result = $this->Dbo->buildJoinStatement($queryData['joins'][0]); - $expected = ' LEFT JOIN ' . $testModel4Table . ' AS `TestModel4` ON (`TestModel5`.`test_model4_id` = `TestModel4`.`id`)'; - $this->assertEquals(trim($expected), trim($result)); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`, `TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+LEFT JOIN\s+\S+`test_model4` AS `TestModel4`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel5`.`test_model4_id` = `TestModel4`.`id`\)\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+`TestModel5`.`name` != \'mariano\'\s*$/', $result); - } - -/** - * testGenerateAssociationQueryHasMany method - * - * @return void - */ - public function testGenerateAssociationQueryHasMany() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+`TestModel6`.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?\s*1 = 1\s*(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQueryHasManyWithLimit method - * - * @return void - */ - public function testGenerateAssociationQueryHasManyWithLimit() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $this->Model->hasMany['TestModel6']['limit'] = 2; - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp( - '/^SELECT\s+' . - '`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+' . - 'FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+' . - '`TestModel6`.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)\s*' . - 'LIMIT \d*' . - '\s*$/', $result - ); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp( - '/^SELECT\s+' . - '`TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`\s+' . - 'FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+' . - '(?:\()?\s*1 = 1\s*(?:\))?' . - '\s*$/', $result - ); - } - -/** - * testGenerateAssociationQueryHasManyWithConditions method - * - * @return void - */ - public function testGenerateAssociationQueryHasManyWithConditions() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('conditions' => array('TestModel5.name !=' => 'mariano')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?`TestModel5`.`name`\s+!=\s+\'mariano\'(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQueryHasManyWithOffsetAndLimit method - * - * @return void - */ - public function testGenerateAssociationQueryHasManyWithOffsetAndLimit() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $backup = $this->Model->hasMany['TestModel6']; - - $this->Model->hasMany['TestModel6']['offset'] = 2; - $this->Model->hasMany['TestModel6']['limit'] = 5; - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - $this->assertRegExp('/\s+LIMIT 2,\s*5\s*$/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - $this->Model->hasMany['TestModel6'] = $backup; - } - -/** - * testGenerateAssociationQueryHasManyWithPageAndLimit method - * - * @return void - */ - public function testGenerateAssociationQueryHasManyWithPageAndLimit() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $backup = $this->Model->hasMany['TestModel6']; - - $this->Model->hasMany['TestModel6']['page'] = 2; - $this->Model->hasMany['TestModel6']['limit'] = 5; - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - $this->assertRegExp('/\s+LIMIT 5,\s*5\s*$/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`test_model4_id`, `TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - $this->Model->hasMany['TestModel6'] = $backup; - } - -/** - * testGenerateAssociationQueryHasManyWithFields method - * - * @return void - */ - public function testGenerateAssociationQueryHasManyWithFields() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('fields' => array('`TestModel5`.`name`')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`name`, `TestModel5`\.`id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('fields' => array('`TestModel5`.`id`, `TestModel5`.`name`')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`name`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('fields' => array('`TestModel5`.`name`', '`TestModel5`.`created`')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`test_model5_id`, `TestModel6`\.`name`, `TestModel6`\.`created`, `TestModel6`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`name`, `TestModel5`\.`created`, `TestModel5`\.`id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - $this->Model->hasMany['TestModel6']['fields'] = array('name'); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('fields' => array('`TestModel5`.`id`', '`TestModel5`.`name`')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`name`, `TestModel6`\.`test_model5_id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`name`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - unset($this->Model->hasMany['TestModel6']['fields']); - - $this->Model->hasMany['TestModel6']['fields'] = array('id', 'name'); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('fields' => array('`TestModel5`.`id`', '`TestModel5`.`name`')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`id`, `TestModel6`\.`name`, `TestModel6`\.`test_model5_id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`name`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - unset($this->Model->hasMany['TestModel6']['fields']); - - $this->Model->hasMany['TestModel6']['fields'] = array('test_model5_id', 'name'); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('fields' => array('`TestModel5`.`id`', '`TestModel5`.`name`')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel6`\.`test_model5_id`, `TestModel6`\.`name`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model6` AS `TestModel6`\s+WHERE\s+/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?`TestModel6`\.`test_model5_id`\s+=\s+\({\$__cakeID__\$}\)(?:\))?/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel5`\.`id`, `TestModel5`\.`name`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model5` AS `TestModel5`\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - unset($this->Model->hasMany['TestModel6']['fields']); - } - -/** - * test generateAssociationQuery with a hasMany and an aggregate function. - * - * @return void - */ - public function testGenerateAssociationQueryHasManyAndAggregateFunction() { - $this->Model = new TestModel5(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasMany', 'model' => 'TestModel6'); - $queryData = array('fields' => array('MIN(`TestModel5`.`test_model4_id`)')); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - $this->Model->recursive = 0; - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, $params['type'], $params['assoc'], $params['assocData'], $queryData, false, $resultSet); - $this->assertRegExp('/^SELECT\s+MIN\(`TestModel5`\.`test_model4_id`\)\s+FROM/', $result); - } - -/** - * testGenerateAssociationQueryHasAndBelongsToMany method - * - * @return void - */ - public function testGenerateAssociationQueryHasAndBelongsToMany() { - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasAndBelongsToMany', 'model' => 'TestModel7'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = $this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $assocTable = $this->Dbo->fullTableName($this->Model->TestModel4TestModel7, true, true); - $this->assertRegExp('/^SELECT\s+`TestModel7`\.`id`, `TestModel7`\.`name`, `TestModel7`\.`created`, `TestModel7`\.`updated`, `TestModel4TestModel7`\.`test_model4_id`, `TestModel4TestModel7`\.`test_model7_id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model7` AS `TestModel7`\s+JOIN\s+' . $assocTable . '/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel4TestModel7`\.`test_model4_id`\s+=\s+{\$__cakeID__\$}\s+AND/', $result); - $this->assertRegExp('/\s+AND\s+`TestModel4TestModel7`\.`test_model7_id`\s+=\s+`TestModel7`\.`id`\)/', $result); - $this->assertRegExp('/WHERE\s+(?:\()?1 = 1(?:\))?\s*$/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model4` AS `TestModel4`\s+WHERE/', $result); - $this->assertRegExp('/\s+WHERE\s+(?:\()?1 = 1(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQueryHasAndBelongsToManyWithConditions method - * - * @return void - */ - public function testGenerateAssociationQueryHasAndBelongsToManyWithConditions() { - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $binding = array('type' => 'hasAndBelongsToMany', 'model' => 'TestModel7'); - $queryData = array('conditions' => array('TestModel4.name !=' => 'mariano')); - $resultSet = null; - $null = null; - - $params = $this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel7`\.`id`, `TestModel7`\.`name`, `TestModel7`\.`created`, `TestModel7`\.`updated`, `TestModel4TestModel7`\.`test_model4_id`, `TestModel4TestModel7`\.`test_model7_id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model7`\s+AS\s+`TestModel7`\s+JOIN\s+\S+`test_model4_test_model7`\s+AS\s+`TestModel4TestModel7`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel4TestModel7`\.`test_model4_id`\s+=\s+{\$__cakeID__\$}/', $result); - $this->assertRegExp('/\s+AND\s+`TestModel4TestModel7`\.`test_model7_id`\s+=\s+`TestModel7`\.`id`\)\s+WHERE\s+/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model4` AS `TestModel4`\s+WHERE\s+(?:\()?`TestModel4`.`name`\s+!=\s+\'mariano\'(?:\))?\s*$/', $result); - } - -/** - * testGenerateAssociationQueryHasAndBelongsToManyWithOffsetAndLimit method - * - * @return void - */ - public function testGenerateAssociationQueryHasAndBelongsToManyWithOffsetAndLimit() { - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $backup = $this->Model->hasAndBelongsToMany['TestModel7']; - - $this->Model->hasAndBelongsToMany['TestModel7']['offset'] = 2; - $this->Model->hasAndBelongsToMany['TestModel7']['limit'] = 5; - - $binding = array('type' => 'hasAndBelongsToMany', 'model' => 'TestModel7'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel7`\.`id`, `TestModel7`\.`name`, `TestModel7`\.`created`, `TestModel7`\.`updated`, `TestModel4TestModel7`\.`test_model4_id`, `TestModel4TestModel7`\.`test_model7_id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model7`\s+AS\s+`TestModel7`\s+JOIN\s+\S+`test_model4_test_model7`\s+AS\s+`TestModel4TestModel7`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel4TestModel7`\.`test_model4_id`\s+=\s+{\$__cakeID__\$}\s+/', $result); - $this->assertRegExp('/\s+AND\s+`TestModel4TestModel7`\.`test_model7_id`\s+=\s+`TestModel7`\.`id`\)\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+(?:\()?1\s+=\s+1(?:\))?\s*\s+LIMIT 2,\s*5\s*$/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model4` AS `TestModel4`\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - $this->Model->hasAndBelongsToMany['TestModel7'] = $backup; - } - -/** - * testGenerateAssociationQueryHasAndBelongsToManyWithPageAndLimit method - * - * @return void - */ - public function testGenerateAssociationQueryHasAndBelongsToManyWithPageAndLimit() { - $this->Model = new TestModel4(); - $this->Model->schema(); - $this->_buildRelatedModels($this->Model); - - $backup = $this->Model->hasAndBelongsToMany['TestModel7']; - - $this->Model->hasAndBelongsToMany['TestModel7']['page'] = 2; - $this->Model->hasAndBelongsToMany['TestModel7']['limit'] = 5; - - $binding = array('type' => 'hasAndBelongsToMany', 'model' => 'TestModel7'); - $queryData = array(); - $resultSet = null; - $null = null; - - $params = &$this->_prepareAssociationQuery($this->Model, $queryData, $binding); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $params['linkModel'], $params['type'], $params['assoc'], $params['assocData'], $queryData, $params['external'], $resultSet); - $this->assertRegExp('/^SELECT\s+`TestModel7`\.`id`, `TestModel7`\.`name`, `TestModel7`\.`created`, `TestModel7`\.`updated`, `TestModel4TestModel7`\.`test_model4_id`, `TestModel4TestModel7`\.`test_model7_id`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model7`\s+AS\s+`TestModel7`\s+JOIN\s+\S+`test_model4_test_model7`\s+AS\s+`TestModel4TestModel7`/', $result); - $this->assertRegExp('/\s+ON\s+\(`TestModel4TestModel7`\.`test_model4_id`\s+=\s+{\$__cakeID__\$}/', $result); - $this->assertRegExp('/\s+AND\s+`TestModel4TestModel7`\.`test_model7_id`\s+=\s+`TestModel7`\.`id`\)\s+WHERE\s+/', $result); - $this->assertRegExp('/\s+(?:\()?1\s+=\s+1(?:\))?\s*\s+LIMIT 5,\s*5\s*$/', $result); - - $result = $this->Dbo->generateAssociationQuery($this->Model, $null, null, null, null, $queryData, false, $null); - $this->assertRegExp('/^SELECT\s+`TestModel4`\.`id`, `TestModel4`\.`name`, `TestModel4`\.`created`, `TestModel4`\.`updated`\s+/', $result); - $this->assertRegExp('/\s+FROM\s+\S+`test_model4` AS `TestModel4`\s+WHERE\s+(?:\()?1\s+=\s+1(?:\))?\s*$/', $result); - - $this->Model->hasAndBelongsToMany['TestModel7'] = $backup; - } - -/** - * testSelectDistict method - * - * @return void - */ - public function testSelectDistict() { - $this->Model = new TestModel4(); - $result = $this->Dbo->fields($this->Model, 'Vendor', "DISTINCT Vendor.id, Vendor.name"); - $expected = array('DISTINCT `Vendor`.`id`', '`Vendor`.`name`'); - $this->assertEquals($expected, $result); - } - -/** - * testStringConditionsParsing method - * - * @return void - */ - public function testStringConditionsParsing() { - $result = $this->Dbo->conditions("ProjectBid.project_id = Project.id"); - $expected = " WHERE `ProjectBid`.`project_id` = `Project`.`id`"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("Candy.name LIKE 'a' AND HardCandy.name LIKE 'c'"); - $expected = " WHERE `Candy`.`name` LIKE 'a' AND `HardCandy`.`name` LIKE 'c'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("HardCandy.name LIKE 'a' AND Candy.name LIKE 'c'"); - $expected = " WHERE `HardCandy`.`name` LIKE 'a' AND `Candy`.`name` LIKE 'c'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("Post.title = '1.1'"); - $expected = " WHERE `Post`.`title` = '1.1'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("User.id != 0 AND User.user LIKE '%arr%'"); - $expected = " WHERE `User`.`id` != 0 AND `User`.`user` LIKE '%arr%'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("SUM(Post.comments_count) > 500"); - $expected = " WHERE SUM(`Post`.`comments_count`) > 500"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("(Post.created < '" . date('Y-m-d H:i:s') . "') GROUP BY YEAR(Post.created), MONTH(Post.created)"); - $expected = " WHERE (`Post`.`created` < '" . date('Y-m-d H:i:s') . "') GROUP BY YEAR(`Post`.`created`), MONTH(`Post`.`created`)"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("score BETWEEN 90.1 AND 95.7"); - $expected = " WHERE score BETWEEN 90.1 AND 95.7"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('score' => array(2 => 1, 2, 10))); - $expected = " WHERE score IN (1, 2, 10)"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("Aro.rght = Aro.lft + 1.1"); - $expected = " WHERE `Aro`.`rght` = `Aro`.`lft` + 1.1"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("(Post.created < '" . date('Y-m-d H:i:s') . "') GROUP BY YEAR(Post.created), MONTH(Post.created)"); - $expected = " WHERE (`Post`.`created` < '" . date('Y-m-d H:i:s') . "') GROUP BY YEAR(`Post`.`created`), MONTH(`Post`.`created`)"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('Sportstaette.sportstaette LIKE "%ru%" AND Sportstaette.sportstaettenart_id = 2'); - $expected = ' WHERE `Sportstaette`.`sportstaette` LIKE "%ru%" AND `Sportstaette`.`sportstaettenart_id` = 2'; - $this->assertRegExp('/\s*WHERE\s+`Sportstaette`\.`sportstaette`\s+LIKE\s+"%ru%"\s+AND\s+`Sports/', $result); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('Sportstaette.sportstaettenart_id = 2 AND Sportstaette.sportstaette LIKE "%ru%"'); - $expected = ' WHERE `Sportstaette`.`sportstaettenart_id` = 2 AND `Sportstaette`.`sportstaette` LIKE "%ru%"'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('SUM(Post.comments_count) > 500 AND NOT Post.title IS NULL AND NOT Post.extended_title IS NULL'); - $expected = ' WHERE SUM(`Post`.`comments_count`) > 500 AND NOT `Post`.`title` IS NULL AND NOT `Post`.`extended_title` IS NULL'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('NOT Post.title IS NULL AND NOT Post.extended_title IS NULL AND SUM(Post.comments_count) > 500'); - $expected = ' WHERE NOT `Post`.`title` IS NULL AND NOT `Post`.`extended_title` IS NULL AND SUM(`Post`.`comments_count`) > 500'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('NOT Post.extended_title IS NULL AND NOT Post.title IS NULL AND Post.title != "" AND SPOON(SUM(Post.comments_count) + 1.1) > 500'); - $expected = ' WHERE NOT `Post`.`extended_title` IS NULL AND NOT `Post`.`title` IS NULL AND `Post`.`title` != "" AND SPOON(SUM(`Post`.`comments_count`) + 1.1) > 500'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('NOT Post.title_extended IS NULL AND NOT Post.title IS NULL AND Post.title_extended != Post.title'); - $expected = ' WHERE NOT `Post`.`title_extended` IS NULL AND NOT `Post`.`title` IS NULL AND `Post`.`title_extended` != `Post`.`title`'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("Comment.id = 'a'"); - $expected = " WHERE `Comment`.`id` = 'a'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("lower(Article.title) LIKE 'a%'"); - $expected = " WHERE lower(`Article`.`title`) LIKE 'a%'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('((MATCH(Video.title) AGAINST(\'My Search*\' IN BOOLEAN MODE) * 2) + (MATCH(Video.description) AGAINST(\'My Search*\' IN BOOLEAN MODE) * 0.4) + (MATCH(Video.tags) AGAINST(\'My Search*\' IN BOOLEAN MODE) * 1.5))'); - $expected = ' WHERE ((MATCH(`Video`.`title`) AGAINST(\'My Search*\' IN BOOLEAN MODE) * 2) + (MATCH(`Video`.`description`) AGAINST(\'My Search*\' IN BOOLEAN MODE) * 0.4) + (MATCH(`Video`.`tags`) AGAINST(\'My Search*\' IN BOOLEAN MODE) * 1.5))'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('DATEDIFF(NOW(),Article.published) < 1 && Article.live=1'); - $expected = " WHERE DATEDIFF(NOW(),`Article`.`published`) < 1 && `Article`.`live`=1"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('file = "index.html"'); - $expected = ' WHERE file = "index.html"'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions("file = 'index.html'"); - $expected = " WHERE file = 'index.html'"; - $this->assertEquals($expected, $result); - - $letter = $letter = 'd.a'; - $conditions = array('Company.name like ' => $letter . '%'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `Company`.`name` like 'd.a%'"; - $this->assertEquals($expected, $result); - - $conditions = array('Artist.name' => 'JUDY and MARY'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `Artist`.`name` = 'JUDY and MARY'"; - $this->assertEquals($expected, $result); - - $conditions = array('Artist.name' => 'JUDY AND MARY'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `Artist`.`name` = 'JUDY AND MARY'"; - $this->assertEquals($expected, $result); - - $conditions = array('Company.name similar to ' => 'a word'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `Company`.`name` similar to 'a word'"; - $this->assertEquals($expected, $result); - } - -/** - * testQuotesInStringConditions method - * - * @return void - */ - public function testQuotesInStringConditions() { - $result = $this->Dbo->conditions('Member.email = \'mariano@cricava.com\''); - $expected = ' WHERE `Member`.`email` = \'mariano@cricava.com\''; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('Member.email = "mariano@cricava.com"'); - $expected = ' WHERE `Member`.`email` = "mariano@cricava.com"'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('Member.email = \'mariano@cricava.com\' AND Member.user LIKE \'mariano.iglesias%\''); - $expected = ' WHERE `Member`.`email` = \'mariano@cricava.com\' AND `Member`.`user` LIKE \'mariano.iglesias%\''; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions('Member.email = "mariano@cricava.com" AND Member.user LIKE "mariano.iglesias%"'); - $expected = ' WHERE `Member`.`email` = "mariano@cricava.com" AND `Member`.`user` LIKE "mariano.iglesias%"'; - $this->assertEquals($expected, $result); - } - -/** - * testParenthesisInStringConditions method - * - * @return void - */ - public function testParenthesisInStringConditions() { - $result = $this->Dbo->conditions('Member.name = \'(lu\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(lu\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \')lu\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\)lu\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'va(lu\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\(lu\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'va)lu\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\)lu\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'va(lu)\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\(lu\)\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'va(lu)e\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\(lu\)e\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'(mariano)\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano\)\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'(mariano)iglesias\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano\)iglesias\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'(mariano) iglesias\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano\) iglesias\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'(mariano word) iglesias\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano word\) iglesias\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'(mariano.iglesias)\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano.iglesias\)\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'Mariano Iglesias (mariano.iglesias)\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'Mariano Iglesias \(mariano.iglesias\)\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'Mariano Iglesias (mariano.iglesias) CakePHP\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'Mariano Iglesias \(mariano.iglesias\) CakePHP\'$/', $result); - - $result = $this->Dbo->conditions('Member.name = \'(mariano.iglesias) CakePHP\''); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano.iglesias\) CakePHP\'$/', $result); - } - -/** - * testParenthesisInArrayConditions method - * - * @return void - */ - public function testParenthesisInArrayConditions() { - $result = $this->Dbo->conditions(array('Member.name' => '(lu')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(lu\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => ')lu')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\)lu\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => 'va(lu')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\(lu\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => 'va)lu')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\)lu\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => 'va(lu)')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\(lu\)\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => 'va(lu)e')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'va\(lu\)e\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => '(mariano)')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano\)\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => '(mariano)iglesias')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano\)iglesias\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => '(mariano) iglesias')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano\) iglesias\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => '(mariano word) iglesias')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano word\) iglesias\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => '(mariano.iglesias)')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano.iglesias\)\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => 'Mariano Iglesias (mariano.iglesias)')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'Mariano Iglesias \(mariano.iglesias\)\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => 'Mariano Iglesias (mariano.iglesias) CakePHP')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'Mariano Iglesias \(mariano.iglesias\) CakePHP\'$/', $result); - - $result = $this->Dbo->conditions(array('Member.name' => '(mariano.iglesias) CakePHP')); - $this->assertRegExp('/^\s+WHERE\s+`Member`.`name`\s+=\s+\'\(mariano.iglesias\) CakePHP\'$/', $result); - } - -/** - * testArrayConditionsParsing method - * - * @return void - */ - public function testArrayConditionsParsing() { - $this->loadFixtures('Post', 'Author'); - $result = $this->Dbo->conditions(array('Stereo.type' => 'in dash speakers')); - $this->assertRegExp("/^\s+WHERE\s+`Stereo`.`type`\s+=\s+'in dash speakers'/", $result); - - $result = $this->Dbo->conditions(array('Candy.name LIKE' => 'a', 'HardCandy.name LIKE' => 'c')); - $this->assertRegExp("/^\s+WHERE\s+`Candy`.`name` LIKE\s+'a'\s+AND\s+`HardCandy`.`name`\s+LIKE\s+'c'/", $result); - - $result = $this->Dbo->conditions(array('HardCandy.name LIKE' => 'a', 'Candy.name LIKE' => 'c')); - $expected = " WHERE `HardCandy`.`name` LIKE 'a' AND `Candy`.`name` LIKE 'c'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('HardCandy.name LIKE' => 'a%', 'Candy.name LIKE' => '%c%')); - $expected = " WHERE `HardCandy`.`name` LIKE 'a%' AND `Candy`.`name` LIKE '%c%'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('HardCandy.name LIKE' => 'to be or%', 'Candy.name LIKE' => '%not to be%')); - $expected = " WHERE `HardCandy`.`name` LIKE 'to be or%' AND `Candy`.`name` LIKE '%not to be%'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('score BETWEEN ? AND ?' => array(90.1, 95.7))); - $expected = " WHERE `score` BETWEEN 90.1 AND 95.7"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Post.title' => 1.1)); - $expected = " WHERE `Post`.`title` = 1.1"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Post.title' => 1.1), true, true, new Post()); - $expected = " WHERE `Post`.`title` = '1.1'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('SUM(Post.comments_count) >' => '500')); - $expected = " WHERE SUM(`Post`.`comments_count`) > '500'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('MAX(Post.rating) >' => '50')); - $expected = " WHERE MAX(`Post`.`rating`) > '50'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('lower(Article.title)' => 'secrets')); - $expected = " WHERE lower(`Article`.`title`) = 'secrets'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('title LIKE' => '%hello')); - $expected = " WHERE `title` LIKE '%hello'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Post.name' => 'mad(g)ik')); - $expected = " WHERE `Post`.`name` = 'mad(g)ik'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('score' => array(1, 2, 10))); - $expected = " WHERE score IN (1, 2, 10)"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('score' => array())); - $expected = " WHERE `score` IS NULL"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('score !=' => array())); - $expected = " WHERE `score` IS NOT NULL"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('score !=' => '20')); - $expected = " WHERE `score` != '20'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('score >' => '20')); - $expected = " WHERE `score` > '20'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('client_id >' => '20'), true, true, new TestModel()); - $expected = " WHERE `client_id` > 20"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('OR' => array( - array('User.user' => 'mariano'), - array('User.user' => 'nate') - ))); - - $expected = " WHERE ((`User`.`user` = 'mariano') OR (`User`.`user` = 'nate'))"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('or' => array( - 'score BETWEEN ? AND ?' => array('4', '5'), 'rating >' => '20' - ))); - $expected = " WHERE ((`score` BETWEEN '4' AND '5') OR (`rating` > '20'))"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('or' => array( - 'score BETWEEN ? AND ?' => array('4', '5'), array('score >' => '20') - ))); - $expected = " WHERE ((`score` BETWEEN '4' AND '5') OR (`score` > '20'))"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('and' => array( - 'score BETWEEN ? AND ?' => array('4', '5'), array('score >' => '20') - ))); - $expected = " WHERE ((`score` BETWEEN '4' AND '5') AND (`score` > '20'))"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array( - 'published' => 1, 'or' => array('score >' => '2', array('score >' => '20')) - )); - $expected = " WHERE `published` = 1 AND ((`score` > '2') OR (`score` > '20'))"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array(array('Project.removed' => false))); - $expected = " WHERE `Project`.`removed` = '0'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array(array('Project.removed' => true))); - $expected = " WHERE `Project`.`removed` = '1'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array(array('Project.removed' => null))); - $expected = " WHERE `Project`.`removed` IS NULL"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array(array('Project.removed !=' => null))); - $expected = " WHERE `Project`.`removed` IS NOT NULL"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('(Usergroup.permissions) & 4' => 4)); - $expected = " WHERE (`Usergroup`.`permissions`) & 4 = 4"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('((Usergroup.permissions) & 4)' => 4)); - $expected = " WHERE ((`Usergroup`.`permissions`) & 4) = 4"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Post.modified >=' => 'DATE_SUB(NOW(), INTERVAL 7 DAY)')); - $expected = " WHERE `Post`.`modified` >= 'DATE_SUB(NOW(), INTERVAL 7 DAY)'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Post.modified >= DATE_SUB(NOW(), INTERVAL 7 DAY)')); - $expected = " WHERE `Post`.`modified` >= DATE_SUB(NOW(), INTERVAL 7 DAY)"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array( - 'NOT' => array('Course.id' => null, 'Course.vet' => 'N', 'level_of_education_id' => array(912,999)), - 'Enrollment.yearcompleted >' => '0') - ); - $this->assertRegExp('/^\s*WHERE\s+\(NOT\s+\(`Course`\.`id` IS NULL\)\s+AND NOT\s+\(`Course`\.`vet`\s+=\s+\'N\'\)\s+AND NOT\s+\(level_of_education_id IN \(912, 999\)\)\)\s+AND\s+`Enrollment`\.`yearcompleted`\s+>\s+\'0\'\s*$/', $result); - - $result = $this->Dbo->conditions(array('id <>' => '8')); - $this->assertRegExp('/^\s*WHERE\s+`id`\s+<>\s+\'8\'\s*$/', $result); - - $result = $this->Dbo->conditions(array('TestModel.field =' => 'gribe$@()lu')); - $expected = " WHERE `TestModel`.`field` = 'gribe$@()lu'"; - $this->assertEquals($expected, $result); - - $conditions['NOT'] = array('Listing.expiration BETWEEN ? AND ?' => array("1", "100")); - $conditions[0]['OR'] = array( - "Listing.title LIKE" => "%term%", - "Listing.description LIKE" => "%term%" - ); - $conditions[1]['OR'] = array( - "Listing.title LIKE" => "%term_2%", - "Listing.description LIKE" => "%term_2%" - ); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE NOT (`Listing`.`expiration` BETWEEN '1' AND '100') AND" . - " ((`Listing`.`title` LIKE '%term%') OR (`Listing`.`description` LIKE '%term%')) AND" . - " ((`Listing`.`title` LIKE '%term_2%') OR (`Listing`.`description` LIKE '%term_2%'))"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('MD5(CONCAT(Reg.email,Reg.id))' => 'blah')); - $expected = " WHERE MD5(CONCAT(`Reg`.`email`,`Reg`.`id`)) = 'blah'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array( - 'MD5(CONCAT(Reg.email,Reg.id))' => array('blah', 'blahblah') - )); - $expected = " WHERE MD5(CONCAT(`Reg`.`email`,`Reg`.`id`)) IN ('blah', 'blahblah')"; - $this->assertEquals($expected, $result); - - $conditions = array('id' => array(2, 5, 6, 9, 12, 45, 78, 43, 76)); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE id IN (2, 5, 6, 9, 12, 45, 78, 43, 76)"; - $this->assertEquals($expected, $result); - - $conditions = array('title' => 'user(s)'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `title` = 'user(s)'"; - $this->assertEquals($expected, $result); - - $conditions = array('title' => 'user(s) data'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `title` = 'user(s) data'"; - $this->assertEquals($expected, $result); - - $conditions = array('title' => 'user(s,arg) data'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `title` = 'user(s,arg) data'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array("Book.book_name" => 'Java(TM)')); - $expected = " WHERE `Book`.`book_name` = 'Java(TM)'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array("Book.book_name" => 'Java(TM) ')); - $expected = " WHERE `Book`.`book_name` = 'Java(TM) '"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array("Book.id" => 0)); - $expected = " WHERE `Book`.`id` = 0"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array("Book.id" => null)); - $expected = " WHERE `Book`.`id` IS NULL"; - $this->assertEquals($expected, $result); - - $conditions = array('MysqlModel.id' => ''); - $result = $this->Dbo->conditions($conditions, true, true, $this->model); - $expected = " WHERE `MysqlModel`.`id` IS NULL"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Listing.beds >=' => 0)); - $expected = " WHERE `Listing`.`beds` >= 0"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array( - 'ASCII(SUBSTRING(keyword, 1, 1)) BETWEEN ? AND ?' => array(65, 90) - )); - $expected = ' WHERE ASCII(SUBSTRING(keyword, 1, 1)) BETWEEN 65 AND 90'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('or' => array( - '? BETWEEN Model.field1 AND Model.field2' => '2009-03-04' - ))); - $expected = " WHERE '2009-03-04' BETWEEN Model.field1 AND Model.field2"; - $this->assertEquals($expected, $result); - } - -/** - * testArrayConditionsParsingComplexKeys method - * - * @return void - */ - public function testArrayConditionsParsingComplexKeys() { - $result = $this->Dbo->conditions(array( - 'CAST(Book.created AS DATE)' => '2008-08-02' - )); - $expected = " WHERE CAST(`Book`.`created` AS DATE) = '2008-08-02'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array( - 'CAST(Book.created AS DATE) <=' => '2008-08-02' - )); - $expected = " WHERE CAST(`Book`.`created` AS DATE) <= '2008-08-02'"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array( - '(Stats.clicks * 100) / Stats.views >' => 50 - )); - $expected = " WHERE (`Stats`.`clicks` * 100) / `Stats`.`views` > 50"; - $this->assertEquals($expected, $result); - } - -/** - * testMixedConditionsParsing method - * - * @return void - */ - public function testMixedConditionsParsing() { - $conditions[] = 'User.first_name = \'Firstname\''; - $conditions[] = array('User.last_name' => 'Lastname'); - $result = $this->Dbo->conditions($conditions); - $expected = " WHERE `User`.`first_name` = 'Firstname' AND `User`.`last_name` = 'Lastname'"; - $this->assertEquals($expected, $result); - - $conditions = array( - 'Thread.project_id' => 5, - 'Thread.buyer_id' => 14, - '1=1 GROUP BY Thread.project_id' - ); - $result = $this->Dbo->conditions($conditions); - $this->assertRegExp('/^\s*WHERE\s+`Thread`.`project_id`\s*=\s*5\s+AND\s+`Thread`.`buyer_id`\s*=\s*14\s+AND\s+1\s*=\s*1\s+GROUP BY `Thread`.`project_id`$/', $result); - } - -/** - * testConditionsOptionalArguments method - * - * @return void - */ - public function testConditionsOptionalArguments() { - $result = $this->Dbo->conditions( array('Member.name' => 'Mariano'), true, false); - $this->assertRegExp('/^\s*`Member`.`name`\s*=\s*\'Mariano\'\s*$/', $result); - - $result = $this->Dbo->conditions( array(), true, false); - $this->assertRegExp('/^\s*1\s*=\s*1\s*$/', $result); - } - -/** - * testConditionsWithModel - * - * @return void - */ - public function testConditionsWithModel() { - $this->Model = new Article2(); - - $result = $this->Dbo->conditions(array('Article2.viewed >=' => 0), true, true, $this->Model); - $expected = " WHERE `Article2`.`viewed` >= 0"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Article2.viewed >=' => '0'), true, true, $this->Model); - $expected = " WHERE `Article2`.`viewed` >= 0"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Article2.viewed >=' => '1'), true, true, $this->Model); - $expected = " WHERE `Article2`.`viewed` >= 1"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Article2.rate_sum BETWEEN ? AND ?' => array(0, 10)), true, true, $this->Model); - $expected = " WHERE `Article2`.`rate_sum` BETWEEN 0 AND 10"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Article2.rate_sum BETWEEN ? AND ?' => array('0', '10')), true, true, $this->Model); - $expected = " WHERE `Article2`.`rate_sum` BETWEEN 0 AND 10"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->conditions(array('Article2.rate_sum BETWEEN ? AND ?' => array('1', '10')), true, true, $this->Model); - $expected = " WHERE `Article2`.`rate_sum` BETWEEN 1 AND 10"; - $this->assertEquals($expected, $result); - } - -/** - * testFieldParsing method - * - * @return void - */ - public function testFieldParsing() { - $this->Model = new TestModel(); - $result = $this->Dbo->fields($this->Model, 'Vendor', "Vendor.id, COUNT(Model.vendor_id) AS `Vendor`.`count`"); - $expected = array('`Vendor`.`id`', 'COUNT(`Model`.`vendor_id`) AS `Vendor`.`count`'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, 'Vendor', "`Vendor`.`id`, COUNT(`Model`.`vendor_id`) AS `Vendor`.`count`"); - $expected = array('`Vendor`.`id`', 'COUNT(`Model`.`vendor_id`) AS `Vendor`.`count`'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, 'Post', "CONCAT(REPEAT(' ', COUNT(Parent.name) - 1), Node.name) AS name, Node.created"); - $expected = array("CONCAT(REPEAT(' ', COUNT(`Parent`.`name`) - 1), Node.name) AS name", "`Node`.`created`"); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, 'round( (3.55441 * fooField), 3 ) AS test'); - $this->assertEquals(array('round( (3.55441 * fooField), 3 ) AS test'), $result); - - $result = $this->Dbo->fields($this->Model, null, 'ROUND(`Rating`.`rate_total` / `Rating`.`rate_count`,2) AS rating'); - $this->assertEquals(array('ROUND(`Rating`.`rate_total` / `Rating`.`rate_count`,2) AS rating'), $result); - - $result = $this->Dbo->fields($this->Model, null, 'ROUND(Rating.rate_total / Rating.rate_count,2) AS rating'); - $this->assertEquals(array('ROUND(Rating.rate_total / Rating.rate_count,2) AS rating'), $result); - - $result = $this->Dbo->fields($this->Model, 'Post', "Node.created, CONCAT(REPEAT(' ', COUNT(Parent.name) - 1), Node.name) AS name"); - $expected = array("`Node`.`created`", "CONCAT(REPEAT(' ', COUNT(`Parent`.`name`) - 1), Node.name) AS name"); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, 'Post', "2.2,COUNT(*), SUM(Something.else) as sum, Node.created, CONCAT(REPEAT(' ', COUNT(Parent.name) - 1), Node.name) AS name,Post.title,Post.1,1.1"); - $expected = array( - '2.2', 'COUNT(*)', 'SUM(`Something`.`else`) as sum', '`Node`.`created`', - "CONCAT(REPEAT(' ', COUNT(`Parent`.`name`) - 1), Node.name) AS name", '`Post`.`title`', '`Post`.`1`', '1.1' - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, "(`Provider`.`star_total` / `Provider`.`total_ratings`) as `rating`"); - $expected = array("(`Provider`.`star_total` / `Provider`.`total_ratings`) as `rating`"); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, 'Post'); - $expected = array( - '`Post`.`id`', '`Post`.`client_id`', '`Post`.`name`', '`Post`.`login`', - '`Post`.`passwd`', '`Post`.`addr_1`', '`Post`.`addr_2`', '`Post`.`zip_code`', - '`Post`.`city`', '`Post`.`country`', '`Post`.`phone`', '`Post`.`fax`', - '`Post`.`url`', '`Post`.`email`', '`Post`.`comments`', '`Post`.`last_login`', - '`Post`.`created`', '`Post`.`updated`' - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, 'Other'); - $expected = array( - '`Other`.`id`', '`Other`.`client_id`', '`Other`.`name`', '`Other`.`login`', - '`Other`.`passwd`', '`Other`.`addr_1`', '`Other`.`addr_2`', '`Other`.`zip_code`', - '`Other`.`city`', '`Other`.`country`', '`Other`.`phone`', '`Other`.`fax`', - '`Other`.`url`', '`Other`.`email`', '`Other`.`comments`', '`Other`.`last_login`', - '`Other`.`created`', '`Other`.`updated`' - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, array(), false); - $expected = array('id', 'client_id', 'name', 'login', 'passwd', 'addr_1', 'addr_2', 'zip_code', 'city', 'country', 'phone', 'fax', 'url', 'email', 'comments', 'last_login', 'created', 'updated'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, 'COUNT(*)'); - $expected = array('COUNT(*)'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, 'SUM(Thread.unread_buyer) AS ' . $this->Dbo->name('sum_unread_buyer')); - $expected = array('SUM(`Thread`.`unread_buyer`) AS `sum_unread_buyer`'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, 'name, count(*)'); - $expected = array('`TestModel`.`name`', 'count(*)'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, 'count(*), name'); - $expected = array('count(*)', '`TestModel`.`name`'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields( - $this->Model, null, 'field1, field2, field3, count(*), name' - ); - $expected = array( - '`TestModel`.`field1`', '`TestModel`.`field2`', - '`TestModel`.`field3`', 'count(*)', '`TestModel`.`name`' - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, array('dayofyear(now())')); - $expected = array('dayofyear(now())'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, array('MAX(Model.field) As Max')); - $expected = array('MAX(`Model`.`field`) As Max'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, array('Model.field AS AnotherName')); - $expected = array('`Model`.`field` AS `AnotherName`'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, array('field AS AnotherName')); - $expected = array('`field` AS `AnotherName`'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, null, array( - 'TestModel.field AS AnotherName' - )); - $expected = array('`TestModel`.`field` AS `AnotherName`'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->Model, 'Foo', array( - 'id', 'title', '(user_count + discussion_count + post_count) AS score' - )); - $expected = array( - '`Foo`.`id`', - '`Foo`.`title`', - '(user_count + discussion_count + post_count) AS score' - ); - $this->assertEquals($expected, $result); - } - -/** - * test that fields() will accept objects made from DboSource::expression - * - * @return void - */ - public function testFieldsWithExpression() { - $this->Model = new TestModel; - $expression = $this->Dbo->expression("CASE Sample.id WHEN 1 THEN 'Id One' ELSE 'Other Id' END AS case_col"); - $result = $this->Dbo->fields($this->Model, null, array("id", $expression)); - $expected = array( - '`TestModel`.`id`', - "CASE Sample.id WHEN 1 THEN 'Id One' ELSE 'Other Id' END AS case_col" - ); - $this->assertEquals($expected, $result); - } - -/** - * testRenderStatement method - * - * @return void - */ - public function testRenderStatement() { - $result = $this->Dbo->renderStatement('select', array( - 'fields' => 'id', 'table' => 'table', 'conditions' => 'WHERE 1=1', - 'alias' => '', 'joins' => '', 'order' => '', 'limit' => '', 'group' => '' - )); - $this->assertRegExp('/^\s*SELECT\s+id\s+FROM\s+table\s+WHERE\s+1=1\s*$/', $result); - - $result = $this->Dbo->renderStatement('update', array('fields' => 'value=2', 'table' => 'table', 'conditions' => 'WHERE 1=1', 'alias' => '')); - $this->assertRegExp('/^\s*UPDATE\s+table\s+SET\s+value=2\s+WHERE\s+1=1\s*$/', $result); - - $result = $this->Dbo->renderStatement('update', array('fields' => 'value=2', 'table' => 'table', 'conditions' => 'WHERE 1=1', 'alias' => 'alias', 'joins' => '')); - $this->assertRegExp('/^\s*UPDATE\s+table\s+AS\s+alias\s+SET\s+value=2\s+WHERE\s+1=1\s*$/', $result); - - $result = $this->Dbo->renderStatement('delete', array('fields' => 'value=2', 'table' => 'table', 'conditions' => 'WHERE 1=1', 'alias' => '')); - $this->assertRegExp('/^\s*DELETE\s+FROM\s+table\s+WHERE\s+1=1\s*$/', $result); - - $result = $this->Dbo->renderStatement('delete', array('fields' => 'value=2', 'table' => 'table', 'conditions' => 'WHERE 1=1', 'alias' => 'alias', 'joins' => '')); - $this->assertRegExp('/^\s*DELETE\s+alias\s+FROM\s+table\s+AS\s+alias\s+WHERE\s+1=1\s*$/', $result); - } - -/** - * testSchema method - * - * @return void - */ - public function testSchema() { - $Schema = new CakeSchema(); - $Schema->tables = array('table' => array(), 'anotherTable' => array()); - - $result = $this->Dbo->dropSchema($Schema, 'non_existing'); - $this->assertTrue(empty($result)); - - $result = $this->Dbo->dropSchema($Schema, 'table'); - $this->assertRegExp('/^\s*DROP TABLE IF EXISTS\s+' . $this->Dbo->fullTableName('table') . ';\s*$/s', $result); - } - -/** - * testDropSchemaNoSchema method - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testDropSchemaNoSchema() { - $result = $this->Dbo->dropSchema(null); - } - -/** - * testOrderParsing method - * - * @return void - */ - public function testOrderParsing() { - $result = $this->Dbo->order("ADDTIME(Event.time_begin, '-06:00:00') ASC"); - $expected = " ORDER BY ADDTIME(`Event`.`time_begin`, '-06:00:00') ASC"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->order("title, id"); - $this->assertRegExp('/^\s*ORDER BY\s+`title`\s+ASC,\s+`id`\s+ASC\s*$/', $result); - - $result = $this->Dbo->order("title desc, id desc"); - $this->assertRegExp('/^\s*ORDER BY\s+`title`\s+desc,\s+`id`\s+desc\s*$/', $result); - - $result = $this->Dbo->order(array("title desc, id desc")); - $this->assertRegExp('/^\s*ORDER BY\s+`title`\s+desc,\s+`id`\s+desc\s*$/', $result); - - $result = $this->Dbo->order(array("title", "id")); - $this->assertRegExp('/^\s*ORDER BY\s+`title`\s+ASC,\s+`id`\s+ASC\s*$/', $result); - - $result = $this->Dbo->order(array(array('title'), array('id'))); - $this->assertRegExp('/^\s*ORDER BY\s+`title`\s+ASC,\s+`id`\s+ASC\s*$/', $result); - - $result = $this->Dbo->order(array("Post.title" => 'asc', "Post.id" => 'desc')); - $this->assertRegExp('/^\s*ORDER BY\s+`Post`.`title`\s+asc,\s+`Post`.`id`\s+desc\s*$/', $result); - - $result = $this->Dbo->order(array(array("Post.title" => 'asc', "Post.id" => 'desc'))); - $this->assertRegExp('/^\s*ORDER BY\s+`Post`.`title`\s+asc,\s+`Post`.`id`\s+desc\s*$/', $result); - - $result = $this->Dbo->order(array("title")); - $this->assertRegExp('/^\s*ORDER BY\s+`title`\s+ASC\s*$/', $result); - - $result = $this->Dbo->order(array(array("title"))); - $this->assertRegExp('/^\s*ORDER BY\s+`title`\s+ASC\s*$/', $result); - - $result = $this->Dbo->order("Dealer.id = 7 desc, Dealer.id = 3 desc, Dealer.title asc"); - $expected = " ORDER BY `Dealer`.`id` = 7 desc, `Dealer`.`id` = 3 desc, `Dealer`.`title` asc"; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->order(array("Page.name" => "='test' DESC")); - $this->assertRegExp("/^\s*ORDER BY\s+`Page`\.`name`\s*='test'\s+DESC\s*$/", $result); - - $result = $this->Dbo->order("Page.name = 'view' DESC"); - $this->assertRegExp("/^\s*ORDER BY\s+`Page`\.`name`\s*=\s*'view'\s+DESC\s*$/", $result); - - $result = $this->Dbo->order("(Post.views)"); - $this->assertRegExp("/^\s*ORDER BY\s+\(`Post`\.`views`\)\s+ASC\s*$/", $result); - - $result = $this->Dbo->order("(Post.views)*Post.views"); - $this->assertRegExp("/^\s*ORDER BY\s+\(`Post`\.`views`\)\*`Post`\.`views`\s+ASC\s*$/", $result); - - $result = $this->Dbo->order("(Post.views) * Post.views"); - $this->assertRegExp("/^\s*ORDER BY\s+\(`Post`\.`views`\) \* `Post`\.`views`\s+ASC\s*$/", $result); - - $result = $this->Dbo->order("(Model.field1 + Model.field2) * Model.field3"); - $this->assertRegExp("/^\s*ORDER BY\s+\(`Model`\.`field1` \+ `Model`\.`field2`\) \* `Model`\.`field3`\s+ASC\s*$/", $result); - - $result = $this->Dbo->order("Model.name+0 ASC"); - $this->assertRegExp("/^\s*ORDER BY\s+`Model`\.`name`\+0\s+ASC\s*$/", $result); - - $result = $this->Dbo->order("Anuncio.destaque & 2 DESC"); - $expected = ' ORDER BY `Anuncio`.`destaque` & 2 DESC'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->order("3963.191 * id"); - $expected = ' ORDER BY 3963.191 * id ASC'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->order(array('Property.sale_price IS NULL')); - $expected = ' ORDER BY `Property`.`sale_price` IS NULL ASC'; - $this->assertEquals($expected, $result); - } - -/** - * testComplexSortExpression method - * - * @return void - */ - public function testComplexSortExpression() { - $result = $this->Dbo->order(array('(Model.field > 100) DESC', 'Model.field ASC')); - $this->assertRegExp("/^\s*ORDER BY\s+\(`Model`\.`field`\s+>\s+100\)\s+DESC,\s+`Model`\.`field`\s+ASC\s*$/", $result); - } - -/** - * testCalculations method - * - * @return void - */ - public function testCalculations() { - $this->Model = new TestModel(); - $result = $this->Dbo->calculate($this->Model, 'count'); - $this->assertEquals('COUNT(*) AS `count`', $result); - - $result = $this->Dbo->calculate($this->Model, 'count', array('id')); - $this->assertEquals('COUNT(`id`) AS `count`', $result); - - $result = $this->Dbo->calculate( - $this->Model, - 'count', - array($this->Dbo->expression('DISTINCT id')) - ); - $this->assertEquals('COUNT(DISTINCT id) AS `count`', $result); - - $result = $this->Dbo->calculate($this->Model, 'count', array('id', 'id_count')); - $this->assertEquals('COUNT(`id`) AS `id_count`', $result); - - $result = $this->Dbo->calculate($this->Model, 'count', array('Model.id', 'id_count')); - $this->assertEquals('COUNT(`Model`.`id`) AS `id_count`', $result); - - $result = $this->Dbo->calculate($this->Model, 'max', array('id')); - $this->assertEquals('MAX(`id`) AS `id`', $result); - - $result = $this->Dbo->calculate($this->Model, 'max', array('Model.id', 'id')); - $this->assertEquals('MAX(`Model`.`id`) AS `id`', $result); - - $result = $this->Dbo->calculate($this->Model, 'max', array('`Model`.`id`', 'id')); - $this->assertEquals('MAX(`Model`.`id`) AS `id`', $result); - - $result = $this->Dbo->calculate($this->Model, 'min', array('`Model`.`id`', 'id')); - $this->assertEquals('MIN(`Model`.`id`) AS `id`', $result); - - $result = $this->Dbo->calculate($this->Model, 'min', 'left'); - $this->assertEquals('MIN(`left`) AS `left`', $result); - } - -/** - * testLength method - * - * @return void - */ - public function testLength() { - $result = $this->Dbo->length('varchar(255)'); - $expected = 255; - $this->assertSame($expected, $result); - - $result = $this->Dbo->length('int(11)'); - $expected = 11; - $this->assertSame($expected, $result); - - $result = $this->Dbo->length('float(5,3)'); - $expected = '5,3'; - $this->assertSame($expected, $result); - - $result = $this->Dbo->length('decimal(5,2)'); - $expected = '5,2'; - $this->assertSame($expected, $result); - - $result = $this->Dbo->length("enum('test','me','now')"); - $expected = 4; - $this->assertSame($expected, $result); - - $result = $this->Dbo->length("set('a','b','cd')"); - $expected = 2; - $this->assertSame($expected, $result); - - $result = $this->Dbo->length(false); - $this->assertTrue($result === null); - - $result = $this->Dbo->length('datetime'); - $expected = null; - $this->assertSame($expected, $result); - - $result = $this->Dbo->length('text'); - $expected = null; - $this->assertSame($expected, $result); - } - -/** - * testBuildIndex method - * - * @return void - */ - public function testBuildIndex() { - $data = array( - 'PRIMARY' => array('column' => 'id') - ); - $result = $this->Dbo->buildIndex($data); - $expected = array('PRIMARY KEY (`id`)'); - $this->assertSame($expected, $result); - - $data = array( - 'MyIndex' => array('column' => 'id', 'unique' => true) - ); - $result = $this->Dbo->buildIndex($data); - $expected = array('UNIQUE KEY `MyIndex` (`id`)'); - $this->assertEquals($expected, $result); - - $data = array( - 'MyIndex' => array('column' => array('id', 'name'), 'unique' => true) - ); - $result = $this->Dbo->buildIndex($data); - $expected = array('UNIQUE KEY `MyIndex` (`id`, `name`)'); - $this->assertEquals($expected, $result); - } - -/** - * testBuildColumn method - * - * @return void - */ - public function testBuildColumn2() { - $data = array( - 'name' => 'testName', - 'type' => 'string', - 'length' => 255, - 'default', - 'null' => true, - 'key' - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`testName` varchar(255) DEFAULT NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'int_field', - 'type' => 'integer', - 'default' => '', - 'null' => false, - ); - $restore = $this->Dbo->columns; - - $this->Dbo->columns = array('integer' => array('name' => 'int', 'limit' => '11', 'formatter' => 'intval'), ); - $result = $this->Dbo->buildColumn($data); - $expected = '`int_field` int(11) NOT NULL'; - $this->assertEquals($expected, $result); - - $this->Dbo->fieldParameters['param'] = array( - 'value' => 'COLLATE', - 'quote' => false, - 'join' => ' ', - 'column' => 'Collate', - 'position' => 'beforeDefault', - 'options' => array('GOOD', 'OK') - ); - $data = array( - 'name' => 'int_field', - 'type' => 'integer', - 'default' => '', - 'null' => false, - 'param' => 'BAD' - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`int_field` int(11) NOT NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'int_field', - 'type' => 'integer', - 'default' => '', - 'null' => false, - 'param' => 'GOOD' - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`int_field` int(11) COLLATE GOOD NOT NULL'; - $this->assertEquals($expected, $result); - - $this->Dbo->columns = $restore; - - $data = array( - 'name' => 'created', - 'type' => 'timestamp', - 'default' => 'current_timestamp', - 'null' => false, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`created` timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'created', - 'type' => 'timestamp', - 'default' => 'CURRENT_TIMESTAMP', - 'null' => true, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`created` timestamp DEFAULT CURRENT_TIMESTAMP'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'modified', - 'type' => 'timestamp', - 'null' => true, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`modified` timestamp NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'modified', - 'type' => 'timestamp', - 'default' => null, - 'null' => true, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '`modified` timestamp NULL'; - $this->assertEquals($expected, $result); - } - -/** - * testBuildColumnBadType method - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testBuildColumnBadType() { - $data = array( - 'name' => 'testName', - 'type' => 'varchar(255)', - 'default', - 'null' => true, - 'key' - ); - $this->Dbo->buildColumn($data); - } - -/** - * test hasAny() - * - * @return void - */ - public function testHasAny() { - $db = $this->Dbo->config['database']; - $this->Dbo = $this->getMock('Mysql', array('connect', '_execute', 'execute', 'value')); - $this->Dbo->config['database'] = $db; - - $this->Model = $this->getMock('TestModel', array('getDataSource')); - $this->Model->expects($this->any()) - ->method('getDataSource') - ->will($this->returnValue($this->Dbo)); - - $this->Dbo->expects($this->at(0))->method('value') - ->with('harry') - ->will($this->returnValue("'harry'")); - - $modelTable = $this->Dbo->fullTableName($this->Model); - $this->Dbo->expects($this->at(1))->method('execute') - ->with('SELECT COUNT(`TestModel`.`id`) AS count FROM ' . $modelTable . ' AS `TestModel` WHERE `TestModel`.`name` = \'harry\''); - $this->Dbo->expects($this->at(2))->method('execute') - ->with('SELECT COUNT(`TestModel`.`id`) AS count FROM ' . $modelTable . ' AS `TestModel` WHERE 1 = 1'); - - $this->Dbo->hasAny($this->Model, array('TestModel.name' => 'harry')); - $this->Dbo->hasAny($this->Model, array()); - } - -/** - * test fields generating usable virtual fields to use in query - * - * @return void - */ - public function testVirtualFields() { - $this->loadFixtures('Article', 'Comment', 'Tag'); - $this->Dbo->virtualFieldSeparator = '__'; - $Article = ClassRegistry::init('Article'); - $commentsTable = $this->Dbo->fullTableName('comments', false, false); - $Article->virtualFields = array( - 'this_moment' => 'NOW()', - 'two' => '1 + 1', - 'comment_count' => 'SELECT COUNT(*) FROM ' . $commentsTable . - ' WHERE Article.id = ' . $commentsTable . '.article_id' - ); - $result = $this->Dbo->fields($Article); - $expected = array( - '`Article`.`id`', - '`Article`.`user_id`', - '`Article`.`title`', - '`Article`.`body`', - '`Article`.`published`', - '`Article`.`created`', - '`Article`.`updated`', - '(NOW()) AS `Article__this_moment`', - '(1 + 1) AS `Article__two`', - "(SELECT COUNT(*) FROM $commentsTable WHERE `Article`.`id` = `$commentsTable`.`article_id`) AS `Article__comment_count`" - ); - - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($Article, null, array('this_moment', 'title')); - $expected = array( - '`Article`.`title`', - '(NOW()) AS `Article__this_moment`', - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($Article, null, array('Article.title', 'Article.this_moment')); - $expected = array( - '`Article`.`title`', - '(NOW()) AS `Article__this_moment`', - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($Article, null, array('Article.this_moment', 'Article.title')); - $expected = array( - '`Article`.`title`', - '(NOW()) AS `Article__this_moment`', - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($Article, null, array('Article.*')); - $expected = array( - '`Article`.*', - '(NOW()) AS `Article__this_moment`', - '(1 + 1) AS `Article__two`', - "(SELECT COUNT(*) FROM $commentsTable WHERE `Article`.`id` = `$commentsTable`.`article_id`) AS `Article__comment_count`" - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($Article, null, array('*')); - $expected = array( - '*', - '(NOW()) AS `Article__this_moment`', - '(1 + 1) AS `Article__two`', - "(SELECT COUNT(*) FROM $commentsTable WHERE `Article`.`id` = `$commentsTable`.`article_id`) AS `Article__comment_count`" - ); - $this->assertEquals($expected, $result); - } - -/** - * test conditions to generate query conditions for virtual fields - * - * @return void - */ - public function testVirtualFieldsInConditions() { - $Article = ClassRegistry::init('Article'); - $commentsTable = $this->Dbo->fullTableName('comments', false, false); - - $Article->virtualFields = array( - 'this_moment' => 'NOW()', - 'two' => '1 + 1', - 'comment_count' => 'SELECT COUNT(*) FROM ' . $commentsTable . - ' WHERE Article.id = ' . $commentsTable . '.article_id' - ); - $conditions = array('two' => 2); - $result = $this->Dbo->conditions($conditions, true, false, $Article); - $expected = '(1 + 1) = 2'; - $this->assertEquals($expected, $result); - - $conditions = array('this_moment BETWEEN ? AND ?' => array(1,2)); - $expected = 'NOW() BETWEEN 1 AND 2'; - $result = $this->Dbo->conditions($conditions, true, false, $Article); - $this->assertEquals($expected, $result); - - $conditions = array('comment_count >' => 5); - $expected = "(SELECT COUNT(*) FROM $commentsTable WHERE `Article`.`id` = `$commentsTable`.`article_id`) > 5"; - $result = $this->Dbo->conditions($conditions, true, false, $Article); - $this->assertEquals($expected, $result); - - $conditions = array('NOT' => array('two' => 2)); - $result = $this->Dbo->conditions($conditions, true, false, $Article); - $expected = 'NOT ((1 + 1) = 2)'; - $this->assertEquals($expected, $result); - } - -/** - * test that virtualFields with complex functions and aliases work. - * - * @return void - */ - public function testConditionsWithComplexVirtualFields() { - $Article = ClassRegistry::init('Article', 'Comment', 'Tag'); - $Article->virtualFields = array( - 'distance' => 'ACOS(SIN(20 * PI() / 180) - * SIN(Article.latitude * PI() / 180) - + COS(20 * PI() / 180) - * COS(Article.latitude * PI() / 180) - * COS((50 - Article.longitude) * PI() / 180) - ) * 180 / PI() * 60 * 1.1515 * 1.609344' - ); - $conditions = array('distance >=' => 20); - $result = $this->Dbo->conditions($conditions, true, true, $Article); - - $this->assertRegExp('/\) >= 20/', $result); - $this->assertRegExp('/[`\'"]Article[`\'"].[`\'"]latitude[`\'"]/', $result); - $this->assertRegExp('/[`\'"]Article[`\'"].[`\'"]longitude[`\'"]/', $result); - } - -/** - * test calculate to generate claculate statements on virtual fields - * - * @return void - */ - public function testVirtualFieldsInCalculate() { - $Article = ClassRegistry::init('Article'); - $commentsTable = $this->Dbo->fullTableName('comments', false, false); - $Article->virtualFields = array( - 'this_moment' => 'NOW()', - 'two' => '1 + 1', - 'comment_count' => 'SELECT COUNT(*) FROM ' . $commentsTable . - ' WHERE Article.id = ' . $commentsTable . '.article_id' - ); - - $result = $this->Dbo->calculate($Article, 'count', array('this_moment')); - $expected = 'COUNT(NOW()) AS `count`'; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->calculate($Article, 'max', array('comment_count')); - $expected = "MAX(SELECT COUNT(*) FROM $commentsTable WHERE `Article`.`id` = `$commentsTable`.`article_id`) AS `comment_count`"; - $this->assertEquals($expected, $result); - } - -/** - * test reading virtual fields containing newlines when recursive > 0 - * - * @return void - */ - public function testReadVirtualFieldsWithNewLines() { - $Article = new Article(); - $Article->recursive = 1; - $Article->virtualFields = array( - 'test' => ' - User.id + User.id - ' - ); - $result = $this->Dbo->fields($Article, null, array()); - $result = $this->Dbo->fields($Article, $Article->alias, $result); - $this->assertRegExp('/[`\"]User[`\"]\.[`\"]id[`\"] \+ [`\"]User[`\"]\.[`\"]id[`\"]/', $result[7]); - } - -/** - * test group to generate GROUP BY statements on virtual fields - * - * @return void - */ - public function testVirtualFieldsInGroup() { - $Article = ClassRegistry::init('Article'); - $Article->virtualFields = array( - 'this_year' => 'YEAR(Article.created)' - ); - - $result = $this->Dbo->group('this_year', $Article); - - $expected = " GROUP BY (YEAR(`Article`.`created`))"; - $this->assertEquals($expected, $result); - } - -/** - * test that virtualFields with complex functions and aliases work. - * - * @return void - */ - public function testFieldsWithComplexVirtualFields() { - $Article = new Article(); - $Article->virtualFields = array( - 'distance' => 'ACOS(SIN(20 * PI() / 180) - * SIN(Article.latitude * PI() / 180) - + COS(20 * PI() / 180) - * COS(Article.latitude * PI() / 180) - * COS((50 - Article.longitude) * PI() / 180) - ) * 180 / PI() * 60 * 1.1515 * 1.609344' - ); - - $fields = array('id', 'distance'); - $result = $this->Dbo->fields($Article, null, $fields); - $qs = $this->Dbo->startQuote; - $qe = $this->Dbo->endQuote; - - $this->assertEquals("{$qs}Article{$qe}.{$qs}id{$qe}", $result[0]); - $this->assertRegExp('/Article__distance/', $result[1]); - $this->assertRegExp('/[`\'"]Article[`\'"].[`\'"]latitude[`\'"]/', $result[1]); - $this->assertRegExp('/[`\'"]Article[`\'"].[`\'"]longitude[`\'"]/', $result[1]); - } - -/** - * test that execute runs queries. - * - * @return void - */ - public function testExecute() { - $query = 'SELECT * FROM ' . $this->Dbo->fullTableName('articles') . ' WHERE 1 = 1'; - $this->Dbo->took = null; - $this->Dbo->affected = null; - $result = $this->Dbo->execute($query, array('log' => false)); - $this->assertNotNull($result, 'No query performed! %s'); - $this->assertNull($this->Dbo->took, 'Stats were set %s'); - $this->assertNull($this->Dbo->affected, 'Stats were set %s'); - - $result = $this->Dbo->execute($query); - $this->assertNotNull($result, 'No query performed! %s'); - $this->assertNotNull($this->Dbo->took, 'Stats were not set %s'); - $this->assertNotNull($this->Dbo->affected, 'Stats were not set %s'); - } - -/** - * test a full example of using virtual fields - * - * @return void - */ - public function testVirtualFieldsFetch() { - $this->loadFixtures('Article', 'Comment'); - - $Article = ClassRegistry::init('Article'); - $Article->virtualFields = array( - 'comment_count' => 'SELECT COUNT(*) FROM ' . $this->Dbo->fullTableName('comments') . - ' WHERE Article.id = ' . $this->Dbo->fullTableName('comments') . '.article_id' - ); - - $conditions = array('comment_count >' => 2); - $query = 'SELECT ' . join(',', $this->Dbo->fields($Article, null, array('id', 'comment_count'))) . - ' FROM ' . $this->Dbo->fullTableName($Article) . ' Article ' . $this->Dbo->conditions($conditions, true, true, $Article); - $result = $this->Dbo->fetchAll($query); - $expected = array(array( - 'Article' => array('id' => 1, 'comment_count' => 4) - )); - $this->assertEquals($expected, $result); - } - -/** - * test reading complex virtualFields with subqueries. - * - * @return void - */ - public function testVirtualFieldsComplexRead() { - $this->loadFixtures('DataTest', 'Article', 'Comment', 'User', 'Tag', 'ArticlesTag'); - - $Article = ClassRegistry::init('Article'); - $commentTable = $this->Dbo->fullTableName('comments'); - $Article = ClassRegistry::init('Article'); - $Article->virtualFields = array( - 'comment_count' => 'SELECT COUNT(*) FROM ' . $commentTable . - ' AS Comment WHERE Article.id = Comment.article_id' - ); - $result = $Article->find('all'); - $this->assertTrue(count($result) > 0); - $this->assertTrue($result[0]['Article']['comment_count'] > 0); - - $DataTest = ClassRegistry::init('DataTest'); - $DataTest->virtualFields = array( - 'complicated' => 'ACOS(SIN(20 * PI() / 180) - * SIN(DataTest.float * PI() / 180) - + COS(20 * PI() / 180) - * COS(DataTest.count * PI() / 180) - * COS((50 - DataTest.float) * PI() / 180) - ) * 180 / PI() * 60 * 1.1515 * 1.609344' - ); - $result = $DataTest->find('all'); - $this->assertTrue(count($result) > 0); - $this->assertTrue($result[0]['DataTest']['complicated'] > 0); - } - -/** - * testIntrospectType method - * - * @return void - */ - public function testIntrospectType() { - $this->assertEquals('integer', $this->Dbo->introspectType(0)); - $this->assertEquals('integer', $this->Dbo->introspectType(2)); - $this->assertEquals('string', $this->Dbo->introspectType('2')); - $this->assertEquals('string', $this->Dbo->introspectType('2.2')); - $this->assertEquals('float', $this->Dbo->introspectType(2.2)); - $this->assertEquals('string', $this->Dbo->introspectType('stringme')); - $this->assertEquals('string', $this->Dbo->introspectType('0stringme')); - - $data = array(2.2); - $this->assertEquals('float', $this->Dbo->introspectType($data)); - - $data = array('2.2'); - $this->assertEquals('float', $this->Dbo->introspectType($data)); - - $data = array(2); - $this->assertEquals('integer', $this->Dbo->introspectType($data)); - - $data = array('2'); - $this->assertEquals('integer', $this->Dbo->introspectType($data)); - - $data = array('string'); - $this->assertEquals('string', $this->Dbo->introspectType($data)); - - $data = array(2.2, '2.2'); - $this->assertEquals('float', $this->Dbo->introspectType($data)); - - $data = array(2, '2'); - $this->assertEquals('integer', $this->Dbo->introspectType($data)); - - $data = array('string one', 'string two'); - $this->assertEquals('string', $this->Dbo->introspectType($data)); - - $data = array('2.2', 3); - $this->assertEquals('integer', $this->Dbo->introspectType($data)); - - $data = array('2.2', '0stringme'); - $this->assertEquals('string', $this->Dbo->introspectType($data)); - - $data = array(2.2, 3); - $this->assertEquals('integer', $this->Dbo->introspectType($data)); - - $data = array(2.2, '0stringme'); - $this->assertEquals('string', $this->Dbo->introspectType($data)); - - $data = array(2, 'stringme'); - $this->assertEquals('string', $this->Dbo->introspectType($data)); - - $data = array(2, '2.2', 'stringgme'); - $this->assertEquals('string', $this->Dbo->introspectType($data)); - - $data = array(2, '2.2'); - $this->assertEquals('integer', $this->Dbo->introspectType($data)); - - $data = array(2, 2.2); - $this->assertEquals('integer', $this->Dbo->introspectType($data)); - - // null - $result = $this->Dbo->value(null, 'boolean'); - $this->assertEquals('NULL', $result); - - // EMPTY STRING - $result = $this->Dbo->value('', 'boolean'); - $this->assertEquals("'0'", $result); - - // BOOLEAN - $result = $this->Dbo->value('true', 'boolean'); - $this->assertEquals("'1'", $result); - - $result = $this->Dbo->value('false', 'boolean'); - $this->assertEquals("'1'", $result); - - $result = $this->Dbo->value(true, 'boolean'); - $this->assertEquals("'1'", $result); - - $result = $this->Dbo->value(false, 'boolean'); - $this->assertEquals("'0'", $result); - - $result = $this->Dbo->value(1, 'boolean'); - $this->assertEquals("'1'", $result); - - $result = $this->Dbo->value(0, 'boolean'); - $this->assertEquals("'0'", $result); - - $result = $this->Dbo->value('abc', 'boolean'); - $this->assertEquals("'1'", $result); - - $result = $this->Dbo->value(1.234, 'boolean'); - $this->assertEquals("'1'", $result); - - $result = $this->Dbo->value('1.234e05', 'boolean'); - $this->assertEquals("'1'", $result); - - // NUMBERS - $result = $this->Dbo->value(123, 'integer'); - $this->assertEquals(123, $result); - - $result = $this->Dbo->value('123', 'integer'); - $this->assertEquals('123', $result); - - $result = $this->Dbo->value('0123', 'integer'); - $this->assertEquals("'0123'", $result); - - $result = $this->Dbo->value('0x123ABC', 'integer'); - $this->assertEquals("'0x123ABC'", $result); - - $result = $this->Dbo->value('0x123', 'integer'); - $this->assertEquals("'0x123'", $result); - - $result = $this->Dbo->value(1.234, 'float'); - $this->assertEquals(1.234, $result); - - $result = $this->Dbo->value('1.234', 'float'); - $this->assertEquals('1.234', $result); - - $result = $this->Dbo->value(' 1.234 ', 'float'); - $this->assertEquals("' 1.234 '", $result); - - $result = $this->Dbo->value('1.234e05', 'float'); - $this->assertEquals("'1.234e05'", $result); - - $result = $this->Dbo->value('1.234e+5', 'float'); - $this->assertEquals("'1.234e+5'", $result); - - $result = $this->Dbo->value('1,234', 'float'); - $this->assertEquals("'1,234'", $result); - - $result = $this->Dbo->value('FFF', 'integer'); - $this->assertEquals("'FFF'", $result); - - $result = $this->Dbo->value('abc', 'integer'); - $this->assertEquals("'abc'", $result); - - // STRINGS - $result = $this->Dbo->value('123', 'string'); - $this->assertEquals("'123'", $result); - - $result = $this->Dbo->value(123, 'string'); - $this->assertEquals("'123'", $result); - - $result = $this->Dbo->value(1.234, 'string'); - $this->assertEquals("'1.234'", $result); - - $result = $this->Dbo->value('abc', 'string'); - $this->assertEquals("'abc'", $result); - - $result = $this->Dbo->value(' abc ', 'string'); - $this->assertEquals("' abc '", $result); - - $result = $this->Dbo->value('a bc', 'string'); - $this->assertEquals("'a bc'", $result); - } - -/** - * testRealQueries method - * - * @return void - */ - public function testRealQueries() { - $this->loadFixtures('Apple', 'Article', 'User', 'Comment', 'Tag', 'Sample', 'ArticlesTag'); - - $Apple = ClassRegistry::init('Apple'); - $Article = ClassRegistry::init('Article'); - - $result = $this->Dbo->rawQuery('SELECT color, name FROM ' . $this->Dbo->fullTableName('apples')); - $this->assertTrue(!empty($result)); - - $result = $this->Dbo->fetchRow($result); - $expected = array($this->Dbo->fullTableName('apples', false, false) => array( - 'color' => 'Red 1', - 'name' => 'Red Apple 1' - )); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fetchAll('SELECT name FROM ' . $this->Dbo->fullTableName('apples') . ' ORDER BY id'); - $expected = array( - array($this->Dbo->fullTableName('apples', false, false) => array('name' => 'Red Apple 1')), - array($this->Dbo->fullTableName('apples', false, false) => array('name' => 'Bright Red Apple')), - array($this->Dbo->fullTableName('apples', false, false) => array('name' => 'green blue')), - array($this->Dbo->fullTableName('apples', false, false) => array('name' => 'Test Name')), - array($this->Dbo->fullTableName('apples', false, false) => array('name' => 'Blue Green')), - array($this->Dbo->fullTableName('apples', false, false) => array('name' => 'My new apple')), - array($this->Dbo->fullTableName('apples', false, false) => array('name' => 'Some odd color')) - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->field($this->Dbo->fullTableName('apples', false, false), 'SELECT color, name FROM ' . $this->Dbo->fullTableName('apples') . ' ORDER BY id'); - $expected = array( - 'color' => 'Red 1', - 'name' => 'Red Apple 1' - ); - $this->assertEquals($expected, $result); - - $Apple->unbindModel(array(), false); - $result = $this->Dbo->read($Apple, array( - 'fields' => array($Apple->escapeField('name')), - 'conditions' => null, - 'recursive' => -1 - )); - $expected = array( - array('Apple' => array('name' => 'Red Apple 1')), - array('Apple' => array('name' => 'Bright Red Apple')), - array('Apple' => array('name' => 'green blue')), - array('Apple' => array('name' => 'Test Name')), - array('Apple' => array('name' => 'Blue Green')), - array('Apple' => array('name' => 'My new apple')), - array('Apple' => array('name' => 'Some odd color')) - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->read($Article, array( - 'fields' => array('id', 'user_id', 'title'), - 'conditions' => null, - 'recursive' => 1 - )); - - $this->assertTrue(Set::matches('/Article[id=1]', $result)); - $this->assertTrue(Set::matches('/Comment[id=1]', $result)); - $this->assertTrue(Set::matches('/Comment[id=2]', $result)); - $this->assertFalse(Set::matches('/Comment[id=10]', $result)); - } - -/** - * @expectedException MissingConnectionException - * @return void - */ - public function testExceptionOnBrokenConnection() { - $dbo = new Mysql(array( - 'driver' => 'mysql', - 'host' => 'imaginary_host', - 'login' => 'mark', - 'password' => 'inyurdatabase', - 'database' => 'imaginary' - )); - } - -/** - * testStatements method - * - * @return void - */ - public function testUpdateStatements() { - $this->loadFixtures('Article', 'User'); - $test = ConnectionManager::getDatasource('test'); - $db = $test->config['database']; - - $this->Dbo = $this->getMock('Mysql', array('execute'), array($test->config)); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("UPDATE `$db`.`articles` SET `field1` = 'value1' WHERE 1 = 1"); - - $this->Dbo->expects($this->at(1))->method('execute') - ->with("UPDATE `$db`.`articles` AS `Article` LEFT JOIN `$db`.`users` AS `User` ON " . - "(`Article`.`user_id` = `User`.`id`)" . - " SET `Article`.`field1` = 2 WHERE 2=2"); - - $this->Dbo->expects($this->at(2))->method('execute') - ->with("UPDATE `$db`.`articles` AS `Article` LEFT JOIN `$db`.`users` AS `User` ON " . - "(`Article`.`user_id` = `User`.`id`)" . - " SET `Article`.`field1` = 'value' WHERE `index` = 'val'"); - - $Article = new Article(); - - $this->Dbo->update($Article, array('field1'), array('value1')); - $this->Dbo->update($Article, array('field1'), array('2'), '2=2'); - $this->Dbo->update($Article, array('field1'), array("'value'"), array('index' => 'val')); - } - -/** - * Test deletes with a mock. - * - * @return void - */ - public function testDeleteStatements() { - $this->loadFixtures('Article', 'User'); - $test = ConnectionManager::getDatasource('test'); - $db = $test->config['database']; - - $this->Dbo = $this->getMock('Mysql', array('execute'), array($test->config)); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("DELETE FROM `$db`.`articles` WHERE 1 = 1"); - - $this->Dbo->expects($this->at(1))->method('execute') - ->with("DELETE `Article` FROM `$db`.`articles` AS `Article` LEFT JOIN `$db`.`users` AS `User` " . - "ON (`Article`.`user_id` = `User`.`id`)" . - " WHERE 1 = 1"); - - $this->Dbo->expects($this->at(2))->method('execute') - ->with("DELETE `Article` FROM `$db`.`articles` AS `Article` LEFT JOIN `$db`.`users` AS `User` " . - "ON (`Article`.`user_id` = `User`.`id`)" . - " WHERE 2=2"); - $Article = new Article(); - - $this->Dbo->delete($Article); - $this->Dbo->delete($Article, true); - $this->Dbo->delete($Article, '2=2'); - } - -/** - * Test truncate with a mock. - * - * @return void - */ - public function testTruncateStatements() { - $this->loadFixtures('Article', 'User'); - $db = ConnectionManager::getDatasource('test'); - $schema = $db->config['database']; - $Article = new Article(); - - $this->Dbo = $this->getMock('Mysql', array('execute'), array($db->config)); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("TRUNCATE TABLE `$schema`.`articles`"); - $this->Dbo->truncate($Article); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("TRUNCATE TABLE `$schema`.`articles`"); - $this->Dbo->truncate('articles'); - - // #2355: prevent duplicate prefix - $this->Dbo->config['prefix'] = 'tbl_'; - $Article->tablePrefix = 'tbl_'; - $this->Dbo->expects($this->at(0))->method('execute') - ->with("TRUNCATE TABLE `$schema`.`tbl_articles`"); - $this->Dbo->truncate($Article); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("TRUNCATE TABLE `$schema`.`tbl_articles`"); - $this->Dbo->truncate('articles'); - } -} diff --git a/lib/Cake/Test/Case/Model/Datasource/Database/PostgresTest.php b/lib/Cake/Test/Case/Model/Datasource/Database/PostgresTest.php deleted file mode 100644 index 54b331f48f4..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/Database/PostgresTest.php +++ /dev/null @@ -1,912 +0,0 @@ -simulated[] = $sql; - return null; - } - -/** - * getLastQuery method - * - * @return void - */ - public function getLastQuery() { - return $this->simulated[count($this->simulated) - 1]; - } - -} - -/** - * PostgresTestModel class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class PostgresTestModel extends Model { - -/** - * name property - * - * @var string 'PostgresTestModel' - */ - public $name = 'PostgresTestModel'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'PostgresClientTestModel' => array( - 'foreignKey' => 'client_id' - ) - ); - -/** - * find method - * - * @param mixed $conditions - * @param mixed $fields - * @param mixed $order - * @param mixed $recursive - * @return void - */ - public function find($conditions = null, $fields = null, $order = null, $recursive = null) { - return $conditions; - } - -/** - * findAll method - * - * @param mixed $conditions - * @param mixed $fields - * @param mixed $order - * @param mixed $recursive - * @return void - */ - public function findAll($conditions = null, $fields = null, $order = null, $recursive = null) { - return $conditions; - } - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - return array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'client_id' => array('type' => 'integer', 'null' => '', 'default' => '0', 'length' => '11'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'login' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'passwd' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_1' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_2' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '25'), - 'zip_code' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'city' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'country' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'phone' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'fax' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'url' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'comments' => array('type' => 'text', 'null' => '1', 'default' => '', 'length' => ''), - 'last_login' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => ''), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - -} - -/** - * PostgresClientTestModel class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class PostgresClientTestModel extends Model { - -/** - * name property - * - * @var string 'PostgresClientTestModel' - */ - public $name = 'PostgresClientTestModel'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - return array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8', 'key' => 'primary'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'created' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - -} - -/** - * PostgresTest class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class PostgresTest extends CakeTestCase { - -/** - * Do not automatically load fixtures for each test, they will be loaded manually - * using CakeTestCase::loadFixtures - * - * @var boolean - */ - public $autoFixtures = false; - -/** - * Fixtures - * - * @var object - */ - public $fixtures = array('core.user', 'core.binary_test', 'core.comment', 'core.article', - 'core.tag', 'core.articles_tag', 'core.attachment', 'core.person', 'core.post', 'core.author', - 'core.datatype', - ); - -/** - * Actual DB connection used in testing - * - * @var DboSource - */ - public $Dbo = null; - -/** - * Simulated DB connection used in testing - * - * @var DboSource - */ - public $Dbo2 = null; - -/** - * Sets up a Dbo class instance for testing - * - */ - public function setUp() { - Configure::write('Cache.disable', true); - $this->Dbo = ConnectionManager::getDataSource('test'); - $this->skipIf(!($this->Dbo instanceof Postgres)); - $this->Dbo2 = new DboPostgresTestDb($this->Dbo->config, false); - $this->model = new PostgresTestModel(); - } - -/** - * Sets up a Dbo class instance for testing - * - */ - public function tearDown() { - Configure::write('Cache.disable', false); - unset($this->Dbo2); - } - -/** - * Test field quoting method - * - */ - public function testFieldQuoting() { - $fields = array( - '"PostgresTestModel"."id" AS "PostgresTestModel__id"', - '"PostgresTestModel"."client_id" AS "PostgresTestModel__client_id"', - '"PostgresTestModel"."name" AS "PostgresTestModel__name"', - '"PostgresTestModel"."login" AS "PostgresTestModel__login"', - '"PostgresTestModel"."passwd" AS "PostgresTestModel__passwd"', - '"PostgresTestModel"."addr_1" AS "PostgresTestModel__addr_1"', - '"PostgresTestModel"."addr_2" AS "PostgresTestModel__addr_2"', - '"PostgresTestModel"."zip_code" AS "PostgresTestModel__zip_code"', - '"PostgresTestModel"."city" AS "PostgresTestModel__city"', - '"PostgresTestModel"."country" AS "PostgresTestModel__country"', - '"PostgresTestModel"."phone" AS "PostgresTestModel__phone"', - '"PostgresTestModel"."fax" AS "PostgresTestModel__fax"', - '"PostgresTestModel"."url" AS "PostgresTestModel__url"', - '"PostgresTestModel"."email" AS "PostgresTestModel__email"', - '"PostgresTestModel"."comments" AS "PostgresTestModel__comments"', - '"PostgresTestModel"."last_login" AS "PostgresTestModel__last_login"', - '"PostgresTestModel"."created" AS "PostgresTestModel__created"', - '"PostgresTestModel"."updated" AS "PostgresTestModel__updated"' - ); - - $result = $this->Dbo->fields($this->model); - $expected = $fields; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->model, null, 'PostgresTestModel.*'); - $expected = $fields; - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->model, null, array('*', 'AnotherModel.id', 'AnotherModel.name')); - $expected = array_merge($fields, array( - '"AnotherModel"."id" AS "AnotherModel__id"', - '"AnotherModel"."name" AS "AnotherModel__name"')); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($this->model, null, array('*', 'PostgresClientTestModel.*')); - $expected = array_merge($fields, array( - '"PostgresClientTestModel"."id" AS "PostgresClientTestModel__id"', - '"PostgresClientTestModel"."name" AS "PostgresClientTestModel__name"', - '"PostgresClientTestModel"."email" AS "PostgresClientTestModel__email"', - '"PostgresClientTestModel"."created" AS "PostgresClientTestModel__created"', - '"PostgresClientTestModel"."updated" AS "PostgresClientTestModel__updated"')); - $this->assertEquals($expected, $result); - } - -/** - * testColumnParsing method - * - * @return void - */ - public function testColumnParsing() { - $this->assertEquals('text', $this->Dbo2->column('text')); - $this->assertEquals('date', $this->Dbo2->column('date')); - $this->assertEquals('boolean', $this->Dbo2->column('boolean')); - $this->assertEquals('string', $this->Dbo2->column('character varying')); - $this->assertEquals('time', $this->Dbo2->column('time without time zone')); - $this->assertEquals('datetime', $this->Dbo2->column('timestamp without time zone')); - } - -/** - * testValueQuoting method - * - * @return void - */ - public function testValueQuoting() { - $this->assertEquals("1.200000", $this->Dbo->value(1.2, 'float')); - $this->assertEquals("'1,2'", $this->Dbo->value('1,2', 'float')); - - $this->assertEquals("0", $this->Dbo->value('0', 'integer')); - $this->assertEquals('NULL', $this->Dbo->value('', 'integer')); - $this->assertEquals('NULL', $this->Dbo->value('', 'float')); - $this->assertEquals("NULL", $this->Dbo->value('', 'integer', false)); - $this->assertEquals("NULL", $this->Dbo->value('', 'float', false)); - $this->assertEquals("'0.0'", $this->Dbo->value('0.0', 'float')); - - $this->assertEquals("'TRUE'", $this->Dbo->value('t', 'boolean')); - $this->assertEquals("'FALSE'", $this->Dbo->value('f', 'boolean')); - $this->assertEquals("'TRUE'", $this->Dbo->value(true)); - $this->assertEquals("'FALSE'", $this->Dbo->value(false)); - $this->assertEquals("'t'", $this->Dbo->value('t')); - $this->assertEquals("'f'", $this->Dbo->value('f')); - $this->assertEquals("'TRUE'", $this->Dbo->value('true', 'boolean')); - $this->assertEquals("'FALSE'", $this->Dbo->value('false', 'boolean')); - $this->assertEquals("'FALSE'", $this->Dbo->value('', 'boolean')); - $this->assertEquals("'FALSE'", $this->Dbo->value(0, 'boolean')); - $this->assertEquals("'TRUE'", $this->Dbo->value(1, 'boolean')); - $this->assertEquals("'TRUE'", $this->Dbo->value('1', 'boolean')); - $this->assertEquals("NULL", $this->Dbo->value(null, 'boolean')); - $this->assertEquals("NULL", $this->Dbo->value(array())); - } - -/** - * test that localized floats don't cause trouble. - * - * @return void - */ - public function testLocalizedFloats() { - $restore = setlocale(LC_ALL, 0); - setlocale(LC_ALL, 'de_DE'); - - $result = $this->db->value(3.141593, 'float'); - $this->assertEquals("3.141593", $result); - - $result = $this->db->value(3.14); - $this->assertEquals("3.140000", $result); - - setlocale(LC_ALL, $restore); - } - -/** - * test that date and time columns do not generate errors with null and nullish values. - * - * @return void - */ - public function testDateAndTimeAsNull() { - $this->assertEquals('NULL', $this->Dbo->value(null, 'date')); - $this->assertEquals('NULL', $this->Dbo->value('', 'date')); - - $this->assertEquals('NULL', $this->Dbo->value('', 'datetime')); - $this->assertEquals('NULL', $this->Dbo->value(null, 'datetime')); - - $this->assertEquals('NULL', $this->Dbo->value('', 'timestamp')); - $this->assertEquals('NULL', $this->Dbo->value(null, 'timestamp')); - - $this->assertEquals('NULL', $this->Dbo->value('', 'time')); - $this->assertEquals('NULL', $this->Dbo->value(null, 'time')); - } - -/** - * Tests that different Postgres boolean 'flavors' are properly returned as native PHP booleans - * - * @return void - */ - public function testBooleanNormalization() { - $this->assertEquals(true, $this->Dbo2->boolean('t', false)); - $this->assertEquals(true, $this->Dbo2->boolean('true', false)); - $this->assertEquals(true, $this->Dbo2->boolean('TRUE', false)); - $this->assertEquals(true, $this->Dbo2->boolean(true, false)); - $this->assertEquals(true, $this->Dbo2->boolean(1, false)); - $this->assertEquals(true, $this->Dbo2->boolean(" ", false)); - - $this->assertEquals(false, $this->Dbo2->boolean('f', false)); - $this->assertEquals(false, $this->Dbo2->boolean('false', false)); - $this->assertEquals(false, $this->Dbo2->boolean('FALSE', false)); - $this->assertEquals(false, $this->Dbo2->boolean(false, false)); - $this->assertEquals(false, $this->Dbo2->boolean(0, false)); - $this->assertEquals(false, $this->Dbo2->boolean('', false)); - } - -/** - * test that default -> false in schemas works correctly. - * - * @return void - */ - public function testBooleanDefaultFalseInSchema() { - $this->loadFixtures('Datatype'); - - $model = new Model(array('name' => 'Datatype', 'table' => 'datatypes', 'ds' => 'test')); - $model->create(); - $this->assertSame(false, $model->data['Datatype']['bool']); - } - -/** - * testLastInsertIdMultipleInsert method - * - * @return void - */ - public function testLastInsertIdMultipleInsert() { - $this->loadFixtures('User'); - $db1 = ConnectionManager::getDataSource('test'); - - $table = $db1->fullTableName('users', false); - $password = '5f4dcc3b5aa765d61d8327deb882cf99'; - $db1->execute( - "INSERT INTO {$table} (\"user\", password) VALUES ('mariano', '{$password}')" - ); - - $this->assertEquals(5, $db1->lastInsertId($table)); - - $db1->execute("INSERT INTO {$table} (\"user\", password) VALUES ('hoge', '{$password}')"); - $this->assertEquals(6, $db1->lastInsertId($table)); - } - -/** - * Tests that column types without default lengths in $columns do not have length values - * applied when generating schemas. - * - * @return void - */ - public function testColumnUseLength() { - $result = array('name' => 'foo', 'type' => 'string', 'length' => 100, 'default' => 'FOO'); - $expected = '"foo" varchar(100) DEFAULT \'FOO\''; - $this->assertEquals($expected, $this->Dbo->buildColumn($result)); - - $result = array('name' => 'foo', 'type' => 'text', 'length' => 100, 'default' => 'FOO'); - $expected = '"foo" text DEFAULT \'FOO\''; - $this->assertEquals($expected, $this->Dbo->buildColumn($result)); - } - -/** - * Tests that binary data is escaped/unescaped properly on reads and writes - * - * @return void - */ - public function testBinaryDataIntegrity() { - $this->loadFixtures('BinaryTest'); - $data = '%PDF-1.3 - %ƒÂÚÂÎßÛ†–ƒ∆ - 4 0 obj - << /Length 5 0 R /Filter /FlateDecode >> - stream - xµYMì€∆Ω„WÃ%)nï0¯îâ-«é]Q"πXµáÿ•Ip - P V,]Ú#c˚ˇ‰ut¥†∏Ti9 Ü=”›Ø_˜4>à∑‚Épcé¢Pxæ®2q\' - 1UªbU ᡒ+ö«√[ıµ⁄ão"R∑"HiGæä€(å≠≈^Ãøsm?YlƒÃõªfi‹âEÚB&‚Î◊7bÒ^¸m°÷˛?2±Øs“fiu#®U√ˇú÷g¥C;ä")n})JºIÔ3ËSnÑÎ¥≤ıD∆¢∂Msx1üèG˚±Œ™⁄>¶ySïufØ ˝¸?UπÃã√6flÌÚC=øK?˝…s - ˛§¯ˇ:-˜ò7€ÓFæ∂∑Õ˛∆“V’>ılflëÅd«ÜQdI ›ÎB%W¿ΩıÉn~h vêCS>«é˛(ØôK!€¡zB!√ - [œÜ"ûß ·iH¸[Àºæ∑¯¡L,ÀÚAlS∫ˆ=∫Œ≤cÄr&ˆÈ:√ÿ£˚È«4fl•À]vc›bÅôÿî=siXe4/¡p]ã]ôÆIœ™ Ωflà_ƒ‚G?«7 ùÿ ı¯K4ïIpV◊÷·\'éµóªÚæ>î - ;›sú!2fl¬F•/f∑j£ - dw"IÊÜπ<ôÿˆ%IG1ytÛDflXg|Éòa§˜}C˛¿ÿe°G´Ú±jÍm~¿/∂hã<#-¥•ıùe87€t˜õ6w}´{æ - m‹ê– ∆¡ 6⁄\ - rAÀBùZ3aË‚r$G·$ó0Ñ üâUY4È™¡%C∑Ÿ2rcÍCäı - =Õec=ëR˝”eñ=ÊuNê°“√Ü ‹Ê9iÙ0˙AAEÍ ˙`∂£\'ûce•åƒX›ŸÁ´1SK{qdá"tÏ[wQ#SµBe∞∑µó…ÌV`B"Ñ≥„!è_Óφ-º*ºú¿Ë0ˆeê∂´ë+HFj…‡zvHÓN|ÔL÷ûñ3õÜ$z%sá…pÎóV38âs Çoµ•ß3†<9B·¨û~¢3)ÂxóÿÁCÕòÆ ∫Í=»ÿSπS;∆~±êÆTEp∑óÈ÷ÀuìDHÈ $ÉõæÜjû§"≤ÃONM®RËíRr{õS ∏Ê™op±W;ÂUÔ P∫kÔˇflTæ∑óflË” ÆC©Ô[≥◊HÁ˚¨hê"ÆbF?ú%h˙ˇ4xèÕ(ó2ÙáíM])Ñd|=fë-cI0ñL¢kÖêk‰Rƒ«ıÄWñ8mO3∏&√æËX¯Hó—ì]yF2»–˜ádàà‡‹Çο„≥7mªHAS∑¶.;Œx(1} _kd©.fidç48M\'àáªCp^Krí<ɉXÓıïl!Ì$N<ı∞B»G]…∂Ó¯>˛ÔbõÒπÀ•:ôO@È$pÖu‹Ê´-QqV ?V≥JÆÍqÛX8(lπï@zgÖ}Fe<ˇ‡Sñ“ÿ˜ê?6‡L∫Oß~µ –?ËeäÚ®YîÕ =Ü=¢DÁu*GvBk;)L¬N«î:flö∂≠ÇΩq„Ñm하Ë∂‚"û≥§:±≤i^ΩÑ!)Wıyŧô á„RÄ÷Òôc’≠—s™rı‚Pdêãh˘ßHVç5fifiÈF€çÌÛuçÖ/M=gëµ±ÿGû1coÔuñæ‘z®. õ∑7ÉÏÜÆ,°’H†ÍÉÌ∂7e º® íˆ⁄◊øNWK”ÂYµ‚ñé;µ¶gV-fl>µtË¥áßN2 ¯¶BaP-)eW.àôt^∏1›C∑Ö?L„&”5’4jvã–ªZ ÷+4% ´0l…»ú^°´© ûiπ∑é®óܱÒÿ‰ïˆÌ–dˆ◊Æ19rQ=Í|ı•rMæ¬;ò‰Y‰é9.” ‹˝V«ã¯∏,+ë®j*¡·/'; - - $model = new AppModel(array('name' => 'BinaryTest', 'ds' => 'test')); - $model->save(compact('data')); - - $result = $model->find('first'); - $this->assertEquals($data, $result['BinaryTest']['data']); - } - -/** - * Tests the syntax of generated schema indexes - * - * @return void - */ - public function testSchemaIndexSyntax() { - $schema = new CakeSchema(); - $schema->tables = array('i18n' => array( - 'id' => array( - 'type' => 'integer', 'null' => false, 'default' => null, - 'length' => 10, 'key' => 'primary' - ), - 'locale' => array('type' => 'string', 'null' => false, 'length' => 6, 'key' => 'index'), - 'model' => array('type' => 'string', 'null' => false, 'key' => 'index'), - 'foreign_key' => array( - 'type' => 'integer', 'null' => false, 'length' => 10, 'key' => 'index' - ), - 'field' => array('type' => 'string', 'null' => false, 'key' => 'index'), - 'content' => array('type' => 'text', 'null' => true, 'default' => null), - 'indexes' => array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'locale' => array('column' => 'locale', 'unique' => 0), - 'model' => array('column' => 'model', 'unique' => 0), - 'row_id' => array('column' => 'foreign_key', 'unique' => 0), - 'field' => array('column' => 'field', 'unique' => 0) - ) - )); - - $result = $this->Dbo->createSchema($schema); - $this->assertNotRegExp('/^CREATE INDEX(.+);,$/', $result); - } - -/** - * testCakeSchema method - * - * Test that schema generated postgresql queries are valid. ref #5696 - * Check that the create statement for a schema generated table is the same as the original sql - * - * @return void - */ - public function testCakeSchema() { - $db1 = ConnectionManager::getDataSource('test'); - $db1->cacheSources = false; - - $db1->rawQuery('CREATE TABLE ' . $db1->fullTableName('datatype_tests') . ' ( - id serial NOT NULL, - "varchar" character varying(40) NOT NULL, - "full_length" character varying NOT NULL, - "timestamp" timestamp without time zone, - "date" date, - CONSTRAINT test_data_types_pkey PRIMARY KEY (id) - )'); - - $model = new Model(array('name' => 'DatatypeTest', 'ds' => 'test')); - $schema = new CakeSchema(array('connection' => 'test')); - $result = $schema->read(array( - 'connection' => 'test', - 'models' => array('DatatypeTest') - )); - $schema->tables = array('datatype_tests' => $result['tables']['missing']['datatype_tests']); - $result = $db1->createSchema($schema, 'datatype_tests'); - - $this->assertNotRegExp('/timestamp DEFAULT/', $result); - $this->assertRegExp('/\"full_length\"\s*text\s.*,/', $result); - $this->assertRegExp('/timestamp\s*,/', $result); - - $db1->query('DROP TABLE ' . $db1->fullTableName('datatype_tests')); - - $db1->query($result); - $result2 = $schema->read(array( - 'connection' => 'test', - 'models' => array('DatatypeTest') - )); - $schema->tables = array('datatype_tests' => $result2['tables']['missing']['datatype_tests']); - $result2 = $db1->createSchema($schema, 'datatype_tests'); - $this->assertEquals($result, $result2); - - $db1->query('DROP TABLE ' . $db1->fullTableName('datatype_tests')); - } - -/** - * Test index generation from table info. - * - * @return void - */ - public function testIndexGeneration() { - $name = $this->Dbo->fullTableName('index_test', false, false); - $this->Dbo->query('CREATE TABLE ' . $name . ' ("id" serial NOT NULL PRIMARY KEY, "bool" integer, "small_char" varchar(50), "description" varchar(40) )'); - $this->Dbo->query('CREATE INDEX pointless_bool ON ' . $name . '("bool")'); - $this->Dbo->query('CREATE UNIQUE INDEX char_index ON ' . $name . '("small_char")'); - $expected = array( - 'PRIMARY' => array('unique' => true, 'column' => 'id'), - 'pointless_bool' => array('unique' => false, 'column' => 'bool'), - 'char_index' => array('unique' => true, 'column' => 'small_char'), - ); - $result = $this->Dbo->index($name); - $this->Dbo->query('DROP TABLE ' . $name); - $this->assertEquals($expected, $result); - - $name = $this->Dbo->fullTableName('index_test_2', false, false); - $this->Dbo->query('CREATE TABLE ' . $name . ' ("id" serial NOT NULL PRIMARY KEY, "bool" integer, "small_char" varchar(50), "description" varchar(40) )'); - $this->Dbo->query('CREATE UNIQUE INDEX multi_col ON ' . $name . '("small_char", "bool")'); - $expected = array( - 'PRIMARY' => array('unique' => true, 'column' => 'id'), - 'multi_col' => array('unique' => true, 'column' => array('small_char', 'bool')), - ); - $result = $this->Dbo->index($name); - $this->Dbo->query('DROP TABLE ' . $name); - $this->assertEquals($expected, $result); - } - -/** - * Test the alterSchema capabilities of postgres - * - * @return void - */ - public function testAlterSchema() { - $Old = new CakeSchema(array( - 'connection' => 'test', - 'name' => 'AlterPosts', - 'alter_posts' => array( - 'id' => array('type' => 'integer', 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => true), - 'body' => array('type' => 'text'), - 'published' => array('type' => 'string', 'length' => 1, 'default' => 'N'), - 'created' => array('type' => 'datetime'), - 'updated' => array('type' => 'datetime'), - ) - )); - $this->Dbo->query($this->Dbo->createSchema($Old)); - - $New = new CakeSchema(array( - 'connection' => 'test', - 'name' => 'AlterPosts', - 'alter_posts' => array( - 'id' => array('type' => 'integer', 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => true), - 'title' => array('type' => 'string', 'null' => false, 'default' => 'my title'), - 'body' => array('type' => 'string', 'length' => 500), - 'status' => array('type' => 'integer', 'length' => 3, 'default' => 1), - 'created' => array('type' => 'datetime'), - 'updated' => array('type' => 'datetime'), - ) - )); - $this->Dbo->query($this->Dbo->alterSchema($New->compare($Old), 'alter_posts')); - - $model = new CakeTestModel(array('table' => 'alter_posts', 'ds' => 'test')); - $result = $model->schema(); - $this->assertTrue(isset($result['status'])); - $this->assertFalse(isset($result['published'])); - $this->assertEquals('string', $result['body']['type']); - $this->assertEquals(1, $result['status']['default']); - $this->assertEquals(true, $result['author_id']['null']); - $this->assertEquals(false, $result['title']['null']); - - $this->Dbo->query($this->Dbo->dropSchema($New)); - - $New = new CakeSchema(array( - 'connection' => 'test_suite', - 'name' => 'AlterPosts', - 'alter_posts' => array( - 'id' => array('type' => 'string', 'length' => 36, 'key' => 'primary'), - 'author_id' => array('type' => 'integer', 'null' => false), - 'title' => array('type' => 'string', 'null' => true), - 'body' => array('type' => 'text'), - 'published' => array('type' => 'string', 'length' => 1, 'default' => 'N'), - 'created' => array('type' => 'datetime'), - 'updated' => array('type' => 'datetime'), - ) - )); - $result = $this->Dbo->alterSchema($New->compare($Old), 'alter_posts'); - $this->assertNotRegExp('/varchar\(36\) NOT NULL/i', $result); - } - -/** - * Test the alter index capabilities of postgres - * - * @return void - */ - public function testAlterIndexes() { - $this->Dbo->cacheSources = false; - - $schema1 = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'group1' => array('type' => 'integer', 'null' => true), - 'group2' => array('type' => 'integer', 'null' => true) - ) - )); - - $this->Dbo->rawQuery($this->Dbo->createSchema($schema1)); - - $schema2 = new CakeSchema(array( - 'name' => 'AlterTest2', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'group1' => array('type' => 'integer', 'null' => true), - 'group2' => array('type' => 'integer', 'null' => true), - 'indexes' => array( - 'name_idx' => array('unique' => false, 'column' => 'name'), - 'group_idx' => array('unique' => false, 'column' => 'group1'), - 'compound_idx' => array('unique' => false, 'column' => array('group1', 'group2')), - 'PRIMARY' => array('unique' => true, 'column' => 'id') - ) - ) - )); - $this->Dbo->query($this->Dbo->alterSchema($schema2->compare($schema1))); - - $indexes = $this->Dbo->index('altertest'); - $this->assertEquals($schema2->tables['altertest']['indexes'], $indexes); - - // Change three indexes, delete one and add another one - $schema3 = new CakeSchema(array( - 'name' => 'AlterTest3', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - 'group1' => array('type' => 'integer', 'null' => true), - 'group2' => array('type' => 'integer', 'null' => true), - 'indexes' => array( - 'name_idx' => array('unique' => true, 'column' => 'name'), - 'group_idx' => array('unique' => false, 'column' => 'group2'), - 'compound_idx' => array('unique' => false, 'column' => array('group2', 'group1')), - 'another_idx' => array('unique' => false, 'column' => array('group1', 'name'))) - ))); - - $this->Dbo->query($this->Dbo->alterSchema($schema3->compare($schema2))); - - $indexes = $this->Dbo->index('altertest'); - $this->assertEquals($schema3->tables['altertest']['indexes'], $indexes); - - // Compare us to ourself. - $this->assertEquals(array(), $schema3->compare($schema3)); - - // Drop the indexes - $this->Dbo->query($this->Dbo->alterSchema($schema1->compare($schema3))); - - $indexes = $this->Dbo->index('altertest'); - $this->assertEquals(array(), $indexes); - - $this->Dbo->query($this->Dbo->dropSchema($schema1)); - } - -/** - * Test it is possible to use virtual field with postgresql - * - * @return void - */ - public function testVirtualFields() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment', 'Tag', 'ArticlesTag'); - $Article = new Article; - $Article->virtualFields = array( - 'next_id' => 'Article.id + 1', - 'complex' => 'Article.title || Article.body', - 'functional' => 'COALESCE(User.user, Article.title)', - 'subquery' => 'SELECT count(*) FROM ' . $Article->Comment->table - ); - $result = $Article->find('first'); - $this->assertEquals(2, $result['Article']['next_id']); - $this->assertEquals($result['Article']['complex'], $result['Article']['title'] . $result['Article']['body']); - $this->assertEquals($result['Article']['functional'], $result['User']['user']); - $this->assertEquals(6, $result['Article']['subquery']); - } - -/** - * Test that virtual fields work with SQL constants - * - * @return void - */ - public function testVirtualFieldAsAConstant() { - $this->loadFixtures('Article', 'Comment'); - $Article = ClassRegistry::init('Article'); - $Article->virtualFields = array( - 'empty' => "NULL", - 'number' => 43, - 'truth' => 'TRUE' - ); - $result = $Article->find('first'); - $this->assertNull($result['Article']['empty']); - $this->assertTrue($result['Article']['truth']); - $this->assertEquals(43, $result['Article']['number']); - } - -/** - * Tests additional order options for postgres - * - * @return void - */ - public function testOrderAdditionalParams() { - $result = $this->Dbo->order(array('title' => 'DESC NULLS FIRST', 'body' => 'DESC')); - $expected = ' ORDER BY "title" DESC NULLS FIRST, "body" DESC'; - $this->assertEquals($expected, $result); - } - -/** - * Test it is possible to do a SELECT COUNT(DISTINCT Model.field) - * query in postgres and it gets correctly quoted - * - * @return void - */ - public function testQuoteDistinctInFunction() { - $this->loadFixtures('Article'); - $Article = new Article; - $result = $this->Dbo->fields($Article, null, array('COUNT(DISTINCT Article.id)')); - $expected = array('COUNT(DISTINCT "Article"."id")'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($Article, null, array('COUNT(DISTINCT id)')); - $expected = array('COUNT(DISTINCT "id")'); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->fields($Article, null, array('COUNT(DISTINCT FUNC(id))')); - $expected = array('COUNT(DISTINCT FUNC("id"))'); - $this->assertEquals($expected, $result); - } - -/** - * test that saveAll works even with conditions that lack a model name. - * - * @return void - */ - public function testUpdateAllWithNonQualifiedConditions() { - $this->loadFixtures('Article'); - $Article = new Article(); - $result = $Article->updateAll(array('title' => "'Awesome'"), array('title' => 'Third Article')); - $this->assertTrue($result); - - $result = $Article->find('count', array( - 'conditions' => array('Article.title' => 'Awesome') - )); - $this->assertEquals(1, $result, 'Article count is wrong or fixture has changed.'); - } - -/** - * test alterSchema on two tables. - * - * @return void - */ - public function testAlteringTwoTables() { - $schema1 = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - ), - 'other_table' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'name' => array('type' => 'string', 'null' => false, 'length' => 50), - ) - )); - $schema2 = new CakeSchema(array( - 'name' => 'AlterTest1', - 'connection' => 'test', - 'altertest' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'field_two' => array('type' => 'string', 'null' => false, 'length' => 50), - ), - 'other_table' => array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => 0), - 'field_two' => array('type' => 'string', 'null' => false, 'length' => 50), - ) - )); - $result = $this->db->alterSchema($schema2->compare($schema1)); - $this->assertEquals(2, substr_count($result, 'field_two'), 'Too many fields'); - $this->assertFalse(strpos(';ALTER', $result), 'Too many semi colons'); - } - -/** - * test encoding setting. - * - * @return void - */ - public function testEncoding() { - $result = $this->Dbo->setEncoding('UTF8'); - $this->assertTrue($result); - - $result = $this->Dbo->getEncoding(); - $this->assertEquals('UTF8', $result); - - $result = $this->Dbo->setEncoding('EUC_JP'); /* 'EUC_JP' is right character code name in PostgreSQL */ - $this->assertTrue($result); - - $result = $this->Dbo->getEncoding(); - $this->assertEquals('EUC_JP', $result); - } - -/** - * Test truncate with a mock. - * - * @return void - */ - public function testTruncateStatements() { - $this->loadFixtures('Article', 'User'); - $db = ConnectionManager::getDatasource('test'); - $schema = $db->config['schema']; - $Article = new Article(); - - $this->Dbo = $this->getMock('Postgres', array('execute'), array($db->config)); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("DELETE FROM \"$schema\".\"articles\""); - $this->Dbo->truncate($Article); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("DELETE FROM \"$schema\".\"articles\""); - $this->Dbo->truncate('articles'); - - // #2355: prevent duplicate prefix - $this->Dbo->config['prefix'] = 'tbl_'; - $Article->tablePrefix = 'tbl_'; - $this->Dbo->expects($this->at(0))->method('execute') - ->with("DELETE FROM \"$schema\".\"tbl_articles\""); - $this->Dbo->truncate($Article); - - $this->Dbo->expects($this->at(0))->method('execute') - ->with("DELETE FROM \"$schema\".\"tbl_articles\""); - $this->Dbo->truncate('articles'); - } - -} diff --git a/lib/Cake/Test/Case/Model/Datasource/Database/SqliteTest.php b/lib/Cake/Test/Case/Model/Datasource/Database/SqliteTest.php deleted file mode 100644 index 4b4cef5a37b..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/Database/SqliteTest.php +++ /dev/null @@ -1,386 +0,0 @@ -simulated[] = $sql; - return null; - } - -/** - * getLastQuery method - * - * @return void - */ - public function getLastQuery() { - return $this->simulated[count($this->simulated) - 1]; - } - -} - -/** - * DboSqliteTest class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class SqliteTest extends CakeTestCase { - -/** - * Do not automatically load fixtures for each test, they will be loaded manually using CakeTestCase::loadFixtures - * - * @var boolean - */ - public $autoFixtures = false; - -/** - * Fixtures - * - * @var object - */ - public $fixtures = array('core.user', 'core.uuid'); - -/** - * Actual DB connection used in testing - * - * @var DboSource - */ - public $Dbo = null; - -/** - * Sets up a Dbo class instance for testing - * - */ - public function setUp() { - parent::setUp(); - Configure::write('Cache.disable', true); - $this->Dbo = ConnectionManager::getDataSource('test'); - if (!$this->Dbo instanceof Sqlite) { - $this->markTestSkipped('The Sqlite extension is not available.'); - } - } - -/** - * Sets up a Dbo class instance for testing - * - */ - public function tearDown() { - parent::tearDown(); - Configure::write('Cache.disable', false); - } - -/** - * Tests that SELECT queries from DboSqlite::listSources() are not cached - * - */ - public function testTableListCacheDisabling() { - $this->assertFalse(in_array('foo_test', $this->Dbo->listSources())); - - $this->Dbo->query('CREATE TABLE foo_test (test VARCHAR(255))'); - $this->assertTrue(in_array('foo_test', $this->Dbo->listSources())); - - $this->Dbo->cacheSources = false; - $this->Dbo->query('DROP TABLE foo_test'); - $this->assertFalse(in_array('foo_test', $this->Dbo->listSources())); - } - -/** - * test Index introspection. - * - * @return void - */ - public function testIndex() { - $name = $this->Dbo->fullTableName('with_a_key', false, false); - $this->Dbo->query('CREATE TABLE ' . $name . ' ("id" int(11) PRIMARY KEY, "bool" int(1), "small_char" varchar(50), "description" varchar(40) );'); - $this->Dbo->query('CREATE INDEX pointless_bool ON ' . $name . '("bool")'); - $this->Dbo->query('CREATE UNIQUE INDEX char_index ON ' . $name . '("small_char")'); - $expected = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'pointless_bool' => array('column' => 'bool', 'unique' => 0), - 'char_index' => array('column' => 'small_char', 'unique' => 1), - - ); - $result = $this->Dbo->index($name); - $this->assertEquals($expected, $result); - $this->Dbo->query('DROP TABLE ' . $name); - - $this->Dbo->query('CREATE TABLE ' . $name . ' ("id" int(11) PRIMARY KEY, "bool" int(1), "small_char" varchar(50), "description" varchar(40) );'); - $this->Dbo->query('CREATE UNIQUE INDEX multi_col ON ' . $name . '("small_char", "bool")'); - $expected = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'multi_col' => array('column' => array('small_char', 'bool'), 'unique' => 1), - ); - $result = $this->Dbo->index($name); - $this->assertEquals($expected, $result); - $this->Dbo->query('DROP TABLE ' . $name); - } - -/** - * Tests that cached table descriptions are saved under the sanitized key name - * - */ - public function testCacheKeyName() { - Configure::write('Cache.disable', false); - - $dbName = 'db' . rand() . '$(*%&).db'; - $this->assertFalse(file_exists(TMP . $dbName)); - - $config = $this->Dbo->config; - $db = new Sqlite(array_merge($this->Dbo->config, array('database' => TMP . $dbName))); - $this->assertTrue(file_exists(TMP . $dbName)); - - $db->execute("CREATE TABLE test_list (id VARCHAR(255));"); - - $db->cacheSources = true; - $this->assertEquals(array('test_list'), $db->listSources()); - $db->cacheSources = false; - - $fileName = '_' . preg_replace('/[^A-Za-z0-9_\-+]/', '_', TMP . $dbName) . '_list'; - - $result = Cache::read($fileName, '_cake_model_'); - $this->assertEquals(array('test_list'), $result); - - Cache::delete($fileName, '_cake_model_'); - Configure::write('Cache.disable', true); - } - -/** - * test building columns with SQLite - * - * @return void - */ - public function testBuildColumn() { - $data = array( - 'name' => 'int_field', - 'type' => 'integer', - 'null' => false, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '"int_field" integer NOT NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'name', - 'type' => 'string', - 'length' => 20, - 'null' => false, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '"name" varchar(20) NOT NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'testName', - 'type' => 'string', - 'length' => 20, - 'default' => null, - 'null' => true, - 'collate' => 'NOCASE' - ); - $result = $this->Dbo->buildColumn($data); - $expected = '"testName" varchar(20) DEFAULT NULL COLLATE NOCASE'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'testName', - 'type' => 'string', - 'length' => 20, - 'default' => 'test-value', - 'null' => false, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '"testName" varchar(20) DEFAULT \'test-value\' NOT NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'testName', - 'type' => 'integer', - 'length' => 10, - 'default' => 10, - 'null' => false, - ); - $result = $this->Dbo->buildColumn($data); - $expected = '"testName" integer(10) DEFAULT 10 NOT NULL'; - $this->assertEquals($expected, $result); - - $data = array( - 'name' => 'testName', - 'type' => 'integer', - 'length' => 10, - 'default' => 10, - 'null' => false, - 'collate' => 'BADVALUE' - ); - $result = $this->Dbo->buildColumn($data); - $expected = '"testName" integer(10) DEFAULT 10 NOT NULL'; - $this->assertEquals($expected, $result); - } - -/** - * test describe() and normal results. - * - * @return void - */ - public function testDescribe() { - $this->loadFixtures('User'); - $Model = new Model(array('name' => 'User', 'ds' => 'test', 'table' => 'users')); - - $this->Dbo->cacheSources = true; - Configure::write('Cache.disable', false); - - $result = $this->Dbo->describe($Model); - $expected = array( - 'id' => array( - 'type' => 'integer', - 'key' => 'primary', - 'null' => false, - 'default' => null, - 'length' => 11 - ), - 'user' => array( - 'type' => 'string', - 'length' => 255, - 'null' => true, - 'default' => null - ), - 'password' => array( - 'type' => 'string', - 'length' => 255, - 'null' => true, - 'default' => null - ), - 'created' => array( - 'type' => 'datetime', - 'null' => true, - 'default' => null, - 'length' => null, - ), - 'updated' => array( - 'type' => 'datetime', - 'null' => true, - 'default' => null, - 'length' => null, - ) - ); - $this->assertEquals($expected, $result); - - $result = $this->Dbo->describe($Model->useTable); - $this->assertEquals($expected, $result); - - $result = Cache::read('test_users', '_cake_model_'); - $this->assertEquals($expected, $result); - } - -/** - * test that describe does not corrupt UUID primary keys - * - * @return void - */ - public function testDescribeWithUuidPrimaryKey() { - $tableName = 'uuid_tests'; - $this->Dbo->query("CREATE TABLE {$tableName} (id VARCHAR(36) PRIMARY KEY, name VARCHAR, created DATETIME, modified DATETIME)"); - $Model = new Model(array('name' => 'UuidTest', 'ds' => 'test', 'table' => 'uuid_tests')); - $result = $this->Dbo->describe($Model); - $expected = array( - 'type' => 'string', - 'length' => 36, - 'null' => false, - 'default' => null, - 'key' => 'primary', - ); - $this->assertEquals($expected, $result['id']); - $this->Dbo->query('DROP TABLE ' . $tableName); - - $tableName = 'uuid_tests'; - $this->Dbo->query("CREATE TABLE {$tableName} (id CHAR(36) PRIMARY KEY, name VARCHAR, created DATETIME, modified DATETIME)"); - $Model = new Model(array('name' => 'UuidTest', 'ds' => 'test', 'table' => 'uuid_tests')); - $result = $this->Dbo->describe($Model); - $expected = array( - 'type' => 'string', - 'length' => 36, - 'null' => false, - 'default' => null, - 'key' => 'primary', - ); - $this->assertEquals($expected, $result['id']); - $this->Dbo->query('DROP TABLE ' . $tableName); - } - -/** - * Test virtualFields with functions. - * - * @return void - */ - public function testVirtualFieldWithFunction() { - $this->loadFixtures('User'); - $User = ClassRegistry::init('User'); - $User->virtualFields = array('name' => 'SUBSTR(User.user, 5)'); - - $result = $User->find('first', array( - 'conditions' => array('User.user' => 'garrett') - )); - $this->assertEquals('ett', $result['User']['name']); - } - -/** - * Test that records can be inserted with uuid primary keys, and - * that the primary key is not blank - * - * @return void - */ - public function testUuidPrimaryKeyInsertion() { - $this->loadFixtures('Uuid'); - $Model = ClassRegistry::init('Uuid'); - - $data = array( - 'title' => 'A uuid should work', - 'count' => 10 - ); - $Model->create($data); - $this->assertTrue((bool)$Model->save()); - $result = $Model->read(); - - $this->assertEquals($data['title'], $result['Uuid']['title']); - $this->assertTrue(Validation::uuid($result['Uuid']['id']), 'Not a uuid'); - } - -} diff --git a/lib/Cake/Test/Case/Model/Datasource/Database/SqlserverTest.php b/lib/Cake/Test/Case/Model/Datasource/Database/SqlserverTest.php deleted file mode 100644 index 3e5c468670b..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/Database/SqlserverTest.php +++ /dev/null @@ -1,669 +0,0 @@ -simulated[] = $sql; - return empty($this->executeResultsStack) ? null : array_pop($this->executeResultsStack); - } - -/** - * fetchAll method - * - * @param mixed $sql - * @return void - */ - protected function _matchRecords(Model $model, $conditions = null) { - return $this->conditions(array('id' => array(1, 2))); - } - -/** - * getLastQuery method - * - * @return string - */ - public function getLastQuery() { - return $this->simulated[count($this->simulated) - 1]; - } - -/** - * getPrimaryKey method - * - * @param mixed $model - * @return string - */ - public function getPrimaryKey($model) { - return parent::_getPrimaryKey($model); - } - -/** - * clearFieldMappings method - * - * @return void - */ - public function clearFieldMappings() { - $this->_fieldMappings = array(); - } - -/** - * describe method - * - * @param object $model - * @return void - */ - public function describe($model) { - return empty($this->describe) ? parent::describe($model) : $this->describe; - } - -} - -/** - * SqlserverTestModel class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class SqlserverTestModel extends CakeTestModel { - -/** - * name property - * - * @var string 'SqlserverTestModel' - */ - public $name = 'SqlserverTestModel'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * _schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8', 'key' => 'primary'), - 'client_id' => array('type' => 'integer', 'null' => '', 'default' => '0', 'length' => '11'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'login' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'passwd' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_1' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_2' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '25'), - 'zip_code' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'city' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'country' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'phone' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'fax' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'url' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'comments' => array('type' => 'text', 'null' => '1', 'default' => '', 'length' => ''), - 'last_login' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => ''), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'SqlserverClientTestModel' => array( - 'foreignKey' => 'client_id' - ) - ); - -/** - * find method - * - * @param mixed $conditions - * @param mixed $fields - * @param mixed $order - * @param mixed $recursive - * @return void - */ - public function find($conditions = null, $fields = null, $order = null, $recursive = null) { - return $conditions; - } - -} - -/** - * SqlserverClientTestModel class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class SqlserverClientTestModel extends CakeTestModel { - -/** - * name property - * - * @var string 'SqlserverAssociatedTestModel' - */ - public $name = 'SqlserverClientTestModel'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * _schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8', 'key' => 'primary'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'created' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); -} - -/** - * SqlserverTestResultIterator class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class SqlserverTestResultIterator extends ArrayIterator { - -/** - * closeCursor method - * - * @return void - */ - public function closeCursor() { - } - -/** - * fetch method - * - * @return void - */ - public function fetch() { - if (!$this->valid()) { - return null; - } - $current = $this->current(); - $this->next(); - return $current; - } - -} - -/** - * SqlserverTest class - * - * @package Cake.Test.Case.Model.Datasource.Database - */ -class SqlserverTest extends CakeTestCase { - -/** - * The Dbo instance to be tested - * - * @var DboSource - */ - public $db = null; - -/** - * autoFixtures property - * - * @var bool false - */ - public $autoFixtures = false; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.user', 'core.category', 'core.author', 'core.post'); - -/** - * Sets up a Dbo class instance for testing - * - */ - public function setUp() { - $this->Dbo = ConnectionManager::getDataSource('test'); - if (!($this->Dbo instanceof Sqlserver)) { - $this->markTestSkipped('Please configure the test datasource to use SQL Server.'); - } - $this->db = new SqlserverTestDb($this->Dbo->config); - $this->model = new SqlserverTestModel(); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - unset($this->Dbo); - unset($this->model); - } - -/** - * testQuoting method - * - * @return void - */ - public function testQuoting() { - $expected = "1.2"; - $result = $this->db->value(1.2, 'float'); - $this->assertSame($expected, $result); - - $expected = "'1,2'"; - $result = $this->db->value('1,2', 'float'); - $this->assertSame($expected, $result); - - $expected = 'NULL'; - $result = $this->db->value('', 'integer'); - $this->assertSame($expected, $result); - - $expected = 'NULL'; - $result = $this->db->value('', 'float'); - $this->assertSame($expected, $result); - - $expected = "''"; - $result = $this->db->value('', 'binary'); - $this->assertSame($expected, $result); - } - -/** - * testFields method - * - * @return void - */ - public function testFields() { - $fields = array( - '[SqlserverTestModel].[id] AS [SqlserverTestModel__id]', - '[SqlserverTestModel].[client_id] AS [SqlserverTestModel__client_id]', - '[SqlserverTestModel].[name] AS [SqlserverTestModel__name]', - '[SqlserverTestModel].[login] AS [SqlserverTestModel__login]', - '[SqlserverTestModel].[passwd] AS [SqlserverTestModel__passwd]', - '[SqlserverTestModel].[addr_1] AS [SqlserverTestModel__addr_1]', - '[SqlserverTestModel].[addr_2] AS [SqlserverTestModel__addr_2]', - '[SqlserverTestModel].[zip_code] AS [SqlserverTestModel__zip_code]', - '[SqlserverTestModel].[city] AS [SqlserverTestModel__city]', - '[SqlserverTestModel].[country] AS [SqlserverTestModel__country]', - '[SqlserverTestModel].[phone] AS [SqlserverTestModel__phone]', - '[SqlserverTestModel].[fax] AS [SqlserverTestModel__fax]', - '[SqlserverTestModel].[url] AS [SqlserverTestModel__url]', - '[SqlserverTestModel].[email] AS [SqlserverTestModel__email]', - '[SqlserverTestModel].[comments] AS [SqlserverTestModel__comments]', - 'CONVERT(VARCHAR(20), [SqlserverTestModel].[last_login], 20) AS [SqlserverTestModel__last_login]', - '[SqlserverTestModel].[created] AS [SqlserverTestModel__created]', - 'CONVERT(VARCHAR(20), [SqlserverTestModel].[updated], 20) AS [SqlserverTestModel__updated]' - ); - - $result = $this->db->fields($this->model); - $expected = $fields; - $this->assertEquals($expected, $result); - - $this->db->clearFieldMappings(); - $result = $this->db->fields($this->model, null, 'SqlserverTestModel.*'); - $expected = $fields; - $this->assertEquals($expected, $result); - - $this->db->clearFieldMappings(); - $result = $this->db->fields($this->model, null, array('*', 'AnotherModel.id', 'AnotherModel.name')); - $expected = array_merge($fields, array( - '[AnotherModel].[id] AS [AnotherModel__id]', - '[AnotherModel].[name] AS [AnotherModel__name]')); - $this->assertEquals($expected, $result); - - $this->db->clearFieldMappings(); - $result = $this->db->fields($this->model, null, array('*', 'SqlserverClientTestModel.*')); - $expected = array_merge($fields, array( - '[SqlserverClientTestModel].[id] AS [SqlserverClientTestModel__id]', - '[SqlserverClientTestModel].[name] AS [SqlserverClientTestModel__name]', - '[SqlserverClientTestModel].[email] AS [SqlserverClientTestModel__email]', - 'CONVERT(VARCHAR(20), [SqlserverClientTestModel].[created], 20) AS [SqlserverClientTestModel__created]', - 'CONVERT(VARCHAR(20), [SqlserverClientTestModel].[updated], 20) AS [SqlserverClientTestModel__updated]')); - $this->assertEquals($expected, $result); - } - -/** - * testDistinctFields method - * - * @return void - */ - public function testDistinctFields() { - $result = $this->db->fields($this->model, null, array('DISTINCT Car.country_code')); - $expected = array('DISTINCT [Car].[country_code] AS [Car__country_code]'); - $this->assertEquals($expected, $result); - - $result = $this->db->fields($this->model, null, 'DISTINCT Car.country_code'); - $expected = array('DISTINCT [Car].[country_code] AS [Car__country_code]'); - $this->assertEquals($expected, $result); - } - -/** - * testDistinctWithLimit method - * - * @return void - */ - public function testDistinctWithLimit() { - $this->db->read($this->model, array( - 'fields' => array('DISTINCT SqlserverTestModel.city', 'SqlserverTestModel.country'), - 'limit' => 5 - )); - $result = $this->db->getLastQuery(); - $this->assertRegExp('/^SELECT DISTINCT TOP 5/', $result); - } - -/** - * testDescribe method - * - * @return void - */ - public function testDescribe() { - $SqlserverTableDescription = new SqlserverTestResultIterator(array( - (object)array( - 'Default' => '((0))', - 'Field' => 'count', - 'Key' => 0, - 'Length' => '4', - 'Null' => 'NO', - 'Type' => 'integer' - ), - (object)array( - 'Default' => '', - 'Field' => 'body', - 'Key' => 0, - 'Length' => '-1', - 'Null' => 'YES', - 'Type' => 'nvarchar' - ), - (object)array( - 'Default' => '', - 'Field' => 'published', - 'Key' => 0, - 'Type' => 'datetime2', - 'Length' => 8, - 'Null' => 'YES', - 'Size' => '' - ), - (object)array( - 'Default' => '', - 'Field' => 'id', - 'Key' => 1, - 'Type' => 'nchar', - 'Length' => 72, - 'Null' => 'NO', - 'Size' => '' - ) - )); - $this->db->executeResultsStack = array($SqlserverTableDescription); - $dummyModel = $this->model; - $result = $this->db->describe($dummyModel); - $expected = array( - 'count' => array( - 'type' => 'integer', - 'null' => false, - 'default' => '0', - 'length' => 4 - ), - 'body' => array( - 'type' => 'text', - 'null' => true, - 'default' => null, - 'length' => null - ), - 'published' => array( - 'type' => 'datetime', - 'null' => true, - 'default' => '', - 'length' => null - ), - 'id' => array( - 'type' => 'string', - 'null' => false, - 'default' => '', - 'length' => 36, - 'key' => 'primary' - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testBuildColumn - * - * @return void - */ - public function testBuildColumn() { - $column = array('name' => 'id', 'type' => 'integer', 'null' => false, 'default' => '', 'length' => '8', 'key' => 'primary'); - $result = $this->db->buildColumn($column); - $expected = '[id] int IDENTITY (1, 1) NOT NULL'; - $this->assertEquals($expected, $result); - - $column = array('name' => 'client_id', 'type' => 'integer', 'null' => false, 'default' => '0', 'length' => '11'); - $result = $this->db->buildColumn($column); - $expected = '[client_id] int DEFAULT 0 NOT NULL'; - $this->assertEquals($expected, $result); - - $column = array('name' => 'client_id', 'type' => 'integer', 'null' => true); - $result = $this->db->buildColumn($column); - $expected = '[client_id] int NULL'; - $this->assertEquals($expected, $result); - - // 'name' => 'type' format for columns - $column = array('type' => 'integer', 'name' => 'client_id'); - $result = $this->db->buildColumn($column); - $expected = '[client_id] int NULL'; - $this->assertEquals($expected, $result); - - $column = array('type' => 'string', 'name' => 'name'); - $result = $this->db->buildColumn($column); - $expected = '[name] nvarchar(255) NULL'; - $this->assertEquals($expected, $result); - - $column = array('name' => 'name', 'type' => 'string', 'null' => false, 'default' => '', 'length' => '255'); - $result = $this->db->buildColumn($column); - $expected = '[name] nvarchar(255) DEFAULT \'\' NOT NULL'; - $this->assertEquals($expected, $result); - - $column = array('name' => 'name', 'type' => 'string', 'null' => false, 'length' => '255'); - $result = $this->db->buildColumn($column); - $expected = '[name] nvarchar(255) NOT NULL'; - $this->assertEquals($expected, $result); - - $column = array('name' => 'name', 'type' => 'string', 'null' => false, 'default' => null, 'length' => '255'); - $result = $this->db->buildColumn($column); - $expected = '[name] nvarchar(255) NOT NULL'; - $this->assertEquals($expected, $result); - - $column = array('name' => 'name', 'type' => 'string', 'null' => true, 'default' => null, 'length' => '255'); - $result = $this->db->buildColumn($column); - $expected = '[name] nvarchar(255) NULL'; - $this->assertEquals($expected, $result); - - $column = array('name' => 'name', 'type' => 'string', 'null' => true, 'default' => '', 'length' => '255'); - $result = $this->db->buildColumn($column); - $expected = '[name] nvarchar(255) DEFAULT \'\''; - $this->assertEquals($expected, $result); - - $column = array('name' => 'body', 'type' => 'text'); - $result = $this->db->buildColumn($column); - $expected = '[body] nvarchar(MAX)'; - $this->assertEquals($expected, $result); - } - -/** - * testBuildIndex method - * - * @return void - */ - public function testBuildIndex() { - $indexes = array( - 'PRIMARY' => array('column' => 'id', 'unique' => 1), - 'client_id' => array('column' => 'client_id', 'unique' => 1) - ); - $result = $this->db->buildIndex($indexes, 'items'); - $expected = array( - 'PRIMARY KEY ([id])', - 'ALTER TABLE items ADD CONSTRAINT client_id UNIQUE([client_id]);' - ); - $this->assertEquals($expected, $result); - - $indexes = array('client_id' => array('column' => 'client_id')); - $result = $this->db->buildIndex($indexes, 'items'); - $this->assertEquals(array(), $result); - - $indexes = array('client_id' => array('column' => array('client_id', 'period_id'), 'unique' => 1)); - $result = $this->db->buildIndex($indexes, 'items'); - $expected = array('ALTER TABLE items ADD CONSTRAINT client_id UNIQUE([client_id], [period_id]);'); - $this->assertEquals($expected, $result); - } - -/** - * testUpdateAllSyntax method - * - * @return void - */ - public function testUpdateAllSyntax() { - $fields = array('SqlserverTestModel.client_id' => '[SqlserverTestModel].[client_id] + 1'); - $conditions = array('SqlserverTestModel.updated <' => date('2009-01-01 00:00:00')); - $this->db->update($this->model, $fields, null, $conditions); - - $result = $this->db->getLastQuery(); - $this->assertNotRegExp('/SqlserverTestModel/', $result); - $this->assertRegExp('/^UPDATE \[sqlserver_test_models\]/', $result); - $this->assertRegExp('/SET \[client_id\] = \[client_id\] \+ 1/', $result); - } - -/** - * testGetPrimaryKey method - * - * @return void - */ - public function testGetPrimaryKey() { - $schema = $this->model->schema(); - - $this->db->describe = $schema; - $result = $this->db->getPrimaryKey($this->model); - $this->assertEquals('id', $result); - - unset($schema['id']['key']); - $this->db->describe = $schema; - $result = $this->db->getPrimaryKey($this->model); - $this->assertNull($result); - } - -/** - * SQL server < 11 doesn't have proper limit/offset support, test that our hack works. - * - * @return void - */ - public function testLimitOffsetHack() { - $this->loadFixtures('Author', 'Post', 'User'); - $query = array( - 'limit' => 2, - 'page' => 1, - 'order' => 'User.user ASC', - ); - $User = ClassRegistry::init('User'); - $results = $User->find('all', $query); - - $this->assertEquals(2, count($results)); - $this->assertEquals('garrett', $results[0]['User']['user']); - $this->assertEquals('larry', $results[1]['User']['user']); - - $query = array( - 'limit' => 2, - 'page' => 2, - 'order' => 'User.user ASC', - ); - $User = ClassRegistry::init('User'); - $results = $User->find('all', $query); - - $this->assertEquals(2, count($results)); - $this->assertFalse(isset($results[0][0])); - $this->assertEquals('mariano', $results[0]['User']['user']); - $this->assertEquals('nate', $results[1]['User']['user']); - } - -/** - * Test that the return of stored procedures is honoured - * - * @return void - */ - public function testStoredProcedureReturn() { - $sql = <<Dbo->execute($sql); - - $sql = <<Dbo->execute($sql); - $this->Dbo->execute('DROP PROC cake_test_procedure'); - - $result = $query->fetch(); - $this->assertEquals(2, $result['value']); - } - -} diff --git a/lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php b/lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php deleted file mode 100644 index 0df2ae367ed..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/DboSourceTest.php +++ /dev/null @@ -1,839 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The Open Group Test Suite License - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model.Datasource - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -App::uses('DataSource', 'Model/Datasource'); -App::uses('DboSource', 'Model/Datasource'); -require_once dirname(dirname(__FILE__)) . DS . 'models.php'; - -class MockPDO extends PDO { - - public function __construct() { - } - -} - -class MockDataSource extends DataSource { -} - -class DboTestSource extends DboSource { - - public function connect($config = array()) { - $this->connected = true; - } - - public function mergeAssociation(&$data, &$merge, $association, $type, $selfJoin = false) { - return parent::_mergeAssociation($data, $merge, $association, $type, $selfJoin); - } - - public function setConfig($config = array()) { - $this->config = $config; - } - - public function setConnection($conn) { - $this->_connection = $conn; - } - -} - -/** - * DboSourceTest class - * - * @package Cake.Test.Case.Model.Datasource - */ -class DboSourceTest extends CakeTestCase { - -/** - * debug property - * - * @var mixed null - */ - public $debug = null; - -/** - * autoFixtures property - * - * @var bool false - */ - public $autoFixtures = false; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array( - 'core.apple', 'core.article', 'core.articles_tag', 'core.attachment', 'core.comment', - 'core.sample', 'core.tag', 'core.user', 'core.post', 'core.author', 'core.data_test' - ); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->__config = $this->db->config; - - $this->testDb = new DboTestSource(); - $this->testDb->cacheSources = false; - $this->testDb->startQuote = '`'; - $this->testDb->endQuote = '`'; - - $this->Model = new TestModel(); - } - -/** - * endTest method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Model); - } - -/** - * test that booleans and null make logical condition strings. - * - * @return void - */ - public function testBooleanNullConditionsParsing() { - $result = $this->testDb->conditions(true); - $this->assertEquals(' WHERE 1 = 1', $result, 'true conditions failed %s'); - - $result = $this->testDb->conditions(false); - $this->assertEquals(' WHERE 0 = 1', $result, 'false conditions failed %s'); - - $result = $this->testDb->conditions(null); - $this->assertEquals(' WHERE 1 = 1', $result, 'null conditions failed %s'); - - $result = $this->testDb->conditions(array()); - $this->assertEquals(' WHERE 1 = 1', $result, 'array() conditions failed %s'); - - $result = $this->testDb->conditions(''); - $this->assertEquals(' WHERE 1 = 1', $result, '"" conditions failed %s'); - - $result = $this->testDb->conditions(' ', '" " conditions failed %s'); - $this->assertEquals(' WHERE 1 = 1', $result); - } - -/** - * test that order() will accept objects made from DboSource::expression - * - * @return void - */ - public function testOrderWithExpression() { - $expression = $this->testDb->expression("CASE Sample.id WHEN 1 THEN 'Id One' ELSE 'Other Id' END AS case_col"); - $result = $this->testDb->order($expression); - $expected = " ORDER BY CASE Sample.id WHEN 1 THEN 'Id One' ELSE 'Other Id' END AS case_col"; - $this->assertEquals($expected, $result); - } - -/** - * testMergeAssociations method - * - * @return void - */ - public function testMergeAssociations() { - $data = array('Article2' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', - 'body' => 'First Article Body', 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - )); - $merge = array('Topic' => array(array( - 'id' => '1', 'topic' => 'Topic', 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ))); - $expected = array( - 'Article2' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', - 'body' => 'First Article Body', 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Topic' => array( - 'id' => '1', 'topic' => 'Topic', 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ) - ); - $this->testDb->mergeAssociation($data, $merge, 'Topic', 'hasOne'); - $this->assertEquals($expected, $data); - - $data = array('Article2' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', - 'body' => 'First Article Body', 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - )); - $merge = array('User2' => array(array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ))); - - $expected = array( - 'Article2' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', - 'body' => 'First Article Body', 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ); - $this->testDb->mergeAssociation($data, $merge, 'User2', 'belongsTo'); - $this->assertEquals($expected, $data); - - $data = array( - 'Article2' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ) - ); - $merge = array(array('Comment' => false)); - $expected = array( - 'Article2' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Comment' => array() - ); - $this->testDb->mergeAssociation($data, $merge, 'Comment', 'hasMany'); - $this->assertEquals($expected, $data); - - $data = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ) - ); - $merge = array( - array( - 'Comment' => array( - 'id' => '1', 'comment' => 'Comment 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Comment' => array( - 'id' => '2', 'comment' => 'Comment 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $expected = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Comment' => array( - array( - 'id' => '1', 'comment' => 'Comment 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - array( - 'id' => '2', 'comment' => 'Comment 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $this->testDb->mergeAssociation($data, $merge, 'Comment', 'hasMany'); - $this->assertEquals($expected, $data); - - $data = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ) - ); - $merge = array( - array( - 'Comment' => array( - 'id' => '1', 'comment' => 'Comment 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Comment' => array( - 'id' => '2', 'comment' => 'Comment 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $expected = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Comment' => array( - array( - 'id' => '1', 'comment' => 'Comment 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'id' => '2', 'comment' => 'Comment 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ) - ); - $this->testDb->mergeAssociation($data, $merge, 'Comment', 'hasMany'); - $this->assertEquals($expected, $data); - - $data = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ) - ); - $merge = array( - array( - 'Comment' => array( - 'id' => '1', 'comment' => 'Comment 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Tag' => array( - array('id' => 1, 'tag' => 'Tag 1'), - array('id' => 2, 'tag' => 'Tag 2') - ) - ), - array( - 'Comment' => array( - 'id' => '2', 'comment' => 'Comment 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Tag' => array() - ) - ); - $expected = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Comment' => array( - array( - 'id' => '1', 'comment' => 'Comment 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Tag' => array( - array('id' => 1, 'tag' => 'Tag 1'), - array('id' => 2, 'tag' => 'Tag 2') - ) - ), - array( - 'id' => '2', 'comment' => 'Comment 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', - 'User2' => array( - 'id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - 'Tag' => array() - ) - ) - ); - $this->testDb->mergeAssociation($data, $merge, 'Comment', 'hasMany'); - $this->assertEquals($expected, $data); - - $data = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ) - ); - $merge = array( - array( - 'Tag' => array( - 'id' => '1', 'tag' => 'Tag 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Tag' => array( - 'id' => '2', 'tag' => 'Tag 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Tag' => array( - 'id' => '3', 'tag' => 'Tag 3', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $expected = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Tag' => array( - array( - 'id' => '1', 'tag' => 'Tag 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - array( - 'id' => '2', 'tag' => 'Tag 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ), - array( - 'id' => '3', 'tag' => 'Tag 3', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $this->testDb->mergeAssociation($data, $merge, 'Tag', 'hasAndBelongsToMany'); - $this->assertEquals($expected, $data); - - $data = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ) - ); - $merge = array( - array( - 'Tag' => array( - 'id' => '1', 'tag' => 'Tag 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Tag' => array( - 'id' => '2', 'tag' => 'Tag 2', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ), - array( - 'Tag' => array( - 'id' => '3', 'tag' => 'Tag 3', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31' - ) - ) - ); - $expected = array( - 'Article' => array( - 'id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31' - ), - 'Tag' => array('id' => '1', 'tag' => 'Tag 1', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31') - ); - $this->testDb->mergeAssociation($data, $merge, 'Tag', 'hasOne'); - $this->assertEquals($expected, $data); - } - -/** - * testMagicMethodQuerying method - * - * @return void - */ - public function testMagicMethodQuerying() { - $result = $this->db->query('findByFieldName', array('value'), $this->Model); - $expected = array('first', array( - 'conditions' => array('TestModel.field_name' => 'value'), - 'fields' => null, 'order' => null, 'recursive' => null - )); - $this->assertEquals($expected, $result); - - $result = $this->db->query('findByFindBy', array('value'), $this->Model); - $expected = array('first', array( - 'conditions' => array('TestModel.find_by' => 'value'), - 'fields' => null, 'order' => null, 'recursive' => null - )); - $this->assertEquals($expected, $result); - - $result = $this->db->query('findAllByFieldName', array('value'), $this->Model); - $expected = array('all', array( - 'conditions' => array('TestModel.field_name' => 'value'), - 'fields' => null, 'order' => null, 'limit' => null, - 'page' => null, 'recursive' => null - )); - $this->assertEquals($expected, $result); - - $result = $this->db->query('findAllById', array('a'), $this->Model); - $expected = array('all', array( - 'conditions' => array('TestModel.id' => 'a'), - 'fields' => null, 'order' => null, 'limit' => null, - 'page' => null, 'recursive' => null - )); - $this->assertEquals($expected, $result); - - $result = $this->db->query('findByFieldName', array(array('value1', 'value2', 'value3')), $this->Model); - $expected = array('first', array( - 'conditions' => array('TestModel.field_name' => array('value1', 'value2', 'value3')), - 'fields' => null, 'order' => null, 'recursive' => null - )); - $this->assertEquals($expected, $result); - - $result = $this->db->query('findByFieldName', array(null), $this->Model); - $expected = array('first', array( - 'conditions' => array('TestModel.field_name' => null), - 'fields' => null, 'order' => null, 'recursive' => null - )); - $this->assertEquals($expected, $result); - - $result = $this->db->query('findByFieldName', array('= a'), $this->Model); - $expected = array('first', array( - 'conditions' => array('TestModel.field_name' => '= a'), - 'fields' => null, 'order' => null, 'recursive' => null - )); - $this->assertEquals($expected, $result); - - $result = $this->db->query('findByFieldName', array(), $this->Model); - $expected = false; - $this->assertEquals($expected, $result); - } - -/** - * - * @expectedException PDOException - * @return void - */ - public function testDirectCallThrowsException() { - $result = $this->db->query('directCall', array(), $this->Model); - } - -/** - * testValue method - * - * @return void - */ - public function testValue() { - if ($this->db instanceof Sqlserver) { - $this->markTestSkipped('Cannot run this test with SqlServer'); - } - $result = $this->db->value('{$__cakeForeignKey__$}'); - $this->assertEquals('{$__cakeForeignKey__$}', $result); - - $result = $this->db->value(array('first', 2, 'third')); - $expected = array('\'first\'', 2, '\'third\''); - $this->assertEquals($expected, $result); - } - -/** - * testReconnect method - * - * @return void - */ - public function testReconnect() { - $this->testDb->reconnect(array('prefix' => 'foo')); - $this->assertTrue($this->testDb->connected); - $this->assertEquals('foo', $this->testDb->config['prefix']); - } - -/** - * testName method - * - * @return void - */ - public function testName() { - $result = $this->testDb->name('name'); - $expected = '`name`'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name(array('name', 'Model.*')); - $expected = array('`name`', '`Model`.*'); - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('MTD()'); - $expected = 'MTD()'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('(sm)'); - $expected = '(sm)'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('name AS x'); - $expected = '`name` AS `x`'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('Model.name AS x'); - $expected = '`Model`.`name` AS `x`'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('Function(Something.foo)'); - $expected = 'Function(`Something`.`foo`)'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('Function(SubFunction(Something.foo))'); - $expected = 'Function(SubFunction(`Something`.`foo`))'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('Function(Something.foo) AS x'); - $expected = 'Function(`Something`.`foo`) AS `x`'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('name-with-minus'); - $expected = '`name-with-minus`'; - $this->assertEquals($expected, $result); - - $result = $this->testDb->name(array('my-name', 'Foo-Model.*')); - $expected = array('`my-name`', '`Foo-Model`.*'); - $this->assertEquals($expected, $result); - - $result = $this->testDb->name(array('Team.P%', 'Team.G/G')); - $expected = array('`Team`.`P%`', '`Team`.`G/G`'); - $this->assertEquals($expected, $result); - - $result = $this->testDb->name('Model.name as y'); - $expected = '`Model`.`name` AS `y`'; - $this->assertEquals($expected, $result); - } - -/** - * test that cacheMethod works as expected - * - * @return void - */ - public function testCacheMethod() { - $this->testDb->cacheMethods = true; - $result = $this->testDb->cacheMethod('name', 'some-key', 'stuff'); - $this->assertEquals('stuff', $result); - - $result = $this->testDb->cacheMethod('name', 'some-key'); - $this->assertEquals('stuff', $result); - - $result = $this->testDb->cacheMethod('conditions', 'some-key'); - $this->assertNull($result); - - $result = $this->testDb->cacheMethod('name', 'other-key'); - $this->assertNull($result); - - $this->testDb->cacheMethods = false; - $result = $this->testDb->cacheMethod('name', 'some-key', 'stuff'); - $this->assertEquals('stuff', $result); - - $result = $this->testDb->cacheMethod('name', 'some-key'); - $this->assertNull($result); - } - -/** - * testLog method - * - * @outputBuffering enabled - * @return void - */ - public function testLog() { - $this->testDb->logQuery('Query 1'); - $this->testDb->logQuery('Query 2'); - - $log = $this->testDb->getLog(false, false); - $result = Set::extract($log['log'], '/query'); - $expected = array('Query 1', 'Query 2'); - $this->assertEquals($expected, $result); - - $oldDebug = Configure::read('debug'); - Configure::write('debug', 2); - ob_start(); - $this->testDb->showLog(); - $contents = ob_get_clean(); - - $this->assertRegExp('/Query 1/s', $contents); - $this->assertRegExp('/Query 2/s', $contents); - - ob_start(); - $this->testDb->showLog(true); - $contents = ob_get_clean(); - - $this->assertRegExp('/Query 1/s', $contents); - $this->assertRegExp('/Query 2/s', $contents); - - Configure::write('debug', $oldDebug); - } - -/** - * test getting the query log as an array. - * - * @return void - */ - public function testGetLog() { - $this->testDb->logQuery('Query 1'); - $this->testDb->logQuery('Query 2'); - - $log = $this->testDb->getLog(); - $expected = array('query' => 'Query 1', 'params' => array(), 'affected' => '', 'numRows' => '', 'took' => ''); - - $this->assertEquals($expected, $log['log'][0]); - $expected = array('query' => 'Query 2', 'params' => array(), 'affected' => '', 'numRows' => '', 'took' => ''); - $this->assertEquals($expected, $log['log'][1]); - $expected = array('query' => 'Error 1', 'affected' => '', 'numRows' => '', 'took' => ''); - } - -/** - * test getting the query log as an array, setting bind params. - * - * @return void - */ - public function testGetLogParams() { - $this->testDb->logQuery('Query 1', array(1,2,'abc')); - $this->testDb->logQuery('Query 2', array('field1' => 1, 'field2' => 'abc')); - - $log = $this->testDb->getLog(); - $expected = array('query' => 'Query 1', 'params' => array(1,2,'abc'), 'affected' => '', 'numRows' => '', 'took' => ''); - $this->assertEquals($expected, $log['log'][0]); - $expected = array('query' => 'Query 2', 'params' => array('field1' => 1, 'field2' => 'abc'), 'affected' => '', 'numRows' => '', 'took' => ''); - $this->assertEquals($expected, $log['log'][1]); - } - -/** - * test that query() returns boolean values from operations like CREATE TABLE - * - * @return void - */ - public function testFetchAllBooleanReturns() { - $name = $this->db->fullTableName('test_query'); - $query = "CREATE TABLE {$name} (name varchar(10));"; - $result = $this->db->query($query); - $this->assertTrue($result, 'Query did not return a boolean'); - - $query = "DROP TABLE {$name};"; - $result = $this->db->query($query); - $this->assertTrue($result, 'Query did not return a boolean'); - } - -/** - * test order to generate query order clause for virtual fields - * - * @return void - */ - public function testVirtualFieldsInOrder() { - $Article = ClassRegistry::init('Article'); - $Article->virtualFields = array( - 'this_moment' => 'NOW()', - 'two' => '1 + 1', - ); - $order = array('two', 'this_moment'); - $result = $this->db->order($order, 'ASC', $Article); - $expected = ' ORDER BY (1 + 1) ASC, (NOW()) ASC'; - $this->assertEquals($expected, $result); - - $order = array('Article.two', 'Article.this_moment'); - $result = $this->db->order($order, 'ASC', $Article); - $expected = ' ORDER BY (1 + 1) ASC, (NOW()) ASC'; - $this->assertEquals($expected, $result); - } - -/** - * test the permutations of fullTableName() - * - * @return void - */ - public function testFullTablePermutations() { - $Article = ClassRegistry::init('Article'); - $result = $this->testDb->fullTableName($Article, false, false); - $this->assertEquals('articles', $result); - - $Article->tablePrefix = 'tbl_'; - $result = $this->testDb->fullTableName($Article, false, false); - $this->assertEquals('tbl_articles', $result); - - $Article->useTable = $Article->table = 'with spaces'; - $Article->tablePrefix = ''; - $result = $this->testDb->fullTableName($Article, true, false); - $this->assertEquals('`with spaces`', $result); - - $this->loadFixtures('Article'); - $Article->useTable = $Article->table = 'articles'; - $Article->setDataSource('test'); - $testdb = $Article->getDataSource(); - $result = $testdb->fullTableName($Article, false, true); - $this->assertEquals($testdb->getSchemaName() . '.articles', $result); - - // tests for empty schemaName - $noschema = ConnectionManager::create('noschema', array( - 'datasource' => 'DboTestSource' - )); - $Article->setDataSource('noschema'); - $Article->schemaName = null; - $result = $noschema->fullTableName($Article, false, true); - $this->assertEquals('articles', $result); - } - -/** - * test that read() only calls queryAssociation on db objects when the method is defined. - * - * @return void - */ - public function testReadOnlyCallingQueryAssociationWhenDefined() { - $this->loadFixtures('Article', 'User', 'ArticlesTag', 'Tag'); - ConnectionManager::create('test_no_queryAssociation', array( - 'datasource' => 'MockDataSource' - )); - $Article = ClassRegistry::init('Article'); - $Article->Comment->useDbConfig = 'test_no_queryAssociation'; - $result = $Article->find('all'); - $this->assertTrue(is_array($result)); - } - -/** - * test that fields() is using methodCache() - * - * @return void - */ - public function testFieldsUsingMethodCache() { - $this->testDb->cacheMethods = false; - DboTestSource::$methodCache = array(); - - $Article = ClassRegistry::init('Article'); - $this->testDb->fields($Article, null, array('title', 'body', 'published')); - $this->assertTrue(empty(DboTestSource::$methodCache['fields']), 'Cache not empty'); - } - -/** - * Test that group works without a model - * - * @return void - */ - public function testGroupNoModel() { - $result = $this->db->group('created'); - $this->assertEquals(' GROUP BY created', $result); - } - -/** - * Test getting the last error. - */ - public function testLastError() { - $stmt = $this->getMock('PDOStatement'); - $stmt->expects($this->any()) - ->method('errorInfo') - ->will($this->returnValue(array('', 'something', 'bad'))); - - $result = $this->db->lastError($stmt); - $expected = 'something: bad'; - $this->assertEquals($expected, $result); - } - -/** - * Tests that transaction commands are logged - * - * @return void - **/ - public function testTransactionLogging() { - $conn = $this->getMock('MockPDO'); - $db = new DboTestSource; - $db->setConnection($conn); - $conn->expects($this->exactly(2))->method('beginTransaction') - ->will($this->returnValue(true)); - $conn->expects($this->once())->method('commit')->will($this->returnValue(true)); - $conn->expects($this->once())->method('rollback')->will($this->returnValue(true)); - - $db->begin(); - $log = $db->getLog(); - $expected = array('query' => 'BEGIN', 'params' => array(), 'affected' => '', 'numRows' => '', 'took' => ''); - $this->assertEquals($expected, $log['log'][0]); - - $db->commit(); - $expected = array('query' => 'COMMIT', 'params' => array(), 'affected' => '', 'numRows' => '', 'took' => ''); - $log = $db->getLog(); - $this->assertEquals($expected, $log['log'][0]); - - $db->begin(); - $expected = array('query' => 'BEGIN', 'params' => array(), 'affected' => '', 'numRows' => '', 'took' => ''); - $log = $db->getLog(); - $this->assertEquals($expected, $log['log'][0]); - - $db->rollback(); - $expected = array('query' => 'ROLLBACK', 'params' => array(), 'affected' => '', 'numRows' => '', 'took' => ''); - $log = $db->getLog(); - $this->assertEquals($expected, $log['log'][0]); - } -} diff --git a/lib/Cake/Test/Case/Model/Datasource/Session/CacheSessionTest.php b/lib/Cake/Test/Case/Model/Datasource/Session/CacheSessionTest.php deleted file mode 100644 index 5fa97123035..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/Session/CacheSessionTest.php +++ /dev/null @@ -1,117 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://cakephp.org CakePHP(tm) Project - * @package Cake.Test.Case.Model.Datasource.Session - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('CakeSession', 'Model/Datasource'); -App::uses('CacheSession', 'Model/Datasource/Session'); -class_exists('CakeSession'); - -class CacheSessionTest extends CakeTestCase { - - protected static $_sessionBackup; - -/** - * test case startup - * - * @return void - */ - public static function setupBeforeClass() { - Cache::config('session_test', array( - 'engine' => 'File', - 'prefix' => 'session_test_' - )); - self::$_sessionBackup = Configure::read('Session'); - - Configure::write('Session.handler.config', 'session_test'); - } - -/** - * cleanup after test case. - * - * @return void - */ - public static function teardownAfterClass() { - Cache::clear(false, 'session_test'); - Cache::drop('session_test'); - - Configure::write('Session', self::$_sessionBackup); - } - -/** - * setup - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->storage = new CacheSession(); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->storage); - } - -/** - * test open - * - * @return void - */ - public function testOpen() { - $this->assertTrue($this->storage->open()); - } - -/** - * test write() - * - * @return void - */ - public function testWrite() { - $this->storage->write('abc', 'Some value'); - $this->assertEquals('Some value', Cache::read('abc', 'session_test'), 'Value was not written.'); - $this->assertFalse(Cache::read('abc', 'default'), 'Cache should only write to the given config.'); - } - -/** - * test reading. - * - * @return void - */ - public function testRead() { - $this->storage->write('test_one', 'Some other value'); - $this->assertEquals('Some other value', $this->storage->read('test_one'), 'Incorrect value.'); - } - -/** - * test destroy - * - * @return void - */ - public function testDestroy() { - $this->storage->write('test_one', 'Some other value'); - $this->assertTrue($this->storage->destroy('test_one'), 'Value was not deleted.'); - - $this->assertFalse(Cache::read('test_one', 'session_test'), 'Value stuck around.'); - } - -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/Model/Datasource/Session/DatabaseSessionTest.php b/lib/Cake/Test/Case/Model/Datasource/Session/DatabaseSessionTest.php deleted file mode 100644 index b52dabe8045..00000000000 --- a/lib/Cake/Test/Case/Model/Datasource/Session/DatabaseSessionTest.php +++ /dev/null @@ -1,188 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://cakephp.org CakePHP(tm) Project - * @package Cake.Test.Case.Model.Datasource.Session - * @since CakePHP(tm) v 2.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('CakeSession', 'Model/Datasource'); -App::uses('DatabaseSession', 'Model/Datasource/Session'); -class_exists('CakeSession'); - -class SessionTestModel extends Model { - - public $name = 'SessionTestModel'; - - public $useTable = 'sessions'; - -} - -/** - * Database session test. - * - * @package Cake.Test.Case.Model.Datasource.Session - */ -class DatabaseSessionTest extends CakeTestCase { - - protected static $_sessionBackup; - -/** - * fixtures - * - * @var string - */ - public $fixtures = array('core.session'); - -/** - * test case startup - * - * @return void - */ - public static function setupBeforeClass() { - self::$_sessionBackup = Configure::read('Session'); - Configure::write('Session.handler', array( - 'model' => 'SessionTestModel', - )); - Configure::write('Session.timeout', 100); - } - -/** - * cleanup after test case. - * - * @return void - */ - public static function teardownAfterClass() { - Configure::write('Session', self::$_sessionBackup); - } - -/** - * setUp - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->storage = new DatabaseSession(); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - unset($this->storage); - ClassRegistry::flush(); - parent::tearDown(); - } - -/** - * test that constructor sets the right things up. - * - * @return void - */ - public function testConstructionSettings() { - ClassRegistry::flush(); - $storage = new DatabaseSession(); - - $session = ClassRegistry::getObject('session'); - $this->assertInstanceOf('SessionTestModel', $session); - $this->assertEquals('Session', $session->alias); - $this->assertEquals('test', $session->useDbConfig); - $this->assertEquals('sessions', $session->useTable); - } - -/** - * test opening the session - * - * @return void - */ - public function testOpen() { - $this->assertTrue($this->storage->open()); - } - -/** - * test write() - * - * @return void - */ - public function testWrite() { - $result = $this->storage->write('foo', 'Some value'); - $expected = array( - 'Session' => array( - 'id' => 'foo', - 'data' => 'Some value', - 'expires' => time() + (Configure::read('Session.timeout') * 60) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testReadAndWriteWithDatabaseStorage method - * - * @return void - */ - public function testWriteEmptySessionId() { - $result = $this->storage->write('', 'This is a Test'); - $this->assertFalse($result); - } - -/** - * test read() - * - * @return void - */ - public function testRead() { - $this->storage->write('foo', 'Some value'); - - $result = $this->storage->read('foo'); - $expected = 'Some value'; - $this->assertEquals($expected, $result); - - $result = $this->storage->read('made up value'); - $this->assertFalse($result); - } - -/** - * test blowing up the session. - * - * @return void - */ - public function testDestroy() { - $this->storage->write('foo', 'Some value'); - - $this->assertTrue($this->storage->destroy('foo'), 'Destroy failed'); - $this->assertFalse($this->storage->read('foo'), 'Value still present.'); - } - -/** - * test the garbage collector - * - * @return void - */ - public function testGc() { - ClassRegistry::flush(); - Configure::write('Session.timeout', 0); - - $storage = new DatabaseSession(); - $storage->write('foo', 'Some value'); - - sleep(1); - $storage->gc(); - $this->assertFalse($storage->read('foo')); - } -} diff --git a/lib/Cake/Test/Case/Model/ModelCrossSchemaHabtmTest.php b/lib/Cake/Test/Case/Model/ModelCrossSchemaHabtmTest.php deleted file mode 100644 index 2e19f9ca769..00000000000 --- a/lib/Cake/Test/Case/Model/ModelCrossSchemaHabtmTest.php +++ /dev/null @@ -1,233 +0,0 @@ - false on *both* database connections, - * or one connection will step on the other. - * - * PHP 5 - * - * CakePHP(tm) Tests - * Copyright 2005-2012, Cake Software Foundation, Inc. - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 2.1 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -require_once dirname(__FILE__) . DS . 'ModelTestBase.php'; - -class ModelCrossSchemaHabtmTest extends BaseModelTest { - -/** - * Fixtures to be used - * - * @var array - */ - public $fixtures = array( - 'core.player', 'core.guild', 'core.guilds_player', - 'core.armor', 'core.armors_player', - ); - -/** - * Don't drop tables if they exist - * - * @var boolean - */ - public $dropTables = false; - -/** - * Don't auto load fixtures - * - * @var boolean - */ - public $autoFixtures = false; - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->_checkConfigs(); - } - -/** - * Check if primary and secondary test databases are configured. - * - * @return void - */ - protected function _checkConfigs() { - $config = ConnectionManager::enumConnectionObjects(); - $this->skipIf($this->db instanceof Sqlite, 'This test is not compatible with Sqlite.'); - $this->skipIf( - !isset($config['test']) || !isset($config['test2']), - 'Primary and secondary test databases not configured, ' . - 'skipping cross-database join tests.' . - ' To run these tests, you must define $test and $test2 in your database configuration.' - ); - } - -/** - * testModelDatasources method - * - * @return void - */ - public function testModelDatasources() { - $this->loadFixtures('Player', 'Guild', 'GuildsPlayer'); - - $Player = ClassRegistry::init('Player'); - $this->assertEquals('test', $Player->useDbConfig); - $this->assertEquals('test', $Player->Guild->useDbConfig); - $this->assertEquals('test2', $Player->GuildsPlayer->useDbConfig); - - $this->assertEquals('test', $Player->getDataSource()->configKeyName); - $this->assertEquals('test', $Player->Guild->getDataSource()->configKeyName); - $this->assertEquals('test2', $Player->GuildsPlayer->getDataSource()->configKeyName); - } - -/** - * testHabtmFind method - * - * @return void - */ - public function testHabtmFind() { - $this->loadFixtures('Player', 'Guild', 'GuildsPlayer'); - $Player = ClassRegistry::init('Player'); - - $players = $Player->find('all', array( - 'fields' => array('id', 'name'), - 'contain' => array( - 'Guild' => array( - 'conditions' => array( - 'Guild.name' => 'Wizards', - ), - ), - ), - )); - $this->assertEquals(4, count($players)); - $wizards = Set::extract('/Guild[name=Wizards]', $players); - $this->assertEquals(1, count($wizards)); - - $players = $Player->find('all', array( - 'fields' => array('id', 'name'), - 'conditions' => array( - 'Player.id' => 1, - ), - )); - $this->assertEquals(1, count($players)); - $wizards = Set::extract('/Guild', $players); - $this->assertEquals(2, count($wizards)); - } - -/** - * testHabtmSave method - * - * @return void - */ - public function testHabtmSave() { - $this->loadFixtures('Player', 'Guild', 'GuildsPlayer'); - $Player = ClassRegistry::init('Player'); - $players = $Player->find('count'); - $this->assertEquals(4, $players); - - $player = $Player->create(array( - 'name' => 'rchavik', - )); - - $results = $Player->saveAll($player, array('validate' => 'first')); - $this->assertNotEqual(false, $results); - $count = $Player->find('count'); - $this->assertEquals(5, $count); - - $count = $Player->GuildsPlayer->find('count'); - $this->assertEquals(3, $count); - - $player = $Player->findByName('rchavik'); - $this->assertEmpty($player['Guild']); - - $player['Guild']['Guild'] = array(1, 2, 3); - $Player->save($player); - - $player = $Player->findByName('rchavik'); - $this->assertEquals(3, count($player['Guild'])); - - $players = $Player->find('all', array( - 'contain' => array( - 'conditions' => array( - 'Guild.name' => 'Rangers', - ), - ), - )); - - $rangers = Set::extract('/Guild[name=Rangers]', $players); - $this->assertEquals(2, count($rangers)); - } - -/** - * testHabtmWithThreeDatabases method - * - * @return void - */ - public function testHabtmWithThreeDatabases() { - $config = ConnectionManager::enumConnectionObjects(); - $this->skipIf( - !isset($config['test']) || !isset($config['test2']) || !isset($config['test_database_three']), - 'Primary, secondary, and tertiary test databases not configured,' . - ' skipping test. To run these tests, you must define ' . - '$test, $test2, and $test_database_three in your database configuration.' - ); - - $this->loadFixtures('Player', 'Guild', 'GuildsPlayer', 'Armor', 'ArmorsPlayer'); - - $Player = ClassRegistry::init('Player'); - $Player->bindModel(array( - 'hasAndBelongsToMany' => array( - 'Armor' => array( - 'with' => 'ArmorsPlayer', - 'unique' => true, - ), - ), - ), false); - $this->assertEquals('test', $Player->useDbConfig); - $this->assertEquals('test2', $Player->Armor->useDbConfig); - $this->assertEquals('test_database_three', $Player->ArmorsPlayer->useDbConfig); - $players = $Player->find('count'); - $this->assertEquals(4, $players); - - $spongebob = $Player->create(array( - 'id' => 10, - 'name' => 'spongebob', - )); - $spongebob['Armor'] = array('Armor' => array(1, 2, 3, 4)); - $result = $Player->save($spongebob); - - $expected = array( - 'Player' => array( - 'id' => 10, - 'name' => 'spongebob', - ), - 'Armor' => array( - 'Armor' => array( - 1, 2, 3, 4, - 1, 2, 3, 4, - ), - ), - ); - unset($result['Player']['created']); - unset($result['Player']['updated']); - $this->assertEquals($expected, $result); - - $spongebob = $Player->find('all', array( - 'conditions' => array( - 'Player.id' => 10, - ) - )); - $spongeBobsArmors = Set::extract('/Armor', $spongebob); - $this->assertEquals(4, count($spongeBobsArmors)); - } -} diff --git a/lib/Cake/Test/Case/Model/ModelDeleteTest.php b/lib/Cake/Test/Case/Model/ModelDeleteTest.php deleted file mode 100644 index 37e7c8695e3..00000000000 --- a/lib/Cake/Test/Case/Model/ModelDeleteTest.php +++ /dev/null @@ -1,865 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -require_once dirname(__FILE__) . DS . 'ModelTestBase.php'; - -/** - * ModelDeleteTest - * - * @package Cake.Test.Case.Model - */ -class ModelDeleteTest extends BaseModelTest { - -/** - * testDeleteHabtmReferenceWithConditions method - * - * @return void - */ - public function testDeleteHabtmReferenceWithConditions() { - $this->loadFixtures('Portfolio', 'Item', 'ItemsPortfolio', 'Syfile', 'Image'); - - $Portfolio = new Portfolio(); - $Portfolio->hasAndBelongsToMany['Item']['conditions'] = array('ItemsPortfolio.item_id >' => 1); - - $result = $Portfolio->find('first', array( - 'conditions' => array('Portfolio.id' => 1) - )); - $expected = array( - array( - 'id' => 3, - 'syfile_id' => 3, - 'published' => false, - 'name' => 'Item 3', - 'ItemsPortfolio' => array( - 'id' => 3, - 'item_id' => 3, - 'portfolio_id' => 1 - )), - array( - 'id' => 4, - 'syfile_id' => 4, - 'published' => false, - 'name' => 'Item 4', - 'ItemsPortfolio' => array( - 'id' => 4, - 'item_id' => 4, - 'portfolio_id' => 1 - )), - array( - 'id' => 5, - 'syfile_id' => 5, - 'published' => false, - 'name' => 'Item 5', - 'ItemsPortfolio' => array( - 'id' => 5, - 'item_id' => 5, - 'portfolio_id' => 1 - ))); - $this->assertEquals($expected, $result['Item']); - - $result = $Portfolio->ItemsPortfolio->find('all', array( - 'conditions' => array('ItemsPortfolio.portfolio_id' => 1) - )); - $expected = array( - array( - 'ItemsPortfolio' => array( - 'id' => 1, - 'item_id' => 1, - 'portfolio_id' => 1 - )), - array( - 'ItemsPortfolio' => array( - 'id' => 3, - 'item_id' => 3, - 'portfolio_id' => 1 - )), - array( - 'ItemsPortfolio' => array( - 'id' => 4, - 'item_id' => 4, - 'portfolio_id' => 1 - )), - array( - 'ItemsPortfolio' => array( - 'id' => 5, - 'item_id' => 5, - 'portfolio_id' => 1 - ))); - $this->assertEquals($expected, $result); - - $Portfolio->delete(1); - - $result = $Portfolio->find('first', array( - 'conditions' => array('Portfolio.id' => 1) - )); - $this->assertFalse($result); - - $result = $Portfolio->ItemsPortfolio->find('all', array( - 'conditions' => array('ItemsPortfolio.portfolio_id' => 1) - )); - $this->assertEquals(array(), $result); - } - -/** - * testDeleteArticleBLinks method - * - * @return void - */ - public function testDeleteArticleBLinks() { - $this->loadFixtures('Article', 'ArticlesTag', 'Tag', 'User'); - $TestModel = new ArticleB(); - - $result = $TestModel->ArticlesTag->find('all'); - $expected = array( - array('ArticlesTag' => array('article_id' => '1', 'tag_id' => '1')), - array('ArticlesTag' => array('article_id' => '1', 'tag_id' => '2')), - array('ArticlesTag' => array('article_id' => '2', 'tag_id' => '1')), - array('ArticlesTag' => array('article_id' => '2', 'tag_id' => '3')) - ); - $this->assertEquals($expected, $result); - - $TestModel->delete(1); - $result = $TestModel->ArticlesTag->find('all'); - - $expected = array( - array('ArticlesTag' => array('article_id' => '2', 'tag_id' => '1')), - array('ArticlesTag' => array('article_id' => '2', 'tag_id' => '3')) - ); - $this->assertEquals($expected, $result); - } - -/** - * testDeleteDependentWithConditions method - * - * @return void - */ - public function testDeleteDependentWithConditions() { - $this->loadFixtures('Cd','Book','OverallFavorite'); - - $Cd = new Cd(); - $Book = new Book(); - $OverallFavorite = new OverallFavorite(); - - $Cd->delete(1); - - $result = $OverallFavorite->find('all', array( - 'fields' => array('model_type', 'model_id', 'priority') - )); - $expected = array( - array( - 'OverallFavorite' => array( - 'model_type' => 'Book', - 'model_id' => 1, - 'priority' => 2 - ))); - - $this->assertTrue(is_array($result)); - $this->assertEquals($expected, $result); - - $Book->delete(1); - - $result = $OverallFavorite->find('all', array( - 'fields' => array('model_type', 'model_id', 'priority') - )); - $expected = array(); - - $this->assertTrue(is_array($result)); - $this->assertEquals($expected, $result); - } - -/** - * testDel method - * - * @return void - */ - public function testDelete() { - $this->loadFixtures('Article', 'Comment', 'Attachment'); - $TestModel = new Article(); - - $result = $TestModel->delete(2); - $this->assertTrue($result); - - $result = $TestModel->read(null, 2); - $this->assertFalse($result); - - $TestModel->recursive = -1; - $result = $TestModel->find('all', array( - 'fields' => array('id', 'title') - )); - $expected = array( - array('Article' => array( - 'id' => 1, - 'title' => 'First Article' - )), - array('Article' => array( - 'id' => 3, - 'title' => 'Third Article' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->delete(3); - $this->assertTrue($result); - - $result = $TestModel->read(null, 3); - $this->assertFalse($result); - - $TestModel->recursive = -1; - $result = $TestModel->find('all', array( - 'fields' => array('id', 'title') - )); - $expected = array( - array('Article' => array( - 'id' => 1, - 'title' => 'First Article' - ))); - - $this->assertEquals($expected, $result); - - // make sure deleting a non-existent record doesn't break save() - // ticket #6293 - $this->loadFixtures('Uuid'); - $Uuid = new Uuid(); - $data = array( - 'B607DAB9-88A2-46CF-B57C-842CA9E3B3B3', - '52C8865C-10EE-4302-AE6C-6E7D8E12E2C8', - '8208C7FE-E89C-47C5-B378-DED6C271F9B8'); - foreach ($data as $id) { - $Uuid->save(array('id' => $id)); - } - $Uuid->delete('52C8865C-10EE-4302-AE6C-6E7D8E12E2C8'); - $Uuid->delete('52C8865C-10EE-4302-AE6C-6E7D8E12E2C8'); - foreach ($data as $id) { - $Uuid->save(array('id' => $id)); - } - $result = $Uuid->find('all', array( - 'conditions' => array('id' => $data), - 'fields' => array('id'), - 'order' => 'id')); - $expected = array( - array('Uuid' => array( - 'id' => '52C8865C-10EE-4302-AE6C-6E7D8E12E2C8')), - array('Uuid' => array( - 'id' => '8208C7FE-E89C-47C5-B378-DED6C271F9B8')), - array('Uuid' => array( - 'id' => 'B607DAB9-88A2-46CF-B57C-842CA9E3B3B3'))); - $this->assertEquals($expected, $result); - } - -/** - * test that delete() updates the correct records counterCache() records. - * - * @return void - */ - public function testDeleteUpdatingCounterCacheCorrectly() { - $this->loadFixtures('CounterCacheUser', 'CounterCachePost'); - $User = new CounterCacheUser(); - - $User->Post->delete(3); - $result = $User->read(null, 301); - $this->assertEquals(0, $result['User']['post_count']); - - $result = $User->read(null, 66); - $this->assertEquals(2, $result['User']['post_count']); - } - -/** - * testDeleteAll method - * - * @return void - */ - public function testDeleteAll() { - $this->loadFixtures('Article'); - $TestModel = new Article(); - - $data = array('Article' => array( - 'user_id' => 2, - 'id' => 4, - 'title' => 'Fourth Article', - 'published' => 'N' - )); - $result = $TestModel->set($data) && $TestModel->save(); - $this->assertTrue($result); - - $data = array('Article' => array( - 'user_id' => 2, - 'id' => 5, - 'title' => 'Fifth Article', - 'published' => 'Y' - )); - $result = $TestModel->set($data) && $TestModel->save(); - $this->assertTrue($result); - - $data = array('Article' => array( - 'user_id' => 1, - 'id' => 6, - 'title' => 'Sixth Article', - 'published' => 'N' - )); - $result = $TestModel->set($data) && $TestModel->save(); - $this->assertTrue($result); - - $TestModel->recursive = -1; - $result = $TestModel->find('all', array( - 'fields' => array('id', 'user_id', 'title', 'published'), - 'order' => array('Article.id' => 'ASC') - )); - - $expected = array( - array('Article' => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'published' => 'Y' - )), - array('Article' => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'published' => 'Y' - )), - array('Article' => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'published' => 'Y')), - array('Article' => array( - 'id' => 4, - 'user_id' => 2, - 'title' => 'Fourth Article', - 'published' => 'N' - )), - array('Article' => array( - 'id' => 5, - 'user_id' => 2, - 'title' => 'Fifth Article', - 'published' => 'Y' - )), - array('Article' => array( - 'id' => 6, - 'user_id' => 1, - 'title' => 'Sixth Article', - 'published' => 'N' - ))); - - $this->assertEquals($expected, $result); - - $result = $TestModel->deleteAll(array('Article.published' => 'N')); - $this->assertTrue($result); - - $TestModel->recursive = -1; - $result = $TestModel->find('all', array( - 'fields' => array('id', 'user_id', 'title', 'published'), - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'published' => 'Y' - )), - array('Article' => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'published' => 'Y' - )), - array('Article' => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'published' => 'Y' - )), - array('Article' => array( - 'id' => 5, - 'user_id' => 2, - 'title' => 'Fifth Article', - 'published' => 'Y' - ))); - $this->assertEquals($expected, $result); - - $data = array('Article.user_id' => array(2, 3)); - $result = $TestModel->deleteAll($data, true, true); - $this->assertTrue($result); - - $TestModel->recursive = -1; - $result = $TestModel->find('all', array( - 'fields' => array('id', 'user_id', 'title', 'published'), - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'published' => 'Y' - )), - array('Article' => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'published' => 'Y' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->deleteAll(array('Article.user_id' => 999)); - $this->assertTrue($result, 'deleteAll returned false when all no records matched conditions. %s'); - } - -/** - * testDeleteAllUnknownColumn method - * - * @expectedException PDOException - * @return void - */ - public function testDeleteAllUnknownColumn() { - $this->loadFixtures('Article'); - $TestModel = new Article(); - $result = $TestModel->deleteAll(array('Article.non_existent_field' => 999)); - $this->assertFalse($result, 'deleteAll returned true when find query generated sql error. %s'); - } - -/** - * testRecursiveDel method - * - * @return void - */ - public function testRecursiveDel() { - $this->loadFixtures('Article', 'Comment', 'Attachment'); - $TestModel = new Article(); - - $result = $TestModel->delete(2); - $this->assertTrue($result); - - $TestModel->recursive = 2; - $result = $TestModel->read(null, 2); - $this->assertFalse($result); - - $result = $TestModel->Comment->read(null, 5); - $this->assertFalse($result); - - $result = $TestModel->Comment->read(null, 6); - $this->assertFalse($result); - - $result = $TestModel->Comment->Attachment->read(null, 1); - $this->assertFalse($result); - - $result = $TestModel->find('count'); - $this->assertEquals(2, $result); - - $result = $TestModel->Comment->find('count'); - $this->assertEquals(4, $result); - - $result = $TestModel->Comment->Attachment->find('count'); - $this->assertEquals(0, $result); - } - -/** - * testDependentExclusiveDelete method - * - * @return void - */ - public function testDependentExclusiveDelete() { - $this->loadFixtures('Article', 'Comment'); - $TestModel = new Article10(); - - $result = $TestModel->find('all'); - $this->assertEquals(4, count($result[0]['Comment'])); - $this->assertEquals(2, count($result[1]['Comment'])); - $this->assertEquals(6, $TestModel->Comment->find('count')); - - $TestModel->delete(1); - $this->assertEquals(2, $TestModel->Comment->find('count')); - } - -/** - * testDeleteLinks method - * - * @return void - */ - public function testDeleteLinks() { - $this->loadFixtures('Article', 'ArticlesTag', 'Tag'); - $TestModel = new Article(); - - $result = $TestModel->ArticlesTag->find('all'); - $expected = array( - array('ArticlesTag' => array( - 'article_id' => '1', - 'tag_id' => '1' - )), - array('ArticlesTag' => array( - 'article_id' => '1', - 'tag_id' => '2' - )), - array('ArticlesTag' => array( - 'article_id' => '2', - 'tag_id' => '1' - )), - array('ArticlesTag' => array( - 'article_id' => '2', - 'tag_id' => '3' - ))); - $this->assertEquals($expected, $result); - - $TestModel->delete(1); - $result = $TestModel->ArticlesTag->find('all'); - - $expected = array( - array('ArticlesTag' => array( - 'article_id' => '2', - 'tag_id' => '1' - )), - array('ArticlesTag' => array( - 'article_id' => '2', - 'tag_id' => '3' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->deleteAll(array('Article.user_id' => 999)); - $this->assertTrue($result, 'deleteAll returned false when all no records matched conditions. %s'); - } - -/** - * test that a plugin model as the 'with' model doesn't have issues - * - * @return void - */ - public function testDeleteLinksWithPLuginJoinModel() { - $this->loadFixtures('Article', 'ArticlesTag', 'Tag'); - $Article = new Article(); - $Article->unbindModel(array('hasAndBelongsToMany' => array('Tag')), false); - unset($Article->Tag, $Article->ArticleTags); - $Article->bindModel(array('hasAndBelongsToMany' => array( - 'Tag' => array('with' => 'TestPlugin.ArticlesTag') - )), false); - - $this->assertTrue($Article->delete(1)); - } - -/** - * testDeleteDependent method - * - * @return void - */ - public function testDeleteDependent() { - $this->loadFixtures('Bidding', 'BiddingMessage', 'Article', - 'ArticlesTag', 'Comment', 'User', 'Attachment' - ); - $Bidding = new Bidding(); - $result = $Bidding->find('all', array('order' => array('Bidding.id' => 'ASC'))); - $expected = array( - array( - 'Bidding' => array('id' => 1, 'bid' => 'One', 'name' => 'Bid 1'), - 'BiddingMessage' => array('bidding' => 'One', 'name' => 'Message 1'), - ), - array( - 'Bidding' => array('id' => 2, 'bid' => 'Two', 'name' => 'Bid 2'), - 'BiddingMessage' => array('bidding' => 'Two', 'name' => 'Message 2'), - ), - array( - 'Bidding' => array('id' => 3, 'bid' => 'Three', 'name' => 'Bid 3'), - 'BiddingMessage' => array('bidding' => 'Three', 'name' => 'Message 3'), - ), - array( - 'Bidding' => array('id' => 4, 'bid' => 'Five', 'name' => 'Bid 5'), - 'BiddingMessage' => array('bidding' => '', 'name' => ''), - ), - ); - $this->assertEquals($expected, $result); - - $Bidding->delete(4, true); - $result = $Bidding->find('all', array('order' => array('Bidding.id' => 'ASC'))); - $expected = array( - array( - 'Bidding' => array('id' => 1, 'bid' => 'One', 'name' => 'Bid 1'), - 'BiddingMessage' => array('bidding' => 'One', 'name' => 'Message 1'), - ), - array( - 'Bidding' => array('id' => 2, 'bid' => 'Two', 'name' => 'Bid 2'), - 'BiddingMessage' => array('bidding' => 'Two', 'name' => 'Message 2'), - ), - array( - 'Bidding' => array('id' => 3, 'bid' => 'Three', 'name' => 'Bid 3'), - 'BiddingMessage' => array('bidding' => 'Three', 'name' => 'Message 3'), - ), - ); - $this->assertEquals($expected, $result); - - $Bidding->delete(2, true); - $result = $Bidding->find('all', array('order' => array('Bidding.id' => 'ASC'))); - $expected = array( - array( - 'Bidding' => array('id' => 1, 'bid' => 'One', 'name' => 'Bid 1'), - 'BiddingMessage' => array('bidding' => 'One', 'name' => 'Message 1'), - ), - array( - 'Bidding' => array('id' => 3, 'bid' => 'Three', 'name' => 'Bid 3'), - 'BiddingMessage' => array('bidding' => 'Three', 'name' => 'Message 3'), - ), - ); - $this->assertEquals($expected, $result); - - $result = $Bidding->BiddingMessage->find('all', array('order' => array('BiddingMessage.name' => 'ASC'))); - $expected = array( - array( - 'BiddingMessage' => array('bidding' => 'One', 'name' => 'Message 1'), - 'Bidding' => array('id' => 1, 'bid' => 'One', 'name' => 'Bid 1'), - ), - array( - 'BiddingMessage' => array('bidding' => 'Three', 'name' => 'Message 3'), - 'Bidding' => array('id' => 3, 'bid' => 'Three', 'name' => 'Bid 3'), - ), - array( - 'BiddingMessage' => array('bidding' => 'Four', 'name' => 'Message 4'), - 'Bidding' => array('id' => '', 'bid' => '', 'name' => ''), - ), - ); - $this->assertEquals($expected, $result); - - $Article = new Article(); - $result = $Article->Comment->find('count', array( - 'conditions' => array('Comment.article_id' => 1) - )); - $this->assertEquals(4, $result); - - $result = $Article->delete(1, true); - $this->assertSame($result, true); - - $result = $Article->Comment->find('count', array( - 'conditions' => array('Comment.article_id' => 1) - )); - $this->assertEquals(0, $result); - } - -/** - * test deleteLinks with Multiple habtm associations - * - * @return void - */ - public function testDeleteLinksWithMultipleHabtmAssociations() { - $this->loadFixtures('JoinA', 'JoinB', 'JoinC', 'JoinAB', 'JoinAC'); - $JoinA = new JoinA(); - - //create two new join records to expose the issue. - $JoinA->JoinAsJoinC->create(array( - 'join_a_id' => 1, - 'join_c_id' => 2, - )); - $JoinA->JoinAsJoinC->save(); - $JoinA->JoinAsJoinB->create(array( - 'join_a_id' => 1, - 'join_b_id' => 2, - )); - $JoinA->JoinAsJoinB->save(); - - $result = $JoinA->delete(1); - $this->assertTrue($result, 'Delete failed %s'); - - $joinedBs = $JoinA->JoinAsJoinB->find('count', array( - 'conditions' => array('JoinAsJoinB.join_a_id' => 1) - )); - $this->assertEquals(0, $joinedBs, 'JoinA/JoinB link records left over. %s'); - - $joinedBs = $JoinA->JoinAsJoinC->find('count', array( - 'conditions' => array('JoinAsJoinC.join_a_id' => 1) - )); - $this->assertEquals(0, $joinedBs, 'JoinA/JoinC link records left over. %s'); - } - -/** - * testHabtmDeleteLinksWhenNoPrimaryKeyInJoinTable method - * - * @return void - */ - public function testHabtmDeleteLinksWhenNoPrimaryKeyInJoinTable() { - $this->loadFixtures('Apple', 'Device', 'ThePaperMonkies'); - $ThePaper = new ThePaper(); - $ThePaper->id = 1; - $ThePaper->save(array('Monkey' => array(2, 3))); - - $result = $ThePaper->findById(1); - $expected = array( - array( - 'id' => '2', - 'device_type_id' => '1', - 'name' => 'Device 2', - 'typ' => '1' - ), - array( - 'id' => '3', - 'device_type_id' => '1', - 'name' => 'Device 3', - 'typ' => '2' - )); - $this->assertEquals($expected, $result['Monkey']); - - $ThePaper = new ThePaper(); - $ThePaper->id = 2; - $ThePaper->save(array('Monkey' => array(2, 3))); - - $result = $ThePaper->findById(2); - $expected = array( - array( - 'id' => '2', - 'device_type_id' => '1', - 'name' => 'Device 2', - 'typ' => '1' - ), - array( - 'id' => '3', - 'device_type_id' => '1', - 'name' => 'Device 3', - 'typ' => '2' - )); - $this->assertEquals($expected, $result['Monkey']); - - $ThePaper->delete(1); - $result = $ThePaper->findById(2); - $expected = array( - array( - 'id' => '2', - 'device_type_id' => '1', - 'name' => 'Device 2', - 'typ' => '1' - ), - array( - 'id' => '3', - 'device_type_id' => '1', - 'name' => 'Device 3', - 'typ' => '2' - )); - $this->assertEquals($expected, $result['Monkey']); - } - -/** - * test that beforeDelete returning false can abort deletion. - * - * @return void - */ - public function testBeforeDeleteDeleteAbortion() { - $this->loadFixtures('Post'); - $Model = new CallbackPostTestModel(); - $Model->beforeDeleteReturn = false; - - $result = $Model->delete(1); - $this->assertFalse($result); - - $exists = $Model->findById(1); - $this->assertTrue(is_array($exists)); - } - -/** - * test for a habtm deletion error that occurs in postgres but should not. - * And should not occur in any dbo. - * - * @return void - */ - public function testDeleteHabtmPostgresFailure() { - $this->loadFixtures('Article', 'Tag', 'ArticlesTag'); - - $Article = ClassRegistry::init('Article'); - $Article->hasAndBelongsToMany['Tag']['unique'] = true; - - $Tag = ClassRegistry::init('Tag'); - $Tag->bindModel(array('hasAndBelongsToMany' => array( - 'Article' => array( - 'className' => 'Article', - 'unique' => true - ) - )), true); - - // Article 1 should have Tag.1 and Tag.2 - $before = $Article->find("all", array( - "conditions" => array("Article.id" => 1), - )); - $this->assertEquals(2, count($before[0]['Tag']), 'Tag count for Article.id = 1 is incorrect, should be 2 %s'); - - // From now on, Tag #1 is only associated with Post #1 - $submittedData = array( - "Tag" => array("id" => 1, 'tag' => 'tag1'), - "Article" => array( - "Article" => array(1) - ) - ); - $Tag->save($submittedData); - - // One more submission (The other way around) to make sure the reverse save looks good. - $submittedData = array( - "Article" => array("id" => 2, 'title' => 'second article'), - "Tag" => array( - "Tag" => array(2, 3) - ) - ); - - // ERROR: - // Postgresql: DELETE FROM "articles_tags" WHERE tag_id IN ('1', '3') - // MySQL: DELETE `ArticlesTag` FROM `articles_tags` AS `ArticlesTag` WHERE `ArticlesTag`.`article_id` = 2 AND `ArticlesTag`.`tag_id` IN (1, 3) - $Article->save($submittedData); - - // Want to make sure Article #1 has Tag #1 and Tag #2 still. - $after = $Article->find("all", array( - "conditions" => array("Article.id" => 1), - )); - - // Removing Article #2 from Tag #1 is all that should have happened. - $this->assertEquals(count($before[0]["Tag"]), count($after[0]["Tag"])); - } - -/** - * test that deleting records inside the beforeDelete doesn't truncate the table. - * - * @return void - */ - public function testBeforeDeleteWipingTable() { - $this->loadFixtures('Comment'); - - $Comment = new BeforeDeleteComment(); - // Delete 3 records. - $Comment->delete(4); - $result = $Comment->find('count'); - - $this->assertTrue($result > 1, 'Comments are all gone.'); - $Comment->create(array( - 'article_id' => 1, - 'user_id' => 2, - 'comment' => 'new record', - 'published' => 'Y' - )); - $Comment->save(); - - $Comment->delete(5); - $result = $Comment->find('count'); - - $this->assertTrue($result > 1, 'Comments are all gone.'); - } - -/** - * test that deleting the same record from the beforeDelete and the delete doesn't truncate the table. - * - * @return void - */ - public function testBeforeDeleteWipingTableWithDuplicateDelete() { - $this->loadFixtures('Comment'); - - $Comment = new BeforeDeleteComment(); - $Comment->delete(1); - - $result = $Comment->find('count'); - $this->assertTrue($result > 1, 'Comments are all gone.'); - } -} diff --git a/lib/Cake/Test/Case/Model/ModelIntegrationTest.php b/lib/Cake/Test/Case/Model/ModelIntegrationTest.php deleted file mode 100644 index 05afa857a77..00000000000 --- a/lib/Cake/Test/Case/Model/ModelIntegrationTest.php +++ /dev/null @@ -1,2428 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -require_once dirname(__FILE__) . DS . 'ModelTestBase.php'; -App::uses('DboSource', 'Model/Datasource'); - -/** - * DboMock class - * A Dbo Source driver to mock a connection and a identity name() method - */ -class DboMock extends DboSource { - -/** - * Returns the $field without modifications - */ - public function name($field) { - return $field; - } - -/** - * Returns true to fake a database connection - */ - public function connect() { - return true; - } - -} - -/** - * ModelIntegrationTest - * - * @package Cake.Test.Case.Model - */ -class ModelIntegrationTest extends BaseModelTest { - -/** - * testAssociationLazyLoading - * - * @group lazyloading - * @return void - */ - public function testAssociationLazyLoading() { - $this->loadFixtures('ArticleFeaturedsTags'); - $Article = new ArticleFeatured(); - $this->assertTrue(isset($Article->belongsTo['User'])); - $this->assertFalse(property_exists($Article, 'User')); - $this->assertInstanceOf('User', $Article->User); - - $this->assertTrue(isset($Article->belongsTo['Category'])); - $this->assertFalse(property_exists($Article, 'Category')); - $this->assertTrue(isset($Article->Category)); - $this->assertInstanceOf('Category', $Article->Category); - - $this->assertTrue(isset($Article->hasMany['Comment'])); - $this->assertFalse(property_exists($Article, 'Comment')); - $this->assertTrue(isset($Article->Comment)); - $this->assertInstanceOf('Comment', $Article->Comment); - - $this->assertTrue(isset($Article->hasAndBelongsToMany['Tag'])); - //There was not enough information to setup the association (joinTable and associationForeignKey) - //so the model was not lazy loaded - $this->assertTrue(property_exists($Article, 'Tag')); - $this->assertTrue(isset($Article->Tag)); - $this->assertInstanceOf('Tag', $Article->Tag); - - $this->assertFalse(property_exists($Article, 'ArticleFeaturedsTag')); - $this->assertInstanceOf('AppModel', $Article->ArticleFeaturedsTag); - $this->assertEquals('article_featureds_tags', $Article->hasAndBelongsToMany['Tag']['joinTable']); - $this->assertEquals('tag_id', $Article->hasAndBelongsToMany['Tag']['associationForeignKey']); - } - -/** - * testAssociationLazyLoadWithHABTM - * - * @group lazyloading - * @return void - */ - public function testAssociationLazyLoadWithHABTM() { - $this->loadFixtures('FruitsUuidTag', 'ArticlesTag'); - $this->db->cacheSources = false; - $Article = new ArticleB(); - $this->assertTrue(isset($Article->hasAndBelongsToMany['TagB'])); - $this->assertFalse(property_exists($Article, 'TagB')); - $this->assertInstanceOf('TagB', $Article->TagB); - - $this->assertFalse(property_exists($Article, 'ArticlesTag')); - $this->assertInstanceOf('AppModel', $Article->ArticlesTag); - - $UuidTag = new UuidTag(); - $this->assertTrue(isset($UuidTag->hasAndBelongsToMany['Fruit'])); - $this->assertFalse(property_exists($UuidTag, 'Fruit')); - $this->assertFalse(property_exists($UuidTag, 'FruitsUuidTag')); - $this->assertTrue(isset($UuidTag->Fruit)); - - $this->assertFalse(property_exists($UuidTag, 'FruitsUuidTag')); - $this->assertTrue(isset($UuidTag->FruitsUuidTag)); - $this->assertInstanceOf('FruitsUuidTag', $UuidTag->FruitsUuidTag); - } - -/** - * testAssociationLazyLoadWithBindModel - * - * @group lazyloading - * @return void - */ - public function testAssociationLazyLoadWithBindModel() { - $this->loadFixtures('Article', 'User'); - $Article = new ArticleB(); - - $this->assertFalse(isset($Article->belongsTo['User'])); - $this->assertFalse(property_exists($Article, 'User')); - - $Article->bindModel(array('belongsTo' => array('User'))); - $this->assertTrue(isset($Article->belongsTo['User'])); - $this->assertFalse(property_exists($Article, 'User')); - $this->assertInstanceOf('User', $Article->User); - } - -/** - * Tests that creating a model with no existent database table associated will throw an exception - * - * @expectedException MissingTableException - * @return void - */ - public function testMissingTable() { - $Article = new ArticleB(false, uniqid()); - $Article->schema(); - } - -/** - * testPkInHAbtmLinkModelArticleB - * - * @return void - */ - public function testPkInHabtmLinkModelArticleB() { - $this->loadFixtures('Article', 'Tag', 'ArticlesTag'); - $TestModel = new ArticleB(); - $this->assertEquals('article_id', $TestModel->ArticlesTag->primaryKey); - } - -/** - * Tests that $cacheSources can only be disabled in the db using model settings, not enabled - * - * @return void - */ - public function testCacheSourcesDisabling() { - $this->loadFixtures('JoinA', 'JoinB', 'JoinAB', 'JoinC', 'JoinAC'); - $this->db->cacheSources = true; - $TestModel = new JoinA(); - $TestModel->cacheSources = false; - $TestModel->setSource('join_as'); - $this->assertFalse($this->db->cacheSources); - - $this->db->cacheSources = false; - $TestModel = new JoinA(); - $TestModel->cacheSources = true; - $TestModel->setSource('join_as'); - $this->assertFalse($this->db->cacheSources); - } - -/** - * testPkInHabtmLinkModel method - * - * @return void - */ - public function testPkInHabtmLinkModel() { - //Test Nonconformant Models - $this->loadFixtures('Content', 'ContentAccount', 'Account', 'JoinC', 'JoinAC', 'ItemsPortfolio'); - $TestModel = new Content(); - $this->assertEquals('iContentAccountsId', $TestModel->ContentAccount->primaryKey); - - //test conformant models with no PK in the join table - $this->loadFixtures('Article', 'Tag'); - $TestModel = new Article(); - $this->assertEquals('article_id', $TestModel->ArticlesTag->primaryKey); - - //test conformant models with PK in join table - $TestModel = new Portfolio(); - $this->assertEquals('id', $TestModel->ItemsPortfolio->primaryKey); - - //test conformant models with PK in join table - join table contains extra field - $this->loadFixtures('JoinA', 'JoinB', 'JoinAB'); - $TestModel = new JoinA(); - $this->assertEquals('id', $TestModel->JoinAsJoinB->primaryKey); - } - -/** - * testDynamicBehaviorAttachment method - * - * @return void - */ - public function testDynamicBehaviorAttachment() { - $this->loadFixtures('Apple', 'Sample', 'Author'); - $TestModel = new Apple(); - $this->assertEquals(array(), $TestModel->Behaviors->attached()); - - $TestModel->Behaviors->attach('Tree', array('left' => 'left_field', 'right' => 'right_field')); - $this->assertTrue(is_object($TestModel->Behaviors->Tree)); - $this->assertEquals(array('Tree'), $TestModel->Behaviors->attached()); - - $expected = array( - 'parent' => 'parent_id', - 'left' => 'left_field', - 'right' => 'right_field', - 'scope' => '1 = 1', - 'type' => 'nested', - '__parentChange' => false, - 'recursive' => -1 - ); - $this->assertEquals($expected, $TestModel->Behaviors->Tree->settings['Apple']); - - $TestModel->Behaviors->attach('Tree', array('enabled' => false)); - $this->assertEquals($expected, $TestModel->Behaviors->Tree->settings['Apple']); - $this->assertEquals(array('Tree'), $TestModel->Behaviors->attached()); - - $TestModel->Behaviors->detach('Tree'); - $this->assertEquals(array(), $TestModel->Behaviors->attached()); - $this->assertFalse(isset($TestModel->Behaviors->Tree)); - } - -/** - * testFindWithJoinsOption method - * - * @access public - * @return void - */ - public function testFindWithJoinsOption() { - $this->loadFixtures('Article', 'User'); - $TestUser = new User(); - - $options = array( - 'fields' => array( - 'user', - 'Article.published', - ), - 'joins' => array( - array( - 'table' => 'articles', - 'alias' => 'Article', - 'type' => 'LEFT', - 'conditions' => array( - 'User.id = Article.user_id', - ), - ), - ), - 'group' => array('User.user', 'Article.published'), - 'recursive' => -1, - 'order' => array('User.user') - ); - $result = $TestUser->find('all', $options); - $expected = array( - array('User' => array('user' => 'garrett'), 'Article' => array('published' => '')), - array('User' => array('user' => 'larry'), 'Article' => array('published' => 'Y')), - array('User' => array('user' => 'mariano'), 'Article' => array('published' => 'Y')), - array('User' => array('user' => 'nate'), 'Article' => array('published' => '')) - ); - $this->assertEquals($expected, $result); - } - -/** - * Tests cross database joins. Requires $test and $test2 to both be set in DATABASE_CONFIG - * NOTE: When testing on MySQL, you must set 'persistent' => false on *both* database connections, - * or one connection will step on the other. - */ - public function testCrossDatabaseJoins() { - $config = ConnectionManager::enumConnectionObjects(); - - $skip = (!isset($config['test']) || !isset($config['test2'])); - if ($skip) { - $this->markTestSkipped('Primary and secondary test databases not configured, skipping cross-database - join tests. To run theses tests defined $test and $test2 in your database configuration.' - ); - } - - $this->loadFixtures('Article', 'Tag', 'ArticlesTag', 'User', 'Comment'); - $TestModel = new Article(); - - $expected = array( - array( - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '2', - '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' - ), - array( - 'id' => '3', - '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' - ), - array( - 'id' => '4', - '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' - )), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ))), - array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => '5', - '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' - ), - array( - 'id' => '6', - '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' - )), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))), - array( - 'Article' => array( - 'id' => '3', - 'user_id' => '1', - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array(), - 'Tag' => array() - )); - $this->assertEquals($expected, $TestModel->find('all')); - - $db2 = ConnectionManager::getDataSource('test2'); - $this->fixtureManager->loadSingle('User', $db2); - $this->fixtureManager->loadSingle('Comment', $db2); - $this->assertEquals(3, $TestModel->find('count')); - - $TestModel->User->setDataSource('test2'); - $TestModel->Comment->setDataSource('test2'); - - foreach ($expected as $key => $value) { - unset($value['Comment'], $value['Tag']); - $expected[$key] = $value; - } - - $TestModel->recursive = 0; - $result = $TestModel->find('all'); - $this->assertEquals($expected, $result); - - foreach ($expected as $key => $value) { - unset($value['Comment'], $value['Tag']); - $expected[$key] = $value; - } - - $TestModel->recursive = 0; - $result = $TestModel->find('all'); - $this->assertEquals($expected, $result); - - $result = Set::extract($TestModel->User->find('all'), '{n}.User.id'); - $this->assertEquals(array('1', '2', '3', '4'), $result); - $this->assertEquals($expected, $TestModel->find('all')); - - $TestModel->Comment->unbindModel(array('hasOne' => array('Attachment'))); - $expected = array( - array( - 'Comment' => array( - 'id' => '1', - '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' - ), - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - ), - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array( - 'Comment' => array( - 'id' => '2', - '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' - ), - 'User' => array( - 'id' => '4', - 'user' => 'garrett', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', - 'updated' => '2007-03-17 01:24:31' - ), - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array( - 'Comment' => array( - 'id' => '3', - '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' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array( - 'Comment' => array( - 'id' => '4', - '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' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array( - 'Comment' => array( - 'id' => '5', - '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' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - )), - array( - 'Comment' => array( - 'id' => '6', - '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' - ), - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - ), - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ))); - $this->assertEquals($expected, $TestModel->Comment->find('all')); - } - -/** - * test HABM operations without clobbering existing records #275 - * - * @return void - */ - public function testHABTMKeepExisting() { - $this->loadFixtures('Site', 'Domain', 'DomainsSite'); - - $Site = new Site(); - $results = $Site->find('count'); - $expected = 3; - $this->assertEquals($expected, $results); - - $data = $Site->findById(1); - - // include api.cakephp.org - $data['Domain'] = array('Domain' => array(1, 2, 3)); - $Site->save($data); - - $Site->id = 1; - $results = $Site->read(); - $expected = 3; // 3 domains belonging to cakephp - $this->assertEquals($expected, count($results['Domain'])); - - $Site->id = 2; - $results = $Site->read(); - $expected = 2; // 2 domains belonging to markstory - $this->assertEquals($expected, count($results['Domain'])); - - $Site->id = 3; - $results = $Site->read(); - $expected = 2; - $this->assertEquals($expected, count($results['Domain'])); - $results['Domain'] = array('Domain' => array(7)); - $Site->save($results); // remove association from domain 6 - $results = $Site->read(); - $expected = 1; // only 1 domain left belonging to rchavik - $this->assertEquals($expected, count($results['Domain'])); - - // add deleted domain back - $results['Domain'] = array('Domain' => array(6, 7)); - $Site->save($results); - $results = $Site->read(); - $expected = 2; // 2 domains belonging to rchavik - $this->assertEquals($expected, count($results['Domain'])); - - $Site->DomainsSite->id = $results['Domain'][0]['DomainsSite']['id']; - $Site->DomainsSite->saveField('active', true); - - $results = $Site->Domain->DomainsSite->find('count', array( - 'conditions' => array( - 'DomainsSite.active' => true, - ), - )); - $expected = 5; - $this->assertEquals($expected, $results); - - // activate api.cakephp.org - $activated = $Site->DomainsSite->findByDomainId(3); - $activated['DomainsSite']['active'] = true; - $Site->DomainsSite->save($activated); - - $results = $Site->DomainsSite->find('count', array( - 'conditions' => array( - 'DomainsSite.active' => true, - ), - )); - $expected = 6; - $this->assertEquals($expected, $results); - - // remove 2 previously active domains, and leave $activated alone - $data = array( - 'Site' => array('id' => 1, 'name' => 'cakephp (modified)'), - 'Domain' => array( - 'Domain' => array(3), - ) - ); - $Site->create($data); - $Site->save($data); - - // tests that record is still identical prior to removal - $Site->id = 1; - $results = $Site->read(); - unset($results['Domain'][0]['DomainsSite']['updated']); - unset($activated['DomainsSite']['updated']); - $this->assertEquals($activated['DomainsSite'], $results['Domain'][0]['DomainsSite']); - } - -/** - * testHABTMKeepExistingAlternateDataFormat - * - * @return void - */ - public function testHABTMKeepExistingAlternateDataFormat() { - $this->loadFixtures('Site', 'Domain', 'DomainsSite'); - - $Site = new Site(); - - $expected = array( - array( - 'DomainsSite' => array( - 'id' => 1, - 'site_id' => 1, - 'domain_id' => 1, - 'active' => true, - 'created' => '2007-03-17 01:16:23' - ) - ), - array( - 'DomainsSite' => array( - 'id' => 2, - 'site_id' => 1, - 'domain_id' => 2, - 'active' => true, - 'created' => '2007-03-17 01:16:23' - ) - ) - ); - $result = $Site->DomainsSite->find('all', array( - 'conditions' => array('DomainsSite.site_id' => 1), - 'fields' => array( - 'DomainsSite.id', - 'DomainsSite.site_id', - 'DomainsSite.domain_id', - 'DomainsSite.active', - 'DomainsSite.created' - ), - 'order' => 'DomainsSite.id' - )); - $this->assertEquals($expected, $result); - - $time = date('Y-m-d H:i:s'); - $data = array( - 'Site' => array( - 'id' => 1 - ), - 'Domain' => array( - array( - 'site_id' => 1, - 'domain_id' => 3, - 'created' => $time, - ), - array( - 'id' => 2, - 'site_id' => 1, - 'domain_id' => 2 - ), - ) - ); - $Site->save($data); - $expected = array( - array( - 'DomainsSite' => array( - 'id' => 2, - 'site_id' => 1, - 'domain_id' => 2, - 'active' => true, - 'created' => '2007-03-17 01:16:23' - ) - ), - array( - 'DomainsSite' => array( - 'id' => 7, - 'site_id' => 1, - 'domain_id' => 3, - 'active' => false, - 'created' => $time - ) - ) - ); - $result = $Site->DomainsSite->find('all', array( - 'conditions' => array('DomainsSite.site_id' => 1), - 'fields' => array( - 'DomainsSite.id', - 'DomainsSite.site_id', - 'DomainsSite.domain_id', - 'DomainsSite.active', - 'DomainsSite.created' - ), - 'order' => 'DomainsSite.id' - )); - $this->assertEquals($expected, $result); - } - -/** - * test HABM operations without clobbering existing records #275 - * - * @return void - */ - public function testHABTMKeepExistingWithThreeDbs() { - $config = ConnectionManager::enumConnectionObjects(); - $this->skipIf($this->db instanceof Sqlite, 'This test is not compatible with Sqlite.'); - $this->skipIf( - !isset($config['test']) || !isset($config['test2']) || !isset($config['test_database_three']), - 'Primary, secondary, and tertiary test databases not configured, skipping test. To run this test define $test, $test2, and $test_database_three in your database configuration.' - ); - - $this->loadFixtures('Player', 'Guild', 'GuildsPlayer', 'Armor', 'ArmorsPlayer'); - $Player = ClassRegistry::init('Player'); - $Player->bindModel(array( - 'hasAndBelongsToMany' => array( - 'Armor' => array( - 'with' => 'ArmorsPlayer', - 'unique' => 'keepExisting', - ), - ), - ), false); - $this->assertEquals('test', $Player->useDbConfig); - $this->assertEquals('test', $Player->Guild->useDbConfig); - $this->assertEquals('test2', $Player->Guild->GuildsPlayer->useDbConfig); - $this->assertEquals('test2', $Player->Armor->useDbConfig); - $this->assertEquals('test_database_three', $Player->ArmorsPlayer->useDbConfig); - - $players = $Player->find('all'); - $this->assertEquals(4 , count($players)); - $playersGuilds = Set::extract('/Guild/GuildsPlayer', $players); - $this->assertEquals(3 , count($playersGuilds)); - $playersArmors = Set::extract('/Armor/ArmorsPlayer', $players); - $this->assertEquals(3 , count($playersArmors)); - unset($players); - - $larry = $Player->findByName('larry'); - $larrysArmor = Set::extract('/Armor/ArmorsPlayer', $larry); - $this->assertEquals(1 , count($larrysArmor)); - - $larry['Guild']['Guild'] = array(1, 3); // larry joins another guild - $larry['Armor']['Armor'] = array(2, 3); // purchases chainmail - $Player->save($larry); - unset($larry); - - $larry = $Player->findByName('larry'); - $larrysGuild = Set::extract('/Guild/GuildsPlayer', $larry); - $this->assertEquals(2 , count($larrysGuild)); - $larrysArmor = Set::extract('/Armor/ArmorsPlayer', $larry); - $this->assertEquals(2 , count($larrysArmor)); - - $larrysArmorsPlayersIds = Set::extract('/Armor/ArmorsPlayer/id', $larry); - - $Player->ArmorsPlayer->id = 3; - $Player->ArmorsPlayer->saveField('broken', true); // larry's cloak broke - - $larry = $Player->findByName('larry'); - $larrysArmor = Set::extract('/Armor/ArmorsPlayer', $larry); - $larrysCloak = Set::extract('/ArmorsPlayer[armor_id=3]', $larrysArmor); - $this->assertNotEmpty($larrysCloak); - $this->assertTrue($larrysCloak[0]['ArmorsPlayer']['broken']); // still broken - } - -/** - * testDisplayField method - * - * @return void - */ - public function testDisplayField() { - $this->loadFixtures('Post', 'Comment', 'Person', 'User'); - $Post = new Post(); - $Comment = new Comment(); - $Person = new Person(); - - $this->assertEquals('title', $Post->displayField); - $this->assertEquals('name', $Person->displayField); - $this->assertEquals('id', $Comment->displayField); - } - -/** - * testSchema method - * - * @return void - */ - public function testSchema() { - $Post = new Post(); - - $result = $Post->schema(); - $columns = array('id', 'author_id', 'title', 'body', 'published', 'created', 'updated'); - $this->assertEquals($columns, array_keys($result)); - - $types = array('integer', 'integer', 'string', 'text', 'string', 'datetime', 'datetime'); - $this->assertEquals(Set::extract(array_values($result), '{n}.type'), $types); - - $result = $Post->schema('body'); - $this->assertEquals('text', $result['type']); - $this->assertNull($Post->schema('foo')); - - $this->assertEquals($Post->getColumnTypes(), array_combine($columns, $types)); - } - -/** - * data provider for time tests. - * - * @return array - */ - public static function timeProvider() { - $db = ConnectionManager::getDataSource('test'); - $now = $db->expression('NOW()'); - return array( - // blank - array( - array('hour' => '', 'min' => '', 'meridian' => ''), - '' - ), - // missing hour - array( - array('hour' => '', 'min' => '00', 'meridian' => 'pm'), - '' - ), - // all blank - array( - array('hour' => '', 'min' => '', 'sec' => ''), - '' - ), - // set and empty merdian - array( - array('hour' => '1', 'min' => '00', 'meridian' => ''), - '' - ), - // midnight - array( - array('hour' => '12', 'min' => '0', 'meridian' => 'am'), - '00:00:00' - ), - array( - array('hour' => '00', 'min' => '00'), - '00:00:00' - ), - // 3am - array( - array('hour' => '03', 'min' => '04', 'sec' => '04'), - '03:04:04' - ), - array( - array('hour' => '3', 'min' => '4', 'sec' => '4'), - '03:04:04' - ), - array( - array('hour' => '03', 'min' => '4', 'sec' => '4'), - '03:04:04' - ), - array( - $now, - $now - ) - ); - } - -/** - * test deconstruct with time fields. - * - * @dataProvider timeProvider - * @return void - */ - public function testDeconstructFieldsTime($input, $result) { - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $this->loadFixtures('Apple'); - $TestModel = new Apple(); - - $data = array( - 'Apple' => array( - 'mytime' => $input - ) - ); - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('mytime' => $result)); - $this->assertEquals($expected, $TestModel->data); - } - -/** - * testDeconstructFields with datetime, timestamp, and date fields - * - * @return void - */ - public function testDeconstructFieldsDateTime() { - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $this->loadFixtures('Apple'); - $TestModel = new Apple(); - - //test null/empty values first - $data['Apple']['created']['year'] = ''; - $data['Apple']['created']['month'] = ''; - $data['Apple']['created']['day'] = ''; - $data['Apple']['created']['hour'] = ''; - $data['Apple']['created']['min'] = ''; - $data['Apple']['created']['sec'] = ''; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['date']['year'] = ''; - $data['Apple']['date']['month'] = ''; - $data['Apple']['date']['day'] = ''; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('date' => '')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['year'] = '2007'; - $data['Apple']['created']['month'] = '08'; - $data['Apple']['created']['day'] = '20'; - $data['Apple']['created']['hour'] = ''; - $data['Apple']['created']['min'] = ''; - $data['Apple']['created']['sec'] = ''; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '2007-08-20 00:00:00')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['year'] = '2007'; - $data['Apple']['created']['month'] = '08'; - $data['Apple']['created']['day'] = '20'; - $data['Apple']['created']['hour'] = '10'; - $data['Apple']['created']['min'] = '12'; - $data['Apple']['created']['sec'] = ''; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '2007-08-20 10:12:00')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['year'] = '2007'; - $data['Apple']['created']['month'] = ''; - $data['Apple']['created']['day'] = '12'; - $data['Apple']['created']['hour'] = '20'; - $data['Apple']['created']['min'] = ''; - $data['Apple']['created']['sec'] = ''; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['hour'] = '20'; - $data['Apple']['created']['min'] = '33'; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['hour'] = '20'; - $data['Apple']['created']['min'] = '33'; - $data['Apple']['created']['sec'] = '33'; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['hour'] = '13'; - $data['Apple']['created']['min'] = '00'; - $data['Apple']['date']['year'] = '2006'; - $data['Apple']['date']['month'] = '12'; - $data['Apple']['date']['day'] = '25'; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array( - 'Apple' => array( - 'created' => '', - 'date' => '2006-12-25' - )); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['year'] = '2007'; - $data['Apple']['created']['month'] = '08'; - $data['Apple']['created']['day'] = '20'; - $data['Apple']['created']['hour'] = '10'; - $data['Apple']['created']['min'] = '12'; - $data['Apple']['created']['sec'] = '09'; - $data['Apple']['date']['year'] = '2006'; - $data['Apple']['date']['month'] = '12'; - $data['Apple']['date']['day'] = '25'; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array( - 'Apple' => array( - 'created' => '2007-08-20 10:12:09', - 'date' => '2006-12-25' - )); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['year'] = '--'; - $data['Apple']['created']['month'] = '--'; - $data['Apple']['created']['day'] = '--'; - $data['Apple']['created']['hour'] = '--'; - $data['Apple']['created']['min'] = '--'; - $data['Apple']['created']['sec'] = '--'; - $data['Apple']['date']['year'] = '--'; - $data['Apple']['date']['month'] = '--'; - $data['Apple']['date']['day'] = '--'; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '', 'date' => '')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['created']['year'] = '2007'; - $data['Apple']['created']['month'] = '--'; - $data['Apple']['created']['day'] = '20'; - $data['Apple']['created']['hour'] = '10'; - $data['Apple']['created']['min'] = '12'; - $data['Apple']['created']['sec'] = '09'; - $data['Apple']['date']['year'] = '2006'; - $data['Apple']['date']['month'] = '12'; - $data['Apple']['date']['day'] = '25'; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('created' => '', 'date' => '2006-12-25')); - $this->assertEquals($expected, $TestModel->data); - - $data = array(); - $data['Apple']['date']['year'] = '2006'; - $data['Apple']['date']['month'] = '12'; - $data['Apple']['date']['day'] = '25'; - - $TestModel->data = null; - $TestModel->set($data); - $expected = array('Apple' => array('date' => '2006-12-25')); - $this->assertEquals($expected, $TestModel->data); - - $db = ConnectionManager::getDataSource('test'); - $data = array(); - $data['Apple']['modified'] = $db->expression('NOW()'); - $TestModel->data = null; - $TestModel->set($data); - $this->assertEquals($TestModel->data, $data); - } - -/** - * testTablePrefixSwitching method - * - * @return void - */ - public function testTablePrefixSwitching() { - ConnectionManager::create('database1', - array_merge($this->db->config, array('prefix' => 'aaa_') - )); - ConnectionManager::create('database2', - array_merge($this->db->config, array('prefix' => 'bbb_') - )); - - $db1 = ConnectionManager::getDataSource('database1'); - $db2 = ConnectionManager::getDataSource('database2'); - - $TestModel = new Apple(); - $TestModel->setDataSource('database1'); - $this->assertContains('aaa_apples', $this->db->fullTableName($TestModel)); - $this->assertContains('aaa_apples', $db1->fullTableName($TestModel)); - $this->assertContains('aaa_apples', $db2->fullTableName($TestModel)); - - $TestModel->setDataSource('database2'); - $this->assertContains('bbb_apples', $this->db->fullTableName($TestModel)); - $this->assertContains('bbb_apples', $db1->fullTableName($TestModel)); - $this->assertContains('bbb_apples', $db2->fullTableName($TestModel)); - - $TestModel = new Apple(); - $TestModel->tablePrefix = 'custom_'; - $this->assertContains('custom_apples', $this->db->fullTableName($TestModel)); - $TestModel->setDataSource('database1'); - $this->assertContains('custom_apples', $this->db->fullTableName($TestModel)); - $this->assertContains('custom_apples', $db1->fullTableName($TestModel)); - - $TestModel = new Apple(); - $TestModel->setDataSource('database1'); - $this->assertContains('aaa_apples', $this->db->fullTableName($TestModel)); - $TestModel->tablePrefix = ''; - $TestModel->setDataSource('database2'); - $this->assertContains('apples', $db2->fullTableName($TestModel)); - $this->assertContains('apples', $db1->fullTableName($TestModel)); - - $TestModel->tablePrefix = null; - $TestModel->setDataSource('database1'); - $this->assertContains('aaa_apples', $db2->fullTableName($TestModel)); - $this->assertContains('aaa_apples', $db1->fullTableName($TestModel)); - - $TestModel->tablePrefix = false; - $TestModel->setDataSource('database2'); - $this->assertContains('apples', $db2->fullTableName($TestModel)); - $this->assertContains('apples', $db1->fullTableName($TestModel)); - } - -/** - * Tests validation parameter order in custom validation methods - * - * @return void - */ - public function testInvalidAssociation() { - $TestModel = new ValidationTest1(); - $this->assertNull($TestModel->getAssociated('Foo')); - } - -/** - * testLoadModelSecondIteration method - * - * @return void - */ - public function testLoadModelSecondIteration() { - $this->loadFixtures('Apple', 'Message', 'Thread', 'Bid'); - $model = new ModelA(); - $this->assertInstanceOf('ModelA', $model); - - $this->assertInstanceOf('ModelB', $model->ModelB); - $this->assertInstanceOf('ModelD', $model->ModelB->ModelD); - - $this->assertInstanceOf('ModelC', $model->ModelC); - $this->assertInstanceOf('ModelD', $model->ModelC->ModelD); - } - -/** - * ensure that exists() does not persist between method calls reset on create - * - * @return void - */ - public function testResetOfExistsOnCreate() { - $this->loadFixtures('Article'); - $Article = new Article(); - $Article->id = 1; - $Article->saveField('title', 'Reset me'); - $Article->delete(); - $Article->id = 1; - $this->assertFalse($Article->exists()); - - $Article->create(); - $this->assertFalse($Article->exists()); - $Article->id = 2; - $Article->saveField('title', 'Staying alive'); - $result = $Article->read(null, 2); - $this->assertEquals('Staying alive', $result['Article']['title']); - } - -/** - * testUseTableFalseExistsCheck method - * - * @return void - */ - public function testUseTableFalseExistsCheck() { - $this->loadFixtures('Article'); - $Article = new Article(); - $Article->id = 1337; - $result = $Article->exists(); - $this->assertFalse($result); - - $Article->useTable = false; - $Article->id = null; - $result = $Article->exists(); - $this->assertFalse($result); - - // An article with primary key of '1' has been loaded by the fixtures. - $Article->useTable = false; - $Article->id = 1; - $result = $Article->exists(); - $this->assertTrue($result); - } - -/** - * testPluginAssociations method - * - * @return void - */ - public function testPluginAssociations() { - $this->loadFixtures('TestPluginArticle', 'User', 'TestPluginComment'); - $TestModel = new TestPluginArticle(); - - $result = $TestModel->find('all'); - $expected = array( - array( - 'TestPluginArticle' => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Plugin Article', - 'body' => 'First Plugin Article Body', - 'published' => 'Y', - 'created' => '2008-09-24 10:39:23', - 'updated' => '2008-09-24 10:41:31' - ), - 'User' => array( - 'id' => 1, - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'TestPluginComment' => array( - array( - 'id' => 1, - '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' - ), - array( - 'id' => 2, - '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' - ), - array( - 'id' => 3, - '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' - ), - array( - 'id' => 4, - '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' - ))), - array( - 'TestPluginArticle' => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Plugin Article', - 'body' => 'Second Plugin Article Body', - 'published' => 'Y', - 'created' => '2008-09-24 10:41:23', - 'updated' => '2008-09-24 10:43:31' - ), - 'User' => array( - 'id' => 3, - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'TestPluginComment' => array( - array( - 'id' => 5, - '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' - ), - array( - 'id' => 6, - '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' - ))), - array( - 'TestPluginArticle' => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Plugin Article', - 'body' => 'Third Plugin Article Body', - 'published' => 'Y', - 'created' => '2008-09-24 10:43:23', - 'updated' => '2008-09-24 10:45:31' - ), - 'User' => array( - 'id' => 1, - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'TestPluginComment' => array() - )); - - $this->assertEquals($expected, $result); - } - -/** - * Tests getAssociated method - * - * @return void - */ - public function testGetAssociated() { - $this->loadFixtures('Article', 'Tag'); - $Article = ClassRegistry::init('Article'); - - $assocTypes = array('hasMany', 'hasOne', 'belongsTo', 'hasAndBelongsToMany'); - foreach ($assocTypes as $type) { - $this->assertEquals($Article->getAssociated($type), array_keys($Article->{$type})); - } - - $Article->bindModel(array('hasMany' => array('Category'))); - $this->assertEquals(array('Comment', 'Category'), $Article->getAssociated('hasMany')); - - $results = $Article->getAssociated(); - $results = array_keys($results); - sort($results); - $this->assertEquals(array('Category', 'Comment', 'Tag', 'User'), $results); - - $Article->unbindModel(array('hasAndBelongsToMany' => array('Tag'))); - $this->assertEquals(array(), $Article->getAssociated('hasAndBelongsToMany')); - - $result = $Article->getAssociated('Category'); - $expected = array( - 'className' => 'Category', - 'foreignKey' => 'article_id', - 'conditions' => '', - 'fields' => '', - 'order' => '', - 'limit' => '', - 'offset' => '', - 'dependent' => '', - 'exclusive' => '', - 'finderQuery' => '', - 'counterQuery' => '', - 'association' => 'hasMany', - ); - $this->assertEquals($expected, $result); - } - -/** - * testAutoConstructAssociations method - * - * @return void - */ - public function testAutoConstructAssociations() { - $this->loadFixtures('User', 'ArticleFeatured', 'Featured', 'ArticleFeaturedsTags'); - $TestModel = new AssociationTest1(); - - $result = $TestModel->hasAndBelongsToMany; - $expected = array('AssociationTest2' => array( - 'unique' => false, - 'joinTable' => 'join_as_join_bs', - 'foreignKey' => false, - 'className' => 'AssociationTest2', - 'with' => 'JoinAsJoinB', - 'dynamicWith' => true, - 'associationForeignKey' => 'join_b_id', - 'conditions' => '', 'fields' => '', 'order' => '', 'limit' => '', 'offset' => '', - 'finderQuery' => '', 'deleteQuery' => '', 'insertQuery' => '' - )); - $this->assertEquals($expected, $result); - - $TestModel = new ArticleFeatured(); - $TestFakeModel = new ArticleFeatured(array('table' => false)); - - $expected = array( - 'User' => array( - 'className' => 'User', 'foreignKey' => 'user_id', - 'conditions' => '', 'fields' => '', 'order' => '', 'counterCache' => '' - ), - 'Category' => array( - 'className' => 'Category', 'foreignKey' => 'category_id', - 'conditions' => '', 'fields' => '', 'order' => '', 'counterCache' => '' - ) - ); - $this->assertSame($TestModel->belongsTo, $expected); - $this->assertSame($TestFakeModel->belongsTo, $expected); - - $this->assertEquals('User', $TestModel->User->name); - $this->assertEquals('User', $TestFakeModel->User->name); - $this->assertEquals('Category', $TestModel->Category->name); - $this->assertEquals('Category', $TestFakeModel->Category->name); - - $expected = array( - 'Featured' => array( - 'className' => 'Featured', - 'foreignKey' => 'article_featured_id', - 'conditions' => '', - 'fields' => '', - 'order' => '', - 'dependent' => '' - )); - - $this->assertSame($TestModel->hasOne, $expected); - $this->assertSame($TestFakeModel->hasOne, $expected); - - $this->assertEquals('Featured', $TestModel->Featured->name); - $this->assertEquals('Featured', $TestFakeModel->Featured->name); - - $expected = array( - 'Comment' => array( - 'className' => 'Comment', - 'dependent' => true, - 'foreignKey' => 'article_featured_id', - 'conditions' => '', - 'fields' => '', - 'order' => '', - 'limit' => '', - 'offset' => '', - 'exclusive' => '', - 'finderQuery' => '', - 'counterQuery' => '' - )); - - $this->assertSame($TestModel->hasMany, $expected); - $this->assertSame($TestFakeModel->hasMany, $expected); - - $this->assertEquals('Comment', $TestModel->Comment->name); - $this->assertEquals('Comment', $TestFakeModel->Comment->name); - - $expected = array( - 'Tag' => array( - 'className' => 'Tag', - 'joinTable' => 'article_featureds_tags', - 'with' => 'ArticleFeaturedsTag', - 'dynamicWith' => true, - 'foreignKey' => 'article_featured_id', - 'associationForeignKey' => 'tag_id', - 'conditions' => '', - 'fields' => '', - 'order' => '', - 'limit' => '', - 'offset' => '', - 'unique' => true, - 'finderQuery' => '', - 'deleteQuery' => '', - 'insertQuery' => '' - )); - - $this->assertSame($TestModel->hasAndBelongsToMany, $expected); - $this->assertSame($TestFakeModel->hasAndBelongsToMany, $expected); - - $this->assertEquals('Tag', $TestModel->Tag->name); - $this->assertEquals('Tag', $TestFakeModel->Tag->name); - } - -/** - * test creating associations with plugins. Ensure a double alias isn't created - * - * @return void - */ - public function testAutoConstructPluginAssociations() { - $Comment = ClassRegistry::init('TestPluginComment'); - - $this->assertEquals(2, count($Comment->belongsTo), 'Too many associations'); - $this->assertFalse(isset($Comment->belongsTo['TestPlugin.User'])); - $this->assertTrue(isset($Comment->belongsTo['User']), 'Missing association'); - $this->assertTrue(isset($Comment->belongsTo['TestPluginArticle']), 'Missing association'); - } - -/** - * test Model::__construct - * - * ensure that $actsAS and $findMethods are merged. - * - * @return void - */ - public function testConstruct() { - $this->loadFixtures('Post'); - - $TestModel = ClassRegistry::init('MergeVarPluginPost'); - $this->assertEquals(array('Containable' => null, 'Tree' => null), $TestModel->actsAs); - $this->assertTrue(isset($TestModel->Behaviors->Containable)); - $this->assertTrue(isset($TestModel->Behaviors->Tree)); - - $TestModel = ClassRegistry::init('MergeVarPluginComment'); - $expected = array('Containable' => array('some_settings')); - $this->assertEquals($expected, $TestModel->actsAs); - $this->assertTrue(isset($TestModel->Behaviors->Containable)); - } - -/** - * test Model::__construct - * - * ensure that $actsAS and $findMethods are merged. - * - * @return void - */ - public function testConstructWithAlternateDataSource() { - $TestModel = ClassRegistry::init(array( - 'class' => 'DoesntMatter', 'ds' => 'test', 'table' => false - )); - $this->assertEquals('test', $TestModel->useDbConfig); - - //deprecated but test it anyway - $NewVoid = new TheVoid(null, false, 'other'); - $this->assertEquals('other', $NewVoid->useDbConfig); - } - -/** - * testColumnTypeFetching method - * - * @return void - */ - public function testColumnTypeFetching() { - $model = new Test(); - $this->assertEquals('integer', $model->getColumnType('id')); - $this->assertEquals('text', $model->getColumnType('notes')); - $this->assertEquals('datetime', $model->getColumnType('updated')); - $this->assertEquals(null, $model->getColumnType('unknown')); - - $model = new Article(); - $this->assertEquals('datetime', $model->getColumnType('User.created')); - $this->assertEquals('integer', $model->getColumnType('Tag.id')); - $this->assertEquals('integer', $model->getColumnType('Article.id')); - } - -/** - * testHabtmUniqueKey method - * - * @return void - */ - public function testHabtmUniqueKey() { - $model = new Item(); - $this->assertFalse($model->hasAndBelongsToMany['Portfolio']['unique']); - } - -/** - * testIdentity method - * - * @return void - */ - public function testIdentity() { - $TestModel = new Test(); - $result = $TestModel->alias; - $expected = 'Test'; - $this->assertEquals($expected, $result); - - $TestModel = new TestAlias(); - $result = $TestModel->alias; - $expected = 'TestAlias'; - $this->assertEquals($expected, $result); - - $TestModel = new Test(array('alias' => 'AnotherTest')); - $result = $TestModel->alias; - $expected = 'AnotherTest'; - $this->assertEquals($expected, $result); - } - -/** - * testWithAssociation method - * - * @return void - */ - public function testWithAssociation() { - $this->loadFixtures('Something', 'SomethingElse', 'JoinThing'); - $TestModel = new Something(); - $result = $TestModel->SomethingElse->find('all'); - - $expected = array( - array( - 'SomethingElse' => array( - 'id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'Something' => array( - array( - 'id' => '3', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31', - 'JoinThing' => array( - 'id' => '3', - 'something_id' => '3', - 'something_else_id' => '1', - 'doomed' => true, - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - )))), - array( - 'SomethingElse' => array( - 'id' => '2', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'Something' => array( - array( - 'id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'JoinThing' => array( - 'id' => '1', - 'something_id' => '1', - 'something_else_id' => '2', - 'doomed' => true, - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )))), - array( - 'SomethingElse' => array( - 'id' => '3', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'Something' => array( - array( - 'id' => '2', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31', - 'JoinThing' => array( - 'id' => '2', - 'something_id' => '2', - 'something_else_id' => '3', - 'doomed' => false, - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ))))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all'); - $expected = array( - array( - 'Something' => array( - 'id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'SomethingElse' => array( - array( - 'id' => '2', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31', - 'JoinThing' => array( - 'doomed' => true, - 'something_id' => '1', - 'something_else_id' => '2' - )))), - array( - 'Something' => array( - 'id' => '2', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'SomethingElse' => array( - array( - 'id' => '3', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31', - 'JoinThing' => array( - 'doomed' => false, - 'something_id' => '2', - 'something_else_id' => '3' - )))), - array( - 'Something' => array( - 'id' => '3', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'SomethingElse' => array( - array( - 'id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'JoinThing' => array( - 'doomed' => true, - 'something_id' => '3', - 'something_else_id' => '1' - ))))); - $this->assertEquals($expected, $result); - - $result = $TestModel->findById(1); - $expected = array( - 'Something' => array( - 'id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'SomethingElse' => array( - array( - 'id' => '2', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31', - 'JoinThing' => array( - 'doomed' => true, - 'something_id' => '1', - 'something_else_id' => '2' - )))); - $this->assertEquals($expected, $result); - - $expected = $TestModel->findById(1); - $TestModel->set($expected); - $TestModel->save(); - $result = $TestModel->findById(1); - $this->assertEquals($expected, $result); - - $TestModel->hasAndBelongsToMany['SomethingElse']['unique'] = false; - $TestModel->create(array( - 'Something' => array('id' => 1), - 'SomethingElse' => array(3, array( - 'something_else_id' => 1, - 'doomed' => true - )))); - - $ts = date('Y-m-d H:i:s'); - $TestModel->save(); - - $TestModel->hasAndBelongsToMany['SomethingElse']['order'] = 'SomethingElse.id ASC'; - $result = $TestModel->findById(1); - $expected = array( - 'Something' => array( - 'id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23' - ), - 'SomethingElse' => array( - array( - 'id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'JoinThing' => array( - 'doomed' => true, - 'something_id' => '1', - 'something_else_id' => '1' - ) - ), - array( - 'id' => '2', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31', - 'JoinThing' => array( - 'doomed' => true, - 'something_id' => '1', - 'something_else_id' => '2' - ) - ), - array( - 'id' => '3', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31', - 'JoinThing' => array( - 'doomed' => false, - 'something_id' => '1', - 'something_else_id' => '3') - ) - ) - ); - $this->assertTrue($result['Something']['updated'] >= $ts); - unset($result['Something']['updated']); - $this->assertEquals($expected, $result); - } - -/** - * testFindSelfAssociations method - * - * @return void - */ - public function testFindSelfAssociations() { - $this->loadFixtures('Person'); - - $TestModel = new Person(); - $TestModel->recursive = 2; - $result = $TestModel->read(null, 1); - $expected = array( - 'Person' => array( - 'id' => 1, - 'name' => 'person', - 'mother_id' => 2, - 'father_id' => 3 - ), - 'Mother' => array( - 'id' => 2, - 'name' => 'mother', - 'mother_id' => 4, - 'father_id' => 5, - 'Mother' => array( - 'id' => 4, - 'name' => 'mother - grand mother', - 'mother_id' => 0, - 'father_id' => 0 - ), - 'Father' => array( - 'id' => 5, - 'name' => 'mother - grand father', - 'mother_id' => 0, - 'father_id' => 0 - )), - 'Father' => array( - 'id' => 3, - 'name' => 'father', - 'mother_id' => 6, - 'father_id' => 7, - 'Father' => array( - 'id' => 7, - 'name' => 'father - grand father', - 'mother_id' => 0, - 'father_id' => 0 - ), - 'Mother' => array( - 'id' => 6, - 'name' => 'father - grand mother', - 'mother_id' => 0, - 'father_id' => 0 - ))); - - $this->assertEquals($expected, $result); - - $TestModel->recursive = 3; - $result = $TestModel->read(null, 1); - $expected = array( - 'Person' => array( - 'id' => 1, - 'name' => 'person', - 'mother_id' => 2, - 'father_id' => 3 - ), - 'Mother' => array( - 'id' => 2, - 'name' => 'mother', - 'mother_id' => 4, - 'father_id' => 5, - 'Mother' => array( - 'id' => 4, - 'name' => 'mother - grand mother', - 'mother_id' => 0, - 'father_id' => 0, - 'Mother' => array(), - 'Father' => array()), - 'Father' => array( - 'id' => 5, - 'name' => 'mother - grand father', - 'mother_id' => 0, - 'father_id' => 0, - 'Father' => array(), - 'Mother' => array() - )), - 'Father' => array( - 'id' => 3, - 'name' => 'father', - 'mother_id' => 6, - 'father_id' => 7, - 'Father' => array( - 'id' => 7, - 'name' => 'father - grand father', - 'mother_id' => 0, - 'father_id' => 0, - 'Father' => array(), - 'Mother' => array() - ), - 'Mother' => array( - 'id' => 6, - 'name' => 'father - grand mother', - 'mother_id' => 0, - 'father_id' => 0, - 'Mother' => array(), - 'Father' => array() - ))); - - $this->assertEquals($expected, $result); - } - -/** - * testDynamicAssociations method - * - * @return void - */ - public function testDynamicAssociations() { - $this->loadFixtures('Article', 'Comment'); - $TestModel = new Article(); - - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = $TestModel->hasOne = array(); - $TestModel->hasMany['Comment'] = array_merge($TestModel->hasMany['Comment'], array( - 'foreignKey' => false, - 'conditions' => array('Comment.user_id =' => '2') - )); - $result = $TestModel->find('all'); - $expected = array( - array( - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - ))), - array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - ))), - array( - 'Article' => array( - 'id' => '3', - 'user_id' => '1', - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - )))); - - $this->assertEquals($expected, $result); - } - -/** - * testCreation method - * - * @return void - */ - public function testCreation() { - $this->loadFixtures('Article', 'ArticleFeaturedsTags', 'User', 'Featured'); - $TestModel = new Test(); - $result = $TestModel->create(); - $expected = array('Test' => array('notes' => 'write some notes here')); - $this->assertEquals($expected, $result); - $TestModel = new User(); - $result = $TestModel->schema(); - - if (isset($this->db->columns['primary_key']['length'])) { - $intLength = $this->db->columns['primary_key']['length']; - } elseif (isset($this->db->columns['integer']['length'])) { - $intLength = $this->db->columns['integer']['length']; - } else { - $intLength = 11; - } - foreach (array('collate', 'charset', 'comment') as $type) { - foreach ($result as $i => $r) { - unset($result[$i][$type]); - } - } - - $expected = array( - 'id' => array( - 'type' => 'integer', - 'null' => false, - 'default' => null, - 'length' => $intLength, - 'key' => 'primary' - ), - 'user' => array( - 'type' => 'string', - 'null' => true, - 'default' => '', - 'length' => 255 - ), - 'password' => array( - 'type' => 'string', - 'null' => true, - 'default' => '', - 'length' => 255 - ), - 'created' => array( - 'type' => 'datetime', - 'null' => true, - 'default' => null, - 'length' => null - ), - 'updated' => array( - 'type' => 'datetime', - 'null' => true, - 'default' => null, - 'length' => null - )); - - $this->assertEquals($expected, $result); - - $TestModel = new Article(); - $result = $TestModel->create(); - $expected = array('Article' => array('published' => 'N')); - $this->assertEquals($expected, $result); - - $FeaturedModel = new Featured(); - $data = array( - 'article_featured_id' => 1, - 'category_id' => 1, - 'published_date' => array( - 'year' => 2008, - 'month' => 06, - 'day' => 11 - ), - 'end_date' => array( - 'year' => 2008, - 'month' => 06, - 'day' => 20 - )); - - $expected = array( - 'Featured' => array( - 'article_featured_id' => 1, - 'category_id' => 1, - 'published_date' => '2008-06-11 00:00:00', - 'end_date' => '2008-06-20 00:00:00' - )); - - $this->assertEquals($expected, $FeaturedModel->create($data)); - - $data = array( - 'published_date' => array( - 'year' => 2008, - 'month' => 06, - 'day' => 11 - ), - 'end_date' => array( - 'year' => 2008, - 'month' => 06, - 'day' => 20 - ), - 'article_featured_id' => 1, - 'category_id' => 1 - ); - - $expected = array( - 'Featured' => array( - 'published_date' => '2008-06-11 00:00:00', - 'end_date' => '2008-06-20 00:00:00', - 'article_featured_id' => 1, - 'category_id' => 1 - )); - - $this->assertEquals($expected, $FeaturedModel->create($data)); - } - -/** - * testEscapeField to prove it escapes the field well even when it has part of the alias on it - * - * @return void - */ - public function testEscapeField() { - $TestModel = new Test(); - $db = $TestModel->getDataSource(); - - $result = $TestModel->escapeField('test_field'); - $expected = $db->name('Test.test_field'); - $this->assertEquals($expected, $result); - - $result = $TestModel->escapeField('TestField'); - $expected = $db->name('Test.TestField'); - $this->assertEquals($expected, $result); - - $result = $TestModel->escapeField('DomainHandle', 'Domain'); - $expected = $db->name('Domain.DomainHandle'); - $this->assertEquals($expected, $result); - - ConnectionManager::create('mock', array('datasource' => 'DboMock')); - $TestModel->setDataSource('mock'); - $db = $TestModel->getDataSource(); - - $result = $TestModel->escapeField('DomainHandle', 'Domain'); - $expected = $db->name('Domain.DomainHandle'); - $this->assertEquals($expected, $result); - ConnectionManager::drop('mock'); - } - -/** - * testGetID - * - * @return void - */ - public function testGetID() { - $TestModel = new Test(); - - $result = $TestModel->getID(); - $this->assertFalse($result); - - $TestModel->id = 9; - $result = $TestModel->getID(); - $this->assertEquals(9, $result); - - $TestModel->id = array(10, 9, 8, 7); - $result = $TestModel->getID(2); - $this->assertEquals(8, $result); - - $TestModel->id = array(array(), 1, 2, 3); - $result = $TestModel->getID(); - $this->assertFalse($result); - } - -/** - * test that model->hasMethod checks self and behaviors. - * - * @return void - */ - public function testHasMethod() { - $Article = new Article(); - $Article->Behaviors = $this->getMock('BehaviorCollection'); - - $Article->Behaviors->expects($this->at(0)) - ->method('hasMethod') - ->will($this->returnValue(true)); - - $Article->Behaviors->expects($this->at(1)) - ->method('hasMethod') - ->will($this->returnValue(false)); - - $this->assertTrue($Article->hasMethod('find')); - - $this->assertTrue($Article->hasMethod('pass')); - $this->assertFalse($Article->hasMethod('fail')); - } - -/** - * testMultischemaFixture - * - * @return void - */ - public function testMultischemaFixture() { - $config = ConnectionManager::enumConnectionObjects(); - $this->skipIf($this->db instanceof Sqlite, 'This test is not compatible with Sqlite.'); - $this->skipIf(!isset($config['test']) || !isset($config['test2']), - 'Primary and secondary test databases not configured, skipping cross-database join tests. To run these tests define $test and $test2 in your database configuration.' - ); - - $this->loadFixtures('Player', 'Guild', 'GuildsPlayer'); - - $Player = ClassRegistry::init('Player'); - $this->assertEquals('test', $Player->useDbConfig); - $this->assertEquals('test', $Player->Guild->useDbConfig); - $this->assertEquals('test2', $Player->Guild->GuildsPlayer->useDbConfig); - $this->assertEquals('test2', $Player->GuildsPlayer->useDbConfig); - - $players = $Player->find('all', array('recursive' => -1)); - $guilds = $Player->Guild->find('all', array('recursive' => -1)); - $guildsPlayers = $Player->GuildsPlayer->find('all', array('recursive' => -1)); - - $this->assertEquals(true, count($players) > 1); - $this->assertEquals(true, count($guilds) > 1); - $this->assertEquals(true, count($guildsPlayers) > 1); - } - -/** - * testMultischemaFixtureWithThreeDatabases, three databases - * - * @return void - */ - public function testMultischemaFixtureWithThreeDatabases() { - $config = ConnectionManager::enumConnectionObjects(); - $this->skipIf($this->db instanceof Sqlite, 'This test is not compatible with Sqlite.'); - $this->skipIf( - !isset($config['test']) || !isset($config['test2']) || !isset($config['test_database_three']), - 'Primary, secondary, and tertiary test databases not configured, skipping test. To run this test define $test, $test2, and $test_database_three in your database configuration.' - ); - - $this->loadFixtures('Player', 'Guild', 'GuildsPlayer', 'Armor', 'ArmorsPlayer'); - - $Player = ClassRegistry::init('Player'); - $Player->bindModel(array( - 'hasAndBelongsToMany' => array( - 'Armor' => array( - 'with' => 'ArmorsPlayer', - ), - ), - ), false); - $this->assertEquals('test', $Player->useDbConfig); - $this->assertEquals('test', $Player->Guild->useDbConfig); - $this->assertEquals('test2', $Player->Guild->GuildsPlayer->useDbConfig); - $this->assertEquals('test2', $Player->GuildsPlayer->useDbConfig); - $this->assertEquals('test2', $Player->Armor->useDbConfig); - $this->assertEquals('test_database_three', $Player->Armor->ArmorsPlayer->useDbConfig); - $this->assertEquals('test', $Player->getDataSource()->configKeyName); - $this->assertEquals('test', $Player->Guild->getDataSource()->configKeyName); - $this->assertEquals('test2', $Player->GuildsPlayer->getDataSource()->configKeyName); - $this->assertEquals('test2', $Player->Armor->getDataSource()->configKeyName); - $this->assertEquals('test_database_three', $Player->Armor->ArmorsPlayer->getDataSource()->configKeyName); - - $players = $Player->find('all', array('recursive' => -1)); - $guilds = $Player->Guild->find('all', array('recursive' => -1)); - $guildsPlayers = $Player->GuildsPlayer->find('all', array('recursive' => -1)); - $armorsPlayers = $Player->ArmorsPlayer->find('all', array('recursive' => -1)); - - $this->assertEquals(true, count($players) > 1); - $this->assertEquals(true, count($guilds) > 1); - $this->assertEquals(true, count($guildsPlayers) > 1); - $this->assertEquals(true, count($armorsPlayers) > 1); - } - -/** - * Tests that calling schema() on a model that is not supposed to use a table - * does not trigger any calls on any datasource - * - * @return void - **/ - public function testSchemaNoDB() { - $model = $this->getMock('Article', array('getDataSource')); - $model->useTable = false; - $model->expects($this->never())->method('getDataSource'); - $this->assertEmpty($model->schema()); - } -} diff --git a/lib/Cake/Test/Case/Model/ModelReadTest.php b/lib/Cake/Test/Case/Model/ModelReadTest.php deleted file mode 100644 index df45ce514ce..00000000000 --- a/lib/Cake/Test/Case/Model/ModelReadTest.php +++ /dev/null @@ -1,7848 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -require_once dirname(__FILE__) . DS . 'ModelTestBase.php'; -/** - * ModelReadTest - * - * @package Cake.Test.Case.Model - */ -class ModelReadTest extends BaseModelTest { - -/** - * testExists function - * @retun void - */ - public function testExists() { - $this->loadFixtures('User'); - $TestModel = new User(); - - $this->assertTrue($TestModel->exists(1)); - - $TestModel->id = 2; - $this->assertTrue($TestModel->exists()); - - $TestModel->delete(); - $this->assertFalse($TestModel->exists()); - - $this->assertFalse($TestModel->exists(2)); - } - -/** - * testFetchingNonUniqueFKJoinTableRecords() - * - * Tests if the results are properly returned in the case there are non-unique FK's - * in the join table but another fields value is different. For example: - * something_id | something_else_id | doomed = 1 - * something_id | something_else_id | doomed = 0 - * Should return both records and not just one. - * - * @return void - */ - public function testFetchingNonUniqueFKJoinTableRecords() { - $this->loadFixtures('Something', 'SomethingElse', 'JoinThing'); - $Something = new Something(); - - $joinThingData = array( - 'JoinThing' => array( - 'something_id' => 1, - 'something_else_id' => 2, - 'doomed' => '0', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ) - ); - - $Something->JoinThing->create($joinThingData); - $Something->JoinThing->save(); - - $result = $Something->JoinThing->find('all', array('conditions' => array('something_else_id' => 2))); - - $this->assertEquals(true, $result[0]['JoinThing']['doomed']); - $this->assertEquals(false, $result[1]['JoinThing']['doomed']); - - $result = $Something->find('first'); - - $this->assertEquals(2, count($result['SomethingElse'])); - - $doomed = Set::extract('/JoinThing/doomed', $result['SomethingElse']); - $this->assertTrue(in_array(true, $doomed)); - $this->assertTrue(in_array(false, $doomed)); - } - -/** - * testGroupBy method - * - * These tests will never pass with Postgres or Oracle as all fields in a select must be - * part of an aggregate function or in the GROUP BY statement. - * - * @return void - */ - public function testGroupBy() { - $isStrictGroupBy = $this->db instanceof Postgres || $this->db instanceof Sqlite || $this->db instanceof Oracle || $this->db instanceof Sqlserver; - $message = 'Postgres, Oracle, SQLite and SQL Server have strict GROUP BY and are incompatible with this test.'; - - $this->skipIf($isStrictGroupBy, $message); - - $this->loadFixtures('Project', 'Product', 'Thread', 'Message', 'Bid'); - $Thread = new Thread(); - $Product = new Product(); - - $result = $Thread->find('all', array( - 'group' => 'Thread.project_id', - 'order' => 'Thread.id ASC' - )); - - $expected = array( - array( - 'Thread' => array( - 'id' => 1, - 'project_id' => 1, - 'name' => 'Project 1, Thread 1' - ), - 'Project' => array( - 'id' => 1, - 'name' => 'Project 1' - ), - 'Message' => array( - array( - 'id' => 1, - 'thread_id' => 1, - 'name' => 'Thread 1, Message 1' - ))), - array( - 'Thread' => array( - 'id' => 3, - 'project_id' => 2, - 'name' => 'Project 2, Thread 1' - ), - 'Project' => array( - 'id' => 2, - 'name' => 'Project 2' - ), - 'Message' => array( - array( - 'id' => 3, - 'thread_id' => 3, - 'name' => 'Thread 3, Message 1' - )))); - $this->assertEquals($expected, $result); - - $rows = $Thread->find('all', array( - 'group' => 'Thread.project_id', - 'fields' => array('Thread.project_id', 'COUNT(*) AS total') - )); - $result = array(); - foreach ($rows as $row) { - $result[$row['Thread']['project_id']] = $row[0]['total']; - } - $expected = array( - 1 => 2, - 2 => 1 - ); - $this->assertEquals($expected, $result); - - $rows = $Thread->find('all', array( - 'group' => 'Thread.project_id', - 'fields' => array('Thread.project_id', 'COUNT(*) AS total'), - 'order' => 'Thread.project_id' - )); - $result = array(); - foreach ($rows as $row) { - $result[$row['Thread']['project_id']] = $row[0]['total']; - } - $expected = array( - 1 => 2, - 2 => 1 - ); - $this->assertEquals($expected, $result); - - $result = $Thread->find('all', array( - 'conditions' => array('Thread.project_id' => 1), - 'group' => 'Thread.project_id' - )); - $expected = array( - array( - 'Thread' => array( - 'id' => 1, - 'project_id' => 1, - 'name' => 'Project 1, Thread 1' - ), - 'Project' => array( - 'id' => 1, - 'name' => 'Project 1' - ), - 'Message' => array( - array( - 'id' => 1, - 'thread_id' => 1, - 'name' => 'Thread 1, Message 1' - )))); - $this->assertEquals($expected, $result); - - $result = $Thread->find('all', array( - 'conditions' => array('Thread.project_id' => 1), - 'group' => 'Thread.project_id, Project.id' - )); - $this->assertEquals($expected, $result); - - $result = $Thread->find('all', array( - 'conditions' => array('Thread.project_id' => 1), - 'group' => 'project_id' - )); - $this->assertEquals($expected, $result); - - $result = $Thread->find('all', array( - 'conditions' => array('Thread.project_id' => 1), - 'group' => array('project_id') - )); - $this->assertEquals($expected, $result); - - $result = $Thread->find('all', array( - 'conditions' => array('Thread.project_id' => 1), - 'group' => array('project_id', 'Project.id') - )); - $this->assertEquals($expected, $result); - - $result = $Thread->find('all', array( - 'conditions' => array('Thread.project_id' => 1), - 'group' => array('Thread.project_id', 'Project.id') - )); - $this->assertEquals($expected, $result); - - $expected = array( - array('Product' => array('type' => 'Clothing'), array('price' => 32)), - array('Product' => array('type' => 'Food'), array('price' => 9)), - array('Product' => array('type' => 'Music'), array('price' => 4)), - array('Product' => array('type' => 'Toy'), array('price' => 3)) - ); - $result = $Product->find('all',array( - 'fields' => array('Product.type', 'MIN(Product.price) as price'), - 'group' => 'Product.type', - 'order' => 'Product.type ASC' - )); - $this->assertEquals($expected, $result); - - $result = $Product->find('all', array( - 'fields' => array('Product.type', 'MIN(Product.price) as price'), - 'group' => array('Product.type'), - 'order' => 'Product.type ASC')); - $this->assertEquals($expected, $result); - } - -/** - * testOldQuery method - * - * @return void - */ - public function testOldQuery() { - $this->loadFixtures('Article', 'User', 'Tag', 'ArticlesTag', 'Comment', 'Attachment'); - $Article = new Article(); - - $query = 'SELECT title FROM '; - $query .= $this->db->fullTableName('articles'); - $query .= ' WHERE ' . $this->db->fullTableName('articles') . '.id IN (1,2)'; - - $results = $Article->query($query); - $this->assertTrue(is_array($results)); - $this->assertEquals(2, count($results)); - - $query = 'SELECT title, body FROM '; - $query .= $this->db->fullTableName('articles'); - $query .= ' WHERE ' . $this->db->fullTableName('articles') . '.id = 1'; - - $results = $Article->query($query, false); - $this->assertFalse($this->db->getQueryCache($query)); - $this->assertTrue(is_array($results)); - - $query = 'SELECT title, id FROM '; - $query .= $this->db->fullTableName('articles'); - $query .= ' WHERE ' . $this->db->fullTableName('articles'); - $query .= '.published = ' . $this->db->value('Y'); - - $results = $Article->query($query, true); - $result = $this->db->getQueryCache($query); - $this->assertFalse(empty($result)); - $this->assertTrue(is_array($results)); - } - -/** - * testPreparedQuery method - * - * @return void - */ - public function testPreparedQuery() { - $this->loadFixtures('Article', 'User', 'Tag', 'ArticlesTag'); - $Article = new Article(); - - $query = 'SELECT title, published FROM '; - $query .= $this->db->fullTableName('articles'); - $query .= ' WHERE ' . $this->db->fullTableName('articles'); - $query .= '.id = ? AND ' . $this->db->fullTableName('articles') . '.published = ?'; - - $params = array(1, 'Y'); - $result = $Article->query($query, $params); - $expected = array( - '0' => array( - $this->db->fullTableName('articles', false, false) => array( - 'title' => 'First Article', 'published' => 'Y') - )); - - if (isset($result[0][0])) { - $expected[0][0] = $expected[0][$this->db->fullTableName('articles', false, false)]; - unset($expected[0][$this->db->fullTableName('articles', false, false)]); - } - - $this->assertEquals($expected, $result); - $result = $this->db->getQueryCache($query, $params); - $this->assertFalse(empty($result)); - - $query = 'SELECT id, created FROM '; - $query .= $this->db->fullTableName('articles'); - $query .= ' WHERE ' . $this->db->fullTableName('articles') . '.title = ?'; - - $params = array('First Article'); - $result = $Article->query($query, $params, false); - $this->assertTrue(is_array($result)); - $this->assertTrue( - isset($result[0][$this->db->fullTableName('articles', false, false)]) - || isset($result[0][0]) - ); - $result = $this->db->getQueryCache($query, $params); - $this->assertTrue(empty($result)); - - $query = 'SELECT title FROM '; - $query .= $this->db->fullTableName('articles'); - $query .= ' WHERE ' . $this->db->fullTableName('articles') . '.title LIKE ?'; - - $params = array('%First%'); - $result = $Article->query($query, $params); - $this->assertTrue(is_array($result)); - $this->assertTrue( - isset($result[0][$this->db->fullTableName('articles', false, false)]['title']) - || isset($result[0][0]['title']) - ); - - //related to ticket #5035 - $query = 'SELECT title FROM '; - $query .= $this->db->fullTableName('articles') . ' WHERE title = ? AND published = ?'; - $params = array('First? Article', 'Y'); - $Article->query($query, $params); - - $result = $this->db->getQueryCache($query, $params); - $this->assertFalse($result === false); - } - -/** - * testParameterMismatch method - * - * @expectedException PDOException - * @return void - */ - public function testParameterMismatch() { - $this->skipIf($this->db instanceof Sqlite, 'Sqlite does not accept real prepared statements, no way to check this'); - $this->loadFixtures('Article', 'User', 'Tag', 'ArticlesTag'); - $Article = new Article(); - - $query = 'SELECT * FROM ' . $this->db->fullTableName('articles'); - $query .= ' WHERE ' . $this->db->fullTableName('articles'); - $query .= '.published = ? AND ' . $this->db->fullTableName('articles') . '.user_id = ?'; - $params = array('Y'); - - $result = $Article->query($query, $params); - } - -/** - * testVeryStrangeUseCase method - * - * @expectedException PDOException - * @return void - */ - public function testVeryStrangeUseCase() { - $this->loadFixtures('Article', 'User', 'Tag', 'ArticlesTag'); - $Article = new Article(); - - $query = 'SELECT * FROM ? WHERE ? = ? AND ? = ?'; - $param = array( - $this->db->fullTableName('articles'), - $this->db->fullTableName('articles') . '.user_id', '3', - $this->db->fullTableName('articles') . '.published', 'Y' - ); - - $result = $Article->query($query, $param); - } - -/** - * testRecursiveUnbind method - * - * @return void - */ - public function testRecursiveUnbind() { - $this->skipIf($this->db instanceof Sqlserver, 'The test of testRecursiveUnbind test is not compatible with SQL Server, because it check for time columns.'); - - $this->loadFixtures('Apple', 'Sample'); - $TestModel = new Apple(); - $TestModel->recursive = 2; - - $result = $TestModel->find('all'); - $expected = array( - array( - 'Apple' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2', - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - )), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 1, - 'apple_id' => 3, - 'name' => 'sample1' - )), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3' - ), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 1, - 'apple_id' => 3, - 'name' => 'sample1', - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - )), - 'Child' => array() - ), - array( - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', 'mytime' => '22:57:17'), - 'Sample' => array('id' => 2, 'apple_id' => 2, 'name' => 'sample2'), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3', - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - )), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4' - ), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4', - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - )), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4' - ), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3' - ), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Sample' => array() - ))), - array( - 'Apple' => array( - 'id' => 7, - 'apple_id' => 6, - 'color' => - 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ), - 'Child' => array())); - $this->assertEquals($expected, $result); - - $result = $TestModel->Parent->unbindModel(array('hasOne' => array('Sample'))); - $this->assertTrue($result); - - $result = $TestModel->find('all'); - $expected = array( - array( - 'Apple' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17'), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2', - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - )), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', 'modified' => - '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 1, - 'apple_id' => 3, - 'name' => 'sample1' - )), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3' - ), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 1, - 'apple_id' => 3, - 'name' => 'sample1', - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - )), - 'Child' => array() - ), - array( - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3', - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - )), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4', - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - )), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4' - ), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))))), - array( - 'Apple' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Sample' => array() - ))), - array( - 'Apple' => array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ), - 'Child' => array() - )); - - $this->assertEquals($expected, $result); - - $result = $TestModel->Parent->unbindModel(array('hasOne' => array('Sample'))); - $this->assertTrue($result); - - $result = $TestModel->unbindModel(array('hasMany' => array('Child'))); - $this->assertTrue($result); - - $result = $TestModel->find('all'); - $expected = array( - array( - 'Apple' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - )), - array( - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2', - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 1, - 'apple_id' => 3, - 'name' => 'sample1', - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3', - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4', - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - )), - array( - 'Apple' => array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ))); - - $this->assertEquals($expected, $result); - - $result = $TestModel->unbindModel(array('hasMany' => array('Child'))); - $this->assertTrue($result); - - $result = $TestModel->Sample->unbindModel(array('belongsTo' => array('Apple'))); - $this->assertTrue($result); - - $result = $TestModel->find('all'); - $expected = array( - array( - 'Apple' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - )), - array( - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - )), - array( - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 1, - 'apple_id' => 3, - 'name' => 'sample1' - )), - array( - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3' - )), - array( - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4' - ), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4' - )), - array( - 'Apple' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3' - ), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - )), - array( - 'Apple' => array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17', - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->Parent->unbindModel(array('belongsTo' => array('Parent'))); - $this->assertTrue($result); - - $result = $TestModel->unbindModel(array('hasMany' => array('Child'))); - $this->assertTrue($result); - - $result = $TestModel->find('all'); - $expected = array( - array( - 'Apple' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - )), - array( - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17', - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2', - 'Apple' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 1, - 'apple_id' => 3, - 'name' => 'sample1', - 'Apple' => array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 2, - 'apple_id' => 1, - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17', - 'Sample' => array( - 'id' => 2, - 'apple_id' => 2, - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => 1, - 'apple_id' => 2, - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => 3, - 'apple_id' => 2, - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3', - 'Apple' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => - '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17', - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4' - ), - 'Child' => array( - array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => 4, - 'apple_id' => 5, - 'name' => 'sample4', - 'Apple' => array( - 'id' => 5, - 'apple_id' => 5, - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17'), - 'Parent' => array( - 'id' => 4, - 'apple_id' => 2, - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17', - 'Sample' => array( - 'id' => 3, - 'apple_id' => 4, - 'name' => 'sample3' - ), - 'Child' => array( - array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - )), - array( - 'Apple' => array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => 6, - 'apple_id' => 4, - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17', - 'Sample' => array(), - 'Child' => array( - array( - 'id' => 7, - 'apple_id' => 6, - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', 'modified' => - '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))), - 'Sample' => array( - 'id' => '', - 'apple_id' => '', - 'name' => '' - ))); - $this->assertEquals($expected, $result); - } - -/** - * testSelfAssociationAfterFind method - * - * @return void - */ - public function testSelfAssociationAfterFind() { - $this->loadFixtures('Apple', 'Sample'); - $afterFindModel = new NodeAfterFind(); - $afterFindModel->recursive = 3; - $afterFindData = $afterFindModel->find('all'); - - $duplicateModel = new NodeAfterFind(); - $duplicateModel->recursive = 3; - $duplicateModelData = $duplicateModel->find('all'); - - $noAfterFindModel = new NodeNoAfterFind(); - $noAfterFindModel->recursive = 3; - $noAfterFindData = $noAfterFindModel->find('all'); - - $this->assertFalse($afterFindModel == $noAfterFindModel); - $this->assertEquals($afterFindData, $noAfterFindData); - } - -/** - * testFindThreadedNoParent method - * - * @return void - */ - public function testFindThreadedNoParent() { - $this->loadFixtures('Apple', 'Sample'); - $Apple = new Apple(); - $result = $Apple->find('threaded'); - $result = Set::extract($result, '{n}.children'); - $expected = array(array(), array(), array(), array(), array(), array(), array()); - $this->assertEquals($expected, $result); - } - -/** - * testFindThreaded method - * - * @return void - */ - public function testFindThreaded() { - $this->loadFixtures('Person'); - $Model = new Person(); - $Model->recursive = -1; - $result = $Model->find('threaded'); - $result = Set::extract($result, '{n}.children'); - $expected = array(array(), array(), array(), array(), array(), array(), array()); - $this->assertEquals($expected, $result); - - $result = $Model->find('threaded', array('parent' => 'mother_id')); - $expected = array( - array( - 'Person' => array( - 'id' => '4', - 'name' => 'mother - grand mother', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array( - array( - 'Person' => array( - 'id' => '2', - 'name' => 'mother', - 'mother_id' => '4', - 'father_id' => '5' - ), - 'children' => array( - array( - 'Person' => array( - 'id' => '1', - 'name' => 'person', - 'mother_id' => '2', - 'father_id' => '3' - ), - 'children' => array() - ) - ) - ) - ) - ), - array( - 'Person' => array( - 'id' => '5', - 'name' => 'mother - grand father', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array() - ), - array( - 'Person' => array( - 'id' => '6', - 'name' => 'father - grand mother', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array( - array( - 'Person' => array( - 'id' => '3', - 'name' => 'father', - 'mother_id' => '6', - 'father_id' => '7' - ), - 'children' => array() - ) - ) - ), - array( - 'Person' => array( - 'id' => '7', - 'name' => 'father - grand father', - 'mother_id' => '0', - 'father_id' => '0' - ), - 'children' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindAllThreaded method - * - * @return void - */ - public function testFindAllThreaded() { - $this->loadFixtures('Category'); - $TestModel = new Category(); - - $result = $TestModel->find('threaded'); - $expected = array( - array( - 'Category' => array( - 'id' => '1', - 'parent_id' => '0', - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '2', - 'parent_id' => '1', - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array('Category' => array( - 'id' => '7', - 'parent_id' => '2', - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array()), - array('Category' => array( - 'id' => '8', - 'parent_id' => '2', - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array())) - ), - array( - 'Category' => array( - 'id' => '3', - 'parent_id' => '1', - 'name' => 'Category 1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ) - ) - ), - array( - 'Category' => array( - 'id' => '4', - 'parent_id' => '0', - 'name' => 'Category 2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ), - array( - 'Category' => array( - 'id' => '5', - 'parent_id' => '0', - 'name' => 'Category 3', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '6', - 'parent_id' => '5', - 'name' => 'Category 3.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('threaded', array( - 'conditions' => array('Category.name LIKE' => 'Category 1%') - )); - - $expected = array( - array( - 'Category' => array( - 'id' => '1', - 'parent_id' => '0', - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '2', - 'parent_id' => '1', - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array('Category' => array( - 'id' => '7', - 'parent_id' => '2', - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array()), - array('Category' => array( - 'id' => '8', - 'parent_id' => '2', - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array())) - ), - array( - 'Category' => array( - 'id' => '3', - 'parent_id' => '1', - 'name' => 'Category 1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('threaded', array( - 'fields' => 'id, parent_id, name' - )); - - $expected = array( - array( - 'Category' => array( - 'id' => '1', - 'parent_id' => '0', - 'name' => 'Category 1' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '2', - 'parent_id' => '1', - 'name' => 'Category 1.1' - ), - 'children' => array( - array('Category' => array( - 'id' => '7', - 'parent_id' => '2', - 'name' => 'Category 1.1.1'), - 'children' => array()), - array('Category' => array( - 'id' => '8', - 'parent_id' => '2', - 'name' => 'Category 1.1.2'), - 'children' => array())) - ), - array( - 'Category' => array( - 'id' => '3', - 'parent_id' => '1', - 'name' => 'Category 1.2' - ), - 'children' => array() - ) - ) - ), - array( - 'Category' => array( - 'id' => '4', - 'parent_id' => '0', - 'name' => 'Category 2' - ), - 'children' => array() - ), - array( - 'Category' => array( - 'id' => '5', - 'parent_id' => '0', - 'name' => 'Category 3' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '6', - 'parent_id' => '5', - 'name' => 'Category 3.1' - ), - 'children' => array() - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('threaded', array('order' => 'id DESC')); - - $expected = array( - array( - 'Category' => array( - 'id' => 5, - 'parent_id' => 0, - 'name' => 'Category 3', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => 6, - 'parent_id' => 5, - 'name' => 'Category 3.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ) - ) - ), - array( - 'Category' => array( - 'id' => 4, - 'parent_id' => 0, - 'name' => 'Category 2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ), - array( - 'Category' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => 3, - 'parent_id' => 1, - 'name' => 'Category 1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ), - array( - 'Category' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array('Category' => array( - 'id' => '8', - 'parent_id' => '2', - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array()), - array('Category' => array( - 'id' => '7', - 'parent_id' => '2', - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array())) - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('threaded', array( - 'conditions' => array('Category.name LIKE' => 'Category 3%') - )); - $expected = array( - array( - 'Category' => array( - 'id' => '5', - 'parent_id' => '0', - 'name' => 'Category 3', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '6', - 'parent_id' => '5', - 'name' => 'Category 3.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'children' => array() - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('threaded', array( - 'conditions' => array('Category.name LIKE' => 'Category 1.1%') - )); - $expected = array( - array('Category' => - array( - 'id' => '2', - 'parent_id' => '1', - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array( - array('Category' => array( - 'id' => '7', - 'parent_id' => '2', - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array()), - array('Category' => array( - 'id' => '8', - 'parent_id' => '2', - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31'), - 'children' => array())))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('threaded', array( - 'fields' => 'id, parent_id, name', - 'conditions' => array('Category.id !=' => 2) - )); - $expected = array( - array( - 'Category' => array( - 'id' => '1', - 'parent_id' => '0', - 'name' => 'Category 1' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '3', - 'parent_id' => '1', - 'name' => 'Category 1.2' - ), - 'children' => array() - ) - ) - ), - array( - 'Category' => array( - 'id' => '4', - 'parent_id' => '0', - 'name' => 'Category 2' - ), - 'children' => array() - ), - array( - 'Category' => array( - 'id' => '5', - 'parent_id' => '0', - 'name' => 'Category 3' - ), - 'children' => array( - array( - 'Category' => array( - 'id' => '6', - 'parent_id' => '5', - 'name' => 'Category 3.1' - ), - 'children' => array() - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array( - 'fields' => 'id, name, parent_id', - 'conditions' => array('Category.id !=' => 1) - )); - $expected = array( - array('Category' => array( - 'id' => '2', - 'name' => 'Category 1.1', - 'parent_id' => '1' - )), - array('Category' => array( - 'id' => '3', - 'name' => 'Category 1.2', - 'parent_id' => '1' - )), - array('Category' => array( - 'id' => '4', - 'name' => 'Category 2', - 'parent_id' => '0' - )), - array('Category' => array( - 'id' => '5', - 'name' => 'Category 3', - 'parent_id' => '0' - )), - array('Category' => array( - 'id' => '6', - 'name' => 'Category 3.1', - 'parent_id' => '5' - )), - array('Category' => array( - 'id' => '7', - 'name' => 'Category 1.1.1', - 'parent_id' => '2' - )), - array('Category' => array( - 'id' => '8', - 'name' => 'Category 1.1.2', - 'parent_id' => '2' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('threaded', array( - 'fields' => 'id, parent_id, name', - 'conditions' => array('Category.id !=' => 1) - )); - $expected = array( - array( - 'Category' => array( - 'id' => '2', - 'parent_id' => '1', - 'name' => 'Category 1.1' - ), - 'children' => array( - array('Category' => array( - 'id' => '7', - 'parent_id' => '2', - 'name' => 'Category 1.1.1'), - 'children' => array()), - array('Category' => array( - 'id' => '8', - 'parent_id' => '2', - 'name' => 'Category 1.1.2'), - 'children' => array())) - ), - array( - 'Category' => array( - 'id' => '3', - 'parent_id' => '1', - 'name' => 'Category 1.2' - ), - 'children' => array() - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * test find('neighbors') - * - * @return void - */ - public function testFindNeighbors() { - $this->loadFixtures('User', 'Article', 'Comment', 'Tag', 'ArticlesTag', 'Attachment'); - $TestModel = new Article(); - - $TestModel->id = 1; - $result = $TestModel->find('neighbors', array('fields' => array('id'))); - - $this->assertNull($result['prev']); - $this->assertEquals(array('id' => 2), $result['next']['Article']); - $this->assertEquals(2, count($result['next']['Comment'])); - $this->assertEquals(2, count($result['next']['Tag'])); - - $TestModel->id = 2; - $TestModel->recursive = 0; - $result = $TestModel->find('neighbors', array( - 'fields' => array('id') - )); - - $expected = array( - 'prev' => array( - 'Article' => array( - 'id' => 1 - )), - 'next' => array( - 'Article' => array( - 'id' => 3 - ))); - $this->assertEquals($expected, $result); - - $TestModel->id = 3; - $TestModel->recursive = 1; - $result = $TestModel->find('neighbors', array('fields' => array('id'))); - - $this->assertNull($result['next']); - $this->assertEquals(array('id' => 2), $result['prev']['Article']); - $this->assertEquals(2, count($result['prev']['Comment'])); - $this->assertEquals(2, count($result['prev']['Tag'])); - - $TestModel->id = 1; - $result = $TestModel->find('neighbors', array('recursive' => -1)); - $expected = array( - 'prev' => null, - 'next' => array( - 'Article' => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $TestModel->id = 2; - $result = $TestModel->find('neighbors', array('recursive' => -1)); - $expected = array( - 'prev' => array( - 'Article' => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ) - ), - 'next' => array( - 'Article' => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $TestModel->id = 3; - $result = $TestModel->find('neighbors', array('recursive' => -1)); - $expected = array( - 'prev' => array( - 'Article' => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ) - ), - 'next' => null - ); - $this->assertEquals($expected, $result); - - $TestModel->recursive = 0; - $TestModel->id = 1; - $one = $TestModel->read(); - $TestModel->id = 2; - $two = $TestModel->read(); - $TestModel->id = 3; - $three = $TestModel->read(); - - $TestModel->id = 1; - $result = $TestModel->find('neighbors'); - $expected = array('prev' => null, 'next' => $two); - $this->assertEquals($expected, $result); - - $TestModel->id = 2; - $result = $TestModel->find('neighbors'); - $expected = array('prev' => $one, 'next' => $three); - $this->assertEquals($expected, $result); - - $TestModel->id = 3; - $result = $TestModel->find('neighbors'); - $expected = array('prev' => $two, 'next' => null); - $this->assertEquals($expected, $result); - - $TestModel->recursive = 2; - $TestModel->id = 1; - $one = $TestModel->read(); - $TestModel->id = 2; - $two = $TestModel->read(); - $TestModel->id = 3; - $three = $TestModel->read(); - - $TestModel->id = 1; - $result = $TestModel->find('neighbors', array('recursive' => 2)); - $expected = array('prev' => null, 'next' => $two); - $this->assertEquals($expected, $result); - - $TestModel->id = 2; - $result = $TestModel->find('neighbors', array('recursive' => 2)); - $expected = array('prev' => $one, 'next' => $three); - $this->assertEquals($expected, $result); - - $TestModel->id = 3; - $result = $TestModel->find('neighbors', array('recursive' => 2)); - $expected = array('prev' => $two, 'next' => null); - $this->assertEquals($expected, $result); - } - -/** - * testFindCombinedRelations method - * - * @return void - */ - public function testFindCombinedRelations() { - $this->skipIf($this->db instanceof Sqlserver, 'The test of testRecursiveUnbind test is not compatible with SQL Server, because it check for time columns.'); - - $this->loadFixtures('Apple', 'Sample'); - $TestModel = new Apple(); - - $result = $TestModel->find('all'); - - $expected = array( - array( - 'Apple' => array( - 'id' => '1', - 'apple_id' => '2', - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => '2', - 'apple_id' => '1', - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => null, - 'apple_id' => null, - 'name' => null - ), - 'Child' => array( - array( - 'id' => '2', - 'apple_id' => '1', - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => '2', - 'apple_id' => '1', - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => '1', - 'apple_id' => '2', - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => '2', - 'apple_id' => '2', - 'name' => 'sample2' - ), - 'Child' => array( - array( - 'id' => '1', - 'apple_id' => '2', - 'color' => 'Red 1', - 'name' => 'Red Apple 1', - 'created' => '2006-11-22 10:38:58', - 'date' => '1951-01-04', - 'modified' => '2006-12-01 13:31:26', - 'mytime' => '22:57:17' - ), - array( - 'id' => '3', - 'apple_id' => '2', - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - array( - 'id' => '4', - 'apple_id' => '2', - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => '3', - 'apple_id' => '2', - 'color' => 'blue green', - 'name' => 'green blue', - 'created' => '2006-12-25 05:13:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:24', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => '2', - 'apple_id' => '1', - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => '1', - 'apple_id' => '3', - 'name' => 'sample1' - ), - 'Child' => array() - ), - array( - 'Apple' => array( - 'id' => '4', - 'apple_id' => '2', - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => '2', - 'apple_id' => '1', - 'color' => 'Bright Red 1', - 'name' => 'Bright Red Apple', - 'created' => '2006-11-22 10:43:13', - 'date' => '2014-01-01', - 'modified' => '2006-11-30 18:38:10', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => '3', - 'apple_id' => '4', - 'name' => 'sample3' - ), - 'Child' => array( - array( - 'id' => '6', - 'apple_id' => '4', - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => '5', - 'apple_id' => '5', - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => '5', - 'apple_id' => '5', - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => '4', - 'apple_id' => '5', - 'name' => 'sample4' - ), - 'Child' => array( - array( - 'id' => '5', - 'apple_id' => '5', - 'color' => 'Green', - 'name' => 'Blue Green', - 'created' => '2006-12-25 05:24:06', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:16', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => '6', - 'apple_id' => '4', - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => '4', - 'apple_id' => '2', - 'color' => 'Blue Green', - 'name' => 'Test Name', - 'created' => '2006-12-25 05:23:36', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:23:36', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => null, - 'apple_id' => null, - 'name' => null - ), - 'Child' => array( - array( - 'id' => '7', - 'apple_id' => '6', - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ))), - array( - 'Apple' => array( - 'id' => '7', - 'apple_id' => '6', - 'color' => 'Some wierd color', - 'name' => 'Some odd color', - 'created' => '2006-12-25 05:34:21', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:34:21', - 'mytime' => '22:57:17' - ), - 'Parent' => array( - 'id' => '6', - 'apple_id' => '4', - 'color' => 'My new appleOrange', - 'name' => 'My new apple', - 'created' => '2006-12-25 05:29:39', - 'date' => '2006-12-25', - 'modified' => '2006-12-25 05:29:39', - 'mytime' => '22:57:17' - ), - 'Sample' => array( - 'id' => null, - 'apple_id' => null, - 'name' => null - ), - 'Child' => array() - )); - $this->assertEquals($expected, $result); - } - -/** - * testSaveEmpty method - * - * @return void - */ - public function testSaveEmpty() { - $this->loadFixtures('Thread'); - $TestModel = new Thread(); - $data = array(); - $expected = $TestModel->save($data); - $this->assertFalse($expected); - } - -/** - * testFindAllWithConditionInChildQuery - * - * @todo external conditions like this are going to need to be revisited at some point - * @return void - */ - public function testFindAllWithConditionInChildQuery() { - $this->loadFixtures('Basket', 'FilmFile'); - - $TestModel = new Basket(); - $recursive = 3; - $result = $TestModel->find('all', compact('recursive')); - - $expected = array( - array( - 'Basket' => array( - 'id' => 1, - 'type' => 'nonfile', - 'name' => 'basket1', - 'object_id' => 1, - 'user_id' => 1, - ), - 'FilmFile' => array( - 'id' => '', - 'name' => '', - ) - ), - array( - 'Basket' => array( - 'id' => 2, - 'type' => 'file', - 'name' => 'basket2', - 'object_id' => 2, - 'user_id' => 1, - ), - 'FilmFile' => array( - 'id' => 2, - 'name' => 'two', - ) - ), - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindAllWithConditionsHavingMixedDataTypes method - * - * @return void - */ - public function testFindAllWithConditionsHavingMixedDataTypes() { - $this->loadFixtures('Article', 'User', 'Tag', 'ArticlesTag'); - $TestModel = new Article(); - $expected = array( - array( - 'Article' => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ) - ), - array( - 'Article' => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ) - ) - ); - $conditions = array('id' => array('1', 2)); - $recursive = -1; - $order = 'Article.id ASC'; - $result = $TestModel->find('all', compact('conditions', 'recursive', 'order')); - $this->assertEquals($expected, $result); - - $this->skipIf($this->db instanceof Postgres, 'The rest of testFindAllWithConditionsHavingMixedDataTypes test is not compatible with Postgres.'); - - $conditions = array('id' => array('1', 2, '3.0')); - $order = 'Article.id ASC'; - $result = $TestModel->find('all', compact('recursive', 'conditions', 'order')); - $expected = array( - array( - 'Article' => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ) - ), - array( - 'Article' => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ) - ), - array( - 'Article' => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testBindUnbind method - * - * @return void - */ - public function testBindUnbind() { - $this->loadFixtures( - 'User', - 'Comment', - 'FeatureSet', - 'DeviceType', - 'DeviceTypeCategory', - 'ExteriorTypeCategory', - 'Device', - 'Document', - 'DocumentDirectory' - ); - $TestModel = new User(); - - $result = $TestModel->hasMany; - $expected = array(); - $this->assertEquals($expected, $result); - - $result = $TestModel->bindModel(array('hasMany' => array('Comment'))); - $this->assertTrue($result); - - $result = $TestModel->find('all', array( - 'fields' => 'User.id, User.user' - )); - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Comment' => array( - array( - 'id' => '3', - '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' - ), - array( - 'id' => '4', - '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' - ), - array( - 'id' => '5', - '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' - ))), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - ))), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry' - ), - 'Comment' => array() - ), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett' - ), - 'Comment' => array( - array( - 'id' => '2', - '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' - )))); - - $this->assertEquals($expected, $result); - - $TestModel->resetAssociations(); - $result = $TestModel->hasMany; - $this->assertEquals(array(), $result); - - $result = $TestModel->bindModel(array('hasMany' => array('Comment')), false); - $this->assertTrue($result); - - $result = $TestModel->find('all', array( - 'fields' => 'User.id, User.user' - )); - - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Comment' => array( - array( - 'id' => '3', - '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' - ), - array( - 'id' => '4', - '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' - ), - array( - 'id' => '5', - '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' - ))), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - ))), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry' - ), - 'Comment' => array() - ), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett' - ), - 'Comment' => array( - array( - 'id' => '2', - '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' - )))); - - $this->assertEquals($expected, $result); - - $result = $TestModel->hasMany; - $expected = array( - 'Comment' => array( - 'className' => 'Comment', - 'foreignKey' => 'user_id', - 'conditions' => null, - 'fields' => null, - 'order' => null, - 'limit' => null, - 'offset' => null, - 'dependent' => null, - 'exclusive' => null, - 'finderQuery' => null, - 'counterQuery' => null - )); - $this->assertEquals($expected, $result); - - $result = $TestModel->unbindModel(array('hasMany' => array('Comment'))); - $this->assertTrue($result); - - $result = $TestModel->hasMany; - $expected = array(); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array( - 'fields' => 'User.id, User.user' - )); - $expected = array( - array('User' => array('id' => '1', 'user' => 'mariano')), - array('User' => array('id' => '2', 'user' => 'nate')), - array('User' => array('id' => '3', 'user' => 'larry')), - array('User' => array('id' => '4', 'user' => 'garrett'))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array( - 'fields' => 'User.id, User.user' - )); - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Comment' => array( - array( - 'id' => '3', - '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' - ), - array( - 'id' => '4', - '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' - ), - array( - 'id' => '5', - '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' - ))), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - ))), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry' - ), - 'Comment' => array() - ), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett' - ), - 'Comment' => array( - array( - 'id' => '2', - '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' - )))); - $this->assertEquals($expected, $result); - - $result = $TestModel->unbindModel(array('hasMany' => array('Comment')), false); - $this->assertTrue($result); - - $result = $TestModel->find('all', array('fields' => 'User.id, User.user')); - $expected = array( - array('User' => array('id' => '1', 'user' => 'mariano')), - array('User' => array('id' => '2', 'user' => 'nate')), - array('User' => array('id' => '3', 'user' => 'larry')), - array('User' => array('id' => '4', 'user' => 'garrett'))); - $this->assertEquals($expected, $result); - - $result = $TestModel->hasMany; - $expected = array(); - $this->assertEquals($expected, $result); - - $result = $TestModel->bindModel(array('hasMany' => array( - 'Comment' => array('className' => 'Comment', 'conditions' => 'Comment.published = \'Y\'') - ))); - $this->assertTrue($result); - - $result = $TestModel->find('all', array('fields' => 'User.id, User.user')); - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Comment' => array( - array( - 'id' => '3', - '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' - ), - array( - 'id' => '5', - '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' - ))), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - ))), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry' - ), - 'Comment' => array() - ), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett' - ), - 'Comment' => array( - array( - 'id' => '2', - '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' - )))); - - $this->assertEquals($expected, $result); - - $TestModel2 = new DeviceType(); - - $expected = array( - 'className' => 'FeatureSet', - 'foreignKey' => 'feature_set_id', - 'conditions' => '', - 'fields' => '', - 'order' => '', - 'counterCache' => '' - ); - $this->assertEquals($expected, $TestModel2->belongsTo['FeatureSet']); - - $TestModel2->bindModel(array( - 'belongsTo' => array( - 'FeatureSet' => array( - 'className' => 'FeatureSet', - 'conditions' => array('active' => true) - ) - ) - )); - $expected['conditions'] = array('active' => true); - $this->assertEquals($expected, $TestModel2->belongsTo['FeatureSet']); - - $TestModel2->bindModel(array( - 'belongsTo' => array( - 'FeatureSet' => array( - 'className' => 'FeatureSet', - 'foreignKey' => false, - 'conditions' => array('Feature.name' => 'DeviceType.name') - ) - ) - )); - $expected['conditions'] = array('Feature.name' => 'DeviceType.name'); - $expected['foreignKey'] = false; - $this->assertEquals($expected, $TestModel2->belongsTo['FeatureSet']); - - $TestModel2->bindModel(array( - 'hasMany' => array( - 'NewFeatureSet' => array( - 'className' => 'FeatureSet', - 'conditions' => array('active' => true) - ) - ) - )); - - $expected = array( - 'className' => 'FeatureSet', - 'conditions' => array('active' => true), - 'foreignKey' => 'device_type_id', - 'fields' => '', - 'order' => '', - 'limit' => '', - 'offset' => '', - 'dependent' => '', - 'exclusive' => '', - 'finderQuery' => '', - 'counterQuery' => '' - ); - $this->assertEquals($expected, $TestModel2->hasMany['NewFeatureSet']); - $this->assertTrue(is_object($TestModel2->NewFeatureSet)); - } - -/** - * testBindMultipleTimes method - * - * @return void - */ - public function testBindMultipleTimes() { - $this->loadFixtures('User', 'Comment', 'Article', 'Tag', 'ArticlesTag'); - $TestModel = new User(); - - $result = $TestModel->hasMany; - $expected = array(); - $this->assertEquals($expected, $result); - - $result = $TestModel->bindModel(array( - 'hasMany' => array( - 'Items' => array('className' => 'Comment') - ))); - $this->assertTrue($result); - - $result = $TestModel->find('all', array( - 'fields' => 'User.id, User.user' - )); - - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Items' => array( - array( - 'id' => '3', - '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' - ), - array( - 'id' => '4', - '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' - ), - array( - 'id' => '5', - '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' - ))), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate' - ), - 'Items' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '6', - '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' - ))), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry' - ), - 'Items' => array() - ), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett' - ), - 'Items' => array( - array( - 'id' => '2', - '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' - )))); - $this->assertEquals($expected, $result); - - $result = $TestModel->bindModel(array( - 'hasMany' => array( - 'Items' => array('className' => 'Article') - ))); - $this->assertTrue($result); - - $result = $TestModel->find('all', array( - 'fields' => 'User.id, User.user' - )); - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Items' => array( - array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ))), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate' - ), - 'Items' => array() - ), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry' - ), - 'Items' => array( - array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ))), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett' - ), - 'Items' => array() - )); - - $this->assertEquals($expected, $result); - } - -/** - * test that multiple reset = true calls to bindModel() result in the original associations. - * - * @return void - */ - public function testBindModelMultipleTimesResetCorrectly() { - $this->loadFixtures('User', 'Comment', 'Article'); - $TestModel = new User(); - - $TestModel->bindModel(array('hasMany' => array('Comment'))); - $TestModel->bindModel(array('hasMany' => array('Comment'))); - $TestModel->resetAssociations(); - - $this->assertFalse(isset($TestModel->hasMany['Comment']), 'Association left behind'); - } - -/** - * testBindMultipleTimes method with different reset settings - * - * @return void - */ - public function testBindMultipleTimesWithDifferentResetSettings() { - $this->loadFixtures('User', 'Comment', 'Article'); - $TestModel = new User(); - - $result = $TestModel->hasMany; - $expected = array(); - $this->assertEquals($expected, $result); - - $result = $TestModel->bindModel(array( - 'hasMany' => array('Comment') - )); - $this->assertTrue($result); - $result = $TestModel->bindModel( - array('hasMany' => array('Article')), - false - ); - $this->assertTrue($result); - - $result = array_keys($TestModel->hasMany); - $expected = array('Comment', 'Article'); - $this->assertEquals($expected, $result); - - $TestModel->resetAssociations(); - - $result = array_keys($TestModel->hasMany); - $expected = array('Article'); - $this->assertEquals($expected, $result); - } - -/** - * test that bindModel behaves with Custom primary Key associations - * - * @return void - */ - public function testBindWithCustomPrimaryKey() { - $this->loadFixtures('Story', 'StoriesTag', 'Tag'); - $Model = ClassRegistry::init('StoriesTag'); - $Model->bindModel(array( - 'belongsTo' => array( - 'Tag' => array( - 'className' => 'Tag', - 'foreignKey' => 'story' - )))); - - $result = $Model->find('all'); - $this->assertFalse(empty($result)); - } - -/** - * test that calling unbindModel() with reset == true multiple times - * leaves associations in the correct state. - * - * @return void - */ - public function testUnbindMultipleTimesResetCorrectly() { - $this->loadFixtures('User', 'Comment', 'Article'); - $TestModel = new Article10(); - - $TestModel->unbindModel(array('hasMany' => array('Comment'))); - $TestModel->unbindModel(array('hasMany' => array('Comment'))); - $TestModel->resetAssociations(); - - $this->assertTrue(isset($TestModel->hasMany['Comment']), 'Association permanently removed'); - } - -/** - * testBindMultipleTimes method with different reset settings - * - * @return void - */ - public function testUnBindMultipleTimesWithDifferentResetSettings() { - $this->loadFixtures('User', 'Comment', 'Article'); - $TestModel = new Comment(); - - $result = array_keys($TestModel->belongsTo); - $expected = array('Article', 'User'); - $this->assertEquals($expected, $result); - - $result = $TestModel->unbindModel(array( - 'belongsTo' => array('User') - )); - $this->assertTrue($result); - $result = $TestModel->unbindModel( - array('belongsTo' => array('Article')), - false - ); - $this->assertTrue($result); - - $result = array_keys($TestModel->belongsTo); - $expected = array(); - $this->assertEquals($expected, $result); - - $TestModel->resetAssociations(); - - $result = array_keys($TestModel->belongsTo); - $expected = array('User'); - $this->assertEquals($expected, $result); - } - -/** - * testAssociationAfterFind method - * - * @return void - */ - public function testAssociationAfterFind() { - $this->loadFixtures('Post', 'Author', 'Comment'); - $TestModel = new Post(); - $result = $TestModel->find('all'); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'Author' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31', - 'test' => 'working' - )), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '3', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'Author' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31', - 'test' => 'working' - )), - array( - 'Post' => array( - 'id' => '3', - 'author_id' => '1', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'Author' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31', - 'test' => 'working' - ))); - $this->assertEquals($expected, $result); - unset($TestModel); - - $Author = new Author(); - $Author->Post->bindModel(array( - 'hasMany' => array( - 'Comment' => array( - 'className' => 'ModifiedComment', - 'foreignKey' => 'article_id', - ) - ))); - $result = $Author->find('all', array( - 'conditions' => array('Author.id' => 1), - 'recursive' => 2 - )); - $expected = array( - 'id' => 1, - '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', - 'callback' => 'Fire' - ); - $this->assertEquals($expected, $result[0]['Post'][0]['Comment'][0]); - } - -/** - * Tests that callbacks can be properly disabled - * - * @return void - */ - public function testCallbackDisabling() { - $this->loadFixtures('Author'); - $TestModel = new ModifiedAuthor(); - - $result = Set::extract($TestModel->find('all'), '/Author/user'); - $expected = array('mariano (CakePHP)', 'nate (CakePHP)', 'larry (CakePHP)', 'garrett (CakePHP)'); - $this->assertEquals($expected, $result); - - $result = Set::extract($TestModel->find('all', array('callbacks' => 'after')), '/Author/user'); - $expected = array('mariano (CakePHP)', 'nate (CakePHP)', 'larry (CakePHP)', 'garrett (CakePHP)'); - $this->assertEquals($expected, $result); - - $result = Set::extract($TestModel->find('all', array('callbacks' => 'before')), '/Author/user'); - $expected = array('mariano', 'nate', 'larry', 'garrett'); - $this->assertEquals($expected, $result); - - $result = Set::extract($TestModel->find('all', array('callbacks' => false)), '/Author/user'); - $expected = array('mariano', 'nate', 'larry', 'garrett'); - $this->assertEquals($expected, $result); - } - -/** - * testAssociationAfterFindCallbacksDisabled method - * - * @return void - */ - public function testAssociationAfterFindCalbacksDisabled() { - $this->loadFixtures('Post', 'Author', 'Comment'); - $TestModel = new Post(); - $result = $TestModel->find('all', array('callbacks' => false)); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'Author' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - )), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '3', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'Author' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - )), - array( - 'Post' => array( - 'id' => '3', - 'author_id' => '1', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'Author' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ))); - $this->assertEquals($expected, $result); - unset($TestModel); - - $Author = new Author(); - $Author->Post->bindModel(array( - 'hasMany' => array( - 'Comment' => array( - 'className' => 'ModifiedComment', - 'foreignKey' => 'article_id', - ) - ))); - $result = $Author->find('all', array( - 'conditions' => array('Author.id' => 1), - 'recursive' => 2, - 'callbacks' => false - )); - $expected = array( - 'id' => 1, - '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' - ); - $this->assertEquals($expected, $result[0]['Post'][0]['Comment'][0]); - } - -/** - * Tests that the database configuration assigned to the model can be changed using - * (before|after)Find callbacks - * - * @return void - */ - public function testCallbackSourceChange() { - $this->loadFixtures('Post'); - $TestModel = new Post(); - $this->assertEquals(3, count($TestModel->find('all'))); - } - -/** - * testCallbackSourceChangeUnknownDatasource method - * - * @expectedException MissingDatasourceConfigException - * @return void - */ - public function testCallbackSourceChangeUnknownDatasource() { - $this->loadFixtures('Post', 'Author'); - $TestModel = new Post(); - $this->assertFalse($TestModel->find('all', array('connection' => 'foo'))); - } - -/** - * testMultipleBelongsToWithSameClass method - * - * @return void - */ - public function testMultipleBelongsToWithSameClass() { - $this->loadFixtures( - 'DeviceType', - 'DeviceTypeCategory', - 'FeatureSet', - 'ExteriorTypeCategory', - 'Document', - 'Device', - 'DocumentDirectory' - ); - - $DeviceType = new DeviceType(); - - $DeviceType->recursive = 2; - $result = $DeviceType->read(null, 1); - - $expected = array( - 'DeviceType' => array( - 'id' => 1, - 'device_type_category_id' => 1, - 'feature_set_id' => 1, - 'exterior_type_category_id' => 1, - 'image_id' => 1, - 'extra1_id' => 1, - 'extra2_id' => 1, - 'name' => 'DeviceType 1', - 'order' => 0 - ), - 'Image' => array( - 'id' => 1, - 'document_directory_id' => 1, - 'name' => 'Document 1', - 'DocumentDirectory' => array( - 'id' => 1, - 'name' => 'DocumentDirectory 1' - )), - 'Extra1' => array( - 'id' => 1, - 'document_directory_id' => 1, - 'name' => 'Document 1', - 'DocumentDirectory' => array( - 'id' => 1, - 'name' => 'DocumentDirectory 1' - )), - 'Extra2' => array( - 'id' => 1, - 'document_directory_id' => 1, - 'name' => 'Document 1', - 'DocumentDirectory' => array( - 'id' => 1, - 'name' => 'DocumentDirectory 1' - )), - 'DeviceTypeCategory' => array( - 'id' => 1, - 'name' => 'DeviceTypeCategory 1' - ), - 'FeatureSet' => array( - 'id' => 1, - 'name' => 'FeatureSet 1' - ), - 'ExteriorTypeCategory' => array( - 'id' => 1, - 'image_id' => 1, - 'name' => 'ExteriorTypeCategory 1', - 'Image' => array( - 'id' => 1, - 'device_type_id' => 1, - 'name' => 'Device 1', - 'typ' => 1 - )), - 'Device' => array( - array( - 'id' => 1, - 'device_type_id' => 1, - 'name' => 'Device 1', - 'typ' => 1 - ), - array( - 'id' => 2, - 'device_type_id' => 1, - 'name' => 'Device 2', - 'typ' => 1 - ), - array( - 'id' => 3, - 'device_type_id' => 1, - 'name' => 'Device 3', - 'typ' => 2 - ))); - - $this->assertEquals($expected, $result); - } - -/** - * testHabtmRecursiveBelongsTo method - * - * @return void - */ - public function testHabtmRecursiveBelongsTo() { - $this->loadFixtures('Portfolio', 'Item', 'ItemsPortfolio', 'Syfile', 'Image'); - $Portfolio = new Portfolio(); - - $result = $Portfolio->find('first', array('conditions' => array('id' => 2), 'recursive' => 3)); - $expected = array( - 'Portfolio' => array( - 'id' => 2, - 'seller_id' => 1, - 'name' => 'Portfolio 2' - ), - 'Item' => array( - array( - 'id' => 2, - 'syfile_id' => 2, - 'published' => false, - 'name' => 'Item 2', - 'ItemsPortfolio' => array( - 'id' => 2, - 'item_id' => 2, - 'portfolio_id' => 2 - ), - 'Syfile' => array( - 'id' => 2, - 'image_id' => 2, - 'name' => 'Syfile 2', - 'item_count' => null, - 'Image' => array( - 'id' => 2, - 'name' => 'Image 2' - ) - )), - array( - 'id' => 6, - 'syfile_id' => 6, - 'published' => false, - 'name' => 'Item 6', - 'ItemsPortfolio' => array( - 'id' => 6, - 'item_id' => 6, - 'portfolio_id' => 2 - ), - 'Syfile' => array( - 'id' => 6, - 'image_id' => null, - 'name' => 'Syfile 6', - 'item_count' => null, - 'Image' => array() - )))); - - $this->assertEquals($expected, $result); - } - -/** - * testNonNumericHabtmJoinKey method - * - * @return void - */ - public function testNonNumericHabtmJoinKey() { - $this->loadFixtures('Post', 'Tag', 'PostsTag', 'Author'); - $Post = new Post(); - $Post->bindModel(array( - 'hasAndBelongsToMany' => array('Tag') - )); - $Post->Tag->primaryKey = 'tag'; - - $result = $Post->find('all'); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '1', - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'Author' => array( - 'id' => 1, - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31', - 'test' => 'working' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ))), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '3', - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'Author' => array( - 'id' => 3, - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31', - 'test' => 'working' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))), - array( - 'Post' => array( - 'id' => '3', - 'author_id' => '1', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'Author' => array( - 'id' => 1, - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31', - 'test' => 'working' - ), - 'Tag' => array() - )); - $this->assertEquals($expected, $result); - } - -/** - * testHabtmFinderQuery method - * - * @return void - */ - public function testHabtmFinderQuery() { - $this->loadFixtures('Article', 'Tag', 'ArticlesTag'); - $Article = new Article(); - - $sql = $this->db->buildStatement( - array( - 'fields' => $this->db->fields($Article->Tag, null, array( - 'Tag.id', 'Tag.tag', 'ArticlesTag.article_id', 'ArticlesTag.tag_id' - )), - 'table' => $this->db->fullTableName('tags'), - 'alias' => 'Tag', - 'limit' => null, - 'offset' => null, - 'group' => null, - 'joins' => array(array( - 'alias' => 'ArticlesTag', - 'table' => 'articles_tags', - 'conditions' => array( - array("ArticlesTag.article_id" => '{$__cakeID__$}'), - array("ArticlesTag.tag_id" => $this->db->identifier('Tag.id')) - ) - )), - 'conditions' => array(), - 'order' => null - ), - $Article - ); - - $Article->hasAndBelongsToMany['Tag']['finderQuery'] = $sql; - $result = $Article->find('first'); - $expected = array( - array( - 'id' => '1', - 'tag' => 'tag1' - ), - array( - 'id' => '2', - 'tag' => 'tag2' - )); - - $this->assertEquals($expected, $result['Tag']); - } - -/** - * testHabtmLimitOptimization method - * - * @return void - */ - public function testHabtmLimitOptimization() { - $this->loadFixtures('Article', 'User', 'Comment', 'Tag', 'ArticlesTag'); - $TestModel = new Article(); - - $TestModel->hasAndBelongsToMany['Tag']['limit'] = 2; - $result = $TestModel->read(null, 2); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => '5', - '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' - ), - array( - 'id' => '6', - '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' - )), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - - $this->assertEquals($expected, $result); - - $TestModel->hasAndBelongsToMany['Tag']['limit'] = 1; - $result = $TestModel->read(null, 2); - unset($expected['Tag'][1]); - - $this->assertEquals($expected, $result); - } - -/** - * testHasManyLimitOptimization method - * - * @return void - */ - public function testHasManyLimitOptimization() { - $this->loadFixtures('Project', 'Thread', 'Message', 'Bid'); - $Project = new Project(); - $Project->recursive = 3; - - $result = $Project->find('all'); - $expected = array( - array( - 'Project' => array( - 'id' => 1, - 'name' => 'Project 1' - ), - 'Thread' => array( - array( - 'id' => 1, - 'project_id' => 1, - 'name' => 'Project 1, Thread 1', - 'Project' => array( - 'id' => 1, - 'name' => 'Project 1', - 'Thread' => array( - array( - 'id' => 1, - 'project_id' => 1, - 'name' => 'Project 1, Thread 1' - ), - array( - 'id' => 2, - 'project_id' => 1, - 'name' => 'Project 1, Thread 2' - ))), - 'Message' => array( - array( - 'id' => 1, - 'thread_id' => 1, - 'name' => 'Thread 1, Message 1', - 'Bid' => array( - 'id' => 1, - 'message_id' => 1, - 'name' => 'Bid 1.1' - )))), - array( - 'id' => 2, - 'project_id' => 1, - 'name' => 'Project 1, Thread 2', - 'Project' => array( - 'id' => 1, - 'name' => 'Project 1', - 'Thread' => array( - array( - 'id' => 1, - 'project_id' => 1, - 'name' => 'Project 1, Thread 1' - ), - array( - 'id' => 2, - 'project_id' => 1, - 'name' => 'Project 1, Thread 2' - ))), - 'Message' => array( - array( - 'id' => 2, - 'thread_id' => 2, - 'name' => 'Thread 2, Message 1', - 'Bid' => array( - 'id' => 4, - 'message_id' => 2, - 'name' => 'Bid 2.1' - )))))), - array( - 'Project' => array( - 'id' => 2, - 'name' => 'Project 2' - ), - 'Thread' => array( - array( - 'id' => 3, - 'project_id' => 2, - 'name' => 'Project 2, Thread 1', - 'Project' => array( - 'id' => 2, - 'name' => 'Project 2', - 'Thread' => array( - array( - 'id' => 3, - 'project_id' => 2, - 'name' => 'Project 2, Thread 1' - ))), - 'Message' => array( - array( - 'id' => 3, - 'thread_id' => 3, - 'name' => 'Thread 3, Message 1', - 'Bid' => array( - 'id' => 3, - 'message_id' => 3, - 'name' => 'Bid 3.1' - )))))), - array( - 'Project' => array( - 'id' => 3, - 'name' => 'Project 3' - ), - 'Thread' => array() - )); - - $this->assertEquals($expected, $result); - } - -/** - * testFindAllRecursiveSelfJoin method - * - * @return void - */ - public function testFindAllRecursiveSelfJoin() { - $this->loadFixtures('Home', 'AnotherArticle', 'Advertisement'); - $TestModel = new Home(); - $TestModel->recursive = 2; - - $result = $TestModel->find('all'); - $expected = array( - array( - 'Home' => array( - 'id' => '1', - 'another_article_id' => '1', - 'advertisement_id' => '1', - 'title' => 'First Home', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'AnotherArticle' => array( - 'id' => '1', - 'title' => 'First Article', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'Home' => array( - array( - 'id' => '1', - 'another_article_id' => '1', - 'advertisement_id' => '1', - 'title' => 'First Home', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ))), - 'Advertisement' => array( - 'id' => '1', - 'title' => 'First Ad', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'Home' => array( - array( - 'id' => '1', - 'another_article_id' => '1', - 'advertisement_id' => '1', - 'title' => 'First Home', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => '2', - 'another_article_id' => '3', - 'advertisement_id' => '1', - 'title' => 'Second Home', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - )))), - array( - 'Home' => array( - 'id' => '2', - 'another_article_id' => '3', - 'advertisement_id' => '1', - 'title' => 'Second Home', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'AnotherArticle' => array( - 'id' => '3', - 'title' => 'Third Article', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31', - 'Home' => array( - array( - 'id' => '2', - 'another_article_id' => '3', - 'advertisement_id' => '1', - 'title' => 'Second Home', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ))), - 'Advertisement' => array( - 'id' => '1', - 'title' => 'First Ad', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31', - 'Home' => array( - array( - 'id' => '1', - 'another_article_id' => '1', - 'advertisement_id' => '1', - 'title' => 'First Home', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => '2', - 'another_article_id' => '3', - 'advertisement_id' => '1', - 'title' => 'Second Home', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ))))); - - $this->assertEquals($expected, $result); - } - -/** - * testFindAllRecursiveWithHabtm method - * - * @return void - */ - public function testFindAllRecursiveWithHabtm() { - $this->loadFixtures( - 'MyCategoriesMyUsers', - 'MyCategoriesMyProducts', - 'MyCategory', - 'MyUser', - 'MyProduct' - ); - - $MyUser = new MyUser(); - $MyUser->recursive = 2; - - $result = $MyUser->find('all'); - $expected = array( - array( - 'MyUser' => array('id' => '1', 'firstname' => 'userA'), - 'MyCategory' => array( - array( - 'id' => '1', - 'name' => 'A', - 'MyProduct' => array( - array( - 'id' => '1', - 'name' => 'book' - ))), - array( - 'id' => '3', - 'name' => 'C', - 'MyProduct' => array( - array( - 'id' => '2', - 'name' => 'computer' - ))))), - array( - 'MyUser' => array( - 'id' => '2', - 'firstname' => 'userB' - ), - 'MyCategory' => array( - array( - 'id' => '1', - 'name' => 'A', - 'MyProduct' => array( - array( - 'id' => '1', - 'name' => 'book' - ))), - array( - 'id' => '2', - 'name' => 'B', - 'MyProduct' => array( - array( - 'id' => '1', - 'name' => 'book' - ), - array( - 'id' => '2', - 'name' => 'computer' - )))))); - - $this->assertEquals($expected, $result); - } - -/** - * testReadFakeThread method - * - * @return void - */ - public function testReadFakeThread() { - $this->loadFixtures('CategoryThread'); - $TestModel = new CategoryThread(); - - $fullDebug = $this->db->fullDebug; - $this->db->fullDebug = true; - $TestModel->recursive = 6; - $TestModel->id = 7; - $result = $TestModel->read(); - $expected = array( - 'CategoryThread' => array( - 'id' => 7, - 'parent_id' => 6, - 'name' => 'Category 2.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 6, - 'parent_id' => 5, - 'name' => 'Category 2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 5, - 'parent_id' => 4, - 'name' => 'Category 1.1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 4, - 'parent_id' => 3, - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 3, - 'parent_id' => 2, - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ))))))); - - $this->db->fullDebug = $fullDebug; - $this->assertEquals($expected, $result); - } - -/** - * testFindFakeThread method - * - * @return void - */ - public function testFindFakeThread() { - $this->loadFixtures('CategoryThread'); - $TestModel = new CategoryThread(); - - $fullDebug = $this->db->fullDebug; - $this->db->fullDebug = true; - $TestModel->recursive = 6; - $result = $TestModel->find('first', array('conditions' => array('CategoryThread.id' => 7))); - - $expected = array( - 'CategoryThread' => array( - 'id' => 7, - 'parent_id' => 6, - 'name' => 'Category 2.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 6, - 'parent_id' => 5, - 'name' => 'Category 2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 5, - 'parent_id' => 4, - 'name' => 'Category 1.1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 4, - 'parent_id' => 3, - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 3, - 'parent_id' => 2, - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ))))))); - - $this->db->fullDebug = $fullDebug; - $this->assertEquals($expected, $result); - } - -/** - * testFindAllFakeThread method - * - * @return void - */ - public function testFindAllFakeThread() { - $this->loadFixtures('CategoryThread'); - $TestModel = new CategoryThread(); - - $fullDebug = $this->db->fullDebug; - $this->db->fullDebug = true; - $TestModel->recursive = 6; - $result = $TestModel->find('all', null, null, 'CategoryThread.id ASC'); - $expected = array( - array( - 'CategoryThread' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => null, - 'parent_id' => null, - 'name' => null, - 'created' => null, - 'updated' => null, - 'ParentCategory' => array() - )), - array( - 'CategoryThread' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array() - )), - array( - 'CategoryThread' => array( - 'id' => 3, - 'parent_id' => 2, - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array() - ))), - array( - 'CategoryThread' => array( - 'id' => 4, - 'parent_id' => 3, - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 3, - 'parent_id' => 2, - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array() - )))), - array( - 'CategoryThread' => array( - 'id' => 5, - 'parent_id' => 4, - 'name' => 'Category 1.1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 4, - 'parent_id' => 3, - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 3, - 'parent_id' => 2, - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array() - ))))), - array( - 'CategoryThread' => array( - 'id' => 6, - 'parent_id' => 5, - 'name' => 'Category 2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 5, - 'parent_id' => 4, - 'name' => 'Category 1.1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 4, - 'parent_id' => 3, - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 3, - 'parent_id' => 2, - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array() - )))))), - array( - 'CategoryThread' => array( - 'id' => 7, - 'parent_id' => 6, - 'name' => 'Category 2.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ), - 'ParentCategory' => array( - 'id' => 6, - 'parent_id' => 5, - 'name' => 'Category 2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 5, - 'parent_id' => 4, - 'name' => 'Category 1.1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 4, - 'parent_id' => 3, - 'name' => 'Category 1.1.2', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 3, - 'parent_id' => 2, - 'name' => 'Category 1.1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 2, - 'parent_id' => 1, - 'name' => 'Category 1.1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31', - 'ParentCategory' => array( - 'id' => 1, - 'parent_id' => 0, - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - )))))))); - - $this->db->fullDebug = $fullDebug; - $this->assertEquals($expected, $result); - } - -/** - * testConditionalNumerics method - * - * @return void - */ - public function testConditionalNumerics() { - $this->loadFixtures('NumericArticle'); - $NumericArticle = new NumericArticle(); - $data = array('conditions' => array('title' => '12345abcde')); - $result = $NumericArticle->find('first', $data); - $this->assertTrue(!empty($result)); - - $data = array('conditions' => array('title' => '12345')); - $result = $NumericArticle->find('first', $data); - $this->assertTrue(empty($result)); - } - -/** - * test buildQuery() - * - * @return void - */ - public function testBuildQuery() { - $this->loadFixtures('User'); - $TestModel = new User(); - $TestModel->cacheQueries = false; - - $expected = array( - 'conditions' => array( - 'user' => 'larry' - ), - 'fields' => null, - 'joins' => array(), - 'limit' => null, - 'offset' => null, - 'order' => array( - 0 => null - ), - 'page' => 1, - 'group' => null, - 'callbacks' => true, - 'returnQuery' => true - ); - $result = $TestModel->buildQuery('all', array('returnQuery' => true, 'conditions' => array('user' => 'larry'))); - $this->assertEquals($expected, $result); - } - -/** - * test find('all') method - * - * @return void - */ - public function testFindAll() { - $this->loadFixtures('User'); - $TestModel = new User(); - $TestModel->cacheQueries = false; - - $result = $TestModel->find('all'); - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - )), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - )), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - )), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', - 'updated' => '2007-03-17 01:24:31' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('conditions' => 'User.id > 2')); - $expected = array( - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - )), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', - 'updated' => '2007-03-17 01:24:31' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array( - 'conditions' => array('User.id !=' => '0', 'User.user LIKE' => '%arr%') - )); - $expected = array( - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - )), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', - 'updated' => '2007-03-17 01:24:31' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('conditions' => array('User.id' => '0'))); - $expected = array(); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array( - 'conditions' => array('or' => array('User.id' => '0', 'User.user LIKE' => '%a%') - ))); - - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - )), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - )), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - )), - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', - 'updated' => '2007-03-17 01:24:31' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('fields' => 'User.id, User.user')); - $expected = array( - array('User' => array('id' => '1', 'user' => 'mariano')), - array('User' => array('id' => '2', 'user' => 'nate')), - array('User' => array('id' => '3', 'user' => 'larry')), - array('User' => array('id' => '4', 'user' => 'garrett'))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('fields' => 'User.user', 'order' => 'User.user ASC')); - $expected = array( - array('User' => array('user' => 'garrett')), - array('User' => array('user' => 'larry')), - array('User' => array('user' => 'mariano')), - array('User' => array('user' => 'nate'))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('fields' => 'User.user', 'order' => 'User.user DESC')); - $expected = array( - array('User' => array('user' => 'nate')), - array('User' => array('user' => 'mariano')), - array('User' => array('user' => 'larry')), - array('User' => array('user' => 'garrett'))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('limit' => 3, 'page' => 1)); - - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - )), - array( - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - )), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ))); - $this->assertEquals($expected, $result); - - $ids = array(4 => 1, 5 => 3); - $result = $TestModel->find('all', array( - 'conditions' => array('User.id' => $ids), - 'order' => 'User.id' - )); - $expected = array( - array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - )), - array( - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ))); - $this->assertEquals($expected, $result); - - // These tests are expected to fail on SQL Server since the LIMIT/OFFSET - // hack can't handle small record counts. - if (!($this->db instanceof Sqlserver)) { - $result = $TestModel->find('all', array('limit' => 3, 'page' => 2)); - $expected = array( - array( - 'User' => array( - 'id' => '4', - 'user' => 'garrett', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:22:23', - 'updated' => '2007-03-17 01:24:31' - ))); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array('limit' => 3, 'page' => 3)); - $expected = array(); - $this->assertEquals($expected, $result); - } - } - -/** - * test find('list') method - * - * @return void - */ - public function testGenerateFindList() { - $this->loadFixtures('Article', 'Apple', 'Post', 'Author', 'User', 'Comment'); - - $TestModel = new Article(); - $TestModel->displayField = 'title'; - - $result = $TestModel->find('list', array( - 'order' => 'Article.title ASC' - )); - - $expected = array( - 1 => 'First Article', - 2 => 'Second Article', - 3 => 'Third Article' - ); - $this->assertEquals($expected, $result); - - $db = ConnectionManager::getDataSource('test'); - if ($db instanceof Mysql) { - $result = $TestModel->find('list', array( - 'order' => array('FIELD(Article.id, 3, 2) ASC', 'Article.title ASC') - )); - $expected = array( - 1 => 'First Article', - 3 => 'Third Article', - 2 => 'Second Article' - ); - $this->assertEquals($expected, $result); - } - - $result = Set::combine( - $TestModel->find('all', array( - 'order' => 'Article.title ASC', - 'fields' => array('id', 'title') - )), - '{n}.Article.id', '{n}.Article.title' - ); - $expected = array( - 1 => 'First Article', - 2 => 'Second Article', - 3 => 'Third Article' - ); - $this->assertEquals($expected, $result); - - $result = Set::combine( - $TestModel->find('all', array( - 'order' => 'Article.title ASC' - )), - '{n}.Article.id', '{n}.Article' - ); - $expected = array( - 1 => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 2 => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 3 => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - )); - - $this->assertEquals($expected, $result); - - $result = Set::combine( - $TestModel->find('all', array( - 'order' => 'Article.title ASC' - )), - '{n}.Article.id', '{n}.Article', '{n}.Article.user_id' - ); - $expected = array( - 1 => array( - 1 => array( - 'id' => 1, - 'user_id' => 1, - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 3 => array( - 'id' => 3, - 'user_id' => 1, - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - )), - 3 => array( - 2 => array( - 'id' => 2, - 'user_id' => 3, - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ))); - - $this->assertEquals($expected, $result); - - $result = Set::combine( - $TestModel->find('all', array( - 'order' => 'Article.title ASC', - 'fields' => array('id', 'title', 'user_id') - )), - '{n}.Article.id', '{n}.Article.title', '{n}.Article.user_id' - ); - - $expected = array( - 1 => array( - 1 => 'First Article', - 3 => 'Third Article' - ), - 3 => array( - 2 => 'Second Article' - )); - $this->assertEquals($expected, $result); - - $TestModel = new Apple(); - $expected = array( - 1 => 'Red Apple 1', - 2 => 'Bright Red Apple', - 3 => 'green blue', - 4 => 'Test Name', - 5 => 'Blue Green', - 6 => 'My new apple', - 7 => 'Some odd color' - ); - - $this->assertEquals($expected, $TestModel->find('list')); - $this->assertEquals($expected, $TestModel->Parent->find('list')); - - $TestModel = new Post(); - $result = $TestModel->find('list', array( - 'fields' => 'Post.title' - )); - $expected = array( - 1 => 'First Post', - 2 => 'Second Post', - 3 => 'Third Post' - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('list', array( - 'fields' => 'title' - )); - $expected = array( - 1 => 'First Post', - 2 => 'Second Post', - 3 => 'Third Post' - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('list', array( - 'fields' => array('title', 'id') - )); - $expected = array( - 'First Post' => '1', - 'Second Post' => '2', - 'Third Post' => '3' - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('list', array( - 'fields' => array('title', 'id', 'created') - )); - $expected = array( - '2007-03-18 10:39:23' => array( - 'First Post' => '1' - ), - '2007-03-18 10:41:23' => array( - 'Second Post' => '2' - ), - '2007-03-18 10:43:23' => array( - 'Third Post' => '3' - ), - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('list', array( - 'fields' => array('Post.body') - )); - $expected = array( - 1 => 'First Post Body', - 2 => 'Second Post Body', - 3 => 'Third Post Body' - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('list', array( - 'fields' => array('Post.title', 'Post.body') - )); - $expected = array( - 'First Post' => 'First Post Body', - 'Second Post' => 'Second Post Body', - 'Third Post' => 'Third Post Body' - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('list', array( - 'fields' => array('Post.id', 'Post.title', 'Author.user'), - 'recursive' => 1 - )); - $expected = array( - 'mariano' => array( - 1 => 'First Post', - 3 => 'Third Post' - ), - 'larry' => array( - 2 => 'Second Post' - )); - $this->assertEquals($expected, $result); - - $TestModel = new User(); - $result = $TestModel->find('list', array( - 'fields' => array('User.user', 'User.password') - )); - $expected = array( - 'mariano' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'nate' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'larry' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'garrett' => '5f4dcc3b5aa765d61d8327deb882cf99' - ); - $this->assertEquals($expected, $result); - - $TestModel = new ModifiedAuthor(); - $result = $TestModel->find('list', array( - 'fields' => array('Author.id', 'Author.user') - )); - $expected = array( - 1 => 'mariano (CakePHP)', - 2 => 'nate (CakePHP)', - 3 => 'larry (CakePHP)', - 4 => 'garrett (CakePHP)' - ); - $this->assertEquals($expected, $result); - - $TestModel = new Article(); - $TestModel->displayField = 'title'; - $result = $TestModel->find('list', array( - 'conditions' => array('User.user' => 'mariano'), - 'recursive' => 0 - )); - $expected = array( - 1 => 'First Article', - 3 => 'Third Article' - ); - $this->assertEquals($expected, $result); - } - -/** - * testFindField method - * - * @return void - */ - public function testFindField() { - $this->loadFixtures('User'); - $TestModel = new User(); - - $TestModel->id = 1; - $result = $TestModel->field('user'); - $this->assertEquals('mariano', $result); - - $result = $TestModel->field('User.user'); - $this->assertEquals('mariano', $result); - - $TestModel->id = false; - $result = $TestModel->field('user', array( - 'user' => 'mariano' - )); - $this->assertEquals('mariano', $result); - - $result = $TestModel->field('COUNT(*) AS count', true); - $this->assertEquals(4, $result); - - $result = $TestModel->field('COUNT(*)', true); - $this->assertEquals(4, $result); - } - -/** - * testFindUnique method - * - * @return void - */ - public function testFindUnique() { - $this->loadFixtures('User'); - $TestModel = new User(); - - $this->assertFalse($TestModel->isUnique(array( - 'user' => 'nate' - ))); - $TestModel->id = 2; - $this->assertTrue($TestModel->isUnique(array( - 'user' => 'nate' - ))); - $this->assertFalse($TestModel->isUnique(array( - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99' - ))); - } - -/** - * test find('count') method - * - * @return void - */ - public function testFindCount() { - $this->loadFixtures('User', 'Article', 'Comment', 'Tag', 'ArticlesTag'); - - $TestModel = new User(); - $this->db->getLog(false, true); - $result = $TestModel->find('count'); - $this->assertEquals(4, $result); - - $this->db->getLog(false, true); - $fullDebug = $this->db->fullDebug; - $this->db->fullDebug = true; - $TestModel->order = 'User.id'; - $result = $TestModel->find('count'); - $this->db->fullDebug = $fullDebug; - $this->assertEquals(4, $result); - - $log = $this->db->getLog(); - $this->assertTrue(isset($log['log'][0]['query'])); - $this->assertNotRegExp('/ORDER\s+BY/', $log['log'][0]['query']); - - $Article = new Article(); - $Article->recursive = -1; - $expected = count($Article->find('all', array( - 'fields' => array('Article.user_id'), - 'group' => 'Article.user_id') - )); - $result = $Article->find('count', array('group' => array('Article.user_id'))); - $this->assertEquals($expected, $result); - } - -/** - * Test that find('first') does not use the id set to the object. - * - * @return void - */ - public function testFindFirstNoIdUsed() { - $this->loadFixtures('Project'); - - $Project = new Project(); - $Project->id = 3; - $result = $Project->find('first'); - - $this->assertEquals('Project 1', $result['Project']['name'], 'Wrong record retrieved'); - } - -/** - * test find with COUNT(DISTINCT field) - * - * @return void - */ - public function testFindCountDistinct() { - $this->skipIf($this->db instanceof Sqlite, 'SELECT COUNT(DISTINCT field) is not compatible with SQLite.'); - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $this->loadFixtures('Project'); - $TestModel = new Project(); - $TestModel->create(array('name' => 'project')) && $TestModel->save(); - $TestModel->create(array('name' => 'project')) && $TestModel->save(); - $TestModel->create(array('name' => 'project')) && $TestModel->save(); - - $result = $TestModel->find('count', array('fields' => 'DISTINCT name')); - $this->assertEquals(4, $result); - } - -/** - * Test find(count) with Db::expression - * - * @return void - */ - public function testFindCountWithDbExpressions() { - $this->skipIf($this->db instanceof Postgres, 'testFindCountWithDbExpressions is not compatible with Postgres.'); - - $this->loadFixtures('Project', 'Thread'); - $db = ConnectionManager::getDataSource('test'); - $TestModel = new Project(); - - $result = $TestModel->find('count', array('conditions' => array( - $db->expression('Project.name = \'Project 3\'') - ))); - $this->assertEquals(1, $result); - - $result = $TestModel->find('count', array('conditions' => array( - 'Project.name' => $db->expression('\'Project 3\'') - ))); - $this->assertEquals(1, $result); - } - -/** - * testFindMagic method - * - * @return void - */ - public function testFindMagic() { - $this->loadFixtures('User'); - $TestModel = new User(); - - $result = $TestModel->findByUser('mariano'); - $expected = array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - )); - $this->assertEquals($expected, $result); - - $result = $TestModel->findByPassword('5f4dcc3b5aa765d61d8327deb882cf99'); - $expected = array('User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - )); - $this->assertEquals($expected, $result); - } - -/** - * testRead method - * - * @return void - */ - public function testRead() { - $this->loadFixtures('User', 'Article'); - $TestModel = new User(); - - $result = $TestModel->read(); - $this->assertFalse($result); - - $TestModel->id = 2; - $result = $TestModel->read(); - $expected = array( - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - )); - $this->assertEquals($expected, $result); - - $result = $TestModel->read(null, 2); - $expected = array( - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - )); - $this->assertEquals($expected, $result); - - $TestModel->id = 2; - $result = $TestModel->read(array('id', 'user')); - $expected = array('User' => array('id' => '2', 'user' => 'nate')); - $this->assertEquals($expected, $result); - - $result = $TestModel->read('id, user', 2); - $expected = array( - 'User' => array( - 'id' => '2', - 'user' => 'nate' - )); - $this->assertEquals($expected, $result); - - $result = $TestModel->bindModel(array('hasMany' => array('Article'))); - $this->assertTrue($result); - - $TestModel->id = 1; - $result = $TestModel->read('id, user'); - $expected = array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Article' => array( - array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => '3', - 'user_id' => '1', - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ))); - $this->assertEquals($expected, $result); - } - -/** - * testRecursiveRead method - * - * @return void - */ - public function testRecursiveRead() { - $this->loadFixtures( - 'User', - 'Article', - 'Comment', - 'Tag', - 'ArticlesTag', - 'Featured', - 'ArticleFeatured' - ); - $TestModel = new User(); - - $result = $TestModel->bindModel(array('hasMany' => array('Article')), false); - $this->assertTrue($result); - - $TestModel->recursive = 0; - $result = $TestModel->read('id, user', 1); - $expected = array( - 'User' => array('id' => '1', 'user' => 'mariano'), - ); - $this->assertEquals($expected, $result); - - $TestModel->recursive = 1; - $result = $TestModel->read('id, user', 1); - $expected = array( - 'User' => array( - 'id' => '1', - 'user' => 'mariano' - ), - 'Article' => array( - array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - array( - 'id' => '3', - 'user_id' => '1', - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ))); - $this->assertEquals($expected, $result); - - $TestModel->recursive = 2; - $result = $TestModel->read('id, user', 3); - $expected = array( - 'User' => array( - 'id' => '3', - 'user' => 'larry' - ), - 'Article' => array( - array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31', - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => '5', - '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' - ), - array( - 'id' => '6', - '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' - )), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))))); - $this->assertEquals($expected, $result); - } - - public function testRecursiveFindAll() { - $this->loadFixtures( - 'User', - 'Article', - 'Comment', - 'Tag', - 'ArticlesTag', - 'Attachment', - 'ArticleFeatured', - 'ArticleFeaturedsTags', - 'Featured', - 'Category' - ); - $TestModel = new Article(); - - $result = $TestModel->find('all', array('conditions' => array('Article.user_id' => 1))); - $expected = array( - array( - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '2', - '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' - ), - array( - 'id' => '3', - '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' - ), - array( - 'id' => '4', - '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' - ) - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ))), - array( - 'Article' => array( - 'id' => '3', - 'user_id' => '1', - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array(), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->find('all', array( - 'conditions' => array('Article.user_id' => 3), - 'limit' => 1, - 'recursive' => 2 - )); - - $expected = array( - array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => '5', - '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' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Attachment' => array( - 'id' => '1', - 'comment_id' => 5, - 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', - 'updated' => '2007-03-18 10:53:31' - ) - ), - array( - 'id' => '6', - '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' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - ), - 'Attachment' => array() - ) - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - )))); - - $this->assertEquals($expected, $result); - - $Featured = new Featured(); - - $Featured->recursive = 2; - $Featured->bindModel(array( - 'belongsTo' => array( - 'ArticleFeatured' => array( - 'conditions' => "ArticleFeatured.published = 'Y'", - 'fields' => 'id, title, user_id, published' - ) - ) - )); - - $Featured->ArticleFeatured->unbindModel(array( - 'hasMany' => array('Attachment', 'Comment'), - 'hasAndBelongsToMany' => array('Tag')) - ); - - $orderBy = 'ArticleFeatured.id ASC'; - $result = $Featured->find('all', array( - 'order' => $orderBy, 'limit' => 3 - )); - - $expected = array( - array( - 'Featured' => array( - 'id' => '1', - 'article_featured_id' => '1', - 'category_id' => '1', - 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'ArticleFeatured' => array( - 'id' => '1', - 'title' => 'First Article', - 'user_id' => '1', - 'published' => 'Y', - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Category' => array(), - 'Featured' => array( - 'id' => '1', - 'article_featured_id' => '1', - 'category_id' => '1', - 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - 'Category' => array( - 'id' => '1', - 'parent_id' => '0', - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - )), - array( - 'Featured' => array( - 'id' => '2', - 'article_featured_id' => '2', - 'category_id' => '1', - 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'ArticleFeatured' => array( - 'id' => '2', - 'title' => 'Second Article', - 'user_id' => '3', - 'published' => 'Y', - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'Category' => array(), - 'Featured' => array( - 'id' => '2', - 'article_featured_id' => '2', - 'category_id' => '1', - 'published_date' => '2007-03-31 10:39:23', - 'end_date' => '2007-05-15 10:39:23', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - 'Category' => array( - 'id' => '1', - 'parent_id' => '0', - 'name' => 'Category 1', - 'created' => '2007-03-18 15:30:23', - 'updated' => '2007-03-18 15:32:31' - ))); - $this->assertEquals($expected, $result); - } - -/** - * testRecursiveFindAllWithLimit method - * - * @return void - */ - public function testRecursiveFindAllWithLimit() { - $this->loadFixtures('Article', 'User', 'Tag', 'ArticlesTag', 'Comment', 'Attachment'); - $TestModel = new Article(); - - $TestModel->hasMany['Comment']['limit'] = 2; - - $result = $TestModel->find('all', array( - 'conditions' => array('Article.user_id' => 1) - )); - $expected = array( - array( - 'Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array( - array( - 'id' => '1', - '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' - ), - array( - 'id' => '2', - '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' - ), - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ))), - array( - 'Article' => array( - 'id' => '3', - 'user_id' => '1', - 'title' => 'Third Article', - 'body' => 'Third Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Comment' => array(), - 'Tag' => array() - ) - ); - $this->assertEquals($expected, $result); - - $TestModel->hasMany['Comment']['limit'] = 1; - - $result = $TestModel->find('all', array( - 'conditions' => array('Article.user_id' => 3), - 'limit' => 1, - 'recursive' => 2 - )); - $expected = array( - array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => '5', - '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' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Attachment' => array( - 'id' => '1', - 'comment_id' => 5, - 'attachment' => 'attachment.zip', - 'created' => '2007-03-18 10:51:23', - 'updated' => '2007-03-18 10:53:31' - ) - ) - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ) - ) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * Testing availability of $this->findQueryType in Model callbacks - * - * @return void - */ - public function testFindQueryTypeInCallbacks() { - $this->loadFixtures('Comment'); - $Comment = new AgainModifiedComment(); - $comments = $Comment->find('all'); - $this->assertEquals('all', $comments[0]['Comment']['querytype']); - $comments = $Comment->find('first'); - $this->assertEquals('first', $comments['Comment']['querytype']); - } - -/** - * testVirtualFields() - * - * Test correct fetching of virtual fields - * currently is not possible to do Relation.virtualField - * - * @return void - */ - public function testVirtualFields() { - $this->loadFixtures('Post', 'Author'); - $Post = ClassRegistry::init('Post'); - $Post->virtualFields = array('two' => "1 + 1"); - $result = $Post->find('first'); - $this->assertEquals(2, $result['Post']['two']); - - // SQL Server does not support operators in expressions - if (!($this->db instanceof Sqlserver)) { - $Post->Author->virtualFields = array('false' => '1 = 2'); - $result = $Post->find('first'); - $this->assertEquals(2, $result['Post']['two']); - $this->assertFalse((bool)$result['Author']['false']); - } - - $result = $Post->find('first',array('fields' => array('author_id'))); - $this->assertFalse(isset($result['Post']['two'])); - $this->assertFalse(isset($result['Author']['false'])); - - $result = $Post->find('first',array('fields' => array('author_id', 'two'))); - $this->assertEquals(2, $result['Post']['two']); - $this->assertFalse(isset($result['Author']['false'])); - - $result = $Post->find('first',array('fields' => array('two'))); - $this->assertEquals(2, $result['Post']['two']); - - $Post->id = 1; - $result = $Post->field('two'); - $this->assertEquals(2, $result); - - $result = $Post->find('first',array( - 'conditions' => array('two' => 2), - 'limit' => 1 - )); - $this->assertEquals(2, $result['Post']['two']); - - $result = $Post->find('first',array( - 'conditions' => array('two <' => 3), - 'limit' => 1 - )); - $this->assertEquals(2, $result['Post']['two']); - - $result = $Post->find('first',array( - 'conditions' => array('NOT' => array('two >' => 3)), - 'limit' => 1 - )); - $this->assertEquals(2, $result['Post']['two']); - - $dbo = $Post->getDataSource(); - $Post->virtualFields = array('other_field' => 'Post.id + 1'); - $result = $Post->find('first', array( - 'conditions' => array('other_field' => 3), - 'limit' => 1 - )); - $this->assertEquals(2, $result['Post']['id']); - - $Post->virtualFields = array('other_field' => 'Post.id + 1'); - $result = $Post->find('all', array( - 'fields' => array($dbo->calculate($Post, 'max', array('other_field'))) - )); - $this->assertEquals(4, $result[0][0]['other_field']); - - ClassRegistry::flush(); - $Writing = ClassRegistry::init(array('class' => 'Post', 'alias' => 'Writing'), 'Model'); - $Writing->virtualFields = array('two' => "1 + 1"); - $result = $Writing->find('first'); - $this->assertEquals(2, $result['Writing']['two']); - - $Post->create(); - $Post->virtualFields = array('other_field' => 'COUNT(Post.id) + 1'); - $result = $Post->field('other_field'); - $this->assertEquals(4, $result); - } - -/** - * testVirtualFieldsOrder() - * - * Test correct order on virtual fields - * - * @return void - */ - public function testVirtualFieldsOrder() { - $this->loadFixtures('Post', 'Author'); - $Post = ClassRegistry::init('Post'); - $Post->virtualFields = array('other_field' => '10 - Post.id'); - $result = $Post->find('list', array('order' => array('Post.other_field' => 'ASC'))); - $expected = array( - '3' => 'Third Post', - '2' => 'Second Post', - '1' => 'First Post' - ); - $this->assertEquals($expected, $result); - - $result = $Post->find('list', array('order' => array('Post.other_field' => 'DESC'))); - $expected = array( - '1' => 'First Post', - '2' => 'Second Post', - '3' => 'Third Post' - ); - $this->assertEquals($expected, $result); - - $Post->Author->virtualFields = array('joined' => 'Post.id * Author.id'); - $result = $Post->find('all'); - $result = Set::extract('{n}.Author.joined', $result); - $expected = array(1, 6, 3); - $this->assertEquals($expected, $result); - - $result = $Post->find('all', array('order' => array('Author.joined' => 'ASC'))); - $result = Set::extract('{n}.Author.joined', $result); - $expected = array(1, 3, 6); - $this->assertEquals($expected, $result); - - $result = $Post->find('all', array('order' => array('Author.joined' => 'DESC'))); - $result = Set::extract('{n}.Author.joined', $result); - $expected = array(6, 3, 1); - $this->assertEquals($expected, $result); - } - -/** - * testVirtualFieldsMysql() - * - * Test correct fetching of virtual fields - * currently is not possible to do Relation.virtualField - * - */ - public function testVirtualFieldsMysql() { - $this->skipIf(!($this->db instanceof Mysql), 'The rest of virtualFields test only compatible with Mysql.'); - - $this->loadFixtures('Post', 'Author'); - $Post = ClassRegistry::init('Post'); - - $Post->create(); - $Post->virtualFields = array( - 'low_title' => 'lower(Post.title)', - 'unique_test_field' => 'COUNT(Post.id)' - ); - - $expectation = array( - 'Post' => array( - 'low_title' => 'first post', - 'unique_test_field' => 1 - ) - ); - - $result = $Post->find('first', array( - 'fields' => array_keys($Post->virtualFields), - 'group' => array('low_title') - )); - - $this->assertEquals($expectation, $result); - - $Author = ClassRegistry::init('Author'); - $Author->virtualFields = array( - 'full_name' => 'CONCAT(Author.user, " ", Author.id)' - ); - - $result = $Author->find('first', array( - 'conditions' => array('Author.user' => 'mariano'), - 'fields' => array('Author.password', 'Author.full_name'), - 'recursive' => -1 - )); - $this->assertTrue(isset($result['Author']['full_name'])); - - $result = $Author->find('first', array( - 'conditions' => array('Author.user' => 'mariano'), - 'fields' => array('Author.full_name', 'Author.password'), - 'recursive' => -1 - )); - $this->assertTrue(isset($result['Author']['full_name'])); - } - -/** - * test that virtual fields work when they don't contain functions. - * - * @return void - */ - public function testVirtualFieldAsAString() { - $this->loadFixtures('Post', 'Author'); - $Post = new Post(); - $Post->virtualFields = array( - 'writer' => 'Author.user' - ); - $result = $Post->find('first'); - $this->assertTrue(isset($result['Post']['writer']), 'virtual field not fetched %s'); - } - -/** - * test that isVirtualField will accept both aliased and non aliased fieldnames - * - * @return void - */ - public function testIsVirtualField() { - $this->loadFixtures('Post'); - $Post = ClassRegistry::init('Post'); - $Post->virtualFields = array('other_field' => 'COUNT(Post.id) + 1'); - - $this->assertTrue($Post->isVirtualField('other_field')); - $this->assertTrue($Post->isVirtualField('Post.other_field')); - $this->assertFalse($Post->isVirtualField('Comment.other_field'), 'Other models should not match.'); - $this->assertFalse($Post->isVirtualField('id')); - $this->assertFalse($Post->isVirtualField('Post.id')); - $this->assertFalse($Post->isVirtualField(array())); - } - -/** - * test that getting virtual fields works with and without model alias attached - * - * @return void - */ - public function testGetVirtualField() { - $this->loadFixtures('Post'); - $Post = ClassRegistry::init('Post'); - $Post->virtualFields = array('other_field' => 'COUNT(Post.id) + 1'); - - $this->assertEquals($Post->getVirtualField('other_field'), $Post->virtualFields['other_field']); - $this->assertEquals($Post->getVirtualField('Post.other_field'), $Post->virtualFields['other_field']); - } - -/** - * test that checks for error when NOT condition passed in key and a 1 element array value - * - * @return void - */ - public function testNotInArrayWithOneValue() { - $this->loadFixtures('Article'); - $Article = new Article(); - $Article->recursive = -1; - - $result = $Article->find( - 'all', - array( - 'conditions' => array( - 'Article.id NOT' => array(1) - ) - ) - ); - $this->assertTrue(is_array($result) && !empty($result)); - } - -/** - * test custom find method - * - * @return void - */ - public function testfindCustom() { - $this->loadFixtures('Article'); - $Article = new CustomArticle(); - $data = array('user_id' => 3, 'title' => 'Fourth Article', 'body' => 'Article Body, unpublished', 'published' => 'N'); - $Article->create($data); - $Article->save(); - $this->assertEquals(4, $Article->id); - - $result = $Article->find('published'); - $this->assertEquals(3, count($result)); - - $result = $Article->find('unPublished'); - $this->assertEquals(1, count($result)); - } - -} diff --git a/lib/Cake/Test/Case/Model/ModelTest.php b/lib/Cake/Test/Case/Model/ModelTest.php deleted file mode 100644 index a451c0364bc..00000000000 --- a/lib/Cake/Test/Case/Model/ModelTest.php +++ /dev/null @@ -1,45 +0,0 @@ -addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'ModelReadTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'ModelWriteTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'ModelDeleteTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'ModelValidationTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'ModelIntegrationTest.php'); - $suite->addTestFile(CORE_TEST_CASES . DS . 'Model' . DS . 'ModelCrossSchemaHabtmTest.php'); - return $suite; - } -} diff --git a/lib/Cake/Test/Case/Model/ModelTestBase.php b/lib/Cake/Test/Case/Model/ModelTestBase.php deleted file mode 100644 index 8e563c209a2..00000000000 --- a/lib/Cake/Test/Case/Model/ModelTestBase.php +++ /dev/null @@ -1,96 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Model', 'Model'); -App::uses('AppModel', 'Model'); -require_once dirname(__FILE__) . DS . 'models.php'; - -/** - * ModelBaseTest - * - * @package Cake.Test.Case.Model - */ -abstract class BaseModelTest extends CakeTestCase { - -/** - * autoFixtures property - * - * @var bool false - */ - public $autoFixtures = false; - -/** - * Whether backup global state for each test method or not - * - * @var bool false - */ - public $backupGlobals = false; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array( - 'core.category', 'core.category_thread', 'core.user', 'core.my_category', 'core.my_product', - 'core.my_user', 'core.my_categories_my_users', 'core.my_categories_my_products', - 'core.article', 'core.featured', 'core.article_featureds_tags', 'core.article_featured', - 'core.articles', 'core.numeric_article', 'core.tag', 'core.articles_tag', 'core.comment', - 'core.attachment', 'core.apple', 'core.sample', 'core.another_article', 'core.item', - 'core.advertisement', 'core.home', 'core.post', 'core.author', 'core.bid', 'core.portfolio', - 'core.product', 'core.project', 'core.thread', 'core.message', 'core.items_portfolio', - 'core.syfile', 'core.image', 'core.device_type', 'core.device_type_category', - 'core.feature_set', 'core.exterior_type_category', 'core.document', 'core.device', - 'core.document_directory', 'core.primary_model', 'core.secondary_model', 'core.something', - 'core.something_else', 'core.join_thing', 'core.join_a', 'core.join_b', 'core.join_c', - 'core.join_a_b', 'core.join_a_c', 'core.uuid', 'core.data_test', 'core.posts_tag', - 'core.the_paper_monkies', 'core.person', 'core.underscore_field', 'core.node', - 'core.dependency', 'core.story', 'core.stories_tag', 'core.cd', 'core.book', 'core.basket', - 'core.overall_favorite', 'core.account', 'core.content', 'core.content_account', - 'core.film_file', 'core.test_plugin_article', 'core.test_plugin_comment', 'core.uuiditem', - 'core.counter_cache_user', 'core.counter_cache_post', - 'core.counter_cache_user_nonstandard_primary_key', - 'core.counter_cache_post_nonstandard_primary_key', 'core.uuidportfolio', - 'core.uuiditems_uuidportfolio', 'core.uuiditems_uuidportfolio_numericid', 'core.fruit', - 'core.fruits_uuid_tag', 'core.uuid_tag', 'core.product_update_all', 'core.group_update_all', - 'core.player', 'core.guild', 'core.guilds_player', 'core.armor', 'core.armors_player', - 'core.bidding', 'core.bidding_message', 'core.site', 'core.domain', 'core.domains_site', - ); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->debug = Configure::read('debug'); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - Configure::write('debug', $this->debug); - ClassRegistry::flush(); - } -} diff --git a/lib/Cake/Test/Case/Model/ModelValidationTest.php b/lib/Cake/Test/Case/Model/ModelValidationTest.php deleted file mode 100644 index b2b79c66ca1..00000000000 --- a/lib/Cake/Test/Case/Model/ModelValidationTest.php +++ /dev/null @@ -1,994 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -require_once dirname(__FILE__) . DS . 'ModelTestBase.php'; - -/** - * ModelValidationTest - * - * @package Cake.Test.Case.Model - */ -class ModelValidationTest extends BaseModelTest { - -/** - * Tests validation parameter order in custom validation methods - * - * @return void - */ - public function testValidationParams() { - $TestModel = new ValidationTest1(); - $TestModel->validate['title'] = array( - 'rule' => 'customValidatorWithParams', - 'required' => true - ); - $TestModel->create(array('title' => 'foo')); - $TestModel->invalidFields(); - - $expected = array( - 'data' => array( - 'title' => 'foo' - ), - 'validator' => array( - 'rule' => 'customValidatorWithParams', - 'on' => null, - 'last' => true, - 'allowEmpty' => false, - 'required' => true - ), - 'or' => true, - 'ignoreOnSame' => 'id' - ); - $this->assertEquals($expected, $TestModel->validatorParams); - - $TestModel->validate['title'] = array( - 'rule' => 'customValidatorWithMessage', - 'required' => true - ); - $expected = array( - 'title' => array('This field will *never* validate! Muhahaha!') - ); - - $this->assertEquals($expected, $TestModel->invalidFields()); - - $TestModel->validate['title'] = array( - 'rule' => array('customValidatorWithSixParams', 'one', 'two', null, 'four'), - 'required' => true - ); - $TestModel->create(array('title' => 'foo')); - $TestModel->invalidFields(); - $expected = array( - 'data' => array( - 'title' => 'foo' - ), - 'one' => 'one', - 'two' => 'two', - 'three' => null, - 'four' => 'four', - 'five' => array( - 'rule' => array(1 => 'one', 2 => 'two', 3 => null, 4 => 'four'), - 'on' => null, - 'last' => true, - 'allowEmpty' => false, - 'required' => true - ), - 'six' => 6 - ); - $this->assertEquals($expected, $TestModel->validatorParams); - - $TestModel->validate['title'] = array( - 'rule' => array('customValidatorWithSixParams', 'one', array('two'), null, 'four', array('five' => 5)), - 'required' => true - ); - $TestModel->create(array('title' => 'foo')); - $TestModel->invalidFields(); - $expected = array( - 'data' => array( - 'title' => 'foo' - ), - 'one' => 'one', - 'two' => array('two'), - 'three' => null, - 'four' => 'four', - 'five' => array('five' => 5), - 'six' => array( - 'rule' => array(1 => 'one', 2 => array('two'), 3 => null, 4 => 'four', 5 => array('five' => 5)), - 'on' => null, - 'last' => true, - 'allowEmpty' => false, - 'required' => true - ) - ); - $this->assertEquals($expected, $TestModel->validatorParams); - } - -/** - * Tests validation parameter fieldList in invalidFields - * - * @return void - */ - public function testInvalidFieldsWithFieldListParams() { - $TestModel = new ValidationTest1(); - $TestModel->validate = $validate = array( - 'title' => array( - 'rule' => 'alphaNumeric', - 'required' => true - ), - 'name' => array( - 'rule' => 'alphaNumeric', - 'required' => true - )); - $TestModel->set(array('title' => '$$', 'name' => '##')); - $TestModel->invalidFields(array('fieldList' => array('title'))); - $expected = array( - 'title' => array('This field cannot be left blank') - ); - $this->assertEquals($expected, $TestModel->validationErrors); - $TestModel->validationErrors = array(); - - $TestModel->invalidFields(array('fieldList' => array('name'))); - $expected = array( - 'name' => array('This field cannot be left blank') - ); - $this->assertEquals($expected, $TestModel->validationErrors); - $TestModel->validationErrors = array(); - - $TestModel->invalidFields(array('fieldList' => array('name', 'title'))); - $expected = array( - 'name' => array('This field cannot be left blank'), - 'title' => array('This field cannot be left blank') - ); - $this->assertEquals($expected, $TestModel->validationErrors); - $TestModel->validationErrors = array(); - - $TestModel->whitelist = array('name'); - $TestModel->invalidFields(); - $expected = array('name' => array('This field cannot be left blank')); - $this->assertEquals($expected, $TestModel->validationErrors); - - $this->assertEquals($TestModel->validate, $validate); - } - -/** - * Test that invalidFields() integrates well with save(). And that fieldList can be an empty type. - * - * @return void - */ - public function testInvalidFieldsWhitelist() { - $TestModel = new ValidationTest1(); - $TestModel->validate = array( - 'title' => array( - 'rule' => 'alphaNumeric', - 'required' => true - ), - 'name' => array( - 'rule' => 'alphaNumeric', - 'required' => true - )); - - $TestModel->whitelist = array('name'); - $TestModel->save(array('name' => '#$$#', 'title' => '$$$$')); - - $expected = array('name' => array('This field cannot be left blank')); - $this->assertEquals($expected, $TestModel->validationErrors); - } - -/** - * testValidates method - * - * @return void - */ - public function testValidates() { - $TestModel = new TestValidate(); - - $TestModel->validate = array( - 'user_id' => 'numeric', - 'title' => array('allowEmpty' => false, 'rule' => 'notEmpty'), - 'body' => 'notEmpty' - ); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => '', - 'body' => 'body' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 'title', - 'body' => 'body' - )); - $result = $TestModel->create($data) && $TestModel->validates(); - $this->assertTrue($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => '0', - 'body' => 'body' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $TestModel->validate['modified'] = array('allowEmpty' => true, 'rule' => 'date'); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'modified' => '' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'modified' => '2007-05-01' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'modified' => 'invalid-date-here' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'modified' => 0 - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'modified' => '0' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $TestModel->validate['modified'] = array('allowEmpty' => false, 'rule' => 'date'); - - $data = array('TestValidate' => array('modified' => null)); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array('modified' => false)); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array('modified' => '')); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'modified' => '2007-05-01' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $TestModel->validate['slug'] = array('allowEmpty' => false, 'rule' => array('maxLength', 45)); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'slug' => '' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'slug' => 'slug-right-here' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $data = array('TestValidate' => array( - 'user_id' => '1', - 'title' => 0, - 'body' => 'body', - 'slug' => 'abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $TestModel->validate = array( - 'number' => array( - 'rule' => 'validateNumber', - 'min' => 3, - 'max' => 5 - ), - 'title' => array( - 'allowEmpty' => false, - 'rule' => 'notEmpty' - )); - - $data = array('TestValidate' => array( - 'title' => 'title', - 'number' => '0' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'title' => 'title', - 'number' => 0 - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'title' => 'title', - 'number' => '3' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $data = array('TestValidate' => array( - 'title' => 'title', - 'number' => 3 - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $TestModel->validate = array( - 'number' => array( - 'rule' => 'validateNumber', - 'min' => 5, - 'max' => 10 - ), - 'title' => array( - 'allowEmpty' => false, - 'rule' => 'notEmpty' - )); - - $data = array('TestValidate' => array( - 'title' => 'title', - 'number' => '3' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'title' => 'title', - 'number' => 3 - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $TestModel->validate = array( - 'title' => array( - 'allowEmpty' => false, - 'rule' => 'validateTitle' - )); - - $data = array('TestValidate' => array('title' => '')); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array('title' => 'new title')); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array('title' => 'title-new')); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $TestModel->validate = array('title' => array( - 'allowEmpty' => true, - 'rule' => 'validateTitle' - )); - $data = array('TestValidate' => array('title' => '')); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $TestModel->validate = array( - 'title' => array( - 'length' => array( - 'allowEmpty' => true, - 'rule' => array('maxLength', 10) - ))); - $data = array('TestValidate' => array('title' => '')); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $TestModel->validate = array( - 'title' => array( - 'rule' => array('userDefined', 'Article', 'titleDuplicate') - )); - $data = array('TestValidate' => array('title' => 'My Article Title')); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertFalse($result); - - $data = array('TestValidate' => array( - 'title' => 'My Article With a Different Title' - )); - $result = $TestModel->create($data); - $this->assertEquals($data, $result); - $result = $TestModel->validates(); - $this->assertTrue($result); - - $TestModel->validate = array( - 'title' => array( - 'tooShort' => array('rule' => array('minLength', 50)), - 'onlyLetters' => array('rule' => '/^[a-z]+$/i') - ), - ); - $data = array('TestValidate' => array( - 'title' => 'I am a short string' - )); - $TestModel->create($data); - $result = $TestModel->validates(); - $this->assertFalse($result); - $result = $TestModel->validationErrors; - $expected = array( - 'title' => array('tooShort') - ); - $this->assertEquals($expected, $result); - - $TestModel->validate = array( - 'title' => array( - 'tooShort' => array( - 'rule' => array('minLength', 50), - 'last' => false - ), - 'onlyLetters' => array('rule' => '/^[a-z]+$/i') - ), - ); - $data = array('TestValidate' => array( - 'title' => 'I am a short string' - )); - $TestModel->create($data); - $result = $TestModel->validates(); - $this->assertFalse($result); - $result = $TestModel->validationErrors; - $expected = array( - 'title' => array('tooShort', 'onlyLetters') - ); - $this->assertEquals($expected, $result); - } - -/** - * test that validates() checks all the 'with' associations as well for validation - * as this can cause partial/wrong data insertion. - * - * @return void - */ - public function testValidatesWithAssociations() { - $this->loadFixtures('Something', 'SomethingElse', 'JoinThing'); - $data = array( - 'Something' => array( - 'id' => 5, - 'title' => 'Extra Fields', - 'body' => 'Extra Fields Body', - 'published' => '1' - ), - 'SomethingElse' => array( - array('something_else_id' => 1, 'doomed' => '') - ) - ); - - $Something = new Something(); - $JoinThing = $Something->JoinThing; - - $JoinThing->validate = array('doomed' => array('rule' => 'notEmpty')); - - $expectedError = array('doomed' => array('This field cannot be left blank')); - - $Something->create(); - $result = $Something->save($data); - $this->assertFalse($result, 'Save occurred even when with models failed. %s'); - $this->assertEquals($expectedError, $JoinThing->validationErrors); - $count = $Something->find('count', array('conditions' => array('Something.id' => $data['Something']['id']))); - $this->assertSame($count, 0); - - $data = array( - 'Something' => array( - 'id' => 5, - 'title' => 'Extra Fields', - 'body' => 'Extra Fields Body', - 'published' => '1' - ), - 'SomethingElse' => array( - array('something_else_id' => 1, 'doomed' => 1), - array('something_else_id' => 1, 'doomed' => '') - ) - ); - $Something->create(); - $result = $Something->save($data); - $this->assertFalse($result, 'Save occurred even when with models failed. %s'); - - $joinRecords = $JoinThing->find('count', array( - 'conditions' => array('JoinThing.something_id' => $data['Something']['id']) - )); - $this->assertEquals(0, $joinRecords, 'Records were saved on the join table. %s'); - } - -/** - * test that saveAll and with models with validation interact well - * - * @return void - */ - public function testValidatesWithModelsAndSaveAll() { - $data = array( - 'Something' => array( - 'id' => 5, - 'title' => 'Extra Fields', - 'body' => 'Extra Fields Body', - 'published' => '1' - ), - 'SomethingElse' => array( - array('something_else_id' => 1, 'doomed' => '') - ) - ); - $Something = new Something(); - $JoinThing = $Something->JoinThing; - - $JoinThing->validate = array('doomed' => array('rule' => 'notEmpty')); - $expectedError = array('doomed' => array('This field cannot be left blank')); - - $Something->create(); - $result = $Something->saveAll($data, array('validate' => 'only')); - $this->assertFalse($result); - $this->assertEquals($expectedError, $JoinThing->validationErrors); - - $Something->create(); - $result = $Something->saveAll($data, array('validate' => 'first')); - $this->assertFalse($result); - $this->assertEquals($expectedError, $JoinThing->validationErrors); - - $count = $Something->find('count', array('conditions' => array('Something.id' => $data['Something']['id']))); - $this->assertSame($count, 0); - - $joinRecords = $JoinThing->find('count', array( - 'conditions' => array('JoinThing.something_id' => $data['Something']['id']) - )); - $this->assertEquals(0, $joinRecords, 'Records were saved on the join table. %s'); - } - -/** - * test that saveAll and with models at initial insert (no id has set yet) - * with validation interact well - * - * @return void - */ - public function testValidatesWithModelsAndSaveAllWithoutId() { - $this->loadFixtures('Post', 'Author'); - - $data = array( - 'Author' => array( - 'name' => 'Foo Bar', - ), - 'Post' => array( - array('title' => 'Hello'), - array('title' => 'World'), - ) - ); - $Author = new Author(); - $Post = $Author->Post; - - $Post->validate = array('author_id' => array('rule' => 'numeric')); - - $Author->create(); - $result = $Author->saveAll($data, array('validate' => 'only')); - $this->assertTrue($result); - - $Author->create(); - $result = $Author->saveAll($data, array('validate' => 'first')); - $this->assertTrue($result); - $this->assertFalse(is_null($Author->id)); - - $id = $Author->id; - $count = $Author->find('count', array('conditions' => array('Author.id' => $id))); - $this->assertSame($count, 1); - - $count = $Post->find('count', array( - 'conditions' => array('Post.author_id' => $id) - )); - $this->assertEquals($count, count($data['Post'])); - } - -/** - * Test that missing validation methods trigger errors in development mode. - * Helps to make development easier. - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testMissingValidationErrorTriggering() { - Configure::write('debug', 2); - - $TestModel = new ValidationTest1(); - $TestModel->create(array('title' => 'foo')); - $TestModel->validate = array( - 'title' => array( - 'rule' => array('thisOneBringsThePain'), - 'required' => true - ) - ); - $TestModel->invalidFields(array('fieldList' => array('title'))); - } - -/** - * Test that missing validation methods does not trigger errors in production mode. - * - * @return void - */ - public function testMissingValidationErrorNoTriggering() { - Configure::write('debug', 0); - $TestModel = new ValidationTest1(); - $TestModel->create(array('title' => 'foo')); - $TestModel->validate = array( - 'title' => array( - 'rule' => array('thisOneBringsThePain'), - 'required' => true - ) - ); - $TestModel->invalidFields(array('fieldList' => array('title'))); - $this->assertEquals(array(), $TestModel->validationErrors); - } - -/** - * Test placeholder replacement when validation message is an array - * - * @return void - */ - public function testValidationMessageAsArray() { - $TestModel = new ValidationTest1(); - $TestModel->validate = array( - 'title' => array( - 'minLength' => array( - 'rule' => array('minLength', 6), - 'required' => true, - 'message' => 'Minimum length allowed is %d chars', - 'last' => false - ), - 'between' => array( - 'rule' => array('between', 5, 15), - 'message' => array('You may enter up to %s chars (minimum is %s chars)', 14, 6) - ) - ) - ); - - $TestModel->create(); - $TestModel->invalidFields(); - $expected = array( - 'title' => array( - 'Minimum length allowed is 6 chars', - ) - ); - $this->assertEquals($expected, $TestModel->validationErrors); - - $TestModel->create(array('title' => 'foo')); - $TestModel->invalidFields(); - $expected = array( - 'title' => array( - 'Minimum length allowed is 6 chars', - 'You may enter up to 14 chars (minimum is 6 chars)' - ) - ); - $this->assertEquals($expected, $TestModel->validationErrors); - } - -/** - * Test for 'on' => [create|update] in validation rules. - * - * @return void - */ - public function testStateValidation() { - $this->loadFixtures('Article'); - $Article = new Article(); - - $data = array( - 'Article' => array( - 'title' => '', - 'body' => 'Extra Fields Body', - 'published' => '1' - ) - ); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'on' => 'create' - ) - ) - ); - - $Article->create($data); - $this->assertFalse($Article->validates()); - - $Article->save(null, array('validate' => false)); - $data['Article']['id'] = $Article->id; - $Article->set($data); - $this->assertTrue($Article->validates()); - - unset($data['Article']['id']); - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'on' => 'update' - ) - ) - ); - - $Article->create($data); - $this->assertTrue($Article->validates()); - - $Article->save(null, array('validate' => false)); - $data['Article']['id'] = $Article->id; - $Article->set($data); - $this->assertFalse($Article->validates()); - } - -/** - * Test for 'required' => [create|update] in validation rules. - * - * @return void - */ - public function testStateRequiredValidation() { - $this->loadFixtures('Article'); - $Article = new Article(); - - // no title field present - $data = array( - 'Article' => array( - 'body' => 'Extra Fields Body', - 'published' => '1' - ) - ); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'create' - ) - ) - ); - - $Article->create($data); - $this->assertFalse($Article->validates()); - - $Article->save(null, array('validate' => false)); - $data['Article']['id'] = $Article->id; - $Article->set($data); - $this->assertTrue($Article->validates()); - - unset($data['Article']['id']); - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'update' - ) - ) - ); - - $Article->create($data); - $this->assertTrue($Article->validates()); - - $Article->save(null, array('validate' => false)); - $data['Article']['id'] = $Article->id; - $Article->set($data); - $this->assertFalse($Article->validates()); - } - -/** - * Test that 'required' and 'on' are not conflicting - * - * @return void - */ - public function testOnRequiredConflictValidation() { - $this->loadFixtures('Article'); - $Article = new Article(); - - // no title field present - $data = array( - 'Article' => array( - 'body' => 'Extra Fields Body', - 'published' => '1' - ) - ); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'create', - 'on' => 'create' - ) - ) - ); - - $Article->create($data); - $this->assertFalse($Article->validates()); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'update', - 'on' => 'create' - ) - ) - ); - - $Article->create($data); - $this->assertTrue($Article->validates()); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'create', - 'on' => 'update' - ) - ) - ); - - $Article->create($data); - $this->assertTrue($Article->validates()); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'update', - 'on' => 'update' - ) - ) - ); - - $Article->create($data); - $this->assertTrue($Article->validates()); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'create', - 'on' => 'create' - ) - ) - ); - - $Article->save(null, array('validate' => false)); - $data['Article']['id'] = $Article->id; - $Article->set($data); - $this->assertTrue($Article->validates()); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'update', - 'on' => 'create' - ) - ) - ); - - $Article->set($data); - $this->assertTrue($Article->validates()); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'create', - 'on' => 'update' - ) - ) - ); - - $Article->set($data); - $this->assertTrue($Article->validates()); - - $Article->validate = array( - 'title' => array( - 'notempty' => array( - 'rule' => 'notEmpty', - 'required' => 'update', - 'on' => 'update' - ) - ) - ); - - $Article->set($data); - $this->assertFalse($Article->validates()); - } - -} diff --git a/lib/Cake/Test/Case/Model/ModelWriteTest.php b/lib/Cake/Test/Case/Model/ModelWriteTest.php deleted file mode 100644 index 994e944817f..00000000000 --- a/lib/Cake/Test/Case/Model/ModelWriteTest.php +++ /dev/null @@ -1,6821 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -require_once dirname(__FILE__) . DS . 'ModelTestBase.php'; -/** - * ModelWriteTest - * - * @package Cake.Test.Case.Model - */ -class ModelWriteTest extends BaseModelTest { - -/** - * testInsertAnotherHabtmRecordWithSameForeignKey method - * - * @access public - * @return void - */ - public function testInsertAnotherHabtmRecordWithSameForeignKey() { - $this->loadFixtures('JoinA', 'JoinB', 'JoinAB', 'JoinC', 'JoinAC'); - $TestModel = new JoinA(); - - $result = $TestModel->JoinAsJoinB->findById(1); - $expected = array( - 'JoinAsJoinB' => array( - 'id' => 1, - 'join_a_id' => 1, - 'join_b_id' => 2, - 'other' => 'Data for Join A 1 Join B 2', - 'created' => '2008-01-03 10:56:33', - 'updated' => '2008-01-03 10:56:33' - )); - $this->assertEquals($expected, $result); - - $TestModel->JoinAsJoinB->create(); - $data = array( - 'join_a_id' => 1, - 'join_b_id' => 1, - 'other' => 'Data for Join A 1 Join B 1', - 'created' => '2008-01-03 10:56:44', - 'updated' => '2008-01-03 10:56:44' - ); - $result = $TestModel->JoinAsJoinB->save($data); - $lastInsertId = $TestModel->JoinAsJoinB->getLastInsertID(); - $data['id'] = $lastInsertId; - $this->assertEquals(array('JoinAsJoinB' => $data), $result); - $this->assertTrue($lastInsertId != null); - - $result = $TestModel->JoinAsJoinB->findById(1); - $expected = array( - 'JoinAsJoinB' => array( - 'id' => 1, - 'join_a_id' => 1, - 'join_b_id' => 2, - 'other' => 'Data for Join A 1 Join B 2', - 'created' => '2008-01-03 10:56:33', - 'updated' => '2008-01-03 10:56:33' - )); - $this->assertEquals($expected, $result); - - $updatedValue = 'UPDATED Data for Join A 1 Join B 2'; - $TestModel->JoinAsJoinB->id = 1; - $result = $TestModel->JoinAsJoinB->saveField('other', $updatedValue, false); - $this->assertFalse(empty($result)); - - $result = $TestModel->JoinAsJoinB->findById(1); - $this->assertEquals($updatedValue, $result['JoinAsJoinB']['other']); - } - -/** - * testSaveDateAsFirstEntry method - * - * @return void - */ - public function testSaveDateAsFirstEntry() { - $this->loadFixtures('Article', 'User', 'Comment', 'Attachment', 'Tag', 'ArticlesTag'); - - $Article = new Article(); - - $data = array( - 'Article' => array( - 'created' => array( - 'day' => '1', - 'month' => '1', - 'year' => '2008' - ), - 'title' => 'Test Title', - 'user_id' => 1 - )); - $Article->create(); - $result = $Article->save($data); - $this->assertFalse(empty($result)); - - $testResult = $Article->find('first', array('conditions' => array('Article.title' => 'Test Title'))); - - $this->assertEquals($testResult['Article']['title'], $data['Article']['title']); - $this->assertEquals('2008-01-01 00:00:00', $testResult['Article']['created']); - } - -/** - * testUnderscoreFieldSave method - * - * @return void - */ - public function testUnderscoreFieldSave() { - $this->loadFixtures('UnderscoreField'); - $UnderscoreField = new UnderscoreField(); - - $currentCount = $UnderscoreField->find('count'); - $this->assertEquals(3, $currentCount); - $data = array('UnderscoreField' => array( - 'user_id' => '1', - 'my_model_has_a_field' => 'Content here', - 'body' => 'Body', - 'published' => 'Y', - 'another_field' => 4 - )); - $ret = $UnderscoreField->save($data); - $this->assertFalse(empty($ret)); - - $currentCount = $UnderscoreField->find('count'); - $this->assertEquals(4, $currentCount); - } - -/** - * testAutoSaveUuid method - * - * @return void - */ - public function testAutoSaveUuid() { - // SQLite does not support non-integer primary keys - $this->skipIf($this->db instanceof Sqlite, 'This test is not compatible with SQLite.'); - - $this->loadFixtures('Uuid'); - $TestModel = new Uuid(); - - $TestModel->save(array('title' => 'Test record')); - $result = $TestModel->findByTitle('Test record'); - $this->assertEquals( - array_keys($result['Uuid']), - array('id', 'title', 'count', 'created', 'updated') - ); - $this->assertEquals(36, strlen($result['Uuid']['id'])); - } - -/** - * Ensure that if the id key is null but present the save doesn't fail (with an - * x sql error: "Column id specified twice") - * - * @return void - */ - public function testSaveUuidNull() { - // SQLite does not support non-integer primary keys - $this->skipIf($this->db instanceof Sqlite, 'This test is not compatible with SQLite.'); - - $this->loadFixtures('Uuid'); - $TestModel = new Uuid(); - - $TestModel->save(array('title' => 'Test record', 'id' => null)); - $result = $TestModel->findByTitle('Test record'); - $this->assertEquals( - array_keys($result['Uuid']), - array('id', 'title', 'count', 'created', 'updated') - ); - $this->assertEquals(36, strlen($result['Uuid']['id'])); - } - -/** - * testZeroDefaultFieldValue method - * - * @return void - */ - public function testZeroDefaultFieldValue() { - $this->skipIf($this->db instanceof Sqlite, 'SQLite uses loose typing, this operation is unsupported.'); - - $this->loadFixtures('DataTest'); - $TestModel = new DataTest(); - - $TestModel->create(array()); - $TestModel->save(); - $result = $TestModel->findById($TestModel->id); - $this->assertEquals(0, $result['DataTest']['count']); - $this->assertEquals(0, $result['DataTest']['float']); - } - -/** - * Tests validation parameter order in custom validation methods - * - * @return void - */ - public function testAllowSimulatedFields() { - $TestModel = new ValidationTest1(); - - $TestModel->create(array( - 'title' => 'foo', - 'bar' => 'baz' - )); - $expected = array( - 'ValidationTest1' => array( - 'title' => 'foo', - 'bar' => 'baz' - )); - $this->assertEquals($expected, $TestModel->data); - } - -/** - * test that Caches are getting cleared on save(). - * ensure that both inflections of controller names are getting cleared - * as url for controller could be either overallFavorites/index or overall_favorites/index - * - * @return void - */ - public function testCacheClearOnSave() { - $_back = array( - 'check' => Configure::read('Cache.check'), - 'disable' => Configure::read('Cache.disable'), - ); - Configure::write('Cache.check', true); - Configure::write('Cache.disable', false); - - $this->loadFixtures('OverallFavorite'); - $OverallFavorite = new OverallFavorite(); - - touch(CACHE . 'views' . DS . 'some_dir_overallfavorites_index.php'); - touch(CACHE . 'views' . DS . 'some_dir_overall_favorites_index.php'); - - $data = array( - 'OverallFavorite' => array( - 'id' => 22, - 'model_type' => '8-track', - 'model_id' => '3', - 'priority' => '1' - ) - ); - $OverallFavorite->create($data); - $OverallFavorite->save(); - - $this->assertFalse(file_exists(CACHE . 'views' . DS . 'some_dir_overallfavorites_index.php')); - $this->assertFalse(file_exists(CACHE . 'views' . DS . 'some_dir_overall_favorites_index.php')); - - Configure::write('Cache.check', $_back['check']); - Configure::write('Cache.disable', $_back['disable']); - } - -/** - * testSaveWithCounterCache method - * - * @return void - */ - public function testSaveWithCounterCache() { - $this->loadFixtures('Syfile', 'Item', 'Image', 'Portfolio', 'ItemsPortfolio'); - $TestModel = new Syfile(); - $TestModel2 = new Item(); - - $result = $TestModel->findById(1); - $this->assertSame($result['Syfile']['item_count'], null); - - $TestModel2->save(array( - 'name' => 'Item 7', - 'syfile_id' => 1, - 'published' => false - )); - - $result = $TestModel->findById(1); - $this->assertEquals(2, $result['Syfile']['item_count']); - - $TestModel2->delete(1); - $result = $TestModel->findById(1); - $this->assertEquals(1, $result['Syfile']['item_count']); - - $TestModel2->id = 2; - $TestModel2->saveField('syfile_id', 1); - - $result = $TestModel->findById(1); - $this->assertEquals(2, $result['Syfile']['item_count']); - - $result = $TestModel->findById(2); - $this->assertEquals(0, $result['Syfile']['item_count']); - } - -/** - * Tests that counter caches are updated when records are added - * - * @return void - */ - public function testCounterCacheIncrease() { - $this->loadFixtures('CounterCacheUser', 'CounterCachePost'); - $User = new CounterCacheUser(); - $Post = new CounterCachePost(); - $data = array('Post' => array( - 'id' => 22, - 'title' => 'New Post', - 'user_id' => 66 - )); - - $Post->save($data); - $user = $User->find('first', array( - 'conditions' => array('id' => 66), - 'recursive' => -1 - )); - - $result = $user[$User->alias]['post_count']; - $expected = 3; - $this->assertEquals($expected, $result); - } - -/** - * Tests that counter caches are updated when records are deleted - * - * @return void - */ - public function testCounterCacheDecrease() { - $this->loadFixtures('CounterCacheUser', 'CounterCachePost'); - $User = new CounterCacheUser(); - $Post = new CounterCachePost(); - - $Post->delete(2); - $user = $User->find('first', array( - 'conditions' => array('id' => 66), - 'recursive' => -1 - )); - - $result = $user[$User->alias]['post_count']; - $expected = 1; - $this->assertEquals($expected, $result); - } - -/** - * Tests that counter caches are updated when foreign keys of counted records change - * - * @return void - */ - public function testCounterCacheUpdated() { - $this->loadFixtures('CounterCacheUser', 'CounterCachePost'); - $User = new CounterCacheUser(); - $Post = new CounterCachePost(); - - $data = $Post->find('first', array( - 'conditions' => array('id' => 1), - 'recursive' => -1 - )); - $data[$Post->alias]['user_id'] = 301; - $Post->save($data); - - $users = $User->find('all',array('order' => 'User.id')); - $this->assertEquals(1, $users[0]['User']['post_count']); - $this->assertEquals(2, $users[1]['User']['post_count']); - } - -/** - * Test counter cache with models that use a non-standard (i.e. not using 'id') - * as their primary key. - * - * @return void - */ - public function testCounterCacheWithNonstandardPrimaryKey() { - $this->loadFixtures( - 'CounterCacheUserNonstandardPrimaryKey', - 'CounterCachePostNonstandardPrimaryKey' - ); - - $User = new CounterCacheUserNonstandardPrimaryKey(); - $Post = new CounterCachePostNonstandardPrimaryKey(); - - $data = $Post->find('first', array( - 'conditions' => array('pid' => 1), - 'recursive' => -1 - )); - $data[$Post->alias]['uid'] = 301; - $Post->save($data); - - $users = $User->find('all',array('order' => 'User.uid')); - $this->assertEquals(1, $users[0]['User']['post_count']); - $this->assertEquals(2, $users[1]['User']['post_count']); - } - -/** - * test Counter Cache With Self Joining table - * - * @return void - */ - public function testCounterCacheWithSelfJoin() { - $this->skipIf($this->db instanceof Sqlite, 'SQLite 2.x does not support ALTER TABLE ADD COLUMN'); - - $this->loadFixtures('CategoryThread'); - $column = 'COLUMN '; - if ($this->db instanceof Sqlserver) { - $column = ''; - } - $column .= $this->db->buildColumn(array('name' => 'child_count', 'type' => 'integer')); - $this->db->query('ALTER TABLE ' . $this->db->fullTableName('category_threads') . ' ADD ' . $column); - $this->db->flushMethodCache(); - $Category = new CategoryThread(); - $result = $Category->updateAll(array('CategoryThread.name' => "'updated'"), array('CategoryThread.parent_id' => 5)); - $this->assertFalse(empty($result)); - - $Category = new CategoryThread(); - $Category->belongsTo['ParentCategory']['counterCache'] = 'child_count'; - $Category->updateCounterCache(array('parent_id' => 5)); - $result = Set::extract($Category->find('all', array('conditions' => array('CategoryThread.id' => 5))), '{n}.CategoryThread.child_count'); - $expected = array(1); - $this->assertEquals($expected, $result); - } - -/** - * testSaveWithCounterCacheScope method - * - * @return void - */ - public function testSaveWithCounterCacheScope() { - $this->loadFixtures('Syfile', 'Item', 'Image', 'ItemsPortfolio', 'Portfolio'); - $TestModel = new Syfile(); - $TestModel2 = new Item(); - $TestModel2->belongsTo['Syfile']['counterCache'] = true; - $TestModel2->belongsTo['Syfile']['counterScope'] = array('published' => true); - - $result = $TestModel->findById(1); - $this->assertSame($result['Syfile']['item_count'], null); - - $TestModel2->save(array( - 'name' => 'Item 7', - 'syfile_id' => 1, - 'published' => true - )); - - $result = $TestModel->findById(1); - - $this->assertEquals(1, $result['Syfile']['item_count']); - - $TestModel2->id = 1; - $TestModel2->saveField('published', true); - $result = $TestModel->findById(1); - $this->assertEquals(2, $result['Syfile']['item_count']); - - $TestModel2->save(array( - 'id' => 1, - 'syfile_id' => 1, - 'published' => false - )); - - $result = $TestModel->findById(1); - $this->assertEquals(1, $result['Syfile']['item_count']); - } - -/** - * Tests having multiple counter caches for an associated model - * - * @access public - * @return void - */ - public function testCounterCacheMultipleCaches() { - $this->loadFixtures('CounterCacheUser', 'CounterCachePost'); - $User = new CounterCacheUser(); - $Post = new CounterCachePost(); - $Post->unbindModel(array('belongsTo' => array('User')), false); - $Post->bindModel(array( - 'belongsTo' => array( - 'User' => array( - 'className' => 'CounterCacheUser', - 'foreignKey' => 'user_id', - 'counterCache' => array( - true, - 'posts_published' => array('Post.published' => true) - ) - ) - ) - ), false); - - // Count Increase - $user = $User->find('first', array( - 'conditions' => array('id' => 66), - 'recursive' => -1 - )); - $data = array('Post' => array( - 'id' => 22, - 'title' => 'New Post', - 'user_id' => 66, - 'published' => true - )); - $Post->save($data); - $result = $User->find('first', array( - 'conditions' => array('id' => 66), - 'recursive' => -1 - )); - $this->assertEquals(3, $result[$User->alias]['post_count']); - $this->assertEquals(2, $result[$User->alias]['posts_published']); - - // Count decrease - $Post->delete(1); - $result = $User->find('first', array( - 'conditions' => array('id' => 66), - 'recursive' => -1 - )); - $this->assertEquals(2, $result[$User->alias]['post_count']); - $this->assertEquals(2, $result[$User->alias]['posts_published']); - - // Count update - $data = $Post->find('first', array( - 'conditions' => array('id' => 1), - 'recursive' => -1 - )); - $data[$Post->alias]['user_id'] = 301; - $Post->save($data); - $result = $User->find('all',array('order' => 'User.id')); - $this->assertEquals(2, $result[0]['User']['post_count']); - $this->assertEquals(1, $result[1]['User']['posts_published']); - } - -/** - * test that beforeValidate returning false can abort saves. - * - * @return void - */ - public function testBeforeValidateSaveAbortion() { - $this->loadFixtures('Post'); - $Model = new CallbackPostTestModel(); - $Model->beforeValidateReturn = false; - - $data = array( - 'title' => 'new article', - 'body' => 'this is some text.' - ); - $Model->create(); - $result = $Model->save($data); - $this->assertFalse($result); - } - -/** - * test that beforeSave returning false can abort saves. - * - * @return void - */ - public function testBeforeSaveSaveAbortion() { - $this->loadFixtures('Post'); - $Model = new CallbackPostTestModel(); - $Model->beforeSaveReturn = false; - - $data = array( - 'title' => 'new article', - 'body' => 'this is some text.' - ); - $Model->create(); - $result = $Model->save($data); - $this->assertFalse($result); - } - -/** - * testSaveField method - * - * @return void - */ - public function testSaveField() { - $this->loadFixtures('Article'); - $TestModel = new Article(); - - $TestModel->id = 1; - $result = $TestModel->saveField('title', 'New First Article'); - $this->assertFalse(empty($result)); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body'), 1); - $expected = array('Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'New First Article', - 'body' => 'First Article Body' - )); - $this->assertEquals($expected, $result); - - $TestModel->id = 1; - $result = $TestModel->saveField('title', ''); - $this->assertFalse(empty($result)); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body'), 1); - $expected = array('Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => '', - 'body' => 'First Article Body' - )); - $result['Article']['title'] = trim($result['Article']['title']); - $this->assertEquals($expected, $result); - - $TestModel->id = 1; - $TestModel->set('body', 'Messed up data'); - $result = $TestModel->saveField('title', 'First Article'); - $this->assertFalse(empty($result)); - $result = $TestModel->read(array('id', 'user_id', 'title', 'body'), 1); - $expected = array('Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body' - )); - $this->assertEquals($expected, $result); - - $TestModel->recursive = -1; - $TestModel->read(array('id', 'user_id', 'title', 'body'), 1); - - $TestModel->id = 1; - $result = $TestModel->saveField('title', '', true); - $this->assertFalse($result); - - $TestModel->recursive = -1; - $TestModel->id = 1; - $result = $TestModel->saveField('user_id', 9999); - $this->assertTrue((bool)$result); - - $result = $TestModel->read(array('id', 'user_id'), 1); - $expected = array('Article' => array( - 'id' => '1', - 'user_id' => '9999', - )); - $this->assertEquals($expected, $result); - - $this->loadFixtures('Node', 'Dependency'); - $Node = new Node(); - $Node->set('id', 1); - $result = $Node->read(); - $this->assertEquals(array('Second'), Set::extract('/ParentNode/name', $result)); - - $Node->saveField('state', 10); - $result = $Node->read(); - $this->assertEquals(array('Second'), Set::extract('/ParentNode/name', $result)); - } - -/** - * testSaveWithCreate method - * - * @return void - */ - public function testSaveWithCreate() { - $this->loadFixtures( - 'User', - 'Article', - 'User', - 'Comment', - 'Tag', - 'ArticlesTag', - 'Attachment' - ); - $TestModel = new User(); - - $data = array('User' => array( - 'user' => 'user', - 'password' => '' - )); - $result = $TestModel->save($data); - $this->assertFalse($result); - $this->assertTrue(!empty($TestModel->validationErrors)); - - $TestModel = new Article(); - - $data = array('Article' => array( - 'user_id' => '', - 'title' => '', - 'body' => '' - )); - $result = $TestModel->create($data) && $TestModel->save(); - $this->assertFalse($result); - $this->assertTrue(!empty($TestModel->validationErrors)); - - $data = array('Article' => array( - 'id' => 1, - 'user_id' => '1', - 'title' => 'New First Article', - 'body' => '' - )); - $result = $TestModel->create($data) && $TestModel->save(); - $this->assertFalse($result); - - $data = array('Article' => array( - 'id' => 1, - 'title' => 'New First Article' - )); - $result = $TestModel->create() && $TestModel->save($data, false); - $this->assertFalse(empty($result)); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 1); - $expected = array('Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'New First Article', - 'body' => 'First Article Body', - 'published' => 'N' - )); - $this->assertEquals($expected, $result); - - $data = array('Article' => array( - 'id' => 1, - 'user_id' => '2', - 'title' => 'First Article', - 'body' => 'New First Article Body', - 'published' => 'Y' - )); - $result = $TestModel->create() && $TestModel->save($data, true, array('id', 'title', 'published')); - $this->assertFalse(empty($result)); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 1); - $expected = array('Article' => array( - 'id' => '1', - 'user_id' => '1', - 'title' => 'First Article', - 'body' => 'First Article Body', - 'published' => 'Y' - )); - $this->assertEquals($expected, $result); - - $data = array( - 'Article' => array( - 'user_id' => '2', - 'title' => 'New Article', - 'body' => 'New Article Body', - 'created' => '2007-03-18 14:55:23', - 'updated' => '2007-03-18 14:57:31' - ), - 'Tag' => array('Tag' => array(1, 3)) - ); - $TestModel->create(); - $result = $TestModel->create() && $TestModel->save($data); - $this->assertFalse(empty($result)); - - $TestModel->recursive = 2; - $result = $TestModel->read(null, 4); - $expected = array( - 'Article' => array( - 'id' => '4', - 'user_id' => '2', - 'title' => 'New Article', - 'body' => 'New Article Body', - 'published' => 'N', - 'created' => '2007-03-18 14:55:23', - 'updated' => '2007-03-18 14:57:31' - ), - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - ), - 'Comment' => array(), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - $this->assertEquals($expected, $result); - - $data = array('Comment' => array( - 'article_id' => '4', - 'user_id' => '1', - 'comment' => 'Comment New Article', - 'published' => 'Y', - 'created' => '2007-03-18 14:57:23', - 'updated' => '2007-03-18 14:59:31' - )); - $result = $TestModel->Comment->create() && $TestModel->Comment->save($data); - $this->assertFalse(empty($result)); - - $data = array('Attachment' => array( - 'comment_id' => '7', - 'attachment' => 'newattachment.zip', - 'created' => '2007-03-18 15:02:23', - 'updated' => '2007-03-18 15:04:31' - )); - $result = $TestModel->Comment->Attachment->save($data); - $this->assertFalse(empty($result)); - - $TestModel->recursive = 2; - $result = $TestModel->read(null, 4); - $expected = array( - 'Article' => array( - 'id' => '4', - 'user_id' => '2', - 'title' => 'New Article', - 'body' => 'New Article Body', - 'published' => 'N', - 'created' => '2007-03-18 14:55:23', - 'updated' => '2007-03-18 14:57:31' - ), - 'User' => array( - 'id' => '2', - 'user' => 'nate', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:18:23', - 'updated' => '2007-03-17 01:20:31' - ), - 'Comment' => array( - array( - 'id' => '7', - 'article_id' => '4', - 'user_id' => '1', - 'comment' => 'Comment New Article', - 'published' => 'Y', - 'created' => '2007-03-18 14:57:23', - 'updated' => '2007-03-18 14:59:31', - 'Article' => array( - 'id' => '4', - 'user_id' => '2', - 'title' => 'New Article', - 'body' => 'New Article Body', - 'published' => 'N', - 'created' => '2007-03-18 14:55:23', - 'updated' => '2007-03-18 14:57:31' - ), - 'User' => array( - 'id' => '1', - 'user' => 'mariano', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:16:23', - 'updated' => '2007-03-17 01:18:31' - ), - 'Attachment' => array( - 'id' => '2', - 'comment_id' => '7', - 'attachment' => 'newattachment.zip', - 'created' => '2007-03-18 15:02:23', - 'updated' => '2007-03-18 15:04:31' - ))), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - - $this->assertEquals($expected, $result); - } - -/** - * test that a null Id doesn't cause errors - * - * @return void - */ - public function testSaveWithNullId() { - $this->loadFixtures('User'); - $User = new User(); - $User->read(null, 1); - $User->data['User']['id'] = null; - $result = $User->save(array('password' => 'test')); - $this->assertFalse(empty($result)); - $this->assertTrue($User->id > 0); - - $User->read(null, 2); - $User->data['User']['id'] = null; - $result = $User->save(array('password' => 'test')); - $this->assertFalse(empty($result)); - $this->assertTrue($User->id > 0); - - $User->data['User'] = array('password' => 'something'); - $result = $User->save(); - $this->assertFalse(empty($result)); - $result = $User->read(); - $this->assertEquals('something', $User->data['User']['password']); - } - -/** - * testSaveWithSet method - * - * @return void - */ - public function testSaveWithSet() { - $this->loadFixtures('Article'); - $TestModel = new Article(); - - // Create record we will be updating later - - $data = array('Article' => array( - 'user_id' => '1', - 'title' => 'Fourth Article', - 'body' => 'Fourth Article Body', - 'published' => 'Y' - )); - $result = $TestModel->create() && $TestModel->save($data); - $this->assertFalse(empty($result)); - - // Check record we created - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 4); - $expected = array('Article' => array( - 'id' => '4', - 'user_id' => '1', - 'title' => 'Fourth Article', - 'body' => 'Fourth Article Body', - 'published' => 'Y' - )); - $this->assertEquals($expected, $result); - - // Create new record just to overlap Model->id on previously created record - - $data = array('Article' => array( - 'user_id' => '4', - 'title' => 'Fifth Article', - 'body' => 'Fifth Article Body', - 'published' => 'Y' - )); - $result = $TestModel->create() && $TestModel->save($data); - $this->assertFalse(empty($result)); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 5); - $expected = array('Article' => array( - 'id' => '5', - 'user_id' => '4', - 'title' => 'Fifth Article', - 'body' => 'Fifth Article Body', - 'published' => 'Y' - )); - $this->assertEquals($expected, $result); - - // Go back and edit the first article we created, starting by checking it's still there - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 4); - $expected = array('Article' => array( - 'id' => '4', - 'user_id' => '1', - 'title' => 'Fourth Article', - 'body' => 'Fourth Article Body', - 'published' => 'Y' - )); - $this->assertEquals($expected, $result); - - // And now do the update with set() - - $data = array('Article' => array( - 'id' => '4', - 'title' => 'Fourth Article - New Title', - 'published' => 'N' - )); - $result = $TestModel->set($data) && $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 4); - $expected = array('Article' => array( - 'id' => '4', - 'user_id' => '1', - 'title' => 'Fourth Article - New Title', - 'body' => 'Fourth Article Body', - 'published' => 'N' - )); - $this->assertEquals($expected, $result); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 5); - $expected = array('Article' => array( - 'id' => '5', - 'user_id' => '4', - 'title' => 'Fifth Article', - 'body' => 'Fifth Article Body', - 'published' => 'Y' - )); - $this->assertEquals($expected, $result); - - $data = array('Article' => array('id' => '5', 'title' => 'Fifth Article - New Title 5')); - $result = ($TestModel->set($data) && $TestModel->save()); - $this->assertFalse(empty($result)); - - $TestModel->recursive = -1; - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 5); - $expected = array('Article' => array( - 'id' => '5', - 'user_id' => '4', - 'title' => 'Fifth Article - New Title 5', - 'body' => 'Fifth Article Body', - 'published' => 'Y' - )); - $this->assertEquals($expected, $result); - - $TestModel->recursive = -1; - $result = $TestModel->find('all', array( - 'fields' => array('id', 'title'), - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array('id' => 1, 'title' => 'First Article')), - array('Article' => array('id' => 2, 'title' => 'Second Article')), - array('Article' => array('id' => 3, 'title' => 'Third Article')), - array('Article' => array('id' => 4, 'title' => 'Fourth Article - New Title')), - array('Article' => array('id' => 5, 'title' => 'Fifth Article - New Title 5')) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveWithNonExistentFields method - * - * @return void - */ - public function testSaveWithNonExistentFields() { - $this->loadFixtures('Article'); - $TestModel = new Article(); - $TestModel->recursive = -1; - - $data = array( - 'non_existent' => 'This field does not exist', - 'user_id' => '1', - 'title' => 'Fourth Article - New Title', - 'body' => 'Fourth Article Body', - 'published' => 'N' - ); - $result = $TestModel->create() && $TestModel->save($data); - $this->assertFalse(empty($result)); - - $expected = array('Article' => array( - 'id' => '4', - 'user_id' => '1', - 'title' => 'Fourth Article - New Title', - 'body' => 'Fourth Article Body', - 'published' => 'N' - )); - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 4); - $this->assertEquals($expected, $result); - - $data = array( - 'user_id' => '1', - 'non_existent' => 'This field does not exist', - 'title' => 'Fifth Article - New Title', - 'body' => 'Fifth Article Body', - 'published' => 'N' - ); - $result = $TestModel->create() && $TestModel->save($data); - $this->assertFalse(empty($result)); - - $expected = array('Article' => array( - 'id' => '5', - 'user_id' => '1', - 'title' => 'Fifth Article - New Title', - 'body' => 'Fifth Article Body', - 'published' => 'N' - )); - $result = $TestModel->read(array('id', 'user_id', 'title', 'body', 'published'), 5); - $this->assertEquals($expected, $result); - } - -/** - * testSaveFromXml method - * - * @return void - */ - public function testSaveFromXml() { - $this->markTestSkipped('This feature needs to be fixed or dropped'); - $this->loadFixtures('Article'); - App::uses('Xml', 'Utility'); - - $Article = new Article(); - $result = $Article->save(Xml::build('
')); - $this->assertFalse(empty($result)); - $results = $Article->find('first', array('conditions' => array('Article.title' => 'test xml'))); - $this->assertFalse(empty($results)); - - $result = $Article->save(Xml::build('
testing6
')); - $this->assertFalse(empty($result)); - $results = $Article->find('first', array('conditions' => array('Article.title' => 'testing'))); - $this->assertFalse(empty($results)); - - $result = $Article->save(Xml::build('
testing with DOMDocument7
', array('return' => 'domdocument'))); - $this->assertFalse(empty($result)); - $results = $Article->find('first', array('conditions' => array('Article.title' => 'testing with DOMDocument'))); - $this->assertFalse(empty($results)); - } - -/** - * testSaveHabtm method - * - * @return void - */ - public function testSaveHabtm() { - $this->loadFixtures('Article', 'User', 'Comment', 'Tag', 'ArticlesTag'); - $TestModel = new Article(); - - $result = $TestModel->findById(2); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'User' => array( - 'id' => '3', - 'user' => 'larry', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', - 'created' => '2007-03-17 01:20:23', - 'updated' => '2007-03-17 01:22:31' - ), - 'Comment' => array( - array( - 'id' => '5', - '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' - ), - array( - 'id' => '6', - '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' - )), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $data = array( - 'Article' => array( - 'id' => '2', - 'title' => 'New Second Article' - ), - 'Tag' => array('Tag' => array(1, 2)) - ); - - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array('belongsTo' => array('User'), 'hasMany' => array('Comment'))); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'New Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ))); - $this->assertEquals($expected, $result); - - $data = array('Article' => array('id' => '2'), 'Tag' => array('Tag' => array(2, 3))); - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'New Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - $this->assertEquals($expected, $result); - - $data = array('Tag' => array('Tag' => array(1, 2, 3))); - - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'New Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - $this->assertEquals($expected, $result); - - $data = array('Tag' => array('Tag' => array())); - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $data = array('Tag' => array('Tag' => '')); - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'New Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array() - ); - $this->assertEquals($expected, $result); - - $data = array('Tag' => array('Tag' => array(2, 3))); - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'New Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - $this->assertEquals($expected, $result); - - $data = array( - 'Tag' => array( - 'Tag' => array(1, 2) - ), - 'Article' => array( - 'id' => '2', - 'title' => 'New Second Article' - )); - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'New Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ))); - $this->assertEquals($expected, $result); - - $data = array( - 'Tag' => array( - 'Tag' => array(1, 2) - ), - 'Article' => array( - 'id' => '2', - 'title' => 'New Second Article Title' - )); - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'New Second Article Title', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $data = array( - 'Tag' => array( - 'Tag' => array(2, 3) - ), - 'Article' => array( - 'id' => '2', - 'title' => 'Changed Second Article' - )); - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Changed Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $data = array( - 'Tag' => array( - 'Tag' => array(1, 3) - ), - 'Article' => array('id' => '2'), - ); - - $result = $TestModel->set($data); - $this->assertFalse(empty($result)); - - $result = $TestModel->save(); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->find('first', array('fields' => array('id', 'user_id', 'title', 'body'), 'conditions' => array('Article.id' => 2))); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Changed Second Article', - 'body' => 'Second Article Body' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - $this->assertEquals($expected, $result); - - $data = array( - 'Article' => array( - 'id' => 10, - 'user_id' => '2', - 'title' => 'New Article With Tags and fieldList', - 'body' => 'New Article Body with Tags and fieldList', - 'created' => '2007-03-18 14:55:23', - 'updated' => '2007-03-18 14:57:31' - ), - 'Tag' => array( - 'Tag' => array(1, 2, 3) - ) - ); - $result = $TestModel->create() - && $TestModel->save($data, true, array('user_id', 'title', 'published')); - $this->assertFalse(empty($result)); - - $TestModel->unbindModel(array( - 'belongsTo' => array('User'), - 'hasMany' => array('Comment') - )); - $result = $TestModel->read(); - $expected = array( - 'Article' => array( - 'id' => 4, - 'user_id' => 2, - 'title' => 'New Article With Tags and fieldList', - 'body' => '', - 'published' => 'N', - 'created' => '', - 'updated' => '' - ), - 'Tag' => array( - 0 => array( - 'id' => 1, - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - 1 => array( - 'id' => 2, - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ), - 2 => array( - 'id' => 3, - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))); - $this->assertEquals($expected, $result); - - $this->loadFixtures('JoinA', 'JoinC', 'JoinAC', 'JoinB', 'JoinAB'); - $TestModel = new JoinA(); - $TestModel->hasBelongsToMany = array('JoinC' => array('unique' => true)); - $data = array( - 'JoinA' => array( - 'id' => 1, - 'name' => 'Join A 1', - 'body' => 'Join A 1 Body', - ), - 'JoinC' => array( - 'JoinC' => array( - array('join_c_id' => 2, 'other' => 'new record'), - array('join_c_id' => 3, 'other' => 'new record') - ) - ) - ); - $TestModel->save($data); - $result = $TestModel->read(null, 1); - $expected = array(4, 5); - $this->assertEquals($expected, Set::extract('/JoinC/JoinAsJoinC/id', $result)); - $expected = array('new record', 'new record'); - $this->assertEquals($expected, Set::extract('/JoinC/JoinAsJoinC/other', $result)); - } - -/** - * testSaveHabtmNoPrimaryData method - * - * @return void - */ - public function testSaveHabtmNoPrimaryData() { - $this->loadFixtures('Article', 'User', 'Comment', 'Tag', 'ArticlesTag'); - $TestModel = new Article(); - - $TestModel->unbindModel(array('belongsTo' => array('User'), 'hasMany' => array('Comment')), false); - $result = $TestModel->findById(2); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - ), - 'Tag' => array( - array( - 'id' => '1', - 'tag' => 'tag1', - 'created' => '2007-03-18 12:22:23', - 'updated' => '2007-03-18 12:24:31' - ), - array( - 'id' => '3', - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $ts = date('Y-m-d H:i:s'); - $TestModel->id = 2; - $data = array('Tag' => array('Tag' => array(2))); - $TestModel->save($data); - - $result = $TestModel->findById(2); - $expected = array( - 'Article' => array( - 'id' => '2', - 'user_id' => '3', - 'title' => 'Second Article', - 'body' => 'Second Article Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => $ts - ), - 'Tag' => array( - array( - 'id' => '2', - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ) - ) - ); - $this->assertEquals($expected, $result); - - $this->loadFixtures('Portfolio', 'Item', 'ItemsPortfolio'); - $TestModel = new Portfolio(); - $result = $TestModel->findById(2); - $expected = array( - 'Portfolio' => array( - 'id' => 2, - 'seller_id' => 1, - 'name' => 'Portfolio 2' - ), - 'Item' => array( - array( - 'id' => 2, - 'syfile_id' => 2, - 'published' => '', - 'name' => 'Item 2', - 'ItemsPortfolio' => array( - 'id' => 2, - 'item_id' => 2, - 'portfolio_id' => 2 - ) - ), - array( - 'id' => 6, - 'syfile_id' => 6, - 'published' => '', - 'name' => 'Item 6', - 'ItemsPortfolio' => array( - 'id' => 6, - 'item_id' => 6, - 'portfolio_id' => 2 - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $data = array('Item' => array('Item' => array(1, 2))); - $TestModel->id = 2; - $TestModel->save($data); - $result = $TestModel->findById(2); - $result['Item'] = Set::sort($result['Item'], '{n}.id', 'asc'); - $expected = array( - 'Portfolio' => array( - 'id' => 2, - 'seller_id' => 1, - 'name' => 'Portfolio 2' - ), - 'Item' => array( - array( - 'id' => 1, - 'syfile_id' => 1, - 'published' => '', - 'name' => 'Item 1', - 'ItemsPortfolio' => array( - 'id' => 7, - 'item_id' => 1, - 'portfolio_id' => 2 - ) - ), - array( - 'id' => 2, - 'syfile_id' => 2, - 'published' => '', - 'name' => 'Item 2', - 'ItemsPortfolio' => array( - 'id' => 8, - 'item_id' => 2, - 'portfolio_id' => 2 - ) - ) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveHabtmCustomKeys method - * - * @return void - */ - public function testSaveHabtmCustomKeys() { - $this->loadFixtures('Story', 'StoriesTag', 'Tag'); - $Story = new Story(); - - $data = array( - 'Story' => array('story' => '1'), - 'Tag' => array( - 'Tag' => array(2, 3) - )); - $result = $Story->set($data); - $this->assertFalse(empty($result)); - - $result = $Story->save(); - $this->assertFalse(empty($result)); - - $result = $Story->find('all', array('order' => array('Story.story'))); - $expected = array( - array( - 'Story' => array( - 'story' => 1, - 'title' => 'First Story' - ), - 'Tag' => array( - array( - 'id' => 2, - 'tag' => 'tag2', - 'created' => '2007-03-18 12:24:23', - 'updated' => '2007-03-18 12:26:31' - ), - array( - 'id' => 3, - 'tag' => 'tag3', - 'created' => '2007-03-18 12:26:23', - 'updated' => '2007-03-18 12:28:31' - ))), - array( - 'Story' => array( - 'story' => 2, - 'title' => 'Second Story' - ), - 'Tag' => array() - )); - $this->assertEquals($expected, $result); - } - -/** - * test that saving habtm records respects conditions set in the 'conditions' key - * for the association. - * - * @return void - */ - public function testHabtmSaveWithConditionsInAssociation() { - $this->loadFixtures('JoinThing', 'Something', 'SomethingElse'); - $Something = new Something(); - $Something->unbindModel(array('hasAndBelongsToMany' => array('SomethingElse')), false); - - $Something->bindModel(array( - 'hasAndBelongsToMany' => array( - 'DoomedSomethingElse' => array( - 'className' => 'SomethingElse', - 'joinTable' => 'join_things', - 'conditions' => array('JoinThing.doomed' => true), - 'unique' => true - ), - 'NotDoomedSomethingElse' => array( - 'className' => 'SomethingElse', - 'joinTable' => 'join_things', - 'conditions' => array('JoinThing.doomed' => 0), - 'unique' => true - ) - ) - ), false); - $result = $Something->read(null, 1); - $this->assertTrue(empty($result['NotDoomedSomethingElse'])); - $this->assertEquals(1, count($result['DoomedSomethingElse'])); - - $data = array( - 'Something' => array('id' => 1), - 'NotDoomedSomethingElse' => array( - 'NotDoomedSomethingElse' => array( - array('something_else_id' => 2, 'doomed' => 0), - array('something_else_id' => 3, 'doomed' => 0) - ) - ) - ); - $Something->create($data); - $result = $Something->save(); - $this->assertFalse(empty($result)); - - $result = $Something->read(null, 1); - $this->assertEquals(2, count($result['NotDoomedSomethingElse'])); - $this->assertEquals(1, count($result['DoomedSomethingElse'])); - } - -/** - * testHabtmSaveKeyResolution method - * - * @return void - */ - public function testHabtmSaveKeyResolution() { - $this->loadFixtures('Apple', 'Device', 'ThePaperMonkies'); - $ThePaper = new ThePaper(); - - $ThePaper->id = 1; - $ThePaper->save(array('Monkey' => array(2, 3))); - - $result = $ThePaper->findById(1); - $expected = array( - array( - 'id' => '2', - 'device_type_id' => '1', - 'name' => 'Device 2', - 'typ' => '1' - ), - array( - 'id' => '3', - 'device_type_id' => '1', - 'name' => 'Device 3', - 'typ' => '2' - )); - $this->assertEquals($expected, $result['Monkey']); - - $ThePaper->id = 2; - $ThePaper->save(array('Monkey' => array(1, 2, 3))); - - $result = $ThePaper->findById(2); - $expected = array( - array( - 'id' => '1', - 'device_type_id' => '1', - 'name' => 'Device 1', - 'typ' => '1' - ), - array( - 'id' => '2', - 'device_type_id' => '1', - 'name' => 'Device 2', - 'typ' => '1' - ), - array( - 'id' => '3', - 'device_type_id' => '1', - 'name' => 'Device 3', - 'typ' => '2' - )); - $this->assertEquals($expected, $result['Monkey']); - - $ThePaper->id = 2; - $ThePaper->save(array('Monkey' => array(1, 3))); - - $result = $ThePaper->findById(2); - $expected = array( - array( - 'id' => '1', - 'device_type_id' => '1', - 'name' => 'Device 1', - 'typ' => '1' - ), - array( - 'id' => '3', - 'device_type_id' => '1', - 'name' => 'Device 3', - 'typ' => '2' - )); - $this->assertEquals($expected, $result['Monkey']); - - $result = $ThePaper->findById(1); - $expected = array( - array( - 'id' => '2', - 'device_type_id' => '1', - 'name' => 'Device 2', - 'typ' => '1' - ), - array( - 'id' => '3', - 'device_type_id' => '1', - 'name' => 'Device 3', - 'typ' => '2' - )); - $this->assertEquals($expected, $result['Monkey']); - } - -/** - * testCreationOfEmptyRecord method - * - * @return void - */ - public function testCreationOfEmptyRecord() { - $this->loadFixtures('Author'); - $TestModel = new Author(); - $this->assertEquals(4, $TestModel->find('count')); - - $TestModel->deleteAll(true, false, false); - $this->assertEquals(0, $TestModel->find('count')); - - $result = $TestModel->save(); - $this->assertTrue(isset($result['Author']['created'])); - $this->assertTrue(isset($result['Author']['updated'])); - $this->assertEquals(1, $TestModel->find('count')); - } - -/** - * testCreateWithPKFiltering method - * - * @return void - */ - public function testCreateWithPKFiltering() { - $TestModel = new Article(); - $data = array( - 'id' => 5, - 'user_id' => 2, - 'title' => 'My article', - 'body' => 'Some text' - ); - - $result = $TestModel->create($data); - $expected = array( - 'Article' => array( - 'published' => 'N', - 'id' => 5, - 'user_id' => 2, - 'title' => 'My article', - 'body' => 'Some text' - )); - - $this->assertEquals($expected, $result); - $this->assertEquals(5, $TestModel->id); - - $result = $TestModel->create($data, true); - $expected = array( - 'Article' => array( - 'published' => 'N', - 'id' => false, - 'user_id' => 2, - 'title' => 'My article', - 'body' => 'Some text' - )); - - $this->assertEquals($expected, $result); - $this->assertFalse($TestModel->id); - - $result = $TestModel->create(array('Article' => $data), true); - $expected = array( - 'Article' => array( - 'published' => 'N', - 'id' => false, - 'user_id' => 2, - 'title' => 'My article', - 'body' => 'Some text' - )); - - $this->assertEquals($expected, $result); - $this->assertFalse($TestModel->id); - - $data = array( - 'id' => 6, - 'user_id' => 2, - 'title' => 'My article', - 'body' => 'Some text', - 'created' => '1970-01-01 00:00:00', - 'updated' => '1970-01-01 12:00:00', - 'modified' => '1970-01-01 12:00:00' - ); - - $result = $TestModel->create($data); - $expected = array( - 'Article' => array( - 'published' => 'N', - 'id' => 6, - 'user_id' => 2, - 'title' => 'My article', - 'body' => 'Some text', - 'created' => '1970-01-01 00:00:00', - 'updated' => '1970-01-01 12:00:00', - 'modified' => '1970-01-01 12:00:00' - )); - $this->assertEquals($expected, $result); - $this->assertEquals(6, $TestModel->id); - - $result = $TestModel->create(array( - 'Article' => array_diff_key($data, array( - 'created' => true, - 'updated' => true, - 'modified' => true - ))), true); - $expected = array( - 'Article' => array( - 'published' => 'N', - 'id' => false, - 'user_id' => 2, - 'title' => 'My article', - 'body' => 'Some text' - )); - $this->assertEquals($expected, $result); - $this->assertFalse($TestModel->id); - } - -/** - * testCreationWithMultipleData method - * - * @return void - */ - public function testCreationWithMultipleData() { - $this->loadFixtures('Article', 'Comment'); - $Article = new Article(); - $Comment = new Comment(); - - $articles = $Article->find('all', array( - 'fields' => array('id','title'), - 'recursive' => -1, - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array( - 'id' => 1, - 'title' => 'First Article' - )), - array('Article' => array( - 'id' => 2, - 'title' => 'Second Article' - )), - array('Article' => array( - 'id' => 3, - 'title' => 'Third Article' - ))); - $this->assertEquals($expected, $articles); - - $comments = $Comment->find('all', array( - 'fields' => array('id','article_id','user_id','comment','published'), - 'recursive' => -1, - 'order' => array('Comment.id' => 'ASC') - )); - $expected = array( - array('Comment' => array( - 'id' => 1, - 'article_id' => 1, - 'user_id' => 2, - 'comment' => 'First Comment for First Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 2, - 'article_id' => 1, - 'user_id' => 4, - 'comment' => 'Second Comment for First Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 3, - 'article_id' => 1, - 'user_id' => 1, - 'comment' => 'Third Comment for First Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 4, - 'article_id' => 1, - 'user_id' => 1, - 'comment' => 'Fourth Comment for First Article', - 'published' => 'N' - )), - array('Comment' => array( - 'id' => 5, - 'article_id' => 2, - 'user_id' => 1, - 'comment' => 'First Comment for Second Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 6, - 'article_id' => 2, - 'user_id' => 2, - 'comment' => 'Second Comment for Second Article', - 'published' => 'Y' - ))); - $this->assertEquals($expected, $comments); - - $data = array( - 'Comment' => array( - 'article_id' => 2, - 'user_id' => 4, - 'comment' => 'Brand New Comment', - 'published' => 'N' - ), - 'Article' => array( - 'id' => 2, - 'title' => 'Second Article Modified' - )); - $result = $Comment->create($data); - $this->assertFalse(empty($result)); - - $result = $Comment->save(); - $this->assertFalse(empty($result)); - - $articles = $Article->find('all', array( - 'fields' => array('id','title'), - 'recursive' => -1, - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array( - 'id' => 1, - 'title' => 'First Article' - )), - array('Article' => array( - 'id' => 2, - 'title' => 'Second Article' - )), - array('Article' => array( - 'id' => 3, - 'title' => 'Third Article' - ))); - - $this->assertEquals($expected, $articles); - - $comments = $Comment->find('all', array( - 'fields' => array('id','article_id','user_id','comment','published'), - 'recursive' => -1, - 'order' => array('Comment.id' => 'ASC') - )); - $expected = array( - array('Comment' => array( - 'id' => 1, - 'article_id' => 1, - 'user_id' => 2, - 'comment' => 'First Comment for First Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 2, - 'article_id' => 1, - 'user_id' => 4, - 'comment' => 'Second Comment for First Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 3, - 'article_id' => 1, - 'user_id' => 1, - 'comment' => 'Third Comment for First Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 4, - 'article_id' => 1, - 'user_id' => 1, - 'comment' => 'Fourth Comment for First Article', - 'published' => 'N' - )), - array('Comment' => array( - 'id' => 5, - 'article_id' => 2, - 'user_id' => 1, - 'comment' => 'First Comment for Second Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 6, - 'article_id' => 2, - 'user_id' => 2, 'comment' => - 'Second Comment for Second Article', - 'published' => 'Y' - )), - array('Comment' => array( - 'id' => 7, - 'article_id' => 2, - 'user_id' => 4, - 'comment' => 'Brand New Comment', - 'published' => 'N' - ))); - $this->assertEquals($expected, $comments); - } - -/** - * testCreationWithMultipleDataSameModel method - * - * @return void - */ - public function testCreationWithMultipleDataSameModel() { - $this->loadFixtures('Article'); - $Article = new Article(); - $SecondaryArticle = new Article(); - - $result = $Article->field('title', array('id' => 1)); - $this->assertEquals('First Article', $result); - - $data = array( - 'Article' => array( - 'user_id' => 2, - 'title' => 'Brand New Article', - 'body' => 'Brand New Article Body', - 'published' => 'Y' - ), - 'SecondaryArticle' => array( - 'id' => 1 - )); - - $Article->create(); - $result = $Article->save($data); - $this->assertFalse(empty($result)); - - $result = $Article->getInsertID(); - $this->assertTrue(!empty($result)); - - $result = $Article->field('title', array('id' => 1)); - $this->assertEquals('First Article', $result); - - $articles = $Article->find('all', array( - 'fields' => array('id','title'), - 'recursive' => -1, - 'order' => array('Article.id' => 'ASC') - )); - $expected = array( - array('Article' => array( - 'id' => 1, - 'title' => 'First Article' - )), - array('Article' => array( - 'id' => 2, - 'title' => 'Second Article' - )), - array('Article' => array( - 'id' => 3, - 'title' => 'Third Article' - )), - array('Article' => array( - 'id' => 4, - 'title' => 'Brand New Article' - ))); - - $this->assertEquals($expected, $articles); - } - -/** - * testCreationWithMultipleDataSameModelManualInstances method - * - * @return void - */ - public function testCreationWithMultipleDataSameModelManualInstances() { - $this->loadFixtures('PrimaryModel'); - $Primary = new PrimaryModel(); - $Secondary = new PrimaryModel(); - - $result = $Primary->field('primary_name', array('id' => 1)); - $this->assertEquals('Primary Name Existing', $result); - - $data = array( - 'PrimaryModel' => array( - 'primary_name' => 'Primary Name New' - ), - 'SecondaryModel' => array( - 'id' => array(1) - )); - - $Primary->create(); - $result = $Primary->save($data); - $this->assertFalse(empty($result)); - - $result = $Primary->field('primary_name', array('id' => 1)); - $this->assertEquals('Primary Name Existing', $result); - - $result = $Primary->getInsertID(); - $this->assertTrue(!empty($result)); - - $result = $Primary->field('primary_name', array('id' => $result)); - $this->assertEquals('Primary Name New', $result); - - $result = $Primary->find('count'); - $this->assertEquals(2, $result); - } - -/** - * testRecordExists method - * - * @return void - */ - public function testRecordExists() { - $this->loadFixtures('User'); - $TestModel = new User(); - - $this->assertFalse($TestModel->exists()); - $TestModel->read(null, 1); - $this->assertTrue($TestModel->exists()); - $TestModel->create(); - $this->assertFalse($TestModel->exists()); - $TestModel->id = 4; - $this->assertTrue($TestModel->exists()); - - $TestModel = new TheVoid(); - $this->assertFalse($TestModel->exists()); - } - -/** - * testRecordExistsMissingTable method - * - * @expectedException PDOException - * @return void - */ - public function testRecordExistsMissingTable() { - $TestModel = new TheVoid(); - $TestModel->id = 5; - $TestModel->exists(); - } - -/** - * testUpdateExisting method - * - * @return void - */ - public function testUpdateExisting() { - $this->loadFixtures('User', 'Article', 'Comment'); - $TestModel = new User(); - $TestModel->create(); - - $TestModel->save(array( - 'User' => array( - 'user' => 'some user', - 'password' => 'some password' - ))); - $this->assertTrue(is_int($TestModel->id) || (intval($TestModel->id) === 5)); - $id = $TestModel->id; - - $TestModel->save(array( - 'User' => array( - 'user' => 'updated user' - ))); - $this->assertEquals($TestModel->id, $id); - - $result = $TestModel->findById($id); - $this->assertEquals('updated user', $result['User']['user']); - $this->assertEquals('some password', $result['User']['password']); - - $Article = new Article(); - $Comment = new Comment(); - $data = array( - 'Comment' => array( - 'id' => 1, - 'comment' => 'First Comment for First Article' - ), - 'Article' => array( - 'id' => 2, - 'title' => 'Second Article' - )); - - $result = $Article->save($data); - $this->assertFalse(empty($result)); - - $result = $Comment->save($data); - $this->assertFalse(empty($result)); - } - -/** - * test updating records and saving blank values. - * - * @return void - */ - public function testUpdateSavingBlankValues() { - $this->loadFixtures('Article'); - $Article = new Article(); - $Article->validate = array(); - $Article->create(); - $result = $Article->save(array( - 'id' => 1, - 'title' => '', - 'body' => '' - )); - $this->assertTrue((bool)$result); - $result = $Article->find('first', array('conditions' => array('Article.id' => 1))); - $this->assertEquals('', $result['Article']['title'], 'Title is not blank'); - $this->assertEquals('', $result['Article']['body'], 'Body is not blank'); - } - -/** - * testUpdateMultiple method - * - * @return void - */ - public function testUpdateMultiple() { - $this->loadFixtures('Comment', 'Article', 'User', 'CategoryThread'); - $TestModel = new Comment(); - $result = Set::extract($TestModel->find('all'), '{n}.Comment.user_id'); - $expected = array('2', '4', '1', '1', '1', '2'); - $this->assertEquals($expected, $result); - - $TestModel->updateAll(array('Comment.user_id' => 5), array('Comment.user_id' => 2)); - $result = Set::combine($TestModel->find('all'), '{n}.Comment.id', '{n}.Comment.user_id'); - $expected = array(1 => 5, 2 => 4, 3 => 1, 4 => 1, 5 => 1, 6 => 5); - $this->assertEquals($expected, $result); - - $result = $TestModel->updateAll( - array('Comment.comment' => "'Updated today'"), - array('Comment.user_id' => 5) - ); - $this->assertFalse(empty($result)); - $result = Set::extract( - $TestModel->find('all', array( - 'conditions' => array( - 'Comment.user_id' => 5 - ))), - '{n}.Comment.comment' - ); - $expected = array_fill(0, 2, 'Updated today'); - $this->assertEquals($expected, $result); - } - -/** - * testHabtmUuidWithUuidId method - * - * @return void - */ - public function testHabtmUuidWithUuidId() { - $this->loadFixtures('Uuidportfolio', 'Uuiditem', 'UuiditemsUuidportfolio', 'UuiditemsUuidportfolioNumericid'); - $TestModel = new Uuidportfolio(); - - $data = array('Uuidportfolio' => array('name' => 'Portfolio 3')); - $data['Uuiditem']['Uuiditem'] = array('483798c8-c7cc-430e-8cf9-4fcc40cf8569'); - $TestModel->create($data); - $TestModel->save(); - $id = $TestModel->id; - $result = $TestModel->read(null, $id); - $this->assertEquals(1, count($result['Uuiditem'])); - $this->assertEquals(36, strlen($result['Uuiditem'][0]['UuiditemsUuidportfolio']['id'])); - } - -/** - * test HABTM saving when join table has no primary key and only 2 columns. - * - * @return void - */ - public function testHabtmSavingWithNoPrimaryKeyUuidJoinTable() { - $this->loadFixtures('UuidTag', 'Fruit', 'FruitsUuidTag'); - $Fruit = new Fruit(); - $data = array( - 'Fruit' => array( - 'color' => 'Red', - 'shape' => 'Heart-shaped', - 'taste' => 'sweet', - 'name' => 'Strawberry', - ), - 'UuidTag' => array( - 'UuidTag' => array( - '481fc6d0-b920-43e0-e50f-6d1740cf8569' - ) - ) - ); - $result = $Fruit->save($data); - $this->assertFalse(empty($result)); - } - -/** - * test HABTM saving when join table has no primary key and only 2 columns, no with model is used. - * - * @return void - */ - public function testHabtmSavingWithNoPrimaryKeyUuidJoinTableNoWith() { - $this->loadFixtures('UuidTag', 'Fruit', 'FruitsUuidTag'); - $Fruit = new FruitNoWith(); - $data = array( - 'Fruit' => array( - 'color' => 'Red', - 'shape' => 'Heart-shaped', - 'taste' => 'sweet', - 'name' => 'Strawberry', - ), - 'UuidTag' => array( - 'UuidTag' => array( - '481fc6d0-b920-43e0-e50f-6d1740cf8569' - ) - ) - ); - $result = $Fruit->save($data); - $this->assertFalse(empty($result)); - } - -/** - * testHabtmUuidWithNumericId method - * - * @return void - */ - public function testHabtmUuidWithNumericId() { - $this->loadFixtures('Uuidportfolio', 'Uuiditem', 'UuiditemsUuidportfolioNumericid'); - $TestModel = new Uuiditem(); - - $data = array('Uuiditem' => array('name' => 'Item 7', 'published' => 0)); - $data['Uuidportfolio']['Uuidportfolio'] = array('480af662-eb8c-47d3-886b-230540cf8569'); - $TestModel->create($data); - $TestModel->save(); - $id = $TestModel->id; - $result = $TestModel->read(null, $id); - $this->assertEquals(1, count($result['Uuidportfolio'])); - } - -/** - * testSaveMultipleHabtm method - * - * @return void - */ - public function testSaveMultipleHabtm() { - $this->loadFixtures('JoinA', 'JoinB', 'JoinC', 'JoinAB', 'JoinAC'); - $TestModel = new JoinA(); - $result = $TestModel->findById(1); - - $expected = array( - 'JoinA' => array( - 'id' => 1, - 'name' => 'Join A 1', - 'body' => 'Join A 1 Body', - 'created' => '2008-01-03 10:54:23', - 'updated' => '2008-01-03 10:54:23' - ), - 'JoinB' => array( - 0 => array( - 'id' => 2, - 'name' => 'Join B 2', - 'created' => '2008-01-03 10:55:02', - 'updated' => '2008-01-03 10:55:02', - 'JoinAsJoinB' => array( - 'id' => 1, - 'join_a_id' => 1, - 'join_b_id' => 2, - 'other' => 'Data for Join A 1 Join B 2', - 'created' => '2008-01-03 10:56:33', - 'updated' => '2008-01-03 10:56:33' - ))), - 'JoinC' => array( - 0 => array( - 'id' => 2, - 'name' => 'Join C 2', - 'created' => '2008-01-03 10:56:12', - 'updated' => '2008-01-03 10:56:12', - 'JoinAsJoinC' => array( - 'id' => 1, - 'join_a_id' => 1, - 'join_c_id' => 2, - 'other' => 'Data for Join A 1 Join C 2', - 'created' => '2008-01-03 10:57:22', - 'updated' => '2008-01-03 10:57:22' - )))); - - $this->assertEquals($expected, $result); - - $ts = date('Y-m-d H:i:s'); - $TestModel->id = 1; - $data = array( - 'JoinA' => array( - 'id' => '1', - 'name' => 'New name for Join A 1', - 'updated' => $ts - ), - 'JoinB' => array( - array( - 'id' => 1, - 'join_b_id' => 2, - 'other' => 'New data for Join A 1 Join B 2', - 'created' => $ts, - 'updated' => $ts - )), - 'JoinC' => array( - array( - 'id' => 1, - 'join_c_id' => 2, - 'other' => 'New data for Join A 1 Join C 2', - 'created' => $ts, - 'updated' => $ts - ))); - - $TestModel->set($data); - $TestModel->save(); - - $result = $TestModel->findById(1); - $expected = array( - 'JoinA' => array( - 'id' => 1, - 'name' => 'New name for Join A 1', - 'body' => 'Join A 1 Body', - 'created' => '2008-01-03 10:54:23', - 'updated' => $ts - ), - 'JoinB' => array( - 0 => array( - 'id' => 2, - 'name' => 'Join B 2', - 'created' => '2008-01-03 10:55:02', - 'updated' => '2008-01-03 10:55:02', - 'JoinAsJoinB' => array( - 'id' => 1, - 'join_a_id' => 1, - 'join_b_id' => 2, - 'other' => 'New data for Join A 1 Join B 2', - 'created' => $ts, - 'updated' => $ts - ))), - 'JoinC' => array( - 0 => array( - 'id' => 2, - 'name' => 'Join C 2', - 'created' => '2008-01-03 10:56:12', - 'updated' => '2008-01-03 10:56:12', - 'JoinAsJoinC' => array( - 'id' => 1, - 'join_a_id' => 1, - 'join_c_id' => 2, - 'other' => 'New data for Join A 1 Join C 2', - 'created' => $ts, - 'updated' => $ts - )))); - - $this->assertEquals($expected, $result); - } - -/** - * testSaveAll method - * - * @return void - */ - public function testSaveAll() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment', 'Article', 'User'); - $TestModel = new Post(); - - $result = $TestModel->find('all'); - $this->assertEquals(3, count($result)); - $this->assertFalse(isset($result[3])); - $ts = date('Y-m-d H:i:s'); - - $TestModel->saveAll(array( - 'Post' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author' - ), - 'Author' => array( - 'user' => 'bob', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf90' - ))); - - $result = $TestModel->find('all'); - $expected = array( - 'Post' => array( - 'id' => '4', - 'author_id' => '5', - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author', - 'published' => 'N' - ), - 'Author' => array( - 'id' => '5', - 'user' => 'bob', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf90', - 'test' => 'working' - )); - $this->assertTrue($result[3]['Post']['created'] >= $ts); - $this->assertTrue($result[3]['Post']['updated'] >= $ts); - $this->assertTrue($result[3]['Author']['created'] >= $ts); - $this->assertTrue($result[3]['Author']['updated'] >= $ts); - unset($result[3]['Post']['created'], $result[3]['Post']['updated']); - unset($result[3]['Author']['created'], $result[3]['Author']['updated']); - $this->assertEquals($expected, $result[3]); - $this->assertEquals(4, count($result)); - - $TestModel->deleteAll(true); - $this->assertEquals(array(), $TestModel->find('all')); - - // SQLite seems to reset the PK counter when that happens, so we need this to make the tests pass - $this->db->truncate($TestModel); - - $ts = date('Y-m-d H:i:s'); - $TestModel->saveAll(array( - array( - 'title' => 'Multi-record post 1', - 'body' => 'First multi-record post', - 'author_id' => 2 - ), - array( - 'title' => 'Multi-record post 2', - 'body' => 'Second multi-record post', - 'author_id' => 2 - ))); - - $result = $TestModel->find('all', array( - 'recursive' => -1, - 'order' => 'Post.id ASC' - )); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '2', - 'title' => 'Multi-record post 1', - 'body' => 'First multi-record post', - 'published' => 'N' - )), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '2', - 'title' => 'Multi-record post 2', - 'body' => 'Second multi-record post', - 'published' => 'N' - ))); - $this->assertTrue($result[0]['Post']['created'] >= $ts); - $this->assertTrue($result[0]['Post']['updated'] >= $ts); - $this->assertTrue($result[1]['Post']['created'] >= $ts); - $this->assertTrue($result[1]['Post']['updated'] >= $ts); - unset($result[0]['Post']['created'], $result[0]['Post']['updated']); - unset($result[1]['Post']['created'], $result[1]['Post']['updated']); - $this->assertEquals($expected, $result); - - $TestModel = new Comment(); - $ts = date('Y-m-d H:i:s'); - $result = $TestModel->saveAll(array( - 'Comment' => array( - 'article_id' => 2, - 'user_id' => 2, - 'comment' => 'New comment with attachment', - 'published' => 'Y' - ), - 'Attachment' => array( - 'attachment' => 'some_file.tgz' - ))); - $this->assertFalse(empty($result)); - - $result = $TestModel->find('all'); - $expected = array( - 'id' => '7', - 'article_id' => '2', - 'user_id' => '2', - 'comment' => 'New comment with attachment', - 'published' => 'Y' - ); - $this->assertTrue($result[6]['Comment']['created'] >= $ts); - $this->assertTrue($result[6]['Comment']['updated'] >= $ts); - unset($result[6]['Comment']['created'], $result[6]['Comment']['updated']); - $this->assertEquals($expected, $result[6]['Comment']); - - $expected = array( - 'id' => '2', - 'comment_id' => '7', - 'attachment' => 'some_file.tgz' - ); - $this->assertTrue($result[6]['Attachment']['created'] >= $ts); - $this->assertTrue($result[6]['Attachment']['updated'] >= $ts); - unset($result[6]['Attachment']['created'], $result[6]['Attachment']['updated']); - $this->assertEquals($expected, $result[6]['Attachment']); - } - -/** - * Test SaveAll with Habtm relations - * - * @return void - */ - public function testSaveAllHabtm() { - $this->loadFixtures('Article', 'Tag', 'Comment', 'User', 'ArticlesTag'); - $data = array( - 'Article' => array( - 'user_id' => 1, - 'title' => 'Article Has and belongs to Many Tags' - ), - 'Tag' => array( - 'Tag' => array(1, 2) - ), - 'Comment' => array( - array( - 'comment' => 'Article comment', - 'user_id' => 1 - ))); - $Article = new Article(); - $result = $Article->saveAll($data); - $this->assertFalse(empty($result)); - - $result = $Article->read(); - $this->assertEquals(2, count($result['Tag'])); - $this->assertEquals('tag1', $result['Tag'][0]['tag']); - $this->assertEquals(1, count($result['Comment'])); - $this->assertEquals(1, count($result['Comment'][0]['comment'])); - } - -/** - * Test SaveAll with Habtm relations and extra join table fields - * - * @return void - */ - public function testSaveAllHabtmWithExtraJoinTableFields() { - $this->loadFixtures('Something', 'SomethingElse', 'JoinThing'); - - $data = array( - 'Something' => array( - 'id' => 4, - 'title' => 'Extra Fields', - 'body' => 'Extra Fields Body', - 'published' => '1' - ), - 'SomethingElse' => array( - array('something_else_id' => 1, 'doomed' => '1'), - array('something_else_id' => 2, 'doomed' => '0'), - array('something_else_id' => 3, 'doomed' => '1') - ) - ); - - $Something = new Something(); - $result = $Something->saveAll($data); - $this->assertFalse(empty($result)); - $result = $Something->read(); - - $this->assertEquals(3, count($result['SomethingElse'])); - $this->assertTrue(Set::matches('/Something[id=4]', $result)); - - $this->assertTrue(Set::matches('/SomethingElse[id=1]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=1]/JoinThing[something_else_id=1]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=1]/JoinThing[doomed=1]', $result)); - - $this->assertTrue(Set::matches('/SomethingElse[id=2]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=2]/JoinThing[something_else_id=2]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=2]/JoinThing[doomed=0]', $result)); - - $this->assertTrue(Set::matches('/SomethingElse[id=3]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=3]/JoinThing[something_else_id=3]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=3]/JoinThing[doomed=1]', $result)); - } - -/** - * testSaveAllHasOne method - * - * @return void - */ - public function testSaveAllHasOne() { - $model = new Comment(); - $model->deleteAll(true); - $this->assertEquals(array(), $model->find('all')); - - $model->Attachment->deleteAll(true); - $this->assertEquals(array(), $model->Attachment->find('all')); - - $this->assertTrue($model->saveAll(array( - 'Comment' => array( - 'comment' => 'Comment with attachment', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Attachment' => array( - 'attachment' => 'some_file.zip' - )))); - $result = $model->find('all', array('fields' => array( - 'Comment.id', 'Comment.comment', 'Attachment.id', - 'Attachment.comment_id', 'Attachment.attachment' - ))); - $expected = array(array( - 'Comment' => array( - 'id' => '1', - 'comment' => 'Comment with attachment' - ), - 'Attachment' => array( - 'id' => '1', - 'comment_id' => '1', - 'attachment' => 'some_file.zip' - ))); - $this->assertEquals($expected, $result); - - $model->Attachment->bindModel(array('belongsTo' => array('Comment')), false); - $data = array( - 'Comment' => array( - 'comment' => 'Comment with attachment', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Attachment' => array( - 'attachment' => 'some_file.zip' - )); - $this->assertTrue($model->saveAll($data, array('validate' => 'first'))); - } - -/** - * testSaveAllBelongsTo method - * - * @return void - */ - public function testSaveAllBelongsTo() { - $model = new Comment(); - $model->deleteAll(true); - $this->assertEquals(array(), $model->find('all')); - - $model->Article->deleteAll(true); - $this->assertEquals(array(), $model->Article->find('all')); - - $this->assertTrue($model->saveAll(array( - 'Comment' => array( - 'comment' => 'Article comment', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Article' => array( - 'title' => 'Model Associations 101', - 'user_id' => 1 - )))); - $result = $model->find('all', array('fields' => array( - 'Comment.id', 'Comment.comment', 'Comment.article_id', 'Article.id', 'Article.title' - ))); - $expected = array(array( - 'Comment' => array( - 'id' => '1', - 'article_id' => '1', - 'comment' => 'Article comment' - ), - 'Article' => array( - 'id' => '1', - 'title' => 'Model Associations 101' - ))); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllHasOneValidation method - * - * @return void - */ - public function testSaveAllHasOneValidation() { - $model = new Comment(); - $model->deleteAll(true); - $this->assertEquals(array(), $model->find('all')); - - $model->Attachment->deleteAll(true); - $this->assertEquals(array(), $model->Attachment->find('all')); - - $model->validate = array('comment' => 'notEmpty'); - $model->Attachment->validate = array('attachment' => 'notEmpty'); - $model->Attachment->bindModel(array('belongsTo' => array('Comment'))); - - $this->assertEquals($model->saveAll( - array( - 'Comment' => array( - 'comment' => '', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Attachment' => array('attachment' => '') - ), - array('validate' => 'first') - ), false); - $expected = array( - 'Comment' => array('comment' => array('This field cannot be left blank')), - 'Attachment' => array('attachment' => array('This field cannot be left blank')) - ); - $this->assertEquals($expected['Comment'], $model->validationErrors); - $this->assertEquals($expected['Attachment'], $model->Attachment->validationErrors); - - $this->assertFalse($model->saveAll( - array( - 'Comment' => array('comment' => '', 'article_id' => 1, 'user_id' => 1), - 'Attachment' => array('attachment' => '') - ), - array('validate' => 'only') - )); - $this->assertEquals($expected['Comment'], $model->validationErrors); - $this->assertEquals($expected['Attachment'], $model->Attachment->validationErrors); - } - -/** - * testSaveAllAtomic method - * - * @return void - */ - public function testSaveAllAtomic() { - $this->loadFixtures('Article', 'User', 'Comment'); - $TestModel = new Article(); - - $result = $TestModel->saveAll(array( - 'Article' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author', - 'user_id' => 2 - ), - 'Comment' => array( - array('comment' => 'First new comment', 'user_id' => 2)) - ), array('atomic' => false)); - - $this->assertSame($result, array('Article' => true, 'Comment' => array(true))); - - $result = $TestModel->saveAll(array( - array( - 'id' => '1', - 'title' => 'Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N' - ), - array( - 'id' => '2', - 'title' => 'Just update the title' - ), - array( - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'user_id' => 2 - ) - ), array('atomic' => false)); - $this->assertSame($result, array(true, true, true)); - - $TestModel->validate = array('title' => 'notEmpty', 'author_id' => 'numeric'); - $result = $TestModel->saveAll(array( - array( - 'id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - ) - ), array('validate' => true, 'atomic' => false)); - - $this->assertSame(array(true, false), $result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => 'First new comment', - 'published' => 'Y', - 'user_id' => 1 - ), - array( - 'comment' => 'Second new comment', - 'published' => 'Y', - 'user_id' => 2 - )) - ), array('validate' => true, 'atomic' => false)); - $this->assertSame($result, array('Article' => true, 'Comment' => array(true, true))); - } - -/** - * testSaveAllDeepAssociated method - * - * @return void - */ - public function testSaveAllDeepAssociated() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->hasMany['Comment']['order'] = array('Comment.created' => 'ASC'); - $TestModel->hasAndBelongsToMany = array(); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => 'newuser', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), array('deep' => true)); - $this->assertTrue($result); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment' - ); - $result = Set::extract(Set::sort($result['Comment'], '{n}.id', 'ASC'), '{n}.comment'); - $this->assertEquals($expected, $result); - - $result = $TestModel->Comment->User->field('id', array('user' => 'newuser', 'password' => 'newuserpass')); - $this->assertEquals(5, $result); - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 5), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => 'deepsaved')) - ) - ), array('deep' => true)); - $this->assertTrue($result); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment', - 'Third new comment', - 'Fourth new comment' - ); - $result = Set::extract(Set::sort($result['Comment'], '{n}.id', 'ASC'), '{n}.comment'); - $this->assertEquals($expected, $result); - - $result = $TestModel->Comment->Attachment->field('id', array('attachment' => 'deepsaved')); - $this->assertEquals(2, $result); - $data = array( - 'Attachment' => array( - 'attachment' => 'deepsave insert', - ), - 'Comment' => array( - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - 'user_id' => 5, - 'Article' => array( - 'title' => 'First Article deepsave insert', - 'body' => 'First Article Body deepsave insert', - 'User' => array( - 'user' => '', - 'password' => 'magic' - ), - ), - ) - ); - - $TestModel->Comment->Attachment->create(); - $result = $TestModel->Comment->Attachment->saveAll($data, array('deep' => true)); - $this->assertFalse($result); - - $expected = array('User' => array('user' => array('This field cannot be left blank'))); - $this->assertEquals($expected, $TestModel->validationErrors); - - $data['Comment']['Article']['User']['user'] = 'deepsave'; - $TestModel->Comment->Attachment->create(); - $result = $TestModel->Comment->Attachment->saveAll($data, array('deep' => true)); - $this->assertTrue($result); - - $result = $TestModel->Comment->Attachment->findById($TestModel->Comment->Attachment->id); - $expected = array( - 'Attachment' => array( - 'id' => '3', - 'comment_id' => '11', - 'attachment' => 'deepsave insert', - ), - 'Comment' => array( - 'id' => '11', - 'article_id' => '4', - 'user_id' => '5', - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - ) - ); - unset($result['Attachment']['created'], $result['Attachment']['updated']); - $this->assertEquals($expected['Attachment'], $result['Attachment']); - - unset($result['Comment']['created'], $result['Comment']['updated']); - $this->assertEquals($expected['Comment'], $result['Comment']); - - $result = $TestModel->findById($result['Comment']['article_id']); - $expected = array( - 'Article' => array( - 'id' => '4', - 'user_id' => '6', - 'title' => 'First Article deepsave insert', - 'body' => 'First Article Body deepsave insert', - 'published' => 'N', - ), - 'User' => array( - 'id' => '6', - 'user' => 'deepsave', - 'password' => 'magic', - ), - 'Comment' => array( - array( - 'id' => '11', - 'article_id' => '4', - 'user_id' => '5', - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - ) - ) - ); - unset( - $result['Article']['created'], $result['Article']['updated'], - $result['User']['created'], $result['User']['updated'], - $result['Comment'][0]['created'], $result['Comment'][0]['updated'] - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllDeepMany - * tests the validate methods with deeper recursive data - * - * @return void - */ - public function testSaveAllDeepMany() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->hasMany['Comment']['order'] = array('Comment.created' => 'ASC'); - $TestModel->hasAndBelongsToMany = array(); - - $data = array( - array( - 'id' => 1, 'body' => '', - 'Comment' => array( - array('comment' => '', 'published' => 'Y', 'User' => array('user' => '', 'password' => 'manysaved')), - array('comment' => 'Second comment deepsaved article 1', 'published' => 'Y', 'user_id' => 2) - ) - ), - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First comment deepsaved article 2', 'published' => 'Y', 'User' => array('user' => 'savemore', 'password' => '')), - array('comment' => '', 'published' => 'Y', 'user_id' => 2) - ) - ) - ); - $TestModel->Comment->validate['comment'] = 'notEmpty'; - $result = $TestModel->saveAll($data, array('deep' => true)); - $this->assertFalse($result); - - $expected = array( - 0 => array( - 'body' => array('This field cannot be left blank') - ), - 1 => array( - 'Comment' => array( - 0 => array( - 'User' => array( - 'password' => array('This field cannot be left blank') - ) - ), - 1 => array( - 'comment' => array('This field cannot be left blank') - ) - ) - ) - ); - $result = $TestModel->validationErrors; - $this->assertSame($expected, $result); - - $data = array( - array( - 'Article' => array('id' => 1), - 'Comment' => array( - array('comment' => 'First comment deepsaved article 1', 'published' => 'Y', 'User' => array('user' => 'savemany', 'password' => 'manysaved')), - array('comment' => 'Second comment deepsaved article 1', 'published' => 'Y', 'user_id' => 2) - ) - ), - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First comment deepsaved article 2', 'published' => 'Y', 'User' => array('user' => 'savemore', 'password' => 'moresaved')), - array('comment' => 'Second comment deepsaved article 2', 'published' => 'Y', 'user_id' => 2) - ) - ) - ); - $result = $TestModel->saveAll($data, array('deep' => true)); - $this->assertTrue($result); - } -/** - * testSaveAllDeepValidateOnly - * tests the validate methods with deeper recursive data - * - * @return void - */ - public function testSaveAllDeepValidateOnly() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->hasMany['Comment']['order'] = array('Comment.created' => 'ASC'); - $TestModel->hasAndBelongsToMany = array(); - $TestModel->Comment->Attachment->validate['attachment'] = 'notEmpty'; - $TestModel->Comment->validate['comment'] = 'notEmpty'; - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => 'newuser', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), - array('validate' => 'only', 'deep' => true) - ); - $this->assertTrue($result); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => '', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), - array('validate' => 'only', 'deep' => true) - ); - $this->assertFalse($result); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => 'newuser', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), - array('validate' => 'only', 'atomic' => false, 'deep' => true) - ); - $expected = array( - 'Article' => true, - 'Comment' => array( - true, - true - ) - ); - $this->assertSame($expected, $result); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => '', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), - array('validate' => 'only', 'atomic' => false, 'deep' => true) - ); - $expected = array( - 'Article' => true, - 'Comment' => array( - false, - true - ) - ); - $this->assertSame($expected, $result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 5), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => 'deepsaved')) - ) - ), - array('validate' => 'only', 'deep' => true) - ); - $this->assertTrue($result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 5), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => '')) - ) - ), - array('validate' => 'only', 'deep' => true) - ); - $this->assertFalse($result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 5), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => 'deepsave')) - ) - ), - array('validate' => 'only', 'atomic' => false, 'deep' => true) - ); - $expected = array( - 'Article' => true, - 'Comment' => array( - true, - true - ) - ); - $this->assertSame($expected, $result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 5), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => '')) - ) - ), - array('validate' => 'only', 'atomic' => false, 'deep' => true) - ); - $expected = array( - 'Article' => true, - 'Comment' => array( - true, - false - ) - ); - $this->assertSame($expected, $result); - - $expected = array( - 'Comment' => array( - 1 => array( - 'Attachment' => array( - 'attachment' => array('This field cannot be left blank') - ) - ) - ) - ); - $result = $TestModel->validationErrors; - $this->assertSame($expected, $result); - - $data = array( - 'Attachment' => array( - 'attachment' => 'deepsave insert', - ), - 'Comment' => array( - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - 'user_id' => 5, - 'Article' => array( - 'title' => 'First Article deepsave insert', - 'body' => 'First Article Body deepsave insert', - 'User' => array( - 'user' => 'deepsave', - 'password' => 'magic' - ), - ), - ) - ); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'deep' => true)); - $this->assertTrue($result); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'atomic' => false, 'deep' => true)); - $expected = array( - 'Attachment' => true, - 'Comment' => true - ); - $this->assertSame($expected, $result); - - $data = array( - 'Attachment' => array( - 'attachment' => 'deepsave insert', - ), - 'Comment' => array( - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - 'user_id' => 5, - 'Article' => array( - 'title' => 'First Article deepsave insert', - 'body' => 'First Article Body deepsave insert', - 'User' => array( - 'user' => '', - 'password' => 'magic' - ), - ), - ) - ); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'deep' => true)); - $this->assertFalse($result); - - $result = $TestModel->Comment->Attachment->validationErrors; - $expected = array( - 'Comment' => array( - 'Article' => array( - 'User' => array( - 'user' => array('This field cannot be left blank') - ) - ) - ) - ); - $this->assertSame($expected, $result); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'atomic' => false, 'deep' => true)); - $expected = array( - 'Attachment' => true, - 'Comment' => false - ); - $this->assertEquals($expected, $result); - - $data['Comment']['Article']['body'] = ''; - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'deep' => true)); - $this->assertFalse($result); - - $result = $TestModel->Comment->Attachment->validationErrors; - $expected = array( - 'Comment' => array( - 'Article' => array( - 'body' => array('This field cannot be left blank') - ) - ) - ); - $this->assertSame($expected, $result); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'atomic' => false, 'deep' => true)); - $expected = array( - 'Attachment' => true, - 'Comment' => false - ); - $this->assertEquals($expected, $result); - - $data['Comment']['comment'] = ''; - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'deep' => true)); - $this->assertFalse($result); - - $result = $TestModel->Comment->Attachment->validationErrors; - $expected = array( - 'Comment' => array( - 'comment' => array('This field cannot be left blank') - ) - ); - $this->assertSame($expected, $result); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'atomic' => false, 'deep' => true)); - $expected = array( - 'Attachment' => true, - 'Comment' => false - ); - $this->assertEquals($expected, $result); - - $data['Attachment']['attachment'] = ''; - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'deep' => true)); - $this->assertFalse($result); - - $result = $TestModel->Comment->Attachment->validationErrors; - $expected = array('attachment' => array('This field cannot be left blank')); - $this->assertSame($expected, $result); - - $result = $TestModel->Comment->validationErrors; - $expected = array('comment' => array('This field cannot be left blank')); - $this->assertSame($expected, $result); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'atomic' => false, 'deep' => true)); - $expected = array( - 'Attachment' => false, - 'Comment' => false - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllNotDeepAssociated method - * test that only directly associated data gets saved - * - * @return void - */ - public function testSaveAllNotDeepAssociated() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->hasMany['Comment']['order'] = array('Comment.created' => 'ASC'); - $TestModel->hasAndBelongsToMany = array(); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => 'First new comment', 'published' => 'Y', 'user_id' => 2, - 'User' => array('user' => 'newuser', 'password' => 'newuserpass') - ), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), array('deep' => false)); - $this->assertTrue($result); - - $result = $TestModel->Comment->User->field('id', array('user' => 'newuser', 'password' => 'newuserpass')); - $this->assertFalse($result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 4), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => 'deepsaved')) - ) - ), array('deep' => false)); - $this->assertTrue($result); - - $result = $TestModel->Comment->Attachment->field('id', array('attachment' => 'deepsaved')); - $this->assertFalse($result); - - $data = array( - 'Attachment' => array( - 'attachment' => 'deepsave insert', - ), - 'Comment' => array( - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - 'user_id' => 4, - 'article_id' => 1, - 'Article' => array( - 'title' => 'First Article deepsave insert', - 'body' => 'First Article Body deepsave insert', - 'User' => array( - 'user' => 'deepsave', - 'password' => 'magic' - ), - ), - ) - ); - $expected = $TestModel->User->find('count'); - - $TestModel->Comment->Attachment->create(); - $result = $TestModel->Comment->Attachment->saveAll($data, array('deep' => false)); - $this->assertTrue($result); - - $result = $TestModel->User->find('count'); - $this->assertEquals($expected, $result); - - $result = $TestModel->Comment->Attachment->findById($TestModel->Comment->Attachment->id); - $expected = array( - 'Attachment' => array( - 'id' => '2', - 'comment_id' => '11', - 'attachment' => 'deepsave insert', - ), - 'Comment' => array( - 'id' => '11', - 'article_id' => 1, - 'user_id' => '4', - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - ) - ); - unset($result['Attachment']['created'], $result['Attachment']['updated']); - $this->assertEquals($expected['Attachment'], $result['Attachment']); - - unset($result['Comment']['created'], $result['Comment']['updated']); - $this->assertEquals($expected['Comment'], $result['Comment']); - } - -/** - * testSaveAllNotDeepMany - * tests the save methods to not save deeper recursive data - * - * @return void - */ - public function testSaveAllNotDeepMany() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->hasMany['Comment']['order'] = array('Comment.created' => 'ASC'); - $TestModel->hasAndBelongsToMany = array(); - - $data = array( - array( - 'id' => 1, 'body' => '', - 'Comment' => array( - array('comment' => '', 'published' => 'Y', 'User' => array('user' => '', 'password' => 'manysaved')), - array('comment' => 'Second comment deepsaved article 1', 'published' => 'Y', 'user_id' => 2) - ) - ), - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First comment deepsaved article 2', 'published' => 'Y', 'User' => array('user' => 'savemore', 'password' => '')), - array('comment' => '', 'published' => 'Y', 'user_id' => 2) - ) - ) - ); - $TestModel->Comment->validate['comment'] = 'notEmpty'; - $result = $TestModel->saveAll($data, array('deep' => false)); - $this->assertFalse($result); - - $expected = array( - 0 => array( - 'body' => array('This field cannot be left blank') - ) - ); - $result = $TestModel->validationErrors; - $this->assertSame($expected, $result); - - $data = array( - array( - 'Article' => array('id' => 1, 'body' => 'Ignore invalid comment'), - 'Comment' => array( - array('comment' => '', 'published' => 'Y', 'user_id' => 2) - ) - ), - array( - 'Article' => array('id' => 2, 'body' => 'Same here'), - 'Comment' => array( - array('comment' => '', 'published' => 'Y', 'user_id' => 2) - ) - ) - ); - $result = $TestModel->saveAll($data, array('deep' => false)); - $this->assertTrue($result); - } -/** - * testSaveAllNotDeepValidateOnly - * tests the validate methods to not validate deeper recursive data - * - * @return void - */ - public function testSaveAllNotDeepValidateOnly() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->hasMany['Comment']['order'] = array('Comment.created' => 'ASC'); - $TestModel->hasAndBelongsToMany = array(); - $TestModel->Comment->Attachment->validate['attachment'] = 'notEmpty'; - $TestModel->Comment->validate['comment'] = 'notEmpty'; - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2, 'body' => ''), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => '', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), - array('validate' => 'only', 'deep' => false) - ); - $this->assertFalse($result); - - $expected = array('body' => array('This field cannot be left blank')); - $result = $TestModel->validationErrors; - $this->assertSame($expected, $result); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2, 'body' => 'Ignore invalid user data'), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => '', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), - array('validate' => 'only', 'deep' => false) - ); - $this->assertTrue($result); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2, 'body' => 'Ignore invalid user data'), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'User' => array('user' => '', 'password' => 'newuserpass')), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), - array('validate' => 'only', 'atomic' => false, 'deep' => false) - ); - $expected = array( - 'Article' => true, - 'Comment' => array( - true, - true - ) - ); - $this->assertSame($expected, $result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2, 'body' => 'Ignore invalid attachment data'), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 5), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => '')) - ) - ), - array('validate' => 'only', 'deep' => false) - ); - $this->assertTrue($result); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2, 'body' => 'Ignore invalid attachment data'), - 'Comment' => array( - array('comment' => 'Third new comment', 'published' => 'Y', 'user_id' => 5), - array('comment' => 'Fourth new comment', 'published' => 'Y', 'user_id' => 2, 'Attachment' => array('attachment' => '')) - ) - ), - array('validate' => 'only', 'atomic' => false, 'deep' => false) - ); - $expected = array( - 'Article' => true, - 'Comment' => array( - true, - true - ) - ); - $this->assertSame($expected, $result); - - $expected = array(); - $result = $TestModel->validationErrors; - $this->assertSame($expected, $result); - - $data = array( - 'Attachment' => array( - 'attachment' => 'deepsave insert', - ), - 'Comment' => array( - 'comment' => 'First comment deepsave insert', - 'published' => 'Y', - 'user_id' => 5, - 'Article' => array( - 'title' => 'First Article deepsave insert ignored', - 'body' => 'First Article Body deepsave insert', - 'User' => array( - 'user' => '', - 'password' => 'magic' - ), - ), - ) - ); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'deep' => false)); - $this->assertTrue($result); - - $result = $TestModel->Comment->Attachment->validationErrors; - $expected = array(); - $this->assertSame($expected, $result); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'atomic' => false, 'deep' => false)); - $expected = array( - 'Attachment' => true, - 'Comment' => true - ); - $this->assertEquals($expected, $result); - - $data['Comment']['Article']['body'] = ''; - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'deep' => false)); - $this->assertTrue($result); - - $result = $TestModel->Comment->Attachment->validationErrors; - $expected = array(); - $this->assertSame($expected, $result); - - $result = $TestModel->Comment->Attachment->saveAll($data, array('validate' => 'only', 'atomic' => false, 'deep' => false)); - $expected = array( - 'Attachment' => true, - 'Comment' => true - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllHasMany method - * - * @return void - */ - public function testSaveAllHasMany() { - $this->loadFixtures('Article', 'Comment'); - $TestModel = new Article(); - $TestModel->hasMany['Comment']['order'] = array('Comment.created' => 'ASC'); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'user_id' => 1), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - )); - $this->assertFalse(empty($result)); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment' - ); - $result = Set::extract(Set::sort($result['Comment'], '{n}.id', 'ASC'), '{n}.comment'); - $this->assertEquals($expected, $result); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => 'Third new comment', - 'published' => 'Y', - 'user_id' => 1 - ))), - array('atomic' => false) - ); - $this->assertFalse(empty($result)); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment', - 'Third new comment' - ); - $result = Set::extract(Set::sort($result['Comment'], '{n}.id', 'ASC'), '{n}.comment'); - $this->assertEquals($expected, $result); - - $TestModel->beforeSaveReturn = false; - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => 'Fourth new comment', - 'published' => 'Y', - 'user_id' => 1 - ))), - array('atomic' => false) - ); - $this->assertEquals(array('Article' => false), $result); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment', - 'Third new comment' - ); - $result = Set::extract(Set::sort($result['Comment'], '{n}.id', 'ASC'), '{n}.comment'); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllHasManyValidation method - * - * @return void - */ - public function testSaveAllHasManyValidation() { - $this->loadFixtures('Article', 'Comment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - $TestModel->Comment->validate = array('comment' => 'notEmpty'); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => '', 'published' => 'Y', 'user_id' => 1), - ) - ), array('validate' => true)); - $this->assertFalse($result); - - $expected = array('Comment' => array( - array('comment' => array('This field cannot be left blank')) - )); - $this->assertEquals($expected, $TestModel->validationErrors); - $expected = array( - array('comment' => array('This field cannot be left blank')) - ); - $this->assertEquals($expected, $TestModel->Comment->validationErrors); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1 - )) - ), array('validate' => 'first')); - $this->assertFalse($result); - } - -/** - * test saveAll with transactions and ensure there is no missing rollback. - * - * @return void - */ - public function testSaveAllManyRowsTransactionNoRollback() { - $this->loadFixtures('Post'); - - $this->getMock('DboSource', array('connect', 'rollback', 'describe'), array(), 'MockTransactionDboSource'); - $db = ConnectionManager::create('mock_transaction', array( - 'datasource' => 'MockTransactionDboSource', - )); - - $db->expects($this->once()) - ->method('describe') - ->will($this->returnValue(array())); - $db->expects($this->once())->method('rollback'); - - $Post = new Post('mock_transaction'); - - $Post->validate = array( - 'title' => array('rule' => array('notEmpty')) - ); - - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => '') - ); - $Post->saveAll($data, array('atomic' => true)); - } - -/** - * test saveAll with transactions and ensure there is no missing rollback. - * - * @return void - */ - public function testSaveAllAssociatedTransactionNoRollback() { - $testDb = ConnectionManager::getDataSource('test'); - - $mock = $this->getMock( - 'DboSource', - array('connect', 'rollback', 'describe', 'create', 'update', 'begin'), - array(), - 'MockTransactionAssociatedDboSource' - ); - $db = ConnectionManager::create('mock_transaction_assoc', array( - 'datasource' => 'MockTransactionAssociatedDboSource', - )); - $this->mockObjects[] = $db; - $db->columns = $testDb->columns; - - $db->expects($this->once())->method('rollback'); - $db->expects($this->any())->method('describe') - ->will($this->returnValue(array( - 'id' => array('type' => 'integer', 'length' => 11), - 'title' => array('type' => 'string'), - 'body' => array('type' => 'text'), - 'published' => array('type' => 'string') - ))); - - $Post = new Post(); - $Post->useDbConfig = 'mock_transaction_assoc'; - $Post->Author->useDbConfig = 'mock_transaction_assoc'; - - $Post->Author->validate = array( - 'user' => array('rule' => array('notEmpty')) - ); - - $data = array( - 'Post' => array( - 'title' => 'New post', - 'body' => 'Content', - 'published' => 'Y' - ), - 'Author' => array( - 'user' => '', - 'password' => "sekret" - ) - ); - $Post->saveAll($data, array('validate' => true)); - } - -/** - * test saveAll with nested saveAll call. - * - * @return void - */ - public function testSaveAllNestedSaveAll() { - $this->loadFixtures('Sample'); - $TransactionTestModel = new TransactionTestModel(); - - $data = array( - array('apple_id' => 1, 'name' => 'sample5'), - ); - - $this->assertTrue($TransactionTestModel->saveAll($data, array('atomic' => true))); - } - -/** - * testSaveAllTransaction method - * - * @return void - */ - public function testSaveAllTransaction() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment'); - $TestModel = new Post(); - - $TestModel->validate = array('title' => 'notEmpty'); - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => 'New Fifth Post'), - array('author_id' => 1, 'title' => '') - ); - $ts = date('Y-m-d H:i:s'); - $this->assertFalse($TestModel->saveAll($data)); - - $result = $TestModel->find('all', array('recursive' => -1)); - $expected = array( - array('Post' => array( - 'id' => '1', - 'author_id' => 1, - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array('Post' => array( - 'id' => '2', - 'author_id' => 3, - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - )), - array('Post' => array( - 'id' => '3', - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ))); - - if (count($result) != 3) { - // Database doesn't support transactions - $expected[] = array( - 'Post' => array( - 'id' => '4', - 'author_id' => 1, - 'title' => 'New Fourth Post', - 'body' => null, - 'published' => 'N', - 'created' => $ts, - 'updated' => $ts - )); - - $expected[] = array( - 'Post' => array( - 'id' => '5', - 'author_id' => 1, - 'title' => 'New Fifth Post', - 'body' => null, - 'published' => 'N', - 'created' => $ts, - 'updated' => $ts - )); - - $this->assertEquals($expected, $result); - // Skip the rest of the transactional tests - return; - } - - $this->assertEquals($expected, $result); - - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => ''), - array('author_id' => 1, 'title' => 'New Sixth Post') - ); - $ts = date('Y-m-d H:i:s'); - $this->assertFalse($TestModel->saveAll($data)); - - $result = $TestModel->find('all', array('recursive' => -1)); - $expected = array( - array('Post' => array( - 'id' => '1', - 'author_id' => 1, - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array('Post' => array( - 'id' => '2', - 'author_id' => 3, - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - )), - array('Post' => array( - 'id' => '3', - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ))); - - if (count($result) != 3) { - // Database doesn't support transactions - $expected[] = array( - 'Post' => array( - 'id' => '4', - 'author_id' => 1, - 'title' => 'New Fourth Post', - 'body' => 'Third Post Body', - 'published' => 'N', - 'created' => $ts, - 'updated' => $ts - )); - - $expected[] = array( - 'Post' => array( - 'id' => '5', - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'N', - 'created' => $ts, - 'updated' => $ts - )); - } - $this->assertEquals($expected, $result); - - $TestModel->validate = array('title' => 'notEmpty'); - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => 'New Fifth Post'), - array('author_id' => 1, 'title' => 'New Sixth Post') - ); - $this->assertTrue($TestModel->saveAll($data)); - - $result = $TestModel->find('all', array( - 'recursive' => -1, - 'fields' => array('author_id', 'title','body','published'), - 'order' => array('Post.created' => 'ASC') - )); - - $expected = array( - array('Post' => array( - 'author_id' => 1, - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y' - )), - array('Post' => array( - 'author_id' => 3, - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'New Fourth Post', - 'body' => '', - 'published' => 'N' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'New Fifth Post', - 'body' => '', - 'published' => 'N' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'New Sixth Post', - 'body' => '', - 'published' => 'N' - ))); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllValidation method - * - * @return void - */ - public function testSaveAllValidation() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment'); - $TestModel = new Post(); - - $data = array( - array( - 'id' => '1', - 'title' => 'Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N' - ), - array( - 'id' => '2', - 'title' => 'Just update the title' - ), - array( - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'author_id' => 2 - )); - - $ts = date('Y-m-d H:i:s'); - $this->assertTrue($TestModel->saveAll($data)); - - $result = $TestModel->find('all', array('recursive' => -1, 'order' => 'Post.id ASC')); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '1', - 'title' => 'Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N', - 'created' => '2007-03-18 10:39:23' - )), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '3', - 'title' => 'Just update the title', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23' - )), - array( - 'Post' => array( - 'id' => '3', - 'author_id' => '1', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - )), - array( - 'Post' => array( - 'id' => '4', - 'author_id' => '2', - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'published' => 'N' - ))); - $this->assertTrue($result[0]['Post']['updated'] >= $ts); - $this->assertTrue($result[1]['Post']['updated'] >= $ts); - $this->assertTrue($result[3]['Post']['created'] >= $ts); - $this->assertTrue($result[3]['Post']['updated'] >= $ts); - unset($result[0]['Post']['updated'], $result[1]['Post']['updated']); - unset($result[3]['Post']['created'], $result[3]['Post']['updated']); - $this->assertEquals($expected, $result); - - $TestModel->validate = array('title' => 'notEmpty', 'author_id' => 'numeric'); - $data = array( - array( - 'id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - )); - $result = $TestModel->saveAll($data); - $this->assertFalse($result); - - $result = $TestModel->find('all', array('recursive' => -1, 'order' => 'Post.id ASC')); - $errors = array(1 => array('title' => array('This field cannot be left blank'))); - $transactionWorked = Set::matches('/Post[1][title=Baleeted First Post]', $result); - if (!$transactionWorked) { - $this->assertTrue(Set::matches('/Post[1][title=Un-Baleeted First Post]', $result)); - $this->assertTrue(Set::matches('/Post[2][title=Just update the title]', $result)); - } - - $this->assertEquals($TestModel->validationErrors, $errors); - - $TestModel->validate = array('title' => 'notEmpty', 'author_id' => 'numeric'); - $data = array( - array( - 'id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - )); - $result = $TestModel->saveAll($data, array('validate' => true, 'atomic' => false)); - $this->assertEquals(array(true, false), $result); - $result = $TestModel->find('all', array('recursive' => -1, 'order' => 'Post.id ASC')); - $errors = array(1 => array('title' => array('This field cannot be left blank'))); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23' - ) - ), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '3', - 'title' => 'Just update the title', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23' - ) - ), - array( - 'Post' => array( - 'id' => '3', - 'author_id' => '1', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ) - ), - array( - 'Post' => array( - 'id' => '4', - 'author_id' => '2', - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'published' => 'N' - ) - ) - ); - - $this->assertTrue($result[0]['Post']['updated'] >= $ts); - $this->assertTrue($result[1]['Post']['updated'] >= $ts); - $this->assertTrue($result[3]['Post']['updated'] >= $ts); - $this->assertTrue($result[3]['Post']['created'] >= $ts); - unset( - $result[0]['Post']['updated'], $result[1]['Post']['updated'], - $result[3]['Post']['updated'], $result[3]['Post']['created'] - ); - $this->assertEquals($expected, $result); - $this->assertEquals($TestModel->validationErrors, $errors); - - $data = array( - array( - 'id' => '1', - 'title' => 'Re-Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - )); - $this->assertFalse($TestModel->saveAll($data, array('validate' => 'first'))); - - $result = $TestModel->find('all', array('recursive' => -1, 'order' => 'Post.id ASC')); - unset( - $result[0]['Post']['updated'], $result[1]['Post']['updated'], - $result[3]['Post']['updated'], $result[3]['Post']['created'] - ); - $this->assertEquals($expected, $result); - $this->assertEquals($TestModel->validationErrors, $errors); - } - -/** - * testSaveAllValidationOnly method - * - * @return void - */ - public function testSaveAllValidationOnly() { - $this->loadFixtures('Comment', 'Attachment'); - $TestModel = new Comment(); - $TestModel->Attachment->validate = array('attachment' => 'notEmpty'); - - $data = array( - 'Comment' => array( - 'comment' => 'This is the comment' - ), - 'Attachment' => array( - 'attachment' => '' - ) - ); - - $result = $TestModel->saveAll($data, array('validate' => 'only')); - $this->assertFalse($result); - - $TestModel = new Article(); - $TestModel->validate = array('title' => 'notEmpty'); - $result = $TestModel->saveAll( - array( - 0 => array('title' => ''), - 1 => array('title' => 'title 1'), - 2 => array('title' => 'title 2'), - ), - array('validate' => 'only') - ); - $this->assertFalse($result); - $expected = array( - 0 => array('title' => array('This field cannot be left blank')), - ); - $this->assertEquals($expected, $TestModel->validationErrors); - - $result = $TestModel->saveAll( - array( - 0 => array('title' => 'title 0'), - 1 => array('title' => ''), - 2 => array('title' => 'title 2'), - ), - array('validate' => 'only') - ); - $this->assertFalse($result); - $expected = array( - 1 => array('title' => array('This field cannot be left blank')), - ); - $this->assertEquals($expected, $TestModel->validationErrors); - } - -/** - * testSaveAllValidateFirst method - * - * @return void - */ - public function testSaveAllValidateFirst() { - $this->loadFixtures('Article', 'Comment', 'Attachment'); - $model = new Article(); - $model->deleteAll(true); - - $model->Comment->validate = array('comment' => 'notEmpty'); - $result = $model->saveAll(array( - 'Article' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved author' - ), - 'Comment' => array( - array('comment' => 'First new comment'), - array('comment' => '') - ) - ), array('validate' => 'first')); - - $this->assertFalse($result); - - $result = $model->find('all'); - $this->assertEquals(array(), $result); - $expected = array('Comment' => array( - 1 => array('comment' => array('This field cannot be left blank')) - )); - - $this->assertEquals($expected['Comment'], $model->Comment->validationErrors); - - $this->assertSame($model->Comment->find('count'), 0); - - $result = $model->saveAll( - array( - 'Article' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author', - 'user_id' => 2 - ), - 'Comment' => array( - array( - 'comment' => 'Only new comment', - 'user_id' => 2 - ))), - array('validate' => 'first') - ); - - $this->assertSame($result, true); - - $result = $model->Comment->find('all'); - $this->assertSame(count($result), 1); - $result = Set::extract('/Comment/article_id', $result); - $this->assertEquals(4, $result[0]); - - $model->deleteAll(true); - $data = array( - 'Article' => array( - 'title' => 'Post with Author saveAlled from comment', - 'body' => 'This post will be saved with an author', - 'user_id' => 2 - ), - 'Comment' => array( - 'comment' => 'Only new comment', 'user_id' => 2 - )); - - $result = $model->Comment->saveAll($data, array('validate' => 'first')); - $this->assertFalse(empty($result)); - - $result = $model->find('all'); - $this->assertEquals( - $result[0]['Article']['title'], - 'Post with Author saveAlled from comment' - ); - $this->assertEquals('Only new comment', $result[0]['Comment'][0]['comment']); - } - -/** - * test saveAll()'s return is correct when using atomic = false and validate = first. - * - * @return void - */ - public function testSaveAllValidateFirstAtomicFalse() { - $Something = new Something(); - $invalidData = array( - array( - 'title' => 'foo', - 'body' => 'bar', - 'published' => 'baz', - ), - array( - 'body' => 3, - 'published' => 'sd', - ), - ); - $Something->create(); - $Something->validate = array( - 'title' => array( - 'rule' => 'alphaNumeric', - 'required' => true, - ), - 'body' => array( - 'rule' => 'alphaNumeric', - 'required' => true, - 'allowEmpty' => true, - ), - ); - $result = $Something->saveAll($invalidData, array( - 'atomic' => false, - 'validate' => 'first', - )); - $expected = array(true, false); - $this->assertEquals($expected, $result); - - $Something = new Something(); - $validData = array( - array( - 'title' => 'title value', - 'body' => 'body value', - 'published' => 'baz', - ), - array( - 'title' => 'valid', - 'body' => 'this body', - 'published' => 'sd', - ), - ); - $Something->create(); - $result = $Something->saveAll($validData, array( - 'atomic' => false, - 'validate' => 'first', - )); - $expected = array(true, true); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllHasManyValidationOnly method - * - * @return void - */ - public function testSaveAllHasManyValidationOnly() { - $this->loadFixtures('Article', 'Comment', 'Attachment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - $TestModel->Comment->validate = array('comment' => 'notEmpty'); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'id' => 1, - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1), - array( - 'id' => 2, - 'comment' => - 'comment', - 'published' => 'Y', - 'user_id' => 1 - ))), - array('validate' => 'only') - ); - $this->assertFalse($result); - - $result = $TestModel->saveAll( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'id' => 1, - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1 - ), - array( - 'id' => 2, - 'comment' => 'comment', - 'published' => 'Y', - 'user_id' => 1 - ), - array( - 'id' => 3, - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1 - ))), - array( - 'validate' => 'only', - 'atomic' => false - )); - $expected = array( - 'Article' => true, - 'Comment' => array(false, true, false) - ); - $this->assertSame($expected, $result); - - $expected = array('Comment' => array( - 0 => array('comment' => array('This field cannot be left blank')), - 2 => array('comment' => array('This field cannot be left blank')) - )); - $this->assertEquals($expected, $TestModel->validationErrors); - - $expected = array( - 0 => array('comment' => array('This field cannot be left blank')), - 2 => array('comment' => array('This field cannot be left blank')) - ); - $this->assertEquals($expected, $TestModel->Comment->validationErrors); - } - -/** - * test that saveAll behaves like plain save() when supplied empty data - * - * @link http://cakephp.lighthouseapp.com/projects/42648/tickets/277-test-saveall-with-validation-returns-incorrect-boolean-when-saving-empty-data - * @return void - */ - public function testSaveAllEmptyData() { - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $this->loadFixtures('Article', 'ProductUpdateAll', 'Comment', 'Attachment'); - $model = new Article(); - $result = $model->saveAll(array(), array('validate' => 'first')); - $this->assertFalse(empty($result)); - - $model = new ProductUpdateAll(); - $result = $model->saveAll(array()); - $this->assertFalse($result); - } - -/** - * testSaveAssociated method - * - * @return void - */ - public function testSaveAssociated() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment', 'Article', 'User'); - $TestModel = new Post(); - - $result = $TestModel->find('all'); - $this->assertEquals(3, count($result)); - $this->assertFalse(isset($result[3])); - $ts = date('Y-m-d H:i:s'); - - $TestModel->saveAssociated(array( - 'Post' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author' - ), - 'Author' => array( - 'user' => 'bob', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf90' - ))); - - $result = $TestModel->find('all'); - $expected = array( - 'Post' => array( - 'id' => '4', - 'author_id' => '5', - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author', - 'published' => 'N' - ), - 'Author' => array( - 'id' => '5', - 'user' => 'bob', - 'password' => '5f4dcc3b5aa765d61d8327deb882cf90', - 'test' => 'working' - )); - $this->assertTrue($result[3]['Post']['updated'] >= $ts); - $this->assertTrue($result[3]['Post']['created'] >= $ts); - $this->assertTrue($result[3]['Author']['created'] >= $ts); - $this->assertTrue($result[3]['Author']['updated'] >= $ts); - unset( - $result[3]['Post']['updated'], $result[3]['Post']['created'], - $result[3]['Author']['updated'], $result[3]['Author']['created'] - ); - $this->assertEquals($expected, $result[3]); - $this->assertEquals(4, count($result)); - - $ts = date('Y-m-d H:i:s'); - - $TestModel = new Comment(); - $ts = date('Y-m-d H:i:s'); - $result = $TestModel->saveAssociated(array( - 'Comment' => array( - 'article_id' => 2, - 'user_id' => 2, - 'comment' => 'New comment with attachment', - 'published' => 'Y' - ), - 'Attachment' => array( - 'attachment' => 'some_file.tgz' - ))); - $this->assertFalse(empty($result)); - - $result = $TestModel->find('all'); - $expected = array( - 'id' => '7', - 'article_id' => '2', - 'user_id' => '2', - 'comment' => 'New comment with attachment', - 'published' => 'Y' - ); - $this->assertTrue($result[6]['Comment']['updated'] >= $ts); - $this->assertTrue($result[6]['Comment']['created'] >= $ts); - unset($result[6]['Comment']['updated'], $result[6]['Comment']['created']); - $this->assertEquals($expected, $result[6]['Comment']); - - $expected = array( - 'id' => '2', - 'comment_id' => '7', - 'attachment' => 'some_file.tgz' - ); - $this->assertTrue($result[6]['Attachment']['updated'] >= $ts); - $this->assertTrue($result[6]['Attachment']['created'] >= $ts); - unset($result[6]['Attachment']['updated'], $result[6]['Attachment']['created']); - $this->assertEquals($expected, $result[6]['Attachment']); - } - -/** - * testSaveMany method - * - * @return void - */ - public function testSaveMany() { - $this->loadFixtures('Post'); - $TestModel = new Post(); - $TestModel->deleteAll(true); - $this->assertEquals(array(), $TestModel->find('all')); - - // SQLite seems to reset the PK counter when that happens, so we need this to make the tests pass - $this->db->truncate($TestModel); - - $ts = date('Y-m-d H:i:s'); - $TestModel->saveMany(array( - array( - 'title' => 'Multi-record post 1', - 'body' => 'First multi-record post', - 'author_id' => 2 - ), - array( - 'title' => 'Multi-record post 2', - 'body' => 'Second multi-record post', - 'author_id' => 2 - ))); - - $result = $TestModel->find('all', array( - 'recursive' => -1, - 'order' => 'Post.id ASC' - )); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '2', - 'title' => 'Multi-record post 1', - 'body' => 'First multi-record post', - 'published' => 'N' - ) - ), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '2', - 'title' => 'Multi-record post 2', - 'body' => 'Second multi-record post', - 'published' => 'N' - ) - ) - ); - $this->assertTrue($result[0]['Post']['updated'] >= $ts); - $this->assertTrue($result[0]['Post']['created'] >= $ts); - $this->assertTrue($result[1]['Post']['updated'] >= $ts); - $this->assertTrue($result[1]['Post']['created'] >= $ts); - unset($result[0]['Post']['updated'], $result[0]['Post']['created']); - unset($result[1]['Post']['updated'], $result[1]['Post']['created']); - $this->assertEquals($expected, $result); - } - -/** - * Test SaveAssociated with Habtm relations - * - * @return void - */ - public function testSaveAssociatedHabtm() { - $this->loadFixtures('Article', 'Tag', 'Comment', 'User', 'ArticlesTag'); - $data = array( - 'Article' => array( - 'user_id' => 1, - 'title' => 'Article Has and belongs to Many Tags' - ), - 'Tag' => array( - 'Tag' => array(1, 2) - ), - 'Comment' => array( - array( - 'comment' => 'Article comment', - 'user_id' => 1 - ))); - $Article = new Article(); - $result = $Article->saveAssociated($data); - $this->assertFalse(empty($result)); - - $result = $Article->read(); - $this->assertEquals(2, count($result['Tag'])); - $this->assertEquals('tag1', $result['Tag'][0]['tag']); - $this->assertEquals(1, count($result['Comment'])); - $this->assertEquals(1, count($result['Comment'][0]['comment'])); - } - -/** - * Test SaveAssociated with Habtm relations and extra join table fields - * - * @return void - */ - public function testSaveAssociatedHabtmWithExtraJoinTableFields() { - $this->loadFixtures('Something', 'SomethingElse', 'JoinThing'); - - $data = array( - 'Something' => array( - 'id' => 4, - 'title' => 'Extra Fields', - 'body' => 'Extra Fields Body', - 'published' => '1' - ), - 'SomethingElse' => array( - array('something_else_id' => 1, 'doomed' => '1'), - array('something_else_id' => 2, 'doomed' => '0'), - array('something_else_id' => 3, 'doomed' => '1') - ) - ); - - $Something = new Something(); - $result = $Something->saveAssociated($data); - $this->assertFalse(empty($result)); - $result = $Something->read(); - - $this->assertEquals(3, count($result['SomethingElse'])); - $this->assertTrue(Set::matches('/Something[id=4]', $result)); - - $this->assertTrue(Set::matches('/SomethingElse[id=1]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=1]/JoinThing[something_else_id=1]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=1]/JoinThing[doomed=1]', $result)); - - $this->assertTrue(Set::matches('/SomethingElse[id=2]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=2]/JoinThing[something_else_id=2]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=2]/JoinThing[doomed=0]', $result)); - - $this->assertTrue(Set::matches('/SomethingElse[id=3]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=3]/JoinThing[something_else_id=3]', $result)); - $this->assertTrue(Set::matches('/SomethingElse[id=3]/JoinThing[doomed=1]', $result)); - } - -/** - * testSaveAssociatedHasOne method - * - * @return void - */ - public function testSaveAssociatedHasOne() { - $model = new Comment(); - $model->deleteAll(true); - $this->assertEquals(array(), $model->find('all')); - - $model->Attachment->deleteAll(true); - $this->assertEquals(array(), $model->Attachment->find('all')); - - $this->assertTrue($model->saveAssociated(array( - 'Comment' => array( - 'comment' => 'Comment with attachment', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Attachment' => array( - 'attachment' => 'some_file.zip' - )))); - $result = $model->find('all', array('fields' => array( - 'Comment.id', 'Comment.comment', 'Attachment.id', - 'Attachment.comment_id', 'Attachment.attachment' - ))); - $expected = array(array( - 'Comment' => array( - 'id' => '1', - 'comment' => 'Comment with attachment' - ), - 'Attachment' => array( - 'id' => '1', - 'comment_id' => '1', - 'attachment' => 'some_file.zip' - ))); - $this->assertEquals($expected, $result); - - $model->Attachment->bindModel(array('belongsTo' => array('Comment')), false); - $data = array( - 'Comment' => array( - 'comment' => 'Comment with attachment', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Attachment' => array( - 'attachment' => 'some_file.zip' - )); - $this->assertTrue($model->saveAssociated($data, array('validate' => 'first'))); - } - -/** - * testSaveAssociatedBelongsTo method - * - * @return void - */ - public function testSaveAssociatedBelongsTo() { - $model = new Comment(); - $model->deleteAll(true); - $this->assertEquals(array(), $model->find('all')); - - $model->Article->deleteAll(true); - $this->assertEquals(array(), $model->Article->find('all')); - - $this->assertTrue($model->saveAssociated(array( - 'Comment' => array( - 'comment' => 'Article comment', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Article' => array( - 'title' => 'Model Associations 101', - 'user_id' => 1 - )))); - $result = $model->find('all', array('fields' => array( - 'Comment.id', 'Comment.comment', 'Comment.article_id', 'Article.id', 'Article.title' - ))); - $expected = array(array( - 'Comment' => array( - 'id' => '1', - 'article_id' => '1', - 'comment' => 'Article comment' - ), - 'Article' => array( - 'id' => '1', - 'title' => 'Model Associations 101' - ))); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAssociatedHasOneValidation method - * - * @return void - */ - public function testSaveAssociatedHasOneValidation() { - $model = new Comment(); - $model->deleteAll(true); - $this->assertEquals(array(), $model->find('all')); - - $model->Attachment->deleteAll(true); - $this->assertEquals(array(), $model->Attachment->find('all')); - - $model->validate = array('comment' => 'notEmpty'); - $model->Attachment->validate = array('attachment' => 'notEmpty'); - $model->Attachment->bindModel(array('belongsTo' => array('Comment'))); - - $this->assertEquals($model->saveAssociated( - array( - 'Comment' => array( - 'comment' => '', - 'article_id' => 1, - 'user_id' => 1 - ), - 'Attachment' => array('attachment' => '') - ) - ), false); - $expected = array( - 'Comment' => array('comment' => array('This field cannot be left blank')), - 'Attachment' => array('attachment' => array('This field cannot be left blank')) - ); - $this->assertEquals($expected['Comment'], $model->validationErrors); - $this->assertEquals($expected['Attachment'], $model->Attachment->validationErrors); - } - -/** - * testSaveAssociatedAtomic method - * - * @return void - */ - public function testSaveAssociatedAtomic() { - $this->loadFixtures('Article', 'User'); - $TestModel = new Article(); - - $result = $TestModel->saveAssociated(array( - 'Article' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author', - 'user_id' => 2 - ), - 'Comment' => array( - array('comment' => 'First new comment', 'user_id' => 2)) - ), array('atomic' => false)); - - $this->assertSame($result, array('Article' => true, 'Comment' => array(true))); - - $result = $TestModel->saveAssociated(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => 'First new comment', - 'published' => 'Y', - 'user_id' => 1 - ), - array( - 'comment' => 'Second new comment', - 'published' => 'Y', - 'user_id' => 2 - )) - ), array('validate' => true, 'atomic' => false)); - $this->assertSame($result, array('Article' => true, 'Comment' => array(true, true))); - } - -/** - * testSaveManyAtomic method - * - * @return void - */ - public function testSaveManyAtomic() { - $this->loadFixtures('Article', 'User'); - $TestModel = new Article(); - - $result = $TestModel->saveMany(array( - array( - 'id' => '1', - 'title' => 'Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N' - ), - array( - 'id' => '2', - 'title' => 'Just update the title' - ), - array( - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'user_id' => 2 - ) - ), array('atomic' => false)); - $this->assertSame($result, array(true, true, true)); - - $TestModel->validate = array('title' => 'notEmpty', 'author_id' => 'numeric'); - $result = $TestModel->saveMany(array( - array( - 'id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - ) - ), array('validate' => true, 'atomic' => false)); - - $this->assertSame(array(true, false), $result); - } - -/** - * testSaveAssociatedHasMany method - * - * @return void - */ - public function testSaveAssociatedHasMany() { - $this->loadFixtures('Article', 'Comment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - - $result = $TestModel->saveAssociated(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'user_id' => 1), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - )); - $this->assertFalse(empty($result)); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment' - ); - $this->assertEquals($expected, Set::extract($result['Comment'], '{n}.comment')); - - $result = $TestModel->saveAssociated( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => 'Third new comment', - 'published' => 'Y', - 'user_id' => 1 - ))), - array('atomic' => false) - ); - $this->assertFalse(empty($result)); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment', - 'Third new comment' - ); - $this->assertEquals($expected, Set::extract($result['Comment'], '{n}.comment')); - - $TestModel->beforeSaveReturn = false; - $result = $TestModel->saveAssociated( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => 'Fourth new comment', - 'published' => 'Y', - 'user_id' => 1 - ))), - array('atomic' => false) - ); - $this->assertEquals(array('Article' => false), $result); - - $result = $TestModel->findById(2); - $expected = array( - 'First Comment for Second Article', - 'Second Comment for Second Article', - 'First new comment', - 'Second new comment', - 'Third new comment' - ); - $this->assertEquals($expected, Set::extract($result['Comment'], '{n}.comment')); - } - -/** - * testSaveAssociatedHasManyValidation method - * - * @return void - */ - public function testSaveAssociatedHasManyValidation() { - $this->loadFixtures('Article', 'Comment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - $TestModel->Comment->validate = array('comment' => 'notEmpty'); - - $result = $TestModel->saveAssociated(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array('comment' => '', 'published' => 'Y', 'user_id' => 1), - ) - ), array('validate' => true)); - $this->assertFalse($result); - - $expected = array('Comment' => array( - array('comment' => array('This field cannot be left blank')) - )); - $this->assertEquals($expected, $TestModel->validationErrors); - $expected = array( - array('comment' => array('This field cannot be left blank')) - ); - $this->assertEquals($expected, $TestModel->Comment->validationErrors); - - $result = $TestModel->saveAssociated(array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1 - )) - ), array('validate' => 'first')); - $this->assertFalse($result); - } - -/** - * test saveMany with transactions and ensure there is no missing rollback. - * - * @return void - */ - public function testSaveManyTransactionNoRollback() { - $this->loadFixtures('Post'); - - $this->getMock('DboSource', array('connect', 'rollback', 'describe'), array(), 'MockManyTransactionDboSource'); - $db = ConnectionManager::create('mock_many_transaction', array( - 'datasource' => 'MockManyTransactionDboSource', - )); - - $db->expects($this->once()) - ->method('describe') - ->will($this->returnValue(array())); - $db->expects($this->once())->method('rollback'); - - $Post = new Post('mock_many_transaction'); - - $Post->validate = array( - 'title' => array('rule' => array('notEmpty')) - ); - - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => '') - ); - $Post->saveMany($data); - } - -/** - * test saveAssociated with transactions and ensure there is no missing rollback. - * - * @return void - */ - public function testSaveAssociatedTransactionNoRollback() { - $testDb = ConnectionManager::getDataSource('test'); - - $mock = $this->getMock( - 'DboSource', - array('connect', 'rollback', 'describe', 'create', 'begin'), - array(), - 'MockAssociatedTransactionDboSource', - false - ); - $db = ConnectionManager::create('mock_assoc_transaction', array( - 'datasource' => 'MockAssociatedTransactionDboSource', - )); - $this->mockObjects[] = $db; - $db->columns = $testDb->columns; - - $db->expects($this->once())->method('rollback'); - $db->expects($this->any())->method('describe') - ->will($this->returnValue(array( - 'id' => array('type' => 'integer', 'length' => 11), - 'title' => array('type' => 'string'), - 'body' => array('type' => 'text'), - 'published' => array('type' => 'string') - ))); - - $Post = new Post(); - $Post->useDbConfig = 'mock_assoc_transaction'; - $Post->Author->useDbConfig = 'mock_assoc_transaction'; - - $Post->Author->validate = array( - 'user' => array('rule' => array('notEmpty')) - ); - - $data = array( - 'Post' => array( - 'title' => 'New post', - 'body' => 'Content', - 'published' => 'Y' - ), - 'Author' => array( - 'user' => '', - 'password' => "sekret" - ) - ); - $Post->saveAssociated($data, array('validate' => true, 'atomic' => true)); - } - -/** - * test saveMany with nested saveMany call. - * - * @return void - */ - public function testSaveManyNestedSaveMany() { - $this->loadFixtures('Sample'); - $TransactionManyTestModel = new TransactionManyTestModel(); - - $data = array( - array('apple_id' => 1, 'name' => 'sample5'), - ); - - $this->assertTrue($TransactionManyTestModel->saveMany($data, array('atomic' => true))); - } - -/** - * testSaveManyTransaction method - * - * @return void - */ - public function testSaveManyTransaction() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment'); - $TestModel = new Post(); - - $TestModel->validate = array('title' => 'notEmpty'); - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => 'New Fifth Post'), - array('author_id' => 1, 'title' => '') - ); - $ts = date('Y-m-d H:i:s'); - $this->assertFalse($TestModel->saveMany($data)); - - $result = $TestModel->find('all', array('recursive' => -1)); - $expected = array( - array('Post' => array( - 'id' => '1', - 'author_id' => 1, - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array('Post' => array( - 'id' => '2', - 'author_id' => 3, - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - )), - array('Post' => array( - 'id' => '3', - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ))); - - if (count($result) != 3) { - // Database doesn't support transactions - $expected[] = array( - 'Post' => array( - 'id' => '4', - 'author_id' => 1, - 'title' => 'New Fourth Post', - 'body' => null, - 'published' => 'N' - )); - - $expected[] = array( - 'Post' => array( - 'id' => '5', - 'author_id' => 1, - 'title' => 'New Fifth Post', - 'body' => null, - 'published' => 'N', - )); - - $this->assertTrue($result[3]['Post']['created'] >= $ts); - $this->assertTrue($result[3]['Post']['updated'] >= $ts); - $this->assertTrue($result[4]['Post']['created'] >= $ts); - $this->assertTrue($result[4]['Post']['updated'] >= $ts); - unset($result[3]['Post']['created'], $result[3]['Post']['updated']); - unset($result[4]['Post']['created'], $result[4]['Post']['updated']); - $this->assertEquals($expected, $result); - // Skip the rest of the transactional tests - return; - } - - $this->assertEquals($expected, $result); - - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => ''), - array('author_id' => 1, 'title' => 'New Sixth Post') - ); - $ts = date('Y-m-d H:i:s'); - $this->assertFalse($TestModel->saveMany($data)); - - $result = $TestModel->find('all', array('recursive' => -1)); - $expected = array( - array('Post' => array( - 'id' => '1', - 'author_id' => 1, - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:39:23', - 'updated' => '2007-03-18 10:41:31' - )), - array('Post' => array( - 'id' => '2', - 'author_id' => 3, - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23', - 'updated' => '2007-03-18 10:43:31' - )), - array('Post' => array( - 'id' => '3', - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - ))); - - if (count($result) != 3) { - // Database doesn't support transactions - $expected[] = array( - 'Post' => array( - 'id' => '4', - 'author_id' => 1, - 'title' => 'New Fourth Post', - 'body' => 'Third Post Body', - 'published' => 'N' - )); - - $expected[] = array( - 'Post' => array( - 'id' => '5', - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'N' - )); - $this->assertTrue($result[3]['Post']['created'] >= $ts); - $this->assertTrue($result[3]['Post']['updated'] >= $ts); - $this->assertTrue($result[4]['Post']['created'] >= $ts); - $this->assertTrue($result[4]['Post']['updated'] >= $ts); - unset($result[3]['Post']['created'], $result[3]['Post']['updated']); - unset($result[4]['Post']['created'], $result[4]['Post']['updated']); - } - $this->assertEquals($expected, $result); - - $TestModel->validate = array('title' => 'notEmpty'); - $data = array( - array('author_id' => 1, 'title' => 'New Fourth Post'), - array('author_id' => 1, 'title' => 'New Fifth Post'), - array('author_id' => 1, 'title' => 'New Sixth Post') - ); - $this->assertTrue($TestModel->saveMany($data)); - - $result = $TestModel->find('all', array( - 'recursive' => -1, - 'fields' => array('author_id', 'title','body','published'), - 'order' => array('Post.created' => 'ASC') - )); - - $expected = array( - array('Post' => array( - 'author_id' => 1, - 'title' => 'First Post', - 'body' => 'First Post Body', - 'published' => 'Y' - )), - array('Post' => array( - 'author_id' => 3, - 'title' => 'Second Post', - 'body' => 'Second Post Body', - 'published' => 'Y' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'New Fourth Post', - 'body' => '', - 'published' => 'N' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'New Fifth Post', - 'body' => '', - 'published' => 'N' - )), - array('Post' => array( - 'author_id' => 1, - 'title' => 'New Sixth Post', - 'body' => '', - 'published' => 'N' - ))); - $this->assertEquals($expected, $result); - } - -/** - * testSaveManyValidation method - * - * @return void - */ - public function testSaveManyValidation() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment'); - $TestModel = new Post(); - - $ts = date('Y-m-d H:i:s'); - $data = array( - array( - 'id' => '1', - 'title' => 'Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N' - ), - array( - 'id' => '2', - 'title' => 'Just update the title' - ), - array( - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'author_id' => 2 - )); - - $this->assertTrue($TestModel->saveMany($data)); - - $result = $TestModel->find('all', array('recursive' => -1, 'order' => 'Post.id ASC')); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '1', - 'title' => 'Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N', - 'created' => '2007-03-18 10:39:23' - ) - ), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '3', - 'title' => 'Just update the title', - 'body' => 'Second Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:41:23' - ) - ), - array( - 'Post' => array( - 'id' => '3', - 'author_id' => '1', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - 'created' => '2007-03-18 10:43:23', - 'updated' => '2007-03-18 10:45:31' - )), - array( - 'Post' => array( - 'id' => '4', - 'author_id' => '2', - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'published' => 'N' - ) - ) - ); - - $this->assertTrue($result[0]['Post']['updated'] >= $ts); - $this->assertTrue($result[1]['Post']['updated'] >= $ts); - $this->assertTrue($result[3]['Post']['created'] >= $ts); - $this->assertTrue($result[3]['Post']['updated'] >= $ts); - unset($result[0]['Post']['updated'], $result[1]['Post']['updated']); - unset($result[3]['Post']['created'], $result[3]['Post']['updated']); - $this->assertEquals($expected, $result); - - $TestModel->validate = array('title' => 'notEmpty', 'author_id' => 'numeric'); - $data = array( - array( - 'id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - )); - $result = $TestModel->saveMany($data); - $this->assertFalse($result); - - $result = $TestModel->find('all', array('recursive' => -1, 'order' => 'Post.id ASC')); - $errors = array(1 => array('title' => array('This field cannot be left blank'))); - $transactionWorked = Set::matches('/Post[1][title=Baleeted First Post]', $result); - if (!$transactionWorked) { - $this->assertTrue(Set::matches('/Post[1][title=Un-Baleeted First Post]', $result)); - $this->assertTrue(Set::matches('/Post[2][title=Just update the title]', $result)); - } - - $this->assertEquals($TestModel->validationErrors, $errors); - - $TestModel->validate = array('title' => 'notEmpty', 'author_id' => 'numeric'); - $data = array( - array( - 'id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - )); - $result = $TestModel->saveMany($data, array('validate' => true, 'atomic' => false)); - $this->assertEquals(array(true, false), $result); - - $result = $TestModel->find('all', array( - 'fields' => array('id', 'author_id', 'title', 'body', 'published'), - 'recursive' => -1, - 'order' => 'Post.id ASC' - )); - $errors = array(1 => array('title' => array('This field cannot be left blank'))); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '1', - 'title' => 'Un-Baleeted First Post', - 'body' => 'Not Baleeted!', - 'published' => 'Y', - )), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '3', - 'title' => 'Just update the title', - 'body' => 'Second Post Body', - 'published' => 'Y', - )), - array( - 'Post' => array( - 'id' => '3', - 'author_id' => '1', - 'title' => 'Third Post', - 'body' => 'Third Post Body', - 'published' => 'Y', - )), - array( - 'Post' => array( - 'id' => '4', - 'author_id' => '2', - 'title' => 'Creating a fourth post', - 'body' => 'Fourth post body', - 'published' => 'N', - ))); - $this->assertEquals($expected, $result); - $this->assertEquals($TestModel->validationErrors, $errors); - - $data = array( - array( - 'id' => '1', - 'title' => 'Re-Baleeted First Post', - 'body' => 'Baleeted!', - 'published' => 'N' - ), - array( - 'id' => '2', - 'title' => '', - 'body' => 'Trying to get away with an empty title' - )); - $this->assertFalse($TestModel->saveMany($data, array('validate' => 'first'))); - - $result = $TestModel->find('all', array( - 'fields' => array('id', 'author_id', 'title', 'body', 'published'), - 'recursive' => -1, - 'order' => 'Post.id ASC' - )); - $this->assertEquals($expected, $result); - $this->assertEquals($TestModel->validationErrors, $errors); - } - -/** - * testValidateMany method - * - * @return void - */ - public function testValidateMany() { - $TestModel = new Article(); - $TestModel->validate = array('title' => 'notEmpty'); - $result = $TestModel->validateMany( - array( - 0 => array('title' => ''), - 1 => array('title' => 'title 1'), - 2 => array('title' => 'title 2'), - )); - $this->assertFalse($result); - $expected = array( - 0 => array('title' => array('This field cannot be left blank')), - ); - $this->assertEquals($expected, $TestModel->validationErrors); - - $result = $TestModel->validateMany( - array( - 0 => array('title' => 'title 0'), - 1 => array('title' => ''), - 2 => array('title' => 'title 2'), - )); - $this->assertFalse($result); - $expected = array( - 1 => array('title' => array('This field cannot be left blank')), - ); - $this->assertEquals($expected, $TestModel->validationErrors); - } - -/** - * testSaveAssociatedValidateFirst method - * - * @return void - */ - public function testSaveAssociatedValidateFirst() { - $this->loadFixtures('Article', 'Comment', 'Attachment'); - $model = new Article(); - $model->deleteAll(true); - - $model->Comment->validate = array('comment' => 'notEmpty'); - $result = $model->saveAssociated(array( - 'Article' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved author' - ), - 'Comment' => array( - array('comment' => 'First new comment'), - array('comment' => '') - ) - ), array('validate' => 'first')); - - $this->assertFalse($result); - - $result = $model->find('all'); - $this->assertEquals(array(), $result); - $expected = array('Comment' => array( - 1 => array('comment' => array('This field cannot be left blank')) - )); - - $this->assertEquals($expected['Comment'], $model->Comment->validationErrors); - - $this->assertSame($model->Comment->find('count'), 0); - - $result = $model->saveAssociated( - array( - 'Article' => array( - 'title' => 'Post with Author', - 'body' => 'This post will be saved with an author', - 'user_id' => 2 - ), - 'Comment' => array( - array( - 'comment' => 'Only new comment', - 'user_id' => 2 - ))), - array('validate' => 'first') - ); - - $this->assertSame($result, true); - - $result = $model->Comment->find('all'); - $this->assertSame(count($result), 1); - $result = Set::extract('/Comment/article_id', $result); - $this->assertEquals(4, $result[0]); - - $model->deleteAll(true); - $data = array( - 'Article' => array( - 'title' => 'Post with Author saveAlled from comment', - 'body' => 'This post will be saved with an author', - 'user_id' => 2 - ), - 'Comment' => array( - 'comment' => 'Only new comment', 'user_id' => 2 - )); - - $result = $model->Comment->saveAssociated($data, array('validate' => 'first')); - $this->assertFalse(empty($result)); - - $result = $model->find('all'); - $this->assertEquals( - $result[0]['Article']['title'], - 'Post with Author saveAlled from comment' - ); - $this->assertEquals('Only new comment', $result[0]['Comment'][0]['comment']); - } - -/** - * test saveMany()'s return is correct when using atomic = false and validate = first. - * - * @return void - */ - public function testSaveManyValidateFirstAtomicFalse() { - $Something = new Something(); - $invalidData = array( - array( - 'title' => 'foo', - 'body' => 'bar', - 'published' => 'baz', - ), - array( - 'body' => 3, - 'published' => 'sd', - ), - ); - $Something->create(); - $Something->validate = array( - 'title' => array( - 'rule' => 'alphaNumeric', - 'required' => true, - ), - 'body' => array( - 'rule' => 'alphaNumeric', - 'required' => true, - 'allowEmpty' => true, - ), - ); - $result = $Something->saveMany($invalidData, array( - 'atomic' => false, - 'validate' => 'first', - )); - $expected = array(true, false); - $this->assertEquals($expected, $result); - - $Something = new Something(); - $validData = array( - array( - 'title' => 'title value', - 'body' => 'body value', - 'published' => 'baz', - ), - array( - 'title' => 'valid', - 'body' => 'this body', - 'published' => 'sd', - ), - ); - $Something->create(); - $result = $Something->saveMany($validData, array( - 'atomic' => false, - 'validate' => 'first', - )); - $expected = array(true, true); - $this->assertEquals($expected, $result); - } - -/** - * testValidateAssociated method - * - * @return void - */ - public function testValidateAssociated() { - $TestModel = new Comment(); - $TestModel->Attachment->validate = array('attachment' => 'notEmpty'); - - $data = array( - 'Comment' => array( - 'comment' => 'This is the comment' - ), - 'Attachment' => array( - 'attachment' => '' - ) - ); - - $result = $TestModel->validateAssociated($data); - $this->assertFalse($result); - - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - $TestModel->Comment->validate = array('comment' => 'notEmpty'); - - $result = $TestModel->validateAssociated( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'id' => 1, - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1), - array( - 'id' => 2, - 'comment' => - 'comment', - 'published' => 'Y', - 'user_id' => 1 - )))); - $this->assertFalse($result); - - $result = $TestModel->validateAssociated( - array( - 'Article' => array('id' => 2), - 'Comment' => array( - array( - 'id' => 1, - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1 - ), - array( - 'id' => 2, - 'comment' => 'comment', - 'published' => 'Y', - 'user_id' => 1 - ), - array( - 'id' => 3, - 'comment' => '', - 'published' => 'Y', - 'user_id' => 1 - ))), - array( - 'atomic' => false - )); - $expected = array( - 'Article' => true, - 'Comment' => array(false, true, false) - ); - $this->assertSame($expected, $result); - - $expected = array('Comment' => array( - 0 => array('comment' => array('This field cannot be left blank')), - 2 => array('comment' => array('This field cannot be left blank')) - )); - $this->assertEquals($expected, $TestModel->validationErrors); - - $expected = array( - 0 => array('comment' => array('This field cannot be left blank')), - 2 => array('comment' => array('This field cannot be left blank')) - ); - $this->assertEquals($expected, $TestModel->Comment->validationErrors); - } - -/** - * test that saveMany behaves like plain save() when suplied empty data - * - * @link http://cakephp.lighthouseapp.com/projects/42648/tickets/277-test-saveall-with-validation-returns-incorrect-boolean-when-saving-empty-data - * @return void - */ - public function testSaveManyEmptyData() { - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $this->loadFixtures('Article', 'ProductUpdateAll', 'Comment', 'Attachment'); - $model = new Article(); - $result = $model->saveMany(array(), array('validate' => true)); - $this->assertFalse(empty($result)); - - $model = new ProductUpdateAll(); - $result = $model->saveMany(array()); - $this->assertFalse($result); - } - -/** - * test that saveAssociated behaves like plain save() when supplied empty data - * - * @link http://cakephp.lighthouseapp.com/projects/42648/tickets/277-test-saveall-with-validation-returns-incorrect-boolean-when-saving-empty-data - * @return void - */ - public function testSaveAssociatedEmptyData() { - $this->skipIf($this->db instanceof Sqlserver, 'This test is not compatible with SQL Server.'); - - $this->loadFixtures('Article', 'ProductUpdateAll', 'Comment', 'Attachment'); - $model = new Article(); - $result = $model->saveAssociated(array(), array('validate' => true)); - $this->assertFalse(empty($result)); - - $model = new ProductUpdateAll(); - $result = $model->saveAssociated(array()); - $this->assertFalse($result); - } - -/** - * testUpdateWithCalculation method - * - * @return void - */ - public function testUpdateWithCalculation() { - $this->loadFixtures('DataTest'); - $model = new DataTest(); - $model->deleteAll(true); - $result = $model->saveMany(array( - array('count' => 5, 'float' => 1.1), - array('count' => 3, 'float' => 1.2), - array('count' => 4, 'float' => 1.3), - array('count' => 1, 'float' => 2.0), - )); - $this->assertFalse(empty($result)); - - $result = Set::extract('/DataTest/count', $model->find('all', array('fields' => 'count'))); - $this->assertEquals(array(5, 3, 4, 1), $result); - - $this->assertTrue($model->updateAll(array('count' => 'count + 2'))); - $result = Set::extract('/DataTest/count', $model->find('all', array('fields' => 'count'))); - $this->assertEquals(array(7, 5, 6, 3), $result); - - $this->assertTrue($model->updateAll(array('DataTest.count' => 'DataTest.count - 1'))); - $result = Set::extract('/DataTest/count', $model->find('all', array('fields' => 'count'))); - $this->assertEquals(array(6, 4, 5, 2), $result); - } - - public function testToggleBoolFields() { - $this->loadFixtures('CounterCacheUser', 'CounterCachePost'); - $Post = new CounterCachePost(); - $Post->unbindModel(array('belongsTo' => array('User')), true); - - $true = array('Post' => array('published' => true, 'id' => 2)); - $false = array('Post' => array('published' => false, 'id' => 2)); - $fields = array('Post.published', 'Post.id'); - $updateConditions = array('Post.id' => 2); - - // check its true - $result = $Post->find('first', array('conditions' => $updateConditions, 'fields' => $fields)); - $this->assertEquals($true, $result); - - // Testing without the alias - $this->assertTrue($Post->updateAll(array('published' => 'NOT published'), $updateConditions)); - $result = $Post->find('first', array('conditions' => $updateConditions, 'fields' => $fields)); - $this->assertEquals($false, $result); - - $this->assertTrue($Post->updateAll(array('published' => 'NOT published'), $updateConditions)); - $result = $Post->find('first', array('conditions' => $updateConditions, 'fields' => $fields)); - $this->assertEquals($true, $result); - - $db = ConnectionManager::getDataSource('test'); - $alias = $db->name('Post.published'); - - // Testing with the alias - $this->assertTrue($Post->updateAll(array('Post.published' => "NOT $alias"), $updateConditions)); - $result = $Post->find('first', array('conditions' => $updateConditions, 'fields' => $fields)); - $this->assertEquals($false, $result); - - $this->assertTrue($Post->updateAll(array('Post.published' => "NOT $alias"), $updateConditions)); - $result = $Post->find('first', array('conditions' => $updateConditions, 'fields' => $fields)); - $this->assertEquals($true, $result); - } - -/** - * TestFindAllWithoutForeignKey - * - * @return void - */ - public function testFindAllForeignKey() { - $this->loadFixtures('ProductUpdateAll', 'GroupUpdateAll'); - $ProductUpdateAll = new ProductUpdateAll(); - - $conditions = array('Group.name' => 'group one'); - - $ProductUpdateAll->bindModel(array( - 'belongsTo' => array( - 'Group' => array('className' => 'GroupUpdateAll') - ) - )); - - $ProductUpdateAll->belongsTo = array( - 'Group' => array('className' => 'GroupUpdateAll', 'foreignKey' => 'group_id') - ); - - $results = $ProductUpdateAll->find('all', compact('conditions')); - $this->assertTrue(!empty($results)); - - $ProductUpdateAll->bindModel(array('belongsTo' => array('Group'))); - $ProductUpdateAll->belongsTo = array( - 'Group' => array( - 'className' => 'GroupUpdateAll', - 'foreignKey' => false, - 'conditions' => 'ProductUpdateAll.groupcode = Group.code' - )); - - $resultsFkFalse = $ProductUpdateAll->find('all', compact('conditions')); - $this->assertTrue(!empty($resultsFkFalse)); - $expected = array( - '0' => array( - 'ProductUpdateAll' => array( - 'id' => 1, - 'name' => 'product one', - 'groupcode' => 120, - 'group_id' => 1), - 'Group' => array( - 'id' => 1, - 'name' => 'group one', - 'code' => 120) - ), - '1' => array( - 'ProductUpdateAll' => array( - 'id' => 2, - 'name' => 'product two', - 'groupcode' => 120, - 'group_id' => 1), - 'Group' => array( - 'id' => 1, - 'name' => 'group one', - 'code' => 120) - ) - - ); - $this->assertEquals($expected, $results); - $this->assertEquals($expected, $resultsFkFalse); - } - -/** - * test updateAll with empty values. - * - * @return void - */ - public function testUpdateAllEmptyValues() { - $this->skipIf($this->db instanceof Sqlserver || $this->db instanceof Postgres, 'This test is not compatible with Postgres or SQL Server.'); - - $this->loadFixtures('Author', 'Post'); - $model = new Author(); - $result = $model->updateAll(array('user' => '""')); - $this->assertTrue($result); - } - -/** - * testUpdateAllWithJoins - * - * @return void - */ - public function testUpdateAllWithJoins() { - $this->skipIf(!$this->db instanceof Mysql, 'Currently, there is no way of doing joins in an update statement in postgresql or sqlite'); - - $this->loadFixtures('ProductUpdateAll', 'GroupUpdateAll'); - $ProductUpdateAll = new ProductUpdateAll(); - - $conditions = array('Group.name' => 'group one'); - - $ProductUpdateAll->bindModel(array('belongsTo' => array( - 'Group' => array('className' => 'GroupUpdateAll'))) - ); - - $ProductUpdateAll->updateAll(array('name' => "'new product'"), $conditions); - $results = $ProductUpdateAll->find('all', array( - 'conditions' => array('ProductUpdateAll.name' => 'new product') - )); - $expected = array( - '0' => array( - 'ProductUpdateAll' => array( - 'id' => 1, - 'name' => 'new product', - 'groupcode' => 120, - 'group_id' => 1), - 'Group' => array( - 'id' => 1, - 'name' => 'group one', - 'code' => 120) - ), - '1' => array( - 'ProductUpdateAll' => array( - 'id' => 2, - 'name' => 'new product', - 'groupcode' => 120, - 'group_id' => 1), - 'Group' => array( - 'id' => 1, - 'name' => 'group one', - 'code' => 120))); - - $this->assertEquals($expected, $results); - } - -/** - * testUpdateAllWithoutForeignKey - * - * @return void - */ - public function testUpdateAllWithoutForeignKey() { - $this->skipIf(!$this->db instanceof Mysql, 'Currently, there is no way of doing joins in an update statement in postgresql'); - - $this->loadFixtures('ProductUpdateAll', 'GroupUpdateAll'); - $ProductUpdateAll = new ProductUpdateAll(); - - $conditions = array('Group.name' => 'group one'); - - $ProductUpdateAll->bindModel(array('belongsTo' => array( - 'Group' => array('className' => 'GroupUpdateAll') - ))); - - $ProductUpdateAll->belongsTo = array( - 'Group' => array( - 'className' => 'GroupUpdateAll', - 'foreignKey' => false, - 'conditions' => 'ProductUpdateAll.groupcode = Group.code' - ) - ); - - $ProductUpdateAll->updateAll(array('name' => "'new product'"), $conditions); - $resultsFkFalse = $ProductUpdateAll->find('all', array('conditions' => array('ProductUpdateAll.name' => 'new product'))); - $expected = array( - '0' => array( - 'ProductUpdateAll' => array( - 'id' => 1, - 'name' => 'new product', - 'groupcode' => 120, - 'group_id' => 1), - 'Group' => array( - 'id' => 1, - 'name' => 'group one', - 'code' => 120) - ), - '1' => array( - 'ProductUpdateAll' => array( - 'id' => 2, - 'name' => 'new product', - 'groupcode' => 120, - 'group_id' => 1), - 'Group' => array( - 'id' => 1, - 'name' => 'group one', - 'code' => 120))); - $this->assertEquals($expected, $resultsFkFalse); - } - -/** - * test writing floats in german locale. - * - * @return void - */ - public function testWriteFloatAsGerman() { - $restore = setlocale(LC_ALL, 0); - setlocale(LC_ALL, 'de_DE'); - - $model = new DataTest(); - $result = $model->save(array( - 'count' => 1, - 'float' => 3.14593 - )); - $this->assertTrue((bool)$result); - setlocale(LC_ALL, $restore); - } - -/** - * Test returned array contains primary key when save creates a new record - * - * @return void - */ - public function testPkInReturnArrayForCreate() { - $this->loadFixtures('Article'); - $TestModel = new Article(); - - $data = array('Article' => array( - 'user_id' => '1', - 'title' => 'Fourth Article', - 'body' => 'Fourth Article Body', - 'published' => 'Y' - )); - $result = $TestModel->save($data); - $this->assertSame($result['Article']['id'], $TestModel->id); - } - -/** - * testSaveAllFieldListValidateBelongsTo - * - * @return void - */ - public function testSaveAllFieldListValidateBelongsTo() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment'); - $TestModel = new Post(); - - $result = $TestModel->find('all'); - $this->assertCount(3, $result); - $this->assertFalse(isset($result[3])); - $ts = date('Y-m-d H:i:s'); - - // test belongsTo - $fieldList = array( - 'Post' => array('title', 'author_id'), - 'Author' => array('user') - ); - $TestModel->saveAll(array( - 'Post' => array( - 'title' => 'Post without body', - 'body' => 'This will not be saved', - ), - 'Author' => array( - 'user' => 'bob', - 'test' => 'This will not be saved', - - )), array('fieldList' => $fieldList)); - - $result = $TestModel->find('all'); - $expected = array( - 'Post' => array ( - 'id' => '4', - 'author_id' => '5', - 'title' => 'Post without body', - 'body' => null, - 'published' => 'N', - 'created' => $ts, - 'updated' => $ts, - ), - 'Author' => array ( - 'id' => '5', - 'user' => 'bob', - 'password' => null, - 'created' => $ts, - 'updated' => $ts, - 'test' => 'working', - ), - ); - $this->assertEquals($expected, $result[3]); - $this->assertCount(4, $result); - $this->assertEquals('', $result[3]['Post']['body']); - $this->assertEquals('working', $result[3]['Author']['test']); - - // test multirecord - $this->db->truncate($TestModel); - - $ts = date('Y-m-d H:i:s'); - $fieldList = array('title', 'author_id'); - $TestModel->saveAll(array( - array( - 'title' => 'Multi-record post 1', - 'body' => 'First multi-record post', - 'author_id' => 2 - ), - array( - 'title' => 'Multi-record post 2', - 'body' => 'Second multi-record post', - 'author_id' => 2 - )), array('fieldList' => $fieldList)); - - $result = $TestModel->find('all', array( - 'recursive' => -1, - 'order' => 'Post.id ASC' - )); - $expected = array( - array( - 'Post' => array( - 'id' => '1', - 'author_id' => '2', - 'title' => 'Multi-record post 1', - 'body' => '', - 'published' => 'N', - 'created' => $ts, - 'updated' => $ts - ) - ), - array( - 'Post' => array( - 'id' => '2', - 'author_id' => '2', - 'title' => 'Multi-record post 2', - 'body' => '', - 'published' => 'N', - 'created' => $ts, - 'updated' => $ts - ) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllFieldListHasMany method - * - * return @void - */ - public function testSaveAllFieldListHasMany() { - $this->loadFixtures('Article', 'Comment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - - $this->db->truncate($TestModel); - $this->db->truncate(new Comment()); - - $fieldList = array( - 'Article' => array('id'), - 'Comment' => array('article_id', 'user_id') - ); - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2, 'title' => 'I will not save'), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'user_id' => 1), - array('comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2) - ) - ), array('fieldList' => $fieldList)); - - $result = $TestModel->find('all'); - $this->assertEquals('', $result[0]['Article']['title']); - $this->assertEquals('', $result[0]['Comment'][0]['comment']); - $this->assertEquals('', $result[0]['Comment'][1]['comment']); - } - -/** - * testSaveAllFieldListHasOne method - * - * @return void - */ - public function testSaveAllFieldListHasOne() { - $this->loadFixtures('Attachment', 'Comment', 'Article', 'User'); - $TestModel = new Comment(); - - $TestModel->validate = array('comment' => 'notEmpty'); - $TestModel->Attachment->validate = array('attachment' => 'notEmpty'); - - $record = array( - 'Comment' => array( - 'user_id' => 1, - 'article_id' => 1, - 'comment' => '', - ), - 'Attachment' => array( - 'attachment' => '' - ) - ); - $result = $TestModel->saveAll($record, array('validate' => 'only')); - $this->assertFalse($result); - - $fieldList = array( - 'Comment' => array('id', 'article_id', 'user_id'), - 'Attachment' => array('comment_id') - ); - $result = $TestModel->saveAll($record, array( - 'fieldList' => $fieldList, 'validate' => 'only' - )); - $this->assertTrue($result); - $this->assertEmpty($TestModel->validationErrors); - } - -/** - * testSaveAllDeepFieldListValidateBelongsTo - * - * @return void - */ - public function testSaveAllDeepFieldListValidateBelongsTo() { - $this->loadFixtures('Post', 'Author', 'Comment', 'Attachment', 'Article', 'User'); - $TestModel = new Post(); - $TestModel->Author->bindModel(array('hasMany' => array('Comment' => array('foreignKey' => 'user_id'))), false); - $TestModel->recursive = 2; - - $result = $TestModel->find('all'); - $this->assertCount(3, $result); - $this->assertFalse(isset($result[3])); - $ts = date('Y-m-d H:i:s'); - - // test belongsTo - $fieldList = array( - 'Post' => array('title', 'author_id'), - 'Author' => array('user'), - 'Comment' => array('comment') - ); - $TestModel->saveAll(array( - 'Post' => array( - 'title' => 'Post without body', - 'body' => 'This will not be saved', - ), - 'Author' => array( - 'user' => 'bob', - 'test' => 'This will not be saved', - 'Comment' => array( - array('id' => 5, 'comment' => 'I am still published', 'published' => 'N')) - - )), array('fieldList' => $fieldList, 'deep' => true)); - - $result = $TestModel->Author->Comment->find('first', array( - 'conditions' => array('Comment.id' => 5), - 'fields' => array('comment', 'published') - )); - $expected = array( - 'Comment' => array( - 'comment' => 'I am still published', - 'published' => 'Y' - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllDeepFieldListHasMany method - * - * return @void - */ - public function testSaveAllDeepFieldListHasMany() { - $this->loadFixtures('Article', 'Comment', 'User'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - - $this->db->truncate($TestModel); - $this->db->truncate(new Comment()); - - $fieldList = array( - 'Article' => array('id'), - 'Comment' => array('article_id', 'user_id'), - 'User' => array('user') - ); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2, 'title' => 'I will not save'), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'user_id' => 1), - array( - 'comment' => 'Second new comment', 'published' => 'Y', 'user_id' => 2, - 'User' => array('user' => 'nopassword', 'password' => 'not saved') - ) - ) - ), array('fieldList' => $fieldList, 'deep' => true)); - - $result = $TestModel->Comment->User->find('first', array( - 'conditions' => array('User.user' => 'nopassword'), - 'fields' => array('user', 'password') - )); - $expected = array( - 'User' => array( - 'user' => 'nopassword', - 'password' => '' - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllDeepHasManyBelongsTo method - * - * return @void - */ - public function testSaveAllDeepHasManyBelongsTo() { - $this->loadFixtures('Article', 'Comment', 'User'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = array(); - - $this->db->truncate($TestModel); - $this->db->truncate(new Comment()); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2, 'title' => 'The title'), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'user_id' => 1), - array( - 'comment' => 'belongsto', 'published' => 'Y', - 'User' => array('user' => 'findme', 'password' => 'somepass') - ) - ) - ), array('deep' => true)); - - $result = $TestModel->Comment->User->find('first', array( - 'conditions' => array('User.user' => 'findme'), - 'fields' => array('id', 'user', 'password') - )); - $expected = array( - 'User' => array( - 'id' => 5, - 'user' => 'findme', - 'password' => 'somepass', - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->Comment->find('first', array( - 'conditions' => array('Comment.user_id' => 5), - 'fields' => array('id', 'comment', 'published', 'user_id') - )); - $expected = array( - 'Comment' => array( - 'id' => 2, - 'comment' => 'belongsto', - 'published' => 'Y', - 'user_id' => 5 - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllDeepHasManyhasMany method - * - * return @void - */ - public function testSaveAllDeepHasManyHasMany() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = $TestModel->Comment->belongsTo = array(); - $TestModel->Comment->unbindModel(array('hasOne' => array('Attachment')), false); - $TestModel->Comment->bindModel(array('hasMany' => array('Attachment')), false); - - $this->db->truncate($TestModel); - $this->db->truncate(new Comment()); - $this->db->truncate(new Attachment()); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2, 'title' => 'The title'), - 'Comment' => array( - array('comment' => 'First new comment', 'published' => 'Y', 'user_id' => 1), - array( - 'comment' => 'hasmany', 'published' => 'Y', 'user_id' => 5, - 'Attachment' => array( - array('attachment' => 'first deep attachment'), - array('attachment' => 'second deep attachment'), - ) - ) - ) - ), array('deep' => true)); - - $result = $TestModel->Comment->find('first', array( - 'conditions' => array('Comment.comment' => 'hasmany'), - 'fields' => array('id', 'comment', 'published', 'user_id'), - 'recursive' => -1 - )); - $expected = array( - 'Comment' => array( - 'id' => 2, - 'comment' => 'hasmany', - 'published' => 'Y', - 'user_id' => 5 - ) - ); - $this->assertEquals($expected, $result); - - $result = $TestModel->Comment->Attachment->find('all', array( - 'fields' => array('attachment', 'comment_id'), - 'order' => array('Attachment.id' => 'ASC') - )); - $expected = array( - array('Attachment' => array('attachment' => 'first deep attachment', 'comment_id' => 2)), - array('Attachment' => array('attachment' => 'second deep attachment', 'comment_id' => 2)), - ); - $this->assertEquals($expected, $result); - } - -/** - * testSaveAllDeepOrderHasManyHasMany method - * - * return @void - */ - public function testSaveAllDeepOrderHasManyHasMany() { - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = $TestModel->Comment->belongsTo = array(); - $TestModel->Comment->unbindModel(array('hasOne' => array('Attachment')), false); - $TestModel->Comment->bindModel(array('hasMany' => array('Attachment')), false); - - $this->db->truncate($TestModel); - $this->db->truncate(new Comment()); - $this->db->truncate(new Attachment()); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 2, 'title' => 'Comment has its data after Attachment'), - 'Comment' => array( - array( - 'Attachment' => array( - array('attachment' => 'attachment should be created with comment_id'), - array('attachment' => 'comment should be created with article_id'), - ), - 'comment' => 'after associated data', - 'user_id' => 1 - ) - ) - ), array('deep' => true)); - $result = $TestModel->Comment->find('first', array( - 'conditions' => array('Comment.article_id' => 2), - )); - - $this->assertEquals(2, $result['Comment']['article_id']); - $this->assertEquals(2, count($result['Attachment'])); - } - -/** - * testSaveAllDeepEmptyHasManyHasMany method - * - * return @void - */ - public function testSaveAllDeepEmptyHasManyHasMany() { - $this->skipIf(!$this->db instanceof Mysql, 'This test is only compatible with Mysql.'); - $this->loadFixtures('Article', 'Comment', 'User', 'Attachment'); - $TestModel = new Article(); - $TestModel->belongsTo = $TestModel->hasAndBelongsToMany = $TestModel->Comment->belongsTo = array(); - $TestModel->Comment->unbindModel(array('hasOne' => array('Attachment')), false); - $TestModel->Comment->bindModel(array('hasMany' => array('Attachment')), false); - - $this->db->truncate($TestModel); - $this->db->truncate(new Comment()); - $this->db->truncate(new Attachment()); - - $result = $TestModel->saveAll(array( - 'Article' => array('id' => 3, 'title' => 'Comment has no data'), - 'Comment' => array( - array( - 'Attachment' => array( - array('attachment' => 'attachment should be created with comment_id'), - array('attachment' => 'comment should be created with article_id'), - ), - ) - ) - ), array('deep' => true)); - $result = $TestModel->Comment->find('first', array( - 'conditions' => array('Comment.article_id' => 3), - )); - - $this->assertEquals(3, $result['Comment']['article_id']); - $this->assertEquals(2, count($result['Attachment'])); - } - -/** - * testUpdateAllBoolean - * - * return @void - */ - public function testUpdateAllBoolean() { - $this->loadFixtures('Item', 'Syfile', 'Portfolio', 'Image', 'ItemsPortfolio'); - $TestModel = new Item(); - $result = $TestModel->updateAll(array('published' => true)); - $this->assertTrue($result); - - $result = $TestModel->find('first', array('fields' => array('id', 'published'))); - $this->assertEquals(true, $result['Item']['published']); - } - -/** - * testUpdateAllBooleanConditions - * - * return @void - */ - public function testUpdateAllBooleanConditions() { - $this->loadFixtures('Item', 'Syfile', 'Portfolio', 'Image', 'ItemsPortfolio'); - $TestModel = new Item(); - - $result = $TestModel->updateAll(array('published' => true), array('Item.id' => 1)); - $this->assertTrue($result); - $result = $TestModel->find('first', array( - 'fields' => array('id', 'published'), - 'conditions' => array('Item.id' => 1))); - $this->assertEquals(true, $result['Item']['published']); - } - -/** - * testUpdateBoolean - * - * return @void - */ - public function testUpdateBoolean() { - $this->loadFixtures('Item', 'Syfile', 'Portfolio', 'Image', 'ItemsPortfolio'); - $TestModel = new Item(); - - $result = $TestModel->save(array('published' => true, 'id' => 1)); - $this->assertTrue((boolean)$result); - $result = $TestModel->find('first', array( - 'fields' => array('id', 'published'), - 'conditions' => array('Item.id' => 1))); - $this->assertEquals(true, $result['Item']['published']); - } -} diff --git a/lib/Cake/Test/Case/Model/models.php b/lib/Cake/Test/Case/Model/models.php deleted file mode 100644 index a11bc74ebec..00000000000 --- a/lib/Cake/Test/Case/Model/models.php +++ /dev/null @@ -1,4923 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Model - * @since CakePHP(tm) v 1.2.0.6464 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Model', 'Model'); -/** - * AppModel class - * - * @package Cake.Test.Case.Model - */ -class AppModel extends Model { - -/** - * findMethods property - * - * @var array - */ - public $findMethods = array('published' => true); - -/** - * useDbConfig property - * - * @var array - */ - public $useDbConfig = 'test'; - -/** - * _findPublished custom find - * - * @return array - */ - protected function _findPublished($state, $query, $results = array()) { - if ($state === 'before') { - $query['conditions']['published'] = 'Y'; - return $query; - } - return $results; - } - -} - -/** - * Test class - * - * @package Cake.Test.Case.Model - */ -class Test extends CakeTestModel { - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'Test' - */ - public $name = 'Test'; - -/** - * schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '1', 'length' => '8', 'key' => 'primary'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'notes' => array('type' => 'text', 'null' => '1', 'default' => 'write some notes here', 'length' => ''), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - -} - -/** - * TestAlias class - * - * @package Cake.Test.Case.Model - */ -class TestAlias extends CakeTestModel { - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'TestAlias' - */ - public $name = 'TestAlias'; - -/** - * alias property - * - * @var string 'TestAlias' - */ - public $alias = 'TestAlias'; - -/** - * schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '1', 'length' => '8', 'key' => 'primary'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'notes' => array('type' => 'text', 'null' => '1', 'default' => 'write some notes here', 'length' => ''), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); -} - -/** - * TestValidate class - * - * @package Cake.Test.Case.Model - */ -class TestValidate extends CakeTestModel { - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'TestValidate' - */ - public $name = 'TestValidate'; - -/** - * schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'title' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'body' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => ''), - 'number' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'modified' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - -/** - * validateNumber method - * - * @param mixed $value - * @param mixed $options - * @return void - */ - public function validateNumber($value, $options) { - $options = array_merge(array('min' => 0, 'max' => 100), $options); - $valid = ($value['number'] >= $options['min'] && $value['number'] <= $options['max']); - return $valid; - } - -/** - * validateTitle method - * - * @param mixed $value - * @return void - */ - public function validateTitle($value) { - return (!empty($value) && strpos(strtolower($value['title']), 'title-') === 0); - } - -} - -/** - * User class - * - * @package Cake.Test.Case.Model - */ -class User extends CakeTestModel { - -/** - * name property - * - * @var string 'User' - */ - public $name = 'User'; - -/** - * validate property - * - * @var array - */ - public $validate = array('user' => 'notEmpty', 'password' => 'notEmpty'); - -/** - * beforeFind() callback used to run ContainableBehaviorTest::testLazyLoad() - * - * @return bool - * @throws Exception - */ - public function beforeFind($queryData) { - if (!empty($queryData['lazyLoad'])) { - if (!isset($this->Article, $this->Comment, $this->ArticleFeatured)) { - throw new Exception('Unavailable associations'); - } - } - return true; - } - -} - -/** - * Article class - * - * @package Cake.Test.Case.Model - */ -class Article extends CakeTestModel { - -/** - * name property - * - * @var string 'Article' - */ - public $name = 'Article'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('User'); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Comment' => array('dependent' => true)); - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Tag'); - -/** - * validate property - * - * @var array - */ - public $validate = array('user_id' => 'numeric', 'title' => array('allowEmpty' => false, 'rule' => 'notEmpty'), 'body' => 'notEmpty'); - -/** - * beforeSaveReturn property - * - * @var bool true - */ - public $beforeSaveReturn = true; - -/** - * beforeSave method - * - * @return void - */ - public function beforeSave($options = array()) { - return $this->beforeSaveReturn; - } - -/** - * titleDuplicate method - * - * @param mixed $title - * @return void - */ - public static function titleDuplicate($title) { - if ($title === 'My Article Title') { - return false; - } - return true; - } - -} - -/** - * Model stub for beforeDelete testing - * - * @see #250 - * @package Cake.Test.Case.Model - */ -class BeforeDeleteComment extends CakeTestModel { - - public $name = 'BeforeDeleteComment'; - - public $useTable = 'comments'; - - public function beforeDelete($cascade = true) { - $db = $this->getDataSource(); - $db->delete($this, array($this->alias . '.' . $this->primaryKey => array(1, 3))); - return true; - } - -} - -/** - * NumericArticle class - * - * @package Cake.Test.Case.Model - */ -class NumericArticle extends CakeTestModel { - -/** - * name property - * - * @var string 'NumericArticle' - */ - public $name = 'NumericArticle'; - -/** - * useTable property - * - * @var string 'numeric_articles' - */ - public $useTable = 'numeric_articles'; - -} - -/** - * Article10 class - * - * @package Cake.Test.Case.Model - */ -class Article10 extends CakeTestModel { - -/** - * name property - * - * @var string 'Article10' - */ - public $name = 'Article10'; - -/** - * useTable property - * - * @var string 'articles' - */ - public $useTable = 'articles'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Comment' => array('dependent' => true, 'exclusive' => true)); - -} - -/** - * ArticleFeatured class - * - * @package Cake.Test.Case.Model - */ -class ArticleFeatured extends CakeTestModel { - -/** - * name property - * - * @var string 'ArticleFeatured' - */ - public $name = 'ArticleFeatured'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('User', 'Category'); - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('Featured'); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Comment' => array('className' => 'Comment', 'dependent' => true)); - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Tag'); - -/** - * validate property - * - * @var array - */ - public $validate = array('user_id' => 'numeric', 'title' => 'notEmpty', 'body' => 'notEmpty'); - -} - -/** - * Featured class - * - * @package Cake.Test.Case.Model - */ -class Featured extends CakeTestModel { - -/** - * name property - * - * @var string 'Featured' - */ - public $name = 'Featured'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('ArticleFeatured', 'Category'); -} - -/** - * Tag class - * - * @package Cake.Test.Case.Model - */ -class Tag extends CakeTestModel { - -/** - * name property - * - * @var string 'Tag' - */ - public $name = 'Tag'; -} - -/** - * ArticlesTag class - * - * @package Cake.Test.Case.Model - */ -class ArticlesTag extends CakeTestModel { - -/** - * name property - * - * @var string 'ArticlesTag' - */ - public $name = 'ArticlesTag'; -} - -/** - * ArticleFeaturedsTag class - * - * @package Cake.Test.Case.Model - */ -class ArticleFeaturedsTag extends CakeTestModel { - -/** - * name property - * - * @var string 'ArticleFeaturedsTag' - */ - public $name = 'ArticleFeaturedsTag'; -} - -/** - * Comment class - * - * @package Cake.Test.Case.Model - */ -class Comment extends CakeTestModel { - -/** - * name property - * - * @var string 'Comment' - */ - public $name = 'Comment'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Article', 'User'); - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('Attachment' => array('dependent' => true)); -} - -/** - * Modified Comment Class has afterFind Callback - * - * @package Cake.Test.Case.Model - */ -class ModifiedComment extends CakeTestModel { - -/** - * name property - * - * @var string 'Comment' - */ - public $name = 'Comment'; - -/** - * useTable property - * - * @var string 'comments' - */ - public $useTable = 'comments'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Article'); - -/** - * afterFind callback - * - * @return void - */ - public function afterFind($results, $primary = false) { - if (isset($results[0])) { - $results[0]['Comment']['callback'] = 'Fire'; - } - return $results; - } - -} - -/** - * Modified Comment Class has afterFind Callback - * - * @package Cake.Test.Case.Model - */ -class AgainModifiedComment extends CakeTestModel { - -/** - * name property - * - * @var string 'Comment' - */ - public $name = 'Comment'; - -/** - * useTable property - * - * @var string 'comments' - */ - public $useTable = 'comments'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Article'); - -/** - * afterFind callback - * - * @return void - */ - public function afterFind($results, $primary = false) { - if (isset($results[0])) { - $results[0]['Comment']['querytype'] = $this->findQueryType; - } - return $results; - } - -} - -/** - * MergeVarPluginAppModel class - * - * @package Cake.Test.Case.Model - */ -class MergeVarPluginAppModel extends AppModel { - -/** - * actsAs parameter - * - * @var array - */ - public $actsAs = array( - 'Containable' - ); -} - -/** - * MergeVarPluginPost class - * - * @package Cake.Test.Case.Model - */ -class MergeVarPluginPost extends MergeVarPluginAppModel { - -/** - * actsAs parameter - * - * @var array - */ - public $actsAs = array( - 'Tree' - ); - -/** - * useTable parameter - * - * @var string - */ - public $useTable = 'posts'; -} - -/** - * MergeVarPluginComment class - * - * @package Cake.Test.Case.Model - */ -class MergeVarPluginComment extends MergeVarPluginAppModel { - -/** - * actsAs parameter - * - * @var array - */ - public $actsAs = array( - 'Containable' => array('some_settings') - ); - -/** - * useTable parameter - * - * @var string - */ - public $useTable = 'comments'; -} - - -/** - * Attachment class - * - * @package Cake.Test.Case.Model - */ -class Attachment extends CakeTestModel { - -/** - * name property - * - * @var string 'Attachment' - */ - public $name = 'Attachment'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Comment'); -} - -/** - * Category class - * - * @package Cake.Test.Case.Model - */ -class Category extends CakeTestModel { - -/** - * name property - * - * @var string 'Category' - */ - public $name = 'Category'; -} - -/** - * CategoryThread class - * - * @package Cake.Test.Case.Model - */ -class CategoryThread extends CakeTestModel { - -/** - * name property - * - * @var string 'CategoryThread' - */ - public $name = 'CategoryThread'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('ParentCategory' => array('className' => 'CategoryThread', 'foreignKey' => 'parent_id')); -} - -/** - * Apple class - * - * @package Cake.Test.Case.Model - */ -class Apple extends CakeTestModel { - -/** - * name property - * - * @var string 'Apple' - */ - public $name = 'Apple'; - -/** - * validate property - * - * @var array - */ - public $validate = array('name' => 'notEmpty'); - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('Sample'); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Child' => array('className' => 'Apple', 'dependent' => true)); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Parent' => array('className' => 'Apple', 'foreignKey' => 'apple_id')); -} - -/** - * Sample class - * - * @package Cake.Test.Case.Model - */ -class Sample extends CakeTestModel { - -/** - * name property - * - * @var string 'Sample' - */ - public $name = 'Sample'; - -/** - * belongsTo property - * - * @var string 'Apple' - */ - public $belongsTo = 'Apple'; -} - -/** - * AnotherArticle class - * - * @package Cake.Test.Case.Model - */ -class AnotherArticle extends CakeTestModel { - -/** - * name property - * - * @var string 'AnotherArticle' - */ - public $name = 'AnotherArticle'; - -/** - * hasMany property - * - * @var string 'Home' - */ - public $hasMany = 'Home'; -} - -/** - * Advertisement class - * - * @package Cake.Test.Case.Model - */ -class Advertisement extends CakeTestModel { - -/** - * name property - * - * @var string 'Advertisement' - */ - public $name = 'Advertisement'; - -/** - * hasMany property - * - * @var string 'Home' - */ - public $hasMany = 'Home'; -} - -/** - * Home class - * - * @package Cake.Test.Case.Model - */ -class Home extends CakeTestModel { - -/** - * name property - * - * @var string 'Home' - */ - public $name = 'Home'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('AnotherArticle', 'Advertisement'); -} - -/** - * Post class - * - * @package Cake.Test.Case.Model - */ -class Post extends CakeTestModel { - -/** - * name property - * - * @var string 'Post' - */ - public $name = 'Post'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Author'); - - public function beforeFind($queryData) { - if (isset($queryData['connection'])) { - $this->useDbConfig = $queryData['connection']; - } - return true; - } - - public function afterFind($results, $primary = false) { - $this->useDbConfig = 'test'; - return $results; - } - -} - -/** - * Author class - * - * @package Cake.Test.Case.Model - */ -class Author extends CakeTestModel { - -/** - * name property - * - * @var string 'Author' - */ - public $name = 'Author'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Post'); - -/** - * afterFind method - * - * @param mixed $results - * @return void - */ - public function afterFind($results, $primary = false) { - $results[0]['Author']['test'] = 'working'; - return $results; - } - -} - -/** - * ModifiedAuthor class - * - * @package Cake.Test.Case.Model - */ -class ModifiedAuthor extends Author { - -/** - * name property - * - * @var string 'Author' - */ - public $name = 'Author'; - -/** - * afterFind method - * - * @param mixed $results - * @return void - */ - public function afterFind($results, $primary = false) { - foreach ($results as $index => $result) { - $results[$index]['Author']['user'] .= ' (CakePHP)'; - } - return $results; - } - -} - -/** - * Project class - * - * @package Cake.Test.Case.Model - */ -class Project extends CakeTestModel { - -/** - * name property - * - * @var string 'Project' - */ - public $name = 'Project'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Thread'); -} - -/** - * Thread class - * - * @package Cake.Test.Case.Model - */ -class Thread extends CakeTestModel { - -/** - * name property - * - * @var string 'Thread' - */ - public $name = 'Thread'; - -/** - * hasMany property - * - * @var array - */ - public $belongsTo = array('Project'); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Message'); -} - -/** - * Message class - * - * @package Cake.Test.Case.Model - */ -class Message extends CakeTestModel { - -/** - * name property - * - * @var string 'Message' - */ - public $name = 'Message'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('Bid'); -} - -/** - * Bid class - * - * @package Cake.Test.Case.Model - */ -class Bid extends CakeTestModel { - -/** - * name property - * - * @var string 'Bid' - */ - public $name = 'Bid'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Message'); -} - -/** - * BiddingMessage class - * - * @package Cake.Test.Case.Model - */ -class BiddingMessage extends CakeTestModel { - -/** - * name property - * - * @var string 'BiddingMessage' - */ - public $name = 'BiddingMessage'; - -/** - * primaryKey property - * - * @var string 'bidding' - */ - public $primaryKey = 'bidding'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'Bidding' => array( - 'foreignKey' => false, - 'conditions' => array('BiddingMessage.bidding = Bidding.bid') - ) - ); -} - -/** - * Bidding class - * - * @package Cake.Test.Case.Model - */ -class Bidding extends CakeTestModel { - -/** - * name property - * - * @var string 'Bidding' - */ - public $name = 'Bidding'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array( - 'BiddingMessage' => array( - 'foreignKey' => false, - 'conditions' => array('BiddingMessage.bidding = Bidding.bid'), - 'dependent' => true - ) - ); -} - -/** - * NodeAfterFind class - * - * @package Cake.Test.Case.Model - */ -class NodeAfterFind extends CakeTestModel { - -/** - * name property - * - * @var string 'NodeAfterFind' - */ - public $name = 'NodeAfterFind'; - -/** - * validate property - * - * @var array - */ - public $validate = array('name' => 'notEmpty'); - -/** - * useTable property - * - * @var string 'apples' - */ - public $useTable = 'apples'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('Sample' => array('className' => 'NodeAfterFindSample')); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Child' => array('className' => 'NodeAfterFind', 'dependent' => true)); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Parent' => array('className' => 'NodeAfterFind', 'foreignKey' => 'apple_id')); - -/** - * afterFind method - * - * @param mixed $results - * @return void - */ - public function afterFind($results, $primary = false) { - return $results; - } - -} - -/** - * NodeAfterFindSample class - * - * @package Cake.Test.Case.Model - */ -class NodeAfterFindSample extends CakeTestModel { - -/** - * name property - * - * @var string 'NodeAfterFindSample' - */ - public $name = 'NodeAfterFindSample'; - -/** - * useTable property - * - * @var string 'samples' - */ - public $useTable = 'samples'; - -/** - * belongsTo property - * - * @var string 'NodeAfterFind' - */ - public $belongsTo = 'NodeAfterFind'; -} - -/** - * NodeNoAfterFind class - * - * @package Cake.Test.Case.Model - */ -class NodeNoAfterFind extends CakeTestModel { - -/** - * name property - * - * @var string 'NodeAfterFind' - */ - public $name = 'NodeAfterFind'; - -/** - * validate property - * - * @var array - */ - public $validate = array('name' => 'notEmpty'); - -/** - * useTable property - * - * @var string 'apples' - */ - public $useTable = 'apples'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('Sample' => array('className' => 'NodeAfterFindSample')); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Child' => array('className' => 'NodeAfterFind', 'dependent' => true)); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Parent' => array('className' => 'NodeAfterFind', 'foreignKey' => 'apple_id')); -} - -/** - * Node class - * - * @package Cake.Test.Case.Model - */ -class Node extends CakeTestModel{ - -/** - * name property - * - * @var string 'Node' - */ - public $name = 'Node'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array( - 'ParentNode' => array( - 'className' => 'Node', - 'joinTable' => 'dependency', - 'with' => 'Dependency', - 'foreignKey' => 'child_id', - 'associationForeignKey' => 'parent_id', - ) - ); -} - -/** - * Dependency class - * - * @package Cake.Test.Case.Model - */ -class Dependency extends CakeTestModel { - -/** - * name property - * - * @var string 'Dependency' - */ - public $name = 'Dependency'; -} - -/** - * ModelA class - * - * @package Cake.Test.Case.Model - */ -class ModelA extends CakeTestModel { - -/** - * name property - * - * @var string 'ModelA' - */ - public $name = 'ModelA'; - -/** - * useTable property - * - * @var string 'apples' - */ - public $useTable = 'apples'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('ModelB', 'ModelC'); -} - -/** - * ModelB class - * - * @package Cake.Test.Case.Model - */ -class ModelB extends CakeTestModel { - -/** - * name property - * - * @var string 'ModelB' - */ - public $name = 'ModelB'; - -/** - * useTable property - * - * @var string 'messages' - */ - public $useTable = 'messages'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('ModelD'); -} - -/** - * ModelC class - * - * @package Cake.Test.Case.Model - */ -class ModelC extends CakeTestModel { - -/** - * name property - * - * @var string 'ModelC' - */ - public $name = 'ModelC'; - -/** - * useTable property - * - * @var string 'bids' - */ - public $useTable = 'bids'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('ModelD'); -} - -/** - * ModelD class - * - * @package Cake.Test.Case.Model - */ -class ModelD extends CakeTestModel { - -/** - * name property - * - * @var string 'ModelD' - */ - public $name = 'ModelD'; - -/** - * useTable property - * - * @var string 'threads' - */ - public $useTable = 'threads'; -} - -/** - * Something class - * - * @package Cake.Test.Case.Model - */ -class Something extends CakeTestModel { - -/** - * name property - * - * @var string 'Something' - */ - public $name = 'Something'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('SomethingElse' => array('with' => array('JoinThing' => array('doomed')))); -} - -/** - * SomethingElse class - * - * @package Cake.Test.Case.Model - */ -class SomethingElse extends CakeTestModel { - -/** - * name property - * - * @var string 'SomethingElse' - */ - public $name = 'SomethingElse'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Something' => array('with' => 'JoinThing')); -} - -/** - * JoinThing class - * - * @package Cake.Test.Case.Model - */ -class JoinThing extends CakeTestModel { - -/** - * name property - * - * @var string 'JoinThing' - */ - public $name = 'JoinThing'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Something', 'SomethingElse'); -} - -/** - * Portfolio class - * - * @package Cake.Test.Case.Model - */ -class Portfolio extends CakeTestModel { - -/** - * name property - * - * @var string 'Portfolio' - */ - public $name = 'Portfolio'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Item'); -} - -/** - * Item class - * - * @package Cake.Test.Case.Model - */ -class Item extends CakeTestModel { - -/** - * name property - * - * @var string 'Item' - */ - public $name = 'Item'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Syfile' => array('counterCache' => true)); - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Portfolio' => array('unique' => false)); -} - -/** - * ItemsPortfolio class - * - * @package Cake.Test.Case.Model - */ -class ItemsPortfolio extends CakeTestModel { - -/** - * name property - * - * @var string 'ItemsPortfolio' - */ - public $name = 'ItemsPortfolio'; -} - -/** - * Syfile class - * - * @package Cake.Test.Case.Model - */ -class Syfile extends CakeTestModel { - -/** - * name property - * - * @var string 'Syfile' - */ - public $name = 'Syfile'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Image'); -} - -/** - * Image class - * - * @package Cake.Test.Case.Model - */ -class Image extends CakeTestModel { - -/** - * name property - * - * @var string 'Image' - */ - public $name = 'Image'; -} - -/** - * DeviceType class - * - * @package Cake.Test.Case.Model - */ -class DeviceType extends CakeTestModel { - -/** - * name property - * - * @var string 'DeviceType' - */ - public $name = 'DeviceType'; - -/** - * order property - * - * @var array - */ - public $order = array('DeviceType.order' => 'ASC'); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'DeviceTypeCategory', 'FeatureSet', 'ExteriorTypeCategory', - 'Image' => array('className' => 'Document'), - 'Extra1' => array('className' => 'Document'), - 'Extra2' => array('className' => 'Document')); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Device' => array('order' => array('Device.id' => 'ASC'))); -} - -/** - * DeviceTypeCategory class - * - * @package Cake.Test.Case.Model - */ -class DeviceTypeCategory extends CakeTestModel { - -/** - * name property - * - * @var string 'DeviceTypeCategory' - */ - public $name = 'DeviceTypeCategory'; -} - -/** - * FeatureSet class - * - * @package Cake.Test.Case.Model - */ -class FeatureSet extends CakeTestModel { - -/** - * name property - * - * @var string 'FeatureSet' - */ - public $name = 'FeatureSet'; -} - -/** - * ExteriorTypeCategory class - * - * @package Cake.Test.Case.Model - */ -class ExteriorTypeCategory extends CakeTestModel { - -/** - * name property - * - * @var string 'ExteriorTypeCategory' - */ - public $name = 'ExteriorTypeCategory'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Image' => array('className' => 'Device')); -} - -/** - * Document class - * - * @package Cake.Test.Case.Model - */ -class Document extends CakeTestModel { - -/** - * name property - * - * @var string 'Document' - */ - public $name = 'Document'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('DocumentDirectory'); -} - -/** - * Device class - * - * @package Cake.Test.Case.Model - */ -class Device extends CakeTestModel { - -/** - * name property - * - * @var string 'Device' - */ - public $name = 'Device'; -} - -/** - * DocumentDirectory class - * - * @package Cake.Test.Case.Model - */ -class DocumentDirectory extends CakeTestModel { - -/** - * name property - * - * @var string 'DocumentDirectory' - */ - public $name = 'DocumentDirectory'; -} - -/** - * PrimaryModel class - * - * @package Cake.Test.Case.Model - */ -class PrimaryModel extends CakeTestModel { - -/** - * name property - * - * @var string 'PrimaryModel' - */ - public $name = 'PrimaryModel'; -} - -/** - * SecondaryModel class - * - * @package Cake.Test.Case.Model - */ -class SecondaryModel extends CakeTestModel { - -/** - * name property - * - * @var string 'SecondaryModel' - */ - public $name = 'SecondaryModel'; -} - -/** - * JoinA class - * - * @package Cake.Test.Case.Model - */ -class JoinA extends CakeTestModel { - -/** - * name property - * - * @var string 'JoinA' - */ - public $name = 'JoinA'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('JoinB', 'JoinC'); -} - -/** - * JoinB class - * - * @package Cake.Test.Case.Model - */ -class JoinB extends CakeTestModel { - -/** - * name property - * - * @var string 'JoinB' - */ - public $name = 'JoinB'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('JoinA'); -} - -/** - * JoinC class - * - * @package Cake.Test.Case.Model - */ -class JoinC extends CakeTestModel { - -/** - * name property - * - * @var string 'JoinC' - */ - public $name = 'JoinC'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('JoinA'); -} - -/** - * ThePaper class - * - * @package Cake.Test.Case.Model - */ -class ThePaper extends CakeTestModel { - -/** - * name property - * - * @var string 'ThePaper' - */ - public $name = 'ThePaper'; - -/** - * useTable property - * - * @var string 'apples' - */ - public $useTable = 'apples'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('Itself' => array('className' => 'ThePaper', 'foreignKey' => 'apple_id')); - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Monkey' => array('joinTable' => 'the_paper_monkies', 'order' => 'id')); -} - -/** - * Monkey class - * - * @package Cake.Test.Case.Model - */ -class Monkey extends CakeTestModel { - -/** - * name property - * - * @var string 'Monkey' - */ - public $name = 'Monkey'; - -/** - * useTable property - * - * @var string 'devices' - */ - public $useTable = 'devices'; -} - -/** - * AssociationTest1 class - * - * @package Cake.Test.Case.Model - */ -class AssociationTest1 extends CakeTestModel { - -/** - * useTable property - * - * @var string 'join_as' - */ - public $useTable = 'join_as'; - -/** - * name property - * - * @var string 'AssociationTest1' - */ - public $name = 'AssociationTest1'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('AssociationTest2' => array( - 'unique' => false, 'joinTable' => 'join_as_join_bs', 'foreignKey' => false - )); -} - -/** - * AssociationTest2 class - * - * @package Cake.Test.Case.Model - */ -class AssociationTest2 extends CakeTestModel { - -/** - * useTable property - * - * @var string 'join_bs' - */ - public $useTable = 'join_bs'; - -/** - * name property - * - * @var string 'AssociationTest2' - */ - public $name = 'AssociationTest2'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('AssociationTest1' => array( - 'unique' => false, 'joinTable' => 'join_as_join_bs' - )); -} - -/** - * Callback class - * - * @package Cake.Test.Case.Model - */ -class Callback extends CakeTestModel { - -} - -/** - * CallbackPostTestModel class - * - * @package Cake.Test.Case.Model - */ -class CallbackPostTestModel extends CakeTestModel { - - public $useTable = 'posts'; - -/** - * variable to control return of beforeValidate - * - * @var string - */ - public $beforeValidateReturn = true; - -/** - * variable to control return of beforeSave - * - * @var string - */ - public $beforeSaveReturn = true; - -/** - * variable to control return of beforeDelete - * - * @var string - */ - public $beforeDeleteReturn = true; - -/** - * beforeSave callback - * - * @return void - */ - public function beforeSave($options = array()) { - return $this->beforeSaveReturn; - } - -/** - * beforeValidate callback - * - * @return void - */ - public function beforeValidate($options = array()) { - return $this->beforeValidateReturn; - } - -/** - * beforeDelete callback - * - * @return void - */ - public function beforeDelete($cascade = true) { - return $this->beforeDeleteReturn; - } - -} - -/** - * Uuid class - * - * @package Cake.Test.Case.Model - */ -class Uuid extends CakeTestModel { - -/** - * name property - * - * @var string 'Uuid' - */ - public $name = 'Uuid'; -} - -/** - * DataTest class - * - * @package Cake.Test.Case.Model - */ -class DataTest extends CakeTestModel { - -/** - * name property - * - * @var string 'DataTest' - */ - public $name = 'DataTest'; -} - -/** - * TheVoid class - * - * @package Cake.Test.Case.Model - */ -class TheVoid extends CakeTestModel { - -/** - * name property - * - * @var string 'TheVoid' - */ - public $name = 'TheVoid'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; -} - -/** - * ValidationTest1 class - * - * @package Cake.Test.Case.Model - */ -class ValidationTest1 extends CakeTestModel { - -/** - * name property - * - * @var string 'ValidationTest' - */ - public $name = 'ValidationTest1'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema property - * - * @var array - */ - protected $_schema = array(); - -/** - * validate property - * - * @var array - */ - public $validate = array( - 'title' => 'notEmpty', - 'published' => 'customValidationMethod', - 'body' => array( - 'notEmpty', - '/^.{5,}$/s' => 'no matchy', - '/^[0-9A-Za-z \\.]{1,}$/s' - ) - ); - -/** - * customValidationMethod method - * - * @param mixed $data - * @return void - */ - public function customValidationMethod($data) { - return $data === 1; - } - -/** - * Custom validator with parameters + default values - * - * @return array - */ - public function customValidatorWithParams($data, $validator, $or = true, $ignoreOnSame = 'id') { - $this->validatorParams = get_defined_vars(); - unset($this->validatorParams['this']); - return true; - } - -/** - * Custom validator with message - * - * @return array - */ - public function customValidatorWithMessage($data) { - return 'This field will *never* validate! Muhahaha!'; - } - -/** - * Test validation with many parameters - * - * @return void - */ - public function customValidatorWithSixParams($data, $one = 1, $two = 2, $three = 3, $four = 4, $five = 5, $six = 6) { - $this->validatorParams = get_defined_vars(); - unset($this->validatorParams['this']); - return true; - } - -} - -/** - * ValidationTest2 class - * - * @package Cake.Test.Case.Model - */ -class ValidationTest2 extends CakeTestModel { - -/** - * name property - * - * @var string 'ValidationTest2' - */ - public $name = 'ValidationTest2'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * validate property - * - * @var array - */ - public $validate = array( - 'title' => 'notEmpty', - 'published' => 'customValidationMethod', - 'body' => array( - 'notEmpty', - '/^.{5,}$/s' => 'no matchy', - '/^[0-9A-Za-z \\.]{1,}$/s' - ) - ); - -/** - * customValidationMethod method - * - * @param mixed $data - * @return void - */ - public function customValidationMethod($data) { - return $data === 1; - } - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - return array(); - } - -} - -/** - * Person class - * - * @package Cake.Test.Case.Model - */ -class Person extends CakeTestModel { - -/** - * name property - * - * @var string 'Person' - */ - public $name = 'Person'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'Mother' => array( - 'className' => 'Person', - 'foreignKey' => 'mother_id' - ), - 'Father' => array( - 'className' => 'Person', - 'foreignKey' => 'father_id' - ) - ); -} - -/** - * UnderscoreField class - * - * @package Cake.Test.Case.Model - */ -class UnderscoreField extends CakeTestModel { - -/** - * name property - * - * @var string 'UnderscoreField' - */ - public $name = 'UnderscoreField'; -} - -/** - * Product class - * - * @package Cake.Test.Case.Model - */ -class Product extends CakeTestModel { - -/** - * name property - * - * @var string 'Product' - */ - public $name = 'Product'; -} - -/** - * Story class - * - * @package Cake.Test.Case.Model - */ -class Story extends CakeTestModel { - -/** - * name property - * - * @var string 'Story' - */ - public $name = 'Story'; - -/** - * primaryKey property - * - * @var string 'story' - */ - public $primaryKey = 'story'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Tag' => array('foreignKey' => 'story')); - -/** - * validate property - * - * @var array - */ - public $validate = array('title' => 'notEmpty'); -} - -/** - * Cd class - * - * @package Cake.Test.Case.Model - */ -class Cd extends CakeTestModel { - -/** - * name property - * - * @var string 'Cd' - */ - public $name = 'Cd'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array( - 'OverallFavorite' => array( - 'foreignKey' => 'model_id', - 'dependent' => true, - 'conditions' => array('model_type' => 'Cd') - ) - ); - -} - -/** - * Book class - * - * @package Cake.Test.Case.Model - */ -class Book extends CakeTestModel { - -/** - * name property - * - * @var string 'Book' - */ - public $name = 'Book'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array( - 'OverallFavorite' => array( - 'foreignKey' => 'model_id', - 'dependent' => true, - 'conditions' => 'OverallFavorite.model_type = \'Book\'' - ) - ); - -} - -/** - * OverallFavorite class - * - * @package Cake.Test.Case.Model - */ -class OverallFavorite extends CakeTestModel { - -/** - * name property - * - * @var string 'OverallFavorite' - */ - public $name = 'OverallFavorite'; -} - -/** - * MyUser class - * - * @package Cake.Test.Case.Model - */ -class MyUser extends CakeTestModel { - -/** - * name property - * - * @var string 'MyUser' - */ - public $name = 'MyUser'; - -/** - * undocumented variable - * - * @var string - */ - public $hasAndBelongsToMany = array('MyCategory'); -} - -/** - * MyCategory class - * - * @package Cake.Test.Case.Model - */ -class MyCategory extends CakeTestModel { - -/** - * name property - * - * @var string 'MyCategory' - */ - public $name = 'MyCategory'; - -/** - * undocumented variable - * - * @var string - */ - public $hasAndBelongsToMany = array('MyProduct', 'MyUser'); -} - -/** - * MyProduct class - * - * @package Cake.Test.Case.Model - */ -class MyProduct extends CakeTestModel { - -/** - * name property - * - * @var string 'MyProduct' - */ - public $name = 'MyProduct'; - -/** - * undocumented variable - * - * @var string - */ - public $hasAndBelongsToMany = array('MyCategory'); -} - -/** - * MyCategoriesMyUser class - * - * @package Cake.Test.Case.Model - */ -class MyCategoriesMyUser extends CakeTestModel { - -/** - * name property - * - * @var string 'MyCategoriesMyUser' - */ - public $name = 'MyCategoriesMyUser'; -} - -/** - * MyCategoriesMyProduct class - * - * @package Cake.Test.Case.Model - */ -class MyCategoriesMyProduct extends CakeTestModel { - -/** - * name property - * - * @var string 'MyCategoriesMyProduct' - */ - public $name = 'MyCategoriesMyProduct'; -} - - -/** - * NumberTree class - * - * @package Cake.Test.Case.Model - */ -class NumberTree extends CakeTestModel { - -/** - * name property - * - * @var string 'NumberTree' - */ - public $name = 'NumberTree'; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Tree'); - -/** - * initialize method - * - * @param int $levelLimit - * @param int $childLimit - * @param mixed $currentLevel - * @param mixed $parent_id - * @param string $prefix - * @param bool $hierachial - * @return void - */ - public function initialize($levelLimit = 3, $childLimit = 3, $currentLevel = null, $parentId = null, $prefix = '1', $hierachial = true) { - if (!$parentId) { - $db = ConnectionManager::getDataSource($this->useDbConfig); - $db->truncate($this->table); - $this->save(array($this->name => array('name' => '1. Root'))); - $this->initialize($levelLimit, $childLimit, 1, $this->id, '1', $hierachial); - $this->create(array()); - } - - if (!$currentLevel || $currentLevel > $levelLimit) { - return; - } - - for ($i = 1; $i <= $childLimit; $i++) { - $name = $prefix . '.' . $i; - $data = array($this->name => array('name' => $name)); - $this->create($data); - - if ($hierachial) { - if ($this->name == 'UnconventionalTree') { - $data[$this->name]['join'] = $parentId; - } else { - $data[$this->name]['parent_id'] = $parentId; - } - } - $this->save($data); - $this->initialize($levelLimit, $childLimit, $currentLevel + 1, $this->id, $name, $hierachial); - } - } - -} - -/** - * NumberTreeTwo class - * - * @package Cake.Test.Case.Model - */ -class NumberTreeTwo extends NumberTree { - -/** - * name property - * - * @var string 'NumberTree' - */ - public $name = 'NumberTreeTwo'; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array(); -} - -/** - * FlagTree class - * - * @package Cake.Test.Case.Model - */ -class FlagTree extends NumberTree { - -/** - * name property - * - * @var string 'FlagTree' - */ - public $name = 'FlagTree'; -} - -/** - * UnconventionalTree class - * - * @package Cake.Test.Case.Model - */ -class UnconventionalTree extends NumberTree { - -/** - * name property - * - * @var string 'FlagTree' - */ - public $name = 'UnconventionalTree'; - - public $actsAs = array( - 'Tree' => array( - 'parent' => 'join', - 'left' => 'left', - 'right' => 'right' - ) - ); - -} - -/** - * UuidTree class - * - * @package Cake.Test.Case.Model - */ -class UuidTree extends NumberTree { - -/** - * name property - * - * @var string 'FlagTree' - */ - public $name = 'UuidTree'; -} - -/** - * Campaign class - * - * @package Cake.Test.Case.Model - */ -class Campaign extends CakeTestModel { - -/** - * name property - * - * @var string 'Campaign' - */ - public $name = 'Campaign'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Ad' => array('fields' => array('id', 'campaign_id', 'name'))); -} - -/** - * Ad class - * - * @package Cake.Test.Case.Model - */ -class Ad extends CakeTestModel { - -/** - * name property - * - * @var string 'Ad' - */ - public $name = 'Ad'; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Tree'); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Campaign'); -} - -/** - * AfterTree class - * - * @package Cake.Test.Case.Model - */ -class AfterTree extends NumberTree { - -/** - * name property - * - * @var string 'AfterTree' - */ - public $name = 'AfterTree'; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Tree'); - - public function afterSave($created) { - if ($created && isset($this->data['AfterTree'])) { - $this->data['AfterTree']['name'] = 'Six and One Half Changed in AfterTree::afterSave() but not in database'; - } - } - -} - -/** - * Nonconformant Content class - * - * @package Cake.Test.Case.Model - */ -class Content extends CakeTestModel { - -/** - * name property - * - * @var string 'Content' - */ - public $name = 'Content'; - -/** - * useTable property - * - * @var string 'Content' - */ - public $useTable = 'Content'; - -/** - * primaryKey property - * - * @var string 'iContentId' - */ - public $primaryKey = 'iContentId'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Account' => array('className' => 'Account', 'with' => 'ContentAccount', 'joinTable' => 'ContentAccounts', 'foreignKey' => 'iContentId', 'associationForeignKey', 'iAccountId')); -} - -/** - * Nonconformant Account class - * - * @package Cake.Test.Case.Model - */ -class Account extends CakeTestModel { - -/** - * name property - * - * @var string 'Account' - */ - public $name = 'Account'; - -/** - * useTable property - * - * @var string 'Account' - */ - public $useTable = 'Accounts'; - -/** - * primaryKey property - * - * @var string 'iAccountId' - */ - public $primaryKey = 'iAccountId'; -} - -/** - * Nonconformant ContentAccount class - * - * @package Cake.Test.Case.Model - */ -class ContentAccount extends CakeTestModel { - -/** - * name property - * - * @var string 'Account' - */ - public $name = 'ContentAccount'; - -/** - * useTable property - * - * @var string 'Account' - */ - public $useTable = 'ContentAccounts'; - -/** - * primaryKey property - * - * @var string 'iAccountId' - */ - public $primaryKey = 'iContentAccountsId'; -} - -/** - * FilmFile class - * - * @package Cake.Test.Case.Model - */ -class FilmFile extends CakeTestModel { - - public $name = 'FilmFile'; - -} - -/** - * Basket test model - * - * @package Cake.Test.Case.Model - */ -class Basket extends CakeTestModel { - - public $name = 'Basket'; - - public $belongsTo = array( - 'FilmFile' => array( - 'className' => 'FilmFile', - 'foreignKey' => 'object_id', - 'conditions' => "Basket.type = 'file'", - 'fields' => '', - 'order' => '' - ) - ); - -} - -/** - * TestPluginArticle class - * - * @package Cake.Test.Case.Model - */ -class TestPluginArticle extends CakeTestModel { - -/** - * name property - * - * @var string 'TestPluginArticle' - */ - public $name = 'TestPluginArticle'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('User'); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'TestPluginComment' => array( - 'className' => 'TestPlugin.TestPluginComment', - 'foreignKey' => 'article_id', - 'dependent' => true - ) - ); -} - -/** - * TestPluginComment class - * - * @package Cake.Test.Case.Model - */ -class TestPluginComment extends CakeTestModel { - -/** - * name property - * - * @var string 'TestPluginComment' - */ - public $name = 'TestPluginComment'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'TestPluginArticle' => array( - 'className' => 'TestPlugin.TestPluginArticle', - 'foreignKey' => 'article_id', - ), - 'TestPlugin.User' - ); -} - -/** - * Uuidportfolio class - * - * @package Cake.Test.Case.Model - */ -class Uuidportfolio extends CakeTestModel { - -/** - * name property - * - * @var string 'Uuidportfolio' - */ - public $name = 'Uuidportfolio'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Uuiditem'); -} - -/** - * Uuiditem class - * - * @package Cake.Test.Case.Model - */ -class Uuiditem extends CakeTestModel { - -/** - * name property - * - * @var string 'Item' - */ - public $name = 'Uuiditem'; - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('Uuidportfolio' => array('with' => 'UuiditemsUuidportfolioNumericid')); - -} - -/** - * UuiditemsPortfolio class - * - * @package Cake.Test.Case.Model - */ -class UuiditemsUuidportfolio extends CakeTestModel { - -/** - * name property - * - * @var string 'ItemsPortfolio' - */ - public $name = 'UuiditemsUuidportfolio'; -} - -/** - * UuiditemsPortfolioNumericid class - * - * @package Cake.Test.Case.Model - */ -class UuiditemsUuidportfolioNumericid extends CakeTestModel { - -/** - * name property - * - * @var string - */ - public $name = 'UuiditemsUuidportfolioNumericid'; -} - -/** - * TranslateTestModel class. - * - * @package Cake.Test.Case.Model - */ -class TranslateTestModel extends CakeTestModel { - -/** - * name property - * - * @var string 'TranslateTestModel' - */ - public $name = 'TranslateTestModel'; - -/** - * useTable property - * - * @var string 'i18n' - */ - public $useTable = 'i18n'; - -/** - * displayField property - * - * @var string 'field' - */ - public $displayField = 'field'; -} - -/** - * TranslateTestModel class. - * - * @package Cake.Test.Case.Model - */ -class TranslateWithPrefix extends CakeTestModel { - -/** - * name property - * - * @var string 'TranslateTestModel' - */ - public $name = 'TranslateWithPrefix'; - -/** - * tablePrefix property - * - * @var string 'i18n' - */ - public $tablePrefix = 'i18n_'; - -/** - * displayField property - * - * @var string 'field' - */ - public $displayField = 'field'; - -} - -/** - * TranslatedItem class. - * - * @package Cake.Test.Case.Model - */ -class TranslatedItem extends CakeTestModel { - -/** - * name property - * - * @var string 'TranslatedItem' - */ - public $name = 'TranslatedItem'; - -/** - * cacheQueries property - * - * @var bool false - */ - public $cacheQueries = false; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Translate' => array('content', 'title')); - -/** - * translateModel property - * - * @var string 'TranslateTestModel' - */ - public $translateModel = 'TranslateTestModel'; - -} - -/** - * TranslatedItem class. - * - * @package Cake.Test.Case.Model - */ -class TranslatedItem2 extends CakeTestModel { - -/** - * name property - * - * @var string 'TranslatedItem' - */ - public $name = 'TranslatedItem'; - -/** - * cacheQueries property - * - * @var bool false - */ - public $cacheQueries = false; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Translate' => array('content', 'title')); - -/** - * translateModel property - * - * @var string 'TranslateTestModel' - */ - public $translateModel = 'TranslateWithPrefix'; - -} - -/** - * TranslatedItemWithTable class. - * - * @package Cake.Test.Case.Model - */ -class TranslatedItemWithTable extends CakeTestModel { - -/** - * name property - * - * @var string 'TranslatedItemWithTable' - */ - public $name = 'TranslatedItemWithTable'; - -/** - * useTable property - * - * @var string 'translated_items' - */ - public $useTable = 'translated_items'; - -/** - * cacheQueries property - * - * @var bool false - */ - public $cacheQueries = false; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Translate' => array('content', 'title')); - -/** - * translateModel property - * - * @var string 'TranslateTestModel' - */ - public $translateModel = 'TranslateTestModel'; - -/** - * translateTable property - * - * @var string 'another_i18n' - */ - public $translateTable = 'another_i18n'; - -} - -/** - * TranslateArticleModel class. - * - * @package Cake.Test.Case.Model - */ -class TranslateArticleModel extends CakeTestModel { - -/** - * name property - * - * @var string 'TranslateArticleModel' - */ - public $name = 'TranslateArticleModel'; - -/** - * useTable property - * - * @var string 'article_i18n' - */ - public $useTable = 'article_i18n'; - -/** - * displayField property - * - * @var string 'field' - */ - public $displayField = 'field'; - -} - -/** - * TranslatedArticle class. - * - * @package Cake.Test.Case.Model - */ -class TranslatedArticle extends CakeTestModel { - -/** - * name property - * - * @var string 'TranslatedArticle' - */ - public $name = 'TranslatedArticle'; - -/** - * cacheQueries property - * - * @var bool false - */ - public $cacheQueries = false; - -/** - * actsAs property - * - * @var array - */ - public $actsAs = array('Translate' => array('title', 'body')); - -/** - * translateModel property - * - * @var string 'TranslateArticleModel' - */ - public $translateModel = 'TranslateArticleModel'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('User'); - -} - -class CounterCacheUser extends CakeTestModel { - - public $name = 'CounterCacheUser'; - - public $alias = 'User'; - - public $hasMany = array( - 'Post' => array( - 'className' => 'CounterCachePost', - 'foreignKey' => 'user_id' - ) - ); -} - -class CounterCachePost extends CakeTestModel { - - public $name = 'CounterCachePost'; - - public $alias = 'Post'; - - public $belongsTo = array( - 'User' => array( - 'className' => 'CounterCacheUser', - 'foreignKey' => 'user_id', - 'counterCache' => true - ) - ); -} - -class CounterCacheUserNonstandardPrimaryKey extends CakeTestModel { - - public $name = 'CounterCacheUserNonstandardPrimaryKey'; - - public $alias = 'User'; - - public $primaryKey = 'uid'; - - public $hasMany = array( - 'Post' => array( - 'className' => 'CounterCachePostNonstandardPrimaryKey', - 'foreignKey' => 'uid' - ) - ); - -} - -class CounterCachePostNonstandardPrimaryKey extends CakeTestModel { - - public $name = 'CounterCachePostNonstandardPrimaryKey'; - - public $alias = 'Post'; - - public $primaryKey = 'pid'; - - public $belongsTo = array( - 'User' => array( - 'className' => 'CounterCacheUserNonstandardPrimaryKey', - 'foreignKey' => 'uid', - 'counterCache' => true - ) - ); - -} - -class ArticleB extends CakeTestModel { - - public $name = 'ArticleB'; - - public $useTable = 'articles'; - - public $hasAndBelongsToMany = array( - 'TagB' => array( - 'className' => 'TagB', - 'joinTable' => 'articles_tags', - 'foreignKey' => 'article_id', - 'associationForeignKey' => 'tag_id' - ) - ); - -} - -class TagB extends CakeTestModel { - - public $name = 'TagB'; - - public $useTable = 'tags'; - - public $hasAndBelongsToMany = array( - 'ArticleB' => array( - 'className' => 'ArticleB', - 'joinTable' => 'articles_tags', - 'foreignKey' => 'tag_id', - 'associationForeignKey' => 'article_id' - ) - ); - -} - -class Fruit extends CakeTestModel { - - public $name = 'Fruit'; - - public $hasAndBelongsToMany = array( - 'UuidTag' => array( - 'className' => 'UuidTag', - 'joinTable' => 'fruits_uuid_tags', - 'foreignKey' => 'fruit_id', - 'associationForeignKey' => 'uuid_tag_id', - 'with' => 'FruitsUuidTag' - ) - ); - -} - -class FruitsUuidTag extends CakeTestModel { - - public $name = 'FruitsUuidTag'; - - public $primaryKey = false; - - public $belongsTo = array( - 'UuidTag' => array( - 'className' => 'UuidTag', - 'foreignKey' => 'uuid_tag_id', - ), - 'Fruit' => array( - 'className' => 'Fruit', - 'foreignKey' => 'fruit_id', - ) - ); - -} - -class UuidTag extends CakeTestModel { - - public $name = 'UuidTag'; - - public $hasAndBelongsToMany = array( - 'Fruit' => array( - 'className' => 'Fruit', - 'joinTable' => 'fruits_uuid_tags', - 'foreign_key' => 'uuid_tag_id', - 'associationForeignKey' => 'fruit_id', - 'with' => 'FruitsUuidTag' - ) - ); - -} - -class FruitNoWith extends CakeTestModel { - - public $name = 'Fruit'; - - public $useTable = 'fruits'; - - public $hasAndBelongsToMany = array( - 'UuidTag' => array( - 'className' => 'UuidTagNoWith', - 'joinTable' => 'fruits_uuid_tags', - 'foreignKey' => 'fruit_id', - 'associationForeignKey' => 'uuid_tag_id', - ) - ); - -} - -class UuidTagNoWith extends CakeTestModel { - - public $name = 'UuidTag'; - - public $useTable = 'uuid_tags'; - - public $hasAndBelongsToMany = array( - 'Fruit' => array( - 'className' => 'FruitNoWith', - 'joinTable' => 'fruits_uuid_tags', - 'foreign_key' => 'uuid_tag_id', - 'associationForeignKey' => 'fruit_id', - ) - ); - -} - -class ProductUpdateAll extends CakeTestModel { - - public $name = 'ProductUpdateAll'; - - public $useTable = 'product_update_all'; - -} - -class GroupUpdateAll extends CakeTestModel { - - public $name = 'GroupUpdateAll'; - - public $useTable = 'group_update_all'; - -} - -class TransactionTestModel extends CakeTestModel { - - public $name = 'TransactionTestModel'; - - public $useTable = 'samples'; - - public function afterSave($created) { - $data = array( - array('apple_id' => 1, 'name' => 'sample6'), - ); - $this->saveAll($data, array('atomic' => true, 'callbacks' => false)); - } - -} - -class TransactionManyTestModel extends CakeTestModel { - - public $name = 'TransactionManyTestModel'; - - public $useTable = 'samples'; - - public function afterSave($created) { - $data = array( - array('apple_id' => 1, 'name' => 'sample6'), - ); - $this->saveMany($data, array('atomic' => true, 'callbacks' => false)); - } - -} - -class Site extends CakeTestModel { - - public $name = 'Site'; - - public $useTable = 'sites'; - - public $hasAndBelongsToMany = array( - 'Domain' => array('unique' => 'keepExisting'), - ); -} - -class Domain extends CakeTestModel { - - public $name = 'Domain'; - - public $useTable = 'domains'; - - public $hasAndBelongsToMany = array( - 'Site' => array('unique' => 'keepExisting'), - ); -} - -/** - * TestModel class - * - * @package Cake.Test.Case.Model - */ -class TestModel extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel' - */ - public $name = 'TestModel'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'client_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '11'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'login' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'passwd' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_1' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_2' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '25'), - 'zip_code' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'city' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'country' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'phone' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'fax' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'url' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'comments' => array('type' => 'text', 'null' => '1', 'default' => '', 'length' => '155'), - 'last_login' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => ''), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - -/** - * find method - * - * @param mixed $conditions - * @param mixed $fields - * @param mixed $order - * @param mixed $recursive - * @return void - */ - public function find($conditions = null, $fields = null, $order = null, $recursive = null) { - return array($conditions, $fields); - } - -/** - * findAll method - * - * @param mixed $conditions - * @param mixed $fields - * @param mixed $order - * @param mixed $recursive - * @return void - */ - public function findAll($conditions = null, $fields = null, $order = null, $recursive = null) { - return $conditions; - } - -} - -/** - * TestModel2 class - * - * @package Cake.Test.Case.Model - */ -class TestModel2 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel2' - */ - public $name = 'TestModel2'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; -} - -/** - * TestModel4 class - * - * @package Cake.Test.Case.Model - */ -class TestModel3 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel3' - */ - public $name = 'TestModel3'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; -} - -/** - * TestModel4 class - * - * @package Cake.Test.Case.Model - */ -class TestModel4 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel4' - */ - public $name = 'TestModel4'; - -/** - * table property - * - * @var string 'test_model4' - */ - public $table = 'test_model4'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'TestModel4Parent' => array( - 'className' => 'TestModel4', - 'foreignKey' => 'parent_id' - ) - ); - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array( - 'TestModel5' => array( - 'className' => 'TestModel5', - 'foreignKey' => 'test_model4_id' - ) - ); - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('TestModel7' => array( - 'className' => 'TestModel7', - 'joinTable' => 'test_model4_test_model7', - 'foreignKey' => 'test_model4_id', - 'associationForeignKey' => 'test_model7_id', - 'with' => 'TestModel4TestModel7' - )); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * TestModel4TestModel7 class - * - * @package Cake.Test.Case.Model - */ -class TestModel4TestModel7 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel4TestModel7' - */ - public $name = 'TestModel4TestModel7'; - -/** - * table property - * - * @var string 'test_model4_test_model7' - */ - public $table = 'test_model4_test_model7'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'test_model4_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'test_model7_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8') - ); - } - return $this->_schema; - } - -} - -/** - * TestModel5 class - * - * @package Cake.Test.Case.Model - */ -class TestModel5 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel5' - */ - public $name = 'TestModel5'; - -/** - * table property - * - * @var string 'test_model5' - */ - public $table = 'test_model5'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('TestModel4' => array( - 'className' => 'TestModel4', - 'foreignKey' => 'test_model4_id' - )); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('TestModel6' => array( - 'className' => 'TestModel6', - 'foreignKey' => 'test_model5_id' - )); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'test_model4_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * TestModel6 class - * - * @package Cake.Test.Case.Model - */ -class TestModel6 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel6' - */ - public $name = 'TestModel6'; - -/** - * table property - * - * @var string 'test_model6' - */ - public $table = 'test_model6'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'TestModel5' => array( - 'className' => 'TestModel5', - 'foreignKey' => 'test_model5_id' - ) - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'test_model5_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * TestModel7 class - * - * @package Cake.Test.Case.Model - */ -class TestModel7 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel7' - */ - public $name = 'TestModel7'; - -/** - * table property - * - * @var string 'test_model7' - */ - public $table = 'test_model7'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * TestModel8 class - * - * @package Cake.Test.Case.Model - */ -class TestModel8 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel8' - */ - public $name = 'TestModel8'; - -/** - * table property - * - * @var string 'test_model8' - */ - public $table = 'test_model8'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array( - 'TestModel9' => array( - 'className' => 'TestModel9', - 'foreignKey' => 'test_model8_id', - 'conditions' => 'TestModel9.name != \'mariano\'' - ) - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'test_model9_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * TestModel9 class - * - * @package Cake.Test.Case.Model - */ -class TestModel9 extends CakeTestModel { - -/** - * name property - * - * @var string 'TestModel9' - */ - public $name = 'TestModel9'; - -/** - * table property - * - * @var string 'test_model9' - */ - public $table = 'test_model9'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'TestModel8' => array( - 'className' => 'TestModel8', - 'foreignKey' => 'test_model8_id', - 'conditions' => 'TestModel8.name != \'larry\'' - ) - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'test_model8_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '11'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * Level class - * - * @package Cake.Test.Case.Model - */ -class Level extends CakeTestModel { - -/** - * name property - * - * @var string 'Level' - */ - public $name = 'Level'; - -/** - * table property - * - * @var string 'level' - */ - public $table = 'level'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'Group' => array( - 'className' => 'Group' - ), - 'User2' => array( - 'className' => 'User2' - ) - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'name' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => '20'), - ); - } - return $this->_schema; - } - -} - -/** - * Group class - * - * @package Cake.Test.Case.Model - */ -class Group extends CakeTestModel { - -/** - * name property - * - * @var string 'Group' - */ - public $name = 'Group'; - -/** - * table property - * - * @var string 'group' - */ - public $table = 'group'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('Level'); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Category2', 'User2'); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'level_id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'name' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => '20'), - ); - } - return $this->_schema; - } - -} - -/** - * User2 class - * - * @package Cake.Test.Case.Model - */ -class User2 extends CakeTestModel { - -/** - * name property - * - * @var string 'User2' - */ - public $name = 'User2'; - -/** - * table property - * - * @var string 'user' - */ - public $table = 'user'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'Group' => array( - 'className' => 'Group' - ), - 'Level' => array( - 'className' => 'Level' - ) - ); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'Article2' => array( - 'className' => 'Article2' - ), - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'group_id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'level_id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'name' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => '20'), - ); - } - return $this->_schema; - } - -} - -/** - * Category2 class - * - * @package Cake.Test.Case.Model - */ -class Category2 extends CakeTestModel { - -/** - * name property - * - * @var string 'Category2' - */ - public $name = 'Category2'; - -/** - * table property - * - * @var string 'category' - */ - public $table = 'category'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'Group' => array( - 'className' => 'Group', - 'foreignKey' => 'group_id' - ), - 'ParentCat' => array( - 'className' => 'Category2', - 'foreignKey' => 'parent_id' - ) - ); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'ChildCat' => array( - 'className' => 'Category2', - 'foreignKey' => 'parent_id' - ), - 'Article2' => array( - 'className' => 'Article2', - 'order' => 'Article2.published_date DESC', - 'foreignKey' => 'category_id', - 'limit' => '3') - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => '', 'length' => '10'), - 'group_id' => array('type' => 'integer', 'null' => false, 'default' => '', 'length' => '10'), - 'parent_id' => array('type' => 'integer', 'null' => false, 'default' => '', 'length' => '10'), - 'name' => array('type' => 'string', 'null' => false, 'default' => '', 'length' => '255'), - 'icon' => array('type' => 'string', 'null' => false, 'default' => '', 'length' => '255'), - 'description' => array('type' => 'text', 'null' => false, 'default' => '', 'length' => null), - - ); - } - return $this->_schema; - } - -} - -/** - * Article2 class - * - * @package Cake.Test.Case.Model - */ -class Article2 extends CakeTestModel { - -/** - * name property - * - * @var string 'Article2' - */ - public $name = 'Article2'; - -/** - * table property - * - * @var string 'article' - */ - public $table = 'articles'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'Category2' => array('className' => 'Category2'), - 'User2' => array('className' => 'User2') - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => '', 'length' => '10'), - 'category_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'user_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'rate_count' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'rate_sum' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'viewed' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'version' => array('type' => 'string', 'null' => true, 'default' => '', 'length' => '45'), - 'title' => array('type' => 'string', 'null' => false, 'default' => '', 'length' => '200'), - 'intro' => array('text' => 'string', 'null' => true, 'default' => '', 'length' => null), - 'comments' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '4'), - 'body' => array('text' => 'string', 'null' => true, 'default' => '', 'length' => null), - 'isdraft' => array('type' => 'boolean', 'null' => false, 'default' => '0', 'length' => '1'), - 'allow_comments' => array('type' => 'boolean', 'null' => false, 'default' => '1', 'length' => '1'), - 'moderate_comments' => array('type' => 'boolean', 'null' => false, 'default' => '1', 'length' => '1'), - 'published' => array('type' => 'boolean', 'null' => false, 'default' => '0', 'length' => '1'), - 'multipage' => array('type' => 'boolean', 'null' => false, 'default' => '0', 'length' => '1'), - 'published_date' => array('type' => 'datetime', 'null' => true, 'default' => '', 'length' => null), - 'created' => array('type' => 'datetime', 'null' => false, 'default' => '0000-00-00 00:00:00', 'length' => null), - 'modified' => array('type' => 'datetime', 'null' => false, 'default' => '0000-00-00 00:00:00', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * CategoryFeatured2 class - * - * @package Cake.Test.Case.Model - */ -class CategoryFeatured2 extends CakeTestModel { - -/** - * name property - * - * @var string 'CategoryFeatured2' - */ - public $name = 'CategoryFeatured2'; - -/** - * table property - * - * @var string 'category_featured' - */ - public $table = 'category_featured'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => '', 'length' => '10'), - 'parent_id' => array('type' => 'integer', 'null' => false, 'default' => '', 'length' => '10'), - 'name' => array('type' => 'string', 'null' => false, 'default' => '', 'length' => '255'), - 'icon' => array('type' => 'string', 'null' => false, 'default' => '', 'length' => '255'), - 'description' => array('text' => 'string', 'null' => false, 'default' => '', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * Featured2 class - * - * @package Cake.Test.Case.Model - */ -class Featured2 extends CakeTestModel { - -/** - * name property - * - * @var string 'Featured2' - */ - public $name = 'Featured2'; - -/** - * table property - * - * @var string 'featured2' - */ - public $table = 'featured2'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'CategoryFeatured2' => array( - 'className' => 'CategoryFeatured2' - ) - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'article_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'category_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'name' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => '20') - ); - } - return $this->_schema; - } - -} - -/** - * Comment2 class - * - * @package Cake.Test.Case.Model - */ -class Comment2 extends CakeTestModel { - -/** - * name property - * - * @var string 'Comment2' - */ - public $name = 'Comment2'; - -/** - * table property - * - * @var string 'comment' - */ - public $table = 'comment'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('ArticleFeatured2', 'User2'); - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'article_featured_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'user_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'name' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => '20') - ); - } - return $this->_schema; - } - -} - -/** - * ArticleFeatured2 class - * - * @package Cake.Test.Case.Model - */ -class ArticleFeatured2 extends CakeTestModel { - -/** - * name property - * - * @var string 'ArticleFeatured2' - */ - public $name = 'ArticleFeatured2'; - -/** - * table property - * - * @var string 'article_featured' - */ - public $table = 'article_featured'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'CategoryFeatured2' => array('className' => 'CategoryFeatured2'), - 'User2' => array('className' => 'User2') - ); - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array( - 'Featured2' => array('className' => 'Featured2') - ); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'Comment2' => array('className' => 'Comment2', 'dependent' => true) - ); - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - if (!isset($this->_schema)) { - $this->_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => null, 'length' => '10'), - 'category_featured_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'user_id' => array('type' => 'integer', 'null' => false, 'default' => '0', 'length' => '10'), - 'title' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => '20'), - 'body' => array('text' => 'string', 'null' => true, 'default' => '', 'length' => null), - 'published' => array('type' => 'boolean', 'null' => false, 'default' => '0', 'length' => '1'), - 'published_date' => array('type' => 'datetime', 'null' => true, 'default' => '', 'length' => null), - 'created' => array('type' => 'datetime', 'null' => false, 'default' => '0000-00-00 00:00:00', 'length' => null), - 'modified' => array('type' => 'datetime', 'null' => false, 'default' => '0000-00-00 00:00:00', 'length' => null) - ); - } - return $this->_schema; - } - -} - -/** - * MysqlTestModel class - * - * @package Cake.Test.Case.Model - */ -class MysqlTestModel extends Model { - -/** - * name property - * - * @var string 'MysqlTestModel' - */ - public $name = 'MysqlTestModel'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * find method - * - * @param mixed $conditions - * @param mixed $fields - * @param mixed $order - * @param mixed $recursive - * @return void - */ - public function find($conditions = null, $fields = null, $order = null, $recursive = null) { - return $conditions; - } - -/** - * findAll method - * - * @param mixed $conditions - * @param mixed $fields - * @param mixed $order - * @param mixed $recursive - * @return void - */ - public function findAll($conditions = null, $fields = null, $order = null, $recursive = null) { - return $conditions; - } - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - return array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'client_id' => array('type' => 'integer', 'null' => '', 'default' => '0', 'length' => '11'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'login' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'passwd' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_1' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'addr_2' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '25'), - 'zip_code' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'city' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'country' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'phone' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'fax' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'url' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '1', 'default' => '', 'length' => '155'), - 'comments' => array('type' => 'text', 'null' => '1', 'default' => '', 'length' => ''), - 'last_login' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => ''), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - } - -} - -/** - * Test model for datasource prefixes - * - */ -class PrefixTestModel extends CakeTestModel { -} - -class PrefixTestUseTableModel extends CakeTestModel { - - public $name = 'PrefixTest'; - - public $useTable = 'prefix_tests'; - -} - -/** - * ScaffoldMock class - * - * @package Cake.Test.Case.Controller - */ -class ScaffoldMock extends CakeTestModel { - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'articles'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'User' => array( - 'className' => 'ScaffoldUser', - 'foreignKey' => 'user_id', - ) - ); - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'Comment' => array( - 'className' => 'ScaffoldComment', - 'foreignKey' => 'article_id', - ) - ); - -/** - * hasAndBelongsToMany property - * - * @var string - */ - public $hasAndBelongsToMany = array( - 'ScaffoldTag' => array( - 'className' => 'ScaffoldTag', - 'foreignKey' => 'something_id', - 'associationForeignKey' => 'something_else_id', - 'joinTable' => 'join_things' - ) - ); - -} - -/** - * ScaffoldUser class - * - * @package Cake.Test.Case.Controller - */ -class ScaffoldUser extends CakeTestModel { - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'users'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'Article' => array( - 'className' => 'ScaffoldMock', - 'foreignKey' => 'article_id', - ) - ); -} - -/** - * ScaffoldComment class - * - * @package Cake.Test.Case.Controller - */ -class ScaffoldComment extends CakeTestModel { - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'comments'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'Article' => array( - 'className' => 'ScaffoldMock', - 'foreignKey' => 'article_id', - ) - ); -} - -/** - * ScaffoldTag class - * - * @package Cake.Test.Case.Controller - */ -class ScaffoldTag extends CakeTestModel { - -/** - * useTable property - * - * @var string 'posts' - */ - public $useTable = 'tags'; - -} - -/** - * Player class - * - * @package Cake.Test.Case.Model - */ -class Player extends CakeTestModel { - - public $hasAndBelongsToMany = array( - 'Guild' => array( - 'with' => 'GuildsPlayer', - 'unique' => true, - ), - ); - -} - -/** - * Guild class - * - * @package Cake.Test.Case.Model - */ -class Guild extends CakeTestModel { - - public $hasAndBelongsToMany = array( - 'Player' => array( - 'with' => 'GuildsPlayer', - 'unique' => true, - ), - ); - -} - -/** - * GuildsPlayer class - * - * @package Cake.Test.Case.Model - */ -class GuildsPlayer extends CakeTestModel { - - public $useDbConfig = 'test2'; - - public $belongsTo = array( - 'Player', - 'Guild', - ); -} - -/** - * Armor class - * - * @package Cake.Test.Case.Model - */ -class Armor extends CakeTestModel { - - public $useDbConfig = 'test2'; - - public $hasAndBelongsToMany = array( - 'Player' => array('with' => 'ArmorsPlayer'), - ); -} - -/** - * ArmorsPlayer class - * - * @package Cake.Test.Case.Model - */ -class ArmorsPlayer extends CakeTestModel { - - public $useDbConfig = 'test_database_three'; - -} - -/** - * CustomArticle class - * - * @package Cake.Test.Case.Model - */ -class CustomArticle extends AppModel { - -/** - * useTable property - * - * @var string - */ - public $useTable = 'articles'; - -/** - * findMethods property - * - * @var array - */ - public $findMethods = array('unPublished' => true); - -/** - * _findUnPublished custom find - * - * @return array - */ - protected function _findUnPublished($state, $query, $results = array()) { - if ($state === 'before') { - $query['conditions']['published'] = 'N'; - return $query; - } - return $results; - } - -} diff --git a/lib/Cake/Test/Case/Network/CakeRequestTest.php b/lib/Cake/Test/Case/Network/CakeRequestTest.php deleted file mode 100644 index 922342c4043..00000000000 --- a/lib/Cake/Test/Case/Network/CakeRequestTest.php +++ /dev/null @@ -1,1758 +0,0 @@ -_server = $_SERVER; - $this->_get = $_GET; - $this->_post = $_POST; - $this->_files = $_FILES; - $this->_app = Configure::read('App'); - $this->_case = null; - if (isset($_GET['case'])) { - $this->_case = $_GET['case']; - unset($_GET['case']); - } - - Configure::write('App.baseUrl', false); - } - -/** - * tearDown- - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - $_SERVER = $this->_server; - $_GET = $this->_get; - $_POST = $this->_post; - $_FILES = $this->_files; - if (!empty($this->_case)) { - $_GET['case'] = $this->_case; - } - Configure::write('App', $this->_app); - } - -/** - * test that the autoparse = false constructor works. - * - * @return void - */ - public function testNoAutoParseConstruction() { - $_GET = array( - 'one' => 'param' - ); - $request = new CakeRequest(null, false); - $this->assertFalse(isset($request->query['one'])); - } - -/** - * test construction - * - * @return void - */ - public function testConstructionGetParsing() { - $_GET = array( - 'one' => 'param', - 'two' => 'banana' - ); - $request = new CakeRequest('some/path'); - $this->assertEquals($request->query, $_GET); - - $_GET = array( - 'one' => 'param', - 'two' => 'banana', - ); - $request = new CakeRequest('some/path'); - $this->assertEquals($request->query, $_GET); - $this->assertEquals('some/path', $request->url); - } - -/** - * Test that querystring args provided in the url string are parsed. - * - * @return void - */ - public function testQueryStringParsingFromInputUrl() { - $_GET = array(); - $request = new CakeRequest('some/path?one=something&two=else'); - $expected = array('one' => 'something', 'two' => 'else'); - $this->assertEquals($expected, $request->query); - $this->assertEquals('some/path?one=something&two=else', $request->url); - } - -/** - * Test that named arguments + querystrings are handled correctly. - * - * @return void - */ - public function testQueryStringAndNamedParams() { - $_SERVER['REQUEST_URI'] = '/tasks/index/page:1?ts=123456'; - $request = new CakeRequest(); - $this->assertEquals('tasks/index/page:1', $request->url); - - $_SERVER['REQUEST_URI'] = '/tasks/index/page:1/?ts=123456'; - $request = new CakeRequest(); - $this->assertEquals('tasks/index/page:1/', $request->url); - } - -/** - * test addParams() method - * - * @return void - */ - public function testAddParams() { - $request = new CakeRequest('some/path'); - $request->params = array('controller' => 'posts', 'action' => 'view'); - $result = $request->addParams(array('plugin' => null, 'action' => 'index')); - - $this->assertSame($result, $request, 'Method did not return itself. %s'); - - $this->assertEquals('posts', $request->controller); - $this->assertEquals('index', $request->action); - $this->assertEquals(null, $request->plugin); - } - -/** - * test splicing in paths. - * - * @return void - */ - public function testAddPaths() { - $request = new CakeRequest('some/path'); - $request->webroot = '/some/path/going/here/'; - $result = $request->addPaths(array( - 'random' => '/something', 'webroot' => '/', 'here' => '/', 'base' => '/base_dir' - )); - - $this->assertSame($result, $request, 'Method did not return itself. %s'); - - $this->assertEquals('/', $request->webroot); - $this->assertEquals('/base_dir', $request->base); - $this->assertEquals('/', $request->here); - $this->assertFalse(isset($request->random)); - } - -/** - * test parsing POST data into the object. - * - * @return void - */ - public function testPostParsing() { - $_POST = array('data' => array( - 'Article' => array('title') - )); - $request = new CakeRequest('some/path'); - $this->assertEquals($_POST['data'], $request->data); - - $_POST = array('one' => 1, 'two' => 'three'); - $request = new CakeRequest('some/path'); - $this->assertEquals($_POST, $request->data); - - $_POST = array( - 'data' => array( - 'Article' => array('title' => 'Testing'), - ), - 'action' => 'update' - ); - $request = new CakeRequest('some/path'); - $expected = array( - 'Article' => array('title' => 'Testing'), - 'action' => 'update' - ); - $this->assertEquals($expected, $request->data); - - $_POST = array('data' => array( - 'Article' => array('title'), - 'Tag' => array('Tag' => array(1, 2)) - )); - $request = new CakeRequest('some/path'); - $this->assertEquals($_POST['data'], $request->data); - - $_POST = array('data' => array( - 'Article' => array('title' => 'some title'), - 'Tag' => array('Tag' => array(1, 2)) - )); - $request = new CakeRequest('some/path'); - $this->assertEquals($_POST['data'], $request->data); - - $_POST = array( - 'a' => array(1, 2), - 'b' => array(1, 2) - ); - $request = new CakeRequest('some/path'); - $this->assertEquals($_POST, $request->data); - } - -/** - * test parsing of FILES array - * - * @return void - */ - public function testFILESParsing() { - $_FILES = array('data' => array('name' => array( - 'File' => array( - array('data' => 'cake_sqlserver_patch.patch'), - array('data' => 'controller.diff'), - array('data' => ''), - array('data' => ''), - ), - 'Post' => array('attachment' => 'jquery-1.2.1.js'), - ), - 'type' => array( - 'File' => array( - array('data' => ''), - array('data' => ''), - array('data' => ''), - array('data' => ''), - ), - 'Post' => array('attachment' => 'application/x-javascript'), - ), - 'tmp_name' => array( - 'File' => array( - array('data' => '/private/var/tmp/phpy05Ywj'), - array('data' => '/private/var/tmp/php7MBztY'), - array('data' => ''), - array('data' => ''), - ), - 'Post' => array('attachment' => '/private/var/tmp/phpEwlrIo'), - ), - 'error' => array( - 'File' => array( - array('data' => 0), - array('data' => 0), - array('data' => 4), - array('data' => 4) - ), - 'Post' => array('attachment' => 0) - ), - 'size' => array( - 'File' => array( - array('data' => 6271), - array('data' => 350), - array('data' => 0), - array('data' => 0), - ), - 'Post' => array('attachment' => 80469) - ), - )); - - $request = new CakeRequest('some/path'); - $expected = array( - 'File' => array( - array('data' => array( - 'name' => 'cake_sqlserver_patch.patch', - 'type' => '', - 'tmp_name' => '/private/var/tmp/phpy05Ywj', - 'error' => 0, - 'size' => 6271, - )), - array( - 'data' => array( - 'name' => 'controller.diff', - 'type' => '', - 'tmp_name' => '/private/var/tmp/php7MBztY', - 'error' => 0, - 'size' => 350, - )), - array('data' => array( - 'name' => '', - 'type' => '', - 'tmp_name' => '', - 'error' => 4, - 'size' => 0, - )), - array('data' => array( - 'name' => '', - 'type' => '', - 'tmp_name' => '', - 'error' => 4, - 'size' => 0, - )), - ), - 'Post' => array('attachment' => array( - 'name' => 'jquery-1.2.1.js', - 'type' => 'application/x-javascript', - 'tmp_name' => '/private/var/tmp/phpEwlrIo', - 'error' => 0, - 'size' => 80469, - )) - ); - $this->assertEquals($expected, $request->data); - - $_FILES = array( - 'data' => array( - 'name' => array( - 'Document' => array( - 1 => array( - 'birth_cert' => 'born on.txt', - 'passport' => 'passport.txt', - 'drivers_license' => 'ugly pic.jpg' - ), - 2 => array( - 'birth_cert' => 'aunt betty.txt', - 'passport' => 'betty-passport.txt', - 'drivers_license' => 'betty-photo.jpg' - ), - ), - ), - 'type' => array( - 'Document' => array( - 1 => array( - 'birth_cert' => 'application/octet-stream', - 'passport' => 'application/octet-stream', - 'drivers_license' => 'application/octet-stream', - ), - 2 => array( - 'birth_cert' => 'application/octet-stream', - 'passport' => 'application/octet-stream', - 'drivers_license' => 'application/octet-stream', - ) - ) - ), - 'tmp_name' => array( - 'Document' => array( - 1 => array( - 'birth_cert' => '/private/var/tmp/phpbsUWfH', - 'passport' => '/private/var/tmp/php7f5zLt', - 'drivers_license' => '/private/var/tmp/phpMXpZgT', - ), - 2 => array( - 'birth_cert' => '/private/var/tmp/php5kHZt0', - 'passport' => '/private/var/tmp/phpnYkOuM', - 'drivers_license' => '/private/var/tmp/php9Rq0P3', - ) - ) - ), - 'error' => array( - 'Document' => array( - 1 => array( - 'birth_cert' => 0, - 'passport' => 0, - 'drivers_license' => 0, - ), - 2 => array( - 'birth_cert' => 0, - 'passport' => 0, - 'drivers_license' => 0, - ) - ) - ), - 'size' => array( - 'Document' => array( - 1 => array( - 'birth_cert' => 123, - 'passport' => 458, - 'drivers_license' => 875, - ), - 2 => array( - 'birth_cert' => 876, - 'passport' => 976, - 'drivers_license' => 9783, - ) - ) - ) - ) - ); - - $request = new CakeRequest('some/path'); - $expected = array( - 'Document' => array( - 1 => array( - 'birth_cert' => array( - 'name' => 'born on.txt', - 'tmp_name' => '/private/var/tmp/phpbsUWfH', - 'error' => 0, - 'size' => 123, - 'type' => 'application/octet-stream', - ), - 'passport' => array( - 'name' => 'passport.txt', - 'tmp_name' => '/private/var/tmp/php7f5zLt', - 'error' => 0, - 'size' => 458, - 'type' => 'application/octet-stream', - ), - 'drivers_license' => array( - 'name' => 'ugly pic.jpg', - 'tmp_name' => '/private/var/tmp/phpMXpZgT', - 'error' => 0, - 'size' => 875, - 'type' => 'application/octet-stream', - ), - ), - 2 => array( - 'birth_cert' => array( - 'name' => 'aunt betty.txt', - 'tmp_name' => '/private/var/tmp/php5kHZt0', - 'error' => 0, - 'size' => 876, - 'type' => 'application/octet-stream', - ), - 'passport' => array( - 'name' => 'betty-passport.txt', - 'tmp_name' => '/private/var/tmp/phpnYkOuM', - 'error' => 0, - 'size' => 976, - 'type' => 'application/octet-stream', - ), - 'drivers_license' => array( - 'name' => 'betty-photo.jpg', - 'tmp_name' => '/private/var/tmp/php9Rq0P3', - 'error' => 0, - 'size' => 9783, - 'type' => 'application/octet-stream', - ), - ), - ) - ); - $this->assertEquals($expected, $request->data); - - $_FILES = array( - 'data' => array( - 'name' => array('birth_cert' => 'born on.txt'), - 'type' => array('birth_cert' => 'application/octet-stream'), - 'tmp_name' => array('birth_cert' => '/private/var/tmp/phpbsUWfH'), - 'error' => array('birth_cert' => 0), - 'size' => array('birth_cert' => 123) - ) - ); - - $request = new CakeRequest('some/path'); - $expected = array( - 'birth_cert' => array( - 'name' => 'born on.txt', - 'type' => 'application/octet-stream', - 'tmp_name' => '/private/var/tmp/phpbsUWfH', - 'error' => 0, - 'size' => 123 - ) - ); - $this->assertEquals($expected, $request->data); - - $_FILES = array( - 'something' => array( - 'name' => 'something.txt', - 'type' => 'text/plain', - 'tmp_name' => '/some/file', - 'error' => 0, - 'size' => 123 - ) - ); - $request = new CakeRequest('some/path'); - $this->assertEquals($request->params['form'], $_FILES); - } - -/** - * test method overrides coming in from POST data. - * - * @return void - */ - public function testMethodOverrides() { - $_POST = array('_method' => 'POST'); - $request = new CakeRequest('some/path'); - $this->assertEquals(env('REQUEST_METHOD'), 'POST'); - - $_POST = array('_method' => 'DELETE'); - $request = new CakeRequest('some/path'); - $this->assertEquals(env('REQUEST_METHOD'), 'DELETE'); - - $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'PUT'; - $request = new CakeRequest('some/path'); - $this->assertEquals(env('REQUEST_METHOD'), 'PUT'); - } - -/** - * test the clientIp method. - * - * @return void - */ - public function testclientIp() { - $_SERVER['HTTP_X_FORWARDED_FOR'] = '192.168.1.5, 10.0.1.1, proxy.com'; - $_SERVER['HTTP_CLIENT_IP'] = '192.168.1.2'; - $_SERVER['REMOTE_ADDR'] = '192.168.1.3'; - $request = new CakeRequest('some/path'); - $this->assertEquals('192.168.1.5', $request->clientIp(false)); - $this->assertEquals('192.168.1.2', $request->clientIp()); - - unset($_SERVER['HTTP_X_FORWARDED_FOR']); - $this->assertEquals('192.168.1.2', $request->clientIp()); - - unset($_SERVER['HTTP_CLIENT_IP']); - $this->assertEquals('192.168.1.3', $request->clientIp()); - - $_SERVER['HTTP_CLIENTADDRESS'] = '10.0.1.2, 10.0.1.1'; - $this->assertEquals('10.0.1.2', $request->clientIp()); - } - -/** - * test the referer function. - * - * @return void - */ - public function testReferer() { - $request = new CakeRequest('some/path'); - $request->webroot = '/'; - - $_SERVER['HTTP_REFERER'] = 'http://cakephp.org'; - $result = $request->referer(); - $this->assertSame($result, 'http://cakephp.org'); - - $_SERVER['HTTP_REFERER'] = ''; - $result = $request->referer(); - $this->assertSame($result, '/'); - - $_SERVER['HTTP_REFERER'] = FULL_BASE_URL . '/some/path'; - $result = $request->referer(true); - $this->assertSame($result, '/some/path'); - - $_SERVER['HTTP_REFERER'] = FULL_BASE_URL . '/some/path'; - $result = $request->referer(false); - $this->assertSame($result, FULL_BASE_URL . '/some/path'); - - $_SERVER['HTTP_REFERER'] = FULL_BASE_URL . '/some/path'; - $result = $request->referer(true); - $this->assertSame($result, '/some/path'); - - $_SERVER['HTTP_REFERER'] = FULL_BASE_URL . '/recipes/add'; - $result = $request->referer(true); - $this->assertSame($result, '/recipes/add'); - - $_SERVER['HTTP_X_FORWARDED_HOST'] = 'cakephp.org'; - $result = $request->referer(); - $this->assertSame($result, 'cakephp.org'); - } - -/** - * test the simple uses of is() - * - * @return void - */ - public function testIsHttpMethods() { - $request = new CakeRequest('some/path'); - - $this->assertFalse($request->is('undefined-behavior')); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $this->assertTrue($request->is('get')); - - $_SERVER['REQUEST_METHOD'] = 'POST'; - $this->assertTrue($request->is('POST')); - - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $this->assertTrue($request->is('put')); - $this->assertFalse($request->is('get')); - - $_SERVER['REQUEST_METHOD'] = 'DELETE'; - $this->assertTrue($request->is('delete')); - $this->assertTrue($request->isDelete()); - - $_SERVER['REQUEST_METHOD'] = 'delete'; - $this->assertFalse($request->is('delete')); - } - -/** - * test the method() method. - * - * @return void - */ - public function testMethod() { - $_SERVER['REQUEST_METHOD'] = 'delete'; - $request = new CakeRequest('some/path'); - - $this->assertEquals('delete', $request->method()); - } - -/** - * test host retrieval. - * - * @return void - */ - public function testHost() { - $_SERVER['HTTP_HOST'] = 'localhost'; - $request = new CakeRequest('some/path'); - - $this->assertEquals('localhost', $request->host()); - } - -/** - * test domain retrieval. - * - * @return void - */ - public function testDomain() { - $_SERVER['HTTP_HOST'] = 'something.example.com'; - $request = new CakeRequest('some/path'); - - $this->assertEquals('example.com', $request->domain()); - - $_SERVER['HTTP_HOST'] = 'something.example.co.uk'; - $this->assertEquals('example.co.uk', $request->domain(2)); - } - -/** - * test getting subdomains for a host. - * - * @return void - */ - public function testSubdomain() { - $_SERVER['HTTP_HOST'] = 'something.example.com'; - $request = new CakeRequest('some/path'); - - $this->assertEquals(array('something'), $request->subdomains()); - - $_SERVER['HTTP_HOST'] = 'www.something.example.com'; - $this->assertEquals(array('www', 'something'), $request->subdomains()); - - $_SERVER['HTTP_HOST'] = 'www.something.example.co.uk'; - $this->assertEquals(array('www', 'something'), $request->subdomains(2)); - - $_SERVER['HTTP_HOST'] = 'example.co.uk'; - $this->assertEquals(array(), $request->subdomains(2)); - } - -/** - * test ajax, flash and friends - * - * @return void - */ - public function testisAjaxFlashAndFriends() { - $request = new CakeRequest('some/path'); - - $_SERVER['HTTP_USER_AGENT'] = 'Shockwave Flash'; - $this->assertTrue($request->is('flash')); - - $_SERVER['HTTP_USER_AGENT'] = 'Adobe Flash'; - $this->assertTrue($request->is('flash')); - - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; - $this->assertTrue($request->is('ajax')); - - $_SERVER['HTTP_X_REQUESTED_WITH'] = 'XMLHTTPREQUEST'; - $this->assertFalse($request->is('ajax')); - $this->assertFalse($request->isAjax()); - - $_SERVER['HTTP_USER_AGENT'] = 'Android 2.0'; - $this->assertTrue($request->is('mobile')); - $this->assertTrue($request->isMobile()); - - $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Windows NT 5.1; rv:2.0b6pre) Gecko/20100902 Firefox/4.0b6pre Fennec/2.0b1pre'; - $this->assertTrue($request->is('mobile')); - $this->assertTrue($request->isMobile()); - - $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; SAMSUNG; OMNIA7)'; - $this->assertTrue($request->is('mobile')); - $this->assertTrue($request->isMobile()); - } - -/** - * test __call expcetions - * - * @expectedException CakeException - * @return void - */ - public function testMagicCallExceptionOnUnknownMethod() { - $request = new CakeRequest('some/path'); - $request->IamABanana(); - } - -/** - * test is(ssl) - * - * @return void - */ - public function testIsSsl() { - $request = new CakeRequest('some/path'); - - $_SERVER['HTTPS'] = 1; - $this->assertTrue($request->is('ssl')); - - $_SERVER['HTTPS'] = 'on'; - $this->assertTrue($request->is('ssl')); - - $_SERVER['HTTPS'] = '1'; - $this->assertTrue($request->is('ssl')); - - $_SERVER['HTTPS'] = 'I am not empty'; - $this->assertTrue($request->is('ssl')); - - $_SERVER['HTTPS'] = 1; - $this->assertTrue($request->is('ssl')); - - $_SERVER['HTTPS'] = 'off'; - $this->assertFalse($request->is('ssl')); - - $_SERVER['HTTPS'] = false; - $this->assertFalse($request->is('ssl')); - - $_SERVER['HTTPS'] = ''; - $this->assertFalse($request->is('ssl')); - } - -/** - * test getting request params with object properties. - * - * @return void - */ - public function testMagicget() { - $request = new CakeRequest('some/path'); - $request->params = array('controller' => 'posts', 'action' => 'view', 'plugin' => 'blogs'); - - $this->assertEquals('posts', $request->controller); - $this->assertEquals('view', $request->action); - $this->assertEquals('blogs', $request->plugin); - $this->assertSame($request->banana, null); - } - -/** - * Test isset()/empty() with overloaded properties. - * - * @return void - */ - public function testMagicisset() { - $request = new CakeRequest('some/path'); - $request->params = array( - 'controller' => 'posts', - 'action' => 'view', - 'plugin' => 'blogs', - 'named' => array() - ); - - $this->assertTrue(isset($request->controller)); - $this->assertFalse(isset($request->notthere)); - $this->assertFalse(empty($request->controller)); - $this->assertTrue(empty($request->named)); - } - -/** - * test the array access implementation - * - * @return void - */ - public function testArrayAccess() { - $request = new CakeRequest('some/path'); - $request->params = array('controller' => 'posts', 'action' => 'view', 'plugin' => 'blogs'); - - $this->assertEquals('posts', $request['controller']); - - $request['slug'] = 'speedy-slug'; - $this->assertEquals('speedy-slug', $request->slug); - $this->assertEquals('speedy-slug', $request['slug']); - - $this->assertTrue(isset($request['action'])); - $this->assertFalse(isset($request['wrong-param'])); - - $this->assertTrue(isset($request['plugin'])); - unset($request['plugin']); - $this->assertFalse(isset($request['plugin'])); - $this->assertNull($request['plugin']); - $this->assertNull($request->plugin); - - $request = new CakeRequest('some/path?one=something&two=else'); - $this->assertTrue(isset($request['url']['one'])); - - $request->data = array('Post' => array('title' => 'something')); - $this->assertEquals('something', $request['data']['Post']['title']); - } - -/** - * test adding detectors and having them work. - * - * @return void - */ - public function testAddDetector() { - $request = new CakeRequest('some/path'); - $request->addDetector('compare', array('env' => 'TEST_VAR', 'value' => 'something')); - - $_SERVER['TEST_VAR'] = 'something'; - $this->assertTrue($request->is('compare'), 'Value match failed.'); - - $_SERVER['TEST_VAR'] = 'wrong'; - $this->assertFalse($request->is('compare'), 'Value mis-match failed.'); - - $request->addDetector('compareCamelCase', array('env' => 'TEST_VAR', 'value' => 'foo')); - - $_SERVER['TEST_VAR'] = 'foo'; - $this->assertTrue($request->is('compareCamelCase'), 'Value match failed.'); - - $request->addDetector('banana', array('env' => 'TEST_VAR', 'pattern' => '/^ban.*$/')); - $_SERVER['TEST_VAR'] = 'banana'; - $this->assertTrue($request->isBanana()); - - $_SERVER['TEST_VAR'] = 'wrong value'; - $this->assertFalse($request->isBanana()); - - $request->addDetector('mobile', array('options' => array('Imagination'))); - $_SERVER['HTTP_USER_AGENT'] = 'Imagination land'; - $this->assertTrue($request->isMobile()); - - $_SERVER['HTTP_USER_AGENT'] = 'iPhone 3.0'; - $this->assertTrue($request->isMobile()); - - $request->addDetector('callme', array('env' => 'TEST_VAR', 'callback' => array($this, 'detectCallback'))); - - $request->addDetector('index', array('param' => 'action', 'value' => 'index')); - $request->params['action'] = 'index'; - $this->assertTrue($request->isIndex()); - - $request->params['action'] = 'add'; - $this->assertFalse($request->isIndex()); - - $request->return = true; - $this->assertTrue($request->isCallMe()); - - $request->return = false; - $this->assertFalse($request->isCallMe()); - } - -/** - * helper function for testing callbacks. - * - * @return void - */ - public function detectCallback($request) { - return $request->return == true; - } - -/** - * test getting headers - * - * @return void - */ - public function testHeader() { - $_SERVER['HTTP_HOST'] = 'localhost'; - $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_4; en-ca) AppleWebKit/534.8+ (KHTML, like Gecko) Version/5.0 Safari/533.16'; - $request = new CakeRequest('/', false); - - $this->assertEquals($_SERVER['HTTP_HOST'], $request->header('host')); - $this->assertEquals($_SERVER['HTTP_USER_AGENT'], $request->header('User-Agent')); - } - -/** - * test accepts() with and without parameters - * - * @return void - */ - public function testAccepts() { - $_SERVER['HTTP_ACCEPT'] = 'text/xml,application/xml;q=0.9,application/xhtml+xml,text/html,text/plain,image/png'; - $request = new CakeRequest('/', false); - - $result = $request->accepts(); - $expected = array( - 'text/xml', 'application/xhtml+xml', 'text/html', 'text/plain', 'image/png', 'application/xml' - ); - $this->assertEquals($expected, $result, 'Content types differ.'); - - $result = $request->accepts('text/html'); - $this->assertTrue($result); - - $result = $request->accepts('image/gif'); - $this->assertFalse($result); - } - -/** - * Test that accept header types are trimmed for comparisons. - * - * @return void - */ - public function testAcceptWithWhitespace() { - $_SERVER['HTTP_ACCEPT'] = 'text/xml , text/html , text/plain,image/png'; - $request = new CakeRequest('/', false); - $result = $request->accepts(); - $expected = array( - 'text/xml', 'text/html', 'text/plain', 'image/png' - ); - $this->assertEquals($expected, $result, 'Content types differ.'); - - $this->assertTrue($request->accepts('text/html')); - } - -/** - * Content types from accepts() should respect the client's q preference values. - * - * @return void - */ - public function testAcceptWithQvalueSorting() { - $_SERVER['HTTP_ACCEPT'] = 'text/html;q=0.8,application/json;q=0.7,application/xml;q=1.0'; - $request = new CakeRequest('/', false); - $result = $request->accepts(); - $expected = array('application/xml', 'text/html', 'application/json'); - $this->assertEquals($expected, $result); - } - -/** - * Test the raw parsing of accept headers into the q value formatting. - * - * @return void - */ - public function testParseAcceptWithQValue() { - $_SERVER['HTTP_ACCEPT'] = 'text/html;q=0.8,application/json;q=0.7,application/xml;q=1.0,image/png'; - $request = new CakeRequest('/', false); - $result = $request->parseAccept(); - $expected = array( - '1.0' => array('application/xml', 'image/png'), - '0.8' => array('text/html'), - '0.7' => array('application/json'), - ); - $this->assertEquals($expected, $result); - } - -/** - * testBaseUrlAndWebrootWithModRewrite method - * - * @return void - */ - public function testBaseUrlAndWebrootWithModRewrite() { - Configure::write('App.baseUrl', false); - - $_SERVER['DOCUMENT_ROOT'] = '/cake/repo/branches'; - $_SERVER['PHP_SELF'] = '/1.2.x.x/app/webroot/index.php'; - $_SERVER['PATH_INFO'] = '/posts/view/1'; - - $request = new CakeRequest(); - $this->assertEquals('/1.2.x.x', $request->base); - $this->assertEquals('/1.2.x.x/', $request->webroot); - $this->assertEquals('posts/view/1', $request->url); - - $_SERVER['DOCUMENT_ROOT'] = '/cake/repo/branches/1.2.x.x/app/webroot'; - $_SERVER['PHP_SELF'] = '/index.php'; - $_SERVER['PATH_INFO'] = '/posts/add'; - $request = new CakeRequest(); - - $this->assertEquals('', $request->base); - $this->assertEquals('/', $request->webroot); - $this->assertEquals('posts/add', $request->url); - - $_SERVER['DOCUMENT_ROOT'] = '/cake/repo/branches/1.2.x.x/test/'; - $_SERVER['PHP_SELF'] = '/webroot/index.php'; - $request = new CakeRequest(); - - $this->assertEquals('', $request->base); - $this->assertEquals('/', $request->webroot); - - $_SERVER['DOCUMENT_ROOT'] = '/some/apps/where'; - $_SERVER['PHP_SELF'] = '/app/webroot/index.php'; - $request = new CakeRequest(); - - $this->assertEquals('', $request->base); - $this->assertEquals('/', $request->webroot); - - Configure::write('App.dir', 'auth'); - - $_SERVER['DOCUMENT_ROOT'] = '/cake/repo/branches'; - $_SERVER['PHP_SELF'] = '/demos/auth/webroot/index.php'; - - $request = new CakeRequest(); - - $this->assertEquals('/demos/auth', $request->base); - $this->assertEquals('/demos/auth/', $request->webroot); - - Configure::write('App.dir', 'code'); - - $_SERVER['DOCUMENT_ROOT'] = '/Library/WebServer/Documents'; - $_SERVER['PHP_SELF'] = '/clients/PewterReport/code/webroot/index.php'; - $request = new CakeRequest(); - - $this->assertEquals('/clients/PewterReport/code', $request->base); - $this->assertEquals('/clients/PewterReport/code/', $request->webroot); - } - -/** - * testBaseUrlwithModRewriteAlias method - * - * @return void - */ - public function testBaseUrlwithModRewriteAlias() { - $_SERVER['DOCUMENT_ROOT'] = '/home/aplusnur/public_html'; - $_SERVER['PHP_SELF'] = '/control/index.php'; - - Configure::write('App.base', '/control'); - - $request = new CakeRequest(); - - $this->assertEquals('/control', $request->base); - $this->assertEquals('/control/', $request->webroot); - - Configure::write('App.base', false); - Configure::write('App.dir', 'affiliate'); - Configure::write('App.webroot', 'newaffiliate'); - - $_SERVER['DOCUMENT_ROOT'] = '/var/www/abtravaff/html'; - $_SERVER['PHP_SELF'] = '/newaffiliate/index.php'; - $request = new CakeRequest(); - - $this->assertEquals('/newaffiliate', $request->base); - $this->assertEquals('/newaffiliate/', $request->webroot); - } - -/** - * test base, webroot, and url parsing when there is no url rewriting - * - * @return void - */ - public function testBaseUrlWithNoModRewrite() { - $_SERVER['DOCUMENT_ROOT'] = '/Users/markstory/Sites'; - $_SERVER['SCRIPT_FILENAME'] = '/Users/markstory/Sites/cake/index.php'; - $_SERVER['PHP_SELF'] = '/cake/index.php/posts/index'; - $_SERVER['REQUEST_URI'] = '/cake/index.php/posts/index'; - - Configure::write('App', array( - 'dir' => APP_DIR, - 'webroot' => WEBROOT_DIR, - 'base' => false, - 'baseUrl' => '/cake/index.php' - )); - - $request = new CakeRequest(); - $this->assertEquals('/cake/index.php', $request->base); - $this->assertEquals('/cake/app/webroot/', $request->webroot); - $this->assertEquals('posts/index', $request->url); - } - -/** - * testBaseUrlAndWebrootWithBaseUrl method - * - * @return void - */ - public function testBaseUrlAndWebrootWithBaseUrl() { - Configure::write('App.dir', 'app'); - Configure::write('App.baseUrl', '/app/webroot/index.php'); - - $request = new CakeRequest(); - $this->assertEquals('/app/webroot/index.php', $request->base); - $this->assertEquals('/app/webroot/', $request->webroot); - - Configure::write('App.baseUrl', '/app/webroot/test.php'); - $request = new CakeRequest(); - $this->assertEquals('/app/webroot/test.php', $request->base); - $this->assertEquals('/app/webroot/', $request->webroot); - - Configure::write('App.baseUrl', '/app/index.php'); - $request = new CakeRequest(); - $this->assertEquals('/app/index.php', $request->base); - $this->assertEquals('/app/webroot/', $request->webroot); - - Configure::write('App.baseUrl', '/CakeBB/app/webroot/index.php'); - $request = new CakeRequest(); - $this->assertEquals('/CakeBB/app/webroot/index.php', $request->base); - $this->assertEquals('/CakeBB/app/webroot/', $request->webroot); - - Configure::write('App.baseUrl', '/CakeBB/app/index.php'); - $request = new CakeRequest(); - - $this->assertEquals('/CakeBB/app/index.php', $request->base); - $this->assertEquals('/CakeBB/app/webroot/', $request->webroot); - - Configure::write('App.baseUrl', '/CakeBB/index.php'); - $request = new CakeRequest(); - - $this->assertEquals('/CakeBB/index.php', $request->base); - $this->assertEquals('/CakeBB/app/webroot/', $request->webroot); - - Configure::write('App.baseUrl', '/dbhauser/index.php'); - $_SERVER['DOCUMENT_ROOT'] = '/kunden/homepages/4/d181710652/htdocs/joomla'; - $_SERVER['SCRIPT_FILENAME'] = '/kunden/homepages/4/d181710652/htdocs/joomla/dbhauser/index.php'; - $request = new CakeRequest(); - - $this->assertEquals('/dbhauser/index.php', $request->base); - $this->assertEquals('/dbhauser/app/webroot/', $request->webroot); - } - -/** - * test baseUrl with no rewrite and using the top level index.php. - * - * @return void - */ - public function testBaseUrlNoRewriteTopLevelIndex() { - Configure::write('App.baseUrl', '/index.php'); - $_SERVER['DOCUMENT_ROOT'] = '/Users/markstory/Sites/cake_dev'; - $_SERVER['SCRIPT_FILENAME'] = '/Users/markstory/Sites/cake_dev/index.php'; - - $request = new CakeRequest(); - $this->assertEquals('/index.php', $request->base); - $this->assertEquals('/app/webroot/', $request->webroot); - } - -/** - * Check that a sub-directory containing app|webroot doesn't get mishandled when re-writing is off. - * - * @return void - */ - public function testBaseUrlWithAppAndWebrootInDirname() { - Configure::write('App.baseUrl', '/approval/index.php'); - $_SERVER['DOCUMENT_ROOT'] = '/Users/markstory/Sites/'; - $_SERVER['SCRIPT_FILENAME'] = '/Users/markstory/Sites/approval/index.php'; - - $request = new CakeRequest(); - $this->assertEquals('/approval/index.php', $request->base); - $this->assertEquals('/approval/app/webroot/', $request->webroot); - - Configure::write('App.baseUrl', '/webrootable/index.php'); - $_SERVER['DOCUMENT_ROOT'] = '/Users/markstory/Sites/'; - $_SERVER['SCRIPT_FILENAME'] = '/Users/markstory/Sites/webrootable/index.php'; - - $request = new CakeRequest(); - $this->assertEquals('/webrootable/index.php', $request->base); - $this->assertEquals('/webrootable/app/webroot/', $request->webroot); - } - -/** - * test baseUrl with no rewrite, and using the app/webroot/index.php file as is normal with virtual hosts. - * - * @return void - */ - public function testBaseUrlNoRewriteWebrootIndex() { - Configure::write('App.baseUrl', '/index.php'); - $_SERVER['DOCUMENT_ROOT'] = '/Users/markstory/Sites/cake_dev/app/webroot'; - $_SERVER['SCRIPT_FILENAME'] = '/Users/markstory/Sites/cake_dev/app/webroot/index.php'; - - $request = new CakeRequest(); - $this->assertEquals('/index.php', $request->base); - $this->assertEquals('/', $request->webroot); - } - -/** - * Test that a request with a . in the main GET parameter is filtered out. - * PHP changes GET parameter keys containing dots to _. - * - * @return void - */ - public function testGetParamsWithDot() { - $_GET = array(); - $_GET['/posts/index/add_add'] = ''; - $_SERVER['PHP_SELF'] = '/cake_dev/app/webroot/index.php'; - $_SERVER['REQUEST_URI'] = '/cake_dev/posts/index/add.add'; - - $request = new CakeRequest(); - $this->assertEquals(array(), $request->query); - } - -/** - * Test that a request with urlencoded bits in the main GET parameter are filtered out. - * - * @return void - */ - public function testGetParamWithUrlencodedElement() { - $_GET = array(); - $_GET['/posts/add/∂∂'] = ''; - $_SERVER['PHP_SELF'] = '/cake_dev/app/webroot/index.php'; - $_SERVER['REQUEST_URI'] = '/cake_dev/posts/add/%E2%88%82%E2%88%82'; - - $request = new CakeRequest(); - $this->assertEquals(array(), $request->query); - } - -/** - * generator for environment configurations - * - * @return void - */ - public static function environmentGenerator() { - return array( - array( - 'IIS - No rewrite base path', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/index.php', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'SCRIPT_NAME' => '/index.php', - 'PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot', - 'QUERY_STRING' => '', - 'REQUEST_URI' => '/index.php', - 'URL' => '/index.php', - 'SCRIPT_FILENAME' => 'C:\\Inetpub\\wwwroot\\index.php', - 'ORIG_PATH_INFO' => '/index.php', - 'PATH_INFO' => '', - 'ORIG_PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot\\index.php', - 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', - 'PHP_SELF' => '/index.php', - ), - ), - array( - 'base' => '/index.php', - 'webroot' => '/app/webroot/', - 'url' => '' - ), - ), - array( - 'IIS - No rewrite with path, no PHP_SELF', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/index.php?', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'QUERY_STRING' => '/posts/add', - 'REQUEST_URI' => '/index.php?/posts/add', - 'PHP_SELF' => '', - 'URL' => '/index.php?/posts/add', - 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', - 'argv' => array('/posts/add'), - 'argc' => 1 - ), - ), - array( - 'url' => 'posts/add', - 'base' => '/index.php?', - 'webroot' => '/app/webroot/' - ) - ), - array( - 'IIS - No rewrite sub dir 2', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/site/index.php', - 'dir' => 'app', - 'webroot' => 'webroot', - ), - 'SERVER' => array( - 'SCRIPT_NAME' => '/site/index.php', - 'PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot', - 'QUERY_STRING' => '', - 'REQUEST_URI' => '/site/index.php', - 'URL' => '/site/index.php', - 'SCRIPT_FILENAME' => 'C:\\Inetpub\\wwwroot\\site\\index.php', - 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', - 'PHP_SELF' => '/site/index.php', - 'argv' => array(), - 'argc' => 0 - ), - ), - array( - 'url' => '', - 'base' => '/site/index.php', - 'webroot' => '/site/app/webroot/' - ), - ), - array( - 'IIS - No rewrite sub dir 2 with path', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/site/index.php', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'GET' => array('/posts/add' => ''), - 'SERVER' => array( - 'SCRIPT_NAME' => '/site/index.php', - 'PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot', - 'QUERY_STRING' => '/posts/add', - 'REQUEST_URI' => '/site/index.php/posts/add', - 'URL' => '/site/index.php/posts/add', - 'ORIG_PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot\\site\\index.php', - 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', - 'PHP_SELF' => '/site/index.php/posts/add', - 'argv' => array('/posts/add'), - 'argc' => 1 - ), - ), - array( - 'url' => 'posts/add', - 'base' => '/site/index.php', - 'webroot' => '/site/app/webroot/' - ) - ), - array( - 'Apache - No rewrite, document root set to webroot, requesting path', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/index.php', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/app/webroot', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/app/webroot/index.php', - 'QUERY_STRING' => '', - 'REQUEST_URI' => '/index.php/posts/index', - 'SCRIPT_NAME' => '/index.php', - 'PATH_INFO' => '/posts/index', - 'PHP_SELF' => '/index.php/posts/index', - ), - ), - array( - 'url' => 'posts/index', - 'base' => '/index.php', - 'webroot' => '/' - ), - ), - array( - 'Apache - No rewrite, document root set to webroot, requesting root', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/index.php', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/app/webroot', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/app/webroot/index.php', - 'QUERY_STRING' => '', - 'REQUEST_URI' => '/index.php', - 'SCRIPT_NAME' => '/index.php', - 'PATH_INFO' => '', - 'PHP_SELF' => '/index.php', - ), - ), - array( - 'url' => '', - 'base' => '/index.php', - 'webroot' => '/' - ), - ), - array( - 'Apache - No rewrite, document root set above top level cake dir, requesting path', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/site/index.php', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'SERVER_NAME' => 'localhost', - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', - 'REQUEST_URI' => '/site/index.php/posts/index', - 'SCRIPT_NAME' => '/site/index.php', - 'PATH_INFO' => '/posts/index', - 'PHP_SELF' => '/site/index.php/posts/index', - ), - ), - array( - 'url' => 'posts/index', - 'base' => '/site/index.php', - 'webroot' => '/site/app/webroot/', - ), - ), - array( - 'Apache - No rewrite, document root set above top level cake dir, request root, no PATH_INFO', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/site/index.php', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'SERVER_NAME' => 'localhost', - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', - 'REQUEST_URI' => '/site/index.php/', - 'SCRIPT_NAME' => '/site/index.php', - 'PHP_SELF' => '/site/index.php/', - ), - ), - array( - 'url' => '', - 'base' => '/site/index.php', - 'webroot' => '/site/app/webroot/', - ), - ), - array( - 'Apache - No rewrite, document root set above top level cake dir, request path, with GET', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => '/site/index.php', - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'GET' => array('a' => 'b', 'c' => 'd'), - 'SERVER' => array( - 'SERVER_NAME' => 'localhost', - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', - 'REQUEST_URI' => '/site/index.php/posts/index?a=b&c=d', - 'SCRIPT_NAME' => '/site/index.php', - 'PATH_INFO' => '/posts/index', - 'PHP_SELF' => '/site/index.php/posts/index', - 'QUERY_STRING' => 'a=b&c=d' - ), - ), - array( - 'urlParams' => array('a' => 'b', 'c' => 'd'), - 'url' => 'posts/index', - 'base' => '/site/index.php', - 'webroot' => '/site/app/webroot/', - ), - ), - array( - 'Apache - w/rewrite, document root set above top level cake dir, request root, no PATH_INFO', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => false, - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'SERVER_NAME' => 'localhost', - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', - 'REQUEST_URI' => '/site/', - 'SCRIPT_NAME' => '/site/app/webroot/index.php', - 'PHP_SELF' => '/site/app/webroot/index.php', - ), - ), - array( - 'url' => '', - 'base' => '/site', - 'webroot' => '/site/', - ), - ), - array( - 'Apache - w/rewrite, document root above top level cake dir, request root, no PATH_INFO/REQUEST_URI', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => false, - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'SERVER_NAME' => 'localhost', - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', - 'SCRIPT_NAME' => '/site/app/webroot/index.php', - 'PHP_SELF' => '/site/app/webroot/index.php', - 'PATH_INFO' => null, - 'REQUEST_URI' => null, - ), - ), - array( - 'url' => '', - 'base' => '/site', - 'webroot' => '/site/', - ), - ), - array( - 'Apache - w/rewrite, document root set to webroot, request root, no PATH_INFO/REQUEST_URI', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => false, - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'SERVER' => array( - 'SERVER_NAME' => 'localhost', - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/app/webroot', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/app/webroot/index.php', - 'SCRIPT_NAME' => '/index.php', - 'PHP_SELF' => '/index.php', - 'PATH_INFO' => null, - 'REQUEST_URI' => null, - ), - ), - array( - 'url' => '', - 'base' => '', - 'webroot' => '/', - ), - ), - array( - 'Nginx - w/rewrite, document root set to webroot, request root, no PATH_INFO', - array( - 'App' => array( - 'base' => false, - 'baseUrl' => false, - 'dir' => 'app', - 'webroot' => 'webroot' - ), - 'GET' => array('/posts/add' => ''), - 'SERVER' => array( - 'SERVER_NAME' => 'localhost', - 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/app/webroot', - 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/app/webroot/index.php', - 'SCRIPT_NAME' => '/index.php', - 'QUERY_STRING' => '/posts/add&', - 'PHP_SELF' => '/index.php', - 'PATH_INFO' => null, - 'REQUEST_URI' => '/posts/add', - ), - ), - array( - 'url' => 'posts/add', - 'base' => '', - 'webroot' => '/', - 'urlParams' => array() - ), - ), - ); - } - -/** - * testEnvironmentDetection method - * - * @dataProvider environmentGenerator - * @return void - */ - public function testEnvironmentDetection($name, $env, $expected) { - $_GET = array(); - $this->__loadEnvironment($env); - - $request = new CakeRequest(); - $this->assertEquals($expected['url'], $request->url, "url error"); - $this->assertEquals($expected['base'], $request->base, "base error"); - $this->assertEquals($expected['webroot'], $request->webroot, "webroot error"); - if (isset($expected['urlParams'])) { - $this->assertEquals($expected['urlParams'], $request->query, "GET param mismatch"); - } - } - -/** - * test the data() method reading - * - * @return void - */ - public function testDataReading() { - $_POST['data'] = array( - 'Model' => array( - 'field' => 'value' - ) - ); - $request = new CakeRequest('posts/index'); - $result = $request->data('Model'); - $this->assertEquals($_POST['data']['Model'], $result); - - $result = $request->data('Model.imaginary'); - $this->assertNull($result); - } - -/** - * test writing with data() - * - * @return void - */ - public function testDataWriting() { - $_POST['data'] = array( - 'Model' => array( - 'field' => 'value' - ) - ); - $request = new CakeRequest('posts/index'); - $result = $request->data('Model.new_value', 'new value'); - $this->assertSame($result, $request, 'Return was not $this'); - - $this->assertEquals('new value', $request->data['Model']['new_value']); - - $request->data('Post.title', 'New post')->data('Comment.1.author', 'Mark'); - $this->assertEquals('New post', $request->data['Post']['title']); - $this->assertEquals('Mark', $request->data['Comment']['1']['author']); - } - -/** - * test writing falsey values. - * - * @return void - */ - public function testDataWritingFalsey() { - $request = new CakeRequest('posts/index'); - - $request->data('Post.null', null); - $this->assertNull($request->data['Post']['null']); - - $request->data('Post.false', false); - $this->assertFalse($request->data['Post']['false']); - - $request->data('Post.zero', 0); - $this->assertSame(0, $request->data['Post']['zero']); - - $request->data('Post.empty', ''); - $this->assertSame('', $request->data['Post']['empty']); - } - -/** - * test accept language - * - * @return void - */ - public function testAcceptLanguage() { - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'inexistent,en-ca'; - $result = CakeRequest::acceptLanguage(); - $this->assertEquals(array('inexistent', 'en-ca'), $result, 'Languages do not match'); - - $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'es_mx;en_ca'; - $result = CakeRequest::acceptLanguage(); - $this->assertEquals(array('es-mx', 'en-ca'), $result, 'Languages do not match'); - - $result = CakeRequest::acceptLanguage('en-ca'); - $this->assertTrue($result); - - $result = CakeRequest::acceptLanguage('en-us'); - $this->assertFalse($result); - } - -/** - * test the here() method - * - * @return void - */ - public function testHere() { - Configure::write('App.base', '/base_path'); - $_GET = array('test' => 'value'); - $request = new CakeRequest('/posts/add/1/name:value'); - - $result = $request->here(); - $this->assertEquals('/base_path/posts/add/1/name:value?test=value', $result); - - $result = $request->here(false); - $this->assertEquals('/posts/add/1/name:value?test=value', $result); - - $request = new CakeRequest('/posts/base_path/1/name:value'); - $result = $request->here(); - $this->assertEquals('/base_path/posts/base_path/1/name:value?test=value', $result); - - $result = $request->here(false); - $this->assertEquals('/posts/base_path/1/name:value?test=value', $result); - } - -/** - * Test the input() method. - * - * @return void - */ - public function testInput() { - $request = $this->getMock('CakeRequest', array('_readInput')); - $request->expects($this->once())->method('_readInput') - ->will($this->returnValue('I came from stdin')); - - $result = $request->input(); - $this->assertEquals('I came from stdin', $result); - } - -/** - * Test input() decoding. - * - * @return void - */ - public function testInputDecode() { - $request = $this->getMock('CakeRequest', array('_readInput')); - $request->expects($this->once())->method('_readInput') - ->will($this->returnValue('{"name":"value"}')); - - $result = $request->input('json_decode'); - $this->assertEquals(array('name' => 'value'), (array)$result); - } - -/** - * Test input() decoding with additional arguments. - * - * @return void - */ - public function testInputDecodeExtraParams() { - $xml = << - - Test - -XML; - - $request = $this->getMock('CakeRequest', array('_readInput')); - $request->expects($this->once())->method('_readInput') - ->will($this->returnValue($xml)); - - $result = $request->input('Xml::build', array('return' => 'domdocument')); - $this->assertInstanceOf('DOMDocument', $result); - $this->assertEquals( - 'Test', - $result->getElementsByTagName('title')->item(0)->childNodes->item(0)->wholeText - ); - } - -/** - * Test is('requested') and isRequested() - * - * @return void - */ - public function testIsRequested() { - $request = new CakeRequest('/posts/index'); - $request->addParams(array( - 'controller' => 'posts', - 'action' => 'index', - 'plugin' => null, - 'requested' => 1 - )); - $this->assertTrue($request->is('requested')); - $this->assertTrue($request->isRequested()); - - $request = new CakeRequest('/posts/index'); - $request->addParams(array( - 'controller' => 'posts', - 'action' => 'index', - 'plugin' => null, - )); - $this->assertFalse($request->is('requested')); - $this->assertFalse($request->isRequested()); - } - -/** - * loadEnvironment method - * - * @param mixed $env - * @return void - */ - protected function __loadEnvironment($env) { - if (isset($env['App'])) { - Configure::write('App', $env['App']); - } - - if (isset($env['GET'])) { - foreach ($env['GET'] as $key => $val) { - $_GET[$key] = $val; - } - } - - if (isset($env['POST'])) { - foreach ($env['POST'] as $key => $val) { - $_POST[$key] = $val; - } - } - - if (isset($env['SERVER'])) { - foreach ($env['SERVER'] as $key => $val) { - $_SERVER[$key] = $val; - } - } - } - -} diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php deleted file mode 100644 index f27ac1f1f1d..00000000000 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ /dev/null @@ -1,1008 +0,0 @@ -assertNull($response->body()); - $this->assertEquals('UTF-8', $response->charset()); - $this->assertEquals('text/html', $response->type()); - $this->assertEquals(200, $response->statusCode()); - - $options = array( - 'body' => 'This is the body', - 'charset' => 'my-custom-charset', - 'type' => 'mp3', - 'status' => '203' - ); - $response = new CakeResponse($options); - $this->assertEquals('This is the body', $response->body()); - $this->assertEquals('my-custom-charset', $response->charset()); - $this->assertEquals('audio/mpeg', $response->type()); - $this->assertEquals(203, $response->statusCode()); - } - -/** - * Tests the body method - * - */ - public function testBody() { - $response = new CakeResponse(); - $this->assertNull($response->body()); - $response->body('Response body'); - $this->assertEquals('Response body', $response->body()); - $this->assertEquals('Changed Body', $response->body('Changed Body')); - } - -/** - * Tests the charset method - * - */ - public function testCharset() { - $response = new CakeResponse(); - $this->assertEquals('UTF-8', $response->charset()); - $response->charset('iso-8859-1'); - $this->assertEquals('iso-8859-1', $response->charset()); - $this->assertEquals('UTF-16', $response->charset('UTF-16')); - } - -/** - * Tests the statusCode method - * - * @expectedException CakeException - */ - public function testStatusCode() { - $response = new CakeResponse(); - $this->assertEquals(200, $response->statusCode()); - $response->statusCode(404); - $this->assertEquals(404, $response->statusCode()); - $this->assertEquals(500, $response->statusCode(500)); - - //Throws exception - $response->statusCode(1001); - } - -/** - * Tests the type method - * - */ - public function testType() { - $response = new CakeResponse(); - $this->assertEquals('text/html', $response->type()); - $response->type('pdf'); - $this->assertEquals('application/pdf', $response->type()); - $this->assertEquals('application/crazy-mime', $response->type('application/crazy-mime')); - $this->assertEquals('application/json', $response->type('json')); - $this->assertEquals('text/vnd.wap.wml', $response->type('wap')); - $this->assertEquals('application/vnd.wap.xhtml+xml', $response->type('xhtml-mobile')); - $this->assertEquals('text/csv', $response->type('csv')); - - $response->type(array('keynote' => 'application/keynote')); - $this->assertEquals('application/keynote', $response->type('keynote')); - - $this->assertFalse($response->type('wackytype')); - } - -/** - * Tests the header method - * - */ - public function testHeader() { - $response = new CakeResponse(); - $headers = array(); - $this->assertEquals($response->header(), $headers); - - $response->header('Location', 'http://example.com'); - $headers += array('Location' => 'http://example.com'); - $this->assertEquals($response->header(), $headers); - - //Headers with the same name are overwritten - $response->header('Location', 'http://example2.com'); - $headers = array('Location' => 'http://example2.com'); - $this->assertEquals($response->header(), $headers); - - $response->header(array('WWW-Authenticate' => 'Negotiate')); - $headers += array('WWW-Authenticate' => 'Negotiate'); - $this->assertEquals($response->header(), $headers); - - $response->header(array('WWW-Authenticate' => 'Not-Negotiate')); - $headers['WWW-Authenticate'] = 'Not-Negotiate'; - $this->assertEquals($response->header(), $headers); - - $response->header(array('Age' => 12, 'Allow' => 'GET, HEAD')); - $headers += array('Age' => 12, 'Allow' => 'GET, HEAD'); - $this->assertEquals($response->header(), $headers); - - // String headers are allowed - $response->header('Content-Language: da'); - $headers += array('Content-Language' => 'da'); - $this->assertEquals($response->header(), $headers); - - $response->header('Content-Language: da'); - $headers += array('Content-Language' => 'da'); - $this->assertEquals($response->header(), $headers); - - $response->header(array('Content-Encoding: gzip', 'Vary: *', 'Pragma' => 'no-cache')); - $headers += array('Content-Encoding' => 'gzip', 'Vary' => '*', 'Pragma' => 'no-cache'); - $this->assertEquals($response->header(), $headers); - } - -/** - * Tests the send method - * - */ - public function testSend() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent', '_setCookies')); - $response->header(array( - 'Content-Language' => 'es', - 'WWW-Authenticate' => 'Negotiate' - )); - $response->body('the response body'); - $response->expects($this->once())->method('_sendContent')->with('the response body'); - $response->expects($this->at(0))->method('_setCookies'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(2)) - ->method('_sendHeader')->with('Content-Language', 'es'); - $response->expects($this->at(3)) - ->method('_sendHeader')->with('WWW-Authenticate', 'Negotiate'); - $response->expects($this->at(4)) - ->method('_sendHeader')->with('Content-Length', 17); - $response->expects($this->at(5)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->send(); - } - -/** - * Tests the send method and changing the content type - * - */ - public function testSendChangingContentYype() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent', '_setCookies')); - $response->type('mp3'); - $response->body('the response body'); - $response->expects($this->once())->method('_sendContent')->with('the response body'); - $response->expects($this->at(0))->method('_setCookies'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(2)) - ->method('_sendHeader')->with('Content-Length', 17); - $response->expects($this->at(3)) - ->method('_sendHeader')->with('Content-Type', 'audio/mpeg'); - $response->send(); - } - -/** - * Tests the send method and changing the content type - * - */ - public function testSendChangingContentType() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent', '_setCookies')); - $response->type('mp3'); - $response->body('the response body'); - $response->expects($this->once())->method('_sendContent')->with('the response body'); - $response->expects($this->at(0))->method('_setCookies'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(2)) - ->method('_sendHeader')->with('Content-Length', 17); - $response->expects($this->at(3)) - ->method('_sendHeader')->with('Content-Type', 'audio/mpeg'); - $response->send(); - } - -/** - * Tests the send method and changing the content type - * - */ - public function testSendWithLocation() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent', '_setCookies')); - $response->header('Location', 'http://www.example.com'); - $response->expects($this->at(0))->method('_setCookies'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('HTTP/1.1 302 Found'); - $response->expects($this->at(2)) - ->method('_sendHeader')->with('Location', 'http://www.example.com'); - $response->expects($this->at(3)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->send(); - } - -/** - * Tests the disableCache method - * - */ - public function testDisableCache() { - $response = new CakeResponse(); - $expected = array( - 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', - 'Last-Modified' => gmdate("D, d M Y H:i:s") . " GMT", - 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0' - ); - $response->disableCache(); - $this->assertEquals($expected, $response->header()); - } - -/** - * Tests the cache method - * - */ - public function testCache() { - $response = new CakeResponse(); - $since = time(); - $time = new DateTime('+1 day', new DateTimeZone('UTC')); - $response->expires('+1 day'); - $expected = array( - 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', - 'Expires' => $time->format('D, j M Y H:i:s') . ' GMT', - 'Cache-Control' => 'public, max-age=' . ($time->format('U') - time()) - ); - $response->cache($since); - $this->assertEquals($expected, $response->header()); - - $response = new CakeResponse(); - $since = time(); - $time = '+5 day'; - $expected = array( - 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', - 'Expires' => gmdate("D, j M Y H:i:s", strtotime($time)) . " GMT", - 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()) - ); - $response->cache($since, $time); - $this->assertEquals($expected, $response->header()); - - $response = new CakeResponse(); - $since = time(); - $time = time(); - $expected = array( - 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', - 'Expires' => gmdate("D, j M Y H:i:s", $time) . " GMT", - 'Cache-Control' => 'public, max-age=0' - ); - $response->cache($since, $time); - $this->assertEquals($expected, $response->header()); - } - -/** - * Tests the compress method - * - * @return void - */ - public function testCompress() { - if (php_sapi_name() !== 'cli') { - $this->markTestSkipped('The response compression can only be tested in cli.'); - } - - $response = new CakeResponse(); - if (ini_get("zlib.output_compression") === '1' || !extension_loaded("zlib")) { - $this->assertFalse($response->compress()); - $this->markTestSkipped('Is not possible to test output compression'); - } - - $_SERVER['HTTP_ACCEPT_ENCODING'] = ''; - $result = $response->compress(); - $this->assertFalse($result); - - $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip'; - $result = $response->compress(); - $this->assertTrue($result); - $this->assertTrue(in_array('ob_gzhandler', ob_list_handlers())); - - ob_get_clean(); - } - -/** - * Tests the httpCodes method - * - */ - public function testHttpCodes() { - $response = new CakeResponse(); - $result = $response->httpCodes(); - $this->assertEquals(39, count($result)); - - $result = $response->httpCodes(100); - $expected = array(100 => 'Continue'); - $this->assertEquals($expected, $result); - - $codes = array( - 1337 => 'Undefined Unicorn', - 1729 => 'Hardy-Ramanujan Located' - ); - - $result = $response->httpCodes($codes); - $this->assertTrue($result); - $this->assertEquals(41, count($response->httpCodes())); - - $result = $response->httpCodes(1337); - $expected = array(1337 => 'Undefined Unicorn'); - $this->assertEquals($expected, $result); - - $codes = array(404 => 'Sorry Bro'); - $result = $response->httpCodes($codes); - $this->assertTrue($result); - $this->assertEquals(41, count($response->httpCodes())); - - $result = $response->httpCodes(404); - $expected = array(404 => 'Sorry Bro'); - $this->assertEquals($expected, $result); - } - -/** - * Tests the download method - * - */ - public function testDownload() { - $response = new CakeResponse(); - $expected = array( - 'Content-Disposition' => 'attachment; filename="myfile.mp3"' - ); - $response->download('myfile.mp3'); - $this->assertEquals($expected, $response->header()); - } - -/** - * Tests the mapType method - * - */ - public function testMapType() { - $response = new CakeResponse(); - $this->assertEquals('wav', $response->mapType('audio/x-wav')); - $this->assertEquals('pdf', $response->mapType('application/pdf')); - $this->assertEquals('xml', $response->mapType('text/xml')); - $this->assertEquals('html', $response->mapType('*/*')); - $this->assertEquals('csv', $response->mapType('application/vnd.ms-excel')); - $expected = array('json', 'xhtml', 'css'); - $result = $response->mapType(array('application/json', 'application/xhtml+xml', 'text/css')); - $this->assertEquals($expected, $result); - } - -/** - * Tests the outputCompressed method - * - */ - public function testOutputCompressed() { - $response = new CakeResponse(); - - $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip'; - $result = $response->outputCompressed(); - $this->assertFalse($result); - - $_SERVER['HTTP_ACCEPT_ENCODING'] = ''; - $result = $response->outputCompressed(); - $this->assertFalse($result); - - if (!extension_loaded("zlib")) { - $this->markTestSkipped('Skipping further tests for outputCompressed as zlib extension is not loaded'); - } - if (php_sapi_name() !== 'cli') { - $this->markTestSkipped('Testing outputCompressed method with compression enabled done only in cli'); - } - - if (ini_get("zlib.output_compression") !== '1') { - ob_start('ob_gzhandler'); - } - $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip'; - $result = $response->outputCompressed(); - $this->assertTrue($result); - - $_SERVER['HTTP_ACCEPT_ENCODING'] = ''; - $result = $response->outputCompressed(); - $this->assertFalse($result); - if (ini_get("zlib.output_compression") !== '1') { - ob_get_clean(); - } - } - -/** - * Tests the send and setting of Content-Length - * - */ - public function testSendContentLength() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->body('the response body'); - $response->expects($this->once())->method('_sendContent')->with('the response body'); - $response->expects($this->at(0)) - ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(2)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Length', strlen('the response body')); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $body = '長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'; - $response->body($body); - $response->expects($this->once())->method('_sendContent')->with($body); - $response->expects($this->at(0)) - ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(2)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Length', 116); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent', 'outputCompressed')); - $body = '長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'; - $response->body($body); - $response->expects($this->once())->method('outputCompressed')->will($this->returnValue(true)); - $response->expects($this->once())->method('_sendContent')->with($body); - $response->expects($this->exactly(2))->method('_sendHeader'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent', 'outputCompressed')); - $body = 'hwy'; - $response->body($body); - $response->header('Content-Length', 1); - $response->expects($this->never())->method('outputCompressed'); - $response->expects($this->once())->method('_sendContent')->with($body); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Length', 1); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $body = 'content'; - $response->statusCode(301); - $response->body($body); - $response->expects($this->once())->method('_sendContent')->with($body); - $response->expects($this->exactly(2))->method('_sendHeader'); - $response->send(); - - ob_start(); - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $goofyOutput = 'I am goofily sending output in the controller'; - echo $goofyOutput; - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $body = '長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'; - $response->body($body); - $response->expects($this->once())->method('_sendContent')->with($body); - $response->expects($this->at(0)) - ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Length', strlen($goofyOutput) + 116); - $response->expects($this->at(2)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->send(); - ob_end_clean(); - } - -/** - * Tests getting/setting the protocol - * - * @return void - */ - public function testProtocol() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->protocol('HTTP/1.0'); - $this->assertEquals('HTTP/1.0', $response->protocol()); - $response->expects($this->at(0)) - ->method('_sendHeader')->with('HTTP/1.0 200 OK'); - $response->send(); - } - -/** - * Tests getting/setting the Content-Length - * - * @return void - */ - public function testLength() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->length(100); - $this->assertEquals(100, $response->length()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Length', 100); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->length(false); - $this->assertFalse($response->length()); - $response->expects($this->exactly(2)) - ->method('_sendHeader'); - $response->send(); - } - -/** - * Tests that the response body is unset if the status code is 304 or 204 - * - * @return void - */ - public function testUnmodifiedContent() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->body('This is a body'); - $response->statusCode(204); - $response->expects($this->once()) - ->method('_sendContent')->with(''); - $response->send(); - $this->assertFalse(array_key_exists('Content-Type', $response->header())); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->body('This is a body'); - $response->statusCode(304); - $response->expects($this->once()) - ->method('_sendContent')->with(''); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->body('This is a body'); - $response->statusCode(200); - $response->expects($this->once()) - ->method('_sendContent')->with('This is a body'); - $response->send(); - } - -/** - * Tests setting the expiration date - * - * @return void - */ - public function testExpires() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); - $response->expires($now); - $now->setTimeZone(new DateTimeZone('UTC')); - $this->assertEquals($now->format('D, j M Y H:i:s') . ' GMT', $response->expires()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Expires', $now->format('D, j M Y H:i:s') . ' GMT'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $now = time(); - $response->expires($now); - $this->assertEquals(gmdate('D, j M Y H:i:s', $now) . ' GMT', $response->expires()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Expires', gmdate('D, j M Y H:i:s', $now) . ' GMT'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $time = new DateTime('+1 day', new DateTimeZone('UTC')); - $response->expires('+1 day'); - $this->assertEquals($time->format('D, j M Y H:i:s') . ' GMT', $response->expires()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Expires', $time->format('D, j M Y H:i:s') . ' GMT'); - $response->send(); - } - -/** - * Tests setting the modification date - * - * @return void - */ - public function testModified() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); - $response->modified($now); - $now->setTimeZone(new DateTimeZone('UTC')); - $this->assertEquals($now->format('D, j M Y H:i:s') . ' GMT', $response->modified()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Last-Modified', $now->format('D, j M Y H:i:s') . ' GMT'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $now = time(); - $response->modified($now); - $this->assertEquals(gmdate('D, j M Y H:i:s', $now) . ' GMT', $response->modified()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Last-Modified', gmdate('D, j M Y H:i:s', $now) . ' GMT'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $time = new DateTime('+1 day', new DateTimeZone('UTC')); - $response->modified('+1 day'); - $this->assertEquals($time->format('D, j M Y H:i:s') . ' GMT', $response->modified()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Last-Modified', $time->format('D, j M Y H:i:s') . ' GMT'); - $response->send(); - } - -/** - * Tests setting of public/private Cache-Control directives - * - * @return void - */ - public function testSharable() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $this->assertNull($response->sharable()); - $response->sharable(true); - $headers = $response->header(); - $this->assertEquals('public', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 'public'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->sharable(false); - $headers = $response->header(); - $this->assertEquals('private', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 'private'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->sharable(true); - $headers = $response->header(); - $this->assertEquals('public', $headers['Cache-Control']); - $response->sharable(false); - $headers = $response->header(); - $this->assertEquals('private', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 'private'); - $response->send(); - $this->assertFalse($response->sharable()); - $response->sharable(true); - $this->assertTrue($response->sharable()); - - $response = new CakeResponse; - $response->sharable(true, 3600); - $headers = $response->header(); - $this->assertEquals('public, s-maxage=3600', $headers['Cache-Control']); - - $response = new CakeResponse; - $response->sharable(false, 3600); - $headers = $response->header(); - $this->assertEquals('private, max-age=3600', $headers['Cache-Control']); - $response->send(); - } - -/** - * Tests setting of max-age Cache-Control directive - * - * @return void - */ - public function testMaxAge() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $this->assertNull($response->maxAge()); - $response->maxAge(3600); - $this->assertEquals(3600, $response->maxAge()); - $headers = $response->header(); - $this->assertEquals('max-age=3600', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 'max-age=3600'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->maxAge(3600); - $response->sharable(false); - $headers = $response->header(); - $this->assertEquals('max-age=3600, private', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 'max-age=3600, private'); - $response->send(); - } - -/** - * Tests setting of s-maxage Cache-Control directive - * - * @return void - */ - public function testSharedMaxAge() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $this->assertNull($response->maxAge()); - $response->sharedMaxAge(3600); - $this->assertEquals(3600, $response->sharedMaxAge()); - $headers = $response->header(); - $this->assertEquals('s-maxage=3600', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->sharedMaxAge(3600); - $response->sharable(true); - $headers = $response->header(); - $this->assertEquals('s-maxage=3600, public', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600, public'); - $response->send(); - } - -/** - * Tests setting of must-revalidate Cache-Control directive - * - * @return void - */ - public function testMustRevalidate() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $this->assertFalse($response->mustRevalidate()); - $response->mustRevalidate(true); - $this->assertTrue($response->mustRevalidate()); - $headers = $response->header(); - $this->assertEquals('must-revalidate', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 'must-revalidate'); - $response->send(); - $response->mustRevalidate(false); - $this->assertFalse($response->mustRevalidate()); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->sharedMaxAge(3600); - $response->mustRevalidate(true); - $headers = $response->header(); - $this->assertEquals('s-maxage=3600, must-revalidate', $headers['Cache-Control']); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600, must-revalidate'); - $response->send(); - } - -/** - * Tests getting/setting the Vary header - * - * @return void - */ - public function testVary() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->vary('Accept-encoding'); - $this->assertEquals(array('Accept-encoding'), $response->vary()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Vary', 'Accept-encoding'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->vary(array('Accept-language', 'Accept-encoding')); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Vary', 'Accept-language, Accept-encoding'); - $response->send(); - $this->assertEquals(array('Accept-language', 'Accept-encoding'), $response->vary()); - } - -/** - * Tests getting/setting the Etag header - * - * @return void - */ - public function testEtag() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->etag('something'); - $this->assertEquals('"something"', $response->etag()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Etag', '"something"'); - $response->send(); - - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->etag('something', true); - $this->assertEquals('W/"something"', $response->etag()); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Etag', 'W/"something"'); - $response->send(); - } - -/** - * Tests that the response is able to be marked as not modified - * - * @return void - */ - public function testNotModified() { - $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); - $response->body('something'); - $response->statusCode(200); - $response->length(100); - $response->modified('now'); - $response->notModified(); - - $this->assertEmpty($response->header()); - $this->assertEmpty($response->body()); - $this->assertEquals(304, $response->statusCode()); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagStar() { - $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; - $response = $this->getMock('CakeResponse', array('notModified')); - $response->etag('something'); - $response->expects($this->once())->method('notModified'); - $response->checkNotModified(new CakeRequest); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagExact() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $response = $this->getMock('CakeResponse', array('notModified')); - $response->etag('something', true); - $response->expects($this->once())->method('notModified'); - $this->assertTrue($response->checkNotModified(new CakeRequest)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagAndTime() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $response = $this->getMock('CakeResponse', array('notModified')); - $response->etag('something', true); - $response->modified('2012-01-01 00:00:00'); - $response->expects($this->once())->method('notModified'); - $this->assertTrue($response->checkNotModified(new CakeRequest)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagAndTimeMismatch() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $response = $this->getMock('CakeResponse', array('notModified')); - $response->etag('something', true); - $response->modified('2012-01-01 00:00:01'); - $response->expects($this->never())->method('notModified'); - $this->assertFalse($response->checkNotModified(new CakeRequest)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagMismatch() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something-else", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $response = $this->getMock('CakeResponse', array('notModified')); - $response->etag('something', true); - $response->modified('2012-01-01 00:00:00'); - $response->expects($this->never())->method('notModified'); - $this->assertFalse($response->checkNotModified(new CakeRequest)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByTime() { - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $response = $this->getMock('CakeResponse', array('notModified')); - $response->modified('2012-01-01 00:00:00'); - $response->expects($this->once())->method('notModified'); - $this->assertTrue($response->checkNotModified(new CakeRequest)); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedNoHints() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $response = $this->getMock('CakeResponse', array('notModified')); - $response->expects($this->never())->method('notModified'); - $this->assertFalse($response->checkNotModified(new CakeRequest)); - } - -/** - * Test cookie setting - * - * @return void - */ - public function testCookieSettings() { - $response = new CakeResponse(); - $cookie = array( - 'name' => 'CakeTestCookie[Testing]' - ); - $response->cookie($cookie); - $expected = array( - 'name' => 'CakeTestCookie[Testing]', - 'value' => '', - 'expire' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false); - $result = $response->cookie('CakeTestCookie[Testing]'); - $this->assertEqual($result, $expected); - - $cookie = array( - 'name' => 'CakeTestCookie[Testing2]', - 'value' => '[a,b,c]', - 'expire' => 1000, - 'path' => '/test', - 'secure' => true - ); - $response->cookie($cookie); - $expected = array( - 'CakeTestCookie[Testing]' => array( - 'name' => 'CakeTestCookie[Testing]', - 'value' => '', - 'expire' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false - ), - 'CakeTestCookie[Testing2]' => array( - 'name' => 'CakeTestCookie[Testing2]', - 'value' => '[a,b,c]', - 'expire' => 1000, - 'path' => '/test', - 'domain' => '', - 'secure' => true, - 'httpOnly' => false - ) - ); - - $result = $response->cookie(); - $this->assertEqual($result, $expected); - - $cookie = $expected['CakeTestCookie[Testing]']; - $cookie['value'] = 'test'; - $response->cookie($cookie); - $expected = array( - 'CakeTestCookie[Testing]' => array( - 'name' => 'CakeTestCookie[Testing]', - 'value' => 'test', - 'expire' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false - ), - 'CakeTestCookie[Testing2]' => array( - 'name' => 'CakeTestCookie[Testing2]', - 'value' => '[a,b,c]', - 'expire' => 1000, - 'path' => '/test', - 'domain' => '', - 'secure' => true, - 'httpOnly' => false - ) - ); - - $result = $response->cookie(); - $this->assertEqual($result, $expected); - } - -} diff --git a/lib/Cake/Test/Case/Network/CakeSocketTest.php b/lib/Cake/Test/Case/Network/CakeSocketTest.php deleted file mode 100644 index 613e08d71d7..00000000000 --- a/lib/Cake/Test/Case/Network/CakeSocketTest.php +++ /dev/null @@ -1,217 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('CakeSocket', 'Network'); - -/** - * SocketTest class - * - * @package Cake.Test.Case.Network - */ -class CakeSocketTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->Socket = new CakeSocket(array('timeout' => 1)); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Socket); - } - -/** - * testConstruct method - * - * @return void - */ - public function testConstruct() { - $this->Socket = new CakeSocket(); - $config = $this->Socket->config; - $this->assertSame($config, array( - 'persistent' => false, - 'host' => 'localhost', - 'protocol' => getprotobyname('tcp'), - 'port' => 80, - 'timeout' => 30 - )); - - $this->Socket->reset(); - $this->Socket->__construct(array('host' => 'foo-bar')); - $config['host'] = 'foo-bar'; - $this->assertSame($this->Socket->config, $config); - - $this->Socket = new CakeSocket(array('host' => 'www.cakephp.org', 'port' => 23, 'protocol' => 'udp')); - $config = $this->Socket->config; - - $config['host'] = 'www.cakephp.org'; - $config['port'] = 23; - $config['protocol'] = 17; - - $this->assertSame($this->Socket->config, $config); - } - -/** - * testSocketConnection method - * - * @return void - */ - public function testSocketConnection() { - $this->assertFalse($this->Socket->connected); - $this->Socket->disconnect(); - $this->assertFalse($this->Socket->connected); - $this->Socket->connect(); - $this->assertTrue($this->Socket->connected); - $this->Socket->connect(); - $this->assertTrue($this->Socket->connected); - - $this->Socket->disconnect(); - $config = array('persistent' => true); - $this->Socket = new CakeSocket($config); - $this->Socket->connect(); - $this->assertTrue($this->Socket->connected); - } - -/** - * data provider function for testInvalidConnection - * - * @return array - */ - public static function invalidConnections() { - return array( - array(array('host' => 'invalid.host', 'port' => 9999, 'timeout' => 1)), - array(array('host' => '127.0.0.1', 'port' => '70000', 'timeout' => 1)) - ); - } - -/** - * testInvalidConnection method - * - * @dataProvider invalidConnections - * @expectedException SocketException - * return void - */ - public function testInvalidConnection($data) { - $this->Socket->config = array_merge($this->Socket->config, $data); - $this->Socket->connect(); - } - -/** - * testSocketHost method - * - * @return void - */ - public function testSocketHost() { - $this->Socket = new CakeSocket(); - $this->Socket->connect(); - $this->assertEquals('127.0.0.1', $this->Socket->address()); - $this->assertEquals(gethostbyaddr('127.0.0.1'), $this->Socket->host()); - $this->assertEquals(null, $this->Socket->lastError()); - $this->assertTrue(in_array('127.0.0.1', $this->Socket->addresses())); - - $this->Socket = new CakeSocket(array('host' => '127.0.0.1')); - $this->Socket->connect(); - $this->assertEquals('127.0.0.1', $this->Socket->address()); - $this->assertEquals(gethostbyaddr('127.0.0.1'), $this->Socket->host()); - $this->assertEquals(null, $this->Socket->lastError()); - $this->assertTrue(in_array('127.0.0.1', $this->Socket->addresses())); - } - -/** - * testSocketWriting method - * - * @return void - */ - public function testSocketWriting() { - $request = "GET / HTTP/1.1\r\nConnection: close\r\n\r\n"; - $this->assertTrue((bool)$this->Socket->write($request)); - } - -/** - * testSocketReading method - * - * @return void - */ - public function testSocketReading() { - $this->Socket = new CakeSocket(array('timeout' => 5)); - $this->Socket->connect(); - $this->assertEquals(null, $this->Socket->read(26)); - - $config = array('host' => 'google.com', 'port' => 80, 'timeout' => 1); - $this->Socket = new CakeSocket($config); - $this->assertTrue($this->Socket->connect()); - $this->assertEquals(null, $this->Socket->read(26)); - $this->assertEquals('2: ' . __d('cake_dev', 'Connection timed out'), $this->Socket->lastError()); - } - -/** - * testTimeOutConnection method - * - * @return void - */ - public function testTimeOutConnection() { - $config = array('host' => '127.0.0.1', 'timeout' => 0.5); - $this->Socket = new CakeSocket($config); - $this->assertTrue($this->Socket->connect()); - - $config = array('host' => '127.0.0.1', 'timeout' => 0.00001); - $this->Socket = new CakeSocket($config); - $this->assertFalse($this->Socket->read(1024 * 1024)); - $this->assertEquals('2: ' . __d('cake_dev', 'Connection timed out'), $this->Socket->lastError()); - } - -/** - * testLastError method - * - * @return void - */ - public function testLastError() { - $this->Socket = new CakeSocket(); - $this->Socket->setLastError(4, 'some error here'); - $this->assertEquals('4: some error here', $this->Socket->lastError()); - } - -/** - * testReset method - * - * @return void - */ - public function testReset() { - $config = array( - 'persistent' => true, - 'host' => '127.0.0.1', - 'protocol' => 'udp', - 'port' => 80, - 'timeout' => 20 - ); - $anotherSocket = new CakeSocket($config); - $anotherSocket->reset(); - $this->assertEquals(array(), $anotherSocket->config); - } -} diff --git a/lib/Cake/Test/Case/Network/Email/CakeEmailTest.php b/lib/Cake/Test/Case/Network/Email/CakeEmailTest.php deleted file mode 100644 index ba7a65e5c54..00000000000 --- a/lib/Cake/Test/Case/Network/Email/CakeEmailTest.php +++ /dev/null @@ -1,1499 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network.Email - * @since CakePHP(tm) v 2.0.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('CakeEmail', 'Network/Email'); - -/** - * Help to test CakeEmail - * - */ -class TestCakeEmail extends CakeEmail { - -/** - * Config - * - */ - protected $_config = array(); - -/** - * Wrap to protected method - * - */ - public function formatAddress($address) { - return parent::_formatAddress($address); - } - -/** - * Wrap to protected method - * - */ - public function wrap($text) { - return parent::_wrap($text); - } - -/** - * Get the boundary attribute - * - * @return string - */ - public function getBoundary() { - return $this->_boundary; - } - -/** - * Encode to protected method - * - */ - public function encode($text) { - return $this->_encode($text); - } - -} - -/* - * EmailConfig class - * - */ -class EmailConfig { - -/** - * test config - * - * @var string - */ - public $test = array( - 'from' => array('some@example.com' => 'My website'), - 'to' => array('test@example.com' => 'Testname'), - 'subject' => 'Test mail subject', - 'transport' => 'Debug', - ); - -} - -/* - * ExtendTransport class - * test class to ensure the class has send() method - * - */ -class ExtendTransport { - -} - -/** - * CakeEmailTest class - * - * @package Cake.Test.Case.Network.Email - */ -class CakeEmailTest extends CakeTestCase { - -/** - * setUp - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->CakeEmail = new TestCakeEmail(); - - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - App::build(); - } - -/** - * testFrom method - * - * @return void - */ - public function testFrom() { - $this->assertSame($this->CakeEmail->from(), array()); - - $this->CakeEmail->from('cake@cakephp.org'); - $expected = array('cake@cakephp.org' => 'cake@cakephp.org'); - $this->assertSame($this->CakeEmail->from(), $expected); - - $this->CakeEmail->from(array('cake@cakephp.org')); - $this->assertSame($this->CakeEmail->from(), $expected); - - $this->CakeEmail->from('cake@cakephp.org', 'CakePHP'); - $expected = array('cake@cakephp.org' => 'CakePHP'); - $this->assertSame($this->CakeEmail->from(), $expected); - - $result = $this->CakeEmail->from(array('cake@cakephp.org' => 'CakePHP')); - $this->assertSame($this->CakeEmail->from(), $expected); - $this->assertSame($this->CakeEmail, $result); - - $this->setExpectedException('SocketException'); - $result = $this->CakeEmail->from(array('cake@cakephp.org' => 'CakePHP', 'fail@cakephp.org' => 'From can only be one address')); - } - -/** - * testSender method - * - * @return void - */ - public function testSender() { - $this->CakeEmail->reset(); - $this->assertSame($this->CakeEmail->sender(), array()); - - $this->CakeEmail->sender('cake@cakephp.org', 'Name'); - $expected = array('cake@cakephp.org' => 'Name'); - $this->assertSame($this->CakeEmail->sender(), $expected); - - $headers = $this->CakeEmail->getHeaders(array('from' => true, 'sender' => true)); - $this->assertSame($headers['From'], false); - $this->assertSame($headers['Sender'], 'Name '); - - $this->CakeEmail->from('cake@cakephp.org', 'CakePHP'); - $headers = $this->CakeEmail->getHeaders(array('from' => true, 'sender' => true)); - $this->assertSame($headers['From'], 'CakePHP '); - $this->assertSame($headers['Sender'], ''); - } - -/** - * testTo method - * - * @return void - */ - public function testTo() { - $this->assertSame($this->CakeEmail->to(), array()); - - $result = $this->CakeEmail->to('cake@cakephp.org'); - $expected = array('cake@cakephp.org' => 'cake@cakephp.org'); - $this->assertSame($this->CakeEmail->to(), $expected); - $this->assertSame($this->CakeEmail, $result); - - $this->CakeEmail->to('cake@cakephp.org', 'CakePHP'); - $expected = array('cake@cakephp.org' => 'CakePHP'); - $this->assertSame($this->CakeEmail->to(), $expected); - - $list = array( - 'cake@cakephp.org' => 'Cake PHP', - 'cake-php@googlegroups.com' => 'Cake Groups', - 'root@cakephp.org' - ); - $this->CakeEmail->to($list); - $expected = array( - 'cake@cakephp.org' => 'Cake PHP', - 'cake-php@googlegroups.com' => 'Cake Groups', - 'root@cakephp.org' => 'root@cakephp.org' - ); - $this->assertSame($this->CakeEmail->to(), $expected); - - $this->CakeEmail->addTo('jrbasso@cakephp.org'); - $this->CakeEmail->addTo('mark_story@cakephp.org', 'Mark Story'); - $result = $this->CakeEmail->addTo(array('phpnut@cakephp.org' => 'PhpNut', 'jose_zap@cakephp.org')); - $expected = array( - 'cake@cakephp.org' => 'Cake PHP', - 'cake-php@googlegroups.com' => 'Cake Groups', - 'root@cakephp.org' => 'root@cakephp.org', - 'jrbasso@cakephp.org' => 'jrbasso@cakephp.org', - 'mark_story@cakephp.org' => 'Mark Story', - 'phpnut@cakephp.org' => 'PhpNut', - 'jose_zap@cakephp.org' => 'jose_zap@cakephp.org' - ); - $this->assertSame($this->CakeEmail->to(), $expected); - $this->assertSame($this->CakeEmail, $result); - } - -/** - * Data provider function for testBuildInvalidData - * - * @return array - */ - public static function invalidEmails() { - return array( - array(1.0), - array(''), - array('string'), - array(''), - array('some@one.whereis'), - array('wrong@key' => 'Name'), - array(array('ok@cakephp.org', 1.0, '', 'string')) - ); - } - -/** - * testBuildInvalidData - * - * @dataProvider invalidEmails - * @expectedException SocketException - * @return void - */ - public function testInvalidEmail($value) { - $this->CakeEmail->to($value); - } - -/** - * testBuildInvalidData - * - * @dataProvider invalidEmails - * @expectedException SocketException - * @return void - */ - public function testInvalidEmailAdd($value) { - $this->CakeEmail->addTo($value); - } - -/** - * testFormatAddress method - * - * @return void - */ - public function testFormatAddress() { - $result = $this->CakeEmail->formatAddress(array('cake@cakephp.org' => 'cake@cakephp.org')); - $expected = array('cake@cakephp.org'); - $this->assertSame($expected, $result); - - $result = $this->CakeEmail->formatAddress(array('cake@cakephp.org' => 'cake@cakephp.org', 'php@cakephp.org' => 'php@cakephp.org')); - $expected = array('cake@cakephp.org', 'php@cakephp.org'); - $this->assertSame($expected, $result); - - $result = $this->CakeEmail->formatAddress(array('cake@cakephp.org' => 'CakePHP', 'php@cakephp.org' => 'Cake')); - $expected = array('CakePHP ', 'Cake '); - $this->assertSame($expected, $result); - - $result = $this->CakeEmail->formatAddress(array('me@example.com' => 'Last, First')); - $expected = array('"Last, First" '); - $this->assertSame($expected, $result); - - $result = $this->CakeEmail->formatAddress(array('me@example.com' => 'Last First')); - $expected = array('Last First '); - $this->assertSame($expected, $result); - - $result = $this->CakeEmail->formatAddress(array('cake@cakephp.org' => 'ÄÖÜTest')); - $expected = array('=?UTF-8?B?w4TDlsOcVGVzdA==?= '); - $this->assertSame($expected, $result); - - $result = $this->CakeEmail->formatAddress(array('cake@cakephp.org' => '日本語Test')); - $expected = array('=?UTF-8?B?5pel5pys6KqeVGVzdA==?= '); - $this->assertSame($expected, $result); - } - -/** - * testFormatAddressJapanese - * - * @return void - */ - public function testFormatAddressJapanese() { - $this->skipIf(!function_exists('mb_convert_encoding')); - - $this->CakeEmail->headerCharset = 'ISO-2022-JP'; - $result = $this->CakeEmail->formatAddress(array('cake@cakephp.org' => '日本語Test')); - $expected = array('=?ISO-2022-JP?B?GyRCRnxLXDhsGyhCVGVzdA==?= '); - $this->assertSame($expected, $result); - - $result = $this->CakeEmail->formatAddress(array('cake@cakephp.org' => '寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末風来末食う寝る処に住む処やぶら小路の藪柑子パイポパイポパイポのシューリンガンシューリンガンのグーリンダイグーリンダイのポンポコピーのポンポコナーの長久命の長助')); - $expected = array("=?ISO-2022-JP?B?GyRCPHc4Qkw1PHc4Qkw1OF45ZSROOyQkakBaJGwzJDo9TXg/ZTV7GyhC?=\r\n" . - " =?ISO-2022-JP?B?GyRCJE4/ZTlUS3YxQE1oS3ZJd01oS3Y/KSQmPzIkaz1oJEs9OyRgGyhC?=\r\n" . - " =?ISO-2022-JP?B?GyRCPWgkZCRWJGk+Lk8pJE5pLjQ7O1IlUSUkJV0lUSUkJV0lUSUkGyhC?=\r\n" . - " =?ISO-2022-JP?B?GyRCJV0kTiU3JWUhPCVqJXMlLCVzJTclZSE8JWolcyUsJXMkTiUwGyhC?=\r\n" . - " =?ISO-2022-JP?B?GyRCITwlaiVzJUAlJCUwITwlaiVzJUAlJCROJV0lcyVdJTMlVCE8GyhC?=\r\n" . - " =?ISO-2022-JP?B?GyRCJE4lXSVzJV0lMyVKITwkTkQ5NVdMPyRORDk9dRsoQg==?= "); - $this->assertSame($expected, $result); - } - -/** - * testAddresses method - * - * @return void - */ - public function testAddresses() { - $this->CakeEmail->reset(); - $this->CakeEmail->from('cake@cakephp.org', 'CakePHP'); - $this->CakeEmail->replyTo('replyto@cakephp.org', 'ReplyTo CakePHP'); - $this->CakeEmail->readReceipt('readreceipt@cakephp.org', 'ReadReceipt CakePHP'); - $this->CakeEmail->returnPath('returnpath@cakephp.org', 'ReturnPath CakePHP'); - $this->CakeEmail->to('to@cakephp.org', 'To CakePHP'); - $this->CakeEmail->cc('cc@cakephp.org', 'Cc CakePHP'); - $this->CakeEmail->bcc('bcc@cakephp.org', 'Bcc CakePHP'); - $this->CakeEmail->addTo('to2@cakephp.org', 'To2 CakePHP'); - $this->CakeEmail->addCc('cc2@cakephp.org', 'Cc2 CakePHP'); - $this->CakeEmail->addBcc('bcc2@cakephp.org', 'Bcc2 CakePHP'); - - $this->assertSame($this->CakeEmail->from(), array('cake@cakephp.org' => 'CakePHP')); - $this->assertSame($this->CakeEmail->replyTo(), array('replyto@cakephp.org' => 'ReplyTo CakePHP')); - $this->assertSame($this->CakeEmail->readReceipt(), array('readreceipt@cakephp.org' => 'ReadReceipt CakePHP')); - $this->assertSame($this->CakeEmail->returnPath(), array('returnpath@cakephp.org' => 'ReturnPath CakePHP')); - $this->assertSame($this->CakeEmail->to(), array('to@cakephp.org' => 'To CakePHP', 'to2@cakephp.org' => 'To2 CakePHP')); - $this->assertSame($this->CakeEmail->cc(), array('cc@cakephp.org' => 'Cc CakePHP', 'cc2@cakephp.org' => 'Cc2 CakePHP')); - $this->assertSame($this->CakeEmail->bcc(), array('bcc@cakephp.org' => 'Bcc CakePHP', 'bcc2@cakephp.org' => 'Bcc2 CakePHP')); - - $headers = $this->CakeEmail->getHeaders(array_fill_keys(array('from', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'bcc'), true)); - $this->assertSame($headers['From'], 'CakePHP '); - $this->assertSame($headers['Reply-To'], 'ReplyTo CakePHP '); - $this->assertSame($headers['Disposition-Notification-To'], 'ReadReceipt CakePHP '); - $this->assertSame($headers['Return-Path'], 'ReturnPath CakePHP '); - $this->assertSame($headers['To'], 'To CakePHP , To2 CakePHP '); - $this->assertSame($headers['Cc'], 'Cc CakePHP , Cc2 CakePHP '); - $this->assertSame($headers['Bcc'], 'Bcc CakePHP , Bcc2 CakePHP '); - } - -/** - * testMessageId method - * - * @return void - */ - public function testMessageId() { - $this->CakeEmail->messageId(true); - $result = $this->CakeEmail->getHeaders(); - $this->assertTrue(isset($result['Message-ID'])); - - $this->CakeEmail->messageId(false); - $result = $this->CakeEmail->getHeaders(); - $this->assertFalse(isset($result['Message-ID'])); - - $result = $this->CakeEmail->messageId(''); - $this->assertSame($this->CakeEmail, $result); - $result = $this->CakeEmail->getHeaders(); - $this->assertSame($result['Message-ID'], ''); - - $result = $this->CakeEmail->messageId(); - $this->assertSame($result, ''); - } - -/** - * testMessageIdInvalid method - * - * @return void - * @expectedException SocketException - */ - public function testMessageIdInvalid() { - $this->CakeEmail->messageId('my-email@localhost'); - } - -/** - * testSubject method - * - * @return void - */ - public function testSubject() { - $this->CakeEmail->subject('You have a new message.'); - $this->assertSame($this->CakeEmail->subject(), 'You have a new message.'); - - $this->CakeEmail->subject('You have a new message, I think.'); - $this->assertSame($this->CakeEmail->subject(), 'You have a new message, I think.'); - $this->CakeEmail->subject(1); - $this->assertSame($this->CakeEmail->subject(), '1'); - - $this->CakeEmail->subject('هذه رسالة بعنوان طويل مرسل للمستلم'); - $expected = '=?UTF-8?B?2YfYsNmHINix2LPYp9mE2Kkg2KjYudmG2YjYp9mGINi32YjZitmEINmF2LE=?=' . "\r\n" . ' =?UTF-8?B?2LPZhCDZhNmE2YXYs9iq2YTZhQ==?='; - $this->assertSame($this->CakeEmail->subject(), $expected); - } - -/** - * testSubjectJapanese - * - * @return void - */ - public function testSubjectJapanese() { - $this->skipIf(!function_exists('mb_convert_encoding')); - mb_internal_encoding('UTF-8'); - - $this->CakeEmail->headerCharset = 'ISO-2022-JP'; - $this->CakeEmail->subject('日本語のSubjectにも対応するよ'); - $expected = '=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0GyRCJEskYkJQMX4kOSRrJGgbKEI=?='; - $this->assertSame($this->CakeEmail->subject(), $expected); - - $this->CakeEmail->subject('長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'); - $expected = "=?ISO-2022-JP?B?GyRCRDkkJEQ5JCREOSQkGyhCU3ViamVjdBskQiROPmw5ZyRPGyhCZm9s?=\r\n" . - " =?ISO-2022-JP?B?ZGluZxskQiQ5JGskTiQsQDUkNyQkJHMkQCQxJEkkJCRDJD8kJCRJGyhC?=\r\n" . - " =?ISO-2022-JP?B?GyRCJCYkSiRrJHMkQCRtJCYhKRsoQg==?="; - $this->assertSame($this->CakeEmail->subject(), $expected); - } - -/** - * testHeaders method - * - * @return void - */ - public function testHeaders() { - $this->CakeEmail->messageId(false); - $this->CakeEmail->setHeaders(array('X-Something' => 'nice')); - $expected = array( - 'X-Something' => 'nice', - 'X-Mailer' => 'CakePHP Email', - 'Date' => date(DATE_RFC2822), - 'MIME-Version' => '1.0', - 'Content-Type' => 'text/plain; charset=UTF-8', - 'Content-Transfer-Encoding' => '8bit' - ); - $this->assertSame($this->CakeEmail->getHeaders(), $expected); - - $this->CakeEmail->addHeaders(array('X-Something' => 'very nice', 'X-Other' => 'cool')); - $expected = array( - 'X-Something' => 'very nice', - 'X-Other' => 'cool', - 'X-Mailer' => 'CakePHP Email', - 'Date' => date(DATE_RFC2822), - 'MIME-Version' => '1.0', - 'Content-Type' => 'text/plain; charset=UTF-8', - 'Content-Transfer-Encoding' => '8bit' - ); - $this->assertSame($this->CakeEmail->getHeaders(), $expected); - - $this->CakeEmail->from('cake@cakephp.org'); - $this->assertSame($this->CakeEmail->getHeaders(), $expected); - - $expected = array( - 'From' => 'cake@cakephp.org', - 'X-Something' => 'very nice', - 'X-Other' => 'cool', - 'X-Mailer' => 'CakePHP Email', - 'Date' => date(DATE_RFC2822), - 'MIME-Version' => '1.0', - 'Content-Type' => 'text/plain; charset=UTF-8', - 'Content-Transfer-Encoding' => '8bit' - ); - $this->assertSame($this->CakeEmail->getHeaders(array('from' => true)), $expected); - - $this->CakeEmail->from('cake@cakephp.org', 'CakePHP'); - $expected['From'] = 'CakePHP '; - $this->assertSame($this->CakeEmail->getHeaders(array('from' => true)), $expected); - - $this->CakeEmail->to(array('cake@cakephp.org', 'php@cakephp.org' => 'CakePHP')); - $expected = array( - 'From' => 'CakePHP ', - 'To' => 'cake@cakephp.org, CakePHP ', - 'X-Something' => 'very nice', - 'X-Other' => 'cool', - 'X-Mailer' => 'CakePHP Email', - 'Date' => date(DATE_RFC2822), - 'MIME-Version' => '1.0', - 'Content-Type' => 'text/plain; charset=UTF-8', - 'Content-Transfer-Encoding' => '8bit' - ); - $this->assertSame($this->CakeEmail->getHeaders(array('from' => true, 'to' => true)), $expected); - - $this->CakeEmail->charset = 'ISO-2022-JP'; - $expected = array( - 'From' => 'CakePHP ', - 'To' => 'cake@cakephp.org, CakePHP ', - 'X-Something' => 'very nice', - 'X-Other' => 'cool', - 'X-Mailer' => 'CakePHP Email', - 'Date' => date(DATE_RFC2822), - 'MIME-Version' => '1.0', - 'Content-Type' => 'text/plain; charset=ISO-2022-JP', - 'Content-Transfer-Encoding' => '7bit' - ); - $this->assertSame($this->CakeEmail->getHeaders(array('from' => true, 'to' => true)), $expected); - - $result = $this->CakeEmail->setHeaders(array()); - $this->assertInstanceOf('CakeEmail', $result); - } - -/** - * Data provider function for testInvalidHeaders - * - * @return array - */ - public static function invalidHeaders() { - return array( - array(10), - array(''), - array('string'), - array(false), - array(null) - ); - } - -/** - * testInvalidHeaders - * - * @dataProvider invalidHeaders - * @expectedException SocketException - * @return void - */ - public function testInvalidHeaders($value) { - $this->CakeEmail->setHeaders($value); - } - -/** - * testInvalidAddHeaders - * - * @dataProvider invalidHeaders - * @expectedException SocketException - * @return void - */ - public function testInvalidAddHeaders($value) { - $this->CakeEmail->addHeaders($value); - } - -/** - * testTemplate method - * - * @return void - */ - public function testTemplate() { - $this->CakeEmail->template('template', 'layout'); - $expected = array('template' => 'template', 'layout' => 'layout'); - $this->assertSame($this->CakeEmail->template(), $expected); - - $this->CakeEmail->template('new_template'); - $expected = array('template' => 'new_template', 'layout' => 'layout'); - $this->assertSame($this->CakeEmail->template(), $expected); - - $this->CakeEmail->template('template', null); - $expected = array('template' => 'template', 'layout' => null); - $this->assertSame($this->CakeEmail->template(), $expected); - - $this->CakeEmail->template(null, null); - $expected = array('template' => null, 'layout' => null); - $this->assertSame($this->CakeEmail->template(), $expected); - } - -/** - * testViewVars method - * - * @return void - */ - public function testViewVars() { - $this->assertSame($this->CakeEmail->viewVars(), array()); - - $this->CakeEmail->viewVars(array('value' => 12345)); - $this->assertSame($this->CakeEmail->viewVars(), array('value' => 12345)); - - $this->CakeEmail->viewVars(array('name' => 'CakePHP')); - $this->assertSame($this->CakeEmail->viewVars(), array('value' => 12345, 'name' => 'CakePHP')); - - $this->CakeEmail->viewVars(array('value' => 4567)); - $this->assertSame($this->CakeEmail->viewVars(), array('value' => 4567, 'name' => 'CakePHP')); - } - -/** - * testAttachments method - * - * @return void - */ - public function testAttachments() { - $this->CakeEmail->attachments(CAKE . 'basics.php'); - $expected = array('basics.php' => array('file' => CAKE . 'basics.php', 'mimetype' => 'application/octet-stream')); - $this->assertSame($this->CakeEmail->attachments(), $expected); - - $this->CakeEmail->attachments(array()); - $this->assertSame($this->CakeEmail->attachments(), array()); - - $this->CakeEmail->attachments(array(array('file' => CAKE . 'basics.php', 'mimetype' => 'text/plain'))); - $this->CakeEmail->addAttachments(CAKE . 'bootstrap.php'); - $this->CakeEmail->addAttachments(array(CAKE . 'bootstrap.php')); - $this->CakeEmail->addAttachments(array('other.txt' => CAKE . 'bootstrap.php', 'license' => CAKE . 'LICENSE.txt')); - $expected = array( - 'basics.php' => array('file' => CAKE . 'basics.php', 'mimetype' => 'text/plain'), - 'bootstrap.php' => array('file' => CAKE . 'bootstrap.php', 'mimetype' => 'application/octet-stream'), - 'other.txt' => array('file' => CAKE . 'bootstrap.php', 'mimetype' => 'application/octet-stream'), - 'license' => array('file' => CAKE . 'LICENSE.txt', 'mimetype' => 'application/octet-stream') - ); - $this->assertSame($this->CakeEmail->attachments(), $expected); - - $this->setExpectedException('SocketException'); - $this->CakeEmail->attachments(array(array('nofile' => CAKE . 'basics.php', 'mimetype' => 'text/plain'))); - } - -/** - * testTransport method - * - * @return void - */ - public function testTransport() { - $result = $this->CakeEmail->transport('Debug'); - $this->assertSame($this->CakeEmail, $result); - $this->assertSame($this->CakeEmail->transport(), 'Debug'); - - $result = $this->CakeEmail->transportClass(); - $this->assertInstanceOf('DebugTransport', $result); - - $this->setExpectedException('SocketException'); - $this->CakeEmail->transport('Invalid'); - $result = $this->CakeEmail->transportClass(); - } - -/** - * testExtendTransport method - * - * @return void - */ - public function testExtendTransport() { - $this->setExpectedException('SocketException'); - $this->CakeEmail->transport('Extend'); - $result = $this->CakeEmail->transportClass(); - } - -/** - * testConfig method - * - * @return void - */ - public function testConfig() { - $transportClass = $this->CakeEmail->transport('debug')->transportClass(); - - $config = array('test' => 'ok', 'test2' => true); - $this->CakeEmail->config($config); - $this->assertSame($transportClass->config(), $config); - $this->assertSame($this->CakeEmail->config(), $config); - - $this->CakeEmail->config(array()); - $this->assertSame($transportClass->config(), array()); - } - -/** - * testConfigString method - * - * @return void - */ - public function testConfigString() { - $configs = new EmailConfig(); - $this->CakeEmail->config('test'); - - $result = $this->CakeEmail->to(); - $this->assertEquals($configs->test['to'], $result); - - $result = $this->CakeEmail->from(); - $this->assertEquals($configs->test['from'], $result); - - $result = $this->CakeEmail->subject(); - $this->assertEquals($configs->test['subject'], $result); - - $result = $this->CakeEmail->transport(); - $this->assertEquals($configs->test['transport'], $result); - - $result = $this->CakeEmail->transportClass(); - $this->assertInstanceOf('DebugTransport', $result); - } - -/** - * testSendWithContent method - * - * @return void - */ - public function testSendWithContent() { - $this->CakeEmail->reset(); - $this->CakeEmail->transport('Debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - - $result = $this->CakeEmail->send("Here is my body, with multi lines.\nThis is the second line.\r\n\r\nAnd the last."); - $expected = array('headers', 'message'); - $this->assertEquals($expected, array_keys($result)); - $expected = "Here is my body, with multi lines.\r\nThis is the second line.\r\n\r\nAnd the last.\r\n\r\n"; - - $this->assertEquals($expected, $result['message']); - $this->assertTrue((bool)strpos($result['headers'], 'Date: ')); - $this->assertTrue((bool)strpos($result['headers'], 'Message-ID: ')); - $this->assertTrue((bool)strpos($result['headers'], 'To: ')); - - $result = $this->CakeEmail->send("Other body"); - $expected = "Other body\r\n\r\n"; - $this->assertSame($result['message'], $expected); - $this->assertTrue((bool)strpos($result['headers'], 'Message-ID: ')); - $this->assertTrue((bool)strpos($result['headers'], 'To: ')); - - $this->CakeEmail->reset(); - $this->CakeEmail->transport('Debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $result = $this->CakeEmail->send(array('Sending content', 'As array')); - $expected = "Sending content\r\nAs array\r\n\r\n\r\n"; - $this->assertSame($result['message'], $expected); - } - -/** - * testSendWithoutFrom method - * - * @return void - */ - public function testSendWithoutFrom() { - $this->CakeEmail->transport('Debug'); - $this->CakeEmail->to('cake@cakephp.org'); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->setExpectedException('SocketException'); - $this->CakeEmail->send("Forgot to set From"); - } - -/** - * testSendWithoutTo method - * - * @return void - */ - public function testSendWithoutTo() { - $this->CakeEmail->transport('Debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->setExpectedException('SocketException'); - $this->CakeEmail->send("Forgot to set To"); - } - -/** - * Test send() with no template. - * - * @return void - */ - public function testSendNoTemplateWithAttachments() { - $this->CakeEmail->transport('debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to('cake@cakephp.org'); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->emailFormat('text'); - $this->CakeEmail->attachments(array(CAKE . 'basics.php')); - $result = $this->CakeEmail->send('Hello'); - - $boundary = $this->CakeEmail->getBoundary(); - $this->assertContains('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); - $expected = "--$boundary\r\n" . - "Content-Type: text/plain; charset=UTF-8\r\n" . - "Content-Transfer-Encoding: 8bit\r\n" . - "\r\n" . - "Hello" . - "\r\n" . - "\r\n" . - "\r\n" . - "--$boundary\r\n" . - "Content-Type: application/octet-stream\r\n" . - "Content-Transfer-Encoding: base64\r\n" . - "Content-Disposition: attachment; filename=\"basics.php\"\r\n\r\n"; - $this->assertContains($expected, $result['message']); - } - -/** - * Test send() with no template as both - * - * @return void - */ - public function testSendNoTemplateWithAttachmentsAsBoth() { - $this->CakeEmail->transport('debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to('cake@cakephp.org'); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->emailFormat('both'); - $this->CakeEmail->attachments(array(CAKE . 'VERSION.txt')); - $result = $this->CakeEmail->send('Hello'); - - $boundary = $this->CakeEmail->getBoundary(); - $this->assertContains('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); - $expected = "--$boundary\r\n" . - "Content-Type: multipart/alternative; boundary=\"alt-$boundary\"\r\n" . - "\r\n" . - "--alt-$boundary\r\n" . - "Content-Type: text/plain; charset=UTF-8\r\n" . - "Content-Transfer-Encoding: 8bit\r\n" . - "\r\n" . - "Hello" . - "\r\n" . - "\r\n" . - "\r\n" . - "--alt-$boundary\r\n" . - "Content-Type: text/html; charset=UTF-8\r\n" . - "Content-Transfer-Encoding: 8bit\r\n" . - "\r\n" . - "Hello" . - "\r\n" . - "\r\n" . - "\r\n" . - "--alt-{$boundary}--\r\n" . - "\r\n" . - "--$boundary\r\n" . - "Content-Type: application/octet-stream\r\n" . - "Content-Transfer-Encoding: base64\r\n" . - "Content-Disposition: attachment; filename=\"VERSION.txt\"\r\n\r\n"; - $this->assertContains($expected, $result['message']); - } - -/** - * Test setting inline attachments and messages. - * - * @return void - */ - public function testSendWithInlineAttachments() { - $this->CakeEmail->transport('debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to('cake@cakephp.org'); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->emailFormat('both'); - $this->CakeEmail->attachments(array( - 'cake.png' => array( - 'file' => CAKE . 'VERSION.txt', - 'contentId' => 'abc123' - ) - )); - $result = $this->CakeEmail->send('Hello'); - - $boundary = $this->CakeEmail->getBoundary(); - $this->assertContains('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); - $expected = "--$boundary\r\n" . - "Content-Type: multipart/related; boundary=\"rel-$boundary\"\r\n" . - "\r\n" . - "--rel-$boundary\r\n" . - "Content-Type: multipart/alternative; boundary=\"alt-$boundary\"\r\n" . - "\r\n" . - "--alt-$boundary\r\n" . - "Content-Type: text/plain; charset=UTF-8\r\n" . - "Content-Transfer-Encoding: 8bit\r\n" . - "\r\n" . - "Hello" . - "\r\n" . - "\r\n" . - "\r\n" . - "--alt-$boundary\r\n" . - "Content-Type: text/html; charset=UTF-8\r\n" . - "Content-Transfer-Encoding: 8bit\r\n" . - "\r\n" . - "Hello" . - "\r\n" . - "\r\n" . - "\r\n" . - "--alt-{$boundary}--\r\n" . - "\r\n" . - "--rel-$boundary\r\n" . - "Content-Type: application/octet-stream\r\n" . - "Content-Transfer-Encoding: base64\r\n" . - "Content-ID: \r\n" . - "Content-Disposition: inline; filename=\"cake.png\"\r\n\r\n"; - $this->assertContains($expected, $result['message']); - $this->assertContains('--rel-' . $boundary . '--', $result['message']); - $this->assertContains('--' . $boundary . '--', $result['message']); - } - -/** - * testSendWithLog method - * - * @return void - */ - public function testSendWithLog() { - $path = CAKE . 'Test' . DS . 'test_app' . DS . 'tmp' . DS; - CakeLog::config('email', array( - 'engine' => 'FileLog', - 'path' => TMP - )); - CakeLog::drop('default'); - $this->CakeEmail->transport('Debug'); - $this->CakeEmail->to('me@cakephp.org'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('log' => 'cake_test_emails')); - $result = $this->CakeEmail->send("Logging This"); - - App::uses('File', 'Utility'); - $File = new File(TMP . 'cake_test_emails.log'); - $log = $File->read(); - $this->assertTrue(strpos($log, $result['headers']) !== false); - $this->assertTrue(strpos($log, $result['message']) !== false); - $File->delete(); - CakeLog::drop('email'); - } - -/** - * testSendRender method - * - * @return void - */ - public function testSendRender() { - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->CakeEmail->template('default', 'default'); - $result = $this->CakeEmail->send(); - - $this->assertContains('This email was sent using the CakePHP Framework', $result['message']); - $this->assertContains('Message-ID: ', $result['headers']); - $this->assertContains('To: ', $result['headers']); - } - -/** - * testSendRender method for ISO-2022-JP - * - * @return void - */ - public function testSendRenderJapanese() { - $this->skipIf(!function_exists('mb_convert_encoding')); - - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->CakeEmail->template('default', 'japanese'); - $this->CakeEmail->charset = 'ISO-2022-JP'; - $result = $this->CakeEmail->send(); - - $expected = mb_convert_encoding('CakePHP Framework を使って送信したメールです。 http://cakephp.org.', 'ISO-2022-JP'); - $this->assertContains($expected, $result['message']); - $this->assertContains('Message-ID: ', $result['headers']); - $this->assertContains('To: ', $result['headers']); - } - -/** - * testSendRenderWithVars method - * - * @return void - */ - public function testSendRenderWithVars() { - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->CakeEmail->template('custom', 'default'); - $this->CakeEmail->viewVars(array('value' => 12345)); - $result = $this->CakeEmail->send(); - - $this->assertContains('Here is your value: 12345', $result['message']); - } - -/** - * testSendRenderWithVars method for ISO-2022-JP - * - * @return void - */ - public function testSendRenderWithVarsJapanese() { - $this->skipIf(!function_exists('mb_convert_encoding')); - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->CakeEmail->template('japanese', 'default'); - $this->CakeEmail->viewVars(array('value' => '日本語の差し込み123')); - $this->CakeEmail->charset = 'ISO-2022-JP'; - $result = $this->CakeEmail->send(); - - $expected = mb_convert_encoding('ここにあなたの設定した値が入ります: 日本語の差し込み123', 'ISO-2022-JP'); - $this->assertTrue((bool)strpos($result['message'], $expected)); - } - -/** - * testSendRenderWithHelpers method - * - * @return void - */ - public function testSendRenderWithHelpers() { - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - - $timestamp = time(); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->CakeEmail->template('custom_helper', 'default'); - $this->CakeEmail->viewVars(array('time' => $timestamp)); - - $result = $this->CakeEmail->helpers(array('Time')); - $this->assertInstanceOf('CakeEmail', $result); - - $result = $this->CakeEmail->send(); - $this->assertTrue((bool)strpos($result['message'], 'Right now: ' . date('Y-m-d\TH:i:s\Z', $timestamp))); - - $result = $this->CakeEmail->helpers(); - $this->assertEquals(array('Time'), $result); - } - -/** - * testSendRenderPlugin method - * - * @return void - */ - public function testSendRenderPlugin() { - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - )); - CakePlugin::load('TestPlugin'); - - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - - $result = $this->CakeEmail->template('TestPlugin.test_plugin_tpl', 'default')->send(); - $this->assertContains('Into TestPlugin.', $result['message']); - $this->assertContains('This email was sent using the CakePHP Framework', $result['message']); - - $result = $this->CakeEmail->template('TestPlugin.test_plugin_tpl', 'TestPlugin.plug_default')->send(); - $this->assertContains('Into TestPlugin.', $result['message']); - $this->assertContains('This email was sent using the TestPlugin.', $result['message']); - - $result = $this->CakeEmail->template('TestPlugin.test_plugin_tpl', 'plug_default')->send(); - $this->assertContains('Into TestPlugin.', $result['message']); - $this->assertContains('This email was sent using the TestPlugin.', $result['message']); - - $this->CakeEmail->viewVars(array('value' => 12345)); - $result = $this->CakeEmail->template('custom', 'TestPlugin.plug_default')->send(); - $this->assertContains('Here is your value: 12345', $result['message']); - $this->assertContains('This email was sent using the TestPlugin.', $result['message']); - - $this->setExpectedException('MissingViewException'); - $this->CakeEmail->template('test_plugin_tpl', 'plug_default')->send(); - } - -/** - * testSendMultipleMIME method - * - * @return void - */ - public function testSendMultipleMIME() { - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->template('custom', 'default'); - $this->CakeEmail->config(array()); - $this->CakeEmail->viewVars(array('value' => 12345)); - $this->CakeEmail->emailFormat('both'); - $result = $this->CakeEmail->send(); - - $message = $this->CakeEmail->message(); - $boundary = $this->CakeEmail->getBoundary(); - $this->assertFalse(empty($boundary)); - $this->assertContains('--' . $boundary, $message); - $this->assertContains('--' . $boundary . '--', $message); - $this->assertContains('--alt-' . $boundary, $message); - $this->assertContains('--alt-' . $boundary . '--', $message); - - $this->CakeEmail->attachments(array('fake.php' => __FILE__)); - $this->CakeEmail->send(); - - $message = $this->CakeEmail->message(); - $boundary = $this->CakeEmail->getBoundary(); - $this->assertFalse(empty($boundary)); - $this->assertContains('--' . $boundary, $message); - $this->assertContains('--' . $boundary . '--', $message); - $this->assertContains('--alt-' . $boundary, $message); - $this->assertContains('--alt-' . $boundary . '--', $message); - } - -/** - * testSendAttachment method - * - * @return void - */ - public function testSendAttachment() { - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array()); - $this->CakeEmail->attachments(array(CAKE . 'basics.php')); - $result = $this->CakeEmail->send('body'); - $this->assertContains("Content-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"basics.php\"", $result['message']); - - $this->CakeEmail->attachments(array('my.file.txt' => CAKE . 'basics.php')); - $result = $this->CakeEmail->send('body'); - $this->assertContains("Content-Type: application/octet-stream\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"my.file.txt\"", $result['message']); - - $this->CakeEmail->attachments(array('file.txt' => array('file' => CAKE . 'basics.php', 'mimetype' => 'text/plain'))); - $result = $this->CakeEmail->send('body'); - $this->assertContains("Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=\"file.txt\"", $result['message']); - - $this->CakeEmail->attachments(array('file2.txt' => array('file' => CAKE . 'basics.php', 'mimetype' => 'text/plain', 'contentId' => 'a1b1c1'))); - $result = $this->CakeEmail->send('body'); - $this->assertContains("Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\nContent-ID: \r\nContent-Disposition: inline; filename=\"file2.txt\"", $result['message']); - } - -/** - * testDeliver method - * - * @return void - */ - public function testDeliver() { - $instance = CakeEmail::deliver('all@cakephp.org', 'About', 'Everything ok', array('from' => 'root@cakephp.org'), false); - $this->assertInstanceOf('CakeEmail', $instance); - $this->assertSame($instance->to(), array('all@cakephp.org' => 'all@cakephp.org')); - $this->assertSame($instance->subject(), 'About'); - $this->assertSame($instance->from(), array('root@cakephp.org' => 'root@cakephp.org')); - - $config = array( - 'from' => 'cake@cakephp.org', - 'to' => 'debug@cakephp.org', - 'subject' => 'Update ok', - 'template' => 'custom', - 'layout' => 'custom_layout', - 'viewVars' => array('value' => 123), - 'cc' => array('cake@cakephp.org' => 'Myself') - ); - $instance = CakeEmail::deliver(null, null, array('name' => 'CakePHP'), $config, false); - $this->assertSame($instance->from(), array('cake@cakephp.org' => 'cake@cakephp.org')); - $this->assertSame($instance->to(), array('debug@cakephp.org' => 'debug@cakephp.org')); - $this->assertSame($instance->subject(), 'Update ok'); - $this->assertSame($instance->template(), array('template' => 'custom', 'layout' => 'custom_layout')); - $this->assertSame($instance->viewVars(), array('value' => 123, 'name' => 'CakePHP')); - $this->assertSame($instance->cc(), array('cake@cakephp.org' => 'Myself')); - - $configs = array('from' => 'root@cakephp.org', 'message' => 'Message from configs', 'transport' => 'Debug'); - $instance = CakeEmail::deliver('all@cakephp.org', 'About', null, $configs, true); - $message = $instance->message(); - $this->assertEquals($configs['message'], $message[0]); - } - -/** - * testMessage method - * - * @return void - */ - public function testMessage() { - $this->CakeEmail->reset(); - $this->CakeEmail->transport('debug'); - $this->CakeEmail->from('cake@cakephp.org'); - $this->CakeEmail->to(array('you@cakephp.org' => 'You')); - $this->CakeEmail->subject('My title'); - $this->CakeEmail->config(array('empty')); - $this->CakeEmail->template('default', 'default'); - $this->CakeEmail->emailFormat('both'); - $result = $this->CakeEmail->send(); - - $expected = '

This email was sent using the CakePHP Framework

'; - $this->assertContains($expected, $this->CakeEmail->message(CakeEmail::MESSAGE_HTML)); - - $expected = 'This email was sent using the CakePHP Framework, http://cakephp.org.'; - $this->assertContains($expected, $this->CakeEmail->message(CakeEmail::MESSAGE_TEXT)); - - $message = $this->CakeEmail->message(); - $this->assertContains('Content-Type: text/plain; charset=UTF-8', $message); - $this->assertContains('Content-Type: text/html; charset=UTF-8', $message); - - // UTF-8 is 8bit - $this->assertTrue($this->checkContentTransferEncoding($message, '8bit')); - - $this->CakeEmail->charset = 'ISO-2022-JP'; - $this->CakeEmail->send(); - $message = $this->CakeEmail->message(); - $this->assertContains('Content-Type: text/plain; charset=ISO-2022-JP', $message); - $this->assertContains('Content-Type: text/html; charset=ISO-2022-JP', $message); - - // ISO-2022-JP is 7bit - $this->assertTrue($this->checkContentTransferEncoding($message, '7bit')); - } - -/** - * testReset method - * - * @return void - */ - public function testReset() { - $this->CakeEmail->to('cake@cakephp.org'); - $this->assertSame($this->CakeEmail->to(), array('cake@cakephp.org' => 'cake@cakephp.org')); - - $this->CakeEmail->reset(); - $this->assertSame($this->CakeEmail->to(), array()); - } - -/** - * testWrap method - * - * @return void - */ - public function testWrap() { - $text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac turpis orci, non commodo odio. Morbi nibh nisi, vehicula pellentesque accumsan amet.'; - $result = $this->CakeEmail->wrap($text); - $expected = array( - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac turpis orci,', - 'non commodo odio. Morbi nibh nisi, vehicula pellentesque accumsan amet.', - '' - ); - $this->assertSame($expected, $result); - - $text = 'Lorem ipsum dolor sit amet, consectetur < adipiscing elit. Donec ac turpis orci, non commodo odio. Morbi nibh nisi, vehicula > pellentesque accumsan amet.'; - $result = $this->CakeEmail->wrap($text); - $expected = array( - 'Lorem ipsum dolor sit amet, consectetur < adipiscing elit. Donec ac turpis', - 'orci, non commodo odio. Morbi nibh nisi, vehicula > pellentesque accumsan', - 'amet.', - '' - ); - $this->assertSame($expected, $result); - - $text = '

Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
Donec ac turpis orci, non commodo odio.
Morbi nibh nisi, vehicula pellentesque accumsan amet.


'; - $result = $this->CakeEmail->wrap($text); - $expected = array( - '

Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
Donec ac', - 'turpis orci, non commodo odio.
Morbi nibh nisi, vehicula', - 'pellentesque accumsan amet.


', - '' - ); - $this->assertSame($expected, $result); - - $text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac turpis orci, non commodo odio. Morbi nibh nisi, vehicula pellentesque accumsan amet.'; - $result = $this->CakeEmail->wrap($text); - $expected = array( - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac', - 'turpis orci, non commodo odio. Morbi nibh', - 'nisi, vehicula pellentesque accumsan amet.', - '' - ); - $this->assertSame($expected, $result); - - $text = 'Lorem ipsum ok'; - $result = $this->CakeEmail->wrap($text); - $expected = array( - 'Lorem ipsum', - '', - 'ok', - '' - ); - $this->assertSame($expected, $result); - - $text = 'Lorem ipsum withonewordverybigMorethanthelineshouldsizeofrfcspecificationbyieeeavailableonieeesite ok.'; - $result = $this->CakeEmail->wrap($text); - $expected = array( - 'Lorem ipsum', - 'withonewordverybigMorethanthelineshouldsizeofrfcspecificationbyieeeavailableonieeesite', - 'ok.', - '' - ); - $this->assertSame($expected, $result); - } - -/** - * testConstructWithConfigArray method - * - * @return void - */ - public function testConstructWithConfigArray() { - $configs = array( - 'from' => array('some@example.com' => 'My website'), - 'to' => 'test@example.com', - 'subject' => 'Test mail subject', - 'transport' => 'Debug', - ); - $this->CakeEmail = new CakeEmail($configs); - - $result = $this->CakeEmail->to(); - $this->assertEquals(array($configs['to'] => $configs['to']), $result); - - $result = $this->CakeEmail->from(); - $this->assertEquals($configs['from'], $result); - - $result = $this->CakeEmail->subject(); - $this->assertEquals($configs['subject'], $result); - - $result = $this->CakeEmail->transport(); - $this->assertEquals($configs['transport'], $result); - - $result = $this->CakeEmail->transportClass(); - $this->assertTrue($result instanceof DebugTransport); - - $result = $this->CakeEmail->send('This is the message'); - - $this->assertTrue((bool)strpos($result['headers'], 'Message-ID: ')); - $this->assertTrue((bool)strpos($result['headers'], 'To: ')); - } - -/** - * testConstructWithConfigString method - * - * @return void - */ - public function testConstructWithConfigString() { - $configs = new EmailConfig(); - $this->CakeEmail = new CakeEmail('test'); - - $result = $this->CakeEmail->to(); - $this->assertEquals($configs->test['to'], $result); - - $result = $this->CakeEmail->from(); - $this->assertEquals($configs->test['from'], $result); - - $result = $this->CakeEmail->subject(); - $this->assertEquals($configs->test['subject'], $result); - - $result = $this->CakeEmail->transport(); - $this->assertEquals($configs->test['transport'], $result); - - $result = $this->CakeEmail->transportClass(); - $this->assertTrue($result instanceof DebugTransport); - - $result = $this->CakeEmail->send('This is the message'); - - $this->assertTrue((bool)strpos($result['headers'], 'Message-ID: ')); - $this->assertTrue((bool)strpos($result['headers'], 'To: ')); - } - -/** - * testViewRender method - * - * @return void - */ - public function testViewRender() { - $result = $this->CakeEmail->viewRender(); - $this->assertEquals('View', $result); - - $result = $this->CakeEmail->viewRender('Theme'); - $this->assertInstanceOf('CakeEmail', $result); - - $result = $this->CakeEmail->viewRender(); - $this->assertEquals('Theme', $result); - } - -/** - * testEmailFormat method - * - * @return void - */ - public function testEmailFormat() { - $result = $this->CakeEmail->emailFormat(); - $this->assertEquals('text', $result); - - $result = $this->CakeEmail->emailFormat('html'); - $this->assertInstanceOf('CakeEmail', $result); - - $result = $this->CakeEmail->emailFormat(); - $this->assertEquals('html', $result); - - $this->setExpectedException('SocketException'); - $result = $this->CakeEmail->emailFormat('invalid'); - } - -/** - * Tests that it is possible to add charset configuration to a CakeEmail object - * - * @return void - */ - public function testConfigCharset() { - $email = new CakeEmail(); - $this->assertEquals(Configure::read('App.encoding'), $email->charset); - $this->assertEquals(Configure::read('App.encoding'), $email->headerCharset); - - $email = new CakeEmail(array('charset' => 'iso-2022-jp', 'headerCharset' => 'iso-2022-jp-ms')); - $this->assertEquals('iso-2022-jp', $email->charset); - $this->assertEquals('iso-2022-jp-ms', $email->headerCharset); - - $email = new CakeEmail(array('charset' => 'iso-2022-jp')); - $this->assertEquals('iso-2022-jp', $email->charset); - $this->assertEquals('iso-2022-jp', $email->headerCharset); - - $email = new CakeEmail(array('headerCharset' => 'iso-2022-jp-ms')); - $this->assertEquals(Configure::read('App.encoding'), $email->charset); - $this->assertEquals('iso-2022-jp-ms', $email->headerCharset); - } - -/** - * Tests that the header is encoded using the configured headerCharset - * - * @return void - */ - public function testHeaderEncoding() { - $this->skipIf(!function_exists('mb_convert_encoding')); - $email = new CakeEmail(array('headerCharset' => 'iso-2022-jp-ms', 'transport' => 'Debug')); - $email->subject('あれ?もしかしての前と'); - $headers = $email->getHeaders(array('subject')); - $expected = "?ISO-2022-JP?B?GyRCJCIkbCEpJGIkNyQrJDckRiROQTAkSBsoQg==?="; - $this->assertContains($expected, $headers['Subject']); - - $email->to('someone@example.com')->from('someone@example.com'); - $result = $email->send('ってテーブルを作ってやってたらう'); - $this->assertContains('ってテーブルを作ってやってたらう', $result['message']); - } - -/** - * Tests that the body is encoded using the configured charset - * - * @return void - */ - public function testBodyEncoding() { - $this->skipIf(!function_exists('mb_convert_encoding')); - $email = new CakeEmail(array( - 'charset' => 'iso-2022-jp', - 'headerCharset' => 'iso-2022-jp-ms', - 'transport' => 'Debug' - )); - $email->subject('あれ?もしかしての前と'); - $headers = $email->getHeaders(array('subject')); - $expected = "?ISO-2022-JP?B?GyRCJCIkbCEpJGIkNyQrJDckRiROQTAkSBsoQg==?="; - $this->assertContains($expected, $headers['Subject']); - - $email->to('someone@example.com')->from('someone@example.com'); - $result = $email->send('ってテーブルを作ってやってたらう'); - $this->assertContains('Content-Type: text/plain; charset=iso-2022-jp', $result['headers']); - $this->assertContains(mb_convert_encoding('ってテーブルを作ってやってたらう','ISO-2022-JP'), $result['message']); - } - - private function checkContentTransferEncoding($message, $charset) { - $boundary = '--alt-' . $this->CakeEmail->getBoundary(); - $result['text'] = false; - $result['html'] = false; - for ($i = 0; $i < count($message); ++$i) { - if ($message[$i] == $boundary) { - $flag = false; - $type = ''; - while (!preg_match('/^$/', $message[$i])) { - if (preg_match('/^Content-Type: text\/plain/', $message[$i])) { - $type = 'text'; - } - if (preg_match('/^Content-Type: text\/html/', $message[$i])) { - $type = 'html'; - } - if ($message[$i] === 'Content-Transfer-Encoding: ' . $charset) { - $flag = true; - } - ++$i; - } - $result[$type] = $flag; - } - } - return $result['text'] && $result['html']; - } - -/** - * Test CakeEmail::_encode function - * - */ - public function testEncode() { - $this->skipIf(!function_exists('mb_convert_encoding')); - - $this->CakeEmail->headerCharset = 'ISO-2022-JP'; - $result = $this->CakeEmail->encode('日本語'); - $expected = '=?ISO-2022-JP?B?GyRCRnxLXDhsGyhC?='; - $this->assertSame($expected, $result); - - $this->CakeEmail->headerCharset = 'ISO-2022-JP'; - $result = $this->CakeEmail->encode('長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'); - $expected = "=?ISO-2022-JP?B?GyRCRDkkJEQ5JCREOSQkGyhCU3ViamVjdBskQiROPmw5ZyRPGyhCZm9s?=\r\n" . - " =?ISO-2022-JP?B?ZGluZxskQiQ5JGskTiQsQDUkNyQkJHMkQCQxJEkkJCRDJD8kJCRJGyhC?=\r\n" . - " =?ISO-2022-JP?B?GyRCJCYkSiRrJHMkQCRtJCYhKRsoQg==?="; - $this->assertSame($expected, $result); - } -} diff --git a/lib/Cake/Test/Case/Network/Email/DebugTransportTest.php b/lib/Cake/Test/Case/Network/Email/DebugTransportTest.php deleted file mode 100644 index 72fe704c85c..00000000000 --- a/lib/Cake/Test/Case/Network/Email/DebugTransportTest.php +++ /dev/null @@ -1,76 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network.Email - * @since CakePHP(tm) v 2.0.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('CakeEmail', 'Network/Email'); -App::uses('AbstractTransport', 'Network/Email'); -App::uses('DebugTransport', 'Network/Email'); - -/** - * Test case - * - */ -class DebugTransportTest extends CakeTestCase { - -/** - * Setup - * - * @return void - */ - public function setUp() { - $this->DebugTransport = new DebugTransport(); - } - -/** - * testSend method - * - * @return void - */ - public function testSend() { - $this->getMock('CakeEmail', array('message'), array(), 'DebugCakeEmail'); - $email = new DebugCakeEmail(); - $email->from('noreply@cakephp.org', 'CakePHP Test'); - $email->to('cake@cakephp.org', 'CakePHP'); - $email->cc(array('mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso')); - $email->bcc('phpnut@cakephp.org'); - $email->messageID('<4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>'); - $email->subject('Testing Message'); - $date = date(DATE_RFC2822); - $email->setHeaders(array('X-Mailer' => DebugCakeEmail::EMAIL_CLIENT, 'Date' => $date)); - $email->expects($this->any())->method('message')->will($this->returnValue(array('First Line', 'Second Line', ''))); - - $headers = "From: CakePHP Test \r\n"; - $headers .= "To: CakePHP \r\n"; - $headers .= "Cc: Mark Story , Juan Basso \r\n"; - $headers .= "X-Mailer: CakePHP Email\r\n"; - $headers .= "Date: " . $date . "\r\n"; - $headers .= "Message-ID: <4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>\r\n"; - $headers .= "Subject: Testing Message\r\n"; - $headers .= "MIME-Version: 1.0\r\n"; - $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; - $headers .= "Content-Transfer-Encoding: 8bit"; - - $data = "First Line\r\n"; - $data .= "Second Line\r\n"; - - $result = $this->DebugTransport->send($email); - - $this->assertEquals($headers, $result['headers']); - $this->assertEquals($data, $result['message']); - } - -} \ No newline at end of file diff --git a/lib/Cake/Test/Case/Network/Email/SmtpTransportTest.php b/lib/Cake/Test/Case/Network/Email/SmtpTransportTest.php deleted file mode 100644 index 86591d5f8ff..00000000000 --- a/lib/Cake/Test/Case/Network/Email/SmtpTransportTest.php +++ /dev/null @@ -1,264 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network.Email - * @since CakePHP(tm) v 2.0.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('CakeEmail', 'Network/Email'); -App::uses('AbstractTransport', 'Network/Email'); -App::uses('SmtpTransport', 'Network/Email'); - -/** - * Help to test SmtpTransport - * - */ -class SmtpTestTransport extends SmtpTransport { - -/** - * Helper to change the socket - * - * @param object $socket - * @return void - */ - public function setSocket(CakeSocket $socket) { - $this->_socket = $socket; - } - -/** - * Helper to change the CakeEmail - * - * @param object $cakeEmail - * @return void - */ - public function setCakeEmail($cakeEmail) { - $this->_cakeEmail = $cakeEmail; - } - -/** - * Disabled the socket change - * - * @return void - */ - protected function _generateSocket() { - } - -/** - * Magic function to call protected methods - * - * @param string $method - * @param string $args - * @return mixed - */ - public function __call($method, $args) { - $method = '_' . $method; - return $this->$method(); - } - -} - -/** - * Test case - * - */ -class SmtpTransportTest extends CakeTestCase { - -/** - * Setup - * - * @return void - */ - public function setUp() { - if (!class_exists('MockSocket')) { - $this->getMock('CakeSocket', array('read', 'write', 'connect'), array(), 'MockSocket'); - } - $this->socket = new MockSocket(); - - $this->SmtpTransport = new SmtpTestTransport(); - $this->SmtpTransport->setSocket($this->socket); - $this->SmtpTransport->config(array('client' => 'localhost')); - } - -/** - * testConnectEhlo method - * - * @return void - */ - public function testConnectEhlo() { - $this->socket->expects($this->any())->method('connect')->will($this->returnValue(true)); - $this->socket->expects($this->at(0))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(1))->method('read')->will($this->returnValue("220 Welcome message\r\n")); - $this->socket->expects($this->at(2))->method('write')->with("EHLO localhost\r\n"); - $this->socket->expects($this->at(3))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(4))->method('read')->will($this->returnValue("250 Accepted\r\n")); - $this->SmtpTransport->connect(); - } - -/** - * testConnectHelo method - * - * @return void - */ - public function testConnectHelo() { - $this->socket->expects($this->any())->method('connect')->will($this->returnValue(true)); - $this->socket->expects($this->at(0))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(1))->method('read')->will($this->returnValue("220 Welcome message\r\n")); - $this->socket->expects($this->at(2))->method('write')->with("EHLO localhost\r\n"); - $this->socket->expects($this->at(3))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(4))->method('read')->will($this->returnValue("200 Not Accepted\r\n")); - $this->socket->expects($this->at(5))->method('write')->with("HELO localhost\r\n"); - $this->socket->expects($this->at(6))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(7))->method('read')->will($this->returnValue("250 Accepted\r\n")); - $this->SmtpTransport->connect(); - } - -/** - * testConnectFail method - * - * @expectedException SocketException - * @return void - */ - public function testConnectFail() { - $this->socket->expects($this->any())->method('connect')->will($this->returnValue(true)); - $this->socket->expects($this->at(0))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(1))->method('read')->will($this->returnValue("220 Welcome message\r\n")); - $this->socket->expects($this->at(2))->method('write')->with("EHLO localhost\r\n"); - $this->socket->expects($this->at(3))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(4))->method('read')->will($this->returnValue("200 Not Accepted\r\n")); - $this->socket->expects($this->at(5))->method('write')->with("HELO localhost\r\n"); - $this->socket->expects($this->at(6))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(7))->method('read')->will($this->returnValue("200 Not Accepted\r\n")); - $this->SmtpTransport->connect(); - } - -/** - * testAuth method - * - * @return void - */ - public function testAuth() { - $this->socket->expects($this->at(0))->method('write')->with("AUTH LOGIN\r\n"); - $this->socket->expects($this->at(1))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(2))->method('read')->will($this->returnValue("334 Login\r\n")); - $this->socket->expects($this->at(3))->method('write')->with("bWFyaw==\r\n"); - $this->socket->expects($this->at(4))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(5))->method('read')->will($this->returnValue("334 Pass\r\n")); - $this->socket->expects($this->at(6))->method('write')->with("c3Rvcnk=\r\n"); - $this->socket->expects($this->at(7))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(8))->method('read')->will($this->returnValue("235 OK\r\n")); - $this->SmtpTransport->config(array('username' => 'mark', 'password' => 'story')); - $this->SmtpTransport->auth(); - } - -/** - * testAuthNoAuth method - * - * @return void - */ - public function testAuthNoAuth() { - $this->socket->expects($this->never())->method('write')->with("AUTH LOGIN\r\n"); - $this->SmtpTransport->config(array('username' => null, 'password' => null)); - $this->SmtpTransport->auth(); - } - -/** - * testRcpt method - * - * @return void - */ - public function testRcpt() { - $email = new CakeEmail(); - $email->from('noreply@cakephp.org', 'CakePHP Test'); - $email->to('cake@cakephp.org', 'CakePHP'); - $email->bcc('phpnut@cakephp.org'); - $email->cc(array('mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso')); - - $this->socket->expects($this->at(0))->method('write')->with("MAIL FROM:\r\n"); - $this->socket->expects($this->at(1))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(2))->method('read')->will($this->returnValue("250 OK\r\n")); - $this->socket->expects($this->at(3))->method('write')->with("RCPT TO:\r\n"); - $this->socket->expects($this->at(4))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(5))->method('read')->will($this->returnValue("250 OK\r\n")); - $this->socket->expects($this->at(6))->method('write')->with("RCPT TO:\r\n"); - $this->socket->expects($this->at(7))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(8))->method('read')->will($this->returnValue("250 OK\r\n")); - $this->socket->expects($this->at(9))->method('write')->with("RCPT TO:\r\n"); - $this->socket->expects($this->at(10))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(11))->method('read')->will($this->returnValue("250 OK\r\n")); - $this->socket->expects($this->at(12))->method('write')->with("RCPT TO:\r\n"); - $this->socket->expects($this->at(13))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(14))->method('read')->will($this->returnValue("250 OK\r\n")); - - $this->SmtpTransport->setCakeEmail($email); - $this->SmtpTransport->sendRcpt(); - } - -/** - * testSendData method - * - * @return void - */ - public function testSendData() { - $this->getMock('CakeEmail', array('message'), array(), 'SmtpCakeEmail'); - $email = new SmtpCakeEmail(); - $email->from('noreply@cakephp.org', 'CakePHP Test'); - $email->returnPath('pleasereply@cakephp.org', 'CakePHP Return'); - $email->to('cake@cakephp.org', 'CakePHP'); - $email->cc(array('mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso')); - $email->bcc('phpnut@cakephp.org'); - $email->messageID('<4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>'); - $email->subject('Testing SMTP'); - $date = date(DATE_RFC2822); - $email->setHeaders(array('X-Mailer' => SmtpCakeEmail::EMAIL_CLIENT, 'Date' => $date)); - $email->expects($this->any())->method('message')->will($this->returnValue(array('First Line', 'Second Line', ''))); - - $data = "From: CakePHP Test \r\n"; - $data .= "Return-Path: CakePHP Return \r\n"; - $data .= "To: CakePHP \r\n"; - $data .= "Cc: Mark Story , Juan Basso \r\n"; - $data .= "X-Mailer: CakePHP Email\r\n"; - $data .= "Date: " . $date . "\r\n"; - $data .= "Message-ID: <4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>\r\n"; - $data .= "Subject: Testing SMTP\r\n"; - $data .= "MIME-Version: 1.0\r\n"; - $data .= "Content-Type: text/plain; charset=UTF-8\r\n"; - $data .= "Content-Transfer-Encoding: 8bit\r\n"; - $data .= "\r\n"; - $data .= "First Line\r\n"; - $data .= "Second Line\r\n"; - $data .= "\r\n"; - $data .= "\r\n\r\n.\r\n"; - - $this->socket->expects($this->at(0))->method('write')->with("DATA\r\n"); - $this->socket->expects($this->at(1))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(2))->method('read')->will($this->returnValue("354 OK\r\n")); - $this->socket->expects($this->at(3))->method('write')->with($data); - $this->socket->expects($this->at(4))->method('read')->will($this->returnValue(false)); - $this->socket->expects($this->at(5))->method('read')->will($this->returnValue("250 OK\r\n")); - - $this->SmtpTransport->setCakeEmail($email); - $this->SmtpTransport->sendData(); - } - -/** - * testQuit method - * - * @return void - */ - public function testQuit() { - $this->socket->expects($this->at(0))->method('write')->with("QUIT\r\n"); - $this->SmtpTransport->disconnect(); - } - -} diff --git a/lib/Cake/Test/Case/Network/Http/BasicAuthenticationTest.php b/lib/Cake/Test/Case/Network/Http/BasicAuthenticationTest.php deleted file mode 100644 index 25c1b5cac18..00000000000 --- a/lib/Cake/Test/Case/Network/Http/BasicAuthenticationTest.php +++ /dev/null @@ -1,64 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network.Http - * @since CakePHP(tm) v 2.0.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('HttpSocket', 'Network/Http'); -App::uses('BasicAuthentication', 'Network/Http'); - -/** - * BasicMethodTest class - * - * @package Cake.Test.Case.Network.Http - */ -class BasicAuthenticationTest extends CakeTestCase { - -/** - * testAuthentication method - * - * @return void - */ - public function testAuthentication() { - $http = new HttpSocket(); - $auth = array( - 'method' => 'Basic', - 'user' => 'mark', - 'pass' => 'secret' - ); - - BasicAuthentication::authentication($http, $auth); - $this->assertEquals('Basic bWFyazpzZWNyZXQ=', $http->request['header']['Authorization']); - } - -/** - * testProxyAuthentication method - * - * @return void - */ - public function testProxyAuthentication() { - $http = new HttpSocket(); - $proxy = array( - 'method' => 'Basic', - 'user' => 'mark', - 'pass' => 'secret' - ); - - BasicAuthentication::proxyAuthentication($http, $proxy); - $this->assertEquals('Basic bWFyazpzZWNyZXQ=', $http->request['header']['Proxy-Authorization']); - } - -} diff --git a/lib/Cake/Test/Case/Network/Http/DigestAuthenticationTest.php b/lib/Cake/Test/Case/Network/Http/DigestAuthenticationTest.php deleted file mode 100644 index 13fb7d3520a..00000000000 --- a/lib/Cake/Test/Case/Network/Http/DigestAuthenticationTest.php +++ /dev/null @@ -1,195 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network.Http - * @since CakePHP(tm) v 2.0.0 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('HttpSocket', 'Network/Http'); -App::uses('DigestAuthentication', 'Network/Http'); - -class DigestHttpSocket extends HttpSocket { - -/** - * nextHeader attribute - * - * @var string - */ - public $nextHeader = ''; - -/** - * request method - * - * @param mixed $request - * @return void - */ - public function request($request = array()) { - if ($request === false) { - if (isset($this->response['header']['WWW-Authenticate'])) { - unset($this->response['header']['WWW-Authenticate']); - } - return; - } - $this->response['header']['WWW-Authenticate'] = $this->nextHeader; - } - -} - -/** - * DigestAuthenticationTest class - * - * @package Cake.Test.Case.Network.Http - */ -class DigestAuthenticationTest extends CakeTestCase { - -/** - * Socket property - * - * @var mixed null - */ - public $HttpSocket = null; - -/** - * This function sets up a HttpSocket instance we are going to use for testing - * - * @return void - */ - public function setUp() { - $this->HttpSocket = new DigestHttpSocket(); - $this->HttpSocket->request['method'] = 'GET'; - $this->HttpSocket->request['uri']['path'] = '/'; - } - -/** - * We use this function to clean up after the test case was executed - * - * @return void - */ - public function tearDown() { - unset($this->HttpSocket); - } - -/** - * testBasic method - * - * @return void - */ - public function testBasic() { - $this->HttpSocket->nextHeader = 'Digest realm="The batcave",nonce="4cded326c6c51"'; - $this->assertFalse(isset($this->HttpSocket->request['header']['Authorization'])); - - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $this->assertTrue(isset($this->HttpSocket->request['header']['Authorization'])); - $this->assertEquals('The batcave', $auth['realm']); - $this->assertEquals('4cded326c6c51', $auth['nonce']); - } - -/** - * testQop method - * - * @return void - */ - public function testQop() { - $this->HttpSocket->nextHeader = 'Digest realm="The batcave",nonce="4cded326c6c51"'; - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $expected = 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/", response="da7e2a46b471d77f70a9bb3698c8902b"'; - $this->assertEquals($expected, $this->HttpSocket->request['header']['Authorization']); - $this->assertFalse(isset($auth['qop'])); - $this->assertFalse(isset($auth['nc'])); - - $this->HttpSocket->nextHeader = 'Digest realm="The batcave",nonce="4cded326c6c51",qop="auth"'; - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $expected = '@Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/", response="[a-z0-9]{32}", qop="auth", nc=00000001, cnonce="[a-z0-9]+"@'; - $this->assertRegExp($expected, $this->HttpSocket->request['header']['Authorization']); - $this->assertEquals('auth', $auth['qop']); - $this->assertEquals(2, $auth['nc']); - } - -/** - * testOpaque method - * - * @return void - */ - public function testOpaque() { - $this->HttpSocket->nextHeader = 'Digest realm="The batcave",nonce="4cded326c6c51"'; - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $this->assertFalse(strpos($this->HttpSocket->request['header']['Authorization'], 'opaque="d8ea7aa61a1693024c4cc3a516f49b3c"')); - - $this->HttpSocket->nextHeader = 'Digest realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"'; - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $this->assertTrue(strpos($this->HttpSocket->request['header']['Authorization'], 'opaque="d8ea7aa61a1693024c4cc3a516f49b3c"') > 0); - } - -/** - * testMultipleRequest method - * - * @return void - */ - public function testMultipleRequest() { - $this->HttpSocket->nextHeader = 'Digest realm="The batcave",nonce="4cded326c6c51",qop="auth"'; - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $this->assertTrue(strpos($this->HttpSocket->request['header']['Authorization'], 'nc=00000001') > 0); - $this->assertEquals(2, $auth['nc']); - - DigestAuthentication::authentication($this->HttpSocket, $auth); - $this->assertTrue(strpos($this->HttpSocket->request['header']['Authorization'], 'nc=00000002') > 0); - $this->assertEquals(3, $auth['nc']); - $responsePos = strpos($this->HttpSocket->request['header']['Authorization'], 'response='); - $response = substr($this->HttpSocket->request['header']['Authorization'], $responsePos + 10, 32); - - $this->HttpSocket->nextHeader = ''; - DigestAuthentication::authentication($this->HttpSocket, $auth); - $this->assertTrue(strpos($this->HttpSocket->request['header']['Authorization'], 'nc=00000003') > 0); - $this->assertEquals(4, $auth['nc']); - $responsePos = strpos($this->HttpSocket->request['header']['Authorization'], 'response='); - $responseB = substr($this->HttpSocket->request['header']['Authorization'], $responsePos + 10, 32); - $this->assertNotEquals($response, $responseB); - } - -/** - * testPathChanged method - * - * @return void - */ - public function testPathChanged() { - $this->HttpSocket->nextHeader = 'Digest realm="The batcave",nonce="4cded326c6c51"'; - $this->HttpSocket->request['uri']['path'] = '/admin'; - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $responsePos = strpos($this->HttpSocket->request['header']['Authorization'], 'response='); - $response = substr($this->HttpSocket->request['header']['Authorization'], $responsePos + 10, 32); - $this->assertNotEquals('da7e2a46b471d77f70a9bb3698c8902b', $response); - } - -/** - * testNoDigestResponse method - * - * @return void - */ - public function testNoDigestResponse() { - $this->HttpSocket->nextHeader = false; - $this->HttpSocket->request['uri']['path'] = '/admin'; - $auth = array('user' => 'admin', 'pass' => '1234'); - DigestAuthentication::authentication($this->HttpSocket, $auth); - $this->assertFalse(isset($this->HttpSocket->request['header']['Authorization'])); - } - -} diff --git a/lib/Cake/Test/Case/Network/Http/HttpResponseTest.php b/lib/Cake/Test/Case/Network/Http/HttpResponseTest.php deleted file mode 100644 index 9a6e35345dc..00000000000 --- a/lib/Cake/Test/Case/Network/Http/HttpResponseTest.php +++ /dev/null @@ -1,558 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network.Http - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('HttpResponse', 'Network/Http'); - -/** - * TestHttpResponse class - * - * @package Cake.Test.Case.Network.Http - */ -class TestHttpResponse extends HttpResponse { - -/** - * Convenience method for testing protected method - * - * @param array $header Header as an indexed array (field => value) - * @return array Parsed header - */ - public function parseHeader($header) { - return parent::_parseHeader($header); - } - -/** - * Convenience method for testing protected method - * - * @param string $body A string containing the body to decode - * @param mixed $encoding Can be false in case no encoding is being used, or a string representing the encoding - * @return mixed Array or false - */ - public function decodeBody($body, $encoding = 'chunked') { - return parent::_decodeBody($body, $encoding); - } - -/** - * Convenience method for testing protected method - * - * @param string $body A string containing the chunked body to decode - * @return mixed Array or false - */ - public function decodeChunkedBody($body) { - return parent::_decodeChunkedBody($body); - } - -/** - * Convenience method for testing protected method - * - * @param string $token Token to unescape - * @return string Unescaped token - */ - public function unescapeToken($token, $chars = null) { - return parent::_unescapeToken($token, $chars); - } - -/** - * Convenience method for testing protected method - * - * @param boolean $hex true to get them as HEX values, false otherwise - * @return array Escape chars - */ - public function tokenEscapeChars($hex = true, $chars = null) { - return parent::_tokenEscapeChars($hex, $chars); - } - -} - -/** - * HttpResponseTest class - * - * @package Cake.Test.Case.Network.Http - */ -class HttpResponseTest extends CakeTestCase { - -/** - * This function sets up a HttpResponse - * - * @return void - */ - public function setUp() { - $this->HttpResponse = new TestHttpResponse(); - } - -/** - * testBody - * - * @return void - */ - public function testBody() { - $this->HttpResponse->body = 'testing'; - $this->assertEquals('testing', $this->HttpResponse->body()); - - $this->HttpResponse->body = null; - $this->assertSame($this->HttpResponse->body(), ''); - } - -/** - * testToString - * - * @return void - */ - public function testToString() { - $this->HttpResponse->body = 'other test'; - $this->assertEquals('other test', $this->HttpResponse->body()); - $this->assertEquals('other test', (string)$this->HttpResponse); - $this->assertTrue(strpos($this->HttpResponse, 'test') > 0); - - $this->HttpResponse->body = null; - $this->assertEquals('', (string)$this->HttpResponse); - } - -/** - * testGetHeader - * - * @return void - */ - public function testGetHeader() { - $this->HttpResponse->headers = array( - 'foo' => 'Bar', - 'Some' => 'ok', - 'HeAdEr' => 'value', - 'content-Type' => 'text/plain' - ); - - $this->assertEquals('Bar', $this->HttpResponse->getHeader('foo')); - $this->assertEquals('Bar', $this->HttpResponse->getHeader('Foo')); - $this->assertEquals('Bar', $this->HttpResponse->getHeader('FOO')); - $this->assertEquals('value', $this->HttpResponse->getHeader('header')); - $this->assertEquals('text/plain', $this->HttpResponse->getHeader('Content-Type')); - $this->assertSame($this->HttpResponse->getHeader(0), null); - - $this->assertEquals('Bar', $this->HttpResponse->getHeader('foo', false)); - $this->assertEquals('not from class', $this->HttpResponse->getHeader('foo', array('foo' => 'not from class'))); - } - -/** - * testIsOk - * - * @return void - */ - public function testIsOk() { - $this->HttpResponse->code = 0; - $this->assertFalse($this->HttpResponse->isOk()); - $this->HttpResponse->code = -1; - $this->assertFalse($this->HttpResponse->isOk()); - $this->HttpResponse->code = 201; - $this->assertFalse($this->HttpResponse->isOk()); - $this->HttpResponse->code = 'what?'; - $this->assertFalse($this->HttpResponse->isOk()); - $this->HttpResponse->code = 200; - $this->assertTrue($this->HttpResponse->isOk()); - } - -/** - * testIsRedirect - * - * @return void - */ - public function testIsRedirect() { - $this->HttpResponse->code = 0; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = -1; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 201; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 'what?'; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 301; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 302; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 303; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 307; - $this->assertFalse($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 301; - $this->HttpResponse->headers['Location'] = 'http://somewhere/'; - $this->assertTrue($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 302; - $this->HttpResponse->headers['Location'] = 'http://somewhere/'; - $this->assertTrue($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 303; - $this->HttpResponse->headers['Location'] = 'http://somewhere/'; - $this->assertTrue($this->HttpResponse->isRedirect()); - $this->HttpResponse->code = 307; - $this->HttpResponse->headers['Location'] = 'http://somewhere/'; - $this->assertTrue($this->HttpResponse->isRedirect()); - } - -/** - * Test that HttpSocket::parseHeader can take apart a given (and valid) $header string and turn it into an array. - * - * @return void - */ - public function testParseHeader() { - $r = $this->HttpResponse->parseHeader(array('foo' => 'Bar', 'fOO-bAr' => 'quux')); - $this->assertEquals(array('foo' => 'Bar', 'fOO-bAr' => 'quux'), $r); - - $r = $this->HttpResponse->parseHeader(true); - $this->assertEquals(false, $r); - - $header = "Host: cakephp.org\t\r\n"; - $r = $this->HttpResponse->parseHeader($header); - $expected = array( - 'Host' => 'cakephp.org' - ); - $this->assertEquals($expected, $r); - - $header = "Date:Sat, 07 Apr 2007 10:10:25 GMT\r\nX-Powered-By: PHP/5.1.2\r\n"; - $r = $this->HttpResponse->parseHeader($header); - $expected = array( - 'Date' => 'Sat, 07 Apr 2007 10:10:25 GMT', - 'X-Powered-By' => 'PHP/5.1.2' - ); - $this->assertEquals($expected, $r); - - $header = "people: Jim,John\r\nfoo-LAND: Bar\r\ncAKe-PHP: rocks\r\n"; - $r = $this->HttpResponse->parseHeader($header); - $expected = array( - 'people' => 'Jim,John', - 'foo-LAND' => 'Bar', - 'cAKe-PHP' => 'rocks' - ); - $this->assertEquals($expected, $r); - - $header = "People: Jim,John,Tim\r\nPeople: Lisa,Tina,Chelsea\r\n"; - $r = $this->HttpResponse->parseHeader($header); - $expected = array( - 'People' => array('Jim,John,Tim', 'Lisa,Tina,Chelsea') - ); - $this->assertEquals($expected, $r); - - $header = "Multi-Line: I am a \r\nmulti line\t\r\nfield value.\r\nSingle-Line: I am not\r\n"; - $r = $this->HttpResponse->parseHeader($header); - $expected = array( - 'Multi-Line' => "I am a\r\nmulti line\r\nfield value.", - 'Single-Line' => 'I am not' - ); - $this->assertEquals($expected, $r); - - $header = "Esc\"@\"ped: value\r\n"; - $r = $this->HttpResponse->parseHeader($header); - $expected = array( - 'Esc@ped' => 'value' - ); - $this->assertEquals($expected, $r); - } - -/** - * testParseResponse method - * - * @return void - */ - public function testParseResponse() { - $tests = array( - 'simple-request' => array( - 'response' => array( - 'status-line' => "HTTP/1.x 200 OK\r\n", - 'header' => "Date: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\n", - 'body' => "

Hello World

\r\n

It's good to be html

" - ), - 'expectations' => array( - 'httpVersion' => 'HTTP/1.x', - 'code' => 200, - 'reasonPhrase' => 'OK', - 'headers' => array('Date' => 'Mon, 16 Apr 2007 04:14:16 GMT', 'Server' => 'CakeHttp Server'), - 'body' => "

Hello World

\r\n

It's good to be html

" - ) - ), - 'no-header' => array( - 'response' => array( - 'status-line' => "HTTP/1.x 404 OK\r\n", - 'header' => null - ), - 'expectations' => array( - 'code' => 404, - 'headers' => array() - ) - ) - ); - - $testResponse = array(); - $expectations = array(); - - foreach ($tests as $name => $test) { - $testResponse = array_merge($testResponse, $test['response']); - $testResponse['response'] = $testResponse['status-line'] . $testResponse['header'] . "\r\n" . $testResponse['body']; - $this->HttpResponse->parseResponse($testResponse['response']); - $expectations = array_merge($expectations, $test['expectations']); - - foreach ($expectations as $property => $expectedVal) { - $this->assertEquals($expectedVal, $this->HttpResponse->{$property}, 'Test "' . $name . '": response.' . $property . ' - %s'); - } - - foreach (array('status-line', 'header', 'body', 'response') as $field) { - $this->assertEquals($this->HttpResponse['raw'][$field], $testResponse[$field], 'Test response.raw.' . $field . ': %s'); - } - } - } - -/** - * data provider function for testInvalidParseResponseData - * - * @return array - */ - public static function invalidParseResponseDataProvider() { - return array( - array(array('foo' => 'bar')), - array(true), - array("HTTP Foo\r\nBar: La"), - array('HTTP/1.1 TEST ERROR') - ); - } - -/** - * testInvalidParseResponseData - * - * @dataProvider invalidParseResponseDataProvider - * @expectedException SocketException - * return void - */ - public function testInvalidParseResponseData($value) { - $this->HttpResponse->parseResponse($value); - } - -/** - * testDecodeBody method - * - * @return void - */ - public function testDecodeBody() { - $r = $this->HttpResponse->decodeBody(true); - $this->assertEquals(false, $r); - - $r = $this->HttpResponse->decodeBody('Foobar', false); - $this->assertEquals(array('body' => 'Foobar', 'header' => false), $r); - - $encoding = 'chunked'; - $sample = array( - 'encoded' => "19\r\nThis is a chunked message\r\n0\r\n", - 'decoded' => array('body' => "This is a chunked message", 'header' => false) - ); - - $r = $this->HttpResponse->decodeBody($sample['encoded'], $encoding); - $this->assertEquals($r, $sample['decoded']); - - $encoding = 'chunked'; - $sample = array( - 'encoded' => "19\nThis is a chunked message\r\n0\n", - 'decoded' => array('body' => "This is a chunked message", 'header' => false) - ); - - $r = $this->HttpResponse->decodeBody($sample['encoded'], $encoding); - $this->assertEquals($r, $sample['decoded'], 'Inconsistent line terminators should be tolerated.'); - } - -/** - * testDecodeFooCoded - * - * @return void - */ - public function testDecodeFooCoded() { - $r = $this->HttpResponse->decodeBody(true); - $this->assertEquals(false, $r); - - $r = $this->HttpResponse->decodeBody('Foobar', false); - $this->assertEquals(array('body' => 'Foobar', 'header' => false), $r); - - $encoding = 'foo-bar'; - $sample = array( - 'encoded' => '!Foobar!', - 'decoded' => array('body' => '!Foobar!', 'header' => false), - ); - - $r = $this->HttpResponse->decodeBody($sample['encoded'], $encoding); - $this->assertEquals($r, $sample['decoded']); - } - -/** - * testDecodeChunkedBody method - * - * @return void - */ - public function testDecodeChunkedBody() { - $r = $this->HttpResponse->decodeChunkedBody(true); - $this->assertEquals(false, $r); - - $encoded = "19\r\nThis is a chunked message\r\n0\r\n"; - $decoded = "This is a chunked message"; - $r = $this->HttpResponse->decodeChunkedBody($encoded); - $this->assertEquals($r['body'], $decoded); - $this->assertEquals(false, $r['header']); - - $encoded = "19 \r\nThis is a chunked message\r\n0\r\n"; - $r = $this->HttpResponse->decodeChunkedBody($encoded); - $this->assertEquals($r['body'], $decoded); - - $encoded = "19\r\nThis is a chunked message\r\nE\r\n\nThat is cool\n\r\n0\r\n"; - $decoded = "This is a chunked message\nThat is cool\n"; - $r = $this->HttpResponse->decodeChunkedBody($encoded); - $this->assertEquals($r['body'], $decoded); - $this->assertEquals(false, $r['header']); - - $encoded = "19\r\nThis is a chunked message\r\nE;foo-chunk=5\r\n\nThat is cool\n\r\n0\r\n"; - $r = $this->HttpResponse->decodeChunkedBody($encoded); - $this->assertEquals($r['body'], $decoded); - $this->assertEquals(false, $r['header']); - - $encoded = "19\r\nThis is a chunked message\r\nE\r\n\nThat is cool\n\r\n0\r\nfoo-header: bar\r\ncake: PHP\r\n\r\n"; - $r = $this->HttpResponse->decodeChunkedBody($encoded); - $this->assertEquals($r['body'], $decoded); - $this->assertEquals(array('foo-header' => 'bar', 'cake' => 'PHP'), $r['header']); - } - -/** - * testDecodeChunkedBodyError method - * - * @expectedException SocketException - * @return void - */ - public function testDecodeChunkedBodyError() { - $encoded = "19\r\nThis is a chunked message\r\nE\r\n\nThat is cool\n\r\n"; - $r = $this->HttpResponse->decodeChunkedBody($encoded); - } - -/** - * testParseCookies method - * - * @return void - */ - public function testParseCookies() { - $header = array( - 'Set-Cookie' => array( - 'foo=bar', - 'people=jim,jack,johnny";";Path=/accounts', - 'google=not=nice' - ), - 'Transfer-Encoding' => 'chunked', - 'Date' => 'Sun, 18 Nov 2007 18:57:42 GMT', - ); - $cookies = $this->HttpResponse->parseCookies($header); - $expected = array( - 'foo' => array( - 'value' => 'bar' - ), - 'people' => array( - 'value' => 'jim,jack,johnny";"', - 'path' => '/accounts', - ), - 'google' => array( - 'value' => 'not=nice', - ) - ); - $this->assertEquals($expected, $cookies); - - $header['Set-Cookie'][] = 'cakephp=great; Secure'; - $expected['cakephp'] = array('value' => 'great', 'secure' => true); - $cookies = $this->HttpResponse->parseCookies($header); - $this->assertEquals($expected, $cookies); - - $header['Set-Cookie'] = 'foo=bar'; - unset($expected['people'], $expected['cakephp'], $expected['google']); - $cookies = $this->HttpResponse->parseCookies($header); - $this->assertEquals($expected, $cookies); - } - -/** - * Test that escaped token strings are properly unescaped by HttpSocket::unescapeToken - * - * @return void - */ - public function testUnescapeToken() { - $this->assertEquals('Foo', $this->HttpResponse->unescapeToken('Foo')); - - $escape = $this->HttpResponse->tokenEscapeChars(false); - foreach ($escape as $char) { - $token = 'My-special-"' . $char . '"-Token'; - $unescapedToken = $this->HttpResponse->unescapeToken($token); - $expectedToken = 'My-special-' . $char . '-Token'; - - $this->assertEquals($expectedToken, $unescapedToken, 'Test token unescaping for ASCII ' . ord($char)); - } - - $token = 'Extreme-":"Token-" "-""""@"-test'; - $escapedToken = $this->HttpResponse->unescapeToken($token); - $expectedToken = 'Extreme-:Token- -"@-test'; - $this->assertEquals($expectedToken, $escapedToken); - } - -/** - * testArrayAccess - * - * @return void - */ - public function testArrayAccess() { - $this->HttpResponse->httpVersion = 'HTTP/1.1'; - $this->HttpResponse->code = 200; - $this->HttpResponse->reasonPhrase = 'OK'; - $this->HttpResponse->headers = array( - 'Server' => 'CakePHP', - 'ContEnt-Type' => 'text/plain' - ); - $this->HttpResponse->cookies = array( - 'foo' => array('value' => 'bar'), - 'bar' => array('value' => 'foo') - ); - $this->HttpResponse->body = 'This is a test!'; - $this->HttpResponse->raw = "HTTP/1.1 200 OK\r\nServer: CakePHP\r\nContEnt-Type: text/plain\r\n\r\nThis is a test!"; - $expectedOne = "HTTP/1.1 200 OK\r\n"; - $this->assertEquals($expectedOne, $this->HttpResponse['raw']['status-line']); - $expectedTwo = "Server: CakePHP\r\nContEnt-Type: text/plain\r\n"; - $this->assertEquals($expectedTwo, $this->HttpResponse['raw']['header']); - $expectedThree = 'This is a test!'; - $this->assertEquals($expectedThree, $this->HttpResponse['raw']['body']); - $expected = $expectedOne . $expectedTwo . "\r\n" . $expectedThree; - $this->assertEquals($expected, $this->HttpResponse['raw']['response']); - - $expected = 'HTTP/1.1'; - $this->assertEquals($expected, $this->HttpResponse['status']['http-version']); - $expected = 200; - $this->assertEquals($expected, $this->HttpResponse['status']['code']); - $expected = 'OK'; - $this->assertEquals($expected, $this->HttpResponse['status']['reason-phrase']); - - $expected = array( - 'Server' => 'CakePHP', - 'ContEnt-Type' => 'text/plain' - ); - $this->assertEquals($expected, $this->HttpResponse['header']); - - $expected = 'This is a test!'; - $this->assertEquals($expected, $this->HttpResponse['body']); - - $expected = array( - 'foo' => array('value' => 'bar'), - 'bar' => array('value' => 'foo') - ); - $this->assertEquals($expected, $this->HttpResponse['cookies']); - - $this->HttpResponse->raw = "HTTP/1.1 200 OK\r\n\r\nThis is a test!"; - $this->assertSame($this->HttpResponse['raw']['header'], null); - } - -} diff --git a/lib/Cake/Test/Case/Network/Http/HttpSocketTest.php b/lib/Cake/Test/Case/Network/Http/HttpSocketTest.php deleted file mode 100644 index 0051c29714e..00000000000 --- a/lib/Cake/Test/Case/Network/Http/HttpSocketTest.php +++ /dev/null @@ -1,1620 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Network.Http - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('HttpSocket', 'Network/Http'); -App::uses('HttpResponse', 'Network/Http'); - -/** - * TestAuthentication class - * - * @package Cake.Test.Case.Network.Http - * @package Cake.Test.Case.Network.Http - */ -class TestAuthentication { - -/** - * authentication method - * - * @param HttpSocket $http - * @param array $authInfo - * @return void - */ - public static function authentication(HttpSocket $http, &$authInfo) { - $http->request['header']['Authorization'] = 'Test ' . $authInfo['user'] . '.' . $authInfo['pass']; - } - -/** - * proxyAuthentication method - * - * @param HttpSocket $http - * @param array $proxyInfo - * @return void - */ - public static function proxyAuthentication(HttpSocket $http, &$proxyInfo) { - $http->request['header']['Proxy-Authorization'] = 'Test ' . $proxyInfo['user'] . '.' . $proxyInfo['pass']; - } - -} - -/** - * CustomResponse - * - */ -class CustomResponse { - -/** - * First 10 chars - * - * @var string - */ - public $first10; - -/** - * Constructor - * - */ - public function __construct($message) { - $this->first10 = substr($message, 0, 10); - } - -} - -/** - * TestHttpSocket - * - */ -class TestHttpSocket extends HttpSocket { - -/** - * Convenience method for testing protected method - * - * @param mixed $uri URI (see {@link _parseUri()}) - * @return array Current configuration settings - */ - public function configUri($uri = null) { - return parent::_configUri($uri); - } - -/** - * Convenience method for testing protected method - * - * @param string $uri URI to parse - * @param mixed $base If true use default URI config, otherwise indexed array to set 'scheme', 'host', 'port', etc. - * @return array Parsed URI - */ - public function parseUri($uri = null, $base = array()) { - return parent::_parseUri($uri, $base); - } - -/** - * Convenience method for testing protected method - * - * @param array $uri A $uri array, or uses $this->config if left empty - * @param string $uriTemplate The Uri template/format to use - * @return string A fully qualified URL formatted according to $uriTemplate - */ - public function buildUri($uri = array(), $uriTemplate = '%scheme://%user:%pass@%host:%port/%path?%query#%fragment') { - return parent::_buildUri($uri, $uriTemplate); - } - -/** - * Convenience method for testing protected method - * - * @param array $header Header to build - * @return string Header built from array - */ - public function buildHeader($header, $mode = 'standard') { - return parent::_buildHeader($header, $mode); - } - -/** - * Convenience method for testing protected method - * - * @param mixed $query A query string to parse into an array or an array to return directly "as is" - * @return array The $query parsed into a possibly multi-level array. If an empty $query is given, an empty array is returned. - */ - public function parseQuery($query) { - return parent::_parseQuery($query); - } - -/** - * Convenience method for testing protected method - * - * @param array $request Needs to contain a 'uri' key. Should also contain a 'method' key, otherwise defaults to GET. - * @param string $versionToken The version token to use, defaults to HTTP/1.1 - * @return string Request line - */ - public function buildRequestLine($request = array(), $versionToken = 'HTTP/1.1') { - return parent::_buildRequestLine($request, $versionToken); - } - -/** - * Convenience method for testing protected method - * - * @param boolean $hex true to get them as HEX values, false otherwise - * @return array Escape chars - */ - public function tokenEscapeChars($hex = true, $chars = null) { - return parent::_tokenEscapeChars($hex, $chars); - } - -/** - * Convenience method for testing protected method - * - * @param string $token Token to escape - * @return string Escaped token - */ - public function escapeToken($token, $chars = null) { - return parent::_escapeToken($token, $chars); - } - -} - -/** - * HttpSocketTest class - * - * @package Cake.Test.Case.Network.Http - */ -class HttpSocketTest extends CakeTestCase { - -/** - * Socket property - * - * @var mixed null - */ - public $Socket = null; - -/** - * RequestSocket property - * - * @var mixed null - */ - public $RequestSocket = null; - -/** - * This function sets up a TestHttpSocket instance we are going to use for testing - * - * @return void - */ - public function setUp() { - if (!class_exists('MockHttpSocket')) { - $this->getMock('TestHttpSocket', array('read', 'write', 'connect'), array(), 'MockHttpSocket'); - $this->getMock('TestHttpSocket', array('read', 'write', 'connect', 'request'), array(), 'MockHttpSocketRequests'); - } - - $this->Socket = new MockHttpSocket(); - $this->RequestSocket = new MockHttpSocketRequests(); - } - -/** - * We use this function to clean up after the test case was executed - * - * @return void - */ - public function tearDown() { - unset($this->Socket, $this->RequestSocket); - } - -/** - * Test that HttpSocket::__construct does what one would expect it to do - * - * @return void - */ - public function testConstruct() { - $this->Socket->reset(); - $baseConfig = $this->Socket->config; - $this->Socket->expects($this->never())->method('connect'); - $this->Socket->__construct(array('host' => 'foo-bar')); - $baseConfig['host'] = 'foo-bar'; - $baseConfig['protocol'] = getprotobyname($baseConfig['protocol']); - $this->assertEquals($this->Socket->config, $baseConfig); - - $this->Socket->reset(); - $baseConfig = $this->Socket->config; - $this->Socket->__construct('http://www.cakephp.org:23/'); - $baseConfig['host'] = $baseConfig['request']['uri']['host'] = 'www.cakephp.org'; - $baseConfig['port'] = $baseConfig['request']['uri']['port'] = 23; - $baseConfig['request']['uri']['scheme'] = 'http'; - $baseConfig['protocol'] = getprotobyname($baseConfig['protocol']); - $this->assertEquals($this->Socket->config, $baseConfig); - - $this->Socket->reset(); - $this->Socket->__construct(array('request' => array('uri' => 'http://www.cakephp.org:23/'))); - $this->assertEquals($this->Socket->config, $baseConfig); - } - -/** - * Test that HttpSocket::configUri works properly with different types of arguments - * - * @return void - */ - public function testConfigUri() { - $this->Socket->reset(); - $r = $this->Socket->configUri('https://bob:secret@www.cakephp.org:23/?query=foo'); - $expected = array( - 'persistent' => false, - 'host' => 'www.cakephp.org', - 'protocol' => 'tcp', - 'port' => 23, - 'timeout' => 30, - 'request' => array( - 'uri' => array( - 'scheme' => 'https', - 'host' => 'www.cakephp.org', - 'port' => 23 - ), - 'redirect' => false, - 'cookies' => array() - ) - ); - $this->assertEquals($expected, $this->Socket->config); - $this->assertTrue($r); - $r = $this->Socket->configUri(array('host' => 'www.foo-bar.org')); - $expected['host'] = 'www.foo-bar.org'; - $expected['request']['uri']['host'] = 'www.foo-bar.org'; - $this->assertEquals($expected, $this->Socket->config); - $this->assertTrue($r); - - $r = $this->Socket->configUri('http://www.foo.com'); - $expected = array( - 'persistent' => false, - 'host' => 'www.foo.com', - 'protocol' => 'tcp', - 'port' => 80, - 'timeout' => 30, - 'request' => array( - 'uri' => array( - 'scheme' => 'http', - 'host' => 'www.foo.com', - 'port' => 80 - ), - 'redirect' => false, - 'cookies' => array() - ) - ); - $this->assertEquals($expected, $this->Socket->config); - $this->assertTrue($r); - - $r = $this->Socket->configUri('/this-is-broken'); - $this->assertEquals($expected, $this->Socket->config); - $this->assertFalse($r); - - $r = $this->Socket->configUri(false); - $this->assertEquals($expected, $this->Socket->config); - $this->assertFalse($r); - } - -/** - * Tests that HttpSocket::request (the heart of the HttpSocket) is working properly. - * - * @return void - */ - public function testRequest() { - $this->Socket->reset(); - - $response = $this->Socket->request(true); - $this->assertFalse($response); - - $tests = array( - array( - 'request' => 'http://www.cakephp.org/?foo=bar', - 'expectation' => array( - 'config' => array( - 'persistent' => false, - 'host' => 'www.cakephp.org', - 'protocol' => 'tcp', - 'port' => 80, - 'timeout' => 30, - 'request' => array( - 'uri' => array( - 'scheme' => 'http', - 'host' => 'www.cakephp.org', - 'port' => 80 - ), - 'redirect' => false, - 'cookies' => array() - ) - ), - 'request' => array( - 'method' => 'GET', - 'uri' => array( - 'scheme' => 'http', - 'host' => 'www.cakephp.org', - 'port' => 80, - 'user' => null, - 'pass' => null, - 'path' => '/', - 'query' => array('foo' => 'bar'), - 'fragment' => null - ), - 'version' => '1.1', - 'body' => '', - 'line' => "GET /?foo=bar HTTP/1.1\r\n", - 'header' => "Host: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\n", - 'raw' => "", - 'redirect' => false, - 'cookies' => array(), - 'proxy' => array(), - 'auth' => array() - ) - ) - ), - array( - 'request' => array( - 'uri' => array( - 'host' => 'www.cakephp.org', - 'query' => '?foo=bar' - ) - ) - ), - array( - 'request' => 'www.cakephp.org/?foo=bar' - ), - array( - 'request' => array( - 'host' => '192.168.0.1', - 'uri' => 'http://www.cakephp.org/?foo=bar' - ), - 'expectation' => array( - 'request' => array( - 'uri' => array('host' => 'www.cakephp.org') - ), - 'config' => array( - 'request' => array( - 'uri' => array('host' => 'www.cakephp.org') - ), - 'host' => '192.168.0.1' - ) - ) - ), - 'reset4' => array( - 'request.uri.query' => array() - ), - array( - 'request' => array( - 'header' => array('Foo@woo' => 'bar-value') - ), - 'expectation' => array( - 'request' => array( - 'header' => "Host: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\nFoo\"@\"woo: bar-value\r\n", - 'line' => "GET / HTTP/1.1\r\n" - ) - ) - ), - array( - 'request' => array('header' => array('Foo@woo' => 'bar-value', 'host' => 'foo.com'), 'uri' => 'http://www.cakephp.org/'), - 'expectation' => array( - 'request' => array( - 'header' => "Host: foo.com\r\nConnection: close\r\nUser-Agent: CakePHP\r\nFoo\"@\"woo: bar-value\r\n" - ), - 'config' => array( - 'host' => 'www.cakephp.org' - ) - ) - ), - array( - 'request' => array('header' => "Foo: bar\r\n"), - 'expectation' => array( - 'request' => array( - 'header' => "Foo: bar\r\n" - ) - ) - ), - array( - 'request' => array('header' => "Foo: bar\r\n", 'uri' => 'http://www.cakephp.org/search?q=http_socket#ignore-me'), - 'expectation' => array( - 'request' => array( - 'uri' => array( - 'path' => '/search', - 'query' => array('q' => 'http_socket'), - 'fragment' => 'ignore-me' - ), - 'line' => "GET /search?q=http_socket HTTP/1.1\r\n" - ) - ) - ), - 'reset8' => array( - 'request.uri.query' => array() - ), - array( - 'request' => array( - 'method' => 'POST', - 'uri' => 'http://www.cakephp.org/posts/add', - 'body' => array( - 'name' => 'HttpSocket-is-released', - 'date' => 'today' - ) - ), - 'expectation' => array( - 'request' => array( - 'method' => 'POST', - 'uri' => array( - 'path' => '/posts/add', - 'fragment' => null - ), - 'body' => "name=HttpSocket-is-released&date=today", - 'line' => "POST /posts/add HTTP/1.1\r\n", - 'header' => "Host: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 38\r\n", - 'raw' => "name=HttpSocket-is-released&date=today" - ) - ) - ), - array( - 'request' => array( - 'method' => 'POST', - 'uri' => 'http://www.cakephp.org:8080/posts/add', - 'body' => array( - 'name' => 'HttpSocket-is-released', - 'date' => 'today' - ) - ), - 'expectation' => array( - 'config' => array( - 'port' => 8080, - 'request' => array( - 'uri' => array( - 'port' => 8080 - ) - ) - ), - 'request' => array( - 'uri' => array( - 'port' => 8080 - ), - 'header' => "Host: www.cakephp.org:8080\r\nConnection: close\r\nUser-Agent: CakePHP\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 38\r\n" - ) - ) - ), - array( - 'request' => array( - 'method' => 'POST', - 'uri' => 'https://www.cakephp.org/posts/add', - 'body' => array( - 'name' => 'HttpSocket-is-released', - 'date' => 'today' - ) - ), - 'expectation' => array( - 'config' => array( - 'port' => 443, - 'request' => array( - 'uri' => array( - 'scheme' => 'https', - 'port' => 443 - ) - ) - ), - 'request' => array( - 'uri' => array( - 'scheme' => 'https', - 'port' => 443 - ), - 'header' => "Host: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 38\r\n" - ) - ) - ), - array( - 'request' => array( - 'method' => 'POST', - 'uri' => 'https://www.cakephp.org/posts/add', - 'body' => array('name' => 'HttpSocket-is-released', 'date' => 'today'), - 'cookies' => array('foo' => array('value' => 'bar')) - ), - 'expectation' => array( - 'request' => array( - 'header' => "Host: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 38\r\nCookie: foo=bar\r\n", - 'cookies' => array( - 'foo' => array('value' => 'bar'), - ) - ) - ) - ) - ); - - $expectation = array(); - foreach ($tests as $i => $test) { - if (strpos($i, 'reset') === 0) { - foreach ($test as $path => $val) { - $expectation = Set::insert($expectation, $path, $val); - } - continue; - } - - if (isset($test['expectation'])) { - $expectation = Set::merge($expectation, $test['expectation']); - } - $this->Socket->request($test['request']); - - $raw = $expectation['request']['raw']; - $expectation['request']['raw'] = $expectation['request']['line'] . $expectation['request']['header'] . "\r\n" . $raw; - - $r = array('config' => $this->Socket->config, 'request' => $this->Socket->request); - $v = $this->assertEquals($r, $expectation, 'Failed test #' . $i . ' '); - $expectation['request']['raw'] = $raw; - } - - $this->Socket->reset(); - $request = array('method' => 'POST', 'uri' => 'http://www.cakephp.org/posts/add', 'body' => array('name' => 'HttpSocket-is-released', 'date' => 'today')); - $response = $this->Socket->request($request); - $this->assertEquals("name=HttpSocket-is-released&date=today", $this->Socket->request['body']); - } - -/** - * Test the scheme + port keys - * - * @return void - */ - public function testGetWithSchemeAndPort() { - $this->Socket->reset(); - $request = array( - 'uri' => array( - 'scheme' => 'http', - 'host' => 'cakephp.org', - 'port' => 8080, - 'path' => '/', - ), - 'method' => 'GET' - ); - $response = $this->Socket->request($request); - $this->assertContains('Host: cakephp.org:8080', $this->Socket->request['header']); - } - -/** - * Test urls like http://cakephp.org/index.php?somestring without key/value pair for query - * - * @return void - */ - public function testRequestWithStringQuery() { - $this->Socket->reset(); - $request = array( - 'uri' => array( - 'scheme' => 'http', - 'host' => 'cakephp.org', - 'path' => 'index.php', - 'query' => 'somestring' - ), - 'method' => 'GET' - ); - $response = $this->Socket->request($request); - $this->assertContains("GET /index.php?somestring HTTP/1.1", $this->Socket->request['line']); - } - -/** - * The "*" asterisk character is only allowed for the following methods: OPTIONS. - * - * @expectedException SocketException - * @return void - */ - public function testRequestNotAllowedUri() { - $this->Socket->reset(); - $request = array('uri' => '*', 'method' => 'GET'); - $response = $this->Socket->request($request); - } - -/** - * testRequest2 method - * - * @return void - */ - public function testRequest2() { - $this->Socket->reset(); - $request = array('uri' => 'htpp://www.cakephp.org/'); - $number = mt_rand(0, 9999999); - $this->Socket->expects($this->once())->method('connect')->will($this->returnValue(true)); - $serverResponse = "HTTP/1.x 200 OK\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

Hello, your lucky number is " . $number . "

"; - $this->Socket->expects($this->at(0))->method('read')->will($this->returnValue(false)); - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->expects($this->once())->method('write') - ->with("GET / HTTP/1.1\r\nHost: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\n\r\n"); - $response = (string)$this->Socket->request($request); - $this->assertEquals($response, "

Hello, your lucky number is " . $number . "

"); - } - -/** - * testRequest3 method - * - * @return void - */ - public function testRequest3() { - $request = array('uri' => 'htpp://www.cakephp.org/'); - $serverResponse = "HTTP/1.x 200 OK\r\nSet-Cookie: foo=bar\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

This is a cookie test!

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->connected = true; - $this->Socket->request($request); - $result = $this->Socket->response['cookies']; - $expect = array( - 'foo' => array( - 'value' => 'bar' - ) - ); - $this->assertEquals($expect, $result); - $this->assertEquals($this->Socket->config['request']['cookies']['www.cakephp.org'], $expect); - $this->assertFalse($this->Socket->connected); - } - -/** - * testRequestWithConstructor method - * - * @return void - */ - public function testRequestWithConstructor() { - $request = array( - 'request' => array( - 'uri' => array( - 'scheme' => 'http', - 'host' => 'localhost', - 'port' => '5984', - 'user' => null, - 'pass' => null - ) - ) - ); - $http = new MockHttpSocketRequests($request); - - $expected = array('method' => 'GET', 'uri' => '/_test'); - $http->expects($this->at(0))->method('request')->with($expected); - $http->get('/_test'); - - $expected = array('method' => 'GET', 'uri' => 'http://localhost:5984/_test?count=4'); - $http->expects($this->at(0))->method('request')->with($expected); - $http->get('/_test', array('count' => 4)); - } - -/** - * testRequestWithResource - * - * @return void - */ - public function testRequestWithResource() { - $serverResponse = "HTTP/1.x 200 OK\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

This is a test!

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->expects($this->at(2))->method('read')->will($this->returnValue(false)); - $this->Socket->expects($this->at(4))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->connected = true; - - $f = fopen(TMP . 'download.txt', 'w'); - if (!$f) { - $this->markTestSkipped('Can not write in TMP directory.'); - } - - $this->Socket->setContentResource($f); - $result = (string)$this->Socket->request('http://www.cakephp.org/'); - $this->assertEquals('', $result); - $this->assertEquals('CakeHttp Server', $this->Socket->response['header']['Server']); - fclose($f); - $this->assertEquals(file_get_contents(TMP . 'download.txt'), '

This is a test!

'); - unlink(TMP . 'download.txt'); - - $this->Socket->setContentResource(false); - $result = (string)$this->Socket->request('http://www.cakephp.org/'); - $this->assertEquals('

This is a test!

', $result); - } - -/** - * testRequestWithCrossCookie - * - * @return void - */ - public function testRequestWithCrossCookie() { - $this->Socket->connected = true; - $this->Socket->config['request']['cookies'] = array(); - - $serverResponse = "HTTP/1.x 200 OK\r\nSet-Cookie: foo=bar\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

This is a test!

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->expects($this->at(2))->method('read')->will($this->returnValue(false)); - $expected = array('www.cakephp.org' => array('foo' => array('value' => 'bar'))); - $this->Socket->request('http://www.cakephp.org/'); - $this->assertEquals($expected, $this->Socket->config['request']['cookies']); - - $serverResponse = "HTTP/1.x 200 OK\r\nSet-Cookie: bar=foo\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

This is a test!

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->expects($this->at(2))->method('read')->will($this->returnValue(false)); - $this->Socket->request('http://www.cakephp.org/other'); - $this->assertEquals(array('foo' => array('value' => 'bar')), $this->Socket->request['cookies']); - $expected['www.cakephp.org'] += array('bar' => array('value' => 'foo')); - $this->assertEquals($expected, $this->Socket->config['request']['cookies']); - - $serverResponse = "HTTP/1.x 200 OK\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

This is a test!

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->expects($this->at(2))->method('read')->will($this->returnValue(false)); - $this->Socket->request('/other2'); - $this->assertEquals($expected, $this->Socket->config['request']['cookies']); - - $serverResponse = "HTTP/1.x 200 OK\r\nSet-Cookie: foobar=ok\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

This is a test!

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->expects($this->at(2))->method('read')->will($this->returnValue(false)); - $this->Socket->request('http://www.cake.com'); - $this->assertTrue(empty($this->Socket->request['cookies'])); - $expected['www.cake.com'] = array('foobar' => array('value' => 'ok')); - $this->assertEquals($expected, $this->Socket->config['request']['cookies']); - } - -/** - * testRequestCustomResponse - * - * @return void - */ - public function testRequestCustomResponse() { - $this->Socket->connected = true; - $serverResponse = "HTTP/1.x 200 OK\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

This is a test!

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse)); - $this->Socket->expects($this->at(2))->method('read')->will($this->returnValue(false)); - - $this->Socket->responseClass = 'CustomResponse'; - $response = $this->Socket->request('http://www.cakephp.org/'); - $this->assertInstanceOf('CustomResponse', $response); - $this->assertEquals('HTTP/1.x 2', $response->first10); - } - -/** - * testRequestWithRedirect method - * - * @return void - */ - public function testRequestWithRedirectAsTrue() { - $request = array( - 'uri' => 'http://localhost/oneuri', - 'redirect' => true - ); - $serverResponse1 = "HTTP/1.x 302 Found\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\nLocation: http://localhost/anotheruri\r\n\r\n"; - $serverResponse2 = "HTTP/1.x 200 OK\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

You have been redirected

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse1)); - $this->Socket->expects($this->at(4))->method('read')->will($this->returnValue($serverResponse2)); - - $response = $this->Socket->request($request); - $this->assertEquals('

You have been redirected

', $response->body()); - } - - public function testRequestWithRedirectAsInt() { - $request = array( - 'uri' => 'http://localhost/oneuri', - 'redirect' => 2 - ); - $serverResponse1 = "HTTP/1.x 302 Found\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\nLocation: http://localhost/anotheruri\r\n\r\n"; - $serverResponse2 = "HTTP/1.x 200 OK\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\n\r\n

You have been redirected

"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse1)); - $this->Socket->expects($this->at(4))->method('read')->will($this->returnValue($serverResponse2)); - - $response = $this->Socket->request($request); - $this->assertEquals(1, $this->Socket->request['redirect']); - } - - public function testRequestWithRedirectAsIntReachingZero() { - $request = array( - 'uri' => 'http://localhost/oneuri', - 'redirect' => 1 - ); - $serverResponse1 = "HTTP/1.x 302 Found\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\nLocation: http://localhost/oneruri\r\n\r\n"; - $serverResponse2 = "HTTP/1.x 302 Found\r\nDate: Mon, 16 Apr 2007 04:14:16 GMT\r\nServer: CakeHttp Server\r\nContent-Type: text/html\r\nLocation: http://localhost/anotheruri\r\n\r\n"; - $this->Socket->expects($this->at(1))->method('read')->will($this->returnValue($serverResponse1)); - $this->Socket->expects($this->at(4))->method('read')->will($this->returnValue($serverResponse2)); - - $response = $this->Socket->request($request); - $this->assertEquals(0, $this->Socket->request['redirect']); - $this->assertEquals(302, $response->code); - $this->assertEquals('http://localhost/anotheruri', $response->getHeader('Location')); - } - -/** - * testProxy method - * - * @return void - */ - public function testProxy() { - $this->Socket->reset(); - $this->Socket->expects($this->any())->method('connect')->will($this->returnValue(true)); - $this->Socket->expects($this->any())->method('read')->will($this->returnValue(false)); - - $this->Socket->configProxy('proxy.server', 123); - $expected = "GET http://www.cakephp.org/ HTTP/1.1\r\nHost: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\n\r\n"; - $this->Socket->request('http://www.cakephp.org/'); - $this->assertEquals($expected, $this->Socket->request['raw']); - $this->assertEquals('proxy.server', $this->Socket->config['host']); - $this->assertEquals(123, $this->Socket->config['port']); - $expected = array( - 'host' => 'proxy.server', - 'port' => 123, - 'method' => null, - 'user' => null, - 'pass' => null - ); - $this->assertEquals($expected, $this->Socket->request['proxy']); - - $expected = "GET http://www.cakephp.org/bakery HTTP/1.1\r\nHost: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\n\r\n"; - $this->Socket->request('/bakery'); - $this->assertEquals($expected, $this->Socket->request['raw']); - $this->assertEquals('proxy.server', $this->Socket->config['host']); - $this->assertEquals(123, $this->Socket->config['port']); - $expected = array( - 'host' => 'proxy.server', - 'port' => 123, - 'method' => null, - 'user' => null, - 'pass' => null - ); - $this->assertEquals($expected, $this->Socket->request['proxy']); - - $expected = "GET http://www.cakephp.org/ HTTP/1.1\r\nHost: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\nProxy-Authorization: Test mark.secret\r\n\r\n"; - $this->Socket->configProxy('proxy.server', 123, 'Test', 'mark', 'secret'); - $this->Socket->request('http://www.cakephp.org/'); - $this->assertEquals($expected, $this->Socket->request['raw']); - $this->assertEquals('proxy.server', $this->Socket->config['host']); - $this->assertEquals(123, $this->Socket->config['port']); - $expected = array( - 'host' => 'proxy.server', - 'port' => 123, - 'method' => 'Test', - 'user' => 'mark', - 'pass' => 'secret' - ); - $this->assertEquals($expected, $this->Socket->request['proxy']); - - $this->Socket->configAuth('Test', 'login', 'passwd'); - $expected = "GET http://www.cakephp.org/ HTTP/1.1\r\nHost: www.cakephp.org\r\nConnection: close\r\nUser-Agent: CakePHP\r\nProxy-Authorization: Test mark.secret\r\nAuthorization: Test login.passwd\r\n\r\n"; - $this->Socket->request('http://www.cakephp.org/'); - $this->assertEquals($expected, $this->Socket->request['raw']); - $expected = array( - 'host' => 'proxy.server', - 'port' => 123, - 'method' => 'Test', - 'user' => 'mark', - 'pass' => 'secret' - ); - $this->assertEquals($expected, $this->Socket->request['proxy']); - $expected = array( - 'Test' => array( - 'user' => 'login', - 'pass' => 'passwd' - ) - ); - $this->assertEquals($expected, $this->Socket->request['auth']); - } - -/** - * testUrl method - * - * @return void - */ - public function testUrl() { - $this->Socket->reset(true); - - $this->assertEquals(false, $this->Socket->url(true)); - - $url = $this->Socket->url('www.cakephp.org'); - $this->assertEquals('http://www.cakephp.org/', $url); - - $url = $this->Socket->url('https://www.cakephp.org/posts/add'); - $this->assertEquals('https://www.cakephp.org/posts/add', $url); - $url = $this->Socket->url('http://www.cakephp/search?q=socket', '/%path?%query'); - $this->assertEquals('/search?q=socket', $url); - - $this->Socket->config['request']['uri']['host'] = 'bakery.cakephp.org'; - $url = $this->Socket->url(); - $this->assertEquals('http://bakery.cakephp.org/', $url); - - $this->Socket->configUri('http://www.cakephp.org'); - $url = $this->Socket->url('/search?q=bar'); - $this->assertEquals('http://www.cakephp.org/search?q=bar', $url); - - $url = $this->Socket->url(array('host' => 'www.foobar.org', 'query' => array('q' => 'bar'))); - $this->assertEquals('http://www.foobar.org/?q=bar', $url); - - $url = $this->Socket->url(array('path' => '/supersearch', 'query' => array('q' => 'bar'))); - $this->assertEquals('http://www.cakephp.org/supersearch?q=bar', $url); - - $this->Socket->configUri('http://www.google.com'); - $url = $this->Socket->url('/search?q=socket'); - $this->assertEquals('http://www.google.com/search?q=socket', $url); - - $url = $this->Socket->url(); - $this->assertEquals('http://www.google.com/', $url); - - $this->Socket->configUri('https://www.google.com'); - $url = $this->Socket->url('/search?q=socket'); - $this->assertEquals('https://www.google.com/search?q=socket', $url); - - $this->Socket->reset(); - $this->Socket->configUri('www.google.com:443'); - $url = $this->Socket->url('/search?q=socket'); - $this->assertEquals('https://www.google.com/search?q=socket', $url); - - $this->Socket->reset(); - $this->Socket->configUri('www.google.com:8080'); - $url = $this->Socket->url('/search?q=socket'); - $this->assertEquals('http://www.google.com:8080/search?q=socket', $url); - } - -/** - * testGet method - * - * @return void - */ - public function testGet() { - $this->RequestSocket->reset(); - - $this->RequestSocket->expects($this->at(0)) - ->method('request') - ->with(array('method' => 'GET', 'uri' => 'http://www.google.com/')); - - $this->RequestSocket->expects($this->at(1)) - ->method('request') - ->with(array('method' => 'GET', 'uri' => 'http://www.google.com/?foo=bar')); - - $this->RequestSocket->expects($this->at(2)) - ->method('request') - ->with(array('method' => 'GET', 'uri' => 'http://www.google.com/?foo=bar')); - - $this->RequestSocket->expects($this->at(3)) - ->method('request') - ->with(array('method' => 'GET', 'uri' => 'http://www.google.com/?foo=23&foobar=42')); - - $this->RequestSocket->expects($this->at(4)) - ->method('request') - ->with(array('method' => 'GET', 'uri' => 'http://www.google.com/', 'version' => '1.0')); - - $this->RequestSocket->expects($this->at(5)) - ->method('request') - ->with(array('method' => 'GET', 'uri' => 'https://secure.example.com/test.php?one=two')); - - $this->RequestSocket->get('http://www.google.com/'); - $this->RequestSocket->get('http://www.google.com/', array('foo' => 'bar')); - $this->RequestSocket->get('http://www.google.com/', 'foo=bar'); - $this->RequestSocket->get('http://www.google.com/?foo=bar', array('foobar' => '42', 'foo' => '23')); - $this->RequestSocket->get('http://www.google.com/', null, array('version' => '1.0')); - $this->RequestSocket->get('https://secure.example.com/test.php', array('one' => 'two')); - } - -/** - * Test authentication - * - * @return void - */ - public function testAuth() { - $socket = new MockHttpSocket(); - $socket->get('http://mark:secret@example.com/test'); - $this->assertTrue(strpos($socket->request['header'], 'Authorization: Basic bWFyazpzZWNyZXQ=') !== false); - - $socket->configAuth(false); - $socket->get('http://example.com/test'); - $this->assertFalse(strpos($socket->request['header'], 'Authorization:')); - - $socket->configAuth('Test', 'mark', 'passwd'); - $socket->get('http://example.com/test'); - $this->assertTrue(strpos($socket->request['header'], 'Authorization: Test mark.passwd') !== false); - } - -/** - * test that two consecutive get() calls reset the authentication credentials. - * - * @return void - */ - public function testConsecutiveGetResetsAuthCredentials() { - $socket = new MockHttpSocket(); - $socket->get('http://mark:secret@example.com/test'); - $this->assertEquals('mark', $socket->request['uri']['user']); - $this->assertEquals('secret', $socket->request['uri']['pass']); - $this->assertTrue(strpos($socket->request['header'], 'Authorization: Basic bWFyazpzZWNyZXQ=') !== false); - - $socket->get('/test2'); - $this->assertTrue(strpos($socket->request['header'], 'Authorization: Basic bWFyazpzZWNyZXQ=') !== false); - - $socket->get('/test3'); - $this->assertTrue(strpos($socket->request['header'], 'Authorization: Basic bWFyazpzZWNyZXQ=') !== false); - } - -/** - * testPostPutDelete method - * - * @return void - */ - public function testPost() { - $this->RequestSocket->reset(); - $this->RequestSocket->expects($this->at(0)) - ->method('request') - ->with(array('method' => 'POST', 'uri' => 'http://www.google.com/', 'body' => array())); - - $this->RequestSocket->expects($this->at(1)) - ->method('request') - ->with(array('method' => 'POST', 'uri' => 'http://www.google.com/', 'body' => array('Foo' => 'bar'))); - - $this->RequestSocket->expects($this->at(2)) - ->method('request') - ->with(array('method' => 'POST', 'uri' => 'http://www.google.com/', 'body' => null, 'line' => 'Hey Server')); - - $this->RequestSocket->post('http://www.google.com/'); - $this->RequestSocket->post('http://www.google.com/', array('Foo' => 'bar')); - $this->RequestSocket->post('http://www.google.com/', null, array('line' => 'Hey Server')); - } - -/** - * testPut - * - * @return void - */ - public function testPut() { - $this->RequestSocket->reset(); - $this->RequestSocket->expects($this->at(0)) - ->method('request') - ->with(array('method' => 'PUT', 'uri' => 'http://www.google.com/', 'body' => array())); - - $this->RequestSocket->expects($this->at(1)) - ->method('request') - ->with(array('method' => 'PUT', 'uri' => 'http://www.google.com/', 'body' => array('Foo' => 'bar'))); - - $this->RequestSocket->expects($this->at(2)) - ->method('request') - ->with(array('method' => 'PUT', 'uri' => 'http://www.google.com/', 'body' => null, 'line' => 'Hey Server')); - - $this->RequestSocket->put('http://www.google.com/'); - $this->RequestSocket->put('http://www.google.com/', array('Foo' => 'bar')); - $this->RequestSocket->put('http://www.google.com/', null, array('line' => 'Hey Server')); - } - -/** - * testDelete - * - * @return void - */ - public function testDelete() { - $this->RequestSocket->reset(); - $this->RequestSocket->expects($this->at(0)) - ->method('request') - ->with(array('method' => 'DELETE', 'uri' => 'http://www.google.com/', 'body' => array())); - - $this->RequestSocket->expects($this->at(1)) - ->method('request') - ->with(array('method' => 'DELETE', 'uri' => 'http://www.google.com/', 'body' => array('Foo' => 'bar'))); - - $this->RequestSocket->expects($this->at(2)) - ->method('request') - ->with(array('method' => 'DELETE', 'uri' => 'http://www.google.com/', 'body' => null, 'line' => 'Hey Server')); - - $this->RequestSocket->delete('http://www.google.com/'); - $this->RequestSocket->delete('http://www.google.com/', array('Foo' => 'bar')); - $this->RequestSocket->delete('http://www.google.com/', null, array('line' => 'Hey Server')); - } - -/** - * testBuildRequestLine method - * - * @return void - */ - public function testBuildRequestLine() { - $this->Socket->reset(); - - $this->Socket->quirksMode = true; - $r = $this->Socket->buildRequestLine('Foo'); - $this->assertEquals('Foo', $r); - $this->Socket->quirksMode = false; - - $r = $this->Socket->buildRequestLine(true); - $this->assertEquals(false, $r); - - $r = $this->Socket->buildRequestLine(array('foo' => 'bar', 'method' => 'foo')); - $this->assertEquals(false, $r); - - $r = $this->Socket->buildRequestLine(array('method' => 'GET', 'uri' => 'http://www.cakephp.org/search?q=socket')); - $this->assertEquals("GET /search?q=socket HTTP/1.1\r\n", $r); - - $request = array( - 'method' => 'GET', - 'uri' => array( - 'path' => '/search', - 'query' => array('q' => 'socket') - ) - ); - $r = $this->Socket->buildRequestLine($request); - $this->assertEquals("GET /search?q=socket HTTP/1.1\r\n", $r); - - unset($request['method']); - $r = $this->Socket->buildRequestLine($request); - $this->assertEquals("GET /search?q=socket HTTP/1.1\r\n", $r); - - $r = $this->Socket->buildRequestLine($request, 'CAKE-HTTP/0.1'); - $this->assertEquals("GET /search?q=socket CAKE-HTTP/0.1\r\n", $r); - - $request = array('method' => 'OPTIONS', 'uri' => '*'); - $r = $this->Socket->buildRequestLine($request); - $this->assertEquals("OPTIONS * HTTP/1.1\r\n", $r); - - $request['method'] = 'GET'; - $this->Socket->quirksMode = true; - $r = $this->Socket->buildRequestLine($request); - $this->assertEquals("GET * HTTP/1.1\r\n", $r); - - $r = $this->Socket->buildRequestLine("GET * HTTP/1.1\r\n"); - $this->assertEquals("GET * HTTP/1.1\r\n", $r); - } - -/** - * testBadBuildRequestLine method - * - * @expectedException SocketException - * @return void - */ - public function testBadBuildRequestLine() { - $r = $this->Socket->buildRequestLine('Foo'); - } - -/** - * testBadBuildRequestLine2 method - * - * @expectedException SocketException - * @return void - */ - public function testBadBuildRequestLine2() { - $r = $this->Socket->buildRequestLine("GET * HTTP/1.1\r\n"); - } - -/** - * Asserts that HttpSocket::parseUri is working properly - * - * @return void - */ - public function testParseUri() { - $this->Socket->reset(); - - $uri = $this->Socket->parseUri(array('invalid' => 'uri-string')); - $this->assertEquals(false, $uri); - - $uri = $this->Socket->parseUri(array('invalid' => 'uri-string'), array('host' => 'somehost')); - $this->assertEquals(array('host' => 'somehost', 'invalid' => 'uri-string'), $uri); - - $uri = $this->Socket->parseUri(false); - $this->assertEquals(false, $uri); - - $uri = $this->Socket->parseUri('/my-cool-path'); - $this->assertEquals(array('path' => '/my-cool-path'), $uri); - - $uri = $this->Socket->parseUri('http://bob:foo123@www.cakephp.org:40/search?q=dessert#results'); - $this->assertEquals($uri, array( - 'scheme' => 'http', - 'host' => 'www.cakephp.org', - 'port' => 40, - 'user' => 'bob', - 'pass' => 'foo123', - 'path' => '/search', - 'query' => array('q' => 'dessert'), - 'fragment' => 'results' - )); - - $uri = $this->Socket->parseUri('http://www.cakephp.org/'); - $this->assertEquals($uri, array( - 'scheme' => 'http', - 'host' => 'www.cakephp.org', - 'path' => '/' - )); - - $uri = $this->Socket->parseUri('http://www.cakephp.org', true); - $this->assertEquals($uri, array( - 'scheme' => 'http', - 'host' => 'www.cakephp.org', - 'port' => 80, - 'user' => null, - 'pass' => null, - 'path' => '/', - 'query' => array(), - 'fragment' => null - )); - - $uri = $this->Socket->parseUri('https://www.cakephp.org', true); - $this->assertEquals($uri, array( - 'scheme' => 'https', - 'host' => 'www.cakephp.org', - 'port' => 443, - 'user' => null, - 'pass' => null, - 'path' => '/', - 'query' => array(), - 'fragment' => null - )); - - $uri = $this->Socket->parseUri('www.cakephp.org:443/query?foo', true); - $this->assertEquals($uri, array( - 'scheme' => 'https', - 'host' => 'www.cakephp.org', - 'port' => 443, - 'user' => null, - 'pass' => null, - 'path' => '/query', - 'query' => array('foo' => ""), - 'fragment' => null - )); - - $uri = $this->Socket->parseUri('http://www.cakephp.org', array('host' => 'piephp.org', 'user' => 'bob', 'fragment' => 'results')); - $this->assertEquals($uri, array( - 'host' => 'www.cakephp.org', - 'user' => 'bob', - 'fragment' => 'results', - 'scheme' => 'http' - )); - - $uri = $this->Socket->parseUri('https://www.cakephp.org', array('scheme' => 'http', 'port' => 23)); - $this->assertEquals($uri, array( - 'scheme' => 'https', - 'port' => 23, - 'host' => 'www.cakephp.org' - )); - - $uri = $this->Socket->parseUri('www.cakephp.org:59', array('scheme' => array('http', 'https'), 'port' => 80)); - $this->assertEquals($uri, array( - 'scheme' => 'http', - 'port' => 59, - 'host' => 'www.cakephp.org' - )); - - $uri = $this->Socket->parseUri(array('scheme' => 'http', 'host' => 'www.google.com', 'port' => 8080), array('scheme' => array('http', 'https'), 'host' => 'www.google.com', 'port' => array(80, 443))); - $this->assertEquals($uri, array( - 'scheme' => 'http', - 'host' => 'www.google.com', - 'port' => 8080 - )); - - $uri = $this->Socket->parseUri('http://www.cakephp.org/?param1=value1¶m2=value2%3Dvalue3'); - $this->assertEquals($uri, array( - 'scheme' => 'http', - 'host' => 'www.cakephp.org', - 'path' => '/', - 'query' => array( - 'param1' => 'value1', - 'param2' => 'value2=value3' - ) - )); - - $uri = $this->Socket->parseUri('http://www.cakephp.org/?param1=value1¶m2=value2=value3'); - $this->assertEquals($uri, array( - 'scheme' => 'http', - 'host' => 'www.cakephp.org', - 'path' => '/', - 'query' => array( - 'param1' => 'value1', - 'param2' => 'value2=value3' - ) - )); - } - -/** - * Tests that HttpSocket::buildUri can turn all kinds of uri arrays (and strings) into fully or partially qualified URI's - * - * @return void - */ - public function testBuildUri() { - $this->Socket->reset(); - - $r = $this->Socket->buildUri(true); - $this->assertEquals(false, $r); - - $r = $this->Socket->buildUri('foo.com'); - $this->assertEquals('http://foo.com/', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org')); - $this->assertEquals('http://www.cakephp.org/', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'scheme' => 'https')); - $this->assertEquals('https://www.cakephp.org/', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'port' => 23)); - $this->assertEquals('http://www.cakephp.org:23/', $r); - - $r = $this->Socket->buildUri(array('path' => 'www.google.com/search', 'query' => 'q=cakephp')); - $this->assertEquals('http://www.google.com/search?q=cakephp', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'scheme' => 'https', 'port' => 79)); - $this->assertEquals('https://www.cakephp.org:79/', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'path' => 'foo')); - $this->assertEquals('http://www.cakephp.org/foo', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'path' => '/foo')); - $this->assertEquals('http://www.cakephp.org/foo', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'path' => '/search', 'query' => array('q' => 'HttpSocket'))); - $this->assertEquals('http://www.cakephp.org/search?q=HttpSocket', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'fragment' => 'bar')); - $this->assertEquals('http://www.cakephp.org/#bar', $r); - - $r = $this->Socket->buildUri(array( - 'scheme' => 'https', - 'host' => 'www.cakephp.org', - 'port' => 25, - 'user' => 'bob', - 'pass' => 'secret', - 'path' => '/cool', - 'query' => array('foo' => 'bar'), - 'fragment' => 'comment' - )); - $this->assertEquals('https://bob:secret@www.cakephp.org:25/cool?foo=bar#comment', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org', 'fragment' => 'bar'), '%fragment?%host'); - $this->assertEquals('bar?www.cakephp.org', $r); - - $r = $this->Socket->buildUri(array('host' => 'www.cakephp.org'), '%fragment???%host'); - $this->assertEquals('???www.cakephp.org', $r); - - $r = $this->Socket->buildUri(array('path' => '*'), '/%path?%query'); - $this->assertEquals('*', $r); - - $r = $this->Socket->buildUri(array('scheme' => 'foo', 'host' => 'www.cakephp.org')); - $this->assertEquals('foo://www.cakephp.org:80/', $r); - } - -/** - * Asserts that HttpSocket::parseQuery is working properly - * - * @return void - */ - public function testParseQuery() { - $this->Socket->reset(); - - $query = $this->Socket->parseQuery(array('framework' => 'cakephp')); - $this->assertEquals(array('framework' => 'cakephp'), $query); - - $query = $this->Socket->parseQuery(''); - $this->assertEquals(array(), $query); - - $query = $this->Socket->parseQuery('framework=cakephp'); - $this->assertEquals(array('framework' => 'cakephp'), $query); - - $query = $this->Socket->parseQuery('?framework=cakephp'); - $this->assertEquals(array('framework' => 'cakephp'), $query); - - $query = $this->Socket->parseQuery('a&b&c'); - $this->assertEquals(array('a' => '', 'b' => '', 'c' => ''), $query); - - $query = $this->Socket->parseQuery('value=12345'); - $this->assertEquals(array('value' => '12345'), $query); - - $query = $this->Socket->parseQuery('a[0]=foo&a[1]=bar&a[2]=cake'); - $this->assertEquals(array('a' => array(0 => 'foo', 1 => 'bar', 2 => 'cake')), $query); - - $query = $this->Socket->parseQuery('a[]=foo&a[]=bar&a[]=cake'); - $this->assertEquals(array('a' => array(0 => 'foo', 1 => 'bar', 2 => 'cake')), $query); - - $query = $this->Socket->parseQuery('a[][]=foo&a[][]=bar&a[][]=cake'); - $expectedQuery = array( - 'a' => array( - 0 => array( - 0 => 'foo' - ), - 1 => array( - 0 => 'bar' - ), - array( - 0 => 'cake' - ) - ) - ); - $this->assertEquals($expectedQuery, $query); - - $query = $this->Socket->parseQuery('a[][]=foo&a[bar]=php&a[][]=bar&a[][]=cake'); - $expectedQuery = array( - 'a' => array( - array('foo'), - 'bar' => 'php', - array('bar'), - array('cake') - ) - ); - $this->assertEquals($expectedQuery, $query); - - $query = $this->Socket->parseQuery('user[]=jim&user[3]=tom&user[]=bob'); - $expectedQuery = array( - 'user' => array( - 0 => 'jim', - 3 => 'tom', - 4 => 'bob' - ) - ); - $this->assertEquals($expectedQuery, $query); - - $queryStr = 'user[0]=foo&user[0][items][]=foo&user[0][items][]=bar&user[][name]=jim&user[1][items][personal][]=book&user[1][items][personal][]=pen&user[1][items][]=ball&user[count]=2&empty'; - $query = $this->Socket->parseQuery($queryStr); - $expectedQuery = array( - 'user' => array( - 0 => array( - 'items' => array( - 'foo', - 'bar' - ) - ), - 1 => array( - 'name' => 'jim', - 'items' => array( - 'personal' => array( - 'book' - , 'pen' - ), - 'ball' - ) - ), - 'count' => '2' - ), - 'empty' => '' - ); - $this->assertEquals($expectedQuery, $query); - - $query = 'openid.ns=example.com&foo=bar&foo=baz'; - $result = $this->Socket->parseQuery($query); - $expected = array( - 'openid.ns' => 'example.com', - 'foo' => array('bar', 'baz') - ); - $this->assertEquals($expected, $result); - } - -/** - * Tests that HttpSocket::buildHeader can turn a given $header array into a proper header string according to - * HTTP 1.1 specs. - * - * @return void - */ - public function testBuildHeader() { - $this->Socket->reset(); - - $r = $this->Socket->buildHeader(true); - $this->assertEquals(false, $r); - - $r = $this->Socket->buildHeader('My raw header'); - $this->assertEquals('My raw header', $r); - - $r = $this->Socket->buildHeader(array('Host' => 'www.cakephp.org')); - $this->assertEquals("Host: www.cakephp.org\r\n", $r); - - $r = $this->Socket->buildHeader(array('Host' => 'www.cakephp.org', 'Connection' => 'Close')); - $this->assertEquals("Host: www.cakephp.org\r\nConnection: Close\r\n", $r); - - $r = $this->Socket->buildHeader(array('People' => array('Bob', 'Jim', 'John'))); - $this->assertEquals("People: Bob,Jim,John\r\n", $r); - - $r = $this->Socket->buildHeader(array('Multi-Line-Field' => "This is my\r\nMulti Line field")); - $this->assertEquals("Multi-Line-Field: This is my\r\n Multi Line field\r\n", $r); - - $r = $this->Socket->buildHeader(array('Multi-Line-Field' => "This is my\r\n Multi Line field")); - $this->assertEquals("Multi-Line-Field: This is my\r\n Multi Line field\r\n", $r); - - $r = $this->Socket->buildHeader(array('Multi-Line-Field' => "This is my\r\n\tMulti Line field")); - $this->assertEquals("Multi-Line-Field: This is my\r\n\tMulti Line field\r\n", $r); - - $r = $this->Socket->buildHeader(array('Test@Field' => "My value")); - $this->assertEquals("Test\"@\"Field: My value\r\n", $r); - } - -/** - * testBuildCookies method - * - * @return void - * @todo Test more scenarios - */ - public function testBuildCookies() { - $cookies = array( - 'foo' => array( - 'value' => 'bar' - ), - 'people' => array( - 'value' => 'jim,jack,johnny;', - 'path' => '/accounts' - ) - ); - $expect = "Cookie: foo=bar; people=jim,jack,johnny\";\"\r\n"; - $result = $this->Socket->buildCookies($cookies); - $this->assertEquals($expect, $result); - } - -/** - * Tests that HttpSocket::_tokenEscapeChars() returns the right characters. - * - * @return void - */ - public function testTokenEscapeChars() { - $this->Socket->reset(); - - $expected = array( - '\x22','\x28','\x29','\x3c','\x3e','\x40','\x2c','\x3b','\x3a','\x5c','\x2f','\x5b','\x5d','\x3f','\x3d','\x7b', - '\x7d','\x20','\x00','\x01','\x02','\x03','\x04','\x05','\x06','\x07','\x08','\x09','\x0a','\x0b','\x0c','\x0d', - '\x0e','\x0f','\x10','\x11','\x12','\x13','\x14','\x15','\x16','\x17','\x18','\x19','\x1a','\x1b','\x1c','\x1d', - '\x1e','\x1f','\x7f' - ); - $r = $this->Socket->tokenEscapeChars(); - $this->assertEquals($expected, $r); - - foreach ($expected as $key => $char) { - $expected[$key] = chr(hexdec(substr($char, 2))); - } - - $r = $this->Socket->tokenEscapeChars(false); - $this->assertEquals($expected, $r); - } - -/** - * Test that HttpSocket::escapeToken is escaping all characters as described in RFC 2616 (HTTP 1.1 specs) - * - * @return void - */ - public function testEscapeToken() { - $this->Socket->reset(); - - $this->assertEquals('Foo', $this->Socket->escapeToken('Foo')); - - $escape = $this->Socket->tokenEscapeChars(false); - foreach ($escape as $char) { - $token = 'My-special-' . $char . '-Token'; - $escapedToken = $this->Socket->escapeToken($token); - $expectedToken = 'My-special-"' . $char . '"-Token'; - - $this->assertEquals($expectedToken, $escapedToken, 'Test token escaping for ASCII ' . ord($char)); - } - - $token = 'Extreme-:Token- -"@-test'; - $escapedToken = $this->Socket->escapeToken($token); - $expectedToken = 'Extreme-":"Token-" "-""""@"-test'; - $this->assertEquals($expectedToken, $escapedToken); - } - -/** - * This tests asserts HttpSocket::reset() resets a HttpSocket instance to it's initial state (before Object::__construct - * got executed) - * - * @return void - */ - public function testReset() { - $this->Socket->reset(); - - $initialState = get_class_vars('HttpSocket'); - foreach ($initialState as $property => $value) { - $this->Socket->{$property} = 'Overwritten'; - } - - $return = $this->Socket->reset(); - - foreach ($initialState as $property => $value) { - $this->assertEquals($this->Socket->{$property}, $value); - } - - $this->assertEquals(true, $return); - } - -/** - * This tests asserts HttpSocket::reset(false) resets certain HttpSocket properties to their initial state (before - * Object::__construct got executed). - * - * @return void - */ - public function testPartialReset() { - $this->Socket->reset(); - - $partialResetProperties = array('request', 'response'); - $initialState = get_class_vars('HttpSocket'); - - foreach ($initialState as $property => $value) { - $this->Socket->{$property} = 'Overwritten'; - } - - $return = $this->Socket->reset(false); - - foreach ($initialState as $property => $originalValue) { - if (in_array($property, $partialResetProperties)) { - $this->assertEquals($this->Socket->{$property}, $originalValue); - } else { - $this->assertEquals('Overwritten', $this->Socket->{$property}); - } - } - $this->assertEquals(true, $return); - } -} diff --git a/lib/Cake/Test/Case/Routing/DispatcherTest.php b/lib/Cake/Test/Case/Routing/DispatcherTest.php deleted file mode 100644 index 00bd99b1b03..00000000000 --- a/lib/Cake/Test/Case/Routing/DispatcherTest.php +++ /dev/null @@ -1,1587 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Routing - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Dispatcher', 'Routing'); - -if (!class_exists('AppController', false)) { - require_once CAKE . 'Test' . DS . 'test_app' . DS . 'Controller' . DS . 'AppController.php'; -} elseif (!defined('APP_CONTROLLER_EXISTS')) { - define('APP_CONTROLLER_EXISTS', true); -} - -/** - * A testing stub that doesn't send headers. - * - * @package Cake.Test.Case.Routing - */ -class DispatcherMockCakeResponse extends CakeResponse { - - protected function _sendHeader($name, $value = null) { - return $name . ' ' . $value; - } - -} - -/** - * TestDispatcher class - * - * @package Cake.Test.Case.Routing - */ -class TestDispatcher extends Dispatcher { - -/** - * invoke method - * - * @param mixed $controller - * @param mixed $request - * @return void - */ - protected function _invoke(Controller $controller, CakeRequest $request, CakeResponse $response) { - $result = parent::_invoke($controller, $request, $response); - return $controller; - } - -} - -/** - * MyPluginAppController class - * - * @package Cake.Test.Case.Routing - */ -class MyPluginAppController extends AppController { -} - -abstract class DispatcherTestAbstractController extends Controller { - - abstract public function index(); - -} - -interface DispatcherTestInterfaceController { - - public function index(); - -} - -/** - * MyPluginController class - * - * @package Cake.Test.Case.Routing - */ -class MyPluginController extends MyPluginAppController { - -/** - * name property - * - * @var string 'MyPlugin' - */ - public $name = 'MyPlugin'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * index method - * - * @return void - */ - public function index() { - return true; - } - -/** - * add method - * - * @return void - */ - public function add() { - return true; - } - -/** - * admin_add method - * - * @param mixed $id - * @return void - */ - public function admin_add($id = null) { - return $id; - } - -} - -/** - * SomePagesController class - * - * @package Cake.Test.Case.Routing - */ -class SomePagesController extends AppController { - -/** - * name property - * - * @var string 'SomePages' - */ - public $name = 'SomePages'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * display method - * - * @param mixed $page - * @return void - */ - public function display($page = null) { - return $page; - } - -/** - * index method - * - * @return void - */ - public function index() { - return true; - } - -/** - * Test method for returning responses. - * - * @return CakeResponse - */ - public function responseGenerator() { - return new CakeResponse(array('body' => 'new response')); - } - -} - -/** - * OtherPagesController class - * - * @package Cake.Test.Case.Routing - */ -class OtherPagesController extends MyPluginAppController { - -/** - * name property - * - * @var string 'OtherPages' - */ - public $name = 'OtherPages'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * display method - * - * @param mixed $page - * @return void - */ - public function display($page = null) { - return $page; - } - -/** - * index method - * - * @return void - */ - public function index() { - return true; - } - -} - -/** - * TestDispatchPagesController class - * - * @package Cake.Test.Case.Routing - */ -class TestDispatchPagesController extends AppController { - -/** - * name property - * - * @var string 'TestDispatchPages' - */ - public $name = 'TestDispatchPages'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * admin_index method - * - * @return void - */ - public function admin_index() { - return true; - } - -/** - * camelCased method - * - * @return void - */ - public function camelCased() { - return true; - } - -} - -/** - * ArticlesTestAppController class - * - * @package Cake.Test.Case.Routing - */ -class ArticlesTestAppController extends AppController { -} - -/** - * ArticlesTestController class - * - * @package Cake.Test.Case.Routing - */ -class ArticlesTestController extends ArticlesTestAppController { - -/** - * name property - * - * @var string 'ArticlesTest' - */ - public $name = 'ArticlesTest'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * admin_index method - * - * @return void - */ - public function admin_index() { - return true; - } - -/** - * fake index method. - * - * @return void - */ - public function index() { - return true; - } - -} - -/** - * SomePostsController class - * - * @package Cake.Test.Case.Routing - */ -class SomePostsController extends AppController { - -/** - * name property - * - * @var string 'SomePosts' - */ - public $name = 'SomePosts'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * autoRender property - * - * @var bool false - */ - public $autoRender = false; - -/** - * beforeFilter method - * - * @return void - */ - public function beforeFilter() { - if ($this->params['action'] == 'index') { - $this->params['action'] = 'view'; - } else { - $this->params['action'] = 'change'; - } - $this->params['pass'] = array('changed'); - } - -/** - * index method - * - * @return void - */ - public function index() { - return true; - } - -/** - * change method - * - * @return void - */ - public function change() { - return true; - } - -} - -/** - * TestCachedPagesController class - * - * @package Cake.Test.Case.Routing - */ -class TestCachedPagesController extends Controller { - -/** - * name property - * - * @var string 'TestCachedPages' - */ - public $name = 'TestCachedPages'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * helpers property - * - * @var array - */ - public $helpers = array('Cache', 'Html'); - -/** - * cacheAction property - * - * @var array - */ - public $cacheAction = array( - 'index' => '+2 sec', - 'test_nocache_tags' => '+2 sec', - 'view' => '+2 sec' - ); - -/** - * Mock out the response object so it doesn't send headers. - * - * @var string - */ - protected $_responseClass = 'DispatcherMockCakeResponse'; - -/** - * viewPath property - * - * @var string 'posts' - */ - public $viewPath = 'Posts'; - -/** - * index method - * - * @return void - */ - public function index() { - $this->render(); - } - -/** - * test_nocache_tags method - * - * @return void - */ - public function test_nocache_tags() { - $this->render(); - } - -/** - * view method - * - * @return void - */ - public function view($id = null) { - $this->render('index'); - } - -/** - * test cached forms / tests view object being registered - * - * @return void - */ - public function cache_form() { - $this->cacheAction = 10; - $this->helpers[] = 'Form'; - } - -/** - * Test cached views with themes. - */ - public function themed() { - $this->cacheAction = 10; - $this->viewClass = 'Theme'; - $this->theme = 'TestTheme'; - } - -} - -/** - * TimesheetsController class - * - * @package Cake.Test.Case.Routing - */ -class TimesheetsController extends Controller { - -/** - * name property - * - * @var string 'Timesheets' - */ - public $name = 'Timesheets'; - -/** - * uses property - * - * @var array - */ - public $uses = array(); - -/** - * index method - * - * @return void - */ - public function index() { - return true; - } - -} - -/** - * DispatcherTest class - * - * @package Cake.Test.Case.Routing - */ -class DispatcherTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $this->_get = $_GET; - $_GET = array(); - $this->_post = $_POST; - $this->_files = $_FILES; - $this->_server = $_SERVER; - - $this->_app = Configure::read('App'); - Configure::write('App.base', false); - Configure::write('App.baseUrl', false); - Configure::write('App.dir', 'app'); - Configure::write('App.webroot', 'webroot'); - - $this->_cache = Configure::read('Cache'); - Configure::write('Cache.disable', true); - - $this->_debug = Configure::read('debug'); - - App::build(); - App::objects('plugin', null, false); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - $_GET = $this->_get; - $_POST = $this->_post; - $_FILES = $this->_files; - $_SERVER = $this->_server; - App::build(); - CakePlugin::unload(); - Configure::write('App', $this->_app); - Configure::write('Cache', $this->_cache); - Configure::write('debug', $this->_debug); - } - -/** - * testParseParamsWithoutZerosAndEmptyPost method - * - * @return void - */ - public function testParseParamsWithoutZerosAndEmptyPost() { - $Dispatcher = new Dispatcher(); - - $test = $Dispatcher->parseParams(new CakeRequest("/testcontroller/testaction/params1/params2/params3")); - $this->assertSame($test['controller'], 'testcontroller'); - $this->assertSame($test['action'], 'testaction'); - $this->assertSame($test['pass'][0], 'params1'); - $this->assertSame($test['pass'][1], 'params2'); - $this->assertSame($test['pass'][2], 'params3'); - $this->assertFalse(!empty($test['form'])); - } - -/** - * testParseParamsReturnsPostedData method - * - * @return void - */ - public function testParseParamsReturnsPostedData() { - $_POST['testdata'] = "My Posted Content"; - $Dispatcher = new Dispatcher(); - - $test = $Dispatcher->parseParams(new CakeRequest("/")); - $this->assertEquals("My Posted Content", $test['data']['testdata']); - } - -/** - * testParseParamsWithSingleZero method - * - * @return void - */ - public function testParseParamsWithSingleZero() { - $Dispatcher = new Dispatcher(); - $test = $Dispatcher->parseParams(new CakeRequest("/testcontroller/testaction/1/0/23")); - $this->assertSame($test['controller'], 'testcontroller'); - $this->assertSame($test['action'], 'testaction'); - $this->assertSame($test['pass'][0], '1'); - $this->assertRegExp('/\\A(?:0)\\z/', $test['pass'][1]); - $this->assertSame($test['pass'][2], '23'); - } - -/** - * testParseParamsWithManySingleZeros method - * - * @return void - */ - public function testParseParamsWithManySingleZeros() { - $Dispatcher = new Dispatcher(); - $test = $Dispatcher->parseParams(new CakeRequest("/testcontroller/testaction/0/0/0/0/0/0")); - $this->assertRegExp('/\\A(?:0)\\z/', $test['pass'][0]); - $this->assertRegExp('/\\A(?:0)\\z/', $test['pass'][1]); - $this->assertRegExp('/\\A(?:0)\\z/', $test['pass'][2]); - $this->assertRegExp('/\\A(?:0)\\z/', $test['pass'][3]); - $this->assertRegExp('/\\A(?:0)\\z/', $test['pass'][4]); - $this->assertRegExp('/\\A(?:0)\\z/', $test['pass'][5]); - } - -/** - * testParseParamsWithManyZerosInEachSectionOfUrl method - * - * @return void - */ - public function testParseParamsWithManyZerosInEachSectionOfUrl() { - $Dispatcher = new Dispatcher(); - $request = new CakeRequest("/testcontroller/testaction/000/0000/00000/000000/000000/0000000"); - $test = $Dispatcher->parseParams($request); - $this->assertRegExp('/\\A(?:000)\\z/', $test['pass'][0]); - $this->assertRegExp('/\\A(?:0000)\\z/', $test['pass'][1]); - $this->assertRegExp('/\\A(?:00000)\\z/', $test['pass'][2]); - $this->assertRegExp('/\\A(?:000000)\\z/', $test['pass'][3]); - $this->assertRegExp('/\\A(?:000000)\\z/', $test['pass'][4]); - $this->assertRegExp('/\\A(?:0000000)\\z/', $test['pass'][5]); - } - -/** - * testParseParamsWithMixedOneToManyZerosInEachSectionOfUrl method - * - * @return void - */ - public function testParseParamsWithMixedOneToManyZerosInEachSectionOfUrl() { - $Dispatcher = new Dispatcher(); - - $request = new CakeRequest("/testcontroller/testaction/01/0403/04010/000002/000030/0000400"); - $test = $Dispatcher->parseParams($request); - $this->assertRegExp('/\\A(?:01)\\z/', $test['pass'][0]); - $this->assertRegExp('/\\A(?:0403)\\z/', $test['pass'][1]); - $this->assertRegExp('/\\A(?:04010)\\z/', $test['pass'][2]); - $this->assertRegExp('/\\A(?:000002)\\z/', $test['pass'][3]); - $this->assertRegExp('/\\A(?:000030)\\z/', $test['pass'][4]); - $this->assertRegExp('/\\A(?:0000400)\\z/', $test['pass'][5]); - } - -/** - * testQueryStringOnRoot method - * - * @return void - */ - public function testQueryStringOnRoot() { - Router::reload(); - Router::connect('/', array('controller' => 'pages', 'action' => 'display', 'home')); - Router::connect('/pages/*', array('controller' => 'pages', 'action' => 'display')); - Router::connect('/:controller/:action/*'); - - $_GET = array('coffee' => 'life', 'sleep' => 'sissies'); - $Dispatcher = new Dispatcher(); - $uri = new CakeRequest('posts/home/?coffee=life&sleep=sissies'); - $result = $Dispatcher->parseParams($uri); - $this->assertRegExp('/posts/', $result['controller']); - $this->assertRegExp('/home/', $result['action']); - $this->assertTrue(isset($result['url']['sleep'])); - $this->assertTrue(isset($result['url']['coffee'])); - - $Dispatcher = new Dispatcher(); - $uri = new CakeRequest('/?coffee=life&sleep=sissy'); - - $result = $Dispatcher->parseParams($uri); - $this->assertRegExp('/pages/', $result['controller']); - $this->assertRegExp('/display/', $result['action']); - $this->assertTrue(isset($result['url']['sleep'])); - $this->assertTrue(isset($result['url']['coffee'])); - $this->assertEquals('life', $result['url']['coffee']); - } - -/** - * testMissingController method - * - * @expectedException MissingControllerException - * @expectedExceptionMessage Controller class SomeControllerController could not be found. - * @return void - */ - public function testMissingController() { - Router::connect('/:controller/:action/*'); - - $Dispatcher = new TestDispatcher(); - Configure::write('App.baseUrl', '/index.php'); - $url = new CakeRequest('some_controller/home/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - } - -/** - * testMissingControllerInterface method - * - * @expectedException MissingControllerException - * @expectedExceptionMessage Controller class DispatcherTestInterfaceController could not be found. - * @return void - */ - public function testMissingControllerInterface() { - Router::connect('/:controller/:action/*'); - - $Dispatcher = new TestDispatcher(); - Configure::write('App.baseUrl', '/index.php'); - $url = new CakeRequest('dispatcher_test_interface/index'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - } - -/** - * testMissingControllerInterface method - * - * @expectedException MissingControllerException - * @expectedExceptionMessage Controller class DispatcherTestAbstractController could not be found. - * @return void - */ - public function testMissingControllerAbstract() { - Router::connect('/:controller/:action/*'); - - $Dispatcher = new TestDispatcher(); - Configure::write('App.baseUrl', '/index.php'); - $url = new CakeRequest('dispatcher_test_abstract/index'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - } - -/** - * testDispatch method - * - * @return void - */ - public function testDispatchBasic() { - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - $Dispatcher = new TestDispatcher(); - Configure::write('App.baseUrl', '/index.php'); - $url = new CakeRequest('pages/home/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $expected = 'Pages'; - $this->assertEquals($expected, $controller->name); - - $expected = array('0' => 'home', 'param' => 'value', 'param2' => 'value2'); - $this->assertSame($expected, $controller->passedArgs); - - Configure::write('App.baseUrl','/pages/index.php'); - - $url = new CakeRequest('pages/home'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $expected = 'Pages'; - $this->assertEquals($expected, $controller->name); - - $url = new CakeRequest('pages/home/'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertNull($controller->plugin); - - $expected = 'Pages'; - $this->assertEquals($expected, $controller->name); - - unset($Dispatcher); - - require CAKE . 'Config' . DS . 'routes.php'; - $Dispatcher = new TestDispatcher(); - Configure::write('App.baseUrl', '/timesheets/index.php'); - - $url = new CakeRequest('timesheets'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $expected = 'Timesheets'; - $this->assertEquals($expected, $controller->name); - - $url = new CakeRequest('timesheets/'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $this->assertEquals('Timesheets', $controller->name); - $this->assertEquals('/timesheets/index.php', $url->base); - - $url = new CakeRequest('test_dispatch_pages/camelCased'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertEquals('TestDispatchPages', $controller->name); - - $url = new CakeRequest('test_dispatch_pages/camelCased/something. .'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertEquals('something. .', $controller->params['pass'][0], 'Period was chopped off. %s'); - } - -/** - * Test that Dispatcher handles actions that return response objects. - * - * @return void - */ - public function testDispatchActionReturnsResponse() { - Router::connect('/:controller/:action'); - $Dispatcher = new Dispatcher(); - $request = new CakeRequest('some_pages/responseGenerator'); - $response = $this->getMock('CakeResponse', array('_sendHeader')); - - ob_start(); - $Dispatcher->dispatch($request, $response); - $result = ob_get_clean(); - - $this->assertEquals('new response', $result); - } - -/** - * testAdminDispatch method - * - * @return void - */ - public function testAdminDispatch() { - $_POST = array(); - $Dispatcher = new TestDispatcher(); - Configure::write('Routing.prefixes', array('admin')); - Configure::write('App.baseUrl','/cake/repo/branches/1.2.x.x/index.php'); - $url = new CakeRequest('admin/test_dispatch_pages/index/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - - Router::reload(); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $this->assertEquals('TestDispatchPages', $controller->name); - - $this->assertSame($controller->passedArgs, array('param' => 'value', 'param2' => 'value2')); - $this->assertTrue($controller->params['admin']); - - $expected = '/cake/repo/branches/1.2.x.x/index.php/admin/test_dispatch_pages/index/param:value/param2:value2'; - $this->assertSame($expected, $controller->here); - - $expected = '/cake/repo/branches/1.2.x.x/index.php'; - $this->assertSame($expected, $controller->base); - } - -/** - * testPluginDispatch method - * - * @return void - */ - public function testPluginDispatch() { - $_POST = array(); - - Router::reload(); - $Dispatcher = new TestDispatcher(); - Router::connect( - '/my_plugin/:controller/*', - array('plugin' => 'my_plugin', 'controller' => 'pages', 'action' => 'display') - ); - - $url = new CakeRequest('my_plugin/some_pages/home/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $result = $Dispatcher->parseParams($url); - $expected = array( - 'pass' => array('home'), - 'named' => array('param' => 'value', 'param2' => 'value2'), 'plugin' => 'my_plugin', - 'controller' => 'some_pages', 'action' => 'display' - ); - foreach ($expected as $key => $value) { - $this->assertEquals($value, $result[$key], 'Value mismatch ' . $key . ' %'); - } - - $this->assertSame($controller->plugin, 'MyPlugin'); - $this->assertSame($controller->name, 'SomePages'); - $this->assertSame($controller->params['controller'], 'some_pages'); - $this->assertSame($controller->passedArgs, array('0' => 'home', 'param' => 'value', 'param2' => 'value2')); - } - -/** - * testAutomaticPluginDispatch method - * - * @return void - */ - public function testAutomaticPluginDispatch() { - $_POST = array(); - $_SERVER['PHP_SELF'] = '/cake/repo/branches/1.2.x.x/index.php'; - - Router::reload(); - $Dispatcher = new TestDispatcher(); - Router::connect( - '/my_plugin/:controller/:action/*', - array('plugin' => 'my_plugin', 'controller' => 'pages', 'action' => 'display') - ); - - $Dispatcher->base = false; - - $url = new CakeRequest('my_plugin/other_pages/index/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $this->assertSame($controller->plugin, 'MyPlugin'); - $this->assertSame($controller->name, 'OtherPages'); - $this->assertSame($controller->action, 'index'); - $this->assertSame($controller->passedArgs, array('param' => 'value', 'param2' => 'value2')); - - $expected = '/cake/repo/branches/1.2.x.x/my_plugin/other_pages/index/param:value/param2:value2'; - $this->assertSame($expected, $url->here); - - $expected = '/cake/repo/branches/1.2.x.x'; - $this->assertSame($expected, $url->base); - } - -/** - * testAutomaticPluginControllerDispatch method - * - * @return void - */ - public function testAutomaticPluginControllerDispatch() { - $plugins = App::objects('plugin'); - $plugins[] = 'MyPlugin'; - $plugins[] = 'ArticlesTest'; - - CakePlugin::load('MyPlugin', array('path' => '/fake/path')); - - Router::reload(); - $Dispatcher = new TestDispatcher(); - $Dispatcher->base = false; - - $url = new CakeRequest('my_plugin/my_plugin/add/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $this->assertSame($controller->plugin, 'MyPlugin'); - $this->assertSame($controller->name, 'MyPlugin'); - $this->assertSame($controller->action, 'add'); - $this->assertEquals(array('param' => 'value', 'param2' => 'value2'), $controller->params['named']); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $Dispatcher = new TestDispatcher(); - $Dispatcher->base = false; - - // Simulates the Route for a real plugin, installed in APP/plugins - Router::connect('/my_plugin/:controller/:action/*', array('plugin' => 'my_plugin')); - - $plugin = 'MyPlugin'; - $pluginUrl = Inflector::underscore($plugin); - - $url = new CakeRequest($pluginUrl); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertSame($controller->plugin, 'MyPlugin'); - $this->assertSame($controller->name, 'MyPlugin'); - $this->assertSame($controller->action, 'index'); - - $expected = $pluginUrl; - $this->assertEquals($expected, $controller->params['controller']); - - Configure::write('Routing.prefixes', array('admin')); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $Dispatcher = new TestDispatcher(); - - $url = new CakeRequest('admin/my_plugin/my_plugin/add/5/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $this->assertEquals('my_plugin', $controller->params['plugin']); - $this->assertEquals('my_plugin', $controller->params['controller']); - $this->assertEquals('admin_add', $controller->params['action']); - $this->assertEquals(array(5), $controller->params['pass']); - $this->assertEquals(array('param' => 'value', 'param2' => 'value2'), $controller->params['named']); - $this->assertSame($controller->plugin, 'MyPlugin'); - $this->assertSame($controller->name, 'MyPlugin'); - $this->assertSame($controller->action, 'admin_add'); - - $expected = array(0 => 5, 'param' => 'value', 'param2' => 'value2'); - $this->assertEquals($expected, $controller->passedArgs); - - Configure::write('Routing.prefixes', array('admin')); - CakePlugin::load('ArticlesTest', array('path' => '/fake/path')); - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - - $Dispatcher = new TestDispatcher(); - - $controller = $Dispatcher->dispatch(new CakeRequest('admin/articles_test'), $response, array('return' => 1)); - $this->assertSame($controller->plugin, 'ArticlesTest'); - $this->assertSame($controller->name, 'ArticlesTest'); - $this->assertSame($controller->action, 'admin_index'); - - $expected = array( - 'pass' => array(), - 'named' => array(), - 'controller' => 'articles_test', - 'plugin' => 'articles_test', - 'action' => 'admin_index', - 'prefix' => 'admin', - 'admin' => true, - 'return' => 1 - ); - foreach ($expected as $key => $value) { - $this->assertEquals($expected[$key], $controller->request[$key], 'Value mismatch ' . $key); - } - } - -/** - * test Plugin dispatching without controller name and using - * plugin short form instead. - * - * @return void - */ - public function testAutomaticPluginDispatchWithShortAccess() { - CakePlugin::load('MyPlugin', array('path' => '/fake/path')); - Router::reload(); - - $Dispatcher = new TestDispatcher(); - $Dispatcher->base = false; - - $url = new CakeRequest('my_plugin/'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertEquals('my_plugin', $controller->params['controller']); - $this->assertEquals('my_plugin', $controller->params['plugin']); - $this->assertEquals('index', $controller->params['action']); - $this->assertFalse(isset($controller->params['pass'][0])); - } - -/** - * test plugin shortcut urls with controllers that need to be loaded, - * the above test uses a controller that has already been included. - * - * @return void - */ - public function testPluginShortCutUrlsWithControllerThatNeedsToBeLoaded() { - $loaded = class_exists('TestPluginController', false); - $this->skipIf($loaded, 'TestPluginController already loaded.'); - - Router::reload(); - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - $Dispatcher = new TestDispatcher(); - $Dispatcher->base = false; - - $url = new CakeRequest('test_plugin/'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertEquals('test_plugin', $controller->params['controller']); - $this->assertEquals('test_plugin', $controller->params['plugin']); - $this->assertEquals('index', $controller->params['action']); - $this->assertFalse(isset($controller->params['pass'][0])); - - $url = new CakeRequest('/test_plugin/tests/index'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertEquals('tests', $controller->params['controller']); - $this->assertEquals('test_plugin', $controller->params['plugin']); - $this->assertEquals('index', $controller->params['action']); - $this->assertFalse(isset($controller->params['pass'][0])); - - $url = new CakeRequest('/test_plugin/tests/index/some_param'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertEquals('tests', $controller->params['controller']); - $this->assertEquals('test_plugin', $controller->params['plugin']); - $this->assertEquals('index', $controller->params['action']); - $this->assertEquals('some_param', $controller->params['pass'][0]); - - App::build(); - } - -/** - * testAutomaticPluginControllerMissingActionDispatch method - * - * @expectedException MissingActionException - * @expectedExceptionMessage Action MyPluginController::not_here() could not be found. - * @return void - */ - public function testAutomaticPluginControllerMissingActionDispatch() { - Router::reload(); - $Dispatcher = new TestDispatcher(); - - $url = new CakeRequest('my_plugin/not_here/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - } - -/** - * testAutomaticPluginControllerMissingActionDispatch method - * - * @expectedException MissingActionException - * @expectedExceptionMessage Action MyPluginController::param:value() could not be found. - * @return void - */ - - public function testAutomaticPluginControllerIndexMissingAction() { - Router::reload(); - $Dispatcher = new TestDispatcher(); - - $url = new CakeRequest('my_plugin/param:value/param2:value2'); - $response = $this->getMock('CakeResponse'); - - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - } - -/** - * Test dispatching into the TestPlugin in the test_app - * - * @return void - */ - public function testTestPluginDispatch() { - $Dispatcher = new TestDispatcher(); - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - Router::reload(); - Router::parse('/'); - - $url = new CakeRequest('/test_plugin/tests/index'); - $response = $this->getMock('CakeResponse'); - $result = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->assertTrue(class_exists('TestsController')); - $this->assertTrue(class_exists('TestPluginAppController')); - $this->assertTrue(class_exists('PluginsComponent')); - - $this->assertEquals('tests', $result->params['controller']); - $this->assertEquals('test_plugin', $result->params['plugin']); - $this->assertEquals('index', $result->params['action']); - - App::build(); - } - -/** - * testChangingParamsFromBeforeFilter method - * - * @return void - */ - public function testChangingParamsFromBeforeFilter() { - $Dispatcher = new TestDispatcher(); - $response = $this->getMock('CakeResponse'); - $url = new CakeRequest('some_posts/index/param:value/param2:value2'); - - try { - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - $this->fail('No exception.'); - } catch (MissingActionException $e) { - $this->assertEquals('Action SomePostsController::view() could not be found.', $e->getMessage()); - } - - $url = new CakeRequest('some_posts/something_else/param:value/param2:value2'); - $controller = $Dispatcher->dispatch($url, $response, array('return' => 1)); - - $expected = 'SomePosts'; - $this->assertEquals($expected, $controller->name); - - $expected = 'change'; - $this->assertEquals($expected, $controller->action); - - $expected = array('changed'); - $this->assertSame($expected, $controller->params['pass']); - } - -/** - * testStaticAssets method - * - * @return void - */ - public function testAssets() { - Router::reload(); - - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'Vendor' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Vendor' . DS), - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - - $Dispatcher = new TestDispatcher(); - $response = $this->getMock('CakeResponse', array('_sendHeader')); - - try { - $Dispatcher->dispatch(new CakeRequest('theme/test_theme/../webroot/css/test_asset.css'), $response); - $this->fail('No exception'); - } catch (MissingControllerException $e) { - $this->assertEquals('Controller class ThemeController could not be found.', $e->getMessage()); - } - - try { - $Dispatcher->dispatch(new CakeRequest('theme/test_theme/pdfs'), $response); - $this->fail('No exception'); - } catch (MissingControllerException $e) { - $this->assertEquals('Controller class ThemeController could not be found.', $e->getMessage()); - } - } - -/** - * Data provider for asset() - * - * - theme assets. - * - plugin assets. - * - plugin assets in sub directories. - * - unknown plugin assets. - * - * @return array - */ - public static function assetProvider() { - return array( - array( - 'theme/test_theme/flash/theme_test.swf', - 'View/Themed/TestTheme/webroot/flash/theme_test.swf' - ), - array( - 'theme/test_theme/pdfs/theme_test.pdf', - 'View/Themed/TestTheme/webroot/pdfs/theme_test.pdf' - ), - array( - 'theme/test_theme/img/test.jpg', - 'View/Themed/TestTheme/webroot/img/test.jpg' - ), - array( - 'theme/test_theme/css/test_asset.css', - 'View/Themed/TestTheme/webroot/css/test_asset.css' - ), - array( - 'theme/test_theme/js/theme.js', - 'View/Themed/TestTheme/webroot/js/theme.js' - ), - array( - 'theme/test_theme/js/one/theme_one.js', - 'View/Themed/TestTheme/webroot/js/one/theme_one.js' - ), - array( - 'theme/test_theme/space%20image.text', - 'View/Themed/TestTheme/webroot/space image.text' - ), - array( - 'test_plugin/root.js', - 'Plugin/TestPlugin/webroot/root.js' - ), - array( - 'test_plugin/flash/plugin_test.swf', - 'Plugin/TestPlugin/webroot/flash/plugin_test.swf' - ), - array( - 'test_plugin/pdfs/plugin_test.pdf', - 'Plugin/TestPlugin/webroot/pdfs/plugin_test.pdf' - ), - array( - 'test_plugin/js/test_plugin/test.js', - 'Plugin/TestPlugin/webroot/js/test_plugin/test.js' - ), - array( - 'test_plugin/css/test_plugin_asset.css', - 'Plugin/TestPlugin/webroot/css/test_plugin_asset.css' - ), - array( - 'test_plugin/img/cake.icon.gif', - 'Plugin/TestPlugin/webroot/img/cake.icon.gif' - ), - array( - 'plugin_js/js/plugin_js.js', - 'Plugin/PluginJs/webroot/js/plugin_js.js' - ), - array( - 'plugin_js/js/one/plugin_one.js', - 'Plugin/PluginJs/webroot/js/one/plugin_one.js' - ), - array( - 'test_plugin/css/unknown.extension', - 'Plugin/TestPlugin/webroot/css/unknown.extension' - ), - array( - 'test_plugin/css/theme_one.htc', - 'Plugin/TestPlugin/webroot/css/theme_one.htc' - ), - ); - } - -/** - * Test assets - * - * @dataProvider assetProvider - * @outputBuffering enabled - * @return void - */ - public function testAsset($url, $file) { - Router::reload(); - - App::build(array( - 'Plugin' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'Vendor' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Vendor' . DS), - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - )); - CakePlugin::load(array('TestPlugin', 'PluginJs')); - - $Dispatcher = new TestDispatcher(); - $response = $this->getMock('CakeResponse', array('_sendHeader')); - - $Dispatcher->dispatch(new CakeRequest($url), $response); - $result = ob_get_clean(); - - $path = CAKE . 'Test' . DS . 'test_app' . DS . str_replace('/', DS, $file); - $file = file_get_contents($path); - $this->assertEquals($file, $result); - - $expected = filesize($path); - $headers = $response->header(); - $this->assertEquals($expected, $headers['Content-Length']); - } - -/** - * test that missing asset processors trigger a 404 with no response body. - * - * @return void - */ - public function testMissingAssetProcessor404() { - $response = $this->getMock('CakeResponse', array('_sendHeader')); - $Dispatcher = new TestDispatcher(); - Configure::write('Asset.filter', array( - 'js' => '', - 'css' => null - )); - - ob_start(); - $this->assertTrue($Dispatcher->asset('ccss/cake.generic.css', $response)); - $result = ob_get_clean(); - } - -/** - * test that asset filters work for theme and plugin assets - * - * @return void - */ - public function testAssetFilterForThemeAndPlugins() { - $Dispatcher = new TestDispatcher(); - $response = $this->getMock('CakeResponse', array('_sendHeader')); - Configure::write('Asset.filter', array( - 'js' => '', - 'css' => '' - )); - $this->assertTrue($Dispatcher->asset('theme/test_theme/ccss/cake.generic.css', $response)); - - $this->assertTrue($Dispatcher->asset('theme/test_theme/cjs/debug_kit.js', $response)); - - $this->assertTrue($Dispatcher->asset('test_plugin/ccss/cake.generic.css', $response)); - - $this->assertTrue($Dispatcher->asset('test_plugin/cjs/debug_kit.js', $response)); - - $this->assertFalse($Dispatcher->asset('css/ccss/debug_kit.css', $response)); - - $this->assertFalse($Dispatcher->asset('js/cjs/debug_kit.js', $response)); - } - -/** - * Data provider for cached actions. - * - * - Test simple views - * - Test views with nocache tags - * - Test requests with named + passed params. - * - Test requests with query string params - * - Test themed views. - * - * @return array - */ - public static function cacheActionProvider() { - return array( - array('/'), - array('test_cached_pages/index'), - array('TestCachedPages/index'), - array('test_cached_pages/test_nocache_tags'), - array('TestCachedPages/test_nocache_tags'), - array('test_cached_pages/view/param/param'), - array('test_cached_pages/view/foo:bar/value:goo'), - array('test_cached_pages/view?q=cakephp'), - array('test_cached_pages/themed'), - ); - } - -/** - * testFullPageCachingDispatch method - * - * @dataProvider cacheActionProvider - * @return void - */ - public function testFullPageCachingDispatch($url) { - Configure::write('Cache.disable', false); - Configure::write('Cache.check', true); - Configure::write('debug', 2); - - Router::reload(); - Router::connect('/', array('controller' => 'test_cached_pages', 'action' => 'index')); - Router::connect('/:controller/:action/*'); - - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS), - ), App::RESET); - - $dispatcher = new TestDispatcher(); - $request = new CakeRequest($url); - $response = new CakeResponse(); - - ob_start(); - $dispatcher->dispatch($request, $response); - $out = ob_get_clean(); - - ob_start(); - $dispatcher->cached($request->here()); - $cached = ob_get_clean(); - - $cached = preg_replace('//', '', $cached); - - $this->assertTextEquals($cached, $out); - - $filename = $this->__cachePath($request->here()); - unlink($filename); - } - -/** - * testHttpMethodOverrides method - * - * @return void - */ - public function testHttpMethodOverrides() { - Router::reload(); - Router::mapResources('Posts'); - - $_SERVER['REQUEST_METHOD'] = 'POST'; - $dispatcher = new Dispatcher(); - - $result = $dispatcher->parseParams(new CakeRequest('/posts')); - $expected = array('pass' => array(), 'named' => array(), 'plugin' => null, 'controller' => 'posts', 'action' => 'add', '[method]' => 'POST'); - foreach ($expected as $key => $value) { - $this->assertEquals($value, $result[$key], 'Value mismatch for ' . $key . ' %s'); - } - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] = 'PUT'; - - $result = $dispatcher->parseParams(new CakeRequest('/posts/5')); - $expected = array( - 'pass' => array('5'), - 'named' => array(), - 'id' => '5', - 'plugin' => null, - 'controller' => 'posts', - 'action' => 'edit', - '[method]' => 'PUT' - ); - foreach ($expected as $key => $value) { - $this->assertEquals($value, $result[$key], 'Value mismatch for ' . $key . ' %s'); - } - - unset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']); - $_SERVER['REQUEST_METHOD'] = 'GET'; - - $result = $dispatcher->parseParams(new CakeRequest('/posts/5')); - $expected = array('pass' => array('5'), 'named' => array(), 'id' => '5', 'plugin' => null, 'controller' => 'posts', 'action' => 'view', '[method]' => 'GET'); - foreach ($expected as $key => $value) { - $this->assertEquals($value, $result[$key], 'Value mismatch for ' . $key . ' %s'); - } - - $_POST['_method'] = 'PUT'; - - $result = $dispatcher->parseParams(new CakeRequest('/posts/5')); - $expected = array('pass' => array('5'), 'named' => array(), 'id' => '5', 'plugin' => null, 'controller' => 'posts', 'action' => 'edit', '[method]' => 'PUT'); - foreach ($expected as $key => $value) { - $this->assertEquals($value, $result[$key], 'Value mismatch for ' . $key . ' %s'); - } - - $_POST['_method'] = 'POST'; - $_POST['data'] = array('Post' => array('title' => 'New Post')); - $_POST['extra'] = 'data'; - $_SERVER = array(); - - $result = $dispatcher->parseParams(new CakeRequest('/posts')); - $expected = array( - 'pass' => array(), 'named' => array(), 'plugin' => null, 'controller' => 'posts', 'action' => 'add', - '[method]' => 'POST', 'data' => array('extra' => 'data', 'Post' => array('title' => 'New Post')), - ); - foreach ($expected as $key => $value) { - $this->assertEquals($value, $result[$key], 'Value mismatch for ' . $key . ' %s'); - } - - unset($_POST['_method']); - } - -/** - * backupEnvironment method - * - * @return void - */ - protected function __backupEnvironment() { - return array( - 'App' => Configure::read('App'), - 'GET' => $_GET, - 'POST' => $_POST, - 'SERVER' => $_SERVER - ); - } - -/** - * reloadEnvironment method - * - * @return void - */ - protected function __reloadEnvironment() { - foreach ($_GET as $key => $val) { - unset($_GET[$key]); - } - foreach ($_POST as $key => $val) { - unset($_POST[$key]); - } - foreach ($_SERVER as $key => $val) { - unset($_SERVER[$key]); - } - Configure::write('App', array()); - } - -/** - * loadEnvironment method - * - * @param mixed $env - * @return void - */ - protected function __loadEnvironment($env) { - if ($env['reload']) { - $this->__reloadEnvironment(); - } - - if (isset($env['App'])) { - Configure::write('App', $env['App']); - } - - if (isset($env['GET'])) { - foreach ($env['GET'] as $key => $val) { - $_GET[$key] = $val; - } - } - - if (isset($env['POST'])) { - foreach ($env['POST'] as $key => $val) { - $_POST[$key] = $val; - } - } - - if (isset($env['SERVER'])) { - foreach ($env['SERVER'] as $key => $val) { - $_SERVER[$key] = $val; - } - } - } - -/** - * cachePath method - * - * @param mixed $her - * @return string - */ - protected function __cachePath($here) { - $path = $here; - if ($here == '/') { - $path = 'home'; - } - $path = strtolower(Inflector::slug($path)); - - $filename = CACHE . 'views' . DS . $path . '.php'; - - if (!file_exists($filename)) { - $filename = CACHE . 'views' . DS . $path . '_index.php'; - } - return $filename; - } -} diff --git a/lib/Cake/Test/Case/Routing/Route/CakeRouteTest.php b/lib/Cake/Test/Case/Routing/Route/CakeRouteTest.php deleted file mode 100644 index 58e0644c4b8..00000000000 --- a/lib/Cake/Test/Case/Routing/Route/CakeRouteTest.php +++ /dev/null @@ -1,873 +0,0 @@ - null, 'prefixes' => array())); - } - -/** - * Test the construction of a CakeRoute - * - * @return void - **/ - public function testConstruction() { - $route = new CakeRoute('/:controller/:action/:id', array(), array('id' => '[0-9]+')); - - $this->assertEquals('/:controller/:action/:id', $route->template); - $this->assertEquals(array(), $route->defaults); - $this->assertEquals(array('id' => '[0-9]+'), $route->options); - $this->assertFalse($route->compiled()); - } - -/** - * test Route compiling. - * - * @return void - **/ - public function testBasicRouteCompiling() { - $route = new CakeRoute('/', array('controller' => 'pages', 'action' => 'display', 'home')); - $result = $route->compile(); - $expected = '#^/*$#'; - $this->assertEquals($expected, $result); - $this->assertEquals(array(), $route->keys); - - $route = new CakeRoute('/:controller/:action', array('controller' => 'posts')); - $result = $route->compile(); - - $this->assertRegExp($result, '/posts/edit'); - $this->assertRegExp($result, '/posts/super_delete'); - $this->assertNotRegExp($result, '/posts'); - $this->assertNotRegExp($result, '/posts/super_delete/1'); - - $route = new CakeRoute('/posts/foo:id', array('controller' => 'posts', 'action' => 'view')); - $result = $route->compile(); - - $this->assertRegExp($result, '/posts/foo:1'); - $this->assertRegExp($result, '/posts/foo:param'); - $this->assertNotRegExp($result, '/posts'); - $this->assertNotRegExp($result, '/posts/'); - - $this->assertEquals(array('id'), $route->keys); - - $route = new CakeRoute('/:plugin/:controller/:action/*', array('plugin' => 'test_plugin', 'action' => 'index')); - $result = $route->compile(); - $this->assertRegExp($result, '/test_plugin/posts/index'); - $this->assertRegExp($result, '/test_plugin/posts/edit/5'); - $this->assertRegExp($result, '/test_plugin/posts/edit/5/name:value/nick:name'); - } - -/** - * test that route parameters that overlap don't cause errors. - * - * @return void - */ - public function testRouteParameterOverlap() { - $route = new CakeRoute('/invoices/add/:idd/:id', array('controller' => 'invoices', 'action' => 'add')); - $result = $route->compile(); - $this->assertRegExp($result, '/invoices/add/1/3'); - - $route = new CakeRoute('/invoices/add/:id/:idd', array('controller' => 'invoices', 'action' => 'add')); - $result = $route->compile(); - $this->assertRegExp($result, '/invoices/add/1/3'); - } - -/** - * test compiling routes with keys that have patterns - * - * @return void - **/ - public function testRouteCompilingWithParamPatterns() { - $route = new CakeRoute( - '/:controller/:action/:id', - array(), - array('id' => Router::ID) - ); - $result = $route->compile(); - $this->assertRegExp($result, '/posts/edit/1'); - $this->assertRegExp($result, '/posts/view/518098'); - $this->assertNotRegExp($result, '/posts/edit/name-of-post'); - $this->assertNotRegExp($result, '/posts/edit/4/other:param'); - $this->assertEquals(array('controller', 'action', 'id'), $route->keys); - - $route = new CakeRoute( - '/:lang/:controller/:action/:id', - array('controller' => 'testing4'), - array('id' => Router::ID, 'lang' => '[a-z]{3}') - ); - $result = $route->compile(); - $this->assertRegExp($result, '/eng/posts/edit/1'); - $this->assertRegExp($result, '/cze/articles/view/1'); - $this->assertNotRegExp($result, '/language/articles/view/2'); - $this->assertNotRegExp($result, '/eng/articles/view/name-of-article'); - $this->assertEquals(array('lang', 'controller', 'action', 'id'), $route->keys); - - foreach (array(':', '@', ';', '$', '-') as $delim) { - $route = new CakeRoute('/posts/:id' . $delim . ':title'); - $result = $route->compile(); - - $this->assertRegExp($result, '/posts/1' . $delim . 'name-of-article'); - $this->assertRegExp($result, '/posts/13244' . $delim . 'name-of_Article[]'); - $this->assertNotRegExp($result, '/posts/11!nameofarticle'); - $this->assertNotRegExp($result, '/posts/11'); - - $this->assertEquals(array('id', 'title'), $route->keys); - } - - $route = new CakeRoute( - '/posts/:id::title/:year', - array('controller' => 'posts', 'action' => 'view'), - array('id' => Router::ID, 'year' => Router::YEAR, 'title' => '[a-z-_]+') - ); - $result = $route->compile(); - $this->assertRegExp($result, '/posts/1:name-of-article/2009/'); - $this->assertRegExp($result, '/posts/13244:name-of-article/1999'); - $this->assertNotRegExp($result, '/posts/hey_now:nameofarticle'); - $this->assertNotRegExp($result, '/posts/:nameofarticle/2009'); - $this->assertNotRegExp($result, '/posts/:nameofarticle/01'); - $this->assertEquals(array('id', 'title', 'year'), $route->keys); - - $route = new CakeRoute( - '/posts/:url_title-(uuid::id)', - array('controller' => 'posts', 'action' => 'view'), - array('pass' => array('id', 'url_title'), 'id' => Router::ID) - ); - $result = $route->compile(); - $this->assertRegExp($result, '/posts/some_title_for_article-(uuid:12534)/'); - $this->assertRegExp($result, '/posts/some_title_for_article-(uuid:12534)'); - $this->assertNotRegExp($result, '/posts/'); - $this->assertNotRegExp($result, '/posts/nameofarticle'); - $this->assertNotRegExp($result, '/posts/nameofarticle-12347'); - $this->assertEquals(array('url_title', 'id'), $route->keys); - } - -/** - * test more complex route compiling & parsing with mid route greedy stars - * and optional routing parameters - * - * @return void - */ - public function testComplexRouteCompilingAndParsing() { - $route = new CakeRoute( - '/posts/:month/:day/:year/*', - array('controller' => 'posts', 'action' => 'view'), - array('year' => Router::YEAR, 'month' => Router::MONTH, 'day' => Router::DAY) - ); - $result = $route->compile(); - $this->assertRegExp($result, '/posts/08/01/2007/title-of-post'); - $result = $route->parse('/posts/08/01/2007/title-of-post'); - - $this->assertEquals(7, count($result)); - $this->assertEquals('posts', $result['controller']); - $this->assertEquals('view', $result['action']); - $this->assertEquals('2007', $result['year']); - $this->assertEquals('08', $result['month']); - $this->assertEquals('01', $result['day']); - $this->assertEquals('title-of-post', $result['pass'][0]); - - $route = new CakeRoute( - "/:extra/page/:slug/*", - array('controller' => 'pages', 'action' => 'view', 'extra' => null), - array("extra" => '[a-z1-9_]*', "slug" => '[a-z1-9_]+', "action" => 'view') - ); - $result = $route->compile(); - - $this->assertRegExp($result, '/some_extra/page/this_is_the_slug'); - $this->assertRegExp($result, '/page/this_is_the_slug'); - $this->assertEquals(array('extra', 'slug'), $route->keys); - $this->assertEquals(array('extra' => '[a-z1-9_]*', 'slug' => '[a-z1-9_]+', 'action' => 'view'), $route->options); - $expected = array( - 'controller' => 'pages', - 'action' => 'view' - ); - $this->assertEquals($expected, $route->defaults); - - $route = new CakeRoute( - '/:controller/:action/*', - array('project' => false), - array( - 'controller' => 'source|wiki|commits|tickets|comments|view', - 'action' => 'branches|history|branch|logs|view|start|add|edit|modify' - ) - ); - $this->assertFalse($route->parse('/chaw_test/wiki')); - - $result = $route->compile(); - $this->assertNotRegExp($result, '/some_project/source'); - $this->assertRegExp($result, '/source/view'); - $this->assertRegExp($result, '/source/view/other/params'); - $this->assertNotRegExp($result, '/chaw_test/wiki'); - $this->assertNotRegExp($result, '/source/wierd_action'); - } - -/** - * test that routes match their pattern. - * - * @return void - **/ - public function testMatchBasic() { - $route = new CakeRoute('/:controller/:action/:id', array('plugin' => null)); - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'plugin' => null)); - $this->assertFalse($result); - - $result = $route->match(array('plugin' => null, 'controller' => 'posts', 'action' => 'view', 0)); - $this->assertFalse($result); - - $result = $route->match(array('plugin' => null, 'controller' => 'posts', 'action' => 'view', 'id' => 1)); - $this->assertEquals('/posts/view/1', $result); - - $route = new CakeRoute('/', array('controller' => 'pages', 'action' => 'display', 'home')); - $result = $route->match(array('controller' => 'pages', 'action' => 'display', 'home')); - $this->assertEquals('/', $result); - - $result = $route->match(array('controller' => 'pages', 'action' => 'display', 'about')); - $this->assertFalse($result); - - $route = new CakeRoute('/pages/*', array('controller' => 'pages', 'action' => 'display')); - $result = $route->match(array('controller' => 'pages', 'action' => 'display', 'home')); - $this->assertEquals('/pages/home', $result); - - $result = $route->match(array('controller' => 'pages', 'action' => 'display', 'about')); - $this->assertEquals('/pages/about', $result); - - $route = new CakeRoute('/blog/:action', array('controller' => 'posts')); - $result = $route->match(array('controller' => 'posts', 'action' => 'view')); - $this->assertEquals('/blog/view', $result); - - $result = $route->match(array('controller' => 'nodes', 'action' => 'view')); - $this->assertFalse($result); - - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 1)); - $this->assertFalse($result); - - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'id' => 2)); - $this->assertFalse($result); - - $route = new CakeRoute('/foo/:controller/:action', array('action' => 'index')); - $result = $route->match(array('controller' => 'posts', 'action' => 'view')); - $this->assertEquals('/foo/posts/view', $result); - - $route = new CakeRoute('/:plugin/:id/*', array('controller' => 'posts', 'action' => 'view')); - $result = $route->match(array('plugin' => 'test', 'controller' => 'posts', 'action' => 'view', 'id' => '1')); - $this->assertEquals('/test/1/', $result); - - $result = $route->match(array('plugin' => 'fo', 'controller' => 'posts', 'action' => 'view', 'id' => '1', '0')); - $this->assertEquals('/fo/1/0', $result); - - $result = $route->match(array('plugin' => 'fo', 'controller' => 'nodes', 'action' => 'view', 'id' => 1)); - $this->assertFalse($result); - - $result = $route->match(array('plugin' => 'fo', 'controller' => 'posts', 'action' => 'edit', 'id' => 1)); - $this->assertFalse($result); - - $route = new CakeRoute('/admin/subscriptions/:action/*', array( - 'controller' => 'subscribe', 'admin' => true, 'prefix' => 'admin' - )); - - $url = array('controller' => 'subscribe', 'admin' => true, 'action' => 'edit', 1); - $result = $route->match($url); - $expected = '/admin/subscriptions/edit/1'; - $this->assertEquals($expected, $result); - } - -/** - * test that non-greedy routes fail with extra passed args - * - * @return void - */ - public function testGreedyRouteFailurePassedArg() { - $route = new CakeRoute('/:controller/:action', array('plugin' => null)); - $result = $route->match(array('controller' => 'posts', 'action' => 'view', '0')); - $this->assertFalse($result); - - $route = new CakeRoute('/:controller/:action', array('plugin' => null)); - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'test')); - $this->assertFalse($result); - } - -/** - * test that non-greedy routes fail with extra passed args - * - * @return void - */ - public function testGreedyRouteFailureNamedParam() { - $route = new CakeRoute('/:controller/:action', array('plugin' => null)); - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'page' => 1)); - $this->assertFalse($result); - } - -/** - * test that falsey values do not interrupt a match. - * - * @return void - */ - public function testMatchWithFalseyValues() { - $route = new CakeRoute('/:controller/:action/*', array('plugin' => null)); - $result = $route->match(array( - 'controller' => 'posts', 'action' => 'index', 'plugin' => null, 'admin' => false - )); - $this->assertEquals('/posts/index/', $result); - } - -/** - * test match() with greedy routes, named parameters and passed args. - * - * @return void - */ - public function testMatchWithNamedParametersAndPassedArgs() { - Router::connectNamed(true); - - $route = new CakeRoute('/:controller/:action/*', array('plugin' => null)); - $result = $route->match(array('controller' => 'posts', 'action' => 'index', 'plugin' => null, 'page' => 1)); - $this->assertEquals('/posts/index/page:1', $result); - - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'plugin' => null, 5)); - $this->assertEquals('/posts/view/5', $result); - - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'plugin' => null, 0)); - $this->assertEquals('/posts/view/0', $result); - - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'plugin' => null, '0')); - $this->assertEquals('/posts/view/0', $result); - - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'plugin' => null, 5, 'page' => 1, 'limit' => 20, 'order' => 'title')); - $this->assertEquals('/posts/view/5/page:1/limit:20/order:title', $result); - - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'plugin' => null, 'word space', 'order' => 'Θ')); - $this->assertEquals('/posts/view/word%20space/order:%CE%98', $result); - - $route = new CakeRoute('/test2/*', array('controller' => 'pages', 'action' => 'display', 2)); - $result = $route->match(array('controller' => 'pages', 'action' => 'display', 1)); - $this->assertFalse($result); - - $result = $route->match(array('controller' => 'pages', 'action' => 'display', 2, 'something')); - $this->assertEquals('/test2/something', $result); - - $result = $route->match(array('controller' => 'pages', 'action' => 'display', 5, 'something')); - $this->assertFalse($result); - } - -/** - * Ensure that named parameters are urldecoded - * - * @return void - */ - public function testParseNamedParametersUrlDecode() { - Router::connectNamed(true); - $route = new CakeRoute('/:controller/:action/*', array('plugin' => null)); - - $result = $route->parse('/posts/index/page:%CE%98'); - $this->assertEquals('Θ', $result['named']['page']); - - $result = $route->parse('/posts/index/page[]:%CE%98'); - $this->assertEquals('Θ', $result['named']['page'][0]); - - $result = $route->parse('/posts/index/something%20else/page[]:%CE%98'); - $this->assertEquals('Θ', $result['named']['page'][0]); - $this->assertEquals('something else', $result['pass'][0]); - } - -/** - * Ensure that keys at named parameters are urldecoded - * - * @return void - */ - public function testParseNamedKeyUrlDecode() { - Router::connectNamed(true); - $route = new CakeRoute('/:controller/:action/*', array('plugin' => null)); - - // checking /post/index/user[0]:a/user[1]:b - $result = $route->parse('/posts/index/user%5B0%5D:a/user%5B1%5D:b'); - $this->assertArrayHasKey('user', $result['named']); - $this->assertEquals(array('a', 'b'), $result['named']['user']); - - // checking /post/index/user[]:a/user[]:b - $result = $route->parse('/posts/index/user%5B%5D:a/user%5B%5D:b'); - $this->assertArrayHasKey('user', $result['named']); - $this->assertEquals(array('a', 'b'), $result['named']['user']); - } - -/** - * test that named params with null/false are excluded - * - * @return void - */ - public function testNamedParamsWithNullFalse() { - $route = new CakeRoute('/:controller/:action/*'); - $result = $route->match(array('controller' => 'posts', 'action' => 'index', 'page' => null, 'sort' => false)); - $this->assertEquals('/posts/index/', $result); - } - -/** - * test that match with patterns works. - * - * @return void - */ - public function testMatchWithPatterns() { - $route = new CakeRoute('/:controller/:action/:id', array('plugin' => null), array('id' => '[0-9]+')); - $result = $route->match(array('controller' => 'posts', 'action' => 'view', 'id' => 'foo')); - $this->assertFalse($result); - - $result = $route->match(array('plugin' => null, 'controller' => 'posts', 'action' => 'view', 'id' => '9')); - $this->assertEquals('/posts/view/9', $result); - - $result = $route->match(array('plugin' => null, 'controller' => 'posts', 'action' => 'view', 'id' => '922')); - $this->assertEquals('/posts/view/922', $result); - - $result = $route->match(array('plugin' => null, 'controller' => 'posts', 'action' => 'view', 'id' => 'a99')); - $this->assertFalse($result); - } - -/** - * test persistParams ability to persist parameters from $params and remove params. - * - * @return void - */ - public function testPersistParams() { - $route = new CakeRoute( - '/:lang/:color/blog/:action', - array('controller' => 'posts'), - array('persist' => array('lang', 'color')) - ); - $url = array('controller' => 'posts', 'action' => 'index'); - $params = array('lang' => 'en', 'color' => 'blue'); - $result = $route->persistParams($url, $params); - $this->assertEquals('en', $result['lang']); - $this->assertEquals('blue', $result['color']); - - $url = array('controller' => 'posts', 'action' => 'index', 'color' => 'red'); - $params = array('lang' => 'en', 'color' => 'blue'); - $result = $route->persistParams($url, $params); - $this->assertEquals('en', $result['lang']); - $this->assertEquals('red', $result['color']); - } - -/** - * test the parse method of CakeRoute. - * - * @return void - */ - public function testParse() { - $route = new CakeRoute( - '/:controller/:action/:id', - array('controller' => 'testing4', 'id' => null), - array('id' => Router::ID) - ); - $route->compile(); - $result = $route->parse('/posts/view/1'); - $this->assertEquals('posts', $result['controller']); - $this->assertEquals('view', $result['action']); - $this->assertEquals('1', $result['id']); - - $route = new Cakeroute( - '/admin/:controller', - array('prefix' => 'admin', 'admin' => 1, 'action' => 'index') - ); - $route->compile(); - $result = $route->parse('/admin/'); - $this->assertFalse($result); - - $result = $route->parse('/admin/posts'); - $this->assertEquals('posts', $result['controller']); - $this->assertEquals('index', $result['action']); - } - -/** - * Test that :key elements are urldecoded - * - * @return void - */ - public function testParseUrlDecodeElements() { - $route = new Cakeroute( - '/:controller/:slug', - array('action' => 'view') - ); - $route->compile(); - $result = $route->parse('/posts/%E2%88%82%E2%88%82'); - $this->assertEquals('posts', $result['controller']); - $this->assertEquals('view', $result['action']); - $this->assertEquals('∂∂', $result['slug']); - - $result = $route->parse('/posts/∂∂'); - $this->assertEquals('posts', $result['controller']); - $this->assertEquals('view', $result['action']); - $this->assertEquals('∂∂', $result['slug']); - } - -/** - * test numerically indexed defaults, get appended to pass - * - * @return void - */ - public function testParseWithPassDefaults() { - $route = new Cakeroute('/:controller', array('action' => 'display', 'home')); - $result = $route->parse('/posts'); - $expected = array( - 'controller' => 'posts', - 'action' => 'display', - 'pass' => array('home'), - 'named' => array() - ); - $this->assertEquals($expected, $result); - } - -/** - * test that http header conditions can cause route failures. - * - * @return void - */ - public function testParseWithHttpHeaderConditions() { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $route = new CakeRoute('/sample', array('controller' => 'posts', 'action' => 'index', '[method]' => 'POST')); - - $this->assertFalse($route->parse('/sample')); - } - -/** - * test that patterns work for :action - * - * @return void - */ - public function testPatternOnAction() { - $route = new CakeRoute( - '/blog/:action/*', - array('controller' => 'blog_posts'), - array('action' => 'other|actions') - ); - $result = $route->match(array('controller' => 'blog_posts', 'action' => 'foo')); - $this->assertFalse($result); - - $result = $route->match(array('controller' => 'blog_posts', 'action' => 'actions')); - $this->assertNotEmpty($result); - - $result = $route->parse('/blog/other'); - $expected = array('controller' => 'blog_posts', 'action' => 'other', 'pass' => array(), 'named' => array()); - $this->assertEquals($expected, $result); - - $result = $route->parse('/blog/foobar'); - $this->assertFalse($result); - } - -/** - * test the parseArgs method - * - * @return void - */ - public function testParsePassedArgument() { - $route = new CakeRoute('/:controller/:action/*'); - $result = $route->parse('/posts/edit/1/2/0'); - $expected = array( - 'controller' => 'posts', - 'action' => 'edit', - 'pass' => array('1', '2', '0'), - 'named' => array() - ); - $this->assertEquals($expected, $result); - - $result = $route->parse('/posts/edit/a-string/page:1/sort:value'); - $expected = array( - 'controller' => 'posts', - 'action' => 'edit', - 'pass' => array('a-string'), - 'named' => array( - 'page' => 1, - 'sort' => 'value' - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * test that only named parameter rules are followed. - * - * @return void - */ - public function testParseNamedParametersWithRules() { - $route = new CakeRoute('/:controller/:action/*', array(), array( - 'named' => array( - 'wibble', - 'fish' => array('action' => 'index'), - 'fizz' => array('controller' => array('comments', 'other')), - 'pattern' => 'val-[\d]+' - ) - )); - $result = $route->parse('/posts/display/wibble:spin/fish:trout/fizz:buzz/unknown:value'); - $expected = array( - 'controller' => 'posts', - 'action' => 'display', - 'pass' => array('fish:trout', 'fizz:buzz', 'unknown:value'), - 'named' => array( - 'wibble' => 'spin' - ) - ); - $this->assertEquals($expected, $result, 'Fish should not be parsed, as action != index'); - - $result = $route->parse('/posts/index/wibble:spin/fish:trout/fizz:buzz'); - $expected = array( - 'controller' => 'posts', - 'action' => 'index', - 'pass' => array('fizz:buzz'), - 'named' => array( - 'wibble' => 'spin', - 'fish' => 'trout' - ) - ); - $this->assertEquals($expected, $result, 'Fizz should be parsed, as controller == comments|other'); - - $result = $route->parse('/comments/index/wibble:spin/fish:trout/fizz:buzz'); - $expected = array( - 'controller' => 'comments', - 'action' => 'index', - 'pass' => array(), - 'named' => array( - 'wibble' => 'spin', - 'fish' => 'trout', - 'fizz' => 'buzz' - ) - ); - $this->assertEquals($expected, $result, 'All params should be parsed as conditions were met.'); - - $result = $route->parse('/comments/index/pattern:val--'); - $expected = array( - 'controller' => 'comments', - 'action' => 'index', - 'pass' => array('pattern:val--'), - 'named' => array() - ); - $this->assertEquals($expected, $result, 'Named parameter pattern unmet.'); - - $result = $route->parse('/comments/index/pattern:val-2'); - $expected = array( - 'controller' => 'comments', - 'action' => 'index', - 'pass' => array(), - 'named' => array('pattern' => 'val-2') - ); - $this->assertEquals($expected, $result, 'Named parameter pattern met.'); - } - -/** - * test that greedyNamed ignores rules. - * - * @return void - */ - public function testParseGreedyNamed() { - $route = new CakeRoute('/:controller/:action/*', array(), array( - 'named' => array( - 'fizz' => array('controller' => 'comments'), - 'pattern' => 'val-[\d]+', - ), - 'greedyNamed' => true - )); - $result = $route->parse('/posts/display/wibble:spin/fizz:buzz/pattern:ignored'); - $expected = array( - 'controller' => 'posts', - 'action' => 'display', - 'pass' => array('fizz:buzz', 'pattern:ignored'), - 'named' => array( - 'wibble' => 'spin', - ) - ); - $this->assertEquals($expected, $result, 'Greedy named grabs everything, rules are followed'); - } - -/** - * Having greedNamed enabled should not capture routing.prefixes. - * - * @return void - */ - public function testMatchGreedyNamedExcludesPrefixes() { - Configure::write('Routing.prefixes', array('admin')); - Router::reload(); - - $route = new CakeRoute('/sales/*', array('controller' => 'sales', 'action' => 'index')); - $this->assertFalse($route->match(array('controller' => 'sales', 'action' => 'index', 'admin' => 1)), 'Greedy named consume routing prefixes.'); - } - -/** - * test that parsing array format named parameters works - * - * @return void - */ - public function testParseArrayNamedParameters() { - $route = new CakeRoute('/:controller/:action/*'); - $result = $route->parse('/tests/action/var[]:val1/var[]:val2'); - $expected = array( - 'controller' => 'tests', - 'action' => 'action', - 'named' => array( - 'var' => array( - 'val1', - 'val2' - ) - ), - 'pass' => array(), - ); - $this->assertEquals($expected, $result); - - $result = $route->parse('/tests/action/theanswer[is]:42/var[]:val2/var[]:val3'); - $expected = array( - 'controller' => 'tests', - 'action' => 'action', - 'named' => array( - 'theanswer' => array( - 'is' => 42 - ), - 'var' => array( - 'val2', - 'val3' - ) - ), - 'pass' => array(), - ); - $this->assertEquals($expected, $result); - - $result = $route->parse('/tests/action/theanswer[is][not]:42/theanswer[]:5/theanswer[is]:6'); - $expected = array( - 'controller' => 'tests', - 'action' => 'action', - 'named' => array( - 'theanswer' => array( - 5, - 'is' => array( - 6, - 'not' => 42 - ) - ), - ), - 'pass' => array(), - ); - $this->assertEquals($expected, $result); - } - -/** - * Test that match can handle array named parameters - * - * @return void - */ - public function testMatchNamedParametersArray() { - $route = new CakeRoute('/:controller/:action/*'); - - $url = array( - 'controller' => 'posts', - 'action' => 'index', - 'filter' => array( - 'one', - 'model' => 'value' - ) - ); - $result = $route->match($url); - $expected = '/posts/index/filter[0]:one/filter[model]:value'; - $this->assertEquals($expected, $result); - - $url = array( - 'controller' => 'posts', - 'action' => 'index', - 'filter' => array( - 'one', - 'model' => array( - 'two', - 'order' => 'field' - ) - ) - ); - $result = $route->match($url); - $expected = '/posts/index/filter[0]:one/filter[model][0]:two/filter[model][order]:field'; - $this->assertEquals($expected, $result); - } - -/** - * test restructuring args with pass key - * - * @return void - */ - public function testPassArgRestructure() { - $route = new CakeRoute('/:controller/:action/:slug', array(), array( - 'pass' => array('slug') - )); - $result = $route->parse('/posts/view/my-title'); - $expected = array( - 'controller' => 'posts', - 'action' => 'view', - 'slug' => 'my-title', - 'pass' => array('my-title'), - 'named' => array() - ); - $this->assertEquals($expected, $result, 'Slug should have moved'); - } - -/** - * Test the /** special type on parsing. - * - * @return void - */ - public function testParseTrailing() { - $route = new CakeRoute('/:controller/:action/**'); - $result = $route->parse('/posts/index/1/2/3/foo:bar'); - $expected = array( - 'controller' => 'posts', - 'action' => 'index', - 'pass' => array('1/2/3/foo:bar'), - 'named' => array() - ); - $this->assertEquals($expected, $result); - - $result = $route->parse('/posts/index/http://example.com'); - $expected = array( - 'controller' => 'posts', - 'action' => 'index', - 'pass' => array('http://example.com'), - 'named' => array() - ); - $this->assertEquals($expected, $result); - } - -/** - * Test the /** special type on parsing - UTF8. - * - * @return void - */ - public function testParseTrailingUTF8() { - $route = new CakeRoute( '/category/**', array('controller' => 'categories','action' => 'index')); - $result = $route->parse('/category/%D9%85%D9%88%D8%A8%D8%A7%DB%8C%D9%84'); - $expected = array( - 'controller' => 'categories', - 'action' => 'index', - 'pass' => array('موبایل'), - 'named' => array() - ); - $this->assertEquals($expected, $result); - } - -} diff --git a/lib/Cake/Test/Case/Routing/Route/PluginShortRouteTest.php b/lib/Cake/Test/Case/Routing/Route/PluginShortRouteTest.php deleted file mode 100644 index 7be78935709..00000000000 --- a/lib/Cake/Test/Case/Routing/Route/PluginShortRouteTest.php +++ /dev/null @@ -1,72 +0,0 @@ - null, 'prefixes' => array())); - Router::reload(); - } - -/** - * test the parsing of routes. - * - * @return void - */ - public function testParsing() { - $route = new PluginShortRoute('/:plugin', array('action' => 'index'), array('plugin' => 'foo|bar')); - - $result = $route->parse('/foo'); - $this->assertEquals('foo', $result['plugin']); - $this->assertEquals('foo', $result['controller']); - $this->assertEquals('index', $result['action']); - - $result = $route->parse('/wrong'); - $this->assertFalse($result, 'Wrong plugin name matched %s'); - } - -/** - * test the reverse routing of the plugin shortcut urls. - * - * @return void - */ - public function testMatch() { - $route = new PluginShortRoute('/:plugin', array('action' => 'index'), array('plugin' => 'foo|bar')); - - $result = $route->match(array('plugin' => 'foo', 'controller' => 'posts', 'action' => 'index')); - $this->assertFalse($result, 'plugin controller mismatch was converted. %s'); - - $result = $route->match(array('plugin' => 'foo', 'controller' => 'foo', 'action' => 'index')); - $this->assertEquals('/foo', $result); - } -} diff --git a/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php b/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php deleted file mode 100644 index 1e6666740f5..00000000000 --- a/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php +++ /dev/null @@ -1,107 +0,0 @@ - null, 'prefixes' => array())); - Router::reload(); - } - -/** - * test the parsing of routes. - * - * @return void - */ - public function testParsing() { - $route = new RedirectRoute('/home', array('controller' => 'posts')); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/home'); - $header = $route->response->header(); - $this->assertEquals(Router::url('/posts', true), $header['Location']); - - $route = new RedirectRoute('/home', array('controller' => 'posts', 'action' => 'index')); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/home'); - $header = $route->response->header(); - $this->assertEquals(Router::url('/posts', true), $header['Location']); - $this->assertEquals(301, $route->response->statusCode()); - - $route = new RedirectRoute('/google', 'http://google.com'); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/google'); - $header = $route->response->header(); - $this->assertEquals('http://google.com', $header['Location']); - - $route = new RedirectRoute('/posts/*', array('controller' => 'posts', 'action' => 'view'), array('status' => 302)); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/posts/2'); - $header = $route->response->header(); - $this->assertEquals(Router::url('/posts/view', true), $header['Location']); - $this->assertEquals(302, $route->response->statusCode()); - - $route = new RedirectRoute('/posts/*', array('controller' => 'posts', 'action' => 'view'), array('persist' => true)); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/posts/2'); - $header = $route->response->header(); - $this->assertEquals(Router::url('/posts/view/2', true), $header['Location']); - - $route = new RedirectRoute('/posts/*', '/test', array('persist' => true)); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/posts/2'); - $header = $route->response->header(); - $this->assertEquals(Router::url('/test', true), $header['Location']); - - $route = new RedirectRoute('/my_controllers/:action/*', array('controller' => 'tags', 'action' => 'add'), array('persist' => true)); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/my_controllers/do_something/passme/named:param'); - $header = $route->response->header(); - $this->assertEquals(Router::url('/tags/add/passme/named:param', true), $header['Location']); - - $route = new RedirectRoute('/my_controllers/:action/*', array('controller' => 'tags', 'action' => 'add')); - $route->stop = false; - $route->response = $this->getMock('CakeResponse', array('_sendHeader')); - $result = $route->parse('/my_controllers/do_something/passme/named:param'); - $header = $route->response->header(); - $this->assertEquals(Router::url('/tags/add', true), $header['Location']); - } - -} diff --git a/lib/Cake/Test/Case/Routing/RouterTest.php b/lib/Cake/Test/Case/Routing/RouterTest.php deleted file mode 100644 index a6c2dffadee..00000000000 --- a/lib/Cake/Test/Case/Routing/RouterTest.php +++ /dev/null @@ -1,2585 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The Open Group Test Suite License - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Routing - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Router', 'Routing'); -App::uses('CakeResponse', 'Network'); - -if (!defined('FULL_BASE_URL')) { - define('FULL_BASE_URL', 'http://cakephp.org'); -} - -/** - * RouterTest class - * - * @package Cake.Test.Case.Routing - */ -class RouterTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - Configure::write('Routing', array('admin' => null, 'prefixes' => array())); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - CakePlugin::unload(); - } - -/** - * testFullBaseURL method - * - * @return void - */ - public function testFullBaseURL() { - $skip = PHP_SAPI == 'cli'; - if ($skip) { - $this->markTestSkipped('Cannot validate base urls in CLI'); - } - $this->assertRegExp('/^http(s)?:\/\//', Router::url('/', true)); - $this->assertRegExp('/^http(s)?:\/\//', Router::url(null, true)); - $this->assertRegExp('/^http(s)?:\/\//', Router::url(array('full_base' => true))); - $this->assertSame(FULL_BASE_URL . '/', Router::url(array('full_base' => true))); - } - -/** - * testRouteDefaultParams method - * - * @return void - */ - public function testRouteDefaultParams() { - Router::connect('/:controller', array('controller' => 'posts')); - $this->assertEquals(Router::url(array('action' => 'index')), '/'); - } - -/** - * testMapResources method - * - * @return void - */ - public function testMapResources() { - $resources = Router::mapResources('Posts'); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/posts'); - $this->assertEquals(array('pass' => array(), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'index', '[method]' => 'GET'), $result); - $this->assertEquals(array('posts'), $resources); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/posts/13'); - $this->assertEquals(array('pass' => array('13'), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'view', 'id' => '13', '[method]' => 'GET'), $result); - - $_SERVER['REQUEST_METHOD'] = 'POST'; - $result = Router::parse('/posts'); - $this->assertEquals(array('pass' => array(), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'add', '[method]' => 'POST'), $result); - - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $result = Router::parse('/posts/13'); - $this->assertEquals(array('pass' => array('13'), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'edit', 'id' => '13', '[method]' => 'PUT'), $result); - - $result = Router::parse('/posts/475acc39-a328-44d3-95fb-015000000000'); - $this->assertEquals(array('pass' => array('475acc39-a328-44d3-95fb-015000000000'), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'edit', 'id' => '475acc39-a328-44d3-95fb-015000000000', '[method]' => 'PUT'), $result); - - $_SERVER['REQUEST_METHOD'] = 'DELETE'; - $result = Router::parse('/posts/13'); - $this->assertEquals(array('pass' => array('13'), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'delete', 'id' => '13', '[method]' => 'DELETE'), $result); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/posts/add'); - $this->assertEquals(array(), $result); - - Router::reload(); - $resources = Router::mapResources('Posts', array('id' => '[a-z0-9_]+')); - $this->assertEquals(array('posts'), $resources); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/posts/add'); - $this->assertEquals(array('pass' => array('add'), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'view', 'id' => 'add', '[method]' => 'GET'), $result); - - $_SERVER['REQUEST_METHOD'] = 'PUT'; - $result = Router::parse('/posts/name'); - $this->assertEquals(array('pass' => array('name'), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'edit', 'id' => 'name', '[method]' => 'PUT'), $result); - } - -/** - * testMapResources with plugin controllers. - * - * @return void - */ - public function testPluginMapResources() { - App::build(array( - 'Plugin' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS - ) - )); - $resources = Router::mapResources('TestPlugin.TestPlugin'); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/test_plugin/test_plugin'); - $expected = array( - 'pass' => array(), - 'named' => array(), - 'plugin' => 'test_plugin', - 'controller' => 'test_plugin', - 'action' => 'index', - '[method]' => 'GET' - ); - $this->assertEquals($expected, $result); - $this->assertEquals(array('test_plugin'), $resources); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/test_plugin/test_plugin/13'); - $expected = array( - 'pass' => array('13'), - 'named' => array(), - 'plugin' => 'test_plugin', - 'controller' => 'test_plugin', - 'action' => 'view', - 'id' => '13', - '[method]' => 'GET' - ); - $this->assertEquals($expected, $result); - } - -/** - * Test mapResources with a plugin and prefix. - * - * @return void - */ - public function testPluginMapResourcesWithPrefix() { - App::build(array( - 'Plugin' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS - ) - )); - $resources = Router::mapResources('TestPlugin.TestPlugin', array('prefix' => '/api/')); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/api/test_plugin'); - $expected = array( - 'pass' => array(), - 'named' => array(), - 'plugin' => 'test_plugin', - 'controller' => 'test_plugin', - 'action' => 'index', - '[method]' => 'GET' - ); - $this->assertEquals($expected, $result); - $this->assertEquals(array('test_plugin'), $resources); - } - -/** - * testMultipleResourceRoute method - * - * @return void - */ - public function testMultipleResourceRoute() { - Router::connect('/:controller', array('action' => 'index', '[method]' => array('GET', 'POST'))); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $result = Router::parse('/posts'); - $this->assertEquals(array('pass' => array(), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'index', '[method]' => array('GET', 'POST')), $result); - - $_SERVER['REQUEST_METHOD'] = 'POST'; - $result = Router::parse('/posts'); - $this->assertEquals(array('pass' => array(), 'named' => array(), 'plugin' => '', 'controller' => 'posts', 'action' => 'index', '[method]' => array('GET', 'POST')), $result); - } - -/** - * testGenerateUrlResourceRoute method - * - * @return void - */ - public function testGenerateUrlResourceRoute() { - Router::mapResources('Posts'); - - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '[method]' => 'GET')); - $expected = '/posts'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'view', '[method]' => 'GET', 'id' => 10)); - $expected = '/posts/10'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'add', '[method]' => 'POST')); - $expected = '/posts'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'edit', '[method]' => 'PUT', 'id' => 10)); - $expected = '/posts/10'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'delete', '[method]' => 'DELETE', 'id' => 10)); - $expected = '/posts/10'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'edit', '[method]' => 'POST', 'id' => 10)); - $expected = '/posts/10'; - $this->assertEquals($expected, $result); - } - -/** - * testUrlNormalization method - * - * @return void - */ - public function testUrlNormalization() { - $expected = '/users/logout'; - - $result = Router::normalize('/users/logout/'); - $this->assertEquals($expected, $result); - - $result = Router::normalize('//users//logout//'); - $this->assertEquals($expected, $result); - - $result = Router::normalize('users/logout'); - $this->assertEquals($expected, $result); - - $result = Router::normalize(array('controller' => 'users', 'action' => 'logout')); - $this->assertEquals($expected, $result); - - $result = Router::normalize('/'); - $this->assertEquals('/', $result); - - $result = Router::normalize('http://google.com/'); - $this->assertEquals('http://google.com/', $result); - - $result = Router::normalize('http://google.com//'); - $this->assertEquals('http://google.com//', $result); - - $result = Router::normalize('/users/login/scope://foo'); - $this->assertEquals('/users/login/scope:/foo', $result); - - $result = Router::normalize('/recipe/recipes/add'); - $this->assertEquals('/recipe/recipes/add', $result); - - $request = new CakeRequest(); - $request->base = '/us'; - Router::setRequestInfo($request); - $result = Router::normalize('/us/users/logout/'); - $this->assertEquals('/users/logout', $result); - - Router::reload(); - - $request = new CakeRequest(); - $request->base = '/cake_12'; - Router::setRequestInfo($request); - $result = Router::normalize('/cake_12/users/logout/'); - $this->assertEquals('/users/logout', $result); - - Router::reload(); - $_back = Configure::read('App.baseUrl'); - Configure::write('App.baseUrl', '/'); - - $request = new CakeRequest(); - $request->base = '/'; - Router::setRequestInfo($request); - $result = Router::normalize('users/login'); - $this->assertEquals('/users/login', $result); - Configure::write('App.baseUrl', $_back); - - Router::reload(); - $request = new CakeRequest(); - $request->base = 'beer'; - Router::setRequestInfo($request); - $result = Router::normalize('beer/admin/beers_tags/add'); - $this->assertEquals('/admin/beers_tags/add', $result); - - $result = Router::normalize('/admin/beers_tags/add'); - $this->assertEquals('/admin/beers_tags/add', $result); - } - -/** - * test generation of basic urls. - * - * @return void - */ - public function testUrlGenerationBasic() { - extract(Router::getNamedExpressions()); - - $request = new CakeRequest(); - $request->addParams(array( - 'action' => 'index', 'plugin' => null, 'controller' => 'subscribe', 'admin' => true - )); - $request->base = '/magazine'; - $request->here = '/magazine'; - $request->webroot = '/magazine/'; - Router::setRequestInfo($request); - - $result = Router::url(); - $this->assertEquals('/magazine', $result); - - Router::reload(); - - Router::connect('/', array('controller' => 'pages', 'action' => 'display', 'home')); - $out = Router::url(array('controller' => 'pages', 'action' => 'display', 'home')); - $this->assertEquals('/', $out); - - Router::connect('/pages/*', array('controller' => 'pages', 'action' => 'display')); - $result = Router::url(array('controller' => 'pages', 'action' => 'display', 'about')); - $expected = '/pages/about'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:plugin/:id/*', array('controller' => 'posts', 'action' => 'view'), array('id' => $ID)); - Router::parse('/'); - - $result = Router::url(array('plugin' => 'cake_plugin', 'controller' => 'posts', 'action' => 'view', 'id' => '1')); - $expected = '/cake_plugin/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('plugin' => 'cake_plugin', 'controller' => 'posts', 'action' => 'view', 'id' => '1', '0')); - $expected = '/cake_plugin/1/0'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:controller/:action/:id', array(), array('id' => $ID)); - Router::parse('/'); - - $result = Router::url(array('controller' => 'posts', 'action' => 'view', 'id' => '1')); - $expected = '/posts/view/1'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:controller/:id', array('action' => 'view')); - Router::parse('/'); - - $result = Router::url(array('controller' => 'posts', 'action' => 'view', 'id' => '1')); - $expected = '/posts/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '0')); - $expected = '/posts/index/0'; - $this->assertEquals($expected, $result); - - Router::connect('/view/*', array('controller' => 'posts', 'action' => 'view')); - Router::promote(); - $result = Router::url(array('controller' => 'posts', 'action' => 'view', '1')); - $expected = '/view/1'; - $this->assertEquals($expected, $result); - - Router::reload(); - $request = new CakeRequest(); - $request->addParams(array( - 'action' => 'index', 'plugin' => null, 'controller' => 'real_controller_name' - )); - $request->base = '/'; - $request->here = '/'; - $request->webroot = '/'; - Router::setRequestInfo($request); - - Router::connect('short_controller_name/:action/*', array('controller' => 'real_controller_name')); - Router::parse('/'); - - $result = Router::url(array('controller' => 'real_controller_name', 'page' => '1')); - $expected = '/short_controller_name/index/page:1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'add')); - $expected = '/short_controller_name/add'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::parse('/'); - $request = new CakeRequest(); - $request->addParams(array( - 'action' => 'index', 'plugin' => null, 'controller' => 'users', 'url' => array('url' => 'users') - )); - $request->base = '/'; - $request->here = '/'; - $request->webroot = '/'; - Router::setRequestInfo($request); - - $result = Router::url(array('action' => 'login')); - $expected = '/users/login'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/page/*', array('plugin' => null, 'controller' => 'pages', 'action' => 'view')); - Router::parse('/'); - - $result = Router::url(array('plugin' => 'my_plugin', 'controller' => 'pages', 'action' => 'view', 'my-page')); - $expected = '/my_plugin/pages/view/my-page'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/contact/:action', array('plugin' => 'contact', 'controller' => 'contact')); - Router::parse('/'); - - $result = Router::url(array('plugin' => 'contact', 'controller' => 'contact', 'action' => 'me')); - - $expected = '/contact/me'; - $this->assertEquals($expected, $result); - - Router::reload(); - $request = new CakeRequest(); - $request->addParams(array( - 'action' => 'index', 'plugin' => 'myplugin', 'controller' => 'mycontroller', 'admin' => false - )); - $request->base = '/'; - $request->here = '/'; - $request->webroot = '/'; - Router::setRequestInfo($request); - - $result = Router::url(array('plugin' => null, 'controller' => 'myothercontroller')); - $expected = '/myothercontroller'; - $this->assertEquals($expected, $result); - } - -/** - * Tests using arrays in named parameters - * - * @return void - */ - public function testArrayNamedParameters() { - $result = Router::url(array('controller' => 'tests', 'pages' => array( - 1, 2, 3 - ))); - $expected = '/tests/index/pages[0]:1/pages[1]:2/pages[2]:3'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'tests', - 'pages' => array( - 'param1' => array( - 'one', - 'two' - ), - 'three' - ) - )); - $expected = '/tests/index/pages[param1][0]:one/pages[param1][1]:two/pages[0]:three'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'tests', - 'pages' => array( - 'param1' => array( - 'one' => 1, - 'two' => 2 - ), - 'three' - ) - )); - $expected = '/tests/index/pages[param1][one]:1/pages[param1][two]:2/pages[0]:three'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'tests', - 'super' => array( - 'nested' => array( - 'array' => 'awesome', - 'something' => 'else' - ), - 'cool' - ) - )); - $expected = '/tests/index/super[nested][array]:awesome/super[nested][something]:else/super[0]:cool'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'tests', 'namedParam' => array( - 'keyed' => 'is an array', - 'test' - ))); - $expected = '/tests/index/namedParam[keyed]:is%20an%20array/namedParam[0]:test'; - $this->assertEquals($expected, $result); - } - -/** - * Test generation of routes with query string parameters. - * - * @return void - **/ - public function testUrlGenerationWithQueryStrings() { - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '0', '?' => 'var=test&var2=test2')); - $expected = '/posts/index/0?var=test&var2=test2'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', '0', '?' => 'var=test&var2=test2')); - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', '0', '?' => array('var' => 'test', 'var2' => 'test2'))); - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', '0', '?' => array('var' => null))); - $this->assertEquals('/posts/index/0', $result); - - $result = Router::url(array('controller' => 'posts', '0', '?' => 'var=test&var2=test2', '#' => 'unencoded string %')); - $expected = '/posts/index/0?var=test&var2=test2#unencoded+string+%25'; - $this->assertEquals($expected, $result); - } - -/** - * test that regex validation of keyed route params is working. - * - * @return void - **/ - public function testUrlGenerationWithRegexQualifiedParams() { - Router::connect( - ':language/galleries', - array('controller' => 'galleries', 'action' => 'index'), - array('language' => '[a-z]{3}') - ); - - Router::connect( - '/:language/:admin/:controller/:action/*', - array('admin' => 'admin'), - array('language' => '[a-z]{3}', 'admin' => 'admin') - ); - - Router::connect('/:language/:controller/:action/*', - array(), - array('language' => '[a-z]{3}') - ); - - $result = Router::url(array('admin' => false, 'language' => 'dan', 'action' => 'index', 'controller' => 'galleries')); - $expected = '/dan/galleries'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('admin' => false, 'language' => 'eng', 'action' => 'index', 'controller' => 'galleries')); - $expected = '/eng/galleries'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:language/pages', - array('controller' => 'pages', 'action' => 'index'), - array('language' => '[a-z]{3}') - ); - Router::connect('/:language/:controller/:action/*', array(), array('language' => '[a-z]{3}')); - - $result = Router::url(array('language' => 'eng', 'action' => 'index', 'controller' => 'pages')); - $expected = '/eng/pages'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('language' => 'eng', 'controller' => 'pages')); - $this->assertEquals($expected, $result); - - $result = Router::url(array('language' => 'eng', 'controller' => 'pages', 'action' => 'add')); - $expected = '/eng/pages/add'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/forestillinger/:month/:year/*', - array('plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar'), - array('month' => '0[1-9]|1[012]', 'year' => '[12][0-9]{3}') - ); - Router::parse('/'); - - $result = Router::url(array('plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar', 'month' => 10, 'year' => 2007, 'min-forestilling')); - $expected = '/forestillinger/10/2007/min-forestilling'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/kalender/:month/:year/*', - array('plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar'), - array('month' => '0[1-9]|1[012]', 'year' => '[12][0-9]{3}') - ); - Router::connect('/kalender/*', array('plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar')); - Router::parse('/'); - - $result = Router::url(array('plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar', 'min-forestilling')); - $expected = '/kalender/min-forestilling'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar', 'year' => 2007, 'month' => 10, 'min-forestilling')); - $expected = '/kalender/10/2007/min-forestilling'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:controller/:action/*', array(), array( - 'controller' => 'source|wiki|commits|tickets|comments|view', - 'action' => 'branches|history|branch|logs|view|start|add|edit|modify' - )); - } - -/** - * Test url generation with an admin prefix - * - * @return void - */ - public function testUrlGenerationWithAdminPrefix() { - Configure::write('Routing.prefixes', array('admin')); - Router::reload(); - - Router::connectNamed(array('event', 'lang')); - Router::connect('/', array('controller' => 'pages', 'action' => 'display', 'home')); - Router::connect('/pages/contact_us', array('controller' => 'pages', 'action' => 'contact_us')); - Router::connect('/pages/*', array('controller' => 'pages', 'action' => 'display')); - Router::connect('/reset/*', array('admin' => true, 'controller' => 'users', 'action' => 'reset')); - Router::connect('/tests', array('controller' => 'tests', 'action' => 'index')); - Router::parseExtensions('rss'); - - $request = new CakeRequest(); - $request->addParams(array( - 'controller' => 'registrations', 'action' => 'admin_index', - 'plugin' => null, 'prefix' => 'admin', 'admin' => true, - 'ext' => 'html' - )); - $request->base = ''; - $request->here = '/admin/registrations/index'; - $request->webroot = '/'; - Router::setRequestInfo($request); - - $result = Router::url(array('page' => 2)); - $expected = '/admin/registrations/index/page:2'; - $this->assertEquals($expected, $result); - - Router::reload(); - $request = new CakeRequest(); - $request->addParams(array( - 'controller' => 'subscriptions', 'action' => 'admin_index', - 'plugin' => null, 'admin' => true, - 'url' => array('url' => 'admin/subscriptions/index/page:2') - )); - $request->base = '/magazine'; - $request->here = '/magazine/admin/subscriptions/index/page:2'; - $request->webroot = '/magazine/'; - Router::setRequestInfo($request); - - Router::parse('/'); - - $result = Router::url(array('page' => 3)); - $expected = '/magazine/admin/subscriptions/index/page:3'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/admin/subscriptions/:action/*', array('controller' => 'subscribe', 'admin' => true, 'prefix' => 'admin')); - Router::parse('/'); - - $request = new CakeRequest(); - $request->addParams(array( - 'action' => 'admin_index', 'plugin' => null, 'controller' => 'subscribe', - 'admin' => true, 'url' => array('url' => 'admin/subscriptions/edit/1') - )); - $request->base = '/magazine'; - $request->here = '/magazine/admin/subscriptions/edit/1'; - $request->webroot = '/magazine/'; - Router::setRequestInfo($request); - - $result = Router::url(array('action' => 'edit', 1)); - $expected = '/magazine/admin/subscriptions/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('admin' => true, 'controller' => 'users', 'action' => 'login')); - $expected = '/magazine/admin/users/login'; - $this->assertEquals($expected, $result); - - Router::reload(); - $request = new CakeRequest(); - $request->addParams(array( - 'admin' => true, 'action' => 'index', 'plugin' => null, 'controller' => 'users', - 'url' => array('url' => 'users') - )); - $request->base = '/'; - $request->here = '/'; - $request->webroot = '/'; - Router::setRequestInfo($request); - - Router::connect('/page/*', array('controller' => 'pages', 'action' => 'view', 'admin' => true, 'prefix' => 'admin')); - Router::parse('/'); - - $result = Router::url(array('admin' => true, 'controller' => 'pages', 'action' => 'view', 'my-page')); - $expected = '/page/my-page'; - $this->assertEquals($expected, $result); - - Router::reload(); - - $request = new CakeRequest(); - $request->addParams(array( - 'plugin' => null, 'controller' => 'pages', 'action' => 'admin_add', 'prefix' => 'admin', 'admin' => true, - 'url' => array('url' => 'admin/pages/add') - )); - $request->base = ''; - $request->here = '/admin/pages/add'; - $request->webroot = '/'; - Router::setRequestInfo($request); - Router::parse('/'); - - $result = Router::url(array('plugin' => null, 'controller' => 'pages', 'action' => 'add', 'id' => false)); - $expected = '/admin/pages/add'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::parse('/'); - $request = new CakeRequest(); - $request->addParams(array( - 'plugin' => null, 'controller' => 'pages', 'action' => 'admin_add', 'prefix' => 'admin', 'admin' => true, - 'url' => array('url' => 'admin/pages/add') - )); - $request->base = ''; - $request->here = '/admin/pages/add'; - $request->webroot = '/'; - Router::setRequestInfo($request); - - $result = Router::url(array('plugin' => null, 'controller' => 'pages', 'action' => 'add', 'id' => false)); - $expected = '/admin/pages/add'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/admin/:controller/:action/:id', array('admin' => true), array('id' => '[0-9]+')); - Router::parse('/'); - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'pages', 'action' => 'admin_edit', 'pass' => array('284'), - 'prefix' => 'admin', 'admin' => true, - 'url' => array('url' => 'admin/pages/edit/284') - ))->addPaths(array( - 'base' => '', 'here' => '/admin/pages/edit/284', 'webroot' => '/' - )) - ); - - $result = Router::url(array('plugin' => null, 'controller' => 'pages', 'action' => 'edit', 'id' => '284')); - $expected = '/admin/pages/edit/284'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::parse('/'); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'pages', 'action' => 'admin_add', 'prefix' => 'admin', - 'admin' => true, 'url' => array('url' => 'admin/pages/add') - ))->addPaths(array( - 'base' => '', 'here' => '/admin/pages/add', 'webroot' => '/' - )) - ); - - $result = Router::url(array('plugin' => null, 'controller' => 'pages', 'action' => 'add', 'id' => false)); - $expected = '/admin/pages/add'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::parse('/'); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'pages', 'action' => 'admin_edit', 'prefix' => 'admin', - 'admin' => true, 'pass' => array('284'), 'url' => array('url' => 'admin/pages/edit/284') - ))->addPaths(array( - 'base' => '', 'here' => '/admin/pages/edit/284', 'webroot' => '/' - )) - ); - - $result = Router::url(array('plugin' => null, 'controller' => 'pages', 'action' => 'edit', 284)); - $expected = '/admin/pages/edit/284'; - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/admin/posts/*', array('controller' => 'posts', 'action' => 'index', 'admin' => true)); - Router::parse('/'); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'posts', 'action' => 'admin_index', 'prefix' => 'admin', - 'admin' => true, 'pass' => array('284'), 'url' => array('url' => 'admin/posts') - ))->addPaths(array( - 'base' => '', 'here' => '/admin/posts', 'webroot' => '/' - )) - ); - - $result = Router::url(array('all')); - $expected = '/admin/posts/all'; - $this->assertEquals($expected, $result); - } - -/** - * testUrlGenerationWithExtensions method - * - * @return void - */ - public function testUrlGenerationWithExtensions() { - Router::parse('/'); - $result = Router::url(array('plugin' => null, 'controller' => 'articles', 'action' => 'add', 'id' => null, 'ext' => 'json')); - $expected = '/articles/add.json'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('plugin' => null, 'controller' => 'articles', 'action' => 'add', 'ext' => 'json')); - $expected = '/articles/add.json'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('plugin' => null, 'controller' => 'articles', 'action' => 'index', 'id' => null, 'ext' => 'json')); - $expected = '/articles.json'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('plugin' => null, 'controller' => 'articles', 'action' => 'index', 'ext' => 'json')); - $expected = '/articles.json'; - $this->assertEquals($expected, $result); - } - -/** - * testPluginUrlGeneration method - * - * @return void - */ - public function testUrlGenerationPlugins() { - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => 'test', 'controller' => 'controller', 'action' => 'index' - ))->addPaths(array( - 'base' => '/base', 'here' => '/clients/sage/portal/donations', 'webroot' => '/base/' - )) - ); - - $this->assertEquals(Router::url('read/1'), '/base/test/controller/read/1'); - - Router::reload(); - Router::connect('/:lang/:plugin/:controller/*', array('action' => 'index')); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'lang' => 'en', - 'plugin' => 'shows', 'controller' => 'shows', 'action' => 'index', - 'url' => array('url' => 'en/shows/'), - ))->addPaths(array( - 'base' => '', 'here' => '/en/shows', 'webroot' => '/' - )) - ); - - Router::parse('/en/shows/'); - - $result = Router::url(array( - 'lang' => 'en', - 'controller' => 'shows', 'action' => 'index', 'page' => '1', - )); - $expected = '/en/shows/shows/page:1'; - $this->assertEquals($expected, $result); - } - -/** - * test that you can leave active plugin routes with plugin = null - * - * @return void - */ - public function testCanLeavePlugin() { - Router::reload(); - Router::connect( - '/admin/other/:controller/:action/*', - array( - 'admin' => 1, - 'plugin' => 'aliased', - 'prefix' => 'admin' - ) - ); - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'pass' => array(), - 'admin' => true, - 'prefix' => 'admin', - 'plugin' => 'this', - 'action' => 'admin_index', - 'controller' => 'interesting', - 'url' => array('url' => 'admin/this/interesting/index'), - ))->addPaths(array( - 'base' => '', - 'here' => '/admin/this/interesting/index', - 'webroot' => '/', - )) - ); - $result = Router::url(array('plugin' => null, 'controller' => 'posts', 'action' => 'index')); - $this->assertEquals('/admin/posts', $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'index')); - $this->assertEquals('/admin/this/posts', $result); - - $result = Router::url(array('plugin' => 'aliased', 'controller' => 'posts', 'action' => 'index')); - $this->assertEquals('/admin/other/posts/index', $result); - } - -/** - * testUrlParsing method - * - * @return void - */ - public function testUrlParsing() { - extract(Router::getNamedExpressions()); - - Router::connect('/posts/:value/:somevalue/:othervalue/*', array('controller' => 'posts', 'action' => 'view'), array('value','somevalue', 'othervalue')); - $result = Router::parse('/posts/2007/08/01/title-of-post-here'); - $expected = array('value' => '2007', 'somevalue' => '08', 'othervalue' => '01', 'controller' => 'posts', 'action' => 'view', 'plugin' => '', 'pass' => array('0' => 'title-of-post-here'), 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/:year/:month/:day/*', array('controller' => 'posts', 'action' => 'view'), array('year' => $Year, 'month' => $Month, 'day' => $Day)); - $result = Router::parse('/posts/2007/08/01/title-of-post-here'); - $expected = array('year' => '2007', 'month' => '08', 'day' => '01', 'controller' => 'posts', 'action' => 'view', 'plugin' => '', 'pass' => array('0' => 'title-of-post-here'), 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/:day/:year/:month/*', array('controller' => 'posts', 'action' => 'view'), array('year' => $Year, 'month' => $Month, 'day' => $Day)); - $result = Router::parse('/posts/01/2007/08/title-of-post-here'); - $expected = array('day' => '01', 'year' => '2007', 'month' => '08', 'controller' => 'posts', 'action' => 'view', 'plugin' => '', 'pass' => array('0' => 'title-of-post-here'), 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/:month/:day/:year/*', array('controller' => 'posts', 'action' => 'view'), array('year' => $Year, 'month' => $Month, 'day' => $Day)); - $result = Router::parse('/posts/08/01/2007/title-of-post-here'); - $expected = array('month' => '08', 'day' => '01', 'year' => '2007', 'controller' => 'posts', 'action' => 'view', 'plugin' => '', 'pass' => array('0' => 'title-of-post-here'), 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/:year/:month/:day/*', array('controller' => 'posts', 'action' => 'view')); - $result = Router::parse('/posts/2007/08/01/title-of-post-here'); - $expected = array('year' => '2007', 'month' => '08', 'day' => '01', 'controller' => 'posts', 'action' => 'view', 'plugin' => '', 'pass' => array('0' => 'title-of-post-here'), 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $result = Router::parse('/pages/display/home'); - $expected = array('plugin' => null, 'pass' => array('home'), 'controller' => 'pages', 'action' => 'display', 'named' => array()); - $this->assertEquals($expected, $result); - - $result = Router::parse('pages/display/home/'); - $this->assertEquals($expected, $result); - - $result = Router::parse('pages/display/home'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/page/*', array('controller' => 'test')); - $result = Router::parse('/page/my-page'); - $expected = array('pass' => array('my-page'), 'plugin' => null, 'controller' => 'test', 'action' => 'index', 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:language/contact', array('language' => 'eng', 'plugin' => 'contact', 'controller' => 'contact', 'action' => 'index'), array('language' => '[a-z]{3}')); - $result = Router::parse('/eng/contact'); - $expected = array('pass' => array(), 'named' => array(), 'language' => 'eng', 'plugin' => 'contact', 'controller' => 'contact', 'action' => 'index'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/forestillinger/:month/:year/*', - array('plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar'), - array('month' => '0[1-9]|1[012]', 'year' => '[12][0-9]{3}') - ); - - $result = Router::parse('/forestillinger/10/2007/min-forestilling'); - $expected = array('pass' => array('min-forestilling'), 'plugin' => 'shows', 'controller' => 'shows', 'action' => 'calendar', 'year' => 2007, 'month' => 10, 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:controller/:action/*'); - Router::connect('/', array('plugin' => 'pages', 'controller' => 'pages', 'action' => 'display')); - $result = Router::parse('/'); - $expected = array('pass' => array(), 'named' => array(), 'controller' => 'pages', 'action' => 'display', 'plugin' => 'pages'); - $this->assertEquals($expected, $result); - - $result = Router::parse('/posts/edit/0'); - $expected = array('pass' => array(0), 'named' => array(), 'controller' => 'posts', 'action' => 'edit', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/:id::url_title', array('controller' => 'posts', 'action' => 'view'), array('pass' => array('id', 'url_title'), 'id' => '[\d]+')); - $result = Router::parse('/posts/5:sample-post-title'); - $expected = array('pass' => array('5', 'sample-post-title'), 'named' => array(), 'id' => 5, 'url_title' => 'sample-post-title', 'plugin' => null, 'controller' => 'posts', 'action' => 'view'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/:id::url_title/*', array('controller' => 'posts', 'action' => 'view'), array('pass' => array('id', 'url_title'), 'id' => '[\d]+')); - $result = Router::parse('/posts/5:sample-post-title/other/params/4'); - $expected = array('pass' => array('5', 'sample-post-title', 'other', 'params', '4'), 'named' => array(), 'id' => 5, 'url_title' => 'sample-post-title', 'plugin' => null, 'controller' => 'posts', 'action' => 'view'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/:url_title-(uuid::id)', array('controller' => 'posts', 'action' => 'view'), array('pass' => array('id', 'url_title'), 'id' => $UUID)); - $result = Router::parse('/posts/sample-post-title-(uuid:47fc97a9-019c-41d1-a058-1fa3cbdd56cb)'); - $expected = array('pass' => array('47fc97a9-019c-41d1-a058-1fa3cbdd56cb', 'sample-post-title'), 'named' => array(), 'id' => '47fc97a9-019c-41d1-a058-1fa3cbdd56cb', 'url_title' => 'sample-post-title', 'plugin' => null, 'controller' => 'posts', 'action' => 'view'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/view/*', array('controller' => 'posts', 'action' => 'view'), array('named' => false)); - $result = Router::parse('/posts/view/foo:bar/routing:fun'); - $expected = array('pass' => array('foo:bar', 'routing:fun'), 'named' => array(), 'plugin' => null, 'controller' => 'posts', 'action' => 'view'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/view/*', array('controller' => 'posts', 'action' => 'view'), array('named' => array('foo', 'answer'))); - $result = Router::parse('/posts/view/foo:bar/routing:fun/answer:42'); - $expected = array('pass' => array('routing:fun'), 'named' => array('foo' => 'bar', 'answer' => '42'), 'plugin' => null, 'controller' => 'posts', 'action' => 'view'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/posts/view/*', array('controller' => 'posts', 'action' => 'view'), array('named' => array('foo', 'answer'), 'greedyNamed' => true)); - $result = Router::parse('/posts/view/foo:bar/routing:fun/answer:42'); - $expected = array('pass' => array(), 'named' => array('foo' => 'bar', 'routing' => 'fun', 'answer' => '42'), 'plugin' => null, 'controller' => 'posts', 'action' => 'view'); - $this->assertEquals($expected, $result); - } - -/** - * test that the persist key works. - * - * @return void - */ - public function testPersistentParameters() { - Router::reload(); - Router::connect( - '/:lang/:color/posts/view/*', - array('controller' => 'posts', 'action' => 'view'), - array('persist' => array('lang', 'color') - )); - Router::connect( - '/:lang/:color/posts/index', - array('controller' => 'posts', 'action' => 'index'), - array('persist' => array('lang') - )); - Router::connect('/:lang/:color/posts/edit/*', array('controller' => 'posts', 'action' => 'edit')); - Router::connect('/about', array('controller' => 'pages', 'action' => 'view', 'about')); - Router::parse('/en/red/posts/view/5'); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'lang' => 'en', - 'color' => 'red', - 'prefix' => 'admin', - 'plugin' => null, - 'action' => 'view', - 'controller' => 'posts', - ))->addPaths(array( - 'base' => '/', - 'here' => '/en/red/posts/view/5', - 'webroot' => '/', - )) - ); - $expected = '/en/red/posts/view/6'; - $result = Router::url(array('controller' => 'posts', 'action' => 'view', 6)); - $this->assertEquals($expected, $result); - - $expected = '/en/blue/posts/index'; - $result = Router::url(array('controller' => 'posts', 'action' => 'index', 'color' => 'blue')); - $this->assertEquals($expected, $result); - - $expected = '/posts/edit/6'; - $result = Router::url(array('controller' => 'posts', 'action' => 'edit', 6, 'color' => null, 'lang' => null)); - $this->assertEquals($expected, $result); - - $expected = '/posts'; - $result = Router::url(array('controller' => 'posts', 'action' => 'index')); - $this->assertEquals($expected, $result); - - $expected = '/posts/edit/7'; - $result = Router::url(array('controller' => 'posts', 'action' => 'edit', 7)); - $this->assertEquals($expected, $result); - - $expected = '/about'; - $result = Router::url(array('controller' => 'pages', 'action' => 'view', 'about')); - $this->assertEquals($expected, $result); - } - -/** - * testUuidRoutes method - * - * @return void - */ - public function testUuidRoutes() { - Router::connect( - '/subjects/add/:category_id', - array('controller' => 'subjects', 'action' => 'add'), - array('category_id' => '\w{8}-\w{4}-\w{4}-\w{4}-\w{12}') - ); - $result = Router::parse('/subjects/add/4795d601-19c8-49a6-930e-06a8b01d17b7'); - $expected = array('pass' => array(), 'named' => array(), 'category_id' => '4795d601-19c8-49a6-930e-06a8b01d17b7', 'plugin' => null, 'controller' => 'subjects', 'action' => 'add'); - $this->assertEquals($expected, $result); - } - -/** - * testRouteSymmetry method - * - * @return void - */ - public function testRouteSymmetry() { - Router::connect( - "/:extra/page/:slug/*", - array('controller' => 'pages', 'action' => 'view', 'extra' => null), - array("extra" => '[a-z1-9_]*', "slug" => '[a-z1-9_]+', "action" => 'view') - ); - - $result = Router::parse('/some_extra/page/this_is_the_slug'); - $expected = array('pass' => array(), 'named' => array(), 'plugin' => null, 'controller' => 'pages', 'action' => 'view', 'slug' => 'this_is_the_slug', 'extra' => 'some_extra'); - $this->assertEquals($expected, $result); - - $result = Router::parse('/page/this_is_the_slug'); - $expected = array('pass' => array(), 'named' => array(), 'plugin' => null, 'controller' => 'pages', 'action' => 'view', 'slug' => 'this_is_the_slug', 'extra' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect( - "/:extra/page/:slug/*", - array('controller' => 'pages', 'action' => 'view', 'extra' => null), - array("extra" => '[a-z1-9_]*', "slug" => '[a-z1-9_]+') - ); - Router::parse('/'); - - $result = Router::url(array('admin' => null, 'plugin' => null, 'controller' => 'pages', 'action' => 'view', 'slug' => 'this_is_the_slug', 'extra' => null)); - $expected = '/page/this_is_the_slug'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('admin' => null, 'plugin' => null, 'controller' => 'pages', 'action' => 'view', 'slug' => 'this_is_the_slug', 'extra' => 'some_extra')); - $expected = '/some_extra/page/this_is_the_slug'; - $this->assertEquals($expected, $result); - } - -/** - * Test that Routing.prefixes are used when a Router instance is created - * or reset - * - * @return void - */ - public function testRoutingPrefixesSetting() { - $restore = Configure::read('Routing'); - - Configure::write('Routing.prefixes', array('admin', 'member', 'super_user')); - Router::reload(); - $result = Router::prefixes(); - $expected = array('admin', 'member', 'super_user'); - $this->assertEquals($expected, $result); - - Configure::write('Routing.prefixes', array('admin', 'member')); - Router::reload(); - $result = Router::prefixes(); - $expected = array('admin', 'member'); - $this->assertEquals($expected, $result); - - Configure::write('Routing', $restore); - } - -/** - * Test prefix routing and plugin combinations - * - * @return void - */ - public function testPrefixRoutingAndPlugins() { - Configure::write('Routing.prefixes', array('admin')); - $paths = App::path('plugins'); - App::build(array( - 'plugins' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS - ) - ), App::RESET); - CakePlugin::load(array('TestPlugin')); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'admin' => true, 'controller' => 'controller', 'action' => 'action', - 'plugin' => null, 'prefix' => 'admin' - ))->addPaths(array( - 'base' => '/', - 'here' => '/', - 'webroot' => '/base/', - )) - ); - Router::parse('/'); - - $result = Router::url(array('plugin' => 'test_plugin', 'controller' => 'test_plugin', 'action' => 'index')); - $expected = '/admin/test_plugin'; - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => 'test_plugin', 'controller' => 'show_tickets', 'action' => 'admin_edit', - 'pass' => array('6'), 'prefix' => 'admin', 'admin' => true, 'form' => array(), - 'url' => array('url' => 'admin/shows/show_tickets/edit/6') - ))->addPaths(array( - 'base' => '/', - 'here' => '/admin/shows/show_tickets/edit/6', - 'webroot' => '/', - )) - ); - - $result = Router::url(array( - 'plugin' => 'test_plugin', 'controller' => 'show_tickets', 'action' => 'edit', 6, - 'admin' => true, 'prefix' => 'admin' - )); - $expected = '/admin/test_plugin/show_tickets/edit/6'; - $this->assertEquals($expected, $result); - - $result = Router::url(array( - 'plugin' => 'test_plugin', 'controller' => 'show_tickets', 'action' => 'index', 'admin' => true - )); - $expected = '/admin/test_plugin/show_tickets'; - $this->assertEquals($expected, $result); - - App::build(array('plugins' => $paths)); - } - -/** - * testExtensionParsingSetting method - * - * @return void - */ - public function testExtensionParsingSetting() { - $this->assertEquals(array(), Router::extensions()); - - Router::parseExtensions('rss'); - $this->assertEquals(Router::extensions(), array('rss')); - } - -/** - * testExtensionParsing method - * - * @return void - */ - public function testExtensionParsing() { - Router::parseExtensions(); - require CAKE . 'Config' . DS . 'routes.php'; - - $result = Router::parse('/posts.rss'); - $expected = array('plugin' => null, 'controller' => 'posts', 'action' => 'index', 'ext' => 'rss', 'pass' => array(), 'named' => array()); - $this->assertEquals($expected, $result); - - $result = Router::parse('/posts/view/1.rss'); - $expected = array('plugin' => null, 'controller' => 'posts', 'action' => 'view', 'pass' => array('1'), 'named' => array(), 'ext' => 'rss', 'named' => array()); - $this->assertEquals($expected, $result); - - $result = Router::parse('/posts/view/1.rss?query=test'); - $this->assertEquals($expected, $result); - - $result = Router::parse('/posts/view/1.atom'); - $expected['ext'] = 'atom'; - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - - Router::parseExtensions('rss', 'xml'); - - $result = Router::parse('/posts.xml'); - $expected = array('plugin' => null, 'controller' => 'posts', 'action' => 'index', 'ext' => 'xml', 'pass' => array(), 'named' => array()); - $this->assertEquals($expected, $result); - - $result = Router::parse('/posts.atom?hello=goodbye'); - $expected = array('plugin' => null, 'controller' => 'posts.atom', 'action' => 'index', 'pass' => array(), 'named' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/controller/action', array('controller' => 'controller', 'action' => 'action', 'ext' => 'rss')); - $result = Router::parse('/controller/action'); - $expected = array('controller' => 'controller', 'action' => 'action', 'plugin' => null, 'ext' => 'rss', 'named' => array(), 'pass' => array()); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::parseExtensions('rss'); - Router::connect('/controller/action', array('controller' => 'controller', 'action' => 'action', 'ext' => 'rss')); - $result = Router::parse('/controller/action'); - $expected = array('controller' => 'controller', 'action' => 'action', 'plugin' => null, 'ext' => 'rss', 'named' => array(), 'pass' => array()); - $this->assertEquals($expected, $result); - } - -/** - * testQuerystringGeneration method - * - * @return void - */ - public function testQuerystringGeneration() { - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '0', '?' => 'var=test&var2=test2')); - $expected = '/posts/index/0?var=test&var2=test2'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '0', '?' => array('var' => 'test', 'var2' => 'test2'))); - $this->assertEquals($expected, $result); - - $expected .= '&more=test+data'; - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '0', '?' => array('var' => 'test', 'var2' => 'test2', 'more' => 'test data'))); - $this->assertEquals($expected, $result); - - // Test bug #4614 - $restore = ini_get('arg_separator.output'); - ini_set('arg_separator.output', '&'); - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '0', '?' => array('var' => 'test', 'var2' => 'test2', 'more' => 'test data'))); - $this->assertEquals($expected, $result); - ini_set('arg_separator.output', $restore); - - $result = Router::url(array('controller' => 'posts', 'action' => 'index', '0', '?' => array('var' => 'test', 'var2' => 'test2')), array('escape' => true)); - $expected = '/posts/index/0?var=test&var2=test2'; - $this->assertEquals($expected, $result); - } - -/** - * testConnectNamed method - * - * @return void - */ - public function testConnectNamed() { - $named = Router::connectNamed(false, array('default' => true)); - $this->assertFalse($named['greedyNamed']); - $this->assertEquals(array_keys($named['rules']), $named['default']); - - Router::reload(); - Router::connect('/foo/*', array('controller' => 'bar', 'action' => 'fubar')); - Router::connectNamed(array(), array('separator' => '=')); - $result = Router::parse('/foo/param1=value1/param2=value2'); - $expected = array('pass' => array(), 'named' => array('param1' => 'value1', 'param2' => 'value2'), 'controller' => 'bar', 'action' => 'fubar', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/controller/action/*', array('controller' => 'controller', 'action' => 'action'), array('named' => array('param1' => 'value[\d]'))); - Router::connectNamed(array(), array('greedy' => false, 'separator' => '=')); - $result = Router::parse('/controller/action/param1=value1/param2=value2'); - $expected = array('pass' => array('param2=value2'), 'named' => array('param1' => 'value1'), 'controller' => 'controller', 'action' => 'action', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/:controller/:action/*'); - Router::connectNamed(array('page'), array('default' => false, 'greedy' => false)); - $result = Router::parse('/categories/index/limit=5'); - $this->assertTrue(empty($result['named'])); - } - -/** - * testNamedArgsUrlGeneration method - * - * @return void - */ - public function testNamedArgsUrlGeneration() { - $result = Router::url(array('controller' => 'posts', 'action' => 'index', 'published' => 1, 'deleted' => 1)); - $expected = '/posts/index/published:1/deleted:1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'posts', 'action' => 'index', 'published' => 0, 'deleted' => 0)); - $expected = '/posts/index/published:0/deleted:0'; - $this->assertEquals($expected, $result); - - Router::reload(); - extract(Router::getNamedExpressions()); - Router::connectNamed(array('file' => '[\w\.\-]+\.(html|png)')); - Router::connect('/', array('controller' => 'graphs', 'action' => 'index')); - Router::connect('/:id/*', array('controller' => 'graphs', 'action' => 'view'), array('id' => $ID)); - - $result = Router::url(array('controller' => 'graphs', 'action' => 'view', 'id' => 12, 'file' => 'asdf.png')); - $expected = '/12/file:asdf.png'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'graphs', 'action' => 'view', 12, 'file' => 'asdf.foo')); - $expected = '/graphs/view/12/file:asdf.foo'; - $this->assertEquals($expected, $result); - - Configure::write('Routing.prefixes', array('admin')); - - Router::reload(); - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'admin' => true, 'controller' => 'controller', 'action' => 'index', 'plugin' => null - ))->addPaths(array( - 'base' => '/', - 'here' => '/', - 'webroot' => '/base/', - )) - ); - Router::parse('/'); - - $result = Router::url(array('page' => 1, 0 => null, 'sort' => 'controller', 'direction' => 'asc', 'order' => null)); - $expected = "/admin/controller/index/page:1/sort:controller/direction:asc"; - $this->assertEquals($expected, $result); - - Router::reload(); - $request = new CakeRequest('admin/controller/index'); - $request->addParams(array( - 'admin' => true, 'controller' => 'controller', 'action' => 'index', 'plugin' => null - )); - $request->base = '/'; - Router::setRequestInfo($request); - - $result = Router::parse('/admin/controller/index/type:whatever'); - $result = Router::url(array('type' => 'new')); - $expected = "/admin/controller/index/type:new"; - $this->assertEquals($expected, $result); - } - -/** - * testNamedArgsUrlParsing method - * - * @return void - */ - public function testNamedArgsUrlParsing() { - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $result = Router::parse('/controller/action/param1:value1:1/param2:value2:3/param:value'); - $expected = array('pass' => array(), 'named' => array('param1' => 'value1:1', 'param2' => 'value2:3', 'param' => 'value'), 'controller' => 'controller', 'action' => 'action', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $result = Router::connectNamed(false); - $this->assertEquals(array(), array_keys($result['rules'])); - $this->assertFalse($result['greedyNamed']); - $result = Router::parse('/controller/action/param1:value1:1/param2:value2:3/param:value'); - $expected = array('pass' => array('param1:value1:1', 'param2:value2:3', 'param:value'), 'named' => array(), 'controller' => 'controller', 'action' => 'action', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - $result = Router::connectNamed(true); - $named = Router::namedConfig(); - $this->assertEquals($named['default'], array_keys($result['rules'])); - $this->assertTrue($result['greedyNamed']); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - Router::connectNamed(array('param1' => 'not-matching')); - $result = Router::parse('/controller/action/param1:value1:1/param2:value2:3/param:value'); - $expected = array('pass' => array('param1:value1:1'), 'named' => array('param2' => 'value2:3', 'param' => 'value'), 'controller' => 'controller', 'action' => 'action', 'plugin' => null); - $this->assertEquals($expected, $result); - - $result = Router::parse('/foo/view/param1:value1:1/param2:value2:3/param:value'); - $expected = array('pass' => array('param1:value1:1'), 'named' => array('param2' => 'value2:3', 'param' => 'value'), 'controller' => 'foo', 'action' => 'view', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - Router::connectNamed(array('param1' => '[\d]', 'param2' => '[a-z]', 'param3' => '[\d]')); - $result = Router::parse('/controller/action/param1:1/param2:2/param3:3'); - $expected = array('pass' => array('param2:2'), 'named' => array('param1' => '1', 'param3' => '3'), 'controller' => 'controller', 'action' => 'action', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - Router::connectNamed(array('param1' => '[\d]', 'param2' => true, 'param3' => '[\d]')); - $result = Router::parse('/controller/action/param1:1/param2:2/param3:3'); - $expected = array('pass' => array(), 'named' => array('param1' => '1', 'param2' => '2', 'param3' => '3'), 'controller' => 'controller', 'action' => 'action', 'plugin' => null); - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - Router::connectNamed(array('param1' => 'value[\d]+:[\d]+'), array('greedy' => false)); - $result = Router::parse('/controller/action/param1:value1:1/param2:value2:3/param3:value'); - $expected = array('pass' => array('param2:value2:3', 'param3:value'), 'named' => array('param1' => 'value1:1'), 'controller' => 'controller', 'action' => 'action', 'plugin' => null); - $this->assertEquals($expected, $result); - } - -/** - * test url generation with legacy (1.2) style prefix routes. - * - * @return void - * @todo Remove tests related to legacy style routes. - * @see testUrlGenerationWithAutoPrefixes - */ - public function testUrlGenerationWithLegacyPrefixes() { - Router::reload(); - Router::connect('/protected/:controller/:action/*', array( - 'prefix' => 'protected', - 'protected' => true - )); - Router::parse('/'); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'images', 'action' => 'index', - 'prefix' => null, 'admin' => false,'url' => array('url' => 'images/index') - ))->addPaths(array( - 'base' => '', - 'here' => '/images/index', - 'webroot' => '/', - )) - ); - - $result = Router::url(array('protected' => true)); - $expected = '/protected/images/index'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'images', 'action' => 'add')); - $expected = '/images/add'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'images', 'action' => 'add', 'protected' => true)); - $expected = '/protected/images/add'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'edit', 1)); - $expected = '/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'edit', 1, 'protected' => true)); - $expected = '/protected/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'protected_edit', 1, 'protected' => true)); - $expected = '/protected/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'edit', 1, 'protected' => true)); - $expected = '/protected/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1)); - $expected = '/others/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1, 'protected' => true)); - $expected = '/protected/others/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1, 'protected' => true, 'page' => 1)); - $expected = '/protected/others/edit/1/page:1'; - $this->assertEquals($expected, $result); - - Router::connectNamed(array('random')); - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1, 'protected' => true, 'random' => 'my-value')); - $expected = '/protected/others/edit/1/random:my-value'; - $this->assertEquals($expected, $result); - } - -/** - * test newer style automatically generated prefix routes. - * - * @return void - */ - public function testUrlGenerationWithAutoPrefixes() { - Configure::write('Routing.prefixes', array('protected')); - Router::reload(); - Router::parse('/'); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'images', 'action' => 'index', - 'prefix' => null, 'protected' => false, 'url' => array('url' => 'images/index') - ))->addPaths(array( - 'base' => '', - 'here' => '/images/index', - 'webroot' => '/', - )) - ); - - $result = Router::url(array('controller' => 'images', 'action' => 'add')); - $expected = '/images/add'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'images', 'action' => 'add', 'protected' => true)); - $expected = '/protected/images/add'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'edit', 1)); - $expected = '/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'edit', 1, 'protected' => true)); - $expected = '/protected/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'protected_edit', 1, 'protected' => true)); - $expected = '/protected/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'protectededit', 1, 'protected' => true)); - $expected = '/protected/images/protectededit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'edit', 1, 'protected' => true)); - $expected = '/protected/images/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1)); - $expected = '/others/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1, 'protected' => true)); - $expected = '/protected/others/edit/1'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1, 'protected' => true, 'page' => 1)); - $expected = '/protected/others/edit/1/page:1'; - $this->assertEquals($expected, $result); - - Router::connectNamed(array('random')); - $result = Router::url(array('controller' => 'others', 'action' => 'edit', 1, 'protected' => true, 'random' => 'my-value')); - $expected = '/protected/others/edit/1/random:my-value'; - $this->assertEquals($expected, $result); - } - -/** - * test that auto-generated prefix routes persist - * - * @return void - */ - public function testAutoPrefixRoutePersistence() { - Configure::write('Routing.prefixes', array('protected')); - Router::reload(); - Router::parse('/'); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'images', 'action' => 'index', 'prefix' => 'protected', - 'protected' => true, 'url' => array('url' => 'protected/images/index') - ))->addPaths(array( - 'base' => '', - 'here' => '/protected/images/index', - 'webroot' => '/', - )) - ); - - $result = Router::url(array('controller' => 'images', 'action' => 'add')); - $expected = '/protected/images/add'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'images', 'action' => 'add', 'protected' => false)); - $expected = '/images/add'; - $this->assertEquals($expected, $result); - } - -/** - * test that setting a prefix override the current one - * - * @return void - */ - public function testPrefixOverride() { - Configure::write('Routing.prefixes', array('protected', 'admin')); - Router::reload(); - Router::parse('/'); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'images', 'action' => 'index', 'prefix' => 'protected', - 'protected' => true, 'url' => array('url' => 'protected/images/index') - ))->addPaths(array( - 'base' => '', - 'here' => '/protected/images/index', - 'webroot' => '/', - )) - ); - - $result = Router::url(array('controller' => 'images', 'action' => 'add', 'admin' => true)); - $expected = '/admin/images/add'; - $this->assertEquals($expected, $result); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'images', 'action' => 'index', 'prefix' => 'admin', - 'admin' => true, 'url' => array('url' => 'admin/images/index') - ))->addPaths(array( - 'base' => '', - 'here' => '/admin/images/index', - 'webroot' => '/', - )) - ); - $result = Router::url(array('controller' => 'images', 'action' => 'add', 'protected' => true)); - $expected = '/protected/images/add'; - $this->assertEquals($expected, $result); - } - -/** - * Test that setting a prefix to false is ignored, as its generally user error. - * - * @return void - */ - public function testPrefixFalseIgnored() { - Configure::write('Routing.prefixes', array('admin')); - Router::reload(); - - Router::connect('/cache_css/*', array('admin' => false, 'controller' => 'asset_compress', 'action' => 'get')); - - $url = Router::url(array('controller' => 'asset_compress', 'action' => 'get', 'test')); - $expected = '/cache_css/test'; - $this->assertEquals($expected, $url); - - $url = Router::url(array('admin' => false, 'controller' => 'asset_compress', 'action' => 'get', 'test')); - $expected = '/cache_css/test'; - $this->assertEquals($expected, $url); - - $url = Router::url(array('admin' => true, 'controller' => 'asset_compress', 'action' => 'get', 'test')); - $this->assertEquals('/admin/asset_compress/get/test', $url); - } - -/** - * testRemoveBase method - * - * @return void - */ - public function testRemoveBase() { - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'controller', 'action' => 'index', - 'bare' => 0, 'url' => array('url' => 'protected/images/index') - ))->addPaths(array( - 'base' => '/base', - 'here' => '/', - 'webroot' => '/base/', - )) - ); - - $result = Router::url(array('controller' => 'my_controller', 'action' => 'my_action')); - $expected = '/base/my_controller/my_action'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'my_controller', 'action' => 'my_action', 'base' => false)); - $expected = '/my_controller/my_action'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'my_controller', 'action' => 'my_action', 'base' => true)); - $expected = '/base/my_controller/my_action/base:1'; - $this->assertEquals($expected, $result); - } - -/** - * testPagesUrlParsing method - * - * @return void - */ - public function testPagesUrlParsing() { - Router::connect('/', array('controller' => 'pages', 'action' => 'display', 'home')); - Router::connect('/pages/*', array('controller' => 'pages', 'action' => 'display')); - - $result = Router::parse('/'); - $expected = array('pass' => array('home'), 'named' => array(), 'plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals($expected, $result); - - $result = Router::parse('/pages/home/'); - $expected = array('pass' => array('home'), 'named' => array(), 'plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals($expected, $result); - - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - Router::connect('/', array('controller' => 'pages', 'action' => 'display', 'home')); - - $result = Router::parse('/'); - $expected = array('pass' => array('home'), 'named' => array(), 'plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals($expected, $result); - - $result = Router::parse('/pages/display/home/event:value'); - $expected = array('pass' => array('home'), 'named' => array('event' => 'value'), 'plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals($expected, $result); - - $result = Router::parse('/pages/display/home/event:Val_u2'); - $expected = array('pass' => array('home'), 'named' => array('event' => 'Val_u2'), 'plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals($expected, $result); - - $result = Router::parse('/pages/display/home/event:val-ue'); - $expected = array('pass' => array('home'), 'named' => array('event' => 'val-ue'), 'plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals($expected, $result); - - Router::reload(); - Router::connect('/', array('controller' => 'posts', 'action' => 'index')); - Router::connect('/pages/*', array('controller' => 'pages', 'action' => 'display')); - $result = Router::parse('/pages/contact/'); - - $expected = array('pass' => array('contact'), 'named' => array(), 'plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals($expected, $result); - } - -/** - * test that requests with a trailing dot don't loose the do. - * - * @return void - */ - public function testParsingWithTrailingPeriod() { - Router::reload(); - Router::connect('/:controller/:action/*'); - $result = Router::parse('/posts/view/something.'); - $this->assertEquals('something.', $result['pass'][0], 'Period was chopped off %s'); - - $result = Router::parse('/posts/view/something. . .'); - $this->assertEquals('something. . .', $result['pass'][0], 'Period was chopped off %s'); - } - -/** - * test that requests with a trailing dot don't loose the do. - * - * @return void - */ - public function testParsingWithTrailingPeriodAndParseExtensions() { - Router::reload(); - Router::connect('/:controller/:action/*'); - Router::parseExtensions('json'); - - $result = Router::parse('/posts/view/something.'); - $this->assertEquals('something.', $result['pass'][0], 'Period was chopped off %s'); - - $result = Router::parse('/posts/view/something. . .'); - $this->assertEquals('something. . .', $result['pass'][0], 'Period was chopped off %s'); - } - -/** - * test that patterns work for :action - * - * @return void - */ - public function testParsingWithPatternOnAction() { - Router::reload(); - Router::connect( - '/blog/:action/*', - array('controller' => 'blog_posts'), - array('action' => 'other|actions') - ); - $result = Router::parse('/blog/other'); - $expected = array( - 'plugin' => null, - 'controller' => 'blog_posts', - 'action' => 'other', - 'pass' => array(), - 'named' => array() - ); - $this->assertEquals($expected, $result); - - $result = Router::parse('/blog/foobar'); - $this->assertEquals(array(), $result); - - $result = Router::url(array('controller' => 'blog_posts', 'action' => 'foo')); - $this->assertEquals('/blog_posts/foo', $result); - - $result = Router::url(array('controller' => 'blog_posts', 'action' => 'actions')); - $this->assertEquals('/blog/actions', $result); - } - -/** - * testParsingWithPrefixes method - * - * @return void - */ - public function testParsingWithPrefixes() { - $adminParams = array('prefix' => 'admin', 'admin' => true); - Router::connect('/admin/:controller', $adminParams); - Router::connect('/admin/:controller/:action', $adminParams); - Router::connect('/admin/:controller/:action/*', $adminParams); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'controller', 'action' => 'index' - ))->addPaths(array( - 'base' => '/base', - 'here' => '/', - 'webroot' => '/base/', - )) - ); - - $result = Router::parse('/admin/posts/'); - $expected = array('pass' => array(), 'named' => array(), 'prefix' => 'admin', 'plugin' => null, 'controller' => 'posts', 'action' => 'admin_index', 'admin' => true); - $this->assertEquals($expected, $result); - - $result = Router::parse('/admin/posts'); - $this->assertEquals($expected, $result); - - $result = Router::url(array('admin' => true, 'controller' => 'posts')); - $expected = '/base/admin/posts'; - $this->assertEquals($expected, $result); - - $result = Router::prefixes(); - $expected = array('admin'); - $this->assertEquals($expected, $result); - - Router::reload(); - - $prefixParams = array('prefix' => 'members', 'members' => true); - Router::connect('/members/:controller', $prefixParams); - Router::connect('/members/:controller/:action', $prefixParams); - Router::connect('/members/:controller/:action/*', $prefixParams); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'controller', 'action' => 'index', - 'bare' => 0 - ))->addPaths(array( - 'base' => '/base', - 'here' => '/', - 'webroot' => '/', - )) - ); - - $result = Router::parse('/members/posts/index'); - $expected = array('pass' => array(), 'named' => array(), 'prefix' => 'members', 'plugin' => null, 'controller' => 'posts', 'action' => 'members_index', 'members' => true); - $this->assertEquals($expected, $result); - - $result = Router::url(array('members' => true, 'controller' => 'posts', 'action' => 'index', 'page' => 2)); - $expected = '/base/members/posts/index/page:2'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('members' => true, 'controller' => 'users', 'action' => 'add')); - $expected = '/base/members/users/add'; - $this->assertEquals($expected, $result); - } - -/** - * Tests URL generation with flags and prefixes in and out of context - * - * @return void - */ - public function testUrlWritingWithPrefixes() { - Router::connect('/company/:controller/:action/*', array('prefix' => 'company', 'company' => true)); - Router::connect('/login', array('controller' => 'users', 'action' => 'login')); - - $result = Router::url(array('controller' => 'users', 'action' => 'login', 'company' => true)); - $expected = '/company/users/login'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'users', 'action' => 'company_login', 'company' => true)); - $expected = '/company/users/login'; - $this->assertEquals($expected, $result); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'users', 'action' => 'login', - 'company' => true - ))->addPaths(array( - 'base' => '/', - 'here' => '/', - 'webroot' => '/base/', - )) - ); - - $result = Router::url(array('controller' => 'users', 'action' => 'login', 'company' => false)); - $expected = '/login'; - $this->assertEquals($expected, $result); - } - -/** - * test url generation with prefixes and custom routes - * - * @return void - */ - public function testUrlWritingWithPrefixesAndCustomRoutes() { - Router::connect( - '/admin/login', - array('controller' => 'users', 'action' => 'login', 'prefix' => 'admin', 'admin' => true) - ); - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'posts', 'action' => 'index', - 'admin' => true, 'prefix' => 'admin' - ))->addPaths(array( - 'base' => '/', - 'here' => '/', - 'webroot' => '/', - )) - ); - $result = Router::url(array('controller' => 'users', 'action' => 'login', 'admin' => true)); - $this->assertEquals('/admin/login', $result); - - $result = Router::url(array('controller' => 'users', 'action' => 'login')); - $this->assertEquals('/admin/login', $result); - - $result = Router::url(array('controller' => 'users', 'action' => 'admin_login')); - $this->assertEquals('/admin/login', $result); - } - -/** - * testPassedArgsOrder method - * - * @return void - */ - public function testPassedArgsOrder() { - Router::connect('/test-passed/*', array('controller' => 'pages', 'action' => 'display', 'home')); - Router::connect('/test2/*', array('controller' => 'pages', 'action' => 'display', 2)); - Router::connect('/test/*', array('controller' => 'pages', 'action' => 'display', 1)); - Router::parse('/'); - - $result = Router::url(array('controller' => 'pages', 'action' => 'display', 1, 'whatever')); - $expected = '/test/whatever'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'pages', 'action' => 'display', 2, 'whatever')); - $expected = '/test2/whatever'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('controller' => 'pages', 'action' => 'display', 'home', 'whatever')); - $expected = '/test-passed/whatever'; - $this->assertEquals($expected, $result); - - Configure::write('Routing.prefixes', array('admin')); - Router::reload(); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'images', 'action' => 'index', - 'url' => array('url' => 'protected/images/index') - ))->addPaths(array( - 'base' => '', - 'here' => '/protected/images/index', - 'webroot' => '/', - )) - ); - - Router::connect('/protected/:controller/:action/*', array( - 'controller' => 'users', - 'action' => 'index', - 'prefix' => 'protected' - )); - - Router::parse('/'); - $result = Router::url(array('controller' => 'images', 'action' => 'add')); - $expected = '/protected/images/add'; - $this->assertEquals($expected, $result); - - $result = Router::prefixes(); - $expected = array('admin', 'protected'); - $this->assertEquals($expected, $result); - } - -/** - * testRegexRouteMatching method - * - * @return void - */ - public function testRegexRouteMatching() { - Router::connect('/:locale/:controller/:action/*', array(), array('locale' => 'dan|eng')); - - $result = Router::parse('/eng/test/test_action'); - $expected = array('pass' => array(), 'named' => array(), 'locale' => 'eng', 'controller' => 'test', 'action' => 'test_action', 'plugin' => null); - $this->assertEquals($expected, $result); - - $result = Router::parse('/badness/test/test_action'); - $this->assertEquals(array(), $result); - - Router::reload(); - Router::connect('/:locale/:controller/:action/*', array(), array('locale' => 'dan|eng')); - - $request = new CakeRequest(); - Router::setRequestInfo( - $request->addParams(array( - 'plugin' => null, 'controller' => 'test', 'action' => 'index', - 'url' => array('url' => 'test/test_action') - ))->addPaths(array( - 'base' => '', - 'here' => '/test/test_action', - 'webroot' => '/', - )) - ); - - $result = Router::url(array('action' => 'test_another_action')); - $expected = '/test/test_another_action'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'test_another_action', 'locale' => 'eng')); - $expected = '/eng/test/test_another_action'; - $this->assertEquals($expected, $result); - - $result = Router::url(array('action' => 'test_another_action', 'locale' => 'badness')); - $expected = '/test/test_another_action/locale:badness'; - $this->assertEquals($expected, $result); - } - -/** - * testStripPlugin - * - * @return void - */ - public function testStripPlugin() { - $pluginName = 'forums'; - $url = 'example.com/' . $pluginName . '/'; - $expected = 'example.com'; - - $this->assertEquals($expected, Router::stripPlugin($url, $pluginName)); - $this->assertEquals(Router::stripPlugin($url), $url); - $this->assertEquals(Router::stripPlugin($url, null), $url); - } - -/** - * testCurrentRoute - * - * This test needs some improvement and actual requestAction() usage - * - * @return void - */ - public function testCurrentRoute() { - $url = array('controller' => 'pages', 'action' => 'display', 'government'); - Router::connect('/government', $url); - Router::parse('/government'); - $route = Router::currentRoute(); - $this->assertEquals(array_merge($url, array('plugin' => null)), $route->defaults); - } - -/** - * testRequestRoute - * - * @return void - */ - public function testRequestRoute() { - $url = array('controller' => 'products', 'action' => 'display', 5); - Router::connect('/government', $url); - Router::parse('/government'); - $route = Router::requestRoute(); - $this->assertEquals(array_merge($url, array('plugin' => null)), $route->defaults); - - // test that the first route is matched - $newUrl = array('controller' => 'products', 'action' => 'display', 6); - Router::connect('/government', $url); - Router::parse('/government'); - $route = Router::requestRoute(); - $this->assertEquals(array_merge($url, array('plugin' => null)), $route->defaults); - - // test that an unmatched route does not change the current route - $newUrl = array('controller' => 'products', 'action' => 'display', 6); - Router::connect('/actor', $url); - Router::parse('/government'); - $route = Router::requestRoute(); - $this->assertEquals(array_merge($url, array('plugin' => null)), $route->defaults); - } - -/** - * testGetParams - * - * @return void - */ - public function testGetParams() { - $paths = array('base' => '/', 'here' => '/products/display/5', 'webroot' => '/webroot'); - $params = array('param1' => '1', 'param2' => '2'); - Router::setRequestInfo(array($params, $paths)); - - $expected = array( - 'plugin' => null, 'controller' => false, 'action' => false, - 'param1' => '1', 'param2' => '2' - ); - $this->assertEquals(Router::getParams(), $expected); - $this->assertEquals(Router::getParam('controller'), false); - $this->assertEquals(Router::getParam('param1'), '1'); - $this->assertEquals(Router::getParam('param2'), '2'); - - Router::reload(); - - $params = array('controller' => 'pages', 'action' => 'display'); - Router::setRequestInfo(array($params, $paths)); - $expected = array('plugin' => null, 'controller' => 'pages', 'action' => 'display'); - $this->assertEquals(Router::getParams(), $expected); - $this->assertEquals(Router::getParams(true), $expected); - } - -/** - * test that connectDefaults() can disable default route connection - * - * @return void - */ - public function testDefaultsMethod() { - Router::connect('/test/*', array('controller' => 'pages', 'action' => 'display', 2)); - $result = Router::parse('/posts/edit/5'); - $this->assertFalse(isset($result['controller'])); - $this->assertFalse(isset($result['action'])); - } - -/** - * test that the required default routes are connected. - * - * @return void - */ - public function testConnectDefaultRoutes() { - App::build(array( - 'plugins' => array( - CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS - ) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'PluginJs')); - Router::reload(); - require CAKE . 'Config' . DS . 'routes.php'; - - $result = Router::url(array('plugin' => 'plugin_js', 'controller' => 'js_file', 'action' => 'index')); - $this->assertEquals('/plugin_js/js_file', $result); - - $result = Router::parse('/plugin_js/js_file'); - $expected = array( - 'plugin' => 'plugin_js', 'controller' => 'js_file', 'action' => 'index', - 'named' => array(), 'pass' => array() - ); - $this->assertEquals($expected, $result); - - $result = Router::url(array('plugin' => 'test_plugin', 'controller' => 'test_plugin', 'action' => 'index')); - $this->assertEquals('/test_plugin', $result); - - $result = Router::parse('/test_plugin'); - $expected = array( - 'plugin' => 'test_plugin', 'controller' => 'test_plugin', 'action' => 'index', - 'named' => array(), 'pass' => array() - ); - - $this->assertEquals($expected, $result, 'Plugin shortcut route broken. %s'); - } - -/** - * test using a custom route class for route connection - * - * @return void - */ - public function testUsingCustomRouteClass() { - $mock = $this->getMock('CakeRoute', array(), array(), 'MockConnectedRoute', false); - $routes = Router::connect( - '/:slug', - array('controller' => 'posts', 'action' => 'view'), - array('routeClass' => 'MockConnectedRoute', 'slug' => '[a-z_-]+') - ); - $this->assertTrue(is_a($routes[0], 'MockConnectedRoute'), 'Incorrect class used. %s'); - $expected = array('controller' => 'posts', 'action' => 'view', 'slug' => 'test'); - $routes[0]->expects($this->any()) - ->method('parse') - ->will($this->returnValue($expected)); - $result = Router::parse('/test'); - $this->assertEquals($expected, $result); - } - -/** - * test that route classes must extend CakeRoute - * - * @expectedException RouterException - * @return void - */ - public function testCustomRouteException() { - Router::connect('/:controller', array(), array('routeClass' => 'Object')); - } - -/** - * test reversing parameter arrays back into strings. - * - * @return void - */ - public function testRouterReverse() { - $params = array( - 'controller' => 'posts', - 'action' => 'view', - 'pass' => array(1), - 'named' => array(), - 'url' => array(), - 'autoRender' => 1, - 'bare' => 1, - 'return' => 1, - 'requested' => 1, - '_Token' => array('key' => 'sekret') - ); - $result = Router::reverse($params); - $this->assertEquals('/posts/view/1', $result); - - $params = array( - 'controller' => 'posts', - 'action' => 'index', - 'pass' => array(1), - 'named' => array('page' => 1, 'sort' => 'Article.title', 'direction' => 'desc'), - 'url' => array() - ); - $result = Router::reverse($params); - $this->assertEquals('/posts/index/1/page:1/sort:Article.title/direction:desc', $result); - - Router::connect('/:lang/:controller/:action/*', array(), array('lang' => '[a-z]{3}')); - $params = array( - 'lang' => 'eng', - 'controller' => 'posts', - 'action' => 'view', - 'pass' => array(1), - 'named' => array(), - 'url' => array('url' => 'eng/posts/view/1') - ); - $result = Router::reverse($params); - $this->assertEquals('/eng/posts/view/1', $result); - - $params = array( - 'lang' => 'eng', - 'controller' => 'posts', - 'action' => 'view', - 'pass' => array(1), - 'named' => array(), - 'url' => array('url' => 'eng/posts/view/1', 'foo' => 'bar', 'baz' => 'quu'), - 'paging' => array(), - 'models' => array() - ); - $result = Router::reverse($params); - $this->assertEquals('/eng/posts/view/1?foo=bar&baz=quu', $result); - - $request = new CakeRequest('/eng/posts/view/1'); - $request->addParams(array( - 'lang' => 'eng', - 'controller' => 'posts', - 'action' => 'view', - 'pass' => array(1), - 'named' => array(), - )); - $request->query = array('url' => 'eng/posts/view/1', 'test' => 'value'); - $result = Router::reverse($request); - $expected = '/eng/posts/view/1?test=value'; - $this->assertEquals($expected, $result); - - $params = array( - 'lang' => 'eng', - 'controller' => 'posts', - 'action' => 'view', - 'pass' => array(1), - 'named' => array(), - 'url' => array('url' => 'eng/posts/view/1') - ); - $result = Router::reverse($params, true); - $this->assertRegExp('/^http(s)?:\/\//', $result); - } - -/** - * Test that extensions work with Router::reverse() - * - * @return void - */ - public function testReverseWithExtension() { - Router::parseExtensions('json'); - - $request = new CakeRequest('/posts/view/1.json'); - $request->addParams(array( - 'controller' => 'posts', - 'action' => 'view', - 'pass' => array(1), - 'named' => array(), - 'ext' => 'json', - )); - $request->query = array(); - $result = Router::reverse($request); - $expected = '/posts/view/1.json'; - $this->assertEquals($expected, $result); - } - -/** - * test that setRequestInfo can accept arrays and turn that into a CakeRequest object. - * - * @return void - */ - public function testSetRequestInfoLegacy() { - Router::setRequestInfo(array( - array( - 'plugin' => null, 'controller' => 'images', 'action' => 'index', - 'url' => array('url' => 'protected/images/index') - ), - array( - 'base' => '', - 'here' => '/protected/images/index', - 'webroot' => '/', - ) - )); - $result = Router::getRequest(); - $this->assertEquals('images', $result->controller); - $this->assertEquals('index', $result->action); - $this->assertEquals('', $result->base); - $this->assertEquals('/protected/images/index', $result->here); - $this->assertEquals('/', $result->webroot); - } - -/** - * Test that Router::url() uses the first request - */ - public function testUrlWithRequestAction() { - $firstRequest = new CakeRequest('/posts/index'); - $firstRequest->addParams(array( - 'plugin' => null, - 'controller' => 'posts', - 'action' => 'index' - ))->addPaths(array('base' => '')); - - $secondRequest = new CakeRequest('/posts/index'); - $secondRequest->addParams(array( - 'requested' => 1, - 'plugin' => null, - 'controller' => 'comments', - 'action' => 'listing' - ))->addPaths(array('base' => '')); - - Router::setRequestInfo($firstRequest); - Router::setRequestInfo($secondRequest); - - $result = Router::url(array('base' => false)); - $this->assertEquals('/comments/listing', $result, 'with second requests, the last should win.'); - - Router::popRequest(); - $result = Router::url(array('base' => false)); - $this->assertEquals('/posts', $result, 'with second requests, the last should win.'); - } - -/** - * test that a route object returning a full url is not modified. - * - * @return void - */ - public function testUrlFullUrlReturnFromRoute() { - $url = 'http://example.com/posts/view/1'; - - $this->getMock('CakeRoute', array(), array('/'), 'MockReturnRoute'); - $routes = Router::connect('/:controller/:action', array(), array('routeClass' => 'MockReturnRoute')); - $routes[0]->expects($this->any())->method('match') - ->will($this->returnValue($url)); - - $result = Router::url(array('controller' => 'posts', 'action' => 'view', 1)); - $this->assertEquals($url, $result); - } - -/** - * test protocol in url - * - * @return void - */ - public function testUrlProtocol() { - $url = 'http://example.com'; - $this->assertEquals($url, Router::url($url)); - - $url = 'ed2k://example.com'; - $this->assertEquals($url, Router::url($url)); - - $url = 'svn+ssh://example.com'; - $this->assertEquals($url, Router::url($url)); - - $url = '://example.com'; - $this->assertEquals($url, Router::url($url)); - } - -/** - * Testing that patterns on the :action param work properly. - * - * @return void - */ - public function testPatternOnAction() { - $route = new CakeRoute( - '/blog/:action/*', - array('controller' => 'blog_posts'), - array('action' => 'other|actions') - ); - $result = $route->match(array('controller' => 'blog_posts', 'action' => 'foo')); - $this->assertFalse($result); - - $result = $route->match(array('controller' => 'blog_posts', 'action' => 'actions')); - $this->assertEquals('/blog/actions/', $result); - - $result = $route->parse('/blog/other'); - $expected = array('controller' => 'blog_posts', 'action' => 'other', 'pass' => array(), 'named' => array()); - $this->assertEquals($expected, $result); - - $result = $route->parse('/blog/foobar'); - $this->assertFalse($result); - } - -/** - * Tests resourceMap as getter and setter. - * - * @return void - */ - public function testResourceMap() { - $default = Router::resourceMap(); - $exepcted = array( - array('action' => 'index', 'method' => 'GET', 'id' => false), - array('action' => 'view', 'method' => 'GET', 'id' => true), - array('action' => 'add', 'method' => 'POST', 'id' => false), - array('action' => 'edit', 'method' => 'PUT', 'id' => true), - array('action' => 'delete', 'method' => 'DELETE', 'id' => true), - array('action' => 'edit', 'method' => 'POST', 'id' => true) - ); - $this->assertEquals($default, $exepcted); - - $custom = array( - array('action' => 'index', 'method' => 'GET', 'id' => false), - array('action' => 'view', 'method' => 'GET', 'id' => true), - array('action' => 'add', 'method' => 'POST', 'id' => false), - array('action' => 'edit', 'method' => 'PUT', 'id' => true), - array('action' => 'delete', 'method' => 'DELETE', 'id' => true), - array('action' => 'update', 'method' => 'POST', 'id' => true) - ); - Router::resourceMap($custom); - $this->assertEquals(Router::resourceMap(), $custom); - - Router::resourceMap($default); - } - -/** - * test setting redirect routes - * - * @return void - */ - public function testRouteRedirection() { - Router::redirect('/blog', array('controller' => 'posts'), array('status' => 302)); - $this->assertEquals(1, count(Router::$routes)); - Router::$routes[0]->response = $this->getMock('CakeResponse', array('_sendHeader')); - Router::$routes[0]->stop = false; - $this->assertEquals(302, Router::$routes[0]->options['status']); - - Router::parse('/blog'); - $header = Router::$routes[0]->response->header(); - $this->assertEquals(Router::url('/posts', true), $header['Location']); - $this->assertEquals(302, Router::$routes[0]->response->statusCode()); - - Router::$routes[0]->response = $this->getMock('CakeResponse', array('_sendHeader')); - Router::parse('/not-a-match'); - $this->assertEquals(array(), Router::$routes[0]->response->header()); - } - -/** - * Test setting the default route class - * - * @return void - */ - public function testDefaultRouteClass() { - $this->getMock('CakeRoute', array(), array('/test'), 'TestDefaultRouteClass'); - Router::defaultRouteClass('TestDefaultRouteClass'); - - $result = Router::connect('/', array('controller' => 'pages', 'action' => 'display', 'home')); - $this->assertInstanceOf('TestDefaultRouteClass', $result[0]); - } - -/** - * Test getting the default route class - * - * @return void - */ - public function testDefaultRouteClassGetter() { - $routeClass = 'TestDefaultRouteClass'; - Router::defaultRouteClass($routeClass); - - $this->assertEqual($routeClass, Router::defaultRouteClass()); - $this->assertEqual($routeClass, Router::defaultRouteClass(null)); - } - -/** - * Test that route classes must extend CakeRoute - * - * @expectedException RouterException - * @return void - */ - public function testDefaultRouteException() { - Router::defaultRouteClass(''); - Router::connect('/:controller', array()); - } - -/** - * Test that route classes must extend CakeRoute - * - * @expectedException RouterException - * @return void - */ - public function testSettingInvalidDefaultRouteException() { - Router::defaultRouteClass('Object'); - } - -/** - * Test that class must exist - * - * @expectedException RouterException - * @return void - */ - public function testSettingNonExistentDefaultRouteException() { - Router::defaultRouteClass('NonExistentClass'); - } - -} diff --git a/lib/Cake/Test/Case/TestSuite/CakeTestCaseTest.php b/lib/Cake/Test/Case/TestSuite/CakeTestCaseTest.php deleted file mode 100644 index ccc43560eee..00000000000 --- a/lib/Cake/Test/Case/TestSuite/CakeTestCaseTest.php +++ /dev/null @@ -1,342 +0,0 @@ -Reporter = $this->getMock('CakeHtmlReporter'); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - unset($this->Result); - unset($this->Reporter); - } - -/** - * testAssertGoodTags - * - * @return void - */ - public function testAssertTagsQuotes() { - $test = new AssertTagsTestCase('testAssertTagsQuotes'); - $result = $test->run(); - $this->assertEquals(0, $result->errorCount()); - $this->assertTrue($result->wasSuccessful()); - $this->assertEquals(0, $result->failureCount()); - - $input = 'My link'; - $pattern = array( - 'a' => array('href' => '/test.html', 'class' => 'active'), - 'My link', - '/a' - ); - $this->assertTrue($test->assertTags($input, $pattern), 'Double quoted attributes %s'); - - $input = "My link"; - $pattern = array( - 'a' => array('href' => '/test.html', 'class' => 'active'), - 'My link', - '/a' - ); - $this->assertTrue($test->assertTags($input, $pattern), 'Single quoted attributes %s'); - - $input = "My link"; - $pattern = array( - 'a' => array('href' => 'preg:/.*\.html/', 'class' => 'active'), - 'My link', - '/a' - ); - $this->assertTrue($test->assertTags($input, $pattern), 'Single quoted attributes %s'); - - $input = "Text"; - $pattern = array( - 'assertTrue($test->assertTags($input, $pattern), 'Tags with no attributes'); - - $input = "Text"; - $pattern = array( - 'span' => array('class'), - 'assertTrue($test->assertTags($input, $pattern), 'Test attribute presence'); - } - -/** - * testNumericValuesInExpectationForAssertTags - * - * @return void - */ - public function testNumericValuesInExpectationForAssertTags() { - $test = new AssertTagsTestCase('testNumericValuesInExpectationForAssertTags'); - $result = $test->run(); - $this->assertEquals(0, $result->errorCount()); - $this->assertTrue($result->wasSuccessful()); - $this->assertEquals(0, $result->failureCount()); - } - -/** - * testBadAssertTags - * - * @return void - */ - public function testBadAssertTags() { - $test = new AssertTagsTestCase('testBadAssertTags'); - $result = $test->run(); - $this->assertEquals(0, $result->errorCount()); - $this->assertFalse($result->wasSuccessful()); - $this->assertEquals(1, $result->failureCount()); - - $test = new AssertTagsTestCase('testBadAssertTags2'); - $result = $test->run(); - $this->assertEquals(0, $result->errorCount()); - $this->assertFalse($result->wasSuccessful()); - $this->assertEquals(1, $result->failureCount()); - } - -/** - * testLoadFixtures - * - * @return void - */ - public function testLoadFixtures() { - $test = new FixturizedTestCase('testFixturePresent'); - $manager = $this->getMock('CakeFixtureManager'); - $manager->fixturize($test); - $test->fixtureManager = $manager; - $manager->expects($this->once())->method('load'); - $manager->expects($this->once())->method('unload'); - $result = $test->run(); - $this->assertEquals(0, $result->errorCount()); - $this->assertTrue($result->wasSuccessful()); - $this->assertEquals(0, $result->failureCount()); - } - -/** - * testLoadFixturesOnDemand - * - * @return void - */ - public function testLoadFixturesOnDemand() { - $test = new FixturizedTestCase('testFixtureLoadOnDemand'); - $test->autoFixtures = false; - $manager = $this->getMock('CakeFixtureManager'); - $manager->fixturize($test); - $test->fixtureManager = $manager; - $manager->expects($this->once())->method('loadSingle'); - $result = $test->run(); - $this->assertEquals(0, $result->errorCount()); - } - -/** - * testLoadFixturesOnDemand - * - * @return void - */ - public function testUnoadFixturesAfterFailure() { - $test = new FixturizedTestCase('testFixtureLoadOnDemand'); - $test->autoFixtures = false; - $manager = $this->getMock('CakeFixtureManager'); - $manager->fixturize($test); - $test->fixtureManager = $manager; - $manager->expects($this->once())->method('loadSingle'); - $result = $test->run(); - $this->assertEquals(0, $result->errorCount()); - } - -/** - * testThrowException - * - * @return void - */ - public function testThrowException() { - $test = new FixturizedTestCase('testThrowException'); - $test->autoFixtures = false; - $manager = $this->getMock('CakeFixtureManager'); - $manager->fixturize($test); - $test->fixtureManager = $manager; - $manager->expects($this->once())->method('unload'); - $result = $test->run(); - $this->assertEquals(1, $result->errorCount()); - } - -/** - * testSkipIf - * - * @return void - */ - public function testSkipIf() { - $test = new FixturizedTestCase('testSkipIfTrue'); - $result = $test->run(); - $this->assertEquals(1, $result->skippedCount()); - - $test = new FixturizedTestCase('testSkipIfFalse'); - $result = $test->run(); - $this->assertEquals(0, $result->skippedCount()); - } - -/** - * Test that CakeTestCase::setUp() backs up values. - * - * @return void - */ - public function testSetupBackUpValues() { - $this->assertArrayHasKey('debug', $this->_configure); - $this->assertArrayHasKey('Plugin', $this->_pathRestore); - } - -/** - * test assertTextNotEquals() - * - * @return void - */ - public function testAssertTextNotEquals() { - $one = "\r\nOne\rTwooo"; - $two = "\nOne\nTwo"; - $this->assertTextNotEquals($one, $two); - } - -/** - * test assertTextEquals() - * - * @return void - */ - public function testAssertTextEquals() { - $one = "\r\nOne\rTwo"; - $two = "\nOne\nTwo"; - $this->assertTextEquals($one, $two); - } - -/** - * test assertTextStartsWith() - * - * @return void - */ - public function testAssertTextStartsWith() { - $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; - $stringClean = "some\nstring\nwith\ndifferent\nline endings!"; - - $this->assertStringStartsWith("some\nstring", $stringDirty); - $this->assertStringStartsNotWith("some\r\nstring\r\nwith", $stringDirty); - $this->assertStringStartsNotWith("some\nstring\nwith", $stringDirty); - - $this->assertTextStartsWith("some\nstring\nwith", $stringDirty); - $this->assertTextStartsWith("some\r\nstring\r\nwith", $stringDirty); - } - -/** - * test assertTextStartsNotWith() - * - * @return void - */ - public function testAssertTextStartsNotWith() { - $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; - $stringClean = "some\nstring\nwith\ndifferent\nline endings!"; - - $this->assertTextStartsNotWith("some\nstring\nwithout", $stringDirty); - } - -/** - * test assertTextEndsWith() - * - * @return void - */ - public function testAssertTextEndsWith() { - $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; - $stringClean = "some\nstring\nwith\ndifferent\nline endings!"; - - $this->assertTextEndsWith("string\nwith\r\ndifferent\rline endings!", $stringDirty); - $this->assertTextEndsWith("string\r\nwith\ndifferent\nline endings!", $stringDirty); - } - -/** - * test assertTextEndsNotWith() - * - * @return void - */ - public function testAssertTextEndsNotWith() { - $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; - $stringClean = "some\nstring\nwith\ndifferent\nline endings!"; - - $this->assertStringEndsNotWith("different\nline endings", $stringDirty); - $this->assertTextEndsNotWith("different\rline endings", $stringDirty); - } - -/** - * test assertTextContains() - * - * @return void - */ - public function testAssertTextContains() { - $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; - $stringClean = "some\nstring\nwith\ndifferent\nline endings!"; - - $this->assertContains("different", $stringDirty); - $this->assertNotContains("different\rline", $stringDirty); - - $this->assertTextContains("different\rline", $stringDirty); - } - -/** - * test assertTextNotContains() - * - * @return void - */ - public function testAssertTextNotContains() { - $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; - $stringClean = "some\nstring\nwith\ndifferent\nline endings!"; - - $this->assertTextNotContains("different\rlines", $stringDirty); - } - -} diff --git a/lib/Cake/Test/Case/TestSuite/CakeTestFixtureTest.php b/lib/Cake/Test/Case/TestSuite/CakeTestFixtureTest.php deleted file mode 100644 index e7e9db503a2..00000000000 --- a/lib/Cake/Test/Case/TestSuite/CakeTestFixtureTest.php +++ /dev/null @@ -1,499 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.TestSuite - * @since CakePHP(tm) v 1.2.0.4667 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('DboSource', 'Model/Datasource'); -App::uses('Model', 'Model'); -App::uses('CakeTestFixture', 'TestSuite/Fixture'); - -/** - * CakeTestFixtureTestFixture class - * - * @package Cake.Test.Case.TestSuite - */ -class CakeTestFixtureTestFixture extends CakeTestFixture { - -/** - * Name property - * - * @var string - */ - public $name = 'FixtureTest'; - -/** - * Table property - * - * @var string - */ - public $table = 'fixture_tests'; - -/** - * Fields array - * - * @var array - */ - public $fields = array( - 'id' => array('type' => 'integer', 'key' => 'primary'), - 'name' => array('type' => 'string', 'length' => '255'), - 'created' => array('type' => 'datetime') - ); - -/** - * Records property - * - * @var array - */ - public $records = array( - array('name' => 'Gandalf', 'created' => '2009-04-28 19:20:00'), - array('name' => 'Captain Picard', 'created' => '2009-04-28 19:20:00'), - array('name' => 'Chewbacca', 'created' => '2009-04-28 19:20:00') - ); -} - -/** - * StringFieldsTestFixture class - * - * @package Cake.Test.Case.TestSuite - * @subpackage cake.cake.tests.cases.libs - */ -class StringsTestFixture extends CakeTestFixture { - -/** - * Name property - * - * @var string - */ - public $name = 'Strings'; - -/** - * Table property - * - * @var string - */ - public $table = 'strings'; - -/** - * Fields array - * - * @var array - */ - public $fields = array( - 'id' => array('type' => 'integer', 'key' => 'primary'), - 'name' => array('type' => 'string', 'length' => '255'), - 'email' => array('type' => 'string', 'length' => '255'), - 'age' => array('type' => 'integer', 'default' => 10) - ); - -/** - * Records property - * - * @var array - */ - public $records = array( - array('name' => 'Mark Doe', 'email' => 'mark.doe@email.com'), - array('name' => 'John Doe', 'email' => 'john.doe@email.com', 'age' => 20), - array('email' => 'jane.doe@email.com', 'name' => 'Jane Doe', 'age' => 30) - ); -} - - -/** - * CakeTestFixtureImportFixture class - * - * @package Cake.Test.Case.TestSuite - */ -class CakeTestFixtureImportFixture extends CakeTestFixture { - -/** - * Name property - * - * @var string - */ - public $name = 'ImportFixture'; - -/** - * Import property - * - * @var mixed - */ - public $import = array('table' => 'fixture_tests', 'connection' => 'fixture_test_suite'); -} - -/** - * CakeTestFixtureDefaultImportFixture class - * - * @package Cake.Test.Case.TestSuite - */ -class CakeTestFixtureDefaultImportFixture extends CakeTestFixture { - -/** - * Name property - * - * @var string - */ - public $name = 'ImportFixture'; -} - -/** - * FixtureImportTestModel class - * - * @package Cake.Test.Case.TestSuite - * @package Cake.Test.Case.TestSuite - */ -class FixtureImportTestModel extends Model { - - public $name = 'FixtureImport'; - - public $useTable = 'fixture_tests'; - - public $useDbConfig = 'test'; - -} - -class FixturePrefixTest extends Model { - - public $name = 'FixturePrefix'; - - public $useTable = '_tests'; - - public $tablePrefix = 'fixture'; - - public $useDbConfig = 'test'; -} - - -/** - * Test case for CakeTestFixture - * - * @package Cake.Test.Case.TestSuite - */ -class CakeTestFixtureTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - $methods = array_diff(get_class_methods('DboSource'), array('enabled')); - $methods[] = 'connect'; - - $this->criticDb = $this->getMock('DboSource', $methods); - $this->criticDb->fullDebug = true; - $this->db = ConnectionManager::getDataSource('test'); - $this->_backupConfig = $this->db->config; - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - unset($this->criticDb); - $this->db->config = $this->_backupConfig; - } - -/** - * testInit - * - * @return void - */ - public function testInit() { - $Fixture = new CakeTestFixtureTestFixture(); - unset($Fixture->table); - $Fixture->init(); - $this->assertEquals('fixture_tests', $Fixture->table); - $this->assertEquals('id', $Fixture->primaryKey); - - $Fixture = new CakeTestFixtureTestFixture(); - $Fixture->primaryKey = 'my_random_key'; - $Fixture->init(); - $this->assertEquals('my_random_key', $Fixture->primaryKey); - } - -/** - * test that init() correctly sets the fixture table when the connection - * or model have prefixes defined. - * - * @return void - */ - public function testInitDbPrefix() { - $this->skipIf($this->db instanceof Sqlite, 'Cannot open 2 connections to Sqlite'); - $db = ConnectionManager::getDataSource('test'); - $Source = new CakeTestFixtureTestFixture(); - $Source->drop($db); - $Source->create($db); - $Source->insert($db); - - $Fixture = new CakeTestFixtureTestFixture(); - $expected = array('id', 'name', 'created'); - $this->assertEquals($expected, array_keys($Fixture->fields)); - - $config = $db->config; - $config['prefix'] = 'fixture_test_suite_'; - ConnectionManager::create('fixture_test_suite', $config); - - $Fixture->fields = $Fixture->records = null; - $Fixture->import = array('table' => 'fixture_tests', 'connection' => 'test', 'records' => true); - $Fixture->init(); - $this->assertEquals(count($Fixture->records), count($Source->records)); - $Fixture->create(ConnectionManager::getDataSource('fixture_test_suite')); - - $Fixture = new CakeTestFixtureImportFixture(); - $Fixture->fields = $Fixture->records = $Fixture->table = null; - $Fixture->import = array('model' => 'FixtureImportTestModel', 'connection' => 'test'); - $Fixture->init(); - $this->assertEquals(array('id', 'name', 'created'), array_keys($Fixture->fields)); - $this->assertEquals('fixture_tests', $Fixture->table); - - $keys = array_flip(ClassRegistry::keys()); - $this->assertFalse(array_key_exists('fixtureimporttestmodel', $keys)); - - $Fixture->drop(ConnectionManager::getDataSource('fixture_test_suite')); - $Source->drop($db); - } - -/** - * test that fixtures don't duplicate the test db prefix. - * - * @return void - */ - public function testInitDbPrefixDuplication() { - $this->skipIf($this->db instanceof Sqlite, 'Cannot open 2 connections to Sqlite'); - $db = ConnectionManager::getDataSource('test'); - $backPrefix = $db->config['prefix']; - $db->config['prefix'] = 'cake_fixture_test_'; - ConnectionManager::create('fixture_test_suite', $db->config); - $newDb = ConnectionManager::getDataSource('fixture_test_suite'); - $newDb->config['prefix'] = 'cake_fixture_test_'; - - $Source = new CakeTestFixtureTestFixture(); - $Source->create($db); - $Source->insert($db); - - $Fixture = new CakeTestFixtureImportFixture(); - $Fixture->fields = $Fixture->records = $Fixture->table = null; - $Fixture->import = array('model' => 'FixtureImportTestModel', 'connection' => 'test'); - - $Fixture->init(); - $this->assertEquals(array('id', 'name', 'created'), array_keys($Fixture->fields)); - $this->assertEquals('fixture_tests', $Fixture->table); - - $Source->drop($db); - $db->config['prefix'] = $backPrefix; - } - -/** - * test init with a model that has a tablePrefix declared. - * - * @return void - */ - public function testInitModelTablePrefix() { - $this->skipIf($this->db instanceof Sqlite, 'Cannot open 2 connections to Sqlite'); - $this->skipIf(!empty($this->db->config['prefix']), 'Cannot run this test, you have a database connection prefix.'); - - $Source = new CakeTestFixtureTestFixture(); - $Source->create($this->db); - $Source->insert($this->db); - - $Fixture = new CakeTestFixtureTestFixture(); - unset($Fixture->table); - $Fixture->fields = $Fixture->records = null; - $Fixture->import = array('model' => 'FixturePrefixTest', 'connection' => 'test', 'records' => false); - $Fixture->init(); - $this->assertEquals('fixture_tests', $Fixture->table); - - $keys = array_flip(ClassRegistry::keys()); - $this->assertFalse(array_key_exists('fixtureimporttestmodel', $keys)); - - $Source->drop($this->db); - } - -/** - * testImport - * - * @return void - */ - public function testImport() { - $testSuiteDb = ConnectionManager::getDataSource('test'); - $testSuiteConfig = $testSuiteDb->config; - ConnectionManager::create('new_test_suite', array_merge($testSuiteConfig, array('prefix' => 'new_' . $testSuiteConfig['prefix']))); - $newTestSuiteDb = ConnectionManager::getDataSource('new_test_suite'); - - $Source = new CakeTestFixtureTestFixture(); - $Source->create($newTestSuiteDb); - $Source->insert($newTestSuiteDb); - - $Fixture = new CakeTestFixtureDefaultImportFixture(); - $Fixture->fields = $Fixture->records = null; - $Fixture->import = array('model' => 'FixtureImportTestModel', 'connection' => 'new_test_suite'); - $Fixture->init(); - $this->assertEquals(array('id', 'name', 'created'), array_keys($Fixture->fields)); - - $keys = array_flip(ClassRegistry::keys()); - $this->assertFalse(array_key_exists('fixtureimporttestmodel', $keys)); - - $Source->drop($newTestSuiteDb); - } - -/** - * test that importing with records works. Make sure to try with postgres as its - * handling of aliases is a workaround at best. - * - * @return void - */ - public function testImportWithRecords() { - $testSuiteDb = ConnectionManager::getDataSource('test'); - $testSuiteConfig = $testSuiteDb->config; - ConnectionManager::create('new_test_suite', array_merge($testSuiteConfig, array('prefix' => 'new_' . $testSuiteConfig['prefix']))); - $newTestSuiteDb = ConnectionManager::getDataSource('new_test_suite'); - - $Source = new CakeTestFixtureTestFixture(); - $Source->create($newTestSuiteDb); - $Source->insert($newTestSuiteDb); - - $Fixture = new CakeTestFixtureDefaultImportFixture(); - $Fixture->fields = $Fixture->records = null; - $Fixture->import = array( - 'model' => 'FixtureImportTestModel', 'connection' => 'new_test_suite', 'records' => true - ); - $Fixture->init(); - $this->assertEquals(array('id', 'name', 'created'), array_keys($Fixture->fields)); - $this->assertFalse(empty($Fixture->records[0]), 'No records loaded on importing fixture.'); - $this->assertTrue(isset($Fixture->records[0]['name']), 'No name loaded for first record'); - - $Source->drop($newTestSuiteDb); - } - -/** - * test create method - * - * @return void - */ - public function testCreate() { - $Fixture = new CakeTestFixtureTestFixture(); - $this->criticDb->expects($this->atLeastOnce())->method('execute'); - $this->criticDb->expects($this->atLeastOnce())->method('createSchema'); - $return = $Fixture->create($this->criticDb); - $this->assertTrue($this->criticDb->fullDebug); - $this->assertTrue($return); - - unset($Fixture->fields); - $return = $Fixture->create($this->criticDb); - $this->assertFalse($return); - } - -/** - * test the insert method - * - * @return void - */ - public function testInsert() { - $Fixture = new CakeTestFixtureTestFixture(); - $this->criticDb->expects($this->atLeastOnce()) - ->method('insertMulti') - ->will($this->returnCallback(array($this, 'insertCallback'))); - - $return = $Fixture->insert($this->criticDb); - $this->assertTrue(!empty($this->insertMulti)); - $this->assertTrue($this->criticDb->fullDebug); - $this->assertTrue($return); - $this->assertEquals('fixture_tests', $this->insertMulti['table']); - $this->assertEquals(array('name', 'created'), $this->insertMulti['fields']); - $expected = array( - array('Gandalf', '2009-04-28 19:20:00'), - array('Captain Picard', '2009-04-28 19:20:00'), - array('Chewbacca', '2009-04-28 19:20:00') - ); - $this->assertEquals($expected, $this->insertMulti['values']); - } - -/** - * Helper function to be used as callback and store the parameters of an insertMulti call - * - * @param string $table - * @param string $fields - * @param string $values - * @return boolean true - */ - public function insertCallback($table, $fields, $values) { - $this->insertMulti['table'] = $table; - $this->insertMulti['fields'] = $fields; - $this->insertMulti['values'] = $values; - return true; - } - -/** - * test the insert method - * - * @return void - */ - public function testInsertStrings() { - $Fixture = new StringsTestFixture(); - $this->criticDb->expects($this->atLeastOnce()) - ->method('insertMulti') - ->will($this->returnCallback(array($this, 'insertCallback'))); - - $return = $Fixture->insert($this->criticDb); - $this->assertTrue($this->criticDb->fullDebug); - $this->assertTrue($return); - $this->assertEquals('strings', $this->insertMulti['table']); - $this->assertEquals(array('email', 'name', 'age'), $this->insertMulti['fields']); - $expected = array( - array('Mark Doe', 'mark.doe@email.com', null), - array('John Doe', 'john.doe@email.com', 20), - array('Jane Doe', 'jane.doe@email.com', 30), - ); - $this->assertEquals($expected, $this->insertMulti['values']); - } - -/** - * Test the drop method - * - * @return void - */ - public function testDrop() { - $Fixture = new CakeTestFixtureTestFixture(); - $this->criticDb->expects($this->at(1))->method('execute')->will($this->returnValue(true)); - $this->criticDb->expects($this->at(3))->method('execute')->will($this->returnValue(false)); - $this->criticDb->expects($this->exactly(2))->method('dropSchema'); - - $return = $Fixture->drop($this->criticDb); - $this->assertTrue($this->criticDb->fullDebug); - $this->assertTrue($return); - - $return = $Fixture->drop($this->criticDb); - $this->assertTrue($return); - - unset($Fixture->fields); - $return = $Fixture->drop($this->criticDb); - $this->assertFalse($return); - } - -/** - * Test the truncate method. - * - * @return void - */ - public function testTruncate() { - $Fixture = new CakeTestFixtureTestFixture(); - $this->criticDb->expects($this->atLeastOnce())->method('truncate'); - $Fixture->truncate($this->criticDb); - $this->assertTrue($this->criticDb->fullDebug); - } -} diff --git a/lib/Cake/Test/Case/TestSuite/CakeTestSuiteTest.php b/lib/Cake/Test/Case/TestSuite/CakeTestSuiteTest.php deleted file mode 100644 index c98c4c6c308..00000000000 --- a/lib/Cake/Test/Case/TestSuite/CakeTestSuiteTest.php +++ /dev/null @@ -1,81 +0,0 @@ -getMock('CakeTestSuite', array('addTestFile')); - $suite - ->expects($this->exactly($count)) - ->method('addTestFile'); - - $suite->addTestDirectory($testFolder); - } - -/** - * testAddTestDirectoryRecursive - * - * @return void - */ - public function testAddTestDirectoryRecursive() { - $testFolder = CORE_TEST_CASES . DS . 'Cache'; - $count = count(glob($testFolder . DS . '*Test.php')); - $count += count(glob($testFolder . DS . 'Engine' . DS . '*Test.php')); - - $suite = $this->getMock('CakeTestSuite', array('addTestFile')); - $suite - ->expects($this->exactly($count)) - ->method('addTestFile'); - - $suite->addTestDirectoryRecursive($testFolder); - } - -/** - * testAddTestDirectoryRecursiveWithHidden - * - * @return void - */ - public function testAddTestDirectoryRecursiveWithHidden() { - $this->skipIf(!is_writeable(TMP), 'Cant addTestDirectoryRecursiveWithHidden unless the tmp folder is writable.'); - - $Folder = new Folder(TMP . 'MyTestFolder', true, 0777); - mkdir($Folder->path . DS . '.svn', 0777, true); - touch($Folder->path . DS . '.svn' . DS . 'InHiddenFolderTest.php'); - touch($Folder->path . DS . 'NotHiddenTest.php'); - touch($Folder->path . DS . '.HiddenTest.php'); - - $suite = $this->getMock('CakeTestSuite', array('addTestFile')); - $suite - ->expects($this->exactly(1)) - ->method('addTestFile'); - - $suite->addTestDirectoryRecursive($Folder->pwd()); - - $Folder->delete(); - } -} diff --git a/lib/Cake/Test/Case/TestSuite/ControllerTestCaseTest.php b/lib/Cake/Test/Case/TestSuite/ControllerTestCaseTest.php deleted file mode 100644 index f76b67d0193..00000000000 --- a/lib/Cake/Test/Case/TestSuite/ControllerTestCaseTest.php +++ /dev/null @@ -1,545 +0,0 @@ - array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS), - 'Controller' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Controller' . DS), - 'Model' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Model' . DS), - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin', 'TestPluginTwo')); - $this->Case = $this->getMockForAbstractClass('ControllerTestCase'); - Router::reload(); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - CakePlugin::unload(); - $this->Case->controller = null; - } - -/** - * Test that ControllerTestCase::generate() creates mock objects correctly - */ - public function testGenerate() { - if (defined('APP_CONTROLLER_EXISTS')) { - $this->markTestSkipped('AppController exists, cannot run.'); - } - $Posts = $this->Case->generate('Posts'); - $this->assertEquals('Posts', $Posts->name); - $this->assertEquals('Post', $Posts->modelClass); - $this->assertNull($Posts->response->send()); - - $Posts = $this->Case->generate('Posts', array( - 'methods' => array( - 'render' - ) - )); - $this->assertNull($Posts->render('index')); - - $Posts = $this->Case->generate('Posts', array( - 'models' => array('Post'), - 'components' => array('RequestHandler') - )); - - $this->assertInstanceOf('Post', $Posts->Post); - $this->assertNull($Posts->Post->save(array())); - $this->assertNull($Posts->Post->find('all')); - $this->assertEquals('posts', $Posts->Post->useTable); - $this->assertNull($Posts->RequestHandler->isAjax()); - - $Posts = $this->Case->generate('Posts', array( - 'models' => array( - 'Post' => true - ) - )); - $this->assertNull($Posts->Post->save(array())); - $this->assertNull($Posts->Post->find('all')); - - $Posts = $this->Case->generate('Posts', array( - 'models' => array( - 'Post' => array('save'), - ) - )); - $this->assertNull($Posts->Post->save(array())); - $this->assertInternalType('array', $Posts->Post->find('all')); - - $Posts = $this->Case->generate('Posts', array( - 'models' => array('Post'), - 'components' => array( - 'RequestHandler' => array('isPut'), - 'Email' => array('send'), - 'Session' - ) - )); - $Posts->RequestHandler->expects($this->once()) - ->method('isPut') - ->will($this->returnValue(true)); - $this->assertTrue($Posts->RequestHandler->isPut()); - - $Posts->Auth->Session->expects($this->any()) - ->method('write') - ->will($this->returnValue('written!')); - $this->assertEquals('written!', $Posts->Auth->Session->write('something')); - } - -/** - * Tests ControllerTestCase::generate() using classes from plugins - */ - public function testGenerateWithPlugin() { - $Tests = $this->Case->generate('TestPlugin.Tests', array( - 'models' => array( - 'TestPlugin.TestPluginComment' - ), - 'components' => array( - 'TestPlugin.Plugins' - ) - )); - $this->assertEquals('Tests', $Tests->name); - $this->assertInstanceOf('PluginsComponent', $Tests->Plugins); - - $result = ClassRegistry::init('TestPlugin.TestPluginComment'); - $this->assertInstanceOf('TestPluginComment', $result); - - $Tests = $this->Case->generate('ControllerTestCaseTest', array( - 'models' => array( - 'TestPlugin.TestPluginComment' => array('save') - ) - )); - $this->assertInstanceOf('TestPluginComment', $Tests->TestPluginComment); - $Tests->TestPluginComment->expects($this->at(0)) - ->method('save') - ->will($this->returnValue(true)); - $Tests->TestPluginComment->expects($this->at(1)) - ->method('save') - ->will($this->returnValue(false)); - $this->assertTrue($Tests->TestPluginComment->save(array())); - $this->assertFalse($Tests->TestPluginComment->save(array())); - } - -/** - * Tests testAction - */ - public function testTestAction() { - $Controller = $this->Case->generate('TestsApps'); - $this->Case->testAction('/tests_apps/index'); - $this->assertInternalType('array', $this->Case->controller->viewVars); - - $this->Case->testAction('/tests_apps/set_action'); - $results = $this->Case->controller->viewVars; - $expected = array( - 'var' => 'string' - ); - $this->assertEquals($expected, $results); - - $result = $this->Case->controller->response->body(); - $this->assertRegExp('/This is the TestsAppsController index view/', $result); - - $Controller = $this->Case->generate('TestsApps'); - $this->Case->testAction('/tests_apps/redirect_to'); - $results = $this->Case->headers; - $expected = array( - 'Location' => 'http://cakephp.org' - ); - $this->assertEquals($expected, $results); - } - -/** - * Make sure testAction() can hit plugin controllers. - * - * @return void - */ - public function testTestActionWithPlugin() { - $Controller = $this->Case->generate('TestPlugin.Tests'); - $this->Case->testAction('/test_plugin/tests/index'); - $this->assertEquals('It is a variable', $this->Case->controller->viewVars['test_value']); - } - -/** - * Tests using loaded routes during tests - * - * @return void - */ - public function testUseRoutes() { - Router::connect('/:controller/:action/*'); - include CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS . 'routes.php'; - - $controller = $this->Case->generate('TestsApps'); - $controller->Components->load('RequestHandler'); - $result = $this->Case->testAction('/tests_apps/index.json', array('return' => 'contents')); - $result = json_decode($result, true); - $expected = array('cakephp' => 'cool'); - $this->assertEquals($expected, $result); - - include CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS . 'routes.php'; - $result = $this->Case->testAction('/some_alias'); - $this->assertEquals(5, $result); - } - -/** - * Tests not using loaded routes during tests - * - * @expectedException MissingActionException - */ - public function testSkipRoutes() { - Router::connect('/:controller/:action/*'); - include CAKE . 'Test' . DS . 'test_app' . DS . 'Config' . DS . 'routes.php'; - - $this->Case->loadRoutes = false; - $result = $this->Case->testAction('/tests_apps/missing_action.json', array('return' => 'view')); - } - -/** - * Tests backwards compatibility with setting the return type - */ - public function testBCSetReturn() { - $this->Case->autoMock = true; - - $result = $this->Case->testAction('/tests_apps/some_method'); - $this->assertEquals(5, $result); - - $data = array('var' => 'set'); - $result = $this->Case->testAction('/tests_apps_posts/post_var', array( - 'data' => $data, - 'return' => 'vars' - )); - $this->assertEquals($data, $result['data']); - - $result = $this->Case->testAction('/tests_apps/set_action', array( - 'return' => 'view' - )); - $this->assertEquals('This is the TestsAppsController index view string', $result); - - $result = $this->Case->testAction('/tests_apps/set_action', array( - 'return' => 'contents' - )); - $this->assertRegExp('/assertRegExp('/This is the TestsAppsController index view/', $result); - $this->assertRegExp('/<\/html>/', $result); - } - -/** - * Tests sending POST data to testAction - */ - public function testTestActionPostData() { - $this->Case->autoMock = true; - - $data = array( - 'Post' => array( - 'name' => 'Some Post' - ) - ); - $this->Case->testAction('/tests_apps_posts/post_var', array( - 'data' => $data - )); - $this->assertEquals($this->Case->controller->viewVars['data'], $data); - $this->assertEquals($this->Case->controller->data, $data); - - $this->Case->testAction('/tests_apps_posts/post_var/named:param', array( - 'data' => $data - )); - $expected = array( - 'named' => 'param' - ); - $this->assertEquals($expected, $this->Case->controller->request->named); - $this->assertEquals($this->Case->controller->data, $data); - - $result = $this->Case->testAction('/tests_apps_posts/post_var', array( - 'return' => 'vars', - 'method' => 'post', - 'data' => array( - 'name' => 'is jonas', - 'pork' => 'and beans', - ) - )); - $this->assertEquals(array('name', 'pork'), array_keys($result['data'])); - - $result = $this->Case->testAction('/tests_apps_posts/add', array('return' => 'vars')); - $this->assertTrue(array_key_exists('posts', $result)); - $this->assertEquals(4, count($result['posts'])); - $this->assertTrue($this->Case->controller->request->is('post')); - } - -/** - * Tests sending GET data to testAction - */ - public function testTestActionGetData() { - $this->Case->autoMock = true; - - $result = $this->Case->testAction('/tests_apps_posts/url_var', array( - 'method' => 'get', - 'data' => array( - 'some' => 'var', - 'lackof' => 'creativity' - ) - )); - $this->assertEquals('var', $this->Case->controller->request->query['some']); - $this->assertEquals('creativity', $this->Case->controller->request->query['lackof']); - - $result = $this->Case->testAction('/tests_apps_posts/url_var/var1:value1/var2:val2', array( - 'return' => 'vars', - 'method' => 'get', - )); - $this->assertEquals(array('var1', 'var2'), array_keys($result['params']['named'])); - - $result = $this->Case->testAction('/tests_apps_posts/url_var/gogo/val2', array( - 'return' => 'vars', - 'method' => 'get', - )); - $this->assertEquals(array('gogo', 'val2'), $result['params']['pass']); - - $result = $this->Case->testAction('/tests_apps_posts/url_var', array( - 'return' => 'vars', - 'method' => 'get', - 'data' => array( - 'red' => 'health', - 'blue' => 'mana' - ) - )); - $query = $this->Case->controller->request->query; - $this->assertTrue(isset($query['red'])); - $this->assertTrue(isset($query['blue'])); - } - -/** - * Test that REST actions with XML/JSON input work. - * - * @return void - */ - public function testTestActionJsonData() { - $result = $this->Case->testAction('/tests_apps_posts/input_data', array( - 'return' => 'vars', - 'method' => 'post', - 'data' => '{"key":"value","json":true}' - )); - $this->assertEquals('value', $result['data']['key']); - $this->assertTrue($result['data']['json']); - } - -/** - * Tests autoMock ability - */ - public function testAutoMock() { - $this->Case->autoMock = true; - $this->Case->testAction('/tests_apps/set_action'); - $results = $this->Case->controller->viewVars; - $expected = array( - 'var' => 'string' - ); - $this->assertEquals($expected, $results); - } - -/** - * Test using testAction and not mocking - */ - public function testNoMocking() { - $result = $this->Case->testAction('/tests_apps/some_method'); - $this->Case->assertEquals(5, $result); - - $data = array('var' => 'set'); - $result = $this->Case->testAction('/tests_apps_posts/post_var', array( - 'data' => $data, - 'return' => 'vars' - )); - $this->assertEquals($data, $result['data']); - - $result = $this->Case->testAction('/tests_apps/set_action', array( - 'return' => 'view' - )); - $this->assertEquals('This is the TestsAppsController index view string', $result); - - $result = $this->Case->testAction('/tests_apps/set_action', array( - 'return' => 'contents' - )); - $this->assertRegExp('/assertRegExp('/This is the TestsAppsController index view/', $result); - $this->assertRegExp('/<\/html>/', $result); - } - -/** - * Test that controllers don't get reused. - * - * @return void - */ - public function testNoControllerReuse() { - $this->Case->autoMock = true; - $result = $this->Case->testAction('/tests_apps/index', array( - 'data' => array('var' => 'first call'), - 'method' => 'get', - 'return' => 'contents', - )); - $this->assertContains('assertContains('This is the TestsAppsController index view', $result); - $this->assertContains('first call', $result); - $this->assertContains('', $result); - - $result = $this->Case->testAction('/tests_apps/index', array( - 'data' => array('var' => 'second call'), - 'method' => 'get', - 'return' => 'contents' - )); - $this->assertContains('second call', $result); - - $result = $this->Case->testAction('/tests_apps/index', array( - 'data' => array('var' => 'third call'), - 'method' => 'get', - 'return' => 'contents' - )); - $this->assertContains('third call', $result); - } - -/** - * Test that multiple calls to redirect in the same test method don't cause issues. - * - * @return void - */ - public function testTestActionWithMultipleRedirect() { - $Controller = $this->Case->generate('TestsApps'); - - $options = array('method' => 'get'); - $this->Case->testAction('/tests_apps/redirect_to', $options); - $this->Case->testAction('/tests_apps/redirect_to', $options); - } - -/** - * Tests that Components storing response or request objects internally during construct - * will always have a fresh reference to those object available - * - * @return void - * @see http://cakephp.lighthouseapp.com/projects/42648-cakephp/tickets/2705-requesthandler-weird-behavior - */ - public function testComponentsSameRequestAndResponse() { - $this->Case->generate('TestsApps'); - $options = array('method' => 'get'); - $this->Case->testAction('/tests_apps/index', $options); - $this->assertSame($this->Case->controller->response, $this->Case->controller->RequestHandler->response); - $this->assertSame($this->Case->controller->request, $this->Case->controller->RequestHandler->request); - } - -} diff --git a/lib/Cake/Test/Case/TestSuite/HtmlCoverageReportTest.php b/lib/Cake/Test/Case/TestSuite/HtmlCoverageReportTest.php deleted file mode 100644 index 8a5b7a30872..00000000000 --- a/lib/Cake/Test/Case/TestSuite/HtmlCoverageReportTest.php +++ /dev/null @@ -1,231 +0,0 @@ - array(CAKE . 'Test' . DS . 'test_app' . DS . 'Plugin' . DS) - ), App::RESET); - CakePlugin::load(array('TestPlugin')); - $reporter = new CakeBaseReporter(); - $reporter->params = array('app' => false, 'plugin' => false, 'group' => false); - $coverage = array(); - $this->Coverage = new HtmlCoverageReport($coverage, $reporter); - } - -/** - * test getting the path filters. - * - * @return void - */ - public function testGetPathFilter() { - $this->Coverage->appTest = false; - $result = $this->Coverage->getPathFilter(); - $this->assertEquals(CAKE, $result); - - $this->Coverage->appTest = true; - $result = $this->Coverage->getPathFilter(); - $this->assertEquals(ROOT . DS . APP_DIR . DS, $result); - - $this->Coverage->appTest = false; - $this->Coverage->pluginTest = 'TestPlugin'; - $result = $this->Coverage->getPathFilter(); - $this->assertEquals(CakePlugin::path('TestPlugin'), $result); - } - -/** - * test filtering coverage data. - * - * @return void - */ - public function testFilterCoverageDataByPathRemovingElements() { - $data = array( - CAKE . 'dispatcher.php' => array( - 10 => -1, - 12 => 1 - ), - APP . 'app_model.php' => array( - 50 => 1, - 52 => -1 - ) - ); - $this->Coverage->setCoverage($data); - $result = $this->Coverage->filterCoverageDataByPath(CAKE); - $this->assertTrue(isset($result[CAKE . 'dispatcher.php'])); - $this->assertFalse(isset($result[APP . 'app_model.php'])); - } - -/** - * test generating HTML reports from file arrays. - * - * @return void - */ - public function testGenerateDiff() { - $file = array( - 'line 1', - 'line 2', - 'line 3', - 'line 4', - 'line 5', - 'line 6', - 'line 7', - 'line 8', - 'line 9', - 'line 10', - ); - $coverage = array( - 1 => array(array('id' => 'HtmlCoverageReportTest::testGenerateDiff')), - 2 => -2, - 3 => array(array('id' => 'HtmlCoverageReportTest::testGenerateDiff')), - 4 => array(array('id' => 'HtmlCoverageReportTest::testGenerateDiff')), - 5 => -1, - 6 => array(array('id' => 'HtmlCoverageReportTest::testGenerateDiff')), - 7 => array(array('id' => 'HtmlCoverageReportTest::testGenerateDiff')), - 8 => array(array('id' => 'HtmlCoverageReportTest::testGenerateDiff')), - 9 => -1, - 10 => array(array('id' => 'HtmlCoverageReportTest::testGenerateDiff')) - ); - $result = $this->Coverage->generateDiff('myfile.php', $file, $coverage); - $this->assertRegExp('/myfile\.php Code coverage\: \d+\.?\d*\%/', $result); - $this->assertRegExp('/
assertRegExp('/
/', $result);
-		foreach ($file as $i => $line) {
-			$this->assertTrue(strpos($line, $result) !== 0, 'Content is missing ' . $i);
-			$class = 'covered';
-			if (in_array($i + 1, array(5, 9, 2))) {
-				$class = 'uncovered';
-			}
-			if ($i + 1 == 2) {
-				$class .= ' dead';
-			}
-			$this->assertTrue(strpos($class, $result) !== 0, 'Class name is wrong ' . $i);
-		}
-	}
-
-/**
- * Test that coverage works with phpunit 3.6 as the data formats from coverage are totally different.
- *
- * @return void
- */
-	public function testPhpunit36Compatibility() {
-		$file = array(
-			'line 1',
-			'line 2',
-			'line 3',
-			'line 4',
-			'line 5',
-			'line 6',
-			'line 7',
-			'line 8',
-			'line 9',
-			'line 10',
-		);
-		$coverage = array(
-			1 => array('HtmlCoverageReportTest::testGenerateDiff'),
-			2 => null,
-			3 => array('HtmlCoverageReportTest::testGenerateDiff'),
-			4 => array('HtmlCoverageReportTest::testGenerateDiff'),
-			5 => array(),
-			6 => array('HtmlCoverageReportTest::testGenerateDiff'),
-			7 => array('HtmlCoverageReportTest::testGenerateDiff'),
-			8 => array('HtmlCoverageReportTest::testGenerateDiff'),
-			9 => array(),
-			10 => array('HtmlCoverageReportTest::testSomething', 'HtmlCoverageReportTest::testGenerateDiff')
-		);
-
-		$result = $this->Coverage->generateDiff('myfile.php', $file, $coverage);
-		$this->assertRegExp('/myfile\.php Code coverage\: \d+\.?\d*\%/', $result);
-		$this->assertRegExp('/
assertRegExp('/
/', $result);
-		foreach ($file as $i => $line) {
-			$this->assertTrue(strpos($line, $result) !== 0, 'Content is missing ' . $i);
-			$class = 'covered';
-			if (in_array($i + 1, array(5, 9, 2))) {
-				$class = 'uncovered';
-			}
-			if ($i + 1 == 2) {
-				$class .= ' dead';
-			}
-			$this->assertTrue(strpos($class, $result) !== 0, 'Class name is wrong ' . $i);
-		}
-	}
-
-/**
- * test that covering methods show up as title attributes for lines.
- *
- * @return void
- */
-	public function testCoveredLinesTitleAttributes() {
-		$file = array(
-			'line 1',
-			'line 2',
-			'line 3',
-			'line 4',
-			'line 5',
-		);
-
-		$coverage = array(
-			1 => array(array('id' => 'HtmlCoverageReportTest::testAwesomeness')),
-			2 => -2,
-			3 => array(array('id' => 'HtmlCoverageReportTest::testCakeIsSuperior')),
-			4 => array(array('id' => 'HtmlCoverageReportTest::testOther')),
-			5 => -1
-		);
-
-		$result = $this->Coverage->generateDiff('myfile.php', $file, $coverage);
-
-		$this->assertTrue(
-			strpos($result, "title=\"Covered by:\nHtmlCoverageReportTest::testAwesomeness\n\">1") !== false,
-			'Missing method coverage for line 1'
-		);
-		$this->assertTrue(
-			strpos($result, "title=\"Covered by:\nHtmlCoverageReportTest::testCakeIsSuperior\n\">3") !== false,
-			'Missing method coverage for line 3'
-		);
-		$this->assertTrue(
-			strpos($result, "title=\"Covered by:\nHtmlCoverageReportTest::testOther\n\">4") !== false,
-			'Missing method coverage for line 4'
-		);
-		$this->assertTrue(
-			strpos($result, "title=\"\">5") !== false,
-			'Coverage report is wrong for line 5'
-		);
-	}
-
-/**
- * tearDown
- *
- * @return void
- */
-	public function tearDown() {
-		CakePlugin::unload();
-		unset($this->Coverage);
-		parent::tearDown();
-	}
-}
diff --git a/lib/Cake/Test/Case/Utility/CakeNumberTest.php b/lib/Cake/Test/Case/Utility/CakeNumberTest.php
deleted file mode 100644
index dc80e66dab8..00000000000
--- a/lib/Cake/Test/Case/Utility/CakeNumberTest.php
+++ /dev/null
@@ -1,462 +0,0 @@
-
- * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice
- *
- * @copyright     Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests
- * @package       Cake.Test.Case.View.Helper
- * @since         CakePHP(tm) v 1.2.0.4206
- * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
- */
-
-App::uses('View', 'View');
-App::uses('CakeNumber', 'Utility');
-
-/**
- * CakeNumberTest class
- *
- * @package       Cake.Test.Case.Utility
- */
-class CakeNumberTest extends CakeTestCase {
-
-/**
- * setUp method
- *
- * @return void
- */
-	public function setUp() {
-		parent::setUp();
-		$this->Number = new CakeNumber();
-	}
-
-/**
- * tearDown method
- *
- * @return void
- */
-	public function tearDown() {
-		parent::tearDown();
-		unset($this->Number);
-	}
-
-/**
- * testFormatAndCurrency method
- *
- * @return void
- */
-	public function testFormat() {
-		$value = '100100100';
-
-		$result = $this->Number->format($value, '#');
-		$expected = '#100,100,100';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->format($value, 3);
-		$expected = '100,100,100.000';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->format($value);
-		$expected = '100,100,100';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->format($value, '-');
-		$expected = '100-100-100';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * Test currency method.
- *
- * @return void
- */
-	public function testCurrency() {
-		$value = '100100100';
-
-		$result = $this->Number->currency($value);
-		$expected = '$100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, '#');
-		$expected = '#100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, false);
-		$expected = '100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'USD');
-		$expected = '$100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR');
-		$expected = '€100.100.100,00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP');
-		$expected = '£100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, '', array('thousands' => ' ', 'wholeSymbol' => '€', 'wholePosition' => 'after', 'decimals' => ',', 'zero' => 'Gratuit'));
-		$expected = '100 100 100,00€';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(1000.45, null, array('after' => 'øre', 'before' => 'Kr. ', 'decimals' => ',', 'thousands' => '.'));
-		$expected = 'Kr. 1.000,45';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(0.5, 'USD');
-		$expected = '50c';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(0.5, null, array('after' => 'øre'));
-		$expected = '50øre';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(1, null, array('wholeSymbol' => '$ '));
-		$expected = '$ 1.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(1, null, array('wholeSymbol' => ' $', 'wholePosition' => 'after'));
-		$expected = '1.00 $';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(0.2, null, array('wholeSymbol' => ' $', 'wholePosition' => 'after', 'fractionSymbol' => 'cents'));
-		$expected = '20cents';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(0.2, null, array('wholeSymbol' => ' $', 'wholePosition' => 'after', 'fractionSymbol' => 'cents', 'fractionPosition' => 'before'));
-		$expected = 'cents20';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(311, 'USD', array('wholePosition' => 'after'));
-		$expected = '311.00$';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(0.2, 'EUR');
-		$expected = '€0,20';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(12, null, array('wholeSymbol' => ' dollars', 'wholePosition' => 'after', 'fractionSymbol' => ' cents', 'fractionPosition' => 'after'));
-		$expected = '12.00 dollars';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(0.12, null, array('wholeSymbol' => ' dollars', 'wholePosition' => 'after', 'fractionSymbol' => ' cents', 'fractionPosition' => 'after'));
-		$expected = '12 cents';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(0.5, null, array('fractionSymbol' => false, 'fractionPosition' => 'before', 'wholeSymbol' => '$'));
-		$expected = '$0.50';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * Test adding currency format options to the number helper
- *
- * @return void
- */
-	public function testCurrencyAddFormat() {
-		$this->Number->addFormat('NOK', array('before' => 'Kr. '));
-		$result = $this->Number->currency(1000, 'NOK');
-		$expected = 'Kr. 1,000.00';
-		$this->assertEquals($expected, $result);
-
-		$this->Number->addFormat('Other', array('before' => '$$ ', 'after' => 'c!'));
-		$result = $this->Number->currency(0.22, 'Other');
-		$expected = '22c!';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency(-10, 'Other');
-		$expected = '($$ 10.00)';
-		$this->assertEquals($expected, $result);
-
-		$this->Number->addFormat('Other2', array('before' => '$ ', 'after' => false));
-		$result = $this->Number->currency(0.22, 'Other2');
-		$expected = '$ 0.22';
-		$this->assertEquals($expected,$result);
-	}
-
-/**
- * testCurrencyPositive method
- *
- * @return void
- */
-	public function testCurrencyPositive() {
-		$value = '100100100';
-
-		$result = $this->Number->currency($value);
-		$expected = '$100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'USD', array('before' => '#'));
-		$expected = '#100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, false);
-		$expected = '100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'USD');
-		$expected = '$100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR');
-		$expected = '€100.100.100,00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP');
-		$expected = '£100,100,100.00';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testCurrencyNegative method
- *
- * @return void
- */
-	public function testCurrencyNegative() {
-		$value = '-100100100';
-
-		$result = $this->Number->currency($value);
-		$expected = '($100,100,100.00)';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR');
-		$expected = '(€100.100.100,00)';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP');
-		$expected = '(£100,100,100.00)';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'USD', array('negative' => '-'));
-		$expected = '-$100,100,100.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR', array('negative' => '-'));
-		$expected = '-€100.100.100,00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP', array('negative' => '-'));
-		$expected = '-£100,100,100.00';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testCurrencyCentsPositive method
- *
- * @return void
- */
-	public function testCurrencyCentsPositive() {
-		$value = '0.99';
-
-		$result = $this->Number->currency($value, 'USD');
-		$expected = '99c';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR');
-		$expected = '€0,99';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP');
-		$expected = '99p';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testCurrencyCentsNegative method
- *
- * @return void
- */
-	public function testCurrencyCentsNegative() {
-		$value = '-0.99';
-
-		$result = $this->Number->currency($value, 'USD');
-		$expected = '(99c)';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR');
-		$expected = '(€0,99)';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP');
-		$expected = '(99p)';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'USD', array('negative' => '-'));
-		$expected = '-99c';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR', array('negative' => '-'));
-		$expected = '-€0,99';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP', array('negative' => '-'));
-		$expected = '-99p';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testCurrencyZero method
- *
- * @return void
- */
-	public function testCurrencyZero() {
-		$value = '0';
-
-		$result = $this->Number->currency($value, 'USD');
-		$expected = '$0.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'EUR');
-		$expected = '€0,00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP');
-		$expected = '£0.00';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP', array('zero' => 'FREE!'));
-		$expected = 'FREE!';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testCurrencyOptions method
- *
- * @return void
- */
-	public function testCurrencyOptions() {
-		$value = '1234567.89';
-
-		$result = $this->Number->currency($value, null, array('before' => 'GBP'));
-		$expected = 'GBP1,234,567.89';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP', array('places' => 0));
-		$expected = '£1,234,568';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency('1234567.8912345', null, array('before' => 'GBP', 'places' => 3));
-		$expected = 'GBP1,234,567.891';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency('650.120001', null, array('before' => 'GBP', 'places' => 4));
-		$expected = 'GBP650.1200';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency($value, 'GBP', array('escape' => true));
-		$expected = '&#163;1,234,567.89';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency('0.35', 'USD', array('after' => false));
-		$expected = '$0.35';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency('0.35', 'GBP', array('after' => false));
-		$expected = '£0.35';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency('0.35', 'GBP');
-		$expected = '35p';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->currency('0.35', 'EUR');
-		$expected = '€0,35';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testToReadableSize method
- *
- * @return void
- */
-	public function testToReadableSize() {
-		$result = $this->Number->toReadableSize(0);
-		$expected = '0 Bytes';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1);
-		$expected = '1 Byte';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(45);
-		$expected = '45 Bytes';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1023);
-		$expected = '1023 Bytes';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024);
-		$expected = '1 KB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 512);
-		$expected = '512 KB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 - 1);
-		$expected = '1.00 MB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 * 512);
-		$expected = '512.00 MB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 * 1024 - 1);
-		$expected = '1.00 GB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 512);
-		$expected = '512.00 GB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 - 1);
-		$expected = '1.00 TB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 512);
-		$expected = '512.00 TB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 - 1);
-		$expected = '1024.00 TB';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024);
-		$expected = (1024 * 1024) . '.00 TB';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testToPercentage method
- *
- * @return void
- */
-	public function testToPercentage() {
-		$result = $this->Number->toPercentage(45, 0);
-		$expected = '45%';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toPercentage(45, 2);
-		$expected = '45.00%';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toPercentage(0, 0);
-		$expected = '0%';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Number->toPercentage(0, 4);
-		$expected = '0.0000%';
-		$this->assertEquals($expected, $result);
-	}
-
-}
diff --git a/lib/Cake/Test/Case/Utility/CakeTimeTest.php b/lib/Cake/Test/Case/Utility/CakeTimeTest.php
deleted file mode 100644
index 70af2ff26a7..00000000000
--- a/lib/Cake/Test/Case/Utility/CakeTimeTest.php
+++ /dev/null
@@ -1,811 +0,0 @@
-
- * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice
- *
- * @copyright     Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests
- * @package       Cake.Test.Case.View.Helper
- * @since         CakePHP(tm) v 1.2.0.4206
- * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
- */
-App::uses('CakeTime', 'Utility');
-
-/**
- * CakeTimeTest class
- *
- * @package       Cake.Test.Case.View.Helper
- */
-class CakeTimeTest extends CakeTestCase {
-
-/**
- * setUp method
- *
- * @return void
- */
-	public function setUp() {
-		$this->Time = new CakeTime();
-	}
-
-/**
- * tearDown method
- *
- * @return void
- */
-	public function tearDown() {
-		unset($this->Time);
-	}
-
-/**
- * testToQuarter method
- *
- * @return void
- */
-	public function testToQuarter() {
-		$result = $this->Time->toQuarter('2007-12-25');
-		$this->assertEquals(4, $result);
-
-		$result = $this->Time->toQuarter('2007-9-25');
-		$this->assertEquals(3, $result);
-
-		$result = $this->Time->toQuarter('2007-3-25');
-		$this->assertEquals(1, $result);
-
-		$result = $this->Time->toQuarter('2007-3-25', true);
-		$this->assertEquals(array('2007-01-01', '2007-03-31'), $result);
-
-		$result = $this->Time->toQuarter('2007-5-25', true);
-		$this->assertEquals(array('2007-04-01', '2007-06-30'), $result);
-
-		$result = $this->Time->toQuarter('2007-8-25', true);
-		$this->assertEquals(array('2007-07-01', '2007-09-30'), $result);
-
-		$result = $this->Time->toQuarter('2007-12-25', true);
-		$this->assertEquals(array('2007-10-01', '2007-12-31'), $result);
-	}
-
-/**
- * testTimeAgoInWords method
- *
- * @return void
- */
-	public function testTimeAgoInWords() {
-		$result = $this->Time->timeAgoInWords('-1 week');
-		$this->assertEquals('1 week ago', $result);
-
-		$result = $this->Time->timeAgoInWords('+1 week');
-		$this->assertEquals('1 week', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+4 months +2 weeks +3 days'), array('end' => '8 years'), true);
-		$this->assertEquals('4 months, 2 weeks, 3 days', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+4 months +2 weeks +2 days'), array('end' => '8 years'), true);
-		$this->assertEquals('4 months, 2 weeks, 2 days', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+4 months +2 weeks +1 day'), array('end' => '8 years'), true);
-		$this->assertEquals('4 months, 2 weeks, 1 day', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+3 months +2 weeks +1 day'), array('end' => '8 years'), true);
-		$this->assertEquals('3 months, 2 weeks, 1 day', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+3 months +2 weeks'), array('end' => '8 years'), true);
-		$this->assertEquals('3 months, 2 weeks', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+3 months +1 week +6 days'), array('end' => '8 years'), true);
-		$this->assertEquals('3 months, 1 week, 6 days', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +2 weeks +1 day'), array('end' => '8 years'), true);
-		$this->assertEquals('2 months, 2 weeks, 1 day', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +2 weeks'), array('end' => '8 years'), true);
-		$this->assertEquals('2 months, 2 weeks', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +1 week +6 days'), array('end' => '8 years'), true);
-		$this->assertEquals('2 months, 1 week, 6 days', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+1 month +1 week +6 days'), array('end' => '8 years'), true);
-		$this->assertEquals('1 month, 1 week, 6 days', $result);
-
-		for ($i = 0; $i < 200; $i ++) {
-			$years = mt_rand(0, 3);
-			$months = mt_rand(0, 11);
-			$weeks = mt_rand(0, 3);
-			$days = mt_rand(0, 6);
-			$hours = 0;
-			$minutes = 0;
-			$seconds = 0;
-			$relativeDate = '';
-
-			// Trying to take into account the number of days in a month
-			$month = date('m') - $months;
-			if ($month <= 0) {
-				$month = $months % 12;
-			}
-			$time = mktime(0, 0, 0, $month, 1, date('y') - $years);
-			$diffDays = date('t') - date('t', $time);
-
-			if ($diffDays > 0 && date('j') - date('t', $time) - $days > 0 && $months > 0 && $weeks === 0) {
-				continue;
-			}
-
-			if ($years > 0) {
-				// years and months and days
-				$relativeDate .= ($relativeDate ? ', -' : '-') . $years . ' year' . ($years > 1 ? 's' : '');
-				$relativeDate .= $months > 0 ? ($relativeDate ? ', -' : '-') . $months . ' month' . ($months > 1 ? 's' : '') : '';
-				$relativeDate .= $weeks > 0 ? ($relativeDate ? ', -' : '-') . $weeks . ' week' . ($weeks > 1 ? 's' : '') : '';
-				$relativeDate .= $days > 0 ? ($relativeDate ? ', -' : '-') . $days . ' day' . ($days > 1 ? 's' : '') : '';
-			} elseif (abs($months) > 0) {
-				// months, weeks and days
-				$relativeDate .= ($relativeDate ? ', -' : '-') . $months . ' month' . ($months > 1 ? 's' : '');
-				$relativeDate .= $weeks > 0 ? ($relativeDate ? ', -' : '-') . $weeks . ' week' . ($weeks > 1 ? 's' : '') : '';
-				$relativeDate .= $days > 0 ? ($relativeDate ? ', -' : '-') . $days . ' day' . ($days > 1 ? 's' : '') : '';
-			} elseif (abs($weeks) > 0) {
-				// weeks and days
-				$relativeDate .= ($relativeDate ? ', -' : '-') . $weeks . ' week' . ($weeks > 1 ? 's' : '');
-				$relativeDate .= $days > 0 ? ($relativeDate ? ', -' : '-') . $days . ' day' . ($days > 1 ? 's' : '') : '';
-			} elseif (abs($days) > 0) {
-				// days and hours
-				$relativeDate .= ($relativeDate ? ', -' : '-') . $days . ' day' . ($days > 1 ? 's' : '');
-				$relativeDate .= $hours > 0 ? ($relativeDate ? ', -' : '-') . $hours . ' hour' . ($hours > 1 ? 's' : '') : '';
-			} elseif (abs($hours) > 0) {
-				// hours and minutes
-				$relativeDate .= ($relativeDate ? ', -' : '-') . $hours . ' hour' . ($hours > 1 ? 's' : '');
-				$relativeDate .= $minutes > 0 ? ($relativeDate ? ', -' : '-') . $minutes . ' minute' . ($minutes > 1 ? 's' : '') : '';
-			} elseif (abs($minutes) > 0) {
-				// minutes only
-				$relativeDate .= ($relativeDate ? ', -' : '-') . $minutes . ' minute' . ($minutes > 1 ? 's' : '');
-			} else {
-				// seconds only
-				$relativeDate .= ($relativeDate ? ', -' : '-') . $seconds . ' second' . ($seconds != 1 ? 's' : '');
-			}
-
-			if (date('j/n/y', strtotime(str_replace(',', '', $relativeDate))) != '1/1/70') {
-				$result = $this->Time->timeAgoInWords(strtotime(str_replace(',', '', $relativeDate)), array('end' => '8 years'), true);
-				if ($relativeDate == '0 seconds') {
-					$relativeDate = '0 seconds ago';
-				}
-
-				$relativeDate = str_replace('-', '', $relativeDate) . ' ago';
-				$this->assertEquals($relativeDate, $result);
-
-			}
-		}
-
-		for ($i = 0; $i < 200; $i ++) {
-			$years = mt_rand(0, 3);
-			$months = mt_rand(0, 11);
-			$weeks = mt_rand(0, 3);
-			$days = mt_rand(0, 6);
-			$hours = 0;
-			$minutes = 0;
-			$seconds = 0;
-
-			$relativeDate = '';
-
-			if ($years > 0) {
-				// years and months and days
-				$relativeDate .= ($relativeDate ? ', ' : '') . $years . ' year' . ($years > 1 ? 's' : '');
-				$relativeDate .= $months > 0 ? ($relativeDate ? ', ' : '') . $months . ' month' . ($months > 1 ? 's' : '') : '';
-				$relativeDate .= $weeks > 0 ? ($relativeDate ? ', ' : '') . $weeks . ' week' . ($weeks > 1 ? 's' : '') : '';
-				$relativeDate .= $days > 0 ? ($relativeDate ? ', ' : '') . $days . ' day' . ($days > 1 ? 's' : '') : '';
-			} elseif (abs($months) > 0) {
-				// months, weeks and days
-				$relativeDate .= ($relativeDate ? ', ' : '') . $months . ' month' . ($months > 1 ? 's' : '');
-				$relativeDate .= $weeks > 0 ? ($relativeDate ? ', ' : '') . $weeks . ' week' . ($weeks > 1 ? 's' : '') : '';
-				$relativeDate .= $days > 0 ? ($relativeDate ? ', ' : '') . $days . ' day' . ($days > 1 ? 's' : '') : '';
-			} elseif (abs($weeks) > 0) {
-				// weeks and days
-				$relativeDate .= ($relativeDate ? ', ' : '') . $weeks . ' week' . ($weeks > 1 ? 's' : '');
-				$relativeDate .= $days > 0 ? ($relativeDate ? ', ' : '') . $days . ' day' . ($days > 1 ? 's' : '') : '';
-			} elseif (abs($days) > 0) {
-				// days and hours
-				$relativeDate .= ($relativeDate ? ', ' : '') . $days . ' day' . ($days > 1 ? 's' : '');
-				$relativeDate .= $hours > 0 ? ($relativeDate ? ', ' : '') . $hours . ' hour' . ($hours > 1 ? 's' : '') : '';
-			} elseif (abs($hours) > 0) {
-				// hours and minutes
-				$relativeDate .= ($relativeDate ? ', ' : '') . $hours . ' hour' . ($hours > 1 ? 's' : '');
-				$relativeDate .= $minutes > 0 ? ($relativeDate ? ', ' : '') . $minutes . ' minute' . ($minutes > 1 ? 's' : '') : '';
-			} elseif (abs($minutes) > 0) {
-				// minutes only
-				$relativeDate .= ($relativeDate ? ', ' : '') . $minutes . ' minute' . ($minutes > 1 ? 's' : '');
-			} else {
-				// seconds only
-				$relativeDate .= ($relativeDate ? ', ' : '') . $seconds . ' second' . ($seconds != 1 ? 's' : '');
-			}
-
-			if (date('j/n/y', strtotime(str_replace(',', '', $relativeDate))) != '1/1/70') {
-				$result = $this->Time->timeAgoInWords(strtotime(str_replace(',', '', $relativeDate)), array('end' => '8 years'), true);
-				if ($relativeDate == '0 seconds') {
-					$relativeDate = '0 seconds ago';
-				}
-
-				$relativeDate = str_replace('-', '', $relativeDate) . '';
-				$this->assertEquals($relativeDate, $result);
-			}
-		}
-
-		$result = $this->Time->timeAgoInWords(strtotime('-2 years -5 months -2 days'), array('end' => '3 years'), true);
-		$this->assertEquals('2 years, 5 months, 2 days ago', $result);
-
-		$result = $this->Time->timeAgoInWords('2007-9-25');
-		$this->assertEquals('on 25/9/07', $result);
-
-		$result = $this->Time->timeAgoInWords('2007-9-25', 'Y-m-d');
-		$this->assertEquals('on 2007-09-25', $result);
-
-		$result = $this->Time->timeAgoInWords('2007-9-25', 'Y-m-d', true);
-		$this->assertEquals('on 2007-09-25', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-2 weeks -2 days'), 'Y-m-d', false);
-		$this->assertEquals('2 weeks, 2 days ago', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 weeks +2 days'), 'Y-m-d', true);
-		$this->assertRegExp('/^2 weeks, [1|2] day(s)?$/', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +2 days'), array('end' => '1 month'));
-		$this->assertEquals('on ' . date('j/n/y', strtotime('+2 months +2 days')), $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +2 days'), array('end' => '3 month'));
-		$this->assertRegExp('/2 months/', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +12 days'), array('end' => '3 month'));
-		$this->assertRegExp('/2 months, 1 week/', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+3 months +5 days'), array('end' => '4 month'));
-		$this->assertEquals('3 months, 5 days', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-2 months -2 days'), array('end' => '3 month'));
-		$this->assertEquals('2 months, 2 days ago', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-2 months -2 days'), array('end' => '3 month'));
-		$this->assertEquals('2 months, 2 days ago', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +2 days'), array('end' => '3 month'));
-		$this->assertRegExp('/2 months/', $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('+2 months +2 days'), array('end' => '1 month', 'format' => 'Y-m-d'));
-		$this->assertEquals('on ' . date('Y-m-d', strtotime('+2 months +2 days')), $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-2 months -2 days'), array('end' => '1 month', 'format' => 'Y-m-d'));
-		$this->assertEquals('on ' . date('Y-m-d', strtotime('-2 months -2 days')), $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-13 months -5 days'), array('end' => '2 years'));
-		$this->assertEquals('1 year, 1 month, 5 days ago', $result);
-
-		$fourHours = $this->Time->timeAgoInWords(strtotime('-5 days -2 hours'), array('userOffset' => -4));
-		$result = $this->Time->timeAgoInWords(strtotime('-5 days -2 hours'), array('userOffset' => 4));
-		$this->assertEquals($fourHours, $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-2 hours'));
-		$expected = '2 hours ago';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-12 minutes'));
-		$expected = '12 minutes ago';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->timeAgoInWords(strtotime('-12 seconds'));
-		$expected = '12 seconds ago';
-		$this->assertEquals($expected, $result);
-
-		$time = strtotime('-3 years -12 months');
-		$result = $this->Time->timeAgoInWords($time);
-		$expected = 'on ' . date('j/n/y', $time);
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * testNice method
- *
- * @return void
- */
-	public function testNice() {
-		$time = time() + 2 * DAY;
-		$this->assertEquals(date('D, M jS Y, H:i', $time), $this->Time->nice($time));
-
-		$time = time() - 2 * DAY;
-		$this->assertEquals(date('D, M jS Y, H:i', $time), $this->Time->nice($time));
-
-		$time = time();
-		$this->assertEquals(date('D, M jS Y, H:i', $time), $this->Time->nice($time));
-
-		$time = 0;
-		$this->assertEquals(date('D, M jS Y, H:i', time()), $this->Time->nice($time));
-
-		$time = null;
-		$this->assertEquals(date('D, M jS Y, H:i', time()), $this->Time->nice($time));
-
-		$time = time();
-		$this->assertEquals(date('D', $time), $this->Time->nice($time, null, '%a'));
-		$this->assertEquals(date('M d, Y', $time), $this->Time->nice($time, null, '%b %d, %Y'));
-
-		$this->Time->niceFormat = '%Y-%d-%m';
-		$this->assertEquals(date('Y-d-m', $time), $this->Time->nice($time));
-		$this->assertEquals('%Y-%d-%m', $this->Time->niceFormat);
-
-		CakeTime::$niceFormat = '%Y-%d-%m %H:%M:%S';
-		$this->assertEquals(date('Y-d-m H:i:s', $time), $this->Time->nice($time));
-		$this->assertEquals('%Y-%d-%m %H:%M:%S', $this->Time->niceFormat);
-	}
-
-/**
- * testNiceShort method
- *
- * @return void
- */
-	public function testNiceShort() {
-		$time = time() + 2 * DAY;
-		if (date('Y', $time) == date('Y')) {
-			$this->assertEquals(date('M jS, H:i', $time), $this->Time->niceShort($time));
-		} else {
-			$this->assertEquals(date('M jS Y, H:i', $time), $this->Time->niceShort($time));
-		}
-
-		$time = time();
-		$this->assertEquals('Today, ' . date('H:i', $time), $this->Time->niceShort($time));
-
-		$time = time() - DAY;
-		$this->assertEquals('Yesterday, ' . date('H:i', $time), $this->Time->niceShort($time));
-	}
-
-/**
- * testDaysAsSql method
- *
- * @return void
- */
-	public function testDaysAsSql() {
-		$begin = time();
-		$end = time() + DAY;
-		$field = 'my_field';
-		$expected = '(my_field >= \'' . date('Y-m-d', $begin) . ' 00:00:00\') AND (my_field <= \'' . date('Y-m-d', $end) . ' 23:59:59\')';
-		$this->assertEquals($expected, $this->Time->daysAsSql($begin, $end, $field));
-	}
-
-/**
- * testDayAsSql method
- *
- * @return void
- */
-	public function testDayAsSql() {
-		$time = time();
-		$field = 'my_field';
-		$expected = '(my_field >= \'' . date('Y-m-d', $time) . ' 00:00:00\') AND (my_field <= \'' . date('Y-m-d', $time) . ' 23:59:59\')';
-		$this->assertEquals($expected, $this->Time->dayAsSql($time, $field));
-	}
-
-/**
- * testToUnix method
- *
- * @return void
- */
-	public function testToUnix() {
-		$this->assertEquals(time(), $this->Time->toUnix(time()));
-		$this->assertEquals(strtotime('+1 day'), $this->Time->toUnix('+1 day'));
-		$this->assertEquals(strtotime('+0 days'), $this->Time->toUnix('+0 days'));
-		$this->assertEquals(strtotime('-1 days'), $this->Time->toUnix('-1 days'));
-		$this->assertEquals(false, $this->Time->toUnix(''));
-		$this->assertEquals(false, $this->Time->toUnix(null));
-	}
-
-/**
- * testToAtom method
- *
- * @return void
- */
-	public function testToAtom() {
-		$this->assertEquals(date('Y-m-d\TH:i:s\Z'), $this->Time->toAtom(time()));
-	}
-
-/**
- * testToRss method
- *
- * @return void
- */
-	public function testToRss() {
-		$this->assertEquals(date('r'), $this->Time->toRss(time()));
-
-		if (!$this->skipIf(!class_exists('DateTimeZone'), '%s DateTimeZone class not available.')) {
-			$timezones = array('Europe/London', 'Europe/Brussels', 'UTC', 'America/Denver', 'America/Caracas', 'Asia/Kathmandu');
-			foreach ($timezones as $timezone) {
-				$yourTimezone = new DateTimeZone($timezone);
-				$yourTime = new DateTime('now', $yourTimezone);
-				$userOffset = $yourTimezone->getOffset($yourTime) / HOUR;
-				$this->assertEquals($yourTime->format('r'), $this->Time->toRss(time(), $userOffset));
-			}
-		}
-	}
-
-/**
- * testFormat method
- *
- * @return void
- */
-	public function testFormat() {
-		$format = 'D-M-Y';
-		$arr = array(time(), strtotime('+1 days'), strtotime('+1 days'), strtotime('+0 days'));
-		foreach ($arr as $val) {
-			$this->assertEquals(date($format, $val), $this->Time->format($format, $val));
-		}
-
-		$result = $this->Time->format('Y-m-d', null, 'never');
-		$this->assertEquals('never', $result);
-	}
-
-/**
- * testOfGmt method
- *
- * @return void
- */
-	public function testGmt() {
-		$hour = 3;
-		$min = 4;
-		$sec = 2;
-		$month = 5;
-		$day = 14;
-		$year = 2007;
-		$time = mktime($hour, $min, $sec, $month, $day, $year);
-		$expected = gmmktime($hour, $min, $sec, $month, $day, $year);
-		$this->assertEquals($expected, $this->Time->gmt(date('Y-n-j G:i:s', $time)));
-
-		$hour = date('H');
-		$min = date('i');
-		$sec = date('s');
-		$month = date('m');
-		$day = date('d');
-		$year = date('Y');
-		$expected = gmmktime($hour, $min, $sec, $month, $day, $year);
-		$this->assertEquals($expected, $this->Time->gmt(null));
-	}
-
-/**
- * testIsToday method
- *
- * @return void
- */
-	public function testIsToday() {
-		$result = $this->Time->isToday('+1 day');
-		$this->assertFalse($result);
-		$result = $this->Time->isToday('+1 days');
-		$this->assertFalse($result);
-		$result = $this->Time->isToday('+0 day');
-		$this->assertTrue($result);
-		$result = $this->Time->isToday('-1 day');
-		$this->assertFalse($result);
-	}
-
-/**
- * testIsThisWeek method
- *
- * @return void
- */
-	public function testIsThisWeek() {
-		// A map of days which goes from -1 day of week to +1 day of week
-		$map = array(
-			'Mon' => array(-1, 7), 'Tue' => array(-2, 6), 'Wed' => array(-3, 5),
-			'Thu' => array(-4, 4), 'Fri' => array(-5, 3), 'Sat' => array(-6, 2),
-			'Sun' => array(-7, 1)
-		);
-		$days = $map[date('D')];
-
-		for ($day = $days[0] + 1; $day < $days[1]; $day++) {
-			$this->assertTrue($this->Time->isThisWeek(($day > 0 ? '+' : '') . $day . ' days'));
-		}
-		$this->assertFalse($this->Time->isThisWeek($days[0] . ' days'));
-		$this->assertFalse($this->Time->isThisWeek('+' . $days[1] . ' days'));
-	}
-
-/**
- * testIsThisMonth method
- *
- * @return void
- */
-	public function testIsThisMonth() {
-		$result = $this->Time->isThisMonth('+0 day');
-		$this->assertTrue($result);
-		$result = $this->Time->isThisMonth($time = mktime(0, 0, 0, date('m'), mt_rand(1, 28), date('Y')));
-		$this->assertTrue($result);
-		$result = $this->Time->isThisMonth(mktime(0, 0, 0, date('m'), mt_rand(1, 28), date('Y') - mt_rand(1, 12)));
-		$this->assertFalse($result);
-		$result = $this->Time->isThisMonth(mktime(0, 0, 0, date('m'), mt_rand(1, 28), date('Y') + mt_rand(1, 12)));
-		$this->assertFalse($result);
-	}
-
-/**
- * testIsThisYear method
- *
- * @return void
- */
-	public function testIsThisYear() {
-		$result = $this->Time->isThisYear('+0 day');
-		$this->assertTrue($result);
-		$result = $this->Time->isThisYear(mktime(0, 0, 0, mt_rand(1, 12), mt_rand(1, 28), date('Y')));
-		$this->assertTrue($result);
-	}
-
-/**
- * testWasYesterday method
- *
- * @return void
- */
-	public function testWasYesterday() {
-		$result = $this->Time->wasYesterday('+1 day');
-		$this->assertFalse($result);
-		$result = $this->Time->wasYesterday('+1 days');
-		$this->assertFalse($result);
-		$result = $this->Time->wasYesterday('+0 day');
-		$this->assertFalse($result);
-		$result = $this->Time->wasYesterday('-1 day');
-		$this->assertTrue($result);
-		$result = $this->Time->wasYesterday('-1 days');
-		$this->assertTrue($result);
-		$result = $this->Time->wasYesterday('-2 days');
-		$this->assertFalse($result);
-	}
-
-/**
- * testIsTomorrow method
- *
- * @return void
- */
-	public function testIsTomorrow() {
-		$result = $this->Time->isTomorrow('+1 day');
-		$this->assertTrue($result);
-		$result = $this->Time->isTomorrow('+1 days');
-		$this->assertTrue($result);
-		$result = $this->Time->isTomorrow('+0 day');
-		$this->assertFalse($result);
-		$result = $this->Time->isTomorrow('-1 day');
-		$this->assertFalse($result);
-	}
-
-/**
- * testWasWithinLast method
- *
- * @return void
- */
-	public function testWasWithinLast() {
-		$this->assertTrue($this->Time->wasWithinLast('1 day', '-1 day'));
-		$this->assertTrue($this->Time->wasWithinLast('1 week', '-1 week'));
-		$this->assertTrue($this->Time->wasWithinLast('1 year', '-1 year'));
-		$this->assertTrue($this->Time->wasWithinLast('1 second', '-1 second'));
-		$this->assertTrue($this->Time->wasWithinLast('1 minute', '-1 minute'));
-		$this->assertTrue($this->Time->wasWithinLast('1 year', '-1 year'));
-		$this->assertTrue($this->Time->wasWithinLast('1 month', '-1 month'));
-		$this->assertTrue($this->Time->wasWithinLast('1 day', '-1 day'));
-
-		$this->assertTrue($this->Time->wasWithinLast('1 week', '-1 day'));
-		$this->assertTrue($this->Time->wasWithinLast('2 week', '-1 week'));
-		$this->assertFalse($this->Time->wasWithinLast('1 second', '-1 year'));
-		$this->assertTrue($this->Time->wasWithinLast('10 minutes', '-1 second'));
-		$this->assertTrue($this->Time->wasWithinLast('23 minutes', '-1 minute'));
-		$this->assertFalse($this->Time->wasWithinLast('0 year', '-1 year'));
-		$this->assertTrue($this->Time->wasWithinLast('13 month', '-1 month'));
-		$this->assertTrue($this->Time->wasWithinLast('2 days', '-1 day'));
-
-		$this->assertFalse($this->Time->wasWithinLast('1 week', '-2 weeks'));
-		$this->assertFalse($this->Time->wasWithinLast('1 second', '-2 seconds'));
-		$this->assertFalse($this->Time->wasWithinLast('1 day', '-2 days'));
-		$this->assertFalse($this->Time->wasWithinLast('1 hour', '-2 hours'));
-		$this->assertFalse($this->Time->wasWithinLast('1 month', '-2 months'));
-		$this->assertFalse($this->Time->wasWithinLast('1 year', '-2 years'));
-
-		$this->assertFalse($this->Time->wasWithinLast('1 day', '-2 weeks'));
-		$this->assertFalse($this->Time->wasWithinLast('1 day', '-2 days'));
-		$this->assertFalse($this->Time->wasWithinLast('0 days', '-2 days'));
-		$this->assertTrue($this->Time->wasWithinLast('1 hour', '-20 seconds'));
-		$this->assertTrue($this->Time->wasWithinLast('1 year', '-60 minutes -30 seconds'));
-		$this->assertTrue($this->Time->wasWithinLast('3 years', '-2 months'));
-		$this->assertTrue($this->Time->wasWithinLast('5 months', '-4 months'));
-
-		$this->assertTrue($this->Time->wasWithinLast('5 ', '-3 days'));
-		$this->assertTrue($this->Time->wasWithinLast('1   ', '-1 hour'));
-		$this->assertTrue($this->Time->wasWithinLast('1   ', '-1 minute'));
-		$this->assertTrue($this->Time->wasWithinLast('1   ', '-23 hours -59 minutes -59 seconds'));
-	}
-
-/**
- * testUserOffset method
- *
- * @return void
- */
-	public function testUserOffset() {
-		$timezoneServer = new DateTimeZone(date_default_timezone_get());
-		$timeServer = new DateTime('now', $timezoneServer);
-		$yourTimezone = $timezoneServer->getOffset($timeServer) / HOUR;
-
-		$expected = time();
-		$result = $this->Time->fromString(time(), $yourTimezone);
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * test fromString()
- *
- * @return void
- */
-	public function testFromString() {
-		$result = $this->Time->fromString('');
-		$this->assertFalse($result);
-
-		$result = $this->Time->fromString(0, 0);
-		$this->assertFalse($result);
-
-		$result = $this->Time->fromString('+1 hour');
-		$expected = strtotime('+1 hour');
-		$this->assertEquals($expected, $result);
-
-		$timezone = date('Z', time());
-		$result = $this->Time->fromString('+1 hour', $timezone);
-		$expected = $this->Time->convert(strtotime('+1 hour'), $timezone);
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * test converting time specifiers using a time definition localfe file
- *
- * @return void
- */
-	public function testConvertSpecifiers() {
-		App::build(array(
-			'Locale' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Locale' . DS)
-		), App::RESET);
-		Configure::write('Config.language', 'time_test');
-		$time = strtotime('Thu Jan 14 11:43:39 2010');
-
-		$result = $this->Time->convertSpecifiers('%a', $time);
-		$expected = 'jue';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%A', $time);
-		$expected = 'jueves';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%c', $time);
-		$expected = 'jue %d ene %Y %H:%M:%S %Z';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%C', $time);
-		$expected = '20';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%D', $time);
-		$expected = '%m/%d/%y';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%b', $time);
-		$expected = 'ene';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%h', $time);
-		$expected = 'ene';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%B', $time);
-		$expected = 'enero';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%n', $time);
-		$expected = "\n";
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%n', $time);
-		$expected = "\n";
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%p', $time);
-		$expected = 'AM';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%P', $time);
-		$expected = 'am';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%r', $time);
-		$expected = '%I:%M:%S AM';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%R', $time);
-		$expected = '11:43';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%t', $time);
-		$expected = "\t";
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%T', $time);
-		$expected = '%H:%M:%S';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%u', $time);
-		$expected = 4;
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%x', $time);
-		$expected = '%d/%m/%y';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%X', $time);
-		$expected = '%H:%M:%S';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * test convert %e on windows.
- *
- * @return void
- */
-	public function testConvertPercentE() {
-		$this->skipIf(DIRECTORY_SEPARATOR !== '\\', 'Cannot run windows tests on non-windows OS.');
-
-		$time = strtotime('Thu Jan 14 11:43:39 2010');
-		$result = $this->Time->convertSpecifiers('%e', $time);
-		$expected = '14';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->convertSpecifiers('%e', strtotime('2011-01-01'));
-		$expected = ' 1';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * test formatting dates taking in account preferred i18n locale file
- *
- * @return void
- */
-	public function testI18nFormat() {
-		App::build(array(
-			'Locale' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'Locale' . DS)
-		), App::RESET);
-		Configure::write('Config.language', 'time_test');
-
-		$time = strtotime('Thu Jan 14 13:59:28 2010');
-
-		$result = $this->Time->i18nFormat($time);
-		$expected = '14/01/10';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->i18nFormat($time, '%c');
-		$expected = 'jue 14 ene 2010 13:59:28 ' . strftime('%Z', $time);
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->i18nFormat($time, 'Time is %r, and date is %x');
-		$expected = 'Time is 01:59:28 PM, and date is 14/01/10';
-		$this->assertEquals($expected, $result);
-
-		$time = strtotime('Wed Jan 13 13:59:28 2010');
-
-		$result = $this->Time->i18nFormat($time);
-		$expected = '13/01/10';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->i18nFormat($time, '%c');
-		$expected = 'mié 13 ene 2010 13:59:28 ' . strftime('%Z', $time);
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->i18nFormat($time, 'Time is %r, and date is %x');
-		$expected = 'Time is 01:59:28 PM, and date is 13/01/10';
-		$this->assertEquals($expected, $result);
-
-		$result = $this->Time->i18nFormat('invalid date', '%x', 'Date invalid');
-		$expected = 'Date invalid';
-		$this->assertEquals($expected, $result);
-	}
-
-/**
- * test new format() syntax which inverts first and second parameters
- *
- * @return void
- */
-	public function testFormatNewSyntax() {
-		$time = time();
-		$this->assertEquals($this->Time->format($time), $this->Time->i18nFormat($time));
-		$this->assertEquals($this->Time->format($time, '%c'), $this->Time->i18nFormat($time, '%c'));
-	}
-}
diff --git a/lib/Cake/Test/Case/Utility/ClassRegistryTest.php b/lib/Cake/Test/Case/Utility/ClassRegistryTest.php
deleted file mode 100644
index bca56f2065e..00000000000
--- a/lib/Cake/Test/Case/Utility/ClassRegistryTest.php
+++ /dev/null
@@ -1,349 +0,0 @@
-
- * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- *
- * Licensed under The MIT License
- * Redistributions of files must retain the above copyright notice
- *
- * @copyright     Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests
- * @package       Cake.Test.Case.Utility
- * @since         CakePHP(tm) v 1.2.0.5432
- * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
- */
-App::uses('ClassRegistry', 'Utility');
-
-/**
- * ClassRegisterModel class
- *
- * @package       Cake.Test.Case.Utility
- */
-class ClassRegisterModel extends CakeTestModel {
-
-/**
- * useTable property
- *
- * @var bool false
- */
-	public $useTable = false;
-}
-
-/**
- * RegisterArticle class
- *
- * @package       Cake.Test.Case.Utility
- */
-class RegisterArticle extends ClassRegisterModel {
-
-/**
- * name property
- *
- * @var string 'RegisterArticle'
- */
-	public $name = 'RegisterArticle';
-}
-
-/**
- * RegisterArticleFeatured class
- *
- * @package       Cake.Test.Case.Utility
- */
-class RegisterArticleFeatured extends ClassRegisterModel {
-
-/**
- * name property
- *
- * @var string 'RegisterArticleFeatured'
- */
-	public $name = 'RegisterArticleFeatured';
-}
-
-/**
- * RegisterArticleTag class
- *
- * @package       Cake.Test.Case.Utility
- */
-class RegisterArticleTag extends ClassRegisterModel {
-
-/**
- * name property
- *
- * @var string 'RegisterArticleTag'
- */
-	public $name = 'RegisterArticleTag';
-}
-
-/**
- * RegistryPluginAppModel class
- *
- * @package       Cake.Test.Case.Utility
- */
-class RegistryPluginAppModel extends ClassRegisterModel {
-
-/**
- * tablePrefix property
- *
- * @var string 'something_'
- */
-	public $tablePrefix = 'something_';
-}
-
-/**
- * TestRegistryPluginModel class
- *
- * @package       Cake.Test.Case.Utility
- */
-class TestRegistryPluginModel extends RegistryPluginAppModel {
-
-/**
- * name property
- *
- * @var string 'TestRegistryPluginModel'
- */
-	public $name = 'TestRegistryPluginModel';
-}
-
-/**
- * RegisterCategory class
- *
- * @package       Cake.Test.Case.Utility
- */
-class RegisterCategory extends ClassRegisterModel {
-
-/**
- * name property
- *
- * @var string 'RegisterCategory'
- */
-	public $name = 'RegisterCategory';
-}
-/**
- * RegisterPrefixedDs class
- *
- * @package       Cake.Test.Case.Utility
- */
-class RegisterPrefixedDs extends ClassRegisterModel {
-
-/**
- * useDbConfig property
- *
- * @var string 'doesnotexist'
- */
-	public $useDbConfig = 'doesnotexist';
-}
-
-/**
- * Abstract class for testing ClassRegistry.
- */
-abstract class ClassRegistryAbstractModel extends ClassRegisterModel {
-
-	public abstract function doSomething();
-
-}
-
-/**
- * Interface for testing ClassRegistry
- */
-interface ClassRegistryInterfaceTest {
-
-	public function doSomething();
-
-}
-
-/**
- * ClassRegistryTest class
- *
- * @package       Cake.Test.Case.Utility
- */
-class ClassRegistryTest extends CakeTestCase {
-
-/**
- * testAddModel method
- *
- * @return void
- */
-	public function testAddModel() {
-		$Tag = ClassRegistry::init('RegisterArticleTag');
-		$this->assertTrue(is_a($Tag, 'RegisterArticleTag'));
-
-		$TagCopy = ClassRegistry::isKeySet('RegisterArticleTag');
-		$this->assertTrue($TagCopy);
-
-		$Tag->name = 'SomeNewName';
-
-		$TagCopy = ClassRegistry::getObject('RegisterArticleTag');
-
-		$this->assertTrue(is_a($TagCopy, 'RegisterArticleTag'));
-		$this->assertSame($Tag, $TagCopy);
-
-		$NewTag = ClassRegistry::init(array('class' => 'RegisterArticleTag', 'alias' => 'NewTag'));
-		$this->assertTrue(is_a($Tag, 'RegisterArticleTag'));
-
-		$NewTagCopy = ClassRegistry::init(array('class' => 'RegisterArticleTag', 'alias' => 'NewTag'));
-
-		$this->assertNotSame($Tag, $NewTag);
-		$this->assertSame($NewTag, $NewTagCopy);
-
-		$NewTag->name = 'SomeOtherName';
-		$this->assertNotSame($Tag, $NewTag);
-		$this->assertSame($NewTag, $NewTagCopy);
-
-		$Tag->name = 'SomeOtherName';
-		$this->assertNotSame($Tag, $NewTag);
-
-		$this->assertTrue($TagCopy->name === 'SomeOtherName');
-
-		$User = ClassRegistry::init(array('class' => 'RegisterUser', 'alias' => 'User', 'table' => false));
-		$this->assertTrue(is_a($User, 'AppModel'));
-
-		$UserCopy = ClassRegistry::init(array('class' => 'RegisterUser', 'alias' => 'User', 'table' => false));
-		$this->assertTrue(is_a($UserCopy, 'AppModel'));
-		$this->assertEquals($User, $UserCopy);
-
-		$Category = ClassRegistry::init(array('class' => 'RegisterCategory'));
-		$this->assertTrue(is_a($Category, 'RegisterCategory'));
-
-		$ParentCategory = ClassRegistry::init(array('class' => 'RegisterCategory', 'alias' => 'ParentCategory'));
-		$this->assertTrue(is_a($ParentCategory, 'RegisterCategory'));
-		$this->assertNotSame($Category, $ParentCategory);
-
-		$this->assertNotEquals($Category->alias, $ParentCategory->alias);
-		$this->assertEquals('RegisterCategory', $Category->alias);
-		$this->assertEquals('ParentCategory', $ParentCategory->alias);
-	}
-
-/**
- * testClassRegistryFlush method
- *
- * @return void
- */
-	public function testClassRegistryFlush() {
-		$Tag = ClassRegistry::init('RegisterArticleTag');
-
-		$ArticleTag = ClassRegistry::getObject('RegisterArticleTag');
-		$this->assertTrue(is_a($ArticleTag, 'RegisterArticleTag'));
-		ClassRegistry::flush();
-
-		$NoArticleTag = ClassRegistry::isKeySet('RegisterArticleTag');
-		$this->assertFalse($NoArticleTag);
-		$this->assertTrue(is_a($ArticleTag, 'RegisterArticleTag'));
-	}
-
-/**
- * testAddMultipleModels method
- *
- * @return void
- */
-	public function testAddMultipleModels() {
-		$Article = ClassRegistry::isKeySet('Article');
-		$this->assertFalse($Article);
-
-		$Featured = ClassRegistry::isKeySet('Featured');
-		$this->assertFalse($Featured);
-
-		$Tag = ClassRegistry::isKeySet('Tag');
-		$this->assertFalse($Tag);
-
-		$models = array(array('class' => 'RegisterArticle', 'alias' => 'Article'),
-				array('class' => 'RegisterArticleFeatured', 'alias' => 'Featured'),
-				array('class' => 'RegisterArticleTag', 'alias' => 'Tag'));
-
-		$added = ClassRegistry::init($models);
-		$this->assertTrue($added);
-
-		$Article = ClassRegistry::isKeySet('Article');
-		$this->assertTrue($Article);
-
-		$Featured = ClassRegistry::isKeySet('Featured');
-		$this->assertTrue($Featured);
-
-		$Tag = ClassRegistry::isKeySet('Tag');
-		$this->assertTrue($Tag);
-
-		$Article = ClassRegistry::getObject('Article');
-		$this->assertTrue(is_a($Article, 'RegisterArticle'));
-
-		$Featured = ClassRegistry::getObject('Featured');
-		$this->assertTrue(is_a($Featured, 'RegisterArticleFeatured'));
-
-		$Tag = ClassRegistry::getObject('Tag');
-		$this->assertTrue(is_a($Tag, 'RegisterArticleTag'));
-	}
-
-/**
- * testPluginAppModel method
- *
- * @return void
- */
-	public function testPluginAppModel() {
-		$TestRegistryPluginModel = ClassRegistry::isKeySet('TestRegistryPluginModel');
-		$this->assertFalse($TestRegistryPluginModel);
-
-		//Faking a plugin
-		CakePlugin::load('RegistryPlugin', array('path' => '/fake/path'));
-		$TestRegistryPluginModel = ClassRegistry::init('RegistryPlugin.TestRegistryPluginModel');
-		$this->assertTrue(is_a($TestRegistryPluginModel, 'TestRegistryPluginModel'));
-
-		$this->assertEquals('something_', $TestRegistryPluginModel->tablePrefix);
-
-		$PluginUser = ClassRegistry::init(array('class' => 'RegistryPlugin.RegisterUser', 'alias' => 'RegistryPluginUser', 'table' => false));
-		$this->assertTrue(is_a($PluginUser, 'RegistryPluginAppModel'));
-
-		$PluginUserCopy = ClassRegistry::getObject('RegistryPluginUser');
-		$this->assertTrue(is_a($PluginUserCopy, 'RegistryPluginAppModel'));
-		$this->assertSame($PluginUser, $PluginUserCopy);
-		CakePlugin::unload();
-	}
-
-/**
- * Tests prefixed datasource names for test purposes
- *
- */
-	public function testPrefixedTestDatasource() {
-		ClassRegistry::config(array('testing' => true));
-		$Model = ClassRegistry::init('RegisterPrefixedDs');
-		$this->assertEquals('test', $Model->useDbConfig);
-		ClassRegistry::removeObject('RegisterPrefixedDs');
-
-		$testConfig = ConnectionManager::getDataSource('test')->config;
-		ConnectionManager::create('test_doesnotexist', $testConfig);
-
-		$Model = ClassRegistry::init('RegisterArticle');
-		$this->assertEquals('test', $Model->useDbConfig);
-		$Model = ClassRegistry::init('RegisterPrefixedDs');
-		$this->assertEquals('test_doesnotexist', $Model->useDbConfig);
-	}
-
-/**
- * Tests that passing the string parameter to init() will return false if the model does not exists
- *
- */
-	public function testInitStrict() {
-		$this->assertFalse(ClassRegistry::init('NonExistent', true));
-	}
-
-/**
- * Test that you cannot init() an abstract class. An exception will be raised.
- *
- * @expectedException CakeException
- * @return void
- */
-	public function testInitAbstractClass() {
-		ClassRegistry::init('ClassRegistryAbstractModel');
-	}
-
-/**
- * Test that you cannot init() an abstract class. A exception will be raised.
- *
- * @expectedException CakeException
- * @return void
- */
-	public function testInitInterface() {
-		ClassRegistry::init('ClassRegistryInterfaceTest');
-	}
-}
diff --git a/lib/Cake/Test/Case/Utility/DebuggerTest.php b/lib/Cake/Test/Case/Utility/DebuggerTest.php
deleted file mode 100644
index 7285b6924b8..00000000000
--- a/lib/Cake/Test/Case/Utility/DebuggerTest.php
+++ /dev/null
@@ -1,477 +0,0 @@
-_restoreError) {
-			restore_error_handler();
-		}
-	}
-
-/**
- * testDocRef method
- *
- * @return void
- */
-	public function testDocRef() {
-		ini_set('docref_root', '');
-		$this->assertEquals(ini_get('docref_root'), '');
-		$debugger = new Debugger();
-		$this->assertEquals(ini_get('docref_root'), 'http://php.net/');
-	}
-
-/**
- * test Excerpt writing
- *
- * @return void
- */
-	public function testExcerpt() {
-		$result = Debugger::excerpt(__FILE__, __LINE__, 2);
-		$this->assertTrue(is_array($result));
-		$this->assertEquals(5, count($result));
-		$this->assertRegExp('/function(.+)testExcerpt/', $result[1]);
-
-		$result = Debugger::excerpt(__FILE__, 2, 2);
-		$this->assertTrue(is_array($result));
-		$this->assertEquals(4, count($result));
-
-		$pattern = '/.*?<\?php/';
-		$this->assertRegExp($pattern, $result[0]);
-
-		$return = Debugger::excerpt('[internal]', 2, 2);
-		$this->assertTrue(empty($return));
-	}
-
-/**
- * testOutput method
- *
- * @return void
- */
-	public function testOutput() {
-		set_error_handler('Debugger::showError');
-		$this->_restoreError = true;
-
-		$result = Debugger::output(false);
-		$this->assertEquals('', $result);
-		$out .= '';
-		$result = Debugger::output(true);
-
-		$this->assertEquals('Notice', $result[0]['error']);
-		$this->assertRegExp('/Undefined variable\:\s+out/', $result[0]['description']);
-		$this->assertRegExp('/DebuggerTest::testOutput/i', $result[0]['trace']);
-
-		ob_start();
-		Debugger::output('txt');
-		$other .= '';
-		$result = ob_get_clean();
-
-		$this->assertRegExp('/Undefined variable:\s+other/', $result);
-		$this->assertRegExp('/Context:/', $result);
-		$this->assertRegExp('/DebuggerTest::testOutput/i', $result);
-
-		ob_start();
-		Debugger::output('html');
-		$wrong .= '';
-		$result = ob_get_clean();
-		$this->assertRegExp('/
.+<\/pre>/', $result);
-		$this->assertRegExp('/Notice<\/b>/', $result);
-		$this->assertRegExp('/variable:\s+wrong/', $result);
-
-		ob_start();
-		Debugger::output('js');
-		$buzz .= '';
-		$result = explode('', ob_get_clean());
-		$this->assertTags($result[0], array(
-			'pre' => array('class' => 'cake-error'),
-			'a' => array(
-				'href' => "javascript:void(0);",
-				'onclick' => "preg:/document\.getElementById\('cakeErr[a-z0-9]+\-trace'\)\.style\.display = " .
-							 "\(document\.getElementById\('cakeErr[a-z0-9]+\-trace'\)\.style\.display == 'none'" .
-							 " \? '' \: 'none'\);/"
-			),
-			'b' => array(), 'Notice', '/b', ' (8)',
-		));
-
-		$this->assertRegExp('/Undefined variable:\s+buzz/', $result[1]);
-		$this->assertRegExp('/]+>Code/', $result[1]);
-		$this->assertRegExp('/]+>Context/', $result[2]);
-	}
-
-/**
- * Tests that changes in output formats using Debugger::output() change the templates used.
- *
- * @return void
- */
-	public function testChangeOutputFormats() {
-		set_error_handler('Debugger::showError');
-		$this->_restoreError = true;
-
-		Debugger::output('js', array(
-			'traceLine' => '{:reference} - {:path}, line {:line}'
-		));
-		$result = Debugger::trace();
-		$this->assertRegExp('/' . preg_quote('txmt://open?url=file://', '/') . '(\/|[A-Z]:\\\\)' . '/', $result);
-
-		Debugger::output('xml', array(
-			'error' => '{:code}{:file}{:line}' .
-						 '{:description}',
-			'context' => "{:context}",
-			'trace' => "{:trace}",
-		));
-		Debugger::output('xml');
-
-		ob_start();
-		$foo .= '';
-		$result = ob_get_clean();
-
-		$data = array(
-			'error' => array(),
-			'code' => array(), '8', '/code',
-			'file' => array(), 'preg:/[^<]+/', '/file',
-			'line' => array(), '' . (intval(__LINE__) - 7), '/line',
-			'preg:/Undefined variable:\s+foo/',
-			'/error'
-		);
-		$this->assertTags($result, $data, true);
-	}
-
-/**
- * Test that outputAs works.
- *
- * @return void
- */
-	public function testOutputAs() {
-		Debugger::outputAs('html');
-		$this->assertEquals('html', Debugger::outputAs());
-	}
-
-/**
- * Test that choosing a non-existent format causes an exception
- *
- * @expectedException CakeException
- * @return void
- */
-	public function testOutputAsException() {
-		Debugger::outputAs('Invalid junk');
-	}
-
-/**
- * Tests that changes in output formats using Debugger::output() change the templates used.
- *
- * @return void
- */
-	public function testAddFormat() {
-		set_error_handler('Debugger::showError');
-		$this->_restoreError = true;
-
-		Debugger::addFormat('js', array(
-			'traceLine' => '{:reference} - {:path}, line {:line}'
-		));
-		Debugger::outputAs('js');
-
-		$result = Debugger::trace();
-		$this->assertRegExp('/' . preg_quote('txmt://open?url=file://', '/') . '(\/|[A-Z]:\\\\)' . '/', $result);
-
-		Debugger::addFormat('xml', array(
-			'error' => '{:code}{:file}{:line}' .
-						 '{:description}',
-		));
-		Debugger::outputAs('xml');
-
-		ob_start();
-		$foo .= '';
-		$result = ob_get_clean();
-
-		$data = array(
-			'assertTags($result, $data, true);
-	}
-
-/**
- * Test adding a format that is handled by a callback.
- *
- * @return void
- */
-	public function testAddFormatCallback() {
-		set_error_handler('Debugger::showError');
-		$this->_restoreError = true;
-
-		Debugger::addFormat('callback', array('callback' => array($this, 'customFormat')));
-		Debugger::outputAs('callback');
-
-		ob_start();
-		$foo .= '';
-		$result = ob_get_clean();
-		$this->assertContains('Notice: I eated an error', $result);
-		$this->assertContains('DebuggerTest.php', $result);
-	}
-
-/**
- * Test method for testing addFormat with callbacks.
- */
-	public function customFormat($error, $strings) {
-		return $error['error'] . ': I eated an error ' . $error['path'];
-	}
-
-/**
- * testTrimPath method
- *
- * @return void
- */
-	public function testTrimPath() {
-		$this->assertEquals(Debugger::trimPath(APP), 'APP' . DS);
-		$this->assertEquals(Debugger::trimPath(CAKE_CORE_INCLUDE_PATH), 'CORE');
-	}
-
-/**
- * testExportVar method
- *
- * @return void
- */
-	public function testExportVar() {
-		App::uses('Controller', 'Controller');
-		$Controller = new Controller();
-		$Controller->helpers = array('Html', 'Form');
-		$View = new View($Controller);
-		$View->int = 2;
-		$View->float = 1.333;
-
-		$result = Debugger::exportVar($View);
-		$expected = << object(HelperCollection) {}
-	Blocks => object(ViewBlock) {}
-	plugin => null
-	name => ''
-	passedArgs => array()
-	helpers => array(
-		(int) 0 => 'Html',
-		(int) 1 => 'Form'
-	)
-	viewPath => ''
-	viewVars => array()
-	view => null
-	layout => 'default'
-	layoutPath => null
-	autoLayout => true
-	ext => '.ctp'
-	subDir => null
-	theme => null
-	cacheAction => false
-	validationErrors => array()
-	hasRendered => false
-	uuids => array()
-	request => null
-	response => object(CakeResponse) {}
-	elementCache => 'default'
-	int => (int) 2
-	float => (float) 1.333
-}
-TEXT;
-		$this->assertTextEquals($expected, $result);
-
-		$data = array(
-			1 => 'Index one',
-			5 => 'Index five'
-		);
-		$result = Debugger::exportVar($data);
-		$expected = << 'Index one',
-	(int) 5 => 'Index five'
-)
-TEXT;
-		$this->assertTextEquals($expected, $result);
-	}
-
-/**
- * testLog method
- *
- * @return void
- */
-	public function testLog() {
-		if (file_exists(LOGS . 'debug.log')) {
-			unlink(LOGS . 'debug.log');
-		}
-
-		Debugger::log('cool');
-		$result = file_get_contents(LOGS . 'debug.log');
-		$this->assertRegExp('/DebuggerTest\:\:testLog/i', $result);
-		$this->assertRegExp("/'cool'/", $result);
-
-		unlink(TMP . 'logs' . DS . 'debug.log');
-
-		Debugger::log(array('whatever', 'here'));
-		$result = file_get_contents(TMP . 'logs' . DS . 'debug.log');
-		$this->assertRegExp('/DebuggerTest\:\:testLog/i', $result);
-		$this->assertRegExp('/\[main\]/', $result);
-		$this->assertRegExp('/array/', $result);
-		$this->assertRegExp("/'whatever',/", $result);
-		$this->assertRegExp("/'here'/", $result);
-	}
-
-/**
- * testDump method
- *
- * @return void
- */
-	public function testDump() {
-		$var = array('People' => array(
-			array(
-				'name' => 'joeseph',
-				'coat' => 'technicolor',
-				'hair_color' => 'brown'
-			),
-			array(
-				'name' => 'Shaft',
-				'coat' => 'black',
-				'hair' => 'black'
-			)
-		));
-		ob_start();
-		Debugger::dump($var);
-		$result = ob_get_clean();
-		$expected = <<array(
-	'People' => array(
-		(int) 0 => array(
-		),
-		(int) 1 => array(
-		)
-	)
-)
-TEXT; - $this->assertTextEquals($expected, $result); - } - -/** - * test getInstance. - * - * @return void - */ - public function testGetInstance() { - $result = Debugger::getInstance(); - $this->assertInstanceOf('Debugger', $result); - - $result = Debugger::getInstance('DebuggerTestCaseDebugger'); - $this->assertInstanceOf('DebuggerTestCaseDebugger', $result); - - $result = Debugger::getInstance(); - $this->assertInstanceOf('DebuggerTestCaseDebugger', $result); - - $result = Debugger::getInstance('Debugger'); - $this->assertInstanceOf('Debugger', $result); - } - -/** - * testNoDbCredentials - * - * If a connection error occurs, the config variable is passed through exportVar - * *** our database login credentials such that they are never visible - * - * @return void - */ - public function testNoDbCredentials() { - $config = array( - 'datasource' => 'mysql', - 'persistent' => false, - 'host' => 'void.cakephp.org', - 'login' => 'cakephp-user', - 'password' => 'cakephp-password', - 'database' => 'cakephp-database', - 'prefix' => '' - ); - - $output = Debugger::exportVar($config); - - $expectedArray = array( - 'datasource' => 'mysql', - 'persistent' => false, - 'host' => '*****', - 'login' => '*****', - 'password' => '*****', - 'database' => '*****', - 'prefix' => '' - ); - $expected = Debugger::exportVar($expectedArray); - - $this->assertEquals($expected, $output); - } - -/** - * test trace exclude - * - * @return void - */ - public function testTraceExclude() { - $result = Debugger::trace(); - $this->assertRegExp('/^DebuggerTest::testTraceExclude/', $result); - - $result = Debugger::trace(array( - 'exclude' => array('DebuggerTest::testTraceExclude') - )); - $this->assertNotRegExp('/^DebuggerTest::testTraceExclude/', $result); - } -} diff --git a/lib/Cake/Test/Case/Utility/FileTest.php b/lib/Cake/Test/Case/Utility/FileTest.php deleted file mode 100644 index 1f6beee0ad3..00000000000 --- a/lib/Cake/Test/Case/Utility/FileTest.php +++ /dev/null @@ -1,508 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Utility - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('File', 'Utility'); -App::uses('Folder', 'Utility'); - -/** - * FileTest class - * - * @package Cake.Test.Case.Utility - */ -class FileTest extends CakeTestCase { - -/** - * File property - * - * @var mixed null - */ - public $File = null; - -/** - * setup the test case - * - * @return void - */ - public function setUp() { - parent::setUp(); - $file = __FILE__; - $this->File = new File($file); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - $this->File->close(); - unset($this->File); - } - -/** - * testBasic method - * - * @return void - */ - public function testBasic() { - $file = __FILE__; - - $result = $this->File->pwd(); - $expecting = $file; - $this->assertEquals($expecting, $result); - - $result = $this->File->name; - $expecting = basename(__FILE__); - $this->assertEquals($expecting, $result); - - $result = $this->File->info(); - $expecting = array( - 'dirname' => dirname(__FILE__), - 'basename' => basename(__FILE__), - 'extension' => 'php', - 'filename' => 'FileTest', - 'filesize' => filesize($file), - 'mime' => 'text/x-php' - ); - if (!function_exists('finfo_open') && !function_exists('mime_content_type')) { - $expecting['mime'] = false; - } - - $this->assertEquals($expecting, $result); - - $result = $this->File->ext(); - $expecting = 'php'; - $this->assertEquals($expecting, $result); - - $result = $this->File->name(); - $expecting = 'FileTest'; - $this->assertEquals($expecting, $result); - - $result = $this->File->md5(); - $expecting = md5_file($file); - $this->assertEquals($expecting, $result); - - $result = $this->File->md5(true); - $expecting = md5_file($file); - $this->assertEquals($expecting, $result); - - $result = $this->File->size(); - $expecting = filesize($file); - $this->assertEquals($expecting, $result); - - $result = $this->File->owner(); - $expecting = fileowner($file); - $this->assertEquals($expecting, $result); - - $result = $this->File->group(); - $expecting = filegroup($file); - $this->assertEquals($expecting, $result); - - $result = $this->File->Folder(); - $this->assertInstanceOf('Folder', $result); - - $this->skipIf(DIRECTORY_SEPARATOR === '\\', 'File permissions tests not supported on Windows.'); - - $result = $this->File->perms(); - $expecting = decoct(0644 & ~umask()); - $this->assertEquals($expecting, $result); - } - -/** - * testRead method - * - * @return void - */ - public function testRead() { - $file = __FILE__; - $this->File = new File($file); - - $result = $this->File->read(); - $expecting = file_get_contents(__FILE__); - $this->assertEquals($expecting, $result); - $this->assertTrue(!is_resource($this->File->handle)); - - $this->File->lock = true; - $result = $this->File->read(); - $expecting = file_get_contents(__FILE__); - $this->assertEquals(trim($expecting), $result); - $this->File->lock = null; - - $data = $expecting; - $expecting = substr($data, 0, 3); - $result = $this->File->read(3); - $this->assertEquals($expecting, $result); - $this->assertTrue(is_resource($this->File->handle)); - - $expecting = substr($data, 3, 3); - $result = $this->File->read(3); - $this->assertEquals($expecting, $result); - } - -/** - * testOffset method - * - * @return void - */ - public function testOffset() { - $this->File->close(); - - $result = $this->File->offset(); - $this->assertFalse($result); - - $this->assertFalse(is_resource($this->File->handle)); - $success = $this->File->offset(0); - $this->assertTrue($success); - $this->assertTrue(is_resource($this->File->handle)); - - $result = $this->File->offset(); - $expecting = 0; - $this->assertSame($result, $expecting); - - $data = file_get_contents(__FILE__); - $success = $this->File->offset(5); - $expecting = substr($data, 5, 3); - $result = $this->File->read(3); - $this->assertTrue($success); - $this->assertEquals($expecting, $result); - - $result = $this->File->offset(); - $expecting = 5 + 3; - $this->assertSame($result, $expecting); - } - -/** - * testOpen method - * - * @return void - */ - public function testOpen() { - $this->File->handle = null; - - $r = $this->File->open(); - $this->assertTrue(is_resource($this->File->handle)); - $this->assertTrue($r); - - $handle = $this->File->handle; - $r = $this->File->open(); - $this->assertTrue($r); - $this->assertTrue($handle === $this->File->handle); - $this->assertTrue(is_resource($this->File->handle)); - - $r = $this->File->open('r', true); - $this->assertTrue($r); - $this->assertFalse($handle === $this->File->handle); - $this->assertTrue(is_resource($this->File->handle)); - } - -/** - * testClose method - * - * @return void - */ - public function testClose() { - $this->File->handle = null; - $this->assertFalse(is_resource($this->File->handle)); - $this->assertTrue($this->File->close()); - $this->assertFalse(is_resource($this->File->handle)); - - $this->File->handle = fopen(__FILE__, 'r'); - $this->assertTrue(is_resource($this->File->handle)); - $this->assertTrue($this->File->close()); - $this->assertFalse(is_resource($this->File->handle)); - } - -/** - * testCreate method - * - * @return void - */ - public function testCreate() { - $tmpFile = TMP . 'tests' . DS . 'cakephp.file.test.tmp'; - $File = new File($tmpFile, true, 0777); - $this->assertTrue($File->exists()); - } - -/** - * testOpeningNonExistentFileCreatesIt method - * - * @return void - */ - public function testOpeningNonExistentFileCreatesIt() { - $someFile = new File(TMP . 'some_file.txt', false); - $this->assertTrue($someFile->open()); - $this->assertEquals('', $someFile->read()); - $someFile->close(); - $someFile->delete(); - } - -/** - * testPrepare method - * - * @return void - */ - public function testPrepare() { - $string = "some\nvery\ncool\r\nteststring here\n\n\nfor\r\r\n\n\r\n\nhere"; - if (DS == '\\') { - $expected = "some\r\nvery\r\ncool\r\nteststring here\r\n\r\n\r\n"; - $expected .= "for\r\n\r\n\r\n\r\n\r\nhere"; - } else { - $expected = "some\nvery\ncool\nteststring here\n\n\nfor\n\n\n\n\nhere"; - } - $this->assertSame(File::prepare($string), $expected); - - $expected = "some\r\nvery\r\ncool\r\nteststring here\r\n\r\n\r\n"; - $expected .= "for\r\n\r\n\r\n\r\n\r\nhere"; - $this->assertSame(File::prepare($string, true), $expected); - } - -/** - * testReadable method - * - * @return void - */ - public function testReadable() { - $someFile = new File(TMP . 'some_file.txt', false); - $this->assertTrue($someFile->open()); - $this->assertTrue($someFile->readable()); - $someFile->close(); - $someFile->delete(); - } - -/** - * testWritable method - * - * @return void - */ - public function testWritable() { - $someFile = new File(TMP . 'some_file.txt', false); - $this->assertTrue($someFile->open()); - $this->assertTrue($someFile->writable()); - $someFile->close(); - $someFile->delete(); - } - -/** - * testExecutable method - * - * @return void - */ - public function testExecutable() { - $someFile = new File(TMP . 'some_file.txt', false); - $this->assertTrue($someFile->open()); - $this->assertFalse($someFile->executable()); - $someFile->close(); - $someFile->delete(); - } - -/** - * testLastAccess method - * - * @return void - */ - public function testLastAccess() { - $ts = time(); - $someFile = new File(TMP . 'some_file.txt', false); - $this->assertFalse($someFile->lastAccess()); - $this->assertTrue($someFile->open()); - $this->assertTrue($someFile->lastAccess() >= $ts); - $someFile->close(); - $someFile->delete(); - } - -/** - * testLastChange method - * - * @return void - */ - public function testLastChange() { - $ts = time(); - $someFile = new File(TMP . 'some_file.txt', false); - $this->assertFalse($someFile->lastChange()); - $this->assertTrue($someFile->open('r+')); - $this->assertTrue($someFile->lastChange() >= $ts); - $someFile->write('something'); - $this->assertTrue($someFile->lastChange() >= $ts); - $someFile->close(); - $someFile->delete(); - } - -/** - * testWrite method - * - * @return void - */ - public function testWrite() { - if (!$tmpFile = $this->_getTmpFile()) { - return false; - }; - if (file_exists($tmpFile)) { - unlink($tmpFile); - } - - $TmpFile = new File($tmpFile); - $this->assertFalse(file_exists($tmpFile)); - $this->assertFalse(is_resource($TmpFile->handle)); - - $testData = array('CakePHP\'s', ' test suite', ' was here ...', ''); - foreach ($testData as $data) { - $r = $TmpFile->write($data); - $this->assertTrue($r); - $this->assertTrue(file_exists($tmpFile)); - $this->assertEquals($data, file_get_contents($tmpFile)); - $this->assertTrue(is_resource($TmpFile->handle)); - $TmpFile->close(); - - } - unlink($tmpFile); - } - -/** - * testAppend method - * - * @return void - */ - public function testAppend() { - if (!$tmpFile = $this->_getTmpFile()) { - return false; - }; - if (file_exists($tmpFile)) { - unlink($tmpFile); - } - - $TmpFile = new File($tmpFile); - $this->assertFalse(file_exists($tmpFile)); - - $fragments = array('CakePHP\'s', ' test suite', ' was here ...', ''); - $data = null; - foreach ($fragments as $fragment) { - $r = $TmpFile->append($fragment); - $this->assertTrue($r); - $this->assertTrue(file_exists($tmpFile)); - $data = $data . $fragment; - $this->assertEquals($data, file_get_contents($tmpFile)); - $TmpFile->close(); - } - } - -/** - * testDelete method - * - * @return void - */ - public function testDelete() { - if (!$tmpFile = $this->_getTmpFile()) { - return false; - } - - if (!file_exists($tmpFile)) { - touch($tmpFile); - } - $TmpFile = new File($tmpFile); - $this->assertTrue(file_exists($tmpFile)); - $result = $TmpFile->delete(); - $this->assertTrue($result); - $this->assertFalse(file_exists($tmpFile)); - - $TmpFile = new File('/this/does/not/exist'); - $result = $TmpFile->delete(); - $this->assertFalse($result); - } - -/** - * Windows has issues unlinking files if there are - * active filehandles open. - * - * @return void - */ - public function testDeleteAfterRead() { - if (!$tmpFile = $this->_getTmpFile()) { - return false; - } - if (!file_exists($tmpFile)) { - touch($tmpFile); - } - $File = new File($tmpFile); - $File->read(); - $this->assertTrue($File->delete()); - } - -/** - * testCopy method - * - * @return void - */ - public function testCopy() { - $dest = TMP . 'tests' . DS . 'cakephp.file.test.tmp'; - $file = __FILE__; - $this->File = new File($file); - $result = $this->File->copy($dest); - $this->assertTrue($result); - - $result = $this->File->copy($dest, true); - $this->assertTrue($result); - - $result = $this->File->copy($dest, false); - $this->assertFalse($result); - - $this->File->close(); - unlink($dest); - - $TmpFile = new File('/this/does/not/exist'); - $result = $TmpFile->copy($dest); - $this->assertFalse($result); - - $TmpFile->close(); - } - -/** - * Test mime() - * - * @return void - */ - public function testMime() { - $this->skipIf(!function_exists('finfo_open') && !function_exists('mime_content_type'), 'Not able to read mime type'); - $path = CAKE . 'Test' . DS . 'test_app' . DS . 'webroot' . DS . 'img' . DS . 'cake.power.gif'; - $file = new File($path); - $this->assertEquals('image/gif', $file->mime()); - } - -/** - * getTmpFile method - * - * @param bool $paintSkip - * @return void - */ - protected function _getTmpFile($paintSkip = true) { - $tmpFile = TMP . 'tests' . DS . 'cakephp.file.test.tmp'; - if (is_writable(dirname($tmpFile)) && (!file_exists($tmpFile) || is_writable($tmpFile))) { - return $tmpFile; - }; - - if ($paintSkip) { - $trace = debug_backtrace(); - $caller = $trace[0]['function']; - $shortPath = dirname($tmpFile); - - $message = __d('cake_dev', '[FileTest] Skipping %s because "%s" not writeable!', $caller, $shortPath); - $this->markTestSkipped($message); - } - return false; - } -} diff --git a/lib/Cake/Test/Case/Utility/FolderTest.php b/lib/Cake/Test/Case/Utility/FolderTest.php deleted file mode 100644 index 22c5d6ea341..00000000000 --- a/lib/Cake/Test/Case/Utility/FolderTest.php +++ /dev/null @@ -1,934 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Utility - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Folder', 'Utility'); -App::uses('File', 'Utility'); - -/** - * FolderTest class - * - * @package Cake.Test.Case.Utility - */ -class FolderTest extends CakeTestCase { - - protected static $_tmp = array(); - -/** - * Save the directory names in TMP - * - * @return void - */ - public static function setUpBeforeClass() { - foreach (scandir(TMP) as $file) { - if (is_dir(TMP . $file) && !in_array($file, array('.', '..'))) { - self::$_tmp[] = $file; - } - } - } - -/** - * setUp clearstatcache() to flush file descriptors. - * - * @return void - */ - public function setUp() { - parent::setUp(); - clearstatcache(); - } - -/** - * Restore the TMP directory to its original state. - * - * @return void - */ - public function tearDown() { - $exclude = array_merge(self::$_tmp, array('.', '..')); - foreach (scandir(TMP) as $dir) { - if (is_dir(TMP . $dir) && !in_array($dir, $exclude)) { - $iterator = new RecursiveDirectoryIterator(TMP . $dir); - foreach (new RecursiveIteratorIterator($iterator, RecursiveIteratorIterator::CHILD_FIRST) as $file) { - if ($file->isFile() || $file->isLink()) { - unlink($file->getPathname()); - } elseif ($file->isDir() && !in_array($file->getFilename(), array('.', '..'))) { - rmdir($file->getPathname()); - } - } - rmdir(TMP . $dir); - } - } - } - -/** - * testBasic method - * - * @return void - */ - public function testBasic() { - $path = dirname(__FILE__); - $Folder = new Folder($path); - - $result = $Folder->pwd(); - $this->assertEquals($path, $result); - - $result = Folder::addPathElement($path, 'test'); - $expected = $path . DS . 'test'; - $this->assertEquals($expected, $result); - - $result = $Folder->cd(ROOT); - $expected = ROOT; - $this->assertEquals($expected, $result); - - $result = $Folder->cd(ROOT . DS . 'non-existent'); - $this->assertFalse($result); - } - -/** - * testInPath method - * - * @return void - */ - public function testInPath() { - $path = dirname(dirname(__FILE__)); - $inside = dirname($path) . DS; - - $Folder = new Folder($path); - - $result = $Folder->pwd(); - $this->assertEquals($path, $result); - - $result = Folder::isSlashTerm($inside); - $this->assertTrue($result); - - $result = $Folder->realpath('Test/'); - $this->assertEquals($path . DS . 'Test' . DS, $result); - - $result = $Folder->inPath('Test' . DS); - $this->assertTrue($result); - - $result = $Folder->inPath(DS . 'non-existing' . $inside); - $this->assertFalse($result); - } - -/** - * test creation of single and multiple paths. - * - * @return void - */ - public function testCreation() { - $Folder = new Folder(TMP . 'tests'); - $result = $Folder->create(TMP . 'tests' . DS . 'first' . DS . 'second' . DS . 'third'); - $this->assertTrue($result); - - rmdir(TMP . 'tests' . DS . 'first' . DS . 'second' . DS . 'third'); - rmdir(TMP . 'tests' . DS . 'first' . DS . 'second'); - rmdir(TMP . 'tests' . DS . 'first'); - - $Folder = new Folder(TMP . 'tests'); - $result = $Folder->create(TMP . 'tests' . DS . 'first'); - $this->assertTrue($result); - rmdir(TMP . 'tests' . DS . 'first'); - } - -/** - * test that creation of folders with trailing ds works - * - * @return void - */ - public function testCreateWithTrailingDs() { - $Folder = new Folder(TMP); - $path = TMP . 'tests' . DS . 'trailing' . DS . 'dir' . DS; - $result = $Folder->create($path); - $this->assertTrue($result); - - $this->assertTrue(is_dir($path), 'Folder was not made'); - - $Folder = new Folder(TMP . 'tests' . DS . 'trailing'); - $this->assertTrue($Folder->delete()); - } - -/** - * test recursive directory create failure. - * - * @return void - */ - public function testRecursiveCreateFailure() { - $this->skipIf(DIRECTORY_SEPARATOR === '\\', 'Cant perform operations using permissions on windows.'); - - $path = TMP . 'tests' . DS . 'one'; - mkdir($path); - chmod($path, '0444'); - - try { - $Folder = new Folder($path); - $result = $Folder->create($path . DS . 'two' . DS . 'three'); - $this->assertFalse($result); - } catch (PHPUnit_Framework_Error $e) { - $this->assertTrue(true); - } - - chmod($path, '0777'); - rmdir($path); - } - -/** - * testOperations method - * - * @return void - */ - public function testOperations() { - $path = CAKE . 'Console' . DS . 'Templates' . DS . 'skel'; - $Folder = new Folder($path); - - $result = is_dir($Folder->pwd()); - $this->assertTrue($result); - - $new = TMP . 'test_folder_new'; - $result = $Folder->create($new); - $this->assertTrue($result); - - $copy = TMP . 'test_folder_copy'; - $result = $Folder->copy($copy); - $this->assertTrue($result); - - $copy = TMP . 'test_folder_copy'; - $result = $Folder->copy($copy); - $this->assertTrue($result); - - $copy = TMP . 'test_folder_copy'; - $result = $Folder->chmod($copy, 0755, false); - $this->assertTrue($result); - - $result = $Folder->cd($copy); - $this->assertTrue((bool)$result); - - $mv = TMP . 'test_folder_mv'; - $result = $Folder->move($mv); - $this->assertTrue($result); - - $mv = TMP . 'test_folder_mv_2'; - $result = $Folder->move($mv); - $this->assertTrue($result); - - $result = $Folder->delete($new); - $this->assertTrue($result); - - $result = $Folder->delete($mv); - $this->assertTrue($result); - - $result = $Folder->delete($mv); - $this->assertTrue($result); - - $new = APP . 'index.php'; - $result = $Folder->create($new); - $this->assertFalse($result); - - $expected = $new . ' is a file'; - $result = $Folder->errors(); - $this->assertEquals($expected, $result[0]); - - $new = TMP . 'test_folder_new'; - $result = $Folder->create($new); - $this->assertTrue($result); - - $result = $Folder->cd($new); - $this->assertTrue((bool)$result); - - $result = $Folder->delete(); - $this->assertTrue($result); - - $Folder = new Folder('non-existent'); - $result = $Folder->pwd(); - $this->assertNull($result); - } - -/** - * testChmod method - * - * @return void - */ - public function testChmod() { - $this->skipIf(DIRECTORY_SEPARATOR === '\\', 'Folder permissions tests not supported on Windows.'); - - $path = TMP; - $Folder = new Folder($path); - - $subdir = 'test_folder_new'; - $new = TMP . $subdir; - - $this->assertTrue($Folder->create($new)); - $this->assertTrue($Folder->create($new . DS . 'test1')); - $this->assertTrue($Folder->create($new . DS . 'test2')); - - $filePath = $new . DS . 'test1.php'; - $File = new File($filePath); - $this->assertTrue($File->create()); - - $filePath = $new . DS . 'skip_me.php'; - $File = new File($filePath); - $this->assertTrue($File->create()); - - $this->assertTrue($Folder->chmod($new, 0755, true)); - $perms = substr(sprintf('%o', fileperms($new . DS . 'test2')), -4); - $this->assertEquals('0755', $perms); - - $this->assertTrue($Folder->chmod($new, 0744, true, array('skip_me.php', 'test2'))); - - $perms = substr(sprintf('%o', fileperms($new . DS . 'test2')), -4); - $this->assertEquals('0755', $perms); - - $perms = substr(sprintf('%o', fileperms($new . DS . 'test1')), -4); - $this->assertEquals('0744', $perms); - - $Folder->delete($new); - } - -/** - * testRealPathForWebroot method - * - * @return void - */ - public function testRealPathForWebroot() { - $Folder = new Folder('files/'); - $this->assertEquals(realpath('files/'), $Folder->path); - } - -/** - * testZeroAsDirectory method - * - * @return void - */ - public function testZeroAsDirectory() { - $Folder = new Folder(TMP); - $new = TMP . '0'; - $this->assertTrue($Folder->create($new)); - - $result = $Folder->read(true, true); - $expected = array('0', 'cache', 'logs', 'sessions', 'tests'); - $this->assertEquals($expected, $result[0]); - - $result = $Folder->read(true, array('logs')); - $expected = array('0', 'cache', 'sessions', 'tests'); - $this->assertEquals($expected, $result[0]); - - $result = $Folder->delete($new); - $this->assertTrue($result); - } - -/** - * test Adding path elements to a path - * - * @return void - */ - public function testAddPathElement() { - $result = Folder::addPathElement(DS . 'some' . DS . 'dir', 'another_path'); - $this->assertEquals(DS . 'some' . DS . 'dir' . DS . 'another_path', $result); - - $result = Folder::addPathElement(DS . 'some' . DS . 'dir' . DS, 'another_path'); - $this->assertEquals(DS . 'some' . DS . 'dir' . DS . 'another_path', $result); - } - -/** - * testFolderRead method - * - * @return void - */ - public function testFolderRead() { - $Folder = new Folder(TMP); - - $expected = array('cache', 'logs', 'sessions', 'tests'); - $result = $Folder->read(true, true); - $this->assertEquals($expected, $result[0]); - - $Folder->path = TMP . 'non-existent'; - $expected = array(array(), array()); - $result = $Folder->read(true, true); - $this->assertEquals($expected, $result); - } - -/** - * testFolderReadWithHiddenFiles method - * - * @return void - */ - public function testFolderReadWithHiddenFiles() { - $this->skipIf(!is_writeable(TMP), 'Cant test Folder::read with hidden files unless the tmp folder is writable.'); - - $Folder = new Folder(TMP . 'folder_tree_hidden', true, 0777); - mkdir($Folder->path . DS . '.svn'); - mkdir($Folder->path . DS . 'some_folder'); - touch($Folder->path . DS . 'not_hidden.txt'); - touch($Folder->path . DS . '.hidden.txt'); - - $expected = array( - array('some_folder'), - array('not_hidden.txt'), - ); - $result = $Folder->read(true, true); - $this->assertEquals($expected, $result); - - $expected = array( - array( - '.svn', - 'some_folder' - ), - array( - '.hidden.txt', - 'not_hidden.txt' - ), - ); - $result = $Folder->read(true); - $this->assertEquals($expected, $result); - } - -/** - * testFolderTree method - * - * @return void - */ - public function testFolderTree() { - $Folder = new Folder(); - $expected = array( - array( - CAKE . 'Config', - CAKE . 'Config' . DS . 'unicode', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' - ), - array( - CAKE . 'Config' . DS . 'config.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0080_00ff.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0100_017f.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0180_024F.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0250_02af.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0370_03ff.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0400_04ff.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0500_052f.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '0530_058f.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '1e00_1eff.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '1f00_1fff.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '2100_214f.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '2150_218f.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '2460_24ff.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '2c00_2c5f.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '2c60_2c7f.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . '2c80_2cff.php', - CAKE . 'Config' . DS . 'unicode' . DS . 'casefolding' . DS . 'ff00_ffef.php' - ) - ); - - $result = $Folder->tree(CAKE . 'Config', false); - $this->assertSame(array(), array_diff($expected[0], $result[0])); - $this->assertSame(array(), array_diff($result[0], $expected[0])); - - $result = $Folder->tree(CAKE . 'Config', false, 'dir'); - $this->assertSame(array(), array_diff($expected[0], $result)); - $this->assertSame(array(), array_diff($expected[0], $result)); - - $result = $Folder->tree(CAKE . 'Config', false, 'files'); - $this->assertSame(array(), array_diff($expected[1], $result)); - $this->assertSame(array(), array_diff($expected[1], $result)); - } - -/** - * testFolderTreeWithHiddenFiles method - * - * @return void - */ - public function testFolderTreeWithHiddenFiles() { - $this->skipIf(!is_writeable(TMP), 'Can\'t test Folder::tree with hidden files unless the tmp folder is writable.'); - - $Folder = new Folder(TMP . 'folder_tree_hidden', true, 0777); - mkdir($Folder->path . DS . '.svn', 0777, true); - touch($Folder->path . DS . '.svn' . DS . 'InHiddenFolder.php'); - mkdir($Folder->path . DS . '.svn' . DS . 'inhiddenfolder'); - touch($Folder->path . DS . '.svn' . DS . 'inhiddenfolder' . DS . 'NestedInHiddenFolder.php'); - touch($Folder->path . DS . 'not_hidden.txt'); - touch($Folder->path . DS . '.hidden.txt'); - mkdir($Folder->path . DS . 'visible_folder' . DS . '.git', 0777, true); - - $expected = array( - array( - $Folder->path, - $Folder->path . DS . 'visible_folder', - ), - array( - $Folder->path . DS . 'not_hidden.txt', - ), - ); - - $result = $Folder->tree(null, true); - $this->assertEquals($expected, $result); - - $result = $Folder->tree(null, array('.')); - $this->assertEquals($expected, $result); - - $expected = array( - array( - $Folder->path, - $Folder->path . DS . 'visible_folder', - $Folder->path . DS . 'visible_folder' . DS . '.git', - $Folder->path . DS . '.svn', - $Folder->path . DS . '.svn' . DS . 'inhiddenfolder', - ), - array( - $Folder->path . DS . 'not_hidden.txt', - $Folder->path . DS . '.hidden.txt', - $Folder->path . DS . '.svn' . DS . 'inhiddenfolder' . DS . 'NestedInHiddenFolder.php', - $Folder->path . DS . '.svn' . DS . 'InHiddenFolder.php', - ), - ); - - $result = $Folder->tree(null, false); - sort($result[0]); - sort($expected[0]); - sort($result[1]); - sort($expected[1]); - $this->assertEquals($expected, $result); - - $Folder->delete(); - } - -/** - * testWindowsPath method - * - * @return void - */ - public function testWindowsPath() { - $this->assertFalse(Folder::isWindowsPath('0:\\cake\\is\\awesome')); - $this->assertTrue(Folder::isWindowsPath('C:\\cake\\is\\awesome')); - $this->assertTrue(Folder::isWindowsPath('d:\\cake\\is\\awesome')); - $this->assertTrue(Folder::isWindowsPath('\\\\vmware-host\\Shared Folders\\file')); - } - -/** - * testIsAbsolute method - * - * @return void - */ - public function testIsAbsolute() { - $this->assertFalse(Folder::isAbsolute('path/to/file')); - $this->assertFalse(Folder::isAbsolute('cake/')); - $this->assertFalse(Folder::isAbsolute('path\\to\\file')); - $this->assertFalse(Folder::isAbsolute('0:\\path\\to\\file')); - $this->assertFalse(Folder::isAbsolute('\\path/to/file')); - $this->assertFalse(Folder::isAbsolute('\\path\\to\\file')); - - $this->assertTrue(Folder::isAbsolute('/usr/local')); - $this->assertTrue(Folder::isAbsolute('//path/to/file')); - $this->assertTrue(Folder::isAbsolute('C:\\cake')); - $this->assertTrue(Folder::isAbsolute('C:\\path\\to\\file')); - $this->assertTrue(Folder::isAbsolute('d:\\path\\to\\file')); - $this->assertTrue(Folder::isAbsolute('\\\\vmware-host\\Shared Folders\\file')); - } - -/** - * testIsSlashTerm method - * - * @return void - */ - public function testIsSlashTerm() { - $this->assertFalse(Folder::isSlashTerm('cake')); - - $this->assertTrue(Folder::isSlashTerm('C:\\cake\\')); - $this->assertTrue(Folder::isSlashTerm('/usr/local/')); - } - -/** - * testStatic method - * - * @return void - */ - public function testSlashTerm() { - $result = Folder::slashTerm('/path/to/file'); - $this->assertEquals('/path/to/file/', $result); - } - -/** - * testNormalizePath method - * - * @return void - */ - public function testNormalizePath() { - $path = '/path/to/file'; - $result = Folder::normalizePath($path); - $this->assertEquals('/', $result); - - $path = '\\path\\\to\\\file'; - $result = Folder::normalizePath($path); - $this->assertEquals('/', $result); - - $path = 'C:\\path\\to\\file'; - $result = Folder::normalizePath($path); - $this->assertEquals('\\', $result); - } - -/** - * correctSlashFor method - * - * @return void - */ - public function testCorrectSlashFor() { - $path = '/path/to/file'; - $result = Folder::correctSlashFor($path); - $this->assertEquals('/', $result); - - $path = '\\path\\to\\file'; - $result = Folder::correctSlashFor($path); - $this->assertEquals('/', $result); - - $path = 'C:\\path\to\\file'; - $result = Folder::correctSlashFor($path); - $this->assertEquals('\\', $result); - } - -/** - * testInCakePath method - * - * @return void - */ - public function testInCakePath() { - $Folder = new Folder(); - $Folder->cd(ROOT); - $path = 'C:\\path\\to\\file'; - $result = $Folder->inCakePath($path); - $this->assertFalse($result); - - $path = ROOT; - $Folder->cd(ROOT); - $result = $Folder->inCakePath($path); - $this->assertFalse($result); - - $path = DS . 'lib' . DS . 'Cake' . DS . 'Config'; - $Folder->cd(ROOT . DS . 'lib' . DS . 'Cake' . DS . 'Config'); - $result = $Folder->inCakePath($path); - $this->assertTrue($result); - } - -/** - * testFind method - * - * @return void - */ - public function testFind() { - $Folder = new Folder(); - $Folder->cd(CAKE . 'Config'); - $result = $Folder->find(); - $expected = array('config.php'); - $this->assertSame(array_diff($expected, $result), array()); - $this->assertSame(array_diff($expected, $result), array()); - - $result = $Folder->find('.*', true); - $expected = array('config.php', 'routes.php'); - $this->assertSame($expected, $result); - - $result = $Folder->find('.*\.php'); - $expected = array('config.php'); - $this->assertSame(array_diff($expected, $result), array()); - $this->assertSame(array_diff($expected, $result), array()); - - $result = $Folder->find('.*\.php', true); - $expected = array('config.php', 'routes.php'); - $this->assertSame($expected, $result); - - $result = $Folder->find('.*ig\.php'); - $expected = array('config.php'); - $this->assertSame($expected, $result); - - $result = $Folder->find('config\.php'); - $expected = array('config.php'); - $this->assertSame($expected, $result); - - $Folder->cd(TMP); - $File = new File($Folder->pwd() . DS . 'paths.php', true); - $Folder->create($Folder->pwd() . DS . 'testme'); - $Folder->cd('testme'); - $result = $Folder->find('paths\.php'); - $expected = array(); - $this->assertSame($expected, $result); - - $Folder->cd($Folder->pwd() . '/..'); - $result = $Folder->find('paths\.php'); - $expected = array('paths.php'); - $this->assertSame($expected, $result); - - $Folder->cd(TMP); - $Folder->delete($Folder->pwd() . DS . 'testme'); - $File->delete(); - } - -/** - * testFindRecursive method - * - * @return void - */ - public function testFindRecursive() { - $Folder = new Folder(); - $Folder->cd(CAKE); - $result = $Folder->findRecursive('(config|paths)\.php'); - $expected = array( - CAKE . 'Config' . DS . 'config.php' - ); - $this->assertSame(array_diff($expected, $result), array()); - $this->assertSame(array_diff($expected, $result), array()); - - $result = $Folder->findRecursive('(config|paths)\.php', true); - $expected = array( - CAKE . 'Config' . DS . 'config.php' - ); - $this->assertSame($expected, $result); - - $Folder->cd(TMP); - $Folder->create($Folder->pwd() . DS . 'testme'); - $Folder->cd('testme'); - $File = new File($Folder->pwd() . DS . 'paths.php'); - $File->create(); - $Folder->cd(TMP . 'sessions'); - $result = $Folder->findRecursive('paths\.php'); - $expected = array(); - $this->assertSame($expected, $result); - - $Folder->cd(TMP . 'testme'); - $File = new File($Folder->pwd() . DS . 'my.php'); - $File->create(); - $Folder->cd($Folder->pwd() . '/../..'); - - $result = $Folder->findRecursive('(paths|my)\.php'); - $expected = array( - TMP . 'testme' . DS . 'my.php', - TMP . 'testme' . DS . 'paths.php' - ); - $this->assertSame(array_diff($expected, $result), array()); - $this->assertSame(array_diff($expected, $result), array()); - - $result = $Folder->findRecursive('(paths|my)\.php', true); - $expected = array( - TMP . 'testme' . DS . 'my.php', - TMP . 'testme' . DS . 'paths.php' - ); - $this->assertSame($expected, $result); - - $Folder->cd(CAKE . 'Config'); - $Folder->cd(TMP); - $Folder->delete($Folder->pwd() . DS . 'testme'); - $File->delete(); - } - -/** - * testConstructWithNonExistentPath method - * - * @return void - */ - public function testConstructWithNonExistentPath() { - $Folder = new Folder(TMP . 'config_non_existent', true); - $this->assertTrue(is_dir(TMP . 'config_non_existent')); - $Folder->cd(TMP); - $Folder->delete($Folder->pwd() . 'config_non_existent'); - } - -/** - * testDirSize method - * - * @return void - */ - public function testDirSize() { - $Folder = new Folder(TMP . 'config_non_existent', true); - $this->assertEquals(0, $Folder->dirSize()); - - $File = new File($Folder->pwd() . DS . 'my.php', true, 0777); - $File->create(); - $File->write('something here'); - $File->close(); - $this->assertEquals(14, $Folder->dirSize()); - - $Folder->cd(TMP); - $Folder->delete($Folder->pwd() . 'config_non_existent'); - } - -/** - * testDelete method - * - * @return void - */ - public function testDelete() { - $path = TMP . 'folder_delete_test'; - mkdir($path); - touch($path . DS . 'file_1'); - mkdir($path . DS . 'level_1_1'); - touch($path . DS . 'level_1_1' . DS . 'file_1_1'); - mkdir($path . DS . 'level_1_1' . DS . 'level_2_1'); - touch($path . DS . 'level_1_1' . DS . 'level_2_1' . DS . 'file_2_1'); - touch($path . DS . 'level_1_1' . DS . 'level_2_1' . DS . 'file_2_2'); - mkdir($path . DS . 'level_1_1' . DS . 'level_2_2'); - - $Folder = new Folder($path, true); - $return = $Folder->delete(); - $this->assertTrue($return); - - $messages = $Folder->messages(); - $errors = $Folder->errors(); - $this->assertEquals(array(), $errors); - - $expected = array( - $path . DS . 'file_1 removed', - $path . DS . 'level_1_1' . DS . 'file_1_1 removed', - $path . DS . 'level_1_1' . DS . 'level_2_1' . DS . 'file_2_1 removed', - $path . DS . 'level_1_1' . DS . 'level_2_1' . DS . 'file_2_2 removed', - $path . DS . 'level_1_1' . DS . 'level_2_1 removed', - $path . DS . 'level_1_1' . DS . 'level_2_2 removed', - $path . DS . 'level_1_1 removed', - $path . ' removed' - ); - sort($expected); - sort($messages); - $this->assertEquals($expected, $messages); - } - -/** - * testCopy method - * - * Verify that directories and files are copied recursively - * even if the destination directory already exists. - * Subdirectories existing in both destination and source directory - * are skipped and not merged or overwritten. - * - * @return void - */ - public function testCopy() { - $path = TMP . 'folder_test'; - $folderOne = $path . DS . 'folder1'; - $folderTwo = $folderOne . DS . 'folder2'; - $folderThree = $path . DS . 'folder3'; - $fileOne = $folderOne . DS . 'file1.php'; - $fileTwo = $folderTwo . DS . 'file2.php'; - - new Folder($path, true); - new Folder($folderOne, true); - new Folder($folderTwo, true); - new Folder($folderThree, true); - touch($fileOne); - touch($fileTwo); - - $Folder = new Folder($folderOne); - $result = $Folder->copy($folderThree); - $this->assertTrue($result); - $this->assertTrue(file_exists($folderThree . DS . 'file1.php')); - $this->assertTrue(file_exists($folderThree . DS . 'folder2' . DS . 'file2.php')); - - $Folder = new Folder($folderThree); - $Folder->delete(); - - $Folder = new Folder($folderOne); - $result = $Folder->copy($folderThree); - $this->assertTrue($result); - $this->assertTrue(file_exists($folderThree . DS . 'file1.php')); - $this->assertTrue(file_exists($folderThree . DS . 'folder2' . DS . 'file2.php')); - - $Folder = new Folder($folderThree); - $Folder->delete(); - - new Folder($folderThree, true); - new Folder($folderThree . DS . 'folder2', true); - file_put_contents($folderThree . DS . 'folder2' . DS . 'file2.php', 'untouched'); - - $Folder = new Folder($folderOne); - $result = $Folder->copy($folderThree); - $this->assertTrue($result); - $this->assertTrue(file_exists($folderThree . DS . 'file1.php')); - $this->assertEquals('untouched', file_get_contents($folderThree . DS . 'folder2' . DS . 'file2.php')); - - $Folder = new Folder($path); - $Folder->delete(); - } - -/** - * testMove method - * - * Verify that directories and files are moved recursively - * even if the destination directory already exists. - * Subdirectories existing in both destination and source directory - * are skipped and not merged or overwritten. - * - * @return void - */ - public function testMove() { - $path = TMP . 'folder_test'; - $folderOne = $path . DS . 'folder1'; - $folderTwo = $folderOne . DS . 'folder2'; - $folderThree = $path . DS . 'folder3'; - $fileOne = $folderOne . DS . 'file1.php'; - $fileTwo = $folderTwo . DS . 'file2.php'; - - new Folder($path, true); - new Folder($folderOne, true); - new Folder($folderTwo, true); - new Folder($folderThree, true); - touch($fileOne); - touch($fileTwo); - - $Folder = new Folder($folderOne); - $result = $Folder->move($folderThree); - $this->assertTrue($result); - $this->assertTrue(file_exists($folderThree . DS . 'file1.php')); - $this->assertTrue(is_dir($folderThree . DS . 'folder2')); - $this->assertTrue(file_exists($folderThree . DS . 'folder2' . DS . 'file2.php')); - $this->assertFalse(file_exists($fileOne)); - $this->assertFalse(file_exists($folderTwo)); - $this->assertFalse(file_exists($fileTwo)); - - $Folder = new Folder($folderThree); - $Folder->delete(); - - new Folder($folderOne, true); - new Folder($folderTwo, true); - touch($fileOne); - touch($fileTwo); - - $Folder = new Folder($folderOne); - $result = $Folder->move($folderThree); - $this->assertTrue($result); - $this->assertTrue(file_exists($folderThree . DS . 'file1.php')); - $this->assertTrue(is_dir($folderThree . DS . 'folder2')); - $this->assertTrue(file_exists($folderThree . DS . 'folder2' . DS . 'file2.php')); - $this->assertFalse(file_exists($fileOne)); - $this->assertFalse(file_exists($folderTwo)); - $this->assertFalse(file_exists($fileTwo)); - - $Folder = new Folder($folderThree); - $Folder->delete(); - - new Folder($folderOne, true); - new Folder($folderTwo, true); - new Folder($folderThree, true); - new Folder($folderThree . DS . 'folder2', true); - touch($fileOne); - touch($fileTwo); - file_put_contents($folderThree . DS . 'folder2' . DS . 'file2.php', 'untouched'); - - $Folder = new Folder($folderOne); - $result = $Folder->move($folderThree); - $this->assertTrue($result); - $this->assertTrue(file_exists($folderThree . DS . 'file1.php')); - $this->assertEquals('untouched', file_get_contents($folderThree . DS . 'folder2' . DS . 'file2.php')); - $this->assertFalse(file_exists($fileOne)); - $this->assertFalse(file_exists($folderTwo)); - $this->assertFalse(file_exists($fileTwo)); - - $Folder = new Folder($path); - $Folder->delete(); - } - -} diff --git a/lib/Cake/Test/Case/Utility/InflectorTest.php b/lib/Cake/Test/Case/Utility/InflectorTest.php deleted file mode 100644 index 9709b03d209..00000000000 --- a/lib/Cake/Test/Case/Utility/InflectorTest.php +++ /dev/null @@ -1,439 +0,0 @@ -assertEquals(Inflector::singularize('categorias'), 'categoria'); - $this->assertEquals(Inflector::singularize('menus'), 'menu'); - $this->assertEquals(Inflector::singularize('news'), 'news'); - $this->assertEquals(Inflector::singularize('food_menus'), 'food_menu'); - $this->assertEquals(Inflector::singularize('Menus'), 'Menu'); - $this->assertEquals(Inflector::singularize('FoodMenus'), 'FoodMenu'); - $this->assertEquals(Inflector::singularize('houses'), 'house'); - $this->assertEquals(Inflector::singularize('powerhouses'), 'powerhouse'); - $this->assertEquals(Inflector::singularize('quizzes'), 'quiz'); - $this->assertEquals(Inflector::singularize('Buses'), 'Bus'); - $this->assertEquals(Inflector::singularize('buses'), 'bus'); - $this->assertEquals(Inflector::singularize('matrix_rows'), 'matrix_row'); - $this->assertEquals(Inflector::singularize('matrices'), 'matrix'); - $this->assertEquals(Inflector::singularize('vertices'), 'vertex'); - $this->assertEquals(Inflector::singularize('indices'), 'index'); - $this->assertEquals(Inflector::singularize('Aliases'), 'Alias'); - $this->assertEquals(Inflector::singularize('Alias'), 'Alias'); - $this->assertEquals(Inflector::singularize('Media'), 'Media'); - $this->assertEquals(Inflector::singularize('NodeMedia'), 'NodeMedia'); - $this->assertEquals(Inflector::singularize('alumni'), 'alumnus'); - $this->assertEquals(Inflector::singularize('bacilli'), 'bacillus'); - $this->assertEquals(Inflector::singularize('cacti'), 'cactus'); - $this->assertEquals(Inflector::singularize('foci'), 'focus'); - $this->assertEquals(Inflector::singularize('fungi'), 'fungus'); - $this->assertEquals(Inflector::singularize('nuclei'), 'nucleus'); - $this->assertEquals(Inflector::singularize('octopuses'), 'octopus'); - $this->assertEquals(Inflector::singularize('radii'), 'radius'); - $this->assertEquals(Inflector::singularize('stimuli'), 'stimulus'); - $this->assertEquals(Inflector::singularize('syllabi'), 'syllabus'); - $this->assertEquals(Inflector::singularize('termini'), 'terminus'); - $this->assertEquals(Inflector::singularize('viri'), 'virus'); - $this->assertEquals(Inflector::singularize('people'), 'person'); - $this->assertEquals(Inflector::singularize('gloves'), 'glove'); - $this->assertEquals(Inflector::singularize('doves'), 'dove'); - $this->assertEquals(Inflector::singularize('lives'), 'life'); - $this->assertEquals(Inflector::singularize('knives'), 'knife'); - $this->assertEquals(Inflector::singularize('wolves'), 'wolf'); - $this->assertEquals(Inflector::singularize('slaves'), 'slave'); - $this->assertEquals(Inflector::singularize('shelves'), 'shelf'); - $this->assertEquals(Inflector::singularize('taxis'), 'taxi'); - $this->assertEquals(Inflector::singularize('taxes'), 'tax'); - $this->assertEquals(Inflector::singularize('Taxes'), 'Tax'); - $this->assertEquals(Inflector::singularize('AwesomeTaxes'), 'AwesomeTax'); - $this->assertEquals(Inflector::singularize('faxes'), 'fax'); - $this->assertEquals(Inflector::singularize('waxes'), 'wax'); - $this->assertEquals(Inflector::singularize('niches'), 'niche'); - $this->assertEquals(Inflector::singularize('waves'), 'wave'); - $this->assertEquals(Inflector::singularize('bureaus'), 'bureau'); - $this->assertEquals(Inflector::singularize('genetic_analyses'), 'genetic_analysis'); - $this->assertEquals(Inflector::singularize('doctor_diagnoses'), 'doctor_diagnosis'); - $this->assertEquals(Inflector::singularize('parantheses'), 'paranthesis'); - $this->assertEquals(Inflector::singularize('Causes'), 'Cause'); - $this->assertEquals(Inflector::singularize('colossuses'), 'colossus'); - $this->assertEquals(Inflector::singularize('diagnoses'), 'diagnosis'); - $this->assertEquals(Inflector::singularize('bases'), 'basis'); - $this->assertEquals(Inflector::singularize('analyses'), 'analysis'); - $this->assertEquals(Inflector::singularize('curves'), 'curve'); - $this->assertEquals(Inflector::singularize('cafes'), 'cafe'); - $this->assertEquals(Inflector::singularize('roofs'), 'roof'); - $this->assertEquals(Inflector::singularize('foes'), 'foe'); - - $this->assertEquals(Inflector::singularize(''), ''); - } - -/** - * testInflectingPlurals method - * - * @return void - */ - public function testInflectingPlurals() { - $this->assertEquals(Inflector::pluralize('categoria'), 'categorias'); - $this->assertEquals(Inflector::pluralize('house'), 'houses'); - $this->assertEquals(Inflector::pluralize('powerhouse'), 'powerhouses'); - $this->assertEquals(Inflector::pluralize('Bus'), 'Buses'); - $this->assertEquals(Inflector::pluralize('bus'), 'buses'); - $this->assertEquals(Inflector::pluralize('menu'), 'menus'); - $this->assertEquals(Inflector::pluralize('news'), 'news'); - $this->assertEquals(Inflector::pluralize('food_menu'), 'food_menus'); - $this->assertEquals(Inflector::pluralize('Menu'), 'Menus'); - $this->assertEquals(Inflector::pluralize('FoodMenu'), 'FoodMenus'); - $this->assertEquals(Inflector::pluralize('quiz'), 'quizzes'); - $this->assertEquals(Inflector::pluralize('matrix_row'), 'matrix_rows'); - $this->assertEquals(Inflector::pluralize('matrix'), 'matrices'); - $this->assertEquals(Inflector::pluralize('vertex'), 'vertices'); - $this->assertEquals(Inflector::pluralize('index'), 'indices'); - $this->assertEquals(Inflector::pluralize('Alias'), 'Aliases'); - $this->assertEquals(Inflector::pluralize('Aliases'), 'Aliases'); - $this->assertEquals(Inflector::pluralize('Media'), 'Media'); - $this->assertEquals(Inflector::pluralize('NodeMedia'), 'NodeMedia'); - $this->assertEquals(Inflector::pluralize('alumnus'), 'alumni'); - $this->assertEquals(Inflector::pluralize('bacillus'), 'bacilli'); - $this->assertEquals(Inflector::pluralize('cactus'), 'cacti'); - $this->assertEquals(Inflector::pluralize('focus'), 'foci'); - $this->assertEquals(Inflector::pluralize('fungus'), 'fungi'); - $this->assertEquals(Inflector::pluralize('nucleus'), 'nuclei'); - $this->assertEquals(Inflector::pluralize('octopus'), 'octopuses'); - $this->assertEquals(Inflector::pluralize('radius'), 'radii'); - $this->assertEquals(Inflector::pluralize('stimulus'), 'stimuli'); - $this->assertEquals(Inflector::pluralize('syllabus'), 'syllabi'); - $this->assertEquals(Inflector::pluralize('terminus'), 'termini'); - $this->assertEquals(Inflector::pluralize('virus'), 'viri'); - $this->assertEquals(Inflector::pluralize('person'), 'people'); - $this->assertEquals(Inflector::pluralize('people'), 'people'); - $this->assertEquals(Inflector::pluralize('glove'), 'gloves'); - $this->assertEquals(Inflector::pluralize('crisis'), 'crises'); - $this->assertEquals(Inflector::pluralize('tax'), 'taxes'); - $this->assertEquals(Inflector::pluralize('wave'), 'waves'); - $this->assertEquals(Inflector::pluralize('bureau'), 'bureaus'); - $this->assertEquals(Inflector::pluralize('cafe'), 'cafes'); - $this->assertEquals(Inflector::pluralize('roof'), 'roofs'); - $this->assertEquals(Inflector::pluralize('foe'), 'foes'); - $this->assertEquals(Inflector::pluralize(''), ''); - } - -/** - * testInflectorSlug method - * - * @return void - */ - public function testInflectorSlug() { - $result = Inflector::slug('Foo Bar: Not just for breakfast any-more'); - $expected = 'Foo_Bar_Not_just_for_breakfast_any_more'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('this/is/a/path'); - $expected = 'this_is_a_path'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('Foo Bar: Not just for breakfast any-more', "-"); - $expected = 'Foo-Bar-Not-just-for-breakfast-any-more'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('Foo Bar: Not just for breakfast any-more', "+"); - $expected = 'Foo+Bar+Not+just+for+breakfast+any+more'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('Äpfel Über Öl grün ärgert groß öko', '-'); - $expected = 'Aepfel-Ueber-Oel-gruen-aergert-gross-oeko'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('The truth - and- more- news', '-'); - $expected = 'The-truth-and-more-news'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('The truth: and more news', '-'); - $expected = 'The-truth-and-more-news'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('La langue française est un attribut de souveraineté en France', '-'); - $expected = 'La-langue-francaise-est-un-attribut-de-souverainete-en-France'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('!@$#exciting stuff! - what !@-# was that?', '-'); - $expected = 'exciting-stuff-what-was-that'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('20% of profits went to me!', '-'); - $expected = '20-of-profits-went-to-me'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('#this melts your face1#2#3', '-'); - $expected = 'this-melts-your-face1-2-3'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('controller/action/りんご/1'); - $expected = 'controller_action_りんご_1'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('の話が出たので大丈夫かなあと'); - $expected = 'の話が出たので大丈夫かなあと'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('posts/view/한국어/page:1/sort:asc'); - $expected = 'posts_view_한국어_page_1_sort_asc'; - $this->assertEquals($expected, $result); - } - -/** - * testInflectorSlugWithMap method - * - * @return void - */ - public function testInflectorSlugWithMap() { - Inflector::rules('transliteration', array('/r/' => '1')); - $result = Inflector::slug('replace every r'); - $expected = '1eplace_eve1y_1'; - $this->assertEquals($expected, $result); - - $result = Inflector::slug('replace every r', '_'); - $expected = '1eplace_eve1y_1'; - $this->assertEquals($expected, $result); - } - -/** - * testInflectorSlugWithMapOverridingDefault method - * - * @return void - */ - public function testInflectorSlugWithMapOverridingDefault() { - Inflector::rules('transliteration', array('/å/' => 'aa', '/ø/' => 'oe')); - $result = Inflector::slug('Testing æ ø å', '-'); - $expected = 'Testing-ae-oe-aa'; - $this->assertEquals($expected, $result); - } - -/** - * testInflectorUnderscore method - * - * @return void - */ - public function testInflectorUnderscore() { - $this->assertSame(Inflector::underscore('TestThing'), 'test_thing'); - $this->assertSame(Inflector::underscore('testThing'), 'test_thing'); - $this->assertSame(Inflector::underscore('TestThingExtra'), 'test_thing_extra'); - $this->assertSame(Inflector::underscore('testThingExtra'), 'test_thing_extra'); - - // Identical checks test the cache code path. - $this->assertSame(Inflector::underscore('TestThing'), 'test_thing'); - $this->assertSame(Inflector::underscore('testThing'), 'test_thing'); - $this->assertSame(Inflector::underscore('TestThingExtra'), 'test_thing_extra'); - $this->assertSame(Inflector::underscore('testThingExtra'), 'test_thing_extra'); - - // Test stupid values - $this->assertSame(Inflector::underscore(''), ''); - $this->assertSame(Inflector::underscore(0), '0'); - $this->assertSame(Inflector::underscore(false), ''); - } - -/** - * testVariableNaming method - * - * @return void - */ - public function testVariableNaming() { - $this->assertEquals(Inflector::variable('test_field'), 'testField'); - $this->assertEquals(Inflector::variable('test_fieLd'), 'testFieLd'); - $this->assertEquals(Inflector::variable('test field'), 'testField'); - $this->assertEquals(Inflector::variable('Test_field'), 'testField'); - } - -/** - * testClassNaming method - * - * @return void - */ - public function testClassNaming() { - $this->assertEquals(Inflector::classify('artists_genres'), 'ArtistsGenre'); - $this->assertEquals(Inflector::classify('file_systems'), 'FileSystem'); - $this->assertEquals(Inflector::classify('news'), 'News'); - $this->assertEquals(Inflector::classify('bureaus'), 'Bureau'); - } - -/** - * testTableNaming method - * - * @return void - */ - public function testTableNaming() { - $this->assertEquals(Inflector::tableize('ArtistsGenre'), 'artists_genres'); - $this->assertEquals(Inflector::tableize('FileSystem'), 'file_systems'); - $this->assertEquals(Inflector::tableize('News'), 'news'); - $this->assertEquals(Inflector::tableize('Bureau'), 'bureaus'); - } - -/** - * testHumanization method - * - * @return void - */ - public function testHumanization() { - $this->assertEquals(Inflector::humanize('posts'), 'Posts'); - $this->assertEquals(Inflector::humanize('posts_tags'), 'Posts Tags'); - $this->assertEquals(Inflector::humanize('file_systems'), 'File Systems'); - } - -/** - * testCustomPluralRule method - * - * @return void - */ - public function testCustomPluralRule() { - Inflector::rules('plural', array('/^(custom)$/i' => '\1izables')); - $this->assertEquals(Inflector::pluralize('custom'), 'customizables'); - - Inflector::rules('plural', array('uninflected' => array('uninflectable'))); - $this->assertEquals(Inflector::pluralize('uninflectable'), 'uninflectable'); - - Inflector::rules('plural', array( - 'rules' => array('/^(alert)$/i' => '\1ables'), - 'uninflected' => array('noflect', 'abtuse'), - 'irregular' => array('amaze' => 'amazable', 'phone' => 'phonezes') - )); - $this->assertEquals(Inflector::pluralize('noflect'), 'noflect'); - $this->assertEquals(Inflector::pluralize('abtuse'), 'abtuse'); - $this->assertEquals(Inflector::pluralize('alert'), 'alertables'); - $this->assertEquals(Inflector::pluralize('amaze'), 'amazable'); - $this->assertEquals(Inflector::pluralize('phone'), 'phonezes'); - } - -/** - * testCustomSingularRule method - * - * @return void - */ - public function testCustomSingularRule() { - Inflector::rules('singular', array('/(eple)r$/i' => '\1', '/(jente)r$/i' => '\1')); - - $this->assertEquals(Inflector::singularize('epler'), 'eple'); - $this->assertEquals(Inflector::singularize('jenter'), 'jente'); - - Inflector::rules('singular', array( - 'rules' => array('/^(bil)er$/i' => '\1', '/^(inflec|contribu)tors$/i' => '\1ta'), - 'uninflected' => array('singulars'), - 'irregular' => array('spins' => 'spinor') - )); - - $this->assertEquals(Inflector::singularize('inflectors'), 'inflecta'); - $this->assertEquals(Inflector::singularize('contributors'), 'contributa'); - $this->assertEquals(Inflector::singularize('spins'), 'spinor'); - $this->assertEquals(Inflector::singularize('singulars'), 'singulars'); - } - -/** - * testCustomTransliterationRule method - * - * @return void - */ - public function testCustomTransliterationRule() { - $this->assertEquals(Inflector::slug('Testing æ ø å'), 'Testing_ae_o_a'); - - Inflector::rules('transliteration', array('/å/' => 'aa', '/ø/' => 'oe')); - $this->assertEquals(Inflector::slug('Testing æ ø å'), 'Testing_ae_oe_aa'); - - Inflector::rules('transliteration', array('/ä|æ/' => 'ae', '/å/' => 'aa'), true); - $this->assertEquals(Inflector::slug('Testing æ ø å'), 'Testing_ae_ø_aa'); - } - -/** - * test that setting new rules clears the inflector caches. - * - * @return void - */ - public function testRulesClearsCaches() { - $this->assertEquals(Inflector::singularize('Bananas'), 'Banana'); - $this->assertEquals(Inflector::tableize('Banana'), 'bananas'); - $this->assertEquals(Inflector::pluralize('Banana'), 'Bananas'); - - Inflector::rules('singular', array( - 'rules' => array('/(.*)nas$/i' => '\1zzz') - )); - $this->assertEquals('Banazzz', Inflector::singularize('Bananas'), 'Was inflected with old rules.'); - - Inflector::rules('plural', array( - 'rules' => array('/(.*)na$/i' => '\1zzz'), - 'irregular' => array('corpus' => 'corpora') - )); - $this->assertEquals(Inflector::pluralize('Banana'), 'Banazzz', 'Was inflected with old rules.'); - $this->assertEquals(Inflector::pluralize('corpus'), 'corpora', 'Was inflected with old irregular form.'); - } - -/** - * Test resetting inflection rules. - * - * @return void - */ - public function testCustomRuleWithReset() { - $uninflected = array('atlas', 'lapis', 'onibus', 'pires', 'virus', '.*x'); - $pluralIrregular = array('as' => 'ases'); - - Inflector::rules('singular', array( - 'rules' => array('/^(.*)(a|e|o|u)is$/i' => '\1\2l'), - 'uninflected' => $uninflected, - ), true); - - Inflector::rules('plural', array( - 'rules' => array( - '/^(.*)(a|e|o|u)l$/i' => '\1\2is', - ), - 'uninflected' => $uninflected, - 'irregular' => $pluralIrregular - ), true); - - $this->assertEquals(Inflector::pluralize('Alcool'), 'Alcoois'); - $this->assertEquals(Inflector::pluralize('Atlas'), 'Atlas'); - $this->assertEquals(Inflector::singularize('Alcoois'), 'Alcool'); - $this->assertEquals(Inflector::singularize('Atlas'), 'Atlas'); - } - -} diff --git a/lib/Cake/Test/Case/Utility/ObjectCollectionTest.php b/lib/Cake/Test/Case/Utility/ObjectCollectionTest.php deleted file mode 100644 index 4db295de2e2..00000000000 --- a/lib/Cake/Test/Case/Utility/ObjectCollectionTest.php +++ /dev/null @@ -1,595 +0,0 @@ -_Collection = $collection; - $this->settings = $settings; - } - -} - -/** - * First Extension of Generic Object - */ -class FirstGenericObject extends GenericObject { - -/** - * A generic callback - */ - public function callback() { - } - -} - -/** - * Second Extension of Generic Object - */ -class SecondGenericObject extends GenericObject { - - public function callback() { - } - -} - -/** - * Third Extension of Generic Object - */ -class ThirdGenericObject extends GenericObject { - - public function callback() { - } - -} - -/** - * A collection of Generic objects - */ -class GenericObjectCollection extends ObjectCollection { - -/** - * Loads a generic object - * - * @param string $object Object name - * @param array $settings Settings array - * @return array List of loaded objects - */ - public function load($object, $settings = array()) { - list($plugin, $name) = pluginSplit($object); - if (isset($this->_loaded[$name])) { - return $this->_loaded[$name]; - } - $objectClass = $name . 'GenericObject'; - $this->_loaded[$name] = new $objectClass($this, $settings); - $enable = isset($settings['enabled']) ? $settings['enabled'] : true; - if ($enable === true) { - $this->enable($name); - } - return $this->_loaded[$name]; - } - -} - -class ObjectCollectionTest extends CakeTestCase { - -/** - * setUp - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->Objects = new GenericObjectCollection(); - } - -/** - * tearDown - * - * @return void - */ - public function tearDown() { - unset($this->Objects); - parent::tearDown(); - } - -/** - * test triggering callbacks on loaded helpers - * - * @return void - */ - public function testLoad() { - $result = $this->Objects->load('First'); - $this->assertInstanceOf('FirstGenericObject', $result); - $this->assertInstanceOf('FirstGenericObject', $this->Objects->First); - - $result = $this->Objects->attached(); - $this->assertEquals(array('First'), $result, 'attached() results are wrong.'); - - $this->assertTrue($this->Objects->enabled('First')); - - $result = $this->Objects->load('First'); - $this->assertSame($result, $this->Objects->First); - } - -/** - * test unload() - * - * @return void - */ - public function testUnload() { - $this->Objects->load('First'); - $this->Objects->load('Second'); - - $result = $this->Objects->attached(); - $this->assertEquals(array('First', 'Second'), $result, 'loaded objects are wrong'); - - $this->Objects->unload('First'); - $this->assertFalse(isset($this->Objects->First)); - $this->assertTrue(isset($this->Objects->Second)); - - $result = $this->Objects->attached(); - $this->assertEquals(array('Second'), $result, 'loaded objects are wrong'); - - $result = $this->Objects->enabled(); - $this->assertEquals(array('Second'), $result, 'enabled objects are wrong'); - } - -/** - * Tests set() - * - * @return void - */ - public function testSet() { - $this->Objects->load('First'); - - $result = $this->Objects->attached(); - $this->assertEquals(array('First'), $result, 'loaded objects are wrong'); - - $result = $this->Objects->set('First', new SecondGenericObject($this->Objects)); - $this->assertInstanceOf('SecondGenericObject', $result['First'], 'set failed'); - - $result = $this->Objects->set('Second', new SecondGenericObject($this->Objects)); - $this->assertInstanceOf('SecondGenericObject', $result['Second'], 'set failed'); - - $this->assertEquals(2, count($result)); - } - -/** - * creates mock classes for testing - * - * @return void - */ - protected function _makeMockClasses() { - if (!class_exists('TriggerMockFirstGenericObject')) { - $this->getMock('FirstGenericObject', array(), array(), 'TriggerMockFirstGenericObject', false); - } - if (!class_exists('TriggerMockSecondGenericObject')) { - $this->getMock('SecondGenericObject', array(), array(), 'TriggerMockSecondGenericObject', false); - } - if (!class_exists('TriggerMockThirdGenericObject')) { - $this->getMock('ThirdGenericObject', array(), array(), 'TriggerMockThirdGenericObject', false); - } - } - -/** - * test triggering callbacks. - * - * @return void - */ - public function testTrigger() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->will($this->returnValue(true)); - $this->Objects->TriggerMockSecond->expects($this->once()) - ->method('callback') - ->will($this->returnValue(true)); - - $this->assertTrue($this->Objects->trigger('callback')); - } - -/** - * test trigger and disabled objects - * - * @return void - */ - public function testTriggerWithDisabledObjects() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->will($this->returnValue(true)); - $this->Objects->TriggerMockSecond->expects($this->never()) - ->method('callback') - ->will($this->returnValue(true)); - - $this->Objects->disable('TriggerMockSecond'); - - $this->assertTrue($this->Objects->trigger('callback', array())); - } - -/** - * test that the collectReturn option works. - * - * @return void - */ - public function testTriggerWithCollectReturn() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->will($this->returnValue(array('one', 'two'))); - $this->Objects->TriggerMockSecond->expects($this->once()) - ->method('callback') - ->will($this->returnValue(array('three', 'four'))); - - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - array('one', 'two'), - array('three', 'four') - ); - $this->assertEquals($expected, $result); - } - -/** - * test that trigger with break & breakOn works. - * - * @return void - */ - public function testTriggerWithBreak() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->will($this->returnValue(false)); - $this->Objects->TriggerMockSecond->expects($this->never()) - ->method('callback'); - - $result = $this->Objects->trigger( - 'callback', - array(), - array('break' => true, 'breakOn' => false) - ); - $this->assertFalse($result); - } - -/** - * test that trigger with modParams works. - * - * @return void - */ - public function testTriggerWithModParams() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->with(array('value')) - ->will($this->returnValue(array('new value'))); - - $this->Objects->TriggerMockSecond->expects($this->once()) - ->method('callback') - ->with(array('new value')) - ->will($this->returnValue(array('newer value'))); - - $result = $this->Objects->trigger( - 'callback', - array(array('value')), - array('modParams' => 0) - ); - $this->assertEquals(array('newer value'), $result); - } - -/** - * test that setting modParams to an index that doesn't exist doesn't cause errors. - * - * @expectedException CakeException - * @return void - */ - public function testTriggerModParamsInvalidIndex() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->never()) - ->method('callback'); - - $this->Objects->TriggerMockSecond->expects($this->never()) - ->method('callback'); - - $result = $this->Objects->trigger( - 'callback', - array(array('value')), - array('modParams' => 2) - ); - } - -/** - * test that returning null doesn't modify parameters. - * - * @return void - */ - public function testTriggerModParamsNullIgnored() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->with(array('value')) - ->will($this->returnValue(null)); - - $this->Objects->TriggerMockSecond->expects($this->once()) - ->method('callback') - ->with(array('value')) - ->will($this->returnValue(array('new value'))); - - $result = $this->Objects->trigger( - 'callback', - array(array('value')), - array('modParams' => 0) - ); - $this->assertEquals(array('new value'), $result); - } - -/** - * test order of callbacks triggering based on priority. - * - * @return void - */ - public function testTriggerPriority() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond', array('priority' => 5)); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $this->Objects->TriggerMockFirst->expects($this->any()) - ->method('callback') - ->will($this->returnValue('1st')); - $this->Objects->TriggerMockSecond->expects($this->any()) - ->method('callback') - ->will($this->returnValue('2nd')); - - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '1st' - ); - $this->assertEquals($expected, $result); - - $this->Objects->load('TriggerMockThird', array('priority' => 7)); - $this->mockObjects[] = $this->Objects->TriggerMockThird; - $this->Objects->TriggerMockThird->expects($this->any()) - ->method('callback') - ->will($this->returnValue('3rd')); - - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '3rd', - '1st' - ); - $this->assertEquals($expected, $result); - - $this->Objects->disable('TriggerMockFirst'); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '3rd' - ); - $this->assertEquals($expected, $result); - - $this->Objects->enable('TriggerMockFirst'); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '3rd', - '1st' - ); - $this->assertEquals($expected, $result); - - $this->Objects->disable('TriggerMockThird'); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '1st' - ); - $this->assertEquals($expected, $result); - - $this->Objects->enable('TriggerMockThird', false); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '1st', - '3rd' - ); - $this->assertEquals($expected, $result); - - $this->Objects->setPriority('TriggerMockThird', 1); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '3rd', - '2nd', - '1st' - ); - $this->assertEquals($expected, $result); - - $this->Objects->disable('TriggerMockThird'); - $this->Objects->setPriority('TriggerMockThird', 11); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '1st' - ); - $this->assertEquals($expected, $result); - - $this->Objects->enable('TriggerMockThird'); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '1st', - '3rd' - ); - $this->assertEquals($expected, $result); - - $this->Objects->setPriority('TriggerMockThird'); - $result = $this->Objects->trigger('callback', array(), array('collectReturn' => true)); - $expected = array( - '2nd', - '1st', - '3rd' - ); - $this->assertEquals($expected, $result); - } - -/** - * test normalizeObjectArray - * - * @return void - */ - public function testnormalizeObjectArray() { - $components = array( - 'Html', - 'Foo.Bar' => array('one', 'two'), - 'Something', - 'Banana.Apple' => array('foo' => 'bar') - ); - $result = ObjectCollection::normalizeObjectArray($components); - $expected = array( - 'Html' => array('class' => 'Html', 'settings' => array()), - 'Bar' => array('class' => 'Foo.Bar', 'settings' => array('one', 'two')), - 'Something' => array('class' => 'Something', 'settings' => array()), - 'Apple' => array('class' => 'Banana.Apple', 'settings' => array('foo' => 'bar')), - ); - $this->assertEquals($expected, $result); - - // This is the result after Controller::_mergeVars - $components = array( - 'Html' => null, - 'Foo.Bar' => array('one', 'two'), - 'Something' => null, - 'Banana.Apple' => array('foo' => 'bar') - ); - $result = ObjectCollection::normalizeObjectArray($components); - $this->assertEquals($expected, $result); - } - -/** - * tests that passing an instance of CakeEvent to trigger will prepend the subject to the list of arguments - * - * @return void - */ - public function testDispatchEventWithSubject() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $subjectClass = new Object(); - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->with($subjectClass, 'first argument') - ->will($this->returnValue(true)); - $this->Objects->TriggerMockSecond->expects($this->once()) - ->method('callback') - ->with($subjectClass, 'first argument') - ->will($this->returnValue(true)); - - $event = new CakeEvent('callback', $subjectClass, array('first argument')); - $this->assertTrue($this->Objects->trigger($event)); - } - -/** - * tests that passing an instance of CakeEvent to trigger with omitSubject property - * will NOT prepend the subject to the list of arguments - * - * @return void - */ - public function testDispatchEventNoSubject() { - $this->_makeMockClasses(); - $this->Objects->load('TriggerMockFirst'); - $this->Objects->load('TriggerMockSecond'); - - $this->mockObjects[] = $this->Objects->TriggerMockFirst; - $this->mockObjects[] = $this->Objects->TriggerMockSecond; - - $subjectClass = new Object(); - $this->Objects->TriggerMockFirst->expects($this->once()) - ->method('callback') - ->with('first argument') - ->will($this->returnValue(true)); - $this->Objects->TriggerMockSecond->expects($this->once()) - ->method('callback') - ->with('first argument') - ->will($this->returnValue(true)); - - $event = new CakeEvent('callback', $subjectClass, array('first argument')); - $event->omitSubject = true; - $this->assertTrue($this->Objects->trigger($event)); - } - -} diff --git a/lib/Cake/Test/Case/Utility/SanitizeTest.php b/lib/Cake/Test/Case/Utility/SanitizeTest.php deleted file mode 100644 index b731b5559a1..00000000000 --- a/lib/Cake/Test/Case/Utility/SanitizeTest.php +++ /dev/null @@ -1,462 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Utility - * @since CakePHP(tm) v 1.2.0.5428 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Sanitize', 'Utility'); - -/** - * DataTest class - * - * @package Cake.Test.Case.Utility - */ -class SanitizeDataTest extends CakeTestModel { - -/** - * name property - * - * @var string 'SanitizeDataTest' - */ - public $name = 'SanitizeDataTest'; - -/** - * useTable property - * - * @var string 'data_tests' - */ - public $useTable = 'data_tests'; -} - -/** - * Article class - * - * @package Cake.Test.Case.Utility - */ -class SanitizeArticle extends CakeTestModel { - -/** - * name property - * - * @var string 'Article' - */ - public $name = 'SanitizeArticle'; - -/** - * useTable property - * - * @var string 'articles' - */ - public $useTable = 'articles'; -} - -/** - * SanitizeTest class - * - * @package Cake.Test.Case.Utility - */ -class SanitizeTest extends CakeTestCase { - -/** - * autoFixtures property - * - * @var bool false - */ - public $autoFixtures = false; - -/** - * fixtures property - * - * @var array - */ - public $fixtures = array('core.data_test', 'core.article'); - -/** - * testEscapeAlphaNumeric method - * - * @return void - */ - public function testEscapeAlphaNumeric() { - $resultAlpha = Sanitize::escape('abc', 'test'); - $this->assertEquals('abc', $resultAlpha); - - $resultNumeric = Sanitize::escape('123', 'test'); - $this->assertEquals('123', $resultNumeric); - - $resultNumeric = Sanitize::escape(1234, 'test'); - $this->assertEquals(1234, $resultNumeric); - - $resultNumeric = Sanitize::escape(1234.23, 'test'); - $this->assertEquals(1234.23, $resultNumeric); - - $resultNumeric = Sanitize::escape('#1234.23', 'test'); - $this->assertEquals('#1234.23', $resultNumeric); - - $resultNull = Sanitize::escape(null, 'test'); - $this->assertEquals(null, $resultNull); - - $resultNull = Sanitize::escape(false, 'test'); - $this->assertEquals(false, $resultNull); - - $resultNull = Sanitize::escape(true, 'test'); - $this->assertEquals(true, $resultNull); - } - -/** - * testClean method - * - * @return void - */ - public function testClean() { - $string = 'test & "quote" \'other\' ;.$ symbol.' . "\r" . 'another line'; - $expected = 'test & "quote" 'other' ;.$ symbol.another line'; - $result = Sanitize::clean($string, array('connection' => 'test')); - $this->assertEquals($expected, $result); - - $string = 'test & "quote" \'other\' ;.$ symbol.' . "\r" . 'another line'; - $expected = 'test & ' . Sanitize::escape('"quote"', 'test') . ' ' . Sanitize::escape('\'other\'', 'test') . ' ;.$ symbol.another line'; - $result = Sanitize::clean($string, array('encode' => false, 'connection' => 'test')); - $this->assertEquals($expected, $result); - - $string = 'test & "quote" \'other\' ;.$ \\$ symbol.' . "\r" . 'another line'; - $expected = 'test & "quote" \'other\' ;.$ $ symbol.another line'; - $result = Sanitize::clean($string, array('encode' => false, 'escape' => false, 'connection' => 'test')); - $this->assertEquals($expected, $result); - - $string = 'test & "quote" \'other\' ;.$ \\$ symbol.' . "\r" . 'another line'; - $expected = 'test & "quote" \'other\' ;.$ \\$ symbol.another line'; - $result = Sanitize::clean($string, array('encode' => false, 'escape' => false, 'dollar' => false, 'connection' => 'test')); - $this->assertEquals($expected, $result); - - $string = 'test & "quote" \'other\' ;.$ symbol.' . "\r" . 'another line'; - $expected = 'test & "quote" \'other\' ;.$ symbol.' . "\r" . 'another line'; - $result = Sanitize::clean($string, array('encode' => false, 'escape' => false, 'carriage' => false, 'connection' => 'test')); - $this->assertEquals($expected, $result); - - $array = array(array('test & "quote" \'other\' ;.$ symbol.' . "\r" . 'another line')); - $expected = array(array('test & "quote" 'other' ;.$ symbol.another line')); - $result = Sanitize::clean($array, array('connection' => 'test')); - $this->assertEquals($expected, $result); - - $array = array(array('test & "quote" \'other\' ;.$ \\$ symbol.' . "\r" . 'another line')); - $expected = array(array('test & "quote" \'other\' ;.$ $ symbol.another line')); - $result = Sanitize::clean($array, array('encode' => false, 'escape' => false, 'connection' => 'test')); - $this->assertEquals($expected, $result); - - $array = array(array('test odd Ä spacesé')); - $expected = array(array('test odd Ä spacesé')); - $result = Sanitize::clean($array, array('odd_spaces' => false, 'escape' => false, 'connection' => 'test')); - $this->assertEquals($expected, $result); - - $array = array(array('\\$', array('key' => 'test & "quote" \'other\' ;.$ \\$ symbol.' . "\r" . 'another line'))); - $expected = array(array('$', array('key' => 'test & "quote" \'other\' ;.$ $ symbol.another line'))); - $result = Sanitize::clean($array, array('encode' => false, 'escape' => false, 'connection' => 'test')); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::clean($string, array('connection' => 'test')); - $this->assertEquals($expected, $string); - - $data = array( - 'Grant' => array( - 'title' => '2 o clock grant', - 'grant_peer_review_id' => 3, - 'institution_id' => 5, - 'created_by' => 1, - 'modified_by' => 1, - 'created' => '2010-07-15 14:11:00', - 'modified' => '2010-07-19 10:45:41' - ), - 'GrantsMember' => array( - 0 => array( - 'id' => 68, - 'grant_id' => 120, - 'member_id' => 16, - 'program_id' => 29, - 'pi_percent_commitment' => 1 - ) - ) - ); - $result = Sanitize::clean($data, array('connection' => 'test')); - $this->assertEquals($data, $result); - } - -/** - * testHtml method - * - * @return void - */ - public function testHtml() { - $string = '

This is a test string & so is this

'; - $expected = 'This is a test string & so is this'; - $result = Sanitize::html($string, array('remove' => true)); - $this->assertEquals($expected, $result); - - $string = 'The "lazy" dog \'jumped\' & flew over the moon. If (1+1) = 2 is true, (2-1) = 1 is also true'; - $expected = 'The "lazy" dog 'jumped' & flew over the moon. If (1+1) = 2 <em>is</em> true, (2-1) = 1 is also true'; - $result = Sanitize::html($string); - $this->assertEquals($expected, $result); - - $string = 'The "lazy" dog \'jumped\''; - $expected = 'The "lazy" dog \'jumped\''; - $result = Sanitize::html($string, array('quotes' => ENT_COMPAT)); - $this->assertEquals($expected, $result); - - $string = 'The "lazy" dog \'jumped\''; - $result = Sanitize::html($string, array('quotes' => ENT_NOQUOTES)); - $this->assertEquals($string, $result); - - $string = 'The "lazy" dog \'jumped\' & flew over the moon. If (1+1) = 2 is true, (2-1) = 1 is also true'; - $expected = 'The "lazy" dog 'jumped' & flew over the moon. If (1+1) = 2 <em>is</em> true, (2-1) = 1 is also true'; - $result = Sanitize::html($string); - $this->assertEquals($expected, $result); - - $string = 'The "lazy" dog & his friend Apple® conquered the world'; - $expected = 'The "lazy" dog & his friend Apple&reg; conquered the world'; - $result = Sanitize::html($string); - $this->assertEquals($expected, $result); - - $string = 'The "lazy" dog & his friend Apple® conquered the world'; - $expected = 'The "lazy" dog & his friend Apple® conquered the world'; - $result = Sanitize::html($string, array('double' => false)); - $this->assertEquals($expected, $result); - } - -/** - * testStripWhitespace method - * - * @return void - */ - public function testStripWhitespace() { - $string = "This sentence \t\t\t has lots of \n\n white\nspace \rthat \r\n needs to be \t \n trimmed."; - $expected = "This sentence has lots of whitespace that needs to be trimmed."; - $result = Sanitize::stripWhitespace($string); - $this->assertEquals($expected, $result); - - $text = 'I love ßá†ö√ letters.'; - $result = Sanitize::stripWhitespace($text); - $expected = 'I love ßá†ö√ letters.'; - $this->assertEquals($expected, $result); - } - -/** - * testParanoid method - * - * @return void - */ - public function testParanoid() { - $string = 'I would like to !%@#% & dance & sing ^$&*()-+'; - $expected = 'Iwouldliketodancesing'; - $result = Sanitize::paranoid($string); - $this->assertEquals($expected, $result); - - $string = array('This |s th% s0ng that never ends it g*es', - 'on and on my friends, b^ca#use it is the', - 'so&g th===t never ends.'); - $expected = array('This s th% s0ng that never ends it g*es', - 'on and on my friends bcause it is the', - 'sog tht never ends.'); - $result = Sanitize::paranoid($string, array('%', '*', '.', ' ')); - $this->assertEquals($expected, $result); - - $string = "anything' OR 1 = 1"; - $expected = 'anythingOR11'; - $result = Sanitize::paranoid($string); - $this->assertEquals($expected, $result); - - $string = "x' AND email IS NULL; --"; - $expected = 'xANDemailISNULL'; - $result = Sanitize::paranoid($string); - $this->assertEquals($expected, $result); - - $string = "x' AND 1=(SELECT COUNT(*) FROM users); --"; - $expected = "xAND1SELECTCOUNTFROMusers"; - $result = Sanitize::paranoid($string); - $this->assertEquals($expected, $result); - - $string = "x'; DROP TABLE members; --"; - $expected = "xDROPTABLEmembers"; - $result = Sanitize::paranoid($string); - $this->assertEquals($expected, $result); - } - -/** - * testStripImages method - * - * @return void - */ - public function testStripImages() { - $string = 'my image'; - $expected = 'my image
'; - $result = Sanitize::stripImages($string); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::stripImages($string); - $this->assertEquals($expected, $result); - - $string = 'test image alt'; - $expected = 'test image alt
'; - $result = Sanitize::stripImages($string); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::stripImages($string); - $this->assertEquals($expected, $result); - } - -/** - * testStripScripts method - * - * @return void - */ - public function testStripScripts() { - $string = ''; - $expected = ''; - $result = Sanitize::stripScripts($string); - $this->assertEquals($expected, $result); - - $string = '' . "\n" . - '' . "\n" . - '' . "\n" . - ''; - $expected = "\n" . '' . "\n" . - '' . "\n" . - ''; - $result = Sanitize::stripScripts($string); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::stripScripts($string); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::stripScripts($string); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::stripScripts($string); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::stripScripts($string); - $this->assertEquals($expected, $result); - - $string = << - - -text -HTML; - $expected = "text\n\ntext"; - $result = Sanitize::stripScripts($string); - $this->assertTextEquals($expected, $result); - - $string = << - - -text -HTML; - $expected = "text\n\ntext"; - $result = Sanitize::stripScripts($string); - $this->assertTextEquals($expected, $result); - } - -/** - * testStripAll method - * - * @return void - */ - public function testStripAll() { - $string = '"/>'; - $expected = '"/>'; - $result = Sanitize::stripAll($string); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = Sanitize::stripAll($string); - $this->assertEquals($expected, $result); - - $string = '<'; - $expected = '<'; - $result = Sanitize::stripAll($string); - $this->assertEquals($expected, $result); - - $string = '' . "\n" . - "

This is ok \t\n text

\n" . - '' . "\n" . - ''; - $expected = '

This is ok text

'; - $result = Sanitize::stripAll($string); - $this->assertEquals($expected, $result); - } - -/** - * testStripTags method - * - * @return void - */ - public function testStripTags() { - $string = '

Headline

My Link could go to a bad site

'; - $expected = 'Headline

My Link could go to a bad site

'; - $result = Sanitize::stripTags($string, 'h2', 'a'); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ' '; - $result = Sanitize::stripTags($string, 'script'); - $this->assertEquals($expected, $result); - - $string = '

Important

Additional information here . Read even more here

'; - $expected = 'Important

Additional information here . Read even more here

'; - $result = Sanitize::stripTags($string, 'h2', 'a'); - $this->assertEquals($expected, $result); - - $string = '

Important

Additional information here . Read even more here

'; - $expected = 'Important

Additional information here . Read even more here

'; - $result = Sanitize::stripTags($string, 'h2', 'a', 'img'); - $this->assertEquals($expected, $result); - - $string = 'Important message!
This message will self destruct!'; - $expected = 'Important message!
This message will self destruct!'; - $result = Sanitize::stripTags($string, 'b'); - $this->assertEquals($expected, $result); - - $string = 'Important message!
This message will self destruct!'; - $expected = 'Important message!
This message will self destruct!'; - $result = Sanitize::stripTags($string, 'b'); - $this->assertEquals($expected, $result); - - $string = '

Important

Additional information here . Read even more here

'; - $expected = 'Important

Additional information here . Read even more here

'; - $result = Sanitize::stripTags($string, 'h2', 'a', 'img'); - $this->assertEquals($expected, $result); - } -} diff --git a/lib/Cake/Test/Case/Utility/SecurityTest.php b/lib/Cake/Test/Case/Utility/SecurityTest.php deleted file mode 100644 index 67f104a0d0c..00000000000 --- a/lib/Cake/Test/Case/Utility/SecurityTest.php +++ /dev/null @@ -1,160 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Utility - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Security', 'Utility'); - -/** - * SecurityTest class - * - * @package Cake.Test.Case.Utility - */ -class SecurityTest extends CakeTestCase { - -/** - * sut property - * - * @var mixed null - */ - public $sut = null; - -/** - * testInactiveMins method - * - * @return void - */ - public function testInactiveMins() { - Configure::write('Security.level', 'high'); - $this->assertEquals(10, Security::inactiveMins()); - - Configure::write('Security.level', 'medium'); - $this->assertEquals(100, Security::inactiveMins()); - - Configure::write('Security.level', 'low'); - $this->assertEquals(300, Security::inactiveMins()); - } - -/** - * testGenerateAuthkey method - * - * @return void - */ - public function testGenerateAuthkey() { - $this->assertEquals(strlen(Security::generateAuthKey()), 40); - } - -/** - * testValidateAuthKey method - * - * @return void - */ - public function testValidateAuthKey() { - $authKey = Security::generateAuthKey(); - $this->assertTrue(Security::validateAuthKey($authKey)); - } - -/** - * testHash method - * - * @return void - */ - public function testHash() { - $_hashType = Security::$hashType; - - $key = 'someKey'; - $hash = 'someHash'; - - $this->assertSame(strlen(Security::hash($key, null, false)), 40); - $this->assertSame(strlen(Security::hash($key, 'sha1', false)), 40); - $this->assertSame(strlen(Security::hash($key, null, true)), 40); - $this->assertSame(strlen(Security::hash($key, 'sha1', true)), 40); - - $result = Security::hash($key, null, $hash); - $this->assertSame($result, 'e38fcb877dccb6a94729a81523851c931a46efb1'); - - $result = Security::hash($key, 'sha1', $hash); - $this->assertSame($result, 'e38fcb877dccb6a94729a81523851c931a46efb1'); - - $hashType = 'sha1'; - Security::setHash($hashType); - $this->assertSame(Security::$hashType, $hashType); - $this->assertSame(strlen(Security::hash($key, null, true)), 40); - $this->assertSame(strlen(Security::hash($key, null, false)), 40); - - $this->assertSame(strlen(Security::hash($key, 'md5', false)), 32); - $this->assertSame(strlen(Security::hash($key, 'md5', true)), 32); - - $hashType = 'md5'; - Security::setHash($hashType); - $this->assertSame(Security::$hashType, $hashType); - $this->assertSame(strlen(Security::hash($key, null, false)), 32); - $this->assertSame(strlen(Security::hash($key, null, true)), 32); - - if (!function_exists('hash') && !function_exists('mhash')) { - $this->assertSame(strlen(Security::hash($key, 'sha256', false)), 32); - $this->assertSame(strlen(Security::hash($key, 'sha256', true)), 32); - } else { - $this->assertSame(strlen(Security::hash($key, 'sha256', false)), 64); - $this->assertSame(strlen(Security::hash($key, 'sha256', true)), 64); - } - - Security::setHash($_hashType); - } - -/** - * testCipher method - * - * @return void - */ - public function testCipher() { - $length = 10; - $txt = ''; - for ($i = 0; $i < $length; $i++) { - $txt .= mt_rand(0, 255); - } - $key = 'my_key'; - $result = Security::cipher($txt, $key); - $this->assertEquals($txt, Security::cipher($result, $key)); - - $txt = ''; - $key = 'my_key'; - $result = Security::cipher($txt, $key); - $this->assertEquals($txt, Security::cipher($result, $key)); - - $txt = 123456; - $key = 'my_key'; - $result = Security::cipher($txt, $key); - $this->assertEquals($txt, Security::cipher($result, $key)); - - $txt = '123456'; - $key = 'my_key'; - $result = Security::cipher($txt, $key); - $this->assertEquals($txt, Security::cipher($result, $key)); - } - -/** - * testCipherEmptyKey method - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testCipherEmptyKey() { - $txt = 'some_text'; - $key = ''; - $result = Security::cipher($txt, $key); - } -} diff --git a/lib/Cake/Test/Case/Utility/SetTest.php b/lib/Cake/Test/Case/Utility/SetTest.php deleted file mode 100644 index cd7b34e57dd..00000000000 --- a/lib/Cake/Test/Case/Utility/SetTest.php +++ /dev/null @@ -1,3596 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Utility - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Set', 'Utility'); -App::uses('Model', 'Model'); - -/** - * SetTest class - * - * @package Cake.Test.Case.Utility - */ -class SetTest extends CakeTestCase { - -/** - * testNumericKeyExtraction method - * - * @return void - */ - public function testNumericKeyExtraction() { - $data = array('plugin' => null, 'controller' => '', 'action' => '', 1, 'whatever'); - $this->assertEquals(array(1, 'whatever'), Set::extract($data, '{n}')); - $this->assertEquals(array('plugin' => null, 'controller' => '', 'action' => ''), Set::diff($data, Set::extract($data, '{n}'))); - } - -/** - * testEnum method - * - * @return void - */ - public function testEnum() { - $result = Set::enum(1, 'one, two'); - $this->assertEquals('two', $result); - $result = Set::enum(2, 'one, two'); - $this->assertNull($result); - - $set = array('one', 'two'); - $result = Set::enum(0, $set); - $this->assertEquals('one', $result); - $result = Set::enum(1, $set); - $this->assertEquals('two', $result); - - $result = Set::enum(1, array('one', 'two')); - $this->assertEquals('two', $result); - $result = Set::enum(2, array('one', 'two')); - $this->assertNull($result); - - $result = Set::enum('first', array('first' => 'one', 'second' => 'two')); - $this->assertEquals('one', $result); - $result = Set::enum('third', array('first' => 'one', 'second' => 'two')); - $this->assertNull($result); - - $result = Set::enum('no', array('no' => 0, 'yes' => 1)); - $this->assertEquals(0, $result); - $result = Set::enum('not sure', array('no' => 0, 'yes' => 1)); - $this->assertNull($result); - - $result = Set::enum(0); - $this->assertEquals('no', $result); - $result = Set::enum(1); - $this->assertEquals('yes', $result); - $result = Set::enum(2); - $this->assertNull($result); - } - -/** - * testFilter method - * - * @return void - */ - public function testFilter() { - $result = Set::filter(array('0', false, true, 0, array('one thing', 'I can tell you', 'is you got to be', false))); - $expected = array('0', 2 => true, 3 => 0, 4 => array('one thing', 'I can tell you', 'is you got to be')); - $this->assertSame($expected, $result); - - $result = Set::filter(array(1, array(false))); - $expected = array(1); - $this->assertEquals($expected, $result); - - $result = Set::filter(array(1, array(false, false))); - $expected = array(1); - $this->assertEquals($expected, $result); - - $result = Set::filter(array(1, array('empty', false))); - $expected = array(1, array('empty')); - $this->assertEquals($expected, $result); - - $result = Set::filter(array(1, array('2', false, array(3, null)))); - $expected = array(1, array('2', 2 => array(3))); - $this->assertEquals($expected, $result); - - $this->assertSame(array(), Set::filter(array())); - } - -/** - * testNumericArrayCheck method - * - * @return void - */ - public function testNumericArrayCheck() { - $data = array('one'); - $this->assertTrue(Set::numeric(array_keys($data))); - - $data = array(1 => 'one'); - $this->assertFalse(Set::numeric($data)); - - $data = array('one'); - $this->assertFalse(Set::numeric($data)); - - $data = array('one' => 'two'); - $this->assertFalse(Set::numeric($data)); - - $data = array('one' => 1); - $this->assertTrue(Set::numeric($data)); - - $data = array(0); - $this->assertTrue(Set::numeric($data)); - - $data = array('one', 'two', 'three', 'four', 'five'); - $this->assertTrue(Set::numeric(array_keys($data))); - - $data = array(1 => 'one', 2 => 'two', 3 => 'three', 4 => 'four', 5 => 'five'); - $this->assertTrue(Set::numeric(array_keys($data))); - - $data = array('1' => 'one', 2 => 'two', 3 => 'three', 4 => 'four', 5 => 'five'); - $this->assertTrue(Set::numeric(array_keys($data))); - - $data = array('one', 2 => 'two', 3 => 'three', 4 => 'four', 'a' => 'five'); - $this->assertFalse(Set::numeric(array_keys($data))); - } - -/** - * testKeyCheck method - * - * @return void - */ - public function testKeyCheck() { - $data = array('Multi' => array('dimensonal' => array('array'))); - $this->assertTrue(Set::check($data, 'Multi.dimensonal')); - $this->assertFalse(Set::check($data, 'Multi.dimensonal.array')); - - $data = array( - array( - 'Article' => array('id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31'), - 'User' => array('id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'), - 'Comment' => array( - array('id' => '1', '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'), - array('id' => '2', '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'), - ), - 'Tag' => array( - array('id' => '1', 'tag' => 'tag1', 'created' => '2007-03-18 12:22:23', 'updated' => '2007-03-18 12:24:31'), - array('id' => '2', 'tag' => 'tag2', 'created' => '2007-03-18 12:24:23', 'updated' => '2007-03-18 12:26:31') - ) - ), - array( - 'Article' => array('id' => '3', 'user_id' => '1', 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31'), - 'User' => array('id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'), - 'Comment' => array(), - 'Tag' => array() - ) - ); - $this->assertTrue(Set::check($data, '0.Article.user_id')); - $this->assertTrue(Set::check($data, '0.Comment.0.id')); - $this->assertFalse(Set::check($data, '0.Comment.0.id.0')); - $this->assertTrue(Set::check($data, '0.Article.user_id')); - $this->assertFalse(Set::check($data, '0.Article.user_id.a')); - } - -/** - * testMerge method - * - * @return void - */ - public function testMerge() { - $r = Set::merge(array('foo')); - $this->assertEquals(array('foo'), $r); - - $r = Set::merge('foo'); - $this->assertEquals(array('foo'), $r); - - $r = Set::merge('foo', 'bar'); - $this->assertEquals(array('foo', 'bar'), $r); - - $r = Set::merge('foo', array('user' => 'bob', 'no-bar'), 'bar'); - $this->assertEquals(array('foo', 'user' => 'bob', 'no-bar', 'bar'), $r); - - $a = array('foo', 'foo2'); - $b = array('bar', 'bar2'); - $this->assertEquals(array('foo', 'foo2', 'bar', 'bar2'), Set::merge($a, $b)); - - $a = array('foo' => 'bar', 'bar' => 'foo'); - $b = array('foo' => 'no-bar', 'bar' => 'no-foo'); - $this->assertEquals(array('foo' => 'no-bar', 'bar' => 'no-foo'), Set::merge($a, $b)); - - $a = array('users' => array('bob', 'jim')); - $b = array('users' => array('lisa', 'tina')); - $this->assertEquals(array('users' => array('bob', 'jim', 'lisa', 'tina')), Set::merge($a, $b)); - - $a = array('users' => array('jim', 'bob')); - $b = array('users' => 'none'); - $this->assertEquals(array('users' => 'none'), Set::merge($a, $b)); - - $a = array('users' => array('lisa' => array('id' => 5, 'pw' => 'secret')), 'cakephp'); - $b = array('users' => array('lisa' => array('pw' => 'new-pass', 'age' => 23)), 'ice-cream'); - $this->assertEquals(array('users' => array('lisa' => array('id' => 5, 'pw' => 'new-pass', 'age' => 23)), 'cakephp', 'ice-cream'), Set::merge($a, $b)); - - $c = array('users' => array('lisa' => array('pw' => 'you-will-never-guess', 'age' => 25, 'pet' => 'dog')), 'chocolate'); - $expected = array('users' => array('lisa' => array('id' => 5, 'pw' => 'you-will-never-guess', 'age' => 25, 'pet' => 'dog')), 'cakephp', 'ice-cream', 'chocolate'); - $this->assertEquals($expected, Set::merge($a, $b, $c)); - - $this->assertEquals(Set::merge($a, $b, array(), $c), $expected); - - $r = Set::merge($a, $b, $c); - $this->assertEquals($expected, $r); - - $a = array('Tree', 'CounterCache', - 'Upload' => array('folder' => 'products', - 'fields' => array('image_1_id', 'image_2_id', 'image_3_id', 'image_4_id', 'image_5_id'))); - $b = array('Cacheable' => array('enabled' => false), - 'Limit', - 'Bindable', - 'Validator', - 'Transactional'); - - $expected = array('Tree', 'CounterCache', - 'Upload' => array('folder' => 'products', - 'fields' => array('image_1_id', 'image_2_id', 'image_3_id', 'image_4_id', 'image_5_id')), - 'Cacheable' => array('enabled' => false), - 'Limit', - 'Bindable', - 'Validator', - 'Transactional'); - - $this->assertEquals($expected, Set::merge($a, $b)); - - $expected = array('Tree' => null, 'CounterCache' => null, - 'Upload' => array('folder' => 'products', - 'fields' => array('image_1_id', 'image_2_id', 'image_3_id', 'image_4_id', 'image_5_id')), - 'Cacheable' => array('enabled' => false), - 'Limit' => null, - 'Bindable' => null, - 'Validator' => null, - 'Transactional' => null); - - $this->assertEquals($expected, Set::normalize(Set::merge($a, $b))); - } - -/** - * testSort method - * - * @return void - */ - public function testSort() { - $a = array( - 0 => array('Person' => array('name' => 'Jeff'), 'Friend' => array(array('name' => 'Nate'))), - 1 => array('Person' => array('name' => 'Tracy'),'Friend' => array(array('name' => 'Lindsay'))) - ); - $b = array( - 0 => array('Person' => array('name' => 'Tracy'),'Friend' => array(array('name' => 'Lindsay'))), - 1 => array('Person' => array('name' => 'Jeff'), 'Friend' => array(array('name' => 'Nate'))) - - ); - $a = Set::sort($a, '{n}.Friend.{n}.name', 'asc'); - $this->assertEquals($a, $b); - - $b = array( - 0 => array('Person' => array('name' => 'Jeff'), 'Friend' => array(array('name' => 'Nate'))), - 1 => array('Person' => array('name' => 'Tracy'),'Friend' => array(array('name' => 'Lindsay'))) - ); - $a = array( - 0 => array('Person' => array('name' => 'Tracy'),'Friend' => array(array('name' => 'Lindsay'))), - 1 => array('Person' => array('name' => 'Jeff'), 'Friend' => array(array('name' => 'Nate'))) - - ); - $a = Set::sort($a, '{n}.Friend.{n}.name', 'desc'); - $this->assertEquals($a, $b); - - $a = array( - 0 => array('Person' => array('name' => 'Jeff'), 'Friend' => array(array('name' => 'Nate'))), - 1 => array('Person' => array('name' => 'Tracy'),'Friend' => array(array('name' => 'Lindsay'))), - 2 => array('Person' => array('name' => 'Adam'),'Friend' => array(array('name' => 'Bob'))) - ); - $b = array( - 0 => array('Person' => array('name' => 'Adam'),'Friend' => array(array('name' => 'Bob'))), - 1 => array('Person' => array('name' => 'Jeff'), 'Friend' => array(array('name' => 'Nate'))), - 2 => array('Person' => array('name' => 'Tracy'),'Friend' => array(array('name' => 'Lindsay'))) - ); - $a = Set::sort($a, '{n}.Person.name', 'asc'); - $this->assertEquals($a, $b); - - $a = array( - array(7,6,4), - array(3,4,5), - array(3,2,1), - ); - - $b = array( - array(3,2,1), - array(3,4,5), - array(7,6,4), - ); - - $a = Set::sort($a, '{n}.{n}', 'asc'); - $this->assertEquals($a, $b); - - $a = array( - array(7,6,4), - array(3,4,5), - array(3,2,array(1,1,1)), - ); - - $b = array( - array(3,2,array(1,1,1)), - array(3,4,5), - array(7,6,4), - ); - - $a = Set::sort($a, '{n}', 'asc'); - $this->assertEquals($a, $b); - - $a = array( - 0 => array('Person' => array('name' => 'Jeff')), - 1 => array('Shirt' => array('color' => 'black')) - ); - $b = array( - 0 => array('Shirt' => array('color' => 'black')), - 1 => array('Person' => array('name' => 'Jeff')), - ); - $a = Set::sort($a, '{n}.Person.name', 'ASC'); - $this->assertEquals($a, $b); - - $names = array( - array('employees' => array(array('name' => array('first' => 'John', 'last' => 'Doe')))), - array('employees' => array(array('name' => array('first' => 'Jane', 'last' => 'Doe')))), - array('employees' => array(array('name' => array()))), - array('employees' => array(array('name' => array()))) - ); - $result = Set::sort($names, '{n}.employees.0.name', 'asc', 1); - $expected = array( - array('employees' => array(array('name' => array('first' => 'John', 'last' => 'Doe')))), - array('employees' => array(array('name' => array('first' => 'Jane', 'last' => 'Doe')))), - array('employees' => array(array('name' => array()))), - array('employees' => array(array('name' => array()))) - ); - $this->assertEquals($expected, $result); - - $menus = array( - 'blogs' => array('title' => 'Blogs', 'weight' => 3), - 'comments' => array('title' => 'Comments', 'weight' => 2), - 'users' => array('title' => 'Users', 'weight' => 1), - ); - $expected = array( - 'users' => array('title' => 'Users', 'weight' => 1), - 'comments' => array('title' => 'Comments', 'weight' => 2), - 'blogs' => array('title' => 'Blogs', 'weight' => 3), - ); - $result = Set::sort($menus, '{[a-z]+}.weight', 'ASC'); - $this->assertEquals($expected, $result); - } - -/** - * test sorting with string keys. - * - * @return void - */ - public function testSortString() { - $toSort = array( - 'four' => array('number' => 4, 'some' => 'foursome'), - 'six' => array('number' => 6, 'some' => 'sixsome'), - 'five' => array('number' => 5, 'some' => 'fivesome'), - 'two' => array('number' => 2, 'some' => 'twosome'), - 'three' => array('number' => 3, 'some' => 'threesome') - ); - $sorted = Set::sort($toSort, '{s}.number', 'asc'); - $expected = array( - 'two' => array('number' => 2, 'some' => 'twosome'), - 'three' => array('number' => 3, 'some' => 'threesome'), - 'four' => array('number' => 4, 'some' => 'foursome'), - 'five' => array('number' => 5, 'some' => 'fivesome'), - 'six' => array('number' => 6, 'some' => 'sixsome') - ); - $this->assertEquals($expected, $sorted); - } - -/** - * test sorting with out of order keys. - * - * @return void - */ - public function testSortWithOutOfOrderKeys() { - $data = array( - 9 => array('class' => 510, 'test2' => 2), - 1 => array('class' => 500, 'test2' => 1), - 2 => array('class' => 600, 'test2' => 2), - 5 => array('class' => 625, 'test2' => 4), - 0 => array('class' => 605, 'test2' => 3), - ); - $expected = array( - array('class' => 500, 'test2' => 1), - array('class' => 510, 'test2' => 2), - array('class' => 600, 'test2' => 2), - array('class' => 605, 'test2' => 3), - array('class' => 625, 'test2' => 4), - ); - $result = Set::sort($data, '{n}.class', 'asc'); - $this->assertEquals($expected, $result); - - $result = Set::sort($data, '{n}.test2', 'asc'); - $this->assertEquals($expected, $result); - } - -/** - * testExtract method - * - * @return void - */ - public function testExtract() { - $a = array( - array( - 'Article' => array('id' => '1', 'user_id' => '1', 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31'), - 'User' => array('id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'), - 'Comment' => array( - array('id' => '1', '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'), - array('id' => '2', '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'), - ), - 'Tag' => array( - array('id' => '1', 'tag' => 'tag1', 'created' => '2007-03-18 12:22:23', 'updated' => '2007-03-18 12:24:31'), - array('id' => '2', 'tag' => 'tag2', 'created' => '2007-03-18 12:24:23', 'updated' => '2007-03-18 12:26:31') - ), - 'Deep' => array( - 'Nesting' => array( - 'test' => array( - 1 => 'foo', - 2 => array( - 'and' => array('more' => 'stuff') - ) - ) - ) - ) - ), - array( - 'Article' => array('id' => '3', 'user_id' => '1', 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31'), - 'User' => array('id' => '2', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'), - 'Comment' => array(), - 'Tag' => array() - ), - array( - 'Article' => array('id' => '3', 'user_id' => '1', 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31'), - 'User' => array('id' => '3', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'), - 'Comment' => array(), - 'Tag' => array() - ), - array( - 'Article' => array('id' => '3', 'user_id' => '1', 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31'), - 'User' => array('id' => '4', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'), - 'Comment' => array(), - 'Tag' => array() - ), - array( - 'Article' => array('id' => '3', 'user_id' => '1', 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y', 'created' => '2007-03-18 10:43:23', 'updated' => '2007-03-18 10:45:31'), - 'User' => array('id' => '5', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'), - 'Comment' => array(), - 'Tag' => array() - ) - ); - $b = array('Deep' => $a[0]['Deep']); - $c = array( - array('a' => array('I' => array('a' => 1))), - array( - 'a' => array( - 2 - ) - ), - array('a' => array('II' => array('a' => 3, 'III' => array('a' => array('foo' => 4))))), - ); - - $expected = array(array('a' => $c[2]['a'])); - $r = Set::extract('/a/II[a=3]/..', $c); - $this->assertEquals($expected, $r); - - $expected = array(1, 2, 3, 4, 5); - $this->assertEquals($expected, Set::extract('/User/id', $a)); - - $expected = array(1, 2, 3, 4, 5); - $this->assertEquals($expected, Set::extract('/User/id', $a)); - - $expected = array( - array('id' => 1), array('id' => 2), array('id' => 3), array('id' => 4), array('id' => 5) - ); - - $r = Set::extract('/User/id', $a, array('flatten' => false)); - $this->assertEquals($expected, $r); - - $expected = array(array('test' => $a[0]['Deep']['Nesting']['test'])); - $this->assertEquals($expected, Set::extract('/Deep/Nesting/test', $a)); - $this->assertEquals($expected, Set::extract('/Deep/Nesting/test', $b)); - - $expected = array(array('test' => $a[0]['Deep']['Nesting']['test'])); - $r = Set::extract('/Deep/Nesting/test/1/..', $a); - $this->assertEquals($expected, $r); - - $expected = array(array('test' => $a[0]['Deep']['Nesting']['test'])); - $r = Set::extract('/Deep/Nesting/test/2/and/../..', $a); - $this->assertEquals($expected, $r); - - $expected = array(array('test' => $a[0]['Deep']['Nesting']['test'])); - $r = Set::extract('/Deep/Nesting/test/2/../../../Nesting/test/2/..', $a); - $this->assertEquals($expected, $r); - - $expected = array(2); - $r = Set::extract('/User[2]/id', $a); - $this->assertEquals($expected, $r); - - $expected = array(4, 5); - $r = Set::extract('/User[id>3]/id', $a); - $this->assertEquals($expected, $r); - - $expected = array(2, 3); - $r = Set::extract('/User[id>1][id<=3]/id', $a); - $this->assertEquals($expected, $r); - - $expected = array(array('I'), array('II')); - $r = Set::extract('/a/@*', $c); - $this->assertEquals($expected, $r); - - $single = array( - 'User' => array( - 'id' => 4, - 'name' => 'Neo', - ) - ); - $tricky = array( - 0 => array( - 'User' => array( - 'id' => 1, - 'name' => 'John', - ) - ), - 1 => array( - 'User' => array( - 'id' => 2, - 'name' => 'Bob', - ) - ), - 2 => array( - 'User' => array( - 'id' => 3, - 'name' => 'Tony', - ) - ), - 'User' => array( - 'id' => 4, - 'name' => 'Neo', - ) - ); - - $expected = array(1, 2, 3, 4); - $r = Set::extract('/User/id', $tricky); - $this->assertEquals($expected, $r); - - $expected = array(4); - $r = Set::extract('/User/id', $single); - $this->assertEquals($expected, $r); - - $expected = array(1, 3); - $r = Set::extract('/User[name=/n/]/id', $tricky); - $this->assertEquals($expected, $r); - - $expected = array(4); - $r = Set::extract('/User[name=/N/]/id', $tricky); - $this->assertEquals($expected, $r); - - $expected = array(1, 3, 4); - $r = Set::extract('/User[name=/N/i]/id', $tricky); - $this->assertEquals($expected, $r); - - $expected = array(array('id', 'name'), array('id', 'name'), array('id', 'name'), array('id', 'name')); - $r = Set::extract('/User/@*', $tricky); - $this->assertEquals($expected, $r); - - $common = array( - array( - 'Article' => array( - 'id' => 1, - 'name' => 'Article 1', - ), - 'Comment' => array( - array( - 'id' => 1, - 'user_id' => 5, - 'article_id' => 1, - 'text' => 'Comment 1', - ), - array( - 'id' => 2, - 'user_id' => 23, - 'article_id' => 1, - 'text' => 'Comment 2', - ), - array( - 'id' => 3, - 'user_id' => 17, - 'article_id' => 1, - 'text' => 'Comment 3', - ), - ), - ), - array( - 'Article' => array( - 'id' => 2, - 'name' => 'Article 2', - ), - 'Comment' => array( - array( - 'id' => 4, - 'user_id' => 2, - 'article_id' => 2, - 'text' => 'Comment 4', - 'addition' => '', - ), - array( - 'id' => 5, - 'user_id' => 23, - 'article_id' => 2, - 'text' => 'Comment 5', - 'addition' => 'foo', - ), - ), - ), - array( - 'Article' => array( - 'id' => 3, - 'name' => 'Article 3', - ), - 'Comment' => array(), - ) - ); - - $r = Set::extract('/Comment/id', $common); - $expected = array(1, 2, 3, 4, 5); - $this->assertEquals($expected, $r); - - $expected = array(1, 2, 4, 5); - $r = Set::extract('/Comment[id!=3]/id', $common); - $this->assertEquals($expected, $r); - - $r = Set::extract('/', $common); - $this->assertEquals($r, $common); - - $expected = array(1, 2, 4, 5); - $r = Set::extract($common, '/Comment[id!=3]/id'); - $this->assertEquals($expected, $r); - - $expected = array($common[0]['Comment'][2]); - $r = Set::extract($common, '/Comment/2'); - $this->assertEquals($expected, $r); - - $expected = array($common[0]['Comment'][0]); - $r = Set::extract($common, '/Comment[1]/.[id=1]'); - $this->assertEquals($expected, $r); - - $expected = array($common[1]['Comment'][1]); - $r = Set::extract($common, '/1/Comment/.[2]'); - $this->assertEquals($expected, $r); - - $expected = array(); - $r = Set::extract('/User/id', array()); - $this->assertEquals($expected, $r); - - $expected = array(5); - $r = Set::extract('/Comment/id[:last]', $common); - $this->assertEquals($expected, $r); - - $expected = array(1); - $r = Set::extract('/Comment/id[:first]', $common); - $this->assertEquals($expected, $r); - - $expected = array(3); - $r = Set::extract('/Article[:last]/id', $common); - $this->assertEquals($expected, $r); - - $expected = array(array('Comment' => $common[1]['Comment'][0])); - $r = Set::extract('/Comment[addition=]', $common); - $this->assertEquals($expected, $r); - - $habtm = array( - array( - 'Post' => array( - 'id' => 1, - 'title' => 'great post', - ), - 'Comment' => array( - array( - 'id' => 1, - 'text' => 'foo', - 'User' => array( - 'id' => 1, - 'name' => 'bob' - ), - ), - array( - 'id' => 2, - 'text' => 'bar', - 'User' => array( - 'id' => 2, - 'name' => 'tod' - ), - ), - ), - ), - array( - 'Post' => array( - 'id' => 2, - 'title' => 'fun post', - ), - 'Comment' => array( - array( - 'id' => 3, - 'text' => '123', - 'User' => array( - 'id' => 3, - 'name' => 'dan' - ), - ), - array( - 'id' => 4, - 'text' => '987', - 'User' => array( - 'id' => 4, - 'name' => 'jim' - ), - ), - ), - ), - ); - - $r = Set::extract('/Comment/User[name=/bob|dan/]/..', $habtm); - $this->assertEquals('bob', $r[0]['Comment']['User']['name']); - $this->assertEquals('dan', $r[1]['Comment']['User']['name']); - $this->assertEquals(2, count($r)); - - $r = Set::extract('/Comment/User[name=/bob|tod/]/..', $habtm); - $this->assertEquals('bob', $r[0]['Comment']['User']['name']); - - $this->assertEquals('tod', $r[1]['Comment']['User']['name']); - $this->assertEquals(2, count($r)); - - $tree = array( - array( - 'Category' => array('name' => 'Category 1'), - 'children' => array(array('Category' => array('name' => 'Category 1.1'))) - ), - array( - 'Category' => array('name' => 'Category 2'), - 'children' => array( - array('Category' => array('name' => 'Category 2.1')), - array('Category' => array('name' => 'Category 2.2')) - ) - ), - array( - 'Category' => array('name' => 'Category 3'), - 'children' => array(array('Category' => array('name' => 'Category 3.1'))) - ) - ); - - $expected = array(array('Category' => $tree[1]['Category'])); - $r = Set::extract('/Category[name=Category 2]', $tree); - $this->assertEquals($expected, $r); - - $expected = array( - array('Category' => $tree[1]['Category'], 'children' => $tree[1]['children']) - ); - $r = Set::extract('/Category[name=Category 2]/..', $tree); - $this->assertEquals($expected, $r); - - $expected = array( - array('children' => $tree[1]['children'][0]), - array('children' => $tree[1]['children'][1]) - ); - $r = Set::extract('/Category[name=Category 2]/../children', $tree); - $this->assertEquals($expected, $r); - - $habtm = array( - array( - 'Post' => array( - 'id' => 1, - 'title' => 'great post', - ), - 'Comment' => array( - array( - 'id' => 1, - 'text' => 'foo', - 'User' => array( - 'id' => 1, - 'name' => 'bob' - ), - ), - array( - 'id' => 2, - 'text' => 'bar', - 'User' => array( - 'id' => 2, - 'name' => 'tod' - ), - ), - ), - ), - array( - 'Post' => array( - 'id' => 2, - 'title' => 'fun post', - ), - 'Comment' => array( - array( - 'id' => 3, - 'text' => '123', - 'User' => array( - 'id' => 3, - 'name' => 'dan' - ), - ), - array( - 'id' => 4, - 'text' => '987', - 'User' => array( - 'id' => 4, - 'name' => 'jim' - ), - ), - ), - ), - ); - - $r = Set::extract('/Comment/User[name=/\w+/]/..', $habtm); - $this->assertEquals('bob', $r[0]['Comment']['User']['name']); - $this->assertEquals('tod', $r[1]['Comment']['User']['name']); - $this->assertEquals('dan', $r[2]['Comment']['User']['name']); - $this->assertEquals('dan', $r[3]['Comment']['User']['name']); - $this->assertEquals(4, count($r)); - - $r = Set::extract('/Comment/User[name=/[a-z]+/]/..', $habtm); - $this->assertEquals('bob', $r[0]['Comment']['User']['name']); - $this->assertEquals('tod', $r[1]['Comment']['User']['name']); - $this->assertEquals('dan', $r[2]['Comment']['User']['name']); - $this->assertEquals('dan', $r[3]['Comment']['User']['name']); - $this->assertEquals(4, count($r)); - - $r = Set::extract('/Comment/User[name=/bob|dan/]/..', $habtm); - $this->assertEquals('bob', $r[0]['Comment']['User']['name']); - $this->assertEquals('dan', $r[1]['Comment']['User']['name']); - $this->assertEquals(2, count($r)); - - $r = Set::extract('/Comment/User[name=/bob|tod/]/..', $habtm); - $this->assertEquals('bob', $r[0]['Comment']['User']['name']); - $this->assertEquals('tod', $r[1]['Comment']['User']['name']); - $this->assertEquals(2, count($r)); - - $mixedKeys = array( - 'User' => array( - 0 => array( - 'id' => 4, - 'name' => 'Neo' - ), - 1 => array( - 'id' => 5, - 'name' => 'Morpheus' - ), - 'stringKey' => array() - ) - ); - $expected = array('Neo', 'Morpheus'); - $r = Set::extract('/User/name', $mixedKeys); - $this->assertEquals($expected, $r); - - $f = array( - array( - 'file' => array( - 'name' => 'zipfile.zip', - 'type' => 'application/zip', - 'tmp_name' => '/tmp/php178.tmp', - 'error' => 0, - 'size' => '564647' - ) - ), - array( - 'file' => array( - 'name' => 'zipfile2.zip', - 'type' => 'application/x-zip-compressed', - 'tmp_name' => '/tmp/php179.tmp', - 'error' => 0, - 'size' => '354784' - ) - ), - array( - 'file' => array( - 'name' => 'picture.jpg', - 'type' => 'image/jpeg', - 'tmp_name' => '/tmp/php180.tmp', - 'error' => 0, - 'size' => '21324' - ) - ) - ); - $expected = array(array('name' => 'zipfile2.zip','type' => 'application/x-zip-compressed','tmp_name' => '/tmp/php179.tmp','error' => 0,'size' => '354784')); - $r = Set::extract('/file/.[type=application/x-zip-compressed]', $f); - $this->assertEquals($expected, $r); - - $expected = array(array('name' => 'zipfile.zip','type' => 'application/zip','tmp_name' => '/tmp/php178.tmp','error' => 0,'size' => '564647')); - $r = Set::extract('/file/.[type=application/zip]', $f); - $this->assertEquals($expected, $r); - - $f = array( - array( - 'file' => array( - 'name' => 'zipfile.zip', - 'type' => 'application/zip', - 'tmp_name' => '/tmp/php178.tmp', - 'error' => 0, - 'size' => '564647' - ) - ), - array( - 'file' => array( - 'name' => 'zipfile2.zip', - 'type' => 'application/x zip compressed', - 'tmp_name' => '/tmp/php179.tmp', - 'error' => 0, - 'size' => '354784' - ) - ), - array( - 'file' => array( - 'name' => 'picture.jpg', - 'type' => 'image/jpeg', - 'tmp_name' => '/tmp/php180.tmp', - 'error' => 0, - 'size' => '21324' - ) - ) - ); - $expected = array(array('name' => 'zipfile2.zip','type' => 'application/x zip compressed','tmp_name' => '/tmp/php179.tmp','error' => 0,'size' => '354784')); - $r = Set::extract('/file/.[type=application/x zip compressed]', $f); - $this->assertEquals($expected, $r); - - $expected = array( - array('name' => 'zipfile.zip','type' => 'application/zip','tmp_name' => '/tmp/php178.tmp','error' => 0,'size' => '564647'), - array('name' => 'zipfile2.zip','type' => 'application/x zip compressed','tmp_name' => '/tmp/php179.tmp','error' => 0,'size' => '354784') - ); - $r = Set::extract('/file/.[tmp_name=/tmp\/php17/]', $f); - $this->assertEquals($expected, $r); - - $hasMany = array( - 'Node' => array( - 'id' => 1, - 'name' => 'First', - 'state' => 50 - ), - 'ParentNode' => array( - 0 => array( - 'id' => 2, - 'name' => 'Second', - 'state' => 60, - ) - ) - ); - $result = Set::extract('/ParentNode/name', $hasMany); - $expected = array('Second'); - $this->assertEquals($expected, $result); - - $data = array( - array( - 'Category' => array( - 'id' => 1, - 'name' => 'First' - ), - 0 => array( - 'value' => 50 - ) - ), - array( - 'Category' => array( - 'id' => 2, - 'name' => 'Second' - ), - 0 => array( - 'value' => 60 - ) - ) - ); - $expected = array( - array( - 'Category' => array( - 'id' => 1, - 'name' => 'First' - ), - 0 => array( - 'value' => 50 - ) - ) - ); - $result = Set::extract('/Category[id=1]/..', $data); - $this->assertEquals($expected, $result); - - $data = array( - array( - 'ChildNode' => array('id' => 1), - array('name' => 'Item 1') - ), - array( - 'ChildNode' => array('id' => 2), - array('name' => 'Item 2') - ), - ); - - $expected = array( - 'Item 1', - 'Item 2' - ); - $result = Set::extract('/0/name', $data); - $this->assertEquals($expected, $result); - - $data = array( - array('A1', 'B1'), - array('A2', 'B2') - ); - $expected = array('A1', 'A2'); - $result = Set::extract('/0', $data); - $this->assertEquals($expected, $result); - } - -/** - * test parent selectors with extract - * - * @return void - */ - public function testExtractParentSelector() { - $tree = array( - array( - 'Category' => array( - 'name' => 'Category 1' - ), - 'children' => array( - array( - 'Category' => array( - 'name' => 'Category 1.1' - ) - ) - ) - ), - array( - 'Category' => array( - 'name' => 'Category 2' - ), - 'children' => array( - array( - 'Category' => array( - 'name' => 'Category 2.1' - ) - ), - array( - 'Category' => array( - 'name' => 'Category 2.2' - ) - ), - ) - ), - array( - 'Category' => array( - 'name' => 'Category 3' - ), - 'children' => array( - array( - 'Category' => array( - 'name' => 'Category 3.1' - ) - ) - ) - ) - ); - $expected = array(array('Category' => $tree[1]['Category'])); - $r = Set::extract('/Category[name=Category 2]', $tree); - $this->assertEquals($expected, $r); - - $expected = array(array('Category' => $tree[1]['Category'], 'children' => $tree[1]['children'])); - $r = Set::extract('/Category[name=Category 2]/..', $tree); - $this->assertEquals($expected, $r); - - $expected = array(array('children' => $tree[1]['children'][0]), array('children' => $tree[1]['children'][1])); - $r = Set::extract('/Category[name=Category 2]/../children', $tree); - $this->assertEquals($expected, $r); - - $single = array( - array( - 'CallType' => array( - 'name' => 'Internal Voice' - ), - 'x' => array( - 'hour' => 7 - ) - ) - ); - - $expected = array(7); - $r = Set::extract('/CallType[name=Internal Voice]/../x/hour', $single); - $this->assertEquals($expected, $r); - - $multiple = array( - array( - 'CallType' => array( - 'name' => 'Internal Voice' - ), - 'x' => array( - 'hour' => 7 - ) - ), - array( - 'CallType' => array( - 'name' => 'Internal Voice' - ), - 'x' => array( - 'hour' => 2 - ) - ), - array( - 'CallType' => array( - 'name' => 'Internal Voice' - ), - 'x' => array( - 'hour' => 1 - ) - ) - ); - - $expected = array(7,2,1); - $r = Set::extract('/CallType[name=Internal Voice]/../x/hour', $multiple); - $this->assertEquals($expected, $r); - - $a = array( - 'Model' => array( - '0' => array( - 'id' => 18, - 'SubModelsModel' => array( - 'id' => 1, - 'submodel_id' => 66, - 'model_id' => 18, - 'type' => 1 - ), - ), - '1' => array( - 'id' => 0, - 'SubModelsModel' => array( - 'id' => 2, - 'submodel_id' => 66, - 'model_id' => 0, - 'type' => 1 - ), - ), - '2' => array( - 'id' => 17, - 'SubModelsModel' => array( - 'id' => 3, - 'submodel_id' => 66, - 'model_id' => 17, - 'type' => 2 - ), - ), - '3' => array( - 'id' => 0, - 'SubModelsModel' => array( - 'id' => 4, - 'submodel_id' => 66, - 'model_id' => 0, - 'type' => 2 - ) - ) - ) - ); - - $expected = array( - array( - 'Model' => array( - 'id' => 17, - 'SubModelsModel' => array( - 'id' => 3, - 'submodel_id' => 66, - 'model_id' => 17, - 'type' => 2 - ), - ) - ), - array( - 'Model' => array( - 'id' => 0, - 'SubModelsModel' => array( - 'id' => 4, - 'submodel_id' => 66, - 'model_id' => 0, - 'type' => 2 - ) - ) - ) - ); - $r = Set::extract('/Model/SubModelsModel[type=2]/..', $a); - $this->assertEquals($expected, $r); - } - -/** - * test that extract() still works when arrays don't contain a 0 index. - * - * @return void - */ - public function testExtractWithNonZeroArrays() { - $nonZero = array( - 1 => array( - 'User' => array( - 'id' => 1, - 'name' => 'John', - ) - ), - 2 => array( - 'User' => array( - 'id' => 2, - 'name' => 'Bob', - ) - ), - 3 => array( - 'User' => array( - 'id' => 3, - 'name' => 'Tony', - ) - ) - ); - $expected = array(1, 2, 3); - $r = Set::extract('/User/id', $nonZero); - $this->assertEquals($expected, $r); - - $expected = array( - array('User' => array('id' => 1, 'name' => 'John')), - array('User' => array('id' => 2, 'name' => 'Bob')), - array('User' => array('id' => 3, 'name' => 'Tony')), - ); - $result = Set::extract('/User', $nonZero); - $this->assertEquals($expected, $result); - - $nonSequential = array( - 'User' => array( - 0 => array('id' => 1), - 2 => array('id' => 2), - 6 => array('id' => 3), - 9 => array('id' => 4), - 3 => array('id' => 5), - ), - ); - - $nonZero = array( - 'User' => array( - 2 => array('id' => 1), - 4 => array('id' => 2), - 6 => array('id' => 3), - 9 => array('id' => 4), - 3 => array('id' => 5), - ), - ); - - $expected = array(1, 2, 3, 4, 5); - $this->assertEquals($expected, Set::extract('/User/id', $nonSequential)); - - $result = Set::extract('/User/id', $nonZero); - $this->assertEquals($expected, $result, 'Failed non zero array key extract'); - - $expected = array(1, 2, 3, 4, 5); - $this->assertEquals($expected, Set::extract('/User/id', $nonSequential)); - - $result = Set::extract('/User/id', $nonZero); - $this->assertEquals($expected, $result, 'Failed non zero array key extract'); - - $startingAtOne = array( - 'Article' => array( - 1 => array( - 'id' => 1, - 'approved' => 1, - ), - ) - ); - - $expected = array(0 => array('Article' => array('id' => 1, 'approved' => 1))); - $result = Set::extract('/Article[approved=1]', $startingAtOne); - $this->assertEquals($expected, $result); - - $items = array( - 240 => array( - 'A' => array( - 'field1' => 'a240', - 'field2' => 'a240', - ), - 'B' => array( - 'field1' => 'b240', - 'field2' => 'b240' - ), - ) - ); - - $expected = array( - 0 => 'b240' - ); - - $result = Set::extract('/B/field1', $items); - $this->assertSame($expected, $result); - $this->assertSame($result, Set::extract('{n}.B.field1', $items)); - } - -/** - * testExtractWithArrays method - * - * @return void - */ - public function testExtractWithArrays() { - $data = array( - 'Level1' => array( - 'Level2' => array('test1', 'test2'), - 'Level2bis' => array('test3', 'test4') - ) - ); - $this->assertEquals(array(array('Level2' => array('test1', 'test2'))), Set::extract('/Level1/Level2', $data)); - $this->assertEquals(array(array('Level2bis' => array('test3', 'test4'))), Set::extract('/Level1/Level2bis', $data)); - } - -/** - * test extract() with elements that have non-array children. - * - * @return void - */ - public function testExtractWithNonArrayElements() { - $data = array( - 'node' => array( - array('foo'), - 'bar' - ) - ); - $result = Set::extract('/node', $data); - $expected = array( - array('node' => array('foo')), - 'bar' - ); - $this->assertEquals($expected, $result); - - $data = array( - 'node' => array( - 'foo' => array('bar'), - 'bar' => array('foo') - ) - ); - $result = Set::extract('/node', $data); - $expected = array( - array('foo' => array('bar')), - array('bar' => array('foo')), - ); - $this->assertEquals($expected, $result); - - $data = array( - 'node' => array( - 'foo' => array( - 'bar' - ), - 'bar' => 'foo' - ) - ); - $result = Set::extract('/node', $data); - $expected = array( - array('foo' => array('bar')), - 'foo' - ); - $this->assertEquals($expected, $result); - } - -/** - * testMatches method - * - * @return void - */ - public function testMatches() { - $a = array( - array('Article' => array('id' => 1, 'title' => 'Article 1')), - array('Article' => array('id' => 2, 'title' => 'Article 2')), - array('Article' => array('id' => 3, 'title' => 'Article 3')) - ); - - $this->assertTrue(Set::matches(array('id=2'), $a[1]['Article'])); - $this->assertFalse(Set::matches(array('id>2'), $a[1]['Article'])); - $this->assertTrue(Set::matches(array('id>=2'), $a[1]['Article'])); - $this->assertFalse(Set::matches(array('id>=3'), $a[1]['Article'])); - $this->assertTrue(Set::matches(array('id<=2'), $a[1]['Article'])); - $this->assertFalse(Set::matches(array('id<2'), $a[1]['Article'])); - $this->assertTrue(Set::matches(array('id>1'), $a[1]['Article'])); - $this->assertTrue(Set::matches(array('id>1', 'id<3', 'id!=0'), $a[1]['Article'])); - - $this->assertTrue(Set::matches(array('3'), null, 3)); - $this->assertTrue(Set::matches(array('5'), null, 5)); - - $this->assertTrue(Set::matches(array('id'), $a[1]['Article'])); - $this->assertTrue(Set::matches(array('id', 'title'), $a[1]['Article'])); - $this->assertFalse(Set::matches(array('non-existant'), $a[1]['Article'])); - - $this->assertTrue(Set::matches('/Article[id=2]', $a)); - $this->assertFalse(Set::matches('/Article[id=4]', $a)); - $this->assertTrue(Set::matches(array(), $a)); - - $r = array( - 'Attachment' => array( - 'keep' => array() - ), - 'Comment' => array( - 'keep' => array( - 'Attachment' => array( - 'fields' => array( - 0 => 'attachment', - ), - ), - ) - ), - 'User' => array( - 'keep' => array() - ), - 'Article' => array( - 'keep' => array( - 'Comment' => array( - 'fields' => array( - 0 => 'comment', - 1 => 'published', - ), - ), - 'User' => array( - 'fields' => array( - 0 => 'user', - ), - ), - ) - ) - ); - - $this->assertTrue(Set::matches('/Article/keep/Comment', $r)); - $this->assertEquals(array('comment', 'published'), Set::extract('/Article/keep/Comment/fields', $r)); - $this->assertEquals(array('user'), Set::extract('/Article/keep/User/fields', $r)); - } - -/** - * testSetExtractReturnsEmptyArray method - * - * @return void - */ - public function testSetExtractReturnsEmptyArray() { - $this->assertEquals(Set::extract(array(), '/Post/id'), array()); - - $this->assertEquals(Set::extract('/Post/id', array()), array()); - - $this->assertEquals(Set::extract('/Post/id', array( - array('Post' => array('name' => 'bob')), - array('Post' => array('name' => 'jim')) - )), array()); - - $this->assertEquals(Set::extract(array(), 'Message.flash'), null); - } - -/** - * testClassicExtract method - * - * @return void - */ - public function testClassicExtract() { - $a = array( - array('Article' => array('id' => 1, 'title' => 'Article 1')), - array('Article' => array('id' => 2, 'title' => 'Article 2')), - array('Article' => array('id' => 3, 'title' => 'Article 3')) - ); - - $result = Set::extract($a, '{n}.Article.id'); - $expected = array( 1, 2, 3 ); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{n}.Article.title'); - $expected = array('Article 1', 'Article 2', 'Article 3'); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '1.Article.title'); - $expected = 'Article 2'; - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '3.Article.title'); - $expected = null; - $this->assertEquals($expected, $result); - - $a = array( - array( - 'Article' => array('id' => 1, 'title' => 'Article 1', - 'User' => array('id' => 1, 'username' => 'mariano.iglesias')) - ), - array( - 'Article' => array('id' => 2, 'title' => 'Article 2', - 'User' => array('id' => 1, 'username' => 'mariano.iglesias')) - ), - array( - 'Article' => array('id' => 3, 'title' => 'Article 3', - 'User' => array('id' => 2, 'username' => 'phpnut')) - ) - ); - - $result = Set::extract($a, '{n}.Article.User.username'); - $expected = array('mariano.iglesias', 'mariano.iglesias', 'phpnut'); - $this->assertEquals($expected, $result); - - $a = array( - array( - 'Article' => array( - 'id' => 1, 'title' => 'Article 1', - 'Comment' => array( - array('id' => 10, 'title' => 'Comment 10'), - array('id' => 11, 'title' => 'Comment 11'), - array('id' => 12, 'title' => 'Comment 12') - ) - ) - ), - array( - 'Article' => array( - 'id' => 2, 'title' => 'Article 2', - 'Comment' => array( - array('id' => 13, 'title' => 'Comment 13'), - array('id' => 14, 'title' => 'Comment 14') - ) - ) - ), - array('Article' => array('id' => 3, 'title' => 'Article 3')) - ); - - $result = Set::extract($a, '{n}.Article.Comment.{n}.id'); - $expected = array(array(10, 11, 12), array(13, 14), null); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{n}.Article.Comment.{n}.title'); - $expected = array( - array('Comment 10', 'Comment 11', 'Comment 12'), - array('Comment 13', 'Comment 14'), - null - ); - $this->assertEquals($expected, $result); - - $a = array(array('1day' => '20 sales'), array('1day' => '2 sales')); - $result = Set::extract($a, '{n}.1day'); - $expected = array('20 sales', '2 sales'); - $this->assertEquals($expected, $result); - - $a = array( - 'pages' => array('name' => 'page'), - 'fruites' => array('name' => 'fruit'), - 0 => array('name' => 'zero') - ); - $result = Set::extract($a, '{s}.name'); - $expected = array('page','fruit'); - $this->assertEquals($expected, $result); - - $a = array( - 0 => array('pages' => array('name' => 'page')), - 1 => array('fruites' => array('name' => 'fruit')), - 'test' => array(array('name' => 'jippi')), - 'dot.test' => array(array('name' => 'jippi')) - ); - - $result = Set::extract($a, '{n}.{s}.name'); - $expected = array(0 => array('page'), 1 => array('fruit')); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{s}.{n}.name'); - $expected = array(array('jippi'), array('jippi')); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{\w+}.{\w+}.name'); - $expected = array( - array('pages' => 'page'), - array('fruites' => 'fruit'), - 'test' => array('jippi'), - 'dot.test' => array('jippi') - ); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{\d+}.{\w+}.name'); - $expected = array(array('pages' => 'page'), array('fruites' => 'fruit')); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{n}.{\w+}.name'); - $expected = array(array('pages' => 'page'), array('fruites' => 'fruit')); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{s}.{\d+}.name'); - $expected = array(array('jippi'), array('jippi')); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{s}'); - $expected = array(array(array('name' => 'jippi')), array(array('name' => 'jippi'))); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{[a-z]}'); - $expected = array( - 'test' => array(array('name' => 'jippi')), - 'dot.test' => array(array('name' => 'jippi')) - ); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, '{dot\.test}.{n}'); - $expected = array('dot.test' => array(array('name' => 'jippi'))); - $this->assertEquals($expected, $result); - - $a = new stdClass(); - $a->articles = array( - array('Article' => array('id' => 1, 'title' => 'Article 1')), - array('Article' => array('id' => 2, 'title' => 'Article 2')), - array('Article' => array('id' => 3, 'title' => 'Article 3')) - ); - - $result = Set::extract($a, 'articles.{n}.Article.id'); - $expected = array(1, 2, 3); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, 'articles.{n}.Article.title'); - $expected = array('Article 1', 'Article 2', 'Article 3'); - $this->assertEquals($expected, $result); - - $a = new ArrayObject(); - $a['articles'] = array( - array('Article' => array('id' => 1, 'title' => 'Article 1')), - array('Article' => array('id' => 2, 'title' => 'Article 2')), - array('Article' => array('id' => 3, 'title' => 'Article 3')) - ); - - $result = Set::extract($a, 'articles.{n}.Article.id'); - $expected = array(1, 2, 3); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, 'articles.{n}.Article.title'); - $expected = array('Article 1', 'Article 2', 'Article 3'); - $this->assertEquals($expected, $result); - - $result = Set::extract($a, 'articles.0.Article.title'); - $expected = 'Article 1'; - $this->assertEquals($expected, $result); - } - -/** - * testInsert method - * - * @return void - */ - public function testInsert() { - $a = array( - 'pages' => array('name' => 'page') - ); - - $result = Set::insert($a, 'files', array('name' => 'files')); - $expected = array( - 'pages' => array('name' => 'page'), - 'files' => array('name' => 'files') - ); - $this->assertEquals($expected, $result); - - $a = array( - 'pages' => array('name' => 'page') - ); - $result = Set::insert($a, 'pages.name', array()); - $expected = array( - 'pages' => array('name' => array()), - ); - $this->assertEquals($expected, $result); - - $a = array( - 'pages' => array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about') - ) - ); - - $result = Set::insert($a, 'pages.1.vars', array('title' => 'page title')); - $expected = array( - 'pages' => array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about', 'vars' => array('title' => 'page title')) - ) - ); - $this->assertEquals($expected, $result); - } - -/** - * testRemove method - * - * @return void - */ - public function testRemove() { - $a = array( - 'pages' => array('name' => 'page'), - 'files' => array('name' => 'files') - ); - - $result = Set::remove($a, 'files'); - $expected = array( - 'pages' => array('name' => 'page') - ); - $this->assertEquals($expected, $result); - - $a = array( - 'pages' => array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about', 'vars' => array('title' => 'page title')) - ) - ); - - $result = Set::remove($a, 'pages.1.vars'); - $expected = array( - 'pages' => array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about') - ) - ); - $this->assertEquals($expected, $result); - - $result = Set::remove($a, 'pages.2.vars'); - $expected = $a; - $this->assertEquals($expected, $result); - } - -/** - * testCheck method - * - * @return void - */ - public function testCheck() { - $set = array( - 'My Index 1' => array('First' => 'The first item') - ); - $this->assertTrue(Set::check($set, 'My Index 1.First')); - $this->assertTrue(Set::check($set, 'My Index 1')); - $this->assertEquals(Set::check($set, array()), $set); - - $set = array( - 'My Index 1' => array('First' => array('Second' => array('Third' => array('Fourth' => 'Heavy. Nesting.')))) - ); - $this->assertTrue(Set::check($set, 'My Index 1.First.Second')); - $this->assertTrue(Set::check($set, 'My Index 1.First.Second.Third')); - $this->assertTrue(Set::check($set, 'My Index 1.First.Second.Third.Fourth')); - $this->assertFalse(Set::check($set, 'My Index 1.First.Seconds.Third.Fourth')); - } - -/** - * testWritingWithFunkyKeys method - * - * @return void - */ - public function testWritingWithFunkyKeys() { - $set = Set::insert(array(), 'Session Test', "test"); - $this->assertEquals('test', Set::extract($set, 'Session Test')); - - $set = Set::remove($set, 'Session Test'); - $this->assertFalse(Set::check($set, 'Session Test')); - - $expected = array('Session Test' => array('Test Case' => 'test')); - $this->assertEquals(Set::insert(array(), 'Session Test.Test Case', "test"), $expected); - $this->assertTrue(Set::check($expected, 'Session Test.Test Case')); - } - -/** - * testDiff method - * - * @return void - */ - public function testDiff() { - $a = array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about') - ); - $b = array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about'), - 2 => array('name' => 'contact') - ); - - $result = Set::diff($a, $b); - $expected = array( - 2 => array('name' => 'contact') - ); - $this->assertEquals($expected, $result); - - $result = Set::diff($a, array()); - $expected = $a; - $this->assertEquals($expected, $result); - - $result = Set::diff(array(), $b); - $expected = $b; - $this->assertEquals($expected, $result); - - $b = array( - 0 => array('name' => 'me'), - 1 => array('name' => 'about') - ); - - $result = Set::diff($a, $b); - $expected = array( - 0 => array('name' => 'main') - ); - $this->assertEquals($expected, $result); - - $a = array(); - $b = array('name' => 'bob', 'address' => 'home'); - $result = Set::diff($a, $b); - $this->assertEquals($b, $result); - - $a = array('name' => 'bob', 'address' => 'home'); - $b = array(); - $result = Set::diff($a, $b); - $this->assertEquals($a, $result); - - $a = array('key' => true, 'another' => false, 'name' => 'me'); - $b = array('key' => 1, 'another' => 0); - $expected = array('name' => 'me'); - $result = Set::diff($a, $b); - $this->assertEquals($expected, $result); - - $a = array('key' => 'value', 'another' => null, 'name' => 'me'); - $b = array('key' => 'differentValue', 'another' => null); - $expected = array('key' => 'value', 'name' => 'me'); - $result = Set::diff($a, $b); - $this->assertEquals($expected, $result); - - $a = array('key' => 'value', 'another' => null, 'name' => 'me'); - $b = array('key' => 'differentValue', 'another' => 'value'); - $expected = array('key' => 'value', 'another' => null, 'name' => 'me'); - $result = Set::diff($a, $b); - $this->assertEquals($expected, $result); - - $a = array('key' => 'value', 'another' => null, 'name' => 'me'); - $b = array('key' => 'differentValue', 'another' => 'value'); - $expected = array('key' => 'differentValue', 'another' => 'value', 'name' => 'me'); - $result = Set::diff($b, $a); - $this->assertEquals($expected, $result); - - $a = array('key' => 'value', 'another' => null, 'name' => 'me'); - $b = array(0 => 'differentValue', 1 => 'value'); - $expected = $a + $b; - $result = Set::diff($a, $b); - $this->assertEquals($expected, $result); - } - -/** - * testContains method - * - * @return void - */ - public function testContains() { - $a = array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about') - ); - $b = array( - 0 => array('name' => 'main'), - 1 => array('name' => 'about'), - 2 => array('name' => 'contact'), - 'a' => 'b' - ); - - $this->assertTrue(Set::contains($a, $a)); - $this->assertFalse(Set::contains($a, $b)); - $this->assertTrue(Set::contains($b, $a)); - } - -/** - * testCombine method - * - * @return void - */ - public function testCombine() { - $result = Set::combine(array(), '{n}.User.id', '{n}.User.Data'); - $this->assertTrue(empty($result)); - $result = Set::combine('', '{n}.User.id', '{n}.User.Data'); - $this->assertTrue(empty($result)); - - $a = array( - array('User' => array('id' => 2, 'group_id' => 1, - 'Data' => array('user' => 'mariano.iglesias','name' => 'Mariano Iglesias'))), - array('User' => array('id' => 14, 'group_id' => 2, - 'Data' => array('user' => 'phpnut', 'name' => 'Larry E. Masters'))), - array('User' => array('id' => 25, 'group_id' => 1, - 'Data' => array('user' => 'gwoo','name' => 'The Gwoo')))); - $result = Set::combine($a, '{n}.User.id'); - $expected = array(2 => null, 14 => null, 25 => null); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.non-existant'); - $expected = array(2 => null, 14 => null, 25 => null); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data'); - $expected = array( - 2 => array('user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'), - 14 => array('user' => 'phpnut', 'name' => 'Larry E. Masters'), - 25 => array('user' => 'gwoo', 'name' => 'The Gwoo')); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data.name'); - $expected = array( - 2 => 'Mariano Iglesias', - 14 => 'Larry E. Masters', - 25 => 'The Gwoo'); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data', '{n}.User.group_id'); - $expected = array( - 1 => array( - 2 => array('user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'), - 25 => array('user' => 'gwoo', 'name' => 'The Gwoo')), - 2 => array( - 14 => array('user' => 'phpnut', 'name' => 'Larry E. Masters'))); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data.name', '{n}.User.group_id'); - $expected = array( - 1 => array( - 2 => 'Mariano Iglesias', - 25 => 'The Gwoo'), - 2 => array( - 14 => 'Larry E. Masters')); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id'); - $expected = array(2 => null, 14 => null, 25 => null); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data'); - $expected = array( - 2 => array('user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'), - 14 => array('user' => 'phpnut', 'name' => 'Larry E. Masters'), - 25 => array('user' => 'gwoo', 'name' => 'The Gwoo')); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data.name'); - $expected = array(2 => 'Mariano Iglesias', 14 => 'Larry E. Masters', 25 => 'The Gwoo'); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data', '{n}.User.group_id'); - $expected = array( - 1 => array( - 2 => array('user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'), - 25 => array('user' => 'gwoo', 'name' => 'The Gwoo')), - 2 => array( - 14 => array('user' => 'phpnut', 'name' => 'Larry E. Masters'))); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', '{n}.User.Data.name', '{n}.User.group_id'); - $expected = array( - 1 => array( - 2 => 'Mariano Iglesias', - 25 => 'The Gwoo'), - 2 => array( - 14 => 'Larry E. Masters')); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, '{n}.User.id', array('{0}: {1}', '{n}.User.Data.user', '{n}.User.Data.name'), '{n}.User.group_id'); - $expected = array( - 1 => array( - 2 => 'mariano.iglesias: Mariano Iglesias', - 25 => 'gwoo: The Gwoo'), - 2 => array(14 => 'phpnut: Larry E. Masters')); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, array('{0}: {1}', '{n}.User.Data.user', '{n}.User.Data.name'), '{n}.User.id'); - $expected = array('mariano.iglesias: Mariano Iglesias' => 2, 'phpnut: Larry E. Masters' => 14, 'gwoo: The Gwoo' => 25); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, array('{1}: {0}', '{n}.User.Data.user', '{n}.User.Data.name'), '{n}.User.id'); - $expected = array('Mariano Iglesias: mariano.iglesias' => 2, 'Larry E. Masters: phpnut' => 14, 'The Gwoo: gwoo' => 25); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, array('%1$s: %2$d', '{n}.User.Data.user', '{n}.User.id'), '{n}.User.Data.name'); - $expected = array('mariano.iglesias: 2' => 'Mariano Iglesias', 'phpnut: 14' => 'Larry E. Masters', 'gwoo: 25' => 'The Gwoo'); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, array('%2$d: %1$s', '{n}.User.Data.user', '{n}.User.id'), '{n}.User.Data.name'); - $expected = array('2: mariano.iglesias' => 'Mariano Iglesias', '14: phpnut' => 'Larry E. Masters', '25: gwoo' => 'The Gwoo'); - $this->assertEquals($expected, $result); - - $b = new stdClass(); - $b->users = array( - array('User' => array('id' => 2, 'group_id' => 1, - 'Data' => array('user' => 'mariano.iglesias','name' => 'Mariano Iglesias'))), - array('User' => array('id' => 14, 'group_id' => 2, - 'Data' => array('user' => 'phpnut', 'name' => 'Larry E. Masters'))), - array('User' => array('id' => 25, 'group_id' => 1, - 'Data' => array('user' => 'gwoo','name' => 'The Gwoo')))); - $result = Set::combine($b, 'users.{n}.User.id'); - $expected = array(2 => null, 14 => null, 25 => null); - $this->assertEquals($expected, $result); - - $result = Set::combine($b, 'users.{n}.User.id', 'users.{n}.User.non-existant'); - $expected = array(2 => null, 14 => null, 25 => null); - $this->assertEquals($expected, $result); - - $result = Set::combine($a, 'fail', 'fail'); - $this->assertEquals(array(), $result); - } - -/** - * testMapReverse method - * - * @return void - */ - public function testMapReverse() { - $result = Set::reverse(null); - $this->assertEquals(null, $result); - - $result = Set::reverse(false); - $this->assertEquals(false, $result); - - $expected = array( - 'Array1' => array( - 'Array1Data1' => 'Array1Data1 value 1', 'Array1Data2' => 'Array1Data2 value 2'), - 'Array2' => array( - 0 => array('Array2Data1' => 1, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 1 => array('Array2Data1' => 2, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 2 => array('Array2Data1' => 3, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 3 => array('Array2Data1' => 4, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 4 => array('Array2Data1' => 5, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4')), - 'Array3' => array( - 0 => array('Array3Data1' => 1, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 1 => array('Array3Data1' => 2, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 2 => array('Array3Data1' => 3, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 3 => array('Array3Data1' => 4, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 4 => array('Array3Data1' => 5, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'))); - $map = Set::map($expected, true); - $this->assertEquals($expected['Array1']['Array1Data1'], $map->Array1->Array1Data1); - $this->assertEquals($expected['Array2'][0]['Array2Data1'], $map->Array2[0]->Array2Data1); - - $result = Set::reverse($map); - $this->assertEquals($expected, $result); - - $expected = array( - 'Post' => array('id' => 1, 'title' => 'First Post'), - 'Comment' => array( - array('id' => 1, 'title' => 'First Comment'), - array('id' => 2, 'title' => 'Second Comment') - ), - 'Tag' => array( - array('id' => 1, 'title' => 'First Tag'), - array('id' => 2, 'title' => 'Second Tag') - ), - ); - $map = Set::map($expected); - $this->assertEquals($expected['Post']['title'], $map->title); - foreach ($map->Comment as $comment) { - $ids[] = $comment->id; - } - $this->assertEquals(array(1, 2), $ids); - - $expected = array( - 'Array1' => array( - 'Array1Data1' => 'Array1Data1 value 1', 'Array1Data2' => 'Array1Data2 value 2', 'Array1Data3' => 'Array1Data3 value 3','Array1Data4' => 'Array1Data4 value 4', - 'Array1Data5' => 'Array1Data5 value 5', 'Array1Data6' => 'Array1Data6 value 6', 'Array1Data7' => 'Array1Data7 value 7', 'Array1Data8' => 'Array1Data8 value 8'), - 'string' => 1, - 'another' => 'string', - 'some' => 'thing else', - 'Array2' => array( - 0 => array('Array2Data1' => 1, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 1 => array('Array2Data1' => 2, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 2 => array('Array2Data1' => 3, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 3 => array('Array2Data1' => 4, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 4 => array('Array2Data1' => 5, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4')), - 'Array3' => array( - 0 => array('Array3Data1' => 1, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 1 => array('Array3Data1' => 2, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 2 => array('Array3Data1' => 3, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 3 => array('Array3Data1' => 4, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 4 => array('Array3Data1' => 5, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'))); - $map = Set::map($expected, true); - $result = Set::reverse($map); - $this->assertEquals($expected, $result); - - $expected = array( - 'Array1' => array( - 'Array1Data1' => 'Array1Data1 value 1', 'Array1Data2' => 'Array1Data2 value 2', 'Array1Data3' => 'Array1Data3 value 3','Array1Data4' => 'Array1Data4 value 4', - 'Array1Data5' => 'Array1Data5 value 5', 'Array1Data6' => 'Array1Data6 value 6', 'Array1Data7' => 'Array1Data7 value 7', 'Array1Data8' => 'Array1Data8 value 8'), - 'string' => 1, - 'another' => 'string', - 'some' => 'thing else', - 'Array2' => array( - 0 => array('Array2Data1' => 1, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 1 => array('Array2Data1' => 2, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 2 => array('Array2Data1' => 3, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 3 => array('Array2Data1' => 4, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4'), - 4 => array('Array2Data1' => 5, 'Array2Data2' => 'Array2Data2 value 2', 'Array2Data3' => 'Array2Data3 value 2', 'Array2Data4' => 'Array2Data4 value 4')), - 'string2' => 1, - 'another2' => 'string', - 'some2' => 'thing else', - 'Array3' => array( - 0 => array('Array3Data1' => 1, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 1 => array('Array3Data1' => 2, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 2 => array('Array3Data1' => 3, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 3 => array('Array3Data1' => 4, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4'), - 4 => array('Array3Data1' => 5, 'Array3Data2' => 'Array3Data2 value 2', 'Array3Data3' => 'Array3Data3 value 2', 'Array3Data4' => 'Array3Data4 value 4')), - 'string3' => 1, - 'another3' => 'string', - 'some3' => 'thing else'); - $map = Set::map($expected, true); - $result = Set::reverse($map); - $this->assertEquals($expected, $result); - - $expected = array('User' => array('psword' => 'whatever', 'Icon' => array('id' => 851))); - $map = Set::map($expected); - $result = Set::reverse($map); - $this->assertEquals($expected, $result); - - $expected = array('User' => array('psword' => 'whatever', 'Icon' => array('id' => 851))); - $class = new stdClass; - $class->User = new stdClass; - $class->User->psword = 'whatever'; - $class->User->Icon = new stdClass; - $class->User->Icon->id = 851; - $result = Set::reverse($class); - $this->assertEquals($expected, $result); - - $expected = array('User' => array('psword' => 'whatever', 'Icon' => array('id' => 851), 'Profile' => array('name' => 'Some Name', 'address' => 'Some Address'))); - $class = new stdClass; - $class->User = new stdClass; - $class->User->psword = 'whatever'; - $class->User->Icon = new stdClass; - $class->User->Icon->id = 851; - $class->User->Profile = new stdClass; - $class->User->Profile->name = 'Some Name'; - $class->User->Profile->address = 'Some Address'; - - $result = Set::reverse($class); - $this->assertEquals($expected, $result); - - $expected = array('User' => array('psword' => 'whatever', - 'Icon' => array('id' => 851), - 'Profile' => array('name' => 'Some Name', 'address' => 'Some Address'), - 'Comment' => array( - array('id' => 1, 'article_id' => 1, 'user_id' => 1, 'comment' => 'First Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31'), - array('id' => 2, 'article_id' => 1, 'user_id' => 2, 'comment' => 'Second Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31')))); - - $class = new stdClass; - $class->User = new stdClass; - $class->User->psword = 'whatever'; - $class->User->Icon = new stdClass; - $class->User->Icon->id = 851; - $class->User->Profile = new stdClass; - $class->User->Profile->name = 'Some Name'; - $class->User->Profile->address = 'Some Address'; - $class->User->Comment = new stdClass; - $class->User->Comment->{'0'} = new stdClass; - $class->User->Comment->{'0'}->id = 1; - $class->User->Comment->{'0'}->article_id = 1; - $class->User->Comment->{'0'}->user_id = 1; - $class->User->Comment->{'0'}->comment = 'First Comment for First Article'; - $class->User->Comment->{'0'}->published = 'Y'; - $class->User->Comment->{'0'}->created = '2007-03-18 10:47:23'; - $class->User->Comment->{'0'}->updated = '2007-03-18 10:49:31'; - $class->User->Comment->{'1'} = new stdClass; - $class->User->Comment->{'1'}->id = 2; - $class->User->Comment->{'1'}->article_id = 1; - $class->User->Comment->{'1'}->user_id = 2; - $class->User->Comment->{'1'}->comment = 'Second Comment for First Article'; - $class->User->Comment->{'1'}->published = 'Y'; - $class->User->Comment->{'1'}->created = '2007-03-18 10:47:23'; - $class->User->Comment->{'1'}->updated = '2007-03-18 10:49:31'; - - $result = Set::reverse($class); - $this->assertEquals($expected, $result); - - $expected = array('User' => array('psword' => 'whatever', - 'Icon' => array('id' => 851), - 'Profile' => array('name' => 'Some Name', 'address' => 'Some Address'), - 'Comment' => array( - array('id' => 1, 'article_id' => 1, 'user_id' => 1, 'comment' => 'First Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31'), - array('id' => 2, 'article_id' => 1, 'user_id' => 2, 'comment' => 'Second Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31')))); - - // @codingStandardsIgnoreStart - $class = new stdClass; - $class->User = new stdClass; - $class->User->psword = 'whatever'; - $class->User->Icon = new stdClass; - $class->User->Icon->id = 851; - $class->User->Profile = new stdClass; - $class->User->Profile->name = 'Some Name'; - $class->User->Profile->address = 'Some Address'; - $class->User->Comment = array(); - $comment = new stdClass; - $comment->id = 1; - $comment->article_id = 1; - $comment->user_id = 1; - $comment->comment = 'First Comment for First Article'; - $comment->published = 'Y'; - $comment->created = '2007-03-18 10:47:23'; - $comment->updated = '2007-03-18 10:49:31'; - $comment2 = new stdClass; - $comment2->id = 2; - $comment2->article_id = 1; - $comment2->user_id = 2; - $comment2->comment = 'Second Comment for First Article'; - $comment2->published = 'Y'; - $comment2->created = '2007-03-18 10:47:23'; - $comment2->updated = '2007-03-18 10:49:31'; - // @codingStandardsIgnoreEnd - $class->User->Comment = array($comment, $comment2); - $result = Set::reverse($class); - $this->assertEquals($expected, $result); - - $class = new stdClass; - $class->User = new stdClass; - $class->User->id = 100; - $class->someString = 'this is some string'; - $class->Profile = new stdClass; - $class->Profile->name = 'Joe Mamma'; - - $result = Set::reverse($class); - $expected = array( - 'User' => array('id' => '100'), - 'someString' => 'this is some string', - 'Profile' => array('name' => 'Joe Mamma') - ); - $this->assertEquals($expected, $result); - - // @codingStandardsIgnoreStart - $class = new stdClass; - $class->User = new stdClass; - $class->User->id = 100; - $class->User->_name_ = 'User'; - $class->Profile = new stdClass; - $class->Profile->name = 'Joe Mamma'; - $class->Profile->_name_ = 'Profile'; - // @codingStandardsIgnoreEnd - - $result = Set::reverse($class); - $expected = array('User' => array('id' => '100'), 'Profile' => array('name' => 'Joe Mamma')); - $this->assertEquals($expected, $result); - } - -/** - * testFormatting method - * - * @return void - */ - public function testFormatting() { - $data = array( - array('Person' => array('first_name' => 'Nate', 'last_name' => 'Abele', 'city' => 'Boston', 'state' => 'MA', 'something' => '42')), - array('Person' => array('first_name' => 'Larry', 'last_name' => 'Masters', 'city' => 'Boondock', 'state' => 'TN', 'something' => '{0}')), - array('Person' => array('first_name' => 'Garrett', 'last_name' => 'Woodworth', 'city' => 'Venice Beach', 'state' => 'CA', 'something' => '{1}'))); - - $result = Set::format($data, '{1}, {0}', array('{n}.Person.first_name', '{n}.Person.last_name')); - $expected = array('Abele, Nate', 'Masters, Larry', 'Woodworth, Garrett'); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '{0}, {1}', array('{n}.Person.last_name', '{n}.Person.first_name')); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '{0}, {1}', array('{n}.Person.city', '{n}.Person.state')); - $expected = array('Boston, MA', 'Boondock, TN', 'Venice Beach, CA'); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '{{0}, {1}}', array('{n}.Person.city', '{n}.Person.state')); - $expected = array('{Boston, MA}', '{Boondock, TN}', '{Venice Beach, CA}'); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '{{0}, {1}}', array('{n}.Person.something', '{n}.Person.something')); - $expected = array('{42, 42}', '{{0}, {0}}', '{{1}, {1}}'); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '{%2$d, %1$s}', array('{n}.Person.something', '{n}.Person.something')); - $expected = array('{42, 42}', '{0, {0}}', '{0, {1}}'); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '{%1$s, %1$s}', array('{n}.Person.something', '{n}.Person.something')); - $expected = array('{42, 42}', '{{0}, {0}}', '{{1}, {1}}'); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '%2$d, %1$s', array('{n}.Person.first_name', '{n}.Person.something')); - $expected = array('42, Nate', '0, Larry', '0, Garrett'); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '%1$s, %2$d', array('{n}.Person.first_name', '{n}.Person.something')); - $expected = array('Nate, 42', 'Larry, 0', 'Garrett, 0'); - $this->assertEquals($expected, $result); - } - -/** - * testFormattingNullValues method - * - * @return void - */ - public function testFormattingNullValues() { - $data = array( - array('Person' => array('first_name' => 'Nate', 'last_name' => 'Abele', 'city' => 'Boston', 'state' => 'MA', 'something' => '42')), - array('Person' => array('first_name' => 'Larry', 'last_name' => 'Masters', 'city' => 'Boondock', 'state' => 'TN', 'something' => null)), - array('Person' => array('first_name' => 'Garrett', 'last_name' => 'Woodworth', 'city' => 'Venice Beach', 'state' => 'CA', 'something' => null))); - - $result = Set::format($data, '%s', array('{n}.Person.something')); - $expected = array('42', '', ''); - $this->assertEquals($expected, $result); - - $result = Set::format($data, '{0}, {1}', array('{n}.Person.city', '{n}.Person.something')); - $expected = array('Boston, 42', 'Boondock, ', 'Venice Beach, '); - $this->assertEquals($expected, $result); - } - -/** - * testCountDim method - * - * @return void - */ - public function testCountDim() { - $data = array('one', '2', 'three'); - $result = Set::countDim($data); - $this->assertEquals(1, $result); - - $data = array('1' => '1.1', '2', '3'); - $result = Set::countDim($data); - $this->assertEquals(1, $result); - - $data = array('1' => array('1.1' => '1.1.1'), '2', '3' => array('3.1' => '3.1.1')); - $result = Set::countDim($data); - $this->assertEquals(2, $result); - - $data = array('1' => '1.1', '2', '3' => array('3.1' => '3.1.1')); - $result = Set::countDim($data); - $this->assertEquals(1, $result); - - $data = array('1' => '1.1', '2', '3' => array('3.1' => '3.1.1')); - $result = Set::countDim($data, true); - $this->assertEquals(2, $result); - - $data = array('1' => array('1.1' => '1.1.1'), '2', '3' => array('3.1' => array('3.1.1' => '3.1.1.1'))); - $result = Set::countDim($data); - $this->assertEquals(2, $result); - - $data = array('1' => array('1.1' => '1.1.1'), '2', '3' => array('3.1' => array('3.1.1' => '3.1.1.1'))); - $result = Set::countDim($data, true); - $this->assertEquals(3, $result); - - $data = array('1' => array('1.1' => '1.1.1'), array('2' => array('2.1' => array('2.1.1' => '2.1.1.1'))), '3' => array('3.1' => array('3.1.1' => '3.1.1.1'))); - $result = Set::countDim($data, true); - $this->assertEquals(4, $result); - - $data = array('1' => array('1.1' => '1.1.1'), array('2' => array('2.1' => array('2.1.1' => array('2.1.1.1')))), '3' => array('3.1' => array('3.1.1' => '3.1.1.1'))); - $result = Set::countDim($data, true); - $this->assertEquals(5, $result); - - $data = array('1' => array('1.1' => '1.1.1'), array('2' => array('2.1' => array('2.1.1' => array('2.1.1.1' => '2.1.1.1.1')))), '3' => array('3.1' => array('3.1.1' => '3.1.1.1'))); - $result = Set::countDim($data, true); - $this->assertEquals(5, $result); - - $set = array('1' => array('1.1' => '1.1.1'), array('2' => array('2.1' => array('2.1.1' => array('2.1.1.1' => '2.1.1.1.1')))), '3' => array('3.1' => array('3.1.1' => '3.1.1.1'))); - $result = Set::countDim($set, false, 0); - $this->assertEquals(2, $result); - - $result = Set::countDim($set, true); - $this->assertEquals(5, $result); - } - -/** - * testMapNesting method - * - * @return void - */ - public function testMapNesting() { - $expected = array( - array( - "IndexedPage" => array( - "id" => 1, - "url" => 'http://blah.com/', - 'hash' => '68a9f053b19526d08e36c6a9ad150737933816a5', - 'headers' => array( - 'Date' => "Wed, 14 Nov 2007 15:51:42 GMT", - 'Server' => "Apache", - 'Expires' => "Thu, 19 Nov 1981 08:52:00 GMT", - 'Cache-Control' => "private", - 'Pragma' => "no-cache", - 'Content-Type' => "text/html; charset=UTF-8", - 'X-Original-Transfer-Encoding' => "chunked", - 'Content-Length' => "50210", - ), - 'meta' => array( - 'keywords' => array('testing','tests'), - 'description' => 'describe me', - ), - 'get_vars' => '', - 'post_vars' => array(), - 'cookies' => array('PHPSESSID' => "dde9896ad24595998161ffaf9e0dbe2d"), - 'redirect' => '', - 'created' => "1195055503", - 'updated' => "1195055503", - ) - ), - array( - "IndexedPage" => array( - "id" => 2, - "url" => 'http://blah.com/', - 'hash' => '68a9f053b19526d08e36c6a9ad150737933816a5', - 'headers' => array( - 'Date' => "Wed, 14 Nov 2007 15:51:42 GMT", - 'Server' => "Apache", - 'Expires' => "Thu, 19 Nov 1981 08:52:00 GMT", - 'Cache-Control' => "private", - 'Pragma' => "no-cache", - 'Content-Type' => "text/html; charset=UTF-8", - 'X-Original-Transfer-Encoding' => "chunked", - 'Content-Length' => "50210", - ), - 'meta' => array( - 'keywords' => array('testing','tests'), - 'description' => 'describe me', - ), - 'get_vars' => '', - 'post_vars' => array(), - 'cookies' => array('PHPSESSID' => "dde9896ad24595998161ffaf9e0dbe2d"), - 'redirect' => '', - 'created' => "1195055503", - 'updated' => "1195055503", - ), - ) - ); - - $mapped = Set::map($expected); - $ids = array(); - - foreach ($mapped as $object) { - $ids[] = $object->id; - } - $this->assertEquals(array(1, 2), $ids); - $this->assertEquals($expected[0]['IndexedPage']['headers'], get_object_vars($mapped[0]->headers)); - - $result = Set::reverse($mapped); - $this->assertEquals($expected, $result); - - $data = array( - array( - "IndexedPage" => array( - "id" => 1, - "url" => 'http://blah.com/', - 'hash' => '68a9f053b19526d08e36c6a9ad150737933816a5', - 'get_vars' => '', - 'redirect' => '', - 'created' => "1195055503", - 'updated' => "1195055503", - ) - ), - array( - "IndexedPage" => array( - "id" => 2, - "url" => 'http://blah.com/', - 'hash' => '68a9f053b19526d08e36c6a9ad150737933816a5', - 'get_vars' => '', - 'redirect' => '', - 'created' => "1195055503", - 'updated' => "1195055503", - ), - ) - ); - $mapped = Set::map($data); - - // @codingStandardsIgnoreStart - $expected = new stdClass(); - $expected->_name_ = 'IndexedPage'; - $expected->id = 2; - $expected->url = 'http://blah.com/'; - $expected->hash = '68a9f053b19526d08e36c6a9ad150737933816a5'; - $expected->get_vars = ''; - $expected->redirect = ''; - $expected->created = "1195055503"; - $expected->updated = "1195055503"; - // @codingStandardsIgnoreEnd - $this->assertEquals($expected, $mapped[1]); - - $ids = array(); - - foreach ($mapped as $object) { - $ids[] = $object->id; - } - $this->assertEquals(array(1, 2), $ids); - - $result = Set::map(null); - $expected = null; - $this->assertEquals($expected, $result); - } - -/** - * testNestedMappedData method - * - * @return void - */ - public function testNestedMappedData() { - $result = Set::map(array( - array( - 'Post' => array('id' => '1', 'author_id' => '1', 'title' => 'First Post', 'body' => 'First Post Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31'), - 'Author' => array('id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', 'test' => 'working'), - ) - , array( - 'Post' => array('id' => '2', 'author_id' => '3', 'title' => 'Second Post', 'body' => 'Second Post Body', 'published' => 'Y', 'created' => '2007-03-18 10:41:23', 'updated' => '2007-03-18 10:43:31'), - 'Author' => array('id' => '3', 'user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', 'test' => 'working'), - ) - )); - - // @codingStandardsIgnoreStart - $expected = new stdClass; - $expected->_name_ = 'Post'; - $expected->id = '1'; - $expected->author_id = '1'; - $expected->title = 'First Post'; - $expected->body = 'First Post Body'; - $expected->published = 'Y'; - $expected->created = "2007-03-18 10:39:23"; - $expected->updated = "2007-03-18 10:41:31"; - - $expected->Author = new stdClass; - $expected->Author->id = '1'; - $expected->Author->user = 'mariano'; - $expected->Author->password = '5f4dcc3b5aa765d61d8327deb882cf99'; - $expected->Author->created = "2007-03-17 01:16:23"; - $expected->Author->updated = "2007-03-17 01:18:31"; - $expected->Author->test = "working"; - $expected->Author->_name_ = 'Author'; - - $expected2 = new stdClass; - $expected2->_name_ = 'Post'; - $expected2->id = '2'; - $expected2->author_id = '3'; - $expected2->title = 'Second Post'; - $expected2->body = 'Second Post Body'; - $expected2->published = 'Y'; - $expected2->created = "2007-03-18 10:41:23"; - $expected2->updated = "2007-03-18 10:43:31"; - - $expected2->Author = new stdClass; - $expected2->Author->id = '3'; - $expected2->Author->user = 'larry'; - $expected2->Author->password = '5f4dcc3b5aa765d61d8327deb882cf99'; - $expected2->Author->created = "2007-03-17 01:20:23"; - $expected2->Author->updated = "2007-03-17 01:22:31"; - $expected2->Author->test = "working"; - $expected2->Author->_name_ = 'Author'; - // @codingStandardsIgnoreEnd - - $test = array(); - $test[0] = $expected; - $test[1] = $expected2; - - $this->assertEquals($test, $result); - - $result = Set::map( - array( - 'Post' => array('id' => '1', 'author_id' => '1', 'title' => 'First Post', 'body' => 'First Post Body', 'published' => 'Y', 'created' => '2007-03-18 10:39:23', 'updated' => '2007-03-18 10:41:31'), - 'Author' => array('id' => '1', 'user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', 'test' => 'working'), - ) - ); - // @codingStandardsIgnoreStart - $expected = new stdClass; - $expected->_name_ = 'Post'; - $expected->id = '1'; - $expected->author_id = '1'; - $expected->title = 'First Post'; - $expected->body = 'First Post Body'; - $expected->published = 'Y'; - $expected->created = "2007-03-18 10:39:23"; - $expected->updated = "2007-03-18 10:41:31"; - - $expected->Author = new stdClass; - $expected->Author->id = '1'; - $expected->Author->user = 'mariano'; - $expected->Author->password = '5f4dcc3b5aa765d61d8327deb882cf99'; - $expected->Author->created = "2007-03-17 01:16:23"; - $expected->Author->updated = "2007-03-17 01:18:31"; - $expected->Author->test = "working"; - $expected->Author->_name_ = 'Author'; - // @codingStandardsIgnoreEnd - $this->assertEquals($expected, $result); - - //Case where extra HABTM fields come back in a result - $data = array( - 'User' => array( - 'id' => 1, - 'email' => 'user@example.com', - 'first_name' => 'John', - 'last_name' => 'Smith', - ), - 'Piece' => array( - array( - 'id' => 1, - 'title' => 'Moonlight Sonata', - 'composer' => 'Ludwig van Beethoven', - 'PiecesUser' => array( - 'id' => 1, - 'created' => '2008-01-01 00:00:00', - 'modified' => '2008-01-01 00:00:00', - 'piece_id' => 1, - 'user_id' => 2, - ) - ), - array( - 'id' => 2, - 'title' => 'Moonlight Sonata 2', - 'composer' => 'Ludwig van Beethoven', - 'PiecesUser' => array( - 'id' => 2, - 'created' => '2008-01-01 00:00:00', - 'modified' => '2008-01-01 00:00:00', - 'piece_id' => 2, - 'user_id' => 2, - ) - ) - ) - ); - - $result = Set::map($data); - - // @codingStandardsIgnoreStart - $expected = new stdClass(); - $expected->_name_ = 'User'; - $expected->id = 1; - $expected->email = 'user@example.com'; - $expected->first_name = 'John'; - $expected->last_name = 'Smith'; - - $piece = new stdClass(); - $piece->id = 1; - $piece->title = 'Moonlight Sonata'; - $piece->composer = 'Ludwig van Beethoven'; - - $piece->PiecesUser = new stdClass(); - $piece->PiecesUser->id = 1; - $piece->PiecesUser->created = '2008-01-01 00:00:00'; - $piece->PiecesUser->modified = '2008-01-01 00:00:00'; - $piece->PiecesUser->piece_id = 1; - $piece->PiecesUser->user_id = 2; - $piece->PiecesUser->_name_ = 'PiecesUser'; - - $piece->_name_ = 'Piece'; - - $piece2 = new stdClass(); - $piece2->id = 2; - $piece2->title = 'Moonlight Sonata 2'; - $piece2->composer = 'Ludwig van Beethoven'; - - $piece2->PiecesUser = new stdClass(); - $piece2->PiecesUser->id = 2; - $piece2->PiecesUser->created = '2008-01-01 00:00:00'; - $piece2->PiecesUser->modified = '2008-01-01 00:00:00'; - $piece2->PiecesUser->piece_id = 2; - $piece2->PiecesUser->user_id = 2; - $piece2->PiecesUser->_name_ = 'PiecesUser'; - - $piece2->_name_ = 'Piece'; - // @codingStandardsIgnoreEnd - - $expected->Piece = array($piece, $piece2); - - $this->assertEquals($expected, $result); - - //Same data, but should work if _name_ has been manually defined: - $data = array( - 'User' => array( - 'id' => 1, - 'email' => 'user@example.com', - 'first_name' => 'John', - 'last_name' => 'Smith', - '_name_' => 'FooUser', - ), - 'Piece' => array( - array( - 'id' => 1, - 'title' => 'Moonlight Sonata', - 'composer' => 'Ludwig van Beethoven', - '_name_' => 'FooPiece', - 'PiecesUser' => array( - 'id' => 1, - 'created' => '2008-01-01 00:00:00', - 'modified' => '2008-01-01 00:00:00', - 'piece_id' => 1, - 'user_id' => 2, - '_name_' => 'FooPiecesUser', - ) - ), - array( - 'id' => 2, - 'title' => 'Moonlight Sonata 2', - 'composer' => 'Ludwig van Beethoven', - '_name_' => 'FooPiece', - 'PiecesUser' => array( - 'id' => 2, - 'created' => '2008-01-01 00:00:00', - 'modified' => '2008-01-01 00:00:00', - 'piece_id' => 2, - 'user_id' => 2, - '_name_' => 'FooPiecesUser', - ) - ) - ) - ); - - $result = Set::map($data); - - // @codingStandardsIgnoreStart - $expected = new stdClass(); - $expected->_name_ = 'FooUser'; - $expected->id = 1; - $expected->email = 'user@example.com'; - $expected->first_name = 'John'; - $expected->last_name = 'Smith'; - - $piece = new stdClass(); - $piece->id = 1; - $piece->title = 'Moonlight Sonata'; - $piece->composer = 'Ludwig van Beethoven'; - $piece->_name_ = 'FooPiece'; - $piece->PiecesUser = new stdClass(); - $piece->PiecesUser->id = 1; - $piece->PiecesUser->created = '2008-01-01 00:00:00'; - $piece->PiecesUser->modified = '2008-01-01 00:00:00'; - $piece->PiecesUser->piece_id = 1; - $piece->PiecesUser->user_id = 2; - $piece->PiecesUser->_name_ = 'FooPiecesUser'; - - $piece2 = new stdClass(); - $piece2->id = 2; - $piece2->title = 'Moonlight Sonata 2'; - $piece2->composer = 'Ludwig van Beethoven'; - $piece2->_name_ = 'FooPiece'; - $piece2->PiecesUser = new stdClass(); - $piece2->PiecesUser->id = 2; - $piece2->PiecesUser->created = '2008-01-01 00:00:00'; - $piece2->PiecesUser->modified = '2008-01-01 00:00:00'; - $piece2->PiecesUser->piece_id = 2; - $piece2->PiecesUser->user_id = 2; - $piece2->PiecesUser->_name_ = 'FooPiecesUser'; - // @codingStandardsIgnoreEnd - - $expected->Piece = array($piece, $piece2); - - $this->assertEquals($expected, $result); - } - -/** - * testPushDiff method - * - * @return void - */ - public function testPushDiff() { - $array1 = array('ModelOne' => array('id' => 1001, 'field_one' => 'a1.m1.f1', 'field_two' => 'a1.m1.f2')); - $array2 = array('ModelTwo' => array('id' => 1002, 'field_one' => 'a2.m2.f1', 'field_two' => 'a2.m2.f2')); - - $result = Set::pushDiff($array1, $array2); - - $this->assertEquals($array1 + $array2, $result); - - $array3 = array('ModelOne' => array('id' => 1003, 'field_one' => 'a3.m1.f1', 'field_two' => 'a3.m1.f2', 'field_three' => 'a3.m1.f3')); - $result = Set::pushDiff($array1, $array3); - - $expected = array('ModelOne' => array('id' => 1001, 'field_one' => 'a1.m1.f1', 'field_two' => 'a1.m1.f2', 'field_three' => 'a3.m1.f3')); - $this->assertEquals($expected, $result); - - $array1 = array( - 0 => array('ModelOne' => array('id' => 1001, 'field_one' => 's1.0.m1.f1', 'field_two' => 's1.0.m1.f2')), - 1 => array('ModelTwo' => array('id' => 1002, 'field_one' => 's1.1.m2.f2', 'field_two' => 's1.1.m2.f2'))); - $array2 = array( - 0 => array('ModelOne' => array('id' => 1001, 'field_one' => 's2.0.m1.f1', 'field_two' => 's2.0.m1.f2')), - 1 => array('ModelTwo' => array('id' => 1002, 'field_one' => 's2.1.m2.f2', 'field_two' => 's2.1.m2.f2'))); - - $result = Set::pushDiff($array1, $array2); - $this->assertEquals($array1, $result); - - $array3 = array(0 => array('ModelThree' => array('id' => 1003, 'field_one' => 's3.0.m3.f1', 'field_two' => 's3.0.m3.f2'))); - - $result = Set::pushDiff($array1, $array3); - $expected = array( - 0 => array('ModelOne' => array('id' => 1001, 'field_one' => 's1.0.m1.f1', 'field_two' => 's1.0.m1.f2'), - 'ModelThree' => array('id' => 1003, 'field_one' => 's3.0.m3.f1', 'field_two' => 's3.0.m3.f2')), - 1 => array('ModelTwo' => array('id' => 1002, 'field_one' => 's1.1.m2.f2', 'field_two' => 's1.1.m2.f2'))); - $this->assertEquals($expected, $result); - - $result = Set::pushDiff($array1, null); - $this->assertEquals($array1, $result); - - $result = Set::pushDiff($array1, $array2); - $this->assertEquals($array1 + $array2, $result); - } - -/** - * testSetApply method - * @return void - * - */ - public function testApply() { - $data = array( - array('Movie' => array('id' => 1, 'title' => 'movie 3', 'rating' => 5)), - array('Movie' => array('id' => 1, 'title' => 'movie 1', 'rating' => 1)), - array('Movie' => array('id' => 1, 'title' => 'movie 2', 'rating' => 3)) - ); - - $result = Set::apply('/Movie/rating', $data, 'array_sum'); - $expected = 9; - $this->assertEquals($expected, $result); - - $result = Set::apply('/Movie/rating', $data, 'array_product'); - $expected = 15; - $this->assertEquals($expected, $result); - - $result = Set::apply('/Movie/title', $data, 'ucfirst', array('type' => 'map')); - $expected = array('Movie 3', 'Movie 1', 'Movie 2'); - $this->assertEquals($expected, $result); - - $result = Set::apply('/Movie/title', $data, 'strtoupper', array('type' => 'map')); - $expected = array('MOVIE 3', 'MOVIE 1', 'MOVIE 2'); - $this->assertEquals($expected, $result); - - $result = Set::apply('/Movie/rating', $data, array('SetTest', 'method'), array('type' => 'reduce')); - $expected = 9; - $this->assertEquals($expected, $result); - - $result = Set::apply('/Movie/rating', $data, 'strtoupper', array('type' => 'non existing type')); - $expected = null; - $this->assertEquals($expected, $result); - } - -/** - * Helper method to test Set::apply() - * - * @return void - */ - public static function method($val1, $val2) { - $val1 += $val2; - return $val1; - } - -/** - * testXmlSetReverse method - * - * @return void - */ - public function testXmlSetReverse() { - App::uses('Xml', 'Utility'); - - $string = ' - - - Cake PHP Google Group - http://groups.google.com/group/cake-php - Search this group before posting anything. There are over 20,000 posts and it&#39;s very likely your question was answered before. Visit the IRC channel #cakephp at irc.freenode.net for live chat with users and developers of Cake. If you post, tell us the version of Cake, PHP, and database. - en - - constructng result array when using findall - http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f - i'm using cakephp to construct a logical data model array that will be <br> passed to a flex app. I have the following model association: <br> ServiceDay-&gt;(hasMany)ServiceTi me-&gt;(hasMany)ServiceTimePrice. So what <br> the current output from my findall is something like this example: <br> <p>Array( <br> [0] =&gt; Array( - http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f - bmil...@gmail.com(bpscrugs) - Fri, 28 Dec 2007 00:44:14 UT - - - Re: share views between actions? - http://groups.google.com/group/cake-php/msg/8b350d898707dad8 - Then perhaps you might do us all a favour and refrain from replying to <br> things you do not understand. That goes especially for asinine comments. <br> Indeed. <br> To sum up: <br> No comment. <br> In my day, a simple &quot;RTFM&quot; would suffice. I'll keep in mind to ignore any <br> further responses from you. <br> You (and I) were referring to the *online documentation*, not other - http://groups.google.com/group/cake-php/msg/8b350d898707dad8 - subtropolis.z...@gmail.com(subtropolis zijn) - Fri, 28 Dec 2007 00:45:01 UT - - - '; - $xml = Xml::build($string); - $result = Set::reverse($xml); - $expected = array('rss' => array( - '@version' => '2.0', - 'channel' => array( - 'title' => 'Cake PHP Google Group', - 'link' => 'http://groups.google.com/group/cake-php', - 'description' => 'Search this group before posting anything. There are over 20,000 posts and it's very likely your question was answered before. Visit the IRC channel #cakephp at irc.freenode.net for live chat with users and developers of Cake. If you post, tell us the version of Cake, PHP, and database.', - 'language' => 'en', - 'item' => array( - array( - 'title' => 'constructng result array when using findall', - 'link' => 'http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f', - 'description' => "i'm using cakephp to construct a logical data model array that will be
passed to a flex app. I have the following model association:
ServiceDay->(hasMany)ServiceTi me->(hasMany)ServiceTimePrice. So what
the current output from my findall is something like this example:

Array(
[0] => Array(", - 'guid' => array('@isPermaLink' => 'true', '@' => 'http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f'), - 'author' => 'bmil...@gmail.com(bpscrugs)', - 'pubDate' => 'Fri, 28 Dec 2007 00:44:14 UT', - ), - array( - 'title' => 'Re: share views between actions?', - 'link' => 'http://groups.google.com/group/cake-php/msg/8b350d898707dad8', - 'description' => 'Then perhaps you might do us all a favour and refrain from replying to
things you do not understand. That goes especially for asinine comments.
Indeed.
To sum up:
No comment.
In my day, a simple "RTFM" would suffice. I\'ll keep in mind to ignore any
further responses from you.
You (and I) were referring to the *online documentation*, not other', - 'guid' => array('@isPermaLink' => 'true', '@' => 'http://groups.google.com/group/cake-php/msg/8b350d898707dad8'), - 'author' => 'subtropolis.z...@gmail.com(subtropolis zijn)', - 'pubDate' => 'Fri, 28 Dec 2007 00:45:01 UT' - ) - ) - ) - )); - $this->assertEquals($expected, $result); - $string = ''; - - $xml = Xml::build($string); - $result = Set::reverse($xml); - $expected = array('data' => array('post' => array('@title' => 'Title of this post', '@description' => 'cool'))); - $this->assertEquals($expected, $result); - - $xml = Xml::build('An example of a correctly reversed SimpleXMLElement'); - $result = Set::reverse($xml); - $expected = array('example' => - array( - 'item' => array( - 'title' => 'An example of a correctly reversed SimpleXMLElement', - 'desc' => '', - ) - ) - ); - $this->assertEquals($expected, $result); - - $xml = Xml::build('title1title2'); - $result = Set::reverse($xml); - $expected = - array('example' => array( - 'item' => array( - '@attr' => '123', - 'titles' => array( - 'title' => array('title1', 'title2') - ) - ) - ) - ); - $this->assertEquals($expected, $result); - - $xml = Xml::build('listtextforitems'); - $result = Set::reverse($xml); - $expected = - array('example' => array( - '@attr' => 'ex_attr', - 'item' => array( - '@attr' => '123', - 'titles' => 'list', - '@' => 'textforitems' - ) - ) - ); - $this->assertEquals($expected, $result); - - $string = ' - - - Cake PHP Google Group - http://groups.google.com/group/cake-php - Search this group before posting anything. There are over 20,000 posts and it&#39;s very likely your question was answered before. Visit the IRC channel #cakephp at irc.freenode.net for live chat with users and developers of Cake. If you post, tell us the version of Cake, PHP, and database. - en - - constructng result array when using findall - http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f - i'm using cakephp to construct a logical data model array that will be <br> passed to a flex app. I have the following model association: <br> ServiceDay-&gt;(hasMany)ServiceTi me-&gt;(hasMany)ServiceTimePrice. So what <br> the current output from my findall is something like this example: <br> <p>Array( <br> [0] =&gt; Array( - cakephp - - - http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f - bmil...@gmail.com(bpscrugs) - Fri, 28 Dec 2007 00:44:14 UT - - - Re: share views between actions? - http://groups.google.com/group/cake-php/msg/8b350d898707dad8 - Then perhaps you might do us all a favour and refrain from replying to <br> things you do not understand. That goes especially for asinine comments. <br> Indeed. <br> To sum up: <br> No comment. <br> In my day, a simple &quot;RTFM&quot; would suffice. I'll keep in mind to ignore any <br> further responses from you. <br> You (and I) were referring to the *online documentation*, not other - cakephp - - - http://groups.google.com/group/cake-php/msg/8b350d898707dad8 - subtropolis.z...@gmail.com(subtropolis zijn) - Fri, 28 Dec 2007 00:45:01 UT - - - '; - - $xml = Xml::build($string); - $result = Set::reverse($xml); - - $expected = array('rss' => array( - '@version' => '2.0', - 'channel' => array( - 'title' => 'Cake PHP Google Group', - 'link' => 'http://groups.google.com/group/cake-php', - 'description' => 'Search this group before posting anything. There are over 20,000 posts and it's very likely your question was answered before. Visit the IRC channel #cakephp at irc.freenode.net for live chat with users and developers of Cake. If you post, tell us the version of Cake, PHP, and database.', - 'language' => 'en', - 'item' => array( - array( - 'title' => 'constructng result array when using findall', - 'link' => 'http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f', - 'description' => "i'm using cakephp to construct a logical data model array that will be
passed to a flex app. I have the following model association:
ServiceDay->(hasMany)ServiceTi me->(hasMany)ServiceTimePrice. So what
the current output from my findall is something like this example:

Array(
[0] => Array(", - 'dc:creator' => 'cakephp', - 'category' => array('cakephp', 'model'), - 'guid' => array('@isPermaLink' => 'true', '@' => 'http://groups.google.com/group/cake-php/msg/49bc00f3bc651b4f'), - 'author' => 'bmil...@gmail.com(bpscrugs)', - 'pubDate' => 'Fri, 28 Dec 2007 00:44:14 UT', - ), - array( - 'title' => 'Re: share views between actions?', - 'link' => 'http://groups.google.com/group/cake-php/msg/8b350d898707dad8', - 'description' => 'Then perhaps you might do us all a favour and refrain from replying to
things you do not understand. That goes especially for asinine comments.
Indeed.
To sum up:
No comment.
In my day, a simple "RTFM" would suffice. I\'ll keep in mind to ignore any
further responses from you.
You (and I) were referring to the *online documentation*, not other', - 'dc:creator' => 'cakephp', - 'category' => array('cakephp', 'model'), - 'guid' => array('@isPermaLink' => 'true', '@' => 'http://groups.google.com/group/cake-php/msg/8b350d898707dad8'), - 'author' => 'subtropolis.z...@gmail.com(subtropolis zijn)', - 'pubDate' => 'Fri, 28 Dec 2007 00:45:01 UT' - ) - ) - ) - )); - $this->assertEquals($expected, $result); - - $text = ' - - - xri://$xrds*simple - 2008-04-13T07:34:58Z - - http://oauth.net/core/1.0/endpoint/authorize - http://oauth.net/core/1.0/parameters/auth-header - http://oauth.net/core/1.0/parameters/uri-query - https://ma.gnolia.com/oauth/authorize - http://ma.gnolia.com/oauth/authorize - - - - xri://$xrds*simple - - http://oauth.net/discovery/1.0 - #oauth - - - '; - - $xml = Xml::build($text); - $result = Set::reverse($xml); - - $expected = array('XRDS' => array( - 'XRD' => array( - array( - '@xml:id' => 'oauth', - '@version' => '2.0', - 'Type' => 'xri://$xrds*simple', - 'Expires' => '2008-04-13T07:34:58Z', - 'Service' => array( - 'Type' => array( - 'http://oauth.net/core/1.0/endpoint/authorize', - 'http://oauth.net/core/1.0/parameters/auth-header', - 'http://oauth.net/core/1.0/parameters/uri-query' - ), - 'URI' => array( - array( - '@' => 'https://ma.gnolia.com/oauth/authorize', - '@priority' => '10', - ), - array( - '@' => 'http://ma.gnolia.com/oauth/authorize', - '@priority' => '20' - ) - ) - ) - ), - array( - '@version' => '2.0', - 'Type' => 'xri://$xrds*simple', - 'Service' => array( - '@priority' => '10', - 'Type' => 'http://oauth.net/discovery/1.0', - 'URI' => '#oauth' - ) - ) - ) - )); - $this->assertEquals($expected, $result); - } - -/** - * testStrictKeyCheck method - * - * @return void - */ - public function testStrictKeyCheck() { - $set = array('a' => 'hi'); - $this->assertFalse(Set::check($set, 'a.b')); - } - -/** - * Tests Set::flatten - * - * @return void - */ - public function testFlatten() { - $data = array('Larry', 'Curly', 'Moe'); - $result = Set::flatten($data); - $this->assertEquals($data, $result); - - $data[9] = 'Shemp'; - $result = Set::flatten($data); - $this->assertEquals($data, $result); - - $data = array( - array( - 'Post' => array('id' => '1', 'author_id' => '1', 'title' => 'First Post'), - 'Author' => array('id' => '1', 'user' => 'nate', 'password' => 'foo'), - ), - array( - 'Post' => array('id' => '2', 'author_id' => '3', 'title' => 'Second Post', 'body' => 'Second Post Body'), - 'Author' => array('id' => '3', 'user' => 'larry', 'password' => null), - ) - ); - - $result = Set::flatten($data); - $expected = array( - '0.Post.id' => '1', '0.Post.author_id' => '1', '0.Post.title' => 'First Post', '0.Author.id' => '1', - '0.Author.user' => 'nate', '0.Author.password' => 'foo', '1.Post.id' => '2', '1.Post.author_id' => '3', - '1.Post.title' => 'Second Post', '1.Post.body' => 'Second Post Body', '1.Author.id' => '3', - '1.Author.user' => 'larry', '1.Author.password' => null - ); - $this->assertEquals($expected, $result); - } - -/** - * test normalization - * - * @return void - */ - public function testNormalizeStrings() { - $result = Set::normalize('one,two,three'); - $expected = array('one' => null, 'two' => null, 'three' => null); - $this->assertEquals($expected, $result); - - $result = Set::normalize('one two three', true, ' '); - $expected = array('one' => null, 'two' => null, 'three' => null); - $this->assertEquals($expected, $result); - - $result = Set::normalize('one , two , three ', true, ',', true); - $expected = array('one' => null, 'two' => null, 'three' => null); - $this->assertEquals($expected, $result); - } - -/** - * test normalizing arrays - * - * @return void - */ - public function testNormalizeArrays() { - $result = Set::normalize(array('one', 'two', 'three')); - $expected = array('one' => null, 'two' => null, 'three' => null); - $this->assertEquals($expected, $result); - - $result = Set::normalize(array('one', 'two', 'three'), false); - $expected = array('one', 'two', 'three'); - $this->assertEquals($expected, $result); - - $result = Set::normalize(array('one' => 1, 'two' => 2, 'three' => 3, 'four'), false); - $expected = array('one' => 1, 'two' => 2, 'three' => 3, 'four' => null); - $this->assertEquals($expected, $result); - - $result = Set::normalize(array('one' => 1, 'two' => 2, 'three' => 3, 'four')); - $expected = array('one' => 1, 'two' => 2, 'three' => 3, 'four' => null); - $this->assertEquals($expected, $result); - - $result = Set::normalize(array('one' => array('a', 'b', 'c' => 'cee'), 'two' => 2, 'three')); - $expected = array('one' => array('a', 'b', 'c' => 'cee'), 'two' => 2, 'three' => null); - $this->assertEquals($expected, $result); - } - -/** - * test Set nest with a normal model result set. For kicks rely on Set nest detecting the key names - * automatically - * - * @return void - */ - public function testNestModel() { - $input = array( - array( - 'ModelName' => array( - 'id' => 1, - 'parent_id' => null - ), - ), - array( - 'ModelName' => array( - 'id' => 2, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 3, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 4, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 5, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 6, - 'parent_id' => null - ), - ), - array( - 'ModelName' => array( - 'id' => 7, - 'parent_id' => 6 - ), - ), - array( - 'ModelName' => array( - 'id' => 8, - 'parent_id' => 6 - ), - ), - array( - 'ModelName' => array( - 'id' => 9, - 'parent_id' => 6 - ), - ), - array( - 'ModelName' => array( - 'id' => 10, - 'parent_id' => 6 - ) - ) - ); - $expected = array( - array( - 'ModelName' => array( - 'id' => 1, - 'parent_id' => null - ), - 'children' => array( - array( - 'ModelName' => array( - 'id' => 2, - 'parent_id' => 1 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 3, - 'parent_id' => 1 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 4, - 'parent_id' => 1 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 5, - 'parent_id' => 1 - ), - 'children' => array() - ), - - ) - ), - array( - 'ModelName' => array( - 'id' => 6, - 'parent_id' => null - ), - 'children' => array( - array( - 'ModelName' => array( - 'id' => 7, - 'parent_id' => 6 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 8, - 'parent_id' => 6 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 9, - 'parent_id' => 6 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 10, - 'parent_id' => 6 - ), - 'children' => array() - ) - ) - ) - ); - $result = Set::nest($input); - $this->assertEquals($expected, $result); - } - -/** - * test Set nest with a normal model result set, and a nominated root id - * - * @return void - */ - public function testNestModelExplicitRoot() { - $input = array( - array( - 'ModelName' => array( - 'id' => 1, - 'parent_id' => null - ), - ), - array( - 'ModelName' => array( - 'id' => 2, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 3, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 4, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 5, - 'parent_id' => 1 - ), - ), - array( - 'ModelName' => array( - 'id' => 6, - 'parent_id' => null - ), - ), - array( - 'ModelName' => array( - 'id' => 7, - 'parent_id' => 6 - ), - ), - array( - 'ModelName' => array( - 'id' => 8, - 'parent_id' => 6 - ), - ), - array( - 'ModelName' => array( - 'id' => 9, - 'parent_id' => 6 - ), - ), - array( - 'ModelName' => array( - 'id' => 10, - 'parent_id' => 6 - ) - ) - ); - $expected = array( - array( - 'ModelName' => array( - 'id' => 6, - 'parent_id' => null - ), - 'children' => array( - array( - 'ModelName' => array( - 'id' => 7, - 'parent_id' => 6 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 8, - 'parent_id' => 6 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 9, - 'parent_id' => 6 - ), - 'children' => array() - ), - array( - 'ModelName' => array( - 'id' => 10, - 'parent_id' => 6 - ), - 'children' => array() - ) - ) - ) - ); - $result = Set::nest($input, array('root' => 6)); - $this->assertEquals($expected, $result); - } - -/** - * test Set nest with a 1d array - this method should be able to handle any type of array input - * - * @return void - */ - public function testNest1Dimensional() { - $input = array( - array( - 'id' => 1, - 'parent_id' => null - ), - array( - 'id' => 2, - 'parent_id' => 1 - ), - array( - 'id' => 3, - 'parent_id' => 1 - ), - array( - 'id' => 4, - 'parent_id' => 1 - ), - array( - 'id' => 5, - 'parent_id' => 1 - ), - array( - 'id' => 6, - 'parent_id' => null - ), - array( - 'id' => 7, - 'parent_id' => 6 - ), - array( - 'id' => 8, - 'parent_id' => 6 - ), - array( - 'id' => 9, - 'parent_id' => 6 - ), - array( - 'id' => 10, - 'parent_id' => 6 - ) - ); - $expected = array( - array( - 'id' => 1, - 'parent_id' => null, - 'children' => array( - array( - 'id' => 2, - 'parent_id' => 1, - 'children' => array() - ), - array( - 'id' => 3, - 'parent_id' => 1, - 'children' => array() - ), - array( - 'id' => 4, - 'parent_id' => 1, - 'children' => array() - ), - array( - 'id' => 5, - 'parent_id' => 1, - 'children' => array() - ), - - ) - ), - array( - 'id' => 6, - 'parent_id' => null, - 'children' => array( - array( - 'id' => 7, - 'parent_id' => 6, - 'children' => array() - ), - array( - 'id' => 8, - 'parent_id' => 6, - 'children' => array() - ), - array( - 'id' => 9, - 'parent_id' => 6, - 'children' => array() - ), - array( - 'id' => 10, - 'parent_id' => 6, - 'children' => array() - ) - ) - ) - ); - $result = Set::nest($input, array('idPath' => '/id', 'parentPath' => '/parent_id')); - $this->assertEquals($expected, $result); - } - -/** - * test Set nest with no specified parent data. - * - * The result should be the same as the input. - * For an easier comparison, unset all the empty children arrays from the result - * - * @return void - */ - public function testMissingParent() { - $input = array( - array( - 'id' => 1, - ), - array( - 'id' => 2, - ), - array( - 'id' => 3, - ), - array( - 'id' => 4, - ), - array( - 'id' => 5, - ), - array( - 'id' => 6, - ), - array( - 'id' => 7, - ), - array( - 'id' => 8, - ), - array( - 'id' => 9, - ), - array( - 'id' => 10, - ) - ); - - $result = Set::nest($input, array('idPath' => '/id', 'parentPath' => '/parent_id')); - foreach ($result as &$row) { - if (empty($row['children'])) { - unset($row['children']); - } - } - $this->assertEquals($input, $result); - } -} diff --git a/lib/Cake/Test/Case/Utility/StringTest.php b/lib/Cake/Test/Case/Utility/StringTest.php deleted file mode 100644 index efce3213ba0..00000000000 --- a/lib/Cake/Test/Case/Utility/StringTest.php +++ /dev/null @@ -1,645 +0,0 @@ -Text = new String(); - } - - public function tearDown() { - parent::tearDown(); - unset($this->Text); - } - -/** - * testUuidGeneration method - * - * @return void - */ - public function testUuidGeneration() { - $result = String::uuid(); - $pattern = "/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/"; - $match = (bool)preg_match($pattern, $result); - $this->assertTrue($match); - } - -/** - * testMultipleUuidGeneration method - * - * @return void - */ - public function testMultipleUuidGeneration() { - $check = array(); - $count = mt_rand(10, 1000); - $pattern = "/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/"; - - for ($i = 0; $i < $count; $i++) { - $result = String::uuid(); - $match = (bool)preg_match($pattern, $result); - $this->assertTrue($match); - $this->assertFalse(in_array($result, $check)); - $check[] = $result; - } - } - -/** - * testInsert method - * - * @return void - */ - public function testInsert() { - $string = 'some string'; - $expected = 'some string'; - $result = String::insert($string, array()); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = :sum. Cake is :adjective.'; - $expected = '2 + 2 = 4. Cake is yummy.'; - $result = String::insert($string, array('sum' => '4', 'adjective' => 'yummy')); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = %sum. Cake is %adjective.'; - $result = String::insert($string, array('sum' => '4', 'adjective' => 'yummy'), array('before' => '%')); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = 2sum2. Cake is 9adjective9.'; - $result = String::insert($string, array('sum' => '4', 'adjective' => 'yummy'), array('format' => '/([\d])%s\\1/')); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = 12sum21. Cake is 23adjective45.'; - $expected = '2 + 2 = 4. Cake is 23adjective45.'; - $result = String::insert($string, array('sum' => '4', 'adjective' => 'yummy'), array('format' => '/([\d])([\d])%s\\2\\1/')); - $this->assertEquals($expected, $result); - - $string = ':web :web_site'; - $expected = 'www http'; - $result = String::insert($string, array('web' => 'www', 'web_site' => 'http')); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = .'; - $expected = '2 + 2 = '4', 'adjective' => 'yummy'), array('before' => '<', 'after' => '>')); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = \:sum. Cake is :adjective.'; - $expected = '2 + 2 = :sum. Cake is yummy.'; - $result = String::insert($string, array('sum' => '4', 'adjective' => 'yummy')); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = !:sum. Cake is :adjective.'; - $result = String::insert($string, array('sum' => '4', 'adjective' => 'yummy'), array('escape' => '!')); - $this->assertEquals($expected, $result); - - $string = '2 + 2 = \%sum. Cake is %adjective.'; - $expected = '2 + 2 = %sum. Cake is yummy.'; - $result = String::insert($string, array('sum' => '4', 'adjective' => 'yummy'), array('before' => '%')); - $this->assertEquals($expected, $result); - - $string = ':a :b \:a :a'; - $expected = '1 2 :a 1'; - $result = String::insert($string, array('a' => 1, 'b' => 2)); - $this->assertEquals($expected, $result); - - $string = ':a :b :c'; - $expected = '2 3'; - $result = String::insert($string, array('b' => 2, 'c' => 3), array('clean' => true)); - $this->assertEquals($expected, $result); - - $string = ':a :b :c'; - $expected = '1 3'; - $result = String::insert($string, array('a' => 1, 'c' => 3), array('clean' => true)); - $this->assertEquals($expected, $result); - - $string = ':a :b :c'; - $expected = '2 3'; - $result = String::insert($string, array('b' => 2, 'c' => 3), array('clean' => true)); - $this->assertEquals($expected, $result); - - $string = ':a, :b and :c'; - $expected = '2 and 3'; - $result = String::insert($string, array('b' => 2, 'c' => 3), array('clean' => true)); - $this->assertEquals($expected, $result); - - $string = '":a, :b and :c"'; - $expected = '"1, 2"'; - $result = String::insert($string, array('a' => 1, 'b' => 2), array('clean' => true)); - $this->assertEquals($expected, $result); - - $string = '"${a}, ${b} and ${c}"'; - $expected = '"1, 2"'; - $result = String::insert($string, array('a' => 1, 'b' => 2), array('before' => '${', 'after' => '}', 'clean' => true)); - $this->assertEquals($expected, $result); - - $string = ':alt'; - $expected = ''; - $result = String::insert($string, array('src' => 'foo'), array('clean' => 'html')); - - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = String::insert($string, array('src' => 'foo'), array('clean' => 'html')); - $this->assertEquals($expected, $result); - - $string = ''; - $expected = ''; - $result = String::insert($string, array('src' => 'foo', 'extra' => 'bar'), array('clean' => 'html')); - $this->assertEquals($expected, $result); - - $result = String::insert("this is a ? string", "test"); - $expected = "this is a test string"; - $this->assertEquals($expected, $result); - - $result = String::insert("this is a ? string with a ? ? ?", array('long', 'few?', 'params', 'you know')); - $expected = "this is a long string with a few? params you know"; - $this->assertEquals($expected, $result); - - $result = String::insert('update saved_urls set url = :url where id = :id', array('url' => 'http://www.testurl.com/param1:url/param2:id','id' => 1)); - $expected = "update saved_urls set url = http://www.testurl.com/param1:url/param2:id where id = 1"; - $this->assertEquals($expected, $result); - - $result = String::insert('update saved_urls set url = :url where id = :id', array('id' => 1, 'url' => 'http://www.testurl.com/param1:url/param2:id')); - $expected = "update saved_urls set url = http://www.testurl.com/param1:url/param2:id where id = 1"; - $this->assertEquals($expected, $result); - - $result = String::insert(':me cake. :subject :verb fantastic.', array('me' => 'I :verb', 'subject' => 'cake', 'verb' => 'is')); - $expected = "I :verb cake. cake is fantastic."; - $this->assertEquals($expected, $result); - - $result = String::insert(':I.am: :not.yet: passing.', array('I.am' => 'We are'), array('before' => ':', 'after' => ':', 'clean' => array('replacement' => ' of course', 'method' => 'text'))); - $expected = "We are of course passing."; - $this->assertEquals($expected, $result); - - $result = String::insert( - ':I.am: :not.yet: passing.', - array('I.am' => 'We are'), - array('before' => ':', 'after' => ':', 'clean' => true) - ); - $expected = "We are passing."; - $this->assertEquals($expected, $result); - - $result = String::insert('?-pended result', array('Pre')); - $expected = "Pre-pended result"; - $this->assertEquals($expected, $result); - - $string = 'switching :timeout / :timeout_count'; - $expected = 'switching 5 / 10'; - $result = String::insert($string, array('timeout' => 5, 'timeout_count' => 10)); - $this->assertEquals($expected, $result); - - $string = 'switching :timeout / :timeout_count'; - $expected = 'switching 5 / 10'; - $result = String::insert($string, array('timeout_count' => 10, 'timeout' => 5)); - $this->assertEquals($expected, $result); - - $string = 'switching :timeout_count by :timeout'; - $expected = 'switching 10 by 5'; - $result = String::insert($string, array('timeout' => 5, 'timeout_count' => 10)); - $this->assertEquals($expected, $result); - - $string = 'switching :timeout_count by :timeout'; - $expected = 'switching 10 by 5'; - $result = String::insert($string, array('timeout_count' => 10, 'timeout' => 5)); - $this->assertEquals($expected, $result); - } - -/** - * test Clean Insert - * - * @return void - */ - public function testCleanInsert() { - $result = String::cleanInsert(':incomplete', array( - 'clean' => true, 'before' => ':', 'after' => '' - )); - $this->assertEquals('', $result); - - $result = String::cleanInsert(':incomplete', array( - 'clean' => array('method' => 'text', 'replacement' => 'complete'), - 'before' => ':', 'after' => '') - ); - $this->assertEquals('complete', $result); - - $result = String::cleanInsert(':in.complete', array( - 'clean' => true, 'before' => ':', 'after' => '' - )); - $this->assertEquals('', $result); - - $result = String::cleanInsert(':in.complete and', array( - 'clean' => true, 'before' => ':', 'after' => '') - ); - $this->assertEquals('', $result); - - $result = String::cleanInsert(':in.complete or stuff', array( - 'clean' => true, 'before' => ':', 'after' => '' - )); - $this->assertEquals('stuff', $result); - - $result = String::cleanInsert( - '

Text here

', - array('clean' => 'html', 'before' => ':', 'after' => '') - ); - $this->assertEquals('

Text here

', $result); - } - -/** - * Tests that non-insertable variables (i.e. arrays) are skipped when used as values in - * String::insert(). - * - * @return void - */ - public function testAutoIgnoreBadInsertData() { - $data = array('foo' => 'alpha', 'bar' => 'beta', 'fale' => array()); - $result = String::insert('(:foo > :bar || :fale!)', $data, array('clean' => 'text')); - $this->assertEquals('(alpha > beta || !)', $result); - } - -/** - * testTokenize method - * - * @return void - */ - public function testTokenize() { - $result = String::tokenize('A,(short,boring test)'); - $expected = array('A', '(short,boring test)'); - $this->assertEquals($expected, $result); - - $result = String::tokenize('A,(short,more interesting( test)'); - $expected = array('A', '(short,more interesting( test)'); - $this->assertEquals($expected, $result); - - $result = String::tokenize('A,(short,very interesting( test))'); - $expected = array('A', '(short,very interesting( test))'); - $this->assertEquals($expected, $result); - - $result = String::tokenize('"single tag"', ' ', '"', '"'); - $expected = array('"single tag"'); - $this->assertEquals($expected, $result); - - $result = String::tokenize('tagA "single tag" tagB', ' ', '"', '"'); - $expected = array('tagA', '"single tag"', 'tagB'); - $this->assertEquals($expected, $result); - } - - public function testReplaceWithQuestionMarkInString() { - $string = ':a, :b and :c?'; - $expected = '2 and 3?'; - $result = String::insert($string, array('b' => 2, 'c' => 3), array('clean' => true)); - $this->assertEquals($expected, $result); - } - -/** - * test wrap method. - * - * @return void - */ - public function testWrap() { - $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; - $result = String::wrap($text, 33); - $expected = <<assertTextEquals($expected, $result, 'Text not wrapped.'); - - $result = String::wrap($text, array('width' => 20, 'wordWrap' => false)); - $expected = <<assertTextEquals($expected, $result, 'Text not wrapped.'); - } - -/** - * test wrap() indenting - * - * @return void - */ - public function testWrapIndent() { - $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; - $result = String::wrap($text, array('width' => 33, 'indent' => "\t", 'indentAt' => 1)); - $expected = <<assertTextEquals($expected, $result); - } - -/** - * testTruncate method - * - * @return void - */ - public function testTruncate() { - $text1 = 'The quick brown fox jumps over the lazy dog'; - $text2 = 'Heizölrückstoßabdämpfung'; - $text3 = '© 2005-2007, Cake Software Foundation, Inc.
written by Alexander Wegener'; - $text4 = ' This image tag is not XHTML conform!

But the following image tag should be conform Me, myself and I
Great, or?'; - $text5 = '01234567890'; - $text6 = '

Extra dates have been announced for this year\'s tour.

Tickets for the new shows in

'; - $text7 = 'El moño está en el lugar correcto. Eso fue lo que dijo la niña, ¿habrá dicho la verdad?'; - $text8 = 'Vive la R' . chr(195) . chr(169) . 'publique de France'; - $text9 = 'НОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; - $text10 = 'http://example.com/something/foo:bar'; - - $this->assertSame($this->Text->truncate($text1, 15), 'The quick br...'); - $this->assertSame($this->Text->truncate($text1, 15, array('exact' => false)), 'The quick...'); - $this->assertSame($this->Text->truncate($text1, 100), 'The quick brown fox jumps over the lazy dog'); - $this->assertSame($this->Text->truncate($text2, 10), 'Heiz&ou...'); - $this->assertSame($this->Text->truncate($text2, 10, array('exact' => false)), '...'); - $this->assertSame($this->Text->truncate($text3, 20), '© 2005-20...'); - $this->assertSame($this->Text->truncate($text4, 15), ' This image ...'); - $this->assertSame($this->Text->truncate($text4, 45, array('html' => true)), ' This image tag is not XHTML conform!

But t...'); - $this->assertSame($this->Text->truncate($text4, 90, array('html' => true)), ' This image tag is not XHTML conform!

But the following image tag should be conform Me, myself and I
Grea...'); - $this->assertSame($this->Text->truncate($text5, 6, array('ending' => '', 'html' => true)), '012345'); - $this->assertSame($this->Text->truncate($text5, 20, array('ending' => '', 'html' => true)), $text5); - $this->assertSame($this->Text->truncate($text6, 57, array('exact' => false, 'html' => true)), "

Extra dates have been announced for this year's...

"); - $this->assertSame($this->Text->truncate($text7, 255), $text7); - $this->assertSame($this->Text->truncate($text7, 15), 'El moño está...'); - $this->assertSame($this->Text->truncate($text8, 15), 'Vive la R' . chr(195) . chr(169) . 'pu...'); - $this->assertSame($this->Text->truncate($text9, 10), 'НОПРСТУ...'); - $this->assertSame($this->Text->truncate($text10, 30), 'http://example.com/somethin...'); - - $text = '

Iamatestwithnospacesandhtml

'; - $result = $this->Text->truncate($text, 10, array( - 'ending' => '...', - 'exact' => false, - 'html' => true - )); - $expected = '

...

'; - $this->assertEquals($expected, $result); - - $text = '

El biógrafo de Steve Jobs, Walter -Isaacson, explica porqué Jobs le pidió que le hiciera su biografía en -este artículo de El País.

-

Por qué Steve era distinto.

-

http://www.elpais.com/articulo/primer/plano/ -Steve/era/distinto/elpepueconeg/20111009elpneglse_4/Tes

-

Ya se ha publicado la biografía de -Steve Jobs escrita por Walter Isaacson "Steve Jobs by Walter -Isaacson", aquí os dejamos la dirección de amazon donde -podeís adquirirla.

-

http://www.amazon.com/Steve- -Jobs-Walter-Isaacson/dp/1451648537

'; - $result = $this->Text->truncate($text, 500, array( - 'ending' => '... ', - 'exact' => false, - 'html' => true - )); - $expected = '

El biógrafo de Steve Jobs, Walter -Isaacson, explica porqué Jobs le pidió que le hiciera su biografía en -este artículo de El País.

-

Por qué Steve era distinto.

-

http://www.elpais.com/articulo/primer/plano/ -Steve/era/distinto/elpepueconeg/20111009elpneglse_4/Tes

-

Ya se ha publicado la biografía de -Steve Jobs escrita por Walter Isaacson "Steve Jobs by Walter -Isaacson", aquí os dejamos la dirección de amazon donde -podeís adquirirla.

-

...

'; - $this->assertEquals($expected, $result); - } - -/** - * testHighlight method - * - * @return void - */ - public function testHighlight() { - $text = 'This is a test text'; - $phrases = array('This', 'text'); - $result = $this->Text->highlight($text, $phrases, array('format' => '\1')); - $expected = 'This is a test text'; - $this->assertEquals($expected, $result); - - $text = 'This is a test text'; - $phrases = null; - $result = $this->Text->highlight($text, $phrases, array('format' => '\1')); - $this->assertEquals($text, $result); - - $text = 'This is a (test) text'; - $phrases = '(test'; - $result = $this->Text->highlight($text, $phrases, array('format' => '\1')); - $this->assertEquals('This is a (test) text', $result); - - $text = 'Ich saß in einem Café am Übergang'; - $expected = 'Ich saß in einem Café am Übergang'; - $phrases = array('saß', 'café', 'übergang'); - $result = $this->Text->highlight($text, $phrases, array('format' => '\1')); - $this->assertEquals($expected, $result); - } - -/** - * testHighlightHtml method - * - * @return void - */ - public function testHighlightHtml() { - $text1 = '

strongbow isn’t real cider

'; - $text2 = '

strongbow isn’t real cider

'; - $text3 = 'What a strong mouse!'; - $text4 = 'What a strong mouse: What a strong mouse!'; - $options = array('format' => '\1', 'html' => true); - - $expected = '

strongbow isn’t real cider

'; - $this->assertEquals($expected, $this->Text->highlight($text1, 'strong', $options)); - - $expected = '

strongbow isn’t real cider

'; - $this->assertEquals($expected, $this->Text->highlight($text2, 'strong', $options)); - - $this->assertEquals($this->Text->highlight($text3, 'strong', $options), $text3); - - $this->assertEquals($this->Text->highlight($text3, array('strong', 'what'), $options), $text3); - - $expected = 'What a strong mouse: What a strong mouse!'; - $this->assertEquals($this->Text->highlight($text4, array('strong', 'what'), $options), $expected); - } - -/** - * testHighlightMulti method - * - * @return void - */ - public function testHighlightMulti() { - $text = 'This is a test text'; - $phrases = array('This', 'text'); - $result = $this->Text->highlight($text, $phrases, array('format' => array('\1', '\1'))); - $expected = 'This is a test text'; - $this->assertEquals($expected, $result); - } - -/** - * testStripLinks method - * - * @return void - */ - public function testStripLinks() { - $text = 'This is a test text'; - $expected = 'This is a test text'; - $result = $this->Text->stripLinks($text); - $this->assertEquals($expected, $result); - - $text = 'This is a test text'; - $expected = 'This is a test text'; - $result = $this->Text->stripLinks($text); - $this->assertEquals($expected, $result); - - $text = 'This is a test text'; - $expected = 'This is a test text'; - $result = $this->Text->stripLinks($text); - $this->assertEquals($expected, $result); - - $text = 'This is a test and some other text'; - $expected = 'This is a test and some other text'; - $result = $this->Text->stripLinks($text); - $this->assertEquals($expected, $result); - } - -/** - * testHighlightCaseInsensitivity method - * - * @return void - */ - public function testHighlightCaseInsensitivity() { - $text = 'This is a Test text'; - $expected = 'This is a Test text'; - - $result = $this->Text->highlight($text, 'test', array('format' => '\1')); - $this->assertEquals($expected, $result); - - $result = $this->Text->highlight($text, array('test'), array('format' => '\1')); - $this->assertEquals($expected, $result); - } - -/** - * testExcerpt method - * - * @return void - */ - public function testExcerpt() { - $text = 'This is a phrase with test text to play with'; - - $expected = '...ase with test text to ...'; - $result = $this->Text->excerpt($text, 'test', 9, '...'); - $this->assertEquals($expected, $result); - - $expected = 'This is a...'; - $result = $this->Text->excerpt($text, 'not_found', 9, '...'); - $this->assertEquals($expected, $result); - - $expected = 'This is a phras...'; - $result = $this->Text->excerpt($text, null, 9, '...'); - $this->assertEquals($expected, $result); - - $expected = $text; - $result = $this->Text->excerpt($text, null, 200, '...'); - $this->assertEquals($expected, $result); - - $expected = '...a phrase w...'; - $result = $this->Text->excerpt($text, 'phrase', 2, '...'); - $this->assertEquals($expected, $result); - - $phrase = 'This is a phrase with test text'; - $expected = $text; - $result = $this->Text->excerpt($text, $phrase, 13, '...'); - $this->assertEquals($expected, $result); - - $text = 'aaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbaaaaaaaaaaaaaaaaaaaaaaaa'; - $phrase = 'bbbbbbbb'; - $result = $this->Text->excerpt($text, $phrase, 10); - $expected = '...aaaaaaaaaabbbbbbbbaaaaaaaaaa...'; - $this->assertEquals($expected, $result); - } - -/** - * testExcerptCaseInsensitivity method - * - * @return void - */ - public function testExcerptCaseInsensitivity() { - $text = 'This is a phrase with test text to play with'; - - $expected = '...ase with test text to ...'; - $result = $this->Text->excerpt($text, 'TEST', 9, '...'); - $this->assertEquals($expected, $result); - - $expected = 'This is a...'; - $result = $this->Text->excerpt($text, 'NOT_FOUND', 9, '...'); - $this->assertEquals($expected, $result); - } - -/** - * testListGeneration method - * - * @return void - */ - public function testListGeneration() { - $result = $this->Text->toList(array()); - $this->assertEquals('', $result); - - $result = $this->Text->toList(array('One')); - $this->assertEquals('One', $result); - - $result = $this->Text->toList(array('Larry', 'Curly', 'Moe')); - $this->assertEquals('Larry, Curly and Moe', $result); - - $result = $this->Text->toList(array('Dusty', 'Lucky', 'Ned'), 'y'); - $this->assertEquals('Dusty, Lucky y Ned', $result); - - $result = $this->Text->toList(array(1 => 'Dusty', 2 => 'Lucky', 3 => 'Ned'), 'y'); - $this->assertEquals('Dusty, Lucky y Ned', $result); - - $result = $this->Text->toList(array(1 => 'Dusty', 2 => 'Lucky', 3 => 'Ned'), 'and', ' + '); - $this->assertEquals('Dusty + Lucky and Ned', $result); - - $result = $this->Text->toList(array('name1' => 'Dusty', 'name2' => 'Lucky')); - $this->assertEquals('Dusty and Lucky', $result); - - $result = $this->Text->toList(array('test_0' => 'banana', 'test_1' => 'apple', 'test_2' => 'lemon')); - $this->assertEquals('banana, apple and lemon', $result); - } - -} diff --git a/lib/Cake/Test/Case/Utility/ValidationTest.php b/lib/Cake/Test/Case/Utility/ValidationTest.php deleted file mode 100644 index 9763e9caa79..00000000000 --- a/lib/Cake/Test/Case/Utility/ValidationTest.php +++ /dev/null @@ -1,2135 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The Open Group Test Suite License - * Redistributions of files must retain the above copyright notice. - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Utility - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Validation', 'Utility'); - -/** - * CustomValidator class - * - * @package Cake.Test.Case.Utility - */ -class CustomValidator { - -/** - * Makes sure that a given $email address is valid and unique - * - * @param string $email - * @return boolean - */ - public static function customValidate($check) { - return (bool)preg_match('/^[0-9]{3}$/', $check); - } - -} - -/** - * TestNlValidation class - * - * Used to test pass through of Validation - * - * @package Cake.Test.Case.Utility - */ -class TestNlValidation { - -/** - * postal function, for testing postal pass through. - * - * @param string $check - * @return void - */ - public static function postal($check) { - return true; - } - -/** - * ssn function for testing ssn pass through - * - * @return void - */ - public static function ssn($check) { - return true; - } - -} - -/** - * TestDeValidation class - * - * Used to test pass through of Validation - * - * @package Cake.Test.Case.Utility - */ -class TestDeValidation { - -/** - * phone function, for testing phone pass through. - * - * @param string $check - * @return void - */ - public static function phone($check) { - return true; - } - -} - -/** - * Test Case for Validation Class - * - * @package Cake.Test.Case.Utility - */ -class ValidationTest extends CakeTestCase { - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->_appEncoding = Configure::read('App.encoding'); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - Configure::write('App.encoding', $this->_appEncoding); - } - -/** - * testNotEmpty method - * - * @return void - */ - public function testNotEmpty() { - $this->assertTrue(Validation::notEmpty('abcdefg')); - $this->assertTrue(Validation::notEmpty('fasdf ')); - $this->assertTrue(Validation::notEmpty('fooo' . chr(243) . 'blabla')); - $this->assertTrue(Validation::notEmpty('abçďĕʑʘπй')); - $this->assertTrue(Validation::notEmpty('José')); - $this->assertTrue(Validation::notEmpty('é')); - $this->assertTrue(Validation::notEmpty('π')); - $this->assertFalse(Validation::notEmpty("\t ")); - $this->assertFalse(Validation::notEmpty("")); - } - -/** - * testNotEmptyISO88591Encoding method - * - * @return void - */ - public function testNotEmptyISO88591AppEncoding() { - Configure::write('App.encoding', 'ISO-8859-1'); - $this->assertTrue(Validation::notEmpty('abcdefg')); - $this->assertTrue(Validation::notEmpty('fasdf ')); - $this->assertTrue(Validation::notEmpty('fooo' . chr(243) . 'blabla')); - $this->assertTrue(Validation::notEmpty('abçďĕʑʘπй')); - $this->assertTrue(Validation::notEmpty('José')); - $this->assertTrue(Validation::notEmpty(utf8_decode('José'))); - $this->assertFalse(Validation::notEmpty("\t ")); - $this->assertFalse(Validation::notEmpty("")); - } - -/** - * testAlphaNumeric method - * - * @return void - */ - public function testAlphaNumeric() { - $this->assertTrue(Validation::alphaNumeric('frferrf')); - $this->assertTrue(Validation::alphaNumeric('12234')); - $this->assertTrue(Validation::alphaNumeric('1w2e2r3t4y')); - $this->assertTrue(Validation::alphaNumeric('0')); - $this->assertTrue(Validation::alphaNumeric('abçďĕʑʘπй')); - $this->assertTrue(Validation::alphaNumeric('ˇˆๆゞ')); - $this->assertTrue(Validation::alphaNumeric('אกあアꀀ豈')); - $this->assertTrue(Validation::alphaNumeric('Džᾈᾨ')); - $this->assertTrue(Validation::alphaNumeric('ÆΔΩЖÇ')); - - $this->assertFalse(Validation::alphaNumeric('12 234')); - $this->assertFalse(Validation::alphaNumeric('dfd 234')); - $this->assertFalse(Validation::alphaNumeric("\n")); - $this->assertFalse(Validation::alphaNumeric("\t")); - $this->assertFalse(Validation::alphaNumeric("\r")); - $this->assertFalse(Validation::alphaNumeric(' ')); - $this->assertFalse(Validation::alphaNumeric('')); - } - -/** - * testAlphaNumericPassedAsArray method - * - * @return void - */ - public function testAlphaNumericPassedAsArray() { - $this->assertTrue(Validation::alphaNumeric(array('check' => 'frferrf'))); - $this->assertTrue(Validation::alphaNumeric(array('check' => '12234'))); - $this->assertTrue(Validation::alphaNumeric(array('check' => '1w2e2r3t4y'))); - $this->assertTrue(Validation::alphaNumeric(array('check' => '0'))); - $this->assertFalse(Validation::alphaNumeric(array('check' => '12 234'))); - $this->assertFalse(Validation::alphaNumeric(array('check' => 'dfd 234'))); - $this->assertFalse(Validation::alphaNumeric(array('check' => "\n"))); - $this->assertFalse(Validation::alphaNumeric(array('check' => "\t"))); - $this->assertFalse(Validation::alphaNumeric(array('check' => "\r"))); - $this->assertFalse(Validation::alphaNumeric(array('check' => ' '))); - $this->assertFalse(Validation::alphaNumeric(array('check' => ''))); - } - -/** - * testBetween method - * - * @return void - */ - public function testBetween() { - $this->assertTrue(Validation::between('abcdefg', 1, 7)); - $this->assertTrue(Validation::between('', 0, 7)); - $this->assertTrue(Validation::between('אกあアꀀ豈', 1, 7)); - - $this->assertFalse(Validation::between('abcdefg', 1, 6)); - $this->assertFalse(Validation::between('ÆΔΩЖÇ', 1, 3)); - } - -/** - * testBlank method - * - * @return void - */ - public function testBlank() { - $this->assertTrue(Validation::blank('')); - $this->assertTrue(Validation::blank(' ')); - $this->assertTrue(Validation::blank("\n")); - $this->assertTrue(Validation::blank("\t")); - $this->assertTrue(Validation::blank("\r")); - $this->assertFalse(Validation::blank(' Blank')); - $this->assertFalse(Validation::blank('Blank')); - } - -/** - * testBlankAsArray method - * - * @return void - */ - public function testBlankAsArray() { - $this->assertTrue(Validation::blank(array('check' => ''))); - $this->assertTrue(Validation::blank(array('check' => ' '))); - $this->assertTrue(Validation::blank(array('check' => "\n"))); - $this->assertTrue(Validation::blank(array('check' => "\t"))); - $this->assertTrue(Validation::blank(array('check' => "\r"))); - $this->assertFalse(Validation::blank(array('check' => ' Blank'))); - $this->assertFalse(Validation::blank(array('check' => 'Blank'))); - } - -/** - * testcc method - * - * @return void - */ - public function testCc() { - //American Express - $this->assertTrue(Validation::cc('370482756063980', array('amex'))); - $this->assertTrue(Validation::cc('349106433773483', array('amex'))); - $this->assertTrue(Validation::cc('344671486204764', array('amex'))); - $this->assertTrue(Validation::cc('344042544509943', array('amex'))); - $this->assertTrue(Validation::cc('377147515754475', array('amex'))); - $this->assertTrue(Validation::cc('375239372816422', array('amex'))); - $this->assertTrue(Validation::cc('376294341957707', array('amex'))); - $this->assertTrue(Validation::cc('341779292230411', array('amex'))); - $this->assertTrue(Validation::cc('341646919853372', array('amex'))); - $this->assertTrue(Validation::cc('348498616319346', array('amex'))); - //BankCard - $this->assertTrue(Validation::cc('5610745867413420', array('bankcard'))); - $this->assertTrue(Validation::cc('5610376649499352', array('bankcard'))); - $this->assertTrue(Validation::cc('5610091936000694', array('bankcard'))); - $this->assertTrue(Validation::cc('5602248780118788', array('bankcard'))); - $this->assertTrue(Validation::cc('5610631567676765', array('bankcard'))); - $this->assertTrue(Validation::cc('5602238211270795', array('bankcard'))); - $this->assertTrue(Validation::cc('5610173951215470', array('bankcard'))); - $this->assertTrue(Validation::cc('5610139705753702', array('bankcard'))); - $this->assertTrue(Validation::cc('5602226032150551', array('bankcard'))); - $this->assertTrue(Validation::cc('5602223993735777', array('bankcard'))); - //Diners Club 14 - $this->assertTrue(Validation::cc('30155483651028', array('diners'))); - $this->assertTrue(Validation::cc('36371312803821', array('diners'))); - $this->assertTrue(Validation::cc('38801277489875', array('diners'))); - $this->assertTrue(Validation::cc('30348560464296', array('diners'))); - $this->assertTrue(Validation::cc('30349040317708', array('diners'))); - $this->assertTrue(Validation::cc('36567413559978', array('diners'))); - $this->assertTrue(Validation::cc('36051554732702', array('diners'))); - $this->assertTrue(Validation::cc('30391842198191', array('diners'))); - $this->assertTrue(Validation::cc('30172682197745', array('diners'))); - $this->assertTrue(Validation::cc('30162056566641', array('diners'))); - $this->assertTrue(Validation::cc('30085066927745', array('diners'))); - $this->assertTrue(Validation::cc('36519025221976', array('diners'))); - $this->assertTrue(Validation::cc('30372679371044', array('diners'))); - $this->assertTrue(Validation::cc('38913939150124', array('diners'))); - $this->assertTrue(Validation::cc('36852899094637', array('diners'))); - $this->assertTrue(Validation::cc('30138041971120', array('diners'))); - $this->assertTrue(Validation::cc('36184047836838', array('diners'))); - $this->assertTrue(Validation::cc('30057460264462', array('diners'))); - $this->assertTrue(Validation::cc('38980165212050', array('diners'))); - $this->assertTrue(Validation::cc('30356516881240', array('diners'))); - $this->assertTrue(Validation::cc('38744810033182', array('diners'))); - $this->assertTrue(Validation::cc('30173638706621', array('diners'))); - $this->assertTrue(Validation::cc('30158334709185', array('diners'))); - $this->assertTrue(Validation::cc('30195413721186', array('diners'))); - $this->assertTrue(Validation::cc('38863347694793', array('diners'))); - $this->assertTrue(Validation::cc('30275627009113', array('diners'))); - $this->assertTrue(Validation::cc('30242860404971', array('diners'))); - $this->assertTrue(Validation::cc('30081877595151', array('diners'))); - $this->assertTrue(Validation::cc('38053196067461', array('diners'))); - $this->assertTrue(Validation::cc('36520379984870', array('diners'))); - //2004 MasterCard/Diners Club Alliance International 14 - $this->assertTrue(Validation::cc('36747701998969', array('diners'))); - $this->assertTrue(Validation::cc('36427861123159', array('diners'))); - $this->assertTrue(Validation::cc('36150537602386', array('diners'))); - $this->assertTrue(Validation::cc('36582388820610', array('diners'))); - $this->assertTrue(Validation::cc('36729045250216', array('diners'))); - //2004 MasterCard/Diners Club Alliance US & Canada 16 - $this->assertTrue(Validation::cc('5597511346169950', array('diners'))); - $this->assertTrue(Validation::cc('5526443162217562', array('diners'))); - $this->assertTrue(Validation::cc('5577265786122391', array('diners'))); - $this->assertTrue(Validation::cc('5534061404676989', array('diners'))); - $this->assertTrue(Validation::cc('5545313588374502', array('diners'))); - //Discover - $this->assertTrue(Validation::cc('6011802876467237', array('disc'))); - $this->assertTrue(Validation::cc('6506432777720955', array('disc'))); - $this->assertTrue(Validation::cc('6011126265283942', array('disc'))); - $this->assertTrue(Validation::cc('6502187151579252', array('disc'))); - $this->assertTrue(Validation::cc('6506600836002298', array('disc'))); - $this->assertTrue(Validation::cc('6504376463615189', array('disc'))); - $this->assertTrue(Validation::cc('6011440907005377', array('disc'))); - $this->assertTrue(Validation::cc('6509735979634270', array('disc'))); - $this->assertTrue(Validation::cc('6011422366775856', array('disc'))); - $this->assertTrue(Validation::cc('6500976374623323', array('disc'))); - //enRoute - $this->assertTrue(Validation::cc('201496944158937', array('enroute'))); - $this->assertTrue(Validation::cc('214945833739665', array('enroute'))); - $this->assertTrue(Validation::cc('214982692491187', array('enroute'))); - $this->assertTrue(Validation::cc('214901395949424', array('enroute'))); - $this->assertTrue(Validation::cc('201480676269187', array('enroute'))); - $this->assertTrue(Validation::cc('214911922887807', array('enroute'))); - $this->assertTrue(Validation::cc('201485025457250', array('enroute'))); - $this->assertTrue(Validation::cc('201402662758866', array('enroute'))); - $this->assertTrue(Validation::cc('214981579370225', array('enroute'))); - $this->assertTrue(Validation::cc('201447595859877', array('enroute'))); - //JCB 15 digit - $this->assertTrue(Validation::cc('210034762247893', array('jcb'))); - $this->assertTrue(Validation::cc('180078671678892', array('jcb'))); - $this->assertTrue(Validation::cc('180010559353736', array('jcb'))); - $this->assertTrue(Validation::cc('210095474464258', array('jcb'))); - $this->assertTrue(Validation::cc('210006675562188', array('jcb'))); - $this->assertTrue(Validation::cc('210063299662662', array('jcb'))); - $this->assertTrue(Validation::cc('180032506857825', array('jcb'))); - $this->assertTrue(Validation::cc('210057919192738', array('jcb'))); - $this->assertTrue(Validation::cc('180031358949367', array('jcb'))); - $this->assertTrue(Validation::cc('180033802147846', array('jcb'))); - //JCB 16 digit - $this->assertTrue(Validation::cc('3096806857839939', array('jcb'))); - $this->assertTrue(Validation::cc('3158699503187091', array('jcb'))); - $this->assertTrue(Validation::cc('3112549607186579', array('jcb'))); - $this->assertTrue(Validation::cc('3112332922425604', array('jcb'))); - $this->assertTrue(Validation::cc('3112001541159239', array('jcb'))); - $this->assertTrue(Validation::cc('3112162495317841', array('jcb'))); - $this->assertTrue(Validation::cc('3337562627732768', array('jcb'))); - $this->assertTrue(Validation::cc('3337107161330775', array('jcb'))); - $this->assertTrue(Validation::cc('3528053736003621', array('jcb'))); - $this->assertTrue(Validation::cc('3528915255020360', array('jcb'))); - $this->assertTrue(Validation::cc('3096786059660921', array('jcb'))); - $this->assertTrue(Validation::cc('3528264799292320', array('jcb'))); - $this->assertTrue(Validation::cc('3096469164130136', array('jcb'))); - $this->assertTrue(Validation::cc('3112127443822853', array('jcb'))); - $this->assertTrue(Validation::cc('3096849995802328', array('jcb'))); - $this->assertTrue(Validation::cc('3528090735127407', array('jcb'))); - $this->assertTrue(Validation::cc('3112101006819234', array('jcb'))); - $this->assertTrue(Validation::cc('3337444428040784', array('jcb'))); - $this->assertTrue(Validation::cc('3088043154151061', array('jcb'))); - $this->assertTrue(Validation::cc('3088295969414866', array('jcb'))); - $this->assertTrue(Validation::cc('3158748843158575', array('jcb'))); - $this->assertTrue(Validation::cc('3158709206148538', array('jcb'))); - $this->assertTrue(Validation::cc('3158365159575324', array('jcb'))); - $this->assertTrue(Validation::cc('3158671691305165', array('jcb'))); - $this->assertTrue(Validation::cc('3528523028771093', array('jcb'))); - $this->assertTrue(Validation::cc('3096057126267870', array('jcb'))); - $this->assertTrue(Validation::cc('3158514047166834', array('jcb'))); - $this->assertTrue(Validation::cc('3528274546125962', array('jcb'))); - $this->assertTrue(Validation::cc('3528890967705733', array('jcb'))); - $this->assertTrue(Validation::cc('3337198811307545', array('jcb'))); - //Maestro (debit card) - $this->assertTrue(Validation::cc('5020147409985219', array('maestro'))); - $this->assertTrue(Validation::cc('5020931809905616', array('maestro'))); - $this->assertTrue(Validation::cc('5020412965470224', array('maestro'))); - $this->assertTrue(Validation::cc('5020129740944022', array('maestro'))); - $this->assertTrue(Validation::cc('5020024696747943', array('maestro'))); - $this->assertTrue(Validation::cc('5020581514636509', array('maestro'))); - $this->assertTrue(Validation::cc('5020695008411987', array('maestro'))); - $this->assertTrue(Validation::cc('5020565359718977', array('maestro'))); - $this->assertTrue(Validation::cc('6339931536544062', array('maestro'))); - $this->assertTrue(Validation::cc('6465028615704406', array('maestro'))); - //Mastercard - $this->assertTrue(Validation::cc('5580424361774366', array('mc'))); - $this->assertTrue(Validation::cc('5589563059318282', array('mc'))); - $this->assertTrue(Validation::cc('5387558333690047', array('mc'))); - $this->assertTrue(Validation::cc('5163919215247175', array('mc'))); - $this->assertTrue(Validation::cc('5386742685055055', array('mc'))); - $this->assertTrue(Validation::cc('5102303335960674', array('mc'))); - $this->assertTrue(Validation::cc('5526543403964565', array('mc'))); - $this->assertTrue(Validation::cc('5538725892618432', array('mc'))); - $this->assertTrue(Validation::cc('5119543573129778', array('mc'))); - $this->assertTrue(Validation::cc('5391174753915767', array('mc'))); - $this->assertTrue(Validation::cc('5510994113980714', array('mc'))); - $this->assertTrue(Validation::cc('5183720260418091', array('mc'))); - $this->assertTrue(Validation::cc('5488082196086704', array('mc'))); - $this->assertTrue(Validation::cc('5484645164161834', array('mc'))); - $this->assertTrue(Validation::cc('5171254350337031', array('mc'))); - $this->assertTrue(Validation::cc('5526987528136452', array('mc'))); - $this->assertTrue(Validation::cc('5504148941409358', array('mc'))); - $this->assertTrue(Validation::cc('5240793507243615', array('mc'))); - $this->assertTrue(Validation::cc('5162114693017107', array('mc'))); - $this->assertTrue(Validation::cc('5163104807404753', array('mc'))); - $this->assertTrue(Validation::cc('5590136167248365', array('mc'))); - $this->assertTrue(Validation::cc('5565816281038948', array('mc'))); - $this->assertTrue(Validation::cc('5467639122779531', array('mc'))); - $this->assertTrue(Validation::cc('5297350261550024', array('mc'))); - $this->assertTrue(Validation::cc('5162739131368058', array('mc'))); - //Solo 16 - $this->assertTrue(Validation::cc('6767432107064987', array('solo'))); - $this->assertTrue(Validation::cc('6334667758225411', array('solo'))); - $this->assertTrue(Validation::cc('6767037421954068', array('solo'))); - $this->assertTrue(Validation::cc('6767823306394854', array('solo'))); - $this->assertTrue(Validation::cc('6334768185398134', array('solo'))); - $this->assertTrue(Validation::cc('6767286729498589', array('solo'))); - $this->assertTrue(Validation::cc('6334972104431261', array('solo'))); - $this->assertTrue(Validation::cc('6334843427400616', array('solo'))); - $this->assertTrue(Validation::cc('6767493947881311', array('solo'))); - $this->assertTrue(Validation::cc('6767194235798817', array('solo'))); - //Solo 18 - $this->assertTrue(Validation::cc('676714834398858593', array('solo'))); - $this->assertTrue(Validation::cc('676751666435130857', array('solo'))); - $this->assertTrue(Validation::cc('676781908573924236', array('solo'))); - $this->assertTrue(Validation::cc('633488724644003240', array('solo'))); - $this->assertTrue(Validation::cc('676732252338067316', array('solo'))); - $this->assertTrue(Validation::cc('676747520084495821', array('solo'))); - $this->assertTrue(Validation::cc('633465488901381957', array('solo'))); - $this->assertTrue(Validation::cc('633487484858610484', array('solo'))); - $this->assertTrue(Validation::cc('633453764680740694', array('solo'))); - $this->assertTrue(Validation::cc('676768613295414451', array('solo'))); - //Solo 19 - $this->assertTrue(Validation::cc('6767838565218340113', array('solo'))); - $this->assertTrue(Validation::cc('6767760119829705181', array('solo'))); - $this->assertTrue(Validation::cc('6767265917091593668', array('solo'))); - $this->assertTrue(Validation::cc('6767938856947440111', array('solo'))); - $this->assertTrue(Validation::cc('6767501945697390076', array('solo'))); - $this->assertTrue(Validation::cc('6334902868716257379', array('solo'))); - $this->assertTrue(Validation::cc('6334922127686425532', array('solo'))); - $this->assertTrue(Validation::cc('6334933119080706440', array('solo'))); - $this->assertTrue(Validation::cc('6334647959628261714', array('solo'))); - $this->assertTrue(Validation::cc('6334527312384101382', array('solo'))); - //Switch 16 - $this->assertTrue(Validation::cc('5641829171515733', array('switch'))); - $this->assertTrue(Validation::cc('5641824852820809', array('switch'))); - $this->assertTrue(Validation::cc('6759129648956909', array('switch'))); - $this->assertTrue(Validation::cc('6759626072268156', array('switch'))); - $this->assertTrue(Validation::cc('5641822698388957', array('switch'))); - $this->assertTrue(Validation::cc('5641827123105470', array('switch'))); - $this->assertTrue(Validation::cc('5641823755819553', array('switch'))); - $this->assertTrue(Validation::cc('5641821939587682', array('switch'))); - $this->assertTrue(Validation::cc('4936097148079186', array('switch'))); - $this->assertTrue(Validation::cc('5641829739125009', array('switch'))); - $this->assertTrue(Validation::cc('5641822860725507', array('switch'))); - $this->assertTrue(Validation::cc('4936717688865831', array('switch'))); - $this->assertTrue(Validation::cc('6759487613615441', array('switch'))); - $this->assertTrue(Validation::cc('5641821346840617', array('switch'))); - $this->assertTrue(Validation::cc('5641825793417126', array('switch'))); - $this->assertTrue(Validation::cc('5641821302759595', array('switch'))); - $this->assertTrue(Validation::cc('6759784969918837', array('switch'))); - $this->assertTrue(Validation::cc('5641824910667036', array('switch'))); - $this->assertTrue(Validation::cc('6759139909636173', array('switch'))); - $this->assertTrue(Validation::cc('6333425070638022', array('switch'))); - $this->assertTrue(Validation::cc('5641823910382067', array('switch'))); - $this->assertTrue(Validation::cc('4936295218139423', array('switch'))); - $this->assertTrue(Validation::cc('6333031811316199', array('switch'))); - $this->assertTrue(Validation::cc('4936912044763198', array('switch'))); - $this->assertTrue(Validation::cc('4936387053303824', array('switch'))); - $this->assertTrue(Validation::cc('6759535838760523', array('switch'))); - $this->assertTrue(Validation::cc('6333427174594051', array('switch'))); - $this->assertTrue(Validation::cc('5641829037102700', array('switch'))); - $this->assertTrue(Validation::cc('5641826495463046', array('switch'))); - $this->assertTrue(Validation::cc('6333480852979946', array('switch'))); - $this->assertTrue(Validation::cc('5641827761302876', array('switch'))); - $this->assertTrue(Validation::cc('5641825083505317', array('switch'))); - $this->assertTrue(Validation::cc('6759298096003991', array('switch'))); - $this->assertTrue(Validation::cc('4936119165483420', array('switch'))); - $this->assertTrue(Validation::cc('4936190990500993', array('switch'))); - $this->assertTrue(Validation::cc('4903356467384927', array('switch'))); - $this->assertTrue(Validation::cc('6333372765092554', array('switch'))); - $this->assertTrue(Validation::cc('5641821330950570', array('switch'))); - $this->assertTrue(Validation::cc('6759841558826118', array('switch'))); - $this->assertTrue(Validation::cc('4936164540922452', array('switch'))); - //Switch 18 - $this->assertTrue(Validation::cc('493622764224625174', array('switch'))); - $this->assertTrue(Validation::cc('564182823396913535', array('switch'))); - $this->assertTrue(Validation::cc('675917308304801234', array('switch'))); - $this->assertTrue(Validation::cc('675919890024220298', array('switch'))); - $this->assertTrue(Validation::cc('633308376862556751', array('switch'))); - $this->assertTrue(Validation::cc('564182377633208779', array('switch'))); - $this->assertTrue(Validation::cc('564182870014926787', array('switch'))); - $this->assertTrue(Validation::cc('675979788553829819', array('switch'))); - $this->assertTrue(Validation::cc('493668394358130935', array('switch'))); - $this->assertTrue(Validation::cc('493637431790930965', array('switch'))); - $this->assertTrue(Validation::cc('633321438601941513', array('switch'))); - $this->assertTrue(Validation::cc('675913800898840986', array('switch'))); - $this->assertTrue(Validation::cc('564182592016841547', array('switch'))); - $this->assertTrue(Validation::cc('564182428380440899', array('switch'))); - $this->assertTrue(Validation::cc('493696376827623463', array('switch'))); - $this->assertTrue(Validation::cc('675977939286485757', array('switch'))); - $this->assertTrue(Validation::cc('490302699502091579', array('switch'))); - $this->assertTrue(Validation::cc('564182085013662230', array('switch'))); - $this->assertTrue(Validation::cc('493693054263310167', array('switch'))); - $this->assertTrue(Validation::cc('633321755966697525', array('switch'))); - $this->assertTrue(Validation::cc('675996851719732811', array('switch'))); - $this->assertTrue(Validation::cc('493699211208281028', array('switch'))); - $this->assertTrue(Validation::cc('493697817378356614', array('switch'))); - $this->assertTrue(Validation::cc('675968224161768150', array('switch'))); - $this->assertTrue(Validation::cc('493669416873337627', array('switch'))); - $this->assertTrue(Validation::cc('564182439172549714', array('switch'))); - $this->assertTrue(Validation::cc('675926914467673598', array('switch'))); - $this->assertTrue(Validation::cc('564182565231977809', array('switch'))); - $this->assertTrue(Validation::cc('675966282607849002', array('switch'))); - $this->assertTrue(Validation::cc('493691609704348548', array('switch'))); - $this->assertTrue(Validation::cc('675933118546065120', array('switch'))); - $this->assertTrue(Validation::cc('493631116677238592', array('switch'))); - $this->assertTrue(Validation::cc('675921142812825938', array('switch'))); - $this->assertTrue(Validation::cc('633338311815675113', array('switch'))); - $this->assertTrue(Validation::cc('633323539867338621', array('switch'))); - $this->assertTrue(Validation::cc('675964912740845663', array('switch'))); - $this->assertTrue(Validation::cc('633334008833727504', array('switch'))); - $this->assertTrue(Validation::cc('493631941273687169', array('switch'))); - $this->assertTrue(Validation::cc('564182971729706785', array('switch'))); - $this->assertTrue(Validation::cc('633303461188963496', array('switch'))); - //Switch 19 - $this->assertTrue(Validation::cc('6759603460617628716', array('switch'))); - $this->assertTrue(Validation::cc('4936705825268647681', array('switch'))); - $this->assertTrue(Validation::cc('5641829846600479183', array('switch'))); - $this->assertTrue(Validation::cc('6759389846573792530', array('switch'))); - $this->assertTrue(Validation::cc('4936189558712637603', array('switch'))); - $this->assertTrue(Validation::cc('5641822217393868189', array('switch'))); - $this->assertTrue(Validation::cc('4903075563780057152', array('switch'))); - $this->assertTrue(Validation::cc('4936510653566569547', array('switch'))); - $this->assertTrue(Validation::cc('4936503083627303364', array('switch'))); - $this->assertTrue(Validation::cc('4936777334398116272', array('switch'))); - $this->assertTrue(Validation::cc('5641823876900554860', array('switch'))); - $this->assertTrue(Validation::cc('6759619236903407276', array('switch'))); - $this->assertTrue(Validation::cc('6759011470269978117', array('switch'))); - $this->assertTrue(Validation::cc('6333175833997062502', array('switch'))); - $this->assertTrue(Validation::cc('6759498728789080439', array('switch'))); - $this->assertTrue(Validation::cc('4903020404168157841', array('switch'))); - $this->assertTrue(Validation::cc('6759354334874804313', array('switch'))); - $this->assertTrue(Validation::cc('6759900856420875115', array('switch'))); - $this->assertTrue(Validation::cc('5641827269346868860', array('switch'))); - $this->assertTrue(Validation::cc('5641828995047453870', array('switch'))); - $this->assertTrue(Validation::cc('6333321884754806543', array('switch'))); - $this->assertTrue(Validation::cc('6333108246283715901', array('switch'))); - $this->assertTrue(Validation::cc('6759572372800700102', array('switch'))); - $this->assertTrue(Validation::cc('4903095096797974933', array('switch'))); - $this->assertTrue(Validation::cc('6333354315797920215', array('switch'))); - $this->assertTrue(Validation::cc('6759163746089433755', array('switch'))); - $this->assertTrue(Validation::cc('6759871666634807647', array('switch'))); - $this->assertTrue(Validation::cc('5641827883728575248', array('switch'))); - $this->assertTrue(Validation::cc('4936527975051407847', array('switch'))); - $this->assertTrue(Validation::cc('5641823318396882141', array('switch'))); - $this->assertTrue(Validation::cc('6759123772311123708', array('switch'))); - $this->assertTrue(Validation::cc('4903054736148271088', array('switch'))); - $this->assertTrue(Validation::cc('4936477526808883952', array('switch'))); - $this->assertTrue(Validation::cc('4936433964890967966', array('switch'))); - $this->assertTrue(Validation::cc('6333245128906049344', array('switch'))); - $this->assertTrue(Validation::cc('4936321036970553134', array('switch'))); - $this->assertTrue(Validation::cc('4936111816358702773', array('switch'))); - $this->assertTrue(Validation::cc('4936196077254804290', array('switch'))); - $this->assertTrue(Validation::cc('6759558831206830183', array('switch'))); - $this->assertTrue(Validation::cc('5641827998830403137', array('switch'))); - //VISA 13 digit - $this->assertTrue(Validation::cc('4024007174754', array('visa'))); - $this->assertTrue(Validation::cc('4104816460717', array('visa'))); - $this->assertTrue(Validation::cc('4716229700437', array('visa'))); - $this->assertTrue(Validation::cc('4539305400213', array('visa'))); - $this->assertTrue(Validation::cc('4728260558665', array('visa'))); - $this->assertTrue(Validation::cc('4929100131792', array('visa'))); - $this->assertTrue(Validation::cc('4024007117308', array('visa'))); - $this->assertTrue(Validation::cc('4539915491024', array('visa'))); - $this->assertTrue(Validation::cc('4539790901139', array('visa'))); - $this->assertTrue(Validation::cc('4485284914909', array('visa'))); - $this->assertTrue(Validation::cc('4782793022350', array('visa'))); - $this->assertTrue(Validation::cc('4556899290685', array('visa'))); - $this->assertTrue(Validation::cc('4024007134774', array('visa'))); - $this->assertTrue(Validation::cc('4333412341316', array('visa'))); - $this->assertTrue(Validation::cc('4539534204543', array('visa'))); - $this->assertTrue(Validation::cc('4485640373626', array('visa'))); - $this->assertTrue(Validation::cc('4929911445746', array('visa'))); - $this->assertTrue(Validation::cc('4539292550806', array('visa'))); - $this->assertTrue(Validation::cc('4716523014030', array('visa'))); - $this->assertTrue(Validation::cc('4024007125152', array('visa'))); - $this->assertTrue(Validation::cc('4539758883311', array('visa'))); - $this->assertTrue(Validation::cc('4024007103258', array('visa'))); - $this->assertTrue(Validation::cc('4916933155767', array('visa'))); - $this->assertTrue(Validation::cc('4024007159672', array('visa'))); - $this->assertTrue(Validation::cc('4716935544871', array('visa'))); - $this->assertTrue(Validation::cc('4929415177779', array('visa'))); - $this->assertTrue(Validation::cc('4929748547896', array('visa'))); - $this->assertTrue(Validation::cc('4929153468612', array('visa'))); - $this->assertTrue(Validation::cc('4539397132104', array('visa'))); - $this->assertTrue(Validation::cc('4485293435540', array('visa'))); - $this->assertTrue(Validation::cc('4485799412720', array('visa'))); - $this->assertTrue(Validation::cc('4916744757686', array('visa'))); - $this->assertTrue(Validation::cc('4556475655426', array('visa'))); - $this->assertTrue(Validation::cc('4539400441625', array('visa'))); - $this->assertTrue(Validation::cc('4485437129173', array('visa'))); - $this->assertTrue(Validation::cc('4716253605320', array('visa'))); - $this->assertTrue(Validation::cc('4539366156589', array('visa'))); - $this->assertTrue(Validation::cc('4916498061392', array('visa'))); - $this->assertTrue(Validation::cc('4716127163779', array('visa'))); - $this->assertTrue(Validation::cc('4024007183078', array('visa'))); - $this->assertTrue(Validation::cc('4041553279654', array('visa'))); - $this->assertTrue(Validation::cc('4532380121960', array('visa'))); - $this->assertTrue(Validation::cc('4485906062491', array('visa'))); - $this->assertTrue(Validation::cc('4539365115149', array('visa'))); - $this->assertTrue(Validation::cc('4485146516702', array('visa'))); - //VISA 16 digit - $this->assertTrue(Validation::cc('4916375389940009', array('visa'))); - $this->assertTrue(Validation::cc('4929167481032610', array('visa'))); - $this->assertTrue(Validation::cc('4485029969061519', array('visa'))); - $this->assertTrue(Validation::cc('4485573845281759', array('visa'))); - $this->assertTrue(Validation::cc('4485669810383529', array('visa'))); - $this->assertTrue(Validation::cc('4929615806560327', array('visa'))); - $this->assertTrue(Validation::cc('4556807505609535', array('visa'))); - $this->assertTrue(Validation::cc('4532611336232890', array('visa'))); - $this->assertTrue(Validation::cc('4532201952422387', array('visa'))); - $this->assertTrue(Validation::cc('4485073797976290', array('visa'))); - $this->assertTrue(Validation::cc('4024007157580969', array('visa'))); - $this->assertTrue(Validation::cc('4053740470212274', array('visa'))); - $this->assertTrue(Validation::cc('4716265831525676', array('visa'))); - $this->assertTrue(Validation::cc('4024007100222966', array('visa'))); - $this->assertTrue(Validation::cc('4539556148303244', array('visa'))); - $this->assertTrue(Validation::cc('4532449879689709', array('visa'))); - $this->assertTrue(Validation::cc('4916805467840986', array('visa'))); - $this->assertTrue(Validation::cc('4532155644440233', array('visa'))); - $this->assertTrue(Validation::cc('4467977802223781', array('visa'))); - $this->assertTrue(Validation::cc('4539224637000686', array('visa'))); - $this->assertTrue(Validation::cc('4556629187064965', array('visa'))); - $this->assertTrue(Validation::cc('4532970205932943', array('visa'))); - $this->assertTrue(Validation::cc('4821470132041850', array('visa'))); - $this->assertTrue(Validation::cc('4916214267894485', array('visa'))); - $this->assertTrue(Validation::cc('4024007169073284', array('visa'))); - $this->assertTrue(Validation::cc('4716783351296122', array('visa'))); - $this->assertTrue(Validation::cc('4556480171913795', array('visa'))); - $this->assertTrue(Validation::cc('4929678411034997', array('visa'))); - $this->assertTrue(Validation::cc('4682061913519392', array('visa'))); - $this->assertTrue(Validation::cc('4916495481746474', array('visa'))); - $this->assertTrue(Validation::cc('4929007108460499', array('visa'))); - $this->assertTrue(Validation::cc('4539951357838586', array('visa'))); - $this->assertTrue(Validation::cc('4716482691051558', array('visa'))); - $this->assertTrue(Validation::cc('4916385069917516', array('visa'))); - $this->assertTrue(Validation::cc('4929020289494641', array('visa'))); - $this->assertTrue(Validation::cc('4532176245263774', array('visa'))); - $this->assertTrue(Validation::cc('4556242273553949', array('visa'))); - $this->assertTrue(Validation::cc('4481007485188614', array('visa'))); - $this->assertTrue(Validation::cc('4716533372139623', array('visa'))); - $this->assertTrue(Validation::cc('4929152038152632', array('visa'))); - $this->assertTrue(Validation::cc('4539404037310550', array('visa'))); - $this->assertTrue(Validation::cc('4532800925229140', array('visa'))); - $this->assertTrue(Validation::cc('4916845885268360', array('visa'))); - $this->assertTrue(Validation::cc('4394514669078434', array('visa'))); - $this->assertTrue(Validation::cc('4485611378115042', array('visa'))); - //Visa Electron - $this->assertTrue(Validation::cc('4175003346287100', array('electron'))); - $this->assertTrue(Validation::cc('4913042516577228', array('electron'))); - $this->assertTrue(Validation::cc('4917592325659381', array('electron'))); - $this->assertTrue(Validation::cc('4917084924450511', array('electron'))); - $this->assertTrue(Validation::cc('4917994610643999', array('electron'))); - $this->assertTrue(Validation::cc('4175005933743585', array('electron'))); - $this->assertTrue(Validation::cc('4175008373425044', array('electron'))); - $this->assertTrue(Validation::cc('4913119763664154', array('electron'))); - $this->assertTrue(Validation::cc('4913189017481812', array('electron'))); - $this->assertTrue(Validation::cc('4913085104968622', array('electron'))); - $this->assertTrue(Validation::cc('4175008803122021', array('electron'))); - $this->assertTrue(Validation::cc('4913294453962489', array('electron'))); - $this->assertTrue(Validation::cc('4175009797419290', array('electron'))); - $this->assertTrue(Validation::cc('4175005028142917', array('electron'))); - $this->assertTrue(Validation::cc('4913940802385364', array('electron'))); - //Voyager - $this->assertTrue(Validation::cc('869940697287073', array('voyager'))); - $this->assertTrue(Validation::cc('869934523596112', array('voyager'))); - $this->assertTrue(Validation::cc('869958670174621', array('voyager'))); - $this->assertTrue(Validation::cc('869921250068209', array('voyager'))); - $this->assertTrue(Validation::cc('869972521242198', array('voyager'))); - } - -/** - * testLuhn method - * - * @return void - */ - public function testLuhn() { - //American Express - $this->assertTrue(Validation::luhn('370482756063980', true)); - //BankCard - $this->assertTrue(Validation::luhn('5610745867413420', true)); - //Diners Club 14 - $this->assertTrue(Validation::luhn('30155483651028', true)); - //2004 MasterCard/Diners Club Alliance International 14 - $this->assertTrue(Validation::luhn('36747701998969', true)); - //2004 MasterCard/Diners Club Alliance US & Canada 16 - $this->assertTrue(Validation::luhn('5597511346169950', true)); - //Discover - $this->assertTrue(Validation::luhn('6011802876467237', true)); - //enRoute - $this->assertTrue(Validation::luhn('201496944158937', true)); - //JCB 15 digit - $this->assertTrue(Validation::luhn('210034762247893', true)); - //JCB 16 digit - $this->assertTrue(Validation::luhn('3096806857839939', true)); - //Maestro (debit card) - $this->assertTrue(Validation::luhn('5020147409985219', true)); - //Mastercard - $this->assertTrue(Validation::luhn('5580424361774366', true)); - //Solo 16 - $this->assertTrue(Validation::luhn('6767432107064987', true)); - //Solo 18 - $this->assertTrue(Validation::luhn('676714834398858593', true)); - //Solo 19 - $this->assertTrue(Validation::luhn('6767838565218340113', true)); - //Switch 16 - $this->assertTrue(Validation::luhn('5641829171515733', true)); - //Switch 18 - $this->assertTrue(Validation::luhn('493622764224625174', true)); - //Switch 19 - $this->assertTrue(Validation::luhn('6759603460617628716', true)); - //VISA 13 digit - $this->assertTrue(Validation::luhn('4024007174754', true)); - //VISA 16 digit - $this->assertTrue(Validation::luhn('4916375389940009', true)); - //Visa Electron - $this->assertTrue(Validation::luhn('4175003346287100', true)); - //Voyager - $this->assertTrue(Validation::luhn('869940697287073', true)); - - $this->assertFalse(Validation::luhn('0000000000000000', true)); - - $this->assertFalse(Validation::luhn('869940697287173', true)); - } - -/** - * testCustomRegexForCc method - * - * @return void - */ - public function testCustomRegexForCc() { - $this->assertTrue(Validation::cc('12332105933743585', null, null, '/123321\\d{11}/')); - $this->assertFalse(Validation::cc('1233210593374358', null, null, '/123321\\d{11}/')); - $this->assertFalse(Validation::cc('12312305933743585', null, null, '/123321\\d{11}/')); - } - -/** - * testCustomRegexForCcWithLuhnCheck method - * - * @return void - */ - public function testCustomRegexForCcWithLuhnCheck() { - $this->assertTrue(Validation::cc('12332110426226941', null, true, '/123321\\d{11}/')); - $this->assertFalse(Validation::cc('12332105933743585', null, true, '/123321\\d{11}/')); - $this->assertFalse(Validation::cc('12332105933743587', null, true, '/123321\\d{11}/')); - $this->assertFalse(Validation::cc('12312305933743585', null, true, '/123321\\d{11}/')); - } - -/** - * testFastCc method - * - * @return void - */ - public function testFastCc() { - // too short - $this->assertFalse(Validation::cc('123456789012')); - //American Express - $this->assertTrue(Validation::cc('370482756063980')); - //Diners Club 14 - $this->assertTrue(Validation::cc('30155483651028')); - //2004 MasterCard/Diners Club Alliance International 14 - $this->assertTrue(Validation::cc('36747701998969')); - //2004 MasterCard/Diners Club Alliance US & Canada 16 - $this->assertTrue(Validation::cc('5597511346169950')); - //Discover - $this->assertTrue(Validation::cc('6011802876467237')); - //Mastercard - $this->assertTrue(Validation::cc('5580424361774366')); - //VISA 13 digit - $this->assertTrue(Validation::cc('4024007174754')); - //VISA 16 digit - $this->assertTrue(Validation::cc('4916375389940009')); - //Visa Electron - $this->assertTrue(Validation::cc('4175003346287100')); - } - -/** - * testAllCc method - * - * @return void - */ - public function testAllCc() { - //American Express - $this->assertTrue(Validation::cc('370482756063980', 'all')); - //BankCard - $this->assertTrue(Validation::cc('5610745867413420', 'all')); - //Diners Club 14 - $this->assertTrue(Validation::cc('30155483651028', 'all')); - //2004 MasterCard/Diners Club Alliance International 14 - $this->assertTrue(Validation::cc('36747701998969', 'all')); - //2004 MasterCard/Diners Club Alliance US & Canada 16 - $this->assertTrue(Validation::cc('5597511346169950', 'all')); - //Discover - $this->assertTrue(Validation::cc('6011802876467237', 'all')); - //enRoute - $this->assertTrue(Validation::cc('201496944158937', 'all')); - //JCB 15 digit - $this->assertTrue(Validation::cc('210034762247893', 'all')); - //JCB 16 digit - $this->assertTrue(Validation::cc('3096806857839939', 'all')); - //Maestro (debit card) - $this->assertTrue(Validation::cc('5020147409985219', 'all')); - //Mastercard - $this->assertTrue(Validation::cc('5580424361774366', 'all')); - //Solo 16 - $this->assertTrue(Validation::cc('6767432107064987', 'all')); - //Solo 18 - $this->assertTrue(Validation::cc('676714834398858593', 'all')); - //Solo 19 - $this->assertTrue(Validation::cc('6767838565218340113', 'all')); - //Switch 16 - $this->assertTrue(Validation::cc('5641829171515733', 'all')); - //Switch 18 - $this->assertTrue(Validation::cc('493622764224625174', 'all')); - //Switch 19 - $this->assertTrue(Validation::cc('6759603460617628716', 'all')); - //VISA 13 digit - $this->assertTrue(Validation::cc('4024007174754', 'all')); - //VISA 16 digit - $this->assertTrue(Validation::cc('4916375389940009', 'all')); - //Visa Electron - $this->assertTrue(Validation::cc('4175003346287100', 'all')); - //Voyager - $this->assertTrue(Validation::cc('869940697287073', 'all')); - } - -/** - * testAllCcDeep method - * - * @return void - */ - public function testAllCcDeep() { - //American Express - $this->assertTrue(Validation::cc('370482756063980', 'all', true)); - //BankCard - $this->assertTrue(Validation::cc('5610745867413420', 'all', true)); - //Diners Club 14 - $this->assertTrue(Validation::cc('30155483651028', 'all', true)); - //2004 MasterCard/Diners Club Alliance International 14 - $this->assertTrue(Validation::cc('36747701998969', 'all', true)); - //2004 MasterCard/Diners Club Alliance US & Canada 16 - $this->assertTrue(Validation::cc('5597511346169950', 'all', true)); - //Discover - $this->assertTrue(Validation::cc('6011802876467237', 'all', true)); - //enRoute - $this->assertTrue(Validation::cc('201496944158937', 'all', true)); - //JCB 15 digit - $this->assertTrue(Validation::cc('210034762247893', 'all', true)); - //JCB 16 digit - $this->assertTrue(Validation::cc('3096806857839939', 'all', true)); - //Maestro (debit card) - $this->assertTrue(Validation::cc('5020147409985219', 'all', true)); - //Mastercard - $this->assertTrue(Validation::cc('5580424361774366', 'all', true)); - //Solo 16 - $this->assertTrue(Validation::cc('6767432107064987', 'all', true)); - //Solo 18 - $this->assertTrue(Validation::cc('676714834398858593', 'all', true)); - //Solo 19 - $this->assertTrue(Validation::cc('6767838565218340113', 'all', true)); - //Switch 16 - $this->assertTrue(Validation::cc('5641829171515733', 'all', true)); - //Switch 18 - $this->assertTrue(Validation::cc('493622764224625174', 'all', true)); - //Switch 19 - $this->assertTrue(Validation::cc('6759603460617628716', 'all', true)); - //VISA 13 digit - $this->assertTrue(Validation::cc('4024007174754', 'all', true)); - //VISA 16 digit - $this->assertTrue(Validation::cc('4916375389940009', 'all', true)); - //Visa Electron - $this->assertTrue(Validation::cc('4175003346287100', 'all', true)); - //Voyager - $this->assertTrue(Validation::cc('869940697287073', 'all', true)); - } - -/** - * testComparison method - * - * @return void - */ - public function testComparison() { - $this->assertFalse(Validation::comparison(7, null, 6)); - $this->assertTrue(Validation::comparison(7, 'is greater', 6)); - $this->assertTrue(Validation::comparison(7, '>', 6)); - $this->assertTrue(Validation::comparison(6, 'is less', 7)); - $this->assertTrue(Validation::comparison(6, '<', 7)); - $this->assertTrue(Validation::comparison(7, 'greater or equal', 7)); - $this->assertTrue(Validation::comparison(7, '>=', 7)); - $this->assertTrue(Validation::comparison(7, 'greater or equal', 6)); - $this->assertTrue(Validation::comparison(7, '>=', 6)); - $this->assertTrue(Validation::comparison(6, 'less or equal', 7)); - $this->assertTrue(Validation::comparison(6, '<=', 7)); - $this->assertTrue(Validation::comparison(7, 'equal to', 7)); - $this->assertTrue(Validation::comparison(7, '==', 7)); - $this->assertTrue(Validation::comparison(7, 'not equal', 6)); - $this->assertTrue(Validation::comparison(7, '!=', 6)); - $this->assertFalse(Validation::comparison(6, 'is greater', 7)); - $this->assertFalse(Validation::comparison(6, '>', 7)); - $this->assertFalse(Validation::comparison(7, 'is less', 6)); - $this->assertFalse(Validation::comparison(7, '<', 6)); - $this->assertFalse(Validation::comparison(6, 'greater or equal', 7)); - $this->assertFalse(Validation::comparison(6, '>=', 7)); - $this->assertFalse(Validation::comparison(6, 'greater or equal', 7)); - $this->assertFalse(Validation::comparison(6, '>=', 7)); - $this->assertFalse(Validation::comparison(7, 'less or equal', 6)); - $this->assertFalse(Validation::comparison(7, '<=', 6)); - $this->assertFalse(Validation::comparison(7, 'equal to', 6)); - $this->assertFalse(Validation::comparison(7, '==', 6)); - $this->assertFalse(Validation::comparison(7, 'not equal', 7)); - $this->assertFalse(Validation::comparison(7, '!=', 7)); - } - -/** - * testComparisonAsArray method - * - * @return void - */ - public function testComparisonAsArray() { - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => 'is greater', 'check2' => 6))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => '>', 'check2' => 6))); - $this->assertTrue(Validation::comparison(array('check1' => 6, 'operator' => 'is less', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 6, 'operator' => '<', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => 'greater or equal', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => '>=', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => 'greater or equal','check2' => 6))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => '>=', 'check2' => 6))); - $this->assertTrue(Validation::comparison(array('check1' => 6, 'operator' => 'less or equal', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 6, 'operator' => '<=', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => 'equal to', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => '==', 'check2' => 7))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => 'not equal', 'check2' => 6))); - $this->assertTrue(Validation::comparison(array('check1' => 7, 'operator' => '!=', 'check2' => 6))); - $this->assertFalse(Validation::comparison(array('check1' => 6, 'operator' => 'is greater', 'check2' => 7))); - $this->assertFalse(Validation::comparison(array('check1' => 6, 'operator' => '>', 'check2' => 7))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => 'is less', 'check2' => 6))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => '<', 'check2' => 6))); - $this->assertFalse(Validation::comparison(array('check1' => 6, 'operator' => 'greater or equal', 'check2' => 7))); - $this->assertFalse(Validation::comparison(array('check1' => 6, 'operator' => '>=', 'check2' => 7))); - $this->assertFalse(Validation::comparison(array('check1' => 6, 'operator' => 'greater or equal', 'check2' => 7))); - $this->assertFalse(Validation::comparison(array('check1' => 6, 'operator' => '>=', 'check2' => 7))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => 'less or equal', 'check2' => 6))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => '<=', 'check2' => 6))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => 'equal to', 'check2' => 6))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => '==','check2' => 6))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => 'not equal', 'check2' => 7))); - $this->assertFalse(Validation::comparison(array('check1' => 7, 'operator' => '!=', 'check2' => 7))); - } - -/** - * testCustom method - * - * @return void - */ - public function testCustom() { - $this->assertTrue(Validation::custom('12345', '/(?assertFalse(Validation::custom('Text', '/(?assertFalse(Validation::custom('123.45', '/(?assertFalse(Validation::custom('missing regex')); - } - -/** - * testCustomAsArray method - * - * @return void - */ - public function testCustomAsArray() { - $this->assertTrue(Validation::custom(array('check' => '12345', 'regex' => '/(?assertFalse(Validation::custom(array('check' => 'Text', 'regex' => '/(?assertFalse(Validation::custom(array('check' => '123.45', 'regex' => '/(?assertTrue(Validation::date('27-12-2006', array('dmy'))); - $this->assertTrue(Validation::date('27.12.2006', array('dmy'))); - $this->assertTrue(Validation::date('27/12/2006', array('dmy'))); - $this->assertTrue(Validation::date('27 12 2006', array('dmy'))); - $this->assertFalse(Validation::date('00-00-0000', array('dmy'))); - $this->assertFalse(Validation::date('00.00.0000', array('dmy'))); - $this->assertFalse(Validation::date('00/00/0000', array('dmy'))); - $this->assertFalse(Validation::date('00 00 0000', array('dmy'))); - $this->assertFalse(Validation::date('31-11-2006', array('dmy'))); - $this->assertFalse(Validation::date('31.11.2006', array('dmy'))); - $this->assertFalse(Validation::date('31/11/2006', array('dmy'))); - $this->assertFalse(Validation::date('31 11 2006', array('dmy'))); - } - -/** - * testDateDdmmyyyyLeapYear method - * - * @return void - */ - public function testDateDdmmyyyyLeapYear() { - $this->assertTrue(Validation::date('29-02-2004', array('dmy'))); - $this->assertTrue(Validation::date('29.02.2004', array('dmy'))); - $this->assertTrue(Validation::date('29/02/2004', array('dmy'))); - $this->assertTrue(Validation::date('29 02 2004', array('dmy'))); - $this->assertFalse(Validation::date('29-02-2006', array('dmy'))); - $this->assertFalse(Validation::date('29.02.2006', array('dmy'))); - $this->assertFalse(Validation::date('29/02/2006', array('dmy'))); - $this->assertFalse(Validation::date('29 02 2006', array('dmy'))); - } - -/** - * testDateDdmmyy method - * - * @return void - */ - public function testDateDdmmyy() { - $this->assertTrue(Validation::date('27-12-06', array('dmy'))); - $this->assertTrue(Validation::date('27.12.06', array('dmy'))); - $this->assertTrue(Validation::date('27/12/06', array('dmy'))); - $this->assertTrue(Validation::date('27 12 06', array('dmy'))); - $this->assertFalse(Validation::date('00-00-00', array('dmy'))); - $this->assertFalse(Validation::date('00.00.00', array('dmy'))); - $this->assertFalse(Validation::date('00/00/00', array('dmy'))); - $this->assertFalse(Validation::date('00 00 00', array('dmy'))); - $this->assertFalse(Validation::date('31-11-06', array('dmy'))); - $this->assertFalse(Validation::date('31.11.06', array('dmy'))); - $this->assertFalse(Validation::date('31/11/06', array('dmy'))); - $this->assertFalse(Validation::date('31 11 06', array('dmy'))); - } - -/** - * testDateDdmmyyLeapYear method - * - * @return void - */ - public function testDateDdmmyyLeapYear() { - $this->assertTrue(Validation::date('29-02-04', array('dmy'))); - $this->assertTrue(Validation::date('29.02.04', array('dmy'))); - $this->assertTrue(Validation::date('29/02/04', array('dmy'))); - $this->assertTrue(Validation::date('29 02 04', array('dmy'))); - $this->assertFalse(Validation::date('29-02-06', array('dmy'))); - $this->assertFalse(Validation::date('29.02.06', array('dmy'))); - $this->assertFalse(Validation::date('29/02/06', array('dmy'))); - $this->assertFalse(Validation::date('29 02 06', array('dmy'))); - } - -/** - * testDateDmyy method - * - * @return void - */ - public function testDateDmyy() { - $this->assertTrue(Validation::date('7-2-06', array('dmy'))); - $this->assertTrue(Validation::date('7.2.06', array('dmy'))); - $this->assertTrue(Validation::date('7/2/06', array('dmy'))); - $this->assertTrue(Validation::date('7 2 06', array('dmy'))); - $this->assertFalse(Validation::date('0-0-00', array('dmy'))); - $this->assertFalse(Validation::date('0.0.00', array('dmy'))); - $this->assertFalse(Validation::date('0/0/00', array('dmy'))); - $this->assertFalse(Validation::date('0 0 00', array('dmy'))); - $this->assertFalse(Validation::date('32-2-06', array('dmy'))); - $this->assertFalse(Validation::date('32.2.06', array('dmy'))); - $this->assertFalse(Validation::date('32/2/06', array('dmy'))); - $this->assertFalse(Validation::date('32 2 06', array('dmy'))); - } - -/** - * testDateDmyyLeapYear method - * - * @return void - */ - public function testDateDmyyLeapYear() { - $this->assertTrue(Validation::date('29-2-04', array('dmy'))); - $this->assertTrue(Validation::date('29.2.04', array('dmy'))); - $this->assertTrue(Validation::date('29/2/04', array('dmy'))); - $this->assertTrue(Validation::date('29 2 04', array('dmy'))); - $this->assertFalse(Validation::date('29-2-06', array('dmy'))); - $this->assertFalse(Validation::date('29.2.06', array('dmy'))); - $this->assertFalse(Validation::date('29/2/06', array('dmy'))); - $this->assertFalse(Validation::date('29 2 06', array('dmy'))); - } - -/** - * testDateDmyyyy method - * - * @return void - */ - public function testDateDmyyyy() { - $this->assertTrue(Validation::date('7-2-2006', array('dmy'))); - $this->assertTrue(Validation::date('7.2.2006', array('dmy'))); - $this->assertTrue(Validation::date('7/2/2006', array('dmy'))); - $this->assertTrue(Validation::date('7 2 2006', array('dmy'))); - $this->assertFalse(Validation::date('0-0-0000', array('dmy'))); - $this->assertFalse(Validation::date('0.0.0000', array('dmy'))); - $this->assertFalse(Validation::date('0/0/0000', array('dmy'))); - $this->assertFalse(Validation::date('0 0 0000', array('dmy'))); - $this->assertFalse(Validation::date('32-2-2006', array('dmy'))); - $this->assertFalse(Validation::date('32.2.2006', array('dmy'))); - $this->assertFalse(Validation::date('32/2/2006', array('dmy'))); - $this->assertFalse(Validation::date('32 2 2006', array('dmy'))); - } - -/** - * testDateDmyyyyLeapYear method - * - * @return void - */ - public function testDateDmyyyyLeapYear() { - $this->assertTrue(Validation::date('29-2-2004', array('dmy'))); - $this->assertTrue(Validation::date('29.2.2004', array('dmy'))); - $this->assertTrue(Validation::date('29/2/2004', array('dmy'))); - $this->assertTrue(Validation::date('29 2 2004', array('dmy'))); - $this->assertFalse(Validation::date('29-2-2006', array('dmy'))); - $this->assertFalse(Validation::date('29.2.2006', array('dmy'))); - $this->assertFalse(Validation::date('29/2/2006', array('dmy'))); - $this->assertFalse(Validation::date('29 2 2006', array('dmy'))); - } - -/** - * testDateMmddyyyy method - * - * @return void - */ - public function testDateMmddyyyy() { - $this->assertTrue(Validation::date('12-27-2006', array('mdy'))); - $this->assertTrue(Validation::date('12.27.2006', array('mdy'))); - $this->assertTrue(Validation::date('12/27/2006', array('mdy'))); - $this->assertTrue(Validation::date('12 27 2006', array('mdy'))); - $this->assertFalse(Validation::date('00-00-0000', array('mdy'))); - $this->assertFalse(Validation::date('00.00.0000', array('mdy'))); - $this->assertFalse(Validation::date('00/00/0000', array('mdy'))); - $this->assertFalse(Validation::date('00 00 0000', array('mdy'))); - $this->assertFalse(Validation::date('11-31-2006', array('mdy'))); - $this->assertFalse(Validation::date('11.31.2006', array('mdy'))); - $this->assertFalse(Validation::date('11/31/2006', array('mdy'))); - $this->assertFalse(Validation::date('11 31 2006', array('mdy'))); - } - -/** - * testDateMmddyyyyLeapYear method - * - * @return void - */ - public function testDateMmddyyyyLeapYear() { - $this->assertTrue(Validation::date('02-29-2004', array('mdy'))); - $this->assertTrue(Validation::date('02.29.2004', array('mdy'))); - $this->assertTrue(Validation::date('02/29/2004', array('mdy'))); - $this->assertTrue(Validation::date('02 29 2004', array('mdy'))); - $this->assertFalse(Validation::date('02-29-2006', array('mdy'))); - $this->assertFalse(Validation::date('02.29.2006', array('mdy'))); - $this->assertFalse(Validation::date('02/29/2006', array('mdy'))); - $this->assertFalse(Validation::date('02 29 2006', array('mdy'))); - } - -/** - * testDateMmddyy method - * - * @return void - */ - public function testDateMmddyy() { - $this->assertTrue(Validation::date('12-27-06', array('mdy'))); - $this->assertTrue(Validation::date('12.27.06', array('mdy'))); - $this->assertTrue(Validation::date('12/27/06', array('mdy'))); - $this->assertTrue(Validation::date('12 27 06', array('mdy'))); - $this->assertFalse(Validation::date('00-00-00', array('mdy'))); - $this->assertFalse(Validation::date('00.00.00', array('mdy'))); - $this->assertFalse(Validation::date('00/00/00', array('mdy'))); - $this->assertFalse(Validation::date('00 00 00', array('mdy'))); - $this->assertFalse(Validation::date('11-31-06', array('mdy'))); - $this->assertFalse(Validation::date('11.31.06', array('mdy'))); - $this->assertFalse(Validation::date('11/31/06', array('mdy'))); - $this->assertFalse(Validation::date('11 31 06', array('mdy'))); - } - -/** - * testDateMmddyyLeapYear method - * - * @return void - */ - public function testDateMmddyyLeapYear() { - $this->assertTrue(Validation::date('02-29-04', array('mdy'))); - $this->assertTrue(Validation::date('02.29.04', array('mdy'))); - $this->assertTrue(Validation::date('02/29/04', array('mdy'))); - $this->assertTrue(Validation::date('02 29 04', array('mdy'))); - $this->assertFalse(Validation::date('02-29-06', array('mdy'))); - $this->assertFalse(Validation::date('02.29.06', array('mdy'))); - $this->assertFalse(Validation::date('02/29/06', array('mdy'))); - $this->assertFalse(Validation::date('02 29 06', array('mdy'))); - } - -/** - * testDateMdyy method - * - * @return void - */ - public function testDateMdyy() { - $this->assertTrue(Validation::date('2-7-06', array('mdy'))); - $this->assertTrue(Validation::date('2.7.06', array('mdy'))); - $this->assertTrue(Validation::date('2/7/06', array('mdy'))); - $this->assertTrue(Validation::date('2 7 06', array('mdy'))); - $this->assertFalse(Validation::date('0-0-00', array('mdy'))); - $this->assertFalse(Validation::date('0.0.00', array('mdy'))); - $this->assertFalse(Validation::date('0/0/00', array('mdy'))); - $this->assertFalse(Validation::date('0 0 00', array('mdy'))); - $this->assertFalse(Validation::date('2-32-06', array('mdy'))); - $this->assertFalse(Validation::date('2.32.06', array('mdy'))); - $this->assertFalse(Validation::date('2/32/06', array('mdy'))); - $this->assertFalse(Validation::date('2 32 06', array('mdy'))); - } - -/** - * testDateMdyyLeapYear method - * - * @return void - */ - public function testDateMdyyLeapYear() { - $this->assertTrue(Validation::date('2-29-04', array('mdy'))); - $this->assertTrue(Validation::date('2.29.04', array('mdy'))); - $this->assertTrue(Validation::date('2/29/04', array('mdy'))); - $this->assertTrue(Validation::date('2 29 04', array('mdy'))); - $this->assertFalse(Validation::date('2-29-06', array('mdy'))); - $this->assertFalse(Validation::date('2.29.06', array('mdy'))); - $this->assertFalse(Validation::date('2/29/06', array('mdy'))); - $this->assertFalse(Validation::date('2 29 06', array('mdy'))); - } - -/** - * testDateMdyyyy method - * - * @return void - */ - public function testDateMdyyyy() { - $this->assertTrue(Validation::date('2-7-2006', array('mdy'))); - $this->assertTrue(Validation::date('2.7.2006', array('mdy'))); - $this->assertTrue(Validation::date('2/7/2006', array('mdy'))); - $this->assertTrue(Validation::date('2 7 2006', array('mdy'))); - $this->assertFalse(Validation::date('0-0-0000', array('mdy'))); - $this->assertFalse(Validation::date('0.0.0000', array('mdy'))); - $this->assertFalse(Validation::date('0/0/0000', array('mdy'))); - $this->assertFalse(Validation::date('0 0 0000', array('mdy'))); - $this->assertFalse(Validation::date('2-32-2006', array('mdy'))); - $this->assertFalse(Validation::date('2.32.2006', array('mdy'))); - $this->assertFalse(Validation::date('2/32/2006', array('mdy'))); - $this->assertFalse(Validation::date('2 32 2006', array('mdy'))); - } - -/** - * testDateMdyyyyLeapYear method - * - * @return void - */ - public function testDateMdyyyyLeapYear() { - $this->assertTrue(Validation::date('2-29-2004', array('mdy'))); - $this->assertTrue(Validation::date('2.29.2004', array('mdy'))); - $this->assertTrue(Validation::date('2/29/2004', array('mdy'))); - $this->assertTrue(Validation::date('2 29 2004', array('mdy'))); - $this->assertFalse(Validation::date('2-29-2006', array('mdy'))); - $this->assertFalse(Validation::date('2.29.2006', array('mdy'))); - $this->assertFalse(Validation::date('2/29/2006', array('mdy'))); - $this->assertFalse(Validation::date('2 29 2006', array('mdy'))); - } - -/** - * testDateYyyymmdd method - * - * @return void - */ - public function testDateYyyymmdd() { - $this->assertTrue(Validation::date('2006-12-27', array('ymd'))); - $this->assertTrue(Validation::date('2006.12.27', array('ymd'))); - $this->assertTrue(Validation::date('2006/12/27', array('ymd'))); - $this->assertTrue(Validation::date('2006 12 27', array('ymd'))); - $this->assertFalse(Validation::date('2006-11-31', array('ymd'))); - $this->assertFalse(Validation::date('2006.11.31', array('ymd'))); - $this->assertFalse(Validation::date('2006/11/31', array('ymd'))); - $this->assertFalse(Validation::date('2006 11 31', array('ymd'))); - } - -/** - * testDateYyyymmddLeapYear method - * - * @return void - */ - public function testDateYyyymmddLeapYear() { - $this->assertTrue(Validation::date('2004-02-29', array('ymd'))); - $this->assertTrue(Validation::date('2004.02.29', array('ymd'))); - $this->assertTrue(Validation::date('2004/02/29', array('ymd'))); - $this->assertTrue(Validation::date('2004 02 29', array('ymd'))); - $this->assertFalse(Validation::date('2006-02-29', array('ymd'))); - $this->assertFalse(Validation::date('2006.02.29', array('ymd'))); - $this->assertFalse(Validation::date('2006/02/29', array('ymd'))); - $this->assertFalse(Validation::date('2006 02 29', array('ymd'))); - } - -/** - * testDateYymmdd method - * - * @return void - */ - public function testDateYymmdd() { - $this->assertTrue(Validation::date('06-12-27', array('ymd'))); - $this->assertTrue(Validation::date('06.12.27', array('ymd'))); - $this->assertTrue(Validation::date('06/12/27', array('ymd'))); - $this->assertTrue(Validation::date('06 12 27', array('ymd'))); - $this->assertFalse(Validation::date('12/27/2600', array('ymd'))); - $this->assertFalse(Validation::date('12.27.2600', array('ymd'))); - $this->assertFalse(Validation::date('12/27/2600', array('ymd'))); - $this->assertFalse(Validation::date('12 27 2600', array('ymd'))); - $this->assertFalse(Validation::date('06-11-31', array('ymd'))); - $this->assertFalse(Validation::date('06.11.31', array('ymd'))); - $this->assertFalse(Validation::date('06/11/31', array('ymd'))); - $this->assertFalse(Validation::date('06 11 31', array('ymd'))); - } - -/** - * testDateYymmddLeapYear method - * - * @return void - */ - public function testDateYymmddLeapYear() { - $this->assertTrue(Validation::date('2004-02-29', array('ymd'))); - $this->assertTrue(Validation::date('2004.02.29', array('ymd'))); - $this->assertTrue(Validation::date('2004/02/29', array('ymd'))); - $this->assertTrue(Validation::date('2004 02 29', array('ymd'))); - $this->assertFalse(Validation::date('2006-02-29', array('ymd'))); - $this->assertFalse(Validation::date('2006.02.29', array('ymd'))); - $this->assertFalse(Validation::date('2006/02/29', array('ymd'))); - $this->assertFalse(Validation::date('2006 02 29', array('ymd'))); - } - -/** - * testDateDdMMMMyyyy method - * - * @return void - */ - public function testDateDdMMMMyyyy() { - $this->assertTrue(Validation::date('27 December 2006', array('dMy'))); - $this->assertTrue(Validation::date('27 Dec 2006', array('dMy'))); - $this->assertFalse(Validation::date('2006 Dec 27', array('dMy'))); - $this->assertFalse(Validation::date('2006 December 27', array('dMy'))); - } - -/** - * testDateDdMMMMyyyyLeapYear method - * - * @return void - */ - public function testDateDdMMMMyyyyLeapYear() { - $this->assertTrue(Validation::date('29 February 2004', array('dMy'))); - $this->assertFalse(Validation::date('29 February 2006', array('dMy'))); - } - -/** - * testDateMmmmDdyyyy method - * - * @return void - */ - public function testDateMmmmDdyyyy() { - $this->assertTrue(Validation::date('December 27, 2006', array('Mdy'))); - $this->assertTrue(Validation::date('Dec 27, 2006', array('Mdy'))); - $this->assertTrue(Validation::date('December 27 2006', array('Mdy'))); - $this->assertTrue(Validation::date('Dec 27 2006', array('Mdy'))); - $this->assertFalse(Validation::date('27 Dec 2006', array('Mdy'))); - $this->assertFalse(Validation::date('2006 December 27', array('Mdy'))); - $this->assertTrue(Validation::date('Sep 12, 2011', array('Mdy'))); - } - -/** - * testDateMmmmDdyyyyLeapYear method - * - * @return void - */ - public function testDateMmmmDdyyyyLeapYear() { - $this->assertTrue(Validation::date('February 29, 2004', array('Mdy'))); - $this->assertTrue(Validation::date('Feb 29, 2004', array('Mdy'))); - $this->assertTrue(Validation::date('February 29 2004', array('Mdy'))); - $this->assertTrue(Validation::date('Feb 29 2004', array('Mdy'))); - $this->assertFalse(Validation::date('February 29, 2006', array('Mdy'))); - } - -/** - * testDateMy method - * - * @return void - */ - public function testDateMy() { - $this->assertTrue(Validation::date('December 2006', array('My'))); - $this->assertTrue(Validation::date('Dec 2006', array('My'))); - $this->assertTrue(Validation::date('December/2006', array('My'))); - $this->assertTrue(Validation::date('Dec/2006', array('My'))); - } - -/** - * testDateMyNumeric method - * - * @return void - */ - public function testDateMyNumeric() { - $this->assertTrue(Validation::date('12/2006', array('my'))); - $this->assertTrue(Validation::date('12-2006', array('my'))); - $this->assertTrue(Validation::date('12.2006', array('my'))); - $this->assertTrue(Validation::date('12 2006', array('my'))); - $this->assertFalse(Validation::date('12/06', array('my'))); - $this->assertFalse(Validation::date('12-06', array('my'))); - $this->assertFalse(Validation::date('12.06', array('my'))); - $this->assertFalse(Validation::date('12 06', array('my'))); - } - -/** - * Test validating dates with multiple formats - * - * @return void - */ - public function testDateMultiple() { - $this->assertTrue(Validation::date('2011-12-31', array('ymd', 'dmy'))); - $this->assertTrue(Validation::date('31-12-2011', array('ymd', 'dmy'))); - } - -/** - * testTime method - * - * @return void - */ - public function testTime() { - $this->assertTrue(Validation::time('00:00')); - $this->assertTrue(Validation::time('23:59')); - $this->assertFalse(Validation::time('24:00')); - $this->assertTrue(Validation::time('12:00')); - $this->assertTrue(Validation::time('12:01')); - $this->assertTrue(Validation::time('12:01am')); - $this->assertTrue(Validation::time('12:01pm')); - $this->assertTrue(Validation::time('1pm')); - $this->assertTrue(Validation::time('1 pm')); - $this->assertTrue(Validation::time('1 PM')); - $this->assertTrue(Validation::time('01:00')); - $this->assertFalse(Validation::time('1:00')); - $this->assertTrue(Validation::time('1:00pm')); - $this->assertFalse(Validation::time('13:00pm')); - $this->assertFalse(Validation::time('9:00')); - } - -/** - * testBoolean method - * - * @return void - */ - public function testBoolean() { - $this->assertTrue(Validation::boolean('0')); - $this->assertTrue(Validation::boolean('1')); - $this->assertTrue(Validation::boolean(0)); - $this->assertTrue(Validation::boolean(1)); - $this->assertTrue(Validation::boolean(true)); - $this->assertTrue(Validation::boolean(false)); - $this->assertFalse(Validation::boolean('true')); - $this->assertFalse(Validation::boolean('false')); - $this->assertFalse(Validation::boolean('-1')); - $this->assertFalse(Validation::boolean('2')); - $this->assertFalse(Validation::boolean('Boo!')); - } - -/** - * testDateCustomRegx method - * - * @return void - */ - public function testDateCustomRegx() { - $this->assertTrue(Validation::date('2006-12-27', null, '%^(19|20)[0-9]{2}[- /.](0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])$%')); - $this->assertFalse(Validation::date('12-27-2006', null, '%^(19|20)[0-9]{2}[- /.](0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])$%')); - } - -/** - * testDecimal method - * - * @return void - */ - public function testDecimal() { - $this->assertTrue(Validation::decimal('+1234.54321')); - $this->assertTrue(Validation::decimal('-1234.54321')); - $this->assertTrue(Validation::decimal('1234.54321')); - $this->assertTrue(Validation::decimal('+0123.45e6')); - $this->assertTrue(Validation::decimal('-0123.45e6')); - $this->assertTrue(Validation::decimal('0123.45e6')); - $this->assertFalse(Validation::decimal('string')); - $this->assertFalse(Validation::decimal('1234')); - $this->assertFalse(Validation::decimal('-1234')); - $this->assertFalse(Validation::decimal('+1234')); - } - -/** - * testDecimalWithPlaces method - * - * @return void - */ - public function testDecimalWithPlaces() { - $this->assertTrue(Validation::decimal('.27', '2')); - $this->assertTrue(Validation::decimal(0.27, 2)); - $this->assertTrue(Validation::decimal(-0.27, 2)); - $this->assertTrue(Validation::decimal(0.27, 2)); - $this->assertTrue(Validation::decimal('0.277', '3')); - $this->assertTrue(Validation::decimal(0.277, 3)); - $this->assertTrue(Validation::decimal(-0.277, 3)); - $this->assertTrue(Validation::decimal(0.277, 3)); - $this->assertTrue(Validation::decimal('1234.5678', '4')); - $this->assertTrue(Validation::decimal(1234.5678, 4)); - $this->assertTrue(Validation::decimal(-1234.5678, 4)); - $this->assertTrue(Validation::decimal(1234.5678, 4)); - $this->assertFalse(Validation::decimal('1234.5678', '3')); - $this->assertFalse(Validation::decimal(1234.5678, 3)); - $this->assertFalse(Validation::decimal(-1234.5678, 3)); - $this->assertFalse(Validation::decimal(1234.5678, 3)); - } - -/** - * testDecimalCustomRegex method - * - * @return void - */ - public function testDecimalCustomRegex() { - $this->assertTrue(Validation::decimal('1.54321', null, '/^[-+]?[0-9]+(\\.[0-9]+)?$/s')); - $this->assertFalse(Validation::decimal('.54321', null, '/^[-+]?[0-9]+(\\.[0-9]+)?$/s')); - } - -/** - * testEmail method - * - * @return void - */ - public function testEmail() { - $this->assertTrue(Validation::email('abc.efg@domain.com')); - $this->assertTrue(Validation::email('efg@domain.com')); - $this->assertTrue(Validation::email('abc-efg@domain.com')); - $this->assertTrue(Validation::email('abc_efg@domain.com')); - $this->assertTrue(Validation::email('raw@test.ra.ru')); - $this->assertTrue(Validation::email('abc-efg@domain-hyphened.com')); - $this->assertTrue(Validation::email("p.o'malley@domain.com")); - $this->assertTrue(Validation::email('abc+efg@domain.com')); - $this->assertTrue(Validation::email('abc&efg@domain.com')); - $this->assertTrue(Validation::email('abc.efg@12345.com')); - $this->assertTrue(Validation::email('abc.efg@12345.co.jp')); - $this->assertTrue(Validation::email('abc@g.cn')); - $this->assertTrue(Validation::email('abc@x.com')); - $this->assertTrue(Validation::email('henrik@sbcglobal.net')); - $this->assertTrue(Validation::email('sani@sbcglobal.net')); - - // all ICANN TLDs - $this->assertTrue(Validation::email('abc@example.aero')); - $this->assertTrue(Validation::email('abc@example.asia')); - $this->assertTrue(Validation::email('abc@example.biz')); - $this->assertTrue(Validation::email('abc@example.cat')); - $this->assertTrue(Validation::email('abc@example.com')); - $this->assertTrue(Validation::email('abc@example.coop')); - $this->assertTrue(Validation::email('abc@example.edu')); - $this->assertTrue(Validation::email('abc@example.gov')); - $this->assertTrue(Validation::email('abc@example.info')); - $this->assertTrue(Validation::email('abc@example.int')); - $this->assertTrue(Validation::email('abc@example.jobs')); - $this->assertTrue(Validation::email('abc@example.mil')); - $this->assertTrue(Validation::email('abc@example.mobi')); - $this->assertTrue(Validation::email('abc@example.museum')); - $this->assertTrue(Validation::email('abc@example.name')); - $this->assertTrue(Validation::email('abc@example.net')); - $this->assertTrue(Validation::email('abc@example.org')); - $this->assertTrue(Validation::email('abc@example.pro')); - $this->assertTrue(Validation::email('abc@example.tel')); - $this->assertTrue(Validation::email('abc@example.travel')); - $this->assertTrue(Validation::email('someone@st.t-com.hr')); - - // strange, but technically valid email addresses - $this->assertTrue(Validation::email('S=postmaster/OU=rz/P=uni-frankfurt/A=d400/C=de@gateway.d400.de')); - $this->assertTrue(Validation::email('customer/department=shipping@example.com')); - $this->assertTrue(Validation::email('$A12345@example.com')); - $this->assertTrue(Validation::email('!def!xyz%abc@example.com')); - $this->assertTrue(Validation::email('_somename@example.com')); - - // invalid addresses - $this->assertFalse(Validation::email('abc@example')); - $this->assertFalse(Validation::email('abc@example.c')); - $this->assertFalse(Validation::email('abc@example.com.')); - $this->assertFalse(Validation::email('abc.@example.com')); - $this->assertFalse(Validation::email('abc@example..com')); - $this->assertFalse(Validation::email('abc@example.com.a')); - $this->assertFalse(Validation::email('abc@example.toolong')); - $this->assertFalse(Validation::email('abc;@example.com')); - $this->assertFalse(Validation::email('abc@example.com;')); - $this->assertFalse(Validation::email('abc@efg@example.com')); - $this->assertFalse(Validation::email('abc@@example.com')); - $this->assertFalse(Validation::email('abc efg@example.com')); - $this->assertFalse(Validation::email('abc,efg@example.com')); - $this->assertFalse(Validation::email('abc@sub,example.com')); - $this->assertFalse(Validation::email("abc@sub'example.com")); - $this->assertFalse(Validation::email('abc@sub/example.com')); - $this->assertFalse(Validation::email('abc@yahoo!.com')); - $this->assertFalse(Validation::email("Nyrée.surname@example.com")); - $this->assertFalse(Validation::email('abc@example_underscored.com')); - $this->assertFalse(Validation::email('raw@test.ra.ru....com')); - } - -/** - * testEmailDeep method - * - * @return void - */ - public function testEmailDeep() { - $this->skipIf(gethostbynamel('example.abcd'), 'Your DNS service responds for non-existant domains, skipping deep email checks.'); - - $this->assertTrue(Validation::email('abc.efg@cakephp.org', true)); - $this->assertFalse(Validation::email('abc.efg@caphpkeinvalid.com', true)); - $this->assertFalse(Validation::email('abc@example.abcd', true)); - } - -/** - * testEmailCustomRegex method - * - * @return void - */ - public function testEmailCustomRegex() { - $this->assertTrue(Validation::email('abc.efg@cakephp.org', null, '/^[A-Z0-9._%-]+@[A-Z0-9.-]+\\.[A-Z]{2,4}$/i')); - $this->assertFalse(Validation::email('abc.efg@com.caphpkeinvalid', null, '/^[A-Z0-9._%-]+@[A-Z0-9.-]+\\.[A-Z]{2,4}$/i')); - } - -/** - * testEqualTo method - * - * @return void - */ - public function testEqualTo() { - $this->assertTrue(Validation::equalTo("1", "1")); - $this->assertFalse(Validation::equalTo(1, "1")); - $this->assertFalse(Validation::equalTo("", null)); - $this->assertFalse(Validation::equalTo("", false)); - $this->assertFalse(Validation::equalTo(0, false)); - $this->assertFalse(Validation::equalTo(null, false)); - } - -/** - * testIpV4 method - * - * @return void - */ - public function testIpV4() { - $this->assertTrue(Validation::ip('0.0.0.0')); - $this->assertTrue(Validation::ip('192.168.1.156')); - $this->assertTrue(Validation::ip('255.255.255.255')); - $this->assertFalse(Validation::ip('127.0.0')); - $this->assertFalse(Validation::ip('127.0.0.a')); - $this->assertFalse(Validation::ip('127.0.0.256')); - } - -/** - * testIp v6 - * - * @return void - */ - public function testIpv6() { - $this->assertTrue(Validation::ip('2001:0db8:85a3:0000:0000:8a2e:0370:7334', 'IPv6')); - $this->assertTrue(Validation::ip('2001:db8:85a3:0:0:8a2e:370:7334', 'IPv6')); - $this->assertTrue(Validation::ip('2001:db8:85a3::8a2e:370:7334', 'IPv6')); - $this->assertTrue(Validation::ip('2001:0db8:0000:0000:0000:0000:1428:57ab', 'IPv6')); - $this->assertTrue(Validation::ip('2001:0db8:0000:0000:0000::1428:57ab', 'IPv6')); - $this->assertTrue(Validation::ip('2001:0db8:0:0:0:0:1428:57ab', 'IPv6')); - $this->assertTrue(Validation::ip('2001:0db8:0:0::1428:57ab', 'IPv6')); - $this->assertTrue(Validation::ip('2001:0db8::1428:57ab', 'IPv6')); - $this->assertTrue(Validation::ip('2001:db8::1428:57ab', 'IPv6')); - $this->assertTrue(Validation::ip('0000:0000:0000:0000:0000:0000:0000:0001', 'IPv6')); - $this->assertTrue(Validation::ip('::1', 'IPv6')); - $this->assertTrue(Validation::ip('::ffff:12.34.56.78', 'IPv6')); - $this->assertTrue(Validation::ip('::ffff:0c22:384e', 'IPv6')); - $this->assertTrue(Validation::ip('2001:0db8:1234:0000:0000:0000:0000:0000', 'IPv6')); - $this->assertTrue(Validation::ip('2001:0db8:1234:ffff:ffff:ffff:ffff:ffff', 'IPv6')); - $this->assertTrue(Validation::ip('2001:db8:a::123', 'IPv6')); - $this->assertTrue(Validation::ip('fe80::', 'IPv6')); - $this->assertTrue(Validation::ip('::ffff:192.0.2.128', 'IPv6')); - $this->assertTrue(Validation::ip('::ffff:c000:280', 'IPv6')); - - $this->assertFalse(Validation::ip('123', 'IPv6')); - $this->assertFalse(Validation::ip('ldkfj', 'IPv6')); - $this->assertFalse(Validation::ip('2001::FFD3::57ab', 'IPv6')); - $this->assertFalse(Validation::ip('2001:db8:85a3::8a2e:37023:7334', 'IPv6')); - $this->assertFalse(Validation::ip('2001:db8:85a3::8a2e:370k:7334', 'IPv6')); - $this->assertFalse(Validation::ip('1:2:3:4:5:6:7:8:9', 'IPv6')); - $this->assertFalse(Validation::ip('1::2::3', 'IPv6')); - $this->assertFalse(Validation::ip('1:::3:4:5', 'IPv6')); - $this->assertFalse(Validation::ip('1:2:3::4:5:6:7:8:9', 'IPv6')); - $this->assertFalse(Validation::ip('::ffff:2.3.4', 'IPv6')); - $this->assertFalse(Validation::ip('::ffff:257.1.2.3', 'IPv6')); - } - -/** - * testMaxLength method - * - * @return void - */ - public function testMaxLength() { - $this->assertTrue(Validation::maxLength('ab', 3)); - $this->assertTrue(Validation::maxLength('abc', 3)); - $this->assertTrue(Validation::maxLength('ÆΔΩЖÇ', 10)); - - $this->assertFalse(Validation::maxLength('abcd', 3)); - $this->assertFalse(Validation::maxLength('ÆΔΩЖÇ', 3)); - } - -/** - * testMinLength method - * - * @return void - */ - public function testMinLength() { - $this->assertFalse(Validation::minLength('ab', 3)); - $this->assertFalse(Validation::minLength('ÆΔΩЖÇ', 10)); - - $this->assertTrue(Validation::minLength('abc', 3)); - $this->assertTrue(Validation::minLength('abcd', 3)); - $this->assertTrue(Validation::minLength('ÆΔΩЖÇ', 2)); - } - -/** - * testUrl method - * - * @return void - */ - public function testUrl() { - $this->assertTrue(Validation::url('http://www.cakephp.org')); - $this->assertTrue(Validation::url('http://cakephp.org')); - $this->assertTrue(Validation::url('http://www.cakephp.org/somewhere#anchor')); - $this->assertTrue(Validation::url('http://192.168.0.1')); - $this->assertTrue(Validation::url('https://www.cakephp.org')); - $this->assertTrue(Validation::url('https://cakephp.org')); - $this->assertTrue(Validation::url('https://www.cakephp.org/somewhere#anchor')); - $this->assertTrue(Validation::url('https://192.168.0.1')); - $this->assertTrue(Validation::url('ftps://www.cakephp.org/pub/cake')); - $this->assertTrue(Validation::url('ftps://cakephp.org/pub/cake')); - $this->assertTrue(Validation::url('ftps://192.168.0.1/pub/cake')); - $this->assertTrue(Validation::url('ftp://www.cakephp.org/pub/cake')); - $this->assertTrue(Validation::url('ftp://cakephp.org/pub/cake')); - $this->assertTrue(Validation::url('ftp://192.168.0.1/pub/cake')); - $this->assertTrue(Validation::url('sftp://192.168.0.1/pub/cake')); - $this->assertFalse(Validation::url('ftps://256.168.0.1/pub/cake')); - $this->assertFalse(Validation::url('ftp://256.168.0.1/pub/cake')); - $this->assertTrue(Validation::url('https://my.domain.com/gizmo/app?class=MySip;proc=start')); - $this->assertTrue(Validation::url('www.domain.tld')); - $this->assertFalse(Validation::url('http://w_w.domain.co_m')); - $this->assertFalse(Validation::url('http://www.domain.12com')); - $this->assertFalse(Validation::url('http://www.domain.longttldnotallowed')); - $this->assertFalse(Validation::url('http://www.-invaliddomain.tld')); - $this->assertFalse(Validation::url('http://www.domain.-invalidtld')); - $this->assertTrue(Validation::url('http://123456789112345678921234567893123456789412345678951234567896123.com')); - $this->assertFalse(Validation::url('http://this-domain-is-too-loooooong-by-icann-rules-maximum-length-is-63.com')); - $this->assertTrue(Validation::url('http://www.domain.com/blogs/index.php?blog=6&tempskin=_rss2')); - $this->assertTrue(Validation::url('http://www.domain.com/blogs/parenth()eses.php')); - $this->assertTrue(Validation::url('http://www.domain.com/index.php?get=params&get2=params')); - $this->assertTrue(Validation::url('http://www.domain.com/ndex.php?get=params&get2=params#anchor')); - $this->assertFalse(Validation::url('http://www.domain.com/fakeenco%ode')); - $this->assertTrue(Validation::url('http://www.domain.com/real%20url%20encodeing')); - $this->assertTrue(Validation::url('http://en.wikipedia.org/wiki/Architectural_pattern_(computer_science)')); - $this->assertFalse(Validation::url('http://en.(wikipedia).org/')); - $this->assertFalse(Validation::url('www.cakephp.org', true)); - $this->assertTrue(Validation::url('http://www.cakephp.org', true)); - $this->assertTrue(Validation::url('http://example.com/~userdir/')); - - $this->assertTrue(Validation::url('http://example.com/~userdir/subdir/index.html')); - $this->assertTrue(Validation::url('http://www.zwischenraume.de')); - $this->assertTrue(Validation::url('http://www.zwischenraume.cz')); - $this->assertTrue(Validation::url('http://www.last.fm/music/浜崎あゆみ'), 'utf8 path failed'); - $this->assertTrue(Validation::url('http://www.electrohome.ro/images/239537750-284232-215_300[1].jpg')); - - $this->assertTrue(Validation::url('http://cakephp.org:80')); - $this->assertTrue(Validation::url('http://cakephp.org:443')); - $this->assertTrue(Validation::url('http://cakephp.org:2000')); - $this->assertTrue(Validation::url('http://cakephp.org:27000')); - $this->assertTrue(Validation::url('http://cakephp.org:65000')); - - $this->assertTrue(Validation::url('[2001:0db8::1428:57ab]')); - $this->assertTrue(Validation::url('[::1]')); - $this->assertTrue(Validation::url('[2001:0db8::1428:57ab]:80')); - $this->assertTrue(Validation::url('[::1]:80')); - $this->assertTrue(Validation::url('http://[2001:0db8::1428:57ab]')); - $this->assertTrue(Validation::url('http://[::1]')); - $this->assertTrue(Validation::url('http://[2001:0db8::1428:57ab]:80')); - $this->assertTrue(Validation::url('http://[::1]:80')); - - $this->assertFalse(Validation::url('[1::2::3]')); - } - - public function testUuid() { - $this->assertTrue(Validation::uuid('550e8400-e29b-11d4-a716-446655440000')); - $this->assertFalse(Validation::uuid('BRAP-e29b-11d4-a716-446655440000')); - $this->assertTrue(Validation::uuid('550E8400-e29b-11D4-A716-446655440000')); - $this->assertFalse(Validation::uuid('550e8400-e29b11d4-a716-446655440000')); - $this->assertFalse(Validation::uuid('550e8400-e29b-11d4-a716-4466440000')); - $this->assertFalse(Validation::uuid('550e8400-e29b-11d4-a71-446655440000')); - $this->assertFalse(Validation::uuid('550e8400-e29b-11d-a716-446655440000')); - $this->assertFalse(Validation::uuid('550e8400-e29-11d4-a716-446655440000')); - } - -/** - * testInList method - * - * @return void - */ - public function testInList() { - $this->assertTrue(Validation::inList('one', array('one', 'two'))); - $this->assertTrue(Validation::inList('two', array('one', 'two'))); - $this->assertFalse(Validation::inList('three', array('one', 'two'))); - $this->assertFalse(Validation::inList('1one', array(0, 1, 2, 3))); - $this->assertFalse(Validation::inList('one', array(0, 1, 2, 3))); - } - -/** - * testRange method - * - * @return void - */ - public function testRange() { - $this->assertFalse(Validation::range(20, 100, 1)); - $this->assertTrue(Validation::range(20, 1, 100)); - $this->assertFalse(Validation::range(.5, 1, 100)); - $this->assertTrue(Validation::range(.5, 0, 100)); - $this->assertTrue(Validation::range(5)); - $this->assertTrue(Validation::range(-5, -10, 1)); - $this->assertFalse(Validation::range('word')); - } - -/** - * testExtension method - * - * @return void - */ - public function testExtension() { - $this->assertTrue(Validation::extension('extension.jpeg')); - $this->assertTrue(Validation::extension('extension.JPEG')); - $this->assertTrue(Validation::extension('extension.gif')); - $this->assertTrue(Validation::extension('extension.GIF')); - $this->assertTrue(Validation::extension('extension.png')); - $this->assertTrue(Validation::extension('extension.jpg')); - $this->assertTrue(Validation::extension('extension.JPG')); - $this->assertFalse(Validation::extension('noextension')); - $this->assertTrue(Validation::extension('extension.pdf', array('PDF'))); - $this->assertFalse(Validation::extension('extension.jpg', array('GIF'))); - $this->assertTrue(Validation::extension(array('extension.JPG', 'extension.gif', 'extension.png'))); - $this->assertTrue(Validation::extension(array('file' => array('name' => 'file.jpg')))); - $this->assertTrue(Validation::extension(array('file1' => array('name' => 'file.jpg'), - 'file2' => array('name' => 'file.jpg'), - 'file3' => array('name' => 'file.jpg')))); - $this->assertFalse(Validation::extension(array('file1' => array('name' => 'file.jpg'), - 'file2' => array('name' => 'file.jpg'), - 'file3' => array('name' => 'file.jpg')), array('gif'))); - - $this->assertFalse(Validation::extension(array('noextension', 'extension.JPG', 'extension.gif', 'extension.png'))); - $this->assertFalse(Validation::extension(array('extension.pdf', 'extension.JPG', 'extension.gif', 'extension.png'))); - } - -/** - * testMoney method - * - * @return void - */ - public function testMoney() { - $this->assertTrue(Validation::money('$100')); - $this->assertTrue(Validation::money('$100.11')); - $this->assertTrue(Validation::money('$100.112')); - $this->assertFalse(Validation::money('$100.1')); - $this->assertFalse(Validation::money('$100.1111')); - $this->assertFalse(Validation::money('text')); - - $this->assertTrue(Validation::money('100', 'right')); - $this->assertTrue(Validation::money('100.11$', 'right')); - $this->assertTrue(Validation::money('100.112$', 'right')); - $this->assertFalse(Validation::money('100.1$', 'right')); - $this->assertFalse(Validation::money('100.1111$', 'right')); - - $this->assertTrue(Validation::money('€100')); - $this->assertTrue(Validation::money('€100.11')); - $this->assertTrue(Validation::money('€100.112')); - $this->assertFalse(Validation::money('€100.1')); - $this->assertFalse(Validation::money('€100.1111')); - - $this->assertTrue(Validation::money('100', 'right')); - $this->assertTrue(Validation::money('100.11€', 'right')); - $this->assertTrue(Validation::money('100.112€', 'right')); - $this->assertFalse(Validation::money('100.1€', 'right')); - $this->assertFalse(Validation::money('100.1111€', 'right')); - } - -/** - * Test Multiple Select Validation - * - * @return void - */ - public function testMultiple() { - $this->assertTrue(Validation::multiple(array(0, 1, 2, 3))); - $this->assertTrue(Validation::multiple(array(50, 32, 22, 0))); - $this->assertTrue(Validation::multiple(array('str', 'var', 'enum', 0))); - $this->assertFalse(Validation::multiple('')); - $this->assertFalse(Validation::multiple(null)); - $this->assertFalse(Validation::multiple(array())); - $this->assertFalse(Validation::multiple(array(0))); - $this->assertFalse(Validation::multiple(array('0'))); - - $this->assertTrue(Validation::multiple(array(0, 3, 4, 5), array('in' => range(0, 10)))); - $this->assertFalse(Validation::multiple(array(0, 15, 20, 5), array('in' => range(0, 10)))); - $this->assertFalse(Validation::multiple(array(0, 5, 10, 11), array('in' => range(0, 10)))); - $this->assertFalse(Validation::multiple(array('boo', 'foo', 'bar'), array('in' => array('foo', 'bar', 'baz')))); - $this->assertFalse(Validation::multiple(array('foo', '1bar'), array('in' => range(0, 10)))); - - $this->assertTrue(Validation::multiple(array(0, 5, 10, 11), array('max' => 3))); - $this->assertFalse(Validation::multiple(array(0, 5, 10, 11, 55), array('max' => 3))); - $this->assertTrue(Validation::multiple(array('foo', 'bar', 'baz'), array('max' => 3))); - $this->assertFalse(Validation::multiple(array('foo', 'bar', 'baz', 'squirrel'), array('max' => 3))); - - $this->assertTrue(Validation::multiple(array(0, 5, 10, 11), array('min' => 3))); - $this->assertTrue(Validation::multiple(array(0, 5, 10, 11, 55), array('min' => 3))); - $this->assertFalse(Validation::multiple(array('foo', 'bar', 'baz'), array('min' => 5))); - $this->assertFalse(Validation::multiple(array('foo', 'bar', 'baz', 'squirrel'), array('min' => 10))); - - $this->assertTrue(Validation::multiple(array(0, 5, 9), array('in' => range(0, 10), 'max' => 5))); - $this->assertFalse(Validation::multiple(array(0, 5, 9, 8, 6, 2, 1), array('in' => range(0, 10), 'max' => 5))); - $this->assertFalse(Validation::multiple(array(0, 5, 9, 8, 11), array('in' => range(0, 10), 'max' => 5))); - - $this->assertFalse(Validation::multiple(array(0, 5, 9), array('in' => range(0, 10), 'max' => 5, 'min' => 3))); - $this->assertFalse(Validation::multiple(array(0, 5, 9, 8, 6, 2, 1), array('in' => range(0, 10), 'max' => 5, 'min' => 2))); - $this->assertFalse(Validation::multiple(array(0, 5, 9, 8, 11), array('in' => range(0, 10), 'max' => 5, 'min' => 2))); - } - -/** - * testNumeric method - * - * @return void - */ - public function testNumeric() { - $this->assertFalse(Validation::numeric('teststring')); - $this->assertFalse(Validation::numeric('1.1test')); - $this->assertFalse(Validation::numeric('2test')); - - $this->assertTrue(Validation::numeric('2')); - $this->assertTrue(Validation::numeric(2)); - $this->assertTrue(Validation::numeric(2.2)); - $this->assertTrue(Validation::numeric('2.2')); - } - -/** - * testPhone method - * - * @return void - */ - public function testPhone() { - $this->assertFalse(Validation::phone('teststring')); - $this->assertFalse(Validation::phone('1-(33)-(333)-(4444)')); - $this->assertFalse(Validation::phone('1-(33)-3333-4444')); - $this->assertFalse(Validation::phone('1-(33)-33-4444')); - $this->assertFalse(Validation::phone('1-(33)-3-44444')); - $this->assertFalse(Validation::phone('1-(33)-3-444')); - $this->assertFalse(Validation::phone('1-(33)-3-44')); - - $this->assertFalse(Validation::phone('(055) 999-9999')); - $this->assertFalse(Validation::phone('(155) 999-9999')); - $this->assertFalse(Validation::phone('(595) 999-9999')); - $this->assertFalse(Validation::phone('(555) 099-9999')); - $this->assertFalse(Validation::phone('(555) 199-9999')); - - $this->assertTrue(Validation::phone('1 (222) 333 4444')); - $this->assertTrue(Validation::phone('+1 (222) 333 4444')); - $this->assertTrue(Validation::phone('(222) 333 4444')); - - $this->assertTrue(Validation::phone('1-(333)-333-4444')); - $this->assertTrue(Validation::phone('1.(333)-333-4444')); - $this->assertTrue(Validation::phone('1.(333).333-4444')); - $this->assertTrue(Validation::phone('1.(333).333.4444')); - $this->assertTrue(Validation::phone('1-333-333-4444')); - } - -/** - * testPostal method - * - * @return void - */ - public function testPostal() { - $this->assertFalse(Validation::postal('111', null, 'de')); - $this->assertFalse(Validation::postal('1111', null, 'de')); - $this->assertTrue(Validation::postal('13089', null, 'de')); - - $this->assertFalse(Validation::postal('111', null, 'be')); - $this->assertFalse(Validation::postal('0123', null, 'be')); - $this->assertTrue(Validation::postal('1204', null, 'be')); - - $this->assertFalse(Validation::postal('111', null, 'it')); - $this->assertFalse(Validation::postal('1111', null, 'it')); - $this->assertTrue(Validation::postal('13089', null, 'it')); - - $this->assertFalse(Validation::postal('111', null, 'uk')); - $this->assertFalse(Validation::postal('1111', null, 'uk')); - $this->assertFalse(Validation::postal('AZA 0AB', null, 'uk')); - $this->assertFalse(Validation::postal('X0A 0ABC', null, 'uk')); - $this->assertTrue(Validation::postal('X0A 0AB', null, 'uk')); - $this->assertTrue(Validation::postal('AZ0A 0AA', null, 'uk')); - $this->assertTrue(Validation::postal('A89 2DD', null, 'uk')); - - $this->assertFalse(Validation::postal('111', null, 'ca')); - $this->assertFalse(Validation::postal('1111', null, 'ca')); - $this->assertFalse(Validation::postal('D2A 0A0', null, 'ca')); - $this->assertFalse(Validation::postal('BAA 0ABC', null, 'ca')); - $this->assertFalse(Validation::postal('B2A AABC', null, 'ca')); - $this->assertFalse(Validation::postal('B2A 2AB', null, 'ca')); - $this->assertTrue(Validation::postal('X0A 0A2', null, 'ca')); - $this->assertTrue(Validation::postal('G4V 4C3', null, 'ca')); - - $this->assertFalse(Validation::postal('111', null, 'us')); - $this->assertFalse(Validation::postal('1111', null, 'us')); - $this->assertFalse(Validation::postal('130896', null, 'us')); - $this->assertFalse(Validation::postal('13089-33333', null, 'us')); - $this->assertFalse(Validation::postal('13089-333', null, 'us')); - $this->assertFalse(Validation::postal('13A89-4333', null, 'us')); - $this->assertTrue(Validation::postal('13089-3333', null, 'us')); - - $this->assertFalse(Validation::postal('111')); - $this->assertFalse(Validation::postal('1111')); - $this->assertFalse(Validation::postal('130896')); - $this->assertFalse(Validation::postal('13089-33333')); - $this->assertFalse(Validation::postal('13089-333')); - $this->assertFalse(Validation::postal('13A89-4333')); - $this->assertTrue(Validation::postal('13089-3333')); - } - -/** - * test that phone and postal pass to other classes. - * - * @return void - */ - public function testPhonePostalSsnPass() { - $this->assertTrue(Validation::postal('text', null, 'testNl')); - $this->assertTrue(Validation::phone('text', null, 'testDe')); - $this->assertTrue(Validation::ssn('text', null, 'testNl')); - } - -/** - * test pass through failure on postal - * - * @expectedException PHPUnit_Framework_Error - * @return void - */ - public function testPassThroughMethodFailure() { - Validation::phone('text', null, 'testNl'); - } - -/** - * test the pass through calling of an alternate locale with postal() - * - * @expectedException PHPUnit_Framework_Error - * @return void - **/ - public function testPassThroughClassFailure() { - Validation::postal('text', null, 'AUTOFAIL'); - } - -/** - * test pass through method - * - * @return void - */ - public function testPassThroughMethod() { - $this->assertTrue(Validation::postal('text', null, 'testNl')); - } - -/** - * testSsn method - * - * @return void - */ - public function testSsn() { - $this->assertFalse(Validation::ssn('111-333', null, 'dk')); - $this->assertFalse(Validation::ssn('111111-333', null, 'dk')); - $this->assertTrue(Validation::ssn('111111-3334', null, 'dk')); - - $this->assertFalse(Validation::ssn('1118333', null, 'nl')); - $this->assertFalse(Validation::ssn('1234567890', null, 'nl')); - $this->assertFalse(Validation::ssn('12345A789', null, 'nl')); - $this->assertTrue(Validation::ssn('123456789', null, 'nl')); - - $this->assertFalse(Validation::ssn('11-33-4333', null, 'us')); - $this->assertFalse(Validation::ssn('113-3-4333', null, 'us')); - $this->assertFalse(Validation::ssn('111-33-333', null, 'us')); - $this->assertTrue(Validation::ssn('111-33-4333', null, 'us')); - } - -/** - * testUserDefined method - * - * @return void - */ - public function testUserDefined() { - $validator = new CustomValidator; - $this->assertFalse(Validation::userDefined('33', $validator, 'customValidate')); - $this->assertFalse(Validation::userDefined('3333', $validator, 'customValidate')); - $this->assertTrue(Validation::userDefined('333', $validator, 'customValidate')); - } - -/** - * testDatetime method - * - * @return void - */ - public function testDatetime() { - $this->assertTrue(Validation::datetime('27-12-2006 01:00', 'dmy')); - $this->assertTrue(Validation::datetime('27-12-2006 01:00', array('dmy'))); - $this->assertFalse(Validation::datetime('27-12-2006 1:00', 'dmy')); - - $this->assertTrue(Validation::datetime('27.12.2006 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('27.12.2006 13:00pm', 'dmy')); - - $this->assertTrue(Validation::datetime('27/12/2006 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('27/12/2006 9:00', 'dmy')); - - $this->assertTrue(Validation::datetime('27 12 2006 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('27 12 2006 24:00', 'dmy')); - - $this->assertFalse(Validation::datetime('00-00-0000 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('00.00.0000 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('00/00/0000 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('00 00 0000 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('31-11-2006 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('31.11.2006 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('31/11/2006 1:00pm', 'dmy')); - $this->assertFalse(Validation::datetime('31 11 2006 1:00pm', 'dmy')); - } - -} diff --git a/lib/Cake/Test/Case/Utility/XmlTest.php b/lib/Cake/Test/Case/Utility/XmlTest.php deleted file mode 100644 index 5118ce274b2..00000000000 --- a/lib/Cake/Test/Case/Utility/XmlTest.php +++ /dev/null @@ -1,904 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.Utility - * @since CakePHP(tm) v 1.2.0.5432 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('Xml', 'Utility'); -App::uses('CakeTestModel', 'TestSuite/Fixture'); - -/** - * Article class - * - * @package Cake.Test.Case.Utility - */ -class XmlArticle extends CakeTestModel { - -/** - * name property - * - * @var string 'Article' - */ - public $name = 'Article'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array( - 'XmlUser' => array( - 'className' => 'XmlArticle', - 'foreignKey' => 'user_id' - ) - ); -} - -/** - * User class - * - * @package Cake.Test.Case.Utility - */ -class XmlUser extends CakeTestModel { - -/** - * name property - * - * @var string 'User' - */ - public $name = 'User'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array('Article'); -} - -/** - * XmlTest class - * - * @package Cake.Test.Case.Utility - */ -class XmlTest extends CakeTestCase { - -/** - * autoFixtures property - * - * @var bool false - */ - public $autoFixtures = false; - -/** - * fixtures property - * @var array - */ - public $fixtures = array( - 'core.article', 'core.user' - ); - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $this->_appEncoding = Configure::read('App.encoding'); - Configure::write('App.encoding', 'UTF-8'); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - parent::tearDown(); - Configure::write('App.encoding', $this->_appEncoding); - } - -/** - * testBuild method - * - * @return void - */ - public function testBuild() { - $xml = 'value'; - $obj = Xml::build($xml); - $this->assertTrue($obj instanceof SimpleXMLElement); - $this->assertEquals('tag', (string)$obj->getName()); - $this->assertEquals('value', (string)$obj); - - $xml = 'value'; - $this->assertEquals($obj, Xml::build($xml)); - - $obj = Xml::build($xml, array('return' => 'domdocument')); - $this->assertTrue($obj instanceof DOMDocument); - $this->assertEquals('tag', $obj->firstChild->nodeName); - $this->assertEquals('value', $obj->firstChild->nodeValue); - - $xml = CAKE . 'Test' . DS . 'Fixture' . DS . 'sample.xml'; - $obj = Xml::build($xml); - $this->assertEquals('tags', $obj->getName()); - $this->assertEquals(2, count($obj)); - - $this->assertEquals(Xml::build($xml), Xml::build(file_get_contents($xml))); - - $obj = Xml::build($xml, array('return' => 'domdocument')); - $this->assertEquals('tags', $obj->firstChild->nodeName); - - $this->assertEquals(Xml::build($xml, array('return' => 'domdocument')), Xml::build(file_get_contents($xml), array('return' => 'domdocument'))); - $this->assertEquals(Xml::build($xml, array('return' => 'simplexml')), Xml::build($xml, 'simplexml')); - - $xml = array('tag' => 'value'); - $obj = Xml::build($xml); - $this->assertEquals('tag', $obj->getName()); - $this->assertEquals('value', (string)$obj); - - $obj = Xml::build($xml, array('return' => 'domdocument')); - $this->assertEquals('tag', $obj->firstChild->nodeName); - $this->assertEquals('value', $obj->firstChild->nodeValue); - - $obj = Xml::build($xml, array('return' => 'domdocument', 'encoding' => null)); - $this->assertNotRegExp('/encoding/', $obj->saveXML()); - } - -/** - * data provider function for testBuildInvalidData - * - * @return array - */ - public static function invalidDataProvider() { - return array( - array(null), - array(false), - array(''), - ); - } - -/** - * testBuildInvalidData - * - * @dataProvider invalidDataProvider - * @expectedException XmlException - * return void - */ - public function testBuildInvalidData($value) { - Xml::build($value); - } - -/** - * test build with a single empty tag - * - * return void - */ - public function testBuildEmptyTag() { - try { - Xml::build(''); - $this->fail('No exception'); - } catch (Exception $e) { - $this->assertTrue(true, 'An exception was raised'); - } - } - -/** - * testFromArray method - * - * @return void - */ - public function testFromArray() { - $xml = array('tag' => 'value'); - $obj = Xml::fromArray($xml); - $this->assertEquals('tag', $obj->getName()); - $this->assertEquals('value', (string)$obj); - - $xml = array('tag' => null); - $obj = Xml::fromArray($xml); - $this->assertEquals('tag', $obj->getName()); - $this->assertEquals('', (string)$obj); - - $xml = array('tag' => array('@' => 'value')); - $obj = Xml::fromArray($xml); - $this->assertEquals('tag', $obj->getName()); - $this->assertEquals('value', (string)$obj); - - $xml = array( - 'tags' => array( - 'tag' => array( - array( - 'id' => '1', - 'name' => 'defect' - ), - array( - 'id' => '2', - 'name' => 'enhancement' - ) - ) - ) - ); - $obj = Xml::fromArray($xml, 'attributes'); - $this->assertTrue($obj instanceof SimpleXMLElement); - $this->assertEquals('tags', $obj->getName()); - $this->assertEquals(2, count($obj)); - $xmlText = '<' . '?xml version="1.0" encoding="UTF-8"?>'; - $this->assertEquals(str_replace(array("\r", "\n"), '', $obj->asXML()), $xmlText); - - $obj = Xml::fromArray($xml); - $this->assertTrue($obj instanceof SimpleXMLElement); - $this->assertEquals('tags', $obj->getName()); - $this->assertEquals(2, count($obj)); - $xmlText = '<' . '?xml version="1.0" encoding="UTF-8"?>1defect2enhancement'; - $this->assertEquals(str_replace(array("\r", "\n"), '', $obj->asXML()), $xmlText); - - $xml = array( - 'tags' => array( - ) - ); - $obj = Xml::fromArray($xml); - $this->assertEquals('tags', $obj->getName()); - $this->assertEquals('', (string)$obj); - - $xml = array( - 'tags' => array( - 'bool' => true, - 'int' => 1, - 'float' => 10.2, - 'string' => 'ok', - 'null' => null, - 'array' => array() - ) - ); - $obj = Xml::fromArray($xml, 'tags'); - $this->assertEquals(6, count($obj)); - $this->assertSame((string)$obj->bool, '1'); - $this->assertSame((string)$obj->int, '1'); - $this->assertSame((string)$obj->float, '10.2'); - $this->assertSame((string)$obj->string, 'ok'); - $this->assertSame((string)$obj->null, ''); - $this->assertSame((string)$obj->array, ''); - - $xml = array( - 'tags' => array( - 'tag' => array( - array( - '@id' => '1', - 'name' => 'defect' - ), - array( - '@id' => '2', - 'name' => 'enhancement' - ) - ) - ) - ); - $obj = Xml::fromArray($xml, 'tags'); - $xmlText = '<' . '?xml version="1.0" encoding="UTF-8"?>defectenhancement'; - $this->assertEquals(str_replace(array("\r", "\n"), '', $obj->asXML()), $xmlText); - - $xml = array( - 'tags' => array( - 'tag' => array( - array( - '@id' => '1', - 'name' => 'defect', - '@' => 'Tag 1' - ), - array( - '@id' => '2', - 'name' => 'enhancement' - ), - ), - '@' => 'All tags' - ) - ); - $obj = Xml::fromArray($xml, 'tags'); - $xmlText = '<' . '?xml version="1.0" encoding="UTF-8"?>All tagsTag 1defectenhancement'; - $this->assertEquals(str_replace(array("\r", "\n"), '', $obj->asXML()), $xmlText); - - $xml = array( - 'tags' => array( - 'tag' => array( - 'id' => 1, - '@' => 'defect' - ) - ) - ); - $obj = Xml::fromArray($xml, 'attributes'); - $xmlText = '<' . '?xml version="1.0" encoding="UTF-8"?>defect'; - $this->assertEquals(str_replace(array("\r", "\n"), '', $obj->asXML()), $xmlText); - } - -/** - * data provider for fromArray() failures - * - * @return array - */ - public static function invalidArrayDataProvider() { - return array( - array(''), - array(null), - array(false), - array(array()), - array(array('numeric key as root')), - array(array('item1' => '', 'item2' => '')), - array(array('items' => array('item1', 'item2'))), - array(array( - 'tags' => array( - 'tag' => array( - array( - array( - 'string' - ) - ) - ) - ) - )), - array(array( - 'tags' => array( - '@tag' => array( - array( - '@id' => '1', - 'name' => 'defect' - ), - array( - '@id' => '2', - 'name' => 'enhancement' - ) - ) - ) - )), - array(new DateTime()) - ); - } - -/** - * testFromArrayFail method - * - * @dataProvider invalidArrayDataProvider - */ - public function testFromArrayFail($value) { - try { - Xml::fromArray($value); - $this->fail('No exception.'); - } catch (Exception $e) { - $this->assertTrue(true, 'Caught exception.'); - } - } - -/** - * testToArray method - * - * @return void - */ - public function testToArray() { - $xml = 'name'; - $obj = Xml::build($xml); - $this->assertEquals(array('tag' => 'name'), Xml::toArray($obj)); - - $xml = CAKE . 'Test' . DS . 'Fixture' . DS . 'sample.xml'; - $obj = Xml::build($xml); - $expected = array( - 'tags' => array( - 'tag' => array( - array( - '@id' => '1', - 'name' => 'defect' - ), - array( - '@id' => '2', - 'name' => 'enhancement' - ) - ) - ) - ); - $this->assertEquals($expected, Xml::toArray($obj)); - - $array = array( - 'tags' => array( - 'tag' => array( - array( - 'id' => '1', - 'name' => 'defect' - ), - array( - 'id' => '2', - 'name' => 'enhancement' - ) - ) - ) - ); - $this->assertEquals(Xml::toArray(Xml::fromArray($array, 'tags')), $array); - - $expected = array( - 'tags' => array( - 'tag' => array( - array( - '@id' => '1', - '@name' => 'defect' - ), - array( - '@id' => '2', - '@name' => 'enhancement' - ) - ) - ) - ); - $this->assertEquals($expected, Xml::toArray(Xml::fromArray($array, 'attributes'))); - $this->assertEquals($expected, Xml::toArray(Xml::fromArray($array, array('return' => 'domdocument', 'format' => 'attributes')))); - $this->assertEquals(Xml::toArray(Xml::fromArray($array)), $array); - $this->assertEquals(Xml::toArray(Xml::fromArray($array, array('return' => 'domdocument'))), $array); - - $array = array( - 'tags' => array( - 'tag' => array( - 'id' => '1', - 'posts' => array( - array('id' => '1'), - array('id' => '2') - ) - ), - 'tagOther' => array( - 'subtag' => array( - 'id' => '1' - ) - ) - ) - ); - $expected = array( - 'tags' => array( - 'tag' => array( - '@id' => '1', - 'posts' => array( - array('@id' => '1'), - array('@id' => '2') - ) - ), - 'tagOther' => array( - 'subtag' => array( - '@id' => '1' - ) - ) - ) - ); - $this->assertEquals($expected, Xml::toArray(Xml::fromArray($array, 'attributes'))); - $this->assertEquals($expected, Xml::toArray(Xml::fromArray($array, array('format' => 'attributes', 'return' => 'domdocument')))); - - $xml = ''; - $xml .= 'defect'; - $xml .= ''; - $obj = Xml::build($xml); - - $expected = array( - 'root' => array( - 'tag' => array( - '@id' => 1, - '@' => 'defect' - ) - ) - ); - $this->assertEquals($expected, Xml::toArray($obj)); - - $xml = ''; - $xml .= '
ApplesBananas
'; - $xml .= 'CakePHPMIT
'; - $xml .= 'The book is on the table.
'; - $xml .= '
'; - $obj = Xml::build($xml); - - $expected = array( - 'root' => array( - 'table' => array( - array('tr' => array('td' => array('Apples', 'Bananas'))), - array('name' => 'CakePHP', 'license' => 'MIT'), - 'The book is on the table.' - ) - ) - ); - $this->assertEquals($expected, Xml::toArray($obj)); - - $xml = ''; - $xml .= 'defect'; - $xml .= '1'; - $xml .= ''; - $obj = Xml::build($xml); - - $expected = array( - 'root' => array( - 'tag' => 'defect', - 'cake:bug' => 1 - ) - ); - $this->assertEquals($expected, Xml::toArray($obj)); - } - -/** - * testRss - * - * @return void - */ - public function testRss() { - $rss = file_get_contents(CAKE . 'Test' . DS . 'Fixture' . DS . 'rss.xml'); - $rssAsArray = Xml::toArray(Xml::build($rss)); - $this->assertEquals('2.0', $rssAsArray['rss']['@version']); - $this->assertEquals(2, count($rssAsArray['rss']['channel']['item'])); - - $atomLink = array('@href' => 'http://bakery.cakephp.org/articles/rss', '@rel' => 'self', '@type' => 'application/rss+xml'); - $this->assertEquals($rssAsArray['rss']['channel']['atom:link'], $atomLink); - $this->assertEquals('http://bakery.cakephp.org/', $rssAsArray['rss']['channel']['link']); - - $expected = array( - 'title' => 'Alertpay automated sales via IPN', - 'link' => 'http://bakery.cakephp.org/articles/view/alertpay-automated-sales-via-ipn', - 'description' => 'I\'m going to show you how I implemented a payment module via the Alertpay payment processor.', - 'pubDate' => 'Tue, 31 Aug 2010 01:42:00 -0500', - 'guid' => 'http://bakery.cakephp.org/articles/view/alertpay-automated-sales-via-ipn' - ); - $this->assertSame($rssAsArray['rss']['channel']['item'][1], $expected); - - $rss = array( - 'rss' => array( - 'xmlns:atom' => 'http://www.w3.org/2005/Atom', - '@version' => '2.0', - 'channel' => array( - 'atom:link' => array( - '@href' => 'http://bakery.cakephp.org/articles/rss', - '@rel' => 'self', - '@type' => 'application/rss+xml' - ), - 'title' => 'The Bakery: ', - 'link' => 'http://bakery.cakephp.org/', - 'description' => 'Recent Articles at The Bakery.', - 'pubDate' => 'Sun, 12 Sep 2010 04:18:26 -0500', - 'item' => array( - array( - 'title' => 'CakePHP 1.3.4 released', - 'link' => 'http://bakery.cakephp.org/articles/view/cakephp-1-3-4-released' - ), - array( - 'title' => 'Wizard Component 1.2 Tutorial', - 'link' => 'http://bakery.cakephp.org/articles/view/wizard-component-1-2-tutorial' - ) - ) - ) - ) - ); - $rssAsSimpleXML = Xml::fromArray($rss); - $xmlText = '<' . '?xml version="1.0" encoding="UTF-8"?>'; - $xmlText .= ''; - $xmlText .= ''; - $xmlText .= ''; - $xmlText .= 'The Bakery: '; - $xmlText .= 'http://bakery.cakephp.org/'; - $xmlText .= 'Recent Articles at The Bakery.'; - $xmlText .= 'Sun, 12 Sep 2010 04:18:26 -0500'; - $xmlText .= 'CakePHP 1.3.4 releasedhttp://bakery.cakephp.org/articles/view/cakephp-1-3-4-released'; - $xmlText .= 'Wizard Component 1.2 Tutorialhttp://bakery.cakephp.org/articles/view/wizard-component-1-2-tutorial'; - $xmlText .= ''; - $this->assertEquals(str_replace(array("\r", "\n"), '', $rssAsSimpleXML->asXML()), $xmlText); - } - -/** - * testXmlRpc - * - * @return void - */ - public function testXmlRpc() { - $xml = Xml::build('test'); - $expected = array( - 'methodCall' => array( - 'methodName' => 'test', - 'params' => '' - ) - ); - $this->assertSame(Xml::toArray($xml), $expected); - - $xml = Xml::build('test12Egypt0-31'); - $expected = array( - 'methodCall' => array( - 'methodName' => 'test', - 'params' => array( - 'param' => array( - 'value' => array( - 'array' => array( - 'data' => array( - 'value' => array( - array('int' => '12'), - array('string' => 'Egypt'), - array('boolean' => '0'), - array('int' => '-31') - ) - ) - ) - ) - ) - ) - ) - ); - $this->assertSame(Xml::toArray($xml), $expected); - - $xmlText = '1testing'; - $xml = Xml::build($xmlText); - $expected = array( - 'methodResponse' => array( - 'params' => array( - 'param' => array( - 'value' => array( - 'array' => array( - 'data' => array( - 'value' => array( - array('int' => '1'), - array('string' => 'testing') - ) - ) - ) - ) - ) - ) - ) - ); - $this->assertSame(Xml::toArray($xml), $expected); - - $xml = Xml::fromArray($expected, 'tags'); - $this->assertEquals(str_replace(array("\r", "\n"), '', $xml->asXML()), $xmlText); - } - -/** - * testSoap - * - * @return void - */ - public function testSoap() { - $xmlRequest = Xml::build(CAKE . 'Test' . DS . 'Fixture' . DS . 'soap_request.xml'); - $expected = array( - 'Envelope' => array( - '@soap:encodingStyle' => 'http://www.w3.org/2001/12/soap-encoding', - 'soap:Body' => array( - 'm:GetStockPrice' => array( - 'm:StockName' => 'IBM' - ) - ) - ) - ); - $this->assertEquals($expected, Xml::toArray($xmlRequest)); - - $xmlResponse = Xml::build(CAKE . 'Test' . DS . 'Fixture' . DS . 'soap_response.xml'); - $expected = array( - 'Envelope' => array( - '@soap:encodingStyle' => 'http://www.w3.org/2001/12/soap-encoding', - 'soap:Body' => array( - 'm:GetStockPriceResponse' => array( - 'm:Price' => '34.5' - ) - ) - ) - ); - $this->assertEquals($expected, Xml::toArray($xmlResponse)); - - $xml = array( - 'soap:Envelope' => array( - 'xmlns:soap' => 'http://www.w3.org/2001/12/soap-envelope', - '@soap:encodingStyle' => 'http://www.w3.org/2001/12/soap-encoding', - 'soap:Body' => array( - 'xmlns:m' => 'http://www.example.org/stock', - 'm:GetStockPrice' => array( - 'm:StockName' => 'IBM' - ) - ) - ) - ); - $xmlRequest = Xml::fromArray($xml, array('encoding' => null)); - $xmlText = '<' . '?xml version="1.0"?>'; - $xmlText .= ''; - $xmlText .= ''; - $xmlText .= 'IBM'; - $xmlText .= ''; - $this->assertEquals(str_replace(array("\r", "\n"), '', $xmlRequest->asXML()), $xmlText); - } - -/** - * testNamespace - * - * @return void - */ - public function testNamespace() { - $xmlResponse = Xml::build('goodbadTag without ns'); - $expected = array( - 'root' => array( - 'ns:tag' => array( - '@id' => '1', - 'child' => 'good', - 'otherchild' => 'bad' - ), - 'tag' => 'Tag without ns' - ) - ); - $this->assertEquals($expected, Xml::toArray($xmlResponse)); - - $xmlResponse = Xml::build('1'); - $expected = array( - 'root' => array( - 'ns:tag' => array( - '@id' => '1' - ), - 'tag' => array( - 'id' => '1' - ) - ) - ); - $this->assertEquals($expected, Xml::toArray($xmlResponse)); - - $xmlResponse = Xml::build('1'); - $expected = array( - 'root' => array( - 'ns:attr' => '1' - ) - ); - $this->assertEquals($expected, Xml::toArray($xmlResponse)); - - $xmlResponse = Xml::build('1'); - $this->assertEquals($expected, Xml::toArray($xmlResponse)); - - $xml = array( - 'root' => array( - 'ns:attr' => array( - 'xmlns:ns' => 'http://cakephp.org', - '@' => 1 - ) - ) - ); - $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>1'; - $xmlResponse = Xml::fromArray($xml); - $this->assertEquals(str_replace(array("\r", "\n"), '', $xmlResponse->asXML()), $expected); - - $xml = array( - 'root' => array( - 'tag' => array( - 'xmlns:pref' => 'http://cakephp.org', - 'pref:item' => array( - 'item 1', - 'item 2' - ) - ) - ) - ); - $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>item 1item 2'; - $xmlResponse = Xml::fromArray($xml); - $this->assertEquals(str_replace(array("\r", "\n"), '', $xmlResponse->asXML()), $expected); - - $xml = array( - 'root' => array( - 'tag' => array( - 'xmlns:' => 'http://cakephp.org' - ) - ) - ); - $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; - $xmlResponse = Xml::fromArray($xml); - $this->assertEquals(str_replace(array("\r", "\n"), '', $xmlResponse->asXML()), $expected); - - $xml = array( - 'root' => array( - 'xmlns:' => 'http://cakephp.org' - ) - ); - $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; - $xmlResponse = Xml::fromArray($xml); - $this->assertEquals(str_replace(array("\r", "\n"), '', $xmlResponse->asXML()), $expected); - - $xml = array( - 'root' => array( - 'xmlns:ns' => 'http://cakephp.org' - ) - ); - $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; - $xmlResponse = Xml::fromArray($xml); - $this->assertEquals(str_replace(array("\r", "\n"), '', $xmlResponse->asXML()), $expected); - } - -/** - * test that CDATA blocks don't get screwed up by SimpleXml - * - * @return void - */ - public function testCdata() { - $xml = '<' . '?xml version="1.0" encoding="UTF-8"?>' . - ''; - - $result = Xml::build($xml); - $this->assertEquals(' Mark ', (string)$result->name); - } - -/** - * data provider for toArray() failures - * - * @return array - */ - public static function invalidToArrayDataProvider() { - return array( - array(new DateTime()), - array(array()) - ); - } - -/** - * testToArrayFail method - * - * @dataProvider invalidToArrayDataProvider - * @expectedException XmlException - */ - public function testToArrayFail($value) { - Xml::toArray($value); - } - -/** - * testWithModel method - * - * @return void - */ - public function testWithModel() { - $this->loadFixtures('User', 'Article'); - - $user = new XmlUser(); - $data = $user->read(null, 1); - - $obj = Xml::build(compact('data')); - $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; - $expected .= '1mariano5f4dcc3b5aa765d61d8327deb882cf99'; - $expected .= '2007-03-17 01:16:232007-03-17 01:18:31'; - $expected .= '
11First ArticleFirst Article Body'; - $expected .= 'Y2007-03-18 10:39:232007-03-18 10:41:31
'; - $expected .= '
31Third ArticleThird Article Body'; - $expected .= 'Y2007-03-18 10:43:232007-03-18 10:45:31
'; - $expected .= '
'; - $this->assertEquals($expected, str_replace(array("\r", "\n"), '', $obj->asXML())); - - //multiple model results - without a records key it would fatal error - $data = $user->find('all', array('limit' => 2)); - $data = array('records' => $data); - $obj = Xml::build(compact('data')); - $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; - $expected .= ''; - $expected .= '1mariano5f4dcc3b5aa765d61d8327deb882cf99'; - $expected .= '2007-03-17 01:16:232007-03-17 01:18:31'; - $expected .= '
11First ArticleFirst Article Body'; - $expected .= 'Y2007-03-18 10:39:232007-03-18 10:41:31
'; - $expected .= '
31Third ArticleThird Article Body'; - $expected .= 'Y2007-03-18 10:43:232007-03-18 10:45:31
'; - $expected .= '
2nate5f4dcc3b5aa765d61d8327deb882cf99'; - $expected .= '2007-03-17 01:18:232007-03-17 01:20:31
'; - $expected .= ''; - $expected .= ''; - $result = $obj->asXML(); - $this->assertEquals($expected, str_replace(array("\r", "\n"), '', $obj->asXML())); - } - -/** - * Test ampersand in text elements. - * - * @return void - */ - public function testAmpInText() { - $data = array( - 'outer' => array( - 'inner' => array('name' => 'mark & mark') - ) - ); - $obj = Xml::build($data); - $result = $obj->asXml(); - $this->assertContains('mark & mark', $result); - } -} diff --git a/lib/Cake/Test/Case/View/Helper/CacheHelperTest.php b/lib/Cake/Test/Case/View/Helper/CacheHelperTest.php deleted file mode 100644 index 768d7a5f57e..00000000000 --- a/lib/Cake/Test/Case/View/Helper/CacheHelperTest.php +++ /dev/null @@ -1,662 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. (http://cakefoundation.org) - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.View.Helper - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ - -App::uses('Controller', 'Controller'); -App::uses('Model', 'Model'); -App::uses('View', 'View'); -App::uses('CacheHelper', 'View/Helper'); - -/** - * CacheTestController class - * - * @package Cake.Test.Case.View.Helper - */ -class CacheTestController extends Controller { - -/** - * helpers property - * - * @var array - */ - public $helpers = array('Html', 'Cache'); - -/** - * cache_parsing method - * - * @return void - */ - public function cache_parsing() { - $this->viewPath = 'Posts'; - $this->layout = 'cache_layout'; - $this->set('variable', 'variableValue'); - $this->set('superman', 'clark kent'); - $this->set('batman', 'bruce wayne'); - $this->set('spiderman', 'peter parker'); - } - -} - -/** - * CacheHelperTest class - * - * @package Cake.Test.Case.View.Helper - */ -class CacheHelperTest extends CakeTestCase { - -/** - * Checks if TMP/views is writable, and skips the case if it is not. - * - * @return void - */ - public function skip() { - if (!is_writable(TMP . 'cache' . DS . 'views' . DS)) { - $this->markTestSkipped('TMP/views is not writable %s'); - } - } - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - $_GET = array(); - $request = new CakeRequest(); - $this->Controller = new CacheTestController($request); - $View = new View($this->Controller); - $this->Cache = new CacheHelper($View); - Configure::write('Cache.check', true); - Configure::write('Cache.disable', false); - App::build(array( - 'View' => array(CAKE . 'Test' . DS . 'test_app' . DS . 'View' . DS) - ), App::RESET); - } - -/** - * tearDown method - * - * @return void - */ - public function tearDown() { - clearCache(); - unset($this->Cache); - parent::tearDown(); - } - -/** - * test cache parsing with no cake:nocache tags in view file. - * - * @return void - */ - public function testLayoutCacheParsingNoTagsInView() { - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->cacheAction = 21600; - $this->Controller->request->here = '/cacheTest/cache_parsing'; - $this->Controller->request->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - - $contents = file_get_contents($filename); - $this->assertRegExp('/php echo \$variable/', $contents); - $this->assertRegExp('/php echo microtime()/', $contents); - $this->assertRegExp('/clark kent/', $result); - - @unlink($filename); - } - -/** - * test cache parsing with non-latin characters in current route - * - * @return void - */ - public function testCacheNonLatinCharactersInRoute() { - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array('風街ろまん'), - 'named' => array() - )); - $this->Controller->cacheAction = 21600; - $this->Controller->request->here = '/posts/view/風街ろまん'; - $this->Controller->action = 'view'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $filename = CACHE . 'views' . DS . 'posts_view_風街ろまん.php'; - $this->assertTrue(file_exists($filename)); - - @unlink($filename); - } - -/** - * Test cache parsing with cake:nocache tags in view file. - * - * @return void - */ - public function testLayoutCacheParsingWithTagsInView() { - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->cacheAction = 21600; - $this->Controller->request->here = '/cacheTest/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('test_nocache_tags'); - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - - $contents = file_get_contents($filename); - $this->assertRegExp('/if \(is_writable\(TMP\)\)\:/', $contents); - $this->assertRegExp('/php echo \$variable/', $contents); - $this->assertRegExp('/php echo microtime()/', $contents); - $this->assertNotRegExp('/cake:nocache/', $contents); - - @unlink($filename); - } - -/** - * test that multiple tags function with multiple nocache tags in the layout. - * - * @return void - */ - public function testMultipleNoCacheTagsInViewfile() { - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->cacheAction = 21600; - $this->Controller->request->here = '/cacheTest/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('multiple_nocache'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - - $contents = file_get_contents($filename); - $this->assertNotRegExp('/cake:nocache/', $contents); - @unlink($filename); - } - -/** - * testComplexNoCache method - * - * @return void - */ - public function testComplexNoCache() { - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_complex', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->cacheAction = array('cache_complex' => 21600); - $this->Controller->request->here = '/cacheTest/cache_complex'; - $this->Controller->action = 'cache_complex'; - $this->Controller->layout = 'multi_cache'; - $this->Controller->viewPath = 'Posts'; - - $View = new View($this->Controller); - $result = $View->render('sequencial_nocache'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - $this->assertRegExp('/A\. Layout Before Content/', $result); - $this->assertRegExp('/B\. In Plain Element/', $result); - $this->assertRegExp('/C\. Layout After Test Element/', $result); - $this->assertRegExp('/D\. In View File/', $result); - $this->assertRegExp('/E\. Layout After Content/', $result); - $this->assertRegExp('/F\. In Element With No Cache Tags/', $result); - $this->assertRegExp('/G\. Layout After Content And After Element With No Cache Tags/', $result); - $this->assertNotRegExp('/1\. layout before content/', $result); - $this->assertNotRegExp('/2\. in plain element/', $result); - $this->assertNotRegExp('/3\. layout after test element/', $result); - $this->assertNotRegExp('/4\. in view file/', $result); - $this->assertNotRegExp('/5\. layout after content/', $result); - $this->assertNotRegExp('/6\. in element with no cache tags/', $result); - $this->assertNotRegExp('/7\. layout after content and after element with no cache tags/', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_complex.php'; - $this->assertTrue(file_exists($filename)); - $contents = file_get_contents($filename); - @unlink($filename); - - $this->assertRegExp('/A\. Layout Before Content/', $contents); - $this->assertNotRegExp('/B\. In Plain Element/', $contents); - $this->assertRegExp('/C\. Layout After Test Element/', $contents); - $this->assertRegExp('/D\. In View File/', $contents); - $this->assertRegExp('/E\. Layout After Content/', $contents); - $this->assertRegExp('/F\. In Element With No Cache Tags/', $contents); - $this->assertRegExp('/G\. Layout After Content And After Element With No Cache Tags/', $contents); - $this->assertRegExp('/1\. layout before content/', $contents); - $this->assertNotRegExp('/2\. in plain element/', $contents); - $this->assertRegExp('/3\. layout after test element/', $contents); - $this->assertRegExp('/4\. in view file/', $contents); - $this->assertRegExp('/5\. layout after content/', $contents); - $this->assertRegExp('/6\. in element with no cache tags/', $contents); - $this->assertRegExp('/7\. layout after content and after element with no cache tags/', $contents); - } - -/** - * test cache of view vars - * - * @return void - */ - public function testCacheViewVars() { - $this->Controller->cache_parsing(); - $this->Controller->params = array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - ); - $this->Controller->cacheAction = 21600; - $this->Controller->here = '/cacheTest/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - - $contents = file_get_contents($filename); - $this->assertRegExp('/\$this\-\>viewVars/', $contents); - $this->assertRegExp('/extract\(\$this\-\>viewVars, EXTR_SKIP\);/', $contents); - $this->assertRegExp('/php echo \$variable/', $contents); - - @unlink($filename); - } - -/** - * Test that callback code is generated correctly. - * - * @return void - */ - public function testCacheCallbacks() { - $this->Controller->cache_parsing(); - $this->Controller->params = array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - ); - $this->Controller->cacheAction = array( - 'cache_parsing' => array( - 'duration' => 21600, - 'callbacks' => true - ) - ); - $this->Controller->here = '/cacheTest/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - - $contents = file_get_contents($filename); - - $this->assertRegExp('/\$controller->startupProcess\(\);/', $contents); - - @unlink($filename); - } - -/** - * test cacheAction set to a boolean - * - * @return void - */ - public function testCacheActionArray() { - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->cacheAction = array( - 'cache_parsing' => 21600 - ); - $this->Controller->request->here = '/cache_test/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cache_test_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - @unlink($filename); - - $this->Controller->cache_parsing(); - $this->Controller->cacheAction = array( - 'cache_parsing' => 21600 - ); - $this->Controller->request->here = '/cacheTest/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - @unlink($filename); - - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->cacheAction = array( - 'some_other_action' => 21600 - ); - $this->Controller->request->here = '/cacheTest/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_parsing.php'; - $this->assertFalse(file_exists($filename)); - } - -/** - * test with named and pass args. - * - * @return void - */ - public function testCacheWithNamedAndPassedArgs() { - Router::reload(); - - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(1, 2), - 'named' => array( - 'name' => 'mark', - 'ice' => 'cream' - ) - )); - $this->Controller->cacheAction = array( - 'cache_parsing' => 21600 - ); - $this->Controller->request->here = '/cache_test/cache_parsing/1/2/name:mark/ice:cream'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cache_test_cache_parsing_1_2_name_mark_ice_cream.php'; - $this->assertTrue(file_exists($filename)); - @unlink($filename); - } - -/** - * Test that query string parameters are included in the cache filename. - * - * @return void - */ - public function testCacheWithQueryStringParams() { - Router::reload(); - - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->request->query = array('q' => 'cakephp'); - $this->Controller->cacheAction = array( - 'cache_parsing' => 21600 - ); - $this->Controller->request->here = '/cache_test/cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cache_test_cache_parsing_q_cakephp.php'; - $this->assertTrue(file_exists($filename), 'Missing cache file ' . $filename); - @unlink($filename); - } - -/** - * test that custom routes are respected when generating cache files. - * - * @return void - */ - public function testCacheWithCustomRoutes() { - Router::reload(); - Router::connect('/:lang/:controller/:action/*', array(), array('lang' => '[a-z]{3}')); - - $this->Controller->cache_parsing(); - $this->Controller->request->addParams(array( - 'lang' => 'en', - 'controller' => 'cache_test', - 'action' => 'cache_parsing', - 'pass' => array(), - 'named' => array() - )); - $this->Controller->cacheAction = array( - 'cache_parsing' => 21600 - ); - $this->Controller->request->here = '/en/cache_test/cache_parsing'; - $this->Controller->action = 'cache_parsing'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'en_cache_test_cache_parsing.php'; - $this->assertTrue(file_exists($filename)); - @unlink($filename); - } - -/** - * test ControllerName contains AppName - * - * This test verifies view cache is created correctly when the app name is contained in part of the controller name. - * (webapp Name) base name is 'cache' controller is 'cacheTest' action is 'cache_name' - * apps url would look something like http://localhost/cache/cacheTest/cache_name - * - * @return void - **/ - public function testCacheBaseNameControllerName() { - $this->Controller->cache_parsing(); - $this->Controller->cacheAction = array( - 'cache_name' => 21600 - ); - $this->Controller->params = array( - 'controller' => 'cacheTest', - 'action' => 'cache_name', - 'pass' => array(), - 'named' => array() - ); - $this->Controller->here = '/cache/cacheTest/cache_name'; - $this->Controller->action = 'cache_name'; - $this->Controller->base = '/cache'; - - $View = new View($this->Controller); - $result = $View->render('index'); - - $this->assertNotRegExp('/cake:nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - - $filename = CACHE . 'views' . DS . 'cache_cachetest_cache_name.php'; - $this->assertTrue(file_exists($filename)); - @unlink($filename); - } - -/** - * test that afterRender checks the conditions correctly. - * - * @return void - */ - public function testAfterRenderConditions() { - Configure::write('Cache.check', true); - $View = new View($this->Controller); - $View->cacheAction = '+1 day'; - $View->output = 'test'; - - $Cache = $this->getMock('CacheHelper', array('_parseContent'), array($View)); - $Cache->expects($this->once()) - ->method('_parseContent') - ->with('posts/index', 'content') - ->will($this->returnValue('')); - - $Cache->afterRenderFile('posts/index', 'content'); - - Configure::write('Cache.check', false); - $Cache->afterRender('posts/index'); - - Configure::write('Cache.check', true); - $View->cacheAction = false; - $Cache->afterRender('posts/index'); - } - -/** - * test that afterRender checks the conditions correctly. - * - * @return void - */ - public function testAfterLayoutConditions() { - Configure::write('Cache.check', true); - $View = new View($this->Controller); - $View->cacheAction = '+1 day'; - $View->output = 'test'; - - $Cache = $this->getMock('CacheHelper', array('cache'), array($View)); - $Cache->expects($this->once()) - ->method('cache') - ->with('posts/index', $View->output) - ->will($this->returnValue('')); - - $Cache->afterLayout('posts/index'); - - Configure::write('Cache.check', false); - $Cache->afterLayout('posts/index'); - - Configure::write('Cache.check', true); - $View->cacheAction = false; - $Cache->afterLayout('posts/index'); - } - -/** - * testCacheEmptySections method - * - * This test must be uncommented/fixed in next release (1.2+) - * - * @return void - */ - public function testCacheEmptySections() { - $this->Controller->cache_parsing(); - $this->Controller->params = array( - 'controller' => 'cacheTest', - 'action' => 'cache_empty_sections', - 'pass' => array(), - 'named' => array() - ); - $this->Controller->cacheAction = array('cache_empty_sections' => 21600); - $this->Controller->here = '/cacheTest/cache_empty_sections'; - $this->Controller->action = 'cache_empty_sections'; - $this->Controller->layout = 'cache_empty_sections'; - $this->Controller->viewPath = 'Posts'; - - $View = new View($this->Controller); - $result = $View->render('cache_empty_sections'); - $this->assertNotRegExp('/nocache/', $result); - $this->assertNotRegExp('/php echo/', $result); - $this->assertRegExp( - '@\s*\s*' . - '\s*' . - 'View Content\s*' . - 'cached count is: 3\s*' . - '@', $result); - - $filename = CACHE . 'views' . DS . 'cachetest_cache_empty_sections.php'; - $this->assertTrue(file_exists($filename)); - $contents = file_get_contents($filename); - $this->assertNotRegExp('/nocache/', $contents); - $this->assertRegExp( - '@\s*Posts\s*' . - '<\?php \$x \= 1; \?>\s*' . - '\s*' . - '\s*' . - '<\?php \$x\+\+; \?>\s*' . - '<\?php \$x\+\+; \?>\s*' . - 'View Content\s*' . - '<\?php \$y = 1; \?>\s*' . - '<\?php echo \'cached count is: \' . \$x; \?>\s*' . - '@', $contents); - @unlink($filename); - } -} diff --git a/lib/Cake/Test/Case/View/Helper/FormHelperTest.php b/lib/Cake/Test/Case/View/Helper/FormHelperTest.php deleted file mode 100644 index ba28c1ca59e..00000000000 --- a/lib/Cake/Test/Case/View/Helper/FormHelperTest.php +++ /dev/null @@ -1,7848 +0,0 @@ - - * Copyright 2005-2012, Cake Software Foundation, Inc. - * - * Licensed under The MIT License - * Redistributions of files must retain the above copyright notice - * - * @copyright Copyright 2005-2012, Cake Software Foundation, Inc. - * @link http://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests - * @package Cake.Test.Case.View.Helper - * @since CakePHP(tm) v 1.2.0.4206 - * @license MIT License (http://www.opensource.org/licenses/mit-license.php) - */ -App::uses('ClassRegistry', 'Utility'); -App::uses('Controller', 'Controller'); -App::uses('View', 'View'); -App::uses('Model', 'Model'); -App::uses('Security', 'Utility'); -App::uses('CakeRequest', 'Network'); -App::uses('HtmlHelper', 'View/Helper'); -App::uses('FormHelper', 'View/Helper'); -App::uses('Router', 'Routing'); - -/** - * ContactTestController class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class ContactTestController extends Controller { - -/** - * name property - * - * @var string 'ContactTest' - */ - public $name = 'ContactTest'; - -/** - * uses property - * - * @var mixed null - */ - public $uses = null; -} - -/** - * Contact class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class Contact extends CakeTestModel { - -/** - * primaryKey property - * - * @var string 'id' - */ - public $primaryKey = 'id'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'Contact' - */ - public $name = 'Contact'; - -/** - * Default schema - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'phone' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'password' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'published' => array('type' => 'date', 'null' => true, 'default' => null, 'length' => null), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null), - 'age' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => null) - ); - -/** - * validate property - * - * @var array - */ - public $validate = array( - 'non_existing' => array(), - 'idontexist' => array(), - 'imrequired' => array('rule' => array('between', 5, 30), 'allowEmpty' => false), - 'string_required' => 'notEmpty', - 'imalsorequired' => array('rule' => 'alphaNumeric', 'allowEmpty' => false), - 'imrequiredtoo' => array('rule' => 'notEmpty'), - 'required_one' => array('required' => array('rule' => array('notEmpty'))), - 'imnotrequired' => array('required' => false, 'rule' => 'alphaNumeric', 'allowEmpty' => true), - 'imalsonotrequired' => array( - 'alpha' => array('rule' => 'alphaNumeric','allowEmpty' => true), - 'between' => array('rule' => array('between', 5, 30)), - ), - 'imnotrequiredeither' => array('required' => true, 'rule' => array('between', 5, 30), 'allowEmpty' => true), - ); - -/** - * schema method - * - * @return void - */ - public function setSchema($schema) { - $this->_schema = $schema; - } - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $hasAndBelongsToMany = array('ContactTag' => array('with' => 'ContactTagsContact')); - -/** - * hasAndBelongsToMany property - * - * @var array - */ - public $belongsTo = array('User' => array('className' => 'UserForm')); -} - -/** - * ContactTagsContact class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class ContactTagsContact extends CakeTestModel { - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'Contact' - */ - public $name = 'ContactTagsContact'; - -/** - * Default schema - * - * @var array - */ - protected $_schema = array( - 'contact_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'contact_tag_id' => array( - 'type' => 'integer', 'null' => '', 'default' => '', 'length' => '8' - ) - ); - -/** - * schema method - * - * @return void - */ - public function setSchema($schema) { - $this->_schema = $schema; - } - -} - -/** - * ContactNonStandardPk class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class ContactNonStandardPk extends Contact { - -/** - * primaryKey property - * - * @var string 'pk' - */ - public $primaryKey = 'pk'; - -/** - * name property - * - * @var string 'ContactNonStandardPk' - */ - public $name = 'ContactNonStandardPk'; - -/** - * schema method - * - * @return void - */ - public function schema($field = false) { - $this->_schema = parent::schema(); - $this->_schema['pk'] = $this->_schema['id']; - unset($this->_schema['id']); - return $this->_schema; - } - -} - -/** - * ContactTag class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class ContactTag extends Model { - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema definition - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => false, 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => false, 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => true, 'default' => '', 'length' => ''), - 'modified' => array('type' => 'datetime', 'null' => true, 'default' => '', 'length' => null) - ); -} - -/** - * UserForm class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class UserForm extends CakeTestModel { - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * primaryKey property - * - * @var string 'id' - */ - public $primaryKey = 'id'; - -/** - * name property - * - * @var string 'UserForm' - */ - public $name = 'UserForm'; - -/** - * hasMany property - * - * @var array - */ - public $hasMany = array( - 'OpenidUrl' => array('className' => 'OpenidUrl', 'foreignKey' => 'user_form_id' - )); - -/** - * schema definition - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'published' => array('type' => 'date', 'null' => true, 'default' => null, 'length' => null), - 'other' => array('type' => 'text', 'null' => true, 'default' => null, 'length' => null), - 'stuff' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => 10), - 'something' => array('type' => 'string', 'null' => true, 'default' => null, 'length' => 255), - 'active' => array('type' => 'boolean', 'null' => false, 'default' => false), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); -} - -/** - * OpenidUrl class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class OpenidUrl extends CakeTestModel { - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * primaryKey property - * - * @var string 'id' - */ - public $primaryKey = 'id'; - -/** - * name property - * - * @var string 'OpenidUrl' - */ - public $name = 'OpenidUrl'; - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('UserForm' => array( - 'className' => 'UserForm', 'foreignKey' => 'user_form_id' - )); - -/** - * validate property - * - * @var array - */ - public $validate = array('openid_not_registered' => array()); - -/** - * schema method - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'user_form_id' => array( - 'type' => 'user_form_id', 'null' => '', 'default' => '', 'length' => '8' - ), - 'url' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - ); - -/** - * beforeValidate method - * - * @return void - */ - public function beforeValidate($options = array()) { - $this->invalidate('openid_not_registered'); - return true; - } - -} - -/** - * ValidateUser class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class ValidateUser extends CakeTestModel { - -/** - * primaryKey property - * - * @var string 'id' - */ - public $primaryKey = 'id'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'ValidateUser' - */ - public $name = 'ValidateUser'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('ValidateProfile' => array( - 'className' => 'ValidateProfile', 'foreignKey' => 'user_id' - )); - -/** - * schema method - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'email' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'balance' => array('type' => 'float', 'null' => false, 'length' => '5,2'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - -/** - * beforeValidate method - * - * @return void - */ - public function beforeValidate($options = array()) { - $this->invalidate('email'); - return false; - } - -} - -/** - * ValidateProfile class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class ValidateProfile extends CakeTestModel { - -/** - * primaryKey property - * - * @var string 'id' - */ - public $primaryKey = 'id'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'user_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'full_name' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'city' => array('type' => 'string', 'null' => '', 'default' => '', 'length' => '255'), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - -/** - * name property - * - * @var string 'ValidateProfile' - */ - public $name = 'ValidateProfile'; - -/** - * hasOne property - * - * @var array - */ - public $hasOne = array('ValidateItem' => array( - 'className' => 'ValidateItem', 'foreignKey' => 'profile_id' - )); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('ValidateUser' => array( - 'className' => 'ValidateUser', 'foreignKey' => 'user_id' - )); - -/** - * beforeValidate method - * - * @return void - */ - public function beforeValidate($options = array()) { - $this->invalidate('full_name'); - $this->invalidate('city'); - return false; - } - -} - -/** - * ValidateItem class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class ValidateItem extends CakeTestModel { - -/** - * primaryKey property - * - * @var string 'id' - */ - public $primaryKey = 'id'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'ValidateItem' - */ - public $name = 'ValidateItem'; - -/** - * schema property - * - * @var array - */ - protected $_schema = array( - 'id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'profile_id' => array('type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'), - 'name' => array('type' => 'text', 'null' => '', 'default' => '', 'length' => '255'), - 'description' => array( - 'type' => 'string', 'null' => '', 'default' => '', 'length' => '255' - ), - 'created' => array('type' => 'date', 'null' => '1', 'default' => '', 'length' => ''), - 'updated' => array('type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null) - ); - -/** - * belongsTo property - * - * @var array - */ - public $belongsTo = array('ValidateProfile' => array('foreignKey' => 'profile_id')); - -/** - * beforeValidate method - * - * @return void - */ - public function beforeValidate($options = array()) { - $this->invalidate('description'); - return false; - } - -} - -/** - * TestMail class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class TestMail extends CakeTestModel { - -/** - * primaryKey property - * - * @var string 'id' - */ - public $primaryKey = 'id'; - -/** - * useTable property - * - * @var bool false - */ - public $useTable = false; - -/** - * name property - * - * @var string 'TestMail' - */ - public $name = 'TestMail'; -} - -/** - * FormHelperTest class - * - * @package cake - * @package Cake.Test.Case.View.Helper - */ -class FormHelperTest extends CakeTestCase { - -/** - * Fixtures to be used - * - * @var array - */ - public $fixtures = array('core.post'); - -/** - * Do not load the fixtures by default - * - * @var boolean - */ - public $autoFixtures = false; - -/** - * setUp method - * - * @return void - */ - public function setUp() { - parent::setUp(); - - Configure::write('App.base', ''); - $this->Controller = new ContactTestController(); - $this->View = new View($this->Controller); - - $this->Form = new FormHelper($this->View); - $this->Form->Html = new HtmlHelper($this->View); - $this->Form->request = new CakeRequest('contacts/add', false); - $this->Form->request->here = '/contacts/add'; - $this->Form->request['action'] = 'add'; - $this->Form->request->webroot = ''; - $this->Form->request->base = ''; - - ClassRegistry::addObject('Contact', new Contact()); - ClassRegistry::addObject('ContactNonStandardPk', new ContactNonStandardPk()); - ClassRegistry::addObject('OpenidUrl', new OpenidUrl()); - ClassRegistry::addObject('User', new UserForm()); - ClassRegistry::addObject('ValidateItem', new ValidateItem()); - ClassRegistry::addObject('ValidateUser', new ValidateUser()); - ClassRegistry::addObject('ValidateProfile', new ValidateProfile()); - - $this->oldSalt = Configure::read('Security.salt'); - - $this->dateRegex = array( - 'daysRegex' => 'preg:/(?:', - 'selectoption' => '', - 'selectend' => '', - 'optiongroup' => '', - 'optiongroupend' => '', - 'checkboxmultiplestart' => '', - 'checkboxmultipleend' => '', - 'password' => '', - 'file' => '', - 'file_no_model' => '', - 'submit' => '', - 'submitimage' => '', - 'button' => '%s', - 'image' => '', - 'tableheader' => '%s', - 'tableheaderrow' => '%s', - 'tablecell' => '%s', - 'tablerow' => '%s', - 'block' => '%s
', - 'blockstart' => '', - 'blockend' => '
', - 'tag' => '<%s%s>%s', - 'tagstart' => '<%s%s>', - 'tagend' => '', - 'tagselfclosing' => '<%s%s/>', - 'para' => '%s

', - 'parastart' => '', - 'label' => '', - 'fieldset' => '%s', - 'fieldsetstart' => '
%s', - 'fieldsetend' => '
', - 'legend' => '%s', - 'css' => '', - 'style' => '', - 'charset' => '', - 'ul' => '%s', - 'ol' => '%s', - 'li' => '%s', - 'error' => '%s', - 'javascriptblock' => '', - 'javascriptstart' => '', - 'javascriptend' => '' - ); - -/** - * Format to attribute - * - * @var string - */ - protected $_attributeFormat = '%s="%s"'; - -/** - * Format to attribute - * - * @var string - */ - protected $_minimizedAttributeFormat = '%s="%s"'; - -/** - * Breadcrumbs. - * - * @var array - */ - protected $_crumbs = array(); - -/** - * Names of script files that have been included once - * - * @var array - */ - protected $_includedScripts = array(); - -/** - * Options for the currently opened script block buffer if any. - * - * @var array - */ - protected $_scriptBlockOptions = array(); - -/** - * Document type definitions - * - * @var array - */ - protected $_docTypes = array( - 'html4-strict' => '', - 'html4-trans' => '', - 'html4-frame' => '', - 'html5' => '', - 'xhtml-strict' => '', - 'xhtml-trans' => '', - 'xhtml-frame' => '', - 'xhtml11' => '' - ); - -/** - * Constructor - * - * ### Settings - * - * - `configFile` A file containing an array of tags you wish to redefine. - * - * ### Customizing tag sets - * - * Using the `configFile` option you can redefine the tag HtmlHelper will use. - * The file named should be compatible with HtmlHelper::loadConfig(). - * - * @param View $View The View this helper is being attached to. - * @param array $settings Configuration settings for the helper. - */ - public function __construct(View $View, $settings = array()) { - parent::__construct($View, $settings); - if (is_object($this->_View->response)) { - $this->response = $this->_View->response; - } else { - $this->response = new CakeResponse(array('charset' => Configure::read('App.encoding'))); - } - if (!empty($settings['configFile'])) { - $this->loadConfig($settings['configFile']); - } - } - -/** - * Adds a link to the breadcrumbs array. - * - * @param string $name Text for link - * @param string $link URL for link (if empty it won't be a link) - * @param mixed $options Link attributes e.g. array('id' => 'selected') - * @return void - * @see HtmlHelper::link() for details on $options that can be used. - * @link http://book.cakephp.org/2.0/en/core-libraries/helpers/html.html#creating-breadcrumb-trails-with-htmlhelper - */ - public function addCrumb($name, $link = null, $options = null) { - $this->_crumbs[] = array($name, $link, $options); - } - -/** - * 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 Doctype string - * @link http://book.cakephp.org/2.0/en/core-libraries/helpers/html.html#HtmlHelper::docType - */ - public function docType($type = 'html5') { - if (isset($this->_docTypes[$type])) { - return $this->_docTypes[$type]; - } - return null; - } - -/** - * Creates a link to an external resource and handles basic meta tags - * - * Create a meta tag that is output inline: - * - * `$this->Html->meta('icon', 'favicon.ico'); - * - * Append the meta tag to `$scripts_for_layout`: - * - * `$this->Html->meta('description', 'A great page', array('inline' => false));` - * - * Append the meta tag to custom view block: - * - * `$this->Html->meta('description', 'A great page', array('block' => 'metaTags'));` - * - * ### Options - * - * - `inline` Whether or not the link element should be output inline. Set to false to - * have the meta tag included in `$scripts_for_layout`, and appended to the 'meta' view block. - * - `block` Choose a custom block to append the meta tag to. Using this option - * will override the inline option. - * - * @param string $type The title of the external resource - * @param mixed $url 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 A completed `` element. - * @link http://book.cakephp.org/2.0/en/core-libraries/helpers/html.html#HtmlHelper::meta - */ - public function meta($type, $url = null, $options = array()) { - $options += array('inline' => true, 'block' => null); - if (!$options['inline'] && empty($options['block'])) { - $options['block'] = __FUNCTION__; - } - unset($options['inline']); - - if (!is_array($type)) { - $types = array( - 'rss' => array('type' => 'application/rss+xml', 'rel' => 'alternate', 'title' => $type, 'link' => $url), - 'atom' => array('type' => 'application/atom+xml', 'title' => $type, 'link' => $url), - 'icon' => array('type' => 'image/x-icon', 'rel' => 'icon', 'link' => $url), - 'keywords' => array('name' => 'keywords', 'content' => $url), - 'description' => array('name' => 'description', 'content' => $url), - ); - - if ($type === 'icon' && $url === null) { - $types['icon']['link'] = $this->webroot('favicon.ico'); - } - - if (isset($types[$type])) { - $type = $types[$type]; - } elseif (!isset($options['type']) && $url !== null) { - if (is_array($url) && isset($url['ext'])) { - $type = $types[$url['ext']]; - } else { - $type = $types['rss']; - } - } elseif (isset($options['type']) && isset($types[$options['type']])) { - $type = $types[$options['type']]; - unset($options['type']); - } else { - $type = array(); - } - } elseif ($url !== null) { - $inline = $url; - } - $options = array_merge($type, $options); - $out = null; - - if (isset($options['link'])) { - if (isset($options['rel']) && $options['rel'] === 'icon') { - $out = sprintf($this->_tags['metalink'], $options['link'], $this->_parseAttributes($options, array('block', 'link'), ' ', ' ')); - $options['rel'] = 'shortcut icon'; - } else { - $options['link'] = $this->url($options['link'], true); - } - $out .= sprintf($this->_tags['metalink'], $options['link'], $this->_parseAttributes($options, array('block', 'link'), ' ', ' ')); - } else { - $out = sprintf($this->_tags['meta'], $this->_parseAttributes($options, array('block', 'type'), ' ', ' ')); - } - - if (empty($options['block'])) { - return $out; - } else { - $this->_View->append($options['block'], $out); - } - } - -/** - * Returns a charset META-tag. - * - * @param string $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 http://book.cakephp.org/2.0/en/core-libraries/helpers/html.html#HtmlHelper::charset - */ - public function charset($charset = null) { - if (empty($charset)) { - $charset = strtolower(Configure::read('App.encoding')); - } - return sprintf($this->_tags['charset'], (!empty($charset) ? $charset : 'utf-8')); - } - -/** - * Creates an HTML link. - * - * If $url starts with "http://" this is treated as an external link. Else, - * it is treated as a path to controller/action and parsed with the - * HtmlHelper::url() method. - * - * If the $url is empty, $title is used instead. - * - * ### Options - * - * - `escape` Set to false to disable escaping of title and attributes. - * - `confirm` JavaScript confirmation message. - * - * @param string $title The content to be wrapped by tags. - * @param mixed $url Cake-relative URL or array of URL parameters, or external URL (starts with http://) - * @param array $options Array of HTML attributes. - * @param string $confirmMessage JavaScript confirmation message. - * @return string An `` element. - * @link http://book.cakephp.org/2.0/en/core-libraries/helpers/html.html#HtmlHelper::link - */ - public function link($title, $url = null, $options = array(), $confirmMessage = false) { - $escapeTitle = true; - if ($url !== null) { - $url = $this->url($url); - } else { - $url = $this->url($title); - $title = h(urldecode($url)); - $escapeTitle = false; - } - - if (isset($options['escape'])) { - $escapeTitle = $options['escape']; - } - - if ($escapeTitle === true) { - $title = h($title); - } elseif (is_string($escapeTitle)) { - $title = htmlentities($title, ENT_QUOTES, $escapeTitle); - } - - if (!empty($options['confirm'])) { - $confirmMessage = $options['confirm']; - unset($options['confirm']); - } - if ($confirmMessage) { - $confirmMessage = str_replace("'", "\'", $confirmMessage); - $confirmMessage = str_replace('"', '\"', $confirmMessage); - $options['onclick'] = "return confirm('{$confirmMessage}');"; - } elseif (isset($options['default']) && $options['default'] == false) { - if (isset($options['onclick'])) { - $options['onclick'] .= ' event.returnValue = false; return false;'; - } else { - $options['onclick'] = 'event.returnValue = false; return false;'; - } - unset($options['default']); - } - return sprintf($this->_tags['link'], $url, $this->_parseAttributes($options), $title); - } - -/** - * Creates a link element for CSS stylesheets. - * - * ### Usage - * - * Include one CSS file: - * - * `echo $this->Html->css('styles.css');` - * - * Include multiple CSS files: - * - * `echo $this->Html->css(array('one.css', 'two.css'));` - * - * Add the stylesheet to the `$scripts_for_layout` layout var: - * - * `$this->Html->css('styles.css', null, array('inline' => false));` - * - * Add the stylesheet to a custom block: - * - * `$this->Html->css('styles.css', null, array('block' => 'layoutCss'));` - * - * ### Options - * - * - `inline` If set to false, the generated tag will be appended to the 'css' block, - * and included in the `$scripts_for_layout` layout variable. Defaults to true. - * - `block` Set the name of the block link/style tag will be appended to. This overrides the `inline` - * option. - * - `plugin` False value will prevent parsing path as a plugin - * - * @param mixed $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 string $rel Rel attribute. Defaults to "stylesheet". If equal to 'import' the stylesheet will be imported. - * @param array $options Array of HTML attributes. - * @return string CSS or + diff --git a/src/Error/Debugger.php b/src/Error/Debugger.php new file mode 100644 index 00000000000..b834e1bd0e7 --- /dev/null +++ b/src/Error/Debugger.php @@ -0,0 +1,874 @@ + + */ + protected array $_defaultConfig = [ + 'outputMask' => [], + 'exportFormatter' => null, + 'editor' => 'phpstorm', + 'editorBasePath' => null, + ]; + + /** + * A map of editors to their link templates. + * + * @var array + */ + 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}', + ]; + + /** + * Holds current output data when outputFormat is false. + * + * @var array + */ + protected array $_data = []; + + /** + * Constructor. + */ + public function __construct() + { + $docRef = ini_get('docref_root'); + if (!$docRef && function_exists('ini_set')) { + ini_set('docref_root', 'https://secure.php.net/'); + } + if (!defined('E_RECOVERABLE_ERROR')) { + define('E_RECOVERABLE_ERROR', 4096); + } + + $config = array_intersect_key((array)Configure::read('Debugger'), $this->_defaultConfig); + $this->setConfig($config); + } + + /** + * Returns a reference to the Debugger singleton object instance. + * + * @param class-string<\Cake\Error\Debugger>|null $class Class name. + * @return static + */ + public static function getInstance(?string $class = null): static + { + /** @var array $instance */ + static $instance = []; + 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 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\CakeException When trying to set a key that is invalid. + */ + 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); + } + + return static::getInstance()->getConfig($key); + } + + /** + * Reads the current output masking. + * + * @return array + */ + public static function outputMask(): array + { + return static::configInstance('outputMask'); + } + + /** + * Sets configurable masking of debugger output by property name and array key names. + * + * ### Example + * + * Debugger::setOutputMask(['password' => '[*************]']); + * + * @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, 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 $maxDepth The depth to output to. Defaults to 3. + * @return void + * @see \Cake\Error\Debugger::exportVar() + * @link https://book.cakephp.org/5/en/development/debugging.html#outputting-values + */ + public static function dump(mixed $var, int $maxDepth = 3): void + { + 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. + * + * @param mixed $var Variable or content to log. + * @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(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 + { + 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; + } + + /** + * Outputs a stack trace based on the supplied 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', '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 array|string Formatted stack trace. + * @link https://book.cakephp.org/5/en/development/debugging.html#generating-stack-traces + */ + public static function trace(array $options = []): array|string + { + // Remove the frame for Debugger::trace() + $backtrace = debug_backtrace(); + array_shift($backtrace); + + return Debugger::formatTrace($backtrace, $options); + } + + /** + * Formats a stack trace based on the supplied options. + * + * ### Options + * + * - `depth` - The number of stack frames to return. Defaults to 999 + * - `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 \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(Throwable|array $backtrace, array $options = []): array|string + { + if ($backtrace instanceof Throwable) { + $backtrace = $backtrace->getTrace(); + } + + $defaults = [ + 'depth' => 999, + 'format' => 'text', + 'args' => false, + 'start' => 0, + 'scope' => null, + 'exclude' => ['call_user_func_array', 'trigger_error'], + 'shortPath' => false, + ]; + $options = Hash::merge($defaults, $options); + + $count = count($backtrace) + 1; + $back = []; + + for ($i = $options['start']; $i < $count && $i < $options['depth']; $i++) { + $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 .= implode(', ', $args); + } + $reference .= ')'; + } + if (in_array($signature, $options['exclude'], true)) { + continue; + } + + $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']); + } + $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 (in_array($options['format'], ['array', 'points', 'shortPoints'])) { + return $back; + } + + /** + * @phpstan-ignore-next-line + */ + return implode("\n", $back); + } + + /** + * Shortens file paths by replacing the application base path with 'APP', and the CakePHP core + * path with 'CORE'. + * + * @param string $path Path to shorten. + * @return string Normalized path + */ + public static function trimPath(string $path): string + { + if (defined('APP') && str_starts_with($path, APP)) { + return str_replace(APP, 'APP/', $path); + } + 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') && str_starts_with($path, ROOT)) { + return str_replace(ROOT, 'ROOT', $path); + } + + return $path; + } + + /** + * Grabs an excerpt from a file and highlights a given line of code. + * + * Usage: + * + * ``` + * Debugger::excerpt('/path/to/file', 100, 4); + * ``` + * + * The above would return an array of 8 items. The 4th item would be the provided line, + * 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 + * @see https://secure.php.net/highlight_string + * @link https://book.cakephp.org/5/en/development/debugging.html#getting-an-excerpt-from-a-file + */ + public static function excerpt(string $file, int $line, int $context = 2): array + { + $lines = []; + if (!file_exists($file)) { + return []; + } + $data = file_get_contents($file); + if (!$data) { + return $lines; + } + if (str_contains($data, "\n")) { + $data = explode("\n", $data); + } + $line--; + if (!isset($data[$line])) { + return $lines; + } + for ($i = $line - $context; $i < $line + $context + 1; $i++) { + if (!isset($data[$i])) { + continue; + } + $string = str_replace(["\r\n", "\n"], '', static::_highlight($data[$i])); + if ($i === $line) { + $lines[] = '' . $string . ''; + } else { + $lines[] = $string; + } + } + + return $lines; + } + + /** + * Wraps the highlight_string function in case the server API does not + * implement the function as it is the case of the HipHop interpreter + * + * @param string $str The string to convert. + * @return string + */ + protected static function _highlight(string $str): string + { + $added = false; + if (!str_contains($str, '', '<?php 
', '<?php '], + '', + $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. + * + * *Note:* The following keys will have their contents + * replaced with `*****`: + * + * - password + * - login + * - host + * - database + * - port + * - prefix + * - schema + * + * This is done to protect database credentials, which could be accidentally + * shown in an error message if CakePHP is deployed in development mode. + * + * @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(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, new DebugContext($maxDepth)); + } + + /** + * Protected export function used to keep track of indentation and recursion. + * + * @param mixed $var The variable to dump. + * @param \Cake\Error\Debug\DebugContext $context Dump context + * @return \Cake\Error\Debug\NodeInterface The dumped variable. + */ + protected static function export(mixed $var, DebugContext $context): NodeInterface + { + $type = static::getType($var); + + 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 + * + * - password + * - login + * - host + * - database + * - port + * - prefix + * - schema + * + * @param array $var The array to export. + * @param \Cake\Error\Debug\DebugContext $context The current dump context. + * @return \Cake\Error\Debug\ArrayNode Exported array. + */ + protected static function exportArray(array $var, DebugContext $context): ArrayNode + { + $items = []; + + $remaining = $context->remainingDepth(); + if ($remaining >= 0) { + $outputMask = static::outputMask(); + foreach ($var as $key => $val) { + if (array_key_exists($key, $outputMask)) { + $node = new ScalarNode('string', $outputMask[$key]); + } elseif ($val !== $var) { + // 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()); + } + $items[] = new ArrayItemNode(static::export($key, $context), $node); + } + } else { + $items[] = new ArrayItemNode( + new ScalarNode('string', ''), + new SpecialNode('[maximum depth reached]'), + ); + } + + return new ArrayNode($items); + } + + /** + * Handles object to node conversion. + * + * @param object $var Object to convert. + * @param \Cake\Error\Debug\DebugContext $context The dump context. + * @return \Cake\Error\Debug\NodeInterface + * @see \Cake\Error\Debugger::exportVar() + */ + protected static function exportObject(object $var, DebugContext $context): NodeInterface + { + $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()})"); + } + } + + $outputMask = static::outputMask(); + $objectVars = get_object_vars($var); + foreach ($objectVars as $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); + + $filters = [ + ReflectionProperty::IS_PROTECTED => 'protected', + ReflectionProperty::IS_PRIVATE => 'private', + ]; + foreach ($filters as $filter => $visibility) { + $reflectionProperties = $ref->getProperties($filter); + foreach ($reflectionProperties as $reflectionProperty) { + 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, + ), + ); + } + } + } + + return $node; + } + + /** + * Get the type of the given variable. Will return the class name + * for objects. + * + * @param mixed $var The variable to get the type of. + * @return string The type of variable. + */ + public static function getType(mixed $var): string + { + $type = get_debug_type($var); + + if ($type === 'double') { + return 'float'; + } + + if ($type === 'unknown type') { + return 'unknown'; + } + + return $type; + } + + /** + * Prints out debug information about given variable. + * + * @param mixed $var Variable to show debug information for. + * @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 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(mixed $var, array $location = [], ?bool $showHtml = null): void + { + $location += ['file' => null, 'line' => null]; + if ($location['file']) { + $location['file'] = static::trimPath((string)$location['file']); + } + + $debugger = static::getInstance(); + $restore = null; + if ($showHtml !== null) { + $restore = $debugger->getConfig('exportFormatter'); + $debugger->setConfig('exportFormatter', $showHtml ? HtmlFormatter::class : TextFormatter::class); + } + $contents = static::exportVar($var, 25); + $formatter = $debugger->getExportFormatter(); + + if ($restore) { + $debugger->setConfig('exportFormatter', $restore); + } + 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); + } + + /** + * Verifies that the application's salt and cipher seed value has been changed from the default value. + * + * @return void + */ + public static function checkSecurityKeys(): void + { + $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/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 @@ + + */ + use EventDispatcherTrait; + use InstanceConfigTrait; + + /** + * Configuration options. Generally these are defined in config/app.php + * + * - `errorLevel` - int - The level of errors you are interested in capturing. + * - `errorRenderer` - string - The class name of render errors with. Defaults + * to choosing between Html and Console based on the SAPI. + * - `log` - boolean - Whether you want errors logged. + * - `logger` - string - The class name of the error logger to use. + * - `trace` - boolean - Whether backtraces should be included in + * logged errors. + * + * @var array + */ + 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/ExceptionRendererInterface.php b/src/Error/ExceptionRendererInterface.php new file mode 100644 index 00000000000..2f7a248952b --- /dev/null +++ b/src/Error/ExceptionRendererInterface.php @@ -0,0 +1,42 @@ + + */ + use EventDispatcherTrait; + use InstanceConfigTrait; + + /** + * Configuration options. Generally these will be defined in your config/app.php + * + * - `exceptionRenderer` - string - The class responsible for rendering uncaught exceptions. + * The chosen class will be used for both CLI and web environments. If you want different + * classes used in CLI and web environments you'll need to write that conditional logic as well. + * The conventional location for custom renderers is in `src/Error`. Your exception renderer needs to + * implement the `render()` method and return either a string or Http\Response. + * - `log` Set to false to disable logging. + * - `logger` - string - The class name of the error logger to use. + * - `trace` - boolean - Whether backtraces should be included in + * logged exceptions. + * - `skipLog` - array - List of exceptions to skip for logging. Exceptions that + * extend one of the listed exceptions will also not be logged. E.g.: + * ``` + * 'skipLog' => ['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); + } + $error = error_get_last(); + if (!is_array($error)) { + return; + } + $fatals = [ + E_USER_ERROR, + E_ERROR, + E_PARSE, + E_COMPILE_ERROR, + ]; + if (!in_array($error['type'], $fatals, true)) { + return; + } + $this->handleFatalError( + $error['type'], + $error['message'], + $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 new file mode 100644 index 00000000000..fa4c754b6e7 --- /dev/null +++ b/src/Error/FatalErrorException.php @@ -0,0 +1,49 @@ +file = $file; + } + if ($line) { + $this->line = $line; + } + } +} diff --git a/src/Error/Middleware/ErrorHandlerMiddleware.php b/src/Error/Middleware/ErrorHandlerMiddleware.php new file mode 100644 index 00000000000..a0f0046e76f --- /dev/null +++ b/src/Error/Middleware/ErrorHandlerMiddleware.php @@ -0,0 +1,235 @@ + + */ + use EventDispatcherTrait; + + /** + * Default configuration values. + * + * Ignored if constructor is passed an ExceptionTrap instance. + * + * 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 + * @see \Cake\Error\ExceptionTrap + */ + protected array $_defaultConfig = [ + 'exceptionRenderer' => WebExceptionRenderer::class, + ]; + + /** + * ExceptionTrap instance + * + * @var \Cake\Error\ExceptionTrap|null + */ + protected ?ExceptionTrap $exceptionTrap = null; + + /** + * @var \Cake\Routing\RoutingApplicationInterface|null + */ + protected ?RoutingApplicationInterface $app = null; + + /** + * Constructor + * + * @param \Cake\Error\ExceptionTrap|array $config The error handler instance + * or config array. + * @param \Cake\Routing\RoutingApplicationInterface|null $app Application instance. + */ + public function __construct(ExceptionTrap|array $config = [], ?RoutingApplicationInterface $app = null) + { + $this->app = $app; + + if (Configure::read('debug')) { + ini_set('zend.exception_ignore_args', '0'); + } + + if (is_array($config)) { + $this->setConfig($config); + + return; + } + + $this->exceptionTrap = $config; + } + + /** + * Wrap the remaining middleware with error handling. + * + * @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 + { + try { + return $handler->handle($request); + } catch (RedirectException $exception) { + return $this->handleRedirect($exception); + } catch (Throwable $exception) { + return $this->handleException($exception, Router::getRequest() ?? $request); + } + } + + /** + * Handle an exception and generate an error response + * + * @param \Throwable $exception The exception to handle. + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @return \Psr\Http\Message\ResponseInterface A response. + */ + public function handleException(Throwable $exception, ServerRequestInterface $request): ResponseInterface + { + $this->loadRoutes(); + + $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); + } + + 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(); + } + } + + /** + * Convert a redirect exception into a response. + * + * @param \Cake\Http\Exception\RedirectException $exception The exception to handle + * @return \Psr\Http\Message\ResponseInterface Response created from the redirect. + */ + public function handleRedirect(RedirectException $exception): ResponseInterface + { + return new RedirectResponse( + $exception->getMessage(), + $exception->getCode(), + $exception->getHeaders(), + ); + } + + /** + * Handle internal errors. + * + * @return \Psr\Http\Message\ResponseInterface A response + */ + protected function handleInternalError(): ResponseInterface + { + return new Response([ + 'body' => 'An Internal Server Error Occurred', + 'status' => 500, + ]); + } + + /** + * Get a exception trap instance + * + * @return \Cake\Error\ExceptionTrap The exception trap. + */ + protected function getExceptionTrap(): ExceptionTrap + { + if ($this->exceptionTrap === null) { + /** @var class-string<\Cake\Error\ExceptionTrap> $className */ + $className = App::className('ExceptionTrap', 'Error'); + $this->exceptionTrap = new $className($this->getConfig()); + } + + return $this->exceptionTrap; + } + + /** + * Ensure that the application's routes are loaded. + * + * @return void + */ + protected function loadRoutes(): void + { + if ( + !($this->app instanceof RoutingApplicationInterface) + || Router::routes() + ) { + return; + } + + try { + $builder = Router::createRouteBuilder('/'); + + $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(), + )); + } + } +} 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 new file mode 100644 index 00000000000..f34d7a9731e --- /dev/null +++ b/src/Event/Decorator/AbstractDecorator.php @@ -0,0 +1,74 @@ + $options Decorator options. + */ + public function __construct(callable $callable, array $options = []) + { + $this->_callable = $callable; + $this->_options = $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(mixed ...$args): mixed + { + return $this->_call($args); + } + + /** + * Calls the decorated callable with the passed arguments. + * + * @param array $args Arguments for the callable. + * @return mixed + */ + protected function _call(array $args): mixed + { + $callable = $this->_callable; + + return $callable(...$args); + } +} diff --git a/src/Event/Decorator/ConditionDecorator.php b/src/Event/Decorator/ConditionDecorator.php new file mode 100644 index 00000000000..297ca18099f --- /dev/null +++ b/src/Event/Decorator/ConditionDecorator.php @@ -0,0 +1,76 @@ +canTrigger($args[0])) { + return null; + } + + return $this->_call($args); + } + + /** + * Checks if the event is triggered for this listener. + * + * @template TSubject of object + * @param \Cake\Event\EventInterface $event Event object. + * @return bool + */ + public function canTrigger(EventInterface $event): bool + { + $if = $this->_evaluateCondition('if', $event); + $unless = $this->_evaluateCondition('unless', $event); + + return $if && !$unless; + } + + /** + * Evaluates the filter conditions + * + * @template TSubject of object + * @param string $condition Condition type + * @param \Cake\Event\EventInterface $event Event object + * @return bool + */ + protected function _evaluateCondition(string $condition, EventInterface $event): bool + { + if (!isset($this->_options[$condition])) { + return $condition !== 'unless'; + } + if (!is_callable($this->_options[$condition])) { + throw new InvalidArgumentException(self::class . ' the `' . $condition . '` condition is not a callable!'); + } + + return (bool)$this->_options[$condition]($event); + } +} diff --git a/src/Event/Decorator/SubjectFilterDecorator.php b/src/Event/Decorator/SubjectFilterDecorator.php new file mode 100644 index 00000000000..f380d9469eb --- /dev/null +++ b/src/Event/Decorator/SubjectFilterDecorator.php @@ -0,0 +1,69 @@ +canTrigger($args[0])) { + return null; + } + + return $this->_call($args); + } + + /** + * Checks if the event is triggered for this listener. + * + * @template TSubject of object + * @param \Cake\Event\EventInterface $event Event object. + * @return bool + */ + public function canTrigger(EventInterface $event): bool + { + if (!isset($this->_options['allowedSubject'])) { + throw new CakeException(self::class . ' Missing subject filter options!'); + } + if (is_string($this->_options['allowedSubject'])) { + $this->_options['allowedSubject'] = [$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 new file mode 100644 index 00000000000..cc1163d458f --- /dev/null +++ b/src/Event/Event.php @@ -0,0 +1,187 @@ + + */ +class Event implements EventInterface +{ + /** + * Name of the event + * + * @var string + */ + protected string $_name; + + /** + * The object this event applies to (usually the same object that generates the event) + * + * @var TSubject|null + */ + protected ?object $_subject = null; + + /** + * Custom data for the method that receives the event + * + * @var array + */ + 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 + */ + protected mixed $result = null; + + /** + * Flags an event as stopped or not, default is false + * + * @var bool + */ + protected bool $_stopped = false; + + /** + * Constructor + * + * ### Examples of usage: + * + * ``` + * $event = new Event('Order.afterBuy', $this, ['buyer' => $userData]); + * $event = new Event('User.afterRegister', $userModel); + * ``` + * + * @param string $name Name of the event + * @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. + * @phpstan-param TSubject|null $subject + */ + public function __construct(string $name, ?object $subject = null, array $data = []) + { + $this->_name = $name; + $this->_subject = $subject; + $this->_data = $data; + } + + /** + * Returns the name of this event. This is usually used as the event identifier + * + * @return string + */ + public function getName(): string + { + return $this->_name; + } + + /** + * Returns the subject of this event + * + * If the event has no subject an exception will be raised. + * + * @return TSubject + * @throws \Cake\Core\Exception\CakeException + */ + public function getSubject(): object + { + if ($this->_subject === null) { + throw new CakeException('No subject set for this event'); + } + + return $this->_subject; + } + + /** + * Stops the event from being used anymore + * + * @return void + */ + public function stopPropagation(): void + { + $this->_stopped = true; + } + + /** + * Check if the event is stopped + * + * @return bool True if the event is stopped + */ + public function isStopped(): bool + { + return $this->_stopped; + } + + /** + * The result value of the event listeners + * + * @return mixed + */ + public function getResult(): mixed + { + return $this->result; + } + + /** + * 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(mixed $value = null) + { + $this->result = $value; + + return $this; + } + + /** + * @inheritDoc + */ + public function getData(?string $key = null): mixed + { + if ($key !== null) { + return $this->_data[$key] ?? null; + } + + return $this->_data; + } + + /** + * @inheritDoc + */ + public function setData(array|string $key, $value = null) + { + if (is_array($key)) { + $this->_data = $key; + } else { + $this->_data[$key] = $value; + } + + return $this; + } +} diff --git a/src/Event/EventDispatcherInterface.php b/src/Event/EventDispatcherInterface.php new file mode 100644 index 00000000000..479260fd01e --- /dev/null +++ b/src/Event/EventDispatcherInterface.php @@ -0,0 +1,63 @@ + + */ + public function dispatchEvent(string $name, array $data = [], ?object $subject = null): EventInterface; + + /** + * 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. + * + * @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 getEventManager(): EventManagerInterface; +} diff --git a/src/Event/EventDispatcherTrait.php b/src/Event/EventDispatcherTrait.php new file mode 100644 index 00000000000..0856b75eddb --- /dev/null +++ b/src/Event/EventDispatcherTrait.php @@ -0,0 +1,96 @@ +_eventManager ??= new EventManager(); + } + + /** + * 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\EventManagerInterface $eventManager the eventManager to set + * @return $this + */ + public function setEventManager(EventManagerInterface $eventManager) + { + $this->_eventManager = $eventManager; + + return $this; + } + + /** + * Wrapper for creating and dispatching events. + * + * Returns a dispatched event. + * + * @param string $name Name of the event. + * @param array $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 + * ($this by default). + * @return \Cake\Event\EventInterface + * @phpstan-ignore missingType.generics + */ + public function dispatchEvent(string $name, array $data = [], ?object $subject = null): EventInterface // @phpstan-ignore missingType.generics + { + $subject ??= $this; + + /** + * @var \Cake\Event\EventInterface $event Coerce for psalm/phpstan + * @phpstan-ignore missingType.generics (TSubject may itself be generic) + */ + $event = new $this->_eventClass($name, $subject, $data); + $this->getEventManager()->dispatch($event); + + return $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, IteratorAggregate +{ + /** + * Events list + * + * @var array<\Cake\Event\EventInterface> + */ + protected array $_events = []; + + /** + * Empties the list of dispatched events. + * + * @return void + */ + public function flush(): void + { + $this->_events = []; + } + + /** + * Adds an event to the list when event listing is enabled. + * + * @param \Cake\Event\EventInterface $event An event to the list of dispatched events. + * @return void + */ + public function add(EventInterface $event): void + { + $this->_events[] = $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(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 \Cake\Event\EventInterface|null + */ + public function offsetGet(mixed $offset): ?EventInterface + { + deprecationWarning( + '5.3.0', + 'Array access for `EventList` is deprecated, you can iterate the instance instead.', + ); + + 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(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(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(): int + { + return count($this->_events); + } + + /** + * Checks if an event is in the list. + * + * @param string $name Event name. + * @return bool + */ + public function hasEvent(string $name): bool + { + foreach ($this->_events as $event) { + if ($event->getName() === $name) { + return true; + } + } + + return false; + } +} diff --git a/src/Event/EventListenerInterface.php b/src/Event/EventListenerInterface.php new file mode 100644 index 00000000000..8439f6f366b --- /dev/null +++ b/src/Event/EventListenerInterface.php @@ -0,0 +1,46 @@ + 'sendEmail', + * 'Article.afterBuy' => 'decrementInventory', + * 'User.onRegister' => ['callable' => 'logRegistration', 'priority' => 20, 'passParams' => true] + * ]; + * } + * ``` + * + * @return array 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(): array; +} diff --git a/src/Event/EventManager.php b/src/Event/EventManager.php new file mode 100644 index 00000000000..1c2c652cd02 --- /dev/null +++ b/src/Event/EventManager.php @@ -0,0 +1,531 @@ +|null + */ + protected ?EventList $_eventList = null; + + /** + * Enables automatic adding of events to the event list object if it is present. + * + * @var bool + */ + protected bool $_trackEvents = false; + + /** + * Returns the globally available instance of a Cake\Event\EventManager + * this is used for dispatching events attached from outside the scope + * other managers were created. Usually for creating hook systems or inter-class + * communication + * + * 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 \Cake\Event\EventManager The global event manager + */ + public static function instance(?EventManager $manager = null): EventManager + { + if ($manager === null && static::$_generalManager) { + return static::$_generalManager; + } + + if ($manager instanceof EventManager) { + static::$_generalManager = $manager; + } + static::$_generalManager ??= new static(); + static::$_generalManager->_isGlobal = true; + + return static::$_generalManager; + } + + /** + * @inheritDoc + */ + public function on( + EventListenerInterface|string $eventKey, + callable|array $options = [], + ?callable $callable = null, + ) { + if ($eventKey instanceof EventListenerInterface) { + $this->_attachSubscriber($eventKey); + + 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 ($callable === null) { + /** @var callable $options */ + $this->_listeners[$eventKey][static::$defaultPriority][] = [ + 'callable' => $options(...), + ]; + + return $this; + } + + /** @var array $options */ + $priority = $options['priority'] ?? static::$defaultPriority; + $this->_listeners[$eventKey][$priority][] = [ + 'callable' => $callable(...), + ]; + + return $this; + } + + /** + * Auxiliary function to attach all implemented callbacks of a Cake\Event\EventListenerInterface class instance + * as individual methods on this manager + * + * @param \Cake\Event\EventListenerInterface $subscriber Event listener. + * @return void + */ + protected function _attachSubscriber(EventListenerInterface $subscriber): void + { + foreach ($subscriber->implementedEvents() as $eventKey => $handlers) { + foreach ($this->normalizeHandlers($subscriber, $handlers) as $handler) { + $this->on($eventKey, $handler['settings'], $handler['callable']); + } + } + } + + /** + * @inheritDoc + */ + public function off( + EventListenerInterface|callable|string $eventKey, + EventListenerInterface|callable|null $callable = null, + ) { + if ($eventKey instanceof EventListenerInterface) { + $this->_detachSubscriber($eventKey); + + return $this; + } + + 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) { + 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) { + unset($this->_listeners[$eventKey][$priority][$k]); + break; + } + } + } + + return $this; + } + + /** + * Auxiliary function to help detach all listeners provided by an object implementing EventListenerInterface + * + * @param \Cake\Event\EventListenerInterface $subscriber the subscriber to be detached + * @param string|null $eventKey optional event key name to unsubscribe the listener from + * @return void + */ + protected function _detachSubscriber(EventListenerInterface $subscriber, ?string $eventKey = null): void + { + $events = $subscriber->implementedEvents(); + if ($eventKey && empty($events[$eventKey])) { + return; + } + if ($eventKey) { + $events = [$eventKey => $events[$eventKey]]; + } + foreach ($events as $key => $handlers) { + foreach ($this->normalizeHandlers($subscriber, $handlers) as $handler) { + $this->off($key, $handler['callable']); + } + } + } + + /** + * 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 \Cake\Event\EventListenerInterface $subscriber Event subscriber + * @param callable|array|string $handlers Event handlers + * @return array + */ + 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); + } + + $listeners = $this->listeners($event->getName()); + + if ($this->_trackEvents) { + $this->addEventToList($event); + } + + if (!$this->_isGlobal && static::instance()->isTrackingEvents()) { + static::instance()->addEventToList($event); + } + + if (!$listeners) { + return $event; + } + + foreach ($listeners as $listener) { + if ($event->isStopped()) { + break; + } + + $this->_callListener($listener['callable'], $event); + } + + return $event; + } + + /** + * Calls a listener. + * + * @template TSubject of object + * @param callable $listener The listener to trigger. + * @param \Cake\Event\EventInterface $event Event instance. + * @return void + */ + protected function _callListener(callable $listener, EventInterface $event): void + { + $result = $listener($event, ...array_values($event->getData())); + + 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(); + } + } + + /** + * @inheritDoc + */ + public function listeners(string $eventKey): array + { + $localListeners = []; + if (!$this->_isGlobal) { + $localListeners = $this->prioritisedListeners($eventKey); + } + $globalListeners = static::instance()->prioritisedListeners($eventKey); + + $priorities = array_merge(array_keys($globalListeners), array_keys($localListeners)); + $priorities = array_unique($priorities); + asort($priorities); + + $result = []; + foreach ($priorities as $priority) { + if (isset($globalListeners[$priority])) { + $result = array_merge($result, $globalListeners[$priority]); + } + if (isset($localListeners[$priority])) { + $result = array_merge($result, $localListeners[$priority]); + } + } + + return $result; + } + + /** + * Returns the listeners for the specified event key indexed by priority + * + * @param string $eventKey Event key. + * @return array + */ + public function prioritisedListeners(string $eventKey): array + { + if (empty($this->_listeners[$eventKey])) { + return []; + } + + return $this->_listeners[$eventKey]; + } + + /** + * Returns the listeners matching a specified pattern + * + * @param string $eventKeyPattern Pattern to match. + * @return array + */ + public function matchingListeners(string $eventKeyPattern): array + { + $matchPattern = '/' . preg_quote($eventKeyPattern, '/') . '/'; + + return array_intersect_key( + $this->_listeners, + array_flip( + preg_grep($matchPattern, array_keys($this->_listeners), 0) ?: [], + ), + ); + } + + /** + * Returns the event list. + * + * @return \Cake\Event\EventList|null + */ + public function getEventList(): ?EventList + { + return $this->_eventList; + } + + /** + * Adds an event to the list if the event list object is present. + * + * @template TSubject of object + * @param \Cake\Event\EventInterface $event An event to add to the list. + * @return $this + */ + public function addEventToList(EventInterface $event) + { + $this->_eventList?->add($event); + + return $this; + } + + /** + * Enables / disables event tracking at runtime. + * + * @param bool $enabled True or false to enable / disable it. + * @return $this + */ + public function trackEvents(bool $enabled) + { + $this->_trackEvents = $enabled; + + return $this; + } + + /** + * Returns whether this manager is set up to track events + * + * @return bool + */ + public function isTrackingEvents(): bool + { + return $this->_trackEvents && $this->_eventList; + } + + /** + * Enables the listing of dispatched events. + * + * @param \Cake\Event\EventList $eventList The event list object to use. + * @return $this + */ + public function setEventList(EventList $eventList) + { + $this->_eventList = $eventList; + $this->_trackEvents = true; + + return $this; + } + + /** + * Disables the listing of dispatched events. + * + * @return $this + */ + public function unsetEventList() + { + $this->_eventList = null; + $this->_trackEvents = false; + + return $this; + } + + /** + * Debug friendly object properties. + * + * @return array + */ + public function __debugInfo(): array + { + $properties = get_object_vars($this); + $properties['_generalManager'] = '(object) EventManager'; + $properties['_listeners'] = []; + foreach ($this->_listeners as $key => $priorities) { + $listenerCount = 0; + foreach ($priorities as $listeners) { + $listenerCount += count($listeners); + } + $properties['_listeners'][$key] = $listenerCount . ' listener(s)'; + } + 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; + } + unset($properties['_eventList']); + + return $properties; + } +} 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/LICENSE.txt b/src/Event/LICENSE.txt new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/Event/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/Event/README.md b/src/Event/README.md new file mode 100644 index 00000000000..7339afd4f11 --- /dev/null +++ b/src/Event/README.md @@ -0,0 +1,51 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/event.svg?style=flat-square)](https://packagist.org/packages/cakephp/event) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Event Library + +This library emulates several aspects of how events are triggered and managed in popular JavaScript +libraries such as jQuery: An event object is dispatched to all listeners. The event object holds information +about the event, and provides the ability to stop event propagation at any point. +Listeners can register themselves or can delegate this task to other objects and have the chance to alter the +state and the event itself for the rest of the callbacks. + +## Usage + +Listeners need to be registered into a manager and events can then be triggered so that listeners can be informed +of the action. + +```php +use Cake\Event\Event; +use Cake\Event\EventDispatcherTrait; + +class Orders +{ + + use EventDispatcherTrait; + + public function placeOrder($order) + { + $this->doStuff(); + $event = new Event('Orders.afterPlace', $this, [ + 'order' => $order + ]); + $this->getEventManager()->dispatch($event); + } +} + +$orders = new Orders(); +$orders->getEventManager()->on(function ($event) { + // Do something after the order was placed + ... +}, 'Orders.afterPlace'); + +$orders->placeOrder($order); +``` + +The above code allows you to easily notify the other parts of the application that an order has been created. +You can then do tasks like send email notifications, update stock, log relevant statistics and other tasks +in separate objects that focus on those concerns. + +## Documentation + +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/core-libraries/events.html) diff --git a/src/Event/composer.json b/src/Event/composer.json new file mode 100644 index 00000000000..942616483c2 --- /dev/null +++ b/src/Event/composer.json @@ -0,0 +1,41 @@ +{ + "name": "cakephp/event", + "description": "CakePHP event dispatcher library that helps implementing the observer pattern", + "type": "library", + "keywords": [ + "cakephp", + "event", + "dispatcher", + "observer pattern" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/event/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/event" + }, + "require": { + "php": ">=8.2", + "cakephp/core": "^5.3.0" + }, + "autoload": { + "psr-4": { + "Cake\\Event\\": "." + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.4.x-dev" + } + } +} diff --git a/src/Form/Form.php b/src/Form/Form.php new file mode 100644 index 00000000000..e5270cc495e --- /dev/null +++ b/src/Form/Form.php @@ -0,0 +1,406 @@ + + */ +class Form implements EventListenerInterface, EventDispatcherInterface, ValidatorAwareInterface +{ + /** + * @use \Cake\Event\EventDispatcherTrait<\Cake\Form\Form> + */ + 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 string + * @phpstan-var class-string<\Cake\Form\Schema> + */ + protected string $_schemaClass = Schema::class; + + /** + * The schema used by this form. + * + * @var \Cake\Form\Schema|null + */ + protected ?Schema $_schema = null; + + /** + * The errors if any + * + * @var array + */ + protected array $_errors = []; + + /** + * Form's data. + * + * @var array + */ + protected array $_data = []; + + /** + * Constructor + * + * @param \Cake\Event\EventManager|null $eventManager The event manager. + * Defaults to a new instance. + */ + public function __construct(?EventManager $eventManager = null) + { + if ($eventManager !== null) { + $this->setEventManager($eventManager); + } + + $this->getEventManager()->on($this); + } + + /** + * Get the Form callbacks this form is interested in. + * + * The conventional method map is: + * + * - Form.buildValidator => buildValidator + * + * @return array + */ + public function implementedEvents(): array + { + 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 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. + * + * @since 4.1.0 + * @return \Cake\Form\Schema the schema instance. + */ + public function getSchema(): Schema + { + $this->_schema ??= $this->_buildSchema(new $this->_schemaClass()); + + return $this->_schema; + } + + /** + * A hook method intended to be implemented by subclasses. + * + * 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\Form\Schema $schema The schema to customize. + * @return \Cake\Form\Schema The schema to use. + */ + protected function _buildSchema(Schema $schema): Schema + { + return $schema; + } + + /** + * Used to check if $data passes this form's validation. + * + * @param array $data The data to check. + * @param string|null $validator Validator name. + * @return bool Whether the data is valid. + * @throws \RuntimeException If validator is invalid. + */ + public function validate(array $data, ?string $validator = null): bool + { + $this->_errors = $this->getValidator($validator ?: static::DEFAULT_VALIDATOR) + ->validate($data); + + return $this->_errors === []; + } + + /** + * Get the errors in the form + * + * Will return the errors from the last call + * to `validate()` or `execute()`. + * + * @return array Last set validation 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. + * + * ``` + * $errors = [ + * 'field_name' => ['rule_name' => 'message'] + * ]; + * + * $form->setErrors($errors); + * ``` + * + * @param array $errors Errors list. + * @return $this + */ + public function setErrors(array $errors) + { + $this->_errors = $errors; + + return $this; + } + + /** + * Execute the form if it is valid. + * + * 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 `process()` method. + */ + public function execute(array $data, array $options = []): bool + { + // 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); + } + + $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); + } + + /** + * Hook method to be implemented in subclasses. + * + * Used by `execute()` to execute the form's action. + * + * @param array $data Form data. + * @return bool + */ + 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 + */ + public function __debugInfo(): array + { + $special = [ + '_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..b303a3629b9 --- /dev/null +++ b/src/Form/FormProtector.php @@ -0,0 +1,596 @@ + + */ + 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 Contains 'fields' and 'unlockedFields' keys. Additional keys allowed. + * @phpstan-return array{fields: array, unlockedFields: array, ...} + */ + 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 The token data. Contains 'fields', 'unlocked', and 'debug' keys. Additional keys allowed. + * @phpstan-return array{fields: string, unlocked: string, debug: string, ...} + */ + 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 new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/Form/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/Form/README.md b/src/Form/README.md new file mode 100644 index 00000000000..618c43a10b1 --- /dev/null +++ b/src/Form/README.md @@ -0,0 +1,63 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/form.svg?style=flat-square)](https://packagist.org/packages/cakephp/form) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Form Library + +Form abstraction used to create forms not tied to ORM backed models, +or to other permanent datastores. Ideal for implementing forms on top of +API services, or contact forms. + +## Usage + + +```php +use Cake\Form\Form; +use Cake\Form\Schema; +use Cake\Validation\Validator; + +class ContactForm extends Form +{ + + protected function _buildSchema(Schema $schema) + { + return $schema->addField('name', 'string') + ->addField('email', ['type' => 'string']) + ->addField('body', ['type' => 'text']); + } + + public function validationDefault(Validator $validator) + { + return $validator->add('name', 'length', [ + 'rule' => ['minLength', 10], + 'message' => 'A name is required' + ])->add('email', 'format', [ + 'rule' => 'email', + 'message' => 'A valid email address is required', + ]); + } + + protected function _execute(array $data) + { + // Send an email. + return true; + } +} +``` + +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. +- `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. + +```php +$contact = new ContactForm(); +$success = $contact->execute($data); +$errors = $contact->getErrors(); +``` + +## Documentation + +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 new file mode 100644 index 00000000000..942e3aae359 --- /dev/null +++ b/src/Form/Schema.php @@ -0,0 +1,139 @@ +> + */ + protected array $_fields = []; + + /** + * The default values for fields. + * + * @var array + */ + protected array $_fieldDefaults = [ + 'type' => null, + 'length' => null, + 'precision' => null, + 'default' => null, + ]; + + /** + * Add multiple fields to the schema. + * + * @param array|string> $fields The fields to add. + * @return $this + */ + public function addFields(array $fields) + { + foreach ($fields as $name => $attrs) { + $this->addField($name, $attrs); + } + + return $this; + } + + /** + * Adds a field to the schema. + * + * @param string $name The field name. + * @param array|string $attrs The attributes for the field, or the type + * as a string. + * @return $this + */ + public function addField(string $name, array|string $attrs) + { + if (is_string($attrs)) { + $attrs = ['type' => $attrs]; + } + $attrs = array_intersect_key($attrs, $this->_fieldDefaults); + $this->_fields[$name] = $attrs + $this->_fieldDefaults; + + return $this; + } + + /** + * Removes a field from the schema. + * + * @param string $name The field to remove. + * @return $this + */ + public function removeField(string $name) + { + unset($this->_fields[$name]); + + return $this; + } + + /** + * Get the list of fields in the schema. + * + * @return array The list of field names. + */ + public function fields(): array + { + return array_keys($this->_fields); + } + + /** + * Get the attributes for a given field. + * + * @param string $name The field name. + * @return array|null The attributes for a field, or null. + */ + public function field(string $name): ?array + { + return $this->_fields[$name] ?? null; + } + + /** + * Get the type of the named field. + * + * @param string $name The name of the field. + * @return string|null Either the field type or null if the + * field does not exist. + */ + public function fieldType(string $name): ?string + { + $field = $this->field($name); + if (!$field) { + return null; + } + + return $field['type']; + } + + /** + * Get the printable version of this object + * + * @return array + */ + public function __debugInfo(): array + { + return [ + '_fields' => $this->_fields, + ]; + } +} diff --git a/src/Form/composer.json b/src/Form/composer.json new file mode 100644 index 00000000000..fecc6fb0ddf --- /dev/null +++ b/src/Form/composer.json @@ -0,0 +1,40 @@ +{ + "name": "cakephp/form", + "description": "CakePHP Form library", + "type": "library", + "keywords": [ + "cakephp", + "form" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/form/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/form" + }, + "require": { + "php": ">=8.2", + "cakephp/event": "^5.3.0", + "cakephp/validation":"^5.3.0" + }, + "autoload": { + "psr-4": { + "Cake\\Form\\": "." + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.4.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/BaseApplication.php b/src/Http/BaseApplication.php new file mode 100644 index 00000000000..0a073bb0407 --- /dev/null +++ b/src/Http/BaseApplication.php @@ -0,0 +1,364 @@ + + * @implements \Cake\Core\PluginApplicationInterface + */ +abstract class BaseApplication implements + ConsoleApplicationInterface, + ContainerApplicationInterface, + EventAwareApplicationInterface, + EventDispatcherInterface, + HttpApplicationInterface, + PluginApplicationInterface, + RoutingApplicationInterface +{ + /** + * @use \Cake\Event\EventDispatcherTrait + */ + use EventDispatcherTrait; + + /** + * @var string Contains the path of the config directory + */ + protected string $configDir; + + /** + * Plugin Collection + * + * @var \Cake\Core\PluginCollection + */ + protected PluginCollection $plugins; + + /** + * Controller factory + * + * @var \Cake\Http\ControllerFactoryInterface<\Cake\Controller\Controller>|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( + 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 $middlewareQueue The middleware queue to set in your App Class + * @return \Cake\Http\MiddlewareQueue + */ + abstract public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue; + + /** + * @inheritDoc + */ + public function pluginMiddleware(MiddlewareQueue $middleware): MiddlewareQueue + { + 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); + } + } + + /** + * @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. + * + * @param \Cake\Routing\RouteBuilder $routes A route builder to add routes into. + * @return void + */ + 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 + { + 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 + * 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(CommandCollection $commands): CommandCollection + { + return $commands->addMany($commands->autoDiscover()); + } + + /** + * @inheritDoc + */ + public function pluginConsole(CommandCollection $commands): CommandCollection + { + foreach ($this->plugins->with('console') as $plugin) { + $commands = $plugin->console($commands); + } + + return $commands; + } + + /** + * @param \Cake\Event\EventManagerInterface $eventManager The global event manager to register listeners on + * @return \Cake\Event\EventManagerInterface + */ + public function pluginEvents(EventManagerInterface $eventManager): EventManagerInterface + { + foreach ($this->plugins->with('events') as $plugin) { + $eventManager = $plugin->events($eventManager); + } + + return $eventManager; + } + + /** + * Get the dependency injection container for the application. + * + * The first time the container is fetched it will be constructed + * and stored for future calls. + * + * @return \Cake\Core\ContainerInterface + */ + public function getContainer(): ContainerInterface + { + return $this->container ??= $this->buildContainer(); + } + + /** + * 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\Core\ContainerInterface + */ + protected function buildContainer(): ContainerInterface + { + $container = new Container(); + $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); + + $eventManager = $this->events($this->getEventManager()); + $this->setEventManager($this->pluginEvents($eventManager)); + + $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 new file mode 100644 index 00000000000..8289c19df4f --- /dev/null +++ b/src/Http/CallbackStream.php @@ -0,0 +1,52 @@ +detach(); + $result = ''; + if ($callback !== null) { + $result = $callback(); + } + if (!is_string($result)) { + return ''; + } + + return $result; + } +} diff --git a/src/Http/Client.php b/src/Http/Client.php new file mode 100644 index 00000000000..43ad1d454a2 --- /dev/null +++ b/src/Http/Client.php @@ -0,0 +1,795 @@ +get('/users', [], ['type' => 'json']); + * ``` + * + * The `type` option sets both the `Content-Type` and `Accept` header, to + * the same mime type. When using `type` you can use either a full mime + * type or an alias. If you need different types in the Accept and Content-Type + * headers you should set them manually and not use `type` + * + * ### Using authentication + * + * By using the `auth` key you can use authentication. The type sub option + * can be used to specify which authentication strategy you want to use. + * CakePHP comes with a few built-in strategies: + * + * - Basic + * - Digest + * - Oauth + * + * ### Using proxies + * + * By using the `proxy` key you can set authentication credentials for + * a proxy if you need to use one. The type sub option can be used to + * specify which authentication strategy you want to use. + * CakePHP comes with built-in support for basic authentication. + * + * @implements \Cake\Event\EventDispatcherInterface<\Cake\Http\Client> + */ +class Client implements EventDispatcherInterface, ClientInterface +{ + /** + * @use \Cake\Event\EventDispatcherTrait<\Cake\Http\Client> + */ + use EventDispatcherTrait; + use InstanceConfigTrait; + + /** + * Default configuration for the client. + * + * @var array + */ + 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', + ]; + + /** + * List of cookies from responses made with this client. + * + * Cookies are indexed by the cookie's domain or + * request host name. + * + * @var \Cake\Http\Cookie\CookieCollection + */ + protected CookieCollection $_cookies; + + /** + * Mock adapter for stubbing requests in tests. + * + * @var \Cake\Http\Client\Adapter\Mock|null + */ + protected static ?MockAdapter $_mockAdapter = null; + + /** + * Adapter for sending requests. + * + * @var \Cake\Http\Client\AdapterInterface + */ + protected AdapterInterface $_adapter; + + /** + * Create a new HTTP Client. + * + * ### Config options + * + * You can set the following options when creating a 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 SSL certificates should be validated. + * Defaults to true. + * - 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. + * - 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(array $config = []) + { + $this->_eventClass = ClientEvent::class; + $this->setConfig($config); + + $adapter = $this->_config['adapter']; + 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->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\Cookie\CookieCollection + */ + public function cookies(): CookieCollection + { + return $this->_cookies; + } + + /** + * Adds a cookie to the Client collection. + * + * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object. + * @return $this + * @throws \InvalidArgumentException + */ + public function addCookie(CookieInterface $cookie) + { + if (!$cookie->getDomain() || !$cookie->getPath()) { + throw new InvalidArgumentException('Cookie must have a domain and a path set.'); + } + $this->_cookies = $this->_cookies->add($cookie); + + return $this; + } + + /** + * Do a GET request. + * + * The $data argument supports a special `_content` key + * for providing a request body in a GET request. This is + * generally not used, but services like ElasticSearch use + * this feature. + * + * @param string $url The url or path you want to 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(string $url, array|string $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $body = null; + if (is_array($data) && isset($data['_content'])) { + $body = $data['_content']; + unset($data['_content']); + } + $url = $this->buildUrl($url, $data, $options); + + return $this->_doRequest( + Request::METHOD_GET, + $url, + $body, + $options, + ); + } + + /** + * Do a POST request. + * + * @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. + * @return \Cake\Http\Client\Response + */ + public function post(string $url, mixed $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $url = $this->buildUrl($url, [], $options); + + return $this->_doRequest(Request::METHOD_POST, $url, $data, $options); + } + + /** + * Do a PUT request. + * + * @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. + * @return \Cake\Http\Client\Response + */ + public function put(string $url, mixed $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $url = $this->buildUrl($url, [], $options); + + return $this->_doRequest(Request::METHOD_PUT, $url, $data, $options); + } + + /** + * Do a PATCH request. + * + * @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. + * @return \Cake\Http\Client\Response + */ + public function patch(string $url, mixed $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $url = $this->buildUrl($url, [], $options); + + return $this->_doRequest(Request::METHOD_PATCH, $url, $data, $options); + } + + /** + * Do an OPTIONS request. + * + * @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. + * @return \Cake\Http\Client\Response + */ + public function options(string $url, mixed $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $url = $this->buildUrl($url, [], $options); + + return $this->_doRequest(Request::METHOD_OPTIONS, $url, $data, $options); + } + + /** + * Do a TRACE request. + * + * @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. + * @return \Cake\Http\Client\Response + */ + public function trace(string $url, mixed $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $url = $this->buildUrl($url, [], $options); + + return $this->_doRequest(Request::METHOD_TRACE, $url, $data, $options); + } + + /** + * Do a DELETE request. + * + * @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. + * @return \Cake\Http\Client\Response + */ + public function delete(string $url, mixed $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $url = $this->buildUrl($url, [], $options); + + return $this->_doRequest(Request::METHOD_DELETE, $url, $data, $options); + } + + /** + * Do a HEAD request. + * + * @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. + * @return \Cake\Http\Client\Response + */ + public function head(string $url, array $data = [], array $options = []): Response + { + $options = $this->_mergeOptions($options); + $url = $this->buildUrl($url, $data, $options); + + return $this->_doRequest(Request::METHOD_HEAD, $url, '', $options); + } + + /** + * Helper method for doing non-GET requests. + * + * @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. + * @return \Cake\Http\Client\Response + */ + protected function _doRequest(string $method, string $url, mixed $data, array $options): Response + { + $request = $this->_createRequest( + $method, + $url, + $data, + $options, + ); + + return $this->send($request, $options); + } + + /** + * Does a recursive merge of the parameter with the scope config. + * + * @param array $options Options to merge. + * @return array Options merged with set config. + */ + 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 \Psr\Http\Message\RequestInterface $request The request to send. + * @param array $options Additional options to use. + * @return \Cake\Http\Client\Response + */ + public function send(RequestInterface $request, array $options = []): Response + { + $redirects = 0; + if (isset($options['redirect'])) { + $redirects = (int)$options['redirect']; + unset($options['redirect']); + } + + do { + /** @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(); + + $location = $response->getHeaderLine('Location'); + $locationUrl = $this->buildUrl($location, [], [ + 'host' => $url->getHost(), + 'port' => $url->getPort(), + 'scheme' => $url->getScheme(), + '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 \Psr\Http\Message\RequestInterface $request The request to send. + * @param array $options Additional options to use. + * @return \Cake\Http\Client\Response + */ + protected function _sendRequest(RequestInterface $request, array $options): Response + { + $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); + } + + /** + * Generate a URL based on the scoped client options. + * + * @param string $url Either a full URL or just the path. + * @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(string $url, array|string $query = [], array $options = []): string + { + if (!$options && !$query) { + return $url; + } + $defaults = [ + 'host' => null, + 'port' => null, + 'scheme' => 'http', + 'basePath' => '', + 'protocolRelative' => false, + ]; + $options += $defaults; + + 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)) { + return $url; + } + + $defaultPorts = [ + 'http' => 80, + 'https' => 443, + ]; + $out = $options['scheme'] . '://' . $options['host']; + 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; + } + + /** + * Creates a new request object based on the parameters. + * + * @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. + * @return \Cake\Http\Client\Request + */ + protected function _createRequest(string $method, string $url, mixed $data, array $options): Request + { + /** @var array $headers */ + $headers = (array)($options['headers'] ?? []); + if (isset($options['type'])) { + $headers = array_merge($headers, $this->_typeHeaders($options['type'])); + } + if (is_string($data) && !isset($headers['Content-Type']) && !isset($headers['content-type'])) { + $headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + + $request = new Request($url, $method, $headers, $data); + $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'])) { + return $this->_addProxy($request, $options); + } + + return $request; + } + + /** + * Returns headers for Accept/Content-Type based on a short type + * or full mime-type. + * + * @param string $type short type alias or full mimetype. + * @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(string $type): array + { + if (str_contains($type, '/')) { + return [ + 'Accept' => $type, + 'Content-Type' => $type, + ]; + } + $typeMap = [ + 'json' => 'application/json', + 'xml' => 'application/xml', + ]; + if (!isset($typeMap[$type])) { + throw new CakeException(sprintf( + 'Unknown type alias `%s`.', + $type, + )); + } + + return [ + 'Accept' => $typeMap[$type], + 'Content-Type' => $typeMap[$type], + ]; + } + + /** + * Add authentication headers to the request. + * + * Uses the authentication type to choose the correct strategy + * 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. + * @return \Cake\Http\Client\Request The updated request object. + */ + protected function _addAuthentication(Request $request, array $options): Request + { + $auth = $options['auth']; + /** @var \Cake\Http\Client\Auth\Basic $adapter */ + $adapter = $this->_createAuth($auth, $options); + + return $adapter->authentication($request, $options['auth']); + } + + /** + * Add proxy authentication headers. + * + * Uses the authentication type to choose the correct strategy + * 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. + * @return \Cake\Http\Client\Request The updated request object. + */ + protected function _addProxy(Request $request, array $options): Request + { + $auth = $options['proxy']; + /** @var \Cake\Http\Client\Auth\Basic $adapter */ + $adapter = $this->_createAuth($auth, $options); + + return $adapter->proxyAuthentication($request, $options['proxy']); + } + + /** + * Create the authentication strategy. + * + * Use the configuration options to create the correct + * authentication strategy handler. + * + * @param array $auth The authentication options to use. + * @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(array $auth, array $options): object + { + if (empty($auth['type'])) { + $auth['type'] = 'basic'; + } + $name = ucfirst($auth['type']); + $class = App::className($name, 'Http/Client/Auth'); + if (!$class) { + throw new CakeException( + sprintf('Invalid authentication type `%s`.', $name), + ); + } + + return new $class($this, $options); + } +} diff --git a/src/Http/Client/Adapter/Curl.php b/src/Http/Client/Adapter/Curl.php new file mode 100644 index 00000000000..40aca2afe0a --- /dev/null +++ b/src/Http/Client/Adapter/Curl.php @@ -0,0 +1,215 @@ +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); + } + + $responses = $this->createResponse($ch, $body); + + return $responses; + } + + /** + * 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'] = 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 new file mode 100644 index 00000000000..ad1d28c55c7 --- /dev/null +++ b/src/Http/Client/Adapter/Stream.php @@ -0,0 +1,342 @@ + + */ + protected array $_contextOptions = []; + + /** + * Array of options/content for the SSL stream context. + * + * @var array + */ + protected array $_sslContextOptions = []; + + /** + * The stream resource. + * + * @var resource|null + */ + protected $_stream; + + /** + * Connection error list. + * + * @var array + */ + protected array $_connectionErrors = []; + + /** + * @inheritDoc + */ + public function send(RequestInterface $request, array $options): array + { + $this->_stream = null; + $this->_context = null; + $this->_contextOptions = []; + $this->_sslContextOptions = []; + $this->_connectionErrors = []; + + $this->_buildContext($request, $options); + + return $this->_send($request); + } + + /** + * Create the response list based on the headers & content + * + * Creates one or many response objects based on the number + * of redirects that occurred. + * + * @param list $headers The list of headers from the request(s) + * @param string $content The response content. + * @return array<\Cake\Http\Client\Response> The list of responses from the request(s) + */ + public function createResponses(array $headers, string $content): array + { + $indexes = []; + $responses = []; + foreach ($headers as $i => $header) { + if (strtoupper(substr($header, 0, 5)) === 'HTTP/') { + $indexes[] = $i; + } + } + $last = count($indexes) - 1; + 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 : ''; + $responses[] = $this->_buildResponse($headerSlice, $body); + } + + return $responses; + } + + /** + * Build the stream context out of the request object. + * + * @param \Psr\Http\Message\RequestInterface $request The request to build context from. + * @param array $options Additional request options. + * @return void + */ + 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((string)$url, PHP_URL_SCHEME); + if ($scheme === 'https') { + $this->_buildSslContext($request, $options); + } + $this->_context = stream_context_create([ + 'http' => $this->_contextOptions, + 'ssl' => $this->_sslContextOptions, + ]); + } + + /** + * Build the header context for the request. + * + * Creates cookies & headers. + * + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. + * @return void + */ + protected function _buildHeaders(RequestInterface $request, array $options): void + { + $headers = []; + foreach ($request->getHeaders() as $name => $values) { + $headers[] = sprintf('%s: %s', $name, implode(', ', $values)); + } + $this->_contextOptions['header'] = implode("\r\n", $headers); + } + + /** + * 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 {@link \Cake\Http\Client\FormData} + * + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. + * @return void + */ + protected function _buildContent(RequestInterface $request, array $options): void + { + $body = $request->getBody(); + $body->rewind(); + $this->_contextOptions['content'] = $body->getContents(); + } + + /** + * Build miscellaneous options for the request. + * + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. + * @return void + */ + protected function _buildOptions(RequestInterface $request, array $options): void + { + $this->_contextOptions['method'] = $request->getMethod(); + $this->_contextOptions['protocol_version'] = $request->getProtocolVersion(); + $this->_contextOptions['ignore_errors'] = true; + + if (isset($options['timeout'])) { + $this->_contextOptions['timeout'] = $options['timeout']; + } + // Redirects are handled in the client layer because of cookie handling issues. + $this->_contextOptions['max_redirects'] = 0; + + if (isset($options['proxy']['proxy'])) { + $this->_contextOptions['request_fulluri'] = true; + $this->_contextOptions['proxy'] = $options['proxy']['proxy']; + } + } + + /** + * Build SSL options for the request. + * + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. + * @return void + */ + protected function _buildSslContext(RequestInterface $request, array $options): void + { + $sslOptions = [ + 'ssl_verify_peer', + 'ssl_verify_peer_name', + 'ssl_verify_depth', + 'ssl_allow_self_signed', + 'ssl_cafile', + 'ssl_local_cert', + 'ssl_local_pk', + 'ssl_passphrase', + ]; + if (empty($options['ssl_cafile'])) { + $options['ssl_cafile'] = CaBundle::getBundledCaBundlePath(); + } + if (!empty($options['ssl_verify_host'])) { + $url = $request->getUri(); + $host = parse_url((string)$url, PHP_URL_HOST); + $this->_sslContextOptions['peer_name'] = $host; + } + foreach ($sslOptions as $key) { + if (isset($options[$key])) { + $name = substr($key, 4); + $this->_sslContextOptions[$name] = $options[$key]; + } + } + } + + /** + * Open the stream and send the request. + * + * @param \Psr\Http\Message\RequestInterface $request The request object. + * @return array Array of populated Response objects + * @throws \Psr\Http\Client\NetworkExceptionInterface + */ + 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((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)); + } + + $content .= fread($this->_stream, 8192); + + $meta = stream_get_meta_data($this->_stream); + if ($meta['timed_out'] || ($deadline !== false && time() > $deadline)) { + $timedOut = true; + break; + } + } + + $meta = stream_get_meta_data($this->_stream); + fclose($this->_stream); + + if ($timedOut) { + throw new NetworkException('Connection timed out ' . $url, $request); + } + + $headers = $meta['wrapper_data']; + if (isset($headers['headers']) && is_array($headers['headers'])) { + $headers = $headers['headers']; + } + + return $this->createResponses($headers, $content); + } + + /** + * Build a response object + * + * @param array $headers Unparsed headers. + * @param string $body The response body. + * @return \Cake\Http\Client\Response + */ + protected function _buildResponse(array $headers, string $body): Response + { + return new Response($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 \Psr\Http\Client\RequestExceptionInterface + */ + protected function _open(string $url, RequestInterface $request): void + { + 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; + }); + try { + $stream = fopen($url, 'rb', false, $this->_context); + if ($stream === false) { + $stream = null; + } + $this->_stream = $stream; + } finally { + restore_error_handler(); + } + + if (!$this->_stream || $this->_connectionErrors) { + throw new RequestException(implode("\n", $this->_connectionErrors), $request); + } + } + + /** + * Get the context options + * + * Useful for debugging and testing context creation. + * + * @return array + */ + public function contextOptions(): array + { + return array_merge($this->_contextOptions, $this->_sslContextOptions); + } +} 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 new file mode 100644 index 00000000000..9afb82e492c --- /dev/null +++ b/src/Http/Client/Auth/Basic.php @@ -0,0 +1,75 @@ +_generateHeader($credentials['username'], $credentials['password']); + $request = $request->withHeader('Authorization', $value); + } + + return $request; + } + + /** + * Proxy Authentication + * + * @param \Cake\Http\Client\Request $request Request instance. + * @param array $credentials Credentials. + * @return \Cake\Http\Client\Request The updated request. + * @see https://www.ietf.org/rfc/rfc2617.txt + */ + public function proxyAuthentication(Request $request, array $credentials): Request + { + if (isset($credentials['username'], $credentials['password'])) { + $value = $this->_generateHeader($credentials['username'], $credentials['password']); + $request = $request->withHeader('Proxy-Authorization', $value); + } + + return $request; + } + + /** + * Generate basic [proxy] authentication header + * + * @param string $user Username. + * @param string $pass Password. + * @return string + */ + protected function _generateHeader(string $user, string $pass): string + { + return 'Basic ' . base64_encode($user . ':' . $pass); + } +} diff --git a/src/Http/Client/Auth/Digest.php b/src/Http/Client/Auth/Digest.php new file mode 100644 index 00000000000..6014cdb70ad --- /dev/null +++ b/src/Http/Client/Auth/Digest.php @@ -0,0 +1,251 @@ + 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 $_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, ?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. + * @return \Cake\Http\Client\Request The updated request. + * @see https://www.ietf.org/rfc/rfc2617.txt + */ + public function authentication(Request $request, array $credentials): Request + { + if (!isset($credentials['username'], $credentials['password'])) { + return $request; + } + if (!isset($credentials['realm'])) { + $credentials = $this->_getServerInfo($request, $credentials); + } + if (!isset($credentials['realm'])) { + return $request; + } + + $this->setAlgorithm($credentials); + $value = $this->_generateHeader($request, $credentials); + + return $request->withHeader('Authorization', $value); + } + + /** + * Retrieve information about the authentication + * + * Will get the realm and other tokens by performing + * another request without authentication to get authentication + * challenge. + * + * @param \Cake\Http\Client\Request $request The request object. + * @param array $credentials Authentication credentials. + * @return array modified credentials. + */ + protected function _getServerInfo(Request $request, array $credentials): array + { + $response = $this->_client->get( + (string)$request->getUri(), + [], + ['auth' => ['type' => null]], + ); + + $header = $response->getHeader('WWW-Authenticate'); + if (!$header) { + return []; + } + $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. + * @return string + */ + protected function _generateHeader(Request $request, array $credentials): string + { + $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'])) { + $ha2 = hash($this->hashType, $a2); + $response = hash($this->hashType, $ha1 . ':' . $credentials['nonce'] . ':' . $ha2); + } else { + 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 '; + $authHeader .= 'username="' . str_replace(['\\', '"'], ['\\\\', '\\"'], $credentials['username']) . '", '; + $authHeader .= 'realm="' . $credentials['realm'] . '", '; + $authHeader .= 'nonce="' . $credentials['nonce'] . '", '; + $authHeader .= 'uri="' . $path . '", '; + $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'] . '"'; + } + + return $authHeader; + } +} diff --git a/src/Http/Client/Auth/Oauth.php b/src/Http/Client/Auth/Oauth.php new file mode 100644 index 00000000000..895be98c0f5 --- /dev/null +++ b/src/Http/Client/Auth/Oauth.php @@ -0,0 +1,388 @@ +_hmacSha1($request, $credentials); + break; + + case 'RSA-SHA1': + if (!isset($credentials['privateKey'])) { + return $request; + } + $value = $this->_rsaSha1($request, $credentials); + break; + + case 'PLAINTEXT': + $hasKeys = isset( + $credentials['consumerSecret'], + $credentials['token'], + $credentials['tokenSecret'], + ); + if (!$hasKeys) { + return $request; + } + $value = $this->_plaintext($request, $credentials); + break; + + default: + throw new CakeException(sprintf('Unknown Oauth signature method `%s`.', $credentials['method'])); + } + + return $request->withHeader('Authorization', $value); + } + + /** + * Plaintext signing + * + * This method is **not** suitable for plain HTTP. + * You should only ever use PLAINTEXT when dealing with SSL + * services. + * + * @param \Cake\Http\Client\Request $request The request object. + * @param array $credentials Authentication credentials. + * @return string Authorization header. + */ + protected function _plaintext(Request $request, array $credentials): string + { + $values = [ + 'oauth_version' => '1.0', + 'oauth_nonce' => uniqid(), + 'oauth_timestamp' => time(), + 'oauth_signature_method' => 'PLAINTEXT', + 'oauth_token' => $credentials['token'], + 'oauth_consumer_key' => $credentials['consumerKey'], + ]; + if (isset($credentials['realm'])) { + $values['oauth_realm'] = $credentials['realm']; + } + $key = [$credentials['consumerSecret'], $credentials['tokenSecret']]; + $key = implode('&', $key); + $values['oauth_signature'] = $key; + + return $this->_buildAuth($values); + } + + /** + * Use HMAC-SHA1 signing. + * + * This method is suitable for plain HTTP or HTTPS. + * + * @param \Cake\Http\Client\Request $request The request object. + * @param array $credentials Authentication credentials. + * @return string + */ + protected function _hmacSha1(Request $request, array $credentials): string + { + $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' => $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 = implode('&', $key); + + $values['oauth_signature'] = base64_encode( + hash_hmac('sha1', $baseString, $key, true), + ); + + return $this->_buildAuth($values); + } + + /** + * Use RSA-SHA1 signing. + * + * This method is suitable for plain HTTP or HTTPS. + * + * @param \Cake\Http\Client\Request $request The request object. + * @param array $credentials Authentication credentials. + * @return string + */ + protected function _rsaSha1(Request $request, array $credentials): string + { + if (!function_exists('openssl_pkey_get_private')) { + throw new CakeException('RSA-SHA1 signature method requires the OpenSSL extension.'); + } + + $nonce = $credentials['nonce'] ?? bin2hex(Security::randomBytes(16)); + $timestamp = $credentials['timestamp'] ?? time(); + $values = [ + 'oauth_version' => '1.0', + 'oauth_nonce' => $nonce, + 'oauth_timestamp' => $timestamp, + 'oauth_signature_method' => 'RSA-SHA1', + 'oauth_consumer_key' => $credentials['consumerKey'], + ]; + if (isset($credentials['consumerSecret'])) { + $values['oauth_consumer_secret'] = $credentials['consumerSecret']; + } + if (isset($credentials['token'])) { + $values['oauth_token'] = $credentials['token']; + } + if (isset($credentials['tokenSecret'])) { + $values['oauth_token_secret'] = $credentials['tokenSecret']; + } + $baseString = $this->baseString($request, $values); + + if (isset($credentials['realm'])) { + $values['oauth_realm'] = $credentials['realm']; + } + + if (is_resource($credentials['privateKey'])) { + $resource = $credentials['privateKey']; + $privateKey = stream_get_contents($resource); + rewind($resource); + $credentials['privateKey'] = $privateKey; + } + + $credentials += [ + 'privateKeyPassphrase' => '', + ]; + if (is_resource($credentials['privateKeyPassphrase'])) { + $resource = $credentials['privateKeyPassphrase']; + $passphrase = stream_get_line($resource, 0, PHP_EOL); + rewind($resource); + $credentials['privateKeyPassphrase'] = $passphrase; + } + $privateKey = openssl_pkey_get_private($credentials['privateKey'], $credentials['privateKeyPassphrase']); + $this->checkSslError(); + + assert($privateKey !== false); + + $signature = ''; + openssl_sign($baseString, $signature, $privateKey); + $this->checkSslError(); + + $values['oauth_signature'] = base64_encode($signature); + + return $this->_buildAuth($values); + } + + /** + * Generate the Oauth basestring + * + * - Querystring, request data and oauth_* parameters are combined. + * - Values are sorted by name and then value. + * - Request values are concatenated and urlencoded. + * - The request URL (without querystring) is normalized. + * - The HTTP method, URL and request parameters are concatenated and returned. + * + * @param \Cake\Http\Client\Request $request The request object. + * @param array $oauthValues Oauth values. + * @return string + */ + 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); + + return implode('&', $parts); + } + + /** + * Builds a normalized URL + * + * Section 9.1.2. of the Oauth spec + * + * @param \Psr\Http\Message\UriInterface $uri Uri object to build a normalized version of. + * @return string Normalized URL + */ + protected function _normalizedUrl(UriInterface $uri): string + { + $out = $uri->getScheme() . '://'; + $out .= strtolower($uri->getHost()); + $out .= $uri->getPath(); + + return $out; + } + + /** + * Sorts and normalizes request data and oauthValues + * + * Section 9.1.1 of Oauth spec. + * + * - URL encode keys + values. + * - Sort keys & values by byte value. + * + * @param \Cake\Http\Client\Request $request The request object. + * @param array $oauthValues Oauth values. + * @return string sorted and normalized values + */ + protected function _normalizedParams(Request $request, array $oauthValues): string + { + $query = parse_url((string)$request->getUri(), PHP_URL_QUERY); + parse_str((string)$query, $queryArgs); + + $post = []; + $contentType = $request->getHeaderLine('Content-Type'); + if ($contentType === '' || $contentType === 'application/x-www-form-urlencoded') { + parse_str((string)$request->getBody(), $post); + } + $args = array_merge($queryArgs, $oauthValues, $post); + $pairs = $this->_normalizeData($args); + $data = []; + foreach ($pairs as $pair) { + $data[] = implode('=', $pair); + } + sort($data, SORT_STRING); + + return implode('&', $data); + } + + /** + * Recursively convert request data into the normalized form. + * + * @param array $args The arguments to normalize. + * @param string $path The current path being converted. + * @see https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + * @return array + */ + protected function _normalizeData(array $args, string $path = ''): array + { + $data = []; + foreach ($args as $key => $value) { + if ($path) { + // Fold string keys with []. + // Numeric keys result in a=b&a=c. While this isn't + // standard behavior in PHP, it is common in other platforms. + if (!is_numeric($key)) { + $key = "{$path}[{$key}]"; + } else { + $key = $path; + } + } + if (is_array($value)) { + uksort($value, 'strcmp'); + $data = array_merge($data, $this->_normalizeData($value, $key)); + } else { + $data[] = [$key, $value]; + } + } + + return $data; + } + + /** + * Builds the Oauth Authorization header value. + * + * @param array $data The oauth_* values to build + * @return string + */ + protected function _buildAuth(array $data): string + { + $out = 'OAuth '; + $params = []; + foreach ($data as $key => $value) { + $params[] = $key . '="' . $this->_encode((string)$value) . '"'; + } + $out .= implode(',', $params); + + return $out; + } + + /** + * URL Encodes a value based on rules of rfc3986 + * + * @param string $value Value to encode. + * @return string + */ + protected function _encode(string $value): string + { + return str_replace(['%7E', '+'], ['~', ' '], rawurlencode($value)); + } + + /** + * 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/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 new file mode 100644 index 00000000000..9b0c87aca82 --- /dev/null +++ b/src/Http/Client/FormData.php @@ -0,0 +1,284 @@ + + */ + protected array $_parts = []; + + /** + * Get the boundary marker + * + * @return string + */ + public function boundary(): string + { + if ($this->_boundary) { + return $this->_boundary; + } + $this->_boundary = hash('xxh128', uniqid((string)time())); + + return $this->_boundary; + } + + /** + * Method for creating new instances of Part + * + * @param string $name The name of the part. + * @param string $value The value to add. + * @return \Cake\Http\Client\FormDataPart + */ + public function newPart(string $name, string $value): FormDataPart + { + return new FormDataPart($name, $value); + } + + /** + * Add a new part to the data. + * + * The value for a part can be a string, array, int, + * float, filehandle, or object implementing __toString() + * + * If the $value is an array, multiple parts will be added. + * Files will be read from their current position and saved in memory. + * + * @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(FormDataPart|string $name, mixed $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; + } + + return $this; + } + + /** + * Add multiple parts at once. + * + * Iterates the parameter and adds all the key/values. + * + * @param array $data Array of data to add. + * @return $this + */ + public function addMany(array $data) + { + foreach ($data as $name => $value) { + $this->add($name, $value); + } + + return $this; + } + + /** + * Add either a file reference (string starting with @) + * or a file handle. + * + * @param string $name The name to use. + * @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(string $name, mixed $value): FormDataPart + { + $this->_hasFile = true; + + $filename = false; + $contentType = 'application/octet-stream'; + 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); + $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 = (string)file_get_contents($value); + $contentType = (string)$finfo->file($value); + } + $part = $this->newPart($name, $content); + $part->type($contentType); + if ($filename) { + $part->filename($filename); + } + $this->add($part); + + return $part; + } + + /** + * Recursively add data. + * + * @param string $name The name to use. + * @param mixed $value The value to add. + * @return void + */ + public function addRecursive(string $name, mixed $value): void + { + foreach ($value as $key => $item) { + $key = $name . '[' . $key . ']'; + $this->add($key, $item); + } + } + + /** + * Returns the count of parts inside this object. + * + * @return int + */ + public function count(): int + { + return count($this->_parts); + } + + /** + * Check whether the current payload + * has any files. + * + * @return bool Whether there is a file in this payload. + */ + public function hasFile(): bool + { + return $this->_hasFile; + } + + /** + * 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 the payload is multipart. + */ + public function isMultipart(): bool + { + return $this->hasFile() || $this->_hasComplexPart; + } + + /** + * Get the content type for this payload. + * + * If this object contains files, `multipart/form-data` will be used, + * otherwise `application/x-www-form-urlencoded` will be used. + * + * @return string + */ + public function contentType(): string + { + if (!$this->isMultipart()) { + return 'application/x-www-form-urlencoded'; + } + + return 'multipart/form-data; boundary=' . $this->boundary(); + } + + /** + * Converts the FormData and its parts into a string suitable + * for use in an HTTP request. + * + * @return string + */ + public function __toString(): string + { + if ($this->isMultipart()) { + $boundary = $this->boundary(); + $out = ''; + foreach ($this->_parts as $part) { + $out .= "--{$boundary}\r\n"; + $out .= (string)$part; + $out .= "\r\n"; + } + $out .= "--{$boundary}--\r\n"; + + return $out; + } + $data = []; + foreach ($this->_parts as $part) { + $data[$part->name()] = $part->value(); + } + + return http_build_query($data); + } +} diff --git a/src/Http/Client/FormDataPart.php b/src/Http/Client/FormDataPart.php new file mode 100644 index 00000000000..561b9f94552 --- /dev/null +++ b/src/Http/Client/FormDataPart.php @@ -0,0 +1,234 @@ +disposition; + } + + return $this->disposition = $disposition; + } + + /** + * Get/set the contentId for a part. + * + * @param string|null $id The content id. + * @return string|null + */ + public function contentId(?string $id = null): ?string + { + if ($id === null) { + return $this->contentId; + } + + return $this->contentId = $id; + } + + /** + * Get/set the filename. + * + * Setting the filename to `false` will exclude it from the + * generated output. + * + * @param string|null $filename Use null to get/string to set. + * @return string|null + */ + public function filename(?string $filename = null): ?string + { + if ($filename === null) { + return $this->filename; + } + + return $this->filename = $filename; + } + + /** + * Get/set the content type. + * + * @param string|null $type Use null to get/string to set. + * @return string|null + */ + public function type(?string $type): ?string + { + if ($type === null) { + return $this->type; + } + + return $this->type = $type; + } + + /** + * Set the transfer-encoding for multipart. + * + * Useful when content bodies are in encodings like base64. + * + * @param string|null $type The type of encoding the value has. + * @return string|null + */ + public function transferEncoding(?string $type): ?string + { + if ($type === null) { + return $this->transferEncoding; + } + + return $this->transferEncoding = $type; + } + + /** + * Get the part name. + * + * @return string + */ + public function name(): string + { + return $this->name; + } + + /** + * Get the value. + * + * @return string + */ + public function value(): string + { + return $this->value; + } + + /** + * Convert the part into a string. + * + * Creates a string suitable for use in HTTP requests. + * + * @return string + */ + public function __toString(): string + { + $out = ''; + if ($this->disposition) { + $out .= 'Content-Disposition: ' . $this->disposition; + if ($this->name) { + $out .= '; ' . $this->_headerParameterToString('name', $this->name); + } + if ($this->filename) { + $out .= '; ' . $this->_headerParameterToString('filename', $this->filename); + } + $out .= "\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->contentId) { + $out .= 'Content-ID: <' . $this->contentId . ">\r\n"; + } + $out .= "\r\n"; + $out .= $this->value; + + return $out; + } + + /** + * 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 new file mode 100644 index 00000000000..c3b978bb41c --- /dev/null +++ b/src/Http/Client/Message.php @@ -0,0 +1,174 @@ +_cookies; + } +} diff --git a/src/Http/Client/Request.php b/src/Http/Client/Request.php new file mode 100644 index 00000000000..16f2b70474e --- /dev/null +++ b/src/Http/Client/Request.php @@ -0,0 +1,117 @@ + $headers + * @param \Psr\Http\Message\UriInterface|string $url The request URL + * @param string $method The HTTP method to use. + * @param array $headers The HTTP headers to set. + * @param array|string|null $data The request body to use. + */ + 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' => ini_get('user_agent') ?: 'CakePHP', + ]; + $this->addHeaders($headers); + if (in_array($data, [null, '', []], true)) { + $this->stream = new Stream('php://memory', 'rw'); + } else { + $this->setContent($data); + } + } + + /** + * Add an array of headers to the request. + * + * @phpstan-param array $headers + * @param array $headers The headers to add. + * @return void + */ + protected function addHeaders(array $headers): void + { + foreach ($headers as $key => $val) { + $normalized = strtolower($key); + $this->headers[$key] = (array)$val; + $this->headerNames[$normalized] = $key; + } + } + + /** + * Set the body/payload for the message. + * + * Array data will be serialized with {@link \Cake\Http\FormData}, + * and the content-type will be set. + * + * @param array|string $content The body for the request. + * @return $this + */ + protected function setContent(array|string $content) + { + 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); + + /** @phpstan-var array $headers */ + $headers = ['Content-Type' => $formData->contentType()]; + $this->addHeaders($headers); + $content = (string)$formData; + } + } + + $stream = new Stream('php://memory', 'rw'); + $stream->write($content); + $this->stream = $stream; + + return $this; + } +} diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php new file mode 100644 index 00000000000..02ca148339f --- /dev/null +++ b/src/Http/Client/Response.php @@ -0,0 +1,474 @@ +getHeaderLine('content-type'); + * ``` + * + * Will read the Content-Type header. You can get all set + * headers using: + * + * ``` + * $response->getHeaders(); + * ``` + * + * ### Get the response body + * + * You can access the response body stream using: + * + * ``` + * $content = $response->getBody(); + * ``` + * + * You can get the body string using: + * + * ``` + * $content = $response->getStringBody(); + * ``` + * + * If your response body is in XML or JSON you can use + * special content type specific accessors to read the decoded data. + * JSON data will be returned as arrays, while XML data will be returned + * as SimpleXML nodes: + * + * ``` + * // Get as XML + * $content = $response->getXml() + * // Get as JSON + * $content = $response->getJson() + * ``` + * + * If the response cannot be decoded, null will be returned. + * + * ### Check the status code + * + * You can access the response status code using: + * + * ``` + * $content = $response->getStatusCode(); + * ``` + */ +class Response extends Message implements ResponseInterface +{ + use MessageTrait; + + /** + * The status code of the response. + * + * @var int + */ + protected int $code = 0; + + /** + * Cookie Collection instance + * + * @var \Cake\Http\Cookie\CookieCollection|null + */ + protected ?CookieCollection $cookies = null; + + /** + * The reason phrase for the status code + * + * @var string + */ + protected string $reasonPhrase; + + /** + * Cached decoded XML data. + * + * @var \SimpleXMLElement|null + */ + protected ?SimpleXMLElement $_xml = null; + + /** + * Cached decoded JSON data. + * + * @var mixed + */ + protected mixed $_json = null; + + /** + * Constructor + * + * @param array $headers Unparsed headers. + * @param string $body The response body. + */ + public function __construct(array $headers = [], string $body = '') + { + $this->_parseHeaders($headers); + if ($this->getHeaderLine('Content-Encoding') === 'gzip') { + $body = $this->_decodeGzipBody($body); + } + $stream = new Stream('php://memory', 'wb+'); + $stream->write($body); + $stream->rewind(); + $this->stream = $stream; + } + + /** + * Uncompress a gzip response. + * + * Looks for gzip signatures, and if gzinflate() exists, + * the body will be decompressed. + * + * @param string $body Gzip encoded body. + * @return string + * @throws \Cake\Core\Exception\CakeException When attempting to decode gzip content without gzinflate. + */ + protected function _decodeGzipBody(string $body): string + { + if (!function_exists('gzinflate')) { + throw new CakeException('Cannot decompress gzip response body without gzinflate()'); + } + $offset = 0; + // Look for gzip 'signature' + if (str_starts_with($body, "\x1f\x8b")) { + $offset = 2; + } + // Check the format byte + if (substr($body, $offset, 1) === "\x08") { + return (string)gzinflate(substr($body, $offset + 8)); + } + + throw new CakeException('Invalid gzip response'); + } + + /** + * Parses headers if necessary. + * + * - Decodes the status code and reason phrase. + * - Parses and normalizes header names and values. + * + * @param array $headers Headers to parse. + * @return void + */ + protected function _parseHeaders(array $headers): void + { + 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; + } + if (!str_contains($value, ':')) { + continue; + } + [$name, $value] = explode(':', $value, 2); + $value = trim($value); + /** @var non-empty-string $name */ + $name = trim($name); + + $normalized = strtolower($name); + + if (isset($this->headers[$name])) { + $this->headers[$name][] = $value; + } else { + $this->headers[$name] = (array)$value; + $this->headerNames[$normalized] = $name; + } + } + } + + /** + * Check if the response status code was in the 2xx/3xx range + * + * @return bool + */ + public function isOk(): bool + { + return $this->code >= 200 && $this->code <= 399; + } + + /** + * Check if the response status code was in the 2xx range + * + * @return bool + */ + public function isSuccess(): bool + { + return $this->code >= 200 && $this->code <= 299; + } + + /** + * Check if the response had a redirect status code. + * + * @return bool + */ + 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, true) && + $this->getHeaderLine('Location'); + } + + /** + * {@inheritDoc} + * + * @return int The status code. + */ + public function getStatusCode(): int + { + return $this->code; + } + + /** + * {@inheritDoc} + * + * @param int $code The status code to set. + * @param string $reasonPhrase The status reason phrase. + * @return static A copy of the current object with an updated status code. + */ + public function withStatus(int $code, string $reasonPhrase = ''): static + { + $new = clone $this; + $new->code = $code; + $new->reasonPhrase = $reasonPhrase; + + return $new; + } + + /** + * {@inheritDoc} + * + * @return string The current reason phrase. + */ + public function getReasonPhrase(): string + { + return $this->reasonPhrase; + } + + /** + * Get the encoding if it was set. + * + * @return string|null + */ + public function getEncoding(): ?string + { + $content = $this->getHeaderLine('content-type'); + if (!$content) { + return null; + } + preg_match('/charset\s?=\s?[\'"]?([a-z0-9-_]+)[\'"]?/i', $content, $matches); + if (empty($matches[1])) { + return null; + } + + return $matches[1]; + } + + /** + * Get the all cookie data. + * + * @return array The cookie data + */ + public function getCookies(): array + { + return $this->_getCookies(); + } + + /** + * Get the cookie collection from this response. + * + * This method exposes the response's CookieCollection + * instance allowing you to interact with cookie objects directly. + * + * @return \Cake\Http\Cookie\CookieCollection + */ + public function getCookieCollection(): CookieCollection + { + return $this->buildCookieCollection(); + } + + /** + * Get the value of a single cookie. + * + * @param string $name The name of the cookie value. + * @return array|string|null Either the cookie's value or null when the cookie is undefined. + */ + public function getCookie(string $name): array|string|null + { + $cookies = $this->buildCookieCollection(); + + if (!$cookies->has($name)) { + return null; + } + + return $cookies->get($name)->getValue(); + } + + /** + * Get the full data for a single cookie. + * + * @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(string $name): ?array + { + $cookies = $this->buildCookieCollection(); + + if (!$cookies->has($name)) { + return null; + } + + return $cookies->get($name)->toArray(); + } + + /** + * Lazily build the CookieCollection and cookie objects from the response header + * + * @return \Cake\Http\Cookie\CookieCollection + */ + protected function buildCookieCollection(): CookieCollection + { + $this->cookies ??= CookieCollection::createFromHeader($this->getHeader('Set-Cookie')); + + return $this->cookies; + } + + /** + * Property accessor for `$this->cookies` + * + * @return array Array of Cookie data. + */ + protected function _getCookies(): array + { + $out = []; + foreach ($this->buildCookieCollection() as $cookie) { + $out[$cookie->getName()] = $cookie->toArray(); + } + + return $out; + } + + /** + * Get the response body as string. + * + * @return string + */ + public function getStringBody(): string + { + return $this->_getBody(); + } + + /** + * Get the response body as JSON decoded data. + * + * @return mixed + */ + public function getJson(): mixed + { + return $this->_getJson(); + } + + /** + * Get the response body as JSON decoded data. + * + * @return mixed + */ + protected function _getJson(): mixed + { + if ($this->_json) { + return $this->_json; + } + + return $this->_json = json_decode($this->_getBody(), true); + } + + /** + * Get the response body as XML decoded data. + * + * @return \SimpleXMLElement|null + */ + public function getXml(): ?SimpleXMLElement + { + 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) { + return null; + } + + $this->_xml = $data; + + return $this->_xml; + } + + /** + * Provides magic __get() support. + * + * @return array + */ + protected function _getHeaders(): array + { + $out = []; + foreach ($this->headers as $key => $values) { + $out[$key] = implode(',', $values); + } + + return $out; + } + + /** + * Provides magic __get() support. + * + * @return string + */ + protected function _getBody(): string + { + $this->stream->rewind(); + + return $this->stream->getContents(); + } +} 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/ControllerFactoryInterface.php b/src/Http/ControllerFactoryInterface.php new file mode 100644 index 00000000000..d7e34fb0bd7 --- /dev/null +++ b/src/Http/ControllerFactoryInterface.php @@ -0,0 +1,47 @@ +withValue('0'); + * ``` + * + * @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. + */ +class Cookie implements CookieInterface +{ + /** + * Cookie name + * + * @var string + */ + protected string $name = ''; + + /** + * Raw Cookie value. + * + * @var array|string + */ + protected array|string $value = ''; + + /** + * Whether a JSON value has been expanded into an array. + * + * @var bool + */ + protected bool $isExpanded = false; + + /** + * Expiration time + * + * @var \DateTimeInterface|null + */ + protected ?DateTimeInterface $expiresAt = null; + + /** + * Path + * + * @var string + */ + protected string $path = '/'; + + /** + * Domain + * + * @var string + */ + protected string $domain = ''; + + /** + * Secure + * + * @var bool + */ + protected bool $secure = false; + + /** + * HTTP only + * + * @var bool + */ + 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 + * + * The constructors args are similar to the native PHP `setcookie()` method. + * The only difference is the 3rd argument which excepts null or an + * DateTime or DateTimeImmutable object instead an integer. + * + * @link https://php.net/manual/en/function.setcookie.php + * @param string $name Cookie name + * @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( + 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->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']); + + 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(): string + { + $value = $this->value; + if ($this->isExpanded) { + 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) { + $headerValue[] = sprintf('expires=%s', $this->getFormattedExpires()); + } + if ($this->path !== '') { + $headerValue[] = sprintf('path=%s', $this->path); + } + if ($this->domain !== '') { + $headerValue[] = sprintf('domain=%s', $this->domain); + } + if ($this->sameSite) { + $headerValue[] = sprintf('samesite=%s', $this->sameSite->value); + } + if ($this->secure) { + $headerValue[] = 'secure'; + } + if ($this->httpOnly) { + $headerValue[] = 'httponly'; + } + + return implode('; ', $headerValue); + } + + /** + * @inheritDoc + */ + public function withName(string $name): static + { + $this->validateName($name); + $new = clone $this; + $new->name = $name; + + return $new; + } + + /** + * @inheritDoc + */ + public function getId(): string + { + return "{$this->name};{$this->domain};{$this->path}"; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return $this->name; + } + + /** + * Validates the cookie name + * + * @param string $name Name of the cookie + * @return void + * @throws \InvalidArgumentException + * @link https://tools.ietf.org/html/rfc2616#section-2.2 Rules for naming cookies. + */ + 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), + ); + } + + if (!$name) { + throw new InvalidArgumentException('The cookie name cannot be empty.'); + } + } + + /** + * @inheritDoc + */ + public function getValue(): array|string + { + return $this->value; + } + + /** + * @inheritDoc + */ + 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 + */ + public function withValue(array|string|float|int|bool $value): static + { + $new = clone $this; + $new->_setValue($value); + + return $new; + } + + /** + * Setter for the value attribute. + * + * @param array|string|float|int|bool $value The value to store. + * @return void + */ + protected function _setValue(array|string|float|int|bool $value): void + { + $this->isExpanded = is_array($value); + $this->value = is_array($value) ? $value : (string)$value; + } + + /** + * @inheritDoc + */ + public function withPath(string $path): static + { + $new = clone $this; + $new->path = $path; + + return $new; + } + + /** + * @inheritDoc + */ + public function getPath(): string + { + return $this->path; + } + + /** + * @inheritDoc + */ + public function withDomain(string $domain): static + { + $new = clone $this; + $new->domain = $domain; + + return $new; + } + + /** + * @inheritDoc + */ + public function getDomain(): string + { + return $this->domain; + } + + /** + * @inheritDoc + */ + public function isSecure(): bool + { + return $this->secure; + } + + /** + * @inheritDoc + */ + public function withSecure(bool $secure): static + { + $new = clone $this; + $new->secure = $secure; + + return $new; + } + + /** + * @inheritDoc + */ + public function withHttpOnly(bool $httpOnly): static + { + $new = clone $this; + $new->httpOnly = $httpOnly; + + return $new; + } + + /** + * @inheritDoc + */ + public function isHttpOnly(): bool + { + return $this->httpOnly; + } + + /** + * @inheritDoc + */ + public function withExpiry(DateTimeInterface $dateTime): static + { + if ($dateTime instanceof DateTime) { + $dateTime = clone $dateTime; + } + + $new = clone $this; + $new->expiresAt = $dateTime->setTimezone(new DateTimeZone('GMT')); + + return $new; + } + + /** + * @inheritDoc + */ + public function getExpiry(): ?DateTimeInterface + { + return $this->expiresAt; + } + + /** + * @inheritDoc + */ + public function getExpiresTimestamp(): ?int + { + if (!$this->expiresAt) { + return null; + } + + return (int)$this->expiresAt->format('U'); + } + + /** + * @inheritDoc + */ + public function getFormattedExpires(): string + { + if (!$this->expiresAt) { + return ''; + } + + return $this->expiresAt->format(static::EXPIRES_FORMAT); + } + + /** + * @inheritDoc + */ + 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; + } + + return $this->expiresAt < $time; + } + + /** + * @inheritDoc + */ + public function withNeverExpire(): static + { + $new = clone $this; + $new->expiresAt = new DateTimeImmutable('2038-01-01'); + + return $new; + } + + /** + * @inheritDoc + */ + public function withExpired(): static + { + $new = clone $this; + $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. + * + * This method will expand serialized complex data, + * on first use. + * + * @param string $path Path to check + * @return bool + */ + 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); + } + + /** + * Create a new cookie with updated data. + * + * @param string $path Path to write to + * @param mixed $value Value to write + * @return static + */ + 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; + } + + /** + * Create a new cookie without a specific path + * + * @param string $path Path to remove + * @return static + */ + 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; + } + + /** + * Read data from the cookie + * + * This method will expand serialized complex data, + * on first use. + * + * @param string|null $path Path to read the data from + * @return mixed + */ + 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); + } + + if ($path === null) { + return $this->value; + } + + assert(is_array($this->value), '$value is not an array'); + + return Hash::get($this->value, $path); + } + + /** + * Checks if the cookie value was expanded + * + * @return bool + */ + 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. + */ + protected function _flatten(array $array): string + { + return json_encode($array, JSON_THROW_ON_ERROR); + } + + /** + * Explode method to return array from string set in CookieComponent::_flatten() + * Maintains reading backwards compatibility with 1.x CookieComponent::_flatten(). + * + * @param string $string A string containing JSON encoded data, or a bare string. + * @return array|string Map of key and values + */ + protected function _expand(string $string): array|string + { + $this->isExpanded = true; + $first = substr($string, 0, 1); + if ($first === '{' || $first === '[') { + return json_decode($string, true) ?? $string; + } + + $array = []; + foreach (explode(',', $string) as $pair) { + $key = explode('|', $pair); + if (!isset($key[1])) { + return $key[0]; + } + $array[$key[0]] = $key[1]; + } + + return $array; + } +} diff --git a/src/Http/Cookie/CookieCollection.php b/src/Http/Cookie/CookieCollection.php new file mode 100644 index 00000000000..82abdef2b98 --- /dev/null +++ b/src/Http/Cookie/CookieCollection.php @@ -0,0 +1,375 @@ + + */ +class CookieCollection implements IteratorAggregate, Countable +{ + /** + * Cookie objects + * + * @var array + */ + protected array $cookies = []; + + /** + * Constructor + * + * @param array<\Cake\Http\Cookie\CookieInterface> $cookies Array of cookie objects + */ + public function __construct(array $cookies = []) + { + $this->checkCookies($cookies); + foreach ($cookies as $cookie) { + $this->cookies[$cookie->getId()] = $cookie; + } + } + + /** + * Create a Cookie Collection from an array of Set-Cookie Headers + * + * @param array $header The array of set-cookie header values. + * @param array $defaults The defaults attributes. + * @return static + */ + public static function createFromHeader(array $header, array $defaults = []): static + { + $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); + } + + /** + * Create a new collection from the cookies in a ServerRequest + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request to extract cookie data from + * @return static + */ + public static function createFromServerRequest(ServerRequestInterface $request): static + { + $data = $request->getCookieParams(); + $cookies = []; + foreach ($data as $name => $value) { + $cookies[] = new Cookie((string)$name, $value); + } + + return new static($cookies); + } + + /** + * Get the number of cookies in the collection. + * + * @return int + */ + public function count(): int + { + return count($this->cookies); + } + + /** + * Add a cookie and get an updated collection. + * + * Cookies are stored by id. This means that there can be duplicate + * cookies if a cookie collection is used for cookies across multiple + * domains. This can impact how get(), has() and remove() behave. + * + * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie instance to add. + * @return static + */ + public function add(CookieInterface $cookie): static + { + $new = clone $this; + $new->cookies[$cookie->getId()] = $cookie; + + return $new; + } + + /** + * 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(string $name): ?CookieInterface + { + $key = mb_strtolower($name); + foreach ($this->cookies as $cookie) { + if (mb_strtolower($cookie->getName()) === $key) { + return $cookie; + } + } + + return null; + } + + /** + * 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 __isset(string $name): bool + { + return $this->__get($name) !== null; + } + + /** + * Create a new collection with all cookies matching $name removed. + * + * If the cookie is not in the collection, this method will do nothing. + * + * @param string $name The name of the cookie to remove. + * @return static + */ + public function remove(string $name): static + { + $new = clone $this; + $key = mb_strtolower($name); + foreach ($new->cookies as $i => $cookie) { + if (mb_strtolower($cookie->getName()) === $key) { + unset($new->cookies[$i]); + } + } + + return $new; + } + + /** + * Checks if only valid cookie objects are in the array + * + * @param array<\Cake\Http\Cookie\CookieInterface> $cookies Array of cookie objects + * @return void + * @throws \InvalidArgumentException + */ + protected function checkCookies(array $cookies): void + { + foreach ($cookies as $index => $cookie) { + if (!$cookie instanceof CookieInterface) { + throw new InvalidArgumentException( + sprintf( + 'Expected `%s[]` as $cookies but instead got `%s` at index %d', + static::class, + get_debug_type($cookie), + $index, + ), + ); + } + } + } + + /** + * Gets the iterator + * + * @return \Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->cookies); + } + + /** + * Add cookies that match the path/domain/expiration to the request. + * + * This allows CookieCollections to be used as a 'cookie jar' in an HTTP client + * situation. Cookies that match the request's domain + path that are not expired + * when this method is called will be applied to the request. + * + * @param \Psr\Http\Message\RequestInterface $request The request to update. + * @param array $extraCookies Associative array of additional cookies to add into the request. This + * 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 = []): RequestInterface + { + $uri = $request->getUri(); + $cookies = $this->findMatchingCookies( + $uri->getScheme(), + $uri->getHost(), + $uri->getPath() ?: '/', + ); + $cookies = $extraCookies + $cookies; + $cookiePairs = []; + foreach ($cookies as $key => $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 (!$cookiePairs) { + return $request; + } + + return $request->withHeader('Cookie', implode('; ', $cookiePairs)); + } + + /** + * Find cookies matching the scheme, host, and path + * + * @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 + */ + protected function findMatchingCookies(string $scheme, string $host, string $path): array + { + $out = []; + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + foreach ($this->cookies as $cookie) { + if ($scheme === 'http' && $cookie->isSecure()) { + continue; + } + if (!str_starts_with($path, $cookie->getPath())) { + continue; + } + $domain = $cookie->getDomain(); + $leadingDot = str_starts_with($domain, '.'); + if ($leadingDot) { + $domain = ltrim($domain, '.'); + } + + if ($cookie->isExpired($now)) { + continue; + } + + $pattern = '/' . preg_quote($domain, '/') . '$/'; + if (!preg_match($pattern, $host)) { + continue; + } + + $out[$cookie->getName()] = $cookie->getValue(); + } + + return $out; + } + + /** + * Create a new collection that includes cookies from the response. + * + * @param \Psr\Http\Message\ResponseInterface $response Response to extract cookies from. + * @param \Psr\Http\Message\RequestInterface $request Request to get cookie context from. + * @return static + */ + public function addFromResponse(ResponseInterface $response, RequestInterface $request): static + { + $uri = $request->getUri(); + $host = $uri->getHost(); + $path = $uri->getPath() ?: '/'; + + $cookies = static::createFromHeader( + $response->getHeader('Set-Cookie'), + ['domain' => $host, 'path' => $path], + ); + $new = clone $this; + foreach ($cookies as $cookie) { + $new->cookies[$cookie->getId()] = $cookie; + } + $new->removeExpiredCookies($host, $path); + + return $new; + } + + /** + * Remove expired cookies from the collection. + * + * @param string $host The host to check for expired cookies on. + * @param string $path The path to check for expired cookies on. + * @return void + */ + 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) { + if (!$cookie->isExpired($time)) { + continue; + } + $pathMatches = str_starts_with($path, $cookie->getPath()); + $hostMatches = preg_match($hostPattern, $cookie->getDomain()); + if ($pathMatches && $hostMatches) { + unset($this->cookies[$i]); + } + } + } +} diff --git a/src/Http/Cookie/CookieInterface.php b/src/Http/Cookie/CookieInterface.php new file mode 100644 index 00000000000..c32ff0fc46d --- /dev/null +++ b/src/Http/Cookie/CookieInterface.php @@ -0,0 +1,262 @@ + + */ + public const SAMESITE_VALUES = [ + self::SAMESITE_LAX, + self::SAMESITE_STRICT, + self::SAMESITE_NONE, + ]; + + /** + * Sets the cookie name + * + * @param string $name Name of the cookie + * @return static + */ + public function withName(string $name): static; + + /** + * Gets the cookie name + * + * @return string + */ + public function getName(): string; + + /** + * Gets the cookie value + * + * @return array|string + */ + public function getValue(): array|string; + + /** + * Gets the cookie value as scalar. + * + * This will collapse any complex data in the cookie with json_encode() + * + * @return string + */ + public function getScalarValue(): string; + + /** + * Create a cookie with an updated value. + * + * @param array|string|float|int|bool $value Value of the cookie to set + * @return static + */ + public function withValue(array|string|float|int|bool $value): static; + + /** + * Get the id for a cookie + * + * Cookies are unique across name, domain, path tuples. + * + * @return string + */ + public function getId(): string; + + /** + * Get the path attribute. + * + * @return string + */ + public function getPath(): string; + + /** + * Create a new cookie with an updated path + * + * @param string $path Sets the path + * @return static + */ + public function withPath(string $path): static; + + /** + * Get the domain attribute. + * + * @return string + */ + public function getDomain(): string; + + /** + * Create a cookie with an updated domain + * + * @param string $domain Domain to set + * @return static + */ + public function withDomain(string $domain): static; + + /** + * Get the current expiry time + * + * @return \DateTimeInterface|null Timestamp of expiry or null + */ + public function getExpiry(): ?DateTimeInterface; + + /** + * Get the timestamp from the expiration time + * + * @return int|null The expiry time as an integer. + */ + public function getExpiresTimestamp(): ?int; + + /** + * Builds the expiration value part of the header string + * + * @return string + */ + public function getFormattedExpires(): string; + + /** + * Create a cookie with an updated expiration date + * + * @param \DateTimeInterface $dateTime Date time object + * @return static + */ + public function withExpiry(DateTimeInterface $dateTime): static; + + /** + * Create a new cookie that will virtually never expire. + * + * @return static + */ + public function withNeverExpire(): static; + + /** + * Create a new cookie that will expire/delete the cookie from the browser. + * + * This is done by setting the expiration time to 1 year ago + * + * @return static + */ + public function withExpired(): static; + + /** + * Check if a cookie is expired when compared to $time + * + * Cookies without an expiration date always return false. + * + * @param \DateTimeInterface|null $time The time to test against. Defaults to 'now' in UTC. + * @return bool + */ + public function isExpired(?DateTimeInterface $time = null): bool; + + /** + * Check if the cookie is HTTP only + * + * @return bool + */ + public function isHttpOnly(): bool; + + /** + * Create a cookie with HTTP Only updated + * + * @param bool $httpOnly HTTP Only + * @return static + */ + public function withHttpOnly(bool $httpOnly): static; + + /** + * Check if the cookie is secure + * + * @return bool + */ + public function isSecure(): bool; + + /** + * Create a cookie with Secure updated + * + * @param bool $secure Secure attribute value + * @return static + */ + 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(): 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/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 new file mode 100644 index 00000000000..17875c2acd9 --- /dev/null +++ b/src/Http/Middleware/CsrfProtectionMiddleware.php @@ -0,0 +1,408 @@ +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 implements MiddlewareInterface +{ + /** + * Config for the CSRF handling. + * + * - `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 + */ + protected array $_config = [ + 'cookieName' => 'csrfToken', + 'expiry' => 0, + 'secure' => false, + 'httponly' => false, + 'samesite' => null, + '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 = 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 int + */ + public const TOKEN_WITH_CHECKSUM_LENGTH = 56; + + /** + * 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); + } + 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 (is_string($cookieData) && $cookieData !== '') { + try { + $request = $request->withAttribute('csrfToken', $this->saltToken($cookieData)); + } catch (InvalidArgumentException) { + $cookieData = null; + } + } + + if ($method === 'GET' && $cookieData === null) { + $token = $this->createToken(); + $request = $request->withAttribute('csrfToken', $this->saltToken($token)); + $response = $handler->handle($request); + + return $this->_addTokenCookie($token, $request, $response); + } + + if ($hasData) { + $this->_validateToken($request); + $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; + } + + /** + * 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 + */ + public function createToken(): string + { + $value = Security::randomBytes(static::TOKEN_VALUE_LENGTH); + + return base64_encode($value . hash_hmac('sha1', $value, Security::getSalt())); + } + + /** + * 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 + { + 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. + */ + public function unsaltToken(string $token): string + { + 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); + + $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 \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( + 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 \Psr\Http\Message\ServerRequestInterface $request The request to validate against. + * @return void + * @throws \Cake\Http\Exception\InvalidCsrfTokenException When the CSRF token is invalid or missing. + */ + protected function _validateToken(ServerRequestInterface $request): void + { + $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()); + + throw $exception; + } + + $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 new file mode 100644 index 00000000000..fcd63f35c9c --- /dev/null +++ b/src/Http/Middleware/EncryptedCookieMiddleware.php @@ -0,0 +1,172 @@ + + */ + protected array $cookieNames; + + /** + * Encryption key to use. + * + * @var string + */ + protected string $key; + + /** + * Encryption type. + * + * @var string + */ + protected string $cipherType; + + /** + * Constructor + * + * @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'. + */ + public function __construct(array $cookieNames, string $key, string $cipherType = 'aes') + { + $this->cookieNames = $cookieNames; + $this->key = $key; + $this->cipherType = $cipherType; + } + + /** + * Apply cookie encryption/decryption. + * + * @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 ($request->getCookieParams()) { + $request = $this->decodeCookies($request); + } + + $response = $handler->handle($request); + if ($response->hasHeader('Set-Cookie')) { + $response = $this->encodeSetCookieHeader($response); + } + if ($response instanceof Response) { + return $this->encodeCookies($response); + } + + return $response; + } + + /** + * Fetch the cookie encryption key. + * + * Part of the CookieCryptTrait implementation. + * + * @return string + */ + protected function _getCookieEncryptionKey(): string + { + return $this->key; + } + + /** + * Decode cookies from the request. + * + * @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): ServerRequestInterface + { + $cookies = $request->getCookieParams(); + foreach ($this->cookieNames as $name) { + if (isset($cookies[$name])) { + $cookies[$name] = $this->_decrypt($cookies[$name], $this->cipherType, $this->key); + } + } + + return $request->withCookieParams($cookies); + } + + /** + * Encode cookies from a response's CookieCollection. + * + * @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): Response + { + 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)); + } + } + + return $response; + } + + /** + * Encode cookies from a response's Set-Cookie header + * + * @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): ResponseInterface + { + $cookies = CookieCollection::createFromHeader($response->getHeader('Set-Cookie')); + $header = []; + foreach ($cookies as $cookie) { + if (in_array($cookie->getName(), $this->cookieNames, true)) { + $value = $this->_encrypt($cookie->getValue(), $this->cipherType); + $cookie = $cookie->withValue($value); + } + $header[] = $cookie->toHeaderValue(); + } + + return $response->withHeader('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..fe201b50bca --- /dev/null +++ b/src/Http/Middleware/RateLimitMiddleware.php @@ -0,0 +1,461 @@ + + */ + 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' => 'x-forwarded-for', + '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(); + + if (is_array($this->config['ipHeader'])) { + foreach ($this->config['ipHeader'] as $header) { + $headerKey = 'HTTP_' . strtoupper(str_replace('-', '_', $header)); + if (!empty($params[$headerKey])) { + $ips = explode(',', $params[$headerKey]); + + return trim($ips[0]); + } + } + } elseif (is_string($this->config['ipHeader'])) { + $headerKey = 'HTTP_' . strtoupper(str_replace('-', '_', $this->config['ipHeader'])); + if (!empty($params[$headerKey])) { + $ips = explode(',', $params[$headerKey]); + + 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 new file mode 100644 index 00000000000..26816621b12 --- /dev/null +++ b/src/Http/Middleware/SecurityHeadersMiddleware.php @@ -0,0 +1,281 @@ + + */ + protected array $headers = []; + + /** + * X-Content-Type-Options + * + * Sets the header value for it to 'nosniff' + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + * @return $this + */ + public function noSniff() + { + $this->headers['x-content-type-options'] = self::NOSNIFF; + + return $this; + } + + /** + * X-Download-Options + * + * Sets the header value for it to 'noopen' + * + * @link https://msdn.microsoft.com/en-us/library/jj542450(v=vs.85).aspx + * @return $this + */ + public function noOpen() + { + $this->headers['x-download-options'] = self::NOOPEN; + + return $this; + } + + /** + * 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' + * @return $this + */ + public function setReferrerPolicy(string $policy = self::SAME_ORIGIN) + { + $available = [ + 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); + $this->headers['referrer-policy'] = $policy; + + return $this; + } + + /** + * X-Frame-Options + * + * @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|null $url URL if mode is `allow-from` + * @return $this + */ + public function setXFrameOptions(string $option = self::SAMEORIGIN, ?string $url = null) + { + $this->checkValues($option, [self::DENY, self::SAMEORIGIN, self::ALLOW_FROM]); + + 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; + } + + $this->headers['x-frame-options'] = $option; + + return $this; + } + + /** + * 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://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(string $mode = self::XSS_BLOCK) + { + if ($mode === self::XSS_BLOCK) { + $mode = self::XSS_ENABLED_BLOCK; + } + + $this->checkValues($mode, [self::XSS_ENABLED, self::XSS_DISABLED, self::XSS_ENABLED_BLOCK]); + $this->headers['x-xss-protection'] = $mode; + + return $this; + } + + /** + * X-Permitted-Cross-Domain-Policies + * + * @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(string $policy = self::ALL) + { + $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 + * @return void + */ + protected function checkValues(string $value, array $allowed): void + { + 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.', + $value, + implode(', ', $allowed), + )); + } + } + + /** + * Serve assets if the path matches one. + * + * @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 + { + $response = $handler->handle($request); + foreach ($this->headers as $header => $value) { + $response = $response->withHeader($header, $value); + } + + return $response; + } +} 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 new file mode 100644 index 00000000000..e565fed18d6 --- /dev/null +++ b/src/Http/MiddlewareQueue.php @@ -0,0 +1,323 @@ + + */ +class MiddlewareQueue implements Countable, SeekableIterator +{ + /** + * Internal position for iterator. + * + * @var int + */ + protected int $position = 0; + + /** + * The queue of middlewares. + * + * @var array + */ + protected array $queue = []; + + /** + * @var \Cake\Core\ContainerInterface|null + */ + protected ?ContainerInterface $container; + + /** + * Constructor + * + * @param array $middleware The list of middleware to append. + * @param \Cake\Core\ContainerInterface|null $container Container instance. + */ + public function __construct(array $middleware = [], ?ContainerInterface $container = null) + { + $this->container = $container; + $this->queue = $middleware; + } + + /** + * Resolve middleware name to a PSR 15 compliant middleware instance. + * + * @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(MiddlewareInterface|Closure|string $middleware): MiddlewareInterface + { + 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 ($middleware instanceof MiddlewareInterface) { + return $middleware; + } + + return new ClosureDecoratorMiddleware($middleware); + } + + /** + * Append a middleware to the end of the queue. + * + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|array|string $middleware The middleware(s) to append. + * @return $this + */ + public function add(MiddlewareInterface|Closure|array|string $middleware) + { + if (is_array($middleware)) { + $this->queue = array_merge($this->queue, $middleware); + + return $this; + } + $this->queue[] = $middleware; + + return $this; + } + + /** + * Alias for MiddlewareQueue::add(). + * + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|array|string $middleware The middleware(s) to append. + * @return $this + * @see MiddlewareQueue::add() + */ + public function push(MiddlewareInterface|Closure|array|string $middleware) + { + return $this->add($middleware); + } + + /** + * Prepend a middleware to the start of the queue. + * + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|array|string $middleware The middleware(s) to prepend. + * @return $this + */ + public function prepend(MiddlewareInterface|Closure|array|string $middleware) + { + if (is_array($middleware)) { + $this->queue = array_merge($middleware, $this->queue); + + return $this; + } + array_unshift($this->queue, $middleware); + + return $this; + } + + /** + * Insert a middleware at a specific index. + * + * 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 \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to insert. + * @return $this + */ + public function insertAt(int $index, MiddlewareInterface|Closure|string $middleware) + { + array_splice($this->queue, $index, 0, [$middleware]); + + return $this; + } + + /** + * Insert a middleware before the first matching class. + * + * Finds the index of the first middleware that matches the provided class, + * and inserts the supplied middleware before it. + * + * @param string $class The classname to insert the middleware before. + * @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(string $class, MiddlewareInterface|Closure|string $middleware) + { + $found = false; + $i = 0; + foreach ($this->queue as $i => $object) { + if ( + ( + is_string($object) + && $object === $class + ) + || is_a($object, $class) + ) { + $found = true; + break; + } + } + if ($found) { + return $this->insertAt($i, $middleware); + } + 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 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 \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to insert. + * @return $this + */ + public function insertAfter(string $class, MiddlewareInterface|Closure|string $middleware) + { + $found = false; + $i = 0; + foreach ($this->queue as $i => $object) { + if ( + ( + is_string($object) + && $object === $class + ) + || is_a($object, $class) + ) { + $found = true; + break; + } + } + if ($found) { + return $this->insertAt($i + 1, $middleware); + } + + return $this->add($middleware); + } + + /** + * Get the number of connected middleware layers. + * + * Implement the Countable interface. + * + * @return int + */ + 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 @@ + + */ + protected array $_statusCodes = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-status', + 208 => 'Already Reported', + 226 => 'IM used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => '(Unused)', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => "I'm a teapot", + 421 => 'Misdirected Request', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 444 => 'Connection Closed Without Response', + 451 => 'Unavailable For Legal Reasons', + 499 => 'Client Closed Request', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'Unsupported Version', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required', + 599 => 'Network Connect Timeout Error', + ]; + + /** + * Status code to send to the client + * + * @var int + */ + protected int $_status = 200; + + /** + * File object for file to be read out as response + * + * @var \SplFileInfo|null + */ + protected ?SplFileInfo $_file = null; + + /** + * File range. Used for requesting ranges of files. + * + * @var array + */ + protected array $_fileRange = []; + + /** + * The charset the response body is encoded with + * + * @var string + */ + protected string $_charset = 'UTF-8'; + + /** + * Holds all the cache directives that will be converted + * into headers when sending the response + * + * @var array + */ + protected array $_cacheDirectives = []; + + /** + * Collection of cookies to send to the client + * + * @var \Cake\Http\Cookie\CookieCollection + */ + protected CookieCollection $_cookies; + + /** + * Reason Phrase + * + * @var string + */ + protected string $_reasonPhrase = 'OK'; + + /** + * Stream mode options. + * + * @var string + */ + protected string $_streamMode = 'wb+'; + + /** + * Stream target or resource object. + * + * @var resource|string + */ + protected $_streamTarget = 'php://memory'; + + /** + * Constructor + * + * @param array $options list of parameters to setup the response. Possible values are: + * + * - body: the response text that should be sent to the client + * - 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 = []) + { + $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'); + } + $this->stream = $options['stream']; + } else { + $this->_createStream(); + } + if (isset($options['body'])) { + $this->stream->write($options['body']); + } + if (isset($options['status'])) { + $this->_setStatus($options['status']); + } + $options['charset'] ??= Configure::read('App.encoding'); + $this->_charset = $options['charset']; + $type = 'text/html'; + if (isset($options['type'])) { + $type = $this->resolveType($options['type']); + } + $this->_setContentType($type); + $this->_cookies = new CookieCollection(); + } + + /** + * Creates the stream object. + * + * @return void + */ + protected function _createStream(): void + { + $this->stream = new Stream($this->_streamTarget, $this->_streamMode); + } + + /** + * 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(string $type): void + { + if (in_array($this->_status, [304, 204], true)) { + $this->_clearHeader('Content-Type'); + + return; + } + $allowed = [ + 'application/javascript', 'application/xml', 'application/rss+xml', + ]; + + $charset = false; + if ( + $this->_charset && + ( + str_starts_with($type, 'text/') || + in_array($type, $allowed, true) + ) + ) { + $charset = true; + } + + if ($charset && !str_contains($type, ';')) { + $this->_setHeader('Content-Type', "{$type}; charset={$this->_charset}"); + } else { + $this->_setHeader('Content-Type', $type); + } + } + + /** + * Return an instance with an updated location header. + * + * If the current status code is 200, it will be replaced + * with 302. + * + * @param string $url The location to redirect to. + * @return static A new response with the Location header set. + */ + public function withLocation(string $url): static + { + $new = $this->withHeader('Location', $url); + if ($new->_status === 200) { + $new->_status = 302; + } + + return $new; + } + + /** + * Sets a header. + * + * @phpstan-param non-empty-string $header + * @param string $header Header key. + * @param string $value Header value. + * @return void + */ + protected function _setHeader(string $header, string $value): void + { + $normalized = strtolower($header); + $this->headerNames[$normalized] = $header; + $this->headers[$header] = [$value]; + } + + /** + * Clear header + * + * @phpstan-param non-empty-string $header + * @param string $header Header key. + * @return void + */ + protected function _clearHeader(string $header): void + { + $normalized = strtolower($header); + if (!isset($this->headerNames[$normalized])) { + return; + } + $original = $this->headerNames[$normalized]; + unset($this->headerNames[$normalized], $this->headers[$original]); + } + + /** + * Gets the response status code. + * + * The status code is a 3-digit integer result code of the server's attempt + * to understand and satisfy the request. + * + * @return int Status code. + */ + public function getStatusCode(): int + { + return $this->_status; + } + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * 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 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(int $code, string $reasonPhrase = ''): static + { + $new = clone $this; + $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. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @link https://tools.ietf.org/html/rfc7231#section-6 + * @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(): string + { + return $this->_reasonPhrase; + } + + /** + * Sets a content type definition into the map. + * + * E.g.: setTypeMap('xhtml', ['application/xhtml+xml', 'application/xhtml']) + * + * This is needed for RequestHandlerComponent and recognition of types. + * + * @param string $type Content type. + * @param array|string $mimeType Definition of the mime type. + * @return void + */ + public function setTypeMap(string $type, array|string $mimeType): void + { + MimeType::setMimeTypes($type, $mimeType); + } + + /** + * Returns the current content type. + * + * @return string + */ + public function getType(): string + { + $header = $this->getHeaderLine('Content-Type'); + if (str_contains($header, ';')) { + return explode(';', $header)[0]; + } + + return $header; + } + + /** + * Get an updated response with the content type set. + * + * 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. + * + * @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(string $contentType): static + { + $mappedType = $this->resolveType($contentType); + $new = clone $this; + $new->_setContentType($mappedType); + + return $new; + } + + /** + * Translate and validate content-types. + * + * @param string $contentType The content-type or type alias. + * @return string The resolved content-type + * @throws \InvalidArgumentException When an invalid content-type or alias is used. + */ + protected function resolveType(string $contentType): string + { + if (str_contains($contentType, '/')) { + return $contentType; + } + + $mimeType = MimeType::getMimeType($contentType); + if ($mimeType === null) { + throw new InvalidArgumentException(sprintf('`%s` is an invalid content type.', $contentType)); + } + + return $mimeType; + } + + /** + * Returns the mime type definition for an alias + * + * e.g `getMimeType('pdf'); // returns 'application/pdf'` + * + * @param string $alias the content type alias to map + * @return array|string|false String mapped mime type or false if $alias is not mapped + */ + public function getMimeType(string $alias): array|string|false + { + $mimeTypes = MimeType::getMimeTypes($alias); + + if ($mimeTypes === null) { + return false; + } + + return count($mimeTypes) === 1 ? $mimeTypes[0] : $mimeTypes; + } + + /** + * Maps a content-type back to an alias + * + * e.g `mapType('application/pdf'); // returns 'pdf'` + * + * @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(array|string $ctype): array|string|null + { + if (is_array($ctype)) { + return array_map($this->mapType(...), $ctype); + } + + return MimeType::getExtension($ctype); + } + + /** + * Returns the current charset. + * + * @return string + */ + public function getCharset(): string + { + return $this->_charset; + } + + /** + * Get a new instance with an updated charset. + * + * @param string $charset Character set string. + * @return static + */ + public function withCharset(string $charset): static + { + $new = clone $this; + $new->_charset = $charset; + $new->_setContentType($this->getType()); + + return $new; + } + + /** + * Create a new instance with headers to instruct the client to not cache the response + * + * @return static + */ + public function withDisabledCache(): static + { + return $this->withHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT') + ->withHeader('Last-Modified', CakeDateTime::parse(time())->toRfc7231String()) + ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'); + } + + /** + * Create a new instance with the headers to enable client caching. + * + * @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(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', CakeDateTime::parse(time())->toRfc7231String()) + ->withModified($since) + ->withExpires($time) + ->withSharable(true) + ->withMaxAge($time - time()); + } + + /** + * 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(bool $public, ?int $time = null): static + { + $new = clone $this; + unset($new->_cacheDirectives['private'], $new->_cacheDirectives['public']); + + $key = $public ? 'public' : 'private'; + $new->_cacheDirectives[$key] = true; + + if ($time !== null) { + $new->_cacheDirectives['max-age'] = $time; + } + $new->_setCacheControl(); + + return $new; + } + + /** + * Create a new instance with 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). + * + * @param int $seconds The number of seconds for shared max-age + * @return static + */ + public function withSharedMaxAge(int $seconds): static + { + $new = clone $this; + $new->_cacheDirectives['s-maxage'] = $seconds; + $new->_setCacheControl(); + + return $new; + } + + /** + * Create an instance with Cache-Control max-age directive set. + * + * 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. + * + * @param int $seconds The seconds a cached response can be considered valid + * @return static + */ + public function withMaxAge(int $seconds): static + { + $new = clone $this; + $new->_cacheDirectives['max-age'] = $seconds; + $new->_setCacheControl(); + + return $new; + } + + /** + * Create an instance with Cache-Control must-revalidate directive set. + * + * 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. + * + * @param bool $enable If boolean sets or unsets the directive. + * @return static + */ + public function withMustRevalidate(bool $enable): static + { + $new = clone $this; + if ($enable) { + $new->_cacheDirectives['must-revalidate'] = true; + } else { + unset($new->_cacheDirectives['must-revalidate']); + } + $new->_setCacheControl(); + + return $new; + } + + /** + * Helper method to generate a valid Cache-Control header from the options set + * in other methods + * + * @return void + */ + protected function _setCacheControl(): void + { + $control = ''; + foreach ($this->_cacheDirectives as $key => $val) { + $control .= $val === true ? $key : sprintf('%s=%s', $key, $val); + $control .= ', '; + } + $control = rtrim($control, ', '); + $this->_setHeader('Cache-Control', $control); + } + + /** + * 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: + * + * ``` + * // Will Expire the response cache now + * $response->withExpires('now') + * + * // Will set the expiration in next 24 hours + * $response->withExpires(new DateTime('+1 day')) + * ``` + * + * @param \DateTimeInterface|string|int|null $time Valid time string or \DateTime instance. + * @return static + */ + public function withExpires(DateTimeInterface|string|int|null $time): static + { + 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 \DateTimeInterface|string|int $time Valid time string or \DateTime instance. + * @return static + */ + public function withModified(DateTimeInterface|string|int $time): static + { + return $this->withHeader('Last-Modified', $this->getRfc7231($time)); + } + + /** + * Create a new instance as 'not modified' + * + * This will remove any body contents set the status code + * to "304" and removing headers that describe + * a response body. + * + * @return static + */ + public function withNotModified(): static + { + $new = $this->withStatus(304); + $new->_createStream(); + $remove = [ + 'Allow', + 'Content-Encoding', + 'Content-Language', + 'Content-Length', + 'Content-MD5', + 'Content-Type', + 'Last-Modified', + ]; + foreach ($remove as $header) { + $new = $new->withoutHeader($header); + } + + return $new; + } + + /** + * Create a new instance with the Vary header set. + * + * 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 array|string $cacheVariances A single Vary string or an array + * containing the list for variances. + * @return static + */ + public function withVary(array|string $cacheVariances): static + { + return $this->withHeader('Vary', (array)$cacheVariances); + } + + /** + * Create a new instance with the Etag header set. + * + * 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 + * that makes the response unique. + * + * The second parameter is used to inform clients that the content has + * changed, but semantically it is equivalent to existing cached values. Consider + * a page with a hit counter, two different page views are equivalent, but + * they differ by a few bytes. This permits the Client to decide whether they should + * use the cached data. + * + * @param string $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. Defaults to false + * @return static + */ + public function withEtag(string $hash, bool $weak = false): static + { + $hash = sprintf('%s"%s"', $weak ? 'W/' : '', $hash); + + return $this->withHeader('Etag', $hash); + } + + /** + * Returns a DateTime object initialized at the $time param and using UTC + * as timezone + * + * @param \DateTimeInterface|string|int|null $time Valid time string or \DateTimeInterface instance. + * @return \DateTimeInterface + */ + protected function _getUTCDate(DateTimeInterface|string|int|null $time = null): DateTimeInterface + { + 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 ?? 'now'); + } + + /** @phpstan-ignore-next-line */ + return $result->setTimezone(new DateTimeZone('UTC')); + } + + /** + * Converts the time zone to GMT and returns a string in RFC7231 format. + * This replaced the deprecated and broken ``DATE_RFC7231`` formatting constant. + * + * @param \DateTimeInterface|string|int|null $time + * @return string + */ + protected function getRfc7231(DateTimeInterface|string|int|null $time = null): string + { + return $this->_getUTCDate($time)->format('D, d M Y H:i:s \G\M\T'); + } + + /** + * Sets the correct output buffering handler to send a compressed response. Responses will + * be compressed with zlib, if the extension is available. + * + * @return bool false if client does not accept compressed responses or no handler is available, true otherwise + */ + public function compress(): bool + { + return ini_get('zlib.output_compression') !== '1' && + extension_loaded('zlib') && + str_contains((string)env('HTTP_ACCEPT_ENCODING'), 'gzip') && + ob_start('ob_gzhandler'); + } + + /** + * Returns whether the resulting output will be compressed by PHP + * + * @return bool + */ + public function outputCompressed(): bool + { + return str_contains((string)env('HTTP_ACCEPT_ENCODING'), 'gzip') + && (ini_get('zlib.output_compression') === '1' || in_array('ob_gzhandler', ob_list_handlers(), true)); + } + + /** + * Create a new instance with the Content-Disposition header set. + * + * @param string $filename The name of the file as the browser will download the response + * @return static + */ + public function withDownload(string $filename): static + { + return $this->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); + } + + /** + * Create a new response with the Content-Length header set. + * + * @param string|int $bytes Number of bytes + * @return static + */ + public function withLength(string|int $bytes): static + { + return $this->withHeader('Content-Length', (string)$bytes); + } + + /** + * 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 string $url The LinkHeader url. + * @param array $options The LinkHeader params. + * @return static + * @since 3.6.0 + */ + public function withAddedLink(string $url, array $options = []): static + { + $params = []; + foreach ($options as $key => $option) { + $params[] = $key . '="' . $option . '"'; + } + + $param = ''; + if ($params) { + $param = '; ' . implode('; ', $params); + } + + 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. + * + * 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 is 'modified' based on cache headers. + */ + public function isNotModified(ServerRequest $request): bool + { + $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); + } + + $modifiedSince = $request->getHeaderLine('If-Modified-Since'); + $timeMatches = null; + if ($modifiedSince && $this->hasHeader('Last-Modified')) { + $timeMatches = strtotime($this->getHeaderLine('Last-Modified')) === strtotime($modifiedSince); + } + if ($etagMatches === null && $timeMatches === null) { + return false; + } + + return $etagMatches !== false && $timeMatches !== false; + } + + /** + * String conversion. Fetches the response body as a string. + * Does *not* send headers. + * If body is a callable, a blank string is returned. + * + * @return string + */ + public function __toString(): string + { + $this->stream->rewind(); + + return $this->stream->getContents(); + } + + /** + * Create a new response with a cookie set. + * + * ### Example + * + * ``` + * // add a cookie object + * $response = $response->withCookie(new Cookie('remember_me', 1)); + * ``` + * + * @param \Cake\Http\Cookie\CookieInterface $cookie cookie object + * @return static + */ + public function withCookie(CookieInterface $cookie): static + { + $new = clone $this; + $new->_cookies = $new->_cookies->add($cookie); + + return $new; + } + + /** + * Create a new response with an expired cookie set. + * + * ### Example + * + * ``` + * // add a cookie object + * $response = $response->withExpiredCookie(new Cookie('remember_me')); + * ``` + * + * @param \Cake\Http\Cookie\CookieInterface $cookie cookie object + * @return static + */ + public function withExpiredCookie(CookieInterface $cookie): static + { + $cookie = $cookie->withExpired(); + + $new = clone $this; + $new->_cookies = $new->_cookies->add($cookie); + + return $new; + } + + /** + * Read a single cookie from the response. + * + * This method provides read access to pending cookies. It will + * not read the `Set-Cookie` header if set. + * + * @param string $name The cookie name you want to read. + * @return array|null Either the cookie data or null + */ + public function getCookie(string $name): ?array + { + if (!$this->_cookies->has($name)) { + return null; + } + + return $this->_cookies->get($name)->toArray(); + } + + /** + * Get all cookies in the response. + * + * Returns an associative array of cookie name => cookie data. + * + * @return array + */ + public function getCookies(): array + { + $out = []; + foreach ($this->_cookies as $cookie) { + $out[$cookie->getName()] = $cookie->toArray(); + } + + return $out; + } + + /** + * Get the CookieCollection from the response + * + * @return \Cake\Http\Cookie\CookieCollection + */ + public function getCookieCollection(): CookieCollection + { + return $this->_cookies; + } + + /** + * Get a new instance with provided cookie collection. + * + * @param \Cake\Http\Cookie\CookieCollection $cookieCollection Cookie collection to set. + * @return static + */ + public function withCookieCollection(CookieCollection $cookieCollection): static + { + $new = clone $this; + $new->_cookies = $cookieCollection; + + return $new; + } + + /** + * 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'); + + return new CorsBuilder($this, $origin, $https); + } + + /** + * Create a new instance that is based on a file. + * + * This method will augment both the body and a number of related headers. + * + * 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 inline. + * + * @param string $path Absolute path to file. + * @param array $options Options See above. + * @return static + * @throws \Cake\Http\Exception\NotFoundException + */ + public function withFile(string $path, array $options = []): static + { + $file = $this->validateFile($path); + $options += [ + 'name' => null, + '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($mapped); + } + + $fileSize = $file->getSize(); + if ($options['download']) { + $name = $options['name'] ?: $file->getFileName(); + $new = $new->withDownload($name) + ->withHeader('Content-Transfer-Encoding', 'binary'); + } + + $new = $new->withHeader('Accept-Ranges', 'bytes'); + $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->getPathname(), 'rb'); + + return $new; + } + + /** + * Convenience method to set a string into the response body + * + * @param string|null $string The string to be sent + * @return static + */ + public function withStringBody(?string $string): static + { + $new = clone $this; + $new->_createStream(); + $new->stream->write((string)$string); + + return $new; + } + + /** + * Validate a file path is a valid response body. + * + * @param string $path The path to the file. + * @throws \Cake\Http\Exception\NotFoundException + * @return \SplFileInfo + */ + protected function validateFile(string $path): SplFileInfo + { + if (str_contains($path, '../') || str_contains($path, '..\\')) { + throw new NotFoundException(__d('cake', 'The requested file contains `..` and will not be read.')); + } + + $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)); + } + throw new NotFoundException(__d('cake', 'The requested file was not found')); + } + + return $file; + } + + /** + * Get the current file if one exists. + * + * @return \SplFileInfo|null The file to use in the response or null + */ + public function getFile(): ?SplFileInfo + { + return $this->_file; + } + + /** + * Apply a file range to a file and set the end offset. + * + * If an invalid range is requested a 416 Status code will be used + * in the response. + * + * @param \SplFileInfo $file The file to set a range on. + * @param string $httpRange The range to use. + * @return void + */ + protected function _fileRange(SplFileInfo $file, string $httpRange): void + { + $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 = $matches[2] ?? ''; + } + + if ($start === '') { + $start = $fileSize - (int)$end; + $end = $lastByte; + } + if ($end === '') { + $end = $lastByte; + } + + if ($start > $end || $end > $lastByte || $start > $lastByte) { + $this->_setStatus(416); + $this->_setHeader('Content-Range', 'bytes 0-' . $lastByte . '/' . $fileSize); + + return; + } + + $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]; + } + + /** + * Returns an array that can be used to describe the internal state of this + * object. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'status' => $this->_status, + 'contentType' => $this->getType(), + 'headers' => $this->headers, + 'file' => $this->_file, + 'fileRange' => $this->_fileRange, + 'cookies' => $this->_cookies, + 'cacheDirectives' => $this->_cacheDirectives, + 'body' => (string)$this->getBody(), + ]; + } +} diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php new file mode 100644 index 00000000000..30f63682096 --- /dev/null +++ b/src/Http/ResponseEmitter.php @@ -0,0 +1,252 @@ +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): bool + { + $file = ''; + $line = 0; + if (headers_sent($file, $line)) { + $message = "Unable to emit headers. Headers sent in file={$file} line={$line}"; + trigger_error($message, E_USER_WARNING); + } + + $this->emitStatusLine($response); + $this->emitHeaders($response); + + $range = $this->parseContentRange($response->getHeaderLine('Content-Range')); + if (is_array($range)) { + $this->emitBodyRange($range, $response); + } else { + $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 + * @return void + */ + protected function emitBody(ResponseInterface $response): void + { + if (in_array($response->getStatusCode(), [204, 304], true)) { + return; + } + $body = $response->getBody(); + + if (!$body->isSeekable()) { + echo $body; + + return; + } + + $body->rewind(); + while (!$body->eof()) { + echo $body->read($this->maxBufferLength); + } + } + + /** + * Emit a range of the message body. + * + * @param array $range The range data to emit + * @param \Psr\Http\Message\ResponseInterface $response The response to emit + * @return void + */ + protected function emitBodyRange(array $range, ResponseInterface $response): void + { + [, $first, $last] = $range; + + $body = $response->getBody(); + + if (!$body->isSeekable()) { + $contents = $body->getContents(); + echo substr($contents, $first, $last - $first + 1); + + return; + } + + $body = new RelativeStream($body, $first); + $body->rewind(); + $pos = 0; + /** @var int $length */ + $length = $last - $first + 1; + while (!$body->eof() && $pos < $length) { + if ($pos + $this->maxBufferLength > $length) { + echo $body->read($length - $pos); + break; + } + + echo $body->read($this->maxBufferLength); + $pos = $body->tell(); + } + } + + /** + * Emit the status line. + * + * Emits the status line using the protocol version and status code from + * the response; if a reason phrase is available, it, too, is emitted. + * + * @param \Psr\Http\Message\ResponseInterface $response The response to emit + * @return void + */ + protected function emitStatusLine(ResponseInterface $response): void + { + $reasonPhrase = $response->getReasonPhrase(); + header(sprintf( + 'HTTP/%s %d%s', + $response->getProtocolVersion(), + $response->getStatusCode(), + ($reasonPhrase ? ' ' . $reasonPhrase : ''), + )); + } + + /** + * Emit response headers. + * + * Loops through each header, emitting each; if the header value + * is an array with multiple values, ensures that each is sent + * in such a way as to create aggregate headers (instead of replace + * the previous). + * + * @param \Psr\Http\Message\ResponseInterface $response The response to emit + * @return void + */ + protected function emitHeaders(ResponseInterface $response): void + { + $cookies = []; + if ($response instanceof Response) { + $cookies = iterator_to_array($response->getCookieCollection()); + } + + foreach ($response->getHeaders() as $name => $values) { + if (strtolower($name) === 'set-cookie') { + $cookies = array_merge($cookies, $values); + continue; + } + $first = true; + foreach ($values as $value) { + header(sprintf( + '%s: %s', + $name, + $value, + ), $first); + $first = false; + } + } + + $this->emitCookies($cookies); + } + + /** + * Emit cookies using setcookie() + * + * @param array<\Cake\Http\Cookie\CookieInterface|string> $cookies An array of cookies. + * @return void + */ + protected function emitCookies(array $cookies): void + { + foreach ($cookies as $cookie) { + $this->setCookie($cookie); + } + } + + /** + * 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()); + } + + /** + * Parse content-range header + * https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16 + * + * @param string $header The Content-Range header to parse. + * @return array|false [unit, first, last, length]; returns false if no + * content range or an invalid content range is provided + */ + protected function parseContentRange(string $header): array|false + { + if (preg_match('/(?P[\w]+)\s+(?P\d+)-(?P\d+)\/(?P\d+|\*)/', $header, $matches)) { + return [ + $matches['unit'], + (int)$matches['first'], + (int)$matches['last'], + $matches['length'] === '*' ? '*' : (int)$matches['length'], + ]; + } + + return false; + } +} 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/Runner.php b/src/Http/Runner.php new file mode 100644 index 00000000000..72d25cf0c77 --- /dev/null +++ b/src/Http/Runner.php @@ -0,0 +1,95 @@ +queue = $queue; + $this->queue->rewind(); + $this->fallbackHandler = $fallbackHandler; + + return $this->handle($request); + } + + /** + * Handle incoming server request and return a response. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The server request + * @return \Psr\Http\Message\ResponseInterface An updated response + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + 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); + } + + if ($this->fallbackHandler) { + return $this->fallbackHandler->handle($request); + } + + 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 new file mode 100644 index 00000000000..7a41fe96918 --- /dev/null +++ b/src/Http/Server.php @@ -0,0 +1,200 @@ + + */ +class Server implements EventDispatcherInterface +{ + /** + * @use \Cake\Event\EventDispatcherTrait<\Cake\Core\HttpApplicationInterface> + */ + use EventDispatcherTrait; + + /** + * @var \Cake\Core\HttpApplicationInterface + */ + protected HttpApplicationInterface $app; + + /** + * Constructor + * + * @param \Cake\Core\HttpApplicationInterface $app The application to use. + * @param \Cake\Http\Runner $runner Application runner. + */ + public function __construct(HttpApplicationInterface $app, protected Runner $runner = new Runner()) + { + $this->app = $app; + } + + /** + * Run the request/response through the Application and its middleware. + * + * This will invoke the following methods: + * + * - App->bootstrap() - Perform any bootstrapping logic for your application here. + * - App->middleware() - Attach any application middleware here. + * - Trigger the 'Server.buildMiddleware' event. You can use this to modify the + * from event listeners. + * - Run the middleware queue including the application. + * + * @param \Psr\Http\Message\ServerRequestInterface|null $request The request 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, + ?MiddlewareQueue $middlewareQueue = null, + ): ResponseInterface { + $this->bootstrap(); + + $request = $request ?: ServerRequestFactory::fromGlobals(); + + 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]); + + $response = $this->runner->run($middleware, $request, $this->app); + + if ($request instanceof ServerRequest) { + $request->getSession()->close(); + } + + return $response; + } + + /** + * Application bootstrap wrapper. + * + * Calls the application's `bootstrap()` hook. After the application the + * plugins are bootstrapped. + * + * @return void + */ + protected function bootstrap(): void + { + $this->app->bootstrap(); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginBootstrap(); + } + } + + /** + * Emit the response using the PHP SAPI. + * + * 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 emit(ResponseInterface $response, ?ResponseEmitter $emitter = null): void + { + $emitter ??= new ResponseEmitter(); + $emitter->emit($response); + + $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')); + } + + /** + * Get the current application. + * + * @return \Cake\Core\HttpApplicationInterface The application that will be run. + */ + public function getApp(): HttpApplicationInterface + { + return $this->app; + } + + /** + * Get the application's event manager or the global one. + * + * @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 setEventManager(EventManagerInterface $eventManager) + { + if ($this->app instanceof EventDispatcherInterface) { + $this->app->setEventManager($eventManager); + + 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 new file mode 100644 index 00000000000..1e29dd3b6d5 --- /dev/null +++ b/src/Http/ServerRequest.php @@ -0,0 +1,1839 @@ + null, + 'controller' => null, + 'action' => null, + '_ext' => null, + 'pass' => [], + ]; + + /** + * Array of POST data. Will contain form data as well as uploaded files. + * In PUT/PATCH/DELETE requests this property will contain the form-urlencoded + * data. + * + * @var object|array|null + */ + protected object|array|null $data = []; + + /** + * Array of query string arguments + * + * @var array + */ + protected array $query = []; + + /** + * Array of cookie data. + * + * @var array + */ + protected array $cookies = []; + + /** + * Array of environment data. + * + * @var array + */ + protected array $_environment = []; + + /** + * Base URL path. + * + * @var string + */ + protected string $base; + + /** + * webroot path segment for the request. + * + * @var string + */ + protected string $webroot = '/'; + + /** + * 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 bool $trustProxy = false; + + /** + * Trusted proxies list + * + * @var array + */ + protected array $trustedProxies = []; + + /** + * The built in detectors used with `is()` can be modified with `addDetector()`. + * + * There are several ways to specify a detector, see \Cake\Http\ServerRequest::addDetector() for the + * various formats and ways to define detectors. + * + * @var array<\Closure|array> + */ + protected static array $_detectors = [ + 'get' => ['env' => 'REQUEST_METHOD', 'value' => 'GET'], + 'post' => ['env' => 'REQUEST_METHOD', 'value' => 'POST'], + 'put' => ['env' => 'REQUEST_METHOD', 'value' => 'PUT'], + 'patch' => ['env' => 'REQUEST_METHOD', 'value' => 'PATCH'], + 'delete' => ['env' => 'REQUEST_METHOD', 'value' => 'DELETE'], + 'head' => ['env' => 'REQUEST_METHOD', 'value' => 'HEAD'], + 'options' => ['env' => 'REQUEST_METHOD', 'value' => 'OPTIONS'], + 'https' => ['env' => 'HTTPS', 'options' => [1, 'on']], + 'ajax' => ['env' => 'HTTP_X_REQUESTED_WITH', 'value' => 'XMLHttpRequest'], + 'json' => ['accept' => ['application/json'], 'param' => '_ext', 'value' => 'json'], + 'xml' => [ + 'accept' => ['application/xml', 'text/xml'], + 'exclude' => ['text/html'], + 'param' => '_ext', + 'value' => 'xml', + ], + ]; + + /** + * Instance cache for results of is(something) calls + * + * @var array + */ + protected array $_detectorCache = []; + + /** + * Request body stream. Contains php://input unless `input` constructor option is used. + * + * @var \Psr\Http\Message\StreamInterface + */ + protected StreamInterface $stream; + + /** + * Uri instance + * + * @var \Psr\Http\Message\UriInterface + */ + protected UriInterface $uri; + + /** + * Instance of a Session object relative to this request + * + * @var \Cake\Http\Session + */ + protected Session $session; + + /** + * Instance of a FlashMessage object relative to this request + * + * @var \Cake\Http\FlashMessage + */ + protected FlashMessage $flash; + + /** + * Store the additional attributes attached to the request. + * + * @var array + */ + protected array $attributes = []; + + /** + * A list of properties that emulated by the PSR7 attribute methods. + * + * @var array + */ + protected array $emulatedAttributes = ['session', 'flash', 'webroot', 'base', 'params', 'here']; + + /** + * Array of Psr\Http\Message\UploadedFileInterface objects. + * + * @var array + */ + protected array $uploadedFiles = []; + + /** + * The HTTP protocol version used. + * + * @var string|null + */ + protected ?string $protocol = null; + + /** + * The request target if overridden + * + * @var string|null + */ + protected ?string $requestTarget = null; + + /** + * Create a new request object. + * + * You can supply the data as either an array or as a string. If you use + * a string you can only supply the URL for the request. Using an array will + * let you provide the following keys: + * + * - `post` POST data or non query string data + * - `query` Additional data from the query string. + * - `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. + * - `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 array $config An array of request data to create a request with. + */ + public function __construct(array $config = []) + { + $config += [ + 'params' => $this->params, + 'query' => [], + 'post' => [], + 'files' => [], + 'cookies' => [], + 'environment' => [], + 'url' => '', + 'uri' => null, + 'base' => '', + 'webroot' => '', + 'input' => null, + ]; + + $this->_setConfig($config); + } + + /** + * Process the config/settings data into properties. + * + * @param array $config The config data to use. + * @return void + */ + protected function _setConfig(array $config): void + { + if (empty($config['session'])) { + $config['session'] = new Session([ + 'cookiePath' => $config['base'], + ]); + } + + if (empty($config['environment']['REQUEST_METHOD'])) { + $config['environment']['REQUEST_METHOD'] = 'GET'; + } + + $this->cookies = $config['cookies']; + + 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 { + if ($config['url'] !== '') { + $config = $this->processUrlOption($config); + } + ['uri' => $uri] = UriFactory::marshalUriAndBaseFromSapi($config['environment']); + } + + $this->_environment = $config['environment']; + + $this->uri = $uri; + $this->base = $config['base']; + $this->webroot = $config['webroot']; + + if (isset($config['input'])) { + $stream = new Stream('php://memory', 'rw'); + $stream->write($config['input']); + $stream->rewind(); + } else { + $stream = new Stream('php://input'); + } + $this->stream = $stream; + + $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); + } + + /** + * Set environment vars based on `url` option to facilitate UriInterface instance generation. + * + * `query` option is also updated based on URL's querystring. + * + * @param array $config Config array. + * @return array Update config. + */ + protected function processUrlOption(array $config): array + { + if (!str_starts_with($config['url'], '/')) { + $config['url'] = '/' . $config['url']; + } + + if (str_contains($config['url'], '?')) { + [$config['url'], $config['environment']['QUERY_STRING']] = explode('?', $config['url']); + + parse_str($config['environment']['QUERY_STRING'], $queryArgs); + $config['query'] += $queryArgs; + } + + $config['environment']['REQUEST_URI'] = $config['url']; + + return $config; + } + + /** + * Get the content type used in this request. + * + * @return string|null + */ + public function contentType(): ?string + { + return $this->getEnv('CONTENT_TYPE') ?: $this->getEnv('HTTP_CONTENT_TYPE'); + } + + /** + * Returns the instance of the Session object for this request + * + * @return \Cake\Http\Session + */ + public function getSession(): Session + { + return $this->session; + } + + /** + * Returns the instance of the FlashMessage object for this request + * + * @return \Cake\Http\FlashMessage + */ + public function getFlash(): FlashMessage + { + return $this->flash; + } + + /** + * Get the IP the client is using, or says they are using. + * + * @return string The client IP. + */ + public function clientIp(): string + { + 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); + + if ($trusted) { + $trusted = array_diff($addresses, $this->trustedProxies); + $trusted = (count($trusted) === 1); + } + + if ($trusted) { + return $addresses[0]; + } + + return $addresses[$n - 1]; + } + + 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'); + } + + return trim((string)$ipaddr); + } + + /** + * register trusted proxies + * + * @param array $proxies ips list of trusted proxies + * @return void + */ + public function setTrustedProxies(array $proxies): void + { + $this->trustedProxies = $proxies; + $this->trustProxy = true; + $this->uri = $this->uri->withScheme($this->scheme()); + } + + /** + * Get trusted proxies + * + * @return array + */ + public function getTrustedProxies(): array + { + 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|null The referring address for this request or null. + */ + public function referer(bool $local = true): ?string + { + $ref = $this->getEnv('HTTP_REFERER'); + + $base = Configure::read('App.fullBaseUrl') . $this->webroot; + if (!$ref || !$base) { + return null; + } + + if ($local && str_starts_with($ref, $base)) { + $ref = substr($ref, strlen($base)); + if ($ref === '' || str_starts_with($ref, '//')) { + $ref = '/'; + } + if (!str_starts_with($ref, '/')) { + return '/' . $ref; + } + + return $ref; + } + + if ($local) { + return null; + } + + return $ref; + } + + /** + * Missing method handler, handles wrapping older style isAjax() type methods + * + * @param string $name The method called + * @param array $params Array of parameters for the method call + * @return bool + * @throws \BadMethodCallException when an invalid method is called. + */ + public function __call(string $name, array $params): bool + { + 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)); + } + + /** + * Check whether a Request is a certain type. + * + * 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 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 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(array|string $type, mixed ...$args): bool + { + if (is_array($type)) { + foreach ($type as $_type) { + if ($this->is($_type)) { + return true; + } + } + + return false; + } + + $type = strtolower($type); + if (!isset(static::$_detectors[$type])) { + throw new InvalidArgumentException(sprintf('No detector set for type `%s`.', $type)); + } + if ($args) { + return $this->_is($type, $args); + } + + return $this->_detectorCache[$type] ??= $this->_is($type, $args); + } + + /** + * Clears the instance detector cache, used by the is() function + * + * @return void + */ + public function clearDetectorCache(): void + { + $this->_detectorCache = []; + } + + /** + * Worker for the public is() function + * + * @param string $type The type of request you want to check. + * @param array $args Array of custom detector arguments. + * @return bool Whether the request is the type you are checking. + */ + protected function _is(string $type, array $args): bool + { + $detect = static::$_detectors[$type]; + if ($detect instanceof Closure) { + array_unshift($args, $this); + + return $detect(...$args); + } + if (isset($detect['env']) && $this->_environmentDetector($detect)) { + return true; + } + if (isset($detect['header']) && $this->_headerDetector($detect)) { + return true; + } + if (isset($detect['accept']) && $this->_acceptHeaderDetector($detect)) { + return true; + } + if (isset($detect['param']) && $this->_paramDetector($detect)) { + return true; + } + + return false; + } + + /** + * Detects if a specific accept header is present. + * + * @param array $detect Detector options array. + * @return bool Whether the request is the type you are checking. + */ + protected function _acceptHeaderDetector(array $detect): bool + { + $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); + } + + $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 the request is the type you are checking. + */ + protected function _headerDetector(array $detect): bool + { + foreach ($detect['header'] as $header => $value) { + $header = $this->getEnv('http_' . $header); + if ($header !== null) { + if ($value instanceof Closure) { + return $value($header); + } + + return $header === $value; + } + } + + return false; + } + + /** + * Detects if a specific request parameter is present. + * + * @param array $detect Detector options array. + * @return bool Whether the request is the type you are checking. + */ + 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; + } + if (isset($detect['options'])) { + return isset($this->params[$key]) && in_array($this->params[$key], $detect['options']); + } + + return false; + } + + /** + * Detects if a specific environment variable is present. + * + * @param array $detect Detector options array. + * @return bool Whether the request is the type you are checking. + */ + protected function _environmentDetector(array $detect): bool + { + if (isset($detect['env'])) { + if (isset($detect['value'])) { + return $this->getEnv($detect['env']) === $detect['value']; + } + if (isset($detect['pattern'])) { + 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, (string)$this->getEnv($detect['env'])); + } + } + + return false; + } + + /** + * Check that a request matches all the given types. + * + * Allows you to test multiple types and union the results. + * See Request::is() for how to add additional types and the + * built-in types. + * + * @param array $types The types to check. + * @return bool Success. + * @see \Cake\Http\ServerRequest::is() + */ + public function isAll(array $types): bool + { + foreach ($types as $type) { + if (!$this->is($type)) { + return false; + } + } + + return true; + } + + /** + * Add a new detector to the list of detectors that a request can use. + * There are several different types of detectors that can be set. + * + * ### Callback comparison + * + * 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 }); + * ``` + * + * ### Environment value comparison + * + * An environment value comparison, compares a value fetched from `env()` to a known value + * the environment value is equality checked against the provided value. + * + * ``` + * 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 + * + * Pattern value comparison allows you to compare a value fetched from `env()` to a regular expression. + * + * ``` + * addDetector('iphone', ['env' => 'HTTP_USER_AGENT', 'pattern' => '/iPhone/i']); + * ``` + * + * ### Option based comparison + * + * Option based comparisons use a list of options to create a regular expression. Subsequent calls + * to add an already defined options detector will merge the options. + * + * ``` + * addDetector('mobile', ['env' => 'HTTP_USER_AGENT', 'options' => ['Fennec']]); + * ``` + * + * You can also make compare against multiple values + * using the `options` key. This is useful when you want to check + * if a request value is in a list of options. + * + * `addDetector('extension', ['param' => '_ext', 'options' => ['pdf', 'csv']]` + * + * @param string $name The name of the detector. + * @param \Closure|array $detector A Closure or options array for the detector definition. + * @return void + */ + public static function addDetector(string $name, Closure|array $detector): void + { + $name = strtolower($name); + if ($detector instanceof Closure) { + static::$_detectors[$name] = $detector; + + return; + } + + if (isset(static::$_detectors[$name], $detector['options'])) { + /** @var array $data */ + $data = static::$_detectors[$name]; + $detector = Hash::merge($data, $detector); + } + static::$_detectors[$name] = $detector; + } + + /** + * Normalize a header name into the SERVER version. + * + * @param string $name The header name. + * @return string The normalized header name. + */ + protected function normalizeHeaderName(string $name): string + { + $name = str_replace('-', '_', strtoupper($name)); + if (!in_array($name, ['CONTENT_LENGTH', 'CONTENT_TYPE'], true)) { + return 'HTTP_' . $name; + } + + return $name; + } + + /** + * Get all headers in the request. + * + * Returns an associative array where the header names are + * the keys and the values are a list of header values. + * + * While header names are not case-sensitive, getHeaders() will normalize + * the headers. + * + * @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(): array + { + $headers = []; + foreach ($this->_environment as $key => $value) { + $name = null; + if (str_starts_with($key, 'HTTP_')) { + $name = substr($key, 5); + } + if (str_starts_with($key, 'CONTENT_')) { + $name = $key; + } + if ($name !== null) { + $name = str_replace('_', ' ', strtolower($name)); + $name = str_replace(' ', '-', ucwords($name)); + $headers[$name] = (array)$value; + } + } + + return $headers; + } + + /** + * Check if a header is set in the request. + * + * @param string $name The header you want to get (case-insensitive) + * @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(string $name): bool + { + $name = $this->normalizeHeaderName($name); + + return isset($this->_environment[$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. + * + * @param string $name The header you want to get (case-insensitive) + * @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 https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + public function getHeader(string $name): array + { + $name = $this->normalizeHeaderName($name); + if (isset($this->_environment[$name])) { + return (array)$this->_environment[$name]; + } + + return []; + } + + /** + * Get a single header as a string from the request. + * + * @param string $name The header you want to get (case-insensitive) + * @return string Header values collapsed into a comma separated string. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + public function getHeaderLine(string $name): string + { + $value = $this->getHeader($name); + + return implode(', ', $value); + } + + /** + * Get a modified request with the provided header. + * + * @param string $name The header name. + * @param array|string $value The header value + * @return static + * @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(string $name, $value): static + { + $new = clone $this; + $name = $this->normalizeHeaderName($name); + $new->_environment[$name] = $value; + + return $new; + } + + /** + * Get a modified request with the provided header. + * + * Existing header values will be retained. The provided value + * will be appended into the existing values. + * + * @param string $name The header name. + * @param array|string $value The header value + * @return static + * @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(string $name, $value): static + { + $new = clone $this; + $name = $this->normalizeHeaderName($name); + $existing = []; + if (isset($new->_environment[$name])) { + $existing = (array)$new->_environment[$name]; + } + $existing = array_merge($existing, (array)$value); + $new->_environment[$name] = $existing; + + return $new; + } + + /** + * Get a modified request without a provided header. + * + * @param string $name The header name to remove. + * @return static + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + public function withoutHeader(string $name): static + { + $new = clone $this; + $name = $this->normalizeHeaderName($name); + unset($new->_environment[$name]); + + return $new; + } + + /** + * 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 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 affect the result of this method. + * + * @return string The name of the HTTP method used. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + public function getMethod(): string + { + return (string)$this->getEnv('REQUEST_METHOD'); + } + + /** + * Update the request method and get a new instance. + * + * @param string $method The HTTP method to use. + * @return static A new instance with the updated method. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + public function withMethod(string $method): static + { + $new = clone $this; + + if (!preg_match('/^[!#$%&\'*+.^_`\|~0-9a-z-]+$/i', $method)) { + throw new InvalidArgumentException(sprintf( + 'Unsupported HTTP method `%s` provided.', + $method, + )); + } + $new->_environment['REQUEST_METHOD'] = $method; + + return $new; + } + + /** + * Get all the server environment parameters. + * + * Read all of the 'environment' or 'server' data that was + * used to create this request. + * + * @return array + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + public function getServerParams(): array + { + return $this->_environment; + } + + /** + * Get all the query parameters in accordance to the PSR-7 specifications. To read specific query values + * use the alternative getQuery() method. + * + * @return array + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + 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 https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + */ + public function withQueryParams(array $query): static + { + $new = clone $this; + $new->query = $query; + + return $new; + } + + /** + * Get the host that the request was handled on. + * + * @return string|null + */ + public function host(): ?string + { + if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_HOST')) { + return $this->getEnv('HTTP_X_FORWARDED_HOST'); + } + + return $this->getEnv('HTTP_HOST'); + } + + /** + * Get the port the request was handled on. + * + * @return string|null + */ + public function port(): ?string + { + if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_PORT')) { + return $this->getEnv('HTTP_X_FORWARDED_PORT'); + } + + return $this->getEnv('SERVER_PORT'); + } + + /** + * Get the current url scheme used for the request. + * + * e.g. 'http', or 'https' + * + * @return string The scheme used for the request. + */ + public function scheme(): string + { + if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_PROTO')) { + return (string)$this->getEnv('HTTP_X_FORWARDED_PROTO'); + } + + return $this->getEnv('HTTPS') ? 'https' : 'http'; + } + + /** + * Get the domain name and include $tldLength segments of the tld. + * + * @param int $tldLength Number of segments your tld contains. For example: `example.com` contains 1 tld. + * While `example.co.uk` contains 2. + * @return string Domain name without subdomains. + */ + public function domain(int $tldLength = 1): string + { + $host = $this->host(); + if (!$host) { + return ''; + } + + $segments = explode('.', $host); + $domain = array_slice($segments, -1 * ($tldLength + 1)); + + return implode('.', $domain); + } + + /** + * Get the subdomains for a host. + * + * @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. + */ + public function subdomains(int $tldLength = 1): array + { + $host = $this->host(); + if (!$host) { + return []; + } + + $segments = explode('.', $host); + + return array_slice($segments, 0, -1 * ($tldLength + 1)); + } + + /** + * Find out which content types the client accepts or check if they accept a + * particular type of content. + * + * #### Get all types: + * + * ``` + * $this->request->accepts(); + * ``` + * + * #### Check for a single type: + * + * ``` + * $this->request->accepts('application/json'); + * ``` + * + * This method will order the returned content types by the preference values indicated + * 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 + * provided type. + */ + public function accepts(?string $type = null): array|bool + { + $content = new ContentTypeNegotiation(); + if ($type) { + return $content->preferredType($this, [$type]) !== null; + } + + $accept = []; + foreach ($content->parseAccept($this) as $types) { + $accept = array_merge($accept, $types); + } + + return $accept; + } + + /** + * Get the languages accepted by the client, or check if a specific language is accepted. + * + * Get the list of accepted languages: + * + * ```$request->acceptLanguage();``` + * + * Check if a specific language is accepted: + * + * ```$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(?string $language = null): array|bool + { + $content = new ContentTypeNegotiation(); + if ($language !== null) { + return $content->acceptLanguage($this, $language); + } + + return $content->acceptedLanguages($this); + } + + /** + * Read a specific query value or dotted path. + * + * 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'); + * ``` + * + * @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 mixed Query data. + * @see ServerRequest::getQueryParams() + */ + public function getQuery(?string $name = null, mixed $default = null): mixed + { + if ($name === null) { + return $this->query; + } + + return Hash::get($this->query, $name, $default); + } + + /** + * Provides a safe accessor for request data. Allows + * you to use Hash::get() compatible paths. + * + * ### Reading values. + * + * ``` + * // get all data + * $request->getData(); + * + * // Read a specific field. + * $request->getData('Post.title'); + * + * // With a default value. + * $request->getData('Post.not there', 'default value'); + * ``` + * + * 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 mixed The value being read. + */ + public function getData(?string $name = null, mixed $default = null): mixed + { + if ($name === null) { + return $this->data; + } + if (!is_array($this->data)) { + return $default; + } + + return Hash::get($this->data, $name, $default); + } + + /** + * Read cookie data from the request's cookie data. + * + * @param string $key The key or dotted path you want to read. + * @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(string $key, array|string|null $default = null): array|string|null + { + return Hash::get($this->cookies, $key, $default); + } + + /** + * Get a cookie collection based on the request's cookies + * + * The CookieCollection lets you interact with request cookies using + * `\Cake\Http\Cookie\Cookie` objects and can make converting request cookies + * into response cookies easier. + * + * This method will create a new cookie collection each time it is called. + * This is an optimization that allows fewer objects to be allocated until + * the more complex CookieCollection is needed. In general you should prefer + * `getCookie()` and `getCookieParams()` over this method. Using a CookieCollection + * is ideal if your cookies contain complex JSON encoded data. + * + * @return \Cake\Http\Cookie\CookieCollection + */ + public function getCookieCollection(): CookieCollection + { + return CookieCollection::createFromServerRequest($this); + } + + /** + * Replace the cookies in the request with those contained in + * the provided CookieCollection. + * + * @param \Cake\Http\Cookie\CookieCollection $cookies The cookie collection + * @return static + */ + public function withCookieCollection(CookieCollection $cookies): static + { + $new = clone $this; + $values = []; + foreach ($cookies as $cookie) { + $values[$cookie->getName()] = $cookie->getValue(); + } + $new->cookies = $values; + + return $new; + } + + /** + * Get all the cookie data from the request. + * + * @return array An array of cookie data. + */ + public function getCookieParams(): array + { + return $this->cookies; + } + + /** + * Replace the cookies and get a new request instance. + * + * @param array $cookies The new cookie data to use. + * @return static + */ + public function withCookieParams(array $cookies): static + { + $new = clone $this; + $new->cookies = $cookies; + + return $new; + } + + /** + * Get the parsed request body data. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * 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 object|array|null The deserialized body parameters, if any. + * These will typically be an array. + */ + public function getParsedBody(): object|array|null + { + return $this->data; + } + + /** + * Update the parsed body and get a new instance. + * + * @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): static + { + $new = clone $this; + $new->data = $data; + + return $new; + } + + /** + * Retrieves the HTTP protocol version as a string. + * + * @return string HTTP protocol version. + */ + public function getProtocolVersion(): string + { + if ($this->protocol) { + return $this->protocol; + } + + // Lazily populate this data as it is generally not used. + preg_match('/^HTTP\/([\d.]+)$/', (string)$this->getEnv('SERVER_PROTOCOL'), $match); + $protocol = '1.1'; + if (isset($match[1])) { + $protocol = $match[1]; + } + $this->protocol = $protocol; + + return $this->protocol; + } + + /** + * Return an instance with the specified HTTP protocol version. + * + * The version string MUST contain only the HTTP version number (e.g., + * "1.1", "1.0"). + * + * @param string $version HTTP protocol version + * @return static + */ + public function withProtocolVersion(string $version): static + { + if (!preg_match('/^(1\.[01]|2)$/', $version)) { + throw new InvalidArgumentException(sprintf('Unsupported protocol version `%s` provided.', $version)); + } + $new = clone $this; + $new->protocol = $version; + + return $new; + } + + /** + * Get a value from the request's environment data. + * Fallback to using env() if the key is not set in the $environment property. + * + * @param string $key The key you want to read from. + * @param string|null $default Default value when trying to retrieve an environment + * 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(string $key, ?string $default = null): ?string + { + $key = strtoupper($key); + if (!array_key_exists($key, $this->_environment)) { + $this->_environment[$key] = env($key); + } + + if ($this->_environment[$key] === null) { + return $default; + } + + if (is_array($this->_environment[$key])) { + return implode(', ', $this->_environment[$key]); + } + + return (string)$this->_environment[$key]; + } + + /** + * Update the request with a new environment data element. + * + * Returns an updated request object. This method returns + * a *new* request object and does not mutate the request in-place. + * + * @param string $key The key you want to write to. + * @param string $value Value to set + * @return static + */ + public function withEnv(string $key, string $value): static + { + $new = clone $this; + $new->_environment[$key] = $value; + $new->clearDetectorCache(); + + return $new; + } + + /** + * 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. + * + * Example: + * + * $this->request->allowMethod('post'); + * or + * $this->request->allowMethod(['post', 'delete']); + * + * If the request would be GET, response header "Allow: POST, DELETE" will be set + * and a 405 error will be returned. + * + * @param array|string $methods Allowed HTTP request methods. + * @return true + * @throws \Cake\Http\Exception\MethodNotAllowedException + */ + public function allowMethod(array|string $methods): bool + { + $methods = (array)$methods; + foreach ($methods as $method) { + if ($this->is($method)) { + return true; + } + } + $allowed = strtoupper(implode(', ', $methods)); + $e = new MethodNotAllowedException(); + $e->setHeader('Allow', $allowed); + throw $e; + } + + /** + * 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. + * + * @param string $name The dot separated path to insert $value at. + * @param mixed $value The value to insert into the request data. + * @return static + */ + public function withData(string $name, mixed $value): static + { + $copy = clone $this; + + if (is_array($copy->data)) { + $copy->data = Hash::insert($copy->data, $name, $value); + } + + return $copy; + } + + /** + * 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 remove. + * @return static + */ + public function withoutData(string $name): static + { + $copy = clone $this; + + if (is_array($copy->data)) { + $copy->data = Hash::remove($copy->data, $name); + } + + return $copy; + } + + /** + * Update the request with a new routing parameter + * + * 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 the request parameters. + * @return static + */ + public function withParam(string $name, mixed $value): static + { + $copy = clone $this; + $copy->params = Hash::insert($copy->params, $name, $value); + + return $copy; + } + + /** + * 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. Default `null`. + * @return mixed + */ + 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); + } + + /** + * Return an instance with the specified request attribute. + * + * @param string $name The attribute name. + * @param mixed $value The value of the attribute. + * @return static + */ + public function withAttribute(string $name, mixed $value): static + { + $new = clone $this; + if (in_array($name, $this->emulatedAttributes, true)) { + $new->{$name} = $value; + } else { + $new->attributes[$name] = $value; + } + + return $new; + } + + /** + * Return an instance without the specified request attribute. + * + * @param string $name The attribute name. + * @return static + * @throws \InvalidArgumentException + */ + 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.", + ); + } + unset($new->attributes[$name]); + + return $new; + } + + /** + * Read an attribute from the request, or get the default + * + * @param string $name The attribute name. + * @param mixed $default The default value if the attribute has not been set. + * @return mixed + */ + 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)) { + return $this->attributes[$name]; + } + + return $default; + } + + /** + * Get all the attributes in the request. + * + * This will include the params, webroot, base, and here attributes that CakePHP + * provides. + * + * @return array + */ + public function getAttributes(): array + { + $emulated = [ + 'params' => $this->params, + 'webroot' => $this->webroot, + 'base' => $this->base, + 'here' => $this->base . $this->uri->getPath(), + ]; + + return $this->attributes + $emulated; + } + + /** + * Get the uploaded file from a dotted path. + * + * @param string $path The dot separated path to the file you want. + * @return \Psr\Http\Message\UploadedFileInterface|null + */ + public function getUploadedFile(string $path): ?UploadedFileInterface + { + $file = Hash::get($this->uploadedFiles, $path); + if (!$file instanceof UploadedFile) { + return null; + } + + return $file; + } + + /** + * Get the array of uploaded files from the request. + * + * @return array + */ + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + /** + * Update the request replacing the files, and creating a new instance. + * + * @param array $uploadedFiles An array of uploaded file objects. + * @return static + * @throws \InvalidArgumentException when $files contains an invalid object. + */ + public function withUploadedFiles(array $uploadedFiles): static + { + $this->validateUploadedFiles($uploadedFiles, ''); + $new = clone $this; + $new->uploadedFiles = $uploadedFiles; + + return $new; + } + + /** + * Recursively validate uploaded file data. + * + * @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. + */ + protected function validateUploadedFiles(array $uploadedFiles, string $path): void + { + foreach ($uploadedFiles as $key => $file) { + if (is_array($file)) { + $this->validateUploadedFiles($file, $key . '.'); + continue; + } + + if (!$file instanceof UploadedFileInterface) { + throw new InvalidArgumentException(sprintf('Invalid file at `%s%s`.', $path, $key)); + } + } + } + + /** + * Gets the body of the message. + * + * @return \Psr\Http\Message\StreamInterface Returns the body as a stream. + */ + public function getBody(): StreamInterface + { + return $this->stream; + } + + /** + * Return an instance with the specified message body. + * + * @param \Psr\Http\Message\StreamInterface $body The new request body + * @return static + */ + public function withBody(StreamInterface $body): static + { + $new = clone $this; + $new->stream = $body; + + return $new; + } + + /** + * Retrieves the URI instance. + * + * @return \Psr\Http\Message\UriInterface Returns a UriInterface instance + * representing the URI of the request. + */ + public function getUri(): UriInterface + { + return $this->uri; + } + + /** + * Return an instance with the specified uri + * + * *Warning* Replacing the Uri will not update the `base`, `webroot`, + * and `url` attributes. + * + * @param \Psr\Http\Message\UriInterface $uri The new request uri + * @param bool $preserveHost Whether the host should be retained. + * @return static + */ + public function withUri(UriInterface $uri, bool $preserveHost = false): static + { + $new = clone $this; + $new->uri = $uri; + + if ($preserveHost && $this->hasHeader('Host')) { + return $new; + } + + $host = $uri->getHost(); + if (!$host) { + return $new; + } + $port = $uri->getPort(); + if ($port) { + $host .= ':' . $port; + } + $new->_environment['HTTP_HOST'] = $host; + + return $new; + } + + /** + * Create a new instance with a specific request-target. + * + * You can use this method to overwrite the request target that is + * inferred from the request's Uri. This also lets you change the request + * target's form to an absolute-form, authority-form or asterisk-form + * + * @link https://tools.ietf.org/html/rfc7230#section-2.7 (for the various + * request-target forms allowed in request messages) + * @param string $requestTarget The request target. + * @return static + */ + public function withRequestTarget(string $requestTarget): static + { + $new = clone $this; + $new->requestTarget = $requestTarget; + + return $new; + } + + /** + * Retrieves the request's target. + * + * Retrieves the message's request-target either as it was requested, + * or as set with `withRequestTarget()`. By default this will return the + * application relative path without base directory, and the query string + * defined in the SERVER environment. + * + * @return string + */ + public function getRequestTarget(): string + { + if ($this->requestTarget !== null) { + return $this->requestTarget; + } + + $target = $this->uri->getPath(); + if ($this->uri->getQuery()) { + $target .= '?' . $this->uri->getQuery(); + } + + if (!$target) { + return '/'; + } + + return $target; + } + + /** + * Get the path of current request. + * + * @return string + * @since 3.6.1 + */ + public function getPath(): string + { + if ($this->requestTarget === null) { + return $this->uri->getPath(); + } + + [$path] = explode('?', $this->requestTarget); + + return $path; + } +} diff --git a/src/Http/ServerRequestFactory.php b/src/Http/ServerRequestFactory.php new file mode 100644 index 00000000000..47df6fb0341 --- /dev/null +++ b/src/Http/ServerRequestFactory.php @@ -0,0 +1,183 @@ + $uri, 'base' => $base, 'webroot' => $webroot] = UriFactory::marshalUriAndBaseFromSapi($server); + + $sessionConfig = (array)Configure::read('Session') + [ + 'defaults' => 'php', + 'cookiePath' => $webroot, + ]; + $session = Session::create($sessionConfig); + + $request = new ServerRequest([ + 'environment' => $server, + 'uri' => $uri, + 'cookies' => $cookies ?? $_COOKIE, + 'query' => $query ?? $_GET, + 'webroot' => $webroot, + 'base' => $base, + 'session' => $session, + 'input' => $server['CAKEPHP_INPUT'] ?? null, + ]); + + $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::marshalFiles($files ?? $_FILES, $request); + } + + /** + * 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. + * + * Request body of content type "application/x-www-form-urlencoded" is parsed + * into array for PUT/PATCH/DELETE requests. + * + * @param array $parsedBody Parsed body. + * @param \Cake\Http\ServerRequest $request Request instance. + * @return \Cake\Http\ServerRequest + */ + protected static function marshalBodyAndRequestMethod(array $parsedBody, ServerRequest $request): ServerRequest + { + $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; + } + + $request = $request->withEnv('ORIGINAL_REQUEST_METHOD', $method); + if (isset($parsedBody['_method'])) { + $request = $request->withEnv('REQUEST_METHOD', $parsedBody['_method']); + unset($parsedBody['_method']); + $override = true; + } + + if ( + $override && + !in_array($request->getMethod(), ['PUT', 'POST', 'DELETE', 'PATCH'], true) + ) { + $parsedBody = []; + } + + return $request->withParsedBody($parsedBody); + } + + /** + * Process uploaded files and move things onto the parsed body. + * + * @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 marshalFiles(array $files, ServerRequest $request): ServerRequest + { + $files = normalizeUploadedFiles($files); + $request = $request->withUploadedFiles($files); + + $parsedBody = $request->getParsedBody(); + if (!is_array($parsedBody)) { + return $request; + } + + $parsedBody = Hash::merge($parsedBody, $files); + + return $request->withParsedBody($parsedBody); + } + + /** + * Create a new server request. + * + * 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 + */ + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + { + $serverParams['REQUEST_METHOD'] = $method; + $options = ['environment' => $serverParams]; + + if (is_string($uri)) { + $uri = (new UriFactory())->createUri($uri); + } + $options['uri'] = $uri; + + 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..0ba277139c9 --- /dev/null +++ b/src/Http/UriFactory.php @@ -0,0 +1,182 @@ + $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 An array containing the base and webroot paths. + * @phpstan-return array{base: string, webroot: string} + */ + 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..4478048f92e --- /dev/null +++ b/src/Http/composer.json @@ -0,0 +1,71 @@ +{ + "name": "cakephp/http", + "description": "CakePHP HTTP client and PSR-7, PSR-15, PSR-17, PSR-18 compliant libraries", + "type": "library", + "keywords": [ + "cakephp", + "http", + "PSR-7", + "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.3.0", + "cakephp/event": "^5.3.0", + "cakephp/utility": "^5.3.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", + "laminas/laminas-diactoros": "^3.8", + "laminas/laminas-httphandlerrunner": "^2.6" + }, + "require-dev": { + "cakephp/cache": "^5.3.0", + "cakephp/console": "^5.3.0", + "cakephp/orm": "^5.3.0", + "cakephp/i18n": "^5.3.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" + }, + "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.4.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 new file mode 100644 index 00000000000..9720c9c78bf --- /dev/null +++ b/src/I18n/ChainMessagesLoader.php @@ -0,0 +1,79 @@ + + */ + 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 array $loaders List of callables to execute + */ + public function __construct(array $loaders) + { + $this->_loaders = $loaders; + } + + /** + * Executes this object returning the translations package as configured in + * the chain. + * + * @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(): Package + { + foreach ($this->_loaders as $k => $loader) { + if (!is_callable($loader)) { + throw new CakeException(sprintf( + 'Loader `%s` in the chain is not a valid callable.', + $k, + )); + } + + $package = $loader(); + if (!$package) { + continue; + } + + if (!($package instanceof Package)) { + throw new CakeException(sprintf( + 'Loader `%s` in the chain did not return a valid Package object.', + $k, + )); + } + + return $package; + } + + return new Package(); + } +} diff --git a/src/I18n/Date.php b/src/I18n/Date.php new file mode 100644 index 00000000000..a1a699059f4 --- /dev/null +++ b/src/I18n/Date.php @@ -0,0 +1,341 @@ + + * @see \Cake\I18n\Date::timeAgoInWords() + */ + public static array $wordAccuracy = [ + 'year' => '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 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 + * on the difference between the current date and this object. + * + * ### Options: + * + * - `from` => another Date object representing the "now" date + * - `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") + * - `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 = []): 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 new file mode 100644 index 00000000000..1d05f532a03 --- /dev/null +++ b/src/I18n/DateFormatTrait.php @@ -0,0 +1,176 @@ + + */ + protected static array $formatters = []; + + /** + * Returns a translated and localized date string. + * Implements what IntlDateFormatter::formatObject() is in PHP 5.5+ + * + * @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( + DateTimeInterface $date, + array|string $format, + ?string $locale, + ): string { + $pattern = ''; + + if (is_array($format)) { + [$dateFormat, $timeFormat] = $format; + } else { + $dateFormat = IntlDateFormatter::FULL; + $timeFormat = IntlDateFormatter::FULL; + $pattern = $format; + } + + $locale ??= I18n::getLocale(); + + if ( + preg_match( + '/@calendar=(japanese|buddhist|chinese|persian|indian|islamic|hebrew|coptic|ethiopic)/', + $locale, + ) + ) { + $calendar = IntlDateFormatter::TRADITIONAL; + } else { + $calendar = IntlDateFormatter::GREGORIAN; + } + + $timezone = $date->getTimezone()->getName(); + $key = "{$locale}.{$dateFormat}.{$timeFormat}.{$timezone}.{$calendar}.{$pattern}"; + + if (!isset(static::$formatters[$key])) { + if ($timezone === '+00:00' || $timezone === 'Z') { + $timezone = 'UTC'; + } elseif (str_starts_with($timezone, '+') || str_starts_with($timezone, '-')) { + $timezone = 'GMT' . $timezone; + } + + $formatter = datefmt_create( + $locale, + $dateFormat, + $timeFormat, + $timezone, + $calendar, + $pattern, + ); + + 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.", + ); + } + + static::$formatters[$key] = $formatter; + } + + return (string)static::$formatters[$key]->format($date); + } + + /** + * 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. + * + * 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 = 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, IntlDateFormatter::NONE]); + * ``` + * + * @param string $time The time string to parse. + * @param array|string $format Any format accepted by IntlDateFormatter. + * @param \DateTimeZone|string|null $tz The timezone for the instance + * @return static|null + */ + protected static function _parseDateTime( + string $time, + array|string $format, + DateTimeZone|string|null $tz = null, + ): ?static { + $pattern = ''; + + if (is_array($format)) { + [$dateFormat, $timeFormat] = $format; + } else { + $dateFormat = IntlDateFormatter::FULL; + $timeFormat = IntlDateFormatter::FULL; + $pattern = $format; + } + + $locale = DateTime::getDefaultLocale() ?? I18n::getLocale(); + $formatter = datefmt_create( + $locale, + $dateFormat, + $timeFormat, + $tz, + null, + $pattern, + ); + if (!$formatter) { + throw new CakeException('Unable to create IntlDateFormatter instance'); + } + $formatter->setLenient(DateTime::lenientParsingEnabled()); + + $time = $formatter->parse($time); + if ($time === false) { + return null; + } + + $dateTime = new DateTimeImmutable('@' . $time); + + if (!($tz instanceof DateTimeZone)) { + $tz = new DateTimeZone($tz ?? date_default_timezone_get()); + } + $dateTime = $dateTime->setTimezone($tz); + + 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..462bddffba4 --- /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 @@ +format($tokenValues); + if ($result === false) { + throw new I18nException($formatter->getErrorMessage(), $formatter->getErrorCode()); + } + + return $result; + } +} diff --git a/src/I18n/Formatter/SprintfFormatter.php b/src/I18n/Formatter/SprintfFormatter.php new file mode 100644 index 00000000000..84c5d7bddd6 --- /dev/null +++ b/src/I18n/Formatter/SprintfFormatter.php @@ -0,0 +1,40 @@ +> + */ + 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 new file mode 100644 index 00000000000..bec29c82f68 --- /dev/null +++ b/src/I18n/FrozenDate.php @@ -0,0 +1,9 @@ + IcuFormatter::class, + 'sprintf' => SprintfFormatter::class, + ]), + static::getLocale(), + ); + + if (class_exists(Cache::class)) { + try { + $pool = Cache::pool('_cake_translations_'); + } catch (InvalidArgumentException) { + $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; + } + + /** + * Sets a translator. + * + * Configures future translators, this is achieved by passing a callable + * as the last argument of this function. + * + * ### Example: + * + * ``` + * I18n::setTranslator('default', function () { + * $package = new \Cake\I18n\Package(); + * $package->setMessages([ + * 'Cake' => 'Gâteau' + * ]); + * return $package; + * }, 'fr_FR'); + * + * $translator = I18n::getTranslator('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 `resources/locales/custom` folder, you would do: + * + * ``` + * I18n::setTranslator( + * 'default', + * new MessagesFileLoader('my_translations', 'custom', 'po'), + * 'fr_FR' + * ); + * ``` + * + * @param string $name The domain of the translation messages. + * @param callable $loader A callback function or callable class responsible for + * constructing a translations package instance. + * @param string|null $locale The locale for the translator. + * @return void + */ + public static function setTranslator(string $name, callable $loader, ?string $locale = null): void + { + $locale = $locale ?: static::getLocale(); + + $translators = static::translators(); + $loader = $translators->setLoaderFallback($name, $loader); + $packages = $translators->getPackages(); + $packages->set($name, $locale, $loader); + } + + /** + * Returns an instance of a translator that was configured for the name and locale. + * + * If no locale is passed then it takes the value returned by the `getLocale()` method. + * + * @param string $name The domain of the translation messages. + * @param string|null $locale The locale for the translator. + * @return \Cake\I18n\Translator The configured translator. + * @throws \Cake\I18n\Exception\I18nException + */ + 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 ($currentLocale !== null) { + $translators->setLocale($currentLocale); + } + + return $translator; + } + + /** + * Registers a callable object that can be used for creating new translator + * instances for the same translations domain. Loaders will be invoked whenever + * a translator object is requested for a domain that has not been configured or + * loaded already. + * + * 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 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 `Cake\I18n\Package` object. + * + * ### Example: + * + * ``` + * use Cake\I18n\MessagesFileLoader; + * I18n::config('my_domain', function ($name, $locale) { + * // Load resources/locales/$locale/filename.po + * $fileLoader = new MessagesFileLoader('filename', $locale, 'po'); + * return $fileLoader(); + * }); + * ``` + * + * You can also assemble the package object yourself: + * + * ``` + * use Cake\I18n\Package; + * I18n::config('my_domain', function ($name, $locale) { + * $package = new Package('default'); + * $messages = (...); // Fetch messages for locale from external service. + * $package->setMessages($message); + * $package->setFallback('default'); + * return $package; + * }); + * ``` + * + * @param string $name The name of the translator to create a loader for + * @param callable $loader A callable object that should return a Package + * instance to be used for assembling a new translator. + * @return void + */ + 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. + * + * @param string $locale The name of the locale to set as default. + * @return void + */ + public static function setLocale(string $locale): void + { + static::getDefaultLocale(); + Locale::setDefault($locale); + if (isset(static::$_collection)) { + static::translators()->setLocale($locale); + } + } + + /** + * 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(): string + { + static::getDefaultLocale(); + $current = Locale::getDefault(); + if ($current === '') { + $current = static::DEFAULT_LOCALE; + Locale::setDefault($current); + } + + return $current; + } + + /** + * Returns the default locale. + * + * 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. + * + * @return string + */ + public static function getDefaultLocale(): string + { + return static::$_defaultLocale ??= Locale::getDefault() ?: static::DEFAULT_LOCALE; + } + + /** + * Returns the currently configured default formatter. + * + * @return string The name of the formatter. + */ + 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 + * are available. + * + * @param string $name The name of the formatter to use. + * @return void + */ + public static function setDefaultFormatter(string $name): void + { + static::translators()->defaultFormatter($name); + } + + /** + * Set if the domain fallback is used. + * + * @param bool $enable flag to enable or disable fallback + * @return void + */ + public static function useFallback(bool $enable = true): void + { + static::translators()->useFallback($enable); + } + + /** + * Destroys all translator instances and creates a new empty translations + * collection. + * + * @return void + */ + public static function clear(): void + { + static::$_collection = null; + } +} diff --git a/src/I18n/LICENSE.txt b/src/I18n/LICENSE.txt new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/I18n/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/I18n/MessagesFileLoader.php b/src/I18n/MessagesFileLoader.php new file mode 100644 index 00000000000..8a57871f7d1 --- /dev/null +++ b/src/I18n/MessagesFileLoader.php @@ -0,0 +1,213 @@ +_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; + } + + /** + * Loads the translation file and parses it. Returns an instance of a translations + * package containing the messages loaded from the file. + * + * @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(): Package|false + { + $folders = $this->translationsFolders(); + $file = $this->translationFile($folders, $this->_name, $this->_extension); + if (!$file) { + return false; + } + + $name = ucfirst($this->_extension); + $class = App::className($name, 'I18n\Parser', 'FileParser'); + + if (!$class) { + throw new CakeException(sprintf('Could not find class `%s`.', "{$name}FileParser")); + } + + /** @var \Cake\I18n\Parser\MoFileParser|\Cake\I18n\Parser\PoFileParser $object */ + $object = new $class(); + $messages = $object->parse($file); + $package = new Package('default'); + $package->setMessages($messages); + + return $package; + } + + /** + * 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 + */ + public function translationsFolders(): array + { + $locale = Locale::parseLocale($this->_locale) + ['region' => null]; + + $folders = [ + $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('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) { + $searchPaths[] = $path . $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 $file; + } +} diff --git a/src/I18n/Middleware/LocaleSelectorMiddleware.php b/src/I18n/Middleware/LocaleSelectorMiddleware.php new file mode 100644 index 00000000000..d9726698ca6 --- /dev/null +++ b/src/I18n/Middleware/LocaleSelectorMiddleware.php @@ -0,0 +1,72 @@ +locales = $locales; + } + + /** + * Set locale based on request headers. + * + * @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 + { + $locale = Locale::acceptFromHttp($request->getHeaderLine('Accept-Language')); + if (!$locale) { + return $handler->handle($request); + } + if ($this->locales !== ['*']) { + $locale = Locale::lookup($this->locales, $locale, true); + } + if ($locale) { + I18n::setLocale($locale); + } + + return $handler->handle($request); + } +} diff --git a/src/I18n/Number.php b/src/I18n/Number.php new file mode 100644 index 00000000000..a264c871c5d --- /dev/null +++ b/src/I18n/Number.php @@ -0,0 +1,430 @@ +> + */ + protected static array $_formatters = []; + + /** + * Default currency used by Number::currency() + * + * @var string|null + */ + protected static ?string $_defaultCurrency = null; + + /** + * Default currency format used by Number::currency() + * + * @var string|null + */ + protected static ?string $_defaultCurrencyFormat = null; + + /** + * Formats a number with a level of precision. + * + * Options: + * + * - `locale`: The locale name to use for formatting the number, e.g. fr_FR + * + * @param string|float|int $value A floating point number. + * @param int $precision The precision of the returned number. + * @param array $options Additional options + * @return string Formatted float. + * @link https://book.cakephp.org/5/en/core-libraries/number.html#formatting-floating-point-numbers + */ + public static function precision(string|float|int $value, int $precision = 3, array $options = []): string + { + $formatter = static::formatter(['precision' => $precision, 'places' => $precision] + $options); + + return (string)$formatter->format((float)$value); + } + + /** + * Returns a formatted-for-humans file size. + * + * @param string|float|int $size Size in bytes + * @return string Human readable size + * @link https://book.cakephp.org/5/en/core-libraries/number.html#interacting-with-human-readable-values + */ + public static function toReadableSize(string|float|int $size): string + { + $size = (int)$size; + + return match (true) { + $size < 1024 => __dn('cake', '{0,number,integer} Byte', '{0,number,integer} Bytes', $size, $size), + round($size / 1024) < 1024 => __d('cake', '{0,number,#,###.##} KB', $size / 1024), + round($size / 1024 / 1024, 2) < 1024 => __d('cake', '{0,number,#,###.##} MB', $size / 1024 / 1024), + round($size / 1024 / 1024 / 1024, 2) < 1024 => + __d('cake', '{0,number,#,###.##} GB', $size / 1024 / 1024 / 1024), + default => __d('cake', '{0,number,#,###.##} TB', $size / 1024 / 1024 / 1024 / 1024), + }; + } + + /** + * Formats a number into a percentage string. + * + * Options: + * + * - `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 string|float|int $value A floating point number + * @param int $precision The precision of the returned number + * @param array $options Options + * @return string Percentage string + * @link https://book.cakephp.org/5/en/core-libraries/number.html#formatting-percentages + */ + public static function toPercentage(string|float|int $value, int $precision = 2, array $options = []): string + { + $options += ['multiply' => false, 'type' => NumberFormatter::PERCENT]; + if (!$options['multiply']) { + $value = (float)$value / 100; + } + + return static::precision($value, $precision, $options); + } + + /** + * Formats a number into the correct locale format + * + * 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 #,##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 string|float|int $value A floating point number. + * @param array $options An array with options. + * @return string Formatted number + */ + public static function format(string|float|int $value, array $options = []): string + { + $formatter = static::formatter($options); + $options += ['before' => '', 'after' => '']; + + return $options['before'] . $formatter->format((float)$value) . $options['after']; + } + + /** + * Parse a localized numeric string and transform it in a float point + * + * Options: + * + * - `locale` - The locale name to use for parsing the number, e.g. fr_FR + * - `type` - The formatter type to construct, set it to `currency` if you need to parse + * numbers representing money. + * + * @param string $value A numeric string. + * @param array $options An array with options. + * @return float point number + */ + public static function parseFloat(string $value, array $options = []): float + { + $formatter = static::formatter($options); + + return (float)$formatter->parse($value, NumberFormatter::TYPE_DOUBLE); + } + + /** + * Formats a number into the correct locale format to show deltas (signed differences in value). + * + * ### Options + * + * - `places` - Minimum number or decimals to use, e.g 0 + * - `precision` - Maximum Number of decimal places to use, e.g. 2 + * - `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 string|float|int $value A floating point number + * @param array $options Options list. + * @return string formatted delta + */ + public static function formatDelta(string|float|int $value, array $options = []): string + { + $options += ['places' => 0]; + $value = number_format((float)$value, $options['places'], '.', ''); + $sign = $value > 0 ? '+' : ''; + $options['before'] = isset($options['before']) ? $options['before'] . $sign : $sign; + + return static::format($value, $options); + } + + /** + * Formats a number into a currency format. + * + * ### Options + * + * - `locale` - The locale name to use for formatting the number, e.g. fr_FR + * - `fractionSymbol` - The currency symbol to use for fractional numbers. + * - `fractionPosition` - The position the fraction symbol should be placed + * valid options are 'before' & 'after'. + * - `before` - Text to display before the rendered number + * - `after` - Text to display after the rendered number + * - `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 + * - `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 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. + * @return string Number formatted as a currency. + */ + public static function currency(string|float|int $value, ?string $currency = null, array $options = []): string + { + $value = (float)$value; + $currency = $currency ?: static::getDefaultCurrency(); + + if (isset($options['zero']) && !$value) { + return $options['zero']; + } + + $formatter = static::formatter(['type' => static::getDefaultCurrencyFormat()] + $options); + $abs = abs($value); + if (!empty($options['fractionSymbol']) && $abs > 0 && $abs < 1) { + $value *= 100; + /** @var string $pos */ + $pos = $options['fractionPosition'] ?? 'after'; + + return static::format($value, ['precision' => 0, $pos => $options['fractionSymbol']]); + } + + $before = $options['before'] ?? ''; + $after = $options['after'] ?? ''; + $value = $formatter->formatCurrency($value, $currency); + + return $before . $value . $after; + } + + /** + * Getter for default currency + * + * @return string Currency + */ + public static function getDefaultCurrency(): string + { + if (static::$_defaultCurrency === null) { + $locale = ini_get('intl.default_locale') ?: static::DEFAULT_LOCALE; + $formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY); + static::$_defaultCurrency = $formatter->getTextAttribute(NumberFormatter::CURRENCY_CODE); + } + + 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; + } + + /** + * Returns a formatter object that can be reused for similar formatting task + * under the same locale and options. This is often a speedier alternative to + * using other methods in this class as only one formatter object needs to be + * constructed. + * + * ### Options + * + * - `locale` - The locale name to use for formatting the number, e.g. fr_FR + * - `type` - The formatter type to construct, set it to `currency` if you need to format + * 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 + * - `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. + * @return \NumberFormatter The configured formatter instance + */ + public static function formatter(array $options = []): NumberFormatter + { + /** @var string $locale */ + $locale = $options['locale'] ?? ini_get('intl.default_locale'); + + if (!$locale) { + $locale = static::DEFAULT_LOCALE; + } + + $type = NumberFormatter::DECIMAL; + if (!empty($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; + } + } + + static::$_formatters[$locale][$type] ??= new NumberFormatter($locale, $type); + + /** @var \NumberFormatter $formatter */ + $formatter = static::$_formatters[$locale][$type]; + $formatter = clone $formatter; + + return static::_setAttributes($formatter, $options); + } + + /** + * Configure formatters. + * + * @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. + * @return void + */ + public static function config(string $locale, int $type = NumberFormatter::DECIMAL, array $options = []): void + { + static::$_formatters[$locale][$type] = static::_setAttributes( + new NumberFormatter($locale, $type), + $options, + ); + } + + /** + * Set formatter attributes + * + * @param \NumberFormatter $formatter Number formatter instance. + * @param array $options See Number::formatter() for possible options. + * @return \NumberFormatter + */ + protected static function _setAttributes(NumberFormatter $formatter, array $options = []): NumberFormatter + { + if (isset($options['places'])) { + $formatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $options['places']); + } + + if (isset($options['precision'])) { + $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']); + } + + if (!empty($options['useIntlCode'])) { + // One of the odd things about ICU is that the currency marker in patterns + // is denoted with ¤, whereas the international code is marked with ¤¤, + // in order to use the code we need to simply duplicate the character wherever + // it appears in the pattern. + $pattern = trim(str_replace('¤', '¤¤ ', $formatter->getPattern())); + $formatter->setPattern($pattern); + } + + return $formatter; + } + + /** + * Returns a formatted integer as an ordinal number string (e.g. 1st, 2nd, 3rd, 4th, [...]) + * + * ### Options + * + * - `type` - The formatter type to construct, set it to `currency` if you need to format + * numbers representing money or a NumberFormatter constant. + * + * For all other options see formatter(). + * + * @param float|int $value An integer + * @param array $options An array with options. + * @return string + */ + public static function ordinal(float|int $value, array $options = []): string + { + 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 new file mode 100644 index 00000000000..52d228ddd4e --- /dev/null +++ b/src/I18n/Parser/MoFileParser.php @@ -0,0 +1,171 @@ +_readLong($stream, $isBigEndian); + $offsetId = $this->_readLong($stream, $isBigEndian); + $offsetTranslated = $this->_readLong($stream, $isBigEndian); + + // Offset to start of translations + fread($stream, 8); + $messages = []; + + for ($i = 0; $i < $count; $i++) { + $pluralId = null; + $context = null; + $plurals = null; + + fseek($stream, $offsetId + $i * 8); + + $length = $this->_readLong($stream, $isBigEndian); + $offset = $this->_readLong($stream, $isBigEndian); + + if ($length < 1) { + continue; + } + + fseek($stream, $offset); + $singularId = (string)fread($stream, $length); + + if (str_contains($singularId, "\x04")) { + [$context, $singularId] = explode("\x04", $singularId); + } + + if (str_contains($singularId, "\000")) { + [$singularId, $pluralId] = explode("\000", $singularId); + } + + fseek($stream, $offsetTranslated + $i * 8); + $length = $this->_readLong($stream, $isBigEndian); + if ($length < 1) { + throw new CakeException('Length must be > 0'); + } + + $offset = $this->_readLong($stream, $isBigEndian); + fseek($stream, $offset); + $translated = (string)fread($stream, $length); + + if ($pluralId !== null || str_contains($translated, "\000")) { + $translated = explode("\000", $translated); + $plurals = $pluralId !== null ? $translated : null; + $translated = $translated[0]; + } + + $singular = $translated; + if ($context !== null) { + $messages[$singularId]['_context'][$context] = $singular; + if ($pluralId !== null) { + $messages[$pluralId]['_context'][$context] = $plurals; + } + continue; + } + + $messages[$singularId]['_context'][''] = $singular; + if ($pluralId !== null) { + $messages[$pluralId]['_context'][''] = $plurals; + } + } + + fclose($stream); + + return $messages; + } + + /** + * Reads an unsigned long from stream respecting endianness. + * + * @param resource $stream The File being read. + * @param bool $isBigEndian Whether the current platform is Big Endian + * @return int + */ + protected function _readLong($stream, bool $isBigEndian): int + { + /** @var array $result */ + $result = unpack($isBigEndian ? 'N1' : 'V1', (string)fread($stream, 4)); + $result = current($result); + + return (int)substr((string)$result, -8); + } +} diff --git a/src/I18n/Parser/PoFileParser.php b/src/I18n/Parser/PoFileParser.php new file mode 100644 index 00000000000..8298db0f6ee --- /dev/null +++ b/src/I18n/Parser/PoFileParser.php @@ -0,0 +1,194 @@ + [], + 'translated' => null, + ]; + + $messages = []; + $item = $defaults; + /** @var array $stage */ + $stage = []; + + while ($line = fgets($stream)) { + $line = trim($line); + + if ($line === '') { + // Whitespace indicated current item is done + $this->_addMessage($messages, $item); + $item = $defaults; + $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); + $stage = ['ids', 'singular']; + } elseif (str_starts_with($line, 'msgstr "')) { + $item['translated'] = substr($line, 8, -1); + $stage = ['translated']; + } elseif (str_starts_with($line, 'msgctxt "')) { + $item['context'] = substr($line, 9, -1); + $stage = ['context']; + } elseif ($line[0] === '"') { + 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 (str_starts_with($line, 'msgid_plural "')) { + $item['ids']['plural'] = substr($line, 14, -1); + $stage = ['ids', 'plural']; + } elseif (str_starts_with($line, 'msgstr[')) { + $size = strpos($line, ']'); + assert(is_int($size)); + + $row = (int)substr($line, 7, 1); + $item['translated'][$row] = substr($line, $size + 3, -1); + $stage = ['translated', $row]; + } + } + // save last item + $this->_addMessage($messages, $item); + fclose($stream); + + return $messages; + } + + /** + * Saves a translation item to the messages. + * + * @param array $messages The messages array being collected from the file + * @param array $item The current item being inspected + * @return void + */ + protected function _addMessage(array &$messages, array $item): void + { + if (empty($item['ids']['singular']) && empty($item['ids']['plural'])) { + return; + } + + $singular = stripcslashes($item['ids']['singular']); + $context = $item['context'] ?? null; + $translation = $item['translated']; + + if (is_array($translation)) { + $translation = $translation[0]; + } + + $translation = stripcslashes((string)$translation); + + if ($context !== null && !isset($messages[$singular]['_context'][$context])) { + $messages[$singular]['_context'][$context] = $translation; + } elseif (!isset($messages[$singular]['_context'][''])) { + $messages[$singular]['_context'][''] = $translation; + } + + if (isset($item['ids']['plural'])) { + $plurals = $item['translated']; + // PO are by definition indexed so sort by index. + ksort($plurals); + + // Make sure every index is filled. + $count = (int)array_key_last($plurals); + + // Fill missing spots with an empty string. + $empties = array_fill(0, $count + 1, ''); + $plurals += $empties; + ksort($plurals); + + $plurals = array_map('stripcslashes', $plurals); + $key = stripcslashes($item['ids']['plural']); + + if ($context !== null) { + $messages[Translator::PLURAL_PREFIX . $key]['_context'][$context] = $plurals; + } else { + $messages[Translator::PLURAL_PREFIX . $key]['_context'][''] = $plurals; + } + } + } +} diff --git a/src/I18n/PluralRules.php b/src/I18n/PluralRules.php new file mode 100644 index 00000000000..a46d8f8f717 --- /dev/null +++ b/src/I18n/PluralRules.php @@ -0,0 +1,197 @@ + plurals group used to determine + * which plural rules apply to the language + * + * @var array + */ + protected static array $_rulesMap = [ + 'af' => 1, + 'am' => 2, + 'ar' => 13, + 'az' => 1, + 'be' => 3, + 'bg' => 1, + 'bh' => 2, + 'bn' => 1, + 'bo' => 0, + 'bs' => 3, + 'ca' => 1, + 'cs' => 4, + 'cy' => 14, + 'da' => 1, + 'de' => 1, + 'dz' => 0, + 'el' => 1, + 'en' => 1, + 'eo' => 1, + 'es' => 17, + 'et' => 1, + 'eu' => 1, + 'fa' => 1, + 'fi' => 1, + 'fil' => 2, + 'fo' => 1, + 'fr' => 16, + 'fur' => 1, + 'fy' => 1, + 'ga' => 5, + 'gl' => 1, + 'gu' => 1, + 'gun' => 2, + 'ha' => 1, + 'he' => 1, + 'hi' => 2, + 'hr' => 3, + 'hu' => 1, + 'id' => 0, + 'is' => 15, + 'it' => 17, + 'ja' => 0, + 'jv' => 0, + 'ka' => 0, + 'km' => 0, + 'kn' => 0, + 'ko' => 0, + 'ku' => 1, + 'lb' => 1, + 'ln' => 2, + 'lt' => 6, + 'lv' => 10, + 'mg' => 2, + 'mk' => 8, + 'ml' => 1, + 'mn' => 1, + 'mr' => 1, + 'ms' => 0, + 'mt' => 9, + 'nah' => 1, + 'nb' => 1, + 'ne' => 1, + 'nl' => 1, + 'nn' => 1, + 'no' => 1, + 'nso' => 2, + 'om' => 1, + 'or' => 1, + 'pa' => 1, + 'pap' => 1, + 'pl' => 11, + 'ps' => 1, + 'pt_PT' => 17, + 'pt' => 16, + 'ro' => 12, + 'ru' => 3, + 'sk' => 4, + 'sl' => 7, + 'so' => 1, + 'sq' => 1, + 'sr' => 3, + 'sv' => 1, + 'sw' => 1, + 'ta' => 1, + 'te' => 1, + 'th' => 0, + 'ti' => 2, + 'tk' => 1, + 'tr' => 1, + 'uk' => 3, + 'ur' => 1, + 'vi' => 0, + 'wa' => 2, + 'zh' => 0, + 'zu' => 1, + ]; + + /** + * 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 $n The number to apply the rules to. + * @return int The plural rule number that should be used. + * @link https://php-gettext.github.io/Languages/#47 + */ + public static function calculate(string $locale, int $n): int + { + $locale = Locale::canonicalize($locale); + + if ($locale === null) { + throw new InvalidArgumentException('Invalid locale provided'); + } + + if (!isset(static::$_rulesMap[$locale])) { + $locale = explode('_', $locale)[0]; + } + + if (!isset(static::$_rulesMap[$locale])) { + return 0; + } + + 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)))), + 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.'), + }; + } +} diff --git a/src/I18n/README.md b/src/I18n/README.md new file mode 100644 index 00000000000..41a9dd1a426 --- /dev/null +++ b/src/I18n/README.md @@ -0,0 +1,103 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/i18n.svg?style=flat-square)](https://packagist.org/packages/cakephp/i18n) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Internationalization Library + +The I18n library provides a `I18n` service locator that can be used for setting +the current locale, building translation bundles and translating messages. + +Additionally, it provides the `Time` and `Number` classes which can be used to +output dates, currencies and any numbers in the right format for the specified locale. + +## Usage + +Internally, the `I18n` class uses [Aura.Intl](https://github.com/auraphp/Aura.Intl). +Getting familiar with it will help you understand how to build and manipulate translation bundles, +should you wish to create them manually instead of using the conventions this library uses. + +### Setting the Current Locale + +```php +use Cake\I18n\I18n; + +I18n::setLocale('en_US'); +``` + +### Setting path to folder containing po files. + +```php +use Cake\Core\Configure; + +Configure::write('App.paths.locales', ['/path/with/trailing/slash/']); +``` + +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 + +```php +echo __( + 'Hi {0,string}, your balance on the {1,date} is {2,number,currency}', + ['Charles', '2014-01-13 11:12:00', 1354.37] +); + +// Returns +Hi Charles, your balance on the Jan 13, 2014, 11:12 AM is $ 1,354.37 +``` + +### Creating Your Own Translators + +```php +use Cake\I18n\I18n; +use Cake\I18n\Package; + +I18n::translator('animals', 'fr_FR', function () { + $package = new Package( + 'default', // The formatting strategy (ICU) + 'default', // The fallback domain + ); + $package->setMessages([ + 'Dog' => 'Chien', + 'Cat' => 'Chat', + 'Bird' => 'Oiseau' + ... + ]); + + return $package; +}); + +I18n::getLocale('fr_FR'); +__d('animals', 'Dog'); // Returns "Chien" +``` + +### Formatting Time + +```php +$time = Time::now(); +echo $time; // shows '4/20/14, 10:10 PM' for the en-US locale +``` + +### Formatting Numbers + +```php +echo Number::format(100100100); +``` + +```php +echo Number::currency(123456.7890, 'EUR'); +// outputs €123,456.79 +``` + +## Documentation + +Please make sure you check the [official I18n +documentation](https://book.cakephp.org/5/en/core-libraries/internationalization-and-localization.html). + +The [documentation for the Time +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/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 new file mode 100644 index 00000000000..b202daad0c4 --- /dev/null +++ b/src/I18n/RelativeTimeFormatter.php @@ -0,0 +1,443 @@ +getTimezone()); + } + } + assert( + ($first instanceof ChronosDate && $second instanceof ChronosDate) || + ($first instanceof DateTimeInterface && $second instanceof DateTimeInterface), + ); + + $diffInterval = $first->diff($second); + + switch (true) { + case $diffInterval->y > 0: + $count = $diffInterval->y; + $message = __dn('cake', '{0} year', '{0} years', $count, $count); + break; + case $diffInterval->m > 0: + $count = $diffInterval->m; + $message = __dn('cake', '{0} month', '{0} months', $count, $count); + break; + case $diffInterval->d > 0: + $count = $diffInterval->d; + 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: + $count = $diffInterval->h; + $message = __dn('cake', '{0} hour', '{0} hours', $count, $count); + break; + case $diffInterval->i > 0: + $count = $diffInterval->i; + $message = __dn('cake', '{0} minute', '{0} minutes', $count, $count); + break; + default: + $count = $diffInterval->s; + $message = __dn('cake', '{0} second', '{0} seconds', $count, $count); + break; + } + if ($absolute) { + return $message; + } + $isFuture = $diffInterval->invert === 1; + if ($isNow) { + return $isFuture ? __d('cake', '{0} from now', $message) : __d('cake', '{0} ago', $message); + } + + return $isFuture ? __d('cake', '{0} after', $message) : __d('cake', '{0} before', $message); + } + + /** + * Format a time into a relative timestring. + * + * @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\DateTime::timeAgoInWords() + */ + public function timeAgoInWords(DateTime|Date $time, array $options = []): string + { + $options = $this->_options($options, DateTime::class); + if ($time instanceof DateTime && $options['timezone']) { + $time = $time->setTimezone($options['timezone']); + } + + /** @var \Cake\Chronos\Chronos $from */ + $from = $options['from']; + $now = (int)$from->format('U'); + $inSeconds = (int)$time->format('U'); + $backwards = ($inSeconds > $now); + + $futureTime = $now; + $pastTime = $inSeconds; + if ($backwards) { + $futureTime = $inSeconds; + $pastTime = $now; + } + $diff = $futureTime - $pastTime; + + if (!$diff) { + return __d('cake', 'just now', 'just now'); + } + + 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); + [$fNum, $fWord, $years, $months, $weeks, $days, $hours, $minutes, $seconds] = array_values($diffData); + + $relativeDate = []; + if ($fNum >= 1 && $years > 0) { + $relativeDate[] = __dn('cake', '{0} year', '{0} years', $years, $years); + } + if ($fNum >= 2 && $months > 0) { + $relativeDate[] = __dn('cake', '{0} month', '{0} months', $months, $months); + } + if ($fNum >= 3 && $weeks > 0) { + $relativeDate[] = __dn('cake', '{0} week', '{0} weeks', $weeks, $weeks); + } + if ($fNum >= 4 && $days > 0) { + $relativeDate[] = __dn('cake', '{0} day', '{0} days', $days, $days); + } + if ($fNum >= 5 && $hours > 0) { + $relativeDate[] = __dn('cake', '{0} hour', '{0} hours', $hours, $hours); + } + if ($fNum >= 6 && $minutes > 0) { + $relativeDate[] = __dn('cake', '{0} minute', '{0} minutes', $minutes, $minutes); + } + if ($fNum >= 7 && $seconds > 0) { + $relativeDate[] = __dn('cake', '{0} second', '{0} seconds', $seconds, $seconds); + } + $relativeDate = implode(', ', $relativeDate); + + // When time has passed + if (!$backwards) { + $aboutAgo = [ + 'second' => __d('cake', 'about a second ago'), + 'minute' => __d('cake', 'about a minute ago'), + 'hour' => __d('cake', 'about an hour ago'), + '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'), + ]; + + return $relativeDate ? sprintf($options['relativeString'], $relativeDate) : $aboutAgo[$fWord]; + } + + // When time is to come + if ($relativeDate) { + return $relativeDate; + } + $aboutIn = [ + 'second' => __d('cake', 'in about a second'), + 'minute' => __d('cake', 'in about a minute'), + 'hour' => __d('cake', 'in about an hour'), + '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'), + ]; + + return $aboutIn[$fWord]; + } + + /** + * Calculate the data needed to format a relative difference string. + * + * @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(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) { + $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; + } + if ((int)$future['m'] < (int)$past['m'] && (int)$future['Y'] - (int)$past['Y'] === 1) { + $years--; + } + + if ((int)$future['d'] >= (int)$past['d']) { + $days = (int)$future['d'] - (int)$past['d']; + } else { + $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 - (int)$past['d'] + (int)$future['d']; + } else { + $days = $daysInFutureMonth - (int)$past['d'] + (int)$future['d']; + } + + if ($future['m'] !== $past['m']) { + $months--; + } + } + + if (!$months && $years >= 1 && $diff < $years * 31536000) { + $months = 11; + $years--; + } + + if ($months >= 12) { + $years++; + $months -= 12; + } + + if ($days >= 7) { + $weeks = floor($days / 7); + $days -= $weeks * 7; + } + } else { + $years = 0; + $months = 0; + $weeks = 0; + $days = floor($diff / 86400); + + $diff -= $days * 86400; + + $hours = floor($diff / 3600); + $diff -= $hours * 3600; + + $minutes = floor($diff / 60); + $diff -= $minutes * 60; + $seconds = $diff; + } + + $fWord = $options['accuracy']['second']; + if ($years > 0) { + $fWord = $options['accuracy']['year']; + } elseif (abs($months) > 0) { + $fWord = $options['accuracy']['month']; + } elseif (abs($weeks) > 0) { + $fWord = $options['accuracy']['week']; + } elseif (abs($days) > 0) { + $fWord = $options['accuracy']['day']; + } elseif (abs($hours) > 0) { + $fWord = $options['accuracy']['hour']; + } elseif (abs($minutes) > 0) { + $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, + (int)$years, + (int)$months, + (int)$weeks, + (int)$days, + (int)$hours, + (int)$minutes, + (int)$seconds, + ]; + } + + /** + * Format a date into a relative date string. + * + * @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(DateTime|Date $date, array $options = []): string + { + $options = $this->_options($options, Date::class); + if ($date instanceof DateTime && $options['timezone']) { + $date = $date->setTimezone($options['timezone']); + } + + /** @var \Cake\Chronos\Chronos $from */ + $from = $options['from']; + $now = (int)$from->format('U'); + $inSeconds = (int)$date->format('U'); + $backwards = ($inSeconds > $now); + + $futureTime = $now; + $pastTime = $inSeconds; + if ($backwards) { + $futureTime = $inSeconds; + $pastTime = $now; + } + $diff = $futureTime - $pastTime; + + if (!$diff) { + return __d('cake', 'today'); + } + + 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); + [$fNum, $fWord, $years, $months, $weeks, $days] = array_values($diffData); + + $relativeDate = []; + if ($fNum >= 1 && $years > 0) { + $relativeDate[] = __dn('cake', '{0} year', '{0} years', $years, $years); + } + if ($fNum >= 2 && $months > 0) { + $relativeDate[] = __dn('cake', '{0} month', '{0} months', $months, $months); + } + if ($fNum >= 3 && $weeks > 0) { + $relativeDate[] = __dn('cake', '{0} week', '{0} weeks', $weeks, $weeks); + } + if ($fNum >= 4 && $days > 0) { + $relativeDate[] = __dn('cake', '{0} day', '{0} days', $days, $days); + } + $relativeDate = implode(', ', $relativeDate); + + // When time has passed + if (!$backwards) { + $aboutAgo = [ + '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'), + ]; + + return $relativeDate ? sprintf($options['relativeString'], $relativeDate) : $aboutAgo[$fWord]; + } + + // When time is to come + if ($relativeDate) { + return $relativeDate; + } + $aboutIn = [ + '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'), + ]; + + return $aboutIn[$fWord]; + } + + /** + * 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. + * @phpstan-param class-string<\Cake\I18n\Date>|class-string<\Cake\I18n\DateTime> $class + */ + protected function _options(array $options, string $class): array + { + $options += [ + 'from' => $class::now(), + 'timezone' => null, + 'format' => $class::$wordFormat, + 'accuracy' => $class::$wordAccuracy, + 'end' => $class::$wordEnd, + 'relativeString' => __d('cake', '%s ago'), + 'absoluteString' => __d('cake', 'on %s'), + ]; + if (is_string($options['accuracy'])) { + $accuracy = $options['accuracy']; + $options['accuracy'] = []; + foreach ($class::$wordAccuracy as $key => $level) { + $options['accuracy'][$key] = $accuracy; + } + } else { + $options['accuracy'] += $class::$wordAccuracy; + } + + return $options; + } +} diff --git a/src/I18n/Time.php b/src/I18n/Time.php new file mode 100644 index 00000000000..79def737ae2 --- /dev/null +++ b/src/I18n/Time.php @@ -0,0 +1,240 @@ +i18nFormat(); + * $time->i18nFormat(\IntlDateFormatter::FULL); + * $time->i18nFormat("HH':'mm':'ss"); + * ``` + * + * You can control the default format used through `Time::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 + * + * ``` + * $time = new Time('2014-04-20'); + * $time->i18nFormat('de-DE'); + * $time->i18nFormat(\IntlDateFormatter::FULL, '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 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 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.'); + } + + $format ??= static::$_toStringFormat; + $format = is_int($format) ? [IntlDateFormatter::NONE, $format] : $format; + $locale = $locale ?: DateTime::getDefaultLocale(); + + return $this->_formatObject($this->toNative(), $format, $locale); + } + + /** + * Returns a nicely formatted date string for this object. + * + * The format to be used is stored in the static property `Time::$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 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); + } + + /** + * @inheritDoc + */ + public function __toString(): string + { + return (string)$this->i18nFormat(); + } +} diff --git a/src/I18n/Translator.php b/src/I18n/Translator.php new file mode 100644 index 00000000000..f1e6bda1f83 --- /dev/null +++ b/src/I18n/Translator.php @@ -0,0 +1,211 @@ +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(string $key, array $tokensValues = []): string + { + if (isset($tokensValues['_count'])) { + $message = $this->getMessage(static::PLURAL_PREFIX . $key); + if (!$message) { + $message = $this->getMessage($key); + } + } else { + $message = $this->getMessage($key); + if (!$message) { + $message = $this->getMessage(static::PLURAL_PREFIX . $key); + } + } + + if (!$message) { + // Fallback to the message key + $message = $key; + } + + // Check for missing/invalid context + if (is_array($message) && isset($message['_context'])) { + $message = $this->resolveContext($key, $message, $tokensValues); + unset($tokensValues['_context']); + } + + if (!$tokensValues) { + // Fallback for plurals that were using the singular key + if (is_array($message)) { + return array_values($message + [''])[0]; + } + + return $message; + } + + // Singular message, but plural call + if (is_string($message) && isset($tokensValues['_singular'])) { + $message = [$tokensValues['_singular'], $message]; + } + + // Resolve plural form. + if (is_array($message)) { + $count = $tokensValues['_count'] ?? 0; + $form = PluralRules::calculate($this->locale, (int)$count); + $message = $message[$form] ?? (string)end($message); + } + + 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); + } + + /** + * Resolve a message's context structure. + * + * @param string $key The message key being handled. + * @param array $message The message content. + * @param array $vars The variables containing the `_context` key. + * @return array|string + */ + protected function resolveContext(string $key, array $message, array $vars): array|string + { + $context = $vars['_context'] ?? null; + + // No or missing context, fallback to the key/first message + if ($context === null) { + if (isset($message['_context'][''])) { + return $message['_context'][''] === '' ? $key : $message['_context']['']; + } + + return current($message['_context']); + } + if (!isset($message['_context'][$context])) { + return $key; + } + if ($message['_context'][$context] === '') { + return $key; + } + + return $message['_context'][$context]; + } + + /** + * Returns the translator package + * + * @return \Cake\I18n\Package + */ + public function getPackage(): Package + { + return $this->package; + } +} diff --git a/src/I18n/TranslatorRegistry.php b/src/I18n/TranslatorRegistry.php new file mode 100644 index 00000000000..c9743e94b05 --- /dev/null +++ b/src/I18n/TranslatorRegistry.php @@ -0,0 +1,364 @@ +> + */ + protected array $registry = []; + + /** + * The current locale code. + * + * @var string + */ + 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 + * translators from the fallback loader + * + * @var string + */ + protected string $_defaultFormatter = 'default'; + + /** + * Use fallback-domain for translation loaders. + * + * @var bool + */ + protected bool $_useFallback = true; + + /** + * A CacheEngine object that is used to remember translator across + * requests. + * + * @var (\Psr\SimpleCache\CacheInterface&\Cake\Cache\CacheEngineInterface)|null + */ + protected $_cacher; + + /** + * Constructor. + * + * @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, + string $locale, + ) { + $this->packages = $packages; + $this->formatters = $formatters; + $this->setLocale($locale); + + $this->registerLoader(static::FALLBACK_LOADER, function ($name, $locale) { + $loader = new ChainMessagesLoader([ + new MessagesFileLoader($name, $locale, 'mo'), + new MessagesFileLoader($name, $locale, 'po'), + ]); + + $formatter = $name === 'cake' ? 'default' : $this->_defaultFormatter; + $package = $loader(); + $package->setFormatter($formatter); + + 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 \Psr\SimpleCache\CacheInterface&\Cake\Cache\CacheEngineInterface $cacher The cacher instance. + * @return void + */ + public function setCacher(CacheInterface&CacheEngineInterface $cacher): void + { + $this->_cacher = $cacher; + } + + /** + * 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 \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(string $name, ?string $locale = null): ?Translator + { + $locale ??= $this->getLocale(); + + if (isset($this->registry[$name][$locale])) { + return $this->registry[$name][$locale]; + } + + if ($this->_cacher === null) { + return $this->registry[$name][$locale] = $this->_getTranslator($name, $locale); + } + + // Cache keys cannot contain / if they go to file engine. + $keyName = str_replace('/', '.', $name); + $key = "translations.{$keyName}.{$locale}"; + /** @var \Cake\I18n\Translator|null $translator */ + $translator = $this->_cacher->get($key); + + if (!$translator) { + $translator = $this->_getTranslator($name, $locale); + $this->_cacher->set($key, $translator); + } + + return $this->registry[$name][$locale] = $translator; + } + + /** + * Gets a translator from the registry by package for a locale. + * + * @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 _getTranslator(string $name, string $locale): Translator + { + 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(); + } + + $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 new Translator($locale, $package, $formatter, $fallback); + } + + /** + * Registers a loader function for a package name that will be used as a fallback + * in case no package with that name can be found. + * + * Loader callbacks will get as first argument the package name and the locale as + * the second argument. + * + * @param string $name The name of the translator package to register a loader for + * @param callable $loader A callable object that should return a Package + * @return void + */ + public function registerLoader(string $name, callable $loader): void + { + $this->_loaders[$name] = $loader; + } + + /** + * Sets the name of the default messages formatter to use for future + * translator instances. + * + * If called with no arguments, it will return the currently configured value. + * + * @param string|null $name The name of the formatter to use. + * @return string The name of the formatter. + */ + public function defaultFormatter(?string $name = null): string + { + if ($name === null) { + return $this->_defaultFormatter; + } + + return $this->_defaultFormatter = $name; + } + + /** + * Set if the default domain fallback is used. + * + * @param bool $enable flag to enable or disable fallback + * @return void + */ + public function useFallback(bool $enable = true): void + { + $this->_useFallback = $enable; + } + + /** + * Set fallback domain for package. + * + * @param string $name The name of the package. + * @param \Cake\I18n\Package $package Package instance + * @return \Cake\I18n\Package + */ + public function setFallbackPackage(string $name, Package $package): Package + { + if ($package->getFallback()) { + return $package; + } + + $fallbackDomain = null; + if ($this->_useFallback && $name !== 'default') { + $fallbackDomain = 'default'; + } + + $package->setFallback($fallbackDomain); + + return $package; + } + + /** + * Set domain fallback for loader. + * + * @param string $name The name of the loader domain + * @param callable $loader invokable loader + * @return callable loader + */ + public function setLoaderFallback(string $name, callable $loader): callable + { + $fallbackDomain = 'default'; + if (!$this->_useFallback || $name === $fallbackDomain) { + return $loader; + } + + return function () use ($loader, $fallbackDomain) { + /** @var \Cake\I18n\Package $package */ + $package = $loader(); + if (!$package->getFallback()) { + $package->setFallback($fallbackDomain); + } + + return $package; + }; + } +} diff --git a/src/I18n/composer.json b/src/I18n/composer.json new file mode 100644 index 00000000000..16755c4af6f --- /dev/null +++ b/src/I18n/composer.json @@ -0,0 +1,54 @@ +{ + "name": "cakephp/i18n", + "description": "CakePHP Internationalization library with support for messages translation and dates and numbers localization", + "type": "library", + "keywords": [ + "cakephp", + "i18n", + "internationalisation", + "internationalization", + "localisation", + "localization", + "translation", + "date", + "number" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/i18n/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/i18n" + }, + "require": { + "php": ">=8.2", + "ext-intl": "*", + "cakephp/core": "^5.3.0", + "cakephp/chronos": "^3.3" + }, + "autoload": { + "psr-4": { + "Cake\\I18n\\": "." + }, + "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.4.x-dev" + } + } +} diff --git a/src/I18n/functions.php b/src/I18n/functions.php new file mode 100644 index 00000000000..51d2f87b55e --- /dev/null +++ b/src/I18n/functions.php @@ -0,0 +1,322 @@ +translate($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, + ); +} + +/** + * 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); +} + +/** + * 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, + ); +} + +/** + * 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); +} + +/** + * 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, + ); +} + +/** + * 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($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, + ); +} + +/** + * 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 (is_string($value)) { + try { + return DateTime::createFromFormat($format, $value); + } catch (Throwable) { + return null; + } + } + + return null; +} + +/** + * 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; + } + + if ($value instanceof DateTimeInterface) { + return Date::parse($value); + } + + if (is_numeric($value)) { + try { + $datetime = DateTime::createFromTimestamp((float)$value); + + return Date::create($datetime->year, $datetime->month, $datetime->day); + } catch (Throwable) { + return null; + } + } + + if (is_string($value)) { + try { + $datetime = DateTime::createFromFormat($format, $value); + + 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 @@ + + */ + 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 new file mode 100644 index 00000000000..19594c88f00 --- /dev/null +++ b/src/Log/Engine/BaseLog.php @@ -0,0 +1,196 @@ + + */ + protected array $_defaultConfig = [ + 'levels' => [], + 'scopes' => [], + 'formatter' => DefaultFormatter::class, + ]; + + /** + * @var \Cake\Log\Formatter\AbstractFormatter + */ + protected AbstractFormatter $formatter; + + /** + * __construct method + * + * @param array $config Configuration array + */ + public function __construct(array $config = []) + { + $this->setConfig($config); + + // 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']; + } + + $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 + */ + public function levels(): array + { + return $this->_config['levels']; + } + + /** + * Get the scopes this logger is interested in. + * + * @return array|null + */ + public function scopes(): ?array + { + return $this->_config['scopes']; + } + + /** + * Replaces placeholders in message string with context values. + * + * @param \Stringable|string $message Formatted message. + * @param array $context Context for placeholder values. + * @return string + */ + protected function interpolate(Stringable|string $message, array $context = []): string + { + $message = (string)$message; + + if (!str_contains($message, '{') && !str_contains($message, '}')) { + return $message; + } + + $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 str_replace(array_keys($replacements), $replacements, $message); + } +} diff --git a/src/Log/Engine/ConsoleLog.php b/src/Log/Engine/ConsoleLog.php new file mode 100644 index 00000000000..49dbc7fcd98 --- /dev/null +++ b/src/Log/Engine/ConsoleLog.php @@ -0,0 +1,99 @@ + + */ + protected array $_defaultConfig = [ + 'stream' => 'php://stderr', + 'levels' => null, + 'scopes' => [], + 'outputAs' => null, + 'formatter' => [ + 'className' => DefaultFormatter::class, + 'includeTags' => true, + ], + ]; + + /** + * Output stream + * + * @var \Cake\Console\ConsoleOutput + */ + protected ConsoleOutput $_output; + + /** + * Constructs a new Console Logger. + * + * Config + * + * - `levels` string or array, levels the engine is interested in + * - `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. + * @throws \InvalidArgumentException + */ + public function __construct(array $config = []) + { + parent::__construct($config); + + $config = $this->_config; + if ($config['stream'] instanceof ConsoleOutput) { + $this->_output = $config['stream']; + } elseif (is_string($config['stream'])) { + $this->_output = new ConsoleOutput($config['stream']); + } else { + throw new InvalidArgumentException('`stream` not a ConsoleOutput nor string'); + } + + if (isset($config['outputAs'])) { + $this->_output->setOutputAs($config['outputAs']); + } + } + + /** + * Implements writing to console. + * + * @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->_output->write($this->formatter->format($level, $message, $context)); + } +} diff --git a/src/Log/Engine/FileLog.php b/src/Log/Engine/FileLog.php new file mode 100644 index 00000000000..5538d1e541b --- /dev/null +++ b/src/Log/Engine/FileLog.php @@ -0,0 +1,217 @@ + + */ + protected array $_defaultConfig = [ + 'path' => null, + 'file' => null, + 'types' => null, + 'levels' => [], + 'scopes' => [], + 'rotate' => 10, + 'size' => 10485760, // 10MB + 'mask' => null, + 'dirMask' => 0777, + 'formatter' => [ + 'className' => DefaultFormatter::class, + ], + ]; + + /** + * Path to save log files on. + * + * @var string + */ + protected string $_path; + + /** + * The name of the file to save logs into. + * + * @var string|null + */ + protected ?string $_file = null; + + /** + * Max file size, used for log file rotation. + * + * @var int|null + */ + protected ?int $_size = null; + + /** + * Sets protected properties based on config provided + * + * @param array $config Configuration array + */ + public function __construct(array $config = []) + { + parent::__construct($config); + + $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 (!str_ends_with($this->_file, '.log')) { + $this->_file .= '.log'; + } + } + + if (!empty($this->_config['size'])) { + if (is_numeric($this->_config['size'])) { + $this->_size = (int)$this->_config['size']; + } else { + $this->_size = Text::parseFileSize($this->_config['size']); + } + } + } + + /** + * Implements writing to log files. + * + * @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 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); + $message = $this->formatter->format($level, $message, $context); + + $filename = $this->_getFilename($level); + if ($this->_size) { + $this->_rotateFile($filename); + } + + $pathname = $this->_path . $filename; + $mask = $this->_config['mask']; + if (!$mask) { + file_put_contents($pathname, $message . "\n", FILE_APPEND); + + return; + } + + $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], + ), E_USER_WARNING); + $selfError = false; + } + } + + /** + * Get filename + * + * @param string $level The level of log. + * @return string File name + */ + protected function _getFilename(string $level): string + { + $debugTypes = ['notice', 'info', 'debug']; + + if ($this->_file) { + $filename = $this->_file; + } elseif ($level === 'error' || $level === 'warning') { + $filename = 'error.log'; + } elseif (in_array($level, $debugTypes, true)) { + $filename = 'debug.log'; + } else { + $filename = $level . '.log'; + } + + return $filename; + } + + /** + * Rotate log file if size specified in config is reached. + * Also if `rotate` count is reached oldest file is removed. + * + * @param string $filename Log file name + * @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(string $filename): ?bool + { + $filePath = $this->_path . $filename; + clearstatcache(true, $filePath); + + if ( + !is_file($filePath) || + filesize($filePath) < $this->_size + ) { + return null; + } + + $rotate = $this->_config['rotate']; + if ($rotate === 0) { + $result = unlink($filePath); + } else { + $result = rename($filePath, $filePath . '.' . time()); + } + + $files = glob($filePath . '.*'); + if ($files) { + $filesToDelete = count($files) - $rotate; + while ($filesToDelete > 0) { + unlink((string)array_shift($files)); + $filesToDelete--; + } + } + + return $result; + } +} diff --git a/src/Log/Engine/SyslogLog.php b/src/Log/Engine/SyslogLog.php new file mode 100644 index 00000000000..543a2d51a9c --- /dev/null +++ b/src/Log/Engine/SyslogLog.php @@ -0,0 +1,155 @@ + 'Syslog', + * 'levels' => ['emergency', 'alert', 'critical', 'error'], + * 'prefix' => 'Web Server 01', + * ]); + * ``` + * + * @var array + */ + protected array $_defaultConfig = [ + 'levels' => [], + 'scopes' => [], + 'flag' => LOG_ODELAY, + 'prefix' => '', + 'facility' => LOG_USER, + 'formatter' => [ + 'className' => DefaultFormatter::class, + 'includeDate' => false, + ], + ]; + + /** + * Used to map the string names back to their LOG_* constants + * + * @var array + */ + protected array $_levelMap = [ + 'emergency' => LOG_EMERG, + 'alert' => LOG_ALERT, + 'critical' => LOG_CRIT, + 'error' => LOG_ERR, + 'warning' => LOG_WARNING, + 'notice' => LOG_NOTICE, + 'info' => LOG_INFO, + 'debug' => LOG_DEBUG, + ]; + + /** + * Whether the logger connection is open or not + * + * @var bool + */ + protected bool $_open = false; + + /** + * Writes a message to syslog + * + * 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 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 + { + if (!$this->_open) { + $config = $this->_config; + $this->_open($config['prefix'], $config['flag'], $config['facility']); + $this->_open = true; + } + + $priority = LOG_DEBUG; + if (isset($this->_levelMap[$level])) { + $priority = $this->_levelMap[$level]; + } + + $lines = explode("\n", $this->interpolate($message, $context)); + foreach ($lines as $line) { + $this->_write($priority, $this->formatter->format($level, $line, $context)); + } + } + + /** + * Extracts the call to openlog() in order to run unit tests on it. This function + * will initialize the connection to the system logger + * + * @param string $ident the prefix to add to all messages logged + * @param int $options the options flags to be used for logged messages + * @param int $facility the stream or facility to log to + * @return void + */ + 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 operation in the system logger + * + * @param int $priority Message priority. + * @param string $message Message to log. + * @return bool + */ + protected function _write(int $priority, string $message): bool + { + return syslog($priority, $message); + } + + /** + * Closes the logger connection + */ + public function __destruct() + { + closelog(); + } +} 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 new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/Log/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/Log/Log.php b/src/Log/Log.php new file mode 100644 index 00000000000..0440836c1ac --- /dev/null +++ b/src/Log/Log.php @@ -0,0 +1,516 @@ + 'FileLog']); + * ``` + * + * You can define the className as any fully namespaced classname or use a short hand + * classname to use loggers in the `App\Log\Engine` & `Cake\Log\Engine` namespaces. + * You can also use plugin short hand to use logging classes provided by plugins. + * + * Log adapters are required to implement `Psr\Log\LoggerInterface`, and there is a + * built-in base class (`Cake\Log\Engine\BaseLog`) that can be used for custom loggers. + * + * Outside of the `className` key, all other configuration values will be passed to the + * logging adapter's constructor as an array. + * + * ### Logging levels + * + * When configuring loggers, you can set which levels a logger will handle. + * This allows you to disable debug messages in production for example: + * + * ``` + * Log::setConfig('default', [ + * 'className' => 'File', + * 'path' => LOGS, + * 'levels' => ['error', 'critical', 'alert', 'emergency'] + * ]); + * ``` + * + * The above logger would only log error messages or higher. Any + * other log messages would be discarded. + * + * ### Logging scopes + * + * When configuring loggers you can define the active scopes the logger + * is for. If defined, only the listed scopes will be handled by the + * logger. If you don't define any scopes an adapter will catch + * all scopes that match the handled levels. + * + * ``` + * Log::setConfig('payments', [ + * 'className' => 'File', + * 'scopes' => ['payment', 'order'] + * ]); + * ``` + * + * The above logger will only capture log entries made in the + * `payment` and `order` scopes. All other scopes including the + * undefined scope will be ignored. + * + * ### Writing to the log + * + * You write to the logs using Log::write(). See its documentation for more information. + * + * ### Logging Levels + * + * By default Cake Log supports all the log levels defined in + * RFC 5424. When logging messages you can either use the named methods, + * or the correct constants with `write()`: + * + * ``` + * Log::error('Something horrible happened'); + * Log::write(LOG_ERR, 'Something horrible happened'); + * ``` + * + * ### Logging scopes + * + * When logging messages and configuring log adapters, you can specify + * 'scopes' that the logger will handle. You can think of scopes as subsystems + * in your application that may require different logging setups. For + * example in an e-commerce application you may want to handle logged errors + * in the cart and ordering subsystems differently than the rest of the + * application. By using scopes you can control logging for each part + * of your application and also use standard log levels. + */ +class Log +{ + use StaticConfigTrait { + setConfig as protected _setConfig; + } + + /** + * An array mapping url schemes to fully qualified Log engine class names + * + * @var array + * @phpstan-var array + */ + protected static array $_dsnClassMap = [ + 'console' => Engine\ConsoleLog::class, + 'file' => Engine\FileLog::class, + 'syslog' => Engine\SyslogLog::class, + ]; + + /** + * Internal flag for tracking whether configuration has been changed. + * + * @var bool + */ + protected static bool $_dirtyConfig = false; + + /** + * LogEngineRegistry class + * + * @var \Cake\Log\LogEngineRegistry + */ + protected static LogEngineRegistry $_registry; + + /** + * Handled log levels + * + * @var array + */ + protected static array $_levels = [ + 'emergency', + 'alert', + 'critical', + 'error', + 'warning', + 'notice', + 'info', + 'debug', + ]; + + /** + * Log levels as detailed in RFC 5424 + * https://tools.ietf.org/html/rfc5424 + * + * @var array + */ + protected static array $_levelMap = [ + 'emergency' => LOG_EMERG, + 'alert' => LOG_ALERT, + 'critical' => LOG_CRIT, + 'error' => LOG_ERR, + 'warning' => LOG_WARNING, + 'notice' => LOG_NOTICE, + 'info' => LOG_INFO, + 'debug' => LOG_DEBUG, + ]; + + /** + * Creates registry if doesn't exist and creates all defined logging + * adapters if config isn't loaded. + * + * @return \Cake\Log\LogEngineRegistry + */ + protected static function getRegistry(): LogEngineRegistry + { + static::$_registry ??= new LogEngineRegistry(); + + if (static::$_dirtyConfig) { + 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; + + return static::$_registry; + } + + /** + * Reset all the connected loggers. This is useful to do when changing the logging + * configuration or during testing when you want to reset the internal state of the + * Log class. + * + * Resets the configured logging adapters, as well as any custom logging levels. + * This will also clear the configuration data. + * + * @return void + */ + public static function reset(): void + { + if (isset(static::$_registry)) { + static::$_registry->reset(); + } + static::$_config = []; + static::$_dirtyConfig = true; + } + + /** + * Gets log levels + * + * Call this method to obtain current + * level configuration. + * + * @return array Active log levels + */ + public static function levels(): array + { + return static::$_levels; + } + + /** + * This method can be used to define logging adapters for an application + * or read existing configuration. + * + * To change an adapter's configuration at runtime, first drop the adapter and then + * reconfigure it. + * + * Loggers will not be constructed until the first log message is written. + * + * ### Usage + * + * Setting a cache engine up. + * + * ``` + * Log::setConfig('default', $settings); + * ``` + * + * Injecting a constructed adapter in: + * + * ``` + * Log::setConfig('default', $instance); + * ``` + * + * Using a factory function to get an adapter: + * + * ``` + * Log::setConfig('default', function () { return new FileLog(); }); + * ``` + * + * Configure multiple adapters at once: + * + * ``` + * Log::setConfig($arrayOfConfig); + * ``` + * + * @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(array|string $key, LoggerInterface|Closure|array|null $config = null): void + { + static::_setConfig($key, $config); + static::$_dirtyConfig = true; + } + + /** + * Get a logging engine. + * + * @param string $name Key name of a configured adapter to get. + * @return \Psr\Log\LoggerInterface|null Instance of LoggerInterface or null if not found + */ + public static function engine(string $name): ?LoggerInterface + { + $registry = static::getRegistry(); + if (!$registry->{$name}) { + return null; + } + + return $registry->{$name}; + } + + /** + * 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. + * + * ### Levels: + * + * - `LOG_EMERG` => 'emergency', + * - `LOG_ALERT` => 'alert', + * - `LOG_CRIT` => 'critical', + * - `LOG_ERR` => 'error', + * - `LOG_WARNING` => 'warning', + * - `LOG_NOTICE` => 'notice', + * - `LOG_INFO` => 'info', + * - `LOG_DEBUG` => 'debug', + * + * ### Basic usage + * + * Write a 'warning' message to the logs: + * + * ``` + * Log::write('warning', 'Stuff is broken here'); + * ``` + * + * ### Using scopes + * + * When writing a log message you can define one or many scopes for the message. + * This allows you to handle messages differently based on application section/feature. + * + * ``` + * Log::write('warning', 'Payment failed', ['scope' => 'payment']); + * ``` + * + * When configuring loggers you can configure the scopes a particular logger will handle. + * When using scopes, you must ensure that the level of the message, and the scope of the message + * intersect with the defined levels & scopes for a logger. + * + * ### Unhandled log messages + * + * If no configured logger can handle a log message (because of level or scope restrictions) + * 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 string|int $level The severity level of the message being written. + * The value must be an integer or string matching a known level. + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * 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(string|int $level, Stringable|string $message, array|string $context = []): bool + { + if (is_int($level) && in_array($level, static::$_levelMap, true)) { + $level = array_search($level, static::$_levelMap, true); + } + + if (!in_array($level, static::$_levels, true)) { + throw new InvalidArgumentException(sprintf('Invalid log level `%s`', $level)); + } + + $logged = false; + $context = (array)$context; + if (isset($context[0])) { + $context = ['scope' => $context]; + } + $context += ['scope' => []]; + + $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(); + } + + $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) { + $logger->log($level, $message, $context); + $logged = true; + } + } + + return $logged; + } + + /** + * Convenience method to log emergency messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + public static function emergency(Stringable|string $message, array|string $context = []): bool + { + return static::write(__FUNCTION__, $message, $context); + } + + /** + * Convenience method to log alert messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + public static function alert(Stringable|string $message, array|string $context = []): bool + { + return static::write(__FUNCTION__, $message, $context); + } + + /** + * Convenience method to log critical messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + public static function critical(Stringable|string $message, array|string $context = []): bool + { + return static::write(__FUNCTION__, $message, $context); + } + + /** + * Convenience method to log error messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + public static function error(Stringable|string $message, array|string $context = []): bool + { + return static::write(__FUNCTION__, $message, $context); + } + + /** + * Convenience method to log warning messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + public static function warning(Stringable|string $message, array|string $context = []): bool + { + return static::write(__FUNCTION__, $message, $context); + } + + /** + * Convenience method to log notice messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + public static function notice(Stringable|string $message, array|string $context = []): bool + { + return static::write(__FUNCTION__, $message, $context); + } + + /** + * Convenience method to log debug messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + public static function debug(Stringable|string $message, array|string $context = []): bool + { + return static::write(__FUNCTION__, $message, $context); + } + + /** + * Convenience method to log info messages + * + * @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 indexed array is passed, it + * will be treated as the `scope` key. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. + * @return bool Success + */ + 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 new file mode 100644 index 00000000000..570badf42f5 --- /dev/null +++ b/src/Log/LogEngineRegistry.php @@ -0,0 +1,96 @@ + + */ +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 class-string<\Psr\Log\LoggerInterface>|null Either the correct class name or null. + */ + protected function _resolveClassName(string $class): ?string + { + /** @var class-string<\Psr\Log\LoggerInterface>|null */ + return App::className($class, 'Log/Engine', 'Log'); + } + + /** + * Throws an exception when a logger 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 logger is missing in. + * @return void + * @throws \Cake\Core\Exception\CakeException + */ + protected function _throwMissingClassError(string $class, ?string $plugin): void + { + throw new CakeException(sprintf('Could not load class `%s`.', $class)); + } + + /** + * Create the logger instance. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * + * @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 $config An array of settings to use for the logger. + * @return \Psr\Log\LoggerInterface The constructed logger class. + */ + protected function _create(callable|object|string $class, string $alias, array $config): LoggerInterface + { + if (is_string($class)) { + /** @var class-string<\Psr\Log\LoggerInterface> $class */ + return new $class($config); + } + + if (is_callable($class)) { + return $class($alias); + } + + return $class; + } + + /** + * Remove a single logger from the registry. + * + * @param string $name The logger name. + * @return $this + */ + public function unload(string $name) + { + unset($this->_loaded[$name]); + + return $this; + } +} diff --git a/src/Log/LogTrait.php b/src/Log/LogTrait.php new file mode 100644 index 00000000000..6843f3300f0 --- /dev/null +++ b/src/Log/LogTrait.php @@ -0,0 +1,43 @@ + 'File', + 'levels' => ['notice', 'info', 'debug'], + 'file' => '/path/to/file.log', +]); + +// Fully namespaced name. +Log::setConfig('production', [ + 'className' => \Cake\Log\Engine\SyslogLog::class, + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], +]); +``` + +It is also possible to create loggers by providing a closure. + +```php +Log::setConfig('special', function () { + // Return any PSR-3 compatible logger + return new MyPSR3CompatibleLogger(); +}); +``` + +Or by injecting an instance directly: + +```php +Log::setConfig('special', new MyPSR3CompatibleLogger()); +``` + +You can then use the `Log` class to pass messages to the logging backends: + +```php +Log::write('debug', 'Something did not work'); +``` + +Only the logging engines subscribed to the log level you are writing to will +get the message passed. In the example above, only the 'local' engine will get +the log message. + +### Filtering messages with scopes + +The Log library supports another level of message filtering. By using scopes, +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::setConfig('payments', [ + 'className' => 'File', + 'levels' => ['error', 'info', 'warning'], + 'scopes' => ['payments'], + 'file' => '/logs/payments.log', +]); + +Log::warning('this gets written only to payments.log', ['scope' => ['payments']]); +``` + +## Documentation + +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 new file mode 100644 index 00000000000..a794a534254 --- /dev/null +++ b/src/Log/composer.json @@ -0,0 +1,45 @@ +{ + "name": "cakephp/log", + "description": "CakePHP logging library with support for multiple different streams", + "type": "library", + "keywords": [ + "cakephp", + "log", + "logging", + "streams" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/log/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/log" + }, + "require": { + "php": ">=8.2", + "cakephp/core": "^5.3.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.4.x-dev" + } + } +} diff --git a/src/Mailer/AbstractTransport.php b/src/Mailer/AbstractTransport.php new file mode 100644 index 00000000000..4964d1c10c6 --- /dev/null +++ b/src/Mailer/AbstractTransport.php @@ -0,0 +1,75 @@ + + */ + protected array $_defaultConfig = []; + + /** + * Send mail + * + * @param \Cake\Mailer\Message $message Email message. + * @return array Contains 'headers' and 'message' keys. Additional keys allowed. + * @phpstan-return array{headers: string, message: string, ...} + */ + abstract public function send(Message $message): array; + + /** + * Constructor + * + * @param array $config Configuration options. + */ + public function __construct(array $config = []) + { + $this->setConfig($config); + } + + /** + * Check that at least one destination header is set. + * + * @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 checkRecipient(Message $message): void + { + 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.', + ); + } + } +} diff --git a/src/Mailer/Exception/MissingActionException.php b/src/Mailer/Exception/MissingActionException.php new file mode 100644 index 00000000000..2bfb2f75d68 --- /dev/null +++ b/src/Mailer/Exception/MissingActionException.php @@ -0,0 +1,28 @@ +setSubject('Reset Password') + * ->setTo($user->email) + * ->set(['token' => $user->token]); + * } + * } + * ``` + * + * Is a trivial example but shows how a mailer could be declared. + * + * ## Sending Messages + * + * After you have defined some messages you will want to send them: + * + * ``` + * $mailer = new UserMailer(); + * $mailer->send('resetPassword', $user); + * ``` + * + * ## Event Listener + * + * Mailers can also subscribe to application event allowing you to + * decouple email delivery from your application code. By re-declaring the + * `implementedEvents()` method you can define event handlers that can + * convert events into email. For example, if your application had a user + * registration event: + * + * ``` + * public function implementedEvents(): array + * { + * return [ + * 'Model.afterSave' => 'onRegistration', + * ]; + * } + * + * public function onRegistration(EventInterface $event, EntityInterface $entity, ArrayObject $options) + * { + * if ($entity->isNew()) { + * $this->send('welcome', [$entity]); + * } + * } + * ``` + * + * The onRegistration method converts the application event into a mailer method. + * Our mailer could either be registered in the application bootstrap, or + * in the Table class' initialize() hook. + * + * @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()} + */ +class Mailer implements EventListenerInterface +{ + use LocatorAwareTrait; + use StaticConfigTrait; + + /** + * Mailer's name. + * + * @var string + */ + public static string $name; + + /** + * The transport instance to use for sending mail. + * + * @var \Cake\Mailer\AbstractTransport|null + */ + protected ?AbstractTransport $transport = null; + + /** + * Message class name. + * + * @var string + * @phpstan-var class-string<\Cake\Mailer\Message> + */ + protected string $messageClass = Message::class; + + /** + * Message instance. + * + * @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 + * @phpstan-var array + */ + protected static array $_dsnClassMap = []; + + /** + * @var array|null + */ + protected ?array $logConfig = null; + + /** + * Constructor + * + * @param array|string|null $config Array of configs, or string to load configs from app.php + */ + public function __construct(array|string|null $config = null) + { + $this->message = new $this->messageClass(); + + $config ??= static::getConfig('default'); + + if ($config) { + $this->setProfile($config); + } + } + + /** + * Get the view builder. + * + * @return \Cake\View\ViewBuilder + */ + public function viewBuilder(): ViewBuilder + { + return $this->getRenderer()->viewBuilder(); + } + + /** + * Get email renderer. + * + * @return \Cake\Mailer\Renderer + */ + public function getRenderer(): Renderer + { + return $this->renderer ??= new Renderer(); + } + + /** + * Set email renderer. + * + * @param \Cake\Mailer\Renderer $renderer Renderer instance. + * @return $this + */ + public function setRenderer(Renderer $renderer) + { + $this->renderer = $renderer; + + return $this; + } + + /** + * Get message instance. + * + * @return \Cake\Mailer\Message + */ + public function getMessage(): Message + { + return $this->message; + } + + /** + * 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(string $method, array $args) + { + $result = $this->message->$method(...$args); + if (str_starts_with($method, 'get')) { + return $result; + } + + return $this; + } + + /** + * Sets email view vars. + * + * @param array|string $key Variable name or hash of view variables. + * @param mixed $value View variable value. + * @return $this + */ + public function setViewVars(array|string $key, mixed $value = null) + { + $this->getRenderer()->set($key, $value); + + return $this; + } + + /** + * Sends email. + * + * 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 Contains 'headers' and 'message' keys. Additional keys allowed. + * @phpstan-return array{headers: string, message: string, ...} + * @throws \Cake\Mailer\Exception\MissingActionException + * @throws \BadMethodCallException + */ + public function send(?string $action = null, array $args = [], array $headers = []): array + { + if ($action === null) { + return $this->deliver(); + } + + 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->deliver(); + } finally { + $this->restore(); + } + + return $result; + } + + /** + * Render content and set message body. + * + * @param string $content Content. + * @return $this + */ + public function render(string $content = '') + { + $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 Contains 'headers' and 'message' keys. Additional keys allowed. + * @phpstan-return array{headers: string, message: string, ...} + */ + 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 $contents The content with 'headers' and 'message' keys. + * @phpstan-param array{headers: string, message: string, ...} $contents + * @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 + */ + public function implementedEvents(): array + { + return []; + } +} diff --git a/src/Mailer/MailerAwareTrait.php b/src/Mailer/MailerAwareTrait.php new file mode 100644 index 00000000000..1b7611ff1b0 --- /dev/null +++ b/src/Mailer/MailerAwareTrait.php @@ -0,0 +1,48 @@ +|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(string $name, array|string|null $config = null): Mailer + { + $className = App::className($name, 'Mailer', 'Mailer'); + if ($className === null) { + throw new MissingMailerException(compact('name')); + } + + 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..d1d9f411fdf --- /dev/null +++ b/src/Mailer/Renderer.php @@ -0,0 +1,119 @@ +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 $types Content types to render. Valid array values are {@link Message::MESSAGE_HTML}, {@link Message::MESSAGE_TEXT}. + * @return array The rendered content with "html" and/or "text" keys. + * @phpstan-param array<\Cake\Mailer\Message::MESSAGE_HTML|\Cake\Mailer\Message::MESSAGE_TEXT> $types + * @phpstan-return array{html?: string, text?: string} + */ + 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 new file mode 100644 index 00000000000..bfd33d459ac --- /dev/null +++ b/src/Mailer/Transport/DebugTransport.php @@ -0,0 +1,42 @@ +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 new file mode 100644 index 00000000000..ff244c16c82 --- /dev/null +++ b/src/Mailer/Transport/MailTransport.php @@ -0,0 +1,98 @@ +checkRecipient($message); + + // https://github.com/cakephp/cakephp/issues/2209 + // https://bugs.php.net/bug.php?id=47983 + $subject = str_replace("\r\n", '', $message->getSubject()); + + $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; + $headers .= $eol . 'Subject: ' . $subject; + + return ['headers' => $headers, 'message' => $message]; + } + + /** + * Wraps internal function mail() and throws exception instead of errors if anything goes wrong + * + * @param string $to email's recipient + * @param string $subject email's subject + * @param string $message email's body + * @param string $headers email's custom headers + * @param string $params additional params for sending email + * @throws \Cake\Network\Exception\SocketException if mail could not be sent + * @return void + */ + 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: ' . ($error['message'] ?? 'unknown'); + throw new CakeException($msg); + } + // phpcs:enable + } +} diff --git a/src/Mailer/Transport/SmtpTransport.php b/src/Mailer/Transport/SmtpTransport.php new file mode 100644 index 00000000000..90f77292c91 --- /dev/null +++ b/src/Mailer/Transport/SmtpTransport.php @@ -0,0 +1,651 @@ + + */ + protected array $_defaultConfig = [ + 'host' => 'localhost', + 'port' => 25, + 'timeout' => 30, + 'username' => null, + 'password' => null, + 'client' => null, + 'tls' => false, + 'keepAlive' => false, + 'authType' => null, + ]; + + /** + * Socket to SMTP server + * + * @var \Cake\Network\Socket + */ + protected Socket $_socket; + + /** + * Content of email to return + * + * @var array + */ + protected array $_content = []; + + /** + * The response of the last sent SMTP command. + * + * @var array + */ + protected array $_lastResponse = []; + + /** + * Authentication type. + * + * @var string|null + */ + protected ?string $authType = null; + + /** + * Destructor + * + * Tries to disconnect to ensure that the connection is being + * terminated properly before the socket gets closed. + */ + public function __destruct() + { + try { + $this->disconnect(); + } 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. + * + * This method tries to connect only in case there is no open + * connection available already. + * + * @return void + */ + public function connect(): void + { + if (!$this->connected()) { + $this->_connect(); + $this->_auth(); + } + } + + /** + * Check whether an open connection to the SMTP server is available. + * + * @return bool + */ + public function connected(): bool + { + return isset($this->_socket) && $this->_socket->isConnected(); + } + + /** + * Disconnect from the SMTP server. + * + * This method tries to disconnect only in case there is an open + * connection available. + * + * @return void + */ + public function disconnect(): void + { + if (!$this->connected()) { + return; + } + + $this->_disconnect(); + } + + /** + * Returns the response of the last sent SMTP command. + * + * A response consists of one or more lines containing a response + * code and an optional response message text: + * ``` + * [ + * [ + * 'code' => '250', + * 'message' => 'mail.example.com' + * ], + * [ + * 'code' => '250', + * 'message' => 'PIPELINING' + * ], + * [ + * 'code' => '250', + * 'message' => '8BITMIME' + * ], + * // etc... + * ] + * ``` + * + * @return array + */ + public function getLastResponse(): array + { + return $this->_lastResponse; + } + + /** + * Send mail + * + * @param \Cake\Mailer\Message $message Message instance + * @return array Contains 'headers' and 'message' keys. Additional keys allowed. + * @phpstan-return array{headers: string, message: string, ...} + * @throws \Cake\Network\Exception\SocketException + */ + public function send(Message $message): array + { + $this->checkRecipient($message); + + if (!$this->connected()) { + $this->_connect(); + $this->_auth(); + } else { + $this->_smtpSend('RSET'); + } + + $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. + * @return void + */ + protected function _bufferResponseLines(array $responseLines): void + { + $response = []; + foreach ($responseLines as $responseLine) { + if (preg_match('/^(\d{3})(?:[ -]+(.*))?$/', $responseLine, $match)) { + $response[] = [ + 'code' => $match[1], + '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(): void + { + $this->_generateSocket(); + if (!$this->_socket->connect()) { + throw new SocketException('Unable to connect to SMTP server.'); + } + $this->_smtpSend(null, '220'); + + $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']; + } else { + $httpHost = env('HTTP_HOST'); + if (is_string($httpHost) && strlen($httpHost)) { + [$host] = explode(':', $httpHost); + } + } + + try { + $this->_smtpSend("EHLO {$host}", '250'); + if ($config['tls']) { + $this->_smtpSend('STARTTLS', '220'); + $this->_socket->enableCrypto('tls'); + $this->_smtpSend("EHLO {$host}", '250'); + } + } 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.', + null, + $e, + ); + } + try { + $this->_smtpSend("HELO {$host}", '250'); + } catch (SocketException $e2) { + throw new SocketException('SMTP server did not accept the connection.', null, $e2); + } + } + + $this->_parseAuthType(); + } + + /** + * Send authentication + * + * @return void + * @throws \Cake\Network\Exception\SocketException + */ + protected function _auth(): void + { + 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; + } + + $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, 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, 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, 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 $message The email address to send with the command. + * @return string + */ + protected function _prepareFromCmd(string $message): string + { + return 'MAIL FROM:<' . $message . '>'; + } + + /** + * Prepares the `RCPT TO` SMTP command. + * + * @param string $message The email address to send with the command. + * @return string + */ + protected function _prepareRcptCmd(string $message): string + { + return 'RCPT TO:<' . $message . '>'; + } + + /** + * Prepares the `from` email address. + * + * @param \Cake\Mailer\Message $message Message instance + * @return array + */ + protected function _prepareFromAddress(Message $message): array + { + $from = $message->getReturnPath(); + if (!$from) { + return $message->getFrom(); + } + + return $from; + } + + /** + * Prepares the recipient email addresses. + * + * @param \Cake\Mailer\Message $message Message instance + * @return array + */ + protected function _prepareRecipientAddresses(Message $message): array + { + $to = $message->getTo(); + $cc = $message->getCc(); + $bcc = $message->getBcc(); + + return array_merge(array_keys($to), array_keys($cc), array_keys($bcc)); + } + + /** + * Prepares the message body. + * + * @param \Cake\Mailer\Message $message Message instance + * @return string + */ + protected function _prepareMessage(Message $message): string + { + $lines = $message->getBody(); + $messages = []; + foreach ($lines as $line) { + if (str_starts_with($line, '.')) { + $messages[] = '.' . $line; + } else { + $messages[] = $line; + } + } + + return implode("\r\n", $messages); + } + + /** + * Send emails + * + * @param \Cake\Mailer\Message $message Message instance + * @throws \Cake\Network\Exception\SocketException + * @return void + */ + protected function _sendRcpt(Message $message): void + { + $from = $this->_prepareFromAddress($message); + $this->_smtpSend($this->_prepareFromCmd((string)key($from))); + + $messages = $this->_prepareRecipientAddresses($message); + foreach ($messages as $mail) { + $this->_smtpSend($this->_prepareRcptCmd($mail)); + } + } + + /** + * Send Data + * + * @param \Cake\Mailer\Message $message Message instance + * @return void + * @throws \Cake\Network\Exception\SocketException + */ + protected function _sendData(Message $message): void + { + $this->_smtpSend('DATA', '354'); + + $headers = $message->getHeadersString([ + 'from', + 'sender', + 'replyTo', + 'readReceipt', + 'to', + 'cc', + 'subject', + 'returnPath', + ]); + $message = $this->_prepareMessage($message); + + $this->_smtpSend($headers . "\r\n\r\n" . $message . "\r\n\r\n\r\n."); + $this->_content = ['headers' => $headers, 'message' => $message]; + } + + /** + * Disconnect + * + * @return void + * @throws \Cake\Network\Exception\SocketException + */ + protected function _disconnect(): void + { + $this->_smtpSend('QUIT', false); + $this->_socket->disconnect(); + $this->authType = null; + } + + /** + * Helper method to generate socket + * + * @return void + * @throws \Cake\Network\Exception\SocketException + */ + protected function _generateSocket(): void + { + $this->_socket = new Socket($this->_config); + } + + /** + * Protected method for sending data to SMTP connection + * + * @param string|null $data Data to be sent to SMTP server + * @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(?string $data, string|false $checkCode = '250'): ?string + { + $this->_lastResponse = []; + + if ($data !== null) { + $this->_socket->write($data . "\r\n"); + } + + $timeout = $this->_config['timeout']; + + while ($checkCode !== false) { + $response = ''; + $startTime = time(); + while (!str_ends_with($response, "\r\n") && (time() - $startTime < $timeout)) { + $bytes = $this->_socket->read(); + if ($bytes === null) { + break; + } + $response .= $bytes; + } + // 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); + + $this->_bufferResponseLines($responseLines); + + if (preg_match('/^(' . $checkCode . ')(.)/', $response, $code)) { + if ($code[2] === '-') { + continue; + } + + return $code[1]; + } + 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..4794e899ed8 --- /dev/null +++ b/src/Mailer/TransportFactory.php @@ -0,0 +1,113 @@ + + * @phpstan-var array + */ + 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 void + * @throws \InvalidArgumentException When a transport cannot be created. + */ + protected static function _buildTransport(string $name): void + { + 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), + ); + } + + 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 (isset($registry->{$name})) { + return $registry->{$name}; + } + + static::_buildTransport($name); + + return $registry->{$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/Exception/SocketException.php b/src/Network/Exception/SocketException.php new file mode 100644 index 00000000000..dadfb499789 --- /dev/null +++ b/src/Network/Exception/SocketException.php @@ -0,0 +1,25 @@ + + */ + protected array $_defaultConfig = [ + 'persistent' => false, + 'host' => 'localhost', + 'protocol' => 'tcp', + 'port' => 80, + 'timeout' => 30, + ]; + + /** + * Reference to socket connection resource + * + * @var resource|null + */ + protected $connection; + + /** + * This boolean contains the current state of the Socket class + * + * @var bool + * @deprecated 5.2.9 Use isConnected() instead. + */ + protected bool $connected = false; + + /** + * This variable contains an array with the last error number (num) and string (str) + * + * @var array + */ + protected array $lastError = []; + + /** + * True if the socket stream is encrypted after a {@link \Cake\Network\Socket::enableCrypto()} call + * + * @var bool + */ + protected bool $encrypted = false; + + /** + * Contains all the encryption methods available + * + * @var array + */ + 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, + '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, + ]; + + /** + * Used to capture connection warnings which can happen when there are + * SSL errors for example. + * + * @var array + */ + protected array $_connectionErrors = []; + + /** + * Constructor. + * + * @param array $config Socket configuration, which will be merged with the base configuration + * @see \Cake\Network\Socket::$_defaultConfig + */ + public function __construct(array $config = []) + { + $this->setConfig($config); + } + + /** + * Connect the socket to the given host and port. + * + * @return bool Success + * @throws \Cake\Network\Exception\SocketException + */ + public function connect(): bool + { + if ($this->connection) { + $this->disconnect(); + } + + if (str_contains($this->_config['host'], '://')) { + [$this->_config['protocol'], $this->_config['host']] = explode('://', $this->_config['host']); + } + $scheme = null; + if (!empty($this->_config['protocol'])) { + $scheme = $this->_config['protocol'] . '://'; + } + + $this->_setSslContext($this->_config['host']); + if (!empty($this->_config['context'])) { + $context = stream_context_create($this->_config['context']); + } else { + $context = stream_context_create(); + } + + $connectAs = STREAM_CLIENT_CONNECT; + if ($this->_config['persistent']) { + $connectAs |= STREAM_CLIENT_PERSISTENT; + } + + /** + * @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, + (int)$this->_config['timeout'], + $connectAs, + $context, + ); + restore_error_handler(); + + if ($this->connection === null && (!$errNum || !$errStr)) { + $this->setLastError($errNum ?? 0, $errStr ?? ''); + throw new SocketException($errStr ?? '', $errNum ?? 0); + } + + if ($this->connection === null && $this->_connectionErrors) { + $message = implode("\n", $this->_connectionErrors); + throw new SocketException($message, E_WARNING); + } + + $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 $resource; + } + + /** + * Configure the SSL context options. + * + * @param string $host The host name being connected to. + * @return void + */ + protected function _setSslContext(string $host): void + { + foreach ($this->_config as $key => $value) { + if (!str_starts_with($key, 'ssl_')) { + continue; + } + $contextKey = substr($key, 4); + if (empty($this->_config['context']['ssl'][$contextKey])) { + $this->_config['context']['ssl'][$contextKey] = $value; + } + unset($this->_config[$key]); + } + $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'])) { + $this->_config['context']['ssl']['cafile'] = CaBundle::getBundledCaBundlePath(); + } + if (!empty($this->_config['context']['ssl']['verify_host'])) { + $this->_config['context']['ssl']['CN_match'] = $host; + } + unset($this->_config['context']['ssl']['verify_host']); + } + + /** + * 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. + * + * @param int $code Code number. + * @param string $message Message. + * @return void + */ + protected function _connectionErrorHandler(int $code, string $message): void + { + $this->_connectionErrors[] = $message; + } + + /** + * Get the connection context. + * + * @return array|null Null when there is no connection, an array when there is. + */ + public function context(): ?array + { + if (!$this->connection) { + return null; + } + + return stream_context_get_options($this->connection); + } + + /** + * Get the host name of the current connection. + * + * @return string Host name + */ + public function host(): string + { + if (Validation::ip($this->_config['host'])) { + return (string)gethostbyaddr($this->_config['host']); + } + + return (string)gethostbyaddr($this->address()); + } + + /** + * Get the IP address of the current connection. + * + * @return string IP address + */ + public function address(): string + { + if (Validation::ip($this->_config['host'])) { + return $this->_config['host']; + } + + return gethostbyname($this->_config['host']); + } + + /** + * Get all IP addresses associated with the current connection. + * + * @return array IP addresses + */ + public function addresses(): array + { + if (Validation::ip($this->_config['host'])) { + return [$this->_config['host']]; + } + + return gethostbynamel($this->_config['host']) ?: []; + } + + /** + * Get the last error as a string. + * + * @return string|null Last error + */ + public function lastError(): ?string + { + if (!$this->lastError) { + return null; + } + + return $this->lastError['num'] . ': ' . $this->lastError['str']; + } + + /** + * Set the last error. + * + * @param int|null $errNum Error code + * @param string $errStr Error string + * @return void + */ + public function setLastError(?int $errNum, string $errStr): void + { + $this->lastError = ['num' => $errNum, 'str' => $errStr]; + } + + /** + * Write data to the socket. + * + * @param string $data The data to write to the socket. + * @return int Bytes written. + */ + public function write(string $data): int + { + 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; + } + $written += $rv; + } + + return $written; + } + + /** + * Read data from the socket. Returns null if no data is available or no connection could be + * established. + * + * @param int $length Optional buffer length to read; defaults to 1024 + * @return string|null Socket data + */ + public function read(int $length = 1024): ?string + { + if ($length < 1) { + throw new InvalidArgumentException('Length must be greater than `0`'); + } + + if (!$this->isConnected() && !$this->connect()) { + return null; + } + + 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 null; + } + + return $buffer === false ? null : $buffer; + } + + /** + * Disconnect the socket from the current connection. + * + * @return bool Success + */ + public function disconnect(): bool + { + if (!is_resource($this->connection)) { + $this->connected = false; + + return true; + } + $this->connected = !fclose($this->connection); + + if (!$this->connected) { + $this->connection = null; + } + + return !$this->connected; + } + + /** + * Destructor, used to disconnect from current connection. + */ + public function __destruct() + { + $this->disconnect(); + } + + /** + * 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 void + */ + public function reset(?array $state = null): void + { + if (!$state) { + static $initialState = []; + if (!$initialState) { + $initialState = get_class_vars(self::class); + } + $state = $initialState; + } + + foreach ($state as $property => $value) { + $this->{$property} = $value; + } + } + + /** + * Encrypts current stream socket, using one of the defined encryption methods + * + * @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 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(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]; + + 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(), null, $e); + } + + if ($enableCryptoResult === true) { + $this->encrypted = $enable; + + 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 new file mode 100644 index 00000000000..4ec9673abf5 --- /dev/null +++ b/src/ORM/Association.php @@ -0,0 +1,1288 @@ +|string + */ + protected array|string $_bindingKey; + + /** + * The name of the field representing the foreign key to the table to load + * + * @var array|string|false + */ + protected array|string|false $_foreignKey; + + /** + * A list of conditions to be always included when fetching records from + * the target association + * + * @var \Closure|array + */ + protected Closure|array $_conditions = []; + + /** + * Whether the records on the target table are dependent on the source table, + * often used to indicate that records should be removed if the owning record in + * the source table is deleted. + * + * @var bool + */ + protected bool $_dependent = false; + + /** + * Whether cascaded deletes should also fire callbacks. + * + * @var bool + */ + protected bool $_cascadeCallbacks = false; + + /** + * Source table instance + * + * @var \Cake\ORM\Table + */ + protected Table $_sourceTable; + + /** + * Target table instance + * + * @var \Cake\ORM\Table + */ + protected Table $_targetTable; + + /** + * The type of join to be used when adding the association to a query + * + * @var string + */ + protected string $_joinType = SelectQuery::JOIN_TYPE_LEFT; + + /** + * The property name that should be filled with data from the target table + * in the source table record. + * + * @var string + */ + protected string $_propertyName; + + /** + * The strategy name to be used to fetch associated records. Some association + * types might not implement but one strategy to fetch records. + * + * @var string + */ + 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 array|string + */ + protected array|string $_finder = 'all'; + + /** + * Valid strategies for this association. Subclasses can narrow this down. + * + * @var array + */ + protected array $_validStrategies = [ + self::STRATEGY_JOIN, + self::STRATEGY_SELECT, + 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 + */ + public function __construct(string $alias, array $options = []) + { + $defaults = [ + 'cascadeCallbacks', + 'className', + 'conditions', + 'dependent', + 'finder', + 'bindingKey', + 'foreignKey', + 'joinType', + 'tableLocator', + 'propertyName', + 'sourceTable', + 'targetTable', + ]; + foreach ($defaults as $property) { + if (isset($options[$property])) { + $this->{'_' . $property} = $options[$property]; + } + } + + $this->_className ??= $alias; + + [, $name] = pluginSplit($alias); + $this->_name = $name; + + $this->_options($options); + + if (!empty($options['strategy'])) { + $this->setStrategy($options['strategy']); + } + } + + /** + * Gets the name for this association, usually the alias + * assigned to the target associated table + * + * @return string + */ + public function getName(): string + { + return $this->_name; + } + + /** + * Sets whether cascaded deletes should also fire callbacks. + * + * @param bool $cascadeCallbacks cascade callbacks switch value + * @return $this + */ + public function setCascadeCallbacks(bool $cascadeCallbacks) + { + $this->_cascadeCallbacks = $cascadeCallbacks; + + return $this; + } + + /** + * Gets whether cascaded deletes should also fire callbacks. + * + * @return bool + */ + public function getCascadeCallbacks(): bool + { + return $this->_cascadeCallbacks; + } + + /** + * Sets the class name of the target table object. + * + * @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 setClassName(string $className) + { + 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, + )); + } + + $this->_className = $className; + + return $this; + } + + /** + * Gets the class name of the target table object. + * + * @return string + */ + public function getClassName(): string + { + return $this->_className; + } + + /** + * Sets the table instance for the source side of the association. + * + * @param \Cake\ORM\Table $table the instance to be assigned as source side + * @return $this + */ + public function setSource(Table $table) + { + $this->_sourceTable = $table; + + return $this; + } + + /** + * Gets the table instance for the source side of the association. + * + * @return \Cake\ORM\Table + */ + public function getSource(): Table + { + return $this->_sourceTable; + } + + /** + * Sets the table instance for the target side of the association. + * + * @param \Cake\ORM\Table $table the instance to be assigned as target side + * @return $this + */ + public function setTarget(Table $table) + { + $this->_targetTable = $table; + + return $this; + } + + /** + * Gets the table instance for the target side of the association. + * + * @return \Cake\ORM\Table + */ + public function getTarget(): Table + { + if (!isset($this->_targetTable)) { + if (str_contains($this->_className, '.')) { + [$plugin] = pluginSplit($this->_className, true); + $registryAlias = $plugin . $this->_name; + } else { + $registryAlias = $this->_name; + } + + $tableLocator = $this->getTableLocator(); + + $config = []; + $exists = $tableLocator->exists($registryAlias); + if (!$exists) { + $config = ['className' => $this->_className]; + } + $this->_targetTable = $tableLocator->get($registryAlias, $config); + + if ($exists) { + $className = App::className($this->_className, 'Model/Table', 'Table') ?: Table::class; + + if (!$this->_targetTable instanceof $className) { + $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 DatabaseException(sprintf( + $msg, + isset($this->_sourceTable) ? $this->_sourceTable::class : 'null', + $this->getName(), + $this->type(), + $this->_targetTable::class, + $className, + )); + } + } + } + + return $this->_targetTable; + } + + /** + * Sets a list of conditions to be always included when fetching records from + * the target association. + * + * @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(Closure|array $conditions) + { + $this->_conditions = $conditions; + + return $this; + } + + /** + * Gets a list of conditions to be always included when fetching records from + * the target association. + * + * @see \Cake\Database\Query::where() for examples on the format of the array + * @return \Closure|array + */ + public function getConditions(): Closure|array + { + return $this->_conditions; + } + + /** + * 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 array|string $key the table field or fields to be used to link both tables together + * @return $this + */ + public function setBindingKey(array|string $key) + { + $this->_bindingKey = $key; + + return $this; + } + + /** + * 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 array|string + */ + public function getBindingKey(): array|string + { + if (!isset($this->_bindingKey)) { + $this->_bindingKey = $this->isOwningSide($this->getSource()) ? + $this->getSource()->getPrimaryKey() : + $this->getTarget()->getPrimaryKey(); + } + + return $this->_bindingKey; + } + + /** + * Gets the name of the field representing the foreign key to the target table. + * + * @return array|string|false + */ + public function getForeignKey(): array|string|false + { + return $this->_foreignKey; + } + + /** + * Sets the name of the field representing the foreign key to the target table. + * + * @param array|string $key the key or keys to be used to link both tables together + * @return $this + */ + public function setForeignKey(array|string $key) + { + $this->_foreignKey = $key; + + return $this; + } + + /** + * 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. + * + * @param bool $dependent Set the dependent mode. Use null to read the current state. + * @return $this + */ + public function setDependent(bool $dependent) + { + $this->_dependent = $dependent; + + return $this; + } + + /** + * 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. + * + * @return bool + */ + public function getDependent(): bool + { + return $this->_dependent; + } + + /** + * Whether this association can be expressed directly in a query join + * + * @param array $options custom options key that could alter the return value + * @return bool + */ + public function canBeJoined(array $options = []): bool + { + $strategy = $options['strategy'] ?? $this->getStrategy(); + + return $strategy === $this::STRATEGY_JOIN; + } + + /** + * Sets the type of join to be used when adding the association to a query. + * + * @param string $type the join type to be used (e.g. INNER) + * @return $this + */ + public function setJoinType(string $type) + { + $this->_joinType = $type; + + return $this; + } + + /** + * Gets the type of join to be used when adding the association to a query. + * + * @return string + */ + public function getJoinType(): string + { + return $this->_joinType; + } + + /** + * 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. + * @return $this + */ + public function setProperty(string $name) + { + $this->_propertyName = $name; + $this->checkPropertyName = true; + + return $this; + } + + /** + * Gets the property name that should be filled with data from the target table + * in the source table record. + * + * @return string + */ + public function getProperty(): string + { + if (!isset($this->_propertyName)) { + $this->setProperty($this->_propertyName()); + } + + 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->_propertyName; + } + + /** + * Returns default property name based on association name. + * + * @return string + */ + protected function _propertyName(): string + { + [, $name] = pluginSplit($this->_name); + + return Inflector::underscore($name); + } + + /** + * Sets the strategy name to be used to fetch associated records. + * + * 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(string $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; + + return $this; + } + + /** + * Gets 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. + * + * @return string + */ + public function getStrategy(): string + { + return $this->_strategy; + } + + /** + * Gets the default finder to use for fetching rows from the target table. + * + * @return array|string + */ + public function getFinder(): array|string + { + return $this->_finder; + } + + /** + * Sets the default finder to use for fetching rows from the target table. + * + * @param array|string $finder the finder name to use or array of finder name and option. + * @return $this + */ + public function setFinder(array|string $finder) + { + $this->_finder = $finder; + + return $this; + } + + /** + * 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 + * @return void + */ + protected function _options(array $options): void + { + } + + /** + * Alters a Query object to include the associated target table data in the final + * result + * + * 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 + * will be used + * - 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 + * - 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 + * properties to be followed from the passed query main entity to this + * association + * - joinType: The SQL join type to use in the query. + * - negateMatch: Will append a condition to the passed query for excluding matches. + * with this association. + * + * @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 Unable to build the query or associations. + */ + public function attachTo(SelectQuery $query, array $options = []): void + { + $target = $this->getTarget(); + $table = $target->getTable(); + + $options += [ + 'includeFields' => true, + 'foreignKey' => $this->getForeignKey(), + 'conditions' => [], + 'joinType' => $this->getJoinType(), + 'fields' => [], + 'table' => $table, + 'finder' => $this->getFinder(), + ]; + + // 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; + } + } + + [$finder, $opts] = $this->_extractFinder($options['finder']); + $dummy = $this + ->find($finder, ...$opts) + ->eagerLoaded(true); + + if (!empty($options['queryBuilder'])) { + assert(is_callable($options['queryBuilder'])); + $dummy = $options['queryBuilder']($dummy); + 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); + + $query->join([$this->_name => [ + 'table' => $options['table'], + 'conditions' => $dummy->clause('where'), + 'type' => $options['joinType'], + ]]); + + $this->_appendFields($query, $dummy, $options); + $this->_formatAssociationResults($query, $dummy, $options); + $this->_bindNewAssociations($query, $dummy, $options); + $this->_appendNotMatching($query, $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\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(SelectQuery $query, array $options): void + { + $target = $this->getTarget(); + if (!empty($options['negateMatch'])) { + $primaryKey = $query->aliasFields((array)$target->getPrimaryKey(), $this->_name); + $query->andWhere(function ($exp) use ($primaryKey) { + /** @var callable $callable */ + $callable = [$exp, 'isNull']; + array_map($callable, $primaryKey); + + return $exp; + }); + } + } + + /** + * Correctly nests a result row associated values into the correct array keys inside the + * source results. + * + * @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 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 should be nested in. Will use the default one if not provided. + * @return array + */ + public function transformRow(array $row, string $nestKey, bool $joined, ?string $targetProperty = null): array + { + $sourceAlias = $this->getSource()->getAlias(); + $nestKey = $nestKey ?: $this->_name; + $targetProperty = $targetProperty ?: $this->getProperty(); + if (isset($row[$sourceAlias])) { + $row[$sourceAlias][$targetProperty] = $row[$nestKey]; + unset($row[$nestKey]); + } + + return $row; + } + + /** + * Returns a modified row after appending a property for this association + * 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 the row is a result of a direct join + * with this association + * @return array + */ + public function defaultRowValue(array $row, bool $joined): array + { + $sourceAlias = $this->getSource()->getAlias(); + if (isset($row[$sourceAlias])) { + $row[$sourceAlias][$this->getProperty()] = null; + } + + return $row; + } + + /** + * Proxies the finding operation to the target table's find method + * and modifies the query accordingly based of this association + * configuration + * + * @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\SelectQuery<\Cake\Datasource\EntityInterface|array> + */ + public function find(array|string|null $type = null, mixed ...$args): SelectQuery + { + $type = $type ?: $this->getFinder(); + [$type, $opts] = $this->_extractFinder($type); + + $args += $opts; + + return $this->getTarget() + ->find($type, ...$args) + ->where($this->getConditions()); + } + + /** + * Proxies the operation to the target table's exists method after + * appending the default conditions for this association + * + * @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(ExpressionInterface|Closure|array|string|null $conditions): bool + { + $conditions = $this->find() + ->where($conditions) + ->clause('where'); + + return $this->getTarget()->exists($conditions); + } + + /** + * Proxies the update operation to the target `Table::updateAll()` method + * + * @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( + QueryExpression|Closure|array|string $fields, + QueryExpression|Closure|array|string|null $conditions, + ): int { + $expression = $this->find() + ->where($conditions) + ->clause('where'); + + return $this->getTarget()->updateAll($fields, $expression); + } + + /** + * Proxies the delete operation to the target `Table::deleteAll()` 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. + * @see \Cake\ORM\Table::deleteAll() + */ + public function deleteAll(QueryExpression|Closure|array|string|null $conditions): int + { + $expression = $this->find() + ->where($conditions) + ->clause('where'); + + 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. + * @return bool true if a list of keys will be required + */ + public function requiresKeys(array $options = []): bool + { + $strategy = $options['strategy'] ?? $this->getStrategy(); + + return $strategy === static::STRATEGY_SELECT; + } + + /** + * Triggers `beforeFind` on the target table for the query this association is + * attaching to + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query this association is attaching itself to + * @return void + */ + protected function _dispatchBeforeFind(SelectQuery $query): void + { + $query->triggerBeforeFind(); + } + + /** + * 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\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(SelectQuery $query, SelectQuery $surrogate, array $options): void + { + if ($query->getEagerLoader()->isAutoFieldsEnabled() === false) { + return; + } + + $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 ($fieldsToAdd) { + $fields = array_merge($fields, $fieldsToAdd); + } + } + + $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 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 a table. + * + * @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` + * @return void + */ + protected function _formatAssociationResults(SelectQuery $query, SelectQuery $surrogate, array $options): void + { + $formatters = $surrogate->getResultFormatters(); + + if (!$formatters || empty($options['propertyPath'])) { + return; + } + + $property = $options['propertyPath']; + $propertyPath = explode('.', $property); + $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]; + } + $extracted[] = $result; + } + $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(); + + return $result; + }); + } + + return $results; + }, + SelectQuery::PREPEND, + ); + } + + /** + * Applies all attachable associations to `$query` out of the containments found + * in the `$surrogate` query. + * + * Copies all contained associations from the `$surrogate` query into the + * passed `$query`. Containments are altered so that they respect the association + * chain from which they originated. + * + * @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(SelectQuery $query, SelectQuery $surrogate, array $options): void + { + $loader = $surrogate->getEagerLoader(); + $contain = $loader->getContain(); + $matching = $loader->getMatching(); + + if (!$contain && !$matching) { + return; + } + + $newContain = []; + foreach ($contain as $alias => $value) { + $newContain[$options['aliasPath'] . '.' . $alias] = $value; + } + + $eagerLoader = $query->getEagerLoader(); + if ($newContain) { + $eagerLoader->contain($newContain); + } + + foreach ($matching as $alias => $value) { + $eagerLoader->setMatching( + $options['aliasPath'] . '.' . $alias, + $value['queryBuilder'], + $value, + ); + } + } + + /** + * 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 \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(array $options): array + { + $conditions = []; + $tAlias = $this->_name; + $sAlias = $this->getSource()->getAlias(); + $foreignKey = (array)$options['foreignKey']; + $bindingKey = (array)$this->getBindingKey(); + + $targetOwns = $this->isOwningSide($this->getTarget()); + if (count($foreignKey) !== count($bindingKey)) { + 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 DatabaseException(sprintf( + $msg, + $this->_name, + implode(', ', $foreignKey), + implode(', ', $bindingKey), + )); + } + + foreach ($foreignKey as $k => $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; + } + + return $conditions; + } + + /** + * Helper method to infer the requested finder and its options. + * + * Returns the inferred options from the finder $type. + * + * ### Examples: + * + * The following will call the finder 'translations' with the value of the finder as its options: + * $query->contain(['Comments' => ['finder' => ['translations']]]); + * $query->contain(['Comments' => ['finder' => ['translations' => []]]]); + * $query->contain(['Comments' => ['finder' => ['translations' => ['locales' => ['en_US']]]]]); + * + * @param array|string $finderData The finder name or an array having the name as key + * and options as value. + * @return array + */ + protected function _extractFinder(array|string $finderData): array + { + $finderData = (array)$finderData; + + if (is_numeric(key($finderData))) { + return [current($finderData), []]; + } + + return [key($finderData), current($finderData)]; + } + + /** + * Proxies property retrieval to the target table. This is handy for getting this + * association's associations + * + * @param string $property the property name + * @return self + * @throws \RuntimeException if no association with such a name exists + */ + public function __get(string $property): Association + { + return $this->getTarget()->{$property}; + } + + /** + * Proxies the isset call to the target table. This is handy to check if the + * target table has another association with the passed name + * + * @param string $property the property name + * @return bool true if the association exists + */ + public function __isset(string $property): bool + { + return $this->getTarget()->hasAssociation($property); + } + + /** + * Proxies method calls to the target table. + * + * @param string $method name of the method to be invoked + * @param array $argument List of arguments passed to the function + * @return mixed + * @throws \BadMethodCallException + */ + public function __call(string $method, array $argument): mixed + { + return $this->getTarget()->$method(...$argument); + } + + /** + * Get the relationship type. + * + * @return string Constant of either ONE_TO_ONE, MANY_TO_ONE, ONE_TO_MANY or MANY_TO_MANY. + */ + 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 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. + * + * The required way of passing related source records is controlled by "strategy" + * When the subquery strategy is used it will require a query on the source table. + * When using the select strategy, the list of primary keys will be used. + * + * Returns a closure that should be run for each record returned in a specific + * Query. This callable will be responsible for injecting the fields that are + * related to each specific passed row. + * + * Options array accepts the following keys: + * + * - 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 + * - sort: The direction in which the records should be returned + * - fields: List of fields to select from the target table + * - contain: List of related tables to eager load associated to the target table + * - 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. + * @return \Closure + */ + abstract public function eagerLoader(array $options): Closure; + + /** + * Handles cascading a delete from an associated model. + * + * Each implementing class should handle the cascaded delete as + * required. + * + * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascaded delete. + * @param array $options The options for the original delete. + * @return bool Success + */ + abstract public function cascadeDelete(EntityInterface $entity, array $options = []): bool; + + /** + * 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): 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 \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 = []): EntityInterface|false; +} diff --git a/src/ORM/Association/BelongsTo.php b/src/ORM/Association/BelongsTo.php new file mode 100644 index 00000000000..908b2914d9e --- /dev/null +++ b/src/ORM/Association/BelongsTo.php @@ -0,0 +1,179 @@ + + */ + protected array $_validStrategies = [ + self::STRATEGY_JOIN, + self::STRATEGY_SELECT, + ]; + + /** + * @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. + * + * @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 setForeignKey(array|string|false $key) + { + $this->_foreignKey = $key; + + return $this; + } + + /** + * Handle cascading deletes. + * + * 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. + * @return bool Success. + */ + public function cascadeDelete(EntityInterface $entity, array $options = []): bool + { + return true; + } + + /** + * Returns default property name based on association name. + * + * @return string + */ + protected function _propertyName(): string + { + [, $name] = pluginSplit($this->_name); + + return Inflector::underscore(Inflector::singularize($name)); + } + + /** + * 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): bool + { + return $side === $this->getTarget(); + } + + /** + * Get the relationship type. + * + * @return string + */ + public function type(): string + { + return self::MANY_TO_ONE; + } + + /** + * 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 + * saved on the target table for this association by passing supplied + * `$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 \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 = []): EntityInterface|false + { + $targetEntity = $entity->get($this->getProperty()); + if (!$targetEntity instanceof EntityInterface) { + return $entity; + } + + $table = $this->getTarget(); + $targetEntity = $table->save($targetEntity, $options); + if (!$targetEntity) { + return false; + } + + /** @var array $foreignKeys */ + $foreignKeys = (array)$this->getForeignKey(); + $properties = array_combine( + $foreignKeys, + $targetEntity->extract((array)$this->getBindingKey()), + ); + + // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) + if (method_exists($entity, 'patch')) { + $entity = $entity->patch($properties, ['guard' => false]); + } else { + $entity->set($properties, ['guard' => false]); + } + + return $entity; + } + + /** + * @inheritDoc + */ + public function eagerLoader(array $options): Closure + { + $loader = new SelectLoader([ + 'alias' => $this->getAlias(), + 'sourceAlias' => $this->getSource()->getAlias(), + 'targetAlias' => $this->getTarget()->getAlias(), + 'foreignKey' => $this->getForeignKey(), + 'bindingKey' => $this->getBindingKey(), + 'strategy' => $this->getStrategy(), + 'associationType' => $this->type(), + 'finder' => $this->find(...), + ]); + + return $loader->buildEagerLoader($options); + } +} diff --git a/src/ORM/Association/BelongsToMany.php b/src/ORM/Association/BelongsToMany.php new file mode 100644 index 00000000000..d9fd2c9ab64 --- /dev/null +++ b/src/ORM/Association/BelongsToMany.php @@ -0,0 +1,1550 @@ +|string|null + */ + protected array|string|null $_targetForeignKey = null; + + /** + * The table instance for the junction relation. + * + * @var \Cake\ORM\Table|string|null + */ + protected Table|string|null $_through = null; + + /** + * Valid strategies for this type of association + * + * @var array + */ + protected array $_validStrategies = [ + self::STRATEGY_SELECT, + self::STRATEGY_SUBQUERY, + ]; + + /** + * Whether the records on the joint table should be removed when a record + * on the source table is deleted. + * + * Defaults to true for backwards compatibility. + * + * @var bool + */ + protected bool $_dependent = true; + + /** + * Filtered conditions that reference the target table. + * + * @var array|null + */ + protected ?array $_targetConditions = null; + + /** + * Filtered conditions that reference the junction table. + * + * @var array|null + */ + protected ?array $_junctionConditions = null; + + /** + * Order in which target records should be returned + * + * @var \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string|null + */ + protected ExpressionInterface|Closure|array|string|null $_sort = null; + + /** + * Sets the name of the field representing the foreign key to the target table. + * + * @param array|string $key the key to be used to link both tables together + * @return $this + */ + public function setTargetForeignKey(array|string $key) + { + $this->_targetForeignKey = $key; + + return $this; + } + + /** + * Gets the name of the field representing the foreign key to the target table. + * + * @return array|string + */ + public function getTargetForeignKey(): array|string + { + 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 + * @return bool if the 'matching' key in $option is true then this function + * will return true, false otherwise + */ + public function canBeJoined(array $options = []): bool + { + return !empty($options['matching']); + } + + /** + * @inheritDoc + */ + public function getForeignKey(): array|string|false + { + return $this->_foreignKey ??= $this->_modelKey($this->getSource()->getTable()); + } + + /** + * Sets the sort order in which target records should be returned. + * + * @param \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string $sort A find() compatible order clause + * @return $this + */ + public function setSort(ExpressionInterface|Closure|array|string $sort) + { + $this->_sort = $sort; + + return $this; + } + + /** + * Gets the sort order in which target records should be returned. + * + * @return \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string|null + */ + public function getSort(): ExpressionInterface|Closure|array|string|null + { + return $this->_sort; + } + + /** + * @inheritDoc + */ + public function defaultRowValue(array $row, bool $joined): array + { + $sourceAlias = $this->getSource()->getAlias(); + if (isset($row[$sourceAlias])) { + $row[$sourceAlias][$this->getProperty()] = $joined ? null : []; + } + + return $row; + } + + /** + * Sets the table instance for the junction relation. If no arguments + * are passed, the current configured table instance is returned + * + * @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|string|null $table = null): Table + { + if ($table === null && isset($this->_junctionTable)) { + return $this->_junctionTable; + } + + $tableLocator = $this->getTableLocator(); + if ($table === null && $this->_through !== null) { + $table = $this->_through; + } elseif ($table === null) { + $tableName = $this->_junctionTableName(); + $tableAlias = Inflector::camelize($tableName); + + $config = []; + if (!$tableLocator->exists($tableAlias)) { + $config = ['table' => $tableName, 'allowFallbackClass' => true]; + + // Propagate the connection if we'll get an auto-model + if (!App::className($tableAlias, 'Model/Table', 'Table')) { + $config['connection'] = $this->getSource()->getConnection(); + } + } + $table = $tableLocator->get($tableAlias, $config); + } + + 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); + $this->_generateJunctionAssociations($table, $source, $target); + + 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. + * + * Generates the following associations: + * + * - target hasMany junction e.g. Articles hasMany ArticlesTags + * - target belongsToMany source e.g Articles belongsToMany Tags. + * + * You can override these generated associations by defining associations + * with the correct aliases. + * + * @param \Cake\ORM\Table $junction The junction table. + * @param \Cake\ORM\Table $source The source table. + * @param \Cake\ORM\Table $target The target table. + * @return void + */ + protected function _generateTargetAssociations(Table $junction, Table $source, Table $target): void + { + $junctionAlias = $junction->getAlias(); + $sAlias = $source->getAlias(); + $tAlias = $target->getAlias(); + + $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->hasAssociation($sAlias)) { + $target->belongsToMany($sAlias, [ + 'sourceTable' => $target, + 'targetTable' => $source, + 'foreignKey' => $this->getTargetForeignKey(), + 'targetForeignKey' => $this->getForeignKey(), + 'through' => $junction, + 'conditions' => $this->getConditions(), + 'strategy' => $this->_strategy, + ]); + } + } + + /** + * Generate additional source table associations as necessary. + * + * Generates the following associations: + * + * - source hasMany junction e.g. Tags hasMany ArticlesTags + * + * You can override these generated associations by defining associations + * with the correct aliases. + * + * @param \Cake\ORM\Table $junction The junction table. + * @param \Cake\ORM\Table $source The source table. + * @return void + */ + protected function _generateSourceAssociations(Table $junction, Table $source): void + { + $junctionAlias = $junction->getAlias(); + $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, + ]); + } + } + + /** + * Generate associations on the junction table as necessary + * + * Generates the following associations: + * + * - junction belongsTo source e.g. ArticlesTags belongsTo Tags + * - junction belongsTo target e.g. ArticlesTags belongsTo Articles + * + * You can override these generated associations by defining associations + * with the correct aliases. + * + * @param \Cake\ORM\Table $junction The junction table. + * @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(Table $junction, Table $source, Table $target): void + { + $tAlias = $target->getAlias(); + $sAlias = $source->getAlias(); + + if (!$junction->hasAssociation($tAlias)) { + $junction->belongsTo($tAlias, [ + 'foreignKey' => $this->getTargetForeignKey(), + '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->hasAssociation($sAlias)) { + $junction->belongsTo($sAlias, [ + 'bindingKey' => $this->getBindingKey(), + 'foreignKey' => $this->getForeignKey(), + 'targetTable' => $source, + ]); + } + } + + /** + * Alters a Query object to include the associated target table data in the final + * result + * + * The options array accept 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 + * will be used + * - conditions: array with a list of conditions to filter the join with + * - 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\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(SelectQuery $query, array $options = []): void + { + if (!empty($options['negateMatch'])) { + $this->_appendNotMatching($query, $options); + + return; + } + + $junction = $this->junction(); + $belongsTo = $junction->getAssociation($this->getSource()->getAlias()); + $cond = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]); + $cond += $this->junctionConditions(); + + $includeFields = $options['includeFields'] ?? null; + + // 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, + 'includeFields' => $includeFields, + 'foreignKey' => false, + ]; + $assoc->attachTo($query, $newOptions); + $query->getEagerLoader()->addToJoinsMap($junction->getAlias(), $assoc, true); + + parent::attachTo($query, $options); + + $foreignKey = $this->getTargetForeignKey(); + $thisJoin = $query->clause('join')[$this->getName()]; + /** @var \Cake\Database\Expression\QueryExpression $conditions */ + $conditions = $thisJoin['conditions']; + $conditions->add($assoc->_joinCondition(['foreignKey' => $foreignKey])); + } + + /** + * @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(SelectQuery $query, array $options): void + { + if (empty($options['negateMatch'])) { + return; + } + $options['conditions'] ??= []; + $junction = $this->junction(); + $belongsTo = $junction->getAssociation($this->getSource()->getAlias()); + $conds = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]); + + $subquery = $this->find() + ->select(array_values($conds)) + ->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); + } + + $subquery = $this->_appendJunctionJoin($subquery); + + $query + ->andWhere(function (QueryExpression $exp) use ($subquery, $conds) { + $identifiers = []; + foreach (array_keys($conds) as $field) { + $identifiers[] = new IdentifierExpression($field); + } + $identifiers = $subquery->expr()->add($identifiers)->setConjunction(','); + $nullExp = clone $exp; + + return $exp + ->or([ + $exp->notIn($identifiers, $subquery), + $nullExp->and(array_map($nullExp->isNull(...), array_keys($conds))), + ]); + }); + } + + /** + * Get the relationship type. + * + * @return string + */ + public function type(): string + { + return self::MANY_TO_MANY; + } + + /** + * Return false as join conditions are defined in the junction table + * + * @param array $options list of options passed to attachTo method + * @return array + */ + protected function _joinCondition(array $options): array + { + return []; + } + + /** + * @inheritDoc + */ + public function eagerLoader(array $options): Closure + { + $name = $this->_junctionAssociationName(); + $loader = new SelectWithPivotLoader([ + 'alias' => $this->getAlias(), + 'sourceAlias' => $this->getSource()->getAlias(), + 'targetAlias' => $this->getTarget()->getAlias(), + 'foreignKey' => $this->getForeignKey(), + 'bindingKey' => $this->getBindingKey(), + 'strategy' => $this->getStrategy(), + 'associationType' => $this->type(), + 'sort' => $this->getSort(), + 'junctionAssociationName' => $name, + 'junctionProperty' => $this->_junctionProperty, + 'junctionAssoc' => $this->getTarget()->getAssociation($name), + 'junctionConditions' => $this->junctionConditions(), + 'finder' => function () { + return $this->_appendJunctionJoin($this->find(), []); + }, + ]); + + return $loader->buildEagerLoader($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. + * @return bool Success. + */ + public function cascadeDelete(EntityInterface $entity, array $options = []): bool + { + if (!$this->getDependent()) { + return true; + } + + /** @var array $foreignKeys */ + $foreignKeys = (array)$this->getForeignKey(); + $bindingKeys = (array)$this->getBindingKey(); + $conditions = []; + + if ($bindingKeys) { + $conditions = array_combine($foreignKeys, $entity->extract($bindingKeys)); + } + + $table = $this->junction(); + $hasMany = $this->getSource()->getAssociation($table->getAlias()); + if ($this->_cascadeCallbacks) { + /** @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; + } + + $assocConditions = $hasMany->getConditions(); + if (is_array($assocConditions)) { + $conditions = array_merge($conditions, $assocConditions); + } else { + $conditions[] = $assocConditions; + } + + $table->deleteAll($conditions); + + return true; + } + + /** + * Returns boolean true, as both of the tables 'own' rows in the other side + * of the association via the joint table. + * + * @param \Cake\ORM\Table $side The potential Table with ownership + * @return bool + */ + public function isOwningSide(Table $side): bool + { + return true; + } + + /** + * Sets the strategy that should be used for saving. + * + * @param string $strategy the strategy name to be used + * @throws \InvalidArgumentException if an invalid strategy name is passed + * @return $this + */ + public function setSaveStrategy(string $strategy) + { + if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE], true)) { + $msg = sprintf('Invalid save strategy `%s`', $strategy); + throw new InvalidArgumentException($msg); + } + + $this->_saveStrategy = $strategy; + + return $this; + } + + /** + * Gets the strategy that should be used for saving. + * + * @return string the strategy to be used for saving + */ + public function getSaveStrategy(): string + { + return $this->_saveStrategy; + } + + /** + * 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 + * saved on the target table for this association by passing supplied + * `$options` + * + * When using the 'append' strategy, this function will only create new links + * between each side of this association. It will not destroy existing ones even + * though they may not be present in the array of entities to be saved. + * + * When using the 'replace' strategy, existing links will be removed and new links + * will be created in the joint table. If there exists links in the database to some + * of the entities intended to be saved by this method, they will be updated, + * 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 + * @throws \InvalidArgumentException if the property representing the association + * in the parent entity cannot be traversed + * @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 = []): EntityInterface|false + { + $targetEntity = $entity->get($this->getProperty()); + $strategy = $this->getSaveStrategy(); + + $isEmpty = in_array($targetEntity, [null, [], '', false], true); + if ($isEmpty && $entity->isNew()) { + return $entity; + } + if ($isEmpty) { + $targetEntity = []; + } + + if ($strategy === self::SAVE_APPEND) { + return $this->_saveTarget($entity, $targetEntity, $options); + } + + if ($this->replaceLinks($entity, $targetEntity, $options)) { + return $entity; + } + + return false; + } + + /** + * Persists each of the entities into the target table and creates links between + * the parent entity and each one of the saved target entities. + * + * @param \Cake\Datasource\EntityInterface $parentEntity the source entity containing the target + * entities to be saved. + * @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()` + * @throws \InvalidArgumentException if the property representing the association + * in the parent entity cannot be traversed + * @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, + array $entities, + array $options, + ): EntityInterface|false { + $joinAssociations = false; + 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(); + $original = $entities; + $persisted = []; + + foreach ($entities as $k => $entity) { + if (!($entity instanceof EntityInterface)) { + break; + } + + if (!empty($options['atomic'])) { + $entity = clone $entity; + } + + $saved = $table->save($entity, $options); + if ($saved) { + $entities[$k] = $entity; + $persisted[] = $entity; + continue; + } + + // Saving the new linked entity failed, copy errors back into the + // original entity if applicable and abort. + if (!empty($options['atomic'])) { + /** @var \Cake\Datasource\EntityInterface $originalEntity */ + $originalEntity = $original[$k]; + $originalEntity->setErrors($entity->getErrors()); + } + + return false; + } + + $options['associated'] = $joinAssociations; + $success = $this->_saveLinks($parentEntity, $persisted, $options); + if (!$success && !empty($options['atomic'])) { + $parentEntity->set($this->getProperty(), $original); + + return false; + } + + $parentEntity->set($this->getProperty(), $entities); + + return $parentEntity; + } + + /** + * Creates links between the source entity and each of the passed target entities + * + * @param \Cake\Datasource\EntityInterface $sourceEntity the entity from source table in this + * association + * @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()` + * @return bool success + */ + protected function _saveLinks(EntityInterface $sourceEntity, array $targetEntities, array $options): bool + { + $target = $this->getTarget(); + $junction = $this->junction(); + $entityClass = $junction->getEntityClass(); + $belongsTo = $junction->getAssociation($target->getAlias()); + /** @var array $foreignKey */ + $foreignKey = (array)$this->getForeignKey(); + /** @var array $assocForeignKey */ + $assocForeignKey = (array)$belongsTo->getForeignKey(); + $targetBindingKey = (array)$belongsTo->getBindingKey(); + $bindingKey = (array)$this->getBindingKey(); + $jointProperty = $this->_junctionProperty; + $junctionRegistryAlias = $junction->getRegistryAlias(); + + foreach ($targetEntities as $e) { + $joint = $e->get($jointProperty); + if (!($joint instanceof EntityInterface)) { + $joint = new $entityClass([], ['markNew' => true, 'source' => $junctionRegistryAlias]); + } + $sourceKeys = array_combine($foreignKey, $sourceEntity->extract($bindingKey)); + $targetKeys = array_combine($assocForeignKey, $e->extract($targetBindingKey)); + + $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 we have a new link + // or if we are updating an existing link. + if ($changedKeys) { + $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); + + if (!$saved && !empty($options['atomic'])) { + return false; + } + + $e->set($jointProperty, $joint); + $e->setDirty($jointProperty, false); + } + + return true; + } + + /** + * Associates the source entity to each of the target entities provided by + * creating links in the junction table. Both the source entity and each of + * the target entities are assumed to be already persisted, if they are marked + * as new or their status is unknown then an exception will be thrown. + * + * When using this method, all entities in `$targetEntities` will be appended to + * the source entity's property corresponding to this association object. + * + * This method does not check link uniqueness. + * + * ### Example: + * + * ``` + * $newTags = $tags->find('relevant')->toArray(); + * $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<\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 + * @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 = []): bool + { + $this->_checkPersistenceStatus($sourceEntity, $targetEntities); + $property = $this->getProperty(); + $links = $sourceEntity->get($property) ?: []; + $links = array_merge($links, $targetEntities); + $sourceEntity->set($property, $links); + + return $this->junction()->getConnection()->transactional( + function () use ($sourceEntity, $targetEntities, $options) { + return $this->_saveLinks($sourceEntity, $targetEntities, $options); + }, + ); + } + + /** + * Removes all links between the passed source entity and each of the provided + * target entities. This method assumes that all passed objects are already persisted + * in the database and that each of them contain a primary key value. + * + * ### Options + * + * Additionally to the default options accepted by `Table::delete()`, the following + * keys are supported: + * + * - 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 + * source entity. + * + * ### Example: + * + * ``` + * $article->tags = [$tag1, $tag2, $tag3, $tag4]; + * $tags = [$tag1, $tag2, $tag3]; + * $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<\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, array|bool $options = []): bool + { + if (is_bool($options)) { + $options = [ + 'cleanProperty' => $options, + ]; + } else { + $options += ['cleanProperty' => true]; + } + + $this->_checkPersistenceStatus($sourceEntity, $targetEntities); + $property = $this->getProperty(); + + $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->offsetSet($e); + } + + foreach ($existing as $k => $e) { + if ($storage->offsetExists($e)) { + unset($existing[$k]); + } + } + + $sourceEntity->set($property, array_values($existing)); + $sourceEntity->setDirty($property, false); + + return true; + } + + /** + * @inheritDoc + */ + public function setConditions(Closure|array $conditions) + { + parent::setConditions($conditions); + $this->_targetConditions = null; + $this->_junctionConditions = null; + + return $this; + } + + /** + * Sets the current join table, either the 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(Table|string $through) + { + $this->_through = $through; + + return $this; + } + + /** + * Gets the current join table, either the name of the Table instance or the instance itself. + * Returns null if not defined. + * + * @return \Cake\ORM\Table|string|null + */ + public function getThrough(): Table|string|null + { + return $this->_through; + } + + /** + * Returns filtered conditions that reference the target table. + * + * Any string expressions, or expression objects will + * also be returned in this list. + * + * @return \Closure|array|null Generally an array. If the conditions + * are not an array, the association conditions will be + * returned unmodified. + */ + protected function targetConditions(): mixed + { + if ($this->_targetConditions !== null) { + return $this->_targetConditions; + } + $conditions = $this->getConditions(); + if (!is_array($conditions)) { + return $conditions; + } + $matching = []; + $alias = $this->getAlias() . '.'; + foreach ($conditions as $field => $value) { + if (is_string($field) && str_starts_with($field, $alias)) { + $matching[$field] = $value; + } elseif (is_int($field) || $value instanceof ExpressionInterface) { + $matching[$field] = $value; + } + } + + return $this->_targetConditions = $matching; + } + + /** + * Returns filtered conditions that specifically reference + * the junction table. + * + * @return array + */ + protected function junctionConditions(): array + { + if ($this->_junctionConditions !== null) { + return $this->_junctionConditions; + } + $matching = []; + $conditions = $this->getConditions(); + if (!is_array($conditions)) { + return $matching; + } + $alias = $this->_junctionAssociationName() . '.'; + foreach ($conditions as $field => $value) { + $isString = is_string($field); + 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'], true)) { + $matching[$field] = $value; + } + } + + return $this->_junctionConditions = $matching; + } + + /** + * Proxies the finding operation to the target table's find method + * and modifies the query accordingly based of this association + * configuration. + * + * If your association includes conditions or a finder, the junction table will be + * included in the query's contained associations. + * + * @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 mixed ...$args Arguments that match up to finder-specific parameters + * @see \Cake\ORM\Table::find() + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> + */ + public function find(array|string|null $type = null, mixed ...$args): SelectQuery + { + $type = $type ?: $this->getFinder(); + [$type, $opts] = $this->_extractFinder($type); + + $args += $opts; + + $query = $this->getTarget() + ->find($type, ...$args) + ->where($this->targetConditions()) + ->addDefaultTypes($this->getTarget()); + + if ($this->junctionConditions()) { + return $this->_appendJunctionJoin($query); + } + + return $query; + } + + /** + * Append a join to the junction table. + * + * @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(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->clause('join'); + assert(is_array($joins)); + $matching = [ + $name => [ + 'table' => $junctionTable->getTable(), + 'conditions' => $conditions, + 'type' => SelectQuery::JOIN_TYPE_INNER, + ], + ]; + + $query + ->addDefaultTypes($junctionTable) + ->join($matching + $joins, [], true); + + return $query; + } + + /** + * Replaces existing association links between the source entity and the target + * with the ones passed. This method does a smart cleanup, links that are already + * persisted and present in `$targetEntities` will not be deleted, new links will + * be created for the passed target entities that are not already in the database + * and the rest will be removed. + * + * For example, if an article is linked to tags 'cake' and 'framework' and you pass + * to this method an array containing the entities for tags 'cake', 'php' and 'awesome', + * only the link for cake will be kept in database, the link for 'framework' will be + * deleted and the links for 'php' and 'awesome' will be created. + * + * Existing links are not deleted and created again, they are either left untouched + * or updated so that potential extra information stored in the joint row is not + * lost. Updating the link row can be done by making sure the corresponding passed + * target entity contains the joint property with its primary key and any extra + * information to be stored. + * + * On success, the passed `$sourceEntity` will contain `$targetEntities` as value + * in the corresponding property for this association. + * + * This method assumes that links between both the source entity and each of the + * target entities are unique. That is, for any given row in the source table there + * can only be one link in the junction table pointing to any other given row in + * the target table. + * + * Additional options for new links to be saved can be passed in the third argument, + * check `Table::save()` for information on the accepted options. + * + * ### Example: + * + * ``` + * $article->tags = [$tag1, $tag2, $tag3, $tag4]; + * $articles->save($article); + * $tags = [$tag1, $tag3]; + * $articles->getAssociation('tags')->replaceLinks($article, $tags); + * ``` + * + * `$article->get('tags')` will contain only `[$tag1, $tag3]` at the end + * + * @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 + * 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 = []): bool + { + $bindingKey = (array)$this->getBindingKey(); + $primaryValue = $sourceEntity->extract($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) { + $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; + } + + $property = $this->getProperty(); + + if ($inserts !== []) { + $inserted = array_combine( + array_keys($inserts), + (array)$sourceEntity->get($property), + ) ?: []; + $targetEntities = $inserted + $targetEntities; + } + + ksort($targetEntities); + $sourceEntity->set($property, array_values($targetEntities)); + $sourceEntity->setDirty($property, false); + + return true; + }, + ); + } + + /** + * Helper method used to delete the difference between the links passed in + * `$existing` and `$jointEntities`. This method will return the values from + * `$targetEntities` that were not deleted from calculating the difference. + * + * @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|false Array of entities not deleted or false in case of deletion failure for atomic saves. + */ + protected function _diffLinks( + SelectQuery $existing, + array $jointEntities, + array $targetEntities, + array $options = [], + ): array|false { + $junction = $this->junction(); + $target = $this->getTarget(); + $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 = []; + $unmatchedEntityKeys = []; + $present = []; + + foreach ($jointEntities as $i => $entity) { + $unmatchedEntityKeys[$i] = $entity->extract($keys); + $present[$i] = array_values($entity->extract($assocForeignKey)); + } + + foreach ($existing as $existingLink) { + /** @var \Cake\ORM\Entity $existingLink */ + $existingKeys = $existingLink->extract($keys); + $found = false; + 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[] = $existingLink; + } + } + + $primary = (array)$target->getPrimaryKey(); + $jointProperty = $this->_junctionProperty; + foreach ($targetEntities as $k => $entity) { + if (!($entity instanceof EntityInterface)) { + continue; + } + $key = array_values($entity->extract($primary)); + foreach ($present as $i => $data) { + if ($key === $data && !$entity->get($jointProperty)) { + unset($targetEntities[$k], $present[$i]); + break; + } + } + } + + foreach ($deletes as $entity) { + if (!$junction->delete($entity, $options) && !empty($options['atomic'])) { + return false; + } + } + + return $targetEntities; + } + + /** + * Throws an exception should any of the passed entities is not persisted. + * + * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side + * of this association + * @param array<\Cake\Datasource\EntityInterface> $targetEntities list of entities belonging to the `target` side + * of this association + * @return bool + * @throws \InvalidArgumentException + */ + 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.'; + throw new InvalidArgumentException($error); + } + + foreach ($targetEntities as $entity) { + if ($entity->isNew()) { + $error = 'Cannot link entities that have not been persisted yet.'; + throw new InvalidArgumentException($error); + } + } + + return true; + } + + /** + * Returns the list of joint entities that exist between the source entity + * and each of the passed target entities + * + * @param \Cake\Datasource\EntityInterface $sourceEntity The row belonging to the source side + * of this association. + * @param array $targetEntities The rows belonging to the target side of this + * association. + * @throws \InvalidArgumentException if any of the entities is lacking a primary + * key value + * @return array<\Cake\Datasource\EntityInterface> + */ + protected function _collectJointEntities(EntityInterface $sourceEntity, array $targetEntities): array + { + $target = $this->getTarget(); + $source = $this->getSource(); + $junction = $this->junction(); + $jointProperty = $this->_junctionProperty; + $primary = (array)$target->getPrimaryKey(); + + $result = []; + $missing = []; + + foreach ($targetEntities as $entity) { + if (!($entity instanceof EntityInterface)) { + continue; + } + $joint = $entity->get($jointProperty); + + if (!($joint instanceof EntityInterface)) { + $missing[] = $entity->extract($primary); + continue; + } + + $result[] = $joint; + } + + if (!$missing) { + return $result; + } + + $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) { + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface> $unionQuery */ + $unionQuery = $hasMany->find() + ->where(array_combine($foreignKey, $sourceKey)) + ->where(array_combine($assocForeignKey, $key)); + + $unions[] = $unionQuery; + } + + $query = array_shift($unions); + foreach ($unions as $q) { + $query->union($q); + } + + return array_merge($result, $query->toArray()); + } + + /** + * Returns the name of the association from the target table to the junction table, + * this name is used to generate alias in the query and to later on retrieve the + * results. + * + * @return string + */ + protected function _junctionAssociationName(): string + { + if (!isset($this->_junctionAssociationName)) { + $this->_junctionAssociationName = $this->getTarget() + ->getAssociation($this->junction()->getAlias()) + ->getName(); + } + + return $this->_junctionAssociationName; + } + + /** + * Sets the name of the junction table. + * If no arguments are passed the current configured name is returned. A default + * name based of the associated tables will be generated if none found. + * + * @param string|null $name The name of the junction table. + * @return string + */ + protected function _junctionTableName(?string $name = null): string + { + if ($name === null) { + if (empty($this->_junctionTableName)) { + $tablesNames = array_map('Cake\Utility\Inflector::underscore', [ + $this->getSource()->getTable(), + $this->getTarget()->getTable(), + ]); + sort($tablesNames); + $this->_junctionTableName = implode('_', $tablesNames); + } + + return $this->_junctionTableName; + } + + return $this->_junctionTableName = $name; + } + + /** + * Parse extra options passed in the constructor. + * + * @param array $options original list of options passed in constructor + * @return void + */ + protected function _options(array $options): void + { + if (!empty($options['targetForeignKey'])) { + $this->setTargetForeignKey($options['targetForeignKey']); + } + if (!empty($options['joinTable'])) { + $this->_junctionTableName($options['joinTable']); + } + if (!empty($options['through'])) { + $this->setThrough($options['through']); + } + if (!empty($options['saveStrategy'])) { + $this->setSaveStrategy($options['saveStrategy']); + } + 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 new file mode 100644 index 00000000000..e260615089d --- /dev/null +++ b/src/ORM/Association/DependentDeleteHelper.php @@ -0,0 +1,71 @@ + $options The options for the original delete. + * @return bool Success. + */ + public function cascadeDelete(Association $association, EntityInterface $entity, array $options = []): bool + { + if (!$association->getDependent()) { + return true; + } + $table = $association->getTarget(); + /** @var callable $callable */ + $callable = $association->aliasField(...); + $foreignKey = array_map($callable, (array)$association->getForeignKey()); + $bindingKey = (array)$association->getBindingKey(); + $bindingValue = $entity->extract($bindingKey); + if (in_array(null, $bindingValue, true)) { + return true; + } + $conditions = array_combine($foreignKey, $bindingValue); + + if ($association->getCascadeCallbacks()) { + /** @var \Cake\Datasource\EntityInterface $related */ + foreach ($association->find()->where($conditions)->toArray() as $related) { + $success = $table->delete($related, $options); + if (!$success) { + return false; + } + } + + return true; + } + + $association->deleteAll($conditions); + + return true; + } +} diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php new file mode 100644 index 00000000000..3b352b1dbf3 --- /dev/null +++ b/src/ORM/Association/HasMany.php @@ -0,0 +1,713 @@ +|string|null + */ + 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 string $_joinType = SelectQuery::JOIN_TYPE_INNER; + + /** + * The strategy name to be used to fetch associated records. + * + * @var string + */ + protected string $_strategy = self::STRATEGY_SELECT; + + /** + * Valid strategies for this type of association + * + * @var array + */ + protected array $_validStrategies = [ + self::STRATEGY_SELECT, + self::STRATEGY_SUBQUERY, + ]; + + /** + * Saving strategy that will only append to the links set + * + * @var string + */ + public const SAVE_APPEND = 'append'; + + /** + * Saving strategy that will replace the links with the provided set + * + * @var string + */ + public const SAVE_REPLACE = 'replace'; + + /** + * Saving strategy to be used by this association + * + * @var string + */ + protected string $_saveStrategy = self::SAVE_APPEND; + + /** + * 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): bool + { + return $side === $this->getSource(); + } + + /** + * Sets the strategy that should be used for saving. + * + * @param string $strategy the strategy name to be used + * @throws \InvalidArgumentException if an invalid strategy name is passed + * @return $this + */ + public function setSaveStrategy(string $strategy) + { + if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE], true)) { + $msg = sprintf('Invalid save strategy `%s`', $strategy); + throw new InvalidArgumentException($msg); + } + + $this->_saveStrategy = $strategy; + + return $this; + } + + /** + * Gets the strategy that should be used for saving. + * + * @return string the strategy to be used for saving + */ + public function getSaveStrategy(): string + { + return $this->_saveStrategy; + } + + /** + * 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 + * saved on the target table for this association by passing supplied + * `$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 \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 = []): EntityInterface|false + { + $targetEntities = $entity->get($this->getProperty()); + + $isEmpty = in_array($targetEntities, [null, [], '', false], true); + if ($isEmpty) { + if ( + $entity->isNew() || + $this->getSaveStrategy() !== self::SAVE_REPLACE + ) { + return $entity; + } + + $targetEntities = []; + } + + 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( + $foreignKeys, + $entity->extract((array)$this->getBindingKey()), + ); + + $options['_sourceTable'] = $this->getSource(); + + 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; + } + + return $entity; + } + + /** + * Persists each of the entities into the target table and creates links between + * the parent entity and each one of the saved target entities. + * + * @param array $foreignKeyReference The foreign key reference defining the link between the + * target entity, and the parent entity. + * @param \Cake\Datasource\EntityInterface $parentEntity The source entity containing the target + * entities to be saved. + * @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, + array $entities, + array $options, + ): bool { + $foreignKey = array_keys($foreignKeyReference); + $table = $this->getTarget(); + $original = $entities; + + foreach ($entities as $k => $entity) { + if (!($entity instanceof EntityInterface)) { + break; + } + + if (!empty($options['atomic'])) { + $entity = clone $entity; + } + + if ($foreignKeyReference !== $entity->extract($foreignKey)) { + // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) + if (method_exists($entity, 'patch')) { + $entity->patch($foreignKeyReference, ['guard' => false]); + } else { + $entity->set($foreignKeyReference, ['guard' => false]); + } + } + + if ($table->save($entity, $options)) { + $entities[$k] = $entity; + continue; + } + + if (!empty($options['atomic'])) { + /** @var \Cake\ORM\Entity $originEntity */ + $originEntity = $original[$k]; + $originEntity->setErrors($entity->getErrors()); + if ($entity instanceof InvalidPropertyInterface) { + $originEntity->setInvalid($entity->getInvalid()); + } + + return false; + } + } + + $parentEntity->set($this->getProperty(), $entities); + + return true; + } + + /** + * Associates the source entity to each of the target entities provided. + * When using this method, all entities in `$targetEntities` will be appended to + * the source entity's property corresponding to this association object. + * + * This method does not check link uniqueness. + * Changes are persisted in the database and also in the source entity. + * + * ### Example: + * + * ``` + * $user = $users->get(1); + * $allArticles = $articles->find('all')->toArray(); + * $users->Articles->link($user, $allArticles); + * ``` + * + * `$user->get('articles')` will contain all articles in `$allArticles` after linking + * + * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side + * of this association + * @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 + * @return bool true on success, false otherwise + */ + public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = []): bool + { + $saveStrategy = $this->getSaveStrategy(); + $this->setSaveStrategy(self::SAVE_APPEND); + $property = $this->getProperty(); + + /** @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(); + + $currentEntities = array_merge($currentEntities, $targetEntities); + } + + $sourceEntity->set($property, $currentEntities); + + $savedEntity = $this->getConnection()->transactional(fn() => $this->saveAssociated($sourceEntity, $options)); + $ok = ($savedEntity instanceof EntityInterface); + + $this->setSaveStrategy($saveStrategy); + + if ($ok) { + $sourceEntity->set($property, $savedEntity->get($property)); + $sourceEntity->setDirty($property, false); + } + + return $ok; + } + + /** + * Removes all links between the passed source entity and each of the provided + * target entities. This method assumes that all passed objects are already persisted + * in the database and that each of them contain a primary key value. + * + * ### Options + * + * Additionally to the default options accepted by `Table::delete()`, the following + * keys are supported: + * + * - 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 + * source entity. + * + * Changes are persisted in the database and also in the source entity. + * + * ### Example: + * + * ``` + * $user = $users->get(1); + * $user->articles = [$article1, $article2, $article3, $article4]; + * $users->save($user, ['Associated' => ['Articles']]); + * $allArticles = [$article1, $article2, $article3]; + * $users->Articles->unlink($user, $allArticles); + * ``` + * + * `$article->get('articles')` will contain only `[$article4]` 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. + * 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 bool + */ + public function unlink(EntityInterface $sourceEntity, array $targetEntities, array|bool $options = []): bool + { + if (is_bool($options)) { + $options = [ + 'cleanProperty' => $options, + ]; + } else { + $options += ['cleanProperty' => true]; + } + if ($targetEntities === []) { + return true; + } + + $foreignKey = (array)$this->getForeignKey(); + $target = $this->getTarget(); + $targetPrimaryKey = array_merge((array)$target->getPrimaryKey(), $foreignKey); + $property = $this->getProperty(); + + $conditions = [ + 'OR' => (new Collection($targetEntities)) + ->map(function (EntityInterface $entity) use ($targetPrimaryKey) { + /** @var array $targetPrimaryKey */ + return $entity->extract($targetPrimaryKey); + }) + ->toList(), + ]; + + $return = $this->_unlink($foreignKey, $target, $conditions, $options); + if (!$return) { + return false; + } + + $result = $sourceEntity->get($property); + if ($options['cleanProperty'] && $result !== null) { + $sourceEntity->set( + $property, + (new Collection($sourceEntity->get($property))) + ->reject( + function ($assoc) use ($targetEntities) { + return in_array($assoc, $targetEntities, true); + }, + ) + ->toList(), + ); + } + + $sourceEntity->setDirty($property, false); + + return true; + } + + /** + * Replaces existing association links between the source entity and the target + * with the ones passed. This method does a smart cleanup, links that are already + * persisted and present in `$targetEntities` will not be deleted, new links will + * be created for the passed target entities that are not already in the database + * and the rest will be removed. + * + * For example, if an author has many articles, such as 'article1','article 2' and 'article 3' and you pass + * to this method an array containing the entities for articles 'article 1' and 'article 4', + * only the link for 'article 1' will be kept in database, the links for 'article 2' and 'article 3' will be + * deleted and the link for 'article 4' will be created. + * + * Existing links are not deleted and created again, they are either left untouched + * or updated. + * + * This method does not check link uniqueness. + * + * On success, the passed `$sourceEntity` will contain `$targetEntities` as value + * in the corresponding property for this association. + * + * Additional options for new links to be saved can be passed in the third argument, + * check `Table::save()` for information on the accepted options. + * + * ### Example: + * + * ``` + * $author->articles = [$article1, $article2, $article3, $article4]; + * $authors->save($author); + * $articles = [$article1, $article3]; + * $authors->getAssociation('articles')->replace($author, $articles); + * ``` + * + * `$author->get('articles')` will contain only `[$article1, $article3]` at the end + * + * @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 + * 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 = []): bool + { + $property = $this->getProperty(); + $sourceEntity->set($property, $targetEntities); + $saveStrategy = $this->getSaveStrategy(); + $this->setSaveStrategy(self::SAVE_REPLACE); + $result = $this->saveAssociated($sourceEntity, $options); + $ok = ($result instanceof EntityInterface); + + if ($ok) { + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $sourceEntity = $result; + } + $this->setSaveStrategy($saveStrategy); + + return $ok; + } + + /** + * 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 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, + iterable $remainingEntities = [], + array $options = [], + ): bool { + $primaryKey = (array)$target->getPrimaryKey(); + $exclusions = new Collection($remainingEntities); + $exclusions = $exclusions->map( + function (EntityInterface $ent) use ($primaryKey) { + return $ent->extract($primaryKey); + }, + ) + ->filter( + function ($v) { + return !in_array(null, $v, true); + }, + ) + ->toList(); + + $conditions = $foreignKeyReference; + + if ($exclusions !== []) { + $conditions = [ + 'NOT' => [ + 'OR' => $exclusions, + ], + $foreignKeyReference, + ]; + } + + return $this->_unlink(array_keys($foreignKeyReference), $target, $conditions, $options); + } + + /** + * 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. + * + * @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()` + * @return bool success + */ + 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): void { + if ($entry instanceof FieldInterface) { + $field = $entry->getField(); + if (is_string($field)) { + $entry->setField($target->aliasField($field)); + } + } + }); + /** @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 true; + } + + $this->deleteAll($conditions); + + return true; + } + + $updateFields = array_fill_keys($foreignKey, null); + $this->updateAll($updateFields, $conditions); + + return true; + } + + /** + * Checks the nullable flag of the foreign key + * + * @param \Cake\ORM\Table $table the table containing the foreign key + * @param array $properties the list of fields that compose the foreign key + * @return bool + */ + protected function _foreignKeyAcceptsNull(Table $table, array $properties): bool + { + return !in_array( + false, + array_map( + $table->getSchema()->isNullable(...), + $properties, + ), + true, + ); + } + + /** + * Get the relationship type. + * + * @return string + */ + public function type(): string + { + return self::ONE_TO_MANY; + } + + /** + * Whether this association can be expressed directly in a query join + * + * @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 = []): bool + { + return !empty($options['matching']); + } + + /** + * @inheritDoc + */ + public function getForeignKey(): array|string|false + { + return $this->_foreignKey ??= $this->_modelKey($this->getSource()->getTable()); + } + + /** + * Sets the sort order in which target records should be returned. + * + * @param \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string $sort A find() compatible order clause + * @return $this + */ + public function setSort(ExpressionInterface|Closure|array|string $sort) + { + $this->_sort = $sort; + + return $this; + } + + /** + * Gets the sort order in which target records should be returned. + * + * @return \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string|null + */ + public function getSort(): ExpressionInterface|Closure|array|string|null + { + return $this->_sort; + } + + /** + * @inheritDoc + */ + public function defaultRowValue(array $row, bool $joined): array + { + $sourceAlias = $this->getSource()->getAlias(); + if (isset($row[$sourceAlias])) { + $row[$sourceAlias][$this->getProperty()] = $joined ? null : []; + } + + return $row; + } + + /** + * Parse extra options passed in the constructor. + * + * @param array $options original list of options passed in constructor + * @return void + */ + protected function _options(array $options): void + { + if (!empty($options['saveStrategy'])) { + $this->setSaveStrategy($options['saveStrategy']); + } + if (isset($options['sort'])) { + $this->setSort($options['sort']); + } + } + + /** + * @inheritDoc + */ + public function eagerLoader(array $options): Closure + { + $loader = new SelectLoader([ + 'alias' => $this->getAlias(), + 'sourceAlias' => $this->getSource()->getAlias(), + 'targetAlias' => $this->getTarget()->getAlias(), + 'foreignKey' => $this->getForeignKey(), + 'bindingKey' => $this->getBindingKey(), + 'strategy' => $this->getStrategy(), + 'associationType' => $this->type(), + 'sort' => $this->getSort(), + 'finder' => $this->find(...), + ]); + + return $loader->buildEagerLoader($options); + } + + /** + * @inheritDoc + */ + public function cascadeDelete(EntityInterface $entity, array $options = []): bool + { + $helper = new DependentDeleteHelper(); + + return $helper->cascadeDelete($this, $entity, $options); + } +} diff --git a/src/ORM/Association/HasOne.php b/src/ORM/Association/HasOne.php new file mode 100644 index 00000000000..71d7d4da7b0 --- /dev/null +++ b/src/ORM/Association/HasOne.php @@ -0,0 +1,174 @@ + + */ + protected array $_validStrategies = [ + self::STRATEGY_JOIN, + self::STRATEGY_SELECT, + ]; + + /** + * @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. + * + * @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 setForeignKey(array|string|false $key) + { + $this->_foreignKey = $key; + + return $this; + } + + /** + * Returns default property name based on association name. + * + * @return string + */ + protected function _propertyName(): string + { + [, $name] = pluginSplit($this->_name); + + return Inflector::underscore(Inflector::singularize($name)); + } + + /** + * 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): bool + { + return $side === $this->getSource(); + } + + /** + * Get the relationship type. + * + * @return string + */ + public function type(): string + { + return self::ONE_TO_ONE; + } + + /** + * 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 + * saved on the target table for this association by passing supplied + * `$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 \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 = []): EntityInterface|false + { + $targetEntity = $entity->get($this->getProperty()); + if (!$targetEntity instanceof EntityInterface) { + return $entity; + } + + /** @var array $foreignKeys */ + $foreignKeys = (array)$this->getForeignKey(); + $properties = array_combine( + $foreignKeys, + $entity->extract((array)$this->getBindingKey()), + ); + // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) + if (method_exists($targetEntity, 'patch')) { + $targetEntity = $targetEntity->patch($properties, ['guard' => false]); + } else { + $targetEntity->set($properties, ['guard' => false]); + } + + if (!$this->getTarget()->save($targetEntity, $options)) { + $targetEntity->unset(array_keys($properties)); + + return false; + } + + return $entity; + } + + /** + * @inheritDoc + */ + public function eagerLoader(array $options): Closure + { + $loader = new SelectLoader([ + 'alias' => $this->getAlias(), + 'sourceAlias' => $this->getSource()->getAlias(), + 'targetAlias' => $this->getTarget()->getAlias(), + 'foreignKey' => $this->getForeignKey(), + 'bindingKey' => $this->getBindingKey(), + 'strategy' => $this->getStrategy(), + 'associationType' => $this->type(), + 'finder' => $this->find(...), + ]); + + return $loader->buildEagerLoader($options); + } + + /** + * @inheritDoc + */ + public function cascadeDelete(EntityInterface $entity, array $options = []): bool + { + $helper = new DependentDeleteHelper(); + + return $helper->cascadeDelete($this, $entity, $options); + } +} diff --git a/src/ORM/Association/Loader/SelectLoader.php b/src/ORM/Association/Loader/SelectLoader.php new file mode 100644 index 00000000000..866e6ac7fbd --- /dev/null +++ b/src/ORM/Association/Loader/SelectLoader.php @@ -0,0 +1,581 @@ + $options Properties to be copied to this class + */ + public function __construct(array $options) + { + $this->alias = $options['alias']; + $this->sourceAlias = $options['sourceAlias']; + $this->targetAlias = $options['targetAlias']; + $this->foreignKey = $options['foreignKey']; + $this->strategy = $options['strategy']; + $this->bindingKey = $options['bindingKey']; + $this->finder = $options['finder']; + $this->associationType = $options['associationType']; + $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 \Closure + */ + public function buildEagerLoader(array $options): Closure + { + $options += $this->_defaultOptions(); + $fetchQuery = $this->_buildQuery($options); + $resultMap = $this->_buildResultMap($fetchQuery, $options); + + return $this->_resultInjector($fetchQuery, $resultMap, $options); + } + + /** + * Returns the default options to use for the eagerLoader + * + * @return array + */ + protected function _defaultOptions(): array + { + return [ + 'foreignKey' => $this->foreignKey, + 'conditions' => [], + 'strategy' => $this->strategy, + 'nestKey' => $this->alias, + 'sort' => $this->sort, + ]; + } + + /** + * Auxiliary function to construct a new Query object to return all the records + * 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\SelectQuery<\Cake\Datasource\EntityInterface|array> + * @throws \InvalidArgumentException When a key is required for associations but not selected. + */ + protected function _buildQuery(array $options): SelectQuery + { + $key = $this->_linkField($options); + $filter = $options['keys']; + $useSubquery = $options['strategy'] === Association::STRATEGY_SUBQUERY; + $finder = $this->finder; + $options['fields'] ??= []; + + $query = $finder(); + assert($query instanceof SelectQuery); + if (isset($options['finder'])) { + [$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($shouldHydrate) + ->setConnectionRole($selectQuery->getConnectionRole()); + if ($selectQuery->isResultsCastingEnabled()) { + $fetchQuery->enableResultsCasting(); + } else { + $fetchQuery->disableResultsCasting(); + } + + if ($useSubquery) { + $filter = $this->_buildSubquery($selectQuery); + $fetchQuery = $this->_addFilteringJoin($fetchQuery, $key, $filter); + } else { + $fetchQuery = $this->_addFilteringCondition($fetchQuery, $key, $filter); + } + + if (!empty($options['sort'])) { + $fetchQuery->orderBy($options['sort']); + } + + if (!empty($options['contain'])) { + $fetchQuery->contain($options['contain']); + } + + if (!empty($options['queryBuilder'])) { + assert(is_callable($options['queryBuilder'])); + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $fetchQuery */ + $fetchQuery = $options['queryBuilder']($fetchQuery); + } + + $this->_assertFieldsPresent($fetchQuery, (array)$key); + + return $fetchQuery; + } + + /** + * Helper method to infer the requested finder and its options. + * + * Returns the inferred options from the finder $type. + * + * ### Examples: + * + * The following will call the finder 'translations' with the value of the finder as its options: + * $query->contain(['Comments' => ['finder' => ['translations']]]); + * $query->contain(['Comments' => ['finder' => ['translations' => []]]]); + * $query->contain(['Comments' => ['finder' => ['translations' => ['locales' => ['en_US']]]]]); + * + * @param array|string $finderData The finder name or an array having the name as key + * and options as value. + * @return array + */ + protected function _extractFinder(array|string $finderData): array + { + $finderData = (array)$finderData; + + if (is_numeric(key($finderData))) { + return [current($finderData), []]; + } + + return [key($finderData), current($finderData)]; + } + + /** + * Checks that the fetching query either has auto fields on or + * has the foreignKey fields selected. + * If the required fields are missing, automatically adds them to ensure + * entities can be properly identified and loaded. + * + * @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 + */ + protected function _assertFieldsPresent(SelectQuery $fetchQuery, array $key): void + { + if ($fetchQuery->isAutoFieldsEnabled()) { + return; + } + + $select = $fetchQuery->aliasFields($fetchQuery->clause('select')); + if (!$select) { + return; + } + + $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; + } + } + } + + // Automatically add missing primary key fields to the query + if ($missingFields) { + $fetchQuery->select($missingFields); + } + } + + /** + * Appends any conditions required to load the relevant set of records in the + * 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\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(SelectQuery $query, array|string $key, SelectQuery $subquery): SelectQuery + { + $filter = []; + $aliasedTable = $this->sourceAlias; + + foreach ($subquery->clause('select') as $aliasedField => $field) { + if (is_int($aliasedField)) { + $filter[] = new IdentifierExpression($field); + } else { + $filter[$aliasedField] = $field; + } + } + $subquery->select($filter, true); + + if (is_array($key)) { + $conditions = $this->_createTupleCondition($query, $key, $filter, '='); + } else { + $filter = current($filter); + $conditions = $query->expr([$key => $filter]); + } + + return $query->innerJoin( + [$aliasedTable => $subquery], + $conditions, + ); + } + + /** + * 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\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\SelectQuery<\Cake\Datasource\EntityInterface|array> + */ + 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]; + } + + return $query->andWhere($conditions); + } + + /** + * 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\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( + SelectQuery $query, + array $keys, + mixed $filter, + string $operator, + ): TupleComparison { + $types = []; + $defaults = $query->getDefaultTypes(); + foreach ($keys as $k) { + if (isset($defaults[$k])) { + $types[] = $defaults[$k]; + } + } + + return new TupleComparison($keys, $filter, $types, $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 array|string + * @throws \Cake\Database\Exception\DatabaseException + */ + protected function _linkField(array $options): array|string + { + $links = []; + $name = $this->alias; + + 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 DatabaseException($msg); + } + + $keys = in_array($this->associationType, [Association::ONE_TO_ONE, Association::ONE_TO_MANY], true) ? + $this->foreignKey : + $this->bindingKey; + + foreach ((array)$keys as $key) { + $links[] = sprintf('%s.%s', $name, $key); + } + + if (count($links) === 1) { + return $links[0]; + } + + return $links; + } + + /** + * Builds a query to be used as a condition for filtering records in the + * target table, it is constructed by cloning the original query that was used + * to load records in the source table. + * + * @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(SelectQuery $query): SelectQuery + { + $filterQuery = clone $query; + $filterQuery->disableAutoFields(); + $filterQuery->mapReduce(null, null, true); + $filterQuery->formatResults(null, true); + $filterQuery->contain([], true); + $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; + + // 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->offset(null); + if ($hasOrder) { + $filterQuery->orderBy([], true); + } + } + + $fields = $this->_subqueryFields($query); + $filterQuery->select($fields['select'], true)->groupBy($fields['group']); + + return $filterQuery; + } + + /** + * 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. + * + * @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(SelectQuery $query): array + { + $keys = (array)$this->bindingKey; + + if ($this->associationType === Association::MANY_TO_ONE) { + $keys = (array)$this->foreignKey; + } + + $fields = $query->aliasFields($keys, $this->sourceAlias); + $group = array_values($fields); + $fields = $group; + + /** @var \Cake\Database\Expression\QueryExpression $order */ + $order = $query->clause('order'); + if ($order) { + $columns = $query->clause('select'); + $order->iterateParts(function ($direction, $field) use (&$fields, $columns): void { + if (isset($columns[$field])) { + $fields[$field] = $columns[$field]; + } + }); + } + + return ['select' => $fields, 'group' => $group]; + } + + /** + * Builds an array containing the results from fetchQuery indexed by + * the foreignKey value corresponding to this association. + * + * @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(SelectQuery $fetchQuery, array $options): array + { + $resultMap = []; + $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; + + $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; + continue; + } + + if ($preserveKeys) { + $resultMap[implode(';', $values)][$i] = $result; + continue; + } + + $resultMap[implode(';', $values)][] = $result; + } + + return $resultMap; + } + + /** + * Returns a callable to be used for each row in a query result set + * for injecting the eager loaded rows + * + * @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 + * @return \Closure + */ + protected function _resultInjector(SelectQuery $fetchQuery, array $resultMap, array $options): Closure + { + $keys = $this->associationType === Association::MANY_TO_ONE ? + $this->foreignKey : + $this->bindingKey; + + $sourceKeys = []; + foreach ((array)$keys as $key) { + $f = $fetchQuery->aliasField($key, $this->sourceAlias); + $sourceKeys[] = (string)key($f); + } + + $nestKey = $options['nestKey']; + if (count($sourceKeys) > 1) { + return $this->_multiKeysInjector($resultMap, $sourceKeys, $nestKey); + } + + $sourceKey = $sourceKeys[0]; + + return function ($row) use ($resultMap, $sourceKey, $nestKey) { + if (isset($row[$sourceKey], $resultMap[$row[$sourceKey]])) { + $row[$nestKey] = $resultMap[$row[$sourceKey]]; + } + + return $row; + }; + } + + /** + * Returns a callable to be used for each row in a query result set + * 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 string $nestKey The key under which results should be nested + * @return \Closure + */ + protected function _multiKeysInjector(array $resultMap, array $sourceKeys, string $nestKey): Closure + { + return function ($row) use ($resultMap, $sourceKeys, $nestKey) { + $values = []; + foreach ($sourceKeys as $key) { + $values[] = $row[$key]; + } + + $key = implode(';', $values); + if (isset($resultMap[$key])) { + $row[$nestKey] = $resultMap[$key]; + } + + return $row; + }; + } +} diff --git a/src/ORM/Association/Loader/SelectWithPivotLoader.php b/src/ORM/Association/Loader/SelectWithPivotLoader.php new file mode 100644 index 00000000000..cc64ad9541b --- /dev/null +++ b/src/ORM/Association/Loader/SelectWithPivotLoader.php @@ -0,0 +1,209 @@ + + */ + protected HasMany $junctionAssoc; + + /** + * Custom conditions for the junction association + * + * @var \Cake\Database\ExpressionInterface|\Closure|array|string|null + */ + protected ExpressionInterface|Closure|array|string|null $junctionConditions = null; + + /** + * @inheritDoc + */ + public function __construct(array $options) + { + parent::__construct($options); + $this->junctionAssociationName = $options['junctionAssociationName']; + $this->junctionProperty = $options['junctionProperty']; + $this->junctionAssoc = $options['junctionAssoc']; + $this->junctionConditions = $options['junctionConditions']; + } + + /** + * Auxiliary function to construct a new Query object to return all the records + * in the target table that are associated to those specified in $options from + * the source table. + * + * 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\SelectQuery<\Cake\Datasource\EntityInterface|array> + * @throws \InvalidArgumentException When a key is required for associations but not selected. + */ + 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']); + } + + $query = parent::_buildQuery($options); + + if ($queryBuilder) { + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query */ + $query = $queryBuilder($query); + } + + if ($query->isAutoFieldsEnabled() === null) { + $query->enableAutoFields($query->clause('select') === []); + } + + // Ensure that association conditions are applied + // and that the required keys are in the selected columns. + + $tempName = $this->alias . '_CJoin'; + $schema = $assoc->getSchema(); + $joinFields = []; + $types = []; + + foreach ($schema->typeMap() as $f => $type) { + $key = $tempName . '__' . $f; + $joinFields[$key] = "{$name}.{$f}"; + $types[$key] = $type; + } + + $query + ->where($this->junctionConditions) + ->select($joinFields); + + $query + ->getEagerLoader() + ->addToJoinsMap($tempName, $assoc, false, $this->junctionProperty); + + $assoc->attachTo($query, [ + 'aliasPath' => $assoc->getAlias(), + 'includeFields' => false, + 'propertyPath' => $this->junctionProperty, + ]); + $query->getTypeMap()->addDefaults($types); + + 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 + */ + protected function _linkField(array $options): array|string + { + $links = []; + $name = $this->junctionAssociationName; + + foreach ((array)$options['foreignKey'] as $key) { + $links[] = sprintf('%s.%s', $name, $key); + } + + if (count($links) === 1) { + return array_pop($links); + } + + return $links; + } + + /** + * Builds an array containing the results from fetchQuery indexed by + * the foreignKey value corresponding to this association. + * + * @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(SelectQuery $fetchQuery, array $options): array + { + $resultMap = []; + $key = (array)$options['foreignKey']; + $preserveKeys = $fetchQuery->getOptions()['preserveKeys'] ?? false; + + foreach ($fetchQuery->all() as $i => $result) { + if (!isset($result[$this->junctionProperty])) { + throw new DatabaseException(sprintf( + '`%s` is missing from the belongsToMany results. Results cannot be created.', + $this->junctionProperty, + )); + } + + $values = []; + foreach ($key as $k) { + $values[] = $result[$this->junctionProperty][$k]; + } + + if ($preserveKeys) { + $resultMap[implode(';', $values)][$i] = $result; + continue; + } + + $resultMap[implode(';', $values)][] = $result; + } + + return $resultMap; + } +} diff --git a/src/ORM/AssociationCollection.php b/src/ORM/AssociationCollection.php new file mode 100644 index 00000000000..a286d783f6a --- /dev/null +++ b/src/ORM/AssociationCollection.php @@ -0,0 +1,386 @@ + + */ +class AssociationCollection implements IteratorAggregate +{ + use AssociationsNormalizerTrait; + use LocatorAwareTrait; + + /** + * Stored associations + * + * @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. + */ + public function __construct(?LocatorInterface $tableLocator = null) + { + if ($tableLocator !== null) { + $this->_tableLocator = $tableLocator; + } + } + + /** + * Add an association to the collection + * + * 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. + * + * @param string $alias The association alias + * @param \Cake\ORM\Association $association The association to add. + * @return \Cake\ORM\Association The association object being added. + * @throws \Cake\Core\Exception\CakeException If the alias is already added. + * @template T of \Cake\ORM\Association + * @phpstan-param T $association + * @phpstan-return T + */ + 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. + * + * @param 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 \Cake\ORM\Association + * @throws \InvalidArgumentException + * @template T of \Cake\ORM\Association + * @phpstan-param class-string $className + * @phpstan-return T + */ + public function load(string $className, string $associated, array $options = []): Association + { + $options += [ + 'tableLocator' => $this->getTableLocator(), + ]; + + $association = new $className($associated, $options); + + return $this->add($association->getName(), $association); + } + + /** + * Fetch an attached association by name. + * + * @param string $alias The association alias to get. + * @return \Cake\ORM\Association|null Either the association or null. + */ + public function get(string $alias): ?Association + { + return $this->_items[$alias] ?? null; + } + + /** + * Fetch an association by property name. + * + * @param string $prop The property to find an association by. + * @return \Cake\ORM\Association|null Either the association or null. + */ + public function getByProperty(string $prop): ?Association + { + foreach ($this->_items as $assoc) { + if ($assoc->getProperty() === $prop) { + return $assoc; + } + } + + return null; + } + + /** + * Check for an attached association by name. + * + * @param string $alias The association alias to get. + * @return bool Whether the association exists. + */ + public function has(string $alias): bool + { + return isset($this->_items[$alias]); + } + + /** + * Get the names of all the associations in the collection. + * + * @return array + */ + public function keys(): array + { + return array_keys($this->_items); + } + + /** + * Get an array of associations matching a specific type. + * + * @param array|string $class The type of associations you want. + * For example 'BelongsTo' or array like ['BelongsTo', 'HasOne'] + * @return array<\Cake\ORM\Association> An array of Association objects. + * @since 3.5.3 + */ + public function getByType(array|string $class): array + { + $class = array_map('strtolower', (array)$class); + + $out = array_filter($this->_items, function (Association $assoc) use ($class) { + [, $name] = namespaceSplit($assoc::class); + + return in_array(strtolower($name), $class, true); + }); + + return array_values($out); + } + + /** + * Drop/remove an association. + * + * Once removed the association will no longer be reachable + * + * @param string $alias The alias name. + * @return void + */ + public function remove(string $alias): void + { + unset($this->_items[$alias]); + } + + /** + * Remove all registered associations. + * + * Once removed associations will no longer be reachable + * + * @return void + */ + public function removeAll(): void + { + foreach ($this->_items as $alias => $object) { + $this->remove($alias); + } + } + + /** + * Save all the associations that are parents of the given entity. + * + * Parent associations include any association where the given table + * is the owning side. + * + * @param \Cake\ORM\Table $table The table entity is for. + * @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. + * @return bool Success + */ + public function saveParents(Table $table, EntityInterface $entity, array $associations, array $options = []): bool + { + if (!$associations) { + return true; + } + + return $this->_saveAssociations($table, $entity, $associations, $options, false); + } + + /** + * Save all the associations that are children of the given entity. + * + * Child associations include any association where the given table + * is not the owning side. + * + * @param \Cake\ORM\Table $table The table entity is for. + * @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. + * @return bool Success + */ + public function saveChildren(Table $table, EntityInterface $entity, array $associations, array $options): bool + { + if (!$associations) { + return true; + } + + return $this->_saveAssociations($table, $entity, $associations, $options, true); + } + + /** + * Helper method for saving an association's data. + * + * @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 bool $owningSide Compared with association classes' + * isOwningSide method. + * @return bool Success + * @throws \InvalidArgumentException When an unknown alias is used. + */ + 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)) { + $alias = $nested; + $nested = []; + } + $relation = $this->get($alias); + if (!$relation) { + $msg = sprintf( + 'Cannot save `%s`, it is not associated to `%s`.', + $alias, + $table->getAlias(), + ); + throw new InvalidArgumentException($msg); + } + if ($relation->isOwningSide($table) !== $owningSide) { + continue; + } + if (!$this->_save($relation, $entity, $nested, $options)) { + return false; + } + } + + return true; + } + + /** + * Helper method for saving an association's data. + * + * @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 + * @return bool Success + */ + protected function _save( + Association $association, + EntityInterface $entity, + array $nested, + array $options, + ): bool { + if (!$entity->isDirty($association->getProperty())) { + return true; + } + if ($nested) { + $options = $nested + $options; + } + + return (bool)$association->saveAssociated($entity, $options); + } + + /** + * Cascade a delete across the various associations. + * 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 bool + */ + public function cascadeDelete(EntityInterface $entity, array $options): bool + { + $noCascade = []; + foreach ($this->_items as $assoc) { + if (!$assoc->getCascadeCallbacks()) { + $noCascade[] = $assoc; + continue; + } + $success = $assoc->cascadeDelete($entity, $options); + if (!$success) { + return false; + } + } + + foreach ($noCascade as $assoc) { + $success = $assoc->cascadeDelete($entity, $options); + if (!$success) { + return false; + } + } + + return true; + } + + /** + * Returns an associative array of association names out a mixed + * array. If true is passed, then it returns all association names + * in this collection. + * + * @param array|string|bool $keys the list of association names to normalize + * @return array + */ + public function normalizeKeys(array|string|bool $keys): array + { + if ($keys === true) { + $keys = $this->keys(); + } + + if (!$keys) { + return []; + } + + return $this->_normalizeAssociations($keys); + } + + /** + * Allow looping through the associations + * + * @return \Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->_items); + } +} diff --git a/src/ORM/AssociationsNormalizerTrait.php b/src/ORM/AssociationsNormalizerTrait.php new file mode 100644 index 00000000000..6f869d2196a --- /dev/null +++ b/src/ORM/AssociationsNormalizerTrait.php @@ -0,0 +1,70 @@ + $options) { + $pointer = &$result; + + if (is_int($table)) { + $table = $options; + $options = []; + } + + if (!str_contains($table, '.')) { + $result[$table] = $options; + continue; + } + + $path = explode('.', $table); + $table = array_pop($path); + $first = array_shift($path); + assert(is_string($first)); + + $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['associated'] += [$table => []]; + $pointer['associated'][$table] = $options + $pointer['associated'][$table]; + } + + return $result['associated'] ?? $result; + } +} diff --git a/src/ORM/Attribute/CollectionOf.php b/src/ORM/Attribute/CollectionOf.php new file mode 100644 index 00000000000..7c60dbff5df --- /dev/null +++ b/src/ORM/Attribute/CollectionOf.php @@ -0,0 +1,50 @@ +doSomething($arg1, $arg2);`. + * + * ### Callback methods + * + * Behaviors can listen to any events fired on a Table. By default, + * CakePHP provides a number of lifecycle events your behaviors can + * listen to: + * + * - `beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options, boolean $primary)` + * Fired before each find operation. By stopping the event and supplying a + * return value you can bypass the find operation entirely. Any changes done + * to the $query instance will be retained for the rest of the find. The + * $primary parameter indicates whether this is the root query + * or an associated query. + * + * - `buildValidator(EventInterface $event, Validator $validator, string $name)` + * Fired when the validator object identified by $name is being built. You can use this + * callback to add validation rules or add validation providers. + * + * - `buildRules(EventInterface $event, RulesChecker $rules)` + * Fired when the rules checking object for the table is being built. You can use this + * callback to add more rules to the set. + * + * - `beforeRules(EventInterface $event, EntityInterface $entity, ArrayObject $options, $operation)` + * Fired before an entity is validated using by a rules checker. By stopping this event, + * you can return the final value of the rules checking operation. + * + * - `afterRules(EventInterface $event, EntityInterface $entity, ArrayObject $options, bool $result, $operation)` + * Fired after the rules have been checked on the entity. By stopping this event, + * you can return the final value of the rules checking operation. + * + * - `beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * 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. + * + * - `afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * Fired after an entity is saved. + * + * - `beforeDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * Fired before an entity is deleted. By stopping this event you will abort + * the delete operation. + * + * - `afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * Fired after an entity has been deleted. + * + * In addition to the core events, behaviors can respond to any + * event fired from your Table classes including custom application + * specific ones. + * + * You can set the priority of behaviors' callbacks by using the + * `priority` setting when attaching a behavior. This will set the + * priority for all the callbacks a behavior provides. + * + * ### Finder methods + * + * Behaviors can provide finder methods that hook into a Table's + * find() method. Custom finders are a great way to provide preset + * queries that relate to your behavior. For example a SluggableBehavior + * could provide a find('slugged') finder. Behavior finders + * are implemented the same as other finders. Any method + * starting with `find` will be setup as a finder. Your finder + * methods should expect the following arguments: + * + * ``` + * findSlugged(SelectQuery $query, array $options) + * ``` + * + * @see \Cake\ORM\Table::addBehavior() + * @see \Cake\Event\EventManager + */ +class Behavior implements EventListenerInterface +{ + use InstanceConfigTrait; + + /** + * Table instance. + * + * @var \Cake\ORM\Table + */ + protected Table $_table; + + /** + * Reflection method cache for behaviors. + * + * Stores the reflected method + finder methods per class. + * This prevents reflecting the same class multiple times in a single process. + * + * @var array + */ + protected static array $_reflectionCache = []; + + /** + * Default configuration + * + * These are merged with user-provided configuration when the behavior is used. + * + * @var array + */ + protected array $_defaultConfig = []; + + /** + * Constructor + * + * 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. + */ + public function __construct(Table $table, array $config = []) + { + $config = $this->_resolveMethodAliases( + 'implementedFinders', + $this->_defaultConfig, + $config, + ); + $config = $this->_resolveMethodAliases( + 'implementedMethods', + $this->_defaultConfig, + $config, + ); + $this->_table = $table; + $this->setConfig($config); + $this->initialize($config); + } + + /** + * Constructor hook method. + * + * Implement this method to avoid having to overwrite + * the constructor and call parent. + * + * @param array $config The configuration settings provided to this behavior. + * @return void + */ + public function initialize(array $config): void + { + } + + /** + * Get the table instance this behavior is bound to. + * + * @return \Cake\ORM\Table The bound table instance. + */ + public function table(): Table + { + return $this->_table; + } + + /** + * 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. + * @return array A de-duped list of config data. + */ + protected function _resolveMethodAliases(string $key, array $defaults, array $config): array + { + if (!isset($defaults[$key], $config[$key])) { + return $config; + } + if ($config[$key] === []) { + $this->setConfig($key, [], false); + unset($config[$key]); + + return $config; + } + + $indexed = array_flip($defaults[$key]); + $indexedCustom = array_flip($config[$key]); + foreach ($indexed as $method => $alias) { + $indexedCustom[$method] ??= $alias; + } + $this->setConfig($key, array_flip($indexedCustom), false); + unset($config[$key]); + + return $config; + } + + /** + * verifyConfig + * + * Checks that implemented keys contain values pointing at callable. + * + * @return void + * @throws \Cake\Core\Exception\CakeException if config are invalid + */ + public function verifyConfig(): void + { + $keys = ['implementedFinders', 'implementedMethods']; + foreach ($keys as $key) { + if (!isset($this->_config[$key])) { + continue; + } + + foreach ($this->_config[$key] as $method) { + if (!is_callable([$this, $method])) { + throw new CakeException(sprintf( + 'The method `%s` is not callable on class `%s`.', + $method, + static::class, + )); + } + } + } + } + + /** + * Gets the Model callbacks this behavior is interested in. + * + * By defining one of the callback methods a behavior is assumed + * to be interested in the related event. + * + * 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 + */ + public function implementedEvents(): array + { + $eventMap = [ + 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.afterMarshal' => 'afterMarshal', + 'Model.beforeFind' => 'beforeFind', + 'Model.beforeSave' => 'beforeSave', + 'Model.afterSave' => 'afterSave', + 'Model.afterSaveCommit' => 'afterSaveCommit', + 'Model.beforeDelete' => 'beforeDelete', + 'Model.afterDelete' => 'afterDelete', + 'Model.afterDeleteCommit' => 'afterDeleteCommit', + 'Model.buildValidator' => 'buildValidator', + 'Model.buildRules' => 'buildRules', + 'Model.beforeRules' => 'beforeRules', + 'Model.afterRules' => 'afterRules', + ]; + $config = $this->getConfig(); + $priority = $config['priority'] ?? null; + $events = []; + + foreach ($eventMap as $event => $method) { + if (!method_exists($this, $method)) { + continue; + } + if ($priority === null) { + $events[$event] = $method; + } else { + $events[$event] = [ + 'callable' => $method, + 'priority' => $priority, + ]; + } + } + + return $events; + } + + /** + * implementedFinders + * + * Provides an alias->methodname map of which finders a behavior implements. Example: + * + * ``` + * [ + * 'this' => 'findThis', + * 'alias' => '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(): array + { + $methods = $this->getConfig('implementedFinders'); + if ($methods !== null) { + return $methods; + } + + return $this->_reflectionCache()['finders']; + } + + /** + * implementedMethods + * + * Provides an alias->methodname map of which methods a behavior implements. Example: + * + * ``` + * [ + * 'method' => 'method', + * '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()` + * + * 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(): array + { + $methods = $this->getConfig('implementedMethods'); + if ($methods !== null) { + return $methods; + } + + return $this->_reflectionCache()['methods']; + } + + /** + * Gets the methods implemented by this behavior + * + * Uses the implementedEvents() method to exclude callback methods. + * Methods starting with `_` will be ignored, as will methods + * declared on Cake\ORM\Behavior + * + * @return array + * @throws \ReflectionException + */ + protected function _reflectionCache(): array + { + $class = static::class; + if (isset(self::$_reflectionCache[$class])) { + return self::$_reflectionCache[$class]; + } + + $events = $this->implementedEvents(); + $eventMethods = []; + foreach ($events as $binding) { + if (is_array($binding) && isset($binding['callable'])) { + $callable = $binding['callable']; + assert(is_string($callable)); + $binding = $callable; + } + $eventMethods[$binding] = true; + } + + $baseClass = self::class; + if (isset(self::$_reflectionCache[$baseClass])) { + $baseMethods = self::$_reflectionCache[$baseClass]; + } else { + $baseMethods = get_class_methods($baseClass); + self::$_reflectionCache[$baseClass] = $baseMethods; + } + + $return = [ + 'finders' => [], + 'methods' => [], + ]; + + $reflection = new ReflectionClass($class); + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $methodName = $method->getName(); + if ( + in_array($methodName, $baseMethods, true) || + isset($eventMethods[$methodName]) + ) { + continue; + } + + if (str_starts_with($methodName, 'find')) { + $return['finders'][lcfirst(substr($methodName, 4))] = $methodName; + } else { + $return['methods'][$methodName] = $methodName; + } + } + + return self::$_reflectionCache[$class] = $return; + } +} diff --git a/src/ORM/Behavior/CounterCacheBehavior.php b/src/ORM/Behavior/CounterCacheBehavior.php new file mode 100644 index 00000000000..811eb819b0d --- /dev/null +++ b/src/ORM/Behavior/CounterCacheBehavior.php @@ -0,0 +1,419 @@ + [ + * 'post_count' + * ] + * ] + * ``` + * + * Counter cache with scope + * ``` + * [ + * 'Users' => [ + * 'posts_published' => [ + * 'conditions' => [ + * 'published' => true + * ] + * ] + * ] + * ] + * ``` + * + * Counter cache using custom find + * ``` + * [ + * 'Users' => [ + * 'posts_published' => [ + * 'finder' => 'published' // Will be using findPublished() + * ] + * ] + * ] + * ``` + * + * Counter cache using lambda function returning the count + * This is equivalent to example #2 + * + * ``` + * [ + * 'Users' => [ + * 'posts_published' => function (EventInterface $event, EntityInterface $entity, Table $table) { + * $query = $table->find('all')->where([ + * 'published' => true, + * 'user_id' => $entity->get('user_id') + * ]); + * return $query->count(); + * } + * ] + * ] + * ``` + * + * 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 + * ``` + * [ + * 'Users' => [ + * 'posts_published' => [ + * 'ignoreDirty' => true + * ] + * ] + * ] + * ``` + * + * You can disable counter updates entirely by sending the `ignoreCounterCache` option + * to your save operation: + * + * ``` + * $this->Articles->save($article, ['ignoreCounterCache' => true]); + * ``` + */ +class CounterCacheBehavior extends Behavior +{ + /** + * Store the fields which should be ignored + * + * @var array> + */ + protected array $_ignoreDirty = []; + + /** + * beforeSave callback. + * + * Check if a field, which should be ignored, is dirty + * + * @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 + * @return void + */ + 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->getAssociation($assoc); + /** @var string|int $field */ + foreach ($settings as $field => $config) { + if (is_int($field)) { + continue; + } + + $registryAlias = $assoc->getTarget()->getRegistryAlias(); + $entityAlias = $assoc->getProperty(); + /** @var \Cake\Datasource\EntityInterface $assocEntity */ + $assocEntity = $entity->$entityAlias; + + if ( + !is_callable($config) && + isset($config['ignoreDirty']) && + $config['ignoreDirty'] === true && + $assocEntity->isDirty($field) + ) { + $this->_ignoreDirty[$registryAlias][$field] = true; + } + } + } + } + + /** + * afterSave callback. + * + * Makes sure to update counter cache when a new record is created or updated. + * + * @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 + * @return void + */ + public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) { + return; + } + + $this->_processAssociations($event, $entity); + $this->_ignoreDirty = []; + } + + /** + * afterDelete callback. + * + * Makes sure to update counter cache when a record is deleted. + * + * @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 + * @return void + */ + public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) { + return; + } + + $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\EventInterface<\Cake\ORM\Table> $event Event instance. + * @param \Cake\Datasource\EntityInterface $entity Entity. + * @return void + */ + protected function _processAssociations(EventInterface $event, EntityInterface $entity): void + { + foreach ($this->_config as $assoc => $settings) { + $assoc = $this->_table->getAssociation($assoc); + $this->_processAssociation($event, $entity, $assoc, $settings); + } + } + + /** + * Updates counter cache for a single association + * + * @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 counter cache for this association + * @return void + * @throws \RuntimeException If invalid callable is passed. + */ + protected function _processAssociation( + EventInterface $event, + EntityInterface $entity, + Association $assoc, + array $settings, + ): void { + /** @var array $foreignKeys */ + $foreignKeys = (array)$assoc->getForeignKey(); + $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); + $updateOriginalConditions = null; + if ($countOriginalConditions !== []) { + $updateOriginalConditions = array_combine($primaryKeys, $countOriginalConditions); + } + + foreach ($settings as $field => $config) { + if (is_int($field)) { + $field = $config; + $config = []; + } + + if ( + isset($this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field]) && + $this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field] + ) { + continue; + } + + 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); + } + } + + if ($updateOriginalConditions && $this->_shouldUpdateCount($updateOriginalConditions)) { + if ($config instanceof Closure) { + $count = $config($event, $entity, $this->_table, true); + } else { + $count = $this->_getCount($config, $countOriginalConditions); + } + 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 $conditions Additional conditions given to the query + * @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): SelectQuery|int + { + $finder = 'all'; + if (!empty($config['finder'])) { + $finder = $config['finder']; + unset($config['finder']); + } + + $config['conditions'] = array_merge($conditions, $config['conditions'] ?? []); + $query = $this->_table->find($finder, ...$config); + + if (isset($config['useSubQuery']) && $config['useSubQuery'] === false) { + 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 new file mode 100644 index 00000000000..24e99314b47 --- /dev/null +++ b/src/ORM/Behavior/TimestampBehavior.php @@ -0,0 +1,228 @@ + + */ + protected array $_defaultConfig = [ + 'implementedFinders' => [], + 'implementedMethods' => [ + 'timestamp' => 'timestamp', + 'touch' => 'touch', + ], + 'events' => [ + 'Model.beforeSave' => [ + 'created' => 'new', + 'modified' => 'always', + ], + ], + 'refreshTimestamp' => true, + ]; + + /** + * Current timestamp + * + * @var \Cake\I18n\DateTime|null + */ + protected ?DateTime $_ts = null; + + /** + * Initialize hook + * + * 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. + * @return void + */ + public function initialize(array $config): void + { + if (isset($config['events'])) { + $this->setConfig('events', $config['events'], false); + } + } + + /** + * There is only one event handler, it can be configured to be called for any event + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Event instance. + * @param \Cake\Datasource\EntityInterface $entity Entity instance. + * @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(EventInterface $event, EntityInterface $entity): void + { + $eventName = $event->getName(); + $events = $this->_config['events']; + + $new = $entity->isNew(); + $refresh = $this->_config['refreshTimestamp']; + + foreach ($events[$eventName] as $field => $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 + ) + ) { + $this->_updateField($entity, $field, $refresh); + } + } + } + + /** + * implementedEvents + * + * The implemented events of this behavior depend on configuration + * + * @return array + */ + public function implementedEvents(): array + { + /** @var array */ + return array_fill_keys(array_keys($this->_config['events']), 'handleEvent'); + } + + /** + * Get or set the timestamp to be used + * + * Set the timestamp to the given DateTime object, or if not passed a new DateTime object + * If an explicit date time is passed, the config option `refreshTimestamp` is + * automatically set to false. + * + * @param \DateTimeInterface|null $ts Timestamp + * @param bool $refreshTimestamp If true timestamp is refreshed. + * @return \Cake\I18n\DateTime + */ + public function timestamp(?DateTimeInterface $ts = null, bool $refreshTimestamp = false): DateTime + { + if ($ts) { + if ($this->_config['refreshTimestamp']) { + $this->_config['refreshTimestamp'] = false; + } + $this->_ts = new DateTime($ts); + } elseif ($this->_ts === null || $refreshTimestamp) { + $this->_ts = new DateTime(); + } + + return $this->_ts; + } + + /** + * Touch an entity + * + * Bumps timestamp fields for an entity. For any fields configured to be updated + * "always" or "existing", update the timestamp value. This method will overwrite + * any pre-existing value. + * + * @param \Cake\Datasource\EntityInterface $entity Entity instance. + * @param string $eventName Event name. + * @return bool true if a field is updated, false if no action performed + */ + public function touch(EntityInterface $entity, string $eventName = 'Model.beforeSave'): bool + { + $events = $this->_config['events']; + if (empty($events[$eventName])) { + return false; + } + + $return = false; + $refresh = $this->_config['refreshTimestamp']; + + foreach ($events[$eventName] as $field => $when) { + if (in_array($when, ['always', 'existing'], true)) { + $return = true; + $entity->setDirty($field, false); + $this->_updateField($entity, $field, $refresh); + } + } + + return $return; + } + + /** + * Update a field, if it hasn't been updated already + * + * @param \Cake\Datasource\EntityInterface $entity Entity instance. + * @param string $field Field name + * @param bool $refreshTimestamp Whether to refresh timestamp. + * @return void + */ + protected function _updateField(EntityInterface $entity, string $field, bool $refreshTimestamp): void + { + if ($entity->isDirty($field)) { + return; + } + + $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..73023bd9c67 --- /dev/null +++ b/src/ORM/Behavior/Translate/EavStrategy.php @@ -0,0 +1,550 @@ + + */ + 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..b63ff19f016 --- /dev/null +++ b/src/ORM/Behavior/Translate/ShadowTableStrategy.php @@ -0,0 +1,673 @@ + + */ + 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..e381affcbc5 --- /dev/null +++ b/src/ORM/Behavior/Translate/TranslateStrategyTrait.php @@ -0,0 +1,202 @@ +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 new file mode 100644 index 00000000000..2e500295634 --- /dev/null +++ b/src/ORM/Behavior/Translate/TranslateTrait.php @@ -0,0 +1,68 @@ +get('_locale')) { + return $this; + } + + $i18n = $this->has('_translations') ? $this->get('_translations') : null; + $created = false; + + if (!$i18n) { + $i18n = []; + $created = true; + } + + if ($created || empty($i18n[$language]) || !($i18n[$language] instanceof EntityInterface)) { + $className = static::class; + + $i18n[$language] = new $className(); + $created = true; + } + + if ($created) { + $this->set('_translations', $i18n); + } + + // Assume the user will modify any of the internal translations, helps with saving + $this->setDirty('_translations', true); + + return $i18n[$language]; + } +} diff --git a/src/ORM/Behavior/TranslateBehavior.php b/src/ORM/Behavior/TranslateBehavior.php new file mode 100644 index 00000000000..c1950963ca5 --- /dev/null +++ b/src/ORM/Behavior/TranslateBehavior.php @@ -0,0 +1,394 @@ + + */ + protected array $_defaultConfig = [ + 'implementedFinders' => ['translations' => 'findTranslations'], + 'implementedMethods' => [ + 'setLocale' => 'setLocale', + 'getLocale' => 'getLocale', + 'translationField' => 'translationField', + 'getStrategy' => 'getStrategy', + ], + 'fields' => [], + 'defaultLocale' => null, + 'referenceName' => '', + 'allowEmptyTranslations' => true, + 'onlyTranslated' => false, + 'strategy' => 'subquery', + 'tableLocator' => null, + 'validator' => false, + 'strategyClass' => null, + ]; + + /** + * Default strategy class name. + * + * @var string + * @phpstan-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. + */ + public function __construct(Table $table, array $config = []) + { + $config += [ + 'defaultLocale' => I18n::getDefaultLocale(), + 'referenceName' => $this->referenceName($table), + 'tableLocator' => $table->associations()->getTableLocator(), + ]; + + parent::__construct($table, $config); + } + + /** + * Initialize hook + * + * @param array $config The config for this behavior. + * @return void + */ + public function initialize(array $config): void + { + $this->getStrategy(); + } + + /** + * Set default strategy class name. + * + * @param string $class Class name. + * @return void + * @since 4.0.0 + * @phpstan-param class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> $class + */ + public static function setDefaultStrategyClass(string $class): void + { + static::$defaultStrategyClass = $class; + } + + /** + * Get default strategy class name. + * + * @return string + * @since 4.0.0 + * @phpstan-return class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> + */ + public static function getDefaultStrategyClass(): string + { + return static::$defaultStrategyClass; + } + + /** + * Get strategy class instance. + * + * @return \Cake\ORM\Behavior\Translate\TranslateStrategyInterface + * @since 4.0.0 + */ + public function getStrategy(): TranslateStrategyInterface + { + return $this->strategy ??= $this->createStrategy(); + } + + /** + * 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); + + return new $className($this->_table, $config); + } + + /** + * 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; + + return $this; + } + + /** + * 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', + ]; + } + + /** + * Hoist fields for the default locale under `_translations` key to the root + * in the data. + * + * 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 beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options): void + { + if (isset($options['translations']) && !$options['translations']) { + return; + } + + $defaultLocale = $this->getConfig('defaultLocale'); + if (!isset($data['_translations'][$defaultLocale])) { + return; + } + + foreach ($data['_translations'][$defaultLocale] as $field => $value) { + $data[$field] = $value; + } + + unset($data['_translations'][$defaultLocale]); + } + + /** + * {@inheritDoc} + * + * 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 + { + return $this->getStrategy()->buildMarshalMap($marshaller, $map, $options); + } + + /** + * Sets the locale that should be used for all future find and save operations on + * the table where this behavior is attached to. + * + * 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 setLocale(?string $locale) + { + $this->getStrategy()->setLocale($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. + * + * @return string + * @see \Cake\I18n\I18n::getLocale() + * @see \Cake\ORM\Behavior\TranslateBehavior::setLocale() + */ + public function getLocale(): string + { + return $this->getStrategy()->getLocale(); + } + + /** + * 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 + { + return $this->getStrategy()->translationField($field); + } + + /** + * Custom finder method used to retrieve all translations for the found records. + * Fetched translations can be filtered by locale by passing the `locales` key + * in the options array. + * + * Translated values will be found for each entity under the property `_translations`, + * containing an array indexed by locale name. + * + * ### Example: + * + * ``` + * $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\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(SelectQuery $query, array $locales = []): SelectQuery + { + $targetAlias = $this->getStrategy()->getTranslationTable()->getAlias(); + + return $query + ->contain([$targetAlias => function (QueryInterface $query) use ($locales, $targetAlias) { + if ($locales) { + $query->where(["{$targetAlias}.locale IN" => $locales]); + } + + return $query; + }]) + ->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); + } + + /** + * Determine the reference name to use for a given table + * + * The reference name is usually derived from the class name of the table object + * (PostsTable -> Posts), however for autotable instances it is derived from + * the database table the object points at - or as a last resort, the alias + * of the autotable instance. + * + * @param \Cake\ORM\Table $table The table class to get a reference name for. + * @return string + */ + protected function referenceName(Table $table): string + { + $name = namespaceSplit($table::class); + $name = substr(end($name), 0, -5); + if (!$name) { + $name = $table->getTable() ?: $table->getAlias(); + $name = Inflector::camelize($name); + } + + return $name; + } +} diff --git a/src/ORM/Behavior/TreeBehavior.php b/src/ORM/Behavior/TreeBehavior.php new file mode 100644 index 00000000000..2a9ffde9bed --- /dev/null +++ b/src/ORM/Behavior/TreeBehavior.php @@ -0,0 +1,1004 @@ + + */ + protected array $_defaultConfig = [ + 'implementedFinders' => [ + 'path' => 'findPath', + 'children' => 'findChildren', + 'treeList' => 'findTreeList', + ], + 'implementedMethods' => [ + 'childCount' => 'childCount', + 'moveUp' => 'moveUp', + 'moveDown' => 'moveDown', + 'recover' => 'recover', + 'removeFromTree' => 'removeFromTree', + 'getLevel' => 'getLevel', + 'formatTreeList' => 'formatTreeList', + ], + 'parent' => 'parent_id', + 'left' => 'lft', + 'right' => 'rght', + 'scope' => null, + 'level' => null, + 'recoverOrder' => null, + 'cascadeCallbacks' => false, + ]; + + /** + * @inheritDoc + */ + public function initialize(array $config): void + { + $this->_config['leftField'] = new IdentifierExpression($this->_config['left']); + $this->_config['rightField'] = new IdentifierExpression($this->_config['right']); + } + + /** + * Before save listener. + * Transparently manages setting the lft and rght fields if the parent field is + * included in the parameters to be 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 + * @throws \Cake\Database\Exception\DatabaseException if the parent to set for the node is invalid + */ + public function beforeSave(EventInterface $event, EntityInterface $entity): void + { + $isNew = $entity->isNew(); + $config = $this->getConfig(); + $parent = $entity->get($config['parent']); + $primaryKey = $this->_getPrimaryKey(); + $dirty = $entity->isDirty($config['parent']); + $level = $config['level']; + + if ($parent && $entity->get($primaryKey) === $parent) { + throw new DatabaseException("Cannot set a node's parent as itself."); + } + + 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); + } + + return; + } + + $edge = $this->_getMax(); + $entity->set($config['left'], $edge + 1); + $entity->set($config['right'], $edge + 2); + + if ($level) { + $entity->set($level, 0); + } + + return; + } + + if ($dirty) { + if ($parent) { + $this->_setParent($entity, $parent); + + if ($level) { + $parentNode = $this->_getNode($parent); + $entity->set($level, $parentNode[$level] + 1); + } + + return; + } + + $this->_setAsRoot($entity); + + if ($level) { + $entity->set($level, 0); + } + } + } + + /** + * After save listener. + * + * Manages updating level of descendants of currently saved entity. + * + * @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(EventInterface $event, EntityInterface $entity): void + { + if (!$this->_config['level'] || $entity->isNew()) { + return; + } + + $this->_setChildrenLevel($entity); + } + + /** + * Set level for descendants. + * + * @param \Cake\Datasource\EntityInterface $entity The entity whose descendants need to be updated. + * @return void + */ + protected function _setChildrenLevel(EntityInterface $entity): void + { + $config = $this->getConfig(); + + if ($entity->get($config['left']) + 1 === $entity->get($config['right'])) { + return; + } + + $primaryKey = $this->_getPrimaryKey(); + $primaryKeyValue = $entity->get($primaryKey); + $depths = [$primaryKeyValue => $entity->get($config['level'])]; + + /** @var \Traversable<\Cake\Datasource\EntityInterface> $children */ + $children = $this->_table->find( + 'children', + for: $primaryKeyValue, + fields: [$this->_getPrimaryKey(), $config['parent'], $config['level']], + order: $config['left'], + ) + ->all(); + + foreach ($children as $node) { + $parentIdValue = $node->get($config['parent']); + $depth = $depths[$parentIdValue] + 1; + $depths[$node->get($primaryKey)] = $depth; + + $this->_table->updateAll( + [$config['level'] => $depth], + [$primaryKey => $node->get($primaryKey)], + ); + } + } + + /** + * Also deletes the nodes in the subtree of the entity to be delete + * + * @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(EventInterface $event, EntityInterface $entity): void + { + $config = $this->getConfig(); + $this->_ensureFields($entity); + $left = $entity->get($config['left']); + $right = $entity->get($config['right']); + $diff = (int)($right - $left + 1); + + if ($diff > 2) { + 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}"); + } + + /** + * Sets the correct left and right values for the passed entity so it can be + * updated to a new parent. It also makes the hole in the tree so the node + * move can be done without corrupting the structure. + * + * @param \Cake\Datasource\EntityInterface $entity The entity to re-parent + * @param mixed $parent the id of the parent to set + * @return void + * @throws \Cake\Database\Exception\DatabaseException if the parent to set to the entity is not valid + */ + protected function _setParent(EntityInterface $entity, mixed $parent): void + { + $config = $this->getConfig(); + $parentNode = $this->_getNode($parent); + $this->_ensureFields($entity); + $parentLeft = $parentNode->get($config['left']); + $parentRight = $parentNode->get($config['right']); + $right = $entity->get($config['right']); + $left = $entity->get($config['left']); + + if ($parentLeft > $left && $parentLeft < $right) { + throw new DatabaseException(sprintf( + 'Cannot use node `%s` as parent for entity `%s`.', + $parent, + $entity->get($this->_getPrimaryKey()), + )); + } + + // Values for moving to the left + $diff = $right - $left + 1; + $targetLeft = $parentRight; + $targetRight = $diff + $parentRight - 1; + $min = $parentRight; + $max = $left - 1; + + if ($left < $targetLeft) { + // Moving to the right + $targetLeft = $parentRight - $diff; + $targetRight = $parentRight - 1; + $min = $right + 1; + $max = $parentRight - 1; + $diff *= -1; + } + + if ($right - $left > 1) { + // Correcting internal subtree + $internalLeft = $left + 1; + $internalRight = $right - 1; + $this->_sync($targetLeft - $left, '+', "BETWEEN {$internalLeft} AND {$internalRight}", true); + } + + $this->_sync($diff, '+', "BETWEEN {$min} AND {$max}"); + + if ($right - $left > 1) { + $this->_unmarkInternalTree(); + } + + // Allocating new position + $entity->set($config['left'], $targetLeft); + $entity->set($config['right'], $targetRight); + } + + /** + * Updates the left and right column for the passed entity so it can be set as + * a new root in the tree. It also modifies the ordering in the rest of the tree + * so the structure remains valid + * + * @param \Cake\Datasource\EntityInterface $entity The entity to set as a new root + * @return void + */ + protected function _setAsRoot(EntityInterface $entity): void + { + $config = $this->getConfig(); + $edge = $this->_getMax(); + $this->_ensureFields($entity); + $right = $entity->get($config['right']); + $left = $entity->get($config['left']); + $diff = $right - $left; + + if ($right - $left > 1) { + //Correcting internal subtree + $internalLeft = $left + 1; + $internalRight = $right - 1; + $this->_sync($edge - $diff - $left, '+', "BETWEEN {$internalLeft} AND {$internalRight}", true); + } + + $this->_sync($diff + 1, '-', "BETWEEN {$right} AND {$edge}"); + + if ($right - $left > 1) { + $this->_unmarkInternalTree(); + } + + $entity->set($config['left'], $edge - $diff); + $entity->set($config['right'], $edge); + } + + /** + * Helper method used to invert the sign of the left and right columns that are + * less than 0. They were set to negative values before so their absolute value + * wouldn't change while performing other tree transformations. + * + * @return void + */ + protected function _unmarkInternalTree(): void + { + $config = $this->getConfig(); + $this->_table->updateAll( + function (QueryExpression $exp) use ($config) { + $leftInverse = clone $exp; + $leftInverse->setConjunction('*')->add('-1'); + $rightInverse = clone $leftInverse; + + return $exp + ->eq($config['leftField'], $leftInverse->add($config['leftField'])) + ->eq($config['rightField'], $rightInverse->add($config['rightField'])); + }, + fn(QueryExpression $exp) => $exp->lt($config['leftField'], 0), + ); + } + + /** + * Custom finder method which can be used to return the list of nodes from the root + * 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\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(SelectQuery $query, string|int $for): SelectQuery + { + $config = $this->getConfig(); + [$left, $right] = array_map( + $this->_table->aliasField(...), + [$config['left'], $config['right']], + ); + + $node = $this->_table->get($for, select: [$left, $right]); + + return $this->_scope($query) + ->where([ + "{$left} <=" => $node->get($config['left']), + "{$right} >=" => $node->get($config['right']), + ]) + ->orderBy([$left => 'ASC']); + } + + /** + * Get the number of children nodes. + * + * @param \Cake\Datasource\EntityInterface $node The entity to count children for + * @param bool $direct whether to count all nodes in the subtree or just + * direct children + * @return int Number of children nodes. + */ + public function childCount(EntityInterface $node, bool $direct = false): int + { + $config = $this->getConfig(); + $parent = $this->_table->aliasField($config['parent']); + + if ($direct) { + return $this->_scope($this->_table->find()) + ->where([$parent => $node->get($this->_getPrimaryKey())]) + ->count(); + } + + $this->_ensureFields($node); + + return ($node->get($config['right']) - $node->get($config['left']) - 1) / 2; + } + + /** + * 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). + * + * @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(SelectQuery $query, int|string $for, bool $direct = false): SelectQuery + { + $config = $this->getConfig(); + [$parent, $left, $right] = array_map( + $this->_table->aliasField(...), + [$config['parent'], $config['left'], $config['right']], + ); + + if ($query->clause('order') === null) { + $query->orderBy([$left => 'ASC']); + } + + if ($direct) { + return $this->_scope($query)->where([$parent => $for]); + } + + $node = $this->_getNode($for); + + return $this->_scope($query) + ->where([ + "{$right} <" => $node->get($config['right']), + "{$left} >" => $node->get($config['left']), + ]); + } + + /** + * Gets a representation of the elements in the tree as a flat list where the keys are + * 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. + * + * @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. + * @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. + * @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( + 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']); + + return $this->formatTreeList($results, $keyPath, $valuePath, $spacer); + } + + /** + * Formats query as a flat list where the keys are 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. + * + * @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. + * @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. + * @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( + 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); + }, + ); + } + + /** + * Removes the current node from the tree, by positioning it as a new root + * and re-parents all children up one level. + * + * Note that the node will not be deleted just moved away from its current position + * without moving its children with it. + * + * @param \Cake\Datasource\EntityInterface $node The node to remove from the tree + * @return \Cake\Datasource\EntityInterface|false the node after being removed from the tree or + * false on error + */ + public function removeFromTree(EntityInterface $node): EntityInterface|false + { + return $this->_table->getConnection()->transactional(function () use ($node) { + $this->_ensureFields($node); + + return $this->_removeFromTree($node); + }); + } + + /** + * Helper function containing the actual code for removeFromTree + * + * @param \Cake\Datasource\EntityInterface $node The node to remove from the tree + * @return \Cake\Datasource\EntityInterface|false the node after being removed from the tree or + * false on error + */ + 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']); + + if ($right - $left === 1) { + return $this->_table->save($node); + } + + $primary = $this->_getPrimaryKey(); + $this->_table->updateAll( + [$config['parent'] => $parent], + [$config['parent'] => $node->get($primary)], + ); + $this->_sync(1, '-', 'BETWEEN ' . ($left + 1) . ' AND ' . ($right - 1)); + $this->_sync(2, '-', "> {$right}"); + $edge = $this->_getMax(); + $node->set($config['left'], $edge + 1); + $node->set($config['right'], $edge + 2); + $fields = [$config['parent'], $config['left'], $config['right']]; + + $this->_table->updateAll($node->extract($fields), [$primary => $node->get($primary)]); + + foreach ($fields as $field) { + $node->setDirty($field, false); + } + + return $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 the same node without any changes + * + * @param \Cake\Datasource\EntityInterface $node The node to move + * @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|false $node The node after being moved or false if `$number` is < 1 + */ + public function moveUp(EntityInterface $node, int|true $number = 1): EntityInterface|false + { + if ($number < 1) { + return false; + } + + return $this->_table->getConnection()->transactional(function () use ($node, $number) { + $this->_ensureFields($node); + + return $this->_moveUp($node, $number); + }); + } + + /** + * Helper function used with the actual code for moveUp + * + * @param \Cake\Datasource\EntityInterface $node The node to move + * @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 + */ + protected function _moveUp(EntityInterface $node, int|true $number): EntityInterface + { + $config = $this->getConfig(); + [$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(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(fn(QueryExpression $exp) => $exp->lt($config['rightField'], $nodeLeft)) + ->orderByAsc($config['leftField']) + ->limit(1) + ->first(); + + if (!$targetNode) { + return $node; + } + } + + [$targetLeft] = array_values($targetNode->extract([$left, $right])); + $edge = $this->_getMax(); + $leftBoundary = $targetLeft; + $rightBoundary = $nodeLeft - 1; + + $nodeToEdge = $edge - $nodeLeft + 1; + $shift = $nodeRight - $nodeLeft + 1; + $nodeToHole = $edge - $leftBoundary + 1; + $this->_sync($nodeToEdge, '+', "BETWEEN {$nodeLeft} AND {$nodeRight}"); + $this->_sync($shift, '+', "BETWEEN {$leftBoundary} AND {$rightBoundary}"); + $this->_sync($nodeToHole, '-', "> {$edge}"); + + /** @var string $left */ + $node->set($left, $targetLeft); + /** @var string $right */ + $node->set($right, $targetLeft + $nodeRight - $nodeLeft); + + $node->setDirty($left, false); + $node->setDirty($right, false); + + return $node; + } + + /** + * 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 the same node without any changes + * + * @param \Cake\Datasource\EntityInterface $node The node to move + * @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|false the entity after being moved or false if `$number` is < 1 + */ + public function moveDown(EntityInterface $node, int|true $number = 1): EntityInterface|false + { + if ($number < 1) { + return false; + } + + return $this->_table->getConnection()->transactional(function () use ($node, $number) { + $this->_ensureFields($node); + + return $this->_moveDown($node, $number); + }); + } + + /** + * Helper function used with the actual code for moveDown + * + * @param \Cake\Datasource\EntityInterface $node The node to move + * @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 + */ + protected function _moveDown(EntityInterface $node, int|true $number): EntityInterface + { + $config = $this->getConfig(); + [$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(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(fn(QueryExpression $exp) => $exp->gt($config['leftField'], $nodeRight)) + ->orderByDesc($config['leftField']) + ->limit(1) + ->first(); + + if (!$targetNode) { + return $node; + } + } + + [, $targetRight] = array_values($targetNode->extract([$left, $right])); + $edge = $this->_getMax(); + $leftBoundary = $nodeRight + 1; + $rightBoundary = $targetRight; + + $nodeToEdge = $edge - $nodeLeft + 1; + $shift = $nodeRight - $nodeLeft + 1; + $nodeToHole = $edge - $rightBoundary + $shift; + $this->_sync($nodeToEdge, '+', "BETWEEN {$nodeLeft} AND {$nodeRight}"); + $this->_sync($shift, '-', "BETWEEN {$leftBoundary} AND {$rightBoundary}"); + $this->_sync($nodeToHole, '-', "> {$edge}"); + + $node->set($left, $targetRight - ($nodeRight - $nodeLeft)); + $node->set($right, $targetRight); + + $node->setDirty($left, false); + $node->setDirty($right, false); + + return $node; + } + + /** + * Returns a single node from the tree from its primary key + * + * @param mixed $id Record id. + * @return \Cake\Datasource\EntityInterface + * @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found + */ + protected function _getNode(mixed $id): EntityInterface + { + $config = $this->getConfig(); + [$parent, $left, $right] = [$config['parent'], $config['left'], $config['right']]; + $primaryKey = $this->_getPrimaryKey(); + $fields = [$parent, $left, $right]; + if ($config['level']) { + $fields[] = $config['level']; + } + + $node = $this->_scope($this->_table->find()) + ->select($fields) + ->where([$this->_table->aliasField($primaryKey) => $id]) + ->first(); + + if (!$node instanceof EntityInterface) { + throw new RecordNotFoundException(sprintf('Node `%s` was not found in the tree.', $id)); + } + + return $node; + } + + /** + * Recovers the lft and right column values out of the hierarchy defined by the + * parent column. + * + * @return void + */ + public function recover(): void + { + $this->_table->getConnection()->transactional(function (): void { + $this->_recoverTree(); + }); + } + + /** + * Recursive method used to recover a single level of the tree + * + * @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 lftRght value + */ + protected function _recoverTree(int $lftRght = 1, mixed $parentId = null, int $level = 0): int + { + $config = $this->getConfig(); + [$parent, $left, $right] = [$config['parent'], $config['left'], $config['right']]; + $primaryKey = $this->_getPrimaryKey(); + $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; + } + + $this->_table->updateAll( + $fields, + [$primaryKey => $node[$primaryKey]], + ); + } + + return $lftRght; + } + + /** + * Returns the maximum index value in the table. + * + * @return int + */ + protected function _getMax(): int + { + $field = $this->_config['right']; + $rightField = $this->_config['rightField']; + $edge = $this->_scope($this->_table->find()) + ->select([$field]) + ->orderByDesc($rightField) + ->first(); + + if ($edge === null || empty($edge[$field])) { + return 0; + } + + return $edge[$field]; + } + + /** + * Auxiliary function used to automatically alter the value of both the left and + * right columns by a certain amount that match the passed conditions + * + * @param int $shift the value to use for operating the left and right columns + * @param string $dir The operator to use for shifting the value (+/-) + * @param string $conditions a SQL snipped to be used for comparing left or right + * against it. + * @param bool $mark whether to mark the updated values so that they can not be + * modified by future calls to this function. + * @return void + */ + 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->updateQuery()); + $exp = $query->expr(); + + $movement = clone $exp; + $movement->add($field)->add((string)$shift)->setConjunction($dir); + + $inverse = clone $exp; + $movement = $mark ? + $inverse->add($movement)->setConjunction('*')->add('-1') : + $movement; + + $where = clone $exp; + $where->add($field)->add($conditions)->setConjunction(''); + + $query + ->set($exp->eq($field, $movement)) + ->where($where) + ->execute(); + } + } + + /** + * Alters the passed query so that it only returns scoped records as defined + * in the tree configuration. + * + * @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(SelectQuery|UpdateQuery|DeleteQuery $query): SelectQuery|UpdateQuery|DeleteQuery + { + $scope = $this->getConfig('scope'); + + if ($scope === null) { + return $query; + } + + return $query->where($scope); + } + + /** + * Ensures that the provided entity contains non-empty values for the left and + * right fields + * + * @param \Cake\Datasource\EntityInterface $entity The entity to ensure fields for + * @return void + */ + protected function _ensureFields(EntityInterface $entity): void + { + $config = $this->getConfig(); + $fields = [$config['left'], $config['right']]; + $values = array_filter($entity->extract($fields)); + if (count($values) === count($fields)) { + return; + } + + $fresh = $this->_table->get($entity->get($this->_getPrimaryKey())); + // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) + 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); + } + } + + /** + * Returns a single string value representing the primary key of the attached table + * + * @return string + */ + protected function _getPrimaryKey(): string + { + if (!$this->_primaryKey) { + $primaryKey = (array)$this->_table->getPrimaryKey(); + $this->_primaryKey = $primaryKey[0]; + } + + return $this->_primaryKey; + } + + /** + * Returns the depth level of a node in the tree. + * + * @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(EntityInterface|string|int $entity): int|false + { + $primaryKey = $this->_getPrimaryKey(); + $id = $entity; + if ($entity instanceof EntityInterface) { + $id = $entity->get($primaryKey); + } + $config = $this->getConfig(); + $entity = $this->_table->find('all') + ->select([$config['left'], $config['right']]) + ->where([$primaryKey => $id]) + ->first(); + + if ($entity === null) { + return false; + } + + $query = $this->_table->find('all')->where([ + $config['left'] . ' <' => $entity[$config['left']], + $config['right'] . ' >' => $entity[$config['right']], + ]); + + return $this->_scope($query)->count(); + } +} diff --git a/src/ORM/BehaviorRegistry.php b/src/ORM/BehaviorRegistry.php new file mode 100644 index 00000000000..b32c93fc2ba --- /dev/null +++ b/src/ORM/BehaviorRegistry.php @@ -0,0 +1,350 @@ + + * @implements \Cake\Event\EventDispatcherInterface<\Cake\ORM\Table> + */ +class BehaviorRegistry extends ObjectRegistry implements EventDispatcherInterface +{ + /** + * @use \Cake\Event\EventDispatcherTrait<\Cake\ORM\Table> + */ + use EventDispatcherTrait; + + /** + * The table using this registry. + * + * @var \Cake\ORM\Table + */ + protected Table $_table; + + /** + * Method mappings. + * + * @var array + */ + protected array $_methodMap = []; + + /** + * Finder method mappings. + * + * @var array + */ + protected array $_finderMap = []; + + /** + * Constructor + * + * @param \Cake\ORM\Table|null $table The table this registry is attached to. + */ + public function __construct(?Table $table = null) + { + if ($table !== null) { + $this->setTable($table); + } + } + + /** + * Attaches a table instance to this registry. + * + * @param \Cake\ORM\Table $table The table this registry is attached to. + * @return void + */ + public function setTable(Table $table): void + { + $this->_table = $table; + $this->setEventManager($table->getEventManager()); + } + + /** + * Resolve a behavior classname. + * + * @param string $class Partial classname to resolve. + * @return string|null Either the correct classname or null. + * @phpstan-return class-string|null + */ + public static function className(string $class): ?string + { + return App::className($class, 'Model/Behavior', 'Behavior') + ?: App::className($class, 'ORM/Behavior', 'Behavior'); + } + + /** + * Resolve a behavior classname. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * + * @param string $class Partial classname to resolve. + * @return class-string<\Cake\ORM\Behavior>|null Either the correct class name or null. + */ + protected function _resolveClassName(string $class): ?string + { + /** @var class-string<\Cake\ORM\Behavior>|null */ + return static::className($class); + } + + /** + * Throws an exception when a behavior 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|null $plugin The plugin the behavior is missing in. + * @return void + * @throws \Cake\ORM\Exception\MissingBehaviorException + */ + protected function _throwMissingClassError(string $class, ?string $plugin): void + { + throw new MissingBehaviorException([ + 'class' => $class . 'Behavior', + 'plugin' => $plugin, + ]); + } + + /** + * Create the behavior instance. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * Enabled behaviors will be registered with the event manager. + * + * @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. + * @return \Cake\ORM\Behavior The constructed behavior class. + */ + protected function _create(object|string $class, string $alias, array $config): Behavior + { + if (is_object($class)) { + return $class; + } + + $instance = new $class($this->_table, $config); + + $enable = $config['enabled'] ?? true; + if ($enable) { + $this->getEventManager()->on($instance); + } + $methods = $this->_getMethods($instance, $class, $alias); + $this->_methodMap += $methods['methods']; + $this->_finderMap += $methods['finders']; + + return $instance; + } + + /** + * Get the behavior methods and ensure there are no duplicates. + * + * Use the implementedEvents() method to exclude callback methods. + * Methods starting with `_` will be ignored, as will methods + * declared on Cake\ORM\Behavior + * + * @param \Cake\ORM\Behavior $instance The behavior to get methods from. + * @param string $class The classname that is missing. + * @param string $alias The alias of the object. + * @return array A list of implemented finders and methods. + * @throws \LogicException when duplicate methods are connected. + */ + protected function _getMethods(Behavior $instance, string $class, string $alias): array + { + $finders = array_change_key_case($instance->implementedFinders()); + $methods = array_change_key_case($instance->implementedMethods()); + + foreach ($finders as $finder => $methodName) { + 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`.', + $class, + $finder, + $duplicate[0], + ); + throw new LogicException($error); + } + $finders[$finder] = [$alias, $methodName]; + } + + foreach ($methods as $method => $methodName) { + 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`.', + $class, + $method, + $duplicate[0], + ); + throw new LogicException($error); + } + $methods[$method] = [$alias, $methodName]; + } + + 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. + * + * Will return true if any behavior provides a public non-finder method + * with the chosen name. + * + * @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(string $method): bool + { + $method = strtolower($method); + + return isset($this->_methodMap[$method]); + } + + /** + * Check if any loaded behavior implements the named finder. + * + * Will return true if any behavior provides a public method with + * the chosen name. + * + * @param string $method The method to check for. + * @return bool + */ + public function hasFinder(string $method): bool + { + $method = strtolower($method); + + return isset($this->_finderMap[$method]); + } + + /** + * Invoke a method on a behavior. + * + * @param string $method The method 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. + * @throws \BadMethodCallException When the method is unknown. + * @deprecated 5.3.0 Calling behavior methods on the table instance is deprecated. + */ + 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])) { + [$behavior, $callMethod] = $this->_methodMap[$method]; + + return $this->_loaded[$behavior]->{$callMethod}(...$args); + } + + throw new BadMethodCallException( + 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 \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(string $type, SelectQuery $query, mixed ...$args): SelectQuery + { + $type = strtolower($type); + + if ($this->hasFinder($type)) { + [$behavior, $callMethod] = $this->_finderMap[$type]; + /** @var \Closure $callable */ + $callable = $this->_loaded[$behavior]->$callMethod(...); + + return $this->_table->invokeFinder($callable, $query, $args); + } + + throw new BadMethodCallException( + 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 new file mode 100644 index 00000000000..65e4551284c --- /dev/null +++ b/src/ORM/EagerLoadable.php @@ -0,0 +1,326 @@ + + */ + protected array $_associations = []; + + /** + * The Association class instance to use for loading the records. + * + * @var \Cake\ORM\Association|null + */ + protected ?Association $_instance = null; + + /** + * A list of options to pass to the association object for loading + * the records. + * + * @var array + */ + protected array $_config = []; + + /** + * A dotted separated string representing the path of associations + * that should be followed to fetch this level. + * + * @var string + */ + protected string $_aliasPath; + + /** + * A dotted separated string representing the path of entity properties + * in which results for this level should be placed. + * + * For example, in the following nested property: + * + * ``` + * $article->author->company->country + * ``` + * + * The property path of `country` will be `author.company` + * + * @var string|null + */ + protected ?string $_propertyPath = null; + + /** + * Whether this level can be fetched using a join. + * + * @var bool + */ + protected bool $_canBeJoined = false; + + /** + * Whether this level was meant for a "matching" fetch + * operation + * + * @var bool|null + */ + protected ?bool $_forMatching = null; + + /** + * The property name where the association result should be nested + * in the result. + * + * For example, in the following nested property: + * + * ``` + * $article->author->company->country + * ``` + * + * The target property of `country` will be just `country` + * + * @var string|null + */ + protected ?string $_targetProperty = null; + + /** + * Constructor. The $config parameter accepts the following array + * keys: + * + * - associations + * - instance + * - config + * - canBeJoined + * - aliasPath + * - propertyPath + * - forMatching + * - targetProperty + * + * 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. + */ + public function __construct(string $name, array $config = []) + { + $this->_name = $name; + $allowed = [ + 'associations', 'instance', 'config', 'canBeJoined', + 'aliasPath', 'propertyPath', 'forMatching', 'targetProperty', + ]; + foreach ($allowed as $property) { + if (isset($config[$property])) { + $this->{'_' . $property} = $config[$property]; + } + } + } + + /** + * Adds a new association to be loaded from this level. + * + * @param string $name The association name. + * @param \Cake\ORM\EagerLoadable $association The association to load. + * @return void + */ + public function addAssociation(string $name, EagerLoadable $association): void + { + $this->_associations[$name] = $association; + } + + /** + * Returns the Association class instance to use for loading the records. + * + * @return array + */ + public function associations(): array + { + return $this->_associations; + } + + /** + * Gets the Association class instance to use for loading the records. + * + * @return \Cake\ORM\Association + * @throws \Cake\Database\Exception\DatabaseException + */ + public function instance(): Association + { + if ($this->_instance === null) { + throw new DatabaseException('No instance set.'); + } + + return $this->_instance; + } + + /** + * Gets a dot separated string representing the path of associations + * that should be followed to fetch this level. + * + * @return string + */ + public function aliasPath(): string + { + return $this->_aliasPath; + } + + /** + * Gets a dot separated string representing the path of entity properties + * in which results for this level should be placed. + * + * For example, in the following nested property: + * + * ``` + * $article->author->company->country + * ``` + * + * The property path of `country` will be `author.company` + * + * @return string|null + */ + public function propertyPath(): ?string + { + return $this->_propertyPath; + } + + /** + * Sets whether this level can be fetched using a join. + * + * @param bool $possible The value to set. + * @return $this + */ + public function setCanBeJoined(bool $possible) + { + $this->_canBeJoined = $possible; + + return $this; + } + + /** + * Gets whether this level can be fetched using a join. + * + * @return bool + */ + public function canBeJoined(): bool + { + return $this->_canBeJoined; + } + + /** + * Sets the list of options to pass to the association object for loading + * the records. + * + * @param array $config The value to set. + * @return $this + */ + public function setConfig(array $config) + { + $this->_config = $config; + + return $this; + } + + /** + * Gets the list of options to pass to the association object for loading + * the records. + * + * @return array + */ + public function getConfig(): array + { + return $this->_config; + } + + /** + * Gets whether this level was meant for a + * "matching" fetch operation. + * + * @return bool|null + */ + public function forMatching(): ?bool + { + return $this->_forMatching; + } + + /** + * The property name where the result of this association + * should be nested at the end. + * + * For example, in the following nested property: + * + * ``` + * $article->author->company->country + * ``` + * + * The target property of `country` will be just `country` + * + * @return string|null + */ + public function targetProperty(): ?string + { + return $this->_targetProperty; + } + + /** + * Returns a representation of this object that can be passed to + * Cake\ORM\EagerLoader::contain() + * + * @return array + */ + public function asContainArray(): array + { + $associations = []; + foreach ($this->_associations as $assoc) { + $associations += $assoc->asContainArray(); + } + $config = $this->_config; + if ($this->_forMatching !== null) { + $config = ['matching' => $this->_forMatching] + $config; + } + + return [ + $this->_name => [ + 'associations' => $associations, + '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 new file mode 100644 index 00000000000..b035d6f9788 --- /dev/null +++ b/src/ORM/EagerLoader.php @@ -0,0 +1,887 @@ + + */ + protected array $_containments = []; + + /** + * Contains a nested array with the compiled containments tree + * This is a normalized version of the user provided containments array. + * + * @var array|null + */ + protected ?array $_normalized = null; + + /** + * List of options accepted by associations in contain() + * index by key for faster access. + * + * @var array + */ + protected array $_containOptions = [ + 'associations' => 1, + 'foreignKey' => 1, + 'conditions' => 1, + 'fields' => 1, + 'sort' => 1, + 'matching' => 1, + 'queryBuilder' => 1, + 'finder' => 1, + 'joinType' => 1, + 'strategy' => 1, + 'negateMatch' => 1, + 'includeFields' => 1, + ]; + + /** + * A list of associations that should be loaded with a separate query. + * + * @var array + */ + protected array $_loadExternal = []; + + /** + * Contains a list of the association names that are to be eagerly loaded. + * + * @var array>> + */ + protected array $_aliasList = []; + + /** + * Another EagerLoader instance that will be used for 'matching' associations. + * + * @var \Cake\ORM\EagerLoader|null + */ + protected ?EagerLoader $_matching = null; + + /** + * A map of table aliases pointing to the association objects they represent + * for the query. + * + * @var array + */ + protected array $_joinsMap = []; + + /** + * Controls whether fields from associated tables will be eagerly loaded. + * When set to false, no fields will be loaded from associations. + * + * @var bool + */ + protected bool $_autoFields = true; + + /** + * Sets 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. + * + * 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. + * + * Accepted options per passed association: + * + * - `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 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) + * + * @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 \Closure|null $queryBuilder The query builder callback. + * @return array Containments. + * @throws \InvalidArgumentException When using $queryBuilder with an array of $associations + */ + public function contain(array|string $associations, ?Closure $queryBuilder = null): array + { + if ($queryBuilder) { + if (!is_string($associations)) { + throw new InvalidArgumentException( + 'Cannot set containments. To use $queryBuilder, $associations must be a string', + ); + } + + $associations = [ + $associations => [ + 'queryBuilder' => $queryBuilder, + ], + ]; + } + + $associations = (array)$associations; + $associations = $this->_reformatContain($associations, $this->_containments); + $this->_normalized = null; + $this->_loadExternal = []; + $this->_aliasList = []; + + 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. + * + * This will reset/clear out any contained associations that were not + * added via matching(). + * + * @return void + */ + public function clearContain(): void + { + $this->_containments = []; + $this->_normalized = null; + $this->_loadExternal = []; + $this->_aliasList = []; + } + + /** + * Sets whether contained associations will load fields automatically. + * + * @param bool $enable The value to set. + * @return $this + */ + public function enableAutoFields(bool $enable = true) + { + $this->_autoFields = $enable; + + return $this; + } + + /** + * Disable auto loading fields of contained associations. + * + * @return $this + */ + public function disableAutoFields() + { + $this->_autoFields = false; + + return $this; + } + + /** + * Gets whether contained associations will load fields automatically. + * + * @return bool The current value. + */ + public function isAutoFieldsEnabled(): bool + { + return $this->_autoFields; + } + + /** + * 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. + * + * ### Options + * + * - `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(string $associationPath, ?Closure $builder = null, array $options = []) + { + $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]; + } + + // 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; + } + + /** + * Returns the current tree of associations to be matched. + * + * @return array The resulting containments array. + */ + public function getMatching(): array + { + $this->_matching ??= new static(); + + return $this->_matching->getContain(); + } + + /** + * Returns the fully normalized array of associations that should be eagerly + * 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 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 + * association instance from the corresponding source table + * + * @param \Cake\ORM\Table $repository The table containing the association that + * will be normalized. + * @return array + */ + public function normalized(Table $repository): array + { + if ($this->_normalized !== null) { + return $this->_normalized; + } + + $contain = []; + foreach ($this->_containments as $alias => $options) { + $contain[$alias] = $this->_normalizeContain( + $repository, + $alias, + $options, + ['root' => ''], + ); + } + + return $this->_normalized = $contain; + } + + /** + * 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. + * + * @param array $associations User provided containments array. + * @param array $original The original containments array to merge + * with the new one. + * @return array + */ + protected function _reformatContain(array $associations, array $original): array + { + $result = $original; + + foreach ($associations as $table => $options) { + $pointer = &$result; + if (is_int($table)) { + $table = $options; + $options = []; + } + + if ($options instanceof EagerLoadable) { + $options = $options->asContainArray(); + $table = key($options); + $options = current($options); + } + + if (isset($this->_containOptions[$table])) { + $pointer[$table] = $options; + continue; + } + + if (str_contains($table, '.')) { + $path = explode('.', $table); + $table = array_pop($path); + foreach ($path as $t) { + $pointer += [$t => []]; + $pointer = &$pointer[$t]; + } + } + + if (is_array($options)) { + // 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) { + $options = ['queryBuilder' => $options]; + } + + $pointer += [$table => []]; + + 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'] = fn($query) => $second($first($query)); + } + + if (is_string($options)) { + $options = [$options => []]; + } + + $pointer[$table] = $options + $pointer[$table]; + } + + return $result; + } + + /** + * Modifies the passed query to apply joins or any other transformation required + * in order to eager load the associations described in the `contain` array. + * 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\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. + * @return void + */ + public function attachAssociations(SelectQuery $query, Table $repository, bool $includeFields): void + { + if (!$this->_containments && $this->_matching === null) { + return; + } + + $attachable = $this->attachableAssociations($repository); + $processed = []; + do { + foreach ($attachable as $alias => $loadable) { + $config = $loadable->getConfig() + [ + 'aliasPath' => $loadable->aliasPath(), + 'propertyPath' => $loadable->propertyPath(), + 'includeFields' => $includeFields, + ]; + $loadable->instance()->attachTo($query, $config); + $processed[$alias] = true; + } + + $newAttachable = $this->attachableAssociations($repository); + $attachable = array_diff_key($newAttachable, $processed); + } while ($attachable !== []); + } + + /** + * Returns an array with the associations that can be fetched using a single query, + * the array keys are the association aliases and the values will contain an array + * with Cake\ORM\EagerLoadable objects. + * + * @param \Cake\ORM\Table $repository The table containing the associations to be + * attached. + * @return array + */ + public function attachableAssociations(Table $repository): array + { + $contain = $this->normalized($repository); + $matching = $this->_matching ? $this->_matching->normalized($repository) : []; + $this->_fixStrategies(); + $this->_loadExternal = []; + + return $this->_resolveJoins($contain, $matching); + } + + /** + * Returns an array with the associations that need to be fetched using a + * 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 array<\Cake\ORM\EagerLoadable> + */ + public function externalAssociations(Table $repository): array + { + if ($this->_loadExternal) { + return $this->_loadExternal; + } + + $this->attachableAssociations($repository); + + return $this->_loadExternal; + } + + /** + * Auxiliary function responsible for fully normalizing deep associations defined + * 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 + * 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, string $alias, array $options, array $paths): EagerLoadable + { + $defaults = $this->_containOptions; + $instance = $parent->getAssociation($alias); + + $paths += ['aliasPath' => '', 'propertyPath' => '', 'root' => $alias]; + $paths['aliasPath'] .= '.' . $alias; + + if ( + isset($options['matching']) && + $options['matching'] === true + ) { + $paths['propertyPath'] = '_matchingData.' . $alias; + } else { + $paths['propertyPath'] .= '.' . $instance->getProperty(); + } + + $table = $instance->getTarget(); + + $extra = array_diff_key($options, $defaults); + $config = [ + 'associations' => [], + 'instance' => $instance, + 'config' => array_diff_key($options, $extra), + 'aliasPath' => trim($paths['aliasPath'], '.'), + 'propertyPath' => trim($paths['propertyPath'], '.'), + 'targetProperty' => $instance->getProperty(), + ]; + $config['canBeJoined'] = $instance->canBeJoined($config['config']); + $eagerLoadable = new EagerLoadable($alias, $config); + + if ($config['canBeJoined']) { + $this->_aliasList[$paths['root']][$alias][] = $eagerLoadable; + } else { + $paths['root'] = $config['aliasPath']; + } + + foreach ($extra as $t => $assoc) { + $eagerLoadable->addAssociation( + $t, + $this->_normalizeContain($table, $t, $assoc, $paths), + ); + } + + return $eagerLoadable; + } + + /** + * Iterates over the joinable aliases list and corrects the fetching strategies + * in order to avoid aliases collision in the generated queries. + * + * This function operates on the array references that were generated by the + * _normalizeContain() function. + * + * @return void + */ + protected function _fixStrategies(): void + { + foreach ($this->_aliasList as $aliases) { + foreach ($aliases as $configs) { + if (count($configs) < 2) { + continue; + } + foreach ($configs as $loadable) { + if (str_contains($loadable->aliasPath(), '.')) { + $this->_correctStrategy($loadable); + } + } + } + } + } + + /** + * Changes the association fetching strategy if required because of duplicate + * under the same direct associations chain. + * + * @param \Cake\ORM\EagerLoadable $loadable The association config. + * @return void + */ + protected function _correctStrategy(EagerLoadable $loadable): void + { + $config = $loadable->getConfig(); + $currentStrategy = $config['strategy'] ?? Association::STRATEGY_JOIN; + + if (!$loadable->canBeJoined() || $currentStrategy !== Association::STRATEGY_JOIN) { + return; + } + + $config['strategy'] = Association::STRATEGY_SELECT; + $loadable->setConfig($config); + $loadable->setCanBeJoined(false); + } + + /** + * 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 + */ + protected function _resolveJoins(array $associations, array $matching = []): array + { + $result = []; + foreach ($matching as $table => $loadable) { + $result[$table] = $loadable; + $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->mergeJoins($result, $this->_resolveJoins($loadable->associations(), [])); + continue; + } + + if ($inMatching) { + $this->_correctStrategy($loadable); + } + + $loadable->setCanBeJoined(false); + $this->_loadExternal[] = $loadable; + } + + return $result; + } + + /** + * Merges association joins and throws an exception if there are conflicts. + * + * @param array $a + * @param array $b + * @return array + */ + private function mergeJoins(array $a, array $b): array + { + 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; + } + + 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(); + $instance = $meta->instance(); + $config = $meta->getConfig(); + $alias = $instance->getSource()->getAlias(); + $path = $meta->aliasPath(); + + $requiresKeys = $instance->requiresKeys($config); + 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 = $collected[$path][$alias] ?? null; + $callback = $instance->eagerLoader( + $config + [ + 'query' => $query, + 'contain' => $contain, + 'keys' => $keys, + 'nestKey' => $meta->aliasPath(), + ], + ); + $results = array_map($callback, $results); + } + + 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: + * + * - `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. + * @return array + */ + public function associationsMap(Table $table): array + { + $map = []; + + 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)); + + return $this->_buildAssociationsMap($map, $this->_joinsMap); + } + + /** + * An internal method to build a map which is used for the return value of the + * associationsMap() method. + * + * @param array $map An initial array for the map. + * @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(array $map, array $level, bool $matching = false): array + { + foreach ($level as $assoc => $meta) { + $canBeJoined = $meta->canBeJoined(); + $instance = $meta->instance(); + $associations = $meta->associations(); + $forMatching = $meta->forMatching(); + $map[] = [ + 'alias' => $assoc, + 'instance' => $instance, + 'canBeJoined' => $canBeJoined, + 'entityClass' => $instance->getTarget()->getEntityClass(), + 'nestKey' => $canBeJoined ? $assoc : $meta->aliasPath(), + 'matching' => $forMatching ?? $matching, + 'targetProperty' => $meta->targetProperty(), + ]; + if ($canBeJoined && $associations) { + $map = $this->_buildAssociationsMap($map, $associations, $matching); + } + } + + return $map; + } + + /** + * Registers a table alias, typically loaded as a join in a query, as belonging to + * an association. This helps hydrators know what to do with the columns coming + * from such joined table. + * + * @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 this join results should be treated as a + * 'matching' association. + * @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( + 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(), + ]); + } + + /** + * Helper function used to return the keys from the query records that will be used + * to eagerly load associations. + * + * @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(array $external, SelectQuery $query, array $results): array + { + $collectKeys = []; + foreach ($external as $meta) { + $instance = $meta->instance(); + if (!$instance->requiresKeys($meta->getConfig())) { + continue; + } + + $source = $instance->getSource(); + $keys = $instance->type() === Association::MANY_TO_ONE ? + (array)$instance->getForeignKey() : + (array)$instance->getBindingKey(); + + $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 (!$collectKeys) { + return []; + } + + return $this->_groupKeys($results, $collectKeys); + } + + /** + * Helper function used to iterate a statement and extract the columns + * defined in $collectKeys. + * + * @param array $results Results array. + * @param array $collectKeys The keys to collect. + * @return array + */ + protected function _groupKeys(array $results, array $collectKeys): array + { + $keys = []; + foreach ($results as $result) { + foreach ($collectKeys as $nestKey => $parts) { + if ($parts[2] === true) { + // 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; + } + + // Handle composite keys. + $collected = []; + foreach ($parts[1] as $key) { + $collected[] = $result[$key]; + } + $keys[$nestKey][$parts[0]][implode(';', $collected)] = $collected; + } + } + + return $keys; + } + + /** + * Handles cloning eager loaders and eager loadables. + */ + public function __clone() + { + if ($this->_matching) { + $this->_matching = clone $this->_matching; + } + } +} diff --git a/src/ORM/Entity.php b/src/ORM/Entity.php new file mode 100644 index 00000000000..bd5de5494fa --- /dev/null +++ b/src/ORM/Entity.php @@ -0,0 +1,89 @@ + 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 + */ + public function __construct(array $properties = [], array $options = []) + { + $options += [ + 'useSetters' => true, + 'markClean' => false, + 'markNew' => null, + 'guard' => false, + 'source' => null, + ]; + + if ($options['source'] !== null) { + $this->setSource($options['source']); + } + + if ($options['markNew'] !== null) { + $this->setNew($options['markNew']); + } + + if ($properties) { + //Remember the original field names here. + $this->setOriginalField(array_keys($properties)); + + if ($options['markClean'] && !$options['useSetters']) { + $this->_fields = $properties; + + return; + } + + $this->patch($properties, [ + 'asOriginal' => true, + 'setter' => $options['useSetters'], + 'guard' => $options['guard'], + ]); + } + + if ($options['markClean']) { + $this->clean(); + } + } +} diff --git a/src/ORM/Exception/MissingBehaviorException.php b/src/ORM/Exception/MissingBehaviorException.php new file mode 100644 index 00000000000..a9fd0ade58c --- /dev/null +++ b/src/ORM/Exception/MissingBehaviorException.php @@ -0,0 +1,28 @@ +|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. + * @param \Throwable|null $previous the previous exception. + */ + 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); + } + + /** + * Get the passed in entity + * + * @return \Cake\Datasource\EntityInterface + */ + public function getEntity(): EntityInterface + { + return $this->_entity; + } +} diff --git a/src/ORM/Exception/RolledbackTransactionException.php b/src/ORM/Exception/RolledbackTransactionException.php new file mode 100644 index 00000000000..c542b1fa9e6 --- /dev/null +++ b/src/ORM/Exception/RolledbackTransactionException.php @@ -0,0 +1,29 @@ + $entities a single entity or list of entities + * @param array $contain A `contain()` compatible array. + * @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<\Cake\Datasource\EntityInterface> + */ + public function loadInto(EntityInterface|array $entities, array $contain, Table $source): EntityInterface|array + { + $returnSingle = false; + + if ($entities instanceof EntityInterface) { + $entities = [$entities]; + $returnSingle = true; + } + + $query = $this->_getQuery($entities, $contain, $source); + $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; + } + + /** + * Builds a query for loading the passed list of entity objects along with the + * associations specified in $contain. + * + * @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\SelectQuery<\Cake\Datasource\EntityInterface|array> + */ + protected function _getQuery(array $entities, array $contain, Table $source): SelectQuery + { + $primaryKey = $source->getPrimaryKey(); + $method = is_string($primaryKey) ? 'get' : 'extract'; + + $keys = Hash::map($entities, '{*}', fn(EntityInterface $entity) => $entity->{$method}($primaryKey)); + + $query = $source + ->find() + ->select((array)$primaryKey) + ->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); + } + + $types = array_intersect_key($q->getDefaultTypes(), array_flip($primaryKey)); + $primaryKey = array_map($source->aliasField(...), $primaryKey); + + return new TupleComparison($primaryKey, $keys, $types, 'IN'); + }) + ->enableAutoFields() + ->contain($contain); + + foreach ($query->getEagerLoader()->attachableAssociations($source) as $loadable) { + $config = $loadable->getConfig(); + $config['includeFields'] = true; + $loadable->setConfig($config); + } + + return $query; + } + + /** + * Returns a map of property names where the association results should be injected + * 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 + */ + protected function _getPropertyMap(Table $source, array $associations): array + { + $map = []; + $container = $source->associations(); + foreach ($associations as $assoc) { + /** @var \Cake\ORM\Association $association */ + $association = $container->get($assoc); + $map[$assoc] = $association->getProperty(); + } + + return $map; + } + + /** + * Injects the results of the eager loader query into the original list of + * entities. + * + * @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<\Cake\Datasource\EntityInterface> + */ + protected function _injectResults( + array $entities, + SelectQuery $query, + array $associations, + Table $source, + ): array { + $injected = []; + $properties = $this->_getPropertyMap($source, $associations); + $primaryKey = (array)$source->getPrimaryKey(); + /** @var array<\Cake\Datasource\EntityInterface> $results */ + $results = $query + ->all() + ->indexBy(fn(EntityInterface $e) => implode(';', $e->extract($primaryKey))) + ->toArray(); + + foreach ($entities as $k => $object) { + $key = implode(';', $object->extract($primaryKey)); + if (!isset($results[$key])) { + $injected[$k] = $object; + continue; + } + + $loaded = $results[$key]; + foreach ($associations as $assoc) { + $property = $properties[$assoc]; + $object->set($property, $loaded->get($property), ['useSetters' => false]); + $object->setDirty($property, false); + } + $injected[$k] = $object; + } + + return $injected; + } +} diff --git a/src/ORM/Locator/LocatorAwareTrait.php b/src/ORM/Locator/LocatorAwareTrait.php new file mode 100644 index 00000000000..850af30aea2 --- /dev/null +++ b/src/ORM/Locator/LocatorAwareTrait.php @@ -0,0 +1,101 @@ +_tableLocator = $tableLocator; + + return $this; + } + + /** + * Gets the table locator. + * + * @return \Cake\ORM\Locator\LocatorInterface + */ + 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 + { + $alias ??= $this->defaultTable; + if (!$alias) { + throw new UnexpectedValueException( + 'You must provide an `$alias` or set the `$defaultTable` property to a non empty string.', + ); + } + + // phpcs:ignore + /** @var T */ + return $this->getTableLocator()->get($alias, $options); + } +} diff --git a/src/ORM/Locator/LocatorInterface.php b/src/ORM/Locator/LocatorInterface.php new file mode 100644 index 00000000000..71b59cab700 --- /dev/null +++ b/src/ORM/Locator/LocatorInterface.php @@ -0,0 +1,69 @@ + + */ +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 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 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. + * @return \Cake\ORM\Table + */ + public function get(string $alias, array $options = []): Table; + + /** + * 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(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 new file mode 100644 index 00000000000..3b11f58ca06 --- /dev/null +++ b/src/ORM/Locator/TableLocator.php @@ -0,0 +1,374 @@ + + */ +class TableLocator extends AbstractLocator implements LocatorInterface +{ + /** + * Contains a list of locations where table classes should be looked for. + * + * @var array + */ + protected array $locations = []; + + /** + * Configuration for aliases. + * + * @var array + */ + 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 array<\Cake\ORM\Table> + */ + protected array $_fallbacked = []; + + /** + * Fallback class to use + * + * @var class-string<\Cake\ORM\Table> + */ + protected string $fallbackClassName = Table::class; + + /** + * Whether fallback class should be used if a table class could not be found. + * + * @var bool + */ + protected bool $allowFallbackClass = true; + + protected QueryFactory $queryFactory; + + /** + * Constructor. + * + * @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 + */ + 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; + + return $this; + } + + if (isset($this->instances[$alias])) { + throw new DatabaseException(sprintf( + 'You cannot configure `%s`, it has already been constructed.', + $alias, + )); + } + + $this->_config[$alias] = $options; + + return $this; + } + + /** + * @inheritDoc + */ + public function getConfig(?string $alias = null): array + { + if ($alias === null) { + return $this->_config; + } + + return $this->_config[$alias] ?? []; + } + + /** + * Get a table instance from the registry. + * + * Tables are only created once until the registry is flushed. + * This means that aliases must be unique across your application. + * 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 {@link \Cake\ORM\Table::__construct()}, but the + * `className` key is also recognized. + * + * ### Options + * + * - `className` Define the specific class name to use. If undefined, CakePHP will generate the + * class name based on the alias. For example 'Users' would result in + * `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()}. + * - `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 + * {@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. 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(string $alias, array $options = []): Table + { + /** @var \Cake\ORM\Table */ + return parent::get($alias, $options); + } + + /** + * @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; + } + + if (isset($this->_config[$alias])) { + $options += $this->_config[$alias]; + } + + $allowFallbackClass = $options['allowFallbackClass'] ?? $this->allowFallbackClass; + $className = $this->_getClassName($alias, $options); + if ($className) { + $options['className'] = $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'] = $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 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; + $instance = $this->_create($options); + + if ($options['className'] === $this->fallbackClassName) { + $this->_fallbacked[$alias] = $instance; + } + + return $instance; + } + + /** + * Gets the table class name. + * + * @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(string $alias, array $options = []): ?string + { + if (empty($options['className'])) { + $options['className'] = $alias; + } + + if (str_contains($options['className'], '\\') && class_exists($options['className'])) { + return $options['className']; + } + + 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. + * @return \Cake\ORM\Table + */ + protected function _create(array $options): Table + { + /** @var class-string<\Cake\ORM\Table> $class */ + $class = $options['className']; + + return new $class($options); + } + + /** + * 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(string $alias, RepositoryInterface $repository): Table + { + return $this->instances[$alias] = $repository; + } + + /** + * @inheritDoc + */ + public function clear(): void + { + parent::clear(); + + $this->_fallbacked = []; + $this->_config = []; + } + + /** + * Returns the list of tables that were created by this registry that could + * not be instantiated from a specific subclass. This method is useful for + * debugging common mistakes when setting up associations or created new table + * classes. + * + * @return array<\Cake\ORM\Table> + */ + public function genericInstances(): array + { + return $this->_fallbacked; + } + + /** + * @inheritDoc + */ + public function remove(string $alias): void + { + 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 new file mode 100644 index 00000000000..b21f9149aac --- /dev/null +++ b/src/ORM/Marshaller.php @@ -0,0 +1,946 @@ +, TEntity> + */ + protected Table $_table; + + /** + * Constructor. + * + * @param \Cake\ORM\Table, TEntity> $table The table this marshaller is for. + */ + public function __construct(Table $table) + { + $this->_table = $table; + } + + /** + * Build the map of property => marshaling callable. + * + * @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 Map of property names to marshaling callables. + * Each callable accepts the value and entity, and returns the marshaled result. + */ + 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] = TypeFactory::build($columnType)->marshal(...); + } + } + + // Map associations + $options['associated'] ??= []; + $include = $this->_normalizeAssociations($options['associated']); + foreach ($include as $key => $nested) { + if (is_int($key) && is_scalar($nested)) { + $key = $nested; + $nested = []; + } + + $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 (!$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, + EntityInterface $entity, + ) use ( + $assoc, + $nested, + ): array|EntityInterface|null { + $options = $nested + ['associated' => [], 'association' => $assoc]; + + return $this->_mergeAssociation( + $this->fieldValue($entity, $assoc->getProperty()), + $assoc, + $value, + $options, + ); + }; + } else { + $callback = function ($value) use ($assoc, $nested): array|EntityInterface|null { + $options = $nested + ['associated' => []]; + + return $this->_marshalAssociation($assoc, $value, $options); + }; + } + $map[$assoc->getProperty()] = $callback; + } + + $behaviors = $this->_table->behaviors(); + foreach ($behaviors->loaded() as $name) { + $behavior = $behaviors->get($name); + if ($behavior instanceof PropertyMarshalInterface) { + $map += $behavior->buildMarshalMap($this, $map, $options); + } + } + + return $map; + } + + /** + * Hydrate one entity and its associated 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 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 + * when primary key values are set, and a record does not already exist. Normally primary key + * on missing entities would be ignored. Defaults to false. + * + * The above options can be used in each nested `associated` array. In addition to the above + * options you can also use the `onlyIds` option for HasMany and BelongsToMany associations. + * When true this option restricts the request data to only be read from `_ids`. + * + * ``` + * $result = $marshaller->one($data, [ + * 'associated' => ['Tags' => ['onlyIds' => true]] + * ]); + * ``` + * + * ``` + * $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 = []): EntityInterface + { + [$data, $options] = $this->_prepareDataAndOptions($data, $options); + + $primaryKey = (array)$this->_table->getPrimaryKey(); + $entity = $this->_table->newEmptyEntity(); + + if (isset($options['accessibleFields'])) { + foreach ((array)$options['accessibleFields'] as $key => $value) { + $entity->setAccess($key, $value); + } + } + + $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) { + $entity->setInvalidField($key, $value); + } + continue; + } + + if ($value === '' && in_array($key, $primaryKey, true)) { + // Skip marshaling '' for pk fields. + continue; + } + if (isset($propertyMap[$key])) { + $properties[$key] = $propertyMap[$key]($value, $entity); + } else { + $properties[$key] = $value; + } + } + + if (isset($options['fields'])) { + foreach ((array)$options['fields'] as $field) { + if (array_key_exists($field, $properties)) { + $entity->set($field, $properties[$field], ['asOriginal' => true]); + } + } + // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) + } elseif (method_exists($entity, 'patch')) { + $entity->patch($properties, ['asOriginal' => true]); + } else { + $entity->set($properties, ['asOriginal' => true]); + } + + // Don't flag clean association entities as + // dirty so we don't persist empty records. + foreach ($properties as $field => $value) { + if ($value instanceof EntityInterface) { + $entity->setDirty($field, $value->isDirty()); + } + } + + $entity->setErrors($errors); + $this->dispatchAfterMarshal($entity, $data, $options); + + return $entity; + } + + /** + * Returns the validation errors for a data set based on the passed options + * + * @param array $data The data to validate. + * @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(array $data, string|bool $validator, bool $isNew, array $context = []): array + { + if (!$validator) { + return []; + } + + if ($validator === true) { + $validator = null; + } + + 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. + * @return array An array containing prepared data and options. + */ + protected function _prepareDataAndOptions(array $data, array $options): array + { + $options += ['validate' => true, 'fields' => null, 'strictFields' => false]; + + $tableName = $this->_table->getAlias(); + if (isset($data[$tableName]) && is_array($data[$tableName])) { + $data += $data[$tableName]; + unset($data[$tableName]); + } + + $data = new ArrayObject($data); + $options = new ArrayObject($options); + $this->_table->dispatchEvent('Model.beforeMarshal', compact('data', 'options')); + + return [(array)$data, (array)$options]; + } + + /** + * Create a new sub-marshaller and marshal the associated data. + * + * @param \Cake\ORM\Association $assoc The association to marshall + * @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(Association $assoc, mixed $value, array $options): EntityInterface|array|null + { + if (!is_array($value)) { + return null; + } + $targetTable = $assoc->getTarget(); + $marshaller = $targetTable->marshaller(); + $types = [Association::ONE_TO_ONE, Association::MANY_TO_ONE]; + $type = $assoc->type(); + if (in_array($type, $types, true)) { + return $marshaller->one($value, $options); + } + 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']; + + if ($hasIds && is_array($value['_ids'])) { + return $this->_loadAssociatedByIds($assoc, $value['_ids']); + } + if ($hasIds || $onlyIds) { + return []; + } + } + if ($type === Association::MANY_TO_MANY) { + assert($assoc instanceof BelongsToMany); + + return $marshaller->_belongsToMany($assoc, $value, $options); + } + + return $marshaller->many($value, $options); + } + + /** + * Hydrate many entities and their associated 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 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 + * when primary key values are set, and a record does not already exist. Normally primary key + * on missing entities would be ignored. Defaults to false. + * + * @param array $data The data to hydrate. + * @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 = []): array + { + $output = []; + foreach ($data as $record) { + if (!is_array($record)) { + continue; + } + $output[] = $this->one($record, $options); + } + + return $output; + } + + /** + * Marshals data for belongsToMany associations. + * + * Builds the related entities and handles the special casing + * for junction table entities. + * + * @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 array<\Cake\Datasource\EntityInterface> An array of built entities. + * @throws \BadMethodCallException + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + protected function _belongsToMany(BelongsToMany $assoc, array $data, array $options = []): array + { + $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); + $junctionProperty = $assoc->getJunctionProperty(); + $options += ['junctionProperty' => $junctionProperty]; + + foreach ($data as $i => $row) { + if (!is_array($row)) { + continue; + } + if (array_intersect_key($primaryKey, $row) === $primaryKey) { + $keys = array_intersect_key($row, $primaryKey); + if (count($keys) === $primaryCount) { + $rowConditions = []; + foreach ($keys as $key => $value) { + $rowConditions[][$target->aliasField($key)] = $value; + } + + if ($forceNew && !$target->exists($rowConditions)) { + $records[$i] = $this->one($row, $options); + } + + $conditions = array_merge($conditions, $rowConditions); + } + } else { + $records[$i] = $this->one($row, $options); + } + } + + 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 ($results as $row) { + $k = implode(';', $row->extract($keyFields)); + $existing[$k] = $row; + } + + foreach ($data as $i => $row) { + $key = []; + foreach ($keyFields as $k) { + if (isset($row[$k])) { + $key[] = $row[$k]; + } + } + $key = implode(';', $key); + + // Update existing record and child associations + if (isset($existing[$key])) { + $records[$i] = $this->merge($existing[$key], $row, $options); + } + } + } + + $jointMarshaller = $assoc->junction()->marshaller(); + + $nested = []; + if (isset($associated[$junctionProperty])) { + $nested = (array)$associated[$junctionProperty]; + } + + foreach ($records as $i => $record) { + // 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); + } + } + + return $records; + } + + /** + * 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 array<\Cake\Datasource\EntityInterface> An array of entities. + */ + protected function _loadAssociatedByIds(Association $assoc, array $ids): array + { + if (!$ids) { + return []; + } + + $target = $assoc->getTarget(); + $primaryKey = (array)$target->getPrimaryKey(); + $multi = count($primaryKey) > 1; + $primaryKey = array_map($target->aliasField(...), $primaryKey); + + if ($multi) { + $first = current($ids); + if (!is_array($first) || count($first) !== count($primaryKey)) { + return []; + } + $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]; + } + + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface> $query */ + $query = $target->find()->where($filter); + + return $query->toArray(); + } + + /** + * Merges `$data` into `$entity` and recursively does the same for each one of + * the association names passed in `$options`. When merging associations, if an + * entity is not present in the parent entity for a given association, a new one + * will be created. + * + * 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. `ids` option can be used + * to determine whether the association must use the `_ids` format. + * + * ### Options: + * + * - 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. + * - 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. + * + * The above options can be used in each nested `associated` array. In addition to the above + * options you can also use the `onlyIds` option for HasMany and BelongsToMany associations. + * When true this option restricts the request data to only be read from `_ids`. + * + * ``` + * $result = $marshaller->merge($entity, $data, [ + * 'associated' => ['Tags' => ['onlyIds' => true]] + * ]); + * ``` + * + * ``` + * $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 TMergedEntity + * @see \Cake\ORM\Entity::$_accessible + */ + public function merge(EntityInterface $entity, array $data, array $options = []): EntityInterface + { + [$data, $options] = $this->_prepareDataAndOptions($data, $options); + + $isNew = $entity->isNew(); + $keys = []; + + if (!$isNew) { + $keys = $entity->extract((array)$this->_table->getPrimaryKey()); + } + + if (isset($options['accessibleFields'])) { + foreach ((array)$options['accessibleFields'] as $key => $value) { + $entity->setAccess($key, $value); + } + } + + $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 = []; + /** + * @var string $key + */ + foreach ($data as $key => $value) { + if (!empty($errors[$key])) { + if ($entity instanceof InvalidPropertyInterface) { + $entity->setInvalidField($key, $value); + } + continue; + } + + if (isset($propertyMap[$key])) { + $value = $propertyMap[$key]($value, $entity); + } + $properties[$key] = $value; + } + + $entity->setErrors($errors); + if (!isset($options['fields'])) { + // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) + 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; + } + $entity->set($field, $properties[$field]); + if ($properties[$field] instanceof EntityInterface) { + $entity->setDirty($field, $properties[$field]->isDirty()); + } + } + $this->dispatchAfterMarshal($entity, $data, $options); + + return $entity; + } + + /** + * Merges each of the elements from `$data` into each of the entities in `$entities` + * and recursively does the same for each of the association names passed in + * `$options`. When merging associations, if an entity is not present in the parent + * entity for a given association, a new one will be created. + * + * 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 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. + * + * ### Options: + * + * - 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 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. + * + * @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 array + * @see \Cake\ORM\Entity::$_accessible + */ + public function mergeMany(iterable $entities, array $data, array $options = []): array + { + $primary = (array)$this->_table->getPrimaryKey(); + + $indexed = (new Collection($data)) + ->groupBy(function ($el) use ($primary) { + $keys = []; + foreach ($primary as $key) { + $keys[] = $el[$key] ?? ''; + } + + return implode(';', $keys); + }) + ->map(function ($element, $key) { + return $key === '' ? $element : $element[0]; + }) + ->toArray(); + + $new = $indexed[''] ?? []; + unset($indexed['']); + $output = []; + + foreach ($entities as $entity) { + if (!($entity instanceof EntityInterface)) { + continue; + } + + $key = implode(';', $entity->extract($primary)); + if (!isset($indexed[$key])) { + continue; + } + + $output[] = $this->merge($entity, $indexed[$key], $options); + unset($indexed[$key]); + } + + $conditions = (new Collection($indexed)) + ->map(function ($data, $key) { + return explode(';', (string)$key); + }) + ->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); + unset($indexed[$key]); + } + } + } + + foreach ((new Collection($indexed))->append($new) as $value) { + if (!is_array($value)) { + continue; + } + /** + * phpcs:ignore SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation.NonFullyQualifiedClassName + * @var TEntity $entity + */ + $entity = $this->one($value, $options); + $output[] = $entity; + } + + return $output; + } + + /** + * Creates a new sub-marshaller and merges the associated data. + * + * @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 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( + EntityInterface|array|null $original, + Association $assoc, + mixed $value, + array $options, + ): EntityInterface|array|null { + if (!$original) { + return $this->_marshalAssociation($assoc, $value, $options); + } + if (!is_array($value)) { + return null; + } + + $targetTable = $assoc->getTarget(); + $marshaller = $targetTable->marshaller(); + $types = [Association::ONE_TO_ONE, Association::MANY_TO_ONE]; + $type = $assoc->type(); + if (in_array($type, $types, true)) { + /** @var \Cake\Datasource\EntityInterface $original */ + return $marshaller->merge($original, $value, $options); + } + if ($type === Association::MANY_TO_MANY && is_array($original)) { + assert($assoc instanceof BelongsToMany); + + return $marshaller->_mergeBelongsToMany($original, $assoc, $value, $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 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 array<\Cake\Datasource\EntityInterface> + */ + protected function _mergeBelongsToMany(array $original, BelongsToMany $assoc, array $value, array $options): array + { + $associated = $options['associated'] ?? []; + + $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 []; + } + + $junctionProperty = $assoc->getJunctionProperty(); + if ($associated && !in_array($junctionProperty, $associated, true) && !isset($associated[$junctionProperty])) { + return $this->mergeMany($original, $value, $options); + } + + return $this->_mergeJoinData($original, $assoc, $value, $options); + } + + /** + * Merge the special junction property (_joinData) into the entity set. + * + * @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 array<\Cake\Datasource\EntityInterface> An array of entities + */ + protected function _mergeJoinData(array $original, BelongsToMany $assoc, array $value, array $options): array + { + $associated = $options['associated'] ?? []; + $extra = []; + $junctionProperty = $assoc->getJunctionProperty(); + foreach ($original as $entity) { + // Mark joinData as accessible so we can marshal it properly. + $entity->setAccess($junctionProperty, true); + + $joinData = $this->fieldValue($entity, $junctionProperty); + if ($joinData instanceof EntityInterface) { + $extra[spl_object_hash($entity)] = $joinData; + } + } + + $joint = $assoc->junction(); + $marshaller = $joint->marshaller(); + + $nested = []; + if (isset($associated[$junctionProperty])) { + $nested = (array)$associated[$junctionProperty]; + } + + $options['accessibleFields'] = [$junctionProperty => true]; + + $records = $this->mergeMany($original, $value, $options); + foreach ($records as $record) { + $hash = spl_object_hash($record); + $value = $this->fieldValue($record, $junctionProperty); + + // Already an entity, no further marshaling required. + if ($value instanceof EntityInterface) { + continue; + } + + // Scalar data can't be handled + if (!is_array($value)) { + $record->unset($junctionProperty); + continue; + } + + // Marshal data into the old object, or make a new joinData object. + if (isset($extra[$hash])) { + $record->set($junctionProperty, $marshaller->merge($extra[$hash], $value, $nested)); + } else { + $joinData = $marshaller->one($value, $nested); + $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 new file mode 100644 index 00000000000..93e4013f56a --- /dev/null +++ b/src/ORM/PropertyMarshalInterface.php @@ -0,0 +1,36 @@ + $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; +} diff --git a/src/ORM/Query.php b/src/ORM/Query.php new file mode 100644 index 00000000000..0aa206233ae --- /dev/null +++ b/src/ORM/Query.php @@ -0,0 +1,16 @@ +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..2717ece7add --- /dev/null +++ b/src/ORM/Query/QueryFactory.php @@ -0,0 +1,69 @@ + + */ + public function select(Table $table): SelectQuery + { + return new SelectQuery($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..09373897b60 --- /dev/null +++ b/src/ORM/Query/SelectQuery.php @@ -0,0 +1,1839 @@ + + */ +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. + * + * @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/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 new file mode 100644 index 00000000000..a6b3c51340b --- /dev/null +++ b/src/ORM/README.md @@ -0,0 +1,241 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/orm.svg?style=flat-square)](https://packagist.org/packages/cakephp/orm) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP ORM + +The CakePHP ORM provides a powerful and flexible way to work with relational +databases. Using a datamapper pattern the ORM allows you to manipulate data as +entities allowing you to create expressive domain layers in your applications. + +## Database engines supported + +The CakePHP ORM is compatible with: + +* MySQL 5.1+ +* Postgres 8+ +* SQLite3 +* SQLServer 2008+ +* Oracle (through a [community plugin](https://github.com/CakeDC/cakephp-oracle-driver)) + +## Connecting to the Database + +The first thing you need to do when using this library is register a connection +object. Before performing any operations with the connection, you need to +specify a driver to use: + +```php +use Cake\Datasource\ConnectionManager; + +ConnectionManager::setConfig('default', [ + 'className' => \Cake\Database\Connection::class, + 'driver' => \Cake\Database\Driver\Mysql::class, + 'database' => 'test', + 'username' => 'root', + 'password' => 'secret', + 'cacheMetadata' => true, + 'quoteIdentifiers' => false, +]); +``` + +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. +* belongsToMany - E.g. An article belongsToMany tags. + +You define associations in your table's `initialize()` method. See the +[documentation](https://book.cakephp.org/5/en/orm/associations.html) for +complete examples. + +## Reading Data + +Once you've defined some table classes you can read existing data in your tables: + +```php +use Cake\ORM\Locator\LocatorAwareTrait; + +$articles = $this->getTableLocator()->get('Articles'); +foreach ($articles->find() as $article) { + echo $article->title; +} +``` + +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 + +Table objects provide ways to convert request data into entities, and then persist +those entities to the database: + +```php +use Cake\ORM\Locator\LocatorAwareTrait; + +$data = [ + 'title' => 'My first article', + 'body' => 'It is a great article', + 'user_id' => 1, + 'tags' => [ + '_ids' => [1, 2, 3] + ], + 'comments' => [ + ['comment' => 'Good job'], + ['comment' => 'Awesome work'], + ] +]; + +$articles = $this->getTableLocator()->get('Articles'); +$article = $articles->newEntity($data, [ + 'associated' => ['Tags', 'Comments'] +]); +$articles->save($article, [ + 'associated' => ['Tags', 'Comments'] +]) +``` + +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/5/en/orm/saving-data.html) +for more in-depth examples. + +## Deleting Data + +Once you have a reference to an entity, you can use it to delete data: + +```php +$articles = $this->getTableLocator()->get('Articles'); +$article = $articles->get(2); +$articles->delete($article); +``` + +## Meta Data Cache + +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; + +$cacheConfig = [ + 'className' => FileEngine::class, + '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 extends Collection implements ResultSetInterface +{ +} 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 new file mode 100644 index 00000000000..32dea8af9c1 --- /dev/null +++ b/src/ORM/Rule/ExistsIn.php @@ -0,0 +1,172 @@ + + */ + protected array $_fields; + + /** + * The repository where the field will be looked for + * + * @var \Cake\ORM\Table|\Cake\ORM\Association|string + */ + protected Table|Association|string $_repository; + + /** + * Options for the constructor + * + * @var array + */ + protected array $_options = []; + + /** + * Constructor. + * + * Available option for $options is 'allowNullableNulls' flag. + * Set to true to accept composite foreign keys where one or more nullable columns are null. + * + * @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(array|string $fields, Table|Association|string $repository, array $options = []) + { + $options += ['allowNullableNulls' => false]; + $this->_options = $options; + + $this->_fields = (array)$fields; + $this->_repository = $repository; + } + + /** + * 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, + * where the `repository` key is required. + * @throws \Cake\Database\Exception\DatabaseException When the rule refers to an undefined association. + * @return bool + */ + public function __invoke(EntityInterface $entity, array $options): bool + { + if (is_string($this->_repository)) { + /** @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, + $options['repository']::class, + )); + } + $repository = $table->getAssociation($this->_repository); + $this->_repository = $repository; + } + + $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(); + } + + if (!$entity->extract($this->_fields, true)) { + return true; + } + + if ($this->_fieldsAreNull($entity, $source)) { + return true; + } + + if ($this->_options['allowNullableNulls']) { + $schema = $source->getSchema(); + foreach ($fields as $i => $field) { + if ($schema->hasColumn($field) && $schema->isNullable($field) && $entity->get($field) === null) { + unset($bindingKey[$i], $fields[$i]); + } + } + } + + $primary = array_map( + fn(string $key) => $target->aliasField($key) . ' IS', + $bindingKey, + ); + $conditions = array_combine( + $primary, + $entity->extract($fields), + ); + + return $target->exists($conditions); + } + + /** + * 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(EntityInterface $entity, Table $source): bool + { + $nulls = 0; + $schema = $source->getSchema(); + foreach ($this->_fields as $field) { + if ($schema->hasColumn($field) && $schema->isNullable($field) && $entity->get($field) === null) { + $nulls++; + } + } + + return $nulls === count($this->_fields); + } +} 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 new file mode 100644 index 00000000000..5c3d4ad421e --- /dev/null +++ b/src/ORM/Rule/IsUnique.php @@ -0,0 +1,110 @@ + + */ + protected array $_fields; + + /** + * The unique check options + * + * @var array + */ + protected array $_options = [ + 'allowMultipleNulls' => true, + ]; + + /** + * Constructor. + * + * ### Options + * + * - `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 options for unique checks. + */ + public function __construct(array $fields, array $options = []) + { + $this->_fields = $fields; + $this->_options = $options + $this->_options; + } + + /** + * Performs the uniqueness check + * + * @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, + * @return bool + */ + public function __invoke(EntityInterface $entity, array $options): bool + { + if (!$entity->extract($this->_fields, true)) { + return true; + } + + $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)$repository->getPrimaryKey(); + $keys = $this->_alias($alias, $entity->extract($keys)); + if (Hash::filter($keys)) { + $conditions['NOT'] = $keys; + } + } + + return !$repository->exists($conditions); + } + + /** + * Add a model alias to all the keys in a set of conditions. + * + * @param string $alias The alias to add. + * @param array $conditions The conditions to alias. + * @return array + */ + protected function _alias(string $alias, array $conditions): array + { + $aliased = []; + foreach ($conditions as $key => $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 new file mode 100644 index 00000000000..06722848a2f --- /dev/null +++ b/src/ORM/Rule/ValidCount.php @@ -0,0 +1,61 @@ +_field = $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. + * @return bool True if successful, else false. + */ + public function __invoke(EntityInterface $entity, array $options): bool + { + $value = $entity->{$this->_field}; + if (!is_array($value) && !$value instanceof Countable) { + return false; + } + + return Validation::comparison(count($value), $options['operator'], $options['count']); + } +} diff --git a/src/ORM/RulesChecker.php b/src/ORM/RulesChecker.php new file mode 100644 index 00000000000..b41553f7c32 --- /dev/null +++ b/src/ORM/RulesChecker.php @@ -0,0 +1,344 @@ +add($rules->isUnique(['email'], 'The email should be unique')); + * ``` + * + * ### 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 \Cake\Datasource\RuleInvoker + */ + public function isUnique(array $fields, array|string|null $message = null): RuleInvoker + { + $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'); + } else { + $message = 'This value is already in use'; + } + } + + $errorField = current($fields); + + return $this->_addError(new IsUnique($fields, $options), '_isUnique', compact('errorField', 'message')); + } + + /** + * Returns a callable that can be used as a rule for checking that the values + * extracted from the entity to check exist as the primary key in another table. + * + * This is useful for enforcing foreign key integrity checks. + * + * ### Example: + * + * ``` + * $rules->add($rules->existsIn('author_id', 'Authors', 'Invalid Author')); + * + * $rules->add($rules->existsIn('site_id', new SitesTable(), 'Invalid Site')); + * ``` + * + * Available $options are error 'message' and 'allowNullableNulls' flag. + * 'message' sets a custom error message. + * Set 'allowNullableNulls' to true to accept composite foreign keys where one or more nullable columns are null. + * + * @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 \Cake\Datasource\RuleInvoker + */ + 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]; + $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 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. + * + * @param string $field The field to check the count on. + * @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 \Cake\Datasource\RuleInvoker + */ + 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]); + } else { + $message = sprintf('The count does not match %s%d', $operator, $count); + } + } + + $errorField = $field; + + return $this->_addError( + new ValidCount($field), + '_validCount', + compact('count', 'operator', 'errorField', 'message'), + ); + } +} diff --git a/src/ORM/Table.php b/src/ORM/Table.php new file mode 100644 index 00000000000..688f7d4a84a --- /dev/null +++ b/src/ORM/Table.php @@ -0,0 +1,3331 @@ +findByUsername('mark'); + * ``` + * + * You can also combine conditions on multiple fields using either `Or` or `And`: + * + * ``` + * $query = $users->findByUsernameOrEmail('mark', 'mark@example.org'); + * ``` + * + * ### Bulk updates/deletes + * + * You can use Table::updateAll() and Table::deleteAll() to do bulk updates/deletes. + * You should be aware that events will *not* be fired for bulk updates/deletes. + * + * ### Events + * + * Table objects emit several events during as life-cycle hooks during find, delete and save + * operations. All events use the CakePHP event package: + * + * - `Model.beforeFind` Fired before each find operation. By stopping the event and + * supplying a return value you can bypass the find operation entirely. Any + * changes done to the $query instance will be retained for the rest of the find. The + * `$primary` parameter indicates whether this is the root query, or an + * associated query. + * + * - `Model.buildValidator` Allows listeners to modify validation rules + * for the provided named validator. + * + * - `Model.buildRules` Allows listeners to modify the rules checker by adding more rules. + * Behaviors or custom listeners can subscribe to this event. For tables you don't + * need to subscribe to this event, simply override the `Table::buildRules()` method. + * + * - `Model.beforeRules` Fired before an entity is validated using the rules checker. + * By stopping this event, you can return the final value of the rules checking operation. + * + * - `Model.afterRules` Fired after the rules have been checked on the entity. By + * stopping this event, you can return the final value of the rules checking operation. + * + * - `Model.beforeSave` 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. + * + * - `Model.afterSave` Fired after an entity is saved. + * + * - `Model.afterSaveCommit` 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. + * + * - `Model.beforeDelete` Fired before an entity is deleted. By stopping this + * event you will abort the delete operation. + * + * - `Model.afterDelete` Fired after an entity has been deleted. + * + * ### Callbacks + * + * You can subscribe to the events listed above in your table classes by implementing the + * lifecycle methods below: + * + * - `beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options, boolean $primary)` + * - `beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options)` + * - `afterMarshal(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * - `buildValidator(EventInterface $event, Validator $validator, string $name)` + * - `beforeRules(EventInterface $event, EntityInterface $entity, ArrayObject $options, string $operation)` + * - `afterRules(EventInterface $event, EntityInterface $entity, ArrayObject $options, bool $result, string $operation)` + * - `beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * - `afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * - `afterSaveCommit(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * - `beforeDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * - `afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * - `afterDeleteCommit(EventInterface $event, EntityInterface $entity, ArrayObject $options)` + * + * @see \Cake\Event\EventManager for reference on the events system. + * @link https://book.cakephp.org/5/en/orm/table-objects.html#event-list + * @template TBehaviors of array = array{} + * @template TEntity of \Cake\Datasource\EntityInterface = \Cake\Datasource\EntityInterface + * @implements \Cake\Event\EventDispatcherInterface<\Cake\ORM\Table> + */ +class Table implements RepositoryInterface, EventListenerInterface, EventDispatcherInterface, ValidatorAwareInterface +{ + /** + * @use \Cake\Event\EventDispatcherTrait<\Cake\ORM\Table> + */ + 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 + */ + public const VALIDATOR_PROVIDER_NAME = 'table'; + + /** + * The name of the event dispatched when a validator has been built. + * + * @var string + */ + public const BUILD_VALIDATOR_EVENT = 'Model.buildValidator'; + + /** + * The rules class name that is used. + * + * @var class-string<\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|null + */ + 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|null + */ + protected ?string $_alias = null; + + /** + * Connection instance + * + * @var \Cake\Database\Connection|null + */ + protected ?Connection $_connection = null; + + /** + * The schema object containing a description of this table fields + * + * @var \Cake\Database\Schema\TableSchemaInterface|null + */ + protected ?TableSchemaInterface $_schema = null; + + /** + * The name of the field that represents the primary key in the table + * + * @var array|string|null + */ + protected array|string|null $_primaryKey = null; + + /** + * The name of the field that represents a human-readable representation of a row + * + * @var array|string|null + */ + protected array|string|null $_displayField = null; + + /** + * The associations container for this Table. + * + * @var \Cake\ORM\AssociationCollection + */ + protected AssociationCollection $_associations; + + /** + * BehaviorRegistry for this table + * + * @var \Cake\ORM\BehaviorRegistry + */ + protected BehaviorRegistry $_behaviors; + + /** + * The name of the class that represent a single row for this table + * + * @var string|null + * @phpstan-var class-string|null + */ + protected ?string $_entityClass = null; + + /** + * Registry key used to create this table object + * + * @var string|null + */ + protected ?string $_registryAlias = null; + + protected QueryFactory $queryFactory; + + /** + * Initializes a new instance + * + * The $config array understands the following keys: + * + * - table: Name of the database table to represent + * - alias: Alias to be assigned to this table (default to table name) + * - 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\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. + * - associations: An AssociationCollection instance. + * - validator: A Validator instance which is assigned as the "default" + * 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. + */ + public function __construct(array $config = []) + { + $methodConfigs = [ + 'registryAlias', + 'table', + 'alias', + 'connection', + 'schema', + 'entityClass', + ]; + foreach ($methodConfigs as $cfg) { + if (isset($config[$cfg])) { + $this->{'set' . $cfg}($config[$cfg]); + } + } + 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 = $config['eventManager'] ?? new EventManager(); + $this->_behaviors = $config['behaviors'] ?? new BehaviorRegistry(); + $this->_behaviors->setTable($this); + $this->_associations = $config['associations'] ?? new AssociationCollection(); + $this->queryFactory = $config['queryFactory'] ?? new QueryFactory(); + + $this->initialize($config); + + $this->getEventManager()->on($this); + $this->dispatchEvent('Model.initialize'); + } + + /** + * Get the default connection name. + * + * This method is used to get the fallback connection name if an + * instance is created through the TableLocator without a connection. + * + * @return string + * @see \Cake\ORM\Locator\TableLocator::get() + */ + public static function defaultConnectionName(): string + { + return 'default'; + } + + /** + * Initialize a table instance. Called after the constructor. + * + * You can use this method to define associations, attach behaviors + * define validation and do any other initialization logic you need. + * + * ``` + * public function initialize(array $config) + * { + * $this->belongsTo('Users'); + * $this->belongsToMany('Tagging.Tags'); + * $this->setPrimaryKey('something_else'); + * } + * ``` + * + * @param array $config Configuration options passed to the constructor + * @return void + */ + 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(string $table) + { + $this->_table = $table; + + return $this; + } + + /** + * Returns the database table name. + * + * This can include the database schema name if set using `setTable()`. + * + * @return string + */ + public function getTable(): string + { + if ($this->_table === null) { + $table = namespaceSplit(static::class); + $table = substr((string)end($table), 0, -5) ?: $this->_alias; + if (!$table) { + throw new CakeException( + 'You must specify either the `alias` or the `table` option for the constructor.', + ); + } + $this->_table = Inflector::underscore($table); + } + + return $this->_table; + } + + /** + * Sets the table alias. + * + * @param string $alias Table alias + * @return $this + */ + public function setAlias(string $alias) + { + $this->_alias = $alias; + + return $this; + } + + /** + * Returns the table alias. + * + * @return string + */ + public function getAlias(): string + { + if ($this->_alias === null) { + $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; + } + + /** + * Alias a field with the table's current alias. + * + * 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(string $field): string + { + if (str_contains($field, '.')) { + return $field; + } + + return $this->getAlias() . '.' . $field; + } + + /** + * Sets the table registry key used to create this table instance. + * + * @param string $registryAlias The key used to access this object. + * @return $this + */ + public function setRegistryAlias(string $registryAlias) + { + $this->_registryAlias = $registryAlias; + + return $this; + } + + /** + * Returns the table registry key used to create this table instance. + * + * @return string + */ + public function getRegistryAlias(): string + { + return $this->_registryAlias ??= $this->getAlias(); + } + + /** + * Sets the connection instance. + * + * @param \Cake\Database\Connection $connection The connection instance + * @return $this + */ + public function setConnection(Connection $connection) + { + $this->_connection = $connection; + + return $this; + } + + /** + * Returns the connection instance. + * + * @return \Cake\Database\Connection + */ + public function getConnection(): Connection + { + if (!$this->_connection) { + $connection = ConnectionManager::get(static::defaultConnectionName()); + assert($connection instanceof Connection); + $this->_connection = $connection; + } + + return $this->_connection; + } + + /** + * Returns the schema table object describing this table's properties. + * + * @return \Cake\Database\Schema\TableSchemaInterface + */ + public function getSchema(): TableSchemaInterface + { + if ($this->_schema === null) { + $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 TableSchemaInterface will be constructed + * out of it and used as the schema for this table. + * + * @param \Cake\Database\Schema\TableSchemaInterface|array $schema Schema to be used for this table + * @return $this + */ + public function setSchema(TableSchemaInterface|array $schema) + { + if (is_array($schema)) { + $constraints = []; + + if (isset($schema['_constraints'])) { + $constraints = $schema['_constraints']; + unset($schema['_constraints']); + } + + $schema = $this->getConnection()->getWriteDriver()->newTableSchema($this->getTable(), $schema); + + foreach ($constraints as $name => $value) { + $schema->addConstraint($name, $value); + } + } + + $this->_schema = $schema; + if (Configure::read('debug')) { + $this->checkAliasLengths(); + } + + return $this; + } + + /** + * Checks if all table name + column name combinations used for + * queries fit into the max length allowed by database driver. + * + * @return void + * @throws \Cake\Database\Exception\DatabaseException When an alias combination is too long + */ + protected function checkAliasLengths(): void + { + if ($this->_schema === null) { + throw new DatabaseException(sprintf( + 'Unable to check max alias lengths for `%s` without schema.', + $this->getAlias(), + )); + } + + $maxLength = $this->getConnection()->getWriteDriver()->getMaxAliasLength(); + if ($maxLength === null) { + return; + } + + $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.', + ); + } + } + } + + /** + * Test to see if a Table has a specific field/column. + * + * Delegates to the schema object and checks for column presence + * using the Schema\Table instance. + * + * @param string $field The field to check for. + * @return bool True if the field exists, false if it does not. + */ + public function hasField(string $field): bool + { + return $this->getSchema()->getColumn($field) !== null; + } + + /** + * Sets the primary key field name. + * + * @param array|string $key Sets a new name to be used as primary key + * @return $this + */ + public function setPrimaryKey(array|string $key) + { + $this->_primaryKey = $key; + + return $this; + } + + /** + * Returns the primary key field name. + * + * @return array|string + */ + public function getPrimaryKey(): array|string + { + if ($this->_primaryKey === null) { + $key = $this->getSchema()->getPrimaryKey(); + if (count($key) === 1) { + $key = $key[0]; + } + $this->_primaryKey = $key; + } + + return $this->_primaryKey; + } + + /** + * Sets the display field. + * + * @param array|string $field Name to be used as display field. + * @return $this + */ + public function setDisplayField(array|string $field) + { + $this->_displayField = $field; + + return $this; + } + + /** + * Returns the display field. + * + * @return array|string|null + */ + public function getDisplayField(): array|string|null + { + if ($this->_displayField !== null) { + return $this->_displayField; + } + + $schema = $this->getSchema(); + foreach (['title', 'name', 'label'] as $field) { + if ($schema->hasColumn($field)) { + return $this->_displayField = $field; + } + } + + 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->_displayField = $this->getPrimaryKey(); + } + + /** + * Returns the class used to hydrate rows for this table. + * + * @return class-string + */ + public function getEntityClass(): string + { + if (!$this->_entityClass) { + /** @var class-string $default */ + $default = Entity::class; + $self = static::class; + $parts = explode('\\', $self); + + if ($self === self::class || count($parts) < 3) { + return $this->_entityClass = $default; + } + + $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]); + } + + $this->_entityClass = $class; + } + + return $this->_entityClass; + } + + /** + * Sets the class used to hydrate rows for this table. + * + * @param string $name The name of the class to use + * @throws \Cake\ORM\Exception\MissingEntityException when the entity class cannot be found + * @return $this + */ + public function setEntityClass(string $name) + { + /** @var class-string|null $class */ + $class = App::className($name, 'Model/Entity'); + if ($class === null) { + throw new MissingEntityException([$name]); + } + + $this->_entityClass = $class; + + return $this; + } + + /** + * Add a behavior. + * + * Adds a behavior to this table's behavior collection. Behaviors + * provide an easy way to create horizontally re-usable features + * that can provide trait like functionality, and allow for events + * to be listened to. + * + * Example: + * + * Load a behavior, with some settings. + * + * ``` + * $this->addBehavior('Tree', ['parent' => 'parentId']); + * ``` + * + * 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. + * @return $this + * @throws \RuntimeException If a behavior is being reloaded. + * @see \Cake\ORM\Behavior + */ + 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. + * + * Example: + * + * Remove a behavior from this table. + * + * ``` + * $this->removeBehavior('Tree'); + * ``` + * + * @param string $name The alias that the behavior was added with. + * @return $this + * @see \Cake\ORM\Behavior + */ + public function removeBehavior(string $name) + { + $this->_behaviors->unload($name); + + return $this; + } + + /** + * Returns the behavior registry for this table. + * + * @return \Cake\ORM\BehaviorRegistry The BehaviorRegistry instance. + */ + public function behaviors(): BehaviorRegistry + { + return $this->_behaviors; + } + + /** + * Get a behavior from the registry. + * + * @param string $name The behavior alias to get from the registry. + * @return \Cake\ORM\Behavior + * @template TName of string + * @phpstan-param TName $name The behavior alias to get from the registry. + * @phpstan-return (TName is key-of ? TBehaviors[TName] : \Cake\ORM\Behavior) + * @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 the behavior exists. + */ + public function hasBehavior(string $name): bool + { + return $this->_behaviors->has($name); + } + + /** + * 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. + * @return \Cake\ORM\Association|null Either the association or null. + */ + protected function findAssociation(string $name): ?Association + { + 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 The collection of association objects. + */ + public function associations(): AssociationCollection + { + return $this->_associations; + } + + /** + * Setup multiple associations. + * + * It takes an array containing set of table names indexed by association type + * as argument: + * + * ``` + * $this->Posts->addAssociations([ + * 'belongsTo' => [ + * 'Users' => ['className' => 'App\Model\Table\UsersTable'] + * ], + * 'hasMany' => ['Comments'], + * 'belongsToMany' => ['Tags'] + * ]); + * ``` + * + * Each association type accepts multiple associations where the keys + * 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) + * @return $this + * @see \Cake\ORM\Table::belongsTo() + * @see \Cake\ORM\Table::hasOne() + * @see \Cake\ORM\Table::hasMany() + * @see \Cake\ORM\Table::belongsToMany() + */ + public function addAssociations(array $params) + { + foreach ($params as $assocType => $tables) { + foreach ($tables as $associated => $options) { + if (is_int($associated)) { + $associated = $options; + $options = []; + } + $this->{$assocType}($associated, $options); + } + } + + return $this; + } + + /** + * Creates a new BelongsTo association between this table and a target + * table. A "belongs to" association is a N-1 relationship where this table + * is the N side, and where there is a single associated record in the target + * table for each one in this table. + * + * Target table can be inferred by its name, which is provided in the + * first argument, or you can either pass the to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target table object + * - targetTable: An instance of a table object to be used as the target table + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - conditions: array with a list of conditions to filter the join with + * - joinType: The type of join to be used (e.g. INNER) + * - strategy: The loading strategy to use. 'join' and 'select' are supported. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. When the strategy is 'join', only the fields, containments, + * and where conditions will be used from the finder. + * + * This method will return the association object that was built. + * + * @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<\Cake\ORM\Table> + */ + public function belongsTo(string $associated, array $options = []): BelongsTo + { + $options += ['sourceTable' => $this]; + + /** @var \Cake\ORM\Association\BelongsTo<\Cake\ORM\Table> */ + return $this->_associations->load(BelongsTo::class, $associated, $options); + } + + /** + * Creates a new HasOne association between this table and a target + * table. A "has one" association is a 1-1 relationship. + * + * Target table can be inferred by its name, which is provided in the + * first argument, or you can either pass the class name to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target table object + * - targetTable: An instance of a table object to be used as the target table + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - dependent: Set to true if you want CakePHP to cascade deletes to the + * associated table when an entity is removed on this table. The delete operation + * on the associated table will not cascade further. To get recursive cascades enable + * `cascadeCallbacks` as well. Set to false if you don't want CakePHP to remove + * associated data, or when you are using database constraints. + * - cascadeCallbacks: Set to true if you want CakePHP to fire callbacks on + * cascaded deletes. If false the ORM will use deleteAll() to remove data. + * When true records will be loaded and then deleted. + * - conditions: array with a list of conditions to filter the join with + * - joinType: The type of join to be used (e.g. LEFT) + * - strategy: The loading strategy to use. 'join' and 'select' are supported. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. When the strategy is 'join', only the fields, containments, + * and where conditions will be used from the finder. + * + * This method will return the association object that was built. + * + * @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<\Cake\ORM\Table> + */ + public function hasOne(string $associated, array $options = []): HasOne + { + $options += ['sourceTable' => $this]; + + /** @var \Cake\ORM\Association\HasOne<\Cake\ORM\Table> */ + return $this->_associations->load(HasOne::class, $associated, $options); + } + + /** + * Creates a new HasMany association between this table and a target + * table. A "has many" association is a 1-N relationship. + * + * Target table can be inferred by its name, which is provided in the + * first argument, or you can either pass the class name to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target table object + * - targetTable: An instance of a table object to be used as the target table + * - foreignKey: The name of the field to use as foreign key, if false none + * will be used + * - dependent: Set to true if you want CakePHP to cascade deletes to the + * associated table when an entity is removed on this table. The delete operation + * on the associated table will not cascade further. To get recursive cascades enable + * `cascadeCallbacks` as well. Set to false if you don't want CakePHP to remove + * associated data, or when you are using database constraints. + * - cascadeCallbacks: Set to true if you want CakePHP to fire callbacks on + * cascaded deletes. If false the ORM will use deleteAll() to remove data. + * When true records will be loaded and then deleted. + * - conditions: array with a list of conditions to filter the join with + * - sort: The order in which results for this association should be returned + * - saveStrategy: Either 'append' or 'replace'. When 'append' the current records + * are appended to any records in the database. When 'replace' associated records + * not in the current set will be removed. If the foreign key is a null able column + * or if `dependent` is true records will be orphaned. + * - strategy: The strategy to be used for selecting results Either 'select' + * or 'subquery'. If subquery is selected the query used to return results + * in the source table will be used as conditions for getting rows in the + * target table. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. + * + * This method will return the association object that was built. + * + * @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<\Cake\ORM\Table> + */ + public function hasMany(string $associated, array $options = []): HasMany + { + $options += ['sourceTable' => $this]; + + /** @var \Cake\ORM\Association\HasMany<\Cake\ORM\Table> */ + return $this->_associations->load(HasMany::class, $associated, $options); + } + + /** + * Creates a new BelongsToMany association between this table and a target + * table. A "belongs to many" association is a M-N relationship. + * + * Target table can be inferred by its name, which is provided in the + * first argument, or you can either pass the class name to be instantiated or + * an instance of it directly. + * + * The options array accept the following keys: + * + * - className: The class name of the target table object. + * - targetTable: An instance of a table object to be used as the target table. + * - foreignKey: The name of the field to use as foreign key. + * - targetForeignKey: The name of the field to use as the target foreign key. + * - joinTable: The name of the table representing the link between the two + * - through: If you choose to use an already instantiated link table, set this + * key to a configured Table instance containing associations to both the source + * and target tables in this association. + * - dependent: Set to false, if you do not want junction table records removed + * when an owning record is removed. + * - cascadeCallbacks: Set to true if you want CakePHP to fire callbacks on + * cascaded deletes. If false the ORM will use deleteAll() to remove data. + * When true join/junction table records will be loaded and then deleted. + * - conditions: array with a list of conditions to filter the join with. + * - sort: The order in which results for this association should be returned. + * - strategy: The strategy to be used for selecting results Either 'select' + * or 'subquery'. If subquery is selected the query used to return results + * in the source table will be used as conditions for getting rows in the + * target table. + * - saveStrategy: Either 'append' or 'replace'. Indicates the mode to be used + * for saving associated entities. The former will only create new links + * between both side of the relation and the latter will do a wipe and + * replace to create the links between the passed entities when saving. + * - strategy: The loading strategy to use. 'select' and 'subquery' are supported. + * - finder: The finder method to use when loading records from this association. + * Defaults to 'all'. + * + * This method will return the association object that was built. + * + * @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<\Cake\ORM\Table> + */ + public function belongsToMany(string $associated, array $options = []): BelongsToMany + { + $options += ['sourceTable' => $this]; + + /** @var \Cake\ORM\Association\BelongsToMany<\Cake\ORM\Table> */ + return $this->_associations->load(BelongsToMany::class, $associated, $options); + } + + /** + * 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, following special named arguments are recognized which are + * used as select query options: + * + * - fields + * - conditions + * - order + * - limit + * - offset + * - page + * - group + * - having + * - contain + * - join + * + * ### Usage + * + * ``` + * $query = $articles->find('all', + * conditions: ['published' => 1], + * limit: 10, + * contain: ['Users', 'Comments'] + * ); + * ``` + * + * Using the builder interface: + * + * ``` + * $query = $articles->find() + * ->where(['published' => 1]) + * ->limit(10) + * ->contain(['Users', 'Comments']); + * ``` + * + * ### Calling finders + * + * The find() method is the entry point for custom finder methods. + * You can invoke a finder by specifying the type. + * + * This will invoke the `findPublished` method: + * + * ``` + * $query = $articles->find('published'); + * ``` + * + * ## 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); + * ``` + * + * @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(string $type = 'all', mixed ...$args): SelectQuery + { + /** @var \Cake\ORM\Query\SelectQuery $query */ + $query = $this->callFinder($type, $this->selectQuery(), ...$args); + + return $query; + } + + /** + * Returns the query as passed. + * + * By default findAll() applies no query clauses, you can override this + * method in subclasses to modify how `find('all')` works. + * + * @param \Cake\ORM\Query\SelectQuery $query The query to find with + * @return \Cake\ORM\Query\SelectQuery The query builder + */ + public function findAll(SelectQuery $query): SelectQuery + { + return $query; + } + + /** + * Sets up a query object so results appear as an indexed array, useful for any + * place where you would want a list such as for populating input select boxes. + * + * 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 + * field as value. + * + * The results of this finder will be in the following form: + * + * ``` + * [ + * 1 => 'value for id 1', + * 2 => 'value for id 2', + * 4 => 'value for id 4' + * ] + * ``` + * + * 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'); + * ``` + * + * 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'); + * ``` + * + * When using a `groupField` results will be returned in this format: + * + * ``` + * [ + * 'group_1' => [ + * 1 => 'value for id 1', + * 2 => 'value for id 2', + * ] + * 'group_2' => [ + * 4 => 'value for id 4' + * ] + * ] + * ``` + * + * @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)$keyField, + (array)$valueField, + (array)$groupField, + ); + $columns = $this->getSchema()->columns(); + if (count($fields) === count(array_intersect($fields, $columns))) { + $query->select($fields); + } + } + + $options = $this->_setFieldMatchers( + compact('keyField', 'valueField', 'groupField', 'valueSeparator'), + ['keyField', 'valueField', 'groupField'], + ); + + return $query->formatResults(fn(CollectionInterface $results) => $results->combine( + $options['keyField'], + $options['valueField'], + $options['groupField'], + )); + } + + /** + * Results for this finder will be a nested array, and is appropriate if you want + * to use the parent_id field of your model data to build nested results. + * + * Values belonging to a parent row based on their parent_id value will be + * recursively nested inside the parent row values using the `children` property + * + * 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 `keyField`, `parentField` or `nestingKey` + * arguments: + * + * ``` + * $table->find('threaded', keyField: 'id', parentField: 'ancestor_id', nestingKey: 'children'); + * ``` + * + * @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( + SelectQuery $query, + Closure|array|string|null $keyField = null, + Closure|array|string $parentField = 'parent_id', + string $nestingKey = 'children', + ): SelectQuery { + $keyField ??= $this->getPrimaryKey(); + + $options = $this->_setFieldMatchers(compact('keyField', 'parentField'), ['keyField', 'parentField']); + + return $query->formatResults(fn(CollectionInterface $results) => $results->nest( + $options['keyField'], + $options['parentField'], + $nestingKey, + )); + } + + /** + * Out of an options array, check if the keys described in `$keys` are arrays + * and change the values for closures that will concatenate the each of the + * properties in the value array when passed a row. + * + * 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 + * the associated value + * @return array + */ + protected function _setFieldMatchers(array $options, array $keys): array + { + foreach ($keys as $field) { + if (!is_array($options[$field])) { + continue; + } + + if (count($options[$field]) === 1) { + $options[$field] = current($options[$field]); + continue; + } + + $fields = $options[$field]; + $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($glue, $matches); + }; + } + + return $options; + } + + /** + * {@inheritDoc} + * + * ### Usage + * + * Get an article and some relationships: + * + * ``` + * $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. + * @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; + } + if (!is_array($primaryKey)) { + $primaryKey = [$primaryKey]; + } + if (count($key) !== count($primaryKey)) { + $primaryKey = $primaryKey ?: [null]; + $primaryKey = array_map(function ($key) { + return var_export($key, true); + }, $primaryKey); + + throw new InvalidPrimaryKeyException(sprintf( + 'Record not found in table `%s` with primary key `[%s]`.', + $this->getTable(), + implode(', ', $primaryKey), + )); + } + $conditions = array_combine($key, $primaryKey); + + 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, ...$args)->where($conditions); + + if ($cache) { + if (!$cacheKey) { + $cacheKey = sprintf( + 'get-%s-%s-%s', + $this->getConnection()->configName(), + $this->getTable(), + json_encode($primaryKey, JSON_THROW_ON_ERROR), + ); + } + $query->cache($cacheKey, $cache); + } + + /** @var TEntity $entity */ + $entity = $query->firstOrFail(); + + return $entity; + } + + /** + * Handles the logic executing of a worker inside a transaction. + * + * @param callable $worker The worker that will run inside the transaction. + * @param bool $atomic Whether to execute the worker inside a database transaction. + * @return mixed + */ + protected function _executeTransaction(callable $worker, bool $atomic = true): mixed + { + if ($atomic) { + return $this->getConnection()->transactional($worker(...)); + } + + return $worker(); + } + + /** + * Checks if the caller would have executed a commit on a transaction. + * + * @param bool $atomic True if an atomic transaction was used. + * @param bool $primary True if a primary was used. + * @return bool Returns true if a transaction was committed. + */ + protected function _transactionCommitted(bool $atomic, bool $primary): bool + { + return !$this->getConnection()->inTransaction() && ($atomic || $primary); + } + + /** + * Finds an existing record or creates a new one. + * + * A find() will be done to locate an existing record using the attributes + * defined in $search. If records matches the conditions, the first record + * will be returned. + * + * If no record can be found, a new entity will be created + * with the $search properties. If a callback is provided, it will be + * called allowing you to define additional default values. The new + * 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\SelectQuery object passed + * as the $search parameter. Allowing you to customize the find results. + * + * ### Options + * + * The options array is passed to the save method with exception to the following keys: + * + * - atomic: Whether to execute the methods for find, save and callbacks inside a database + * transaction (default: true) + * - defaults: Whether to use the search criteria as default values for the new entity (default: true) + * + * @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|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 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 insert query + * + * @return \Cake\ORM\Query\InsertQuery + */ + public function insertQuery(): InsertQuery + { + return $this->queryFactory->insert($this); + } + + /** + * Creates a new update query + * + * @return \Cake\ORM\Query\UpdateQuery + */ + public function updateQuery(): UpdateQuery + { + return $this->queryFactory->update($this); + } + + /** + * Creates a new delete query + * + * @return \Cake\ORM\Query\DeleteQuery + */ + public function deleteQuery(): DeleteQuery + { + return $this->queryFactory->delete($this); + } + + /** + * Creates a new Query instance with field auto aliasing disabled. + * + * This is useful for subqueries. + * + * @return \Cake\ORM\Query\SelectQuery + */ + public function subquery(): SelectQuery + { + /** @var \Cake\ORM\Query\SelectQuery $query */ + $query = $this->queryFactory->select($this)->disableAutoAliasing(); + + return $query; + } + + /** + * 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( + QueryExpression|Closure|array|string $fields, + QueryExpression|Closure|array|string|null $conditions, + ): int { + $statement = $this->updateQuery() + ->set($fields) + ->where($conditions) + ->execute(); + + return $statement->rowCount(); + } + + /** + * 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(QueryExpression|Closure|array|string|null $conditions): int + { + $statement = $this->deleteQuery() + ->where($conditions) + ->execute(); + + return $statement->rowCount(); + } + + /** + * @inheritDoc + */ + public function exists(QueryExpression|Closure|array|string|null $conditions): bool + { + return (bool)count( + $this->find('all') + ->select(['existing' => 1]) + ->where($conditions) + ->limit(1) + ->disableHydration() + ->toArray(), + ); + } + + /** + * {@inheritDoc} + * + * ### Options + * + * The options array accepts the following keys: + * + * - atomic: Whether to execute the save and callbacks inside a database + * transaction (default: true) + * - 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 + * is marked as dirty. If an array, it will be interpreted as the list of associations + * 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 to check if the entity already exists, assuming that the + * entity is marked as not new, and the primary key has been set. + * + * ### Events + * + * When saving, this method will trigger four events: + * + * - Model.beforeRules: Will be triggered right before any rule checking is done + * for the passed entity if the `checkRules` key in $options is not set to false. + * Listeners will receive as arguments the entity, options array and the operation type. + * If the event is stopped the rules check result will be set to the result of the event itself. + * - Model.afterRules: Will be triggered right after the `checkRules()` method is + * called for the entity. Listeners will receive as arguments the entity, + * options array, the result of checking the rules and the operation type. + * If the event is stopped the checking result will be set to the result of + * the event itself. + * - Model.beforeSave: Will be triggered just before the list of fields to be + * persisted is calculated. It receives both the entity and the options as + * arguments. The options array is passed as an ArrayObject, so any changes in + * it will be reflected in every listener and remembered at the end of the event + * so it can be used for the rest of the save operation. Returning false in any + * of the listeners will abort the saving process. If the event is stopped + * using the event API, the event object's `result` property will be returned. + * This can be useful when having your own saving strategy implemented inside a + * listener. + * - Model.afterSave: Will be triggered after a successful insert or save, + * 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 committed + * for atomic save, listeners will receive the entity and the options array + * as arguments. + * + * This method will determine whether the passed entity needs to be + * inserted or updated in the database. It does that by checking the `isNew` + * method on the entity. If the entity to be saved returns a non-empty value from + * its `errors()` method, it will not be saved. + * + * ### Saving on associated tables + * + * This method will by default persist entities belonging to associated tables, + * whenever a dirty property matching the name of the property name set for an + * association in this table. It is possible to control what associations will + * be saved and to pass additional option for saving them. + * + * ``` + * // Only save the comments association + * $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 + * $companies->save($entity, [ + * 'associated' => [ + * 'Employees' => [ + * 'associated' => ['Addresses'], + * 'checkRules' => false + * ] + * ] + * ]); + * + * // Save no associations + * $articles->save($entity, ['associated' => false]); + * ``` + * + * @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, + '_cleanOnSuccess' => true, + ]); + + if ($entity->hasErrors((bool)$options['associated'])) { + return false; + } + + if ($entity->isNew() === false && !$entity->isDirty()) { + return $entity; + } + + $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']) { + if ($options['_cleanOnSuccess']) { + $entity->clean(); + $entity->setNew(false); + } + $entity->setSource($this->getRegistryAlias()); + } + } + + return $success; + } + + /** + * 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. + * + * @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, array $options = []): EntityInterface + { + $saved = $this->save($entity, $options); + if ($saved === false) { + throw new PersistenceFailedException($entity, ['save']); + } + + return $saved; + } + + /** + * 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|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(EntityInterface $entity, ArrayObject $options): EntityInterface|false + { + $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; + } + $entity->setNew(!$this->exists($conditions)); + } + + $mode = $entity->isNew() ? RulesChecker::CREATE : RulesChecker::UPDATE; + if ($options['checkRules'] && !$this->checkRules($entity, $mode, $options)) { + return false; + } + + $options['associated'] = $this->_associations->normalizeKeys($options['associated']); + $event = $this->dispatchEvent('Model.beforeSave', compact('entity', 'options')); + + if ($event->isStopped()) { + $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(), + ); + + if (!$saved && $options['atomic']) { + return false; + } + + $data = $entity->extract($this->getSchema()->columns(), true); + $isNew = $entity->isNew(); + + if ($isNew) { + $success = $this->_insert($entity, $data); + } else { + $success = $this->_update($entity, $data); + } + + if ($success) { + $success = $this->_onSaveSuccess($entity, $options); + } + + if (!$success && $isNew) { + $entity->unset($this->getPrimaryKey()); + $entity->setNew(true); + } + + return $success ? $entity : false; + } + + /** + * Handles the saving of children associations and executing the afterSave logic + * 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 + * @return bool True on success + * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction + * is aborted in the afterSave event. + */ + protected function _onSaveSuccess(EntityInterface $entity, ArrayObject $options): bool + { + $success = $this->_associations->saveChildren( + $this, + $entity, + $options['associated'], + ['_primary' => false] + $options->getArrayCopy(), + ); + + if (!$success && $options['atomic']) { + return false; + } + + $this->dispatchEvent('Model.afterSave', compact('entity', 'options')); + + if ($options['atomic'] && !$this->getConnection()->inTransaction()) { + throw new RolledbackTransactionException(['table' => static::class]); + } + + if (!$options['atomic'] && !$options['_primary']) { + $entity->clean(); + $entity->setNew(false); + $entity->setSource($this->getRegistryAlias()); + } + + return true; + } + + /** + * Auxiliary function to handle the insert of an entity's data in the table + * + * @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|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(EntityInterface $entity, array $data): EntityInterface|false + { + $primary = (array)$this->getPrimaryKey(); + if (!$primary) { + $msg = sprintf( + 'Cannot insert row in `%s` table, it has no primary key.', + $this->getTable(), + ); + throw new DatabaseException($msg); + } + $keys = array_fill(0, count($primary), null); + $id = (array)$this->_newId($primary) + $keys; + + // Generate primary keys preferring values in $data. + $primary = array_combine($primary, $id); + $primary = array_intersect_key($data, $primary) + $primary; + + $filteredKeys = array_filter($primary, function ($v) { + return $v !== null; + }); + $data += $filteredKeys; + + if (count($primary) > 1) { + $schema = $this->getSchema(); + foreach ($primary as $k => $v) { + if (!isset($data[$k]) && empty($schema->getColumn($k)['autoIncrement'])) { + $msg = 'Cannot insert row, some of the primary key values are missing. '; + $msg .= sprintf( + 'Got (%s), expecting (%s)', + implode(', ', $filteredKeys + $entity->extract(array_keys($primary))), + implode(', ', array_keys($primary)), + ); + throw new DatabaseException($msg); + } + } + } + + if (!$data) { + return false; + } + + $statement = $this->insertQuery()->insert(array_keys($data)) + ->values($data) + ->execute(); + + $success = false; + if ($statement->rowCount() !== 0) { + $success = $entity; + + // @phpstan-ignore function.alreadyNarrowedType (patch method available on EntityInterface) + if (method_exists($entity, 'patch')) { + $entity = $entity->patch($filteredKeys, ['guard' => false]); + } else { + $entity->set($filteredKeys, ['guard' => false]); + } + + $schema = $this->getSchema(); + $driver = $this->getConnection()->getWriteDriver(); + foreach ($primary as $key => $v) { + if (!isset($data[$key])) { + $id = $statement->lastInsertId($this->getTable(), $key); + $type = $schema->getColumnType($key); + assert($type !== null); + $entity->set($key, TypeFactory::build($type)->toPHP($id, $driver)); + break; + } + } + } + + return $success; + } + + /** + * Generate a primary key value for a new record. + * + * By default, this uses the type system to generate a new primary key + * value if possible. You can override this method if you have specific requirements + * for id generation. + * + * 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 string|null The primary key value when a single primary key is available, or null. + */ + protected function _newId(array $primary): ?string + { + if (!$primary || count($primary) > 1) { + return null; + } + $typeName = $this->getSchema()->getColumnType($primary[0]); + assert($typeName !== null); + $type = TypeFactory::build($typeName); + + return $type->newId(); + } + + /** + * Auxiliary function to handle the update of an entity's data in the table + * + * @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|false + * @throws \InvalidArgumentException When primary key data is missing. + */ + protected function _update(EntityInterface $entity, array $data): EntityInterface|false + { + $primaryColumns = (array)$this->getPrimaryKey(); + $primaryKey = $entity->extract($primaryColumns); + + $data = array_diff_key($data, $primaryKey); + 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 .= $entity::class . ' is missing ' . implode(', ', $primaryColumns); + throw new InvalidArgumentException($message); + } + + $statement = $this->updateQuery() + ->set($data) + ->where($primaryKey) + ->execute(); + + return $statement->errorCode() === '00000' ? $entity : false; + } + + /** + * 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; + } + } + + /** + * 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 Entities list. + * @throws \Exception + * @throws \Cake\ORM\Exception\PersistenceFailedException If an entity couldn't be saved. + */ + public function saveManyOrFail(iterable $entities, array $options = []): iterable + { + return $this->_saveMany($entities, $options); + } + + /** + * @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; + + /** @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->unset($this->getPrimaryKey()); + $entity->setNew(true); + } + } + }; + + /** @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; + } + + /** + * {@inheritDoc} + * + * For HasMany and HasOne associations records will be removed based on + * the dependent option. Join table records in BelongsToMany associations + * will always be removed. You can use the `cascadeCallbacks` option + * when defining associations to change how associated data is deleted. + * + * ### Options + * + * - `atomic` Defaults to true. When true the deletion happens within a transaction. + * - `checkRules` Defaults to true. Check deletion rules before deleting the record. + * + * ### Events + * + * - `Model.beforeDelete` Fired before the delete occurs. If stopped the delete + * will be aborted. Receives the event, entity, and options. + * - `Model.afterDelete` Fired after the delete has been successful. Receives + * the event, entity, and options. + * - `Model.afterDeleteCommit` Fired after the transaction is committed for + * an atomic delete. Receives the event, entity, and options. + * + * The options argument will be converted into an \ArrayObject instance + * 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, array $options = []): bool + { + $options = new ArrayObject($options + [ + 'atomic' => true, + 'checkRules' => true, + '_primary' => true, + ]); + + $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, + ]); + } + + 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 $options The options for the delete. + * @return true + * @throws \Cake\ORM\Exception\PersistenceFailedException + * @see \Cake\ORM\Table::delete() + */ + public function deleteOrFail(EntityInterface $entity, array $options = []): bool + { + $deleted = $this->delete($entity, $options); + if ($deleted === false) { + throw new PersistenceFailedException($entity, ['delete']); + } + + return true; + } + + /** + * Perform the delete operation. + * + * Will delete the entity provided. Will remove rows from any + * 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. + * @throws \InvalidArgumentException if there are no primary key values of the + * passed entity + * @return bool success + */ + protected function _processDelete(EntityInterface $entity, ArrayObject $options): bool + { + if ($entity->isNew()) { + return false; + } + + $primaryKey = (array)$this->getPrimaryKey(); + if (!$entity->has($primaryKey)) { + $msg = 'Deleting requires all primary key values.'; + throw new InvalidArgumentException($msg); + } + + if ($options['checkRules'] && !$this->checkRules($entity, RulesChecker::DELETE, $options)) { + return false; + } + + $event = $this->dispatchEvent('Model.beforeDelete', [ + 'entity' => $entity, + 'options' => $options, + ]); + + if ($event->isStopped()) { + return (bool)$event->getResult(); + } + + $success = $this->_associations->cascadeDelete( + $entity, + ['_primary' => false] + $options->getArrayCopy(), + ); + if (!$success) { + return $success; + } + + $statement = $this->deleteQuery() + ->where($entity->extract($primaryKey)) + ->execute(); + + if ($statement->rowCount() < 1) { + return false; + } + + $this->dispatchEvent('Model.afterDelete', [ + 'entity' => $entity, + 'options' => $options, + ]); + + return true; + } + + /** + * Returns true if the finder exists for the table + * + * @param string $type name of finder to check + * @return bool + */ + public function hasFinder(string $type): bool + { + $finder = 'find' . $type; + + return method_exists($this, $finder) || $this->_behaviors->hasFinder($type); + } + + /** + * Calls a finder method and applies it to the passed 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(string $type, SelectQuery $query, mixed ...$args): SelectQuery + { + $finder = 'find' . $type; + if (method_exists($this, $finder)) { + return $this->invokeFinder($this->{$finder}(...), $query, $args); + } + + if ($this->_behaviors->hasFinder($type)) { + return $this->_behaviors->callFinder($type, $query, ...$args); + } + + 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 findAllBy methods. + * + * @param string $method The method name that was fired. + * @param array $args List of arguments passed to the function. + * @return \Cake\ORM\Query\SelectQuery + * @throws \BadMethodCallException when there are missing arguments, or when + * and & or are combined. + */ + protected function _dynamicFinder(string $method, array $args): SelectQuery + { + $method = Inflector::underscore($method); + preg_match('/^find_([\w]+)_by_/', $method, $matches); + if (!$matches) { + // find_by_ is 8 characters. + $fields = substr($method, 8); + $findType = 'all'; + } else { + $fields = substr($method, strlen($matches[0])); + $findType = Inflector::variable($matches[1]); + } + $hasOr = str_contains($fields, '_or_'); + $hasAnd = str_contains($fields, '_and_'); + + $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), + )); + } + foreach ($fields as $field) { + $conditions[$this->aliasField($field)] = array_shift($args); + } + + return $conditions; + }; + + if ($hasOr && $hasAnd) { + throw new BadMethodCallException( + 'Cannot mix "and" & "or" in a magic finder. Use find() instead.', + ); + } + + if ($hasOr === false && $hasAnd === false) { + $conditions = $makeConditions([$fields], $args); + } elseif ($hasOr) { + $fields = explode('_or_', $fields); + $conditions = [ + 'OR' => $makeConditions($fields, $args), + ]; + } else { + $fields = explode('_and_', $fields); + $conditions = $makeConditions($fields, $args); + } + + return $this->find($findType, conditions: $conditions); + } + + /** + * Handles behavior delegation + dynamic finders. + * + * If your Table uses any behaviors you can call them as if + * they were on the table object. + * + * @param string $method name of the method to be invoked + * @param array $args List of arguments passed to the function + * @return mixed + * @throws \BadMethodCallException + */ + public function __call(string $method, array $args): mixed + { + if ($this->_behaviors->hasMethod($method)) { + return $this->_behaviors->call($method, $args); + } + if (preg_match('/^find(?:\w+)?By/', $method) > 0) { + return $this->_dynamicFinder($method, $args); + } + + throw new BadMethodCallException( + sprintf('Unknown method `%s` called on `%s`', $method, static::class), + ); + } + + /** + * Returns the association named after the passed value if exists, otherwise + * throws an exception. + * + * @param string $property the association name + * @return \Cake\ORM\Association + * @throws \Cake\Database\Exception\DatabaseException if no association with such name exists + */ + public function __get(string $property): Association + { + $association = $this->_associations->get($property); + if (!$association) { + throw new DatabaseException(sprintf( + 'Undefined property `%s`. ' . + 'You have not defined the `%s` association on `%s`.', + $property, + $property, + static::class, + )); + } + + return $association; + } + + /** + * Returns whether an association named after the passed value + * exists for this table. + * + * @param string $property the association name + * @return bool + */ + public function __isset(string $property): bool + { + return $this->_associations->has($property); + } + + /** + * Get the object used to marshal/convert array data into objects. + * + * Override this method if you want a table object to use custom + * marshaling logic. + * + * @return \Cake\ORM\Marshaller + * @see \Cake\ORM\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} + * + * By default all the associations on this table will be hydrated. You can + * limit which associations are built, or include deeper associations + * using the options parameter: + * + * ``` + * $article = $this->Articles->newEntity( + * $this->request->getData(), + * ['associated' => ['Tags', 'Comments.Users']] + * ); + * ``` + * + * You can limit fields that will be present in the constructed entity by + * passing the `fields` option, which is also accepted for associations: + * + * ``` + * $article = $this->Articles->newEntity($this->request->getData(), [ + * 'fields' => ['title', 'body', 'tags', 'comments'], + * 'associated' => ['Tags', 'Comments.Users' => ['fields' => 'username']] + * ] + * ); + * ``` + * + * 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: + * + * ``` + * $article = $this->Articles->newEntity( + * $this->request->getData(), + * ['accessibleFields' => ['protected_field' => true]] + * ); + * ``` + * + * By default, the data is validated before being passed to the new entity. In + * the case of invalid fields, those will not be present in the resulting object. + * The `validate` option can be used to disable validation on the passed data: + * + * ``` + * $article = $this->Articles->newEntity( + * $this->request->getData(), + * ['validate' => false] + * ); + * ``` + * + * You can also pass the name of the validator to use in the `validate` option. + * If `null` is passed to the first param of this function, no validation will + * be performed. + * + * 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(array $data, array $options = []): EntityInterface + { + $options['associated'] ??= $this->_associations->keys(); + + return $this->marshaller()->one($data, $options); + } + + /** + * {@inheritDoc} + * + * By default all the associations on this table will be hydrated. You can + * limit which associations are built, or include deeper associations + * using the options parameter: + * + * ``` + * $articles = $this->Articles->newEntities( + * $this->request->getData(), + * ['associated' => ['Tags', 'Comments.Users']] + * ); + * ``` + * + * You can limit fields that will be present in the constructed entities by + * passing the `fields` option, which is also accepted for associations: + * + * ``` + * $articles = $this->Articles->newEntities($this->request->getData(), [ + * '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 = []): array + { + $options['associated'] ??= $this->_associations->keys(); + + return $this->marshaller()->many($data, $options); + } + + /** + * {@inheritDoc} + * + * 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 entity by + * passing the `fields` option, which is also accepted for associations: + * + * ``` + * $article = $this->Articles->patchEntity($article, $this->request->getData(), [ + * '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: + * + * ``` + * $article = $this->patchEntity($article, $this->request->getData(),[ + * 'validate' => false + * ]); + * ``` + * + * You can use the `Model.beforeMarshal` event to modify request data + * before it is converted into entities. + * + * When patching scalar values (null/booleans/string/integer/float), if the property + * 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 = []): EntityInterface + { + $options['associated'] ??= $this->_associations->keys(); + + return $this->marshaller()->merge($entity, $data, $options); + } + + /** + * {@inheritDoc} + * + * 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 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 `fields` option, which is also accepted for associations: + * + * ``` + * $articles = $this->Articles->patchEntities($articles, $this->request->getData(), [ + * '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(iterable $entities, array $data, array $options = []): array + { + $options['associated'] ??= $this->_associations->keys(); + + return $this->marshaller()->mergeMany($entities, $data, $options); + } + + /** + * Validator method used to check the uniqueness of a value for a column. + * This is meant to be used with the validation API and not to be called + * directly. + * + * ### Example: + * + * ``` + * $validator->add('email', [ + * 'unique' => ['rule' => 'validateUnique', 'provider' => 'table'] + * ]) + * ``` + * + * Unique validation can be scoped to the value of another column: + * + * ``` + * $validator->add('email', [ + * 'unique' => [ + * 'rule' => ['validateUnique', ['scope' => 'site_id']], + * 'provider' => 'table' + * ] + * ]); + * ``` + * + * In the above example, the email uniqueness will be scoped to only rows having + * the same site_id. Scoping will only be used if the scoping field is present in + * 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. + * 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(mixed $value, array $options = [], ?array $context = null): bool + { + if ($context === null) { + $context = $options; + } + $entity = new ($this->getEntityClass())( + $context['data'], + [ + 'useSetters' => false, + 'markNew' => $context['newRecord'], + 'source' => $this->getRegistryAlias(), + ], + ); + $fields = array_merge( + [$context['field']], + isset($options['scope']) ? (array)$options['scope'] : [], + ); + $values = $entity->extract($fields); + foreach ($values as $field) { + if ($field !== null && !is_scalar($field)) { + return false; + } + } + $class = static::IS_UNIQUE_CLASS; + $rule = new $class($fields, $options); + + return $rule($entity, ['repository' => $this]); + } + + /** + * Get the Model callbacks this table is interested in. + * + * By implementing the conventional methods a table class is assumed + * to be interested in the related event. + * + * Override this method if you need to add non-conventional event listeners. + * Or if you want you table to listen to non-standard events. + * + * The conventional method map is: + * + * - Model.beforeMarshal => beforeMarshal + * - Model.afterMarshal => afterMarshal + * - Model.buildValidator => buildValidator + * - Model.beforeFind => beforeFind + * - Model.beforeSave => beforeSave + * - Model.afterSave => afterSave + * - Model.afterSaveCommit => afterSaveCommit + * - Model.beforeDelete => beforeDelete + * - Model.afterDelete => afterDelete + * - Model.afterDeleteCommit => afterDeleteCommit + * - Model.beforeRules => beforeRules + * - Model.afterRules => afterRules + * + * @return array + */ + public function implementedEvents(): array + { + $eventMap = [ + 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.afterMarshal' => 'afterMarshal', + 'Model.buildValidator' => 'buildValidator', + 'Model.beforeFind' => 'beforeFind', + 'Model.beforeSave' => 'beforeSave', + 'Model.afterSave' => 'afterSave', + 'Model.afterSaveCommit' => 'afterSaveCommit', + 'Model.beforeDelete' => 'beforeDelete', + 'Model.afterDelete' => 'afterDelete', + 'Model.afterDeleteCommit' => 'afterDeleteCommit', + 'Model.beforeRules' => 'beforeRules', + 'Model.afterRules' => 'afterRules', + ]; + $events = []; + + foreach ($eventMap as $event => $method) { + if (!method_exists($this, $method)) { + continue; + } + $events[$event] = $method; + } + + return $events; + } + + /** + * {@inheritDoc} + * + * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. + * @return \Cake\ORM\RulesChecker + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + return $rules; + } + + /** + * 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 + * appropriate properties. + * + * ### Example: + * + * ``` + * $user = $usersTable->get(1); + * $user = $usersTable->loadInto($user, ['Articles.Tags', 'Articles.Comments']); + * echo $user->articles[0]->title; + * ``` + * + * You can also load associations for multiple entities at once + * + * ### Example: + * + * ``` + * $users = $usersTable->find()->where([...])->toList(); + * $users = $usersTable->loadInto($users, ['Articles.Tags', 'Articles.Comments']); + * echo $user[1]->articles[0]->title; + * ``` + * + * The properties for the associations to be loaded will be overwritten on each entity. + * + * @param TEntity|array $entities a single entity or list of entities + * @param array $contain A `contain()` compatible array. + * @see \Cake\ORM\Query\SelectQuery::contain() + * @return TEntity|array + */ + public function loadInto(EntityInterface|array $entities, array $contain): EntityInterface|array + { + /** @var TEntity|array $result */ + $result = (new LazyEagerLoader())->loadInto($entities, $contain, $this); + + return $result; + } + + /** + * @inheritDoc + */ + protected function validationMethodExists(string $name): bool + { + 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 + */ + public function __debugInfo(): array + { + $conn = $this->getConnection(); + + return [ + 'registryAlias' => $this->getRegistryAlias(), + 'table' => $this->getTable(), + 'alias' => $this->getAlias(), + 'entityClass' => $this->getEntityClass(), + /** @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->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 new file mode 100644 index 00000000000..1c6671e1bdb --- /dev/null +++ b/src/ORM/TableRegistry.php @@ -0,0 +1,76 @@ +setConfig('Users', ['table' => 'my_users']); + * ``` + * + * Configuration data is stored *per alias* if you use the same table with + * multiple aliases you will need to set configuration multiple times. + * + * ### Getting instances + * + * 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::getTableLocator()->get('Users', $config); + * ``` + */ +class TableRegistry +{ + /** + * Returns a singleton instance of LocatorInterface implementation. + * + * @return \Cake\ORM\Locator\LocatorInterface + */ + public static function getTableLocator(): LocatorInterface + { + /** @var \Cake\ORM\Locator\LocatorInterface */ + return FactoryLocator::get('Table'); + } + + /** + * Sets singleton instance of LocatorInterface implementation. + * + * @param \Cake\ORM\Locator\LocatorInterface $tableLocator Instance of a locator to use. + * @return void + */ + public static function setTableLocator(LocatorInterface $tableLocator): void + { + 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 @@ +=8.2", + "cakephp/collection": "^5.3.0", + "cakephp/core": "^5.3.0", + "cakephp/datasource": "^5.3.0", + "cakephp/database": "^5.3.0", + "cakephp/event": "^5.3.0", + "cakephp/utility": "^5.3.0", + "cakephp/validation": "^5.3.0" + }, + "require-dev": { + "cakephp/cache": "^5.3.0", + "cakephp/i18n": "^5.3.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.4.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..a76dcbe5dee --- /dev/null +++ b/src/Routing/Asset.php @@ -0,0 +1,372 @@ + $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 Array with 2 indexes. 0 => plugin name, 1 => filename. + * @phpstan-return array{string|null, string} + */ + 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/Exception/DuplicateNamedRouteException.php b/src/Routing/Exception/DuplicateNamedRouteException.php new file mode 100644 index 00000000000..9c20616bc3a --- /dev/null +++ b/src/Routing/Exception/DuplicateNamedRouteException.php @@ -0,0 +1,45 @@ +|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(array|string $message, ?int $code = 404, ?Throwable $previous = null) + { + if (is_array($message) && isset($message['message'])) { + $this->_messageTemplate = $message['message']; + } + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Routing/Exception/MissingRouteException.php b/src/Routing/Exception/MissingRouteException.php new file mode 100644 index 00000000000..c931bf7679e --- /dev/null +++ b/src/Routing/Exception/MissingRouteException.php @@ -0,0 +1,63 @@ +|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(array|string $message, ?int $code = 404, ?Throwable $previous = null) + { + if (is_array($message)) { + if (isset($message['message'])) { + $this->_messageTemplate = $message['message']; + } elseif (isset($message['method']) && $message['method']) { + $this->_messageTemplate = $this->_messageTemplateWithMethod; + } + } + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Routing/Middleware/AssetMiddleware.php b/src/Routing/Middleware/AssetMiddleware.php new file mode 100644 index 00000000000..068c16552f6 --- /dev/null +++ b/src/Routing/Middleware/AssetMiddleware.php @@ -0,0 +1,177 @@ + $options The options to use + */ + public function __construct(array $options = []) + { + if (!empty($options['cacheTime'])) { + $this->cacheTime = $options['cacheTime']; + } + } + + /** + * Serve assets if the path matches one. + * + * @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 + { + $url = $request->getUri()->getPath(); + if (str_contains($url, '..') || !str_contains($url, '.')) { + return $handler->handle($request); + } + + if (str_contains($url, '/.')) { + return $handler->handle($request); + } + + $assetFile = $this->_getAssetFile($url); + if ($assetFile === null || !is_file($assetFile)) { + return $handler->handle($request); + } + + $file = new SplFileInfo($assetFile); + $modifiedTime = $file->getMTime(); + if ($this->isNotModified($request, $file)) { + return (new Response()) + ->withStringBody('') + ->withStatus(304) + ->withHeader( + 'Last-Modified', + date(DATE_RFC850, $modifiedTime), + ); + } + + return $this->deliverAsset($request, $file); + } + + /** + * Check the not modified header. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request to check. + * @param \SplFileInfo $file The file object to compare. + * @return bool + */ + protected function isNotModified(ServerRequestInterface $request, SplFileInfo $file): bool + { + $modifiedSince = $request->getHeaderLine('If-Modified-Since'); + if (!$modifiedSince) { + return false; + } + + return strtotime($modifiedSince) === $file->getMTime(); + } + + /** + * Builds asset file path based off url + * + * @param string $url Asset URL + * @return string|null Absolute path for asset file, null on failure + */ + protected function _getAssetFile(string $url): ?string + { + $parts = explode('/', ltrim($url, '/'), 3); + $pluginPart = []; + for ($i = 0; $i < 2; $i++) { + if (!isset($parts[$i])) { + break; + } + $pluginPart[] = Inflector::camelize($parts[$i]); + $plugin = implode('/', $pluginPart); + if (Plugin::isLoaded($plugin)) { + $parts = array_slice($parts, $i + 1); + $fileFragment = implode(DIRECTORY_SEPARATOR, $parts); + $pluginWebroot = Plugin::path($plugin) . 'webroot' . DIRECTORY_SEPARATOR; + + return $pluginWebroot . $fileFragment; + } + } + + return null; + } + + /** + * Sends an asset file to the client + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request object to use. + * @param \SplFileInfo $file The file wrapper for the file. + * @return \Cake\Http\Response The response with the file & headers. + */ + protected function deliverAsset(ServerRequestInterface $request, SplFileInfo $file): Response + { + $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); + if ($expire === false) { + throw new CakeException(sprintf('Invalid cache time value `%s`', $this->cacheTime)); + } + + $now = time(); + $maxAge = $expire - $now; + + return $response + ->withHeader('Content-Type', $contentType) + ->withHeader('Cache-Control', 'public,max-age=' . $maxAge) + ->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 new file mode 100644 index 00000000000..939ebc4ea13 --- /dev/null +++ b/src/Routing/Middleware/RoutingMiddleware.php @@ -0,0 +1,129 @@ +app = $app; + } + + /** + * Trigger the application's and plugin's routes() hook. + * + * @return void + */ + protected function loadRoutes(): void + { + $builder = Router::createRouteBuilder('/'); + $this->app->routes($builder); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginRoutes($builder); + } + } + + /** + * Apply routing and update the request. + * + * Any route/path specific middleware will be wrapped around $next and then the new middleware stack will be + * invoked. + * + * @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 + { + $this->loadRoutes(); + try { + assert($request instanceof ServerRequest); + Router::setRequest($request); + $params = (array)$request->getAttribute('params', []); + $middleware = []; + if (empty($params['controller'])) { + $params = Router::parseRequest($request) + $params; + if (isset($params['_middleware'])) { + $middleware = $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(), + $e->getHeaders(), + ); + } + $matching = Router::getRouteCollection()->getMiddleware($middleware); + if (!$matching) { + return $handler->handle($request); + } + + $container = $this->app instanceof ContainerApplicationInterface + ? $this->app->getContainer() + : null; + $middleware = new MiddlewareQueue($matching, $container); + $runner = new Runner(); + + return $runner->run($middleware, $request, $handler); + } +} diff --git a/src/Routing/Route/DashedRoute.php b/src/Routing/Route/DashedRoute.php new file mode 100644 index 00000000000..c406e32b373 --- /dev/null +++ b/src/Routing/Route/DashedRoute.php @@ -0,0 +1,130 @@ + 'MyPlugin', 'controller' => 'MyController', 'action' => 'myAction']` + */ +class DashedRoute extends Route +{ + /** + * Flag for tracking whether the defaults have been inflected. + * + * Default values need to be inflected so that they match the inflections that + * match() will create. + * + * @var array|null + */ + protected ?array $_inflectedDefaults = null; + + /** + * Camelizes the previously dashed plugin route taking into account plugin vendors + * + * @param string $plugin Plugin name + * @return string + */ + protected function _camelizePlugin(string $plugin): string + { + $plugin = str_replace('-', '_', $plugin); + if (!str_contains($plugin, '/')) { + return Inflector::camelize($plugin); + } + [$vendor, $plugin] = explode('/', $plugin, 2); + + return Inflector::camelize($vendor) . '/' . Inflector::camelize($plugin); + } + + /** + * Parses a string URL into an array. If it matches, it will convert the + * controller and plugin keys to their CamelCased form and action key to + * camelBacked form. + * + * @param string $url The URL to parse + * @param string $method The HTTP method. + * @return array|null An array of request parameters, or null on failure. + */ + public function parse(string $url, string $method = ''): ?array + { + $params = parent::parse($url, $method); + if (!$params) { + return null; + } + if (!empty($params['controller'])) { + $params['controller'] = Inflector::camelize($params['controller'], '-'); + } + if (!empty($params['plugin'])) { + $params['plugin'] = $this->_camelizePlugin($params['plugin']); + } + if (!empty($params['action'])) { + $params['action'] = Inflector::variable(str_replace( + '-', + '_', + $params['action'], + )); + } + + return $params; + } + + /** + * Dasherizes the controller, action and plugin params before passing them on + * to the parent class. + * + * @param array $url Array of parameters to convert to a string. + * @param array $context An array of the current request context. + * Contains information such as the current host, scheme, port, and base + * directory. + * @return string|null Either a string URL or null. + */ + public function match(array $url, array $context = []): ?string + { + $url = $this->_dasherize($url); + 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); + } finally { + $this->defaults = $restore; + } + } + + /** + * Helper method for dasherizing keys in a URL array. + * + * @param array $url An array of URL keys. + * @return array + */ + protected function _dasherize(array $url): array + { + foreach (['controller', 'plugin', 'action'] as $element) { + if (!empty($url[$element])) { + $url[$element] = Inflector::dasherize($url[$element]); + } + } + + return $url; + } +} 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 new file mode 100644 index 00000000000..fa53bdf3685 --- /dev/null +++ b/src/Routing/Route/InflectedRoute.php @@ -0,0 +1,110 @@ + 'MyController']` + */ +class InflectedRoute extends Route +{ + /** + * Flag for tracking whether the defaults have been inflected. + * + * Default values need to be inflected so that they match the inflections that match() + * will create. + * + * @var array|null + */ + protected ?array $_inflectedDefaults = null; + + /** + * Parses a string URL into an array. If it matches, it will convert the prefix, controller and + * plugin keys to their camelized form. + * + * @param string $url The URL to parse + * @param string $method The HTTP method being matched. + * @return array|null An array of request parameters, or null on failure. + */ + public function parse(string $url, string $method = ''): ?array + { + $params = parent::parse($url, $method); + if (!$params) { + return null; + } + if (!empty($params['controller'])) { + $params['controller'] = Inflector::camelize($params['controller']); + } + if (!empty($params['plugin'])) { + if (!str_contains($params['plugin'], '/')) { + $params['plugin'] = Inflector::camelize($params['plugin']); + } else { + [$vendor, $plugin] = explode('/', $params['plugin'], 2); + $params['plugin'] = Inflector::camelize($vendor) . '/' . Inflector::camelize($plugin); + } + } + + return $params; + } + + /** + * Underscores the prefix, controller and plugin params before passing them on to the + * parent class + * + * @param array $url Array of parameters to convert to a string. + * @param array $context An array of the current request context. + * Contains information such as the current host, scheme, port, and base + * directory. + * @return string|null Either a string URL for the parameters if they match or null. + */ + public function match(array $url, array $context = []): ?string + { + $url = $this->_underscore($url); + 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); + } finally { + $this->defaults = $restore; + } + } + + /** + * Helper method for underscoring keys in a URL array. + * + * @param array $url An array of URL keys. + * @return array + */ + protected function _underscore(array $url): array + { + if (!empty($url['controller'])) { + $url['controller'] = Inflector::underscore($url['controller']); + } + if (!empty($url['plugin'])) { + $url['plugin'] = Inflector::underscore($url['plugin']); + } + + return $url; + } +} diff --git a/src/Routing/Route/PluginShortRoute.php b/src/Routing/Route/PluginShortRoute.php new file mode 100644 index 00000000000..bf9347c2251 --- /dev/null +++ b/src/Routing/Route/PluginShortRoute.php @@ -0,0 +1,65 @@ +defaults['controller'] = $url['controller']; + $result = parent::match($url, $context); + unset($this->defaults['controller']); + + return $result; + } +} diff --git a/src/Routing/Route/RedirectRoute.php b/src/Routing/Route/RedirectRoute.php new file mode 100644 index 00000000000..9f51611b3ee --- /dev/null +++ b/src/Routing/Route/RedirectRoute.php @@ -0,0 +1,30 @@ +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 new file mode 100644 index 00000000000..e513a788e5a --- /dev/null +++ b/src/Routing/Route/Route.php @@ -0,0 +1,959 @@ + + */ + public array $options = []; + + /** + * Default parameters for a Route + * + * @var array + */ + public array $defaults = []; + + /** + * The routes template string. + * + * @var string + */ + public string $template; + + /** + * Is this route a greedy route? Greedy routes have a `/*` in their + * template + * + * @var bool + */ + protected bool $_greedy = false; + + /** + * The compiled route regular expression + * + * @var string|null + */ + protected ?string $_compiledRoute = null; + + /** + * The name for a route. Fetch with Route::getName(); + * + * @var string|null + */ + protected ?string $_name = null; + + /** + * List of connected extensions for this route. + * + * @var array + */ + protected array $_extensions = []; + + /** + * List of middleware that should be applied. + * + * @var array + */ + protected array $middleware = []; + + /** + * Valid HTTP methods. + * + * @var array + */ + 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 + * + * ### Options + * + * - `_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 $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(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; + $this->defaults = $defaults; + $this->options = $options + ['_ext' => [], '_middleware' => []]; + $this->setExtensions((array)$this->options['_ext']); + $this->setMiddleware((array)$this->options['_middleware']); + unset($this->options['_middleware']); + + if (isset($this->defaults['_method'])) { + $this->defaults['_method'] = $this->normalizeAndValidateMethods($this->defaults['_method']); + } + } + + /** + * Set the supported extensions for this route. + * + * @param array $extensions The extensions to set. + * @return $this + */ + public function setExtensions(array $extensions) + { + $this->_extensions = array_map('strtolower', $extensions); + + return $this; + } + + /** + * Get the supported extensions for this route. + * + * @return array + */ + public function getExtensions(): array + { + return $this->_extensions; + } + + /** + * Set the accepted HTTP methods for this route. + * + * @param array $methods The HTTP methods to accept. + * @return $this + * @throws \InvalidArgumentException When methods are not in `VALID_METHODS` list. + */ + public function setMethods(array $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)), + ); + } + + return $methods; + } + + /** + * Set regexp patterns for routing parameters + * + * If any of your patterns contain multibyte values, the `multibytePattern` + * mode will be enabled. + * + * @param array $patterns The patterns to apply to routing elements + * @return $this + */ + public function setPatterns(array $patterns) + { + $patternValues = implode('', $patterns); + if (mb_strlen($patternValues) < strlen($patternValues)) { + $this->options['multibytePattern'] = true; + } + $this->options = $patterns + $this->options; + + return $this; + } + + /** + * Set host requirement + * + * @param string $host The host name this route is bound to + * @return $this + */ + public function setHost(string $host) + { + $this->options['_host'] = $host; + + return $this; + } + + /** + * Set the names of parameters that will be converted into passed parameters + * + * @param array $names The names of the parameters that should be passed. + * @return $this + */ + public function setPass(array $names) + { + $this->options['pass'] = $names; + + return $this; + } + + /** + * Set the names of parameters that will be persisted 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, ...]); + * ``` + * + * @param array $names The names of the parameters that should be passed. + * @return $this + */ + public function setPersist(array $names) + { + $this->options['persist'] = $names; + + return $this; + } + + /** + * Check if a Route has been compiled into a regular expression. + * + * @return bool + */ + public function compiled(): bool + { + return $this->_compiledRoute !== null; + } + + /** + * Compiles the route's regular expression. + * + * Modifies defaults property so all necessary keys are set + * and populates $this->names with the named routing elements. + * + * @return string Returns a string regular expression of the compiled route. + */ + public function compile(): string + { + if ($this->_compiledRoute === null) { + $this->_writeRoute(); + } + assert($this->_compiledRoute !== null); + + return $this->_compiledRoute; + } + + /** + * Builds a route regular expression. + * + * Uses the template, defaults and options properties to compile a + * regular expression that can be used to parse request strings. + * + * @return void + */ + protected function _writeRoute(): void + { + if (empty($this->template) || ($this->template === '/')) { + $this->_compiledRoute = '#^/*$#'; + $this->keys = []; + + return; + } + $route = $this->template; + $names = []; + $routeParams = []; + $parsed = preg_quote($this->template, '#'); + + 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 = ''; + if ($name !== 'plugin' && array_key_exists($name, $this->defaults)) { + $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 = (string)preg_replace('#/\\\\\*\\\\\*$#', '(?:/(?P<_trailing_>.*))?', $parsed); + $this->_greedy = true; + } + if (preg_match('#\/\*$#', $route)) { + $parsed = (string)preg_replace('#/\\\\\*$#', '(?:/(?P<_args_>.*))?', $parsed); + $this->_greedy = true; + } + $mode = empty($this->options['multibytePattern']) ? '' : 'u'; + krsort($routeParams); + $parsed = str_replace(array_keys($routeParams), $routeParams, $parsed); + $this->_compiledRoute = '#^' . $parsed . '[/]*$#' . $mode; + $this->keys = $names; + + // Remove defaults that are also keys. They can cause match failures + foreach ($this->keys as $key) { + unset($this->defaults[$key]); + } + + $keys = $this->keys; + sort($keys); + $this->keys = array_reverse($keys); + } + + /** + * Get the standardized plugin.controller:action name for a route. + * + * @return string + */ + public function getName(): string + { + if ($this->_name) { + return $this->_name; + } + $name = ''; + $keys = [ + 'prefix' => ':', + 'plugin' => '.', + 'controller' => ':', + 'action' => '', + ]; + foreach ($keys as $key => $glue) { + $value = null; + if (str_contains($this->template, '{' . $key . '}')) { + $value = '_' . $key; + } elseif (isset($this->defaults[$key])) { + $value = $this->defaults[$key]; + } + + if ($value === null) { + continue; + } + if ($value === true || $value === false) { + $value = $value ? '1' : '0'; + } + $name .= $value . $glue; + } + + return $this->_name = strtolower($name); + } + + /** + * 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 + * `null` will be returned. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The URL to attempt to parse. + * @return array|null An array of request parameters, or `null` on failure. + */ + public function parseRequest(ServerRequestInterface $request): ?array + { + $uri = $request->getUri(); + if (isset($this->options['_host']) && !$this->hostMatches($uri->getHost())) { + return null; + } + + return $this->parse($uri->getPath(), $request->getMethod()); + } + + /** + * 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 + * `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|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(string $url, string $method): ?array + { + try { + if ($method !== '') { + $method = $this->normalizeAndValidateMethods($method); + } + } catch (InvalidArgumentException $e) { + throw new BadRequestException($e->getMessage()); + } + + $compiledRoute = $this->compile(); + [$url, $ext] = $this->_parseExtension($url); + + $urldecode = $this->options['_urldecode'] ?? true; + if ($urldecode) { + $url = urldecode($url); + } + + 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); + $count = count($this->keys); + for ($i = 0; $i <= $count; $i++) { + unset($route[$i]); + } + $route['pass'] = []; + + // Assign defaults, set passed args to pass + foreach ($this->defaults as $key => $value) { + if (isset($route[$key])) { + continue; + } + if (is_int($key)) { + $route['pass'][] = $value; + continue; + } + $route[$key] = $value; + } + + if (isset($route['_args_'])) { + $pass = $this->_parseArgs($route['_args_'], $route); + $route['pass'] = array_merge($route['pass'], $pass); + unset($route['_args_']); + } + + if (isset($route['_trailing_'])) { + $route['pass'][] = $route['_trailing_']; + unset($route['_trailing_']); + } + + if ($ext) { + $route['_ext'] = $ext; + } + + // pass the name if set + if (isset($this->options['_name'])) { + $route['_name'] = $this->options['_name']; + } + + // restructure 'pass' key route params + if (isset($this->options['pass'])) { + $j = count($this->options['pass']); + while ($j--) { + if (isset($route[$this->options['pass'][$j]])) { + array_unshift($route['pass'], $route[$this->options['pass'][$j]]); + } + } + } + + $route['_route'] = $this; + $route['_matchedRoute'] = $this->template; + if ($this->middleware !== []) { + $route['_middleware'] = $this->middleware; + } + + return $route; + } + + /** + * Check to see if the host matches the route requirements + * + * @param string $host The request's host name + * @return bool Whether the host matches any conditions set in for this route. + */ + public function hostMatches(string $host): bool + { + $pattern = '@^' . str_replace('\*', '.*', preg_quote($this->options['_host'], '@')) . '$@'; + + return preg_match($pattern, $host) !== 0; + } + + /** + * Removes the extension from $url if it contains a registered extension. + * If no registered extension is found, no extension is returned and the URL is returned unmodified. + * + * @param string $url The url to parse. + * @return array containing url, extension + */ + protected function _parseExtension(string $url): array + { + if (count($this->_extensions) && str_contains($url, '.')) { + foreach ($this->_extensions as $ext) { + $len = strlen($ext) + 1; + if (substr($url, -$len) === '.' . $ext) { + return [substr($url, 0, $len * -1), $ext]; + } + } + } + + return [$url, null]; + } + + /** + * Parse passed parameters into a list of passed args. + * + * Return true if a given named $param's $val matches a given $rule depending on $context. + * 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 array $context The current route context, which should contain controller/action keys. + * @return array Array of passed args. + */ + protected function _parseArgs(string $args, array $context): array + { + $pass = []; + $args = explode('/', $args); + $urldecode = $this->options['_urldecode'] ?? true; + + foreach ($args as $param) { + if (!$param && $param !== '0') { + continue; + } + $pass[] = $urldecode ? rawurldecode($param) : $param; + } + + return $pass; + } + + /** + * Apply persistent parameters to a URL array. Persistent parameters are a + * special key used during route creation to force route parameters to + * persist when omitted from a URL array. + * + * @param array $url The array to apply persistent parameters to. + * @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): array + { + foreach ($this->options['persist'] as $persistKey) { + if (array_key_exists($persistKey, $params) && !isset($url[$persistKey])) { + $url[$persistKey] = $params[$persistKey]; + } + } + + return $url; + } + + /** + * Check if a URL array matches this route instance. + * + * If the URL matches the route parameters and settings, then + * return a generated string URL. If the URL doesn't match the route parameters, false will be returned. + * This method handles the reverse routing or conversion of URL arrays into string URLs. + * + * @param array $url An array of parameters to check matching with. + * @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|null Either a string URL for the parameters if they match or null. + */ + public function match(array $url, array $context = []): ?string + { + if (!$this->_compiledRoute) { + $this->compile(); + } + $defaults = $this->defaults; + $context += ['params' => [], '_port' => null, '_scheme' => null, '_host' => null]; + + if ( + !empty($this->options['persist']) && + is_array($this->options['persist']) + ) { + $url = $this->_persistParams($url, $context['params']); + } + 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']) || + isset($hostOptions['_port']) || + isset($hostOptions['_host']) + ) { + $hostOptions += $context; + + if ( + $hostOptions['_scheme'] && + getservbyname($hostOptions['_scheme'], 'tcp') === $hostOptions['_port'] + ) { + unset($hostOptions['_port']); + } + } + + // If no base is set, copy one in. + if (!isset($hostOptions['_base']) && isset($context['_base'])) { + $hostOptions['_base'] = $context['_base']; + } + + $query = !empty($url['?']) ? (array)$url['?'] : []; + unset($url['_host'], $url['_scheme'], $url['_port'], $url['_base'], $url['?']); + + // Move extension into the hostOptions so it is not part of + // reverse matches. + if (isset($url['_ext'])) { + $hostOptions['_ext'] = $url['_ext']; + unset($url['_ext']); + } + + // Check the method first as it is special. + if (!$this->_matchMethod($url)) { + return null; + } + unset($url['_method'], $url['[method]'], $defaults['_method']); + + // Defaults with different values are a fail. + // 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 + // not set, rekey elements. + if (isset($this->options['pass'])) { + foreach ($this->options['pass'] as $i => $name) { + if (isset($url[$i]) && !isset($url[$name])) { + $url[$name] = $url[$i]; + unset($url[$i]); + } + } + } + + // check that all the key names are in the url + $keyNames = array_flip($this->keys); + if (array_intersect_key($keyNames, $url) !== $keyNames) { + return null; + } + + $pass = []; + foreach ($url as $key => $value) { + // If the key is a routed key, it's not different yet. + if (array_key_exists($key, $keyNames)) { + continue; + } + + // pull out passed args + $numeric = is_numeric($key); + if ($numeric && isset($defaults[$key]) && $defaults[$key] === $value) { + continue; + } + if ($numeric) { + $pass[] = $value; + unset($url[$key]); + } + } + + // if not a greedy route, no extra params are allowed. + if (!$this->_greedy && $pass !== []) { + return null; + } + + // check patterns for routed params + 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 the URL's HTTP method matches. + * + * @param array $url The array for the URL being generated. + * @return bool + */ + protected function _matchMethod(array $url): bool + { + if (empty($this->defaults['_method'])) { + return true; + } + if (empty($url['_method'])) { + $url['_method'] = 'GET'; + } + $defaults = (array)$this->defaults['_method']; + $methods = (array)$this->normalizeAndValidateMethods($url['_method']); + foreach ($methods as $value) { + if (in_array($value, $defaults, true)) { + return true; + } + } + + return false; + } + + /** + * Converts a matching route array into a URL string. + * + * Composes the string URL using the template + * used to create the route. + * + * @param array $params The params to convert to a string url + * @param array $pass The additional passed arguments + * @param array $query An array of parameters + * @return string Composed route string. + */ + protected function _writeUrl(array $params, array $pass = [], array $query = []): string + { + $pass = array_map(function ($value) { + return rawurlencode((string)$value); + }, $pass); + $pass = implode('/', $pass); + $out = $this->template; + $search = []; + $replace = []; + foreach ($this->keys as $key) { + if (!array_key_exists($key, $params)) { + throw new InvalidArgumentException(sprintf( + 'Missing required route key `%s`.', + $key, + )); + } + $string = $params[$key]; + if ($string instanceof BackedEnum) { + $string = $string->value; + } elseif ($string instanceof UnitEnum) { + $string = $string->name; + } + + $search[] = "{{$key}}"; + $replace[] = $string; + } + + if (str_contains($this->template, '**')) { + $search[] = '**'; + $search[] = '%2F'; + $replace[] = $pass; + $replace[] = '/'; + } elseif (str_contains($this->template, '*')) { + $search[] = '*'; + $replace[] = $pass; + } + $out = str_replace($search, $replace, $out); + + // add base url if applicable. + if (isset($params['_base'])) { + $out = $params['_base'] . $out; + unset($params['_base']); + } + + $out = str_replace('//', '/', $out); + if ( + isset($params['_scheme']) || + isset($params['_host']) || + isset($params['_port']) + ) { + $host = $params['_host']; + + // append the port and scheme if they exist. + if (isset($params['_port'])) { + $host .= ':' . $params['_port']; + } + $scheme = $params['_scheme'] ?? 'http'; + $out = "{$scheme}://{$host}{$out}"; + } + if (!empty($params['_ext']) || $query !== []) { + $out = rtrim($out, '/'); + } + if (!empty($params['_ext'])) { + $out .= '.' . $params['_ext']; + } + if ($query) { + $out .= rtrim('?' . http_build_query($query), '?'); + } + + return $out; + } + + /** + * Get the static path portion for this route. + * + * @return string + */ + public function staticPath(): string + { + $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), '/'); + + return $path === '' ? '/' : $path; + } + + return $this->template; + } + + /** + * Set the names of the middleware that should be applied to this route. + * + * @param array $middleware The list of middleware names to apply to this route. + * Middleware names will not be checked until the route is matched. + * @return $this + */ + public function setMiddleware(array $middleware) + { + $this->middleware = $middleware; + + return $this; + } + + /** + * Get the names of the middleware that should be applied to this route. + * + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + /** + * Set state magic method to support var_export + * + * This method helps for applications that want to implement + * router caching. + * + * @param array $fields Key/Value of object attributes + * @return static A new instance of the route + */ + public static function __set_state(array $fields): static + { + $class = static::class; + $obj = new $class(''); + foreach ($fields as $field => $value) { + $obj->$field = $value; + } + + return $obj; + } +} diff --git a/src/Routing/RouteBuilder.php b/src/Routing/RouteBuilder.php new file mode 100644 index 00000000000..022b14f04b4 --- /dev/null +++ b/src/Routing/RouteBuilder.php @@ -0,0 +1,1068 @@ + controller action map. + * + * @var array + */ + 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}'], + ]; + + /** + * Default route class to use if none is provided in connect() options. + * + * @var string + */ + protected string $_routeClass = Route::class; + + /** + * The extensions that should be set into the routes connected. + * + * @var array + */ + protected array $_extensions = []; + + /** + * The path prefix scope that this collection uses. + * + * @var string + */ + protected string $_path; + + /** + * The scope parameters if there are any. + * + * @var array + */ + protected array $_params; + + /** + * Name prefix for connected routes. + * + * @var string + */ + protected string $_namePrefix = ''; + + /** + * The route collection routes should be added to. + * + * @var \Cake\Routing\RouteCollection + */ + protected RouteCollection $_collection; + + /** + * The list of middleware that routes in this builder get + * added during construction. + * + * @var array + */ + protected array $middleware = []; + + /** + * Default route options to apply to all routes created in this builder. + * + * @var array + */ + protected array $defaultOptions = []; + + /** + * Constructor + * + * ### Options + * + * - `routeClass` - The default route class to use when adding routes. + * - `extensions` - The extensions to connect when adding routes. + * - `namePrefix` - The prefix to prepend to all route names. + * - `middleware` - The names of the middleware routes should have applied. + * + * @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. + */ + public function __construct(RouteCollection $collection, string $path, array $params = [], array $options = []) + { + $this->_collection = $collection; + $this->_path = $path; + $this->_params = $params; + if (isset($options['routeClass'])) { + $this->_routeClass = $options['routeClass']; + } + if (isset($options['extensions'])) { + $this->_extensions = $options['extensions']; + } + if (isset($options['namePrefix'])) { + $this->_namePrefix = $options['namePrefix']; + } + if (isset($options['middleware'])) { + $this->middleware = (array)$options['middleware']; + } + } + + /** + * Set default route class. + * + * @param string $routeClass Class name. + * @return $this + */ + public function setRouteClass(string $routeClass) + { + $this->_routeClass = $routeClass; + + return $this; + } + + /** + * Get default route class. + * + * @return string + */ + public function getRouteClass(): string + { + return $this->_routeClass; + } + + /** + * 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 array|string $extensions The extensions to set. + * @return $this + */ + public function setExtensions(array|string $extensions) + { + $this->_extensions = (array)$extensions; + + return $this; + } + + /** + * Get the extensions in this route builder's scope. + * + * @return array + */ + public function getExtensions(): array + { + return $this->_extensions; + } + + /** + * Add additional extensions to what is already in current scope + * + * @param array|string $extensions One or more extensions to add + * @return $this + */ + 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; + } + + /** + * Get the path this scope is for. + * + * @return string + */ + 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); + } + + return $this->_path; + } + + /** + * Get the parameter names/values for this scope. + * + * @return array + */ + public function params(): array + { + return $this->_params; + } + + /** + * Checks if there is already a route with a given name. + * + * @param string $name Name. + * @return bool + */ + public function nameExists(string $name): bool + { + return array_key_exists($name, $this->_collection->named()); + } + + /** + * Get/set the name prefix for this scope. + * + * Modifying the name prefix will only change the prefix + * used for routes connected after the prefix is changed. + * + * @param string|null $value Either the value to set or null. + * @return string + */ + public function namePrefix(?string $value = null): string + { + if ($value !== null) { + $this->_namePrefix = $value; + } + + return $this->_namePrefix; + } + + /** + * Generate REST resource routes for the given controller(s). + * + * A quick way to generate a default routes to a set of REST resources (controller(s)). + * + * ### Usage + * + * Connect resource routes for an app controller: + * + * ``` + * $routes->resources('Posts'); + * ``` + * + * Connect resource routes for the Comments controller in the + * Comments plugin: + * + * ``` + * Router::plugin('Comments', function ($routes) { + * $routes->resources('Comments'); + * }); + * ``` + * + * 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) { + * $routes->resources('Articles'); + * }); + * ``` + * + * Prefixes will create lowercase dasherized resource routes. e.g + * `/admin/posts` + * + * You can create nested resources by passing a callback in: + * + * ``` + * $routes->resources('Articles', function ($routes) { + * $routes->resources('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: + * + * ``` + * $routes->resources('Articles', [ + * 'map' => ['deleteAll' => ['action' => 'deleteAll', 'method' => 'DELETE']] + * ]); + * ``` + * + * 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 + * definition to customize the path name. + * + * You can use the `inflect` option to change how path segments are generated: + * + * ``` + * $routes->resources('PaymentTypes', ['inflect' => 'underscore']); + * ``` + * + * Will generate routes like `/payment-types` instead of `/payment_types` + * + * ### Options: + * + * - '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 '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', + * make sure that your mapped methods are also in the 'only' list. + * - 'prefix' - Define a routing prefix for the resource controller. If the current scope + * defines a prefix, this prefix will be appended to it. + * - 'connectOptions' - Custom options for connecting the routes. + * - 'path' - Change the path so it doesn't match the resource name. E.g ArticlesController + * is available at `/posts` + * + * @param string $name A controller name to connect resource routes for. + * @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 $this + */ + public function resources(string $name, Closure|array $options = [], ?Closure $callback = null) + { + if (!is_array($options)) { + $callback = $options; + $options = []; + } + $options += [ + 'connectOptions' => [], + 'inflect' => 'dasherize', + 'id' => static::ID . '|' . static::UUID, + 'only' => [], + 'actions' => [], + 'map' => [], + 'prefix' => null, + 'path' => null, + ]; + + foreach ($options['map'] as $k => $mapped) { + $options['map'][$k] += ['method' => 'GET', 'path' => $k, 'action' => '']; + } + + $ext = null; + if (!empty($options['_ext'])) { + $ext = $options['_ext']; + } + + $connectOptions = $options['connectOptions']; + if (empty($options['path'])) { + $method = $options['inflect']; + $options['path'] = Inflector::$method($name); + } + $resourceMap = array_merge(static::$_resourceMap, $options['map']); + + $only = (array)$options['only']; + if (!$only) { + $only = array_keys($resourceMap); + } + + $prefix = ''; + if ($options['prefix']) { + $prefix = $options['prefix']; + } + if (isset($this->_params['prefix']) && $prefix) { + $prefix = $this->_params['prefix'] . '/' . $prefix; + } + + foreach ($resourceMap as $method => $params) { + if (!in_array($method, $only, true)) { + continue; + } + + $action = $options['actions'][$method] ?? $params['action']; + + $url = '/' . implode('/', array_filter([$options['path'], $params['path']])); + $params = [ + 'controller' => $name, + 'action' => $action, + '_method' => $params['method'], + ]; + if ($prefix) { + $params['prefix'] = $prefix; + } + $routeOptions = $connectOptions + [ + 'id' => $options['id'], + 'pass' => ['id'], + '_ext' => $ext, + ]; + $this->connect($url, $params, $routeOptions); + } + + if ($callback !== null) { + $idName = Inflector::singularize(Inflector::underscore($name)) . '_id'; + $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|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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + public function get(string $template, array|string $target, ?string $name = null): Route + { + return $this->_methodRoute('GET', $template, $target, $name); + } + + /** + * Create a route that only responds to POST requests. + * + * @param string $template The URL template to use. + * @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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + public function post(string $template, array|string $target, ?string $name = null): Route + { + return $this->_methodRoute('POST', $template, $target, $name); + } + + /** + * Create a route that only responds to PUT requests. + * + * @param string $template The URL template to use. + * @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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + public function put(string $template, array|string $target, ?string $name = null): Route + { + return $this->_methodRoute('PUT', $template, $target, $name); + } + + /** + * Create a route that only responds to PATCH requests. + * + * @param string $template The URL template to use. + * @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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + public function patch(string $template, array|string $target, ?string $name = null): Route + { + return $this->_methodRoute('PATCH', $template, $target, $name); + } + + /** + * Create a route that only responds to DELETE requests. + * + * @param string $template The URL template to use. + * @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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + public function delete(string $template, array|string $target, ?string $name = null): Route + { + return $this->_methodRoute('DELETE', $template, $target, $name); + } + + /** + * Create a route that only responds to HEAD requests. + * + * @param string $template The URL template to use. + * @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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + public function head(string $template, array|string $target, ?string $name = null): Route + { + return $this->_methodRoute('HEAD', $template, $target, $name); + } + + /** + * Create a route that only responds to OPTIONS requests. + * + * @param string $template The URL template to use. + * @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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + public function options(string $template, array|string $target, ?string $name = null): Route + { + return $this->_methodRoute('OPTIONS', $template, $target, $name); + } + + /** + * Helper to create routes that only respond to a single HTTP method. + * + * @param string $method The HTTP method name to match. + * @param string $template The URL template to use. + * @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|null $name The name of the route. + * @return \Cake\Routing\Route\Route + */ + protected function _methodRoute(string $method, string $template, array|string $target, ?string $name): Route + { + if ($name !== null) { + $name = $this->_namePrefix . $name; + } + $options = [ + '_name' => $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); + $this->_collection->add($route, $options); + + return $route; + } + + /** + * Load routes from a plugin. + * + * The routes file will have a local variable named `$routes` made available which contains + * the current RouteBuilder instance. + * + * @param string $name The plugin name + * @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(string $name) + { + $plugins = Plugin::getCollection(); + if (!$plugins->has($name)) { + throw new MissingPluginException(['plugin' => $name]); + } + $plugin = $plugins->get($name); + $plugin->routes($this); + + // Disable the routes hook to prevent duplicate route issues. + $plugin->disable('routes'); + + return $this; + } + + /** + * Connects a new Route. + * + * Routes are a way of connecting request URLs to objects in your application. + * At their core routes are a set or regular expressions that are used to + * match requests to destinations. + * + * Examples: + * + * ``` + * $routes->connect('/{controller}/{action}/*'); + * ``` + * + * The first parameter will be used as a controller name while the second is + * used as the action name. The '/*' syntax makes this route greedy in that + * it will match requests like `/posts/index` as well as requests + * like `/posts/edit/1/foo/bar`. + * + * ``` + * $routes->connect('/home-page', ['controller' => 'Pages', 'action' => 'display', 'home']); + * ``` + * + * The above shows the use of route parameter defaults. And providing routing + * parameters for a static route. + * + * ``` + * $routes->connect( + * '/{lang}/{controller}/{action}/{id}', + * [], + * ['id' => '[0-9]+', 'lang' => '[a-z]{3}'] + * ); + * ``` + * + * Shows connecting a route with custom route parameters as well as + * providing patterns for those parameters. Patterns for routing parameters + * do not need capturing groups, as one will be added for each route params. + * + * $options offers several 'special' keys that have special meaning + * in the $options array. + * + * - `routeClass` is used to extend and change how individual routes parse requests + * and handle reverse routing, via a custom routing class. + * Ex. `'routeClass' => 'SlugRoute'` + * - `pass` is used to define which of the routed parameters should be shifted + * into the pass array. Adding a parameter to pass will remove it from the + * regular route array. Ex. `'pass' => ['slug']`. + * - `persist` is used 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 parameter to `false`. + * Ex. `'persist' => ['lang']` + * - `multibytePattern` Set to true to enable multibyte pattern support in route + * parameter patterns. + * - `_name` is used to define a specific name for routes. This can be used to optimize + * reverse routing lookups. If undefined a name will be generated for each + * connected route. + * - `_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: + * + * ``` + * $routes->connect('/tasks', ['controller' => 'Tasks', 'action' => 'index', '_method' => 'GET']); + * ``` + * + * The above route will only be matched for GET requests. POST requests will fail to match this route. + * + * @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. + * @return \Cake\Routing\Route\Route + * @throws \InvalidArgumentException + * @throws \BadMethodCallException + */ + public function connect(Route|string $route, array|string $defaults = [], array $options = []): Route + { + $defaults = $this->parseDefaults($defaults); + $options += $this->defaultOptions; + + if (empty($options['_ext'])) { + $options['_ext'] = $this->_extensions; + } + if (empty($options['routeClass'])) { + $options['routeClass'] = $this->_routeClass; + } + if (isset($options['_name']) && $this->_namePrefix) { + $options['_name'] = $this->_namePrefix . $options['_name']; + } + if (empty($options['_middleware'])) { + $options['_middleware'] = $this->middleware; + } + + $route = $this->_makeRoute($route, $defaults, $options); + $this->_collection->add($route, $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 \Cake\Routing\Route\Route|string $route The route template or route object. + * @param array $defaults Default 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|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 === null) { + throw new InvalidArgumentException(sprintf( + 'Cannot find route class %s', + $options['routeClass'], + )); + } + + $route = str_replace('//', '/', $this->_path . $route); + if ($route !== '/') { + $route = rtrim($route, '/'); + } + + foreach ($this->_params as $param => $val) { + if (isset($defaults[$param]) && $param !== 'prefix' && $defaults[$param] !== $val) { + $msg = 'You cannot define routes that conflict with the scope. ' . + 'Scope had %s = %s, while route had %s = %s'; + throw new BadMethodCallException(sprintf( + $msg, + $param, + $val, + $param, + $defaults[$param], + )); + } + } + $defaults += $this->_params + ['plugin' => null]; + if (!isset($defaults['action']) && !isset($options['action'])) { + $defaults['action'] = 'index'; + } + + $route = new $routeClass($route, $defaults, $options); + } + + return $route; + } + + /** + * Connects a new redirection Route in the router. + * + * Redirection routes are different from normal routes as they perform an actual + * header redirection if a match is found. The redirection can occur within your + * application or redirect to an outside location. + * + * Examples: + * + * ``` + * $routes->redirect('/home/*', ['controller' => 'Posts', 'action' => 'view']); + * ``` + * + * Redirects /home/* to /posts/view and passes the parameters to /posts/view. Using an array as the + * redirect destination allows you to use other routes to define where a URL string should be redirected to. + * + * ``` + * $routes->redirect('/posts/*', 'http://google.com', ['status' => 302]); + * ``` + * + * Redirects /posts/* to http://google.com with a HTTP status of 302 + * + * ### Options: + * + * - `status` Sets the HTTP status (default 301) + * - `persist` Passes the params to the redirected route, if it can. This is useful with greedy routes, + * routes that end in `*` are greedy. As you can remap URLs and not lose any passed args. + * + * @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 + * 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 \Cake\Routing\Route\Route + */ + public function redirect(string $route, array|string $url, array $options = []): Route + { + $options['routeClass'] ??= RedirectRoute::class; + if (is_string($url)) { + $url = ['redirect' => $url]; + } + + return $this->connect($route, $url, $options); + } + + /** + * Add prefixed routes. + * + * This method creates a scoped route collection that includes + * relevant prefix information. + * + * The $name 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. + * + * 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. + * + * If you need to have prefix with dots, eg: '/api/v1.0', use 'path' key + * for $params argument: + * + * ``` + * $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 \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(string $name, Closure|array $params = [], ?Closure $callback = null) + { + if (!is_array($params)) { + $callback = $params; + $params = []; + } + $path = '/' . Inflector::dasherize($name); + $name = Inflector::camelize($name); + if (isset($params['path'])) { + $path = $params['path']; + unset($params['path']); + } + if (isset($this->_params['prefix'])) { + $name = $this->_params['prefix'] . '/' . $name; + } + $params = array_merge($params, ['prefix' => $name]); + $this->scope($path, $params, $callback); + + return $this; + } + + /** + * Add plugin routes. + * + * This method creates a new scoped route collection that includes + * relevant plugin information. + * + * 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. + * + * ### 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 \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 $this + */ + public function plugin(string $name, Closure|array $options = [], ?Closure $callback = null) + { + if (!is_array($options)) { + $callback = $options; + $options = []; + } + + $path = $options['path'] ?? '/' . Inflector::dasherize($name); + unset($options['path']); + $options = ['plugin' => $name] + $options; + $this->scope($path, $options, $callback); + + return $this; + } + + /** + * Create a new routing scope. + * + * Scopes created with this method will inherit the properties of the scope they are + * 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 \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 $this + * @throws \InvalidArgumentException when there is no callable parameter. + */ + public function scope(string $path, Closure|array $params, ?Closure $callback = null) + { + if ($params instanceof Closure) { + $callback = $params; + $params = []; + } + if ($callback === null) { + throw new InvalidArgumentException('Need a valid Closure to connect routes.'); + } + + if ($this->_path !== '/') { + $path = $this->_path . $path; + } + $namePrefix = $this->_namePrefix; + if (isset($params['_namePrefix'])) { + $namePrefix .= $params['_namePrefix']; + } + unset($params['_namePrefix']); + + $params += $this->_params; + $builder = new static($this->_collection, $path, $params, [ + 'routeClass' => $this->_routeClass, + 'extensions' => $this->_extensions, + '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. + * + * 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 $this + */ + public function fallbacks(?string $routeClass = null) + { + $routeClass = $routeClass ?: $this->_routeClass; + $this->connect('/{controller}', ['action' => 'index'], compact('routeClass')); + $this->connect('/{controller}/{action}/*', [], compact('routeClass')); + + return $this; + } + + /** + * Register a middleware with the RouteCollection. + * + * Once middleware has been registered, it can be applied to the current routing + * 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 \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to register. + * @return $this + * @see \Cake\Routing\RouteCollection + */ + public function registerMiddleware(string $name, MiddlewareInterface|Closure|string $middleware) + { + $this->_collection->registerMiddleware($name, $middleware); + + return $this; + } + + /** + * Apply one or many middleware to the current route scope. + * + * Requires middleware to be registered via `registerMiddleware()`. + * + * @param string ...$names The names of the middleware to apply to the current scope. + * @return $this + * @throws \InvalidArgumentException If it cannot apply one of the given middleware or middleware groups. + * @see \Cake\Routing\RouteCollection::registerMiddleware() + */ + 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 InvalidArgumentException($message); + } + } + $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 + * @return $this + */ + public function middlewareGroup(string $name, array $middlewareNames) + { + $this->_collection->middlewareGroup($name, $middlewareNames); + + return $this; + } +} diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php new file mode 100644 index 00000000000..ecec092a060 --- /dev/null +++ b/src/Routing/RouteCollection.php @@ -0,0 +1,483 @@ +> + */ + protected array $_routeTable = []; + + /** + * The hash map of named routes that are in this collection. + * + * @var array<\Cake\Routing\Route\Route> + */ + protected array $_named = []; + + /** + * Routes indexed by static path. + * + * @var array> + */ + protected array $staticPaths = []; + + /** + * Routes indexed by path prefix. + * + * @var array> + */ + protected array $_paths = []; + + /** + * A map of middleware names and the related objects. + * + * @var array + */ + protected array $_middleware = []; + + /** + * A map of middleware group names and the related middleware names. + * + * @var array + */ + protected array $_middlewareGroups = []; + + /** + * Route extensions + * + * @var array + */ + 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 + * `_name` option, which enables named routes. + * @return void + */ + public function add(Route $route, array $options = []): void + { + // Explicit names + if (isset($options['_name'])) { + if (isset($this->_named[$options['_name']])) { + $matched = $this->_named[$options['_name']]; + throw new DuplicateNamedRouteException([ + 'name' => $options['_name'], + 'url' => $matched->template, + 'duplicate' => $matched, + ]); + } + $this->_named[$options['_name']] = $route; + } + + // Generated names. + $name = $route->getName(); + $this->_routeTable[$name] ??= []; + $this->_routeTable[$name][] = $route; + + // Index path prefixes (for parsing) + $path = $route->staticPath(); + + $extensions = $route->getExtensions(); + if ($extensions !== []) { + $this->setExtensions($extensions); + } + + if ($path === $route->template) { + $this->staticPaths[$path][] = $route; + } + + $this->_paths[$path][] = $route; + } + + /** + * 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): array + { + $uri = $request->getUri(); + $urlPath = $uri->getPath(); + if (str_contains($urlPath, '%')) { + // decode urlencoded segments, but don't decode %2f aka / + $parts = explode('/', $urlPath); + $parts = array_map( + fn(string $part) => str_replace('/', '%2f', urldecode($part)), + $parts, + ); + $urlPath = implode('/', $parts); + } + 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 ($uri->getQuery()) { + parse_str($uri->getQuery(), $queryParameters); + $r['?'] = array_merge($r['?'] ?? [], $queryParameters); + } + + return $r; + } + } + + // Sort path segments matching longest paths first. + krsort($this->_paths); + + foreach ($this->_paths as $path => $routes) { + if (!str_starts_with($urlPath, $path)) { + continue; + } + + foreach ($routes as $route) { + $r = $route->parseRequest($request); + if ($r === null) { + continue; + } + if ($uri->getQuery()) { + parse_str($uri->getQuery(), $queryParameters); + $r['?'] = $queryParameters; + } + + return $r; + } + } + throw new MissingRouteException(['url' => $urlPath]); + } + + /** + * Get the set of names from the $url. Accepts both older style array urls, + * and newer style urls containing '_name' + * + * @param array $url The url to match. + * @return array The set of names of the url + */ + protected function _getNames(array $url): array + { + $plugin = false; + if (isset($url['plugin']) && $url['plugin'] !== false) { + $plugin = strtolower($url['plugin']); + } + $prefix = false; + if (isset($url['prefix']) && $url['prefix'] !== false) { + $prefix = strtolower($url['prefix']); + } + $controller = isset($url['controller']) ? strtolower($url['controller']) : null; + $action = strtolower($url['action']); + + $names = [ + "{$controller}:{$action}", + "{$controller}:_action", + "_controller:{$action}", + '_controller:_action', + ]; + + // No prefix, no plugin + if ($prefix === false && $plugin === false) { + return $names; + } + + // 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', + ]; + } + + // 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 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', + ]; + } + + /** + * Reverse route or match a $url array with the connected routes. + * + * Returns either the URL string generated by the route, + * or throws an exception on failure. + * + * @param array $url The URL to match. + * @param array $context The request context to use. Contains _base, _port, + * _host, _scheme and params keys. + * @return string The URL string on match. + * @throws \Cake\Routing\Exception\MissingRouteException When no route could be matched. + */ + public function match(array $url, array $context): string + { + // Named routes support optimization. + if (isset($url['_name'])) { + $name = $url['_name']; + unset($url['_name']); + if (isset($this->_named[$name])) { + $route = $this->_named[$name]; + $out = $route->match($url + $route->defaults, $context); + if ($out) { + return $out; + } + throw new MissingRouteException([ + 'url' => $name, + 'context' => $context, + 'message' => "A named route was found for `{$name}`, but matching failed.", + ]); + } + throw new MissingRouteException(['url' => $name, 'context' => $context]); + } + + foreach ($this->_getNames($url) as $name) { + if (empty($this->_routeTable[$name])) { + continue; + } + foreach ($this->_routeTable[$name] as $route) { + $match = $route->match($url, $context); + if ($match) { + return $match === '/' ? $match : trim($match, '/'); + } + } + } + throw new MissingRouteException(['url' => var_export($url, true), 'context' => $context]); + } + + /** + * Get all the connected routes as a flat list. + * + * Routes will not be returned in the order they were added. + * + * @return array<\Cake\Routing\Route\Route> + */ + public function routes(): array + { + krsort($this->_paths); + + return array_reduce( + $this->_paths, + 'array_merge', + [], + ); + } + + /** + * Get the connected named routes. + * + * @return array<\Cake\Routing\Route\Route> + */ + public function named(): array + { + return $this->_named; + } + + /** + * Get the extensions that can be handled. + * + * @return array The valid extensions. + */ + public function getExtensions(): array + { + return $this->_extensions; + } + + /** + * Set the extensions that the route collection can handle. + * + * @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, bool $merge = true) + { + if ($merge) { + $extensions = array_unique(array_merge( + $this->_extensions, + $extensions, + )); + } + $this->_extensions = $extensions; + + return $this; + } + + /** + * Register a middleware with the RouteCollection. + * + * Once middleware has been registered, it can be applied to the current routing + * 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 \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to register. + * @return $this + */ + public function registerMiddleware(string $name, MiddlewareInterface|Closure|string $middleware) + { + $this->_middleware[$name] = $middleware; + + return $this; + } + + /** + * Add middleware to a middleware group + * + * @param string $name Name of the middleware group + * @param array $middlewareNames Names of the middleware + * @return $this + * @throws \InvalidArgumentException + */ + 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 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 InvalidArgumentException($message); + } + } + + $this->_middlewareGroups[$name] = $middlewareNames; + + return $this; + } + + /** + * Check if the named middleware group has been created. + * + * @param string $name The name of the middleware group to check. + * @return bool + */ + public function hasMiddlewareGroup(string $name): bool + { + return array_key_exists($name, $this->_middlewareGroups); + } + + /** + * Check if the named middleware has been registered. + * + * @param string $name The name of the middleware to check. + * @return bool + */ + public function hasMiddleware(string $name): bool + { + return isset($this->_middleware[$name]); + } + + /** + * Check if the named middleware or middleware group has been registered. + * + * @param string $name The name of the middleware to check. + * @return bool + */ + public function middlewareExists(string $name): bool + { + return $this->hasMiddleware($name) || $this->hasMiddlewareGroup($name); + } + + /** + * Get an array of middleware given a list of names + * + * @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 \InvalidArgumentException when a requested middleware does not exist. + */ + public function getMiddleware(array $names): array + { + $out = []; + foreach ($names as $name) { + if ($this->hasMiddlewareGroup($name)) { + $out = array_merge($out, $this->getMiddleware($this->_middlewareGroups[$name])); + continue; + } + if (!$this->hasMiddleware($name)) { + throw new InvalidArgumentException(sprintf( + 'The middleware named `%s` has not been registered. Use registerMiddleware() to define it.', + $name, + )); + } + $out[] = $this->_middleware[$name]; + } + + return $out; + } +} diff --git a/src/Routing/Router.php b/src/Routing/Router.php new file mode 100644 index 00000000000..e13b79bc5ad --- /dev/null +++ b/src/Routing/Router.php @@ -0,0 +1,882 @@ + + */ + protected static array $_requestContext = []; + + /** + * Named expressions + * + * @var array + */ + protected static array $_namedExpressions = [ + 'Action' => Router::ACTION, + 'Year' => Router::YEAR, + 'Month' => Router::MONTH, + 'Day' => Router::DAY, + 'ID' => Router::ID, + 'UUID' => Router::UUID, + ]; + + /** + * Maintains the request object reference. + * + * @var \Cake\Http\ServerRequest|null + */ + protected static ?ServerRequest $_request = null; + + /** + * Initial state is populated the first time reload() is called which is at the bottom + * of this file. This is a cheat as get_class_vars() returns the value of static vars even if they + * have changed. + * + * @var array + */ + protected static array $_initialState = []; + + /** + * The stack of URL filters to apply against routing URLs before passing the + * parameters to the route collection. + * + * @var array<\Closure> + */ + protected static array $_urlFilters = []; + + /** + * Default extensions defined with Router::extensions() + * + * @var array + */ + protected static array $_defaultExtensions = []; + + /** + * Cache of parsed route paths + * + * @var array + */ + protected static array $_routePaths = []; + + /** + * Get or set default route class. + * + * @param string|null $routeClass Class name. + * @return string|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 + * @see \Cake\Routing\Router::$_namedExpressions + */ + public static function getNamedExpressions(): array + { + return static::$_namedExpressions; + } + + /** + * Get the routing parameters for the request if possible. + * + * @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 + */ + public static function parseRequest(ServerRequest $request): array + { + return static::$_collection->parseRequest($request); + } + + /** + * Set current request instance. + * + * @param \Cake\Http\ServerRequest $request request object. + * @return void + */ + public static function setRequest(ServerRequest $request): void + { + static::$_request = $request; + $uri = $request->getUri(); + + 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. + * + * @return \Cake\Http\ServerRequest|null + */ + public static function getRequest(): ?ServerRequest + { + return static::$_request; + } + + /** + * Reloads default Router settings. Resets all class variables and + * removes all connected routes. + * + * @return void + */ + public static function reload(): void + { + if (static::$_initialState === []) { + static::$_collection = new RouteCollection(); + static::$_initialState = get_class_vars(static::class); + + return; + } + foreach (static::$_initialState as $key => $val) { + 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 = []; + } + + /** + * Add a URL filter to Router. + * + * URL filter functions are applied to every array $url provided to + * Router::url() before the URLs are sent to the route collection. + * + * Callback functions should expect the following parameters: + * + * - `$params` The URL params being processed. + * - `$request` The current request. + * + * The URL filter function should *always* return the params even if unmodified. + * + * ### Usage + * + * URL filters allow you to easily implement features like persistent parameters. + * + * ``` + * Router::addUrlFilter(function ($params, $request) { + * if ($request->getParam('lang') && !isset($params['lang'])) { + * $params['lang'] = $request->getParam('lang'); + * } + * return $params; + * }); + * ``` + * + * @param \Closure $function The function to add + * @return void + */ + public static function addUrlFilter(Closure $function): void + { + static::$_urlFilters[] = $function; + } + + /** + * Applies all the connected URL filters to the URL. + * + * @param array $url The URL array being modified. + * @return array The modified URL. + * @see \Cake\Routing\Router::url() + * @see \Cake\Routing\Router::addUrlFilter() + */ + protected static function _applyUrlFilters(array $url): array + { + $request = static::getRequest(); + foreach (static::$_urlFilters as $filter) { + 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; + } + + /** + * Finds URL for specified action. + * + * Returns a URL pointing to a combination of controller and action. + * + * ### Usage + * + * - `Router::url('/posts/edit/1');` Returns the string with the base dir prepended. + * 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. + * + * 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". + * - `_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. + * - `_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 \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 + */ + public static function url(UriInterface|array|string|null $url = null, bool $full = false): string + { + $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 = [ + 'plugin' => null, + 'controller' => null, + 'action' => 'index', + '_ext' => null, + ]; + if (!empty($context['params'])) { + $params = $context['params']; + } + + $frag = ''; + + if (is_array($url)) { + if (isset($url['_path'])) { + $url = self::unwrapShortString($url); + } + + if (isset($url['_https'])) { + $url['_scheme'] = $url['_https'] === true ? 'https' : 'http'; + } + + if (isset($url['_full']) && $url['_full'] === true) { + $full = true; + } + if (isset($url['#'])) { + $frag = '#' . $url['#']; + } + 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'] + ) + ) { + $url['action'] = $params['action']; + } + + // Keep the current prefix around if none set. + if (isset($params['prefix']) && !isset($url['prefix'])) { + $url['prefix'] = $params['prefix']; + } + + $url += [ + 'plugin' => $params['plugin'], + 'controller' => $params['controller'], + 'action' => 'index', + '_ext' => null, + ]; + } + + // 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 { + $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 = $context['_base'] . $url; + } + + $protocol = preg_match('#^[a-z][a-z0-9+\-.]*\://#i', $output); + if ($protocol === 0) { + $output = str_replace('//', '/', '/' . $output); + if ($full) { + $output = static::fullBaseUrl() . $output; + } + } + + 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 no parameters are passed, + * the currently configured value is returned. + * + * ### Note: + * + * If you change the configuration value `App.fullBaseUrl` during runtime + * and expect the router to produce links using the new setting, you are + * required to call this method passing such value again. + * + * @param string|null $base the prefix for URLs generated containing the domain. + * For example: `http://example.com` + * @return string + */ + 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); + } 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; + } + + /** + * Reverses a parsed parameter array into an array. + * + * Works similarly to Router::url(), but since parsed URL's contain additional + * keys like 'pass', '_matchedRoute' etc. those keys need to be specially + * handled in order to reverse a params array into a string URL. + * + * @param \Cake\Http\ServerRequest|array $params The params array or + * {@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(ServerRequest|array $params): array + { + $route = null; + if ($params instanceof ServerRequest) { + $route = $params->getAttribute('route'); + assert($route === null || $route instanceof Route); + + $queryString = $params->getQueryParams(); + $params = $params->getAttribute('params'); + assert(is_array($params)); + $params['?'] = $queryString; + } + $pass = $params['pass'] ?? []; + + $template = $params['_matchedRoute'] ?? null; + unset( + $params['pass'], + $params['_matchedRoute'], + $params['_name'], + ); + 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 array_merge($params, $pass); + } + + /** + * Reverses a parsed parameter array into a string. + * + * Works similarly to Router::url(), but since parsed URL's contain additional + * keys like 'pass', '_matchedRoute' etc. those keys need to be specially + * handled in order to reverse a params array into a string URL. + * + * @param \Cake\Http\ServerRequest|array $params The params array or + * {@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(ServerRequest|array $params, bool $full = false): string + { + $params = static::reverseToArray($params); + + return static::url($params, $full); + } + + /** + * Normalizes a URL for purposes of comparison. + * + * Will strip the base path off and replace any double /'s. + * It will not unify the casing and underscoring of the input value. + * + * @param array|string $url URL to normalize Either an array or a string URL. + * @return string Normalized URL + */ + public static function normalize(array|string $url = '/'): string + { + if (is_array($url)) { + $url = static::url($url); + } + if (preg_match('/^[a-z\-]+:\/\//', $url)) { + return $url; + } + $request = static::getRequest(); + + if ($request) { + $base = $request->getAttribute('base', ''); + if ($base !== '' && stristr($url, $base)) { + $url = (string)preg_replace('/^' . preg_quote($base, '/') . '/', '', $url, 1); + } + } + $url = '/' . $url; + + while (str_contains($url, '//')) { + $url = str_replace('//', '/', $url); + } + $url = preg_replace('/(?:(\/$))/', '', $url); + + if (!$url) { + return '/'; + } + + return $url; + } + + /** + * Get or set valid extensions for all routes connected later. + * + * 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 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 bool $merge Whether to merge with or override existing extensions. + * Defaults to `true`. + * @return array Array of extensions Router is configured to parse. + */ + public static function extensions(array|string|null $extensions = null, bool $merge = true): array + { + $collection = static::$_collection; + if ($extensions === null) { + return array_unique(array_merge(static::$_defaultExtensions, $collection->getExtensions())); + } + + $extensions = (array)$extensions; + if ($merge) { + $extensions = array_unique(array_merge(static::$_defaultExtensions, $extensions)); + } + + return static::$_defaultExtensions = $extensions; + } + + /** + * 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 + * @return \Cake\Routing\RouteBuilder + */ + public static function createRouteBuilder(string $path, array $options = []): RouteBuilder + { + $defaults = [ + 'routeClass' => static::defaultRouteClass(), + 'extensions' => static::$_defaultExtensions, + ]; + $options += $defaults; + + return new RouteBuilder(static::$_collection, $path, [], [ + 'routeClass' => $options['routeClass'], + 'extensions' => $options['extensions'], + ]); + } + + /** + * Get the route scopes and their connected routes. + * + * @return array<\Cake\Routing\Route\Route> + */ + public static function routes(): array + { + return static::$_collection->routes(); + } + + /** + * Get the RouteCollection inside the Router + * + * @return \Cake\Routing\RouteCollection + */ + public static function getRouteCollection(): RouteCollection + { + return static::$_collection; + } + + /** + * Set the RouteCollection inside the Router + * + * @param \Cake\Routing\RouteCollection $routeCollection route collection + * @return void + */ + public static function setRouteCollection(RouteCollection $routeCollection): void + { + static::$_collection = $routeCollection; + } + + /** + * Inject route defaults from `_path` key + * + * @param array $url Route array with `_path` key + * @return array + */ + protected static function unwrapShortString(array $url): array + { + 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 $url; + } + + /** + * Parse a string route path + * + * String examples: + * - Bookmarks::view + * - Admin/Bookmarks::view + * - Cms.Articles::edit + * - Vendor/Cms.Management/Admin/Articles::view + * + * @param string $url Route path in [Plugin.][Prefix/]Controller::action format + * @return array + */ + public static function parseRoutePath(string $url): array + { + 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); +} 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 @@ +` 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/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 new file mode 100644 index 00000000000..5ec467c3b70 --- /dev/null +++ b/src/TestSuite/Constraint/EventFired.php @@ -0,0 +1,74 @@ +_eventManager = $eventManager; + + if ($this->_eventManager->getEventList() === null) { + throw new AssertionFailedError( + 'The event manager you are asserting against is not configured to track events.', + ); + } + } + + /** + * Checks if event is in fired array + * + * @param mixed $other Constraint check + * @return bool + */ + public function matches(mixed $other): bool + { + $list = $this->_eventManager->getEventList(); + + return $list === null ? false : $list->hasEvent($other); + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + return 'was fired'; + } +} diff --git a/src/TestSuite/Constraint/EventFiredWith.php b/src/TestSuite/Constraint/EventFiredWith.php new file mode 100644 index 00000000000..47631e490b3 --- /dev/null +++ b/src/TestSuite/Constraint/EventFiredWith.php @@ -0,0 +1,112 @@ +_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.', + ); + } + } + + /** + * Checks if event is in fired array + * + * @param mixed $other Constraint check + * @return bool + * @throws \PHPUnit\Framework\AssertionFailedError + */ + public function matches(mixed $other): bool + { + $eventGroup = []; + $list = $this->_eventManager->getEventList(); + if ($list !== null) { + $eventGroup = (new Collection($list)) + ->groupBy(function (EventInterface $event): string { + 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), + )); + } + + $event = $events[0]; + + if (array_key_exists($this->_dataKey, (array)$event->getData()) === false) { + return false; + } + + return $event->getData($this->_dataKey) === $this->_dataValue; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + 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/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/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 new file mode 100644 index 00000000000..4697d501b6b --- /dev/null +++ b/src/TestSuite/Fixture/TestFixture.php @@ -0,0 +1,300 @@ +connection) { + $connection = $this->connection; + if (!str_starts_with($connection, 'test')) { + $message = sprintf( + 'Invalid datasource name `%s` for `%s` fixture. Fixture datasource names must begin with `test`.', + $connection, + static::class, + ); + throw new CakeException($message); + } + } + $this->init(); + } + + /** + * @inheritDoc + */ + public function connection(): string + { + return $this->connection; + } + + /** + * @inheritDoc + */ + public function sourceName(): string + { + return $this->table; + } + + /** + * Initialize the fixture. + * + * @return void + * @throws \Cake\ORM\Exception\MissingTableClassException When importing from a table that does not exist. + */ + public function init(): void + { + 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(); + } + + $this->_schemaFromReflection(); + } + + /** + * Returns the ORM table alias using the fixture class. + * + * Uses tableize() then camelize() to respect custom Inflector rules + * like uninflected words. + * + * For plugin fixtures (namespace pattern `{Plugin}\Test\Fixture\`), + * the plugin name is automatically prepended to the alias. + * + * @return string + */ + protected function _aliasFromClass(): string + { + [, $class] = namespaceSplit(static::class); + preg_match('/^(.*)Fixture$/', $class, $matches); + $name = $matches[1] ?? $class; + + $alias = Inflector::camelize(Inflector::tableize($name)); + + // 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; + } + + return $alias; + } + + /** + * Build fixture schema directly from the datasource + * + * @return void + * @throws \Cake\Core\Exception\CakeException when trying to reflect a table that does not exist + */ + protected function _schemaFromReflection(): void + { + $db = ConnectionManager::get($this->connection()); + assert($db instanceof Connection); + try { + $ormTable = $this->fetchTable($this->tableAlias, ['connection' => $db]); + + // 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); + + if (!$this->table) { + $this->table = $ormTable->getTable(); + } + + $schema = $ormTable->getSchema(); + assert($schema instanceof TableSchema); + $this->_schema = $schema; + + $this->getTableLocator()->clear(); + } catch (CakeException $e) { + $message = sprintf( + 'Cannot describe schema for table `%s` for fixture `%s`. The table does not exist.', + $this->table, + static::class, + ); + throw new CakeException($message, null, $e); + } + } + + /** + * @inheritDoc + */ + public function insert(ConnectionInterface $connection): bool + { + assert($connection instanceof Connection); + if ($this->records) { + [$fields, $values, $types] = $this->_getRecords(); + $query = $connection->insertQuery() + ->insert($fields, $types) + ->into($this->sourceName()); + + foreach ($values as $row) { + $query->values($row); + } + $query->execute(); + } + + return true; + } + + /** + * Converts the internal records into data used to generate a query. + * + * @return array + */ + protected function _getRecords(): array + { + $fields = []; + $values = []; + $types = []; + $columns = $this->_schema->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)); + } + /** @var list $fields */ + $fields = array_values($fields); + foreach ($fields as $field) { + $column = $this->_schema->getColumn($field); + assert($column !== null); + $types[$field] = $column['type']; + } + $default = array_fill_keys($fields, null); + foreach ($this->records as $record) { + $values[] = array_merge($default, $record); + } + + return [$fields, $values, $types]; + } + + /** + * @inheritDoc + */ + public function truncate(ConnectionInterface $connection): bool + { + assert($connection instanceof Connection); + $sql = $this->_schema->truncateSql($connection); + foreach ($sql as $stmt) { + $connection->execute($stmt); + } + + return true; + } + + /** + * Returns the table schema for this fixture. + * + * @return \Cake\Database\Schema\TableSchemaInterface&\Cake\Database\Schema\SqlGeneratorInterface + */ + public function getTableSchema(): TableSchemaInterface&SqlGeneratorInterface + { + return $this->_schema; + } +} 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/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/LogTestTrait.php b/src/TestSuite/LogTestTrait.php new file mode 100644 index 00000000000..ad0a04ff8f9 --- /dev/null +++ b/src/TestSuite/LogTestTrait.php @@ -0,0 +1,164 @@ + $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; + $expectedMessage = sprintf('%s: %s', $level, $expectedMessage); + 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 ($contains && str_contains($message, $expectedMessage) || $message === $expectedMessage) { + $messageFound = true; + break; + } + } + } + if (!$messageFound) { + $failMsg = "Could not find the message `{$expectedMessage}` in logs. " . $failMsg; + $this->fail($failMsg); + } + $this->assertTrue(true); + } +} diff --git a/src/TestSuite/MiddlewareDispatcher.php b/src/TestSuite/MiddlewareDispatcher.php new file mode 100644 index 00000000000..4e6f18ab892 --- /dev/null +++ b/src/TestSuite/MiddlewareDispatcher.php @@ -0,0 +1,156 @@ +app = $app; + } + + /** + * Resolve the provided URL into a string. + * + * @param array|string $url The URL array/string to resolve. + * @return string + * @deprecated 5.1.0 Use IntegrationTestTrait::resolveUrl() instead. + */ + public function resolveUrl(array|string $url): string + { + 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); + } + + /** + * Convert a URL array into a string URL via routing. + * + * @param array $url The url to resolve + * @return string + * @deprecated 5.1.0 Use IntegrationTestTrait::resolveRouter() instead. + */ + protected function resolveRoute(array $url): string + { + 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('/'); + + if ($this->app instanceof RoutingApplicationInterface) { + $this->app->routes($builder); + } + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginRoutes($builder); + } + + $out = Router::url($url); + Router::resetRoutes(); + + return $out; + } + + /** + * Create a PSR7 request from the request spec. + * + * @param array $spec The request spec. + * @return \Cake\Http\ServerRequest + */ + 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'], + ); + if (str_contains($environment['PHP_SELF'], 'phpunit')) { + $environment['PHP_SELF'] = '/'; + } + $request = ServerRequestFactory::fromGlobals( + $environment, + $spec['query'], + $spec['post'], + $spec['cookies'], + $spec['files'], + ); + + 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 $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 new file mode 100644 index 00000000000..68663e9d567 --- /dev/null +++ b/src/TestSuite/StringCompareTrait.php @@ -0,0 +1,76 @@ +_compareBasePath . $path; + } + + $this->_updateComparisons ??= (bool)env('UPDATE_TEST_COMPARISON_FILES'); + + if ($this->_updateComparisons) { + file_put_contents($path, $result); + } + + $expected = file_get_contents($path); + $this->assertTextEquals($expected, $result, 'Content does not match file ' . $path); + } +} diff --git a/src/TestSuite/Stub/TestExceptionRenderer.php b/src/TestSuite/Stub/TestExceptionRenderer.php new file mode 100644 index 00000000000..e26f91b1836 --- /dev/null +++ b/src/TestSuite/Stub/TestExceptionRenderer.php @@ -0,0 +1,64 @@ + + */ + protected array $fixtures = []; + + /** + * @var \Cake\TestSuite\Fixture\FixtureStrategyInterface|null + */ + protected ?FixtureStrategyInterface $fixtureStrategy = null; + + /** + * Configure values to restore at end of test. + * + * @var array + */ + protected array $_configure = []; + + /** + * Plugins to be loaded after app instance is created ContainerStubTrait::creatApp() + * + * @var array + */ + protected array $appPluginsToLoad = []; + + /** + * @var \Cake\Error\PhpError|null + */ + private ?PhpError $_capturedError = null; + + /** + * Overrides SimpleTestCase::skipIf to provide a boolean return value + * + * @param bool $shouldSkip Whether the test should be skipped. + * @param string $message The message to display. + * @return bool + */ + public function skipIf(bool $shouldSkip, string $message = ''): bool + { + if ($shouldSkip) { + $this->markTestSkipped($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 + * not already been backed up. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->setupFixtures(); + + if (!$this->_configure) { + $this->_configure = Configure::read(); + } + 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); + } + } + + /** + * teardown any static object changes and restore them. + * + * @return void + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->teardownFixtures(); + + if ($this->_configure) { + Configure::clear(); + Configure::write($this->_configure); + } + $this->getTableLocator()->clear(); + $this->_configure = []; + $this->_tableLocator = null; + if (class_exists(Mockery::class)) { + Mockery::close(); + } + } + + /** + * Initialized and loads any use fixtures. + * + * @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 + * @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<\Cake\Http\BaseApplication> + */ + public function loadPlugins(array $plugins = []): BaseApplication // @phpstan-ignore missingType.generics + { + $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 loadAllPlugins(?string $configPath = null) + { + $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; + } + } + } + + // Ensure we have an array + if (!is_array($plugins)) { + $plugins = []; + } + + // 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 \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(string $name, ?EventManager $eventManager = null, string $message = ''): void + { + if (!$eventManager) { + $eventManager = EventManager::instance(); + } + $this->assertThat($name, new EventFired($eventManager), $message); + } + + /** + * Asserts an event was fired with data + * + * If a third argument is passed, that value is used to compare with the value in $dataKey + * + * @param string $name Event name + * @param string $dataKey Data key + * @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( + string $name, + string $dataKey, + mixed $dataValue, + ?EventManager $eventManager = null, + string $message = '', + ): void { + if (!$eventManager) { + $eventManager = EventManager::instance(); + } + $this->assertThat($name, new EventFiredWith($eventManager, $dataKey, $dataValue), $message); + } + + /** + * Assert text equality, ignoring differences in newlines. + * Helpful for doing cross platform tests of blocks of text. + * + * @param string $expected The expected value. + * @param string $result The actual value. + * @param string $message The message to use for failure. + * @return void + */ + 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); + $this->assertNotEquals($expected, $result, $message); + } + + /** + * Assert text equality, ignoring differences in newlines. + * Helpful for doing cross platform tests of blocks of text. + * + * @param string $expected The expected value. + * @param string $result The actual value. + * @param string $message The message to use for failure. + * @return void + */ + 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); + $this->assertEquals($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 string $string The string to search in. + * @param string $message The message to use for failure. + * @return void + * @phpstan-param non-empty-string $prefix + */ + 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); + } + + /** + * 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 string $string The string to search. + * @param string $message The message to use for failure. + * @return void + * @phpstan-param non-empty-string $prefix + */ + 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); + } + + /** + * 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 string $string The string to search. + * @param string $message The message to use for failure. + * @return void + * @phpstan-param non-empty-string $suffix + */ + 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); + } + + /** + * 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 string $string The string to search. + * @param string $message The message to use for failure. + * @return void + * @phpstan-param non-empty-string $suffix + */ + 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); + } + + /** + * Assert that a string contains another string, ignoring differences in newlines. + * Helpful for doing cross platform tests of blocks of text. + * + * @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 the search should be case-sensitive. + * @return void + */ + 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); + + if ($ignoreCase) { + $this->assertStringContainsStringIgnoringCase($needle, $haystack, $message); + } else { + $this->assertStringContainsString($needle, $haystack, $message); + } + } + + /** + * Assert that a text doesn't contain another text, ignoring differences in newlines. + * Helpful for doing cross platform tests of blocks of text. + * + * @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 the search should be case-sensitive. + * @return void + */ + 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); + + if ($ignoreCase) { + $this->assertStringNotContainsStringIgnoringCase($needle, $haystack, $message); + } else { + $this->assertStringNotContainsString($needle, $haystack, $message); + } + } + + /** + * Assert that a string matches SQL with db-specific characters like quotes removed. + * + * @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 + */ + public function assertRegExpSql(string $pattern, string $actual, bool $optional = false): void + { + $optional = $optional ? '?' : ''; + $pattern = str_replace('<', '[`"\[]' . $optional, $pattern); + $pattern = str_replace('>', '[`"\]]' . $optional, $pattern); + $this->assertMatchesRegularExpression('#' . $pattern . '#', $actual); + } + + /** + * Asserts HTML tags. + * + * Takes an array $expected and generates a regex from it to match the provided $string. + * Samples for $expected: + * + * Checks for an input tag with a name attribute (contains any non-empty value) and an id + * attribute that contains 'my-input': + * + * ``` + * ['input' => ['name', 'id' => 'my-input']] + * ``` + * + * Checks for two p elements with some text in them: + * + * ``` + * [ + * ['p' => true], + * 'textA', + * '/p', + * ['p' => true], + * 'textB', + * '/p' + * ] + * ``` + * + * You can also specify a pattern expression as part of the attribute values, or the tag + * being defined, if you prepend the value with preg: and enclose it with slashes, like so: + * + * ``` + * [ + * ['input' => ['name', 'id' => 'preg:/FieldName\d+/']], + * 'preg:/My\s+field/' + * ] + * ``` + * + * Important: This function is very forgiving about whitespace and also accepts any + * permutation of attribute order. It will also allow whitespace between specified tags. + * + * @param array $expected An array, see above + * @param string $string An HTML/XHTML/XML string + * @param bool $fullDebug Whether more verbose output should be used. + * @return bool + */ + public function assertHtml(array $expected, string $string, bool $fullDebug = false): bool + { + $regex = []; + $normalized = []; + foreach ($expected as $key => $val) { + if (!is_numeric($key)) { + $normalized[] = [$key => $val]; + } else { + $normalized[] = $val; + } + } + $i = 0; + foreach ($normalized as $tags) { + if (!is_array($tags)) { + $tags = (string)$tags; + } + $i++; + 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 = ['', '']; + + if ($match[0] === '*/') { + $prefix = ['Anything, ', '.*?']; + } + $regex[] = [ + sprintf('%sClose %s tag', $prefix[0], substr($tags, strlen($match[0]))), + sprintf('%s\s*<[\s]*\/[\s]*%s[\s]*>[\n\r]*', $prefix[1], substr($tags, strlen($match[0]))), + $i, + ]; + continue; + } + if ($tags && preg_match('/^preg\:\/(.+)\/$/i', $tags, $matches)) { + $tags = $matches[1]; + $type = 'Regex matches'; + } else { + $tags = '\s*' . preg_quote($tags, '/'); + $type = 'Text equals'; + } + $regex[] = [ + sprintf('%s `%s`', $type, $tags), + $tags, + $i, + ]; + continue; + } + foreach ($tags as $tag => $attributes) { + $regex[] = [ + sprintf('Open %s tag', $tag), + sprintf('[\s]*<%s', preg_quote($tag, '/')), + $i, + ]; + if ($attributes === true) { + $attributes = []; + } + $attrs = []; + $explanations = []; + $i = 1; + foreach ($attributes as $attr => $val) { + if (is_numeric($attr) && preg_match('/^preg:\/(.+)\/$/i', (string)$val, $matches)) { + $attrs[] = $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 ($val && preg_match('/^preg:\/(.+)\/$/i', $val, $matches)) { + $val = str_replace( + ['.*', '.+'], + ['.*?', '.+?'], + $matches[1], + ); + $quotes = $val !== $matches[1] ? '["\']' : '["\']?'; + + $explanations[] = sprintf('Attribute `%s` matches `%s`', $attr, $val); + } else { + $explanations[] = sprintf('Attribute `%s` == `%s`', $attr, $val); + $val = preg_quote($val, '/'); + } + $attrs[] = '[\s]+' . preg_quote($attr, '/') . '=' . $quotes . $val . $quotes; + $i++; + } + if ($attrs) { + $regex[] = [ + 'explains' => $explanations, + 'attrs' => $attrs, + ]; + } + $regex[] = [ + sprintf('End %s tag', $tag), + '[\s]*\/?[\s]*>[\n\r]*', + $i, + ]; + } + } + + 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 && $string === false) { + debug($string, true); + debug($regex, true); + } + continue; + } + + // 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 ($string && preg_match($expression, $string, $match)) { + $matches = true; + $string = substr($string, strlen($match[0])); + break; + } + } + if (!$matches) { + if ($fullDebug) { + debug($string); + debug($regex); + } + $this->assertMatchesRegularExpression( + $expression, + (string)$string, + sprintf('Item #%d / regex #%d failed: %s', $itemNum, $i, $description), + ); + + return false; + } + } + + $this->assertTrue(true, '%s'); + + return true; + } + + /** + * Check the attributes as part of an assertTags() check. + * + * @param array $assertions Assertions to run. + * @param string $string The HTML string to check. + * @param bool $fullDebug Whether more verbose output should be used. + * @param array|string $regex Full regexp from `assertHtml` + * @return string|false + */ + protected function _assertAttributes( + array $assertions, + string $string, + bool $fullDebug = false, + array|string $regex = '', + ): string|false { + $asserts = $assertions['attrs']; + $explains = $assertions['explains']; + do { + $matches = false; + $j = null; + foreach ($asserts as $j => $assert) { + if (preg_match(sprintf('/^%s/s', $assert), $string, $match)) { + $matches = true; + $string = substr($string, strlen($match[0])); + array_splice($asserts, $j, 1); + array_splice($explains, $j, 1); + break; + } + } + if ($matches === false) { + if ($fullDebug) { + debug($string); + debug($regex); + } + $this->assertTrue(false, 'Attribute did not match. Was expecting ' . $explains[$j]); + } + $len = count($asserts); + } while ($len > 0); + + return $string; + } + + /** + * Normalize a path for comparison. + * + * @param string $path Path separated by "/" slash. + * @return string Normalized path separated by DIRECTORY_SEPARATOR. + */ + protected function _normalizePath(string $path): string + { + return str_replace('/', DIRECTORY_SEPARATOR, $path); + } + +// phpcs:disable + + /** + * Compatibility function to test if a value is between an acceptable range. + * + * @param float $expected + * @param float $result + * @param float $margin the rage of acceptation + * @param string $message the text to display if the assertion is not correct + * @return void + */ + protected static function assertWithinRange($expected, $result, $margin, $message = '') + { + $upper = $result + $margin; + $lower = $result - $margin; + self::assertTrue(($expected <= $upper) && ($expected >= $lower), $message); + } + + /** + * Compatibility function to test if a value is not between an acceptable range. + * + * @param float $expected + * @param float $result + * @param float $margin the rage of acceptation + * @param string $message the text to display if the assertion is not correct + * @return void + */ + protected static function assertNotWithinRange($expected, $result, $margin, $message = '') + { + $upper = $result + $margin; + $lower = $result - $margin; + self::assertTrue(($expected > $upper) || ($expected < $lower), $message); + } + + /** + * Compatibility function to test paths. + * + * @param string $expected + * @param string $result + * @param string $message the text to display if the assertion is not correct + * @return void + */ + protected static function assertPathEquals($expected, $result, $message = '') + { + $expected = str_replace(DIRECTORY_SEPARATOR, '/', $expected); + $result = str_replace(DIRECTORY_SEPARATOR, '/', $result); + self::assertEquals($expected, $result, $message); + } + + /** + * Compatibility function for skipping. + * + * @param bool $condition Condition to trigger skipping + * @param string $message Message for skip + * @return bool + */ + protected function skipUnless($condition, $message = '') + { + if (!$condition) { + $this->markTestSkipped($message); + } + + return $condition; + } + +// phpcs:enable + + /** + * Mock a model, 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. + * @throws \Cake\ORM\Exception\MissingTableClassException + * @return \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject + */ + public function getMockForModel(string $alias, array $methods = [], array $options = []): Table|MockObject + { + $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); + } + } + + if (stripos($mock->getTable(), 'mock') === 0) { + $mock->setTable(Inflector::tableize($baseClass)); + } + + $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 string|null The previous app namespace or null if not set. + */ + 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..0b34fd257ce --- /dev/null +++ b/src/TestSuite/TestEmailTransport.php @@ -0,0 +1,87 @@ + Contains 'headers' and 'message' keys. Additional keys allowed. + * @phpstan-return array{headers: string, message: string, ...} + */ + public function send(Message $message): array + { + static::$messages[] = clone $message; + + return parent::send($message); + } + + /** + * Replaces all currently configured transports with this one + * + * @return void + */ + public static function replaceAllTransports(): void + { + $configuredTransports = TransportFactory::configured(); + + foreach ($configuredTransports as $configuredTransport) { + $config = TransportFactory::getConfig($configuredTransport); + $config['className'] = self::class; + TransportFactory::drop($configuredTransport); + TransportFactory::setConfig($configuredTransport, $config); + } + } + + /** + * Gets emails sent + * + * @return array<\Cake\Mailer\Message> + */ + 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/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 array $_validCiphers = ['aes']; + + /** + * Returns the encryption key to be used. + * + * @return string + */ + abstract protected function _getCookieEncryptionKey(): string; + + /** + * Encrypts $value using public $type method in Security class + * + * @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(array|string $value, string|false $encrypt, ?string $key = null): string + { + if (is_array($value)) { + $value = $this->_implode($value); + } + if ($encrypt === false) { + return $value; + } + $this->_checkCipher($encrypt); + $prefix = 'Q2FrZQ==.'; + $cipher = ''; + $key ??= $this->_getCookieEncryptionKey(); + if ($encrypt === 'aes') { + $cipher = Security::encrypt($value, $key); + } + + return $prefix . base64_encode($cipher); + } + + /** + * Helper method for validating encryption cipher names. + * + * @param string $encrypt The cipher name. + * @return void + * @throws \RuntimeException When an invalid cipher is provided. + */ + protected function _checkCipher(string $encrypt): void + { + if (!in_array($encrypt, $this->_validCiphers, true)) { + $msg = sprintf( + 'Invalid encryption cipher. Must be one of %s or false.', + implode(', ', $this->_validCiphers), + ); + throw new InvalidArgumentException($msg); + } + } + + /** + * Decrypts $value using public $type method in Security class + * + * @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 array|string Decrypted values + */ + protected function _decrypt(array|string $values, string|false $mode, ?string $key = null): array|string + { + if (is_string($values)) { + return $this->_decode($values, $mode, $key); + } + + $decrypted = []; + foreach ($values as $name => $value) { + $decrypted[$name] = $this->_decode($value, $mode, $key); + } + + return $decrypted; + } + + /** + * Decodes and decrypts a single value. + * + * @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 array|string Decoded values. + */ + protected function _decode(string $value, string|false $encrypt, ?string $key): array|string + { + if (!$encrypt) { + return $this->_explode($value); + } + $this->_checkCipher($encrypt); + $prefix = 'Q2FrZQ==.'; + $prefixLength = strlen($prefix); + + if (strncmp($value, $prefix, $prefixLength) !== 0) { + return ''; + } + + $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 in multidimensional arrays + * + * @param array $array Map of key and values + * @return string A JSON encoded string. + */ + protected function _implode(array $array): string + { + return json_encode($array, JSON_THROW_ON_ERROR); + } + + /** + * Explode method to return array from string set in CookieComponent::_implode() + * Maintains reading backwards compatibility with 1.x CookieComponent::_implode(). + * + * @param string $string A string containing JSON encoded data, or a bare string. + * @return array|string Map of key and values + */ + protected function _explode(string $string): array|string + { + $first = substr($string, 0, 1); + if ($first === '{' || $first === '[') { + return json_decode($string, true) ?? $string; + } + $array = []; + foreach (explode(',', $string) as $pair) { + $key = explode('|', $pair); + if (!isset($key[1])) { + return $key[0]; + } + $array[$key[0]] = $key[1]; + } + + return $array; + } +} diff --git a/src/Utility/Crypto/OpenSsl.php b/src/Utility/Crypto/OpenSsl.php new file mode 100644 index 00000000000..ea1f6e235ab --- /dev/null +++ b/src/Utility/Crypto/OpenSsl.php @@ -0,0 +1,87 @@ +filterIterator($directory, $filter); + } + + /** + * Find files/ directories recursively in given directory path. + * + * @param string $path Directory path. + * @param \Closure|string|null $filter If string will be used as regex for filtering using + * `RegexIterator`, if callable will be as callback for `CallbackFilterIterator`. + * Hidden directories (starting with dot e.g. .git) are always skipped. + * @param int|null $flags Flags for FilesystemIterator::__construct(); + * @return \Iterator + */ + public function findRecursive(string $path, Closure|string|null $filter = null, ?int $flags = null): Iterator + { + $flags ??= FilesystemIterator::KEY_AS_PATHNAME + | FilesystemIterator::CURRENT_AS_FILEINFO + | FilesystemIterator::SKIP_DOTS; + $directory = new RecursiveDirectoryIterator($path, $flags); + + $dirFilter = new RecursiveCallbackFilterIterator( + $directory, + function (SplFileInfo $current) { + if (str_starts_with($current->getFilename(), '.') && $current->isDir()) { + return false; + } + + return true; + }, + ); + + $flatten = new RecursiveIteratorIterator( + $dirFilter, + RecursiveIteratorIterator::CHILD_FIRST, + ); + + if ($filter === null) { + return $flatten; + } + + return $this->filterIterator($flatten, $filter); + } + + /** + * Wrap iterator in additional filtering iterator. + * + * @param \Iterator $iterator Iterator + * @param \Closure|string $filter Regex string or callback. + * @return \Iterator + */ + protected function filterIterator(Iterator $iterator, Closure|string $filter): Iterator + { + if (is_string($filter)) { + return new RegexIterator($iterator, $filter); + } + + return new CallbackFilterIterator($iterator, $filter); + } + + /** + * Dump contents to file. + * + * @param string $filename File path. + * @param string $content Content to dump. + * @return void + * @throws \Cake\Core\Exception\CakeException When dumping fails. + */ + public function dumpFile(string $filename, string $content): void + { + $dir = dirname($filename); + if (!is_dir($dir)) { + $this->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/Hash.php b/src/Utility/Hash.php new file mode 100644 index 00000000000..f8abbda50db --- /dev/null +++ b/src/Utility/Hash.php @@ -0,0 +1,1285 @@ +|array $data Array of data or object implementing + * \ArrayAccess interface to operate on. + * @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 $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 + */ + public static function get(ArrayAccess|array $data, array|string|int|null $path, mixed $default = null): mixed + { + if (!$data || $path === null) { + return $default; + } + + if (is_string($path) || is_int($path)) { + $parts = explode('.', (string)$path); + } else { + $parts = $path; + } + + switch (count($parts)) { + case 1: + return $data[$parts[0]] ?? $default; + case 2: + return $data[$parts[0]][$parts[1]] ?? $default; + case 3: + return $data[$parts[0]][$parts[1]][$parts[2]] ?? $default; + default: + foreach ($parts as $key) { + if ((is_array($data) || $data instanceof ArrayAccess) && isset($data[$key])) { + $data = $data[$key]; + } else { + return $default; + } + } + } + + return $data; + } + + /** + * Gets the values from an array matching the $path expression. + * The path expression is a dot separated expression, that can contain a set + * of patterns and expressions: + * + * - `{n}` Matches any numeric key, or integer. + * - `{s}` Matches any string key. + * - `{*}` Matches any value. + * - `Foo` Matches any key with the exact same value. + * + * There are a number of attribute operators: + * + * - `=`, `!=` Equality. + * - `>`, `<`, `>=`, `<=` Value comparison. + * - `=/.../` Regular expression pattern match. + * + * 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. + * - `{n}.User[id].name` Get the name of every user with an id key. + * - `{n}.User[id>=2].name` Get the name of every user with an id key greater than or equal to 2. + * - `{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 \ArrayAccess|array $data The data to extract from. + * @param string $path The path to extract. + * @return \ArrayAccess|array An array of the extracted values. Returns an empty array + * if there are no matches. + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-extract + * @phpstan-return ($path is non-empty-string ? array : \ArrayAccess|array) + */ + public static function extract(ArrayAccess|array $data, string $path): ArrayAccess|array + { + if (!$path) { + return $data; + } + + // Simple paths. + if (!preg_match('/[{\[]/', $path)) { + $data = static::get($data, $path); + if ($data !== null && (!is_array($data) && !$data instanceof ArrayAccess)) { + return [$data]; + } + + return $data !== null ? (array)$data : []; + } + + if (!str_contains($path, '[')) { + $tokens = explode('.', $path); + } else { + $tokens = Text::tokenize($path, '.', '[', ']'); + } + + $_key = '__set_item__'; + + $context = [$_key => [$data]]; + + foreach ($tokens as $token) { + $next = []; + + [$token, $conditions] = self::_splitConditions($token); + + foreach ($context[$_key] as $item) { + if (is_object($item) && method_exists($item, 'toArray')) { + /** @var \Cake\Datasource\EntityInterface $item */ + $item = $item->toArray(); + } + foreach ((array)$item as $k => $v) { + if (static::_matchToken($k, $token)) { + $next[] = $v; + } + } + } + + // Filter for attributes. + if ($conditions) { + $filter = []; + foreach ($next as $item) { + if ( + ( + is_array($item) || + $item instanceof ArrayAccess + ) && + static::_matches($item, $conditions) + ) { + $filter[] = $item; + } + } + $next = $filter; + } + $context = [$_key => $next]; + } + + return $context[$_key]; + } + + /** + * Split token conditions + * + * @param string $token the token being split. + * @return array [token, conditions] with token split + */ + protected static function _splitConditions(string $token): array + { + $conditions = false; + $position = strpos($token, '['); + if ($position !== false) { + $conditions = substr($token, $position); + $token = substr($token, 0, $position); + } + + return [$token, $conditions]; + } + + /** + * Check a key against a token. + * + * @param mixed $key The key in the array being searched. + * @param string $token The token being matched. + * @return bool + */ + protected static function _matchToken(mixed $key, string $token): bool + { + return match ($token) { + '{n}' => is_numeric($key), + '{s}' => is_string($key), + '{*}' => true, + default => is_numeric($token) ? ($key == $token) : $key === $token, + }; + } + + /** + * Checks whether $data matches the attribute patterns + * + * @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(ArrayAccess|array $data, string $selector): bool + { + preg_match_all( + '/(\[ (?P[^=>[><]) \s* (?P(?:\/.*?\/ | [^\]]+)) )? \])/x', + $selector, + $conditions, + PREG_SET_ORDER, + ); + + foreach ($conditions as $cond) { + $attr = $cond['attr']; + $op = $cond['op'] ?? null; + $val = $cond['val'] ?? null; + + // Presence test. + 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 (!$attrPresent) { + return false; + } + + $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. + if ($op === '=' && $val && $val[0] === '/') { + if (!preg_match($val, $prop)) { + return false; + } + // 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; + } + } + + return true; + } + + /** + * Insert $values into an array with the given $path. You can use + * `{n}` and `{s}` elements to insert $data multiple times. + * + * @template T of \ArrayAccess|array + * @param T $data The data to insert into. + * @param string $path The path to insert at. + * @param mixed $values The values to insert. + * @return \ArrayAccess|array The data with $values inserted. + * @phpstan-return (T is array ? array : \ArrayAccess) + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-insert + */ + public static function insert(ArrayAccess|array $data, string $path, mixed $values = null): ArrayAccess|array + { + $noTokens = !str_contains($path, '['); + if ($noTokens && !str_contains($path, '.')) { + $data[$path] = $values; + + return $data; + } + + if ($noTokens) { + $tokens = explode('.', $path); + } else { + $tokens = Text::tokenize($path, '.', '[', ']'); + } + + 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); + + [$token, $conditions] = static::_splitConditions($token); + + foreach ($data as $k => $v) { + 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); + } + } + + return $data; + } + + /** + * Perform a simple insert/remove operation. + * + * @param string $op The operation to do. + * @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 \ArrayAccess|array + */ + protected static function _simpleOp( + string $op, + ArrayAccess|array $data, + array $path, + mixed $values = null, + ): ArrayAccess|array { + $_list = &$data; + + $count = count($path); + $last = $count - 1; + foreach ($path as $i => $key) { + if ($op === 'insert') { + if ($i === $last) { + $_list[$key] = $values; + + return $data; + } + $_list[$key] ??= []; + $_list = &$_list[$key]; + if (!is_array($_list) && !$_list instanceof ArrayAccess) { + $_list = []; + } + } elseif ($op === 'remove') { + if ($i === $last) { + if (is_array($_list) || $_list instanceof ArrayAccess) { + unset($_list[$key]); + } + + return $data; + } + if (!isset($_list[$key])) { + return $data; + } + $_list = &$_list[$key]; + } + } + + return $data; + } + + /** + * Remove data matching $path from the $data array. + * You can use `{n}` and `{s}` to remove multiple elements + * from $data. + * + * @template T of \ArrayAccess|array + * @param T $data The data to operate on + * @param string $path A path expression to use to remove. + * @return \ArrayAccess|array The modified array. + * @phpstan-return (T is array ? array : \ArrayAccess) + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-remove + */ + public static function remove(ArrayAccess|array $data, string $path): ArrayAccess|array + { + $noTokens = !str_contains($path, '['); + $noExpansion = !str_contains($path, '{'); + + if ($noExpansion && $noTokens && !str_contains($path, '.')) { + unset($data[$path]); + + return $data; + } + + $tokens = $noTokens ? explode('.', $path) : Text::tokenize($path, '.', '[', ']'); + + if ($noExpansion && $noTokens) { + 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); + + [$token, $conditions] = self::_splitConditions($token); + + foreach ($data as $k => $v) { + $match = static::_matchToken($k, $token); + if ($match && (is_array($v) || $v instanceof ArrayAccess)) { + /** @var \ArrayAccess|array $v */ + if ($conditions) { + if (static::_matches($v, $conditions)) { + if ($nextPath !== '') { + $data[$k] = static::remove($v, $nextPath); + } else { + unset($data[$k]); + } + } + } else { + $data[$k] = static::remove($v, $nextPath); + } + if (empty($data[$k])) { + unset($data[$k]); + } + } elseif ($match && $nextPath === '') { + unset($data[$k]); + } + } + + return $data; + } + + /** + * Creates an associative array using `$keyPath` as the path to build its keys, and optionally + * `$valuePath` as path to get the values. If `$valuePath` is not specified, all values will be initialized + * to null (useful for Hash::merge). You can optionally group the values by what is obtained when + * following the path specified in `$groupPath`. + * + * @param array $data Array from where to extract keys and values + * @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/5/en/core-libraries/hash.html#hash-combine + * @throws \InvalidArgumentException When keys and values count is unequal. + */ + 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 ($keyPath !== null && empty($keys)) { + return []; + } + + $vals = null; + if ($valuePath && is_array($valuePath)) { + $format = array_shift($valuePath); + $vals = static::format($data, $valuePath, $format); + assert(is_array($vals)); + } elseif ($valuePath) { + $vals = static::extract($data, $valuePath); + assert(is_array($vals)); + } + if (!$vals) { + $vals = array_fill(0, $keys === null ? count($data) : count($keys), null); + } + + 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 ($group) { + $c = is_array($keys) ? count($keys) : count($vals); + $out = []; + for ($i = 0; $i < $c; $i++) { + $group[$i] ??= 0; + $out[$group[$i]] ??= []; + if ($keys === null) { + $out[$group[$i]][] = $vals[$i]; + } else { + $out[$group[$i]][$keys[$i]] = $vals[$i]; + } + } + + return $out; + } + } + if (!$vals) { + return []; + } + + return array_combine($keys ?? range(0, count($vals) - 1), $vals); + } + + /** + * Returns a formatted series of values extracted from `$data`, using + * `$format` as the format and `$paths` as the values to extract. + * + * Usage: + * + * ``` + * $result = Hash::format($users, ['{n}.User.id', '{n}.User.name'], '%s : %s'); + * ``` + * + * 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 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`, + * 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() + * @phpstan-return ($paths is non-empty-array ? array : null) + */ + public static function format(array $data, array $paths, string $format): ?array + { + $extracted = []; + $count = count($paths); + + if ($count === 0) { + return null; + } + + for ($i = 0; $i < $count; $i++) { + $extracted[] = static::extract($data, $paths[$i]); + } + $out = []; + /** @var array $data */ + $data = $extracted; + $count = count($data[0]); + + $countTwo = count($data); + for ($j = 0; $j < $count; $j++) { + $args = []; + for ($i = 0; $i < $countTwo; $i++) { + if (array_key_exists($j, $data[$i])) { + $args[] = $data[$i][$j]; + } + } + $out[] = vsprintf($format, $args); + } + + return $out; + } + + /** + * 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 find in $data + * @return bool true If $data contains $needle, false otherwise + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-contains + */ + public static function contains(array $data, array $needle): bool + { + if (!$data || !$needle) { + return false; + } + $stack = []; + + while ($needle) { + $key = key($needle); + $val = $needle[$key]; + unset($needle[$key]); + + if (array_key_exists($key, $data) && is_array($val)) { + $next = $data[$key]; + unset($data[$key]); + + if ($val) { + $stack[] = [$val, $next]; + } + } elseif (!array_key_exists($key, $data) || $data[$key] != $val) { + return false; + } + + if (!$needle && $stack) { + [$needle, $data] = array_pop($stack); + } + } + + return true; + } + + /** + * 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 + * make sure that at least one matching element exists. + * + * @param array $data The data to check. + * @param string $path The path to check for. + * @return bool Existence of path. + * @see \Cake\Utility\Hash::extract() + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-check + */ + public static function check(array $data, string $path): bool + { + $results = static::extract($data, $path); + if (!is_array($results)) { + return false; + } + + return $results !== []; + } + + /** + * Recursively filters a data set. + * + * @param array $data Either an array to filter, or value when in callback + * @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/5/en/core-libraries/hash.html#hash-filter + */ + public static function filter(array $data, ?callable $callback = null): array + { + foreach ($data as $k => $v) { + if (is_array($v)) { + $data[$k] = static::filter($v, $callback); + } + } + + return array_filter($data, $callback ?? static::_filter(...)); + } + + /** + * Callback function for filtering. + * + * @param mixed $var Array to filter. + * @return bool + */ + protected static function _filter(mixed $var): bool + { + 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']`. + * + * @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/5/en/core-libraries/hash.html#hash-flatten + */ + public static function flatten(array $data, string $separator = '.'): array + { + $result = []; + $stack = []; + $path = ''; + + while (!empty($data)) { + $key = array_key_first($data); + $element = $data[$key]; + unset($data[$key]); + + if (is_array($element) && $element !== []) { + if ($data) { + $stack[] = [$data, $path]; + } + $data = $element; + reset($data); + $path .= $key . $separator; + } else { + $result[$path . $key] = $element; + } + + if (!$data && $stack) { + [$data, $path] = array_pop($stack); + reset($data); + } + } + + return $result; + } + + /** + * Expands a flat array to a nested array. + * + * For example, unflattens an array that was collapsed with `Hash::flatten()` + * into a multi-dimensional array. So, `['0.Foo.Bar' => 'Far']` becomes + * `[['Foo' => ['Bar' => 'Far']]]`. + * + * @phpstan-param non-empty-string $separator + * @param array $data Flattened array + * @param string $separator The delimiter used + * @return array + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-expand + */ + public static function expand(array $data, string $separator = '.'): array + { + $hash = []; + foreach ($data as $path => $value) { + $keys = explode($separator, (string)$path); + if (count($keys) === 1) { + $hash[$path] = $value; + continue; + } + + $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 $hash; + } + + /** + * This function can be thought of as a hybrid between PHP's `array_merge` and `array_merge_recursive`. + * + * The difference between this method and the built-in ones, is that if an array key contains another array, then + * 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`). + * + * 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/5/en/core-libraries/hash.html#hash-merge + */ + public static function merge(array $data, mixed $merge): array + { + $args = array_slice(func_get_args(), 1); + $return = $data; + $stack = []; + + foreach ($args as &$curArg) { + $stack[] = [(array)$curArg, &$return]; + } + unset($curArg); + static::_merge($stack, $return); + + return $return; + } + + /** + * Merge helper function to reduce duplicated code between merge() and expand(). + * + * @param array $stack The stack of operations to work with. + * @param array $return The return value to operate on. + * @return void + */ + protected static function _merge(array $stack, array &$return): void + { + while ($stack !== []) { + foreach ($stack as $curKey => &$curMerge) { + foreach ($curMerge[0] as $key => &$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 && isset($curMerge[1][$key])) { + $curMerge[1][] = $val; + } else { + $curMerge[1][$key] = $val; + } + } + unset($stack[$curKey]); + } + unset($curMerge); + } + } + + /** + * Checks to see if all the values in the array are numeric + * + * @param array $data The array to check. + * @return bool true if values are numeric, false otherwise + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-numeric + */ + public static function numeric(array $data): bool + { + if (!$data) { + return false; + } + + return $data === array_filter($data, 'is_numeric'); + } + + /** + * Counts the dimensions of an array. + * Only considers the dimension of the first element in the array. + * + * If you have an un-even or heterogeneous array, consider using Hash::maxDimensions() + * to get the dimensions of the array. + * + * @param array $data Array to count dimensions on + * @return int The number of dimensions in $data + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-dimensions + */ + public static function dimensions(array $data): int + { + if (!$data) { + return 0; + } + reset($data); + $depth = 1; + while ($elem = array_shift($data)) { + if (is_array($elem)) { + $depth++; + $data = $elem; + } else { + break; + } + } + + return $depth; + } + + /** + * Counts the dimensions of *all* array elements. Useful for finding the maximum + * number of dimensions in a mixed array. + * + * @param array $data Array to count dimensions on + * @return int The maximum number of dimensions in $data + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-maxdimensions + */ + public static function maxDimensions(array $data): int + { + $depth = []; + foreach ($data as $value) { + if (is_array($value)) { + $depth[] = static::maxDimensions($value) + 1; + } else { + $depth[] = 1; + } + } + + return $depth === [] ? 0 : max($depth); + } + + /** + * Map a callback across all elements in a set. + * Can be provided a path to only modify slices of the set. + * + * @param array $data The data to map over, and extract data out of. + * @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/5/en/core-libraries/hash.html#hash-map + */ + public static function map(array $data, string $path, callable $function): array + { + $values = (array)static::extract($data, $path); + + return array_map($function, $values); + } + + /** + * Reduce a set of extracted values using `$function`. + * + * @param array $data The data to reduce. + * @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/5/en/core-libraries/hash.html#hash-reduce + */ + public static function reduce(array $data, string $path, callable $function): mixed + { + $values = (array)static::extract($data, $path); + + return array_reduce($values, $function); + } + + /** + * Apply a callback to a set of extracted values using `$function`. + * The function will get the extracted values as the first argument. + * + * ### Example + * + * You can easily count the results of an extract using apply(). + * For example to count the comments on an Article: + * + * ``` + * $count = Hash::apply($data, 'Article.Comment.{n}', 'count'); + * ``` + * + * You could also use a function like `array_sum` to sum the results. + * + * ``` + * $total = Hash::apply($data, '{n}.Item.price', 'array_sum'); + * ``` + * + * @param array $data The data to reduce. + * @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/5/en/core-libraries/hash.html#hash-apply + */ + public static function apply(array $data, string $path, callable $function): mixed + { + $values = (array)static::extract($data, $path); + + return $function($values); + } + + /** + * Sorts an array by any value, determined by a Set-compatible path + * + * ### Sort directions + * + * - `asc` or \SORT_ASC Sort ascending. + * - `desc` or \SORT_DESC Sort descending. + * + * ### Sort types + * + * - `regular` For regular sorting (don't change types) + * - `numeric` Compare values numerically + * - `string` Compare values as strings + * - `locale` Compare items as strings, based on the current locale + * - `natural` Compare items as strings using "natural ordering" in a human friendly way + * Will sort foo10 below foo2 as an example. + * + * To do case insensitive sorting, pass the type as an array as follows: + * + * ``` + * Hash::sort($data, 'some.attribute', 'asc', ['type' => 'regular', 'ignoreCase' => true]); + * ``` + * + * When using the array form, `type` defaults to 'regular'. The `ignoreCase` option + * defaults to `false`. + * + * @param array $data An array of data to sort + * @param string $path A Set-compatible path to the array value + * @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/5/en/core-libraries/hash.html#hash-sort + */ + public static function sort( + array $data, + string $path, + string|int $dir = 'asc', + array|string $type = 'regular', + ): array { + if (!$data) { + return []; + } + $originalKeys = array_keys($data); + $numeric = is_numeric(implode('', $originalKeys)); + if ($numeric) { + $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 + // the sorted value path. + $missingData = count($sortValues) < $dataCount; + if ($missingData && $numeric) { + // Get the path without the leading '{n}.' + $itemPath = substr($path, 4); + foreach ($data as $key => $value) { + $sortValues[$key] = static::get($value, $itemPath); + } + } elseif ($missingData) { + $sortValues = array_pad($sortValues, $dataCount, null); + } + $result = static::_squash($sortValues); + $keys = static::extract($result, '{n}.id'); + + $values = static::extract($result, '{n}.value'); + + 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 + if (is_array($type)) { + $type += ['ignoreCase' => false, 'type' => 'regular']; + $ignoreCase = $type['ignoreCase']; + $type = $type['type']; + } + $type = strtolower($type); + + if ($type === 'numeric') { + $type = SORT_NUMERIC; + } elseif ($type === 'string') { + $type = SORT_STRING; + } elseif ($type === 'natural') { + $type = SORT_NATURAL; + } elseif ($type === 'locale') { + $type = SORT_LOCALE_STRING; + } else { + $type = SORT_REGULAR; + } + if ($ignoreCase) { + $values = array_map('mb_strtolower', $values); + } + array_multisort($values, $dir, $type, $keys, $dir, $type); + $sorted = []; + $keys = array_unique($keys); + + foreach ($keys as $k) { + if ($numeric) { + $sorted[] = $data[$k]; + continue; + } + if (isset($originalKeys[$k])) { + $sorted[$originalKeys[$k]] = $data[$originalKeys[$k]]; + } else { + $sorted[$k] = $data[$k]; + } + } + + return $sorted; + } + + /** + * Helper method for sort() + * Squashes an array to a single hash so it can be sorted. + * + * @param array $data The data to squash. + * @param string|int|null $key The key for the data. + * @return array + */ + protected static function _squash(array $data, string|int|null $key = null): array + { + $stack = []; + foreach ($data as $k => $r) { + $id = $k; + if ($key !== null) { + $id = $key; + } + if (is_array($r) && $r !== []) { + $stack = array_merge($stack, static::_squash($r, $id)); + } else { + $stack[] = ['id' => $id, 'value' => $r]; + } + } + + return $stack; + } + + /** + * Computes the difference between two complex arrays. + * This method differs from the built-in array_diff() in that it will preserve keys + * and work on multi-dimensional arrays. + * + * @param array $data First value + * @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/5/en/core-libraries/hash.html#hash-diff + */ + public static function diff(array $data, array $compare): array + { + if (!$data) { + return $compare; + } + if (!$compare) { + return $data; + } + $intersection = array_intersect_key($data, $compare); + while (($key = key($intersection)) !== null) { + if ($data[$key] == $compare[$key]) { + unset($data[$key], $compare[$key]); + } + next($intersection); + } + + return $data + $compare; + } + + /** + * Merges the difference between $data and $compare onto $data. + * + * @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/5/en/core-libraries/hash.html#hash-mergediff + */ + public static function mergeDiff(array $data, array $compare): array + { + if (!$data && $compare !== []) { + return $compare; + } + if (!$compare) { + return $data; + } + foreach ($compare as $key => $value) { + if (!array_key_exists($key, $data)) { + $data[$key] = $value; + } elseif (is_array($value) && is_array($data[$key])) { + $data[$key] = static::mergeDiff($data[$key], $value); + } + } + + return $data; + } + + /** + * Normalizes an array, and converts it to a standard format. + * + * @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/5/en/core-libraries/hash.html#hash-normalize + */ + public static function normalize(array $data, bool $assoc = true, mixed $default = null): array + { + $keys = array_keys($data); + $count = count($keys); + $numeric = true; + + if (!$assoc) { + for ($i = 0; $i < $count; $i++) { + if (!is_int($keys[$i])) { + $numeric = false; + break; + } + } + } + if (!$numeric || $assoc) { + $newList = []; + for ($i = 0; $i < $count; $i++) { + if (is_int($keys[$i])) { + $newList[$data[$keys[$i]]] = $default; + } else { + $newList[$keys[$i]] = $data[$keys[$i]]; + } + } + $data = $newList; + } + + return $data; + } + + /** + * Takes in a flat array and returns a nested array + * + * ### Options: + * + * - `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. + * Should be compatible with Hash::extract(). Defaults to `{n}.$alias.parent_id` + * - `root` The id of the desired top-most result. + * + * @param array $data The data to nest. + * @param array $options Options. + * @return array of results, nested + * @see \Cake\Utility\Hash::extract() + * @throws \InvalidArgumentException When providing invalid data. + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-nest + * @phpstan-param array{idPath?: string, parentPath?: string, children?: string, root?: string|null} $options + */ + public static function nest(array $data, array $options = []): array + { + if (!$data) { + return $data; + } + + $alias = key(current($data)); + $options += [ + 'idPath' => "{n}.{$alias}.id", + 'parentPath' => "{n}.{$alias}.parent_id", + 'children' => 'children', + 'root' => null, + ]; + $return = []; + $idMap = []; + $ids = static::extract($data, $options['idPath']); + assert(is_array($ids)); + + $idKeys = explode('.', $options['idPath']); + array_shift($idKeys); + + $parentKeys = explode('.', $options['parentPath']); + array_shift($parentKeys); + + foreach ($data as $result) { + $result[$options['children']] = []; + + $id = static::get($result, $idKeys); + $parentId = static::get($result, $parentKeys); + + if (isset($idMap[$id][$options['children']])) { + $idMap[$id] = array_merge($result, $idMap[$id]); + } else { + $idMap[$id] = array_merge($result, [$options['children'] => []]); + } + if (!$parentId || !in_array($parentId, $ids)) { + $return[] = &$idMap[$id]; + } else { + $idMap[$parentId][$options['children']][] = &$idMap[$id]; + } + } + + if (!$return) { + throw new InvalidArgumentException('Invalid data array to nest.'); + } + + if ($options['root']) { + $root = $options['root']; + } else { + $root = static::get($return[0], $parentKeys); + } + + foreach ($return as $i => $result) { + $id = static::get($result, $idKeys); + $parentId = static::get($result, $parentKeys); + 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 new file mode 100644 index 00000000000..f9cbbef4c59 --- /dev/null +++ b/src/Utility/Inflector.php @@ -0,0 +1,524 @@ + + */ + protected static array $_plural = [ + '/(s)tatus$/i' => '\1tatuses', + '/(quiz)$/i' => '\1zes', + '/^(ox)$/i' => '\1\2en', + '/([m|l])ouse$/i' => '\1ice', + '/(matr|vert)(ix|ex)$/i' => '\1ices', + '/(x|ch|ss|sh)$/i' => '\1es', + '/([^aeiouy]|qu)y$/i' => '\1ies', + '/(hive)$/i' => '\1s', + '/(chef)$/i' => '\1s', + '/(?:([^f])fe|([lre])f)$/i' => '\1\2ves', + '/sis$/i' => 'ses', + '/([ti])um$/i' => '\1a', + '/(p)erson$/i' => '\1eople', + '/(? '\1en', + '/(c)hild$/i' => '\1hildren', + '/(buffal|tomat)o$/i' => '\1\2oes', + '/(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin)us$/i' => '\1i', + '/us$/i' => 'uses', + '/(alias)$/i' => '\1es', + '/(ax|cris|test)is$/i' => '\1es', + '/s$/' => 's', + '/^$/' => '', + '/$/' => 's', + ]; + + /** + * Singular inflector rules + * + * @var array + */ + 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|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', + '/(shoe)s$/i' => '\1', + '/(o)es$/i' => '\1', + '/ouses$/' => 'ouse', + '/([^a])uses$/' => '\1us', + '/([m|l])ice$/i' => '\1ouse', + '/(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', + '/(drive)s$/i' => '\1', + '/([le])ves$/i' => '\1f', + '/([^rfoa])ves$/i' => '\1fe', + '/(^analy)ses$/i' => '\1sis', + '/(analy|diagno|^ba|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i' => '\1\2sis', + '/([ti])a$/i' => '\1um', + '/(p)eople$/i' => '\1\2erson', + '/(m)en$/i' => '\1an', + '/(c)hildren$/i' => '\1\2hild', + '/(n)ews$/i' => '\1\2ews', + '/eaus$/' => 'eau', + '/^(.*us)$/' => '\\1', + '/s$/i' => '', + ]; + + /** + * Irregular rules + * + * @var array + */ + protected static array $_irregular = [ + 'atlas' => 'atlases', + 'beef' => 'beefs', + 'brief' => 'briefs', + 'brother' => 'brothers', + 'cafe' => 'cafes', + 'child' => 'children', + 'cookie' => 'cookies', + 'corpus' => 'corpuses', + 'cow' => 'cows', + 'criterion' => 'criteria', + 'ganglion' => 'ganglions', + 'genie' => 'genies', + 'genus' => 'genera', + 'graffito' => 'graffiti', + 'hoof' => 'hoofs', + 'loaf' => 'loaves', + 'man' => 'men', + 'money' => 'monies', + 'mongoose' => 'mongooses', + 'move' => 'moves', + 'mythos' => 'mythoi', + 'niche' => 'niches', + 'numen' => 'numina', + 'occiput' => 'occiputs', + 'octopus' => 'octopuses', + 'opus' => 'opuses', + 'ox' => 'oxen', + 'penis' => 'penises', + 'person' => 'people', + 'sex' => 'sexes', + 'soliloquy' => 'soliloquies', + 'testis' => 'testes', + 'trilby' => 'trilbys', + 'turf' => 'turfs', + 'potato' => 'potatoes', + 'hero' => 'heroes', + 'tooth' => 'teeth', + 'goose' => 'geese', + 'foot' => 'feet', + 'foe' => 'foes', + 'sieve' => 'sieves', + 'cache' => 'caches', + ]; + + /** + * Words that should not be inflected + * + * @var array + */ + 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', + ]; + + /** + * Method cache array. + * + * @var array + */ + protected static array $_cache = []; + + /** + * The initial state of Inflector so reset() works. + * + * @var array + */ + protected static array $_initialState = []; + + /** + * Cache inflected values, and return if already available + * + * @param string $type Inflection type + * @param string $key Original value + * @param string|false $value Inflected value + * @return string|false Inflected value on cache hit or false on cache miss. + */ + protected static function _cache(string $type, string $key, string|false $value = false): string|false + { + $key = '_' . $key; + $type = '_' . $type; + if ($value !== false) { + static::$_cache[$type][$key] = $value; + + return $value; + } + if (!isset(static::$_cache[$type][$key])) { + return false; + } + + return static::$_cache[$type][$key]; + } + + /** + * Clears Inflectors inflected value caches. And resets the inflection + * rules to the initial values. + * + * @return void + */ + public static function reset(): void + { + if (static::$_initialState === []) { + static::$_initialState = get_class_vars(self::class); + + return; + } + foreach (static::$_initialState as $key => $val) { + if ($key !== '_initialState') { + static::${$key} = $val; + } + } + } + + /** + * Adds custom inflection $rules, of either 'plural', 'singular', + * 'uninflected' or 'irregular' $type. + * + * ### Usage: + * + * ``` + * Inflector::rules('plural', ['/^(inflect)or$/i' => '\1ables']); + * Inflector::rules('irregular', ['red' => 'redlings']); + * Inflector::rules('uninflected', ['dontinflectme']); + * ``` + * + * @param string $type The type of inflection, either 'plural', 'singular', + * 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(string $type, array $rules, bool $reset = false): void + { + $var = '_' . $type; + + if ($reset) { + static::${$var} = $rules; + } elseif ($type === 'uninflected') { + static::$_uninflected = array_merge( + $rules, + static::$_uninflected, + ); + } else { + static::${$var} = $rules + static::${$var}; + } + + static::$_cache = []; + } + + /** + * Return $word in plural form. + * + * @param string $word Word in singular + * @return string Word in plural + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-plural-singular-forms + */ + public static function pluralize(string $word): string + { + if (isset(static::$_cache['pluralize'][$word])) { + return static::$_cache['pluralize'][$word]; + } + + if (!isset(static::$_cache['irregular']['pluralize'])) { + $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(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); + + return static::$_cache['pluralize'][$word]; + } + + if (!isset(static::$_cache['uninflected'])) { + static::$_cache['uninflected'] = '/^(' . implode('|', static::$_uninflected) . ')$/i'; + } + + if (preg_match(static::$_cache['uninflected'], $word, $regs)) { + static::$_cache['pluralize'][$word] = $word; + + return $word; + } + + foreach (static::$_plural as $rule => $replacement) { + if (preg_match($rule, $word)) { + static::$_cache['pluralize'][$word] = (string)preg_replace($rule, $replacement, $word); + + return static::$_cache['pluralize'][$word]; + } + } + + return $word; + } + + /** + * Return $word in singular form. + * + * @param string $word Word in plural + * @return string Word in singular + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-plural-singular-forms + */ + public static function singularize(string $word): string + { + if (isset(static::$_cache['singularize'][$word])) { + return static::$_cache['singularize'][$word]; + } + + if (!isset(static::$_cache['irregular']['singular'])) { + $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(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) . ')$/i'; + } + + 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] = (string)preg_replace($rule, $replacement, $word); + + return static::$_cache['singularize'][$word]; + } + } + static::$_cache['singularize'][$word] = $word; + + return $word; + } + + /** + * Returns the input lower_case_delimited_string as a CamelCasedString. + * + * @param string $string String to camelize + * @param string $delimiter the delimiter in the input string + * @return string CamelizedStringLikeThis. + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-camelcase-and-under-scored-forms + */ + public static function camelize(string $string, string $delimiter = '_'): string + { + $cacheKey = __FUNCTION__ . $delimiter; + + $result = static::_cache($cacheKey, $string); + + if ($result === false) { + $result = str_replace(' ', '', static::humanize($string, $delimiter)); + static::_cache($cacheKey, $string, $result); + } + + return $result; + } + + /** + * Returns the input CamelCasedString as an underscored_string. + * + * Also replaces dashes with underscores + * + * @param string $string CamelCasedString to be "underscorized" + * @return string underscore_version of the input string + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-camelcase-and-under-scored-forms + */ + public static function underscore(string $string): string + { + return static::delimit(str_replace('-', '_', $string), '_'); + } + + /** + * Returns the input CamelCasedString as an dashed-string. + * + * Also replaces underscores with dashes + * + * @param string $string The string to dasherize. + * @return string Dashed version of the input string + */ + public static function dasherize(string $string): string + { + return static::delimit(str_replace('_', '-', $string), '-'); + } + + /** + * Returns the input lower_case_delimited_string as 'A Human Readable String'. + * (Underscores are replaced by spaces and capitalized following words.) + * + * @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/5/en/core-libraries/inflector.html#creating-human-readable-forms + */ + public static function humanize(string $string, string $delimiter = '_'): string + { + $cacheKey = __FUNCTION__ . $delimiter; + + $result = static::_cache($cacheKey, $string); + + if ($result === false) { + $result = explode(' ', str_replace($delimiter, ' ', $string)); + foreach ($result as &$word) { + $word = mb_strtoupper(mb_substr($word, 0, 1)) . mb_substr($word, 1); + } + $result = implode(' ', $result); + static::_cache($cacheKey, $string, $result); + } + + return $result; + } + + /** + * Expects a CamelCasedInputString, and produces a lower_case_delimited_string + * + * @param string $string String to delimit + * @param string $delimiter the character to use as a delimiter + * @return string delimited string + */ + public static function delimit(string $string, string $delimiter = '_'): string + { + $cacheKey = __FUNCTION__ . $delimiter; + + $result = static::_cache($cacheKey, $string); + + if ($result === false) { + $result = mb_strtolower((string)preg_replace('/(?<=\\w)([A-Z])/', $delimiter . '\\1', $string)); + static::_cache($cacheKey, $string, $result); + } + + return $result; + } + + /** + * 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/5/en/core-libraries/inflector.html#creating-table-and-class-name-forms + */ + public static function tableize(string $className): string + { + $result = static::_cache(__FUNCTION__, $className); + + if ($result === false) { + $result = static::pluralize(static::underscore($className)); + static::_cache(__FUNCTION__, $className, $result); + } + + return $result; + } + + /** + * 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/5/en/core-libraries/inflector.html#creating-table-and-class-name-forms + */ + public static function classify(string $tableName): string + { + $result = static::_cache(__FUNCTION__, $tableName); + + if ($result === false) { + $result = static::camelize(static::singularize($tableName)); + static::_cache(__FUNCTION__, $tableName, $result); + } + + return $result; + } + + /** + * Returns camelBacked version of an underscored string. + * + * @param string $string String to convert. + * @return string in variable form + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-variable-names + */ + public static function variable(string $string): string + { + $result = static::_cache(__FUNCTION__, $string); + + if ($result === false) { + $camelized = static::camelize(static::underscore($string)); + $replace = strtolower(substr($camelized, 0, 1)); + $result = $replace . substr($camelized, 1); + static::_cache(__FUNCTION__, $string, $result); + } + + return $result; + } +} diff --git a/src/Utility/LICENSE.txt b/src/Utility/LICENSE.txt new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/Utility/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/Utility/MergeVariablesTrait.php b/src/Utility/MergeVariablesTrait.php new file mode 100644 index 00000000000..3db0f304d86 --- /dev/null +++ b/src/Utility/MergeVariablesTrait.php @@ -0,0 +1,116 @@ + $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(array $properties, array $options = []): void + { + $class = static::class; + $parents = []; + while (true) { + $parent = get_parent_class($class); + if (!$parent) { + break; + } + $parents[] = $parent; + $class = $parent; + } + foreach ($properties as $property) { + if (!property_exists($this, $property)) { + continue; + } + $thisValue = $this->{$property}; + if ($thisValue === null || $thisValue === false) { + continue; + } + $this->_mergeProperty($property, $parents, $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() + * @return void + */ + 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'], true) + ) { + $isAssoc = true; + } + + if ($isAssoc) { + $thisValue = Hash::normalize($thisValue); + } + foreach ($parentClasses as $class) { + $parentProperties = get_class_vars($class); + if (empty($parentProperties[$property])) { + continue; + } + $parentProperty = $parentProperties[$property]; + if (!is_array($parentProperty)) { + continue; + } + $thisValue = $this->_mergePropertyData($thisValue, $parentProperty, $isAssoc); + } + $this->{$property} = $thisValue; + } + + /** + * Merge each of the keys in a property together. + * + * @param array $current The current merged value. + * @param array $parent The parent class' value. + * @param bool $isAssoc Whether the merging should be done in associative mode. + * @return array The updated value. + */ + 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) { + $current[$key] ??= $value; + } + + return $current; + } +} diff --git a/src/Utility/README.md b/src/Utility/README.md new file mode 100644 index 00000000000..a175976064e --- /dev/null +++ b/src/Utility/README.md @@ -0,0 +1,91 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/utility.svg?style=flat-square)](https://packagist.org/packages/cakephp/utility) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Utility Classes + +This library provides a range of utility classes that are used throughout the CakePHP framework + +## What's in the toolbox? + +### Hash + +A ``Hash`` (as in PHP arrays) class, capable of extracting data using an intuitive DSL: + +```php +$things = [ + ['name' => 'Mark', 'age' => 15], + ['name' => 'Susan', 'age' => 30], + ['name' => 'Lucy', 'age' => 25] +]; + +$bigPeople = Hash::extract($things, '{n}[age>21].name'); + +// $bigPeople will contain ['Susan', 'Lucy'] +``` + +Check the [official Hash class documentation](https://book.cakephp.org/5/en/core-libraries/hash.html) + +### Inflector + +The Inflector class takes a string and can manipulate it to handle word variations +such as pluralizations or camelizing. + +```php +echo Inflector::pluralize('Apple'); // echoes Apples + +echo Inflector::singularize('People'); // echoes Person +``` + +Check the [official Inflector class documentation](https://book.cakephp.org/5/en/core-libraries/inflector.html) + +### Text + +The Text class includes convenience methods for creating and manipulating strings. + +```php +Text::insert( + 'My name is :name and I am :age years old.', + ['name' => 'Bob', 'age' => '65'] +); +// Returns: "My name is Bob and I am 65 years old." + +$text = 'This is the song that never ends.'; +$result = Text::wrap($text, 22); + +// Returns +This is the song +that never ends. +``` + +Check the [official Text class documentation](https://book.cakephp.org/5/en/core-libraries/text.html) + +### Security + +The security library handles basic security measures such as providing methods for hashing and encrypting data. + +```php +$key = 'wt1U5MACWJFTXGenFoZoiLwQGrLgdbHA'; +$result = Security::encrypt($value, $key); + +Security::decrypt($result, $key); +``` + +Check the [official Security class documentation](https://book.cakephp.org/5/en/core-libraries/security.html) + +### Xml + +The Xml class allows you to easily transform arrays into SimpleXMLElement or DOMDocument objects +and back into arrays again + +```php +$data = [ + 'post' => [ + 'id' => 1, + 'title' => 'Best post', + 'body' => ' ... ' + ] +]; +$xml = Xml::build($data); +``` + +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 new file mode 100644 index 00000000000..b00d3ecc86f --- /dev/null +++ b/src/Utility/Security.php @@ -0,0 +1,308 @@ +`'); + } + + return random_bytes($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, + ); + } + + /** + * Like randomBytes() above, but not cryptographically secure. + * + * @param int $length The number of bytes you want. + * @return string Random bytes in binary. + * @see \Cake\Utility\Security::randomBytes() + */ + public static function insecureRandomBytes(int $length): string + { + $length *= 2; + + $bytes = ''; + $byteLength = 0; + while ($byteLength < $length) { + $bytes .= static::hash(Text::uuid() . uniqid((string)mt_rand(), true), 'sha512', true); + $byteLength = strlen($bytes); + } + $bytes = substr($bytes, 0, $length); + + return pack('H*', $bytes); + } + + /** + * Get the crypto implementation based on the loaded extensions. + * + * You can use this method to forcibly decide between openssl/custom implementations. + * + * @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(?object $instance = null): object + { + if ($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 the openssl extension.', + ); + } + + /** + * Encrypt a value using AES-256. + * + * *Caveat* You cannot properly encrypt/decrypt data with trailing null bytes. + * Any trailing null bytes will be removed on decryption due to how PHP pads messages + * with nulls prior to encryption. + * + * @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 value of Security::getSalt(). + * @return string Encrypted data. + * @throws \InvalidArgumentException On invalid data or key. + */ + public static function encrypt(string $plain, string $key, ?string $hmacSalt = null): string + { + self::_checkKey($key, 'encrypt()'); + + $hmacSalt ??= static::getSalt(); + // Generate the encryption and hmac key. + $key = mb_substr(hash('sha256', $key . $hmacSalt), 0, 32, '8bit'); + + $crypto = static::engine(); + $ciphertext = $crypto->encrypt($plain, $key); + $hmac = hash_hmac('sha256', $ciphertext, $key); + + return $hmac . $ciphertext; + } + + /** + * Check the encryption key for proper length. + * + * @param string $key Key to check. + * @param string $method The method the key is being checked for. + * @return void + * @throws \InvalidArgumentException When key length is not 256 bit/32 bytes + */ + protected static function _checkKey(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), + ); + } + } + + /** + * 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 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(string $cipher, string $key, ?string $hmacSalt = null): ?string + { + self::_checkKey($key, 'decrypt()'); + if (!$cipher) { + throw new InvalidArgumentException('The data to decrypt cannot be empty.'); + } + $hmacSalt ??= static::getSalt(); + + // Generate the encryption and hmac key. + $key = mb_substr(hash('sha256', $key . $hmacSalt), 0, 32, '8bit'); + + // 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 null; + } + + $crypto = static::engine(); + + return $crypto->decrypt($cipher, $key); + } + + /** + * A timing attack resistant comparison that prefers native PHP implementations. + * + * @param mixed $original The original value. + * @param mixed $compare The comparison value. + * @return bool + * @since 3.6.2 + */ + public static function constantEquals(mixed $original, mixed $compare): bool + { + return is_string($original) && is_string($compare) && hash_equals($original, $compare); + } + + /** + * Gets the HMAC salt to be used for encryption/decryption + * routines. + * + * @return string The currently configured salt + */ + 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; + } + + /** + * Sets the HMAC salt to be used for encryption/decryption + * routines. + * + * @param string $salt The salt to use for encryption routines. + * @return void + */ + public static function setSalt(string $salt): void + { + static::$_salt = $salt; + } +} diff --git a/src/Utility/Text.php b/src/Utility/Text.php new file mode 100644 index 00000000000..ef2349b9eec --- /dev/null +++ b/src/Utility/Text.php @@ -0,0 +1,1193 @@ + + */ + protected static array $_defaultHtmlNoCount = [ + 'style', + '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 `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 {@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(): string + { + $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_int(0, 65535), + random_int(0, 65535), + // 16 bits for "time_mid" + random_int(0, 65535), + // 12 bits before the 0100 of (version) 4 for "time_hi_and_version" + 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_int(0, 0x3fff) | 0x8000, + // 48 bits for "node" + random_int(0, 65535), + random_int(0, 65535), + random_int(0, 65535), + ); + } + + /** + * Tokenizes a string using $separator, ignoring any instance of $separator that appears between + * $leftBound and $rightBound. + * + * @param string $data The data to tokenize. + * @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 Array of tokens in $data. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-tokenize + */ + public static function tokenize( + string $data, + string $separator = ',', + string $leftBound = '(', + string $rightBound = ')', + ): array { + if (!$data) { + return []; + } + + $depth = 0; + $offset = 0; + $buffer = ''; + $results = []; + $length = mb_strlen($data); + $open = false; + + while ($offset <= $length) { + $tmpOffset = -1; + $offsets = [ + mb_strpos($data, $separator, $offset), + mb_strpos($data, $leftBound, $offset), + mb_strpos($data, $rightBound, $offset), + ]; + for ($i = 0; $i < 3; $i++) { + if ($offsets[$i] !== false && ($offsets[$i] < $tmpOffset || $tmpOffset === -1)) { + $tmpOffset = $offsets[$i]; + } + } + if ($tmpOffset !== -1) { + $buffer .= mb_substr($data, $offset, $tmpOffset - $offset); + $char = mb_substr($data, $tmpOffset, 1); + if (!$depth && $char === $separator) { + $results[] = $buffer; + $buffer = ''; + } else { + $buffer .= $char; + } + if ($leftBound !== $rightBound) { + if ($char === $leftBound) { + $depth++; + } + if ($char === $rightBound) { + $depth--; + } + } elseif ($char === $leftBound) { + if (!$open) { + $depth++; + $open = true; + } else { + $depth--; + $open = false; + } + } + $tmpOffset += 1; + $offset = $tmpOffset; + } else { + $results[] = $buffer . mb_substr($data, $offset); + $offset = $length + 1; + } + } + if (!$results && $buffer) { + $results[] = $buffer; + } + + if ($results) { + return array_map('trim', $results); + } + + return []; + } + + /** + * Replaces variable placeholders inside a $str with any given $data. Each key in the $data array + * corresponds to a variable placeholder name in $str. + * Example: + * ``` + * Text::insert(':name is :age years old.', ['name' => 'Bob', 'age' => '65']); + * ``` + * Returns: Bob is 65 years old. + * + * Available $options are: + * + * - before: The character or string in front of the name of the variable placeholder (Defaults to `:`) + * - after: The character or string after the name of the variable placeholder (Defaults to null) + * - escape: The character or string used to escape the before character / string (Defaults to `\`) + * - format: A regex to use for matching variable placeholders. Default is: `/(? 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 + * @return string + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-insert + */ + public static function insert(string $str, array $data, array $options = []): string + { + $options += ['before' => ':', 'after' => '', 'escape' => '\\', 'format' => null, 'clean' => false]; + if (!$data) { + return $options['clean'] ? static::cleanInsert($str, $options) : $str; + } + + $format = $options['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 = (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) ? '' : (string)$tmpValue; + $str = (string)str_replace($tmpHash, $tmpValue, $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; + } + + /** + * Cleans up a Text::insert() formatted string with given $options depending on the 'clean' key in + * $options. The default method used is text but html is also available. The goal of this function + * is to replace all whitespace and unneeded markup around placeholders that did not get replaced + * by Text::insert(). + * + * @param string $str String to clean. + * @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(string $str, array $options): string + { + $clean = $options['clean']; + if (!$clean) { + return $str; + } + if ($clean === true) { + $clean = ['method' => 'text']; + } + if (!is_array($clean)) { + $clean = ['method' => $options['clean']]; + } + switch ($clean['method']) { + case 'html': + $clean += [ + 'word' => '[\w,.]+', + 'andText' => true, + 'replacement' => '', + ]; + $kleenex = sprintf( + '/[\s]*[a-z]+=(")(%s%s%s[\s]*)+\\1/i', + preg_quote($options['before'], '/'), + $clean['word'], + preg_quote($options['after'], '/'), + ); + $str = (string)preg_replace($kleenex, $clean['replacement'], $str); + if ($clean['andText']) { + $options['clean'] = ['method' => 'text']; + $str = static::cleanInsert($str, $options); + } + break; + case 'text': + $clean += [ + 'word' => '[\w,.]+', + 'gap' => '[\s]*(?:(?:and|or)[\s]*)?', + 'replacement' => '', + ]; + + $kleenex = sprintf( + '/(%s%s%s%s|%s%s%s%s)/', + preg_quote($options['before'], '/'), + $clean['word'], + preg_quote($options['after'], '/'), + $clean['gap'], + $clean['gap'], + preg_quote($options['before'], '/'), + $clean['word'], + preg_quote($options['after'], '/'), + ); + $str = (string)preg_replace($kleenex, $clean['replacement'], $str); + break; + } + + return $str; + } + + /** + * Wraps text to a specific width, can optionally wrap at word breaks. + * + * ### Options + * + * - `width` The width to wrap to. Defaults to 72. + * - `wordWrap` Only wrap on words breaks (spaces) Defaults to true. + * - `indent` String to indent with. Defaults to null. + * - `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. + * @return string Formatted text. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-wrap + */ + public static function wrap(string $text, array|int $options = []): string + { + 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 { + $length = $options['width'] - 1; + if ($length < 1) { + throw new InvalidArgumentException('Length must be `int<1, max>`.'); + } + $wrapped = trim(chunk_split($text, $length, "\n")); + } + if ($options['indent']) { + $chunks = explode("\n", $wrapped); + for ($i = $options['indentAt'], $len = count($chunks); $i < $len; $i++) { + $chunks[$i] = $options['indent'] . $chunks[$i]; + } + $wrapped = implode("\n", $chunks); + } + + return $wrapped; + } + + /** + * Wraps a complete block of text to a specific width, can optionally wrap + * at word breaks. + * + * ### Options + * + * - `width` The width to wrap to. Defaults to 72. + * - `wordWrap` Only wrap on words breaks (spaces) Defaults to true. + * - `indent` String to indent with. Defaults to null. + * - `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. + * @return string Formatted text. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-wrapblock + */ + public static function wrapBlock(string $text, array|int $options = []): string + { + if (is_int($options)) { + $options = ['width' => $options]; + } + $options += ['width' => 72, 'wordWrap' => true, 'indent' => null, 'indentAt' => 0]; + + $wrapped = self::wrap($text, $options); + + if ($options['indent']) { + $indentationLength = mb_strlen($options['indent']); + $chunks = explode("\n", $wrapped); + $count = count($chunks); + if ($count < 2) { + return $wrapped; + } + $toRewrap = ''; + for ($i = $options['indentAt']; $i < $count; $i++) { + $toRewrap .= mb_substr($chunks[$i], $indentationLength) . ' '; + unset($chunks[$i]); + } + $options['width'] -= $indentationLength; + $options['indentAt'] = 0; + $rewrapped = self::wrap($toRewrap, $options); + $newChunks = explode("\n", $rewrapped); + + $chunks = array_merge($chunks, $newChunks); + $wrapped = implode("\n", $chunks); + } + + return $wrapped; + } + + /** + * Unicode and newline aware version of wordwrap. + * + * @phpstan-param non-empty-string $break + * @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 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(string $text, int $width = 72, string $break = "\n", bool $cut = false): string + { + $paragraphs = explode($break, $text); + foreach ($paragraphs as &$paragraph) { + $paragraph = static::_wordWrap($paragraph, $width, $break, $cut); + } + + return implode($break, $paragraphs); + } + + /** + * Unicode aware version of wordwrap as helper method. + * + * @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 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(string $text, int $width = 72, string $break = "\n", bool $cut = false): string + { + $parts = []; + if ($cut) { + while (mb_strlen($text) > 0) { + $part = mb_substr($text, 0, $width); + $parts[] = trim($part); + $text = trim(mb_substr($text, mb_strlen($part))); + } + + return implode($break, $parts); + } + + while (mb_strlen($text) > 0) { + if ($width >= mb_strlen($text)) { + $parts[] = trim($text); + break; + } + + $part = mb_substr($text, 0, $width); + $nextChar = mb_substr($text, $width, 1); + if ($nextChar !== ' ') { + $breakAt = mb_strrpos($part, ' '); + if ($breakAt === false) { + $breakAt = mb_strpos($text, ' ', $width); + } + if ($breakAt === false) { + $parts[] = trim($text); + break; + } + $part = mb_substr($text, 0, $breakAt); + } + + $part = trim($part); + $parts[] = $part; + $text = trim(mb_substr($text, mb_strlen($part))); + } + + return implode($break, $parts); + } + + /** + * Highlights a given phrase in a text. You can specify any expression in highlighter that + * may include the \1 expression to include the $phrase found. + * + * ### Options: + * + * - `format` The piece of HTML with that the phrase will be highlighted + * - `html` If true, will ignore any HTML tags, ensuring that only the correct text is highlighted + * - `regex` A custom regex rule that is used to match words, default is '|$tag|iu' + * - `limit` A limit, optional, defaults to -1 (none) + * + * @param string $text Text to search the phrase in. + * @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/5/en/core-libraries/text.html#highlighting-substrings + */ + public static function highlight(string $text, array|string $phrase, array $options = []): string + { + if (!$phrase) { + return $text; + } + + $options += [ + 'format' => '\1', + 'html' => false, + 'regex' => '|%s|iu', + 'limit' => -1, + ]; + + if (is_array($phrase)) { + $replace = []; + $with = []; + + foreach ($phrase as $key => $segment) { + $segment = '(' . preg_quote($segment, '|') . ')'; + if ($options['html']) { + $segment = "(?![^<]+>){$segment}(?![^<]+>)"; + } + + $with[] = is_array($options['format']) ? $options['format'][$key] : $options['format']; + $replace[] = sprintf($options['regex'], $segment); + } + + return (string)preg_replace($replace, $with, $text, $options['limit']); + } + + $phrase = '(' . preg_quote($phrase, '|') . ')'; + if ($options['html']) { + $phrase = "(?![^<]+>){$phrase}(?![^<]+>)"; + } + + return (string)preg_replace( + sprintf($options['regex'], $phrase), + $options['format'], + $text, + $options['limit'], + ); + } + + /** + * Truncates text starting from the end. + * + * Cuts a string to the length of $length and replaces the first characters + * with the ellipsis if the text is longer than length. + * + * ### Options: + * + * - `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. + * @return string Trimmed string. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-tail + */ + public static function tail(string $text, int $length = 100, array $options = []): string + { + $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 (!$options['exact']) { + $spacepos = mb_strpos($truncate, ' '); + $truncate = $spacepos === false ? '' : trim(mb_substr($truncate, $spacepos)); + } + + return $ellipsis . $truncate; + } + + /** + * Truncates text. + * + * Cuts a string to the length of $length and replaces the last characters + * with the ellipsis if the text is longer than length. + * + * ### Options: + * + * - `ellipsis` Will be used as ending and appended to the trimmed string + * - `exact` If false, $text will not be cut mid-word + * - `html` If true, HTML tags would be handled correctly + * - `trimWidth` If true, $text will be truncated with the width + * + * @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. + * @return string Trimmed string. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#truncating-text + */ + public static function truncate(string $text, int $length = 100, array $options = []): string + { + $default = [ + 'ellipsis' => '…', 'exact' => true, 'html' => false, 'trimWidth' => false, + ]; + if (!empty($options['html']) && strtolower(mb_internal_encoding()) === 'utf-8') { + $default['ellipsis'] = "\xe2\x80\xa6"; + } + $options += $default; + + $prefix = ''; + $suffix = $options['ellipsis']; + + if ($options['html']) { + $ellipsisLength = self::_strlen(strip_tags($options['ellipsis']), $options); + + $truncateLength = 0; + $totalLength = 0; + $openTags = []; + $truncate = ''; + + preg_match_all('/(<\/?([\w+]+)[^>]*>)?([^<>]*)/', $text, $tags, PREG_SET_ORDER); + foreach ($tags as $tag) { + $contentLength = 0; + if (!in_array($tag[2], static::$_defaultHtmlNoCount, true)) { + $contentLength = self::_strlen($tag[3], $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('/<[\w]+[^>]*>/', $tag[0])) { + array_unshift($openTags, $tag[2]); + } elseif (preg_match('/<\/([\w]+)[^>]*>/', $tag[0], $closeTag)) { + $pos = array_search($closeTag[1], $openTags, true); + if ($pos !== false) { + array_splice($openTags, $pos, 1); + } + } + } + + $prefix .= $tag[1]; + + if ($totalLength + $contentLength + $ellipsisLength > $length) { + $truncate = $tag[3]; + $truncateLength = $length - $totalLength; + } else { + $prefix .= $tag[3]; + } + } + + $totalLength += $contentLength; + if ($totalLength > $length) { + break; + } + } + + if ($totalLength <= $length) { + return $text; + } + + $text = $truncate; + $length = $truncateLength; + + foreach ($openTags as $tag) { + $suffix .= ''; + } + } else { + if (self::_strlen($text, $options) <= $length) { + return $text; + } + $ellipsisLength = self::_strlen($options['ellipsis'], $options); + } + + $result = self::_substr($text, 0, $length - $ellipsisLength, $options); + + if (!$options['exact']) { + if (self::_substr($text, $length - $ellipsisLength, 1, $options) !== ' ') { + $result = self::_removeLastWord($result); + } + + // If result is empty, then we don't need to count ellipsis in the cut. + if ($result === '') { + $result = self::_substr($text, 0, $length, $options); + } + } + + return $prefix . $result . $suffix; + } + + /** + * Truncate text with specified width. + * + * @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. + * @return string Trimmed string. + * @see \Cake\Utility\Text::truncate() + */ + public static function truncateByWidth(string $text, int $length = 100, array $options = []): string + { + return static::truncate($text, $length, ['trimWidth' => true] + $options); + } + + /** + * Get string length. + * + * ### Options: + * + * - `html` If true, HTML entities will be handled as decoded characters. + * - `trimWidth` If true, the width will return. + * + * @param string $text The string being checked for length + * @param array $options An array of options. + * @return int + */ + protected static function _strlen(string $text, array $options): int + { + if (empty($options['trimWidth'])) { + $strlen = 'mb_strlen'; + } else { + $strlen = 'mb_strwidth'; + } + + if (empty($options['html'])) { + return $strlen($text); + } + + $pattern = '/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i'; + $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, + ); + + return $strlen($replace); + } + + /** + * Return part of a string. + * + * ### Options: + * + * - `html` If true, HTML entities will be handled as decoded characters. + * - `trimWidth` If true, will be truncated with specified width. + * + * @param string $text The input string. + * @param int $start The position to begin extracting. + * @param int|null $length The desired length. + * @param array $options An array of options. + * @return string + */ + protected static function _substr(string $text, int $start, ?int $length, array $options): string + { + if (empty($options['trimWidth'])) { + $substr = 'mb_substr'; + } else { + $substr = 'mb_strimwidth'; + } + + $maxPosition = self::_strlen($text, ['trimWidth' => false] + $options); + if ($start < 0) { + $start += $maxPosition; + if ($start < 0) { + $start = 0; + } + } + if ($start >= $maxPosition) { + return ''; + } + + $length ??= self::_strlen($text, $options); + + if ($length < 0) { + $text = self::_substr($text, $start, null, $options); + $start = 0; + $length += self::_strlen($text, $options); + } + + if ($length <= 0) { + return ''; + } + + if (empty($options['html'])) { + return (string)$substr($text, $start, $length); + } + + $totalOffset = 0; + $totalLength = 0; + $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) ?: []; + foreach ($parts as $part) { + $offset = 0; + + if ($totalOffset < $start) { + $len = self::_strlen($part, ['trimWidth' => false] + $options); + if ($totalOffset + $len <= $start) { + $totalOffset += $len; + continue; + } + + $offset = $start - $totalOffset; + $totalOffset = $start; + } + + $len = self::_strlen($part, $options); + if ($offset !== 0 || $totalLength + $len > $length) { + 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. + continue; + } + + $part = $substr($part, $offset, $length - $totalLength); + $len = self::_strlen($part, $options); + } + + $result .= $part; + $totalLength += $len; + if ($totalLength >= $length) { + break; + } + } + + return $result; + } + + /** + * Removes the last word from the input text. + * + * @param string $text The input text + * @return string + */ + protected static function _removeLastWord(string $text): string + { + $spacepos = mb_strrpos($text, ' '); + + if ($spacepos !== false) { + $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)) { + return mb_substr($text, 0, $spacepos); + } + + return $text; + } + + return ''; + } + + /** + * Extracts an excerpt from the text surrounding the phrase with a number of characters on each side + * determined by radius. + * + * @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 found phrase + * @param string $ellipsis Ending that will be appended + * @return string Modified string + * @link https://book.cakephp.org/5/en/core-libraries/text.html#extracting-an-excerpt + */ + public static function excerpt(string $text, string $phrase, int $radius = 100, string $ellipsis = '…'): string + { + if ($text === '' || $phrase === '') { + return static::truncate($text, $radius * 2, ['ellipsis' => $ellipsis]); + } + $append = $ellipsis; + $prepend = $ellipsis; + + $phraseLen = mb_strlen($phrase); + $textLen = mb_strlen($text); + + $pos = mb_stripos($text, $phrase); + if ($pos === false) { + return mb_substr($text, 0, $radius) . $ellipsis; + } + + $startPos = $pos - $radius; + if ($startPos <= 0) { + $startPos = 0; + $prepend = ''; + } + + $endPos = $pos + $phraseLen + $radius; + if ($endPos >= $textLen) { + $endPos = $textLen; + $append = ''; + } + + $excerpt = mb_substr($text, $startPos, $endPos - $startPos); + + 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 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/5/en/core-libraries/text.html#converting-an-array-to-sentence-form + */ + public static function toList(array $list, ?string $and = null, string $separator = ', '): string + { + static::$useI18n ??= function_exists('Cake\I18n\__d'); + $and ??= static::$useI18n ? __d('cake', 'and') : 'and'; + + if (count($list) > 1) { + return implode($separator, array_slice($list, 0, -1)) . ' ' . $and . ' ' . array_pop($list); + } + + return (string)array_pop($list); + } + + /** + * Check if the string contain multibyte characters + * + * @param string $string value to test + * @return bool + */ + public static function isMultibyte(string $string): bool + { + $length = strlen($string); + + for ($i = 0; $i < $length; $i++) { + $value = ord($string[$i]); + if ($value > 128) { + return true; + } + } + + return false; + } + + /** + * Converts a multibyte character string + * to the decimal value of the character + * + * @param string $string String to convert. + * @return array + */ + public static function utf8(string $string): array + { + $map = []; + + $values = []; + $find = 1; + $length = strlen($string); + + for ($i = 0; $i < $length; $i++) { + $value = ord($string[$i]); + + if ($value < 128) { + $map[] = $value; + } else { + if (!$values) { + $find = $value < 224 ? 2 : 3; + } + $values[] = $value; + + if (count($values) === $find) { + if ($find === 3) { + $map[] = (($values[0] % 16) * 4096) + (($values[1] % 64) * 64) + ($values[2] % 64); + } else { + $map[] = (($values[0] % 32) * 64) + ($values[1] % 64); + } + $values = []; + $find = 1; + } + } + } + + return $map; + } + + /** + * Converts the decimal value of a multibyte character string + * to a string + * + * @param array $array Array + * @return string + */ + public static function ascii(array $array): string + { + $ascii = ''; + + foreach ($array as $utf8) { + if ($utf8 < 128) { + $ascii .= chr($utf8); + } elseif ($utf8 < 2048) { + $ascii .= chr(192 + (int)(($utf8 - ($utf8 % 64)) / 64)); + $ascii .= chr(128 + ($utf8 % 64)); + } else { + $ascii .= chr(224 + (int)(($utf8 - ($utf8 % 4096)) / 4096)); + $ascii .= chr(128 + (int)((($utf8 % 4096) - ($utf8 % 64)) / 64)); + $ascii .= chr(128 + ($utf8 % 64)); + } + } + + return $ascii; + } + + /** + * 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. + * 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(string $size, mixed $default = false): mixed + { + if (ctype_digit($size)) { + return (int)$size; + } + $size = strtoupper($size); + + $l = -2; + $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'], true); + } + if ($i !== false) { + $size = (float)substr($size, 0, $l); + + return (int)($size * pow(1024, $i + 1)); + } + + if (str_ends_with($size, 'B') && ctype_digit(substr($size, 0, -1))) { + $size = substr($size, 0, -1); + + return (int)$size; + } + + if ($default !== false) { + return $default; + } + 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(): string + { + return static::$_defaultTransliteratorId; + } + + /** + * Set default transliterator identifier string. + * + * @param string $transliteratorId Transliterator identifier. + * @return void + */ + 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; + } + + /** + * Transliterate string. + * + * @param string $string String to transliterate. + * @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 $string, Transliterator|string|null $transliterator = null): string + { + 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 $return; + } + + /** + * Returns a string with all spaces converted to dashes (by default), + * characters transliterated to ASCII characters, and non word characters removed. + * + * ### Options: + * + * - `replacement`: Replacement string. Default '-'. + * - `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|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 $string, array|string $options = []): string + { + if (is_string($options)) { + $options = ['replacement' => $options]; + } + $options += [ + 'replacement' => '-', + 'transliteratorId' => null, + 'preserve' => null, + ]; + + if ($options['transliteratorId'] !== false) { + $string = static::transliterate($string, $options['transliteratorId']); + } + + $regex = '^\p{Ll}\p{Lm}\p{Lo}\p{Lt}\p{Lu}\p{Nd}'; + if ($options['preserve']) { + $regex .= preg_quote($options['preserve'], '/'); + } + $quotedReplacement = preg_quote((string)$options['replacement'], '/'); + $map = [ + '/[' . $regex . ']/mu' => $options['replacement'], + sprintf('/^[%s]+|[%s]+$/', $quotedReplacement, $quotedReplacement) => '', + ]; + if (is_string($options['replacement']) && $options['replacement'] !== '') { + $map[sprintf('/[%s]+/mu', $quotedReplacement)] = $options['replacement']; + } + + return (string)preg_replace(array_keys($map), $map, $string); + } +} diff --git a/src/Utility/Xml.php b/src/Utility/Xml.php new file mode 100644 index 00000000000..c49c4d4771f --- /dev/null +++ b/src/Utility/Xml.php @@ -0,0 +1,531 @@ +text'); + * ``` + * + * Building XML from string (output DOMDocument): + * + * ``` + * $xml = Xml::build('text', ['return' => 'domdocument']); + * ``` + * + * Building XML from a file path: + * + * ``` + * $xml = Xml::build('/path/to/an/xml/file.xml', ['readFile' => true]); + * ``` + * + * Building XML from a remote URL: + * + * ``` + * use Cake\Http\Client; + * + * $http = new Client(); + * $response = $http->get('http://example.com/example.xml'); + * $xml = Xml::build($response->body()); + * ``` + * + * Building from an array: + * + * ``` + * $value = [ + * 'tags' => [ + * 'tag' => [ + * [ + * 'id' => '1', + * 'name' => 'defect' + * ], + * [ + * 'id' => '2', + * 'name' => 'enhancement' + * ] + * ] + * ] + * ]; + * $xml = Xml::build($value); + * ``` + * + * When building XML from an array ensure that there is only one top level element. + * + * ### Options + * + * - `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(object|array|string $input, array $options = []): SimpleXMLElement|DOMDocument + { + $defaults = [ + 'return' => 'simplexml', + 'loadEntities' => false, + 'readFile' => false, + 'parseHuge' => false, + ]; + $options += $defaults; + + if (is_array($input) || is_object($input)) { + return static::fromArray($input, $options); + } + + if ($options['readFile'] && file_exists($input)) { + $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 (str_contains($input, '<')) { + return static::_loadXml($input, $options); + } + + throw new XmlException('XML cannot be read.'); + } + + /** + * 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() + * @return \SimpleXMLElement|\DOMDocument + * @throws \Cake\Utility\Exception\XmlException + */ + protected static function _loadXml(string $input, array $options): SimpleXMLElement|DOMDocument + { + 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 { + return $callable($input, $options, $flags); + } catch (Exception $e) { + throw new XmlException('Xml cannot be read. ' . $e->getMessage(), null, $e); + } finally { + libxml_use_internal_errors($internalErrors); + } + } + + /** + * Transform an array into a SimpleXMLElement + * + * ### Options + * + * - `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. + * Defaults to the application's encoding + * - `return` If return object of SimpleXMLElement ('simplexml') + * or DOMDocument ('domdocument'). Default is SimpleXMLElement. + * + * Using the following data: + * + * ``` + * $value = [ + * 'root' => [ + * 'tag' => [ + * 'id' => 1, + * 'value' => 'defect', + * '@' => 'description' + * ] + * ] + * ]; + * ``` + * + * Calling `Xml::fromArray($value, 'tags');` Will generate: + * + * `1defectdescription` + * + * And calling `Xml::fromArray($value, 'attributes');` Will generate: + * + * `description` + * + * @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(object|array $input, array $options = []): SimpleXMLElement|DOMDocument + { + if (is_object($input) && method_exists($input, 'toArray') && is_callable([$input, 'toArray'])) { + $input = $input->toArray(); + } + if (!is_array($input) || count($input) !== 1) { + 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'); + } + + $defaults = [ + 'format' => 'tags', + 'version' => '1.0', + 'encoding' => mb_internal_encoding(), + 'return' => 'simplexml', + 'pretty' => false, + ]; + $options += $defaults; + + $dom = new DOMDocument($options['version'], $options['encoding']); + if ($options['pretty']) { + $dom->formatOutput = true; + } + self::_fromArray($dom, $dom, $input, $options['format']); + + $options['return'] = strtolower($options['return']); + if ($options['return'] === 'simplexml' || $options['return'] === 'simplexmlelement') { + $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 children from array + * + * @param \DOMDocument $dom Handler to DOMDocument + * @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( + 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 = $value->toArray(); + } + + if (!is_array($value)) { + if (is_bool($value)) { + $value = (int)$value; + } elseif ($value === null) { + $value = ''; + } + if (str_contains($key, 'xmlns:')) { + assert($node instanceof DOMElement); + $node->setAttributeNS('http://www.w3.org/2000/xmlns/', $key, (string)$value); + continue; + } + 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, (string)$value); + } + $node->appendChild($child); + } else { + if (str_starts_with($key, '@')) { + $key = substr($key, 1); + } + $attribute = $dom->createAttribute($key); + $attribute->appendChild($dom->createTextNode((string)$value)); + $node->appendChild($attribute); + } + } else { + if (str_starts_with($key, '@')) { + throw new XmlException('Invalid array'); + } + if (is_numeric(implode('', array_keys($value)))) { +// List + foreach ($value as $item) { + $itemData = compact('dom', 'node', 'key', 'format'); + $itemData['value'] = $item; + static::_createChild($itemData); + } + } else { +// Struct + static::_createChild(compact('dom', 'node', 'key', 'value', 'format')); + } + } + } else { + throw new XmlException('Invalid array'); + } + } + } + + /** + * Helper to _fromArray(). It will create children of arrays + * + * @param array $data Array with information to create children + * @return void + * @phpstan-param array{dom: \DOMDocument, node: \DOMNode, key: string, format: string, value?: mixed} $data + */ + protected static function _createChild(array $data): void + { + $data += [ + 'value' => null, + ]; + + $key = $data['key']; + $format = $data['format']; + $value = $data['value']; + $dom = $data['dom']; + $node = $data['node']; + $childNS = null; + $childValue = null; + if (is_object($value) && method_exists($value, 'toArray') && is_callable([$value, 'toArray'])) { + $value = $value->toArray(); + } + if (is_array($value)) { + if (isset($value['@'])) { + $childValue = (string)$value['@']; + unset($value['@']); + } + if (isset($value['xmlns:'])) { + $childNS = $value['xmlns:']; + unset($value['xmlns:']); + } + } elseif ($value || $value === 0 || $value === '0') { + $childValue = (string)$value; + } + + $child = $dom->createElement($key); + if ($childValue !== null) { + $child->appendChild($dom->createTextNode($childValue)); + } + if ($childNS) { + $child->setAttribute('xmlns', $childNS); + } + + static::_fromArray($dom, $child, $value, $format); + $node->appendChild($child); + } + + /** + * Returns this XML structure as an array. + * + * @param \SimpleXMLElement|\DOMNode $obj SimpleXMLElement, DOMNode instance + * @return array Array representation of the XML structure. + * @throws \Cake\Utility\Exception\XmlException + */ + public static function toArray(SimpleXMLElement|DOMNode $obj): array + { + if ($obj instanceof DOMNode) { + $obj = simplexml_import_dom($obj); + } + + 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)); + + return $result; + } + + /** + * Recursive method to toArray + * + * @param \SimpleXMLElement $xml SimpleXMLElement object + * @param array $parentData Parent array with data + * @param string $ns Namespace of current child + * @param array $namespaces List of namespaces in XML + * @return void + */ + protected static function _toArray(SimpleXMLElement $xml, array &$parentData, string $ns, array $namespaces): void + { + $data = []; + + foreach ($namespaces as $namespace) { + $attributes = $xml->attributes($namespace, true); + foreach ($attributes as $key => $value) { + if ($namespace) { + $key = $namespace . ':' . $key; + } + $data['@' . $key] = (string)$value; + } + + foreach ($xml->children($namespace, true) as $child) { + static::_toArray($child, $data, $namespace, $namespaces); + } + } + + $asString = trim((string)$xml); + if (!$data) { + $data = $asString; + } elseif ($asString !== '') { + $data['@'] = $asString; + } + + if ($ns) { + $ns .= ':'; + } + $name = $ns . $xml->getName(); + if (isset($parentData[$name])) { + if (!is_array($parentData[$name]) || !isset($parentData[$name][0])) { + $parentData[$name] = [$parentData[$name]]; + } + $parentData[$name][] = $data; + } else { + $parentData[$name] = $data; + } + } +} diff --git a/src/Utility/bootstrap.php b/src/Utility/bootstrap.php new file mode 100644 index 00000000000..d335b754ddf --- /dev/null +++ b/src/Utility/bootstrap.php @@ -0,0 +1,21 @@ +=8.2", + "cakephp/core": "^5.3.0" + }, + "autoload": { + "psr-4": { + "Cake\\Utility\\": "." + }, + "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.4.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 new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/Validation/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/Validation/README.md b/src/Validation/README.md new file mode 100644 index 00000000000..9d43d566e98 --- /dev/null +++ b/src/Validation/README.md @@ -0,0 +1,37 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/validation.svg?style=flat-square)](https://packagist.org/packages/cakephp/validation) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Validation Library + +The validation library in CakePHP provides features to build validators that can validate arbitrary +arrays of data with ease. + +## Usage + +Validator objects define the rules that apply to a set of fields. Validator objects contain a mapping between +fields and validation sets. Creating a validator is simple: + +```php +use Cake\Validation\Validator; + +$validator = new Validator(); +$validator + ->requirePresence('email') + ->add('email', 'validFormat', [ + 'rule' => 'email', + 'message' => 'E-mail must be valid' + ]) + ->requirePresence('name') + ->notEmptyString('name', 'We need your name.') + ->requirePresence('comment') + ->notEmptyString('comment', 'You need to give a comment.'); + +$errors = $validator->validate($_POST); +if ($errors) { + // display errors. +} +``` + +## Documentation + +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 new file mode 100644 index 00000000000..077b7cc77f0 --- /dev/null +++ b/src/Validation/RulesProvider.php @@ -0,0 +1,93 @@ + + */ + protected ReflectionClass $_reflection; + + /** + * Constructor, sets the default class to use for calling methods + * + * @param object|string $class the default class to proxy + * @throws \ReflectionException + * @phpstan-param object|class-string $class + */ + 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); + } + + /** + * Proxies validation method calls to the Validation class. + * + * The last argument (context) will be sliced off, if the validation + * method's last parameter is not named 'context'. This lets + * the various wrapped validation methods to not receive the validation + * context unless they need it. + * + * @param string $method the validation method to call + * @param array $arguments the list of arguments to pass to the method + * @return bool Whether the validation rule passed + */ + public function __call(string $method, array $arguments): bool + { + $method = $this->_reflection->getMethod($method); + $argumentList = $method->getParameters(); + /** @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; + + return $method->invokeArgs($object, $arguments); + } +} diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php new file mode 100644 index 00000000000..f1fb7d741e6 --- /dev/null +++ b/src/Validation/Validation.php @@ -0,0 +1,1976 @@ +'; + + /** + * 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 + */ + 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+)?)', + ]; + + /** + * Holds an array of errors messages set in this class. + * These are used for debugging purposes + * + * @var array + */ + public static array $errors = []; + + /** + * Checks that a string contains something other than whitespace + * + * Returns true if string contains something other than whitespace + * + * @param mixed $check Value to check + * @return bool Success + */ + public static function notBlank(mixed $check): bool + { + if (!$check && !is_bool($check) && !is_numeric($check)) { + return false; + } + + return static::_check($check, '/[^\s]+/m'); + } + + /** + * Checks that a 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 mixed $check Value to check + * @return bool Success + */ + public static function alphaNumeric(mixed $check): bool + { + if ((empty($check) && $check !== '0') || !is_scalar($check)) { + return false; + } + + return self::_check($check, '/^[\p{Ll}\p{Lm}\p{Lo}\p{Lt}\p{Lu}\p{Nd}]+$/Du'); + } + + /** + * Checks that a value doesn't contain any alpha numeric characters + * + * 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 notAlphaNumeric(mixed $check): bool + { + 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; + } + + return self::_check($check, '/^[[:alnum:]]+$/'); + } + + /** + * Checks that a value doesn't contain any non-ascii alpha numeric characters + * + * @param mixed $check Value to check + * @return bool Success + */ + public static function notAsciiAlphaNumeric(mixed $check): bool + { + return !static::asciiAlphaNumeric($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, + * + * @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 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, this will be used instead of the defined regex values. + * @return bool Success + * @see \Cake\Validation\Validation::luhn() + */ + 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(['-', ' '], '', (string)$check); + if (mb_strlen($check) < 13) { + return false; + } + + if ($regex !== null && static::_check($check, $regex)) { + return !$deep || static::luhn($check); + } + $cards = [ + 'all' => [ + 'amex' => '/^3[47]\\d{13}$/', + 'bankcard' => '/^56(10\\d\\d|022[1-5])\\d{10}$/', + 'diners' => '/^(?:3(0[0-5]|[68]\\d)\\d{11})|(?:5[1-5]\\d{14})$/', + 'disc' => '/^(?:6011|650\\d)\\d{12}$/', + 'electron' => '/^(?:417500|4917\\d{2}|4913\\d{2})\\d{10}$/', + 'enroute' => '/^2(?:014|149)\\d{11}$/', + 'jcb' => '/^(3\\d{4}|2131|1800)\\d{11}$/', + '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}$/', + ], + // 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)) { + foreach ($type as $value) { + $regex = $cards['all'][strtolower($value)]; + + if (static::_check($check, $regex)) { + return static::luhn($check); + } + } + } elseif ($type === 'all') { + foreach ($cards['all'] as $value) { + $regex = $value; + + if (static::_check($check, $regex)) { + return static::luhn($check); + } + } + } else { + $regex = $cards['fast']; + + if (static::_check($check, $regex)) { + return static::luhn($check); + } + } + + return false; + } + + /** + * Used to check the count of a given value of type array or Countable. + * + * @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(mixed $check, string $operator, int $expectedCount): bool + { + if (!is_array($check) && !$check instanceof Countable) { + return false; + } + + return self::comparison(count($check), $operator, $expectedCount); + } + + /** + * Used to compare 2 numeric values. + * + * @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(mixed $check1, string $operator, mixed $check2): bool + { + if ( + (!is_numeric($check1) || !is_numeric($check2)) && + !in_array($operator, static::COMPARE_STRING, true) + ) { + return false; + } + + 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; + } + + /** + * Compare one field to another. + * + * If both fields have exactly the same value this method will return true. + * + * @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. + * @return bool + */ + public static function compareWith(mixed $check, string $field, array $context): bool + { + return self::compareFields($check, $field, static::COMPARE_SAME, $context); + } + + /** + * Compare one field to another. + * + * Return true if the comparison matches the expected result. + * + * @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 compareFields(mixed $check, string $field, string $operator, array $context): bool + { + if (!isset($context['data']) || !array_key_exists($field, $context['data'])) { + return false; + } + + return static::comparison($check, $operator, $context['data'][$field]); + } + + /** + * Used when a custom regular expression is needed. + * + * @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(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()'; + + return false; + } + + return static::_check($check, $regex); + } + + /** + * 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 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 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(mixed $check, array|string $format = 'ymd', ?string $regex = null): bool + { + if ( + (class_exists(ChronosDate::class) && $check instanceof ChronosDate) + || $check instanceof DateTimeInterface + ) { + return true; + } + if (is_object($check)) { + return false; + } + if (is_array($check)) { + $check = static::_getDateString($check); + $format = 'ymd'; + } + + if ($regex !== null) { + return static::_check($check, $regex); + } + $month = '(0[123456789]|10|11|12)'; + $separator = '([- /.])'; + // 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?[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))' . $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'] = '%^(?:(?:' . $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)?\\ ' . $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(?=,?\\ ' . $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 . $fourDigitYear . '$%'; + // phpcs:enable Generic.Files.LineLength + + $regex['my'] = '%^(' . $month . $separator . $year . ')$%'; + $regex['ym'] = '%^(' . $year . $separator . $month . ')$%'; + $regex['y'] = '%^(' . $fourDigitYear . ')$%'; + + $format = (array)$format; + foreach ($format as $key) { + if (static::_check($check, $regex[$key])) { + return true; + } + } + + return false; + } + + /** + * Validates a datetime value + * + * 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 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(mixed $check, array|string $dateFormat = 'ymd', ?string $regex = null): bool + { + if ($check instanceof DateTimeInterface) { + return true; + } + 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'; + } + 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[: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 a valid time string/object + * @return bool Success + */ + public static function time(mixed $check): bool + { + if ( + (class_exists(ChronosTime::class) && $check instanceof ChronosTime) + || $check instanceof DateTimeInterface + ) { + return true; + } + if (is_array($check)) { + $check = static::_getDateString($check); + } + + 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 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\Date::parseDate() + * @see \Cake\I18n\Time::parseTime() + * @see \Cake\I18n\DateTime::parseDateTime() + */ + public static function localizedTime(mixed $check, string $type = 'datetime', string|int|null $format = null): bool + { + 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_string($check)) { + return false; + } + static $methods = [ + 'date' => 'parseDate', + 'time' => 'parseTime', + 'datetime' => 'parseDateTime', + ]; + if (empty($methods[$type])) { + throw new InvalidArgumentException('Unsupported parser type given.'); + } + $method = $methods[$type]; + + 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. + * + * @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(mixed $check, array $booleanValues = [true, false, 0, 1, '0', '1']): bool + { + return in_array($check, $booleanValues, true); + } + + /** + * Validates if given value is truthy. + * + * The list of what is considered to be truthy values, may be set via $truthyValues. + * + * @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(mixed $check, array $truthyValues = [true, 1, '1']): bool + { + return in_array($check, $truthyValues, true); + } + + /** + * Validates if given value is falsey. + * + * The list of what is considered to be falsey values, may be set via $falseyValues. + * + * @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(mixed $check, array $falseyValues = [false, 0, '0']): bool + { + 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 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(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}"; + $sign = '[+-]?'; + $exp = "(?:[eE]{$sign}{$lnum})?"; + + if ($places === null) { + $regex = "/^{$sign}(?:{$lnum}|{$dnum}){$exp}$/"; + } elseif ($places === true) { + if (is_float($check) && floor($check) === $check) { + $check = sprintf('%.1f', $check); + } + $regex = "/^{$sign}{$dnum}{$exp}$/"; + } else { + $places = '[0-9]{' . $places . '}'; + $dnum = "(?:[0-9]*[\.]{$places}|{$lnum}[\.]{$places})"; + $regex = "/^{$sign}{$dnum}{$exp}$/"; + } + } + + // account for localized floats. + $locale = ini_get('intl.default_locale') ?: static::DEFAULT_LOCALE; + $formatter = new NumberFormatter($locale, NumberFormatter::DECIMAL); + $decimalPoint = $formatter->getSymbol(NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); + $groupingSep = $formatter->getSymbol(NumberFormatter::GROUPING_SEPARATOR_SYMBOL); + + // 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); + } + + /** + * Validates for an email address. + * + * Only uses getmxrr() checking for deep validation, or + * any PHP version on a non-windows distribution + * + * @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(mixed $check, ?bool $deep = false, ?string $regex = null): bool + { + if (!is_string($check)) { + return false; + } + + // 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 && preg_match('/@(' . static::$_pattern['hostname'] . ')$/i', $check, $regs)) { + if (function_exists('getmxrr') && getmxrr($regs[1], $mxhosts)) { + return true; + } + if (function_exists('checkdnsrr') && checkdnsrr($regs[1], 'MX')) { + return true; + } + + return is_array(gethostbynamel($regs[1] . '.')); + } + + 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. + * + * @param mixed $check Value to check + * @param mixed $comparedTo Value to compare + * @return bool Success + */ + public static function equalTo(mixed $check, mixed $comparedTo): bool + { + return $check === $comparedTo; + } + + /** + * Checks that value has a valid file extension. + * + * 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(mixed $check, array $extensions = ['gif', 'jpeg', 'png', 'jpg']): bool + { + 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); + } + + if (!$check) { + return false; + } + + $extension = strtolower(pathinfo($check, PATHINFO_EXTENSION)); + foreach ($extensions as $value) { + if ($extension === strtolower($value)) { + return true; + } + } + + return false; + } + + /** + * Validation of an IP address. + * + * @param mixed $check The string to test. + * @param string $type The IP Protocol version to validate against + * @return bool Success + */ + public static function ip(mixed $check, string $type = 'both'): bool + { + if (!is_string($check)) { + return false; + } + + $type = strtolower($type); + $flags = 0; + if ($type === 'ipv4') { + $flags = FILTER_FLAG_IPV4; + } + if ($type === 'ipv6') { + $flags = FILTER_FLAG_IPV6; + } + + 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 mixed $check The string to test + * @param int $min The minimal string length + * @return bool Success + */ + public static function minLength(mixed $check, int $min): bool + { + 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 mixed $check The string to test + * @param int $max The maximal string length + * @return bool Success + */ + public static function maxLength(mixed $check, int $max): bool + { + 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 mixed $check The string to test + * @param int $min The minimal string length (in bytes) + * @return bool Success + */ + public static function minLengthBytes(mixed $check, int $min): bool + { + 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 mixed $check The string to test + * @param int $max The maximal string length + * @return bool Success + */ + public static function maxLengthBytes(mixed $check, int $max): bool + { + if (!is_scalar($check)) { + return false; + } + + return strlen((string)$check) <= $max; + } + + /** + * Checks that a value is a monetary amount. + * + * @param mixed $check Value to check + * @param string $symbolPosition Where symbol is located (left/right) + * @return bool Success + */ + 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') { + $regex = '/^' . $money . '(? provide a list of choices that selections must be made from + * - max => maximum number of non-zero choices that can be made + * - min => minimum number of non-zero choices that can be made + * + * @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(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); + }); + if (!$check) { + return false; + } + if ($options['max'] && count($check) > $options['max']) { + return false; + } + if ($options['min'] && count($check) < $options['min']) { + return false; + } + if ($options['in'] && is_array($options['in'])) { + if ($caseInsensitive) { + $options['in'] = array_map('mb_strtolower', $options['in']); + } + foreach ($check as $val) { + $strict = !is_numeric($val); + if ($caseInsensitive) { + $val = mb_strtolower((string)$val); + } + if (!in_array((string)$val, $options['in'], $strict)) { + return false; + } + } + } + + return true; + } + + /** + * Checks if a value is numeric. + * + * @param mixed $check Value to check + * @return bool Success + */ + public static function numeric(mixed $check): bool + { + return is_numeric($check); + } + + /** + * Checks if a value is a natural number. + * + * @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(mixed $check, bool $allowZero = false): bool + { + $regex = $allowZero ? '/^(?:0|[1-9][0-9]*)$/' : '/^[1-9][0-9]*$/'; + + return static::_check($check, $regex); + } + + /** + * Validates that a number is in specified range. + * + * If $lower and $upper are set, the range is inclusive. + * If they are not set, will return true if $check is a + * legal finite on this platform. + * + * @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(mixed $check, ?float $lower = null, ?float $upper = null): bool + { + if (!is_numeric($check)) { + return false; + } + if ((float)$check != $check) { + return false; + } + if (isset($lower, $upper)) { + return $check >= $lower && $check <= $upper; + } + + return is_finite((float)$check); + } + + /** + * Checks that a value is a valid URL according to https://www.w3.org/Addressing/URL/url-spec.txt + * + * The regex checks for the following component parts: + * + * - a valid, optional, scheme + * - 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 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(mixed $check, bool $strict = false): bool + { + if (!is_string($check)) { + return false; + } + + static::_populateIp(); + + $emoji = '\x{1F190}-\x{1F9EF}'; + $alpha = '0-9\p{L}\p{N}' . $emoji; + $hex = '(%[0-9a-f]{2})'; + $subDelimiters = preg_quote('/!"$&\'()*+,-.@_:;=~[]', '/'); + $path = '([' . $subDelimiters . $alpha . ']|' . $hex . ')'; + $fragmentAndQuery = '([\?' . $subDelimiters . $alpha . ']|' . $hex . ')'; + // 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. + * + * @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(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((string)$check); + } else { + $list = array_map('strval', $list); + } + + return in_array((string)$check, $list, true); + } + + /** + * Checks that a value is a valid UUID - https://tools.ietf.org/html/rfc4122 + * + * @param mixed $check Value to check + * @return bool Success + */ + public static function uuid(mixed $check): bool + { + $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); + } + + /** + * Runs a regular expression match. + * + * @param mixed $check Value to check against the $regex expression + * @param string $regex Regular expression + * @return bool Success of match + */ + protected static function _check(mixed $check, string $regex): bool + { + return is_scalar($check) && preg_match($regex, (string)$check); + } + + /** + * Luhn algorithm + * + * @param mixed $check Value to check. + * @return bool Success + * @see https://en.wikipedia.org/wiki/Luhn_algorithm + */ + 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 += (int)$check[$position]; + } + + for ($position = $length % 2; $position < $length; $position += 2) { + $number = (int)$check[$position] * 2; + $sum += $number < 10 ? $number : $number - 9; + } + + return $sum % 10 === 0; + } + + /** + * Checks the mime type of a file. + * + * Will check the mimetype of files/UploadedFileInterface instances + * by checking the using finfo on the file, not relying on the content-type + * sent by the client. + * + * @param mixed $check Value to check. + * @param array|string $mimeTypes Array of mime types or regex pattern to check. + * @return bool Success + * @throws \Cake\Core\Exception\CakeException when mime type can not be determined. + */ + public static function mimeType(mixed $check, array|string $mimeTypes = []): bool + { + $file = static::getFilename($check); + if ($file === null) { + return false; + } + + if (!function_exists('finfo_open')) { + throw new CakeException('ext/fileinfo is required for validating file mime types'); + } + + if (!is_file($file)) { + throw new CakeException('Cannot validate mimetype for a missing file'); + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = $finfo ? finfo_file($finfo, $file) : null; + + if (!$mime) { + throw new CakeException('Can not determine the mimetype.'); + } + + if (is_string($mimeTypes)) { + return self::_check($mime, $mimeTypes); + } + + foreach ($mimeTypes as $key => $val) { + $mimeTypes[$key] = strtolower($val); + } + + return in_array(strtolower($mime), $mimeTypes, true); + } + + /** + * Helper for reading the file name. + * + * @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(mixed $check): ?string + { + if ($check instanceof UploadedFileInterface) { + // Uploaded files throw exceptions on upload errors. + try { + $uri = $check->getStream()->getMetadata('uri'); + if (is_string($uri)) { + return $uri; + } + + return null; + } catch (RuntimeException) { + return null; + } + } + + if (is_string($check)) { + return $check; + } + + return null; + } + + /** + * Checks the filesize + * + * Will check the filesize of files/UploadedFileInterface instances + * by checking the filesize() on disk and not relying on the length + * reported by the client. + * + * @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(mixed $check, string $operator, string|int $size): bool + { + $file = static::getFilename($check); + if ($file === null) { + return false; + } + + if (is_string($size)) { + $size = Text::parseFileSize($size); + } + $filesize = filesize($file); + + return static::comparison($filesize, $operator, $size); + } + + /** + * Checking for upload errors + * + * 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(mixed $check, bool $allowNoFile = false): bool + { + if ($check instanceof UploadedFileInterface) { + $code = $check->getError(); + } elseif (is_array($check)) { + if (!isset($check['error'])) { + return false; + } + + $code = $check['error']; + } else { + $code = $check; + } + if ($allowNoFile) { + return in_array((int)$code, [UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE], true); + } + + return (int)$code === UPLOAD_ERR_OK; + } + + /** + * Validate an uploaded file. + * + * Helps join `uploadError`, `fileSize` and `mimeType` into + * one higher level validation method. + * + * ### Options + * + * - `types` - An array of valid mime types. If empty all types + * will be accepted. The `type` will not be looked at, instead + * 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 this file is optional. Defaults to false. + * If true a missing file will pass the validator regardless of other constraints. + * + * @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(mixed $file, array $options = []): bool + { + if (!($file instanceof UploadedFileInterface)) { + return false; + } + + $options += [ + 'minSize' => null, + 'maxSize' => null, + 'types' => null, + 'optional' => false, + ]; + + if (!static::uploadError($file, $options['optional'])) { + return false; + } + + if ($options['optional'] && $file->getError() === UPLOAD_ERR_NO_FILE) { + return true; + } + + if ( + isset($options['minSize']) + && !static::fileSize($file, static::COMPARE_GREATER_OR_EQUAL, $options['minSize']) + ) { + return false; + } + + 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 true; + } + + /** + * Validates the size of an uploaded image. + * + * @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(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`.', + ); + } + + $file = static::getFilename($file); + if ($file === null) { + return false; + } + $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]); + } + if (isset($options['width'])) { + $validWidth = self::comparison($width, $options['width'][0], $options['width'][1]); + } + if ($validHeight !== null && $validWidth !== null) { + return $validHeight && $validWidth; + } + if ($validHeight !== null) { + return $validHeight; + } + if ($validWidth !== null) { + return $validWidth; + } + + throw new InvalidArgumentException('The 2nd argument is missing the `width` and / or `height` options.'); + } + + /** + * Validates the image width. + * + * @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(mixed $file, string $operator, int $width): bool + { + return self::imageSize($file, [ + 'width' => [ + $operator, + $width, + ], + ]); + } + + /** + * Validates the image height. + * + * @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(mixed $file, string $operator, int $height): bool + { + return self::imageSize($file, [ + 'height' => [ + $operator, + $height, + ], + ]); + } + + /** + * Validates a geographic coordinate. + * + * Supported formats: + * + * - `, ` Example: `-25.274398, 133.775136` + * + * ### Options + * + * - `type` - A string of the coordinate format, right now only `latLong`. + * - `format` - By default `both`, can be `long` and `lat` as well to validate + * only a part of the coordinate. + * + * @param mixed $value Geographic location as string + * @param array $options Options for the validation logic. + * @return bool + */ + public static function geoCoordinate(mixed $value, array $options = []): bool + { + if (!is_scalar($value)) { + return false; + } + + $options += [ + 'format' => 'both', + 'type' => 'latLong', + ]; + if ($options['type'] !== 'latLong') { + throw new InvalidArgumentException(sprintf( + 'Unsupported coordinate type `%s`. Use `latLong` instead.', + $options['type'], + )); + } + $pattern = '/^' . self::$_pattern['latitude'] . ',\s*' . self::$_pattern['longitude'] . '$/'; + if ($options['format'] === 'long') { + $pattern = '/^' . self::$_pattern['longitude'] . '$/'; + } + if ($options['format'] === 'lat') { + $pattern = '/^' . self::$_pattern['latitude'] . '$/'; + } + + return (bool)preg_match($pattern, (string)$value); + } + + /** + * Convenience method for latitude validation. + * + * @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(mixed $value, array $options = []): bool + { + $options['format'] = 'lat'; + + return self::geoCoordinate($value, $options); + } + + /** + * Convenience method for longitude validation. + * + * @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(mixed $value, array $options = []): bool + { + $options['format'] = 'long'; + + return self::geoCoordinate($value, $options); + } + + /** + * Check that the input value is within the ascii byte range. + * + * This method will reject all non-string values. + * + * @param mixed $value The value to check + * @return bool + */ + public static function ascii(mixed $value): bool + { + if (!is_string($value)) { + return false; + } + + return strlen($value) <= mb_strlen($value, 'utf-8'); + } + + /** + * Check that the input value is a utf8 string. + * + * This method will reject all non-string values. + * + * # Options + * + * - `extended` - Disallow bytes higher within the basic multilingual plane. + * MySQL's older utf8 encoding type does not allow characters above + * the basic multilingual plane. Defaults to false. + * + * @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(mixed $value, array $options = []): bool + { + if (!is_string($value)) { + return false; + } + $options += ['extended' => false]; + if ($options['extended']) { + return preg_match('//u', $value) === 1; + } + + return preg_match('/[\x{10000}-\x{10FFFF}]/u', $value) === 0; + } + + /** + * Check that the input value is an integer + * + * This method will accept strings that contain only integer data + * as well. + * + * @param mixed $value The value to check + * @return bool + */ + public static function isInteger(mixed $value): bool + { + 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 mixed $value The value to check + * @return bool + */ + public static function isArray(mixed $value): bool + { + return is_array($value); + } + + /** + * Check that the input value is a scalar. + * + * This method will accept integers, floats, strings and booleans, but + * not accept arrays, objects, resources and nulls. + * + * @param mixed $value The value to check + * @return bool + */ + public static function isScalar(mixed $value): bool + { + return is_scalar($value); + } + + /** + * Check that the input value is a 6 digits hex color. + * + * @param mixed $check The value to check + * @return bool Success + */ + 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. + * @return string + */ + 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']) + ) + ) { + $formatted .= sprintf('%d-%02d-%02d ', $value['year'], $value['month'], $value['day']); + } + + if (isset($value['hour'])) { + 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; + } + $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'], + ); + } + } + + return trim($formatted); + } + + /** + * Lazily populate the IP address patterns used for validations + * + * @return void + */ + 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})'; + $pattern .= '|(:[0-9A-Fa-f]{1,4})))|(([0-9A-Fa-f]{1,4}:){5}((:((25[0-5]|2[0-4]\d|[01]?\d{1,2})'; + $pattern .= '(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})){3})?)|((:[0-9A-Fa-f]{1,4}){1,2})))|(([0-9A-Fa-f]{1,4}:)'; + $pattern .= '{4}(:[0-9A-Fa-f]{1,4}){0,1}((:((25[0-5]|2[0-4]\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2}))'; + $pattern .= '{3})?)|((:[0-9A-Fa-f]{1,4}){1,2})))|(([0-9A-Fa-f]{1,4}:){3}(:[0-9A-Fa-f]{1,4}){0,2}'; + $pattern .= '((:((25[0-5]|2[0-4]\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})){3})?)|'; + $pattern .= '((:[0-9A-Fa-f]{1,4}){1,2})))|(([0-9A-Fa-f]{1,4}:){2}(:[0-9A-Fa-f]{1,4}){0,3}'; + $pattern .= '((:((25[0-5]|2[0-4]\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2}))'; + $pattern .= '{3})?)|((:[0-9A-Fa-f]{1,4}){1,2})))|(([0-9A-Fa-f]{1,4}:)(:[0-9A-Fa-f]{1,4})'; + $pattern .= '{0,4}((:((25[0-5]|2[0-4]\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})){3})?)'; + $pattern .= '|((:[0-9A-Fa-f]{1,4}){1,2})))|(:(:[0-9A-Fa-f]{1,4}){0,5}((:((25[0-5]|2[0-4]'; + $pattern .= '\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})){3})?)|((:[0-9A-Fa-f]{1,4})'; + $pattern .= '{1,2})))|(((25[0-5]|2[0-4]\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})){3})))(%.+)?'; + + static::$_pattern['IPv6'] = $pattern; + } + if (!isset(static::$_pattern['IPv4'])) { + $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 + } + + /** + * Reset internal variables for another validation run. + * + * @return void + */ + protected static function _reset(): void + { + static::$errors = []; + } +} diff --git a/src/Validation/ValidationRule.php b/src/Validation/ValidationRule.php new file mode 100644 index 00000000000..a712185440d --- /dev/null +++ b/src/Validation/ValidationRule.php @@ -0,0 +1,233 @@ + $validator The validator properties + */ + public function __construct(array $validator) + { + $this->_addValidatorProps($validator); + } + + /** + * Returns whether this rule should break validation process for associated field + * after it fails + * + * @return bool + */ + public function isLast(): bool + { + return $this->_last; + } + + /** + * Dispatches the validation rule to the given validator method and returns + * a boolean indicating whether the rule passed or not. If a string is returned + * 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 + * 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 + * during validation. Recognized keys are: + * - 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 array|string|bool + * @throws \InvalidArgumentException when the supplied rule is not a valid + * callable for the configured scope + */ + public function process(mixed $value, array $providers, array $context = []): array|string|bool + { + $context += ['data' => [], 'newRecord' => true, 'providers' => $providers]; + + if ($this->_skip($context)) { + return true; + } + + if (is_string($this->_rule)) { + $provider = $providers[$this->_provider]; + 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; + } + } + } + + /** @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_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; + } + + return $result; + } + + /** + * Checks if the validation rule should be skipped + * + * @param array $context A key value list of data that could be used as context + * during validation. Recognized keys are: + * - 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(array $context): bool + { + if (is_string($this->_on)) { + $newRecord = $context['newRecord']; + + return ($this->_on === Validator::WHEN_CREATE && !$newRecord) + || ($this->_on === Validator::WHEN_UPDATE && $newRecord); + } + + if ($this->_on !== null) { + $function = $this->_on; + + return !$function($context); + } + + return false; + } + + /** + * Sets the rule properties from the rule entry in validate + * + * @param array $validator [optional] + * @return void + */ + protected function _addValidatorProps(array $validator = []): void + { + foreach ($validator as $key => $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'], true)) { + $this->{"_{$key}"} = $value; + } + } + } + + /** + * Returns the value of a property by name + * + * @param string $property The name of the property to retrieve. + * @return mixed + */ + public function get(string $property): mixed + { + $property = '_' . $property; + + return $this->{$property} ?? null; + } +} diff --git a/src/Validation/ValidationSet.php b/src/Validation/ValidationSet.php new file mode 100644 index 00000000000..6e8d70f533c --- /dev/null +++ b/src/Validation/ValidationSet.php @@ -0,0 +1,253 @@ + + * @template-implements \IteratorAggregate + */ +class ValidationSet implements ArrayAccess, IteratorAggregate, Countable +{ + /** + * Holds the ValidationRule objects + * + * @var array<\Cake\Validation\ValidationRule> + */ + protected array $_rules = []; + + /** + * Denotes whether the field name key must be present in data array + * + * @var callable|string|bool + */ + protected $_validatePresent = false; + + /** + * Denotes if a field is allowed to be empty + * + * @var callable|string|bool + */ + protected $_allowEmpty = false; + + /** + * Returns whether a field can be left out. + * + * @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 callable|string|bool $validatePresent Valid values are true, false, 'create', 'update' or a callable. + * @return $this + */ + public function requirePresence(callable|string|bool $validatePresent) + { + $this->_validatePresent = $validatePresent; + + return $this; + } + + /** + * Returns whether a field can be left empty. + * + * @return callable|string|bool + */ + public function isEmptyAllowed(): callable|string|bool + { + return $this->_allowEmpty; + } + + /** + * Sets whether a field value is allowed to be empty. + * + * @param callable|string|bool $allowEmpty Valid values are true, false, + * 'create', 'update' or a callable. + * @return $this + */ + public function allowEmpty(callable|string|bool $allowEmpty) + { + $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|null + */ + public function rule(string $name): ?ValidationRule + { + if (empty($this->_rules[$name])) { + return null; + } + + return $this->_rules[$name]; + } + + /** + * Returns all rules for this validation set + * + * @return array<\Cake\Validation\ValidationRule> + */ + 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 + * + * ### Example: + * + * ``` + * $set + * ->add('notBlank', ['rule' => 'notBlank']) + * ->add('inRange', ['rule' => ['between', 4, 10]) + * ``` + * + * @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(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; + } + + /** + * Removes a validation rule from the set + * + * ### Example: + * + * ``` + * $set + * ->remove('notBlank') + * ->remove('inRange') + * ``` + * + * @param string $name The name under which the rule should be unset + * @return $this + */ + public function remove(string $name) + { + unset($this->_rules[$name]); + + return $this; + } + + /** + * Returns whether an index exists in the rule set + * + * @param string $index name of the rule + * @return bool + */ + public function offsetExists(mixed $index): bool + { + return isset($this->_rules[$index]); + } + + /** + * Returns a rule object by its index + * + * @param string $index name of the rule + * @return \Cake\Validation\ValidationRule + */ + public function offsetGet(mixed $index): ValidationRule + { + return $this->_rules[$index]; + } + + /** + * Sets or replace a validation rule + * + * @param string $offset name of the rule + * @param \Cake\Validation\ValidationRule|array $value Rule to add to $index + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->add($offset, $value); + } + + /** + * Unsets a validation rule + * + * @param string $index name of the rule + * @return void + */ + public function offsetUnset(mixed $index): void + { + unset($this->_rules[$index]); + } + + /** + * Returns an iterator for each of the rules to be applied + * + * @return \Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->_rules); + } + + /** + * Returns the number of rules in this set + * + * @return int + */ + public function count(): int + { + return count($this->_rules); + } +} diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php new file mode 100644 index 00000000000..81934e83c26 --- /dev/null +++ b/src/Validation/Validator.php @@ -0,0 +1,3251 @@ + + * @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 + */ + 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 + */ + protected array $_fields = []; + + /** + * An associative array of objects or classes containing methods + * used for validation + * + * @var array + * @phpstan-var array + */ + protected array $_providers = []; + + /** + * An associative array of objects or classes used as a default provider list + * + * @var array + * @phpstan-var array + */ + protected static array $_defaultProviders = []; + + /** + * Contains the validation messages associated with checking the presence + * for each corresponding field. + * + * @var array + */ + protected array $_presenceMessages = []; + + /** + * Whether to use I18n functions for translating default error messages + * + * @var bool + */ + protected bool $_useI18n; + + /** + * Contains the validation messages associated with checking the emptiness + * for each corresponding field. + * + * @var array + */ + protected array $_allowEmptyMessages = []; + + /** + * 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('\Cake\I18n\__d'); + $this->_providers = self::$_defaultProviders; + $this->_providers['default'] ??= Validation::class; + } + + /** + * Whether to stop validation rule evaluation on the first failed rule. + * + * 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 setStopOnFailure(bool $stopOnFailure = true) + { + $this->_stopOnFailure = $stopOnFailure; + + return $this; + } + + /** + * 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; + + if (!$keyPresent && !$this->_checkPresence($field, $context)) { + $errors[$name]['_required'] = $this->getRequiredMessage($name); + continue; + } + if (!$keyPresent) { + continue; + } + + $canBeEmpty = $this->_canBeEmpty($field, $context); + + $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'] = $this->getNotEmptyMessage($name); + continue; + } + + if ($isEmpty) { + continue; + } + + $result = $this->_processRules($name, $field, $data, $newRecord, $context); + if ($result) { + $errors[$name] = $result; + } + } + + return $errors; + } + + /** + * Returns a ValidationSet object containing all validation rules for a field, if + * passed a ValidationSet as second argument, it will replace any other rule set defined + * before + * + * @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(string $name, ?ValidationSet $set = null): ValidationSet + { + if (!isset($this->_fields[$name])) { + $set = $set ?: new ValidationSet(); + $this->_fields[$name] = $set; + } + + return $this->_fields[$name]; + } + + /** + * Check whether a validator contains any rules for the given field. + * + * @param string $name The field name to check. + * @return bool + */ + public function hasField(string $name): bool + { + return isset($this->_fields[$name]); + } + + /** + * 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. + * + * @param string $name The name under which the provider should be set. + * @param object|string $object Provider object or class name. + * @phpstan-param object|class-string $object + * @return $this + */ + public function setProvider(string $name, object|string $object) + { + $this->_providers[$name] = $object; + + return $this; + } + + /** + * Returns the provider stored under that name if it exists. + * + * @param string $name The name under which the provider should be set. + * @return object|class-string|null + */ + public function getProvider(string $name): object|string|null + { + 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|class-string|null + */ + public static function getDefaultProvider(string $name): object|string|null + { + 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. + * @phpstan-param object|class-string $object + * @return void + */ + public static function addDefaultProvider(string $name, object|string $object): void + { + self::$_defaultProviders[$name] = $object; + } + + /** + * Get the list of default providers. + * + * @return array + */ + public static function getDefaultProviders(): array + { + return array_keys(self::$_defaultProviders); + } + + /** + * Get the list of providers in this validator. + * + * @return array + */ + public function providers(): array + { + return array_keys($this->_providers); + } + + /** + * Returns whether a rule set is defined for a field or not + * + * @param string $field name of the field to check + * @return bool + */ + public function offsetExists(mixed $field): bool + { + return isset($this->_fields[$field]); + } + + /** + * Returns the rule set for a field + * + * @param string|int $field name of the field to check + * @return \Cake\Validation\ValidationSet + */ + public function offsetGet(mixed $field): ValidationSet + { + return $this->field((string)$field); + } + + /** + * Sets the rule set for a 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(mixed $offset, mixed $value): void + { + if (!$value instanceof ValidationSet) { + $set = new ValidationSet(); + foreach ($value as $name => $rule) { + $set->add($name, $rule); + } + $value = $set; + } + $this->_fields[$offset] = $value; + } + + /** + * Unsets the rule set for a field + * + * @param string $field name of the field to unset + * @return void + */ + public function offsetUnset(mixed $field): void + { + unset($this->_fields[$field]); + } + + /** + * Returns an iterator for each of the fields to be validated + * + * @return \Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->_fields); + } + + /** + * Returns the number of fields having validation rules + * + * @return int + */ + public function count(): int + { + return count($this->_fields); + } + + /** + * Adds a new rule to a field's rule set. If second argument is an array + * then rules list for the field will be replaced with second argument and + * third argument will be ignored. + * + * ### Example: + * + * ``` + * $validator + * ->add('title', 'required', ['rule' => 'notBlank']) + * ->add('user_id', 'valid', ['rule' => 'numeric', 'message' => 'Invalid User']) + * + * $validator->add('password', [ + * 'size' => ['rule' => ['lengthBetween', 8, 20]], + * 'hasSpecialCharacter' => ['rule' => 'validateSpecialchar', 'message' => 'not valid'] + * ]); + * ``` + * + * @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 \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(string $field, array|string $name, ValidationRule|array $rule = []) + { + $validationSet = $this->field($field); + + if (!is_array($name)) { + $rules = [$name => $rule]; + } else { + $rules = $name; + } + + foreach ($rules as $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; + } + + /** + * Adds a nested validator. + * + * Nesting validators allows you to define validators for array + * types. For example, nested validators are ideal when you want to validate a + * sub-document, or complex array type. + * + * This method assumes that the sub-document has a 1:1 relationship with the parent. + * + * The providers of the parent validator will be synced into the nested validator, when + * errors are checked. This ensures that any validation rule providers connected + * in the parent will have the same values in the nested validator when rules are evaluated. + * + * @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( + 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 $name) { + /** @var object|class-string $provider */ + $provider = $this->getProvider($name); + $validator->setProvider($name, $provider); + } + $errors = $validator->validate($value, $context['newRecord'], ['parentContext' => $context]); + + $message = $message ? [static::NESTED => $message] : []; + + return $errors === [] ? true : $errors + $message; + }]); + + return $this; + } + + /** + * Adds a nested validator. + * + * Nesting validators allows you to define validators for array + * types. For example, nested validators are ideal when you want to validate many + * similar sub-documents or complex array types. + * + * This method assumes that the sub-document has a 1:N relationship with the parent. + * + * The providers of the parent validator will be synced into the nested validator, when + * errors are checked. This ensures that any validation rule providers connected + * in the parent will have the same values in the nested validator when rules are evaluated. + * + * @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( + 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 $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->validate( + $row, + $context['newRecord'], + ['parentContext' => $context, 'nestedManyIndex' => $i], + ); + if ($check) { + $errors[$i] = $check; + } + } + + $message = $message ? [static::NESTED => $message] : []; + + return $errors === [] ? true : $errors + $message; + }]); + + return $this; + } + + /** + * Removes a rule from the set by its name + * + * ### Example: + * + * ``` + * $validator + * ->remove('title', 'required') + * ->remove('user_id') + * ``` + * + * @param string $field The name of the field from which the rule will be removed + * @param string|null $rule the name of the rule to be removed + * @return $this + */ + public function remove(string $field, ?string $rule = null) + { + if ($rule === null) { + unset($this->_fields[$field]); + } else { + $this->field($field)->remove($rule); + } + + return $this; + } + + /** + * Sets whether a field is required to be present in data array. + * You can also pass array. Using an array will let you provide the following + * keys: + * + * - `mode` individual mode for field + * - `message` individual error message for field + * + * You can also set mode and message for all passed fields, the individual + * setting takes precedence over group settings. + * + * @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(array|string $field, Closure|string|bool $mode = true, ?string $message = null) + { + $defaults = [ + 'mode' => $mode, + 'message' => $message, + ]; + + if (is_string($field)) { + $field = $this->_convertValidatorToArray($field, $defaults); + } + + foreach ($field as $fieldName => $setting) { + $settings = $this->_convertValidatorToArray((string)$fieldName, $defaults, $setting); + /** @var string $fieldName */ + $fieldName = current(array_keys($settings)); + + $this->field((string)$fieldName)->requirePresence($settings[$fieldName]['mode']); + if ($settings[$fieldName]['message']) { + $this->_presenceMessages[$fieldName] = $settings[$fieldName]['message']; + } + } + + return $this; + } + + /** + * Low-level method to indicate that a field can be empty. + * + * This method should generally not be used, and instead you should + * use: + * + * - `allowEmptyString()` + * - `allowEmptyArray()` + * - `allowEmptyFile()` + * - `allowEmptyDate()` + * - `allowEmptyDatetime()` + * - `allowEmptyTime()` + * + * 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->allowEmptyFor('email', Validator::EMPTY_STRING); + * + * // Email can be empty on create + * $validator->allowEmptyFor('email', Validator::EMPTY_STRING, Validator::WHEN_CREATE); + * + * // Email can be empty on update + * $validator->allowEmptyFor('email', Validator::EMPTY_STRING, Validator::WHEN_UPDATE); + * ``` + * + * It is possible to conditionally allow emptiness on a field by passing a callback + * as a second argument. The callback will receive the validation context array as + * argument: + * + * ``` + * $validator->allowEmpty('email', Validator::EMPTY_STRING, function ($context) { + * return !$context['newRecord'] || $context['data']['role'] === 'admin'; + * }); + * ``` + * + * If you want to allow other kind of empty data on a field, you need to pass other + * flags: + * + * ``` + * $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 $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 allowEmptyString(string $field, ?string $message = null, Closure|string|bool $when = true) + { + return $this->allowEmptyFor($field, self::EMPTY_STRING, $when, $message); + } + + /** + * 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); + + return $this->allowEmptyFor($field, self::EMPTY_STRING, $when, $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); + } + + /** + * 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); + } + + /** + * 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 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 + */ + public function allowEmptyFile(string $field, ?string $message = null, Closure|string|bool $when = true) + { + return $this->allowEmptyFor($field, self::EMPTY_FILE, $when, $message); + } + + /** + * 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); + } + + /** + * Allows a field to be an empty date. + * + * Empty date values are `null`, `''`, `[]` and arrays where all values are `''` + * and the `year` key is present. + * + * @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 + * + * @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. + * + * Empty date values are `null`, `''`, `[]` and arrays where all values are `''` + * and the `hour` key is present. + * + * This method is equivalent to calling allowEmptyFor() with EMPTY_STRING + + * 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 \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. + * + * Opposite to allowEmptyTime() + * + * @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. + * + * 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 \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 allowEmptyDateTime(string $field, ?string $message = null, Closure|string|bool $when = true) + { + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_DATE | self::EMPTY_TIME, $when, $message); + } + + /** + * 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); + + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_DATE | self::EMPTY_TIME, $when, $message); + } + + /** + * 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; + + 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 $when; + } + + /** + * Add a notBlank 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::notBlank() + * @return $this + */ + 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 + [ + 'rule' => 'notBlank', + ]); + } + + /** + * Add an 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::alphaNumeric() + * @return $this + */ + 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 + [ + 'rule' => 'alphaNumeric', + ]); + } + + /** + * 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 \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 + * @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', $lowerBound, $upperBound], + ]); + } + + /** + * Add a credit card rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @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 \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::creditCard() + * @return $this + */ + 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' => ['creditCard', $type, true], + ]); + } + + /** + * Add a greater 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 greater 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 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', Validation::COMPARE_GREATER, $value], + ]); + } + + /** + * 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 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 \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 + * @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, 'notSameAs', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_NOT_SAME], + ]); + } + + /** + * Add a rule to compare one field is equal to another. + * + * @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 + * @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, 'equalToField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_EQUAL], + ]); + } + + /** + * 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 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 + * @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, 'notEqualToField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_NOT_EQUAL], + ]); + } + + /** + * Add a rule to compare one field is greater than another. + * + * @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 + * @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, 'greaterThanField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_GREATER], + ]); + } + + /** + * 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 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 + * @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, 'greaterThanOrEqualToField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_GREATER_OR_EQUAL], + ]); + } + + /** + * Add a rule to compare one field is less than another. + * + * @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 + * @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, 'lessThanField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_LESS], + ]); + } + + /** + * 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 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 + * @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, '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 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::date() + * @return $this + */ + 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], + ]); + } + + /** + * 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 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::datetime() + * @return $this + */ + 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], + ]); + } + + /** + * Add a time format validation 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::time() + * @return $this + */ + 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', + ]); + } + + /** + * Add a localized time, date or datetime format validation rule to a field. + * + * @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 \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( + 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], + ]); + } + + /** + * Add a boolean validation 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::boolean() + * @return $this + */ + 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', + ]); + } + + /** + * Add a decimal validation rule to a field. + * + * @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 \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( + 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], + ]); + } + + /** + * Add an email validation rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param bool $checkMX Whether to check the MX records. + * @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::email() + * @return $this + */ + 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], + ]); + } + + /** + * 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], + ]); + } + + /** + * Add an IP validation rule to a field. + * + * This rule will accept both IPv4 and IPv6 addresses. + * + * @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::ip() + * @return $this + */ + 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', + ]); + } + + /** + * Add an IPv4 validation 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::ip() + * @return $this + */ + 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'], + ]); + } + + /** + * Add an IPv6 validation 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::ip() + * @return $this + */ + 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'], + ]); + } + + /** + * Add a string length validation rule to a field. + * + * @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 \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(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], + ]); + } + + /** + * Add a string length validation rule to a field. + * + * @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 \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(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], + ]); + } + + /** + * Add a string length validation rule to a field. + * + * @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 \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(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], + ]); + } + + /** + * Add a string length validation rule to a field. + * + * @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 \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(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], + ]); + } + + /** + * Add a numeric value validation 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::numeric() + * @return $this + */ + 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', + ]); + } + + /** + * Add a natural number validation 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::naturalNumber() + * @return $this + */ + 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], + ]); + } + + /** + * Add a validation rule to ensure a field is a non negative integer. + * + * @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::naturalNumber() + * @return $this + */ + 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], + ]); + } + + /** + * Add a validation rule to ensure a field is within a numeric range + * + * @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 \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(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', $lowerBound, $upperBound], + ]); + } + + /** + * Add a validation rule to ensure a field is a URL. + * + * This validator does not require a protocol. + * + * @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::url() + * @return $this + */ + 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], + ]); + } + + /** + * Add a validation rule to ensure a field is a URL. + * + * This validator requires the URL to have a protocol. + * + * @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::url() + * @return $this + */ + 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], + ]); + } + + /** + * 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 \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(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], + ]); + } + + /** + * Add a validation rule to ensure the field is a UUID + * + * @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::uuid() + * @return $this + */ + 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', + ]); + } + + /** + * Add a validation rule to ensure the field is an uploaded file + * + * @param string $field The field you want to apply the rule to. + * @param array $options An array of options. + * @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::uploadedFile() For options + * @return $this + */ + 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], + ]); + } + + /** + * Add a validation rule to ensure the field is a lat/long tuple. + * + * e.g. `, ` + * + * @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::geoCoordinate() + * @return $this + */ + 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', + ]); + } + + /** + * Add a validation rule to ensure the field is a latitude. + * + * @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::latitude() + * @return $this + */ + 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', + ]); + } + + /** + * Add a validation rule to ensure the field is a longitude. + * + * @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::longitude() + * @return $this + */ + 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', + ]); + } + + /** + * Add a validation rule to ensure a field contains only ascii bytes + * + * @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::ascii() + * @return $this + */ + 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', + ]); + } + + /** + * Add a validation rule to ensure a field contains only BMP utf8 bytes + * + * @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::utf8() + * @return $this + */ + 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]], + ]); + } + + /** + * Add a validation rule to ensure a field contains only utf8 bytes. + * + * This rule will accept 3 and 4 byte UTF8 sequences, which are necessary for emoji. + * + * @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::utf8() + * @return $this + */ + 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]], + ]); + } + + /** + * Add a validation rule to ensure a field is an integer value. + * + * @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::isInteger() + * @return $this + */ + 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', + ]); + } + + /** + * Add a validation rule to ensure that a field contains an array. + * + * @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::isArray() + * @return $this + */ + 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, 'array', $extra + [ + 'rule' => 'isArray', + ]); + } + + /** + * Add a validation rule to ensure that a field contains a scalar. + * + * @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::isScalar() + * @return $this + */ + 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', + ]); + } + + /** + * Add a validation rule to ensure a field is a 6 digits hex color value. + * + * @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::hexColor() + * @return $this + */ + 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 + [ + 'rule' => 'hexColor', + ]); + } + + /** + * 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 + * \Cake\Validation\Validation::multiple() and the `caseInsensitive` parameter. + * @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::multiple() + * @return $this + */ + 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 = $options['caseInsensitive'] ?? false; + unset($options['caseInsensitive']); + + return $this->add($field, 'multipleOptions', $extra + [ + 'rule' => ['multiple', $options, $caseInsensitive], + ]); + } + + /** + * Add a validation rule to ensure that a field is an array containing at least + * the specified amount of elements + * + * @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 \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(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 + [ + 'rule' => function ($value) use ($count) { + if (is_array($value) && isset($value['_ids'])) { + $value = $value['_ids']; + } + + return Validation::numElements($value, Validation::COMPARE_GREATER_OR_EQUAL, $count); + }, + ]); + } + + /** + * Add a validation rule to ensure that a field is an array containing at most + * the specified amount of elements + * + * @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 \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(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 + [ + 'rule' => function ($value) use ($count) { + if (is_array($value) && isset($value['_ids'])) { + $value = $value['_ids']; + } + + return Validation::numElements($value, Validation::COMPARE_LESS_OR_EQUAL, $count); + }, + ]); + } + + /** + * 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(string $field, bool $newRecord): bool + { + $providers = $this->_providers; + $data = []; + $context = compact('data', 'newRecord', 'field', 'providers'); + + return $this->_canBeEmpty($this->field($field), $context); + } + + /** + * 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(string $field, bool $newRecord): bool + { + $providers = $this->_providers; + $data = []; + $context = compact('data', 'newRecord', 'field', 'providers'); + + return !$this->_checkPresence($this->field($field), $context); + } + + /** + * 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 \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(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], + ]); + } + + /** + * 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. + * @return bool + */ + protected function _checkPresence(ValidationSet $field, array $context): bool + { + $required = $field->isPresenceRequired(); + + if ($required instanceof Closure) { + return !$required($context); + } + + $newRecord = $context['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; + } + + /** + * 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. + * @return bool + */ + protected function _canBeEmpty(ValidationSet $field, array $context): bool + { + $allowed = $field->isEmptyAllowed(); + + if ($allowed instanceof Closure) { + return $allowed($context); + } + + $newRecord = $context['newRecord']; + if (in_array($allowed, [static::WHEN_CREATE, static::WHEN_UPDATE], true)) { + $allowed = ($allowed === static::WHEN_CREATE && $newRecord) || + ($allowed === static::WHEN_UPDATE && !$newRecord); + } + + return (bool)$allowed; + } + + /** + * Returns true if the field is empty in the passed data array + * + * @param mixed $data Value to check against. + * @param int $flags A bitmask of EMPTY_* flags which specify what is empty + * @return bool + */ + protected function isEmpty(mixed $data, int $flags): bool + { + 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; + } + + 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 ( + ($flags & self::EMPTY_FILE) + && $data instanceof UploadedFileInterface + && $data->getError() === UPLOAD_ERR_NO_FILE + ) { + return true; + } + + return false; + } + + /** + * Iterates over each rule in the validation set and collects the errors resulting + * from executing them + * + * @param string $field The name of the field that is being processed + * @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 + * @param array $context Additional validation context. + * @return array + */ + protected function _processRules( + string $field, + ValidationSet $rules, + array $data, + bool $newRecord, + array $context = [], + ): array { + $errors = []; + $context = compact('newRecord', 'data', 'field') + $context; + + 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, $context); + if ($result === true) { + continue; + } + + $errors[$name] = $message; + if (is_array($result) && $name === static::NESTED) { + $errors = $result; + } + if (is_string($result)) { + $errors[$name] = $result; + } + + if ($rule->isLast()) { + break; + } + } + + return $errors; + } + + /** + * Get the printable version of this object. + * + * @return array + */ + public function __debugInfo(): array + { + $fields = []; + foreach ($this->_fields as $name => $fieldSet) { + $fields[$name] = [ + 'isPresenceRequired' => $fieldSet->isPresenceRequired(), + 'isEmptyAllowed' => $fieldSet->isEmptyAllowed(), + 'rules' => array_keys($fieldSet->rules()), + ]; + } + + return [ + '_presenceMessages' => $this->_presenceMessages, + '_allowEmptyMessages' => $this->_allowEmptyMessages, + '_allowEmptyFlags' => $this->_allowEmptyFlags, + '_useI18n' => $this->_useI18n, + '_stopOnFailure' => $this->_stopOnFailure, + '_providers' => array_keys($this->_providers), + '_fields' => $fields, + ]; + } +} diff --git a/src/Validation/ValidatorAwareInterface.php b/src/Validation/ValidatorAwareInterface.php new file mode 100644 index 00000000000..ee7425506bd --- /dev/null +++ b/src/Validation/ValidatorAwareInterface.php @@ -0,0 +1,52 @@ + + */ + protected string $_validatorClass = Validator::class; + + /** + * A list of validation objects indexed by name + * + * @var array<\Cake\Validation\Validator> + */ + 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 validator + * using a method inside your class. + * + * 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'); + * } + * + * $validator = $this->getValidator('forSubscription'); + * ``` + * + * 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. + * + * If a $name argument has not been provided, the default validator will be returned. + * You can configure your default validator name in a `DEFAULT_VALIDATOR` + * class constant. + * + * @param string|null $name The name of the validation set to return. + * @return \Cake\Validation\Validator + */ + public function getValidator(?string $name = null): Validator + { + $name = $name ?: static::DEFAULT_VALIDATOR; + if (!isset($this->_validators[$name])) { + $this->setValidator($name, $this->createValidator($name)); + } + + return $this->_validators[$name]; + } + + /** + * Creates a validator using a custom method inside your class. + * + * This method is used only to build a new validator and it does not store + * it in your object. If you want to build and reuse validators, + * use getValidator() method instead. + * + * @param string $name The name of the validation set to create. + * @return \Cake\Validation\Validator + * @throws \InvalidArgumentException + */ + protected function createValidator(string $name): Validator + { + $method = 'validation' . ucfirst($name); + if (!$this->validationMethodExists($method)) { + $message = sprintf('The `%s::%s()` validation method does not exist.', static::class, $method); + throw new InvalidArgumentException($message); + } + + $validator = new $this->_validatorClass(); + $validator = $this->$method($validator); + if ($this instanceof EventDispatcherInterface) { + $event = defined(static::class . '::BUILD_VALIDATOR_EVENT') + ? static::BUILD_VALIDATOR_EVENT + : 'Model.buildValidator'; + $this->dispatchEvent($event, compact('validator', 'name')); + } + + assert( + $validator instanceof Validator, + sprintf( + 'The `%s::%s()` validation method must return an instance of `%s`.', + static::class, + $method, + Validator::class, + ), + ); + + return $validator; + } + + /** + * This method stores a custom validator under the given name. + * + * You can build the object by yourself and store it in your object: + * + * ``` + * $validator = new \Cake\Validation\Validator(); + * $validator + * ->add('email', 'valid-email', ['rule' => 'email']) + * ->add('password', 'valid', ['rule' => 'notBlank']) + * ->allowEmpty('bio'); + * $this->setValidator('forSubscription', $validator); + * ``` + * + * @param string $name The name of a validator to be set. + * @param \Cake\Validation\Validator $validator Validator object to be set. + * @return $this + */ + public function setValidator(string $name, Validator $validator) + { + $validator->setProvider(static::VALIDATOR_PROVIDER_NAME, $this); + $this->_validators[$name] = $validator; + + return $this; + } + + /** + * Checks whether a validator has been set. + * + * @param string $name The name of a validator. + * @return bool + */ + public function hasValidator(string $name): bool + { + $method = 'validation' . ucfirst($name); + if ($this->validationMethodExists($method)) { + return true; + } + + return isset($this->_validators[$name]); + } + + /** + * Checks if validation method exists. + * + * @param string $name Validation method name. + * @return bool + */ + protected function validationMethodExists(string $name): bool + { + return method_exists($this, $name); + } + + /** + * Returns the default validator object. Subclasses can override this function + * to add a default validation set to the validator object. + * + * @param \Cake\Validation\Validator $validator The validator that can be modified to + * add some rules to it. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + return $validator; + } +} diff --git a/src/Validation/composer.json b/src/Validation/composer.json new file mode 100644 index 00000000000..e888bac34ae --- /dev/null +++ b/src/Validation/composer.json @@ -0,0 +1,48 @@ +{ + "name": "cakephp/validation", + "description": "CakePHP Validation library", + "type": "library", + "keywords": [ + "cakephp", + "validation", + "data validation" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/validation/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/validation" + }, + "require": { + "php": ">=8.2", + "cakephp/core": "^5.3.0", + "cakephp/utility": "^5.3.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "require-dev": { + "cakephp/i18n": "^5.3.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.4.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 new file mode 100644 index 00000000000..d8a800e5889 --- /dev/null +++ b/src/View/AjaxView.php @@ -0,0 +1,40 @@ + + */ +abstract class Cell implements EventDispatcherInterface, Stringable +{ + /** + * @use \Cake\Event\EventDispatcherTrait + */ + use EventDispatcherTrait; + use LocatorAwareTrait; + use ViewVarsTrait; + + /** + * Constant for folder name containing cell templates. + * + * @var string + */ + public const TEMPLATE_FOLDER = 'cell'; + + /** + * Instance of the View created during rendering. Won't be set until after + * Cell::__toString()/render() is called. + * + * @var TSubject + */ + protected View $View; + + /** + * 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 + */ + protected ServerRequest $request; + + /** + * An instance of a Response object that contains information about the impending response + * + * @var \Cake\Http\Response + */ + protected Response $response; + + /** + * The cell's action to invoke. + * + * @var string + */ + protected string $action; + + /** + * Arguments to pass to cell's action. + * + * @var array + */ + protected array $args = []; + + /** + * The plugin name this cell belongs to. + * + * @var string|null + */ + protected ?string $plugin = null; + + /** + * List of valid options (constructor's fourth arguments) + * Override this property in subclasses to allow + * which options you want set as properties in your Cell. + * + * @var array + */ + protected array $_validCellOptions = []; + + /** + * Caching setup. + * + * @var array|bool + */ + protected array|bool $_cache = false; + + /** + * Constructor. + * + * @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, + Response $response, + ?EventManagerInterface $eventManager = null, + array $cellOptions = [], + ) { + if ($eventManager !== null) { + $this->setEventManager($eventManager); + } + $this->request = $request; + $this->response = $response; + + $this->_validCellOptions = array_merge(['action', 'args', 'plugin'], $this->_validCellOptions); + foreach ($this->_validCellOptions as $var) { + if (isset($cellOptions[$var])) { + $this->{$var} = $cellOptions[$var]; + } + } + 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; + } + + /** + * Render the cell. + * + * @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\MissingCellTemplateException|\BadMethodCallException + */ + public function render(?string $template = null): string + { + $cache = []; + if ($this->_cache) { + $cache = $this->_cacheConfig($this->action, $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); + $this->dispatchEvent('Cell.afterAction', [$this, $this->action, $this->args]); + } catch (ReflectionException) { + throw new BadMethodCallException(sprintf( + 'Class `%s` does not have a `%s` method.', + static::class, + $this->action, + )); + } + + $builder = $this->viewBuilder(); + + if ($template !== null) { + $builder->setTemplate($template); + } + + $className = static::class; + $namePrefix = '\View\Cell\\'; + $name = substr($className, strpos($className, $namePrefix) + strlen($namePrefix)); + $name = substr($name, 0, -4); + if (!$builder->getTemplatePath()) { + $builder->setTemplatePath( + static::TEMPLATE_FOLDER . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $name), + ); + } + $template = $builder->getTemplate(); + + $view = $this->createView(); + try { + return $view->render($template, false); + } catch (MissingTemplateException $e) { + $attributes = $e->getAttributes(); + throw new MissingCellTemplateException( + $name, + $attributes['file'], + $attributes['paths'], + null, + $e, + ); + } + }; + + if ($cache) { + return Cache::remember($cache['key'], $render, $cache['config']); + } + + return $render(); + } + + /** + * Generate the cache key to use for this cell. + * + * If the key is undefined, the cell class and action name will be used. + * + * @param string $action The action invoked. + * @param string|null $template The name of the template to be rendered. + * @return array The cache configuration. + */ + protected function _cacheConfig(string $action, ?string $template = null): array + { + if (!$this->_cache) { + return []; + } + $template = $template ?: 'default'; + $key = 'cell_' . Inflector::underscore(static::class) . '_' . $action . '_' . $template; + $key = str_replace('\\', '_', $key); + $default = [ + 'config' => 'default', + 'key' => $key, + ]; + if ($this->_cache === true) { + return $default; + } + + return $this->_cache + $default; + } + + /** + * Magic method. + * + * Starts the rendering process when Cell is echoed. + * + * *Note* This method will trigger an error when view rendering has a problem. + * This is because PHP will not allow a __toString() method to throw an exception. + * + * @return string Rendered cell + * @throws \Error Include error details for PHP 7 fatal errors. + */ + 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); + + 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(), + ), 0, $e); + } + } + + /** + * Debug info. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'action' => $this->action, + 'args' => $this->args, + 'request' => $this->request, + 'response' => $this->response, + 'viewBuilder' => $this->viewBuilder(), + ]; + } +} diff --git a/src/View/CellTrait.php b/src/View/CellTrait.php new file mode 100644 index 00000000000..2f9e62b5059 --- /dev/null +++ b/src/View/CellTrait.php @@ -0,0 +1,128 @@ +cell('Taxonomy.TagCloud::smallList', ['limit' => 10]); + * + * // App\View\Cell\TagCloudCell::smallList() + * $cell = $this->cell('TagCloud::smallList', ['limit' => 10]); + * ``` + * + * The `display` action will be used by default when no action is provided: + * + * ``` + * // Taxonomy\View\Cell\TagCloudCell::display() + * $cell = $this->cell('Taxonomy.TagCloud'); + * ``` + * + * Cells are not rendered until they are echoed. + * + * @param string $cell You must indicate cell name, and optionally a cell action. e.g.: `TagCloud::smallList` will + * invoke `View\Cell\TagCloudCell::smallList()`, `display` action will be invoked by default when none is provided. + * @param array $data Additional arguments for cell method. e.g.: + * `cell('TagCloud::smallList', ['a1' => 'v1', 'a2' => 'v2'])` maps to `View\Cell\TagCloud::smallList(v1, v2)` + * @param array $options Options for Cell's constructor + * @return \Cake\View\Cell<\Cake\View\View> The cell instance + * @throws \Cake\View\Exception\MissingCellException If Cell class was not found. + */ + protected function cell(string $cell, array $data = [], array $options = []): Cell + { + $parts = explode('::', $cell); + + if (count($parts) === 2) { + [$pluginAndCell, $action] = [$parts[0], $parts[1]]; + } else { + [$pluginAndCell, $action] = [$parts[0], 'display']; + } + + [$plugin] = pluginSplit($pluginAndCell); + $className = App::className($pluginAndCell, 'View/Cell', 'Cell'); + + if (!$className) { + throw new MissingCellException(['className' => $pluginAndCell . 'Cell']); + } + + $options = ['action' => $action, 'args' => $data] + $options; + + return $this->_createCell($className, $action, $plugin, $options); + } + + /** + * Create and configure the cell instance. + * + * @param string $className The cell classname. + * @param string $action The action name. + * @param string|null $plugin The plugin name. + * @param array $options The constructor options for the cell. + * @return \Cake\View\Cell<\Cake\View\View> + */ + protected function _createCell(string $className, string $action, ?string $plugin, array $options): Cell + { + if ($plugin) { + $options['plugin'] = $plugin; + } + + /** @var \Cake\View\Cell<\Cake\View\View> $instance */ + $instance = new $className($this->request, $this->response, $this->getEventManager(), $options); + + $builder = $instance->viewBuilder(); + $builder->setTemplate(Inflector::underscore($action)); + + if ($plugin) { + $builder->setPlugin($plugin); + } + + if ($this instanceof View) { + $builder->addHelpers($this->helpers); + + if ($this->theme) { + $builder->setTheme($this->theme); + } + + $builder->setClassName(static::class); + + return $instance; + } + + if (method_exists($this, 'viewBuilder')) { + $builder->setTheme($this->viewBuilder()->getTheme()); + + 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 new file mode 100644 index 00000000000..ab5e2aab2bb --- /dev/null +++ b/src/View/Exception/MissingCellException.php @@ -0,0 +1,30 @@ + $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/MissingElementException.php b/src/View/Exception/MissingElementException.php new file mode 100644 index 00000000000..a11a5b0dbeb --- /dev/null +++ b/src/View/Exception/MissingElementException.php @@ -0,0 +1,26 @@ + + */ + 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; + } + + /** + * 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 new file mode 100644 index 00000000000..9f587dda43c --- /dev/null +++ b/src/View/Exception/MissingViewException.php @@ -0,0 +1,30 @@ + [ + * 'id' => '1', + * 'title' => 'First post!', + * ], + * 'schema' => [ + * 'id' => ['type' => 'integer'], + * 'title' => ['type' => 'string', 'length' => 255], + * '_constraints' => [ + * 'primary' => ['type' => 'primary', 'columns' => ['id']] + * ] + * ], + * 'defaults' => [ + * 'title' => 'Default title', + * ], + * 'required' => [ + * 'id' => true, // will use default required message + * 'title' => 'Please enter a title', + * 'body' => false, + * ], + * ]; + * ``` + */ +class ArrayContext implements ContextInterface +{ + /** + * Context data for this object. + * + * @var array + */ + protected array $_context; + + /** + * Constructor. + * + * @param array $context Context info. + */ + public function __construct(array $context) + { + $context += [ + 'data' => [], + 'schema' => [], + 'required' => [], + 'defaults' => [], + 'errors' => [], + ]; + $this->_context = $context; + } + + /** + * Get the fields used in the context as a primary key. + * + * @return array + */ + public function getPrimaryKey(): array + { + 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 (array)($data['columns'] ?? []); + } + } + + return []; + } + + /** + * @inheritDoc + */ + public function isPrimaryKey(string $field): bool + { + $primaryKey = $this->getPrimaryKey(); + + return in_array($field, $primaryKey, true); + } + + /** + * 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 + * contain a value for all fields in the key. + * + * @return bool + */ + public function isCreate(): bool + { + $primary = $this->getPrimaryKey(); + foreach ($primary as $column) { + if (!empty($this->_context['defaults'][$column])) { + return false; + } + } + + return true; + } + + /** + * Get the current value for a given field. + * + * 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 data or + * context record. + * - `schemaDefault`: Boolean indicating whether default value from + * context's schema should be used if it's not explicitly provided. + * @return mixed + */ + public function val(string $field, array $options = []): mixed + { + $options += [ + 'default' => null, + 'schemaDefault' => true, + ]; + + if (Hash::check($this->_context['data'], $field)) { + return Hash::get($this->_context['data'], $field); + } + + if ($options['default'] !== null || !$options['schemaDefault']) { + return $options['default']; + } + if (empty($this->_context['defaults']) || !is_array($this->_context['defaults'])) { + return 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); + } + + return Hash::get($this->_context['defaults'], $this->stripNesting($field)); + } + + /** + * Check if a given field is 'required'. + * + * 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|null + */ + public function isRequired(string $field): ?bool + { + 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 || $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; + } + + if ($required === true) { + return __d('cake', 'This field cannot be left empty'); + } + + return $required; + } + + /** + * 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 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); + } + + /** + * Get the abstract field type for a given field name. + * + * @param string $field A dot separated path to get a schema type for. + * @return string|null An abstract data type or null. + * @see \Cake\Database\TypeFactory + */ + public function type(string $field): ?string + { + if (!is_array($this->_context['schema'])) { + return null; + } + + $schema = Hash::get($this->_context['schema'], $field) + ?? Hash::get($this->_context['schema'], $this->stripNesting($field)); + + return $schema['type'] ?? null; + } + + /** + * Get an associative array of other attributes for a field name. + * + * @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(string $field): array + { + if (!is_array($this->_context['schema'])) { + return []; + } + $schema = Hash::get($this->_context['schema'], $field) + ?? Hash::get($this->_context['schema'], $this->stripNesting($field)); + + return array_intersect_key( + (array)$schema, + array_flip(static::VALID_ATTRIBUTES), + ); + } + + /** + * 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(string $field): bool + { + if (empty($this->_context['errors'])) { + return false; + } + + return Hash::check($this->_context['errors'], $field); + } + + /** + * Get the errors for a given field + * + * @param string $field A dot separated path to check errors on. + * @return array An array of errors, an empty array will be returned when the + * context has no errors. + */ + public function error(string $field): array + { + if (empty($this->_context['errors'])) { + return []; + } + + return (array)Hash::get($this->_context['errors'], $field); + } + + /** + * Strips out any numeric nesting + * + * For example users.0.age will output as users.age + * + * @param string $field A dot separated path + * @return string A string with stripped numeric nesting + */ + protected function stripNesting(string $field): string + { + return (string)preg_replace('/\.\d*\./', '.', $field); + } +} diff --git a/src/View/Form/ContextFactory.php b/src/View/Form/ContextFactory.php new file mode 100644 index 00000000000..a34989bb32d --- /dev/null +++ b/src/View/Form/ContextFactory.php @@ -0,0 +1,164 @@ + + */ + protected array $providers = []; + + /** + * Constructor. + * + * @param array $providers Array of provider callables. Each element should + * be of form `['type' => 'a-string', 'callable' => ..]` + */ + public function __construct(array $providers = []) + { + foreach ($providers as $provider) { + $this->addProvider($provider['type'], $provider['callable']); + } + } + + /** + * Create factory instance with providers "array", "form" and "orm". + * + * @param array $providers Array of provider callables. Each element should + * be of form `['type' => 'a-string', 'callable' => ..]` + * @return static + */ + public static function createWithDefaults(array $providers = []): static + { + $providers = [ + [ + 'type' => 'orm', + 'callable' => function ($request, $data) { + 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($data); + } + + return new NullContext($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($data['entity']); + } + }, + ], + [ + 'type' => 'null', + 'callable' => function ($request, $data) { + if ($data['entity'] === null) { + return new NullContext($data); + } + }, + ], + ] + $providers; + + return new static($providers); + } + + /** + * Add a new context type. + * + * Form context types allow FormHelper to interact with + * data providers that come from outside CakePHP. For example + * if you wanted to use an alternative ORM like Doctrine you could + * create and connect a new context class to allow FormHelper to + * read metadata from doctrine. + * + * @param string $type The type of context. This key + * can be used to overwrite existing providers. + * @param callable $check A callable that returns an object + * when the form context is the correct type. + * @return $this + */ + public function addProvider(string $type, callable $check) + { + $this->providers = [$type => ['type' => $type, 'callable' => $check]] + + $this->providers; + + return $this; + } + + /** + * Find the matching context for the data. + * + * 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. + * @return \Cake\View\Form\ContextInterface Context provider. + * @throws \Cake\Core\Exception\CakeException When a context instance cannot be generated for given entity. + */ + 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); + if ($context) { + break; + } + } + + 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']), + )); + } + + return $context; + } +} diff --git a/src/View/Form/ContextInterface.php b/src/View/Form/ContextInterface.php new file mode 100644 index 00000000000..b1eeaacd565 --- /dev/null +++ b/src/View/Form/ContextInterface.php @@ -0,0 +1,136 @@ + + */ + public const VALID_ATTRIBUTES = ['length', 'precision', 'comment', 'null', 'default']; + + /** + * Get the fields used in the context as a primary key. + * + * @return array + */ + public function getPrimaryKey(): array; + + /** + * Returns true if the passed field name is part of the primary key for this context + * + * @param string $field A dot separated path to the field a value + * is needed for. + * @return bool + */ + public function isPrimaryKey(string $field): bool; + + /** + * Returns whether this form is for a create operation. + * + * @return bool + */ + public function isCreate(): bool; + + /** + * Get the current value for a given field. + * + * 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 data or + * context record. + * - `schemaDefault`: Boolean indicating whether default value from + * 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(string $field, array $options = []): mixed; + + /** + * Check if a given field is 'required'. + * + * 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|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 getMaxLength(string $field): ?int; + + /** + * Get the field names of the top level object in this context. + * + * @return array A list of the field names in the context. + */ + 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 string|null An abstract data type or null. + * @see \Cake\Database\TypeFactory + */ + public function type(string $field): ?string; + + /** + * Get an associative array of other attributes for a field name. + * + * @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(string $field): array; + + /** + * 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(string $field): bool; + + /** + * Get the errors for a given field + * + * @param string $field A dot separated path to check errors on. + * @return array An array of errors, an empty array will be returned when the + * context has no errors. + */ + public function error(string $field): array; +} diff --git a/src/View/Form/EntityContext.php b/src/View/Form/EntityContext.php new file mode 100644 index 00000000000..2ce73a4da98 --- /dev/null +++ b/src/View/Form/EntityContext.php @@ -0,0 +1,751 @@ +validators when + * dealing with associated forms. + */ +class EntityContext implements ContextInterface +{ + use LocatorAwareTrait; + + /** + * Context data for this object. + * + * @var array + */ + protected array $_context; + + /** + * The name of the top level entity/table object. + * + * @var string + */ + protected string $_rootName; + + /** + * Boolean to track whether the entity is a + * collection. + * + * @var bool + */ + protected bool $_isCollection = false; + + /** + * A dictionary of tables + * + * @var array<\Cake\ORM\Table> + */ + protected array $_tables = []; + + /** + * Dictionary of validators. + * + * @var array<\Cake\Validation\Validator> + */ + protected array $_validator = []; + + /** + * Constructor. + * + * @param array $context Context info. + */ + public function __construct(array $context) + { + $context += [ + 'entity' => null, + 'table' => null, + 'validator' => [], + ]; + $this->_context = $context; + $this->_prepare(); + } + + /** + * Prepare some additional data from the context. + * + * If the table option was provided to the constructor and it + * 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. + * + * If no table option is provided, the table name will be derived based on + * naming conventions. This inference will work with a number of common objects + * like arrays, Collection objects and ResultSets. + * + * @return void + * @throws \Cake\Core\Exception\CakeException When a table object cannot be located/inferred. + */ + protected function _prepare(): void + { + $table = $this->_context['table']; + + /** @var \Cake\Datasource\EntityInterface|iterable<\Cake\Datasource\EntityInterface|array> $entity */ + $entity = $this->_context['entity']; + $this->_isCollection = is_iterable($entity); + + if (!$table) { + if ($this->_isCollection) { + /** @var iterable<\Cake\Datasource\EntityInterface|array> $entity */ + foreach ($entity as $e) { + $entity = $e; + break; + } + } + + if ($entity instanceof EntityInterface) { + $table = $entity->getSource(); + } + if (!$table && $entity instanceof EntityInterface && $entity::class !== Entity::class) { + [, $entityClass] = namespaceSplit($entity::class); + $table = Inflector::pluralize($entityClass); + } + } + if (is_string($table) && $table !== '') { + $table = $this->getTableLocator()->get($table); + } + + if (!($table instanceof Table)) { + throw new CakeException('Unable to find table class for current entity.'); + } + $alias = $table->getAlias(); + $this->_rootName = $alias; + $this->_tables[$alias] = $table; + } + + /** + * Get the primary key data for the context. + * + * Gets the primary key columns from the root entity's schema. + * + * @return array + */ + public function getPrimaryKey(): array + { + return (array)$this->_tables[$this->_rootName]->getPrimaryKey(); + } + + /** + * @inheritDoc + */ + 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, true); + } + + /** + * 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. + * + * If the context is for a collection or array the first object in the + * collection will be used. + * + * @return bool + */ + public function isCreate(): bool + { + $entity = $this->_context['entity']; + if (is_iterable($entity)) { + foreach ($entity as $e) { + $entity = $e; + break; + } + } + if ($entity instanceof EntityInterface) { + return $entity->isNew(); + } + + return true; + } + + /** + * Get the value for a given path. + * + * 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 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(string $field, array $options = []): mixed + { + $options += [ + 'default' => null, + 'schemaDefault' => true, + ]; + + if (!$this->_context['entity']) { + return $options['default']; + } + $parts = explode('.', $field); + $entity = $this->entity($parts); + + if ($entity && end($parts) === '_ids') { + return $this->_extractMultiple($entity, $parts); + } + + if ($entity instanceof EntityInterface) { + $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 + || !$options['schemaDefault'] + || !$entity->isNew() + ) { + return $options['default']; + } + + return $this->_schemaDefault($parts); + } + if (is_array($entity) || $entity instanceof ArrayAccess) { + $key = array_pop($parts); + + return $entity[$key] ?? $options['default']; + } + + return null; + } + + /** + * Get default value from table schema for given entity field. + * + * @param array $parts Each one of the parts in a path for a field name + * @return mixed + */ + protected function _schemaDefault(array $parts): mixed + { + $table = $this->_getTable($parts); + if ($table === null) { + return null; + } + $field = end($parts); + $defaults = $table->getSchema()->defaultValues(); + if ($field === false || !array_key_exists($field, $defaults)) { + return null; + } + + return $defaults[$field]; + } + + /** + * 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 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(mixed $values, array $path): ?array + { + if (!is_iterable($values)) { + return null; + } + $table = $this->_getTable($path, false); + $primary = $table ? (array)$table->getPrimaryKey() : ['id']; + + return (new Collection($values))->extract($primary[0])->toArray(); + } + + /** + * Fetch the entity or data value for a given path + * + * This method will traverse the given path and find the entity + * or array value for a given path. + * + * 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|iterable|null + * @throws \Cake\Core\Exception\CakeException When properties cannot be read. + */ + public function entity(?array $path = null): EntityInterface|iterable|null + { + if ($path === null) { + return $this->_context['entity']; + } + + $oneElement = count($path) === 1; + if ($oneElement && $this->_isCollection) { + return null; + } + $entity = $this->_context['entity']; + if ($oneElement) { + return $entity; + } + + if ($path[0] === $this->_rootName) { + $path = array_slice($path, 1); + } + + $len = count($path); + $last = $len - 1; + for ($i = 0; $i < $len; $i++) { + $prop = $path[$i]; + $next = $this->_getProp($entity, $prop); + $isLast = ($i === $last); + if (!$isLast && $next === null && $prop !== '_ids') { + $table = $this->_getTable($path); + if ($table) { + return $table->newEmptyEntity(); + } + } + + $isTraversable = ( + is_iterable($next) || + $next instanceof EntityInterface + ); + if ($isLast || !$isTraversable) { + return $entity; + } + $entity = $next; + } + 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), + )); + } + + /** + * Read property values or traverse arrays/iterators. + * + * @param mixed $target The entity/array/collection to fetch $field from. + * @param string $field The next field to fetch. + * @return mixed + */ + protected function _getProp(mixed $target, string $field): mixed + { + if (is_array($target) && isset($target[$field])) { + return $target[$field]; + } + if ($target instanceof EntityInterface) { + return $target->get($field); + } + if ($target instanceof Traversable) { + foreach ($target as $i => $val) { + 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|null + */ + public function isRequired(string $field): ?bool + { + $parts = explode('.', $field); + $entity = $this->entity($parts); + + $isNew = true; + if ($entity instanceof EntityInterface) { + $isNew = $entity->isNew(); + } + + $validator = $this->_getValidator($parts); + $fieldName = array_pop($parts); + + if (!$validator->hasField($fieldName)) { + 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); + } + + 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 field names in the table/entity. + */ + public function fieldNames(): array + { + $table = $this->_getTable('0'); + if (!$table) { + return []; + } + + return $table->getSchema()->columns(); + } + + /** + * 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 + * @return \Cake\Validation\Validator + * @throws \Cake\Core\Exception\CakeException If validator cannot be retrieved based on the parts. + */ + protected function _getValidator(array $parts): Validator + { + $keyParts = array_filter(array_slice($parts, 0, -1), function (string $part) { + return !is_numeric($part); + }); + $key = implode('.', $keyParts); + $entity = $this->entity($parts); + + if (isset($this->_validator[$key])) { + 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'; + if (is_string($this->_context['validator'])) { + $method = $this->_context['validator']; + } elseif (isset($this->_context['validator'][$alias])) { + $method = $this->_context['validator'][$alias]; + } + + $validator = $table->getValidator($method); + + if (is_object($entity)) { + $validator->setProvider('entity', $entity); + } + + return $this->_validator[$key] = $validator; + } + + /** + * Get the table instance from a property path + * + * @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(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 (string $part) { + return !is_numeric($part); + }), 0, -1); + + $path = implode('.', $normalized); + if (isset($this->_tables[$path])) { + return $this->_tables[$path]; + } + + if (current($normalized) === $this->_rootName) { + $normalized = array_slice($normalized, 1); + } + + $table = $this->_tables[$this->_rootName]; + $assoc = null; + foreach ($normalized as $part) { + if ($assoc instanceof BelongsToMany && $part === $assoc->getJunctionProperty()) { + $table = $assoc->junction(); + $assoc = null; + continue; + } + $associationCollection = $table->associations(); + $assoc = $associationCollection->getByProperty($part); + + if ($assoc === null) { + if ($fallback) { + break; + } + + return null; + } + + $table = $assoc->getTarget(); + } + + return $this->_tables[$path] = $table; + } + + /** + * Get the abstract field type for a given field name. + * + * @param string $field A dot separated path to get a schema type for. + * @return string|null An abstract data type or null. + * @see \Cake\Database\TypeFactory + */ + public function type(string $field): ?string + { + $parts = explode('.', $field); + + return $this->_getTable($parts)?->getSchema()->baseColumnType(array_pop($parts)); + } + + /** + * Get an associative array of other attributes for a field name. + * + * @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(string $field): array + { + $parts = explode('.', $field); + $table = $this->_getTable($parts); + if (!$table) { + return []; + } + + return array_intersect_key( + (array)$table->getSchema()->getColumn(array_pop($parts)), + array_flip(static::VALID_ATTRIBUTES), + ); + } + + /** + * 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(string $field): bool + { + return $this->error($field) !== []; + } + + /** + * Get the errors for a given field + * + * @param string $field A dot separated path to check errors on. + * @return array An array of errors. + */ + public function error(string $field): array + { + $parts = explode('.', $field); + 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) { + $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 new file mode 100644 index 00000000000..d487704f0bb --- /dev/null +++ b/src/View/Form/FormContext.php @@ -0,0 +1,231 @@ +_form = $context['entity']; + $this->_validator = $context['validator'] ?? null; + } + + /** + * @inheritDoc + */ + public function getPrimaryKey(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function isPrimaryKey(string $field): bool + { + return false; + } + + /** + * @inheritDoc + */ + public function isCreate(): bool + { + return true; + } + + /** + * @inheritDoc + */ + public function val(string $field, array $options = []): mixed + { + $options += [ + 'default' => null, + 'schemaDefault' => true, + ]; + + $val = $this->_form->getData($field); + if ($val !== null) { + return $val; + } + + if ($options['default'] !== null || !$options['schemaDefault']) { + return $options['default']; + } + + return $this->_schemaDefault($field); + } + + /** + * Get default value from form schema for given field. + * + * @param string $field Field name. + * @return mixed + */ + protected function _schemaDefault(string $field): mixed + { + $field = $this->_form->getSchema()->field($field); + if (!$field) { + return null; + } + + return $field['default']; + } + + /** + * @inheritDoc + */ + public function isRequired(string $field): ?bool + { + $validator = $this->_form->getValidator($this->_validator); + if (!$validator->hasField($field)) { + return null; + } + if ($this->type($field) !== 'boolean') { + return !$validator->isEmptyAllowed($field, $this->isCreate()); + } + + return false; + } + + /** + * @inheritDoc + */ + public function getRequiredMessage(string $field): ?string + { + $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 + */ + public function fieldNames(): array + { + return $this->_form->getSchema()->fields(); + } + + /** + * @inheritDoc + */ + public function type(string $field): ?string + { + return $this->_form->getSchema()->fieldType($field); + } + + /** + * @inheritDoc + */ + public function attributes(string $field): array + { + return array_intersect_key( + (array)$this->_form->getSchema()->field($field), + array_flip(static::VALID_ATTRIBUTES), + ); + } + + /** + * @inheritDoc + */ + public function hasError(string $field): bool + { + $errors = $this->error($field); + + return $errors !== []; + } + + /** + * @inheritDoc + */ + public function error(string $field): array + { + return (array)Hash::get($this->_form->getErrors(), $field, []); + } +} diff --git a/src/View/Form/NullContext.php b/src/View/Form/NullContext.php new file mode 100644 index 00000000000..ce6351637cb --- /dev/null +++ b/src/View/Form/NullContext.php @@ -0,0 +1,131 @@ + + */ + protected array $_defaultConfig = []; + + /** + * Loaded helper instances. + * + * @var array> + */ + protected array $helperInstances = []; + + /** + * The View instance this helper is attached to + * + * @var TView + */ + protected View $_View; + + /** + * Default Constructor + * + * @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 = []) + { + $this->_View = $view; + $this->setConfig($config); + + if ($this->helpers) { + $this->helpers = $view->helpers()->normalizeArray($this->helpers); + } + + $this->initialize($config); + } + + /** + * Lazy loads helpers. + * + * @param string $name Name of the property being accessed. + * @return \Cake\View\Helper<\Cake\View\View>|null Helper instance if helper with provided name exists + */ + public function __get(string $name): ?Helper + { + if (isset($this->helperInstances[$name])) { + return $this->helperInstances[$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 TView The bound view instance. + */ + public function getView(): View + { + return $this->_View; + } + + /** + * Returns a string to be used as onclick handler for confirm dialogs. + * + * @param string $okCode Code to be executed after user chose 'OK' + * @param string $cancelCode Code to be executed after user chose 'Cancel' + * @return string "onclick" JS code + */ + protected function _confirm(string $okCode, string $cancelCode): string + { + 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 $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, string $class, string $key = 'class'): array + { + if (isset($options[$key]) && is_array($options[$key])) { + $options[$key][] = $class; + } elseif (isset($options[$key]) && trim($options[$key])) { + $options[$key] .= ' ' . $class; + } else { + $options[$key] = $class; + } + + return $options; + } + + /** + * Get the View callbacks this helper is interested in. + * + * By defining one of the callback methods a helper is assumed + * to be interested in the related event. + * + * 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 + */ + public function implementedEvents(): array + { + $eventMap = [ + 'View.beforeRenderFile' => 'beforeRenderFile', + 'View.afterRenderFile' => 'afterRenderFile', + 'View.beforeRender' => 'beforeRender', + 'View.afterRender' => 'afterRender', + 'View.beforeLayout' => 'beforeLayout', + 'View.afterLayout' => 'afterLayout', + ]; + $events = []; + foreach ($eventMap as $event => $method) { + if (method_exists($this, $method)) { + $events[$event] = $method; + } + } + + return $events; + } + + /** + * Constructor hook method. + * + * Implement this method to avoid having to overwrite the constructor and call parent. + * + * @param array $config The configuration settings provided to this helper. + * @return void + */ + public function initialize(array $config): void + { + } + + /** + * Returns an array that can be used to describe the internal state of this + * object. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'helpers' => $this->helpers, + 'implementedEvents' => $this->implementedEvents(), + '_config' => $this->getConfig(), + ]; + } +} diff --git a/src/View/Helper/BreadcrumbsHelper.php b/src/View/Helper/BreadcrumbsHelper.php new file mode 100644 index 00000000000..87de279a2e6 --- /dev/null +++ b/src/View/Helper/BreadcrumbsHelper.php @@ -0,0 +1,409 @@ + + */ +class BreadcrumbsHelper extends Helper +{ + use StringTemplateTrait; + + /** + * Other helpers used by BreadcrumbsHelper. + * + * @var array + */ + protected array $helpers = ['Url']; + + /** + * Default config for the helper. + * + * @var array + */ + protected array $_defaultConfig = [ + 'templates' => [ + 'wrapper' => '{{content}}', + 'item' => '{{title}}{{separator}}', + 'itemWithoutLink' => '{{title}}{{separator}}', + 'separator' => '{{separator}}', + ], + ]; + + /** + * The crumb list. + * + * @var array + */ + protected array $crumbs = []; + + /** + * Add a crumb to the end of the trail. + * + * @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 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 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) + * - *templateVars*: Specific template vars in case you override the templates provided. + * @return $this + */ + public function add(array|string $title, array|string|null $url = null, array $options = []) + { + if (is_array($title)) { + deprecationWarning( + '5.3.0', + 'Passing an array as the first argument to BreadcrumbsHelper::add() is deprecated. ' . + 'Use addMany() instead.', + ); + + return $this->addMany($title, $options); + } + + $this->crumbs[] = compact('title', 'url', '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 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 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 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) + * - *templateVars*: Specific template vars in case you override the templates provided. + * @return $this + */ + public function prepend(array|string $title, array|string|null $url = null, array $options = []) + { + if (is_array($title)) { + deprecationWarning( + '5.3.0', + 'Passing an array as the first argument to BreadcrumbsHelper::prepend() is deprecated. ' . + 'Use prependMany() instead.', + ); + + return $this->prependMany($title, $options); + } + + array_unshift($this->crumbs, compact('title', 'url', '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, + * 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 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 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) + * - *templateVars*: Specific template vars in case you override the templates provided. + * @return $this + * @throws \LogicException In case the index is out of bound + */ + public function insertAt(int $index, string $title, array|string|null $url = null, array $options = []) + { + 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')]); + + return $this; + } + + /** + * Insert a crumb before the first matching crumb with the specified title. + * + * Finds the index of the first crumb that matches the provided class, + * and inserts the supplied callable before it. + * + * @param string $matchingTitle The title of the crumb you want to insert this one before. + * @param string $title Title of the crumb. + * @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 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) + * - *templateVars*: Specific template vars in case you override the templates provided. + * @return $this + * @throws \LogicException In case the matching crumb can not be found + */ + 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)); + } + + return $this->insertAt($key, $title, $url, $options); + } + + /** + * Insert a crumb after the first matching crumb with the specified title. + * + * Finds the index of the first crumb that matches the provided class, + * and inserts the supplied callable before it. + * + * @param string $matchingTitle The title of the crumb you want to insert this one after. + * @param string $title Title of the crumb. + * @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 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) + * - *templateVars*: Specific template vars in case you override the templates provided. + * @return $this + * @throws \LogicException In case the matching crumb can not be found. + */ + 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)); + } + + return $this->insertAt($key + 1, $title, $url, $options); + } + + /** + * Returns the crumb list. + * + * @return array + */ + public function getCrumbs(): array + { + return $this->crumbs; + } + + /** + * Removes all existing crumbs. + * + * @return $this + */ + public function reset() + { + $this->crumbs = []; + + return $this; + } + + /** + * Renders the breadcrumbs trail. + * + * @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. + * 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 = []): string + { + if (!$this->crumbs) { + return ''; + } + + $crumbs = $this->crumbs; + $crumbsCount = count($crumbs); + $templater = $this->templater(); + $separatorString = ''; + + if ($separator) { + if (isset($separator['innerAttrs'])) { + $separator['innerAttrs'] = $templater->formatAttributes($separator['innerAttrs']); + } + + $separator['attrs'] = $templater->formatAttributes( + $separator, + ['innerAttrs', 'separator'], + ); + + $separatorString = $this->formatTemplate('separator', $separator); + } + + $crumbTrail = ''; + foreach ($crumbs as $key => $crumb) { + $url = $crumb['url'] ? $this->Url->build($crumb['url']) : null; + $title = $crumb['title']; + $options = $crumb['options']; + + $optionsLink = []; + if (isset($options['innerAttrs'])) { + $optionsLink = $options['innerAttrs']; + unset($options['innerAttrs']); + } + + $template = 'item'; + $templateParams = [ + 'attrs' => $templater->formatAttributes($options, ['templateVars']), + 'innerAttrs' => $templater->formatAttributes($optionsLink), + 'title' => $title, + 'url' => $url, + 'separator' => '', + 'templateVars' => $options['templateVars'] ?? [], + ]; + + if (!$url) { + $template = 'itemWithoutLink'; + } + + if ($separatorString && $key !== $crumbsCount - 1) { + $templateParams['separator'] = $separatorString; + } + + $crumbTrail .= $this->formatTemplate($template, $templateParams); + } + + return $this->formatTemplate('wrapper', [ + 'content' => $crumbTrail, + 'attrs' => $templater->formatAttributes($attributes, ['templateVars']), + 'templateVars' => $attributes['templateVars'] ?? [], + ]); + } + + /** + * Search a crumb in the current stack which title matches the one provided as argument. + * If found, the index of the matching crumb will be returned. + * + * @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(string $title): ?int + { + foreach ($this->crumbs as $key => $crumb) { + if ($crumb['title'] === $title) { + return $key; + } + } + + return null; + } +} diff --git a/src/View/Helper/FlashHelper.php b/src/View/Helper/FlashHelper.php new file mode 100644 index 00000000000..6448b72346d --- /dev/null +++ b/src/View/Helper/FlashHelper.php @@ -0,0 +1,97 @@ + + */ +class FlashHelper extends Helper +{ + /** + * Used to render the message set in FlashComponent::set() + * + * In your template file: $this->Flash->render('somekey'); + * Will default to flash if no param is passed + * + * You can pass additional information into the flash message generation. This allows you + * to consolidate all the parameters for a given type of flash message into the view. + * + * ``` + * echo $this->Flash->render('flash', ['params' => ['name' => $user['User']['name']]]); + * ``` + * + * This would pass the current user's name into the flash message, so you could create personalized + * messages without the controller needing access to that data. + * + * Lastly you can choose the element that is used for rendering the flash message. Using + * custom elements allows you to fully customize how flash messages are generated. + * + * ``` + * echo $this->Flash->render('flash', ['element' => 'my_custom_element']); + * ``` + * + * If you want to use an element from a plugin for rendering your flash message + * you can use the dot notation for the plugin's element name: + * + * ``` + * echo $this->Flash->render('flash', [ + * 'element' => 'MyPlugin.my_custom_element', + * ]); + * ``` + * + * If you have several messages stored in the Session, each message will be rendered in its own + * 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. + * 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. + */ + public function render(string $key = 'flash', array $options = []): ?string + { + $messages = $this->_View->getRequest()->getFlash()->consume($key); + if ($messages === null) { + return null; + } + + $out = ''; + foreach ($messages as $message) { + $message = $options + $message; + $out .= $this->_View->element($message['element'], $message); + } + + return $out; + } + + /** + * Event listeners. + * + * @return array + */ + public function implementedEvents(): array + { + return []; + } +} diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php new file mode 100644 index 00000000000..c9bc5c57fa6 --- /dev/null +++ b/src/View/Helper/FormHelper.php @@ -0,0 +1,2749 @@ + + */ +class FormHelper extends Helper +{ + use IdGeneratorTrait; + use StringTemplateTrait; + + /** + * Other helpers used by FormHelper + * + * @var array + */ + protected array $helpers = ['Url', 'Html']; + + /** + * Default config for the helper. + * + * @var array + */ + protected array $_defaultConfig = [ + 'idPrefix' => null, + // 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', + 'tinyinteger' => 'number', + 'smallinteger' => 'number', + 'decimal' => 'number', + 'binary' => 'file', + ], + 'templates' => [ + // Used for button elements in button(). + 'button' => '{{text}}', + // Used for checkboxes in checkbox() and multiCheckbox(). + 'checkbox' => '', + // Input group wrapper for checkboxes created via control(). + 'checkboxFormGroup' => '{{input}}{{label}}', + // Wrapper container for checkboxes in a multicheckbox input + 'checkboxWrapper' => '
    {{input}}{{label}}
    ', + // Error message wrapper elements. + 'error' => '
    {{content}}
    ', + // Container for error items. + 'errorList' => '
      {{content}}
    ', + // Error item wrapper. + 'errorItem' => '
  • {{text}}
  • ', + // File input used by file(). + 'file' => '', + // Fieldset element used by allControls(). + 'fieldset' => '{{content}}', + // Open tag used by create(). + 'formStart' => '', + // Close tag used by end(). + 'formEnd' => '', + // General grouping container for control(). Defines input/label ordering. + 'formGroup' => '{{label}}{{input}}', + // Wrapper content used to hide other content. + 'hiddenBlock' => '{{content}}', + // Generic input element. + 'input' => '', + // Submit input element. + 'inputSubmit' => '', + // Container element used by control(). + 'inputContainer' => '
    {{content}}
    ', + // Container element used by control() when a field has an 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. + 'nestingLabel' => '{{hidden}}{{input}}{{text}}', + // Legends created by allControls() + 'legend' => '{{text}}', + // Multi-Checkbox input set title element. + 'multicheckboxTitle' => '{{text}}', + // Multi-Checkbox wrapping container. + 'multicheckboxWrapper' => '{{content}}', + // Option element used in select pickers. + 'option' => '', + // Option group element used in select pickers. + 'optgroup' => '{{content}}', + // Select element, + 'select' => '', + // Multi-select element, + 'selectMultiple' => '', + // Radio input element, + 'radio' => '', + // Wrapping container for radio input/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 ', + 'javascriptlink' => '', + 'confirmJs' => '{{confirm}}', + ], + ]; + + /** + * Names of script & css files that have been included once + * + * @var array + */ + protected array $_includedAssets = []; + + /** + * Options for the currently opened script block buffer if any. + * + * @var array + */ + protected array $_scriptBlockOptions = []; + + /** + * Creates a link to an external resource and handles basic meta tags + * + * Create a meta tag that is output inline: + * + * ``` + * $this->Html->meta('icon', 'favicon.ico'); + * ``` + * + * Append the meta tag to custom view block "meta": + * + * ``` + * $this->Html->meta('description', 'A great page', ['block' => true]); + * ``` + * + * Append the meta tag to custom view block: + * + * ``` + * $this->Html->meta('description', 'A great page', ['block' => 'metaTags']); + * ``` + * + * Create a custom meta tag: + * + * ``` + * $this->Html->meta(['property' => 'og:site_name', 'content' => 'CakePHP']); + * ``` + * + * ### Options + * + * - `block` - Set to true to append output to view block "meta" or provide + * custom block name. + * + * @param array|string $type The title of the external resource, Or an array of attributes for a + * custom meta tag. + * @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 `` 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(array|string $type, array|string|null $content = null, array $options = []): ?string + { + 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], + 'keywords' => ['name' => 'keywords', 'content' => $content], + 'description' => ['name' => 'description', 'content' => $content], + 'robots' => ['name' => 'robots', 'content' => $content], + 'viewport' => ['name' => 'viewport', 'content' => $content], + 'canonical' => ['rel' => 'canonical', 'link' => $content], + 'next' => ['rel' => 'next', 'link' => $content], + 'prev' => ['rel' => 'prev', 'link' => $content], + 'first' => ['rel' => 'first', '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) { + if (is_array($content) && isset($content['_ext'])) { + $type = $types[$content['_ext']]; + } else { + $type = ['name' => $type, 'content' => $content]; + } + } elseif (isset($options['type'], $types[$options['type']])) { + $type = $types[$options['type']]; + unset($options['type']); + } else { + $type = []; + } + } + + $options += $type + ['block' => $this->getConfig('defaultMetaBlock')]; + $out = ''; + + if (isset($options['link'])) { + 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']), + ]); + } else { + $out = $this->formatTemplate('meta', [ + 'attrs' => $this->templater()->formatAttributes($options, ['block', 'type']), + ]); + } + + if (empty($options['block'])) { + return $out; + } + if ($options['block'] === true) { + $options['block'] = __FUNCTION__; + } + $this->_View->append($options['block'], $out); + + return null; + } + + /** + * Returns a charset META-tag. + * + * @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/5/en/views/helpers/html.html#creating-charset-tags + */ + public function charset(?string $charset = null): string + { + if (!$charset) { + $charset = strtolower((string)Configure::read('App.encoding')); + } + + return $this->formatTemplate('charset', [ + 'charset' => $charset ?: 'utf-8', + ]); + } + + /** + * Creates an HTML link. + * + * If $url starts with "http://" this is treated as an external link. Else, + * it is treated as a path to controller/action and parsed with the + * UrlHelper::build() method. + * + * If the $url is empty, $title is used instead. + * + * ### 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 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 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/5/en/views/helpers/html.html#creating-links + */ + public function link(array|string $title, array|string|null $url = null, array $options = []): string + { + $escapeTitle = true; + if ($url !== null) { + $url = $this->Url->build($url, $options); + unset($options['fullBase']); + } else { + $url = $this->Url->build($title); + $title = htmlspecialchars_decode($url, ENT_QUOTES); + $title = h(urldecode($title)); + $escapeTitle = false; + } + + if (isset($options['escapeTitle'])) { + $escapeTitle = $options['escapeTitle']; + unset($options['escapeTitle']); + } elseif (isset($options['escape'])) { + $escapeTitle = $options['escape']; + } + + if ($escapeTitle === true) { + $title = h($title); + } elseif (is_string($escapeTitle)) { + $title = htmlentities($title, ENT_QUOTES, $escapeTitle); + } + + $templater = $this->templater(); + $confirmMessage = null; + if (isset($options['confirm'])) { + $confirmMessage = $options['confirm']; + unset($options['confirm']); + } + if ($confirmMessage) { + $confirm = $this->_confirm('return true;', 'return false;'); + $options['data-confirm-message'] = $confirmMessage; + $options['onclick'] = $templater->format('confirmJs', [ + 'confirmMessage' => h($confirmMessage), + 'confirm' => $confirm, + ]); + } + + return $templater->format('link', [ + 'url' => $url, + 'attrs' => $templater->formatAttributes($options), + '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. + * + * ### Usage + * + * Include one CSS file: + * + * ``` + * echo $this->Html->css('styles.css'); + * ``` + * + * Include multiple CSS files: + * + * ``` + * echo $this->Html->css(['one.css', 'two.css']); + * ``` + * + * Add the stylesheet to view block "css": + * + * ``` + * $this->Html->css('styles.css', ['block' => true]); + * ``` + * + * Add the stylesheet to a custom block: + * + * ``` + * $this->Html->css('styles.css', ['block' => 'layoutCss']); + * ``` + * + * ### Options + * + * - `block` Set to true to append output to view block "css" or provide + * custom block name. + * - `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. + * + * 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 @@ + 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'], + ]; +} 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 new file mode 100644 index 00000000000..76bf031236b --- /dev/null +++ b/tests/Fixture/ArticlesTagsFixture.php @@ -0,0 +1,35 @@ + 1, 'tag_id' => 1], + ['article_id' => 1, 'tag_id' => 2], + ['article_id' => 2, 'tag_id' => 1], + ['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/AttachmentsFixture.php b/tests/Fixture/AttachmentsFixture.php new file mode 100644 index 00000000000..dad30b95cca --- /dev/null +++ b/tests/Fixture/AttachmentsFixture.php @@ -0,0 +1,32 @@ + 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 new file mode 100644 index 00000000000..41944af32c4 --- /dev/null +++ b/tests/Fixture/AuthUsersFixture.php @@ -0,0 +1,36 @@ + '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'], + ['username' => 'garrett', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31'], + ['username' => 'nate', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31'], + ]; +} diff --git a/tests/Fixture/AuthorsFixture.php b/tests/Fixture/AuthorsFixture.php new file mode 100644 index 00000000000..a3697b3c47d --- /dev/null +++ b/tests/Fixture/AuthorsFixture.php @@ -0,0 +1,35 @@ + 'mariano'], + ['name' => 'nate'], + ['name' => 'larry'], + ['name' => 'garrett'], + ]; +} diff --git a/tests/Fixture/AuthorsTagsFixture.php b/tests/Fixture/AuthorsTagsFixture.php new file mode 100644 index 00000000000..7985bd9eb09 --- /dev/null +++ b/tests/Fixture/AuthorsTagsFixture.php @@ -0,0 +1,34 @@ + 3, 'tag_id' => 1], + ['author_id' => 3, 'tag_id' => 2], + ['author_id' => 2, 'tag_id' => 1], + ['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 new file mode 100644 index 00000000000..f0842860ec4 --- /dev/null +++ b/tests/Fixture/CakeSessionsFixture.php @@ -0,0 +1,30 @@ + 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'], + ['parent_id' => 0, 'name' => 'Category 2', 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31'], + ['parent_id' => 0, 'name' => 'Category 3', 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31'], + ['parent_id' => 5, 'name' => 'Category 3.1', 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31'], + ['parent_id' => 2, 'name' => 'Category 1.1.1', 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31'], + ['parent_id' => 2, 'name' => 'Category 1.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 new file mode 100644 index 00000000000..c0c21eafd34 --- /dev/null +++ b/tests/Fixture/CommentsFixture.php @@ -0,0 +1,37 @@ + 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'], + ]; +} 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 new file mode 100644 index 00000000000..5a3c204a07f --- /dev/null +++ b/tests/Fixture/CompositeIncrementsFixture.php @@ -0,0 +1,28 @@ + 'Sport', 'post_count' => 1], + ['name' => 'Music', 'post_count' => 2], + ]; +} diff --git a/tests/Fixture/CounterCacheCommentsFixture.php b/tests/Fixture/CounterCacheCommentsFixture.php new file mode 100644 index 00000000000..fb8561816c5 --- /dev/null +++ b/tests/Fixture/CounterCacheCommentsFixture.php @@ -0,0 +1,29 @@ + '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 new file mode 100644 index 00000000000..be8eefa2050 --- /dev/null +++ b/tests/Fixture/CounterCachePostsFixture.php @@ -0,0 +1,29 @@ + '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 new file mode 100644 index 00000000000..bac057be465 --- /dev/null +++ b/tests/Fixture/CounterCacheUserCategoryPostsFixture.php @@ -0,0 +1,29 @@ + 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], + ]; +} diff --git a/tests/Fixture/CounterCacheUsersFixture.php b/tests/Fixture/CounterCacheUsersFixture.php new file mode 100644 index 00000000000..d1310d5479d --- /dev/null +++ b/tests/Fixture/CounterCacheUsersFixture.php @@ -0,0 +1,28 @@ + '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 new file mode 100644 index 00000000000..ffa69426845 --- /dev/null +++ b/tests/Fixture/DatatypesFixture.php @@ -0,0 +1,28 @@ + 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 new file mode 100644 index 00000000000..6b8555252ec --- /dev/null +++ b/tests/Fixture/FixturizedTestCase.php @@ -0,0 +1,87 @@ + + */ + protected array $fixtures = ['core.Categories', 'core.Articles']; + + /** + * test that the shared fixture is correctly set + */ + public function testFixturePresent(): void + { + $this->assertInstanceOf(FixtureManager::class, $this->fixtureManager); + } + + /** + * test that it is possible to load fixtures on demand + */ + public function testFixtureLoadOnDemand(): void + { + $this->loadFixtures('Categories'); + } + + /** + * test that calling loadFixtures without args loads all fixtures + */ + public function testLoadAllFixtures(): void + { + $this->loadFixtures(); + $article = $this->getTableLocator()->get('Articles')->get(1); + $this->assertSame(1, $article->id); + $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 + */ + 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 + */ + 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 + * + * @throws \Exception + */ + public function testThrowException(): void + { + throw new Exception(); + } +} diff --git a/tests/Fixture/MembersFixture.php b/tests/Fixture/MembersFixture.php new file mode 100644 index 00000000000..4f79365bb03 --- /dev/null +++ b/tests/Fixture/MembersFixture.php @@ -0,0 +1,32 @@ + 2], + ]; +} diff --git a/tests/Fixture/MenuLinkTreesFixture.php b/tests/Fixture/MenuLinkTreesFixture.php new file mode 100644 index 00000000000..3b8d56a1a2e --- /dev/null +++ b/tests/Fixture/MenuLinkTreesFixture.php @@ -0,0 +1,203 @@ + 'main-menu', + 'lft' => '1', + 'rght' => '10', + 'parent_id' => null, + 'url' => '/link1.html', + 'title' => 'Link 1', + ], + [ + 'menu' => 'main-menu', + 'lft' => '2', + 'rght' => '3', + 'parent_id' => '1', + 'url' => 'http://example.com', + 'title' => 'Link 2', + ], + [ + 'menu' => 'main-menu', + 'lft' => '4', + 'rght' => '9', + 'parent_id' => '1', + 'url' => '/what/even-more-links.html', + 'title' => 'Link 3', + ], + [ + 'menu' => 'main-menu', + 'lft' => '5', + 'rght' => '8', + 'parent_id' => '3', + 'url' => '/lorem/ipsum.html', + 'title' => 'Link 4', + ], + [ + 'menu' => 'main-menu', + 'lft' => '6', + 'rght' => '7', + 'parent_id' => '4', + 'url' => '/what/the.html', + 'title' => 'Link 5', + ], + [ + 'menu' => 'main-menu', + 'lft' => '11', + 'rght' => '14', + 'parent_id' => null, + 'url' => '/yeah/another-link.html', + 'title' => 'Link 6', + ], + [ + 'menu' => 'main-menu', + 'lft' => '12', + 'rght' => '13', + 'parent_id' => '6', + 'url' => 'https://cakephp.org', + 'title' => 'Link 7', + ], + [ + 'menu' => 'main-menu', + 'lft' => '15', + 'rght' => '16', + 'parent_id' => null, + 'url' => '/page/who-we-are.html', + 'title' => 'Link 8', + ], + [ + 'menu' => 'categories', + 'lft' => '1', + 'rght' => '10', + 'parent_id' => null, + 'url' => '/cagetory/electronics.html', + 'title' => 'electronics', + ], + [ + 'menu' => 'categories', + 'lft' => '2', + 'rght' => '9', + 'parent_id' => '9', + 'url' => '/category/televisions.html', + 'title' => 'televisions', + ], + [ + 'menu' => 'categories', + 'lft' => '3', + 'rght' => '4', + 'parent_id' => '10', + 'url' => '/category/tube.html', + 'title' => 'tube', + ], + [ + 'menu' => 'categories', + 'lft' => '5', + 'rght' => '8', + 'parent_id' => '10', + 'url' => '/category/lcd.html', + 'title' => 'lcd', + ], + [ + 'menu' => 'categories', + 'lft' => '6', + 'rght' => '7', + 'parent_id' => '12', + 'url' => '/category/plasma.html', + 'title' => 'plasma', + ], + [ + 'menu' => 'categories', + 'lft' => '11', + 'rght' => '20', + 'parent_id' => null, + 'url' => '/category/portable.html', + 'title' => 'portable', + ], + [ + 'menu' => 'categories', + 'lft' => '12', + 'rght' => '15', + 'parent_id' => '14', + 'url' => '/category/mp3.html', + 'title' => 'mp3', + ], + [ + 'menu' => 'categories', + 'lft' => '13', + 'rght' => '14', + 'parent_id' => '15', + 'url' => '/category/flash.html', + 'title' => 'flash', + ], + [ + 'menu' => 'categories', + 'lft' => '16', + 'rght' => '17', + 'parent_id' => '14', + 'url' => '/category/cd.html', + 'title' => 'cd', + ], + [ + 'menu' => 'categories', + 'lft' => '18', + 'rght' => '19', + 'parent_id' => '14', + 'url' => '/category/radios.html', + 'title' => 'radios', + ], + ]; +} 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 new file mode 100644 index 00000000000..9b5a98a5abf --- /dev/null +++ b/tests/Fixture/NumberTreesFixture.php @@ -0,0 +1,122 @@ + 'electronics', + 'parent_id' => null, + 'lft' => '1', + 'rght' => '20', + 'depth' => 0, + ], + [ + 'name' => 'televisions', + 'parent_id' => '1', + 'lft' => '2', + 'rght' => '9', + 'depth' => 1, + ], + [ + 'name' => 'tube', + 'parent_id' => '2', + 'lft' => '3', + 'rght' => '4', + 'depth' => 2, + ], + [ + 'name' => 'lcd', + 'parent_id' => '2', + 'lft' => '5', + 'rght' => '6', + 'depth' => 2, + ], + [ + 'name' => 'plasma', + 'parent_id' => '2', + 'lft' => '7', + 'rght' => '8', + 'depth' => 2, + ], + [ + 'name' => 'portable', + 'parent_id' => '1', + 'lft' => '10', + 'rght' => '19', + 'depth' => 1, + ], + [ + 'name' => 'mp3', + 'parent_id' => '6', + 'lft' => '11', + 'rght' => '14', + 'depth' => 2, + ], + [ + 'name' => 'flash', + 'parent_id' => '7', + 'lft' => '12', + 'rght' => '13', + 'depth' => 3, + ], + [ + 'name' => 'cd', + 'parent_id' => '6', + 'lft' => '15', + 'rght' => '16', + 'depth' => 2, + ], + [ + 'name' => 'radios', + 'parent_id' => '6', + 'lft' => '17', + 'rght' => '18', + 'depth' => 2, + ], + [ + 'name' => 'alien hardware', + 'parent_id' => null, + 'lft' => '21', + 'rght' => '22', + 'depth' => 0, + ], + ]; +} diff --git a/tests/Fixture/OrderedUuidItemsFixture.php b/tests/Fixture/OrderedUuidItemsFixture.php new file mode 100644 index 00000000000..cbbc440a44d --- /dev/null +++ b/tests/Fixture/OrderedUuidItemsFixture.php @@ -0,0 +1,31 @@ + 1, 'product_id' => 1], + ]; +} diff --git a/tests/Fixture/OtherArticlesFixture.php b/tests/Fixture/OtherArticlesFixture.php new file mode 100644 index 00000000000..54c0ab6937d --- /dev/null +++ b/tests/Fixture/OtherArticlesFixture.php @@ -0,0 +1,68 @@ + 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 new file mode 100644 index 00000000000..f81515bf546 --- /dev/null +++ b/tests/Fixture/PostsFixture.php @@ -0,0 +1,34 @@ + 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'], + ]; +} diff --git a/tests/Fixture/ProductsFixture.php b/tests/Fixture/ProductsFixture.php new file mode 100644 index 00000000000..34ffc41906b --- /dev/null +++ b/tests/Fixture/ProductsFixture.php @@ -0,0 +1,39 @@ + 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], + ]; +} diff --git a/tests/Fixture/ProfilesFixture.php b/tests/Fixture/ProfilesFixture.php new file mode 100644 index 00000000000..992b87ba1c4 --- /dev/null +++ b/tests/Fixture/ProfilesFixture.php @@ -0,0 +1,35 @@ + 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], + ['user_id' => 4, 'first_name' => 'garrett', 'last_name' => 'woodworth', 'is_active' => false], + ]; +} 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 @@ + 1, + 'author_id' => 1, + 'site_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + ], + [ + 'id' => 2, + 'author_id' => 3, + 'site_id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + ], + [ + 'id' => 3, + 'author_id' => 1, + 'site_id' => 2, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + ], + [ + 'id' => 4, + 'author_id' => 3, + 'site_id' => 1, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + ], + ]; +} diff --git a/tests/Fixture/SiteArticlesTagsFixture.php b/tests/Fixture/SiteArticlesTagsFixture.php new file mode 100644 index 00000000000..efa5b4335c7 --- /dev/null +++ b/tests/Fixture/SiteArticlesTagsFixture.php @@ -0,0 +1,33 @@ + 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], + ]; +} diff --git a/tests/Fixture/SiteAuthorsFixture.php b/tests/Fixture/SiteAuthorsFixture.php new file mode 100644 index 00000000000..55283c042ff --- /dev/null +++ b/tests/Fixture/SiteAuthorsFixture.php @@ -0,0 +1,32 @@ + 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], + ]; +} diff --git a/tests/Fixture/SiteTagsFixture.php b/tests/Fixture/SiteTagsFixture.php new file mode 100644 index 00000000000..a6735b27ba6 --- /dev/null +++ b/tests/Fixture/SiteTagsFixture.php @@ -0,0 +1,32 @@ + 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'], + ]; +} 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 @@ + 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], + ]; +} 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 new file mode 100644 index 00000000000..e194f76e89a --- /dev/null +++ b/tests/Fixture/TagsFixture.php @@ -0,0 +1,34 @@ + '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'], + ]; +} 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 new file mode 100644 index 00000000000..b3e403f46cd --- /dev/null +++ b/tests/Fixture/TagsTranslationsFixture.php @@ -0,0 +1,34 @@ + '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'], + ]; +} diff --git a/tests/Fixture/TestPluginCommentsFixture.php b/tests/Fixture/TestPluginCommentsFixture.php new file mode 100644 index 00000000000..f806f919c4a --- /dev/null +++ b/tests/Fixture/TestPluginCommentsFixture.php @@ -0,0 +1,37 @@ + 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'], + ]; +} diff --git a/tests/Fixture/ThingsFixture.php b/tests/Fixture/ThingsFixture.php new file mode 100644 index 00000000000..1bae5d7e54a --- /dev/null +++ b/tests/Fixture/ThingsFixture.php @@ -0,0 +1,30 @@ + 1, 'title' => 'a title', 'body' => 'a body'], + ['id' => 2, 'title' => 'another title', 'body' => 'another body'], + ]; +} diff --git a/tests/Fixture/TranslatesFixture.php b/tests/Fixture/TranslatesFixture.php new file mode 100644 index 00000000000..3e067017132 --- /dev/null +++ b/tests/Fixture/TranslatesFixture.php @@ -0,0 +1,68 @@ + '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'], + ['locale' => 'spa', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'Contenido #1'], + ['locale' => 'spa', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'description', 'content' => ''], + ['locale' => 'deu', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titel #1'], + ['locale' => 'deu', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'Inhalt #1'], + ['locale' => 'cze', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Titulek #1'], + ['locale' => 'cze', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'Obsah #1'], + ['locale' => 'eng', 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'title', 'content' => 'Title #2'], + ['locale' => 'eng', 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'body', 'content' => 'Content #2'], + ['locale' => 'deu', 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'title', 'content' => 'Titel #2'], + ['locale' => 'deu', 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'body', 'content' => 'Inhalt #2'], + ['locale' => 'cze', 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'title', 'content' => 'Titulek #2'], + ['locale' => 'cze', 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'body', 'content' => 'Obsah #2'], + ['locale' => 'eng', 'model' => 'Articles', 'foreign_key' => 3, 'field' => 'title', 'content' => 'Title #3'], + ['locale' => 'eng', 'model' => 'Articles', 'foreign_key' => 3, 'field' => 'body', 'content' => 'Content #3'], + ['locale' => 'deu', 'model' => 'Articles', 'foreign_key' => 3, 'field' => 'title', 'content' => 'Titel #3'], + ['locale' => 'deu', 'model' => 'Articles', 'foreign_key' => 3, 'field' => 'body', 'content' => 'Inhalt #3'], + ['locale' => 'cze', 'model' => 'Articles', 'foreign_key' => 3, 'field' => 'title', 'content' => 'Titulek #3'], + ['locale' => 'cze', 'model' => 'Articles', 'foreign_key' => 3, 'field' => 'body', 'content' => 'Obsah #3'], + ['locale' => 'eng', 'model' => 'Comments', 'foreign_key' => 1, 'field' => 'comment', 'content' => 'Comment #1'], + ['locale' => 'eng', 'model' => 'Comments', 'foreign_key' => 2, 'field' => 'comment', 'content' => 'Comment #2'], + ['locale' => 'eng', 'model' => 'Comments', 'foreign_key' => 3, 'field' => 'comment', 'content' => 'Comment #3'], + ['locale' => 'eng', 'model' => 'Comments', 'foreign_key' => 4, 'field' => 'comment', 'content' => 'Comment #4'], + ['locale' => 'spa', 'model' => 'Comments', 'foreign_key' => 4, 'field' => 'comment', 'content' => 'Comentario #4'], + ['locale' => 'eng', 'model' => 'Authors', 'foreign_key' => 1, 'field' => 'name', 'content' => 'May-rianoh'], + ['locale' => 'dan', 'model' => 'NumberTrees', 'foreign_key' => 1, 'field' => 'name', 'content' => 'Elektroniker'], + ['locale' => 'dan', 'model' => 'NumberTrees', 'foreign_key' => 11, 'field' => 'name', 'content' => 'Alien Tingerne'], + ['locale' => 'eng', 'model' => 'SpecialTags', 'foreign_key' => 2, 'field' => 'extra_info', 'content' => 'Translated Info'], + ]; +} 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 new file mode 100644 index 00000000000..0a34ddb5c68 --- /dev/null +++ b/tests/Fixture/UsersFixture.php @@ -0,0 +1,35 @@ + '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'], + ['username' => 'garrett', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2012-06-10 01:22:23', 'updated' => '2012-06-12 01:24: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/lib/Cake/Test/Fixture/rss.xml b/tests/Fixture/rss.xml similarity index 100% rename from lib/Cake/Test/Fixture/rss.xml rename to tests/Fixture/rss.xml 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/lib/Cake/Test/Fixture/sample.xml b/tests/Fixture/sample.xml similarity index 100% rename from lib/Cake/Test/Fixture/sample.xml rename to tests/Fixture/sample.xml diff --git a/lib/Cake/Test/Fixture/soap_request.xml b/tests/Fixture/soap_request.xml similarity index 100% rename from lib/Cake/Test/Fixture/soap_request.xml rename to tests/Fixture/soap_request.xml diff --git a/lib/Cake/Test/Fixture/soap_response.xml b/tests/Fixture/soap_response.xml similarity index 100% rename from lib/Cake/Test/Fixture/soap_response.xml rename to tests/Fixture/soap_response.xml diff --git a/tests/PHPStan/AssociationTableMixinClassReflectionExtension.php b/tests/PHPStan/AssociationTableMixinClassReflectionExtension.php new file mode 100644 index 00000000000..f2927bfa081 --- /dev/null +++ b/tests/PHPStan/AssociationTableMixinClassReflectionExtension.php @@ -0,0 +1,86 @@ +reflectionProvider = $reflectionProvider; + } + + protected function getTableReflection(): ClassReflection + { + return $this->reflectionProvider->getClass(Table::class); + } + + /** + * @param ClassReflection $classReflection Class reflection + * @param string $methodName Method name + */ + 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; + } + + return $this->getTableReflection()->hasMethod($methodName); + } + + /** + * @param ClassReflection $classReflection Class reflection + * @param string $methodName Method name + */ + 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 + */ + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + if (!$classReflection->isSubclassOf(Association::class)) { + return false; + } + + return $this->getTableReflection()->hasProperty($propertyName); + } + + /** + * @param ClassReflection $classReflection Class reflection + * @param string $propertyName Method name + */ + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->getTableReflection()->getNativeProperty($propertyName); + } +} 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/Cache/CacheTest.php b/tests/TestCase/Cache/CacheTest.php new file mode 100644 index 00000000000..7a595532d47 --- /dev/null +++ b/tests/TestCase/Cache/CacheTest.php @@ -0,0 +1,904 @@ + 'File', + 'path' => CACHE, + 'prefix' => 'test_', + ]); + } + + /** + * tests Cache::pool() fallback + */ + public function testCachePoolFallback(): void + { + $filename = tempnam(CACHE, 'tmp_'); + + Cache::setConfig('tests', [ + 'engine' => 'File', + 'path' => $filename, + 'prefix' => 'test_', + 'fallback' => 'tests_fallback', + ]); + Cache::setConfig('tests_fallback', [ + 'engine' => 'File', + 'path' => CACHE, + 'prefix' => 'test_', + ]); + + $this->expectWarningMessageMatches('/^.* is not writable/', function () use (&$engine): void { + $engine = Cache::pool('tests'); + }); + $path = $engine->getConfig('path'); + $this->assertSame(CACHE, $path); + + unlink($filename); + } + + /** + * tests you can disable Cache::pool() fallback + */ + public function testCachePoolFallbackDisabled(): void + { + $engine = new class extends TestAppCacheEngine { + public function init(array $config = []): bool + { + return false; + } + }; + + 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', + ]); + + $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::pool() fallback when using groups + */ + public function testCacheFallbackWithGroups(): void + { + $filename = tempnam(CACHE, 'tmp_'); + + Cache::setConfig('tests', [ + 'engine' => 'File', + 'path' => $filename, + 'prefix' => 'test_', + 'fallback' => 'tests_fallback', + 'groups' => ['group1', 'group2'], + ]); + Cache::setConfig('tests_fallback', [ + 'engine' => 'File', + 'path' => CACHE, + 'prefix' => 'test_', + 'groups' => ['group3', '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); + + unlink($filename); + } + + /** + * tests cache fallback + */ + public function testCacheFallbackIntegration(): void + { + $filename = tempnam(CACHE, 'tmp_'); + + Cache::setConfig('tests', [ + 'engine' => 'File', + 'path' => $filename, + 'fallback' => 'tests_fallback', + 'groups' => ['integration_group', 'integration_group_2'], + ]); + Cache::setConfig('tests_fallback', [ + 'engine' => 'File', + 'path' => $filename, + 'fallback' => 'tests_fallback_final', + 'groups' => ['integration_group'], + ]); + Cache::setConfig('tests_fallback_final', [ + 'engine' => 'File', + 'path' => CACHE . 'cake_test' . DS, + 'groups' => ['integration_group_3'], + ]); + + $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->assertNull(Cache::read('grouped', 'tests')); + $this->assertNull(Cache::read('grouped_2', 'tests_fallback')); + + $this->assertSame('worked', Cache::read('grouped_3', 'tests_fallback_final')); + + unlink($filename); + } + + /** + * Check that no fatal errors are issued doing normal things when Cache.disable is true. + */ + public function testNonFatalErrorsWithCacheDisable(): void + { + Cache::disable(); + $this->_configCache(); + + $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. + */ + public function testNullEngineWhenCacheDisable(): void + { + $this->_configCache(); + Cache::disable(); + + $result = Cache::pool('tests'); + $this->assertInstanceOf(NullEngine::class, $result); + } + + /** + * Test configuring an invalid class fails + */ + public function testConfigInvalidClassType(): void + { + Cache::setConfig('tests', [ + 'className' => '\stdClass', + ]); + + $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 + */ + public function testConfigFailedInit(): void + { + $engine = new class extends TestAppCacheEngine { + public function init(array $config = []): bool + { + return false; + } + }; + Cache::setConfig('tests', [ + 'engine' => $engine, + ]); + + $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 + */ + public function testConfigWithLibAndPluginEngines(): void + { + static::setAppNamespace(); + $this->loadPlugins(['TestPlugin']); + + $config = ['engine' => 'TestAppCache', 'path' => CACHE, 'prefix' => 'cake_test_']; + Cache::setConfig('libEngine', $config); + $engine = Cache::pool('libEngine'); + $this->assertInstanceOf(TestAppCacheEngine::class, $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'); + + $this->clearPlugins(); + } + + /** + * Test write from a config that is undefined. + */ + public function testWriteNonExistentConfig(): void + { + $this->expectException(InvalidArgumentException::class); + + Cache::write('key', 'value', 'totally fake'); + } + + /** + * Test write from a config that is undefined. + */ + public function testIncrementNonExistentConfig(): void + { + $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. + */ + 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); + + Cache::decrement('key', -1); + } + + /** + * Data provider for valid config data sets. + * + * @return array + */ + public static function configProvider(): array + { + return [ + 'Array of data using engine key.' => [[ + 'engine' => 'File', + 'path' => CACHE . 'tests', + 'prefix' => 'cake_test_', + ]], + 'Array of data using classname key.' => [[ + 'className' => 'File', + 'path' => CACHE . 'tests', + 'prefix' => 'cake_test_', + ]], + 'Direct instance' => [new FileEngine()], + ]; + } + + /** + * testConfig method + * + * @param \Cake\Cache\CacheEngine|array $config + */ + #[DataProvider('configProvider')] + public function testConfigVariants($config): void + { + $this->assertNotContains('test', Cache::configured(), 'test config should not exist.'); + Cache::setConfig('tests', $config); + + $engine = Cache::pool('tests'); + $this->assertInstanceOf(FileEngine::class, $engine); + $this->assertContains('tests', Cache::configured()); + } + + /** + * testConfigInvalidEngine method + */ + public function testConfigInvalidEngine(): void + { + $config = ['engine' => 'Imaginary']; + Cache::setConfig('test', $config); + + $this->expectException(BadMethodCallException::class); + + Cache::pool('test'); + } + + /** + * test that trying to configure classes that don't extend CacheEngine fail. + */ + public function testConfigInvalidObject(): void + { + $object = new stdClass(); + $this->expectException(BadMethodCallException::class); + + Cache::setConfig('test', [ + 'engine' => $object, + ]); + } + + /** + * Ensure you cannot reconfigure a cache adapter. + */ + public function testConfigErrorOnReconfigure(): void + { + Cache::setConfig('tests', ['engine' => 'File', 'path' => CACHE]); + + $this->expectException(BadMethodCallException::class); + + Cache::setConfig('tests', ['engine' => 'Apc']); + } + + /** + * Test reading configuration. + */ + public function testConfigRead(): void + { + $config = [ + 'engine' => 'File', + 'path' => CACHE, + 'prefix' => 'cake_', + ]; + Cache::setConfig('tests', $config); + $expected = $config; + $expected['className'] = $config['engine']; + unset($expected['engine']); + $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 + */ + public function testConfigDottedAlias(): void + { + Cache::setConfig('cache.dotted', [ + 'className' => 'File', + 'path' => CACHE, + 'prefix' => 'cache_value_', + ]); + + $engine = Cache::pool('cache.dotted'); + $this->assertContains('cache.dotted', Cache::configured()); + $this->assertNotContains('dotted', Cache::configured()); + $this->assertInstanceOf(FileEngine::class, $engine); + Cache::drop('cache.dotted'); + } + + /** + * testGroupConfigs method + */ + public function testGroupConfigs(): void + { + Cache::drop('test'); + Cache::setConfig('latest', [ + 'duration' => 300, + 'engine' => 'File', + 'groups' => ['posts', 'comments'], + ]); + + $result = Cache::groupConfigs(); + + $this->assertArrayHasKey('posts', $result); + $this->assertContains('latest', $result['posts']); + + $this->assertArrayHasKey('comments', $result); + $this->assertContains('latest', $result['comments']); + + $result = Cache::groupConfigs('posts'); + $this->assertEquals(['posts' => ['latest']], $result); + + Cache::setConfig('page', [ + 'duration' => 86400, + 'engine' => 'File', + 'groups' => ['posts', 'archive'], + ]); + + $result = Cache::groupConfigs(); + + $this->assertArrayHasKey('posts', $result); + $this->assertContains('latest', $result['posts']); + $this->assertContains('page', $result['posts']); + + $this->assertArrayHasKey('comments', $result); + $this->assertContains('latest', $result['comments']); + $this->assertNotContains('page', $result['comments']); + + $this->assertArrayHasKey('archive', $result); + $this->assertContains('page', $result['archive']); + $this->assertNotContains('latest', $result['archive']); + + $result = Cache::groupConfigs('archive'); + $this->assertEquals(['archive' => ['page']], $result); + + Cache::setConfig('archive', [ + 'duration' => 86400 * 30, + 'engine' => 'File', + 'groups' => ['posts', 'archive', 'comments'], + ]); + + $result = Cache::groupConfigs('archive'); + $this->assertEquals(['archive' => ['archive', 'page']], $result); + } + + /** + * testGroupConfigsWithCacheInstance method + */ + public function testGroupConfigsWithCacheInstance(): void + { + Cache::drop('test'); + $cache = new FileEngine(); + $cache->init([ + 'duration' => 300, + 'engine' => 'File', + 'groups' => ['users', 'comments'], + ]); + Cache::setConfig('cached', $cache); + + $result = Cache::groupConfigs('users'); + $this->assertEquals(['users' => ['cached']], $result); + } + + /** + * testGroupConfigsThrowsException method + */ + public function testGroupConfigsThrowsException(): void + { + $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 + */ + public function testConfigured(): void + { + Cache::drop('default'); + $result = Cache::configured(); + $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. + */ + public function testDrop(): void + { + static::setAppNamespace(); + + $result = Cache::drop('some_config_that_does_not_exist'); + $this->assertFalse($result, 'Drop should not succeed when config is missing.'); + + Cache::setConfig('unconfigTest', [ + 'engine' => 'TestAppCache', + ]); + $this->assertInstanceOf( + TestAppCacheEngine::class, + Cache::pool('unconfigTest'), + ); + $this->assertTrue(Cache::drop('unconfigTest')); + } + + /** + * testWriteEmptyValues method + */ + public function testWriteEmptyValues(): void + { + $this->_configCache(); + Cache::write('App.falseTest', false, 'tests'); + $this->assertFalse(Cache::read('App.falseTest', 'tests')); + + Cache::write('App.trueTest', true, 'tests'); + $this->assertTrue(Cache::read('App.trueTest', 'tests')); + + Cache::write('App.nullTest', null, 'tests'); + $this->assertNull(Cache::read('App.nullTest', 'tests')); + + Cache::write('App.zeroTest', 0, 'tests'); + $this->assertSame(Cache::read('App.zeroTest', 'tests'), 0); + + Cache::write('App.zeroTest2', '0', 'tests'); + $this->assertSame(Cache::read('App.zeroTest2', 'tests'), '0'); + } + + /** + * testWriteEmptyValues method + */ + public function testWriteEmptyKey(): void + { + $this->_configCache(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A cache key must be a non-empty string'); + + Cache::write('', 'not null', 'tests'); + } + + /** + * testReadWriteMany method + */ + public function testReadWriteMany(): void + { + $this->_configCache(); + $data = [ + 'App.falseTest' => false, + 'App.trueTest' => true, + 'App.nullTest' => null, + 'App.zeroTest' => 0, + 'App.zeroTest2' => '0', + ]; + Cache::writeMany($data, 'tests'); + + $read = Cache::readMany(array_keys($data), 'tests'); + + $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 + */ + public function testDeleteMany(): void + { + $this->_configCache(); + $data = [ + 'App.falseTest' => false, + 'App.trueTest' => true, + 'App.nullTest' => null, + 'App.zeroTest' => 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->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'); + } + + /** + * testDeleteMany partial failure + */ + 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 + { + static::setAppNamespace(); + Cache::setConfig('test_trigger', [ + 'engine' => 'TestAppCache', + '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'); + } + + /** + * testCacheDisable method + * + * Check that the "Cache.disable" configuration and a change to it + * (even after a cache config has been setup) is taken into account. + */ + public function testCacheDisable(): void + { + Cache::enable(); + Cache::setConfig('test_cache_disable_1', [ + 'engine' => 'File', + 'path' => CACHE . 'tests', + ]); + + $this->assertTrue(Cache::write('key_1', 'hello', 'test_cache_disable_1')); + $this->assertSame(Cache::read('key_1', 'test_cache_disable_1'), 'hello'); + + Cache::disable(); + + $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('test_cache_disable_1'); + + Cache::disable(); + Cache::setConfig('test_cache_disable_2', [ + 'engine' => 'File', + 'path' => CACHE . 'tests', + ]); + + $this->assertTrue(Cache::write('key_4', 'hello', 'test_cache_disable_2')); + $this->assertNull(Cache::read('key_4', 'test_cache_disable_2')); + + Cache::enable(); + + $this->assertTrue(Cache::write('key_5', 'hello', 'test_cache_disable_2')); + $this->assertSame(Cache::read('key_5', 'test_cache_disable_2'), 'hello'); + + Cache::disable(); + $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('test_cache_disable_2'); + } + + /** + * test clearAll() method + */ + public function testClearAll(): void + { + Cache::setConfig('configTest', [ + 'engine' => 'File', + 'path' => CACHE . 'tests', + ]); + Cache::setConfig('anotherConfigTest', [ + 'engine' => 'File', + 'path' => CACHE . 'tests', + ]); + + Cache::write('key_1', 'hello', 'configTest'); + Cache::write('key_2', 'hello again', 'anotherConfigTest'); + + $this->assertSame(Cache::read('key_1', 'configTest'), 'hello'); + $this->assertSame(Cache::read('key_2', 'anotherConfigTest'), 'hello again'); + + $result = Cache::clearAll(); + $this->assertTrue($result['configTest']); + $this->assertTrue($result['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. + */ + public function testEnableDisableEnabled(): void + { + Cache::enable(); + $this->assertTrue(Cache::enabled(), 'Should be on'); + Cache::disable(); + $this->assertFalse(Cache::enabled(), 'Should be off'); + } + + /** + * test remember method. + */ + public function testRemember(): void + { + $this->_configCache(); + $counter = 0; + $cacher = function () use ($counter) { + return 'This is some data ' . $counter; + }; + + $expected = 'This is some data 0'; + $result = Cache::remember('test_key', $cacher, 'tests'); + $this->assertSame($expected, $result); + + $result = Cache::remember('test_key', $cacher, 'tests'); + $this->assertSame($expected, $result); + } + + /** + * Test add method. + */ + public function testAdd(): void + { + $this->_configCache(); + Cache::delete('test_add_key', 'tests'); + + $result = Cache::add('test_add_key', 'test data', 'tests'); + $this->assertTrue($result); + + $expected = 'test data'; + $result = Cache::read('test_add_key', 'tests'); + $this->assertSame($expected, $result); + + $result = Cache::add('test_add_key', 'test data 2', 'tests'); + $this->assertFalse($result); + } + + /** + * Test getting the registry + */ + public function testGetRegistry(): void + { + $this->assertInstanceOf(CacheRegistry::class, Cache::getRegistry()); + } + + /** + * Test setting the registry + */ + public function testSetAndGetRegistry(): void + { + $registry = new CacheRegistry(); + Cache::setRegistry($registry); + + $this->assertSame($registry, Cache::getRegistry()); + } + + /** + * Test getting instances with pool + */ + public function testPool(): void + { + $this->_configCache(); + + $pool = Cache::pool('tests'); + $this->assertInstanceOf(SimpleCacheInterface::class, $pool); + } + + /** + * Test getting instances with pool + */ + public function testPoolCacheDisabled(): void + { + Cache::disable(); + $pool = Cache::pool('tests'); + $this->assertInstanceOf(SimpleCacheInterface::class, $pool); + } +} 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 new file mode 100644 index 00000000000..e9f2da17087 --- /dev/null +++ b/tests/TestCase/Cache/Engine/FileEngineTest.php @@ -0,0 +1,658 @@ +_configCache(); + Cache::clear('file_test'); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + Cache::drop('file_test'); + Cache::drop('file_groups'); + Cache::drop('file_groups2'); + Cache::drop('file_groups3'); + parent::tearDown(); + } + + /** + * Helper method for testing. + * + * @param array $config + */ + protected function _configCache($config = []): void + { + $defaults = [ + 'className' => 'File', + 'path' => TMP . 'tests', + ]; + $this->engine = 'file_test'; + Cache::drop('file_test'); + 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 + */ + public function testReadAndWriteCacheExpired(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'file_test'); + $this->assertNull($result); + } + + /** + * Test reading and writing to the cache. + */ + public function testReadAndWrite(): void + { + $result = Cache::read('test', 'file_test'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + Cache::write('test', $data, 'file_test'); + $this->assertFileExists(TMP . 'tests/cake_test'); + + $result = Cache::read('test', 'file_test'); + $expecting = $data; + $this->assertSame($expecting, $result); + + Cache::delete('test', 'file_test'); + } + + /** + * Test read/write on the same cache key. Ensures file handles are re-wound. + */ + public function testConsecutiveReadWrite(): void + { + Cache::write('rw', 'first write', 'file_test'); + $result = Cache::read('rw', 'file_test'); + + Cache::write('rw', 'second write', 'file_test'); + $resultB = Cache::read('rw', 'file_test'); + + Cache::delete('rw', 'file_test'); + $this->assertSame('first write', $result); + $this->assertSame('second write', $resultB); + } + + /** + * testExpiry method + */ + public function testExpiry(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'file_test'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'file_test'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'file_test'); + $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']); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'file_test'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'file_test'); + $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 + */ + public function testDeleteCache(): void + { + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('delete_test', $data, 'file_test'); + $this->assertTrue($result); + + $result = Cache::delete('delete_test', 'file_test'); + $this->assertTrue($result); + $this->assertFileDoesNotExist(TMP . 'tests/delete_test'); + + $result = Cache::delete('delete_test', 'file_test'); + $this->assertFalse($result); + } + + /** + * testSerialize method + */ + public function testSerialize(): void + { + $this->_configCache(['serialize' => true]); + $data = 'this is a test of the emergency broadcasting system'; + $write = Cache::write('serialize_test', $data, 'file_test'); + $this->assertTrue($write); + + $this->_configCache(['serialize' => false]); + $read = Cache::read('serialize_test', 'file_test'); + + Cache::delete('serialize_test', 'file_test'); + $this->assertSame($read, serialize($data)); + $this->assertSame(unserialize($read), $data); + } + + /** + * testClear method + */ + public function testClear(): void + { + $this->_configCache(['duration' => 0]); + + $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('file_test'); + $this->assertTrue($result); + $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. + */ + public function testClearWithPrefixes(): void + { + $FileOne = new FileEngine(); + $FileOne->init([ + 'prefix' => 'prefix_one_', + 'duration' => 3600, + ]); + $FileTwo = new FileEngine(); + $FileTwo->init([ + 'prefix' => 'prefix_two_', + 'duration' => 3600, + ]); + $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. + */ + public function testClearWithGroups(): void + { + $engine = new FileEngine(); + $engine->init([ + 'prefix' => 'cake_test_', + 'duration' => 3600, + 'groups' => ['short', 'round'], + ]); + $key = 'cake_test_test_key'; + $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. + */ + public function testClearWithNoKeys(): void + { + $engine = new FileEngine(); + $engine->init([ + 'prefix' => 'cake_test_', + 'duration' => 3600, + 'groups' => ['one', 'two'], + ]); + $key = 'cake_test_test_key'; + $engine->clear(); + $this->assertNull($engine->get($key), 'No errors should be found'); + } + + /** + * testKeyPath method + */ + public function testKeyPath(): void + { + $result = Cache::write('views.countries.something', 'here', 'file_test'); + $this->assertTrue($result); + $this->assertFileExists(TMP . 'tests/cake_views.countries.something'); + + $result = Cache::read('views.countries.something', 'file_test'); + $this->assertSame('here', $result); + + $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::read($key, 'file_test'); + $this->assertSame('here', $result); + + $result = Cache::clear('file_test'); + $this->assertTrue($result); + } + + /** + * testRemoveWindowsSlashesFromCache method + */ + public function testRemoveWindowsSlashesFromCache(): void + { + Cache::setConfig('windows_test', [ + 'engine' => 'File', + 'prefix' => null, + 'path' => CACHE, + ]); + + $expected = [ + 'C:\dev\prj2\sites\cake\libs' => [ + 0 => 'C:\dev\prj2\sites\cake\libs', 1 => 'C:\dev\prj2\sites\cake\libs\view', + 2 => 'C:\dev\prj2\sites\cake\libs\view\scaffolds', 3 => 'C:\dev\prj2\sites\cake\libs\view\pages', + 4 => 'C:\dev\prj2\sites\cake\libs\view\layouts', 5 => 'C:\dev\prj2\sites\cake\libs\view\layouts\xml', + 6 => 'C:\dev\prj2\sites\cake\libs\view\layouts\rss', 7 => 'C:\dev\prj2\sites\cake\libs\view\layouts\js', + 8 => 'C:\dev\prj2\sites\cake\libs\view\layouts\email', 9 => 'C:\dev\prj2\sites\cake\libs\view\layouts\email\text', + 10 => 'C:\dev\prj2\sites\cake\libs\view\layouts\email\html', 11 => 'C:\dev\prj2\sites\cake\libs\view\helpers', + 12 => 'C:\dev\prj2\sites\cake\libs\view\errors', 13 => 'C:\dev\prj2\sites\cake\libs\view\elements', + 14 => 'C:\dev\prj2\sites\cake\libs\view\elements\email', 15 => 'C:\dev\prj2\sites\cake\libs\view\elements\email\text', + 16 => 'C:\dev\prj2\sites\cake\libs\view\elements\email\html', 17 => 'C:\dev\prj2\sites\cake\libs\model', + 18 => 'C:\dev\prj2\sites\cake\libs\model\datasources', 19 => 'C:\dev\prj2\sites\cake\libs\model\datasources\dbo', + 20 => 'C:\dev\prj2\sites\cake\libs\model\behaviors', 21 => 'C:\dev\prj2\sites\cake\libs\controller', + 22 => 'C:\dev\prj2\sites\cake\libs\controller\components', 23 => 'C:\dev\prj2\sites\cake\libs\cache'], + 'C:\dev\prj2\sites\main_site\vendors' => [ + 0 => 'C:\dev\prj2\sites\main_site\vendors', 1 => 'C:\dev\prj2\sites\main_site\vendors\shells', + 2 => 'C:\dev\prj2\sites\main_site\vendors\shells\templates', 3 => 'C:\dev\prj2\sites\main_site\vendors\shells\templates\cdc_project', + 4 => 'C:\dev\prj2\sites\main_site\vendors\shells\tasks', 5 => 'C:\dev\prj2\sites\main_site\vendors\js', + 6 => 'C:\dev\prj2\sites\main_site\vendors\css'], + 'C:\dev\prj2\sites\vendors' => [ + 0 => 'C:\dev\prj2\sites\vendors', 1 => 'C:\dev\prj2\sites\vendors\simpletest', + 2 => 'C:\dev\prj2\sites\vendors\simpletest\test', 3 => 'C:\dev\prj2\sites\vendors\simpletest\test\support', + 4 => 'C:\dev\prj2\sites\vendors\simpletest\test\support\collector', 5 => 'C:\dev\prj2\sites\vendors\simpletest\extensions', + 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'], + ]; + + Cache::write('test_dir_map', $expected, 'windows_test'); + $data = Cache::read('test_dir_map', 'windows_test'); + Cache::delete('test_dir_map', 'windows_test'); + $this->assertEquals($expected, $data); + + Cache::drop('windows_test'); + } + + /** + * testWriteQuotedString method + */ + 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"'); + Cache::write('App.singleQuoteTest', "'this is a quoted string'", 'file_test'); + $this->assertSame(Cache::read('App.singleQuoteTest', 'file_test'), "'this is a quoted string'"); + + Cache::drop('file_test'); + Cache::setConfig('file_test', [ + 'className' => 'File', + 'isWindows' => true, + 'path' => TMP . 'tests', + ]); + + $this->assertSame(Cache::read('App.doubleQuoteTest', 'file_test'), '"this is a quoted string"'); + Cache::write('App.singleQuoteTest', "'this is a quoted string'", 'file_test'); + $this->assertSame(Cache::read('App.singleQuoteTest', 'file_test'), "'this is a quoted string'"); + Cache::delete('App.singleQuoteTest', 'file_test'); + Cache::delete('App.doubleQuoteTest', 'file_test'); + } + + /** + * check that FileEngine does not generate an error when a configured Path does not exist in debug mode. + */ + public function testPathDoesNotExist(): void + { + Configure::write('debug', true); + $dir = TMP . 'tests/autocreate-' . microtime(true); + + Cache::drop('file_test'); + Cache::setConfig('file_test', [ + 'engine' => 'File', + 'path' => $dir, + ]); + + Cache::read('Test', 'file_test'); + $this->assertFileExists($dir, 'Dir should exist.'); + + // Cleanup + rmdir($dir); + } + + /** + * Test that under debug 0 directories do get made. + */ + public function testPathDoesNotExistDebugOff(): void + { + Configure::write('debug', false); + $dir = TMP . 'tests/autocreate-' . microtime(true); + + Cache::drop('file_test'); + Cache::setConfig('file_test', [ + 'engine' => 'File', + 'path' => $dir, + ]); + + Cache::read('Test', 'file_test'); + $this->assertFileExists($dir, 'Dir should exist.'); + + // Cleanup + rmdir($dir); + } + + /** + * Testing the mask setting in FileEngine + */ + public function testMaskSetting(): void + { + if (DS === '\\') { + $this->markTestSkipped('File permission testing does not work on Windows.'); + } + Cache::setConfig('mask_test', ['engine' => 'File', 'path' => TMP . 'tests']); + $data = 'This is some test content'; + Cache::write('masking_test', $data, 'mask_test'); + $result = substr(sprintf('%o', fileperms(TMP . 'tests/cake_masking_test')), -4); + $expected = '0664'; + $this->assertSame($expected, $result); + Cache::delete('masking_test', 'mask_test'); + Cache::drop('mask_test'); + + 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->assertSame($expected, $result); + Cache::delete('masking_test', 'mask_test'); + Cache::drop('mask_test'); + + 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->assertSame($expected, $result); + Cache::delete('masking_test', 'mask_test'); + Cache::drop('mask_test'); + + 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->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 + */ + public function testGroupsReadWrite(): void + { + Cache::setConfig('file_groups', [ + 'engine' => 'File', + 'duration' => 3600, + 'groups' => ['group_a', 'group_b'], + ]); + $this->assertTrue(Cache::write('test_groups', 'value', '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')); + } + + /** + * Test that clearing with repeat writes works properly + */ + public function testClearingWithRepeatWrites(): void + { + Cache::setConfig('repeat', [ + 'engine' => 'File', + 'groups' => ['users'], + ]); + + $this->assertTrue(Cache::write('user', 'rchavik', 'repeat')); + $this->assertSame('rchavik', Cache::read('user', 'repeat')); + + Cache::delete('user', 'repeat'); + $this->assertNull(Cache::read('user', 'repeat')); + + $this->assertTrue(Cache::write('user', 'ADmad', 'repeat')); + $this->assertSame('ADmad', Cache::read('user', 'repeat')); + + Cache::clearGroup('users', 'repeat'); + $this->assertNull(Cache::read('user', 'repeat')); + + $this->assertTrue(Cache::write('user', 'markstory', 'repeat')); + $this->assertSame('markstory', Cache::read('user', 'repeat')); + + Cache::drop('repeat'); + } + + /** + * Tests that deleting from a groups-enabled config is possible + */ + public function testGroupDelete(): void + { + Cache::setConfig('file_groups', [ + 'engine' => 'File', + 'duration' => 3600, + 'groups' => ['group_a', 'group_b'], + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'file_groups')); + $this->assertSame('value', Cache::read('test_groups', 'file_groups')); + $this->assertTrue(Cache::delete('test_groups', 'file_groups')); + + $this->assertNull(Cache::read('test_groups', 'file_groups')); + } + + /** + * Test clearing a cache group + */ + public function testGroupClear(): void + { + Cache::setConfig('file_groups', [ + 'engine' => 'File', + 'duration' => 3600, + 'groups' => ['group_a', 'group_b'], + ]); + Cache::setConfig('file_groups2', [ + 'engine' => 'File', + 'duration' => 3600, + 'groups' => ['group_b'], + ]); + Cache::setConfig('file_groups3', [ + 'engine' => 'File', + 'duration' => 3600, + 'groups' => ['group_b'], + 'prefix' => 'leading_', + ]); + + $this->assertTrue(Cache::write('test_groups', 'value', 'file_groups')); + $this->assertTrue(Cache::write('test_groups2', 'value 2', 'file_groups2')); + $this->assertTrue(Cache::write('test_groups3', 'value 3', 'file_groups3')); + + $this->assertTrue(Cache::clearGroup('group_b', 'file_groups')); + $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->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. + */ + public function testGroupClearNoPrefix(): void + { + Cache::setConfig('file_groups', [ + 'className' => 'File', + 'duration' => 3600, + 'prefix' => '', + '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->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. + */ + public function testAdd(): void + { + Cache::delete('test_add_key', 'file_test'); + + $result = Cache::add('test_add_key', 'test data', 'file_test'); + $this->assertTrue($result); + + $expected = 'test data'; + $result = Cache::read('test_add_key', 'file_test'); + $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 new file mode 100644 index 00000000000..dd6995db3e5 --- /dev/null +++ b/tests/TestCase/Cache/Engine/MemcachedEngineTest.php @@ -0,0 +1,957 @@ +skipIf(!class_exists('Memcached'), 'Memcached is not installed or configured properly.'); + + $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); + + $this->_configCache(); + } + + /** + * Helper method for testing. + * + * @param array $config + */ + protected function _configCache($config = []): void + { + $defaults = [ + 'className' => 'Memcached', + 'prefix' => 'cake_', + 'duration' => 3600, + 'servers' => ['127.0.0.1:' . $this->port], + ]; + $this->engine = 'memcached'; + Cache::drop('memcached'); + Cache::setConfig('memcached', array_merge($defaults, $config)); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + Cache::drop('memcached'); + Cache::drop('memcached2'); + Cache::drop('memcached_groups'); + Cache::drop('memcached_helper'); + Cache::drop('compressed_memcached'); + Cache::drop('long_memcached'); + Cache::drop('short_memcached'); + } + + /** + * testConfig method + */ + public function testConfig(): void + { + $config = Cache::pool('memcached')->getConfig(); + unset($config['path']); + $expecting = [ + 'prefix' => 'cake_', + 'duration' => 3600, + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'compress' => false, + 'username' => null, + 'password' => null, + 'groups' => [], + 'serialize' => 'php', + 'options' => [], + 'host' => null, + 'port' => null, + ]; + $this->assertEquals($expecting, $config); + } + + /** + * testCompressionSetting method + */ + public function testCompressionSetting(): void + { + $Memcached = new MemcachedEngine(); + $Memcached->init([ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'compress' => false, + ]); + + $this->assertFalse($Memcached->getOption(Memcached::OPT_COMPRESSION)); + + $MemcachedCompressed = new MemcachedEngine(); + $MemcachedCompressed->init([ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'compress' => true, + ]); + + $this->assertTrue($MemcachedCompressed->getOption(Memcached::OPT_COMPRESSION)); + } + + /** + * test setting options + */ + public function testOptionsSetting(): void + { + $memcached = new MemcachedEngine(); + $memcached->init([ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'options' => [ + Memcached::OPT_BINARY_PROTOCOL => true, + ], + ]); + $this->assertSame(1, $memcached->getOption(Memcached::OPT_BINARY_PROTOCOL)); + } + + /** + * test accepts only valid serializer engine + */ + public function testInvalidSerializerSetting(): void + { + $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:' . $this->port], + 'persistent' => false, + 'serialize' => 'invalid_serializer', + ]; + $Memcached->init($config); + } + + /** + * testPhpSerializerSetting method + */ + public function testPhpSerializerSetting(): void + { + $Memcached = new MemcachedEngine(); + $config = [ + 'className' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'serialize' => 'php', + ]; + + $Memcached->init($config); + $this->assertSame(Memcached::SERIALIZER_PHP, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + } + + /** + * testJsonSerializerSetting method + */ + public function testJsonSerializerSetting(): void + { + $this->skipIf( + !Memcached::HAVE_JSON, + 'Memcached extension is not compiled with json support', + ); + + $Memcached = new MemcachedEngine(); + $config = [ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'serialize' => 'json', + ]; + + $Memcached->init($config); + $this->assertSame(Memcached::SERIALIZER_JSON, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + } + + /** + * testIgbinarySerializerSetting method + */ + public function testIgbinarySerializerSetting(): void + { + $this->skipIf( + !Memcached::HAVE_IGBINARY, + 'Memcached extension is not compiled with igbinary support', + ); + + $Memcached = new MemcachedEngine(); + $config = [ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'serialize' => 'igbinary', + ]; + + $Memcached->init($config); + $this->assertSame(Memcached::SERIALIZER_IGBINARY, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + } + + /** + * testMsgpackSerializerSetting method + */ + public function testMsgpackSerializerSetting(): void + { + $this->skipIf( + !defined('Memcached::HAVE_MSGPACK') || !Memcached::HAVE_MSGPACK, + 'Memcached extension is not compiled with msgpack support', + ); + + $Memcached = new MemcachedEngine(); + $config = [ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'serialize' => 'msgpack', + ]; + + $Memcached->init($config); + $this->assertSame(Memcached::SERIALIZER_MSGPACK, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + } + + /** + * testJsonSerializerThrowException method + */ + public function testJsonSerializerThrowException(): void + { + $this->skipIf( + (bool)Memcached::HAVE_JSON, + 'Memcached extension is compiled with json support', + ); + + $Memcached = new MemcachedEngine(); + $config = [ + 'className' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'serialize' => 'json', + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Memcached extension is not compiled with json support'); + $Memcached->init($config); + } + + /** + * testMsgpackSerializerThrowException method + */ + public function testMsgpackSerializerThrowException(): void + { + $this->skipIf( + !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:' . $this->port], + 'persistent' => false, + 'serialize' => 'msgpack', + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Memcached extension is not compiled with msgpack support'); + $Memcached->init($config); + } + + /** + * testIgbinarySerializerThrowException method + */ + public function testIgbinarySerializerThrowException(): void + { + $this->skipIf( + (bool)Memcached::HAVE_IGBINARY, + 'Memcached extension is compiled with igbinary support', + ); + + $Memcached = new MemcachedEngine(); + $config = [ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'serialize' => 'igbinary', + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Memcached extension is not compiled with igbinary support'); + $Memcached->init($config); + } + + /** + * test using authentication without memcached installed with SASL support + * throw an exception + */ + public function testSaslAuthException(): void + { + $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.', + ); + $MemcachedEngine = new MemcachedEngine(); + $config = [ + 'engine' => 'Memcached', + 'servers' => ['127.0.0.1:' . $this->port], + 'persistent' => false, + 'username' => 'test', + 'password' => 'password', + ]; + $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 + */ + public function testMultipleServers(): void + { + $servers = ['127.0.0.1:' . $this->port, '127.0.0.1:11222']; + $available = true; + $Memcached = new Memcached(); + + foreach ($servers as $server) { + [$host, $port] = explode(':', $server); + // phpcs:disable + if (!$Memcached->addServer($host, (int)$port)) { + $available = false; + } + // phpcs:enable + } + + $this->skipIf(!$available, 'Need memcached servers at ' . implode(', ', $servers) . ' to run this test.'); + + $Memcached = new MemcachedEngine(); + $Memcached->init(['engine' => 'Memcached', 'servers' => $servers]); + + $config = $Memcached->getConfig(); + $this->assertEquals($config['servers'], $servers); + Cache::drop('dual_server'); + } + + /** + * test connecting to an ipv6 server. + */ + public function testConnectIpv6(): void + { + $Memcached = new MemcachedEngine(); + $result = $Memcached->init([ + 'prefix' => 'cake_', + 'duration' => 200, + 'engine' => 'Memcached', + 'servers' => [ + '[::1]:' . $this->port, + ], + ]); + $this->assertTrue($result); + } + + /** + * test domain starts with u + */ + public function testParseServerStringWithU(): void + { + $Memcached = new MemcachedEngine(); + $result = $Memcached->parseServerString('udomain.net:13211'); + $this->assertEquals(['udomain.net', '13211'], $result); + } + + /** + * test non latin domains. + */ + public function testParseServerStringNonLatin(): void + { + $Memcached = new MemcachedEngine(); + $result = $Memcached->parseServerString('schülervz.net:13211'); + $this->assertEquals(['schülervz.net', '13211'], $result); + + $result = $Memcached->parseServerString('sülül:1111'); + $this->assertEquals(['sülül', '1111'], $result); + } + + /** + * test unix sockets. + */ + public function testParseServerStringUnix(): void + { + $Memcached = new MemcachedEngine(); + $result = $Memcached->parseServerString('unix:///path/to/memcachedd.sock'); + $this->assertEquals(['/path/to/memcachedd.sock', 0], $result); + } + + /** + * testReadAndWriteCache method + */ + public function testReadAndWriteCache(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'memcached'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('test', $data, 'memcached'); + $this->assertTrue($result); + + $result = Cache::read('test', 'memcached'); + $expecting = $data; + $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 + */ + public function testReadMany(): 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'); + } + + $read = Cache::readMany(array_merge(array_keys($data), ['App.doesNotExist']), 'memcached'); + + $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->assertNull($read['App.doesNotExist']); + } + + /** + * Test readMany where null is a valid cache value + * + * @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(): void + { + $this->_configCache(['duration' => 2]); + $data = [ + 'App.falseTest' => false, + 'App.trueTest' => true, + 'App.nullTest' => null, + 'App.zeroTest' => 0, + 'App.zeroTest2' => '0', + ]; + Cache::writeMany($data, 'memcached'); + + $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 + */ + public function testExpiry(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'memcached'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'memcached'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'memcached'); + $this->assertNull($result); + + $this->_configCache(['duration' => '+1 second']); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'memcached'); + $this->assertTrue($result); + + sleep(3); + $result = Cache::read('other_test', 'memcached'); + $this->assertNull($result); + + $result = Cache::read('other_test', 'memcached'); + $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, 'memcached'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('long_expiry_test', 'memcached'); + $expecting = $data; + $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 + */ + public function testDeleteCache(): void + { + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('delete_test', $data, 'memcached'); + $this->assertTrue($result); + + $result = Cache::delete('delete_test', 'memcached'); + $this->assertTrue($result); + } + + /** + * testDeleteMany method + */ + public function testDeleteMany(): void + { + $this->_configCache(); + $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'); + } + Cache::write('App.keepTest', 'keepMe', 'memcached'); + + Cache::deleteMany(array_merge(array_keys($data), ['App.doesNotExist']), 'memcached'); + + $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 + */ + public function testDecrement(): void + { + $result = Cache::write('test_decrement', 5, 'memcached'); + $this->assertTrue($result); + + $result = Cache::decrement('test_decrement', 1, 'memcached'); + $this->assertSame(4, $result); + + $result = Cache::read('test_decrement', 'memcached'); + $this->assertSame(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'memcached'); + $this->assertSame(2, $result); + + $result = Cache::read('test_decrement', 'memcached'); + $this->assertSame(2, $result); + + Cache::delete('test_decrement', 'memcached'); + } + + /** + * test decrementing compressed keys + */ + public function testDecrementCompressedKeys(): void + { + Cache::setConfig('compressed_memcached', [ + 'engine' => 'Memcached', + 'duration' => '+2 seconds', + '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->assertSame(4, $result); + + $result = Cache::read('test_decrement', 'compressed_memcached'); + $this->assertSame(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'compressed_memcached'); + $this->assertSame(2, $result); + + $result = Cache::read('test_decrement', 'compressed_memcached'); + $this->assertSame(2, $result); + + Cache::delete('test_decrement', 'compressed_memcached'); + } + + /** + * testIncrement method + */ + public function testIncrement(): void + { + $result = Cache::write('test_increment', 5, 'memcached'); + $this->assertTrue($result); + + $result = Cache::increment('test_increment', 1, 'memcached'); + $this->assertSame(6, $result); + + $result = Cache::read('test_increment', 'memcached'); + $this->assertSame(6, $result); + + $result = Cache::increment('test_increment', 2, 'memcached'); + $this->assertSame(8, $result); + + $result = Cache::read('test_increment', 'memcached'); + $this->assertSame(8, $result); + + Cache::delete('test_increment', 'memcached'); + } + + /** + * Test that increment and decrement set ttls. + */ + public function testIncrementDecrementExpiring(): void + { + $this->_configCache(['duration' => 1]); + Cache::write('test_increment', 1, 'memcached'); + Cache::write('test_decrement', 1, 'memcached'); + + $this->assertSame(2, Cache::increment('test_increment', 1, 'memcached')); + $this->assertSame(0, Cache::decrement('test_decrement', 1, 'memcached')); + + sleep(2); + + $this->assertNull(Cache::read('test_increment', 'memcached')); + $this->assertNull(Cache::read('test_decrement', 'memcached')); + } + + /** + * test incrementing compressed keys + */ + public function testIncrementCompressedKeys(): void + { + Cache::setConfig('compressed_memcached', [ + 'engine' => 'Memcached', + 'duration' => '+2 seconds', + '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->assertSame(6, $result); + + $result = Cache::read('test_increment', 'compressed_memcached'); + $this->assertSame(6, $result); + + $result = Cache::increment('test_increment', 2, 'compressed_memcached'); + $this->assertSame(8, $result); + + $result = Cache::read('test_increment', 'compressed_memcached'); + $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. + */ + public function testConfigurationConflict(): void + { + Cache::setConfig('long_memcached', [ + 'engine' => 'Memcached', + 'duration' => '+3 seconds', + 'servers' => ['127.0.0.1:' . $this->port], + ]); + Cache::setConfig('short_memcached', [ + 'engine' => 'Memcached', + 'duration' => '+2 seconds', + '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->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->assertSame('yay', Cache::read('duration_test', 'long_memcached'), 'Value was not read %s'); + + usleep(3000000); + $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'); + } + + /** + * test clearing memcached. + */ + public function testClear(): void + { + Cache::setConfig('memcached2', [ + 'engine' => 'Memcached', + 'prefix' => 'cake2_', + 'duration' => 3600, + 'servers' => ['127.0.0.1:' . $this->port], + ]); + + Cache::write('some_value', 'cache1', 'memcached'); + Cache::write('some_value', 'cache2', '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('memcached2'); + } + + /** + * test that a 0 duration can successfully write. + */ + 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->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 + */ + public function testGroupReadWrite(): void + { + Cache::setConfig('memcached_groups', [ + 'engine' => 'Memcached', + 'duration' => 3600, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'servers' => ['127.0.0.1:' . $this->port], + ]); + Cache::setConfig('memcached_helper', [ + 'engine' => 'Memcached', + 'duration' => 3600, + 'prefix' => 'test_', + 'servers' => ['127.0.0.1:' . $this->port], + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); + $this->assertSame('value', Cache::read('test_groups', 'memcached_groups')); + + Cache::increment('group_a', 1, 'memcached_helper'); + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); + $this->assertTrue(Cache::write('test_groups', 'value2', 'memcached_groups')); + $this->assertSame('value2', Cache::read('test_groups', 'memcached_groups')); + + Cache::increment('group_b', 1, 'memcached_helper'); + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); + $this->assertTrue(Cache::write('test_groups', 'value3', 'memcached_groups')); + $this->assertSame('value3', Cache::read('test_groups', 'memcached_groups')); + } + + /** + * Tests that deleting from a groups-enabled config is possible + */ + public function testGroupDelete(): void + { + Cache::setConfig('memcached_groups', [ + 'engine' => 'Memcached', + 'duration' => 3600, + 'groups' => ['group_a', 'group_b'], + 'servers' => ['127.0.0.1:' . $this->port], + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); + $this->assertSame('value', Cache::read('test_groups', 'memcached_groups')); + $this->assertTrue(Cache::delete('test_groups', 'memcached_groups')); + + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); + } + + /** + * Test clearing a cache group + */ + public function testGroupClear(): void + { + Cache::setConfig('memcached_groups', [ + 'engine' => 'Memcached', + 'duration' => 3600, + '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->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->assertNull(Cache::read('test_groups', 'memcached_groups')); + } + + /** + * Test add + */ + public function testAdd(): void + { + Cache::delete('test_add_key', 'memcached'); + + $result = Cache::add('test_add_key', 'test data', 'memcached'); + $this->assertTrue($result); + + $expected = 'test data'; + $result = Cache::read('test_add_key', 'memcached'); + $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..3bc67c58771 --- /dev/null +++ b/tests/TestCase/Cache/Engine/NullEngineTest.php @@ -0,0 +1,69 @@ + NullEngine::class, + ]); + } + + 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..2b557dc7eea --- /dev/null +++ b/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php @@ -0,0 +1,599 @@ +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, + ]; + $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 new file mode 100644 index 00000000000..86a62f06cd0 --- /dev/null +++ b/tests/TestCase/Cache/Engine/RedisEngineTest.php @@ -0,0 +1,963 @@ +skipIf(!class_exists('Redis'), 'Redis extension is not installed or configured properly.'); + + $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(); + } + + /** + * tearDown method + */ + 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'); + } + + /** + * Helper method for testing. + * + * @param array $config + */ + protected function _configCache($config = []): void + { + $defaults = [ + 'className' => 'Redis', + 'prefix' => 'cake_', + 'duration' => 3600, + 'port' => $this->port, + ]; + $this->engine = 'redis'; + Cache::drop('redis'); + Cache::setConfig('redis', array_merge($defaults, $config)); + } + + /** + * testConfig method + */ + public function testConfig(): void + { + $config = Cache::pool('redis')->getConfig(); + $expecting = [ + 'prefix' => 'cake_', + 'duration' => 3600, + 'groups' => [], + 'server' => '127.0.0.1', + '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, + ]; + $this->assertEquals($expecting, $config); + } + + /** + * testConfigDsn method + */ + public function testConfigDsn(): void + { + Cache::setConfig('redis_dsn', [ + 'url' => 'redis://localhost:' . $this->port . '?database=1&prefix=redis_', + ]); + + $config = Cache::pool('redis_dsn')->getConfig(); + $expecting = [ + 'prefix' => 'redis_', + 'duration' => 3600, + 'groups' => [], + 'server' => 'localhost', + 'port' => $this->port, + 'tls' => false, + 'timeout' => 0, + 'persistent' => true, + 'password' => false, + 'database' => '1', + 'unix_socket' => false, + 'host' => 'localhost', + 'scheme' => 'redis', + 'scanCount' => 10, + 'readTimeout' => 0, + 'clusterName' => null, + 'nodes' => [], + 'clearUsesFlushDb' => false, + 'failover' => null, + ]; + $this->assertEquals($expecting, $config); + } + + /** + * 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, + ]; + $this->assertEquals($expecting, $config); + } + + /** + * testConnect method + */ + public function testConnect(): void + { + $Redis = new RedisEngine(); + $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 + */ + public function testMultiDatabaseOperations(): void + { + Cache::setConfig('redisdb0', [ + 'engine' => 'Redis', + 'prefix' => 'cake2_', + 'duration' => 3600, + 'persistent' => false, + 'port' => $this->port, + ]); + + Cache::setConfig('redisdb1', [ + 'engine' => 'Redis', + 'database' => 1, + 'prefix' => 'cake2_', + 'duration' => 3600, + 'persistent' => false, + 'port' => $this->port, + ]); + + $result = Cache::write('save_in_0', true, 'redisdb0'); + $exist = Cache::read('save_in_0', 'redisdb0'); + $this->assertTrue($result); + $this->assertTrue($exist); + + $result = Cache::write('save_in_1', true, 'redisdb1'); + $this->assertTrue($result); + $exist = Cache::read('save_in_0', 'redisdb1'); + $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->assertNull($exist); + + Cache::delete('save_in_1', 'redisdb1'); + $exist = Cache::read('save_in_1', 'redisdb1'); + $this->assertNull($exist); + + Cache::drop('redisdb0'); + Cache::drop('redisdb1'); + } + + /** + * test write numbers method + */ + 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 + */ + public function testReadAndWriteCache(): 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('test', $data, 'redis'); + $this->assertTrue($result); + + $result = Cache::read('test', 'redis'); + $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'); + } + + /** + * 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 + */ + 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); + } + + /** + * 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 + */ + 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); + } + + /** + * 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 + */ + 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->assertSame(4, $result); + + $result = Cache::read('test_decrement', 'redis'); + $this->assertSame(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'redis'); + $this->assertSame(2, $result); + + $result = Cache::read('test_decrement', 'redis'); + $this->assertSame(2, $result); + } + + /** + * testIncrement method + */ + public function testIncrement(): void + { + Cache::delete('test_increment', 'redis'); + $result = Cache::increment('test_increment', 1, 'redis'); + $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->assertSame(1, $result); + + $result = Cache::increment('test_increment', 2, 'redis'); + $this->assertSame(3, $result); + + $result = Cache::read('test_increment', 'redis'); + $this->assertSame(3, $result); + } + + /** + * Test that increment() and decrement() can live forever. + */ + 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->assertSame(1, $result); + + $result = Cache::decrement('test_decrement', 1, 'redis'); + $this->assertSame(-1, $result); + + $this->assertSame(1, Cache::read('test_increment', 'redis')); + $this->assertSame(-1, Cache::read('test_decrement', 'redis')); + } + + /** + * Test that increment and decrement set ttls. + */ + 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. + */ + public function testClear(): void + { + Cache::setConfig('redis2', [ + 'engine' => 'Redis', + 'prefix' => 'cake2_', + 'duration' => 3600, + 'port' => $this->port, + ]); + + 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'); + } + + /** + * 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. + */ + 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->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 + */ + public function testGroupReadWrite(): void + { + Cache::setConfig('redis_groups', [ + 'engine' => 'Redis', + 'duration' => 3600, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'port' => $this->port, + ]); + Cache::setConfig('redis_helper', [ + 'engine' => 'Redis', + 'duration' => 3600, + 'prefix' => 'test_', + 'port' => $this->port, + ]); + $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 + */ + public function testGroupDelete(): void + { + Cache::setConfig('redis_groups', [ + 'engine' => 'Redis', + 'duration' => 3600, + 'groups' => ['group_a', 'group_b'], + 'port' => $this->port, + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'redis_groups')); + $this->assertSame('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 + */ + public function testGroupClear(): void + { + Cache::setConfig('redis_groups', [ + 'engine' => 'Redis', + 'duration' => 3600, + '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->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 + */ + 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->assertSame($expected, $result); + + $result = Cache::add('test_add_key', 'test data 2', 'redis'); + $this->assertFalse($result); + } +} diff --git a/tests/TestCase/Collection/CollectionTest.php b/tests/TestCase/Collection/CollectionTest.php new file mode 100644 index 00000000000..9ed8e5ccfd5 --- /dev/null +++ b/tests/TestCase/Collection/CollectionTest.php @@ -0,0 +1,2825 @@ +assertEquals($items, iterator_to_array($collection)); + } + + /** + * Provider for average tests + * + * @return array + */ + public static function avgProvider(): array + { + $items = [1, 2, 3]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests the avg method + */ + #[DataProvider('avgProvider')] + public function testAvg(iterable $items): void + { + $collection = new Collection($items); + $this->assertSame(2, $collection->avg()); + + $items = [['foo' => 1], ['foo' => 2], ['foo' => 3]]; + $collection = new Collection($items); + $this->assertSame(2, $collection->avg('foo')); + } + + /** + * Tests the avg method when on an empty collection + */ + public function testAvgWithEmptyCollection(): void + { + $collection = new Collection([]); + $this->assertNull($collection->avg()); + + $collection = new Collection([null, null]); + $this->assertSame(0, $collection->avg()); + } + + /** + * Provider for average tests with use of a matcher + * + * @return array + */ + public static function avgWithMatcherProvider(): array + { + $items = [['foo' => 1], ['foo' => 2], ['foo' => 3]]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * ests the avg method + */ + #[DataProvider('avgWithMatcherProvider')] + public function testAvgWithMatcher(iterable $items): void + { + $collection = new Collection($items); + $this->assertSame(2, $collection->avg('foo')); + } + + /** + * Provider for some median tests + * + * @return array + */ + public static function medianProvider(): array + { + $items = [5, 2, 4]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests the median method + */ + #[DataProvider('medianProvider')] + public function testMedian(iterable $items): void + { + $collection = new Collection($items); + $this->assertSame(4, $collection->median()); + } + + /** + * Tests the median method when on an empty collection + */ + 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')] + public function testMedianEven(iterable $items): void + { + $collection = new Collection($items); + $this->assertSame(2.5, $collection->median()); + } + + /** + * Provider for median tests with use of a matcher + * + * @return array + */ + public static function medianWithMatcherProvider(): array + { + $items = [ + ['invoice' => ['total' => 400]], + ['invoice' => ['total' => 500]], + ['invoice' => ['total' => 200]], + ['invoice' => ['total' => 100]], + ['invoice' => ['total' => 333]], + ]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests the median method + */ + #[DataProvider('medianWithMatcherProvider')] + public function testMedianWithMatcher(iterable $items): void + { + $this->assertSame(333, (new Collection($items))->median('invoice.total')); + } + + /** + * Tests that it is possible to convert an iterator into a collection + */ + public function testIteratorIsWrapped(): void + { + $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 + */ + public function testEach(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + + $results = []; + $collection->each(function ($value, $key) use (&$results): void { + $results[] = [$key => $value]; + }); + $this->assertSame([['a' => 1], ['b' => 2], ['c' => 3]], $results); + } + + public static function filterProvider(): array + { + $items = [1, 2, 0, 3, false, 4, null, 5, '']; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Test filter() with no callback. + */ + #[DataProvider('filterProvider')] + public function testFilterNoCallback(iterable $items): void + { + $collection = new Collection($items); + $result = $collection->filter()->toArray(); + $expected = [1, 2, 3, 4, 5]; + $this->assertSame($expected, array_values($result)); + } + + /** + * Tests that it is possible to chain filter() as it returns a collection object + */ + public function testFilterChaining(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + + $filtered = $collection->filter(function ($value, $key, $iterator) { + return $value > 2; + }); + $this->assertInstanceOf(Collection::class, $filtered); + + $results = []; + $filtered->each(function ($value, $key) use (&$results): void { + $results[] = [$key => $value]; + }); + $this->assertSame([['c' => 3]], $results); + } + + /** + * Tests reject + */ + 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); + $result = $collection->reject(function ($v, $k, $items) use ($collection) { + $this->assertSame($collection->getInnerIterator(), $items); + + return $v > 2; + }); + $this->assertEquals(['a' => 1, 'b' => 2], iterator_to_array($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 + */ + public function testEveryReturnTrue(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + + $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 + */ + public function testEveryReturnFalse(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($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); + + $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 + */ + public function testSomeReturnTrue(): void + { + $collection = new Collection([]); + $result = $collection->some(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 some() when none of the calls return true + */ + public function testSomeReturnFalse(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + + $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 + */ + public function testContains(): void + { + $collection = new Collection([]); + $this->assertFalse($collection->contains('a')); + + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + $this->assertTrue($collection->contains(2)); + $this->assertTrue($collection->contains(1)); + $this->assertFalse($collection->contains(10)); + $this->assertFalse($collection->contains('2')); + } + + /** + * Provider for some simple tests + * + * @return array + */ + public static function simpleProvider(): array + { + $items = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests map + */ + #[DataProvider('simpleProvider')] + public function testMap(iterable $items): void + { + $collection = new Collection($items); + $map = $collection->map(function ($v, $k, $it) use ($collection) { + $this->assertSame($collection->getInnerIterator(), $it); + + return $v * $v; + }); + $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')] + public function testReduceWithInitialValue(iterable $items): void + { + $collection = new Collection($items); + $this->assertSame(20, $collection->reduce(function ($reduction, $value, $key) { + return $value + $reduction; + }, 10)); + } + + /** + * Tests reduce without initial value + */ + #[DataProvider('simpleProvider')] + public function testReduceWithoutInitialValue(iterable $items): void + { + $collection = new Collection($items); + $this->assertSame(10, $collection->reduce(function ($reduction, $value, $key) { + return $value + $reduction; + })); + } + + /** + * Provider for some extract tests + * + * @return array + */ + public static function extractProvider(): array + { + $items = [['a' => ['b' => ['c' => 1]]], 2]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests extract + */ + #[DataProvider('extractProvider')] + public function testExtract(iterable $items): void + { + $collection = new Collection($items); + $map = $collection->extract('a.b.c'); + $this->assertInstanceOf(ExtractIterator::class, $map); + $this->assertEquals([1, null], iterator_to_array($map)); + } + + /** + * Provider for some sort tests + * + * @return array + */ + public static function sortProvider(): array + { + $items = [ + ['a' => ['b' => ['c' => 4]]], + ['a' => ['b' => ['c' => 10]]], + ['a' => ['b' => ['c' => 6]]], + ]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests sort + */ + #[DataProvider('sortProvider')] + public function testSortString(iterable $items): void + { + $collection = new Collection($items); + $map = $collection->sortBy('a.b.c'); + $this->assertInstanceOf(Collection::class, $map); + $expected = [ + ['a' => ['b' => ['c' => 10]]], + ['a' => ['b' => ['c' => 6]]], + ['a' => ['b' => ['c' => 4]]], + ]; + $this->assertEquals($expected, $map->toList()); + } + + /** + * Tests max + */ + #[DataProvider('sortProvider')] + public function testMax(iterable $items): void + { + $collection = new Collection($items); + $this->assertEquals(['a' => ['b' => ['c' => 10]]], $collection->max('a.b.c')); + } + + /** + * Tests max + */ + #[DataProvider('sortProvider')] + public function testMaxCallback(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)); + } + + /** + * Tests max + */ + #[DataProvider('sortProvider')] + public function testMaxCallable(iterable $items): void + { + $collection = new Collection($items); + $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')] + 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 static function groupByProvider(): array + { + $items = [ + ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + ]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests groupBy + */ + #[DataProvider('groupByProvider')] + public function testGroupBy(iterable $items): void + { + $collection = new Collection($items); + $grouped = $collection->groupBy('parent_id'); + $expected = [ + 10 => [ + ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + ], + 11 => [ + ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ], + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + $this->assertInstanceOf(Collection::class, $grouped); + } + + /** + * Tests groupBy + */ + #[DataProvider('groupByProvider')] + public function testGroupByCallback(iterable $items): void + { + $collection = new Collection($items); + $expected = [ + 10 => [ + ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + ], + 11 => [ + ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ], + ]; + $grouped = $collection->groupBy(function ($element) { + return $element['parent_id']; + }); + $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 + */ + public function testGroupByDeepKey(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + ]; + $collection = new Collection($items); + $grouped = $collection->groupBy('thing.parent_id'); + $expected = [ + 10 => [ + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + ], + 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 static function indexByProvider(): array + { + $items = [ + ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + ]; + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests indexBy + */ + #[DataProvider('indexByProvider')] + public function testIndexBy(iterable $items): void + { + $collection = new Collection($items); + $grouped = $collection->indexBy('id'); + $expected = [ + 1 => ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + 3 => ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + 2 => ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + $this->assertInstanceOf(Collection::class, $grouped); + } + + /** + * Tests indexBy + */ + #[DataProvider('indexByProvider')] + public function testIndexByCallback(iterable $items): void + { + $collection = new Collection($items); + $grouped = $collection->indexBy(function ($element) { + return $element['id']; + }); + $expected = [ + 1 => ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + 3 => ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + 2 => ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ]; + $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 + */ + public function testIndexByDeep(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + ]; + $collection = new Collection($items); + $grouped = $collection->indexBy('thing.parent_id'); + $expected = [ + 10 => ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + 11 => ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], + ]; + $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')] + public function testCountBy(iterable $items): void + { + $collection = new Collection($items); + $grouped = $collection->countBy('parent_id'); + $expected = [ + 10 => 2, + 11 => 1, + ]; + $result = iterator_to_array($grouped); + $this->assertInstanceOf(Collection::class, $grouped); + $this->assertEquals($expected, $result); + } + + /** + * Tests countBy + */ + #[DataProvider('groupByProvider')] + public function testCountByCallback(iterable $items): void + { + $expected = [ + 10 => 2, + 11 => 1, + ]; + $collection = new Collection($items); + $grouped = $collection->countBy(function ($element) { + return $element['parent_id']; + }); + $this->assertEquals($expected, iterator_to_array($grouped)); + } + + /** + * Tests shuffle + */ + #[DataProvider('simpleProvider')] + public function testShuffle(iterable $data): void + { + $collection = (new Collection($data))->shuffle(); + $result = $collection->toArray(); + $this->assertCount(4, $result); + $this->assertContains(1, $result); + $this->assertContains(2, $result); + $this->assertContains(3, $result); + $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')] + public function testSample(iterable $data): void + { + $result = (new Collection($data))->sample(2)->toArray(); + $this->assertCount(2, $result); + foreach ($result as $number) { + $this->assertContains($number, [1, 2, 3, 4]); + } + } + + /** + * Tests the sample() method with a traversable non-iterator + */ + public function testSampleWithTraversableNonIterator(): void + { + $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); + $result = $collection->sample(3)->toList(); + $list = [ + '2017-01-01', + '2017-01-02', + '2017-01-03', + '2017-01-04', + '2017-01-05', + '2017-01-06', + ]; + $this->assertCount(3, $result); + foreach ($result as $date) { + $this->assertContains($date->format('Y-m-d'), $list); + } + } + + /** + * Test toArray method + */ + public function testToArray(): void + { + $data = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; + $collection = new Collection($data); + $this->assertEquals($data, $collection->toArray()); + } + + /** + * Test toList method + */ + #[DataProvider('simpleProvider')] + public function testToList(iterable $data): void + { + $collection = new Collection($data); + $this->assertEquals([1, 2, 3, 4], $collection->toList()); + } + + /** + * Test JSON encoding + */ + public function testToJson(): void + { + $data = [1, 2, 3, 4]; + $collection = new Collection($data); + $this->assertEquals(json_encode($data), json_encode($collection)); + } + + /** + * Tests that Count returns the number of elements + */ + #[DataProvider('simpleProvider')] + public function testCollectionCount(iterable $list): void + { + $list = (new Collection($list))->buffered(); + $collection = new Collection($list); + $this->assertSame(8, $collection->append($list)->count()); + } + + /** + * Tests that countKeys returns the number of unique keys + */ + #[DataProvider('simpleProvider')] + public function testCollectionCountKeys(iterable $list): void + { + $list = (new Collection($list))->buffered(); + $collection = new Collection($list); + $this->assertSame(4, $collection->append($list)->countKeys()); + } + + /** + * Tests take method + */ + public function testTake(): void + { + $data = [1, 2, 3, 4]; + $collection = new Collection($data); + + $taken = $collection->take(2); + $this->assertEquals([1, 2], $taken->toArray()); + + $taken = $collection->take(3); + $this->assertEquals([1, 2, 3], $taken->toArray()); + + $taken = $collection->take(500); + $this->assertEquals([1, 2, 3, 4], $taken->toArray()); + + $taken = $collection->take(1); + $this->assertEquals([1], $taken->toArray()); + + $taken = $collection->take(); + $this->assertEquals([1], $taken->toArray()); + + $taken = $collection->take(2, 2); + $this->assertEquals([2 => 3, 3 => 4], $taken->toArray()); + } + + /** + * Tests the take() method with a traversable non-iterator + */ + 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'), + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests match + */ + public function testMatch(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + ]; + $collection = new Collection($items); + $matched = $collection->match(['thing.parent_id' => 10, 'name' => 'baz']); + $this->assertEquals([2 => $items[2]], $matched->toArray()); + + $matched = $collection->match(['thing.parent_id' => 10]); + $this->assertEquals( + [0 => $items[0], 2 => $items[2]], + $matched->toArray(), + ); + + $matched = $collection->match(['thing.parent_id' => 500]); + $this->assertEquals([], $matched->toArray()); + + $matched = $collection->match(['parent_id' => 10, 'name' => 'baz']); + $this->assertEquals([], $matched->toArray()); + } + + /** + * Tests firstMatch + */ + public function testFirstMatch(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + ]; + $collection = new Collection($items); + $matched = $collection->firstMatch(['thing.parent_id' => 10]); + $this->assertEquals( + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + $matched, + ); + + $matched = $collection->firstMatch(['thing.parent_id' => 10, 'name' => 'baz']); + $this->assertEquals( + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + $matched, + ); + } + + /** + * Tests the append method + */ + public function testAppend(): void + { + $collection = new Collection([1, 2, 3]); + $combined = $collection->append([4, 5, 6]); + $this->assertEquals([1, 2, 3, 4, 5, 6], $combined->toArray(false)); + + $collection = new Collection(['a' => 1, 'b' => 2]); + $combined = $collection->append(['c' => 3, 'a' => 4]); + $this->assertEquals(['a' => 4, 'b' => 2, 'c' => 3], $combined->toArray()); + } + + /** + * Tests the appendItem method + */ + public function testAppendItem(): void + { + $collection = new Collection([1, 2, 3]); + $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()); + } + + /** + * 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]); + $this->assertEquals([1, 2, 3, 4, 5, 6], $combined->toList()); + } + + /** + * Tests that by calling compile internal iteration operations are not done + * more than once + */ + public function testCompile(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + + $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. + */ + public function testBuffered(): void + { + $items = new NoRewindIterator(new ArrayIterator(['a' => 4, 'b' => 5, 'c' => 6])); + $buffered = (new Collection($items))->buffered(); + $this->assertEquals(['a' => 4, 'b' => 5, 'c' => 6], $buffered->toArray()); + $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 + */ + public function testCombine(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'parent' => 'a'], + ['id' => 2, 'name' => 'bar', 'parent' => 'b'], + ['id' => 3, 'name' => 'baz', 'parent' => 'a'], + ]; + $collection = (new Collection($items))->combine('id', 'name'); + $expected = [1 => 'foo', 2 => 'bar', 3 => 'baz']; + $this->assertEquals($expected, $collection->toArray()); + + $expected = ['foo' => 1, 'bar' => 2, 'baz' => 3]; + $collection = (new Collection($items))->combine('name', 'id'); + $this->assertEquals($expected, $collection->toArray()); + + $collection = (new Collection($items))->combine('id', 'name', 'parent'); + $expected = ['a' => [1 => 'foo', 3 => 'baz'], 'b' => [2 => 'bar']]; + $this->assertEquals($expected, $collection->toArray()); + + $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'], + ]; + $collection = (new Collection($items))->combine( + function ($value, $key) { + return $value['name'] . '-' . $key; + }, + function ($value, $key) { + return $key . '-' . $value['name']; + }, + 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 + */ + public function testNest(): void + { + $items = [ + ['id' => 1, 'parent_id' => null], + ['id' => 2, 'parent_id' => 1], + ['id' => 3, 'parent_id' => 1], + ['id' => 4, 'parent_id' => 1], + ['id' => 5, 'parent_id' => 6], + ['id' => 6, 'parent_id' => null], + ['id' => 7, 'parent_id' => 1], + ['id' => 8, 'parent_id' => 6], + ['id' => 9, 'parent_id' => 6], + ['id' => 10, 'parent_id' => 6], + ]; + $collection = (new Collection($items))->nest('id', 'parent_id'); + $expected = [ + [ + 'id' => 1, + 'parent_id' => null, + 'children' => [ + ['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' => 6, + 'parent_id' => null, + 'children' => [ + ['id' => 5, 'parent_id' => 6, 'children' => []], + ['id' => 8, 'parent_id' => 6, 'children' => []], + ['id' => 9, 'parent_id' => 6, 'children' => []], + ['id' => 10, 'parent_id' => 6, 'children' => []], + ], + ], + ]; + $this->assertEquals($expected, $collection->toArray()); + } + + /** + * Tests the nest method with alternate nesting key + */ + public function testNestAlternateNestingKey(): void + { + $items = [ + ['id' => 1, 'parent_id' => null], + ['id' => 2, 'parent_id' => 1], + ['id' => 3, 'parent_id' => 1], + ['id' => 4, 'parent_id' => 1], + ['id' => 5, 'parent_id' => 6], + ['id' => 6, 'parent_id' => null], + ['id' => 7, 'parent_id' => 1], + ['id' => 8, 'parent_id' => 6], + ['id' => 9, 'parent_id' => 6], + ['id' => 10, 'parent_id' => 6], + ]; + $collection = (new Collection($items))->nest('id', 'parent_id', 'nodes'); + $expected = [ + [ + 'id' => 1, + 'parent_id' => null, + 'nodes' => [ + ['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' => 6, + 'parent_id' => null, + 'nodes' => [ + ['id' => 5, 'parent_id' => 6, 'nodes' => []], + ['id' => 8, 'parent_id' => 6, 'nodes' => []], + ['id' => 9, 'parent_id' => 6, 'nodes' => []], + ['id' => 10, 'parent_id' => 6, 'nodes' => []], + ], + ], + ]; + $this->assertEquals($expected, $collection->toArray()); + } + + /** + * Tests the nest method with more than one level + */ + public function testNestMultiLevel(): void + { + $items = [ + ['id' => 1, 'parent_id' => null], + ['id' => 2, 'parent_id' => 1], + ['id' => 3, 'parent_id' => 2], + ['id' => 4, 'parent_id' => 2], + ['id' => 5, 'parent_id' => 3], + ['id' => 6, 'parent_id' => null], + ['id' => 7, 'parent_id' => 3], + ['id' => 8, 'parent_id' => 4], + ['id' => 9, 'parent_id' => 6], + ['id' => 10, 'parent_id' => 6], + ]; + $collection = (new Collection($items))->nest('id', 'parent_id', 'nodes'); + $expected = [ + [ + 'id' => 1, + 'parent_id' => null, + 'nodes' => [ + [ + 'id' => 2, + 'parent_id' => 1, + 'nodes' => [ + [ + 'id' => 3, + 'parent_id' => 2, + 'nodes' => [ + ['id' => 5, 'parent_id' => 3, 'nodes' => []], + ['id' => 7, 'parent_id' => 3, 'nodes' => []], + ], + ], + [ + 'id' => 4, + 'parent_id' => 2, + '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' => []], + ], + ], + ]; + $this->assertEquals($expected, $collection->toArray()); + } + + /** + * Tests the nest method with more than one level + */ + public function testNestMultiLevelAlternateNestingKey(): void + { + $items = [ + ['id' => 1, 'parent_id' => null], + ['id' => 2, 'parent_id' => 1], + ['id' => 3, 'parent_id' => 2], + ['id' => 4, 'parent_id' => 2], + ['id' => 5, 'parent_id' => 3], + ['id' => 6, 'parent_id' => null], + ['id' => 7, 'parent_id' => 3], + ['id' => 8, 'parent_id' => 4], + ['id' => 9, 'parent_id' => 6], + ['id' => 10, 'parent_id' => 6], + ]; + $collection = (new Collection($items))->nest('id', 'parent_id'); + $expected = [ + [ + 'id' => 1, + 'parent_id' => null, + 'children' => [ + [ + 'id' => 2, + 'parent_id' => 1, + 'children' => [ + [ + 'id' => 3, + 'parent_id' => 2, + 'children' => [ + ['id' => 5, 'parent_id' => 3, 'children' => []], + ['id' => 7, 'parent_id' => 3, 'children' => []], + ], + ], + [ + 'id' => 4, + 'parent_id' => 2, + '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' => []], + ], + ], + ]; + $this->assertEquals($expected, $collection->toArray()); + } + + /** + * Tests the nest method with more than one level + */ + public function testNestObjects(): void + { + $items = [ + new ArrayObject(['id' => 1, 'parent_id' => null]), + new ArrayObject(['id' => 2, 'parent_id' => 1]), + new ArrayObject(['id' => 3, 'parent_id' => 2]), + new ArrayObject(['id' => 4, 'parent_id' => 2]), + new ArrayObject(['id' => 5, 'parent_id' => 3]), + new ArrayObject(['id' => 6, 'parent_id' => null]), + 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]), + ]; + $collection = (new Collection($items))->nest('id', 'parent_id'); + $expected = [ + new ArrayObject([ + 'id' => 1, + 'parent_id' => null, + 'children' => [ + new ArrayObject([ + 'id' => 2, + 'parent_id' => 1, + 'children' => [ + new ArrayObject([ + 'id' => 3, + 'parent_id' => 2, + 'children' => [ + new ArrayObject(['id' => 5, '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' => 6, + 'parent_id' => null, + 'children' => [ + new ArrayObject(['id' => 9, '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 + */ + public function testNestObjectsAlternateNestingKey(): void + { + $items = [ + new ArrayObject(['id' => 1, 'parent_id' => null]), + new ArrayObject(['id' => 2, 'parent_id' => 1]), + new ArrayObject(['id' => 3, 'parent_id' => 2]), + new ArrayObject(['id' => 4, 'parent_id' => 2]), + new ArrayObject(['id' => 5, 'parent_id' => 3]), + new ArrayObject(['id' => 6, 'parent_id' => null]), + 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]), + ]; + $collection = (new Collection($items))->nest('id', 'parent_id', 'nodes'); + $expected = [ + new ArrayObject([ + 'id' => 1, + 'parent_id' => null, + 'nodes' => [ + new ArrayObject([ + 'id' => 2, + 'parent_id' => 1, + 'nodes' => [ + new ArrayObject([ + 'id' => 3, + 'parent_id' => 2, + 'nodes' => [ + new ArrayObject(['id' => 5, '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' => 6, + 'parent_id' => null, + 'nodes' => [ + new ArrayObject(['id' => 9, 'parent_id' => 6, 'nodes' => []]), + new ArrayObject(['id' => 10, 'parent_id' => 6, 'nodes' => []]), + ], + ]), + ]; + $this->assertEquals($expected, $collection->toArray()); + } + + /** + * Tests insert + */ + public function testInsert(): void + { + $items = [['a' => 1], ['b' => 2]]; + $collection = new Collection($items); + $iterator = $collection->insert('c', [3, 4]); + $this->assertInstanceOf(InsertIterator::class, $iterator); + $this->assertEquals( + [['a' => 1, 'c' => 3], ['b' => 2, 'c' => 4]], + iterator_to_array($iterator), + ); + } + + /** + * Provider for testing each of the directions for listNested + * + * @return array + */ + 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]], + ]; + } + + /** + * Tests the listNested method with the default 'children' nesting key + */ + #[DataProvider('nestedListProvider')] + public function testListNested(string $dir, array $expected): void + { + $items = [ + ['id' => 1, 'parent_id' => null], + ['id' => 2, 'parent_id' => 1], + ['id' => 3, 'parent_id' => 2], + ['id' => 4, 'parent_id' => 2], + ['id' => 5, 'parent_id' => 3], + ['id' => 6, 'parent_id' => null], + ['id' => 7, 'parent_id' => 3], + ['id' => 8, 'parent_id' => 4], + ['id' => 9, '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 + */ + public function testListNestedCustomKey(): void + { + $items = [ + ['id' => 1, 'stuff' => [['id' => 2, 'stuff' => [['id' => 3]]]]], + ['id' => 4, 'stuff' => [['id' => 5]]], + ]; + $collection = (new Collection($items))->listNested('desc', 'stuff'); + $this->assertEquals(range(1, 5), $collection->extract('id')->toArray(false)); + } + + /** + * Tests flattening the collection using a custom callable function + */ + public function testListNestedWithCallable(): void + { + $items = [ + ['id' => 1, 'stuff' => [['id' => 2, 'stuff' => [['id' => 3]]]]], + ['id' => 4, 'stuff' => [['id' => 5]]], + ]; + $collection = (new Collection($items))->listNested('desc', function ($item) { + return $item['stuff'] ?? []; + }); + $this->assertEquals(range(1, 5), $collection->extract('id')->toArray(false)); + } + + /** + * Provider for sumOf tests + * + * @return array + */ + public static function sumOfProvider(): array + { + $items = [ + ['invoice' => ['total' => 100]], + ['invoice' => ['total' => 200]], + ]; + + $floatItems = [ + ['invoice' => ['total' => 100.0]], + ['invoice' => ['total' => 200.0]], + ]; + + return [ + 'array' => [$items, 300], + 'iterator' => [self::yieldItems($items), 300], + 'floatArray' => [$floatItems, 300.0], + 'floatIterator' => [self::yieldItems($floatItems), 300.0], + ]; + } + + /** + * Tests the sumOf method + * + * @param float|int $expected + */ + #[DataProvider('sumOfProvider')] + public function testSumOf(iterable $items, $expected): void + { + $this->assertEquals($expected, (new Collection($items))->sumOf('invoice.total')); + } + + /** + * Tests the sumOf method + * + * @param float|int $expected + */ + #[DataProvider('sumOfProvider')] + public function testSumOfCallable(iterable $items, $expected): void + { + $sum = (new Collection($items))->sumOf(function ($v) { + return $v['invoice']['total']; + }); + $this->assertEquals($expected, $sum); + } + + /** + * Tests the stopWhen method with a callable + */ + #[DataProvider('simpleProvider')] + public function testStopWhenCallable(iterable $items): void + { + $collection = (new Collection($items))->stopWhen(function ($v) { + return $v > 3; + }); + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 3], $collection->toArray()); + } + + /** + * Tests the stopWhen method with a matching array + */ + public function testStopWhenWithArray(): void + { + $items = [ + ['foo' => 'bar'], + ['foo' => 'baz'], + ['foo' => 'foo'], + ]; + $collection = (new Collection($items))->stopWhen(['foo' => 'baz']); + $this->assertEquals([['foo' => 'bar']], $collection->toArray()); + } + + /** + * Tests the unfold method + */ + public function testUnfold(): void + { + $items = [ + [1, 2, 3, 4], + [5, 6], + [7, 8], + ]; + + $collection = (new Collection($items))->unfold(); + $this->assertEquals(range(1, 8), $collection->toArray(false)); + + $items = [ + [1, 2], + new Collection([3, 4]), + ]; + $collection = (new Collection($items))->unfold(); + $this->assertEquals(range(1, 4), $collection->toArray(false)); + } + + /** + * Tests the unfold method with empty levels + */ + public function testUnfoldEmptyLevels(): void + { + $items = [[], [1, 2], []]; + $collection = (new Collection($items))->unfold(); + $this->assertEquals(range(1, 2), $collection->toArray(false)); + + $items = []; + $collection = (new Collection($items))->unfold(); + $this->assertEmpty($collection->toArray(false)); + } + + /** + * Tests the unfold when passing a callable + */ + public function testUnfoldWithCallable(): void + { + $items = [1, 2, 3]; + $collection = (new Collection($items))->unfold(function ($item) { + return range($item, $item * 2); + }); + $expected = [1, 2, 2, 3, 4, 3, 4, 5, 6]; + $this->assertEquals($expected, $collection->toArray(false)); + } + + /** + * Tests the through() method + */ + public function testThrough(): void + { + $items = [1, 2, 3]; + $collection = (new Collection($items))->through(function ($collection) { + return $collection->append($collection->toList()); + }); + + $this->assertEquals([1, 2, 3, 1, 2, 3], $collection->toList()); + } + + /** + * Tests the through method when it returns an array + */ + public function testThroughReturnArray(): void + { + $items = [1, 2, 3]; + $collection = (new Collection($items))->through(function ($collection) { + $list = $collection->toList(); + + return array_merge($list, $list); + }); + + $this->assertEquals([1, 2, 3, 1, 2, 3], $collection->toList()); + } + + /** + * Tests that the sortBy method does not die when something that is not a + * collection is passed + */ + public function testComplexSortBy(): void + { + $results = collection([3, 7]) + ->unfold(function ($value) { + return [ + ['sorting' => $value * 2], + ['sorting' => $value * 2], + ]; + }) + ->sortBy('sorting') + ->extract('sorting') + ->toList(); + $this->assertEquals([14, 14, 6, 6], $results); + } + + /** + * Tests __debugInfo() or debug() usage + */ + public function testDebug(): void + { + $items = [1, 2, 3]; + + $collection = new Collection($items); + + $result = $collection->__debugInfo(); + $expected = [ + 'count' => 3, + 'items' => [1, 2, 3], + ]; + $this->assertSame($expected, $result); + + // Calling it again will rewind + $result = $collection->__debugInfo(); + $expected = [ + 'count' => 3, + 'items' => [1, 2, 3], + ]; + $this->assertSame($expected, $result); + + // Make sure it also works with non rewindable iterators + $iterator = new NoRewindIterator(new ArrayIterator($items)); + $collection = new Collection($iterator); + + $result = $collection->__debugInfo(); + $this->assertStringContainsString('NoRewindIterator', $result['innerIterator']::class); + + // Calling it again will in this case not rewind + $result = $collection->__debugInfo(); + $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 + */ + public function testIsEmpty(): void + { + $collection = new Collection([1, 2, 3]); + $this->assertFalse($collection->isEmpty()); + + $collection = $collection->map(function () { + return null; + }); + $this->assertFalse($collection->isEmpty()); + + $collection = $collection->filter(); + $this->assertTrue($collection->isEmpty()); + } + + /** + * Tests the isEmpty() method does not consume data + * from buffered iterators. + */ + public function testIsEmptyDoesNotConsume(): void + { + $array = new ArrayIterator([1, 2, 3]); + $inner = new BufferedIterator($array); + $collection = new Collection($inner); + $this->assertFalse($collection->isEmpty()); + $this->assertCount(3, $collection->toArray()); + } + + /** + * Tests the zip() method + */ + public function testZip(): void + { + $collection = new Collection([1, 2]); + $zipped = $collection->zip([3, 4]); + $this->assertEquals([[1, 3], [2, 4]], $zipped->toList()); + + $collection = new Collection([1, 2]); + $zipped = $collection->zip([3]); + $this->assertEquals([[1, 3]], $zipped->toList()); + + $collection = new Collection([1, 2]); + $zipped = $collection->zip([3, 4], [5, 6], [7, 8], [9, 10, 11]); + $this->assertEquals([ + [1, 3, 5, 7, 9], + [2, 4, 6, 8, 10], + ], $zipped->toList()); + } + + /** + * Tests the zipWith() method + */ + public function testZipWith(): void + { + $collection = new Collection([1, 2]); + $zipped = $collection->zipWith([3, 4], function ($a, $b) { + return $a * $b; + }); + $this->assertEquals([3, 8], $zipped->toList()); + + $zipped = $collection->zipWith([3, 4], [5, 6, 7], function (...$args) { + return array_sum($args); + }); + $this->assertEquals([9, 12], $zipped->toList()); + } + + /** + * Tests the skip() method + */ + 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 + */ + 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'), + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the first() method with a traversable non-iterator + */ + public function testFirstWithTraversableNonIterator(): void + { + $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); + $date = $collection->first(); + $this->assertInstanceOf('DateTime', $date); + $this->assertSame('2017-01-01', $date->format('Y-m-d')); + } + + /** + * Tests the last() method + */ + public function testLast(): void + { + $collection = new Collection([1, 2, 3]); + $this->assertSame(3, $collection->last()); + + $collection = $collection->map(function ($e) { + return $e * 2; + }); + $this->assertSame(6, $collection->last()); + } + + /** + * Tests the last() method when on an empty collection + */ + public function testLastWithEmptyCollection(): void + { + $collection = new Collection([]); + $this->assertNull($collection->last()); + } + + /** + * Tests the last() method with a countable object + */ + public function testLastWithCountable(): void + { + $collection = new Collection(new ArrayObject([1, 2, 3])); + $this->assertSame(3, $collection->last()); + } + + /** + * Tests the last() method with an empty countable object + */ + public function testLastWithEmptyCountable(): void + { + $collection = new Collection(new ArrayObject([])); + $this->assertNull($collection->last()); + } + + /** + * Tests the last() method with a non-rewindable iterator + */ + public function testLastWithNonRewindableIterator(): void + { + $iterator = new NoRewindIterator(new ArrayIterator([1, 2, 3])); + $collection = new Collection($iterator); + $this->assertSame(3, $collection->last()); + } + + /** + * Tests the last() method with a traversable non-iterator + */ + public function testLastWithTraversableNonIterator(): void + { + $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); + $date = $collection->last(); + $this->assertInstanceOf('DateTime', $date); + $this->assertSame('2017-01-06', $date->format('Y-m-d')); + } + + /** + * 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 + * + * @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(): void + { + $collection = new Collection([1, 2, 3]); + $this->assertSame(6, $collection->sumOf()); + + $collection = new Collection(['a' => 1, 'b' => 4, 'c' => 6]); + $this->assertSame(11, $collection->sumOf()); + } + + /** + * Tests using extract with the {*} notation + */ + public function testUnfoldedExtract(): void + { + $items = [ + ['comments' => [['id' => 1], ['id' => 2]]], + ['comments' => [['id' => 3], ['id' => 4]]], + ['comments' => [['id' => 7], ['nope' => 8]]], + ]; + + $extracted = (new Collection($items))->extract('comments.{*}.id'); + $this->assertEquals([1, 2, 3, 4, 7, null], $extracted->toArray()); + + $items = [ + [ + 'comments' => [ + [ + 'voters' => [['id' => 1], ['id' => 2]], + ], + ], + ], + [ + 'comments' => [ + [ + 'voters' => [['id' => 3], ['id' => 4]], + ], + ], + ], + [ + 'comments' => [ + [ + 'voters' => [['id' => 5], ['nope' => 'fail'], ['id' => 6]], + ], + ], + ], + [ + 'comments' => [ + [ + 'not_voters' => [['id' => 5]], + ], + ], + ], + ['not_comments' => []], + ]; + $extracted = (new Collection($items))->extract('comments.{*}.voters.{*}.id'); + $expected = [1, 2, 3, 4, 5, null, 6]; + $this->assertEquals($expected, $extracted->toArray()); + $this->assertEquals($expected, $extracted->toList()); + } + + /** + * Tests serializing a simple collection + */ + public function testSerializeSimpleCollection(): void + { + $collection = new Collection([1, 2, 3]); + $serialized = serialize($collection); + $unserialized = unserialize($serialized); + $this->assertEquals($collection->toList(), $unserialized->toList()); + $this->assertEquals($collection->toArray(), $unserialized->toArray()); + } + + /** + * Tests serialization when using append + */ + public function testSerializeWithAppendIterators(): void + { + $collection = new Collection([1, 2, 3]); + $collection = $collection->append(['a' => 4, 'b' => 5, 'c' => 6]); + $serialized = serialize($collection); + $unserialized = unserialize($serialized); + $this->assertEquals($collection->toList(), $unserialized->toList()); + $this->assertEquals($collection->toArray(), $unserialized->toArray()); + } + + /** + * Tests serialization when using nested iterators + */ + public function testSerializeWithNestedIterators(): void + { + $collection = new Collection([1, 2, 3]); + $collection = $collection->map(function ($e) { + return $e * 3; + }); + + $collection = $collection->groupBy(function ($e) { + return $e % 2; + }); + + $serialized = serialize($collection); + $unserialized = unserialize($serialized); + $this->assertEquals($collection->toList(), $unserialized->toList()); + $this->assertEquals($collection->toArray(), $unserialized->toArray()); + } + + /** + * Tests serializing a zip() call + */ + public function testSerializeWithZipIterator(): void + { + $collection = new Collection([4, 5]); + $collection = $collection->zip([1, 2]); + $serialized = serialize($collection); + $unserialized = unserialize($serialized); + $this->assertEquals($collection->toList(), $unserialized->toList()); + } + + /** + * Provider for some chunk tests + * + * @return array + */ + public static function chunkProvider(): array + { + $items = range(1, 10); + + return [ + 'array' => [$items], + 'iterator' => [self::yieldItems($items)], + ]; + } + + /** + * Tests the chunk method with exact chunks + */ + #[DataProvider('chunkProvider')] + public function testChunk(iterable $items): void + { + $collection = new Collection($items); + $chunked = $collection->chunk(2)->toList(); + $expected = [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]; + $this->assertEquals($expected, $chunked); + } + + /** + * Tests the chunk method with overflowing chunk size + */ + public function testChunkOverflow(): void + { + $collection = new Collection(range(1, 11)); + $chunked = $collection->chunk(2)->toList(); + $expected = [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10], [11]]; + $this->assertEquals($expected, $chunked); + } + + /** + * Tests the chunk method with non-scalar items + */ + public function testChunkNested(): void + { + $collection = new Collection([1, 2, 3, [4, 5], 6, [7, [8, 9], 10], 11]); + $chunked = $collection->chunk(2)->toList(); + $expected = [[1, 2], [3, [4, 5]], [6, [7, [8, 9], 10]], [11]]; + $this->assertEquals($expected, $chunked); + } + + /** + * Tests the chunkWithKeys method with exact chunks + */ + public function testChunkWithKeys(): void + { + $collection = new Collection(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5, 'f' => 6]); + $chunked = $collection->chunkWithKeys(2)->toList(); + $expected = [['a' => 1, 'b' => 2], ['c' => 3, 'd' => 4], ['e' => 5, 'f' => 6]]; + $this->assertEquals($expected, $chunked); + } + + /** + * Tests the chunkWithKeys method with overflowing chunk size + */ + 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(); + $expected = [['a' => 1, 'b' => 2], ['c' => 3, 'd' => 4], ['e' => 5, 'f' => 6], ['g' => 7]]; + $this->assertEquals($expected, $chunked); + } + + /** + * Tests the chunkWithKeys method with non-scalar items + */ + 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(); + $expected = [['a' => 1, 'b' => 2], ['c' => 3, 'd' => [4, 5]], ['e' => 6, 'f' => [7, [8, 9], 10]], ['g' => 11]]; + $this->assertEquals($expected, $chunked); + } + + /** + * Tests the chunkWithKeys method without preserving keys + */ + 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(); + $expected = [[0 => 1, 1 => 2], [0 => 3, 1 => 4], [0 => 5, 1 => 6], [0 => 7]]; + $this->assertEquals($expected, $chunked); + } + + /** + * Tests cartesianProduct + */ + public function testCartesianProduct(): void + { + $collection = new Collection([]); + + $result = $collection->cartesianProduct(); + + $expected = []; + + $this->assertEquals($expected, $result->toList()); + + $collection = new Collection([['A', 'B', 'C'], [1, 2, 3]]); + + $result = $collection->cartesianProduct(); + + $expected = [ + ['A', 1], + ['A', 2], + ['A', 3], + ['B', 1], + ['B', 2], + ['B', 3], + ['C', 1], + ['C', 2], + ['C', 3], + ]; + + $this->assertEquals($expected, $result->toList()); + + $collection = new Collection([[1, 2, 3], ['A', 'B', 'C'], ['a', 'b', 'c']]); + + $result = $collection->cartesianProduct(function ($value) { + return [strval($value[0]) . $value[1] . $value[2]]; + }, function ($value) { + return $value[0] >= 2; + }); + + $expected = [ + ['2Aa'], + ['2Ab'], + ['2Ac'], + ['2Ba'], + ['2Bb'], + ['2Bc'], + ['2Ca'], + ['2Cb'], + ['2Cc'], + ['3Aa'], + ['3Ab'], + ['3Ac'], + ['3Ba'], + ['3Bb'], + ['3Bc'], + ['3Ca'], + ['3Cb'], + ['3Cc'], + ]; + + $this->assertEquals($expected, $result->toList()); + + $collection = new Collection([['1', '2', '3', '4'], ['A', 'B', 'C'], ['name', 'surname', 'telephone']]); + + $result = $collection->cartesianProduct(function ($value) { + return [$value[0] => [$value[1] => $value[2]]]; + }, function ($value) { + return $value[2] !== 'surname'; + }); + + $expected = [ + [1 => ['A' => 'name']], + [1 => ['A' => 'telephone']], + [1 => ['B' => 'name']], + [1 => ['B' => 'telephone']], + [1 => ['C' => 'name']], + [1 => ['C' => 'telephone']], + [2 => ['A' => 'name']], + [2 => ['A' => 'telephone']], + [2 => ['B' => 'name']], + [2 => ['B' => 'telephone']], + [2 => ['C' => 'name']], + [2 => ['C' => 'telephone']], + [3 => ['A' => 'name']], + [3 => ['A' => 'telephone']], + [3 => ['B' => 'name']], + [3 => ['B' => 'telephone']], + [3 => ['C' => 'name']], + [3 => ['C' => 'telephone']], + [4 => ['A' => 'name']], + [4 => ['A' => 'telephone']], + [4 => ['B' => 'name']], + [4 => ['B' => 'telephone']], + [4 => ['C' => 'name']], + [4 => ['C' => 'telephone']], + ]; + + $this->assertEquals($expected, $result->toList()); + + $collection = new Collection([ + [ + 'name1' => 'alex', + 'name2' => 'kostas', + 0 => 'leon', + ], + [ + 'val1' => 'alex@example.com', + 24 => 'kostas@example.com', + 'val2' => 'leon@example.com', + ], + ]); + + $result = $collection->cartesianProduct(); + + $expected = [ + ['alex', 'alex@example.com'], + ['alex', 'kostas@example.com'], + ['alex', 'leon@example.com'], + ['kostas', 'alex@example.com'], + ['kostas', 'kostas@example.com'], + ['kostas', 'leon@example.com'], + ['leon', 'alex@example.com'], + ['leon', 'kostas@example.com'], + ['leon', 'leon@example.com'], + ]; + + $this->assertEquals($expected, $result->toList()); + } + + /** + * Tests that an exception is thrown if the cartesian product is called with multidimensional arrays + */ + public function testCartesianProductMultidimensionalArray(): void + { + $this->expectException(LogicException::class); + + $collection = new Collection([ + [ + 'names' => [ + 'alex', 'kostas', 'leon', + ], + ], + [ + 'locations' => [ + 'crete', 'london', 'paris', + ], + ], + ]); + + $collection->cartesianProduct(); + } + + public function testTranspose(): void + { + $collection = new Collection([ + ['Products', '2012', '2013', '2014'], + ['Product A', '200', '100', '50'], + ['Product B', '300', '200', '100'], + ['Product C', '400', '300', '200'], + ['Product D', '500', '400', '300'], + ]); + $transposed = $collection->transpose(); + $expected = [ + ['Products', 'Product A', 'Product B', 'Product C', 'Product D'], + ['2012', '200', '300', '400', '500'], + ['2013', '100', '200', '300', '400'], + ['2014', '50', '100', '200', '300'], + ]; + + $this->assertEquals($expected, $transposed->toList()); + } + + /** + * Tests that provided arrays do not have even length + */ + public function testTransposeUnEvenLengthShouldThrowException(): void + { + $this->expectException(LogicException::class); + + $collection = new Collection([ + ['Products', '2012', '2013', '2014'], + ['Product A', '200', '100', '50'], + ['Product B', '300'], + ['Product C', '400', '300'], + ]); + + $collection->transpose(); + } + + /** + * Yields all the elements as passed + * + * @param iterable $items the elements to be yielded + * @return \Generator + */ + protected static function yieldItems(iterable $items): Generator + { + foreach ($items as $k => $v) { + yield $k => $v; + } + } + + /** + * Create a DatePeriod object. + * + * @param string $start Start date + * @param string $end End date + */ + 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 + { + $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 that extending Collection does not cause infinite loops + * when iterating and calling methods like every() inside the loop. + * + * @see https://github.com/cakephp/cakephp/issues/17483 + */ + public function testExtendedCollectionNoInfiniteLoop(): void + { + $items = [ + ['id' => 1, 'name' => 'foo'], + ['id' => 2, 'name' => 'bar'], + ['id' => 3, 'name' => 'baz'], + ]; + + $collection = new class ($items) extends Collection { + }; + + $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); + } +} 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 new file mode 100644 index 00000000000..590c9ca72cf --- /dev/null +++ b/tests/TestCase/Collection/Iterator/BufferedIteratorTest.php @@ -0,0 +1,106 @@ + 1, + 'b' => 2, + 'c' => 3, + ]); + $iterator = new BufferedIterator($items); + $expected = (array)$items; + $this->assertSame($expected, $iterator->toArray()); + + $items['c'] = 5; + $buffered = $iterator->toArray(); + $this->assertSame($expected, $buffered); + } + + /** + * Tests that items are cached once iterated over them + */ + public function testCount(): void + { + $items = new ArrayObject([ + 'a' => 1, + 'b' => 2, + 'c' => 3, + ]); + $iterator = new BufferedIterator($items); + $this->assertCount(3, $iterator); + $buffered = $iterator->toArray(); + $this->assertSame((array)$items, $buffered); + + $iterator = new BufferedIterator(new NoRewindIterator($items->getIterator())); + $this->assertCount(3, $iterator); + $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 new file mode 100644 index 00000000000..53b73051436 --- /dev/null +++ b/tests/TestCase/Collection/Iterator/ExtractIteratorTest.php @@ -0,0 +1,95 @@ + 1, 'b' => 2], + ['a' => 3, 'b' => 4], + ]; + $extractor = new ExtractIterator($items, 'a'); + $this->assertEquals([1, 3], iterator_to_array($extractor)); + + $extractor = new ExtractIterator($items, 'b'); + $this->assertEquals([2, 4], iterator_to_array($extractor)); + + $extractor = new ExtractIterator($items, 'c'); + $this->assertEquals([null, null], iterator_to_array($extractor)); + } + + /** + * Tests it is possible to extract a column in the first level of an object + */ + public function testExtractFromObjectShallow(): void + { + $items = [ + new ArrayObject(['a' => 1, 'b' => 2]), + new ArrayObject(['a' => 3, 'b' => 4]), + ]; + $extractor = new ExtractIterator($items, 'a'); + $this->assertEquals([1, 3], iterator_to_array($extractor)); + + $extractor = new ExtractIterator($items, 'b'); + $this->assertEquals([2, 4], iterator_to_array($extractor)); + + $extractor = new ExtractIterator($items, 'c'); + $this->assertEquals([null, null], iterator_to_array($extractor)); + } + + /** + * Tests it is possible to extract a column deeply nested in the structure + */ + public function testExtractFromArrayDeep(): void + { + $items = [ + ['a' => ['b' => ['c' => 10]], 'b' => 2], + ['a' => ['b' => ['d' => 15]], 'b' => 4], + ['a' => ['x' => ['z' => 20]], 'b' => 4], + ['a' => ['b' => ['c' => 25]], 'b' => 2], + ]; + $extractor = new ExtractIterator($items, 'a.b.c'); + $this->assertEquals([10, null, null, 25], iterator_to_array($extractor)); + } + + /** + * Tests that it is possible to pass a callable as the extractor. + */ + public function testExtractWithCallable(): void + { + $items = [ + ['a' => 1, 'b' => 2], + ['a' => 3, 'b' => 4], + ]; + $extractor = new ExtractIterator($items, function ($item) { + return $item['b']; + }); + $this->assertEquals([2, 4], iterator_to_array($extractor)); + } +} diff --git a/tests/TestCase/Collection/Iterator/FilterIteratorTest.php b/tests/TestCase/Collection/Iterator/FilterIteratorTest.php new file mode 100644 index 00000000000..5b40c0aa922 --- /dev/null +++ b/tests/TestCase/Collection/Iterator/FilterIteratorTest.php @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000000..d36c86ec199 --- /dev/null +++ b/tests/TestCase/Collection/Iterator/InsertIteratorTest.php @@ -0,0 +1,147 @@ + ['name' => 'Derp'], + '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], + ]; + $this->assertSame($expected, $result); + } + + /** + * Test insert deep path + */ + public function testInsertDeepPath(): void + { + $items = [ + 'a' => ['name' => 'Derp', 'a' => ['deep' => ['thing' => 1]]], + 'b' => ['name' => 'Derpina', 'a' => ['deep' => ['thing' => 2]]], + ]; + $values = new ArrayIterator([20, 21]); + $iterator = new InsertIterator($items, 'a.deep.path', $values); + $result = $iterator->toArray(); + $expected = [ + 'a' => ['name' => 'Derp', 'a' => ['deep' => ['thing' => 1, 'path' => 20]]], + 'b' => ['name' => 'Derpina', 'a' => ['deep' => ['thing' => 2, 'path' => 21]]], + ]; + $this->assertSame($expected, $result); + } + + /** + * Test that missing properties in the path will skip inserting + */ + public function testInsertDeepPathMissingStep(): void + { + $items = [ + 'a' => ['name' => 'Derp', 'a' => ['deep' => ['thing' => 1]]], + 'b' => ['name' => 'Derpina', 'a' => ['nested' => 2]], + ]; + $values = [20, 21]; + $iterator = new InsertIterator($items, 'a.deep.path', $values); + $result = $iterator->toArray(); + $expected = [ + 'a' => ['name' => 'Derp', 'a' => ['deep' => ['thing' => 1, 'path' => 20]]], + 'b' => ['name' => 'Derpina', 'a' => ['nested' => 2]], + ]; + $this->assertSame($expected, $result); + } + + /** + * Tests that the iterator will insert values as long as there still exist + * some in the values array + */ + public function testInsertTargetCountBigger(): void + { + $items = [ + 'a' => ['name' => 'Derp'], + 'b' => ['name' => 'Derpina'], + ]; + $values = [20]; + $iterator = new InsertIterator($items, 'age', $values); + $result = $iterator->toArray(); + $expected = [ + 'a' => ['name' => 'Derp', 'age' => 20], + 'b' => ['name' => 'Derpina'], + ]; + $this->assertSame($expected, $result); + } + + /** + * Tests that the iterator will insert values as long as there still exist + * some in the values array + */ + public function testInsertSourceBigger(): void + { + $items = [ + 'a' => ['name' => 'Derp'], + '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], + ]; + $this->assertSame($expected, $result); + } + + /** + * Tests the iterator can be rewound + */ + public function testRewind(): void + { + $items = [ + 'a' => ['name' => 'Derp'], + 'b' => ['name' => 'Derpina'], + ]; + $values = [20, 21]; + $iterator = new InsertIterator($items, 'age', $values); + $iterator->next(); + $this->assertEquals(['name' => 'Derpina', 'age' => 21], $iterator->current()); + $iterator->rewind(); + + $result = $iterator->toArray(); + $expected = [ + 'a' => ['name' => 'Derp', 'age' => 20], + '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 new file mode 100644 index 00000000000..13d7c1eee9c --- /dev/null +++ b/tests/TestCase/Collection/Iterator/MapReduceTest.php @@ -0,0 +1,127 @@ + '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', + ]; + $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): void { + $mr->emit(array_unique($documents), $word); + }; + $results = new MapReduce(new ArrayIterator($data), $mapper, $reducer); + $expected = [ + 'dogs' => ['document_1', 'document_3'], + 'are' => ['document_1'], + 'the' => ['document_1'], + 'most' => ['document_1'], + 'amazing' => ['document_1', 'document_2'], + 'animal' => ['document_1'], + 'in' => ['document_1'], + 'history' => ['document_1', 'document_2'], + 'is' => ['document_2', 'document_3'], + 'not' => ['document_2', 'document_3'], + 'only' => ['document_2'], + 'but' => ['document_2'], + 'boring' => ['document_2', 'document_3'], + 'one' => ['document_3'], + 'thing' => ['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 + */ + public function testEmitFinalInMapper(): void + { + $data = ['a' => ['one', 'two'], 'b' => ['three', 'four']]; + $mapper = function ($row, $key, $mr): void { + foreach ($row as $number) { + $mr->emit($number); + } + }; + $results = new MapReduce(new ArrayIterator($data), $mapper); + $expected = ['one', 'two', 'three', 'four']; + $this->assertEquals($expected, iterator_to_array($results)); + } + + /** + * Tests that a reducer is required when there are intermediate results + */ + public function testReducerRequired(): void + { + $this->expectException(LogicException::class); + $data = ['a' => ['one', 'two'], 'b' => ['three', 'four']]; + $mapper = function ($row, $key, $mr): void { + foreach ($row as $number) { + $mr->emitIntermediate('a', $number); + } + }; + $results = new MapReduce(new ArrayIterator($data), $mapper); + iterator_to_array($results); + } +} diff --git a/tests/TestCase/Collection/Iterator/ReplaceIteratorTest.php b/tests/TestCase/Collection/Iterator/ReplaceIteratorTest.php new file mode 100644 index 00000000000..4c9c9535d6c --- /dev/null +++ b/tests/TestCase/Collection/Iterator/ReplaceIteratorTest.php @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000000..ad41f87c395 --- /dev/null +++ b/tests/TestCase/Collection/Iterator/SortIteratorTest.php @@ -0,0 +1,297 @@ +assertEquals($expected, $sorted->toList()); + + $sorted = new SortIterator($items, $identity, SORT_ASC); + $expected = range(1, 5); + $this->assertEquals($expected, $sorted->toList()); + } + + /** + * Tests sorting numbers with custom callback + */ + public function testSortNumbersCustom(): void + { + $items = new ArrayObject([3, 5, 1, 2, 4]); + $callback = function ($a) { + return $a * -1; + }; + $sorted = new SortIterator($items, $callback); + $expected = range(1, 5); + $this->assertEquals($expected, $sorted->toList()); + + $sorted = new SortIterator($items, $callback, SORT_ASC); + $expected = range(5, 1); + $this->assertEquals($expected, $sorted->toList()); + } + + /** + * Tests sorting a complex structure with numeric sort + */ + public function testSortComplexNumeric(): void + { + $items = new ArrayObject([ + ['foo' => 1, 'bar' => 'a'], + ['foo' => 10, 'bar' => 'a'], + ['foo' => 2, 'bar' => 'a'], + ['foo' => 13, 'bar' => 'a'], + ]); + $callback = function ($a) { + return $a['foo']; + }; + $sorted = new SortIterator($items, $callback, SORT_DESC, SORT_NUMERIC); + $expected = [ + ['foo' => 13, 'bar' => 'a'], + ['foo' => 10, 'bar' => 'a'], + ['foo' => 2, 'bar' => 'a'], + ['foo' => 1, 'bar' => 'a'], + ]; + $this->assertEquals($expected, $sorted->toList()); + + $sorted = new SortIterator($items, $callback, SORT_ASC, SORT_NUMERIC); + $expected = [ + ['foo' => 1, 'bar' => 'a'], + ['foo' => 2, 'bar' => 'a'], + ['foo' => 10, 'bar' => 'a'], + ['foo' => 13, 'bar' => 'a'], + ]; + $this->assertEquals($expected, $sorted->toList()); + } + + /** + * Tests sorting a complex structure with natural sort + */ + public function testSortComplexNatural(): void + { + $items = new ArrayObject([ + ['foo' => 'foo_1', 'bar' => 'a'], + ['foo' => 'foo_10', 'bar' => 'a'], + ['foo' => 'foo_2', 'bar' => 'a'], + ['foo' => 'foo_13', 'bar' => 'a'], + ]); + $callback = function ($a) { + return $a['foo']; + }; + $sorted = new SortIterator($items, $callback, SORT_DESC, SORT_NATURAL); + $expected = [ + ['foo' => 'foo_13', 'bar' => 'a'], + ['foo' => 'foo_10', 'bar' => 'a'], + ['foo' => 'foo_2', 'bar' => 'a'], + ['foo' => 'foo_1', 'bar' => 'a'], + ]; + $this->assertEquals($expected, $sorted->toList()); + + $sorted = new SortIterator($items, $callback, SORT_ASC, SORT_NATURAL); + $expected = [ + ['foo' => 'foo_1', 'bar' => 'a'], + ['foo' => 'foo_2', 'bar' => 'a'], + ['foo' => 'foo_10', 'bar' => 'a'], + ['foo' => 'foo_13', 'bar' => 'a'], + ]; + $this->assertEquals($expected, $sorted->toList()); + $this->assertEquals($expected, $sorted->toList(), 'Iterator should rewind'); + } + + /** + * Tests sorting a complex structure with natural sort with string callback + */ + public function testSortComplexNaturalWithPath(): void + { + $items = new ArrayObject([ + ['foo' => 'foo_1', 'bar' => 'a'], + ['foo' => 'foo_10', 'bar' => 'a'], + ['foo' => 'foo_2', 'bar' => 'a'], + ['foo' => 'foo_13', 'bar' => 'a'], + ]); + $sorted = new SortIterator($items, 'foo', SORT_DESC, SORT_NATURAL); + $expected = [ + ['foo' => 'foo_13', 'bar' => 'a'], + ['foo' => 'foo_10', 'bar' => 'a'], + ['foo' => 'foo_2', 'bar' => 'a'], + ['foo' => 'foo_1', 'bar' => 'a'], + ]; + $this->assertEquals($expected, $sorted->toList()); + + $sorted = new SortIterator($items, 'foo', SORT_ASC, SORT_NATURAL); + $expected = [ + ['foo' => 'foo_1', 'bar' => 'a'], + ['foo' => 'foo_2', 'bar' => 'a'], + ['foo' => 'foo_10', 'bar' => 'a'], + ['foo' => 'foo_13', 'bar' => 'a'], + ]; + $this->assertEquals($expected, $sorted->toList()); + $this->assertEquals($expected, $sorted->toList(), 'Iterator should rewind'); + } + + /** + * Tests sorting a complex structure with a deep path + */ + public function testSortComplexDeepPath(): void + { + $items = new ArrayObject([ + ['foo' => ['bar' => 1], 'bar' => 'a'], + ['foo' => ['bar' => 12], 'bar' => 'a'], + ['foo' => ['bar' => 10], 'bar' => 'a'], + ['foo' => ['bar' => 2], 'bar' => 'a'], + ]); + $sorted = new SortIterator($items, 'foo.bar', SORT_ASC, SORT_NUMERIC); + $expected = [ + ['foo' => ['bar' => 1], 'bar' => 'a'], + ['foo' => ['bar' => 2], 'bar' => 'a'], + ['foo' => ['bar' => 10], 'bar' => 'a'], + ['foo' => ['bar' => 12], 'bar' => 'a'], + ]; + $this->assertEquals($expected, $sorted->toList()); + } + + /** + * Tests sorting datetime + */ + public function testSortDateTime(): void + { + $items = new ArrayObject([ + new DateTime('2014-07-21'), + new DateTime('2015-06-30'), + new DateTimeImmutable('2013-08-12'), + ]); + + $callback = function ($a) { + 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'), + + ]; + $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 ChronosTime('12:00:00'), + new ChronosTime('10:00:01'), + new ChronosTime('11:00:00'), + ]); + + $sorted = new SortIterator($items, $callback, SORT_ASC); + $expected = [ + 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 new file mode 100644 index 00000000000..092ba74ffa8 --- /dev/null +++ b/tests/TestCase/Collection/Iterator/TreeIteratorTest.php @@ -0,0 +1,112 @@ + 1, + 'name' => 'a', + 'stuff' => [ + ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]], + ], + ], + ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]], + ]; + $items = new NestIterator($items, 'stuff'); + $result = (new TreeIterator($items))->printer('name')->toArray(); + $expected = [ + 'a', + '__b', + '____c', + 'd', + '__e', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the printer function with a custom key extractor and spacer + */ + public function testPrinterCustomKeyAndSpacer(): void + { + $items = [ + [ + 'id' => 1, + 'name' => 'a', + 'stuff' => [ + ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]], + ], + ], + ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]], + ]; + $items = new NestIterator($items, 'stuff'); + $result = (new TreeIterator($items))->printer('id', 'name', '@@')->toArray(); + $expected = [ + 'a' => '1', + 'b' => '@@2', + 'c' => '@@@@3', + 'd' => '4', + 'e' => '@@5', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the printer function with a closure extractor + */ + public function testPrinterWithClosure(): void + { + $items = [ + [ + 'id' => 1, + 'name' => 'a', + 'stuff' => [ + ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]], + ], + ], + ['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, '') + ->toArray(); + $expected = [ + '1.0 a', + '2.0 b', + '3.0 c', + '1.1 d', + '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..38a842c50ef --- /dev/null +++ b/tests/TestCase/Command/CompletionCommandTest.php @@ -0,0 +1,399 @@ +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', + 'help', + '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/Helper/BannerHelperTest.php b/tests/TestCase/Command/Helper/BannerHelperTest.php new file mode 100644 index 00000000000..b2d2b49798c --- /dev/null +++ b/tests/TestCase/Command/Helper/BannerHelperTest.php @@ -0,0 +1,124 @@ +stub = new StubConsoleOutput(); + $this->io = new ConsoleIo($this->stub); + $this->helper = new BannerHelper($this->io); + } + + /** + * Test that the callback is invoked until 100 is reached. + */ + public function testOutputInvalidPadding(): void + { + $this->expectException(InvalidArgumentException::class); + $this->helper->withPadding(-1); + } + + /** + * Test output with all options + */ + public function testOutputSuccess(): void + { + $this->helper + ->withPadding(5) + ->withStyle('info.bg') + ->output(['All done']); + $expected = [ + '', + ' ', + ' All done ', + ' ', + '', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test that width is respected + */ + public function testOutputPadding(): void + { + $this->helper + ->withPadding(1) + ->withStyle('info.bg') + ->output(['All done']); + $expected = [ + '', + ' ', + ' All done ', + ' ', + '', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test that width is respected + */ + public function testOutputLongestLine(): void + { + $this->helper + ->withPadding(1) + ->withStyle('info.bg') + ->output(['All done', 'This line is longer', 'tiny']); + $expected = [ + '', + ' ', + ' All done ', + ' This line is longer ', + ' tiny ', + ' ', + '', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } +} diff --git a/tests/TestCase/Command/Helper/ProgressHelperTest.php b/tests/TestCase/Command/Helper/ProgressHelperTest.php new file mode 100644 index 00000000000..2da9d9c0958 --- /dev/null +++ b/tests/TestCase/Command/Helper/ProgressHelperTest.php @@ -0,0 +1,294 @@ +stub = new StubConsoleOutput(); + $this->io = new ConsoleIo($this->stub); + $this->helper = new ProgressHelper($this->io); + } + + /** + * Test using the helper manually. + */ + public function testInit(): void + { + $helper = $this->helper->init([ + 'total' => 200, + 'width' => 50, + ]); + $this->assertSame($helper, $this->helper, 'Should be chainable'); + } + + public function testIncrementWithoutInit(): void + { + $this->helper->increment(10); + $this->helper->draw(); + $expected = [ + '', + '======> 10%', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test that a callback is required. + */ + public function testOutputFailure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->helper->output(['not a callback']); + } + + /** + * Test that the callback is invoked until 100 is reached. + */ + public function testOutputSuccess(): void + { + $this->helper->output([function (ProgressHelper $progress): void { + $progress->increment(20); + }]); + $expected = [ + '', + '', + '==============> 20%', + '', + '=============================> 40%', + '', + '============================================> 60%', + '', + '===========================================================> 80%', + '', + '==========================================================================> 100%', + '', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with options + */ + public function testOutputSuccessOptions(): void + { + $this->helper->output([ + 'total' => 10, + 'width' => 20, + 'callback' => function (ProgressHelper $progress): void { + $progress->increment(2); + }, + ]); + $expected = [ + '', + '', + '==> 20%', + '', + '=====> 40%', + '', + '========> 60%', + '', + '===========> 80%', + '', + '==============> 100%', + '', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test using the helper manually. + */ + public function testIncrementAndRender(): void + { + $this->helper->init(); + + $this->helper->increment(20); + $this->helper->draw(); + + $this->helper->increment(40.0); + $this->helper->draw(); + + $this->helper->increment(40); + $this->helper->draw(); + + $expected = [ + '', + '==============> 20%', + '', + '============================================> 60%', + '', + '==========================================================================> 100%', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test using the helper chained. + */ + public function testIncrementAndRenderChained(): void + { + $this->helper->init() + ->increment(20) + ->draw() + ->increment(40) + ->draw() + ->increment(40) + ->draw(); + + $expected = [ + '', + '==============> 20%', + '', + '============================================> 60%', + '', + '==========================================================================> 100%', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test negative numbers + */ + public function testIncrementWithNegatives(): void + { + $this->helper->init(); + + $this->helper->increment(40); + $this->helper->draw(); + + $this->helper->increment(-60); + $this->helper->draw(); + + $this->helper->increment(80); + $this->helper->draw(); + + $expected = [ + '', + '=============================> 40%', + '', + ' 0%', + '', + '===========================================================> 80%', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test increment and draw with options + */ + public function testIncrementWithOptions(): void + { + $this->helper->init([ + 'total' => 10, + 'width' => 20, + ]); + $expected = [ + '', + '=====> 40%', + '', + '===========> 80%', + '', + '==============> 100%', + ]; + $this->helper->increment(4); + $this->helper->draw(); + $this->helper->increment(4); + $this->helper->draw(); + $this->helper->increment(4); + $this->helper->draw(); + + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test increment and draw with value that makes the pad + * be a float + */ + public function testIncrementFloatPad(): void + { + $this->helper->init([ + 'total' => 50, + ]); + $expected = [ + '', + '=========> 14%', + '', + '====================> 28%', + '', + '==============================> 42%', + '', + '=========================================> 56%', + '', + '===================================================> 70%', + '', + '========================================================> 76%', + '', + '==============================================================> 84%', + '', + '==========================================================================> 100%', + ]; + $this->helper->increment(7); + $this->helper->draw(); + $this->helper->increment(7); + $this->helper->draw(); + $this->helper->increment(7); + $this->helper->draw(); + $this->helper->increment(7); + $this->helper->draw(); + $this->helper->increment(7); + $this->helper->draw(); + $this->helper->increment(3); + $this->helper->draw(); + $this->helper->increment(4); + $this->helper->draw(); + $this->helper->increment(8); + $this->helper->draw(); + + $this->assertEquals($expected, $this->stub->messages()); + } +} diff --git a/tests/TestCase/Command/Helper/TableHelperTest.php b/tests/TestCase/Command/Helper/TableHelperTest.php new file mode 100644 index 00000000000..77a8067efb5 --- /dev/null +++ b/tests/TestCase/Command/Helper/TableHelperTest.php @@ -0,0 +1,452 @@ +stub = new StubConsoleOutput(); + $this->io = new ConsoleIo($this->stub); + $this->helper = new TableHelper($this->io); + } + + /** + * Test output + */ + public function testOutputDefaultOutput(): void + { + $data = [ + ['Header 1', 'Header', 'Long Header'], + ['short', 'Longish thing', 'short'], + ['Longer thing', 'short', 'Longest Value'], + ]; + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| Header 1 | Header | Long Header |', + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with inconsistent keys. + * + * When outputting entities or other structured data, + * headers shouldn't need to have the same keys as it is + * annoying to use. + */ + public function testOutputInconsistentKeys(): void + { + $data = [ + ['Header 1', 'Header', 'Long Header'], + ['a' => 'short', 'b' => 'Longish thing', 'c' => 'short'], + ['c' => 'Longer thing', 'a' => 'short', 'b' => 'Longest Value'], + ]; + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| Header 1 | Header | Long Header |', + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test that output works when data contains just empty strings. + */ + public function testOutputEmptyStrings(): void + { + $data = [ + ['Header 1', 'Header', 'Empty'], + ['short', 'Longish thing', ''], + ['Longer thing', 'short', ''], + ]; + $this->helper->output($data); + $expected = [ + '+--------------+---------------+-------+', + '| Header 1 | Header | Empty |', + '+--------------+---------------+-------+', + '| short | Longish thing | |', + '| Longer thing | short | |', + '+--------------+---------------+-------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test that output works when data contains nulls. + */ + public function testNullValues(): void + { + $data = [ + ['Header 1', 'Header', 'Empty'], + ['short', 'Longish thing', null], + ['Longer thing', 'short', null], + ]; + $this->helper->output($data); + $expected = [ + '+--------------+---------------+-------+', + '| Header 1 | Header | Empty |', + '+--------------+---------------+-------+', + '| short | Longish thing | |', + '| Longer thing | short | |', + '+--------------+---------------+-------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with multi-byte characters + */ + public function testOutputUtf8(): void + { + $data = [ + ['Header 1', 'Head', 'Long Header'], + ['short', 'ÄÄÄÜÜÜ', 'short'], + ['Longer thing', 'longerish', 'Longest Value'], + ]; + $this->helper->output($data); + $expected = [ + '+--------------+-----------+---------------+', + '| Header 1 | Head | Long Header |', + '+--------------+-----------+---------------+', + '| short | ÄÄÄÜÜÜ | short |', + '| Longer thing | longerish | Longest Value |', + '+--------------+-----------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with multi-byte characters + */ + public function testOutputFullwidth(): void + { + $data = [ + ['Header 1', 'Head', 'Long Header'], + ['short', '竜頭蛇尾', 'short'], + ['Longer thing', 'longerish', 'Longest Value'], + ]; + $this->helper->output($data); + $expected = [ + '+--------------+-----------+---------------+', + '| Header 1 | Head | Long Header |', + '+--------------+-----------+---------------+', + '| short | 竜頭蛇尾 | short |', + '| Longer thing | longerish | Longest Value |', + '+--------------+-----------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output without headers + */ + public function testOutputWithoutHeaderStyle(): void + { + $data = [ + ['Header 1', 'Header', 'Long Header'], + ['short', 'Longish thing', 'short'], + ['Longer thing', 'short', 'Longest Value'], + ]; + $this->helper->setConfig(['headerStyle' => false]); + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| Header 1 | Header | Long Header |', + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with different header style + */ + public function testOutputWithDifferentHeaderStyle(): void + { + $data = [ + ['Header 1', 'Header', 'Long Header'], + ['short', 'Longish thing', 'short'], + ['Longer thing', 'short', 'Longest Value'], + ]; + $this->helper->setConfig(['headerStyle' => 'error']); + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| Header 1 | Header | Long Header |', + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output without table headers + */ + public function testOutputWithoutHeaders(): void + { + $data = [ + ['short', 'Longish thing', 'short'], + ['Longer thing', 'short', 'Longest Value'], + ]; + $this->helper->setConfig(['headers' => false]); + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with formatted cells + */ + public function testOutputWithFormattedCells(): void + { + $data = [ + ['short', 'Longish thing', 'short'], + ['Longer thing', 'short', 'Longest Value'], + ]; + $this->helper->setConfig(['headers' => false]); + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with row separator + */ + public function testOutputWithRowSeparator(): void + { + $data = [ + ['Header 1', 'Header', 'Long Header'], + ['short', 'Longish thing', 'short'], + ['Longer thing', 'short', 'Longest Value'], + ]; + $this->helper->setConfig(['rowSeparator' => true]); + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| Header 1 | Header | Long Header |', + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '+--------------+---------------+---------------+', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output with row separator and no headers + */ + public function testOutputWithRowSeparatorAndHeaders(): void + { + $data = [ + ['Header 1', 'Header', 'Long Header'], + ['short', 'Longish thing', 'short'], + ['Longer thing', 'short', 'Longest Value'], + ]; + $this->helper->setConfig(['rowSeparator' => true]); + $this->helper->output($data); + $expected = [ + '+--------------+---------------+---------------+', + '| Header 1 | Header | Long Header |', + '+--------------+---------------+---------------+', + '| short | Longish thing | short |', + '+--------------+---------------+---------------+', + '| Longer thing | short | Longest Value |', + '+--------------+---------------+---------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test output when there is no data. + */ + public function testOutputWithNoData(): void + { + $this->helper->output([]); + $this->assertEquals([], $this->stub->messages()); + } + + /** + * Test output with a header but no data. + */ + public function testOutputWithHeaderAndNoData(): void + { + $data = [ + ['Header 1', 'Header', 'Long Header'], + ]; + $this->helper->output($data); + $expected = [ + '+----------+--------+-------------+', + '| Header 1 | Header | Long Header |', + '+----------+--------+-------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Test no data when headers are disabled. + */ + public function testOutputHeaderDisabledNoData(): void + { + $this->helper->setConfig(['header' => false]); + $this->helper->output([]); + $this->assertEquals([], $this->stub->messages()); + } + + /** + * Right-aligned text style test. + */ + public function testTextRightStyle(): void + { + $data = [ + ['Item', 'Price per piece (yen)'], + ['Apple', '¥ 200'], + ['Orange', '100'], + ]; + $this->helper->output($data); + $expected = [ + '+--------+-----------------------+', + '| Item | Price per piece (yen) |', + '+--------+-----------------------+', + '| Apple | ¥ 200 |', + '| Orange | 100 |', + '+--------+-----------------------+', + ]; + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Right-aligned text style test.(If there is text rightside the text-right tag) + */ + public function testTextRightsideTheTextRightTag(): void + { + $this->expectException(UnexpectedValueException::class); + $data = [ + ['Item', 'Price per piece (yen)'], + ['Apple', 'sometext'], + ]; + $this->helper->output($data); + } + + /** + * Right-aligned text style test.(If there is text leftside the text-right tag) + */ + public function testTextLeftsideTheTextRightTag(): void + { + $this->expectException(UnexpectedValueException::class); + $data = [ + ['Item', 'Price per piece (yen)'], + ['Apple', 'textsome'], + ]; + $this->helper->output($data); + } + + /** + * Table row column of type integer should be cast to string + */ + public function testRowValueInteger(): void + { + $data = [ + ['Item', 'Quantity'], + ['Cakes', 2], + ]; + $this->helper->output($data); + $expected = [ + '+-------+----------+', + '| Item | Quantity |', + '+-------+----------+', + '| Cakes | 2 |', + '+-------+----------+', + ]; + + $this->assertEquals($expected, $this->stub->messages()); + } + + /** + * Table row column of type null should be cast to empty string + */ + public function testRowValueNull(): void + { + $data = [ + ['Item', 'Quantity'], + ['Cakes', null], + ]; + $this->helper->output($data); + $expected = [ + '+-------+----------+', + '| Item | Quantity |', + '+-------+----------+', + '| Cakes | |', + '+-------+----------+', + ]; + + $this->assertEquals($expected, $this->stub->messages()); + } +} diff --git a/tests/TestCase/Command/Helper/TreeHelperTest.php b/tests/TestCase/Command/Helper/TreeHelperTest.php new file mode 100644 index 00000000000..d9c7cb13a23 --- /dev/null +++ b/tests/TestCase/Command/Helper/TreeHelperTest.php @@ -0,0 +1,148 @@ +stub = new StubConsoleOutput(); + $this->io = new ConsoleIo($this->stub); + $this->helper = new TreeHelper($this->io); + } + + public function testEmptyTree(): void + { + $this->helper->output([]); + $this->assertEquals([], $this->stub->messages()); + } + + public function testSingleList(): void + { + $this->helper->output(['one', 'two', 'three']); + $this->assertEquals([ + '├── one', + '├── two', + '└── three', + ], $this->stub->messages()); + } + + public function testNestedTree(): void + { + $this->helper->output(['one', 'two' => ['two_1' => ['two_1_1', 'two_1_2'], 'two_2' => ['two_2_1', 'two_2_2']]]); + $this->assertEquals([ + '├── one', + '└── two', + ' ├── two_1', + ' │ ├── two_1_1', + ' │ └── two_1_2', + ' └── two_2', + ' ├── two_2_1', + ' └── two_2_2', + ], $this->stub->messages()); + } + + public function testNestedTreeCustomIndent(): void + { + $this->helper->setConfig(['baseIndent' => 1, 'elementIndent' => 2]); + $this->helper->output(['one', 'two' => ['two_1' => ['two_1_1', 'two_1_2'], 'two_2' => ['two_2_1', 'two_2_2']]]); + $this->assertEquals([ + ' ├── one', + ' └── two', + ' ├── two_1', + ' │ ├── two_1_1', + ' │ └── two_1_2', + ' └── two_2', + ' ├── two_2_1', + ' └── two_2_2', + ], $this->stub->messages()); + } + + public function testClosureValue(): void + { + $this->helper->output([fn() => 'from closure']); + $this->assertEquals([ + '└── from closure', + ], $this->stub->messages()); + } + + public function testEnumValue(): void + { + $this->helper->output([NonBacked::Basic, Gender::NoSelection, Priority::Low]); + $this->assertEquals([ + '├── Basic', + '├── ', + '└── Is Low', + ], $this->stub->messages()); + } + + public function testBoolValue(): void + { + $this->helper->output([true, false]); + $this->assertEquals([ + '├── true', + '└── false', + ], $this->stub->messages()); + } + + public function testStringbleValue(): void + { + $c = new class () implements Stringable { + public function __toString(): string + { + return 'from stringable'; + } + }; + + $this->helper->output([new $c()]); + $this->assertEquals([ + '└── from stringable', + ], $this->stub->messages()); + } +} 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..ea6f30624b2 --- /dev/null +++ b/tests/TestCase/Command/I18nExtractCommandTest.php @@ -0,0 +1,428 @@ +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->assertExitSuccess(); + + $this->assertFileExists($this->path . DS . 'cake.pot'); + $result = file_get_contents($this->path . DS . 'cake.pot'); + + $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 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..1530c0eb20d --- /dev/null +++ b/tests/TestCase/Command/PluginListCommandTest.php @@ -0,0 +1,217 @@ +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(); + 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/Command/HelpCommandTest.php b/tests/TestCase/Console/Command/HelpCommandTest.php new file mode 100644 index 00000000000..a95760a25a9 --- /dev/null +++ b/tests/TestCase/Console/Command/HelpCommandTest.php @@ -0,0 +1,168 @@ +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->assertCommandListVerbose(); + } + + /** + * Test the compact command listing (default) + */ + public function testMainCompact(): void + { + $this->exec('help'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Available Commands:', 'single commands header'); + $this->assertOutputContains('routes:', 'routes group header'); + $this->assertOutputContains('cache:', 'cache group header'); + $this->assertOutputContains('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->assertOutputContains('To run a command', 'more info present'); + } + + /** + * 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( + 'HiddenCommand', + 'Hidden commands should not appear in XML output.', + ); + } + + /** + * Test that hidden commands are still executable + */ + public function testHiddenCommandStillExecutable(): void + { + $this->exec('hidden'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Hidden Command Executed!'); + } +} diff --git a/tests/TestCase/Console/CommandCollectionTest.php b/tests/TestCase/Console/CommandCollectionTest.php new file mode 100644 index 00000000000..ec906dae082 --- /dev/null +++ b/tests/TestCase/Console/CommandCollectionTest.php @@ -0,0 +1,365 @@ +clearPlugins(); + } + + /** + * Test constructor with valid classnames + */ + public function testConstructor(): void + { + $collection = new CommandCollection([ + 'sample' => SampleCommand::class, + 'routes' => RoutesCommand::class, + ]); + $this->assertTrue($collection->has('routes')); + $this->assertTrue($collection->has('sample')); + $this->assertCount(2, $collection); + } + + /** + * Test basic add/get + */ + public function testAdd(): void + { + $collection = new CommandCollection(); + $this->assertSame($collection, $collection->add('routes', RoutesCommand::class)); + $this->assertTrue($collection->has('routes')); + $this->assertSame(RoutesCommand::class, $collection->get('routes')); + } + + /** + * test adding a command instance. + */ + public function testAddCommand(): void + { + $collection = new CommandCollection(); + $this->assertSame($collection, $collection->add('ex', DemoCommand::class)); + $this->assertTrue($collection->has('ex')); + $this->assertSame(DemoCommand::class, $collection->get('ex')); + } + + /** + * Test that add() replaces. + */ + public function testAddReplace(): void + { + $collection = new CommandCollection(); + $this->assertSame($collection, $collection->add('routes', RoutesCommand::class)); + $this->assertSame($collection, $collection->add('routes', SampleCommand::class)); + $this->assertTrue($collection->has('routes')); + $this->assertSame(SampleCommand::class, $collection->get('routes')); + } + + /** + * Test adding with instances + */ + public function testAddInstance(): void + { + $collection = new CommandCollection(); + $command = new RoutesCommand(); + $collection->add('routes', $command); + + $this->assertTrue($collection->has('routes')); + $this->assertSame($command, $collection->get('routes')); + } + + /** + * Provider for invalid names. + * + * @return array + */ + public static function invalidNameProvider(): array + { + return [ + // Empty + [''], + // Leading spaces + [' spaced'], + // Trailing spaces + ['spaced '], + // Too many words + ['one two three four'], + ]; + } + + /** + * test adding a command instance. + */ + #[DataProvider('invalidNameProvider')] + public function testAddCommandInvalidName(string $name): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The command name `{$name}` is invalid."); + $collection = new CommandCollection(); + $collection->add($name, DemoCommand::class); + } + + /** + * Test removing a command + */ + public function testRemove(): void + { + $collection = new CommandCollection(); + $collection->add('routes', RoutesCommand::class); + $this->assertSame($collection, $collection->remove('routes')); + $this->assertFalse($collection->has('routes')); + } + + /** + * Removing an unknown command does not fail + */ + public function testRemoveUnknown(): void + { + $collection = new CommandCollection(); + $this->assertSame($collection, $collection->remove('nope')); + $this->assertFalse($collection->has('nope')); + } + + /** + * Test replacing a command + * + * @return void + */ + public function testReplace(): void + { + $oldName = 'routes'; + $newName = 'routing'; + $collection = new CommandCollection(); + $collection->add($oldName, RoutesCommand::class); + $collection->replace($oldName, $newName, RoutesCommand::class); + $this->assertFalse($collection->has($oldName)); + $this->assertTrue($collection->has($newName)); + $this->assertSame(RoutesCommand::class, $collection->get($newName)); + } + + /** + * Test replacing with an instance of a command + * + * @return void + */ + public function testReplaceInstance(): void + { + $oldName = 'routes'; + $newName = 'routing'; + $collection = new CommandCollection(); + $command = new RoutesCommand(); + $collection->add($oldName, $command); + $collection->replace($oldName, $newName, $command); + $this->assertFalse($collection->has($oldName)); + $this->assertTrue($collection->has($newName)); + $this->assertSame($command, $collection->get($newName)); + } + + /** + * Test replacing a command instance with an invalid name. + */ + public function testReplaceCommandInvalidName(): void + { + $oldName = 'routes'; + $newName = ' spaced'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The command name `{$newName}` is invalid."); + $collection = new CommandCollection(); + $collection->replace($oldName, $newName, RoutesCommand::class); + } + + /** + * Test has + * + * @return void + */ + public function testHas(): void + { + $collection = new CommandCollection(); + $collection->add('routes', RoutesCommand::class); + $this->assertTrue($collection->has('routes')); + } + + /** + * Test get + * + * @return void + */ + public function testGet(): void + { + $collection = new CommandCollection(); + $collection->add('routes', RoutesCommand::class); + $this->assertSame(RoutesCommand::class, $collection->get('routes')); + } + + /** + * Test get invalid + * + * @return void + */ + public function testGetInvalid(): void + { + $collection = new CommandCollection(); + $this->expectExceptionMessage('The `invalid` is not a known command name.'); + $collection->get('invalid'); + } + + /** + * test getIterator + */ + public function testGetIterator(): void + { + $in = [ + 'sample' => SampleCommand::class, + 'routes' => RoutesCommand::class, + ]; + $collection = new CommandCollection($in); + $out = []; + foreach ($collection as $key => $value) { + $out[$key] = $value; + } + $this->assertEquals($in, $out); + } + + /** + * test autodiscovering app shells + */ + public function testAutoDiscoverApp(): void + { + $collection = new CommandCollection(); + $collection->addMany($collection->autoDiscover()); + + $this->assertTrue($collection->has('demo')); + $this->assertTrue($collection->has('sample')); + + $this->assertSame(DemoCommand::class, $collection->get('demo')); + $this->assertSame(SampleCommand::class, $collection->get('sample')); + } + + /** + * test autodiscovering core shells + */ + public function testAutoDiscoverCore(): void + { + $collection = new CommandCollection(); + $collection->addMany($collection->autoDiscover()); + + $this->assertTrue($collection->has('version')); + $this->assertTrue($collection->has('routes')); + $this->assertTrue($collection->has('sample')); + $this->assertTrue($collection->has('schema_cache build')); + $this->assertTrue($collection->has('schema_cache clear')); + $this->assertTrue($collection->has('server')); + $this->assertTrue($collection->has('cache clear')); + $this->assertFalse($collection->has('command_list'), 'Hidden commands should stay hidden'); + + // These have to be strings as ::class uses the local namespace. + $this->assertSame(RoutesCommand::class, $collection->get('routes')); + $this->assertSame(SampleCommand::class, $collection->get('sample')); + $this->assertSame(VersionCommand::class, $collection->get('version')); + } + + /** + * test missing plugin discovery + */ + public function testDiscoverPluginUnknown(): void + { + $collection = new CommandCollection(); + $this->assertSame([], $collection->discoverPlugin('Nope')); + } + + /** + * test autodiscovering plugin shells + */ + public function testDiscoverPlugin(): void + { + $this->loadPlugins(['TestPlugin', 'Company/TestPluginThree']); + + $collection = new CommandCollection(); + // Add a dupe to test de-duping + $collection->add('sample', DemoCommand::class); + + $result = $collection->discoverPlugin('TestPlugin'); + + $this->assertArrayHasKey( + 'example', + $result, + 'Used short name for unique plugin shell', + ); + $this->assertArrayHasKey( + 'test_plugin.example', + $result, + 'Long names are stored for unique shells', + ); + $this->assertArrayNotHasKey('sample', $result, 'Existing command not output'); + $this->assertArrayHasKey( + 'test_plugin.sample', + $result, + 'Duplicate shell was given a full alias', + ); + $this->assertSame(ExampleCommand::class, $result['example']); + $this->assertSame($result['example'], $result['test_plugin.example']); + $this->assertSame(PluginSampleCommand::class, $result['test_plugin.sample']); + + $result = $collection->discoverPlugin('Company/TestPluginThree'); + $this->assertArrayHasKey( + 'company', + $result, + 'Used short name for unique plugin shell', + ); + $this->assertArrayHasKey( + 'company/test_plugin_three.company', + $result, + 'Long names are stored as well', + ); + $this->assertSame($result['company'], $result['company/test_plugin_three.company']); + $this->clearPlugins(); + } + + /** + * Test keys + */ + public function testKeys(): void + { + $collection = new CommandCollection(); + $collection->add('demo', DemoCommand::class); + $collection->add('demo sample', DemoCommand::class); + $collection->add('dang', DemoCommand::class); + + $result = $collection->keys(); + $this->assertSame(['demo', 'demo sample', 'dang'], $result); + } +} diff --git a/tests/TestCase/Console/CommandFactoryTest.php b/tests/TestCase/Console/CommandFactoryTest.php new file mode 100644 index 00000000000..30b5586c35c --- /dev/null +++ b/tests/TestCase/Console/CommandFactoryTest.php @@ -0,0 +1,48 @@ +create(DemoCommand::class); + $this->assertInstanceOf(DemoCommand::class, $command); + $this->assertInstanceOf(CommandInterface::class, $command); + } + + public function testCreateCommandDependencies(): void + { + $container = new Container(); + $container->add(stdClass::class, json_decode('{"key":"value"}')); + $container->add(DependencyCommand::class) + ->addArgument(stdClass::class); + $factory = new CommandFactory($container); + + $command = $factory->create(DependencyCommand::class); + $this->assertInstanceOf(DependencyCommand::class, $command); + $this->assertInstanceOf(stdClass::class, $command->inject); + } +} diff --git a/tests/TestCase/Console/CommandRunnerTest.php b/tests/TestCase/Console/CommandRunnerTest.php new file mode 100644 index 00000000000..f21dc9a5146 --- /dev/null +++ b/tests/TestCase/Console/CommandRunnerTest.php @@ -0,0 +1,610 @@ +config = CONFIG; + } + + /** + * test event manager proxies to the application. + */ + public function testEventManagerProxies(): void + { + $app = new class ($this->config) extends BaseApplication + { + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + + $runner = new CommandRunner($app); + $this->assertSame($app->getEventManager(), $runner->getEventManager()); + } + + /** + * test event manager cannot be set on applications without events. + */ + public function testGetEventManagerNonEventedApplication(): void + { + $runner = $this->getRunner(); + $this->assertSame(EventManager::instance(), $runner->getEventManager()); + } + + /** + * Test that running an unknown command raises an error. + */ + public function testRunInvalidCommand(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $runner->run(['cake', 'nope', 'nope', 'nope'], $this->getMockIo($output)); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString( + 'Unknown command `cake nope`. Run `cake --help` to get the list of commands.', + $messages, + ); + } + + /** + * Test that using special characters in an unknown command does + * not cause a PHP error. + */ + public function testRunInvalidCommandWithSpecialCharacters(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $runner->run(['cake', 's/pec[ial'], $this->getMockIo($output)); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString( + 'Unknown command `cake s/pec[ial`. Run `cake --help` to get the list of commands.', + $messages, + ); + } + + /** + * Test that running a command prefix shows help for those commands. + */ + public function testRunCommandPrefixShowsHelp(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $result = $runner->run(['cake', 'cache'], $this->getMockIo($output)); + + $this->assertSame(0, $result); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('cache:', $messages); + $this->assertStringContainsString('cache clear', $messages); + $this->assertStringContainsString('cache list', $messages); + } + + /** + * Test that running an invalid subcommand shows help listing commands + * that start with the given prefix. + */ + public function testRunInvalidSubcommandShowsPrefixHelp(): void + { + $output = new StubConsoleOutput(); + $app = $this->makeAppWithCommands([ + 'help' => HelpCommand::class, + 'schema_cache build' => SchemacacheBuildCommand::class, + 'schema_cache clear' => SchemacacheClearCommand::class, + ]); + $runner = new CommandRunner($app); + $result = $runner->run(['cake', 'schema_cache', 'invalid'], $this->getMockIo($output)); + + $this->assertSame(0, $result); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('schema_cache:', $messages); + $this->assertStringContainsString('schema_cache build', $messages); + $this->assertStringContainsString('schema_cache clear', $messages); + } + + /** + * Test using `cake --help` invokes the help command + */ + public function testRunHelpLongOption(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $result = $runner->run(['cake', '--help'], $this->getMockIo($output)); + $this->assertSame(0, $result); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('i18n:', $messages); + $this->assertStringContainsString('Available Commands:', $messages); + } + + /** + * Test using `cake -h` invokes the help command + */ + public function testRunHelpShortOption(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $result = $runner->run(['cake', '-h'], $this->getMockIo($output)); + $this->assertSame(0, $result); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('i18n:', $messages); + $this->assertStringContainsString('Available Commands:', $messages); + } + + /** + * Test using `cake -v` invokes the verbose help command + */ + public function testRunVerboseShortOption(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $result = $runner->run(['cake', '-v'], $this->getMockIo($output)); + $this->assertSame(0, $result); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Available Commands:', $messages); + $this->assertStringContainsString('Current Paths', $messages, 'Verbose output should include paths'); + } + + /** + * Test that no command outputs the command list + */ + public function testRunNoCommand(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $result = $runner->run(['cake'], $this->getMockIo($output)); + + $this->assertSame(0, $result, 'help output is success.'); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('No command provided. Choose one of the available commands', $messages); + $this->assertStringContainsString('i18n:', $messages); + $this->assertStringContainsString('Available Commands:', $messages); + } + + /** + * Test using `cake --version` invokes the version command + */ + public function testRunVersionAlias(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $runner->run(['cake', '--version'], $this->getMockIo($output)); + $this->assertStringContainsString(Configure::version(), $output->messages()[0]); + } + + /** + * Test running a valid command + */ + public function testRunValidCommand(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $result = $runner->run(['cake', 'routes'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $contents = implode("\n", $output->messages()); + $this->assertStringContainsString('URI template', $contents); + } + + /** + * Test running a valid command and that backwards compatible + * inflection is hooked up. + */ + public function testRunValidCommandInflection(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $result = $runner->run(['cake', 'schema_cache', 'build'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $contents = implode("\n", $output->messages()); + $this->assertStringContainsString('Cache', $contents); + } + + /** + * Test running a valid raising an error + */ + public function testRunValidCommandWithAbort(): void + { + $app = $this->makeAppWithCommands(['failure' => AbortCommand::class]); + $output = new StubConsoleOutput(); + + $runner = new CommandRunner($app, 'cake'); + $result = $runner->run(['cake', 'failure'], $this->getMockIo($output)); + $this->assertSame(127, $result); + } + + /** + * Ensure that the root command name propagates to shell help + */ + public function testRunRootNamePropagates(): void + { + $app = $this->makeAppWithCommands(['sample' => SampleCommand::class]); + $output = new StubConsoleOutput(); + + $runner = new CommandRunner($app, 'widget'); + $runner->run(['widget', 'sample', '-h'], $this->getMockIo($output)); + $result = implode("\n", $output->messages()); + $this->assertStringContainsString('widget sample [-h]', $result); + $this->assertStringNotContainsString('cake sample [-h]', $result); + } + + /** + * Test running a valid command + */ + public function testRunValidCommandClass(): void + { + $app = $this->makeAppWithCommands(['ex' => DemoCommand::class]); + $output = new StubConsoleOutput(); + + $runner = new CommandRunner($app, 'cake'); + $result = $runner->run(['cake', 'ex'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Demo Command!', $messages); + } + + /** + * Test running a valid command with spaces in the name + */ + public function testRunValidCommandSubcommandName(): void + { + $app = $this->makeAppWithCommands([ + 'tool build' => DemoCommand::class, + 'tool' => AbortCommand::class, + ]); + $output = new StubConsoleOutput(); + + $runner = new CommandRunner($app, 'cake'); + $result = $runner->run(['cake', 'tool', 'build'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Demo Command!', $messages); + } + + /** + * Test running a valid command with spaces in the name + */ + public function testRunValidCommandNestedName(): void + { + $app = $this->makeAppWithCommands([ + 'tool build assets' => DemoCommand::class, + 'tool' => AbortCommand::class, + ]); + $output = new StubConsoleOutput(); + + $runner = new CommandRunner($app, 'cake'); + $result = $runner->run(['cake', 'tool', 'build', 'assets'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Demo Command!', $messages); + } + + /** + * Test running a valid command with spaces in the name + */ + public function testRunSubcommandNameInflection(): void + { + // Simulate typical plugin command registration. + $app = $this->makeAppWithCommands([ + 'my_plugin.tool demo' => DemoCommand::class, + 'tool demo' => DemoCommand::class, + ]); + $runner = new CommandRunner($app, 'cake'); + + // With underscore inflection + $output = new StubConsoleOutput(); + $result = $runner->run(['cake', 'my_plugin.tool', 'demo'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + // Unprefixed + $output = new StubConsoleOutput(); + $result = $runner->run(['cake', 'tool', 'demo'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + // Inflected in typical plugin casing + $output = new StubConsoleOutput(); + $result = $runner->run(['cake', 'MyPlugin.tool', 'demo'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + } + + /** + * Test using a custom factory + */ + public function testRunWithCustomFactory(): void + { + $output = new StubConsoleOutput(); + $io = $this->getMockIo($output); + $factory = Mockery::mock(CommandFactoryInterface::class); + $factory->shouldReceive('create') + ->once() + ->with(DemoCommand::class) + ->andReturn(new DemoCommand()); + + $app = $this->makeAppWithCommands(['ex' => DemoCommand::class]); + + $runner = new CommandRunner($app, 'cake', $factory); + $result = $runner->run(['cake', 'ex'], $io); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Demo Command!', $messages); + } + + public function testRunWithContainerDependencies(): void + { + $app = $this->makeAppWithCommands([ + 'dependency' => DependencyCommand::class, + ]); + $container = $app->getContainer(); + $container->add(stdClass::class, json_decode('{"key":"value"}')); + $container->add(DependencyCommand::class) + ->addArgument(stdClass::class); + + $output = new StubConsoleOutput(); + + $runner = new CommandRunner($app, 'cake'); + $result = $runner->run(['cake', 'dependency'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Dependency Command', $messages); + $this->assertStringContainsString('constructor inject: {"key":"value"}', $messages); + } + + /** + * Test running a command class' help + */ + public function testRunValidCommandClassHelp(): void + { + $app = $this->makeAppWithCommands(['ex' => DemoCommand::class]); + $output = new StubConsoleOutput(); + + $runner = new CommandRunner($app, 'cake'); + $result = $runner->run(['cake', 'ex', '-h'], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_SUCCESS, $result); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString("\ncake ex [-h]", $messages); + $this->assertStringNotContainsString('Demo Command!', $messages); + } + + /** + * Test that run() fires off the buildCommands event. + */ + public function testRunTriggersBuildCommandsEvent(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $runner->getEventManager()->on('Console.buildCommands', function ($event, $commands): void { + $this->assertInstanceOf(CommandCollection::class, $commands); + $this->eventTriggered = true; + }); + $runner->run(['cake', '--version'], $this->getMockIo($output)); + $this->assertTrue($this->eventTriggered, 'Should have triggered event.'); + } + + /** + * Test that run() fires off the Command.beforeExecute and Command.afterExecute events. + */ + public function testRunTriggersCommandEvents(): void + { + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $startedEventTriggered = false; + $finishedEventTriggered = false; + $runner->getEventManager()->on('Command.beforeExecute', function ($event, $args, $io) use (&$startedEventTriggered): void { + $this->assertInstanceOf(VersionCommand::class, $event->getSubject()); + $this->assertInstanceOf(Arguments::class, $args); + $this->assertInstanceOf(ConsoleIo::class, $io); + $startedEventTriggered = true; + }); + $runner->getEventManager()->on('Command.afterExecute', function ($event, $args, $io, $result) use (&$finishedEventTriggered): void { + $this->assertInstanceOf(VersionCommand::class, $event->getSubject()); + $this->assertInstanceOf(Arguments::class, $args); + $this->assertInstanceOf(ConsoleIo::class, $io); + $this->assertEquals(CommandInterface::CODE_SUCCESS, $result); + $finishedEventTriggered = true; + }); + $runner->run(['cake', '--version'], $this->getMockIo($output)); + $this->assertTrue($startedEventTriggered, 'Should have triggered Command.started event.'); + $this->assertTrue($finishedEventTriggered, 'Should have triggered Command.finished event.'); + } + + /** + * Test that run calls plugin hook methods + */ + public function testRunCallsPluginHookMethods(): void + { + $app = $this->getMockBuilder(BaseApplication::class) + ->onlyMethods([ + 'middleware', 'bootstrap', 'routes', + 'pluginBootstrap', 'pluginConsole', 'pluginRoutes', + ]) + ->setConstructorArgs([$this->config]) + ->getMock(); + + $app->expects($this->once())->method('bootstrap'); + $app->expects($this->once())->method('pluginBootstrap'); + + $app->expects($this->once()) + ->method('pluginConsole') + ->with($this->isinstanceOf(CommandCollection::class)) + ->willReturnCallback(function ($commands) { + return $commands; + }); + $app->expects($this->once())->method('routes'); + $app->expects($this->once())->method('pluginRoutes'); + + $output = new StubConsoleOutput(); + $runner = new CommandRunner($app, 'cake'); + $runner->run(['cake', '--version'], $this->getMockIo($output)); + $this->assertStringContainsString(Configure::version(), $output->messages()[0]); + } + + /** + * Test that run() loads routing. + */ + public function testRunLoadsRoutes(): void + { + $this->config = TEST_APP . 'config' . DS; + $output = new StubConsoleOutput(); + $runner = $this->getRunner(); + $runner->run(['cake', '--version'], $this->getMockIo($output)); + $this->assertGreaterThan(2, count(Router::getRouteCollection()->routes())); + } + + /** + * Test that events registered in EventAwareApplicationInterface methods + * are available early during command runner execution. + */ + public function testRunRegistersEventsEarly(): void + { + $output = new StubConsoleOutput(); + $app = new class ($this->config) extends Application { + public bool $customEventFired = false; + public bool $pluginEventFired = false; + + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + $eventManager->on('Test.customEvent', function (): void { + $this->customEventFired = true; + }); + + return $eventManager; + } + + public function pluginEvents(EventManagerInterface $eventManager): EventManagerInterface + { + $eventManager->on('Test.pluginEvent', function (): void { + $this->pluginEventFired = true; + }); + + return $eventManager; + } + }; + + $runner = new CommandRunner($app); + $runner->getEventManager()->on('Console.buildCommands', function () use ($runner): void { + // Trigger the events that should have been registered by events() and pluginEvents() + $runner->getEventManager()->dispatch('Test.customEvent'); + $runner->getEventManager()->dispatch('Test.pluginEvent'); + }); + + $runner->run(['cake', '--version'], $this->getMockIo($output)); + + $this->assertTrue($app->customEventFired, 'Custom event should have been fired'); + $this->assertTrue($app->pluginEventFired, 'Plugin event should have been fired'); + } + + protected function makeAppWithCommands(array $commands): BaseApplication + { + $app = $this->getMockBuilder(BaseApplication::class) + ->onlyMethods(['middleware', 'bootstrap', 'console', 'routes']) + ->setConstructorArgs([$this->config]) + ->getMock(); + $collection = new CommandCollection($commands); + $app->method('console')->willReturn($collection); + + return $app; + } + + protected function getMockIo(StubConsoleOutput $output): ConsoleIo + { + return Mockery::mock(ConsoleIo::class, [$output, $output, null, null]) + ->shouldAllowMockingMethod('in') + ->makePartial(); + } + + protected function getRunner(): CommandRunner + { + $app = new class ($this->config) extends BaseApplication { + public function bootstrap(): void + { + parent::bootstrap(); + } + + public function console(CommandCollection $commands): CommandCollection + { + parent::console($commands); + + return $commands; + } + + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + + return new CommandRunner($app); + } +} diff --git a/tests/TestCase/Console/CommandScannerTest.php b/tests/TestCase/Console/CommandScannerTest.php new file mode 100644 index 00000000000..85114df1345 --- /dev/null +++ b/tests/TestCase/Console/CommandScannerTest.php @@ -0,0 +1,64 @@ +clearPlugins(); + } + + /** + * The `file` value of a scanned plugin command must point at the real + * file, i.e. include the `Command` directory separator. Regression test + * for a missing separator that produced paths like + * `.../src/CommandFooCommand.php`. + */ + public function testScanPluginFilePath(): void + { + $this->loadPlugins(['Company/TestPluginThree']); + + $expectedFile = Plugin::classPath('Company/TestPluginThree') + . 'Command' . DIRECTORY_SEPARATOR . 'CompanyCommand.php'; + + $commandScanner = new CommandScanner(); + $result = $commandScanner->scanPlugin('Company/TestPluginThree'); + + $this->assertSame($expectedFile, $result[0]['file']); + } + + /** + * A non-existent plugin yields no commands. + */ + public function testScanPluginNonExistentPlugin(): void + { + $commandScanner = new CommandScanner(); + $this->assertSame([], $commandScanner->scanPlugin('NonExistentPlugin')); + } +} diff --git a/tests/TestCase/Console/CommandTest.php b/tests/TestCase/Console/CommandTest.php new file mode 100644 index 00000000000..dd9aedc0883 --- /dev/null +++ b/tests/TestCase/Console/CommandTest.php @@ -0,0 +1,377 @@ +getTableLocator(); + $this->assertInstanceOf(TableLocator::class, $result); + } + + /** + * test loadModel is configured properly + */ + public function testConstructorAutoLoadModel(): void + { + // No deprecation as AutoLoadModelCommand class defines Posts property + $command = new AutoLoadModelCommand(); + $this->assertInstanceOf(Table::class, $command->fetchTable()); + } + + /** + * Test name + */ + public function testSetName(): void + { + $command = new Command(); + $this->assertSame($command, $command->setName('routes show')); + $this->assertSame('routes show', $command->getName()); + $this->assertSame('routes', $command->getRootName()); + } + + /** + * Test invalid name + */ + public function testSetNameInvalid(): void + { + $this->expectException(AssertionError::class); + $this->expectExceptionMessage("The name 'routes_show' is missing a space. Names should look like `cake routes`"); + + $command = new Command(); + $command->setName('routes_show'); + } + + /** + * Test invalid name + */ + public function testSetNameInvalidLeadingSpace(): void + { + $this->expectException(AssertionError::class); + + $command = new Command(); + $command->setName(' routes_show'); + } + + /** + * Test option parser fetching + */ + public function testGetOptionParser(): void + { + $command = new Command(); + $command->setName('cake routes show'); + $parser = $command->getOptionParser(); + $this->assertInstanceOf(ConsoleOptionParser::class, $parser); + $this->assertSame('routes show', $parser->getCommand()); + } + + /** + * Test CommandHiddenInterface is not implemented by default + */ + public function testCommandHiddenInterfaceNotImplementedByDefault(): void + { + $command = new Command(); + $this->assertNotInstanceOf(CommandHiddenInterface::class, $command); + } + + /** + * Test CommandHiddenInterface can be implemented to hide commands + */ + public function testCommandHiddenInterfaceImplementation(): void + { + $this->assertInstanceOf(CommandHiddenInterface::class, new HiddenCommand()); + } + + /** + * Test that initialize is called. + */ + public function testRunCallsInitialize(): void + { + $command = new class extends Command { + public bool $initializeCalled = false; + + public function initialize(): void + { + $this->initializeCalled = true; + } + }; + $command->setName('cake example'); + $command->run([], $this->getMockIo(new StubConsoleOutput())); + $this->assertTrue($command->initializeCalled); + } + + /** + * Test run() outputs help + */ + public function testRunOutputHelp(): void + { + $command = new Command(); + $command->setName('cake demo'); + $output = new StubConsoleOutput(); + + $this->assertSame( + CommandInterface::CODE_SUCCESS, + $command->run(['-h'], $this->getMockIo($output)), + ); + $messages = implode("\n", $output->messages()); + $this->assertStringNotContainsString('Demo', $messages); + $this->assertStringContainsString('cake demo [-h]', $messages); + } + + /** + * Test run() outputs help + */ + public function testRunOutputHelpLongOption(): void + { + $command = new Command(); + $command->setName('cake demo'); + $output = new StubConsoleOutput(); + + $this->assertSame( + CommandInterface::CODE_SUCCESS, + $command->run(['--help'], $this->getMockIo($output)), + ); + $messages = implode("\n", $output->messages()); + $this->assertStringNotContainsString('Demo', $messages); + $this->assertStringContainsString('cake demo [-h]', $messages); + } + + /** + * Test run() sets output level + */ + public function testRunVerboseOption(): void + { + $command = new DemoCommand(); + $command->setName('cake demo'); + $output = new StubConsoleOutput(); + + $this->assertNull($command->run(['--verbose'], $this->getMockIo($output))); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Verbose!', $messages); + $this->assertStringContainsString('Demo Command!', $messages); + $this->assertStringContainsString('Quiet!', $messages); + $this->assertStringNotContainsString('cake demo [-h]', $messages); + } + + /** + * Test run() sets output level + */ + public function testRunQuietOption(): void + { + $command = new DemoCommand(); + $command->setName('cake demo'); + $output = new StubConsoleOutput(); + + $this->assertNull($command->run(['--quiet'], $this->getMockIo($output))); + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString('Quiet!', $messages); + $this->assertStringNotContainsString('Verbose!', $messages); + $this->assertStringNotContainsString('Demo Command!', $messages); + } + + /** + * Test run() sets option parser failure + */ + public function testRunOptionParserFailure(): void + { + $command = new class extends Command { + public function getOptionParser(): ConsoleOptionParser + { + $parser = new ConsoleOptionParser('cake example'); + $parser->addArgument('name', ['required' => true]); + + return $parser; + } + }; + + $output = new StubConsoleOutput(); + $result = $command->run([], $this->getMockIo($output)); + $this->assertSame(CommandInterface::CODE_ERROR, $result); + + $messages = implode("\n", $output->messages()); + $this->assertStringContainsString( + 'Error: Missing required argument. The `name` argument is required', + $messages, + ); + } + + /** + * Test abort() + */ + public function testAbort(): void + { + $this->expectException(StopException::class); + $this->expectExceptionCode(1); + + $command = new Command(); + $command->abort(); + } + + /** + * Test abort() + */ + public function testAbortCustomCode(): void + { + $this->expectException(StopException::class); + $this->expectExceptionCode(99); + + $command = new Command(); + $command->abort(99); + } + + /** + * test executeCommand with a string class + */ + public function testExecuteCommandString(): void + { + $output = new StubConsoleOutput(); + $command = new Command(); + $result = $command->executeCommand(DemoCommand::class, [], $this->getMockIo($output)); + $this->assertNull($result); + $this->assertEquals(['Quiet!', 'Demo Command!'], $output->messages()); + } + + /** + * test executeCommand with arguments + */ + public function testExecuteCommandArguments(): void + { + $output = new StubConsoleOutput(); + $command = new Command(); + $command->executeCommand(DemoCommand::class, ['Jane'], $this->getMockIo($output)); + $this->assertEquals(['Quiet!', 'Demo Command!', 'Jane'], $output->messages()); + } + + /** + * test executeCommand with arguments + */ + public function testExecuteCommandArgumentsOptions(): void + { + $output = new StubConsoleOutput(); + $command = new Command(); + $command->executeCommand(DemoCommand::class, ['--quiet', 'Jane'], $this->getMockIo($output)); + $this->assertEquals(['Quiet!'], $output->messages()); + } + + /** + * test executeCommand with an instance + */ + public function testExecuteCommandInstance(): void + { + $output = new StubConsoleOutput(); + $command = new Command(); + $result = $command->executeCommand(new DemoCommand(), [], $this->getMockIo($output)); + $this->assertNull($result); + $this->assertEquals(['Quiet!', 'Demo Command!'], $output->messages()); + } + + /** + * test executeCommand with an abort + */ + public function testExecuteCommandAbort(): void + { + $output = new StubConsoleOutput(); + $command = new Command(); + $result = $command->executeCommand(AbortCommand::class, [], $this->getMockIo($output)); + $this->assertSame(127, $result); + $this->assertEquals(['Command aborted'], $output->messages()); + } + + /** + * Test that noninteractive commands use defaults where applicable. + */ + public function testExecuteCommandNonInteractive(): void + { + $output = new StubConsoleOutput(); + $command = new Command(); + $command->executeCommand(NonInteractiveCommand::class, ['--quiet'], $this->getMockIo($output)); + $this->assertEquals(['Result: Default!'], $output->messages()); + } + + public function testExecuteCommandWithDI(): void + { + $output = new StubConsoleOutput(); + $container = new Container(); + $factory = new CommandFactory($container); + + $container->add(CommandFactoryInterface::class, $factory); + $container->add(Command::class) + ->addArgument(CommandFactoryInterface::class); + $container->add(stdClass::class); + $container->add(DependencyCommand::class) + ->addArgument(stdClass::class); + + $command = $factory->create(Command::class); + $result = $command->executeCommand(DependencyCommand::class, [], $this->getMockIo($output)); + + $this->assertSame(Command::CODE_SUCCESS, $result); + $this->assertEquals(['Dependency Command', 'constructor inject: {}'], $output->messages()); + } + + public function testExecuteCommandWithEventHooks(): void + { + $output = new StubConsoleOutput(); + $command = new Command(); + $command->executeCommand(EventsCommand::class, [], $this->getMockIo($output)); + $this->assertEquals([ + 'beforeExecute run', + 'execute run', + 'afterExecute run', + ], $output->messages()); + } + + /** + * @param \Cake\Console\ConsoleOutput $output + * @return \Cake\Console\ConsoleIo|\Mockery\MockInterface + */ + protected function getMockIo($output) + { + return Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + } +} diff --git a/tests/TestCase/Console/ConsoleInputArgumentTest.php b/tests/TestCase/Console/ConsoleInputArgumentTest.php new file mode 100644 index 00000000000..622e2311d8c --- /dev/null +++ b/tests/TestCase/Console/ConsoleInputArgumentTest.php @@ -0,0 +1,265 @@ +$method(); + $this->assertSame($expected, $result); + } + + /** + * Test separator must not contain space. + */ + public function testNoSpaceInSeparator(): void + { + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('The argument separator must not contain spaces for `colors`.'); + new ConsoleInputArgument( + 'colors', + '', + false, + ['red', 'blue'], + 'red', + ' ', + ); + } + + /** + * Test help. + */ + public function testHelp(): void + { + $input = new ConsoleInputArgument( + 'colors', + 'help message', + true, + ['red', 'blue'], + 'red', + ';', + ); + $output = $input->help(72); + $this->assertStringStartsWith('colors ', $output); + $this->assertStringContainsString(' help message ', $output); + $this->assertStringContainsString(' (choices: red|blue)', $output); + $this->assertStringContainsString(' default: "red"', $output); + $this->assertStringContainsString(' (separator: ";")', $output); + } + + /** + * Test usage. + */ + public function testUsage(): void + { + $input = new ConsoleInputArgument( + 'color', + '', + false, + ['red', 'blue'], + 'red', + ); + $output = $input->usage(); + $this->assertEquals('[]', $output); + } + + /** + * Test usage. + */ + public function testUsageRequired(): void + { + $input = new ConsoleInputArgument( + 'color', + '', + true, + ['red', 'blue'], + 'red', + ); + $output = $input->usage(); + $this->assertEquals('', $output); + } + + /** + * Test valid choice empty. + */ + public function testValidChoiceEmpty(): void + { + $input = new ConsoleInputArgument( + 'color', + '', + false, + [], + ); + $this->assertTrue($input->validChoice('yellow')); + } + + /** + * Test valid choice empty. + */ + public function testValidChoiceFail(): void + { + $input = new ConsoleInputArgument( + 'color', + '', + false, + ['red', 'blue'], + ); + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('`yellow` is not a valid value for `color`. Please use one of `red|blue`'); + $input->validChoice('yellow'); + } + + /** + * Test valid choice. + */ + public function testValidChoiceSuccess(): void + { + $input = new ConsoleInputArgument( + 'color', + '', + false, + ['red', 'blue'], + ); + $this->assertTrue($input->validChoice('red')); + } + + /** + * @return array + */ + public static function dataValidChoiceSeparatorSuccess(): array + { + return [ + [['red', 'blue', 'green'], null, 'blue'], + [['blue,red', 'green,yellow'], null, 'blue,red'], + [['red', 'blue', 'green'], ';', 'blue;red'], + ]; + } + + /** + * Test valid choice with value contain multiple and separator. + * + * @param array $choices + * @param string|null $separator + * @param string $value + */ + #[DataProvider('dataValidChoiceSeparatorSuccess')] + public function testValidChoiceSeparatorSuccess(array $choices, ?string $separator, string $value): void + { + $input = new ConsoleInputArgument( + 'colors', + '', + false, + $choices, + null, + $separator, + ); + + $success = $input->validChoice($value); + $this->assertTrue($success); + } + + public static function dataValidChoiceSeparatorFail(): array + { + return [ + [['red', 'blue', 'green'], null, 'blue,yellow'], + [['red', 'blue', 'green'], ';', 'blue;yellow'], + ]; + } + + /** + * Test valid choice with value contain multiple and separator. + * + * @param array $choices + * @param string|null $separator + * @param string $value + */ + #[DataProvider('dataValidChoiceSeparatorFail')] + public function testValidChoiceSeparatorFail(array $choices, ?string $separator, string $value): void + { + $input = new ConsoleInputArgument( + 'colors', + '', + false, + $choices, + null, + $separator, + ); + + $this->expectException(ConsoleException::class); + $input->validChoice($value); + } + + /** + * Test xml. + */ + public function testXml(): void + { + $input = new ConsoleInputArgument( + 'colors', + 'flower colors', + true, + ['red', 'blue'], + 'red', + ',', + ); + $parent = new SimpleXMLElement(''); + $xml = $input->xml($parent); + + $expected = << +redblue + +XML; + + $this->assertEquals($expected, (string)$xml->asXML()); + } +} diff --git a/tests/TestCase/Console/ConsoleInputOptionTest.php b/tests/TestCase/Console/ConsoleInputOptionTest.php new file mode 100644 index 00000000000..5e68ddb79c0 --- /dev/null +++ b/tests/TestCase/Console/ConsoleInputOptionTest.php @@ -0,0 +1,400 @@ +$method(); + $this->assertSame($expected, $result); + } + + /** + * Test separator must not contain space. + */ + public function testNoSpaceInSeparator(): void + { + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('The option separator must not contain spaces for `colors`.'); + new ConsoleInputOption( + 'colors', + 'c', + '', + false, + 'red', + ['red', 'blue'], + true, + false, + null, + '; ', + ); + } + + /** + * Test short option too long. + */ + public function testShortOptionTooLong(): void + { + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Short option `col` is invalid, short options must be one letter.'); + new ConsoleInputOption('color', 'col'); + } + + /** + * Test default and prompt can't be set together. + */ + public function testSetDefaultAndPrompt(): void + { + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('You cannot set both `prompt` and `default` options. Use either a static `default` or interactive `prompt`'); + new ConsoleInputOption('color', '', '', false, 'red', [], false, false, 'color ?'); + } + + /** + * Test help. + */ + public function testHelp(): void + { + $input = new ConsoleInputOption( + 'color', + 'c', + 'help message', + false, + 'red', + ['red', 'blue'], + true, + true, + null, + ';', + ); + $output = $input->help(72); + $this->assertStringStartsWith('--color, -c ', $output); + $this->assertStringContainsString(' help message (default: red)', $output); + $this->assertStringContainsString(' (choices: red|blue)', $output); + $this->assertStringContainsString(' (separator: `;`)', $output); + $this->assertStringEndsWith(' (required)', $output); + } + + /** + * Test usage. + */ + public function testUsage(): void + { + $input = new ConsoleInputOption( + 'color', + '', + '', + false, + 'red', + ['red', 'blue'], + ); + $output = $input->usage(); + $this->assertEquals('[--color red|blue]', $output); + } + + /** + * Test usage. + */ + public function testUsageRequired(): void + { + $input = new ConsoleInputOption( + 'color', + '', + '', + false, + '', + [], + false, + true, + ); + $output = $input->usage(); + $this->assertEquals('--color', $output); + } + + /** + * Test valid choice empty. + */ + public function testValidChoiceEmpty(): void + { + $input = new ConsoleInputOption( + 'color', + '', + '', + false, + '', + [], + ); + $this->assertTrue($input->validChoice('yellow')); + } + + /** + * Test valid choice empty. + */ + public function testValidChoiceFail(): void + { + $input = new ConsoleInputOption( + 'color', + '', + '', + false, + '', + ['red', 'blue'], + ); + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('`yellow` is not a valid value for `--color`. Please use one of `red|blue`'); + $input->validChoice('yellow'); + } + + /** + * Test valid choice. + */ + public function testValidChoiceSuccess(): void + { + $input = new ConsoleInputOption( + 'color', + '', + '', + false, + '', + ['red', 'blue'], + ); + $this->assertTrue($input->validChoice('red')); + } + + /** + * Test valid choice strict. + */ + public function testValidChoiceStrict(): void + { + $input = new ConsoleInputOption( + 'color', + '', + '', + false, + '', + ['1', '0'], + ); + $this->expectException(ConsoleException::class); + $input->validChoice(true); + } + + /** + * @return array + */ + public static function dataValidChoiceSeparatorSuccess(): array + { + return [ + [['red', 'blue', 'green'], null, false, 'blue'], + [['blue,red', 'green,yellow'], null, false, 'blue,red'], + [['red', 'blue', 'green'], ';', false, 'blue;red'], + [[false, true], ';', true, '1;0'], + [[false, true], ';', true, 'true;false'], // Is boolean, so 'false' mean `true` and is valid + ]; + } + + /** + * Test valid choice with value contain multiple and separator. + * + * @param array $choices + * @param string|null $separator + * @param bool $isBoolean + * @param string $value + */ + #[DataProvider('dataValidChoiceSeparatorSuccess')] + public function testValidChoiceSeparatorSuccess(array $choices, ?string $separator, bool $isBoolean, string $value): void + { + $input = new ConsoleInputOption( + 'colors', + '', + '', + $isBoolean, + '', + $choices, + true, + false, + null, + $separator, + ); + + $success = $input->validChoice($value); + $this->assertTrue($success); + } + + public static function dataValidChoiceSeparatorFail(): array + { + return [ + [['red', 'blue', 'green'], null, false, 'blue,yellow'], + [['red', 'blue', 'green'], ';', false, 'blue;yellow'], + [[1, 0], ';', false, '1;0'], + ]; + } + + /** + * Test valid choice with value contain multiple and separator. + * + * @param array $choices + * @param string|null $separator + * @param bool $isBoolean + * @param string $value + */ + #[DataProvider('dataValidChoiceSeparatorFail')] + public function testValidChoiceSeparatorFail(array $choices, ?string $separator, bool $isBoolean, string $value): void + { + $input = new ConsoleInputOption( + 'colors', + '', + '', + $isBoolean, + '', + $choices, + true, + false, + null, + $separator, + ); + + $this->expectException(ConsoleException::class); + $input->validChoice($value); + } + + /** + * Test xml. + */ + public function testXml(): void + { + $input = new ConsoleInputOption( + 'colors', + 'c', + 'flower colors', + false, + 'red', + ['red', 'blue'], + true, + true, + ); + $parent = new SimpleXMLElement(''); + $xml = $input->xml($parent); + + $expected = << + + +XML; + + $this->assertEquals($expected, (string)$xml->asXML()); + } + + /** + * Test xml default as true + */ + public function testXmlDefaultTrue(): void + { + $input = new ConsoleInputOption( + 'verbose', + '', + '', + true, + true, + ); + $parent = new SimpleXMLElement(''); + $xml = $input->xml($parent); + + $expected = << + + +XML; + + $this->assertEquals($expected, (string)$xml->asXML()); + } + + /** + * Test xml default as true + */ + public function testXmlDefaultFalse(): void + { + $input = new ConsoleInputOption( + 'verbose', + '', + '', + true, + false, + ); + $parent = new SimpleXMLElement(''); + $xml = $input->xml($parent); + + $expected = << + + +XML; + + $this->assertEquals($expected, (string)$xml->asXML()); + } +} diff --git a/tests/TestCase/Console/ConsoleInputTest.php b/tests/TestCase/Console/ConsoleInputTest.php new file mode 100644 index 00000000000..dc46f9094b3 --- /dev/null +++ b/tests/TestCase/Console/ConsoleInputTest.php @@ -0,0 +1,63 @@ +in = new ConsoleInput(); + } + + /** + * Test dataAvailable method + */ + public function testDataAvailable(): void + { + $this->skipIf( + (bool)env('GITHUB_ACTIONS'), + 'Skip test for ConsoleInput::dataAvailable() on Github VM as stream_select() incorrectly return 1 even though no data is available on STDIN.', + ); + + try { + $this->assertFalse($this->in->dataAvailable()); + } catch (ConsoleException) { + $this->markTestSkipped( + 'stream_select raised an exception. ' . + 'This can happen when FD_SETSIZE is too small.', + ); + } + } +} diff --git a/tests/TestCase/Console/ConsoleIoTest.php b/tests/TestCase/Console/ConsoleIoTest.php new file mode 100644 index 00000000000..228528c4fae --- /dev/null +++ b/tests/TestCase/Console/ConsoleIoTest.php @@ -0,0 +1,750 @@ +out = Mockery::mock(ConsoleOutput::class)->shouldIgnoreMissing(); + $this->err = Mockery::mock(ConsoleOutput::class)->shouldIgnoreMissing(); + $this->in = Mockery::mock(ConsoleInput::class); + + $this->io = new ConsoleIo($this->out, $this->err, $this->in); + } + + /** + * teardown method + */ + protected function tearDown(): void + { + parent::tearDown(); + if (is_dir(TMP . 'shell_test')) { + $fs = new Filesystem(); + $fs->deleteDir(TMP . 'shell_test'); + } + Log::drop('console-logger'); + } + + /** + * Provider for testing choice types. + * + * @return array + */ + public static function choiceProvider(): array + { + return [ + [['y', 'n']], + ['y,n'], + ['y/n'], + ['y'], + ]; + } + + /** + * test ask choices method + * + * @param array|string $choices + */ + #[DataProvider('choiceProvider')] + public function testAskChoices($choices): void + { + $this->in->shouldReceive('read')->andReturn('y')->once(); + + $result = $this->io->askChoice('Just a test?', $choices); + $this->assertSame('y', $result); + } + + /** + * test ask choices method + * + * @param array|string $choices + */ + #[DataProvider('choiceProvider')] + public function testAskChoicesInsensitive($choices): void + { + $this->in->shouldReceive('read')->andReturn('Y')->once(); + + $result = $this->io->askChoice('Just a test?', $choices); + $this->assertSame('Y', $result); + } + + /** + * Test ask method + */ + public function testAsk(): void + { + $this->out->shouldReceive('write') + ->with("Just a test?\n> ", 0) + ->once(); + + $this->in->shouldReceive('read')->andReturn('y')->once(); + + $result = $this->io->ask('Just a test?'); + $this->assertSame('y', $result); + } + + /** + * Test ask method + */ + public function testAskDefaultValue(): void + { + $this->out->shouldReceive('write') + ->with("Just a test?\n[n] > ", 0) + ->once(); + + $this->in->shouldReceive('read')->andReturn('')->once(); + + $result = $this->io->ask('Just a test?', 'n'); + $this->assertSame('n', $result); + } + + /** + * testOut method + */ + #[TestWith(['Just a test'])] + #[TestWith([['Just', 'a', 'test']])] + #[TestWith([['Just', 'a', 'test'], 2])] + #[TestWith([''])] + public function testOut(string|array $message, int $newLines = 1): void + { + $this->out->shouldReceive('write') + ->with($message, $newLines) + ->once(); + + $this->io->out($message, $newLines); + } + + /** + * test that verbose and quiet output levels work + */ + #[TestWith(['Verbose', 1, ConsoleIo::VERBOSE])] + #[TestWith(['Normal', 1, ConsoleIo::NORMAL])] + #[TestWith(['Quiet', 1, ConsoleIo::QUIET])] + public function testVerboseOut(string $message, int $newlines, int $level): void + { + $this->out->shouldReceive('write') + ->with($message, $newlines) + ->once(); + + $this->io->level(ConsoleIo::VERBOSE); + $this->io->out($message, $newlines, $level); + } + + /** + * test that verbose and quiet output levels work + */ + #[TestWith(['verbose', 'Verbose'])] + #[TestWith(['out', 'Out'])] + #[TestWith(['quiet', 'Quiet'])] + public function testVerboseOutput(string $method, string $message): void + { + $this->out->shouldReceive('write') + ->with($message, 1) + ->once(); + + $this->io->level(ConsoleIo::VERBOSE); + $this->io->{$method}($message); + } + + /** + * test that verbose and quiet output levels work + */ + public function testQuietOutput(): void + { + $this->out->shouldReceive('write') + ->with('Quiet', 1) + ->twice(); + + $this->io->level(ConsoleIo::QUIET); + + $this->io->out('Verbose', 1, ConsoleIo::VERBOSE); + $this->io->out('Normal', 1, ConsoleIo::NORMAL); + $this->io->out('Quiet', 1, ConsoleIo::QUIET); + $this->io->verbose('Verbose'); + $this->io->quiet('Quiet'); + } + + /** + * testErr method + */ + #[TestWith(['Just a test'])] + #[TestWith([['Just', 'a', 'test']])] + #[TestWith([['Just', 'a', 'test'], 2])] + #[TestWith([''])] + public function testErr(string|array $message, int $newLines = 1): void + { + $this->err->shouldReceive('write') + ->with($message, $newLines) + ->once(); + + $this->io->err($message, $newLines); + } + + /** + * Tests abort() wrapper. + */ + public function testAbort(): void + { + $this->expectException(StopException::class); + $this->expectExceptionMessage('Some error'); + $this->expectExceptionCode(1); + + $this->err->shouldReceive('write') + ->with('Some error', 1) + ->once(); + + $this->expectException(StopException::class); + $this->expectExceptionCode(1); + $this->expectExceptionMessage('Some error'); + + $this->io->abort('Some error'); + } + + /** + * Tests abort() wrapper. + */ + public function testAbortCustomCode(): void + { + $this->expectException(StopException::class); + $this->expectExceptionMessage('Some error'); + $this->expectExceptionCode(99); + + $this->err->shouldReceive('write') + ->with('Some error', 1) + ->once(); + + $this->expectException(StopException::class); + $this->expectExceptionCode(99); + $this->expectExceptionMessage('Some error'); + + $this->io->abort('Some error', 99); + } + + /** + * testNl + */ + public function testNl(): void + { + $newLine = "\n"; + if (DS === '\\') { + $newLine = "\r\n"; + } + $this->assertSame($this->io->nl(), $newLine); + $this->assertSame($this->io->nl(2), $newLine . $newLine); + $this->assertSame($this->io->nl(1), $newLine); + } + + /** + * testHr + */ + #[TestWith([0])] + #[TestWith([2])] + public function testHr(int $newlines): void + { + $bar = str_repeat('-', 79); + + $this->out->shouldReceive('write')->with('', $newlines)->once(); + $this->out->shouldReceive('write')->with($bar, 1)->once(); + $this->out->shouldReceive('write')->with('', $newlines)->once(); + + $this->io->hr($newlines); + } + + /** + * Test overwriting. + */ + public function testOverwrite(): void + { + $number = strlen('Some text I want to overwrite'); + + $this->out->shouldReceive('write') + ->with('Some text I want to overwrite', 0) + ->andReturn($number) + ->once(); + + $this->out->shouldReceive('write') + ->with(str_repeat("\x08", $number), 0) + ->andReturn(9) + ->once(); + + $this->out->shouldReceive('write') + ->with('Less text', 0) + ->andReturn(9) + ->once(); + + $this->out->shouldReceive('write') + ->with(str_repeat(' ', $number - 9), 0) + ->andReturn(1) + ->once(); + + $this->out->shouldReceive('write') + ->with(PHP_EOL, 0) + ->andReturn(0) + ->once(); + + $this->io->out('Some text I want to overwrite', 0); + $this->io->overwrite('Less text'); + } + + /** + * Test overwriting content with shorter content + */ + public function testOverwriteWithShorterContent(): void + { + $length = strlen('12345'); + + $this->out->shouldReceive('write') + ->with('12345', 1) + ->andReturn($length) + ->once(); + + // Backspaces + $this->out->shouldReceive('write') + ->with(str_repeat("\x08", $length), 0) + ->andReturn($length) + ->once(); + + $this->out->shouldReceive('write') + ->with('123', 0) + ->andReturn(3) + ->once(); + + // 2 spaces output to pad up to 5 bytes + $this->out->shouldReceive('write') + ->with(str_repeat(' ', $length - 3), 0) + ->andReturn($length - 3) + ->once(); + + // Backspaces + $this->out->shouldReceive('write') + ->with(str_repeat("\x08", $length), 0) + ->andReturn($length) + ->once(); + + $this->out->shouldReceive('write') + ->with('12', 0) + ->andReturn(2) + ->once(); + + $this->out->shouldReceive('write') + ->with(str_repeat(' ', $length - 2), 0) + ->andReturn($length - 2) + ->once(); + + $this->io->out('12345'); + $this->io->overwrite('123', 0); + $this->io->overwrite('12', 0); + } + + /** + * Test overwriting content with longer content + */ + public function testOverwriteWithLongerContent(): void + { + $this->out->shouldReceive('write') + ->with('1', 1) + ->andReturn(1) + ->once(); + + // Backspaces + $this->out->shouldReceive('write') + ->with(str_repeat("\x08", 1), 0) + ->andReturn(1) + ->once(); + + $this->out->shouldReceive('write') + ->with('123', 0) + ->andReturn(3) + ->once(); + + // Backspaces + $this->out->shouldReceive('write') + ->with(str_repeat("\x08", 3), 0) + ->andReturn(3) + ->once(); + + $this->out->shouldReceive('write') + ->with('12345', 0) + ->andReturn(5) + ->once(); + + $this->io->out('1'); + $this->io->overwrite('123', 0); + $this->io->overwrite('12345', 0); + } + + /** + * Tests that setLoggers works properly + */ + public function testSetLoggers(): void + { + Log::drop('stdout'); + Log::drop('stderr'); + $this->io->setLoggers(true); + $this->assertNotEmpty(Log::engine('stdout')); + $this->assertNotEmpty(Log::engine('stderr')); + + $this->io->setLoggers(false); + $this->assertNull(Log::engine('stdout')); + $this->assertNull(Log::engine('stderr')); + } + + /** + * Tests that setLoggers does not add loggers if the + * application already has a console logger. This + * lets developers opt-out of the default behavior + * by configuring something equivalent. + */ + public function testSetLoggersWithCustom(): void + { + Log::drop('stdout'); + Log::drop('stderr'); + Log::setConfig('console-logger', [ + 'className' => 'Console', + 'stream' => $this->out, + 'types' => ['error', 'warning'], + ]); + $this->io->setLoggers(true); + $this->assertEmpty(Log::engine('stdout')); + $this->assertEmpty(Log::engine('stderr')); + $this->assertNotEmpty(Log::engine('console-logger')); + + $this->io->setLoggers(false); + $this->assertNull(Log::engine('stdout')); + $this->assertNull(Log::engine('stderr')); + $this->assertNotEmpty(Log::engine('console-logger')); + } + + /** + * Tests that setLoggers works properly with quiet + */ + public function testSetLoggersQuiet(): void + { + Log::drop('stdout'); + Log::drop('stderr'); + $this->io->setLoggers(ConsoleIo::QUIET); + $this->assertEmpty(Log::engine('stdout')); + $this->assertNotEmpty(Log::engine('stderr')); + } + + /** + * Tests that setLoggers works properly with verbose + */ + public function testSetLoggersVerbose(): void + { + Log::drop('stdout'); + Log::drop('stderr'); + $this->io->setLoggers(ConsoleIo::VERBOSE); + + $this->assertNotEmpty(Log::engine('stderr')); + /** @var \Cake\Log\Log $engine */ + $engine = Log::engine('stdout'); + $this->assertEquals(['notice', 'info', 'debug'], $engine->getConfig('levels')); + } + + /** + * Ensure that setStyle() just proxies to stdout. + */ + public function testSetStyle(): void + { + $this->out->shouldReceive('setStyle') + ->with('name', ['props']) + ->once(); + + $this->io->setStyle('name', ['props']); + } + + /** + * Ensure that getStyle() just proxies to stdout. + */ + public function testGetStyle(): void + { + $this->out->shouldReceive('getStyle') + ->with('name') + ->once(); + + $this->io->getStyle('name'); + } + + /** + * Ensure that styles() just proxies to stdout. + */ + public function testStyles(): void + { + $this->out->shouldReceive('styles')->once(); + + $this->io->styles(); + } + + /** + * Test the helper method. + */ + public function testHelper(): void + { + $this->out->shouldReceive('write') + ->with('It works!well ish', 1) + ->once(); + + $helper = $this->io->helper('simple'); + $helper->output(['well', 'ish']); + } + + /** + * test out helper methods + */ + #[TestWith(['info'])] + #[TestWith(['success'])] + #[TestWith(['comment'])] + public function testOutHelpers(string $method): void + { + $this->out->shouldReceive('write') + ->with("<{$method}>Just a test", 1) + ->once(); + + $this->out->shouldReceive('write') + ->with(["<{$method}>Just", "<{$method}>a test"], 1) + ->once(); + + $this->io->{$method}('Just a test'); + $this->io->{$method}(['Just', 'a test']); + } + + /** + * test err helper methods + */ + #[TestWith(['warning'])] + #[TestWith(['error'])] + public function testErrHelpers(string $method): void + { + $this->err->shouldReceive('write') + ->with("<{$method}>Just a test", 1) + ->once(); + + $this->err->shouldReceive('write') + ->with(["<{$method}>Just", "<{$method}>a test"], 1) + ->once(); + + $this->io->{$method}('Just a test'); + $this->io->{$method}(['Just', 'a test']); + } + + /** + * Test that createFile + */ + public function testCreateFileSuccess(): void + { + $this->err->shouldNotReceive('write'); + + $path = TMP . 'shell_test'; + mkdir($path); + + $file = $path . DS . 'file1.php'; + $contents = 'some content'; + $result = $this->io->createFile($file, $contents); + + $this->assertTrue($result); + $this->assertFileExists($file); + $this->assertStringEqualsFile($file, $contents); + } + + public function testCreateFileEmptySuccess(): void + { + $this->err->shouldNotReceive('write'); + + $path = TMP . 'shell_test'; + mkdir($path); + + $file = $path . DS . 'file_empty.php'; + $contents = ''; + $result = $this->io->createFile($file, $contents); + + $this->assertTrue($result); + $this->assertFileExists($file); + $this->assertStringEqualsFile($file, $contents); + } + + public function testCreateFileDirectoryCreation(): void + { + $this->err->shouldNotReceive('write'); + + $directory = TMP . 'shell_test'; + $this->assertFileDoesNotExist($directory, 'Directory should not exist before createFile'); + + $path = $directory . DS . 'create.txt'; + $contents = 'some content'; + $result = $this->io->createFile($path, $contents); + + $this->assertTrue($result, 'File should create'); + $this->assertFileExists($path); + $this->assertStringEqualsFile($path, $contents); + } + + /** + * Test that createFile with permissions error. + */ + public function testCreateFilePermissionsError(): void + { + $this->skipIf(DS === '\\', 'Cant perform operations using permissions on windows.'); + + $path = TMP . 'shell_test'; + $file = $path . DS . 'no_perms'; + + if (!is_dir($path)) { + mkdir($path); + } + chmod($path, 0444); + + $this->io->createFile($file, 'testing'); + $this->assertFileDoesNotExist($file); + + chmod($path, 0744); + rmdir($path); + } + + /** + * Test that `q` raises an error. + */ + public function testCreateFileOverwriteQuit(): void + { + $path = TMP . 'shell_test'; + mkdir($path); + + $file = $path . DS . 'file1.php'; + touch($file); + + $this->expectException(StopException::class); + + $this->in->shouldReceive('read')->andReturn('q')->once(); + + $this->io->createFile($file, 'some content'); + } + + /** + * Test that `n` raises an error. + */ + public function testCreateFileOverwriteNo(): void + { + $path = TMP . 'shell_test'; + mkdir($path); + + $file = $path . DS . 'file1.php'; + file_put_contents($file, 'original'); + touch($file); + + $this->in->shouldReceive('read') + ->andReturn('n') + ->once(); + + $contents = 'new content'; + $result = $this->io->createFile($file, $contents); + + $this->assertFalse($result); + $this->assertFileExists($file); + $this->assertStringEqualsFile($file, 'original'); + } + + /** + * Test the forceOverwrite parameter + */ + public function testCreateFileOverwriteParam(): void + { + $path = TMP . 'shell_test'; + mkdir($path); + + $file = $path . DS . 'file1.php'; + file_put_contents($file, 'original'); + touch($file); + + $contents = 'new content'; + $result = $this->io->createFile($file, $contents, true); + + $this->assertTrue($result); + $this->assertFileExists($file); + $this->assertStringEqualsFile($file, $contents); + } + + /** + * Test the `a` response + */ + public function testCreateFileOverwriteAll(): void + { + $path = TMP . 'shell_test'; + mkdir($path); + + $file = $path . DS . 'file1.php'; + file_put_contents($file, 'original'); + touch($file); + + $this->in->shouldReceive('read') + ->andReturn('a') + ->once(); + + $this->io->createFile($file, 'new content'); + $this->assertStringEqualsFile($file, 'new content'); + + $this->io->createFile($file, 'newer content'); + $this->assertStringEqualsFile($file, 'newer content'); + + $this->io->createFile($file, 'newest content', false); + $this->assertStringEqualsFile( + $file, + 'newest content', + 'overwrite state replaces parameter', + ); + } +} diff --git a/tests/TestCase/Console/ConsoleOptionParserTest.php b/tests/TestCase/Console/ConsoleOptionParserTest.php new file mode 100644 index 00000000000..059154ba1be --- /dev/null +++ b/tests/TestCase/Console/ConsoleOptionParserTest.php @@ -0,0 +1,1093 @@ +io = new ConsoleIo(new StubConsoleOutput(), new StubConsoleOutput(), new StubConsoleInput([])); + } + + /** + * test setting the console description + */ + public function testDescription(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->setDescription('A test'); + + $this->assertEquals($parser, $result, 'Setting description is not chainable'); + $this->assertSame('A test', $parser->getDescription(), 'getting value is wrong.'); + + $parser->setDescription(['A test', 'something']); + $this->assertSame("A test\nsomething", $parser->getDescription(), 'getting value is wrong.'); + } + + /** + * test setting and getting the console epilog + */ + public function testEpilog(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->setEpilog('A test'); + + $this->assertEquals($parser, $result, 'Setting epilog is not chainable'); + $this->assertSame('A test', $parser->getEpilog(), 'getting value is wrong.'); + + $parser->setEpilog(['A test', 'something']); + $this->assertSame("A test\nsomething", $parser->getEpilog(), 'getting value is wrong.'); + } + + /** + * test adding an option returns self. + */ + public function testAddOptionReturnSelf(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->addOption('test'); + $this->assertEquals($parser, $result, 'Did not return $this from addOption'); + } + + /** + * test removing an option + */ + public function testRemoveOption(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->addOption('test') + ->removeOption('test') + ->removeOption('help'); + $this->assertSame($parser, $result, 'Did not return $this from removeOption'); + $this->assertEquals([], $result->options()); + } + + /** + * test removing an option clears also shortOption. + */ + public function testRemoveOptionAlsoClearsShort(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->addOption('test', ['short' => 't']) + ->removeOption('test') + ->removeOption('help') + ->addOption('test', ['short' => 't']); + $this->assertSame($parser, $result, 'Did not return $this from removeOption'); + } + + /** + * test adding an option and using the long value for parsing. + */ + public function testAddOptionLong(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', [ + 'short' => 't', + ]); + $result = $parser->parse(['--test', 'value'], $this->io); + $this->assertEquals(['test' => 'value', 'help' => false], $result[0], 'Long parameter did not parse out'); + } + + /** + * test adding an option with a zero value + */ + public function testAddOptionZero(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('count', []); + $result = $parser->parse(['--count', '0'], $this->io); + $this->assertEquals(['count' => '0', 'help' => false], $result[0], 'Zero parameter did not parse out'); + } + + /** + * test addOption with an object. + */ + public function testAddOptionObject(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption(new ConsoleInputOption('test', 't')); + $result = $parser->parse(['--test=value'], $this->io); + $this->assertEquals(['test' => 'value', 'help' => false], $result[0], 'Long parameter did not parse out'); + } + + /** + * test adding an option and using the long value for parsing. + */ + public function testAddOptionLongEquals(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', [ + 'short' => 't', + ]); + $result = $parser->parse(['--test=value'], $this->io); + $this->assertEquals(['test' => 'value', 'help' => false], $result[0], 'Long parameter did not parse out'); + } + + /** + * test adding an option and using the default. + */ + public function testAddOptionDefault(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser + ->addOption('test', [ + 'default' => 'default value', + ]) + ->addOption('no-default', []); + + $result = $parser->parse(['--test'], $this->io); + $this->assertSame( + ['test' => 'default value', 'help' => false], + $result[0], + 'Default value did not parse out', + ); + + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', [ + 'default' => 'default value', + ]); + $result = $parser->parse([], $this->io); + $this->assertEquals(['test' => 'default value', 'help' => false], $result[0], 'Default value did not parse out'); + } + + /** + * test adding an option with a non-string default. + */ + public function testAddOptionNonStringDefault(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser + ->addOption('test_int', [ + 'default' => 1, + ]) + ->addOption('test_float', [ + 'default' => 1.5, + ]) + ->addOption('no-default', []); + + $result = $parser->parse([], $this->io); + $this->assertEquals( + ['test_int' => '1', 'test_float' => '1.5', 'help' => false], + $result[0], + 'Default value did not parse out', + ); + } + + /** + * test adding an option and using the short value for parsing. + */ + public function testAddOptionShort(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', [ + 'short' => 't', + ]); + $result = $parser->parse(['-t', 'value'], $this->io); + $this->assertEquals(['test' => 'value', 'help' => false], $result[0], 'Short parameter did not parse out'); + } + + /** + * test adding an option with a conflicting short value throws an exception. + */ + public function testAddOptionShortConflict(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', [ + 'short' => 't', + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Short option `t` is already defined for option `test`.'); + + $parser->addOption('other', [ + 'short' => 't', + ]); + } + + /** + * test adding an option and using the short value for parsing. + */ + public function testAddOptionWithMultipleShort(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('source', [ + 'multiple' => true, + 'short' => 's', + ]); + $result = $parser->parse(['-s', 'mysql', '-s', 'postgres'], $this->io); + $this->assertEquals( + [ + 'source' => ['mysql', 'postgres'], + 'help' => false, + ], + $result[0], + 'Short parameter did not parse out', + ); + } + + /** + * Test that adding an option using a two letter short value causes an exception. + * As they will not parse correctly. + */ + public function testAddOptionShortOneLetter(): void + { + $this->expectException(ConsoleException::class); + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', ['short' => 'te']); + } + + /** + * test adding and using boolean options. + */ + public function testAddOptionBoolean(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', [ + 'boolean' => true, + ]); + + $result = $parser->parse(['--test', 'value'], $this->io); + $expected = [['test' => true, 'help' => false], ['value']]; + $this->assertEquals($expected, $result); + + $result = $parser->parse(['value'], $this->io); + $expected = [['test' => false, 'help' => false], ['value']]; + $this->assertEquals($expected, $result); + } + + /** + * test adding an multiple shorts. + */ + public function testAddOptionMultipleShort(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test', ['short' => 't', 'boolean' => true]) + ->addOption('file', ['short' => 'f', 'boolean' => true]) + ->addOption('output', ['short' => 'o', 'boolean' => true]); + + $result = $parser->parse(['-o', '-t', '-f'], $this->io); + $expected = ['file' => true, 'test' => true, 'output' => true, 'help' => false]; + $this->assertEquals($expected, $result[0], 'Short parameter did not parse out'); + + $result = $parser->parse(['-otf'], $this->io); + $this->assertEquals($expected, $result[0], 'Short parameter did not parse out'); + } + + /** + * test multiple options at once. + */ + public function testAddOptionMultipleOptions(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test') + ->addOption('connection') + ->addOption('table', ['short' => 't', 'default' => true]); + + $result = $parser->parse(['--test', 'value', '-t', '--connection', 'postgres'], $this->io); + $expected = ['test' => 'value', 'table' => true, 'connection' => 'postgres', 'help' => false]; + $this->assertEquals($expected, $result[0], 'multiple options did not parse'); + } + + /** + * test adding an option that accepts multiple values. + */ + public function testAddOptionWithMultiple(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('source', ['short' => 's', 'multiple' => true]); + + $result = $parser->parse(['--source', 'mysql', '-s', 'postgres'], $this->io); + $expected = [ + 'source' => [ + 'mysql', + 'postgres', + ], + 'help' => false, + ]; + $this->assertEquals($expected, $result[0], 'options with multiple values did not parse'); + } + + /** + * test adding multiple options, including one that accepts multiple values. + */ + public function testAddOptionMultipleOptionsWithMultiple(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser + ->addOption('source', ['multiple' => true]) + ->addOption('name') + ->addOption('export', ['boolean' => true]); + + $result = $parser->parse(['--export', '--source', 'mysql', '--name', 'annual-report', '--source', 'postgres'], $this->io); + $expected = [ + 'export' => true, + 'source' => [ + 'mysql', + 'postgres', + ], + 'name' => 'annual-report', + 'help' => false, + ]; + $this->assertEquals($expected, $result[0], 'options with multiple values did not parse'); + } + + /** + * test adding a required option with a default. + */ + public function testAddOptionRequiredDefaultValue(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser + ->addOption('test', [ + 'default' => 'default value', + 'required' => true, + ]) + ->addOption('no-default', [ + 'required' => true, + ]); + $result = $parser->parse(['--test', '--no-default', 'value'], $this->io); + $this->assertSame( + ['test' => 'default value', 'no-default' => 'value', 'help' => false], + $result[0], + ); + + $result = $parser->parse(['--no-default', 'value'], $this->io); + $this->assertSame( + ['no-default' => 'value', 'help' => false, 'test' => 'default value'], + $result[0], + ); + } + + /** + * test adding a required option that is missing. + */ + public function testAddOptionRequiredMissing(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser + ->addOption('test', [ + 'default' => 'default value', + 'required' => true, + ]) + ->addOption('no-default', [ + 'required' => true, + ]); + + $this->expectException(ConsoleException::class); + $parser->parse(['--test'], $this->io); + } + + /** + * test adding an option and prompting and optional options + */ + public function testAddOptionWithPromptNoIo(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('color', [ + 'prompt' => 'What is your favorite?', + ]); + $this->expectException(ConsoleException::class); + $parser->parse([]); + } + + /** + * test adding an option and prompting and optional options + */ + public function testAddOptionWithPrompt(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('color', [ + 'prompt' => 'What is your favorite?', + ]); + $out = new StubConsoleOutput(); + $io = new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput(['red'])); + + $result = $parser->parse([], $io); + $this->assertEquals(['color' => 'red', 'help' => false], $result[0]); + $messages = $out->messages(); + + $this->assertCount(1, $messages); + $expected = "What is your favorite?\n> "; + $this->assertEquals($expected, $messages[0]); + } + + /** + * test adding an option and default values + */ + public function testAddOptionWithPromptAndDefault(): void + { + $parser = new ConsoleOptionParser('test', false); + + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('You cannot set both `prompt` and `default`'); + $parser->addOption('color', [ + 'prompt' => 'What is your favorite?', + 'default' => 'blue', + ]); + } + + /** + * test adding an option and prompting with cli data + */ + public function testAddOptionWithPromptAndProvidedValue(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('color', [ + 'prompt' => 'What is your favorite?', + ]); + $out = new StubConsoleOutput(); + $io = new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput([])); + + $result = $parser->parse(['--color', 'blue'], $io); + $this->assertEquals(['color' => 'blue', 'help' => false], $result[0]); + $this->assertCount(0, $out->messages()); + } + + /** + * test adding an option and prompting and required options + */ + public function testAddOptionWithPromptAndRequired(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('color', [ + 'required' => true, + 'prompt' => 'What is your favorite?', + ]); + $out = new StubConsoleOutput(); + $io = new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput(['red'])); + + $result = $parser->parse([], $io); + $this->assertEquals(['color' => 'red', 'help' => false], $result[0]); + $messages = $out->messages(); + + $this->assertCount(1, $messages); + $expected = "What is your favorite?\n> "; + $this->assertEquals($expected, $messages[0]); + } + + /** + * test adding an option and prompting for additional values. + */ + public function testAddOptionWithPromptAndOptions(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('color', [ + 'required' => true, + 'prompt' => 'What is your favorite?', + 'choices' => ['red', 'green', 'blue'], + ]); + $out = new StubConsoleOutput(); + $io = new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput(['purple', 'red'])); + + $result = $parser->parse([], $io); + $this->assertEquals(['color' => 'red', 'help' => false], $result[0]); + $messages = $out->messages(); + + $this->assertCount(2, $messages); + $expected = "What is your favorite? (red/green/blue) \n> "; + $this->assertEquals($expected, $messages[0]); + $this->assertEquals($expected, $messages[1]); + } + + /** + * Test adding multiple options. + */ + public function testAddOptions(): void + { + $parser = new ConsoleOptionParser('something', false); + $result = $parser->addOptions([ + 'name' => ['help' => 'The name'], + 'other' => ['help' => 'The other arg'], + ]); + $this->assertEquals($parser, $result, 'addOptions is not chainable.'); + + $result = $parser->options(); + $this->assertCount(3, $result, 'Not enough options'); + } + + /** + * test that boolean options work + */ + public function testOptionWithBooleanParam(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('no-commit', ['boolean' => true]) + ->addOption('table', ['short' => 't']); + + $result = $parser->parse(['--table', 'posts', '--no-commit', 'arg1', 'arg2'], $this->io); + $expected = [['table' => 'posts', 'no-commit' => true, 'help' => false], ['arg1', 'arg2']]; + $this->assertEquals($expected, $result, 'Boolean option did not parse correctly.'); + } + + /** + * test parsing options that do not exist. + */ + public function testOptionThatDoesNotExist(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('no-commit', ['boolean' => true]); + + try { + $parser->parse(['--he', 'other'], $this->io); + } catch (MissingOptionException $e) { + $this->assertStringContainsString( + "Unknown option `he`.\n" . + "Did you mean: `help`?\n" . + "\n" . + "Other valid choices:\n" . + "\n" . + '- help', + $e->getFullMessage(), + ); + } + } + + /** + * test parsing short options that do not exist. + */ + public function testShortOptionThatDoesNotExist(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('no-commit', ['boolean' => true, 'short' => 'n']); + $parser->addOption('construct', ['boolean' => true]); + $parser->addOption('clear', ['boolean' => true, 'short' => 'c']); + + try { + $parser->parse(['-f'], $this->io); + } catch (MissingOptionException $e) { + $this->assertStringContainsString('Unknown short option `f`.', $e->getFullMessage()); + } + } + + /** + * test that options with choices enforce them. + */ + public function testOptionWithChoices(): void + { + $this->expectException(ConsoleException::class); + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('name', ['choices' => ['mark', 'jose']]); + + $result = $parser->parse(['--name', 'mark'], $this->io); + $expected = ['name' => 'mark', 'help' => false]; + $this->assertEquals($expected, $result[0], 'Got the correct value.'); + + $parser->parse(['--name', 'jimmy'], $this->io); + } + + /** + * Ensure that option values can start with - + */ + public function testOptionWithValueStartingWithMinus(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('name') + ->addOption('age'); + + $result = $parser->parse(['--name', '-foo', '--age', 'old'], $this->io); + $expected = ['name' => '-foo', 'age' => 'old', 'help' => false]; + $this->assertEquals($expected, $result[0], 'Option values starting with "-" are broken.'); + } + + /** + * test positional argument parsing. + */ + public function testAddArgument(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->addArgument('name', ['help' => 'An argument']); + $this->assertEquals($parser, $result, 'Should return this'); + } + + /** + * test positional argument with default parsing. + */ + public function testAddArgumentWithDefault(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->addArgument('name', ['help' => 'An argument', 'default' => 'foo']); + $args = $parser->arguments(); + $this->assertEquals($parser, $result, 'Should return this'); + $this->assertEquals('foo', $args[0]->defaultValue()); + } + + /** + * Add arguments that were once considered the same + */ + public function testAddArgumentDuplicate(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser + ->addArgument('first', ['help' => 'An argument', 'choices' => [1, 2]]) + ->addArgument('second', ['help' => 'An argument', 'choices' => [1, 2]]); + $args = $parser->arguments(); + $this->assertCount(2, $args); + $this->assertEquals('first', $args[0]->name()); + $this->assertEquals('second', $args[1]->name()); + } + + /** + * test addArgument with an object. + */ + public function testAddArgumentObject(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument(new ConsoleInputArgument('test')); + $result = $parser->arguments(); + $this->assertCount(1, $result); + $this->assertSame('test', $result[0]->name()); + } + + /** + * test addArgument with default value with an object. + */ + public function testAddArgumentDefaultObject(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument(new ConsoleInputArgument('test', '', false, [], 'foo')); + $result = $parser->arguments(); + $this->assertCount(1, $result); + $this->assertSame('foo', $result[0]->defaultValue()); + } + + /** + * Test adding arguments out of order. + */ + public function testAddArgumentOutOfOrder(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument('name', ['index' => 1, 'help' => 'first argument']) + ->addArgument('bag', ['index' => 2, 'help' => 'second argument']) + ->addArgument('other', ['index' => 0, 'help' => 'Zeroth argument']); + + $result = $parser->arguments(); + $this->assertCount(3, $result); + $this->assertSame('other', $result[0]->name()); + $this->assertSame('name', $result[1]->name()); + $this->assertSame('bag', $result[2]->name()); + $this->assertSame([0, 1, 2], array_keys($result)); + $this->assertEquals( + ['other', 'name', 'bag'], + $parser->argumentNames(), + ); + } + + /** + * test overwriting positional arguments. + */ + public function testPositionalArgOverwrite(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument('name', ['help' => 'An argument']) + ->addArgument('other', ['index' => 0]); + + $result = $parser->arguments(); + $this->assertCount(1, $result, 'Overwrite did not occur'); + } + + /** + * test parsing arguments. + */ + public function testParseArgumentTooMany(): void + { + $this->expectException(ConsoleException::class); + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument('name', ['help' => 'An argument']) + ->addArgument('other'); + + $expected = ['one', 'two']; + $result = $parser->parse($expected, $this->io); + $this->assertEquals($expected, $result[1], 'Arguments are not as expected'); + + $parser->parse(['one', 'two', 'three'], $this->io); + } + + /** + * Test that the "too many arguments" error message reports the correct 1-based count. + */ + public function testParseArgumentTooManyErrorMessage(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument('name', ['help' => 'An argument']); + + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Received too many arguments. Got `2` (or more) but only `1` arguments are defined.'); + + $parser->parse(['one', 'two', 'three'], $this->io); + } + + /** + * test parsing arguments with 0 value. + */ + public function testParseArgumentZero(): void + { + $parser = new ConsoleOptionParser('test', false); + + $expected = ['one', 'two', 0, 'after', 'zero']; + $result = $parser->parse($expected, $this->io); + $this->assertEquals($expected, $result[1], 'Arguments are not as expected'); + } + + /** + * Test parsing argument with separator + */ + public function testParseArgumentWithSeparator(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument('colors', [ + 'separator' => ',', + 'choices' => ['red', 'blue', 'green'], + ]); + $result = $parser->parse(['blue,red'], $this->io); + $this->assertEquals([['blue', 'red']], $result[1]); + } + + /** + * test parse with multiples and default list separator. + */ + public function testParseOptionWithMultiplesDefaultSeparator(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('colors', [ + 'multiple' => true, + 'choices' => ['blue,red', 'yellow,green'], + ]); + $out = new StubConsoleOutput(); + + $result = $parser->parse(['--colors', 'blue,red']); + $this->assertEquals(['colors' => ['blue,red'], 'help' => false], $result[0]); + $this->assertCount(0, $out->messages()); + } + + /** + * test parse with multiples and custom list separator. + */ + public function testParseOptionWithMultiplesCustomSeparator(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('colors', [ + 'multiple' => true, + 'choices' => ['1st,choice', '2nd,choice', '3rd,choice'], + 'separator' => ';', + ]); + $out = new StubConsoleOutput(); + $io = new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput([])); + + $result = $parser->parse(['--colors', '2nd,choice;3rd,choice'], $io); + $this->assertEquals(['colors' => ['2nd,choice', '3rd,choice'], 'help' => false], $result[0]); + $this->assertCount(0, $out->messages()); + } + + /** + * test mixing multiples option as list and duplicate + */ + public function testParseOptionWithMultiplesMixed(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('colors', [ + 'multiple' => true, + 'separator' => ',', + 'choices' => ['red', 'blue', 'green', 'yellow', 'purple'], + ]); + $out = new StubConsoleOutput(); + $io = new ConsoleIo($out, new StubConsoleOutput(), new StubConsoleInput([])); + + $result = $parser->parse(['--colors', 'green,purple', '--colors', 'red'], $io); + $this->assertEquals(['colors' => ['green', 'purple', 'red'], 'help' => false], $result[0]); + $this->assertCount(0, $out->messages()); + } + + /** + * test that when there are not enough arguments an exception is raised + */ + public function testPositionalArgNotEnough(): void + { + $this->expectException(ConsoleException::class); + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument('name', ['required' => true]) + ->addArgument('other', ['required' => true]); + + $parser->parse(['one'], $this->io); + } + + /** + * test that when there are required arguments after optional ones an exception is raised + */ + public function testPositionalArgRequiredAfterOptional(): void + { + $this->expectException(LogicException::class); + $parser = new ConsoleOptionParser('test'); + $parser->addArgument('name', ['required' => false]) + ->addArgument('other', ['required' => true]); + } + + /** + * test that arguments with choices enforce them. + */ + public function testPositionalArgWithChoices(): void + { + $this->expectException(ConsoleException::class); + $parser = new ConsoleOptionParser('test', false); + $parser->addArgument('name', ['choices' => ['mark', 'jose']]) + ->addArgument('alias', ['choices' => ['cowboy', 'samurai']]) + ->addArgument('weapon', ['choices' => ['gun', 'sword']]); + + $result = $parser->parse(['mark', 'samurai', 'sword'], $this->io); + $expected = ['mark', 'samurai', 'sword']; + $this->assertEquals($expected, $result[1], 'Got the correct value.'); + + $parser->parse(['jose', 'coder'], $this->io); + } + + /** + * test argument with default value. + */ + public function testPositionalArgumentWithDefault(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->addArgument('name', ['help' => 'An argument', 'default' => 'foo']); + + $result = $parser->parse(['bar'], $this->io); + $this->assertEquals(['bar'], $result[1], 'Got the correct value.'); + + $result = $parser->parse([], $this->io); + $this->assertEquals(['foo'], $result[1], 'Got the correct default value.'); + } + + /** + * Test adding multiple arguments. + */ + public function testAddArguments(): void + { + $parser = new ConsoleOptionParser('test', false); + $result = $parser->addArguments([ + 'name' => ['help' => 'The name'], + 'other' => ['help' => 'The other arg'], + ]); + $this->assertEquals($parser, $result, 'addArguments is not chainable.'); + + $result = $parser->arguments(); + $this->assertCount(2, $result, 'Not enough arguments'); + } + + public function testParseArgumentsDoubleDash(): void + { + $parser = new ConsoleOptionParser('test'); + + $result = $parser->parse(['one', 'two', '--', '-h', '--help', '--test=value'], $this->io); + $this->assertEquals(['one', 'two', '-h', '--help', '--test=value'], $result[1]); + } + + public function testParseArgumentsOptionsDoubleDash(): void + { + $parser = new ConsoleOptionParser('test', false); + $parser->addOption('test'); + + $result = $parser->parse(['--test=value', '--', '--test'], $this->io); + $this->assertEquals(['test' => 'value', 'help' => false], $result[0]); + $this->assertEquals(['--test'], $result[1]); + + $result = $parser->parse(['--', '--test'], $this->io); + $this->assertEquals(['help' => false], $result[0]); + $this->assertEquals(['--test'], $result[1]); + } + + /** + * test that no exception is triggered for required arguments when help is being generated + */ + public function testHelpNoExceptionForRequiredArgumentsWhenGettingHelp(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.', 'required' => true]); + + $result = $parser->parse(['--help'], $this->io); + $this->assertTrue($result[0]['help']); + } + + /** + * test that no exception is triggered for required options when help is being generated + */ + public function testHelpNoExceptionForRequiredOptionsWhenGettingHelp(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.']) + ->addOption('model', ['help' => 'The model to make.', 'required' => true]); + + $result = $parser->parse(['--help']); + $this->assertTrue($result[0]['help']); + } + + /** + * test that help() with a custom rootName + */ + public function testHelpWithRootName(): void + { + $parser = new ConsoleOptionParser('sample', false); + $parser->setDescription('A command!') + ->setRootName('tool') + ->addOption('test', ['help' => 'A test option.']) + ->addOption('reqd', ['help' => 'A required option.', 'required' => true]) + ->addArgument('name', ['help' => 'An argument', 'default' => 'foo']); + + $result = $parser->help(); + $expected = <<Usage: +tool sample [-h] --reqd [--test] [] + +Options: + +--help, -h Display this help. +--reqd A required option. (required) +--test A test option. + +Arguments: + +name An argument (optional) default: + "foo" + +TEXT; + $this->assertTextEquals($expected, $result, 'Help is not correct.'); + } + + /** + * test building a parser from an array. + */ + public function testBuildFromArray(): void + { + $spec = [ + 'command' => 'test', + 'arguments' => [ + 'name' => ['help' => 'The name'], + 'other' => ['help' => 'The other arg'], + ], + 'options' => [ + 'name' => ['help' => 'The name'], + 'other' => ['help' => 'The other arg'], + ], + 'description' => 'description text', + 'epilog' => 'epilog text', + ]; + $parser = ConsoleOptionParser::buildFromArray($spec); + + $this->assertSame($spec['description'], $parser->getDescription()); + $this->assertSame($spec['epilog'], $parser->getEpilog()); + + $options = $parser->options(); + $this->assertArrayHasKey('name', $options); + $this->assertArrayHasKey('other', $options); + + $args = $parser->arguments(); + $this->assertCount(2, $args); + } + + /** + * test that create() returns instances + */ + public function testCreateFactory(): void + { + $parser = ConsoleOptionParser::create('factory', false); + $this->assertInstanceOf(ConsoleOptionParser::class, $parser); + $this->assertSame('factory', $parser->getCommand()); + } + + /** + * test that getCommand() inflects the command name. + */ + public function testCommandInflection(): void + { + $parser = new ConsoleOptionParser('CommandLine'); + $this->assertSame('command_line', $parser->getCommand()); + } + + /** + * Tests toArray() + */ + public function testToArray(): void + { + $spec = [ + 'command' => 'test', + 'arguments' => [ + 'name' => ['help' => 'The name', 'default' => 'foo'], + 'other' => ['help' => 'The other arg'], + ], + 'options' => [ + 'name' => ['help' => 'The name'], + 'other' => ['help' => 'The other arg'], + ], + 'description' => 'description text', + 'epilog' => 'epilog text', + ]; + $parser = ConsoleOptionParser::buildFromArray($spec); + $result = $parser->toArray(); + + $this->assertSame($spec['description'], $result['description']); + $this->assertSame($spec['epilog'], $result['epilog']); + + $options = $result['options']; + $this->assertArrayHasKey('name', $options); + $this->assertArrayHasKey('other', $options); + + $this->assertCount(2, $result['arguments']); + } + + /** + * Tests merge() + */ + public function testMerge(): void + { + $parser = new ConsoleOptionParser('test'); + $parser->addOption('test', ['short' => 't', 'boolean' => true]) + ->addArgument('one', ['required' => true, 'choices' => ['a', 'b']]) + ->addArgument('two', ['required' => true]); + + $parserTwo = new ConsoleOptionParser('test'); + $parserTwo->addOption('file', ['short' => 'f', 'boolean' => true]) + ->addOption('output', ['short' => 'o', 'boolean' => true]) + ->addArgument('one', ['required' => true, 'choices' => ['a', 'b']]); + + $parser->merge($parserTwo); + $result = $parser->toArray(); + + $options = $result['options']; + $this->assertArrayHasKey('quiet', $options); + $this->assertArrayHasKey('test', $options); + $this->assertArrayHasKey('file', $options); + $this->assertArrayHasKey('output', $options); + + $this->assertCount(2, $result['arguments']); + $this->assertCount(6, $result['options']); + } +} diff --git a/tests/TestCase/Console/ConsoleOutputTest.php b/tests/TestCase/Console/ConsoleOutputTest.php new file mode 100644 index 00000000000..a8662fa1a22 --- /dev/null +++ b/tests/TestCase/Console/ConsoleOutputTest.php @@ -0,0 +1,342 @@ +output = $this->getMockBuilder(ConsoleOutput::class) + ->onlyMethods(['_write']) + ->getMock(); + $this->output->setOutputAs(ConsoleOutput::COLOR); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->output); + } + + public function testNoColorEnvironmentVariable(): void + { + $_SERVER['NO_COLOR'] = '1'; + $output = new ConsoleOutput(); + $this->assertSame(ConsoleOutput::PLAIN, $output->getOutputAs()); + + unset($_SERVER['NO_COLOR']); + } + + /** + * test writing with no new line + */ + public function testWriteNoNewLine(): void + { + $this->output->expects($this->once())->method('_write') + ->with('Some output'); + + $this->output->write('Some output', 0); + } + + /** + * test writing with no new line + */ + public function testWriteNewLine(): void + { + $this->output->expects($this->once())->method('_write') + ->with('Some output' . PHP_EOL); + + $this->output->write('Some output'); + } + + /** + * test write() with multiple new lines + */ + public function testWriteMultipleNewLines(): void + { + $this->output->expects($this->once())->method('_write') + ->with('Some output' . PHP_EOL . PHP_EOL . PHP_EOL . PHP_EOL); + + $this->output->write('Some output', 4); + } + + /** + * test writing an array of messages. + */ + public function testWriteArray(): void + { + $this->output->expects($this->once())->method('_write') + ->with('Line' . PHP_EOL . 'Line' . PHP_EOL . 'Line' . PHP_EOL); + + $this->output->write(['Line', 'Line', 'Line']); + } + + /** + * test getting a style. + */ + public function testStylesGet(): void + { + $result = $this->output->getStyle('error'); + $expected = ['text' => 'red']; + $this->assertEquals($expected, $result); + + $this->assertSame([], $this->output->getStyle('made_up_goop')); + + $result = $this->output->styles(); + $this->assertNotEmpty($result, 'Error is missing'); + $this->assertNotEmpty($result, 'Warning is missing'); + } + + /** + * test adding a style. + */ + public function testStylesAdding(): void + { + $this->output->setStyle('test', ['text' => 'red', 'background' => 'black']); + $result = $this->output->getStyle('test'); + $expected = ['text' => 'red', 'background' => 'black']; + $this->assertEquals($expected, $result); + + $this->output->setStyle('test', []); + $this->assertSame([], $this->output->getStyle('test'), 'Removed styles should be empty.'); + } + + /** + * test formatting text with styles. + */ + public function testFormattingSimple(): void + { + $this->output->expects($this->once())->method('_write') + ->with("\033[31mError:\033[0m Something bad"); + + $this->output->write('Error: Something bad', 0); + } + + /** + * test that formatting doesn't eat tags it doesn't know about. + */ + public function testFormattingNotEatingTags(): void + { + $this->output->expects($this->once())->method('_write') + ->with(' Something bad'); + + $this->output->write(' Something bad', 0); + } + + /** + * test formatting with custom styles. + */ + public function testFormattingCustom(): void + { + $this->output->setStyle('annoying', [ + 'text' => 'magenta', + 'background' => 'cyan', + 'blink' => true, + 'underline' => true, + ]); + + $this->output->expects($this->once())->method('_write') + ->with("\033[35;46;5;4mAnnoy:\033[0m Something bad"); + + $this->output->write('Annoy: Something bad', 0); + } + + /** + * test formatting text with missing styles. + */ + public function testFormattingMissingStyleName(): void + { + $this->output->expects($this->once())->method('_write') + ->with('Error: Something bad'); + + $this->output->write('Error: Something bad', 0); + } + + /** + * test formatting text with multiple styles. + */ + public function testFormattingMultipleStylesName(): void + { + $this->output->expects($this->once())->method('_write') + ->with("\033[31mBad\033[0m \033[33mWarning\033[0m Regular"); + + $this->output->write('Bad Warning Regular', 0); + } + + /** + * test that multiple tags of the same name work in one string. + */ + public function testFormattingMultipleSameTags(): void + { + $this->output->expects($this->once())->method('_write') + ->with("\033[31mBad\033[0m \033[31mWarning\033[0m Regular"); + + $this->output->write('Bad Warning Regular', 0); + } + + /** + * test raw output not getting tags replaced. + */ + public function testSetOutputAsRaw(): void + { + $this->output->setOutputAs(ConsoleOutput::RAW); + $this->output->expects($this->once())->method('_write') + ->with('Bad Regular'); + + $this->output->write('Bad Regular', 0); + } + + /** + * test set/get plain output. + */ + public function testSetOutputAsPlain(): void + { + $this->output->setOutputAs(ConsoleOutput::PLAIN); + $this->assertSame(ConsoleOutput::PLAIN, $this->output->getOutputAs()); + $this->output->expects($this->once())->method('_write') + ->with('Bad Regular'); + + $this->output->write('Bad Regular', 0); + } + + /** + * test plain output only strips tags used for formatting. + */ + public function testSetOutputAsPlainSelectiveTagRemoval(): void + { + $this->output->setOutputAs(ConsoleOutput::PLAIN); + $this->output->expects($this->once()) + ->method('_write') + ->with('Bad Regular Left behind '); + + $this->output->write('Bad Regular Left behind ', 0); + } + + public function testWorkingWithStub(): void + { + $output = new StubConsoleOutput(); + $output->write('Test line 1.'); + $output->write('Test line 2.'); + + $result = $output->messages(); + $expected = [ + 'Test line 1.', + 'Test line 2.', + ]; + $this->assertSame($expected, $result); + + $result = $output->output(); + $expected = "Test line 1.\nTest line 2."; + $this->assertSame($expected, $result); + } + + /** + * Writing to a closed underlying stream resource must not raise a + * TypeError. Long-running queue workers can hold a globally registered + * `ConsoleLog` engine whose ConsoleOutput's stream goes out of scope + * mid-run; the next write through that engine would otherwise crash + * the parent process. + */ + public function testWriteToClosedResourceIsNoOp(): void + { + $tmpfile = tempnam(sys_get_temp_dir(), 'cakephp_console_output_test_'); + $this->assertIsString($tmpfile); + $stream = fopen($tmpfile, 'w'); + $this->assertIsResource($stream); + + try { + $output = new ConsoleOutput($stream); + + // Sanity: writing to the open stream returns bytes written. + $this->assertGreaterThan(0, $output->write('open', 0)); + + fclose($stream); + + // After the underlying resource is closed, write() must return 0 + // rather than throwing a fwrite() TypeError. + $this->assertSame(0, $output->write('after-close', 0)); + } finally { + @unlink($tmpfile); + } + } + + /** + * `ConsoleOutput::__destruct()` unsets the `_output` property, so any + * write that reaches the same instance during the rest of the shutdown + * sequence sees an undefined property — observed when another + * destructor (e.g. `Connection::__destruct()`) routes a `Log::warning()` + * through a globally registered `ConsoleLog` engine. + * + * `_write()` must guard with `isset()` in addition to `is_resource()`; + * reading an undefined property emits an "Undefined property" warning + * before evaluating to false. + */ + public function testWriteAfterDestructIsNoOpWithoutWarning(): void + { + $tmpfile = tempnam(sys_get_temp_dir(), 'cakephp_console_output_test_'); + $this->assertIsString($tmpfile); + $stream = fopen($tmpfile, 'w'); + $this->assertIsResource($stream); + + $captured = []; + set_error_handler(function (int $errno, string $errstr) use (&$captured): bool { + $captured[] = $errstr; + + return true; + }); + + try { + $output = new ConsoleOutput($stream); + // Explicitly invoke the destructor to simulate the shutdown cascade + // where this instance is destructed before another logging caller + // reaches it. + $output->__destruct(); + + $this->assertSame(0, $output->write('after-destruct', 0)); + $this->assertSame([], $captured, 'write() must not emit warnings after __destruct()'); + } finally { + restore_error_handler(); + if (is_resource($stream)) { + fclose($stream); + } + @unlink($tmpfile); + } + } +} diff --git a/tests/TestCase/Console/HelpFormatterTest.php b/tests/TestCase/Console/HelpFormatterTest.php new file mode 100644 index 00000000000..c1252a10281 --- /dev/null +++ b/tests/TestCase/Console/HelpFormatterTest.php @@ -0,0 +1,441 @@ +setDescription('This is fifteen This is fifteen This is fifteen') + ->addOption('four', ['help' => 'this is help text this is help text']) + ->addArgument('four', ['help' => 'this is help text this is help text']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->text(30); + $expected = <<Usage: +cake test [--four] [-h] [] + +Options: + +--four this is help text + this is help text +--help, -h Display this help. + +Arguments: + +four this is help text this + is help text + (optional) + +txt; + $this->assertTextEquals($expected, $result, 'Generated help is too wide'); + } + + /** + * test help() with options and arguments that have choices. + */ + public function testHelpWithChoices(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.', 'choices' => ['one', 'two']]) + ->addArgument('type', [ + 'help' => 'Resource type.', + 'choices' => ['aco', 'aro'], + 'required' => true, + ]) + ->addArgument('other_longer', ['help' => 'Another argument.']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->text(); + $expected = <<Usage: +cake mycommand [-h] [--test one|two] [] + +Options: + +--help, -h Display this help. +--test A test option. (choices: one|two) + +Arguments: + +type Resource type. (choices: aco|aro) +other_longer Another argument. (optional) + +txt; + $this->assertTextEquals($expected, $result, 'Help does not match'); + } + + /** + * test description and epilog in the help + */ + public function testHelpDescriptionAndEpilog(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->setDescription('Description text') + ->setEpilog('epilog text') + ->addOption('test', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.', 'required' => true]); + + $formatter = new HelpFormatter($parser); + $result = $formatter->text(); + $expected = <<Usage: +cake mycommand [-h] [--test] + +Options: + +--help, -h Display this help. +--test A test option. + +Arguments: + +model The model to make. + +epilog text + +txt; + $this->assertTextEquals($expected, $result, 'Help is wrong.'); + } + + /** + * test getting help with defined options. + */ + public function testHelpWithOptions(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.']) + ->addOption('number', [ + 'help' => 'The number', + 'default' => '2', + ]) + ->addOption('connection', [ + 'short' => 'c', + 'help' => 'The connection to use.', + 'default' => 'default', + ]); + + $formatter = new HelpFormatter($parser); + $result = $formatter->text(); + $expected = <<Usage: +cake mycommand [-c default] [-h] [--number 2] [--test] + +Options: + +--connection, -c The connection to use. (default: + default) +--help, -h Display this help. +--number The number (default: 2) +--test A test option. + +txt; + $this->assertTextEquals($expected, $result, 'Help does not match'); + } + + /** + * test getting help with defined options. + */ + public function testHelpWithOptionsAndArguments(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.', 'required' => true]) + ->addArgument('other_longer', ['help' => 'Another argument.']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->text(); + $expected = <<Usage: +cake mycommand [-h] [--test] [] + +Options: + +--help, -h Display this help. +--test A test option. + +Arguments: + +model The model to make. +other_longer Another argument. (optional) + +xml; + $this->assertTextEquals($expected, $result, 'Help does not match'); + } + + /** + * Test that a long set of options doesn't make useless output. + */ + public function testHelpWithLotsOfOptions(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser + ->addOption('test', ['help' => 'A test option.']) + ->addOption('test2', ['help' => 'A test option.']) + ->addOption('test3', ['help' => 'A test option.']) + ->addOption('test4', ['help' => 'A test option.']) + ->addOption('test5', ['help' => 'A test option.']) + ->addOption('test6', ['help' => 'A test option.']) + ->addOption('test7', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.', 'required' => true]) + ->addArgument('other_longer', ['help' => 'Another argument.']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->text(); + $expected = 'cake mycommand [options] []'; + $this->assertStringContainsString($expected, $result); + } + + /** + * Test that a long set of arguments doesn't make useless output. + */ + public function testHelpWithLotsOfArguments(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser + ->addArgument('test', ['help' => 'A test option.', 'required' => true]) + ->addArgument('test2', ['help' => 'A test option.', 'required' => true]) + ->addArgument('test3', ['help' => 'A test option.']) + ->addArgument('test4', ['help' => 'A test option.']) + ->addArgument('test5', ['help' => 'A test option.']) + ->addArgument('test6', ['help' => 'A test option.']) + ->addArgument('test7', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.']) + ->addArgument('other_longer', ['help' => 'Another argument.']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->text(); + $expected = 'cake mycommand [-h] [arguments]'; + $this->assertStringContainsString($expected, $result); + } + + /** + * Test setting a help alias + */ + public function testWithHelpAlias(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $formatter = new HelpFormatter($parser); + $formatter->setAlias('foo'); + $result = $formatter->text(); + $expected = 'foo mycommand [-h]'; + $this->assertStringContainsString($expected, $result); + } + + /** + * test help() with options and arguments that have choices. + */ + public function testXmlHelpWithChoices(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.', 'choices' => ['one', 'two']]) + ->addArgument('type', [ + 'help' => 'Resource type.', + 'choices' => ['aco', 'aro'], + 'required' => true, + ]) + ->addArgument('other_longer', ['help' => 'Another argument.', 'default' => 'foo']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->xml(); + $expected = << + +mycommand + + + + + + + + + aco + aro + + + + + + + + +xml; + $this->assertXmlStringEqualsXmlString($expected, $result, 'Help does not match'); + } + + /** + * test description and epilog in the help + */ + public function testXmlHelpDescriptionAndEpilog(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->setDescription('Description text') + ->setEpilog('epilog text') + ->addOption('test', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.', 'required' => true]); + + $formatter = new HelpFormatter($parser); + $result = $formatter->xml(); + $expected = << + +mycommand +Description text + + + + + + + + + +epilog text + +xml; + $this->assertXmlStringEqualsXmlString($expected, $result, 'Help does not match'); + } + + /** + * test getting help with defined options. + */ + public function testXmlHelpWithOptions(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.']) + ->addOption('connection', [ + 'short' => 'c', 'help' => 'The connection to use.', 'default' => 'default', + ]); + + $formatter = new HelpFormatter($parser); + $result = $formatter->xml(); + $expected = << + +mycommand + + + + + + + + + +xml; + $this->assertXmlStringEqualsXmlString($expected, $result, 'Help does not match'); + } + + /** + * test getting help with defined options. + */ + public function testXmlHelpWithOptionsAndArguments(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.', 'required' => true]) + ->addArgument('other_longer', ['help' => 'Another argument.']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->xml(); + $expected = << + + mycommand + + + + + + + + + + + + + + + +xml; + $this->assertXmlStringEqualsXmlString($expected, $result, 'Help does not match'); + } + + /** + * Test XML help as object + */ + public function testXmlHelpAsObject(): void + { + $parser = new ConsoleOptionParser('mycommand', false); + $parser->addOption('test', ['help' => 'A test option.']) + ->addArgument('model', ['help' => 'The model to make.', 'required' => true]) + ->addArgument('other_longer', ['help' => 'Another argument.']); + + $formatter = new HelpFormatter($parser); + $result = $formatter->xml(false); + $this->assertInstanceOf('SimpleXmlElement', $result); + } +} diff --git a/tests/TestCase/Console/HelperRegistryTest.php b/tests/TestCase/Console/HelperRegistryTest.php new file mode 100644 index 00000000000..24bacbc615a --- /dev/null +++ b/tests/TestCase/Console/HelperRegistryTest.php @@ -0,0 +1,129 @@ +helpers = new HelperRegistry(); + $this->helpers->setIo($io); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + unset($this->helpers); + parent::tearDown(); + } + + /** + * test loading helpers. + */ + public function testLoad(): void + { + $result = $this->helpers->load('Simple'); + $this->assertInstanceOf(SimpleHelper::class, $result); + $this->assertInstanceOf(SimpleHelper::class, $this->helpers->Simple); + + $result = $this->helpers->loaded(); + $this->assertEquals(['Simple'], $result, 'loaded() results are wrong.'); + } + + /** + * test loading helpers. + */ + public function testLoadCommandNamespace(): void + { + $result = $this->helpers->load('Command'); + $this->assertInstanceOf(CommandHelper::class, $result); + $this->assertInstanceOf(CommandHelper::class, $this->helpers->Command); + + $result = $this->helpers->loaded(); + $this->assertEquals(['Command'], $result, 'loaded() results are wrong.'); + } + + /** + * test triggering callbacks on loaded helpers + */ + public function testLoadWithConfig(): void + { + $result = $this->helpers->load('Simple', ['key' => 'value']); + $this->assertSame('value', $result->getConfig('key')); + } + + /** + * test missing helper exception + */ + public function testLoadMissingHelper(): void + { + $this->expectException(MissingHelperException::class); + $this->helpers->load('ThisTaskShouldAlwaysBeMissing'); + } + + /** + * Tests loading as an alias + */ + public function testLoadWithAlias(): void + { + $this->loadPlugins(['TestPlugin']); + + $result = $this->helpers->load('SimpleAliased', ['className' => 'Simple']); + $this->assertInstanceOf(SimpleHelper::class, $result); + $this->assertInstanceOf(SimpleHelper::class, $this->helpers->SimpleAliased); + + $result = $this->helpers->loaded(); + $this->assertEquals(['SimpleAliased'], $result, 'loaded() results are wrong.'); + + $result = $this->helpers->load('SomeHelper', ['className' => 'TestPlugin.Example']); + $this->assertInstanceOf(ExampleHelper::class, $result); + $this->assertInstanceOf(ExampleHelper::class, $this->helpers->SomeHelper); + + $result = $this->helpers->loaded(); + $this->assertEquals(['SimpleAliased', 'SomeHelper'], $result, 'loaded() results are wrong.'); + $this->clearPlugins(); + } +} diff --git a/tests/TestCase/Console/TestSuite/ConsoleIntegrationTestTraitTest.php b/tests/TestCase/Console/TestSuite/ConsoleIntegrationTestTraitTest.php new file mode 100644 index 00000000000..045f4ad0ecf --- /dev/null +++ b/tests/TestCase/Console/TestSuite/ConsoleIntegrationTestTraitTest.php @@ -0,0 +1,306 @@ +setAppNamespace(); + } + + /** + * tests exec when using the command runner + */ + public function testExecWithCommandRunner(): void + { + $this->exec(''); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Available Commands'); + $this->assertExitSuccess(); + } + + /** + * tests exec + */ + public function testExec(): void + { + $this->exec('sample'); + + $this->assertOutputContains('SampleCommand'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + /** + * tests that exec catches a StopException + */ + public function testExecCommandWithStopException(): void + { + $this->exec('abort_command'); + $this->assertExitCode(127); + $this->assertErrorContains('Command aborted'); + } + + /** + * tests that exec with a format specifier + */ + public function testExecCommandWithFormatSpecifier(): void + { + $this->exec('format_specifier_command'); + $this->assertOutputContains('format specifier'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + /** + * tests exec clears output + */ + public function testExecClearsOutput(): void + { + $this->exec('sample'); + $this->assertOutputContains('SampleCommand'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $this->exec('abort'); + $this->assertOutputNotContains('SampleCommand'); + $this->assertErrorContains('Command aborted'); + $this->assertExitCode(127); + } + + /** + * tests a valid core command + */ + public function testExecCoreCommand(): void + { + $this->exec('routes'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + /** + * tests exec with an arg and an option + */ + public function testExecWithArgsAndOption(): void + { + $this->exec('integration arg --opt="some string"'); + + $this->assertErrorEmpty(); + $this->assertOutputContains('arg: arg'); + $this->assertOutputContains('opt: some string'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + /** + * tests exec with an arg and an option + */ + public function testExecWithJsonArg(): void + { + $this->exec("integration '{\"key\":\"value\"}'"); + + $this->assertErrorEmpty(); + $this->assertOutputContains('arg: {"key":"value"}'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + /** + * tests exec with missing required argument + */ + public function testExecWithMissingRequiredArg(): void + { + $this->exec('integration'); + + $this->assertErrorContains('Missing required argument'); + $this->assertErrorContains('`arg` argument is required'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + } + + /** + * tests exec with input + */ + public function testExecWithInput(): void + { + $this->exec('bridge', ['javascript']); + + $this->assertErrorContains('No!'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + } + + /** + * tests exec with fewer inputs than questions + */ + public function testExecWithMissingInput(): void + { + $this->expectException(MissingConsoleInputException::class); + $this->expectExceptionMessage('no more input'); + $this->exec('bridge', ['cake']); + } + + /** + * tests exec with multiple inputs + */ + public function testExecWithMultipleInput(): void + { + $this->exec('bridge', ['cake', 'blue']); + + $this->assertOutputContains('You may pass'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + /** + * tests exec with input + */ + public function testExecWithInputOnSecondCall(): void + { + $this->exec('integration "test"'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + // This exec() call should work. + $this->exec('bridge', ['cake', 'blue']); + $this->assertOutputContains('You may pass'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + public function testExecWithMockServiceDependencies(): void + { + $this->mockService(stdClass::class, function () { + return json_decode('{"console-mock":true}'); + }); + $this->exec('dependency'); + + $this->assertOutputContains('constructor inject: {"console-mock":true}'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + } + + /** + * tests assertOutputRegExp assertion + */ + public function testAssertOutputRegExp(): void + { + $this->exec('sample'); + + $this->assertOutputRegExp('/^[A-Z]+/mi'); + } + + /** + * tests commandStringToArgs + */ + public function testCommandStringToArgs(): void + { + $result = $this->commandStringToArgs('command --something=nothing --with-spaces="quote me on that" \'quoted \"arg\"\''); + $expected = [ + 'command', + '--something=nothing', + '--with-spaces=quote me on that', + 'quoted \"arg\"', + ]; + $this->assertSame($expected, $result); + + $json = json_encode(['key' => '"val"', 'this' => true]); + $result = $this->commandStringToArgs(" --json='{$json}'"); + $expected = [ + '--json=' . $json, + ]; + $this->assertSame($expected, $result); + } + + /** + * tests failure messages for assertions + * + * @param string $assertion Assertion method + * @param string $message Expected failure message + * @param string $command Command to test + * @param mixed ...$rest + */ + #[DataProvider('assertionFailureMessagesProvider')] + public function testAssertionFailureMessages($assertion, $message, $command, ...$rest): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('#' . $message . '.?#sm'); + + $this->exec($command); + + $this->$assertion(...$rest); + } + + /** + * data provider for assertion failure messages + * + * @return array + */ + public static function assertionFailureMessagesProvider(): array + { + return [ + 'assertExitCode' => ['assertExitCode', 'Failed asserting that `1` matches exit code `0`', 'routes', CommandInterface::CODE_ERROR], + 'assertExitCodeOutput' => ['assertExitCode', 'STDOUT.*Route name.*STDERR', 'routes', CommandInterface::CODE_ERROR], + 'assertOutputEmpty' => ['assertOutputEmpty', 'Failed asserting that output is empty', 'routes'], + 'assertOutputContains' => ['assertOutputContains', "Failed asserting that 'missing' is in output", 'routes', 'missing'], + 'assertOutputNotContains' => ['assertOutputNotContains', "Failed asserting that 'controller' is not in output", 'routes', 'controller'], + 'assertOutputRegExp' => ['assertOutputRegExp', 'Failed asserting that `/missing/` PCRE pattern found in output', 'routes', '/missing/'], + 'assertOutputContainsRow' => ['assertOutputContainsRow', 'Failed asserting that `.*` row was in output', 'routes', ['test', 'missing']], + 'assertErrorContains' => ['assertErrorContains', "Failed asserting that 'test' is in error output", 'routes', 'test'], + 'assertErrorRegExp' => ['assertErrorRegExp', 'Failed asserting that `/test/` PCRE pattern found in error output', 'routes', '/test/'], + ]; + } + + /** + * Test the debugOutput helper + * + * @return void + */ + public function testDebugOutput(): void + { + $this->exec('sample'); + $temp = tempnam(TMP, 'debug-output'); + $f = fopen($temp, 'w'); + $this->debugOutput($f); + + $f = fopen($temp, 'r'); + $result = fread($f, 1024); + $file = __FILE__; + $line = __LINE__ - 5; + + $expected = <<assertEquals($expected, str_replace("\r\n", "\n", $result)); + } +} diff --git a/tests/TestCase/Controller/Component/CheckHttpCacheComponentTest.php b/tests/TestCase/Controller/Component/CheckHttpCacheComponentTest.php new file mode 100644 index 00000000000..ff1da42dcbd --- /dev/null +++ b/tests/TestCase/Controller/Component/CheckHttpCacheComponentTest.php @@ -0,0 +1,77 @@ +withHeader('If-Modified-Since', '2012-01-01 00:00:00') + ->withHeader('If-None-Match', '*'); + $this->Controller = new Controller($request); + $this->Component = new CheckHttpCacheComponent($this->Controller->components()); + } + + public function testBeforeRenderSuccess(): void + { + $response = $this->Controller->getResponse() + ->withEtag('something', true); + $this->Controller->setResponse($response); + + $event = new Event('Controller.beforeRender', $this->Controller); + $this->Component->beforeRender($event); + + $this->assertTrue($event->isStopped()); + $response = $this->Controller->getResponse(); + $this->assertSame(304, $response->getStatusCode()); + } + + public function testBeforeRenderNoOp(): void + { + $event = new Event('Controller.beforeRender', $this->Controller); + $this->Component->beforeRender($event); + + $this->assertFalse($event->isStopped()); + $response = $this->Controller->getResponse(); + $this->assertSame(200, $response->getStatusCode()); + } +} diff --git a/tests/TestCase/Controller/Component/FlashComponentTest.php b/tests/TestCase/Controller/Component/FlashComponentTest.php new file mode 100644 index 00000000000..1b335255b0c --- /dev/null +++ b/tests/TestCase/Controller/Component/FlashComponentTest.php @@ -0,0 +1,359 @@ +Controller = new Controller(new ServerRequest(['session' => new Session()])); + $ComponentRegistry = new ComponentRegistry($this->Controller); + $this->Flash = new FlashComponent($ComponentRegistry); + $this->Session = new Session(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->Session->destroy(); + } + + /** + * testSet method + */ + public function testSet(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->set('This is a test message'); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is a test message', ['element' => 'test', 'params' => ['foo' => 'bar']]); + $expected[] = [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/test', + 'params' => ['foo' => 'bar'], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is a test message', ['element' => 'MyPlugin.alert']); + $expected[] = [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'MyPlugin.flash/alert', + 'params' => [], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is a test message', ['key' => 'foobar']); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'foobar', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.foobar'); + $this->assertEquals($expected, $result); + } + + public function testDuplicateIgnored(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->setConfig('duplicate', false); + $this->Flash->set('This test message should appear once only'); + $this->Flash->set('This test message should appear once only'); + $result = $this->Session->read('Flash.flash'); + $this->assertCount(1, $result); + } + + public function testSetEscape(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->set('This is a test message', ['escape' => false, 'params' => ['foo' => 'bar']]); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => ['foo' => 'bar', 'escape' => false], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is a test message', ['key' => 'escaped', 'escape' => false, 'params' => ['foo' => 'bar', 'escape' => true]]); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'escaped', + 'element' => 'flash/default', + 'params' => ['foo' => 'bar', 'escape' => true], + ], + ]; + $result = $this->Session->read('Flash.escaped'); + $this->assertEquals($expected, $result); + } + + /** + * test setting messages with using the clear option + */ + public function testSetWithClear(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->set('This is a test message'); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is another test message', ['clear' => true]); + $expected = [ + [ + 'message' => 'This is another test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + /** + * testSetWithException method + */ + public function testSetWithException(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->set(new Exception('This is a test message', 404)); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/error', + 'params' => ['code' => 404], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + /** + * testSetWithComponentConfiguration method + */ + public function testSetWithComponentConfiguration(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Controller->loadComponent('Flash', ['element' => 'test']); + $this->Controller->Flash->set('This is a test message'); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/test', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + /** + * Test magic call method. + */ + public function testCall(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->success('It worked'); + $expected = [ + [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->error('It did not work', ['element' => 'error_thing']); + + $expected[] = [ + 'message' => 'It did not work', + 'key' => 'flash', + 'element' => 'flash/error', + 'params' => [], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result, 'Element is ignored in magic call.'); + + $this->Flash->success('It worked', ['plugin' => 'MyPlugin']); + + $expected[] = [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'MyPlugin.flash/success', + 'params' => [], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + /** + * Test magic call method, with named parameters. + */ + public function testCallWithNamedParams(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->success(message: 'It worked'); + $expected = [ + [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->error(message: 'It did not work', options: ['element' => 'error_thing']); + + $expected[] = [ + 'message' => 'It did not work', + 'key' => 'flash', + 'element' => 'flash/error', + 'params' => [], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result, 'Element is ignored in magic call.'); + + $this->Flash->success(message: 'It worked', options: ['plugin' => 'MyPlugin']); + + $expected[] = [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'MyPlugin.flash/success', + 'params' => [], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->success('It worked', options: ['plugin' => 'MyPlugin']); + + $expected[] = [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'MyPlugin.flash/success', + 'params' => [], + ]; + + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + /** + * Test a magic call with the "clear" flag to true + */ + public function testCallWithClear(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + $this->Flash->success('It worked'); + $expected = [ + [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + $this->Flash->success('It worked too', ['clear' => true]); + $expected = [ + [ + 'message' => 'It worked too', + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Controller/Component/FormProtectionComponentTest.php b/tests/TestCase/Controller/Component/FormProtectionComponentTest.php new file mode 100644 index 00000000000..ce48e4459ca --- /dev/null +++ b/tests/TestCase/Controller/Component/FormProtectionComponentTest.php @@ -0,0 +1,332 @@ +id('cli'); + $request = new ServerRequest([ + 'url' => '/articles/index', + 'session' => $session, + 'params' => ['controller' => 'Articles', 'action' => 'index'], + ]); + + $this->Controller = new Controller($request); + $this->Controller->loadComponent('FormProtection'); + $this->FormProtection = $this->Controller->FormProtection; + + Security::setSalt('foo!'); + } + + public function testConstructorSettingProperties(): void + { + $settings = [ + 'requireSecure' => ['update_account'], + 'validatePost' => false, + ]; + $FormProtection = new FormProtectionComponent($this->Controller->components(), $settings); + $this->assertEquals($FormProtection->validatePost, $settings['validatePost']); + } + + public function testValidation(): void + { + $fields = '4697b45f7f430ff3ab73018c20f315eecb0ba5a6%3AModel.valid'; + $unlocked = ''; + $debug = ''; + + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([ + 'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ])); + + $event = new Event('Controller.startup', $this->Controller); + + $this->assertNull($this->FormProtection->startup($event)); + } + + public function testValidationWithBaseUrl(): void + { + $session = new Session(); + $session->id('cli'); + $request = new ServerRequest([ + 'url' => '/articles/index', + 'base' => '/subfolder', + 'webroot' => '/subfolder/', + 'session' => $session, + 'params' => ['controller' => 'Articles', 'action' => 'index'], + ]); + Router::setRequest($request); + $this->Controller->setRequest($request); + + $unlocked = ''; + $fields = ['id' => '1']; + $debug = urlencode(json_encode([ + '/subfolder/articles/index', + $fields, + [], + ])); + $fields = hash_hmac( + 'sha1', + '/subfolder/articles/index' . serialize($fields) . $unlocked . 'cli', + Security::getSalt(), + ); + $fields .= urlencode(':id'); + + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([ + 'id' => '1', + '_Token' => compact('fields', 'unlocked', 'debug'), + ])); + + $event = new Event('Controller.startup', $this->Controller); + $this->assertNull($this->FormProtection->startup($event)); + } + + public function testValidationOnGetWithData(): void + { + $fields = 'an-invalid-token'; + $unlocked = ''; + $debug = urlencode(json_encode([ + 'some-action', + [], + [], + ])); + + $this->Controller->setRequest($this->Controller->getRequest() + ->withEnv('REQUEST_METHOD', 'GET') + ->withData('Model', ['username' => 'nate', 'password' => 'foo', 'valid' => '0']) + ->withData('_Token', compact('fields', 'unlocked', 'debug'))); + + $event = new Event('Controller.startup', $this->Controller); + + $this->expectException(FormProtectionException::class); + $this->FormProtection->startup($event); + } + + public function testValidationNoSession(): void + { + $unlocked = ''; + $debug = urlencode(json_encode([ + '/articles/index', + [], + [], + ])); + + $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877%3AModel.valid'; + + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([ + 'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ])); + + $event = new Event('Controller.startup', $this->Controller); + + $this->expectException(FormProtectionException::class); + $this->expectExceptionMessage('Unexpected field `Model.password` in POST data, Unexpected field `Model.username` in POST data'); + $this->FormProtection->startup($event); + } + + public function testValidationEmptyForm(): void + { + $this->Controller->setRequest($this->Controller->getRequest() + ->withEnv('REQUEST_METHOD', 'POST') + ->withParsedBody([])); + + $event = new Event('Controller.startup', $this->Controller); + + $this->expectException(FormProtectionException::class); + $this->expectExceptionMessage('`_Token` was not found in request data.'); + $this->FormProtection->startup($event); + } + + public function testValidationFailTampering(): void + { + $unlocked = ''; + $fields = ['Model.hidden' => 'value', 'Model.id' => '1']; + $debug = urlencode(json_encode([ + '/articles/index', + $fields, + [], + ])); + $fields = hash_hmac('sha1', '/articles/index' . serialize($fields) . $unlocked . 'cli', Security::getSalt()); + $fields .= urlencode(':Model.hidden|Model.id'); + + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([ + 'Model' => [ + 'hidden' => 'tampered', + 'id' => '1', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ])); + + $this->expectException(FormProtectionException::class); + $this->expectExceptionMessage('Tampered field `Model.hidden` in POST data (expected value `value` but found `tampered`)'); + + $event = new Event('Controller.startup', $this->Controller); + $this->FormProtection->startup($event); + } + + public function testValidationUnlockedFieldsMismatch(): void + { + // Unlocked is empty when the token is created. + $unlocked = ''; + $fields = ['open', 'title']; + $debug = urlencode(json_encode([ + '/articles/index', + $fields, + [''], + ])); + $fields = hash_hmac('sha1', '/articles/index' . serialize($fields) . $unlocked . 'cli', Security::getSalt()); + + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([ + 'open' => 'yes', + 'title' => 'yay', + '_Token' => compact('fields', 'unlocked', 'debug'), + ])); + + $this->expectException(FormProtectionException::class); + $this->expectExceptionMessage('Missing unlocked field'); + + $event = new Event('Controller.startup', $this->Controller); + $this->FormProtection->setConfig('unlockedFields', ['open']); + $this->FormProtection->startup($event); + } + + public function testValidationUnlockedFieldsSuccess(): void + { + $unlocked = 'open'; + $fields = ['title']; + $debug = urlencode(json_encode([ + '/articles/index', + $fields, + ['open'], + ])); + $fields = hash_hmac('sha1', '/articles/index' . serialize($fields) . $unlocked . 'cli', Security::getSalt()); + + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody([ + 'title' => 'yay', + 'open' => 'yes', + '_Token' => compact('fields', 'unlocked', 'debug'), + ])); + + $event = new Event('Controller.startup', $this->Controller); + $this->FormProtection->setConfig('unlockedFields', ['open']); + $result = $this->FormProtection->startup($event); + $this->assertNull($result); + } + + public function testCallbackReturnResponse(): void + { + $this->FormProtection->setConfig('validationFailureCallback', function (FormProtectionException $exception) { + return new Response(['body' => 'from callback']); + }); + + $this->Controller->setRequest($this->Controller->getRequest() + ->withEnv('REQUEST_METHOD', 'POST') + ->withParsedBody([])); + + $event = new Event('Controller.startup', $this->Controller); + + $result = $this->FormProtection->startup($event); + $this->assertNull($result); + $this->assertInstanceOf(Response::class, $event->getResult()); + $this->assertSame('from callback', (string)$event->getResult()->getBody()); + } + + public function testUnlockedActions(): void + { + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody(['data'])); + + $this->FormProtection->setConfig('unlockedActions', ['index']); + + $event = new Event('Controller.startup', $this->Controller); + $result = $this->Controller->FormProtection->startup($event); + + $this->assertNull($result); + } + + public function testCallbackThrowsException(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('error description'); + + $this->FormProtection->setConfig('validationFailureCallback', function (FormProtectionException $exception): void { + throw new NotFoundException('error description'); + }); + + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody(['data'])); + $event = new Event('Controller.startup', $this->Controller); + + $this->Controller->FormProtection->startup($event); + } + + public function testSettingTokenDataAsRequestAttribute(): void + { + $event = new Event('Controller.startup', $this->Controller); + $this->Controller->FormProtection->startup($event); + + $securityToken = $this->Controller->getRequest()->getAttribute('formTokenData'); + $this->assertNotEmpty($securityToken); + $this->assertSame([], $securityToken['unlockedFields']); + } + + public function testClearingOfTokenFromRequestData(): void + { + $this->Controller->setRequest($this->Controller->getRequest()->withParsedBody(['_Token' => 'data'])); + + $this->FormProtection->setConfig('validate', false); + + $event = new Event('Controller.startup', $this->Controller); + $this->Controller->FormProtection->startup($event); + + $this->assertSame([], $this->Controller->getRequest()->getParsedBody()); + } +} diff --git a/tests/TestCase/Controller/ComponentRegistryTest.php b/tests/TestCase/Controller/ComponentRegistryTest.php new file mode 100644 index 00000000000..2c8b5159e53 --- /dev/null +++ b/tests/TestCase/Controller/ComponentRegistryTest.php @@ -0,0 +1,386 @@ +Components = new ComponentRegistry($controller); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->Components); + $this->clearPlugins(); + } + + /** + * test triggering callbacks on loaded helpers + */ + public function testLoad(): void + { + $result = $this->Components->load('Flash'); + $this->assertInstanceOf(FlashComponent::class, $result); + $this->assertInstanceOf(FlashComponent::class, $this->Components->Flash); + + $result = $this->Components->loaded(); + $this->assertEquals(['Flash'], $result, 'loaded() results are wrong.'); + + $result = $this->Components->load('Flash'); + $this->assertSame($result, $this->Components->Flash); + } + + /** + * test load() with the container set + */ + public function testLoadWithContainer(): void + { + $controller = new Controller(new ServerRequest()); + $container = new Container(); + $components = new ComponentRegistry($controller, $container); + $this->assertEquals([], $components->loaded()); + + $container->add(ComponentRegistry::class, $components); + $container->add(FlashComponent::class, function (ComponentRegistry $registry, array $config) { + $this->created = true; + + return new FlashComponent($registry, $config); + }) + ->addArgument(ComponentRegistry::class) + ->addArgument(['key' => 'customFlash']); + + $flash = $components->load('Flash'); + + // Container was modified for the current registry and our factory was called + $this->assertTrue($container->has(ComponentRegistry::class)); + $this->assertTrue($this->created); + + $this->assertInstanceOf(FlashComponent::class, $flash); + $this->assertSame('customFlash', $flash->getConfig('key')); + } + + public function testLoadWithContainerAutoWiring(): void + { + $controller = new Controller(new ServerRequest()); + $container = new Container(); + $container->delegate(new ReflectionContainer()); + $components = new ComponentRegistry($controller, $container); + + $container->add(ComponentRegistry::class, $components); + + $component = $components->load(ConfiguredComponent::class, ['key' => 'customFlash']); + + $this->assertInstanceOf(ConfiguredComponent::class, $component); + $this->assertSame(['key' => 'customFlash'], $component->configCopy); + } + + /** + * Test loading component with manually configured DI in container + * Regression test for issue where arguments were duplicated + */ + public function testLoadWithManualDependencyInjection(): void + { + $controller = new Controller(new ServerRequest()); + $container = new Container(); + $components = new ComponentRegistry($controller, $container); + + // Register service and component with explicit arguments (as user would do) + $service = new TestService(); + $container->add(TestService::class, $service); + $container->add(ComponentRegistry::class, $components); + $container->add(InjectedServiceComponent::class) + ->addArgument(ComponentRegistry::class) + ->addArgument(TestService::class); + + // This should work without duplicating arguments and config should be passed through + $component = $components->load(InjectedServiceComponent::class, ['key' => 'value']); + + $this->assertInstanceOf(InjectedServiceComponent::class, $component); + $this->assertSame($service, $component->getService()); + $this->assertSame('value', $component->getConfig('key')); + } + + /** + * Test loading component registered as shared instance in container + * Documents edge case where shared instances can cause state leakage + */ + public function testLoadWithSharedInstance(): void + { + $controller1 = new Controller(new ServerRequest()); + $controller2 = new Controller(new ServerRequest()); + $container = new Container(); + + $components1 = new ComponentRegistry($controller1, $container); + $components2 = new ComponentRegistry($controller2, $container); + + // Register component as shared - this is generally not recommended for components + $container->add(ComponentRegistry::class, $components1); + $container->add(FlashComponent::class) + ->addArgument(ComponentRegistry::class) + ->setShared(true); + + $flash1 = $components1->load('Flash', ['key' => 'first']); + $flash2 = $components2->load('Flash', ['key' => 'second']); + + // Both should be the same instance (shared) + $this->assertSame($flash1, $flash2); + // Config from second load should be merged into shared instance + $this->assertSame('second', $flash2->getConfig('key')); + // This demonstrates the edge case: config is shared between controllers + $this->assertSame('second', $flash1->getConfig('key')); + } + + /** + * Tests loading as an alias + */ + public function testLoadWithAlias(): void + { + $result = $this->Components->load('Flash', ['className' => FlashAliasComponent::class, 'somesetting' => true]); + $this->assertInstanceOf(FlashAliasComponent::class, $result); + $this->assertInstanceOf(FlashAliasComponent::class, $this->Components->Flash); + $this->assertTrue($this->Components->Flash->getConfig('somesetting')); + + $result = $this->Components->loaded(); + $this->assertEquals(['Flash'], $result, 'loaded() results are wrong.'); + + $result = $this->Components->load('Flash'); + $this->assertInstanceOf(FlashAliasComponent::class, $result); + + $this->loadPlugins(['TestPlugin']); + $result = $this->Components->load('SomeOther', ['className' => 'TestPlugin.Other']); + $this->assertInstanceOf(OtherComponent::class, $result); + $this->assertInstanceOf(OtherComponent::class, $this->Components->SomeOther); + + $result = $this->Components->loaded(); + $this->assertEquals(['Flash', 'SomeOther'], $result, 'loaded() results are wrong.'); + } + + /** + * test load and enable = false + */ + public function testLoadWithEnableFalse(): void + { + $eventManager = new class extends EventManager { + public function on($eventKey, $options = null, $callable = []): never + { + throw new Exception('Should not be called'); + } + }; + + $this->Components->getController()->setEventManager($eventManager); + + $result = $this->Components->load('Flash', ['enabled' => false]); + $this->assertInstanceOf(FlashComponent::class, $result); + $this->assertInstanceOf(FlashComponent::class, $this->Components->Flash); + } + + /** + * test MissingComponent exception + */ + public function testLoadMissingComponent(): void + { + $this->expectException(MissingComponentException::class); + $this->Components->load('ThisComponentShouldAlwaysBeMissing'); + } + + /** + * test loading a plugin component. + */ + public function testLoadPluginComponent(): void + { + $this->loadPlugins(['TestPlugin']); + $result = $this->Components->load('TestPlugin.Other'); + $this->assertInstanceOf(OtherComponent::class, $result, 'Component class is wrong.'); + $this->assertInstanceOf(OtherComponent::class, $this->Components->Other, 'Class is wrong'); + } + + /** + * Test loading components with aliases and plugins. + */ + public function testLoadWithAliasAndPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + $result = $this->Components->load('AliasedOther', ['className' => 'TestPlugin.Other']); + $this->assertInstanceOf(OtherComponent::class, $result); + $this->assertInstanceOf(OtherComponent::class, $this->Components->AliasedOther); + + $result = $this->Components->loaded(); + $this->assertEquals(['AliasedOther'], $result, 'loaded() results are wrong.'); + } + + /** + * test getting the controller out of the collection + */ + public function testGetController(): void + { + $result = $this->Components->getController(); + $this->assertInstanceOf(Controller::class, $result); + } + + /** + * Test reset. + */ + public function testReset(): void + { + $eventManager = $this->Components->getController()->getEventManager(); + $instance = $this->Components->load('FormProtection'); + $this->assertSame( + $instance, + $this->Components->FormProtection, + 'Instance in registry should be the same as previously loaded', + ); + $this->assertCount(1, $eventManager->listeners('Controller.startup')); + + $this->assertSame($this->Components, $this->Components->reset()); + $this->assertCount(0, $eventManager->listeners('Controller.startup')); + + $this->assertNotSame($instance, $this->Components->load('FormProtection')); + } + + /** + * Test unloading. + */ + public function testUnload(): void + { + $eventManager = $this->Components->getController()->getEventManager(); + + $this->Components->load('FormProtection'); + $result = $this->Components->unload('FormProtection'); + + $this->assertSame($this->Components, $result); + $this->assertFalse(isset($this->Components->FormProtection), 'Should be gone'); + $this->assertCount(0, $eventManager->listeners('Controller.startup')); + } + + /** + * Test __unset. + */ + public function testUnset(): void + { + $eventManager = $this->Components->getController()->getEventManager(); + + $this->Components->load('FormProtection'); + unset($this->Components->FormProtection); + + $this->assertFalse(isset($this->Components->FormProtection), 'Should be gone'); + $this->assertCount(0, $eventManager->listeners('Controller.startup')); + } + + /** + * Test that unloading a none existing component triggers an error. + */ + public function testUnloadUnknown(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Object named `Foo` is not loaded.'); + $this->Components->unload('Foo'); + } + + /** + * Test set. + */ + public function testSet(): void + { + $eventManager = $this->Components->getController()->getEventManager(); + $this->assertCount(0, $eventManager->listeners('Controller.startup')); + + $formProtection = new FormProtectionComponent($this->Components); + $result = $this->Components->set('FormProtection', $formProtection); + + $this->assertEquals($this->Components, $result); + $this->assertTrue(isset($this->Components->FormProtection), 'Should be present'); + $this->assertCount(1, $eventManager->listeners('Controller.startup')); + } + + /** + * Test __set. + */ + public function testMagicSet(): void + { + $eventManager = $this->Components->getController()->getEventManager(); + $this->assertCount(0, $eventManager->listeners('Controller.startup')); + + $formProtection = new FormProtectionComponent($this->Components); + $this->Components->FormProtection = $formProtection; + + $this->assertTrue(isset($this->Components->FormProtection), 'Should be present'); + $this->assertCount(1, $eventManager->listeners('Controller.startup')); + } + + /** + * Test Countable. + */ + public function testCountable(): void + { + $this->Components->load('FormProtection'); + $this->assertInstanceOf(Countable::class, $this->Components); + $count = count($this->Components); + $this->assertSame(1, $count); + } + + /** + * Test Traversable. + */ + public function testTraversable(): void + { + $this->Components->load('FormProtection'); + $this->assertInstanceOf(Traversable::class, $this->Components); + + $result = null; + foreach ($this->Components as $component) { + $result = $component; + } + $this->assertNotNull($result); + } +} diff --git a/tests/TestCase/Controller/ComponentTest.php b/tests/TestCase/Controller/ComponentTest.php new file mode 100644 index 00000000000..cccba0738f3 --- /dev/null +++ b/tests/TestCase/Controller/ComponentTest.php @@ -0,0 +1,277 @@ + + * Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests + * @since 1.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Controller; + +use Cake\Controller\Component\FlashComponent; +use Cake\Controller\ComponentRegistry; +use Cake\Controller\Controller; +use Cake\Controller\Exception\MissingComponentException; +use Cake\Core\Exception\CakeException; +use Cake\Event\EventManager; +use Cake\Http\ServerRequest; +use Cake\TestSuite\TestCase; +use Exception; +use TestApp\Controller\Component\AppleComponent; +use TestApp\Controller\Component\BananaComponent; +use TestApp\Controller\Component\ConfiguredComponent; +use TestApp\Controller\Component\OrangeComponent; +use TestApp\Controller\Component\SomethingWithFlashComponent; +use TestApp\Controller\ComponentTestController; + +/** + * ComponentTest class + */ +class ComponentTest extends TestCase +{ + /** + * setUp method + */ + protected function setUp(): void + { + parent::setUp(); + static::setAppNamespace(); + } + + /** + * test accessing inner components. + */ + public function testInnerComponentConstruction(): void + { + $Collection = new ComponentRegistry(new Controller(new ServerRequest())); + $Component = new AppleComponent($Collection); + + $this->assertInstanceOf(OrangeComponent::class, $Component->Orange, 'class is wrong'); + } + + /** + * test component loading + */ + public function testNestedComponentLoading(): void + { + $Collection = new ComponentRegistry(new Controller(new ServerRequest())); + $Apple = new AppleComponent($Collection); + + $this->assertInstanceOf(OrangeComponent::class, $Apple->Orange, 'class is wrong'); + $this->assertInstanceOf(BananaComponent::class, $Apple->Orange->Banana, 'class is wrong'); + $this->assertEmpty($Apple->Session); + $this->assertEmpty($Apple->Orange->Session); + } + + /** + * test that component components are not enabled in the collection. + */ + public function testInnerComponentsAreNotEnabled(): void + { + $eventManager = new class extends EventManager + { + public bool $isCalled = false; + public bool $isCorrectType = false; + public function on($eventKey, $options = null, $callable = []): void + { + $this->isCalled = true; + $this->isCorrectType = $eventKey instanceof AppleComponent; + } + }; + $controller = new Controller(new ServerRequest()); + $controller->setEventManager($eventManager); + + $Collection = new ComponentRegistry($controller); + $Apple = $Collection->load('Apple'); + + $this->assertInstanceOf(OrangeComponent::class, $Apple->Orange, 'class is wrong'); + $this->assertTrue($eventManager->isCalled, 'on() should be called'); + $this->assertTrue($eventManager->isCorrectType, 'on() should be called with the correct type'); + } + + /** + * test a component being used more than once. + */ + public function testMultipleComponentInitialize(): void + { + $Collection = new ComponentRegistry(new Controller(new ServerRequest())); + $Banana = $Collection->load('Banana'); + $Orange = $Collection->load('Orange'); + + $this->assertSame($Banana, $Orange->Banana, 'Should be references'); + $Banana->testField = 'OrangeField'; + + $this->assertSame($Banana->testField, $Orange->Banana->testField, 'References are broken'); + } + + /** + * Test a duplicate component being loaded more than once with same and differing configurations. + */ + public function testDuplicateComponentInitialize(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('The `Banana` alias has already been loaded. The `property` key'); + $Collection = new ComponentRegistry(new Controller(new ServerRequest())); + $Collection->load('Banana', ['property' => ['closure' => function (): void { + }]]); + $Collection->load('Banana', ['property' => ['closure' => function (): void { + }]]); + + $this->assertInstanceOf(BananaComponent::class, $Collection->Banana, 'class is wrong'); + + $Collection->load('Banana', ['property' => ['differs']]); + } + + /** + * Test mutually referencing components. + */ + public function testSomethingReferencingFlashComponent(): void + { + $Controller = new ComponentTestController(new ServerRequest()); + $Controller->loadComponent('SomethingWithFlash'); + $Controller->startupProcess(); + + $this->assertInstanceOf(SomethingWithFlashComponent::class, $Controller->SomethingWithFlash); + $this->assertInstanceOf(FlashComponent::class, $Controller->SomethingWithFlash->Flash); + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $Collection = new ComponentRegistry(new Controller(new ServerRequest())); + $Component = new AppleComponent($Collection); + + $expected = [ + 'components' => [ + 'Orange' => [], + ], + 'implementedEvents' => [ + 'Controller.startup' => 'startup', + ], + '_config' => [], + ]; + $result = $Component->__debugInfo(); + $this->assertEquals($expected, $result); + } + + /** + * Tests null return for unknown magic properties. + */ + public function testMagicReturnsNull(): void + { + $Component = new AppleComponent(new ComponentRegistry(new Controller(new ServerRequest()))); + $this->assertNull($Component->ShouldBeNull); + } + + /** + * Tests config via constructor + */ + public function testConfigViaConstructor(): void + { + $Component = new ConfiguredComponent( + new ComponentRegistry(new Controller(new ServerRequest())), + ['chicken' => 'soup'], + ); + $this->assertEquals(['chicken' => 'soup'], $Component->configCopy); + $this->assertEquals(['chicken' => 'soup'], $Component->getConfig()); + } + + /** + * Lazy load a component without events. + */ + public function testLazyLoading(): void + { + $Component = new ConfiguredComponent( + new ComponentRegistry(new Controller(new ServerRequest())), + [], + ['Apple', 'Banana', 'Orange'], + ); + $this->assertInstanceOf(AppleComponent::class, $Component->Apple, 'class is wrong'); + $this->assertInstanceOf(OrangeComponent::class, $Component->Orange, 'class is wrong'); + $this->assertInstanceOf(BananaComponent::class, $Component->Banana, 'class is wrong'); + } + + /** + * Lazy load a component that does not exist. + */ + public function testLazyLoadingDoesNotExists(): void + { + $this->expectException(MissingComponentException::class); + $this->expectExceptionMessage('Component class `YouHaveNoBananasComponent` could not be found.'); + $Component = new ConfiguredComponent(new ComponentRegistry(new Controller(new ServerRequest())), [], ['YouHaveNoBananas']); + $Component->YouHaveNoBananas; + } + + /** + * Lazy loaded components can have config options + */ + public function testConfiguringInnerComponent(): void + { + $Component = new ConfiguredComponent( + new ComponentRegistry(new Controller(new ServerRequest())), + [], + ['Configured' => ['foo' => 'bar']], + ); + $this->assertInstanceOf(ConfiguredComponent::class, $Component->Configured, 'class is wrong'); + $this->assertNotSame($Component, $Component->Configured, 'Component instance was reused'); + $this->assertEquals(['foo' => 'bar', 'enabled' => false], $Component->Configured->getConfig()); + } + + /** + * Test enabling events for lazy loaded components + */ + public function testEventsInnerComponent(): void + { + $eventManager = new class extends EventManager + { + public bool $isCalled = false; + public bool $isCorrectType = false; + public function on($eventKey, $options = null, $callable = []): void + { + $this->isCalled = true; + $this->isCorrectType = $eventKey instanceof AppleComponent; + } + }; + + $controller = new Controller(new ServerRequest()); + $controller->setEventManager($eventManager); + + $Collection = new ComponentRegistry($controller); + + $Component = new ConfiguredComponent($Collection, [], ['Apple' => ['enabled' => true]]); + $this->assertInstanceOf(AppleComponent::class, $Component->Apple, 'class is wrong'); + $this->assertTrue($eventManager->isCalled, 'on() should be called'); + $this->assertTrue($eventManager->isCorrectType, 'on() should be called with the correct type'); + } + + /** + * Disabled events do not register for event listeners. + */ + public function testNoEventsInnerComponent(): void + { + $eventManager = new class extends EventManager + { + public function on($eventKey, $options = null, $callable = []): never + { + throw new Exception('Should not be called'); + } + }; + + $controller = new Controller(new ServerRequest()); + $controller->setEventManager($eventManager); + + $Collection = new ComponentRegistry($controller); + + $Component = new ConfiguredComponent($Collection, [], ['Apple' => ['enabled' => false]]); + $this->assertInstanceOf(AppleComponent::class, $Component->Apple, 'class is wrong'); + } +} diff --git a/tests/TestCase/Controller/ControllerFactoryTest.php b/tests/TestCase/Controller/ControllerFactoryTest.php new file mode 100644 index 00000000000..dd9aa90620f --- /dev/null +++ b/tests/TestCase/Controller/ControllerFactoryTest.php @@ -0,0 +1,972 @@ +container = new Container(); + $this->factory = new ControllerFactory($this->container); + } + + /** + * Test building an application controller + */ + public function testApplicationController(): void + { + $request = new ServerRequest([ + 'url' => 'cakes/index', + 'params' => [ + 'controller' => 'Cakes', + 'action' => 'index', + ], + ]); + $result = $this->factory->create($request); + $this->assertInstanceOf(CakesController::class, $result); + $this->assertSame($request, $result->getRequest()); + } + + /** + * Test building a prefixed app controller. + */ + public function testPrefixedAppController(): void + { + $request = new ServerRequest([ + 'url' => 'admin/posts/index', + 'params' => [ + 'prefix' => 'Admin', + 'controller' => 'Posts', + 'action' => 'index', + ], + ]); + $result = $this->factory->create($request); + $this->assertInstanceOf( + PostsController::class, + $result, + ); + $this->assertSame($request, $result->getRequest()); + } + + /** + * Test building a nested prefix app controller + */ + public function testNestedPrefixedAppController(): void + { + $request = new ServerRequest([ + 'url' => 'admin/sub/posts/index', + 'params' => [ + 'prefix' => 'Admin/Sub', + 'controller' => 'Posts', + 'action' => 'index', + ], + ]); + $result = $this->factory->create($request); + $this->assertInstanceOf( + SubPostsController::class, + $result, + ); + $this->assertSame($request, $result->getRequest()); + } + + /** + * Test building a plugin controller + */ + public function testPluginController(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin/test_plugin/index', + 'params' => [ + 'plugin' => 'TestPlugin', + 'controller' => 'TestPlugin', + 'action' => 'index', + ], + ]); + $result = $this->factory->create($request); + $this->assertInstanceOf( + TestPluginController::class, + $result, + ); + $this->assertSame($request, $result->getRequest()); + } + + /** + * Test building a vendored plugin controller. + */ + public function testVendorPluginController(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/ovens/index', + 'params' => [ + 'plugin' => 'Company/TestPluginThree', + 'controller' => 'Ovens', + 'action' => 'index', + ], + ]); + $result = $this->factory->create($request); + $this->assertInstanceOf( + OvensController::class, + $result, + ); + $this->assertSame($request, $result->getRequest()); + } + + /** + * Test building a prefixed plugin controller + */ + public function testPrefixedPluginController(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin/admin/comments', + 'params' => [ + 'prefix' => 'Admin', + 'plugin' => 'TestPlugin', + 'controller' => 'Comments', + 'action' => 'index', + ], + ]); + $result = $this->factory->create($request); + $this->assertInstanceOf( + CommentsController::class, + $result, + ); + $this->assertSame($request, $result->getRequest()); + } + + public function testAbstractClassFailure(): void + { + $this->expectException(MissingControllerException::class); + $this->expectExceptionMessage('Controller class `Abstract` could not be found.'); + $request = new ServerRequest([ + 'url' => 'abstract/index', + 'params' => [ + 'controller' => 'Abstract', + 'action' => 'index', + ], + ]); + $this->factory->create($request); + } + + public function testInterfaceFailure(): void + { + $this->expectException(MissingControllerException::class); + $this->expectExceptionMessage('Controller class `Interface` could not be found.'); + $request = new ServerRequest([ + 'url' => 'interface/index', + 'params' => [ + 'controller' => 'Interface', + 'action' => 'index', + ], + ]); + $this->factory->create($request); + } + + public function testMissingClassFailure(): void + { + $this->expectException(MissingControllerException::class); + $this->expectExceptionMessage('Controller class `Invisible` could not be found.'); + $request = new ServerRequest([ + 'url' => 'interface/index', + 'params' => [ + 'controller' => 'Invisible', + 'action' => 'index', + ], + ]); + $this->factory->create($request); + } + + public function testSlashedControllerFailure(): void + { + $this->expectException(MissingControllerException::class); + $this->expectExceptionMessage('Controller class `Admin/Posts` could not be found.'); + $request = new ServerRequest([ + 'url' => 'admin/posts/index', + 'params' => [ + 'controller' => 'Admin/Posts', + 'action' => 'index', + ], + ]); + $this->factory->create($request); + } + + public function testAbsoluteReferenceFailure(): void + { + $this->expectException(MissingControllerException::class); + $this->expectExceptionMessage('Controller class `TestApp\Controller\CakesController` could not be found.'); + $request = new ServerRequest([ + 'url' => 'interface/index', + 'params' => [ + 'controller' => CakesController::class, + 'action' => 'index', + ], + ]); + $this->factory->create($request); + } + + /** + * Test create() injecting dependencies on defined controllers. + */ + public function testCreateWithContainerDependenciesNoController(): void + { + $this->container->add(stdClass::class, json_decode('{"key":"value"}')); + + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'index', + ], + ]); + $controller = $this->factory->create($request); + $this->assertNull($controller->inject); + } + + /** + * Test create() injecting dependencies on defined controllers. + */ + public function testCreateWithContainerDependenciesWithController(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'index', + ], + ]); + $this->container->add(stdClass::class, json_decode('{"key":"value"}')); + $this->container->add(ServerRequest::class, $request); + $this->container->add(DependenciesController::class) + ->addArgument(ServerRequest::class) + ->addArgument(null) + ->addArgument(null) + ->addArgument(stdClass::class); + + $controller = $this->factory->create($request); + $this->assertInstanceOf(DependenciesController::class, $controller); + $this->assertSame($controller->inject, $this->container->get(stdClass::class)); + } + + /** + * Test building controller name when passing no controller name + */ + public function testGetControllerClassNoControllerName(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/ovens/index', + 'params' => [ + 'plugin' => 'Company/TestPluginThree', + 'controller' => 'Ovens', + 'action' => 'index', + ], + ]); + $result = $this->factory->getControllerClass($request); + $this->assertSame(OvensController::class, $result); + } + + /** + * Test invoke with autorender + */ + public function testInvokeAutoRender(): void + { + $request = new ServerRequest([ + 'url' => 'posts', + 'params' => [ + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + + $this->assertInstanceOf(Response::class, $result); + $this->assertStringContainsString('posts index', (string)$result->getBody()); + } + + /** + * Test dispatch with autorender=false + */ + public function testInvokeAutoRenderFalse(): void + { + $request = new ServerRequest([ + 'url' => 'posts', + 'params' => [ + 'controller' => 'Cakes', + 'action' => 'noRender', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + + $this->assertInstanceOf(Response::class, $result); + $this->assertStringContainsString('autoRender false body', (string)$result->getBody()); + } + + /** + * Ensure that a controller's startup event can stop the request. + */ + public function testStartupProcessAbort(): void + { + $request = new ServerRequest([ + 'url' => 'cakes/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Cakes', + 'action' => 'index', + 'stop' => 'startup', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + + $this->assertSame('startup stop', (string)$result->getBody()); + } + + /** + * Ensure that a controllers startup process can emit a response + */ + public function testShutdownProcessResponse(): void + { + $request = new ServerRequest([ + 'url' => 'cakes/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Cakes', + 'action' => 'index', + 'stop' => 'shutdown', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + + $this->assertSame('shutdown stop', (string)$result->getBody()); + } + + /** + * Ensure that a controllers startup process can emit a response + */ + public function testInvokeInjectOptionalParameterDefined(): void + { + $this->container->add(stdClass::class, json_decode('{"key":"value"}')); + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/optionalDep', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'optionalDep', + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertNull($data->any); + $this->assertNull($data->str); + $this->assertSame('value', $data->dep->key); + } + + /** + * Ensure that a controllers startup process can emit a response + */ + public function testInvokeInjectParametersOptionalNotDefined(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'optionalDep', + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertNull($data->any); + $this->assertNull($data->str); + $this->assertNull($data->dep); + } + + /** + * Test invoke passing basic typed data from pass parameters. + */ + public function testInvokeInjectParametersOptionalWithPassedParameters(): void + { + $this->container->add(stdClass::class, json_decode('{"key":"value"}')); + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/optionalDep', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'optionalDep', + 'pass' => ['one', 'two'], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertSame($data->any, 'one'); + $this->assertSame($data->str, 'two'); + $this->assertSame('value', $data->dep->key); + } + + /** + * Test invoke() injecting dependencies that exist in passed params as objects. + * The accepted types of `params.pass` was never enforced and userland code has + * creative uses of this previously unspecified behavior. + */ + public function testCreateWithContainerDependenciesWithObjectRouteParam(): void + { + $inject = new stdClass(); + $inject->id = uniqid(); + + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredDep', + 'pass' => [$inject], + ], + ]); + $controller = $this->factory->create($request); + $response = $this->factory->invoke($controller); + + $data = json_decode((string)$response->getBody()); + $this->assertNotNull($data); + $this->assertEquals($data->dep->id, $inject->id); + } + + public function testCreateWithNonStringScalarRouteParam(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/required_typed', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredTyped', + 'pass' => [1.1, 2, true, ['foo' => 'bar']], + ], + ]); + $controller = $this->factory->create($request); + $response = $this->factory->invoke($controller); + + $expected = ['one' => 1.1, 'two' => 2, 'three' => true, 'four' => ['foo' => 'bar']]; + $data = json_decode((string)$response->getBody(), true); + $this->assertSame($expected, $data); + } + + /** + * Ensure that a controllers startup process can emit a response + */ + public function testInvokeInjectParametersRequiredDefined(): void + { + $this->container->add(stdClass::class, json_decode('{"key":"value"}')); + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredDep', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredDep', + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertNull($data->any); + $this->assertNull($data->str); + $this->assertSame('value', $data->dep->key); + } + + /** + * Ensure that a controllers startup process can emit a response + */ + public function testInvokeInjectParametersRequiredNotDefined(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredDep', + ], + ]); + $controller = $this->factory->create($request); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage( + 'Failed to inject dependency from service container for parameter `dep` with type `stdClass` in action `Dependencies::requiredDep()`', + ); + $this->factory->invoke($controller); + } + + public function testInvokeInjectParametersRequiredMissingUntyped(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredParam', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredParam', + ], + ]); + $controller = $this->factory->create($request); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Missing passed parameter for `one` in action `Dependencies::requiredParam()`'); + $this->factory->invoke($controller); + } + + public function testInvokeInjectParametersRequiredUntyped(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredParam', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredParam', + 'pass' => ['one'], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertSame($data->one, 'one'); + } + + public function testInvokeInjectParametersRequiredWithPassedParameters(): void + { + $this->container->add(stdClass::class, json_decode('{"key":"value"}')); + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredDep', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredDep', + 'pass' => ['one', 'two'], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertSame($data->any, 'one'); + $this->assertSame($data->str, 'two'); + $this->assertSame('value', $data->dep->key); + } + + /** + * Test that routing parameters are passed into variadic controller functions + */ + public function testInvokeInjectPassedParametersVariadic(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/variadic', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'variadic', + 'pass' => ['one', 'two'], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertSame(['one', 'two'], $data->args); + } + + /** + * Test that routing parameters are passed into controller action using spread operator + */ + public function testInvokeInjectPassedParametersSpread(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/spread', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'spread', + 'pass' => ['one', 'two'], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertSame(['one', 'two'], $data->args); + } + + /** + * Test that routing parameters are passed into controller action using spread operator + */ + public function testInvokeInjectPassedParametersSpreadNoParams(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/spread', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'spread', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertSame([], $data->args); + } + + /** + * Test that default parameters work for controller methods + */ + public function testInvokeOptionalStringParam(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/optionalString', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'optionalString', + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody()); + + $this->assertNotNull($data); + $this->assertSame('default val', $data->str); + } + + /** + * Test that required strings a default value. + */ + public function testInvokeRequiredStringParam(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredString', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredString', + ], + ]); + $controller = $this->factory->create($request); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Missing passed parameter for `str` in action `Dependencies::requiredString()`'); + $this->factory->invoke($controller); + } + + /** + * Test that coercing string to float, int and bool params + */ + public function testInvokePassedParametersCoercion(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredTyped', + 'pass' => ['1.0', '2', '0', '8,9'], + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + $this->assertSame(['one' => 1.0, 'two' => 2, 'three' => false, 'four' => ['8', '9']], $data); + + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredTyped', + 'pass' => ['1.0', '0', '0', ''], + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + $this->assertSame(['one' => 1.0, 'two' => 0, 'three' => false, 'four' => []], $data); + + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredTyped', + 'pass' => ['1.0', '-1', '0', ''], + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + $this->assertSame(['one' => 1.0, 'two' => -1, 'three' => false, 'four' => []], $data); + } + + /** + * Test that default values work for typed parameters + */ + public function testInvokeOptionalTypedParam(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/optionalTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'optionalTyped', + 'pass' => ['1.0'], + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + + $this->assertSame(['one' => 1.0, 'two' => 2, 'three' => true], $data); + } + + /** + * Test using invalid value for supported type + */ + public function testInvokePassedParametersUnsupportedFloatCoercion(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredTyped', + 'pass' => ['true', '2', '1'], + ], + ]); + $controller = $this->factory->create($request); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Unable to coerce `true` to `float` for `one` in action `Dependencies::requiredTyped()`'); + $this->factory->invoke($controller); + } + + /** + * Test using invalid value for supported type + */ + public function testInvokePassedParametersUnsupportedIntCoercion(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredTyped', + 'pass' => ['1', '2.0', '1'], + ], + ]); + $controller = $this->factory->create($request); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Unable to coerce `2.0` to `int` for `two` in action `Dependencies::requiredTyped()`'); + $this->factory->invoke($controller); + } + + /** + * Test using invalid value for supported type + */ + public function testInvokePassedParametersUnsupportedBoolCoercion(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/requiredTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'requiredTyped', + 'pass' => ['1', '1', 'true'], + ], + ]); + $controller = $this->factory->create($request); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Unable to coerce `true` to `bool` for `three` in action `Dependencies::requiredTyped()`'); + $this->factory->invoke($controller); + } + + /** + * Test using an unsupported type. + */ + public function testInvokePassedParamUnsupportedType(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/unsupportedTyped', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'unsupportedTyped', + 'pass' => ['test'], + ], + ]); + $controller = $this->factory->create($request); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Unable to coerce `test` to `iterable` for `one` in action `Dependencies::unsupportedTyped()`'); + $this->factory->invoke($controller); + } + + /** + * Test using an unsupported reflection type. + */ + public function testInvokePassedParamUnsupportedReflectionType(): void + { + $request = new ServerRequest([ + 'url' => 'test_plugin_three/dependencies/unsupportedTypedUnion', + 'params' => [ + 'plugin' => null, + 'controller' => 'Dependencies', + 'action' => 'typedUnion', + 'pass' => ['1'], + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + + $this->assertSame(['one' => '1'], $data); + } + + /** + * Test that default values work for typed parameters + */ + public function testInvokeComponentFromContainer(): void + { + $this->container->add(FlashComponent::class, function (ComponentRegistry $registry, array $config) { + return new FlashComponent($registry, $config); + }) + ->addArgument(ComponentRegistry::class) + ->addArgument(['key' => 'customFlash']); + + $request = new ServerRequest([ + 'url' => 'test_plugin_three/component-test/flash', + 'params' => [ + 'plugin' => null, + 'controller' => 'ComponentTest', + 'action' => 'flash', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + + $result = $this->factory->invoke($controller); + $data = json_decode((string)$result->getBody(), true); + + $this->assertSame(['flashKey' => 'customFlash'], $data); + } + + public function testMiddleware(): void + { + $request = new ServerRequest([ + 'url' => 'posts', + 'params' => [ + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + $this->factory->invoke($controller); + + $request = $controller->getRequest(); + $this->assertTrue($request->getAttribute('for-all')); + $this->assertTrue($request->getAttribute('index-only')); + $this->assertNull($request->getAttribute('all-except-index')); + + $request = new ServerRequest([ + 'url' => 'posts/get', + 'params' => [ + 'controller' => 'Posts', + 'action' => 'get', + 'pass' => [], + ], + ]); + $controller = $this->factory->create($request); + $this->factory->invoke($controller); + + $request = $controller->getRequest(); + $this->assertTrue($request->getAttribute('for-all')); + $this->assertNull($request->getAttribute('index-only')); + $this->assertTrue($request->getAttribute('all-except-index')); + } +} diff --git a/tests/TestCase/Controller/ControllerTest.php b/tests/TestCase/Controller/ControllerTest.php new file mode 100644 index 00000000000..ae0ba677833 --- /dev/null +++ b/tests/TestCase/Controller/ControllerTest.php @@ -0,0 +1,1146 @@ + + */ + protected array $fixtures = [ + 'core.Comments', + 'core.Posts', + ]; + + /** + * reset environment. + */ + protected function setUp(): void + { + parent::setUp(); + + static::setAppNamespace(); + Router::reload(); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + } + + /** + * test autoload default table + */ + public function testTableAutoload(): void + { + $request = new ServerRequest(['url' => 'controller/posts/index']); + $Controller = new Controller($request, 'Articles'); + + $this->assertInstanceOf( + ArticlesTable::class, + $Controller->Articles, + ); + } + + /** + * testUndefinedPropertyError + */ + public function testUndefinedPropertyError(): void + { + $this->expectNoticeMessageMatches('/Undefined property `Controller::\$Foo` in `.*` on line \d+/', function (): void { + $controller = new Controller(new ServerRequest()); + $controller->Foo->baz(); + }); + } + + /** + * testGetTable method + */ + public function testGetTable(): void + { + $request = new ServerRequest(['url' => 'controller/posts/index']); + $Controller = new Controller($request); + + $this->assertFalse(isset($Controller->Articles)); + + $result = $Controller->fetchTable('Articles'); + $this->assertInstanceOf( + ArticlesTable::class, + $result, + ); + } + + public function testAutoLoadModelUsingDefaultTable(): void + { + Configure::write('App.namespace', 'TestApp'); + $Controller = new WithDefaultTableController(new ServerRequest()); + + $this->assertInstanceOf(PostsTable::class, $Controller->Posts); + + Configure::write('App.namespace', 'App'); + } + + /** + * @link https://github.com/cakephp/cakephp/issues/14804 + */ + public function testAutoLoadTableUsingFqcn(): void + { + Configure::write('App.namespace', 'TestApp'); + $Controller = new ArticlesController(new ServerRequest()); + + $this->assertInstanceOf(ArticlesTable::class, $Controller->fetchTable()); + + Configure::write('App.namespace', 'App'); + } + + public function testGetTableInPlugins(): void + { + $this->loadPlugins(['TestPlugin']); + + $Controller = new TestPluginController(new ServerRequest()); + $Controller->setPlugin('TestPlugin'); + + $this->assertFalse(isset($Controller->TestPluginComments)); + + $result = $Controller->fetchTable('TestPlugin.TestPluginComments'); + $this->assertInstanceOf( + TestPluginCommentsTable::class, + $result, + ); + } + + /** + * Test that the constructor sets defaultTable properly. + */ + public function testConstructSetDefaultTable(): void + { + $this->loadPlugins(['TestPlugin']); + + $request = new ServerRequest(); + $controller = new PostsController($request); + $this->assertInstanceOf(PostsTable::class, $controller->fetchTable()); + + $controller = new AdminPostsController($request); + $this->assertInstanceOf(PostsTable::class, $controller->fetchTable()); + + $request = $request->withParam('plugin', 'TestPlugin'); + $controller = new CommentsController($request); + $this->assertInstanceOf(CommentsTable::class, $controller->fetchTable()); + } + + /** + * testConstructClassesWithComponents method + */ + public function testConstructClassesWithComponents(): void + { + $this->loadPlugins(['TestPlugin']); + + $Controller = new TestPluginController(new ServerRequest()); + $Controller->loadComponent('TestPlugin.Other'); + + $this->assertInstanceOf(OtherComponent::class, $Controller->Other); + } + + /** + * testRender method + */ + public function testRender(): void + { + $this->loadPlugins(['TestPlugin']); + + $request = new ServerRequest([ + 'url' => 'controller_posts/index', + 'params' => [ + 'action' => 'header', + ], + ]); + + $Controller = new Controller($request); + $Controller->viewBuilder()->setTemplatePath('Posts'); + + $result = $Controller->render('index'); + $this->assertMatchesRegularExpression('/posts index/', (string)$result); + + $Controller->viewBuilder()->setTemplate('index'); + $result = $Controller->render(); + $this->assertMatchesRegularExpression('/posts index/', (string)$result); + + $result = $Controller->render('/element/test_element'); + $this->assertMatchesRegularExpression('/this is the test element/', (string)$result); + } + + public function testAddViewClasses(): void + { + $request = new ServerRequest([ + 'url' => 'controller_posts/index', + ]); + $controller = new ContentTypesController($request); + $this->assertSame([], $controller->viewClasses()); + + $controller->addViewClasses([PlainTextView::class]); + $this->assertSame([PlainTextView::class], $controller->viewClasses()); + + $controller->addViewClasses([XmlView::class]); + $this->assertSame([PlainTextView::class, XmlView::class], $controller->viewClasses()); + } + + /** + * Test that render() will do content negotiation when supported + * by the controller. + */ + public function testRenderViewClassesContentNegotiationMatch(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => ['HTTP_ACCEPT' => 'application/json'], + ]); + $controller = new ContentTypesController($request); + $controller->all(); + $response = $controller->render(); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type'), 'Has correct header'); + $this->assertNotEmpty(json_decode($response->getBody() . ''), 'Body should be json'); + } + + /** + * Test that render() will do content negotiation when supported + * by the controller. + */ + public function testRenderViewClassContentNegotiationMatchLast(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => ['HTTP_ACCEPT' => 'application/xml'], + ]); + $controller = new ContentTypesController($request); + $controller->all(); + $response = $controller->render(); + $this->assertSame( + 'application/xml; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + 'Has correct header', + ); + $this->assertStringContainsString('getBody() . ''); + } + + public function testRenderViewClassesContentNegotiationNoMatch(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => ['HTTP_ACCEPT' => 'text/plain'], + 'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'all'], + ]); + $controller = new ContentTypesController($request); + $controller->all(); + $response = $controller->render(); + $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('hello world', $response->getBody() . ''); + } + + /** + * Test that render() will skip content-negotiation when a view class is set. + */ + public function testRenderViewClassContentNegotiationSkipWithViewClass(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => ['HTTP_ACCEPT' => 'application/xml'], + 'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'all'], + ]); + $controller = new ContentTypesController($request); + $controller->all(); + $controller->viewBuilder()->setClassName(View::class); + $response = $controller->render(); + $this->assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + 'Should not be XML response.', + ); + $this->assertStringContainsString('hello world', $response->getBody() . ''); + } + + /** + * Test that render() will do content negotiation when supported + * by the controller. + */ + public function testRenderViewClassesContentNegotiationMatchAllType(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => ['HTTP_ACCEPT' => 'text/html'], + ]); + $controller = new ContentTypesController($request); + $controller->matchAll(); + $response = $controller->render(); + $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type'), 'Default response type'); + $this->assertEmpty($response->getBody() . '', 'Body should be empty'); + $this->assertSame(406, $response->getStatusCode(), 'status code is wrong'); + } + + public function testRenderViewClassesSetContentTypeHeader(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => ['HTTP_ACCEPT' => 'text/plain'], + 'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'plain'], + ]); + $controller = new ContentTypesController($request); + $controller->plain(); + $response = $controller->render(); + $this->assertSame('text/plain; charset=UTF-8', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('hello world', $response->getBody() . ''); + } + + public function testRenderViewClassesUsesSingleMimeExt(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => [], + 'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'all', '_ext' => 'json'], + ]); + $controller = new ContentTypesController($request); + $controller->all(); + $response = $controller->render(); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertNotEmpty(json_decode($response->getBody() . ''), 'Body should be json'); + } + + public function testRenderViewClassesUsesMultiMimeExt(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => [], + 'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'all', '_ext' => 'xml'], + ]); + $controller = new ContentTypesController($request); + $controller->all(); + $response = $controller->render(); + $this->assertSame('application/xml; charset=UTF-8', $response->getHeaderLine('Content-Type')); + $this->assertTextStartsWith('getBody() . '', 'Body should be xml'); + } + + public function testRenderViewClassesMineExtMissingView(): void + { + $request = new ServerRequest([ + 'url' => '/', + 'environment' => [], + 'params' => ['plugin' => null, 'controller' => 'ContentTypes', 'action' => 'all', '_ext' => 'json'], + ]); + $controller = new ContentTypesController($request); + $controller->plain(); + + $this->expectException(NotFoundException::class); + $controller->render(); + } + + /** + * test view rendering changing response + */ + public function testRenderViewChangesResponse(): void + { + $request = new ServerRequest([ + 'url' => 'controller_posts/index', + 'params' => [ + 'action' => 'header', + ], + ]); + + $controller = new Controller($request); + $controller->viewBuilder()->setTemplatePath('Posts'); + + $result = $controller->render('header'); + $this->assertStringContainsString('header template', (string)$result); + $this->assertTrue($controller->getResponse()->hasHeader('X-view-template')); + $this->assertSame('yes', $controller->getResponse()->getHeaderLine('X-view-template')); + } + + /** + * test that a component beforeRender can change the controller view class. + */ + public function testBeforeRenderCallbackChangingViewClass(): void + { + $Controller = new Controller(new ServerRequest()); + + $Controller->getEventManager()->on('Controller.beforeRender', function (EventInterface $event): void { + $controller = $event->getSubject(); + $controller->viewBuilder()->setClassName('Json'); + }); + + $Controller->set([ + 'test' => 'value', + ]); + $Controller->viewBuilder()->setOption('serialize', ['test']); + $debug = Configure::read('debug'); + Configure::write('debug', false); + $result = $Controller->render('index'); + $this->assertSame('{"test":"value"}', (string)$result->getBody()); + Configure::write('debug', $debug); + } + + /** + * test that a component beforeRender can change the controller view class. + */ + public function testBeforeRenderEventCancelsRender(): void + { + $Controller = new Controller(new ServerRequest()); + + $Controller->getEventManager()->on('Controller.beforeRender', function (EventInterface $event): void { + $event->stopPropagation(); + }); + + $result = $Controller->render('index'); + $this->assertInstanceOf(Response::class, $result); + } + + public function testControllerRedirect(): void + { + $Controller = new Controller(new ServerRequest()); + $uri = new Uri('/foo/bar'); + $response = $Controller->redirect($uri); + $this->assertSame('http://localhost/foo/bar', $response->getHeaderLine('Location')); + + $Controller = new Controller(new ServerRequest()); + $uri = new Uri('http://cakephp.org/foo/bar'); + $response = $Controller->redirect($uri); + $this->assertSame('http://cakephp.org/foo/bar', $response->getHeaderLine('Location')); + } + + /** + * Generates status codes for redirect test. + * + * @return array + */ + public static function statusCodeProvider(): array + { + return [ + [300, 'Multiple Choices'], + [301, 'Moved Permanently'], + [302, 'Found'], + [303, 'See Other'], + [304, 'Not Modified'], + [305, 'Use Proxy'], + [307, 'Temporary Redirect'], + ]; + } + + /** + * testRedirect method + */ + #[DataProvider('statusCodeProvider')] + public function testRedirectByCode(int $code, string $msg): void + { + $Controller = new Controller(new ServerRequest()); + + $response = $Controller->redirect('http://cakephp.org', (int)$code); + $this->assertSame($response, $Controller->getResponse()); + $this->assertEquals($code, $response->getStatusCode()); + $this->assertSame('http://cakephp.org', $response->getHeaderLine('Location')); + $this->assertFalse($Controller->isAutoRenderEnabled()); + } + + /** + * test that beforeRedirect callbacks can set the URL that is being redirected to. + */ + public function testRedirectBeforeRedirectModifyingUrl(): void + { + $Controller = new Controller(new ServerRequest()); + + $Controller->getEventManager()->on('Controller.beforeRedirect', function (EventInterface $event, $url, Response $response): void { + $controller = $event->getSubject(); + $controller->setResponse($response->withLocation('https://book.cakephp.org')); + }); + + $response = $Controller->redirect('http://cakephp.org', 301); + $this->assertSame('https://book.cakephp.org', $response->getHeaderLine('Location')); + $this->assertSame(301, $response->getStatusCode()); + } + + /** + * test that beforeRedirect callback returning null doesn't affect things. + */ + public function testRedirectBeforeRedirectModifyingStatusCode(): void + { + $Controller = new Controller(new ServerRequest()); + + $Controller->getEventManager()->on('Controller.beforeRedirect', function (EventInterface $event, $url, Response $response): void { + $controller = $event->getSubject(); + $controller->setResponse($response->withStatus(302)); + }); + + $response = $Controller->redirect('http://cakephp.org', 301); + + $this->assertSame('http://cakephp.org', $response->getHeaderLine('Location')); + $this->assertSame(302, $response->getStatusCode()); + } + + public function testRedirectBeforeRedirectListenerReturnResponse(): void + { + $Controller = new Controller(new ServerRequest()); + + $newResponse = new Response(); + $Controller->getEventManager()->on('Controller.beforeRedirect', function (EventInterface $event, $url, Response $response) use ($newResponse): void { + $event->setResult($newResponse); + }); + + $result = $Controller->redirect('http://cakephp.org'); + $this->assertSame($newResponse, $result); + $this->assertSame($newResponse, $Controller->getResponse()); + } + + public function testRedirectWithInvalidStatusCode(): void + { + $Controller = new Controller(new ServerRequest()); + $uri = new Uri('/foo/bar'); + $this->expectException(InvalidArgumentException::class); + $Controller->redirect($uri, 200); + } + + /** + * testReferer method + */ + public function testReferer(): void + { + $request = new ServerRequest([ + 'environment' => ['HTTP_REFERER' => 'http://localhost/posts/index'], + ]); + $Controller = new Controller($request); + $result = $Controller->referer(); + $this->assertSame('/posts/index', $result); + + $request = new ServerRequest([ + 'environment' => ['HTTP_REFERER' => 'http://localhost/posts/index'], + ]); + $Controller = new Controller($request); + $result = $Controller->referer(['controller' => 'Posts', 'action' => 'index'], true); + $this->assertSame('/posts/index', $result); + + $request = new ServerRequest([ + 'environment' => ['HTTP_REFERER' => 'http://localhost/posts/index'], + ]); + $Controller = new Controller($request); + $result = $Controller->referer(null, false); + $this->assertSame('http://localhost/posts/index', $result); + + $Controller = new Controller(new ServerRequest()); + $result = $Controller->referer('/', false); + $this->assertSame('http://localhost/', $result); + } + + /** + * Test that the referer is not absolute if it is '/'. + * + * This avoids the base path being applied twice on string urls. + */ + public function testRefererSlash(): void + { + $request = new ServerRequest(); + $request = $request->withAttribute('base', '/base'); + Router::setRequest($request); + + $controller = new Controller($request); + $result = $controller->referer('/', true); + $this->assertSame('/', $result); + + $controller = new Controller($request); + $result = $controller->referer('/some/path', true); + $this->assertSame('/some/path', $result); + } + + /** + * Tests that the startup process calls the correct functions + */ + public function testStartupProcess(): void + { + $eventManager = Mockery::spy(EventManager::class); + $controller = new Controller(new ServerRequest(), null, $eventManager); + $controller->startupProcess(); + + $eventManager + ->shouldHaveReceived('dispatch') + ->withArgs(function ($event) { + return $event->getName() === 'Controller.initialize'; + }); + + $eventManager + ->shouldHaveReceived('dispatch') + ->withArgs(function ($event) { + return $event->getName() === 'Controller.startup'; + }); + } + + /** + * Tests that the shutdown process calls the correct functions + */ + public function testShutdownProcess(): void + { + $eventManager = Mockery::spy(EventManager::class); + $controller = new Controller(new ServerRequest(), null, $eventManager); + $controller->shutdownProcess(); + + $eventManager->shouldHaveReceived('dispatch') + ->once() + ->withArgs(function ($event) { + return $event->getName() === 'Controller.shutdown'; + }); + } + + /** + * test using Controller::paginate() + */ + public function testPaginate(): void + { + $request = new ServerRequest(['url' => 'controller_posts/index']); + + $Controller = new Controller($request); + $Controller->setRequest($Controller->getRequest()->withQueryParams([ + 'posts' => [ + 'page' => 2, + 'limit' => 2, + ], + ])); + + $this->assertNotContains('Paginator', $Controller->viewBuilder()->getHelpers()); + $this->assertArrayNotHasKey('Paginator', $Controller->viewBuilder()->getHelpers()); + + $results = $Controller->paginate('Posts'); + $this->assertInstanceOf(PaginatedInterface::class, $results); + $this->assertCount(3, $results); + + $results = $Controller->paginate($this->getTableLocator()->get('Posts')); + $this->assertInstanceOf(PaginatedInterface::class, $results); + $this->assertCount(3, $results); + + $this->assertSame($results->currentPage(), 1); + $this->assertSame($results->pageCount(), 1); + $this->assertFalse($results->hasPrevPage()); + $this->assertFalse($results->hasPrevPage()); + $this->assertNull($results->pagingParam('scope')); + + $results = $Controller->paginate( + $this->getTableLocator()->get('Posts'), + ['scope' => 'posts', 'className' => 'Numeric'], + ); + $this->assertInstanceOf(PaginatedInterface::class, $results); + $this->assertCount(1, $results); + + $this->assertSame($results->currentPage(), 2); + $this->assertSame($results->pageCount(), 2); + $this->assertTrue($results->hasPrevPage()); + $this->assertFalse($results->hasNextPage()); + $this->assertSame($results->pagingParam('scope'), 'posts'); + + $results = $Controller->paginate( + $this->getTableLocator()->get('Posts'), + ['className' => 'Simple'], + ); + $this->assertInstanceOf(PaginatedInterface::class, $results); + + $this->assertNull($results->pageCount(), "SimplePaginator doesn't have a page count"); + } + + /** + * test that paginate uses modelClass property. + */ + public function testPaginateUsesModelClass(): void + { + $request = new ServerRequest([ + 'url' => 'controller_posts/index', + ]); + + $Controller = new Controller($request, 'Posts'); + $results = $Controller->paginate(); + + $this->assertInstanceOf(PaginatedInterface::class, $results); + } + + public function testPaginateException(): void + { + $this->expectException(NotFoundException::class); + + $request = new ServerRequest(['url' => 'controller_posts/index?page=2&limit=100']); + + $Controller = new Controller($request, 'Posts'); + + $Controller->paginate(); + } + + /** + * testMissingAction method + */ + public function testGetActionMissingAction(): void + { + $url = new ServerRequest([ + 'url' => 'test/missing', + 'params' => ['controller' => 'Test', 'action' => 'missing'], + ]); + + $Controller = new TestController($url); + try { + $Controller->getAction(); + } catch (MissingActionException $e) { + $this->assertEquals( + 'Action `TestController::missing()` could not be found, or is not accessible.', + $e->getMessage(), + ); + $this->assertEquals( + ['controller' => 'TestController', 'action' => 'missing', 'prefix' => null, 'plugin' => null], + $e->getAttributes(), + ); + } + } + + /** + * test invoking private methods. + */ + public function testGetActionPrivate(): void + { + $this->expectException(MissingActionException::class); + $this->expectExceptionMessage('Action `TestController::private_m()` could not be found, or is not accessible.'); + $url = new ServerRequest([ + 'url' => 'test/private_m/', + 'params' => ['controller' => 'Test', 'action' => 'private_m'], + ]); + + $Controller = new TestController($url); + $Controller->getAction(); + } + + /** + * test invoking protected methods. + */ + public function testGetActionProtected(): void + { + $this->expectException(MissingActionException::class); + $this->expectExceptionMessage('Action `TestController::protected_m()` could not be found, or is not accessible.'); + $url = new ServerRequest([ + 'url' => 'test/protected_m/', + 'params' => ['controller' => 'Test', 'action' => 'protected_m'], + ]); + + $Controller = new TestController($url); + $Controller->getAction(); + } + + /** + * test invoking controller methods. + */ + public function testGetActionBaseMethods(): void + { + $this->expectException(MissingActionException::class); + $this->expectExceptionMessage('Action `TestController::redirect()` could not be found, or is not accessible.'); + $url = new ServerRequest([ + 'url' => 'test/redirect/', + 'params' => ['controller' => 'Test', 'action' => 'redirect'], + ]); + + $Controller = new TestController($url); + $Controller->getAction(); + } + + /** + * test invoking action method with mismatched casing. + */ + public function testGetActionMethodCasing(): void + { + $this->expectException(MissingActionException::class); + $this->expectExceptionMessage('Action `TestController::RETURNER()` could not be found, or is not accessible.'); + $url = new ServerRequest([ + 'url' => 'test/RETURNER/', + 'params' => ['controller' => 'Test', 'action' => 'RETURNER'], + ]); + + $Controller = new TestController($url); + $Controller->getAction(); + } + + public function testGetActionArgsReflection(): void + { + $request = new ServerRequest([ + 'url' => 'test/reflection/1', + 'params' => [ + 'controller' => 'Test', + 'action' => 'reflection', + 'pass' => ['1'], + ], + ]); + $controller = new TestController($request); + + $closure = $controller->getAction(); + $args = (new ReflectionFunction($closure))->getParameters(); + + $this->assertSame('Parameter #0 [ $passed ]', (string)$args[0]); + $this->assertSame('Parameter #1 [ Cake\ORM\Table $table ]', (string)$args[1]); + } + + /** + * test invoking controller methods. + */ + public function testInvokeActionReturnValue(): void + { + $url = new ServerRequest([ + 'url' => 'test/returner/', + 'params' => [ + 'controller' => 'Test', + 'action' => 'returner', + 'pass' => [], + ], + ]); + + $Controller = new TestController($url); + $Controller->invokeAction($Controller->getAction(), $Controller->getRequest()->getParam('pass')); + + $this->assertSame('I am from the controller.', (string)$Controller->getResponse()); + } + + /** + * test invoking controller methods with passed params + */ + public function testInvokeActionWithPassedParams(): void + { + $request = new ServerRequest([ + 'url' => 'test/index/1/2', + 'params' => [ + 'controller' => 'Test', + 'action' => 'index', + 'pass' => ['param1' => '1', 'param2' => '2'], + ], + ]); + $controller = new TestController($request); + $controller->disableAutoRender(); + $controller->invokeAction($controller->getAction(), array_values($controller->getRequest()->getParam('pass'))); + + $this->assertEquals( + ['testId' => '1', 'test2Id' => '2'], + $controller->getRequest()->getData(), + ); + } + + /** + * test invalid return value from action method. + */ + public function testInvokeActionException(): void + { + $this->expectException(AssertionError::class); + $this->expectExceptionMessage( + 'Controller actions can only return Response instance or null. ' + . 'Got string instead.', + ); + + $url = new ServerRequest([ + 'url' => 'test/willCauseException', + 'params' => [ + 'controller' => 'Test', + 'action' => 'willCauseException', + 'pass' => [], + ], + ]); + + $Controller = new TestController($url); + $Controller->invokeAction($Controller->getAction(), $Controller->getRequest()->getParam('pass')); + } + + /** + * test that a classes namespace is used in the viewPath. + */ + public function testViewPathConventions(): void + { + $request = new ServerRequest([ + 'url' => 'admin/posts', + 'params' => ['prefix' => 'Admin'], + ]); + $Controller = new AdminPostsController($request); + $Controller->getEventManager()->on('Controller.beforeRender', function (EventInterface $e): void { + $e->setResult($e->getSubject()->getResponse()); + }); + $Controller->render(); + $this->assertSame('Admin' . DS . 'Posts', $Controller->viewBuilder()->getTemplatePath()); + + $request = $request->withParam('prefix', 'admin/super'); + $Controller = new AdminPostsController($request); + $Controller->getEventManager()->on('Controller.beforeRender', function (EventInterface $e): void { + $e->setResult($e->getSubject()->getResponse()); + }); + $Controller->render(); + $this->assertSame('Admin' . DS . 'Super' . DS . 'Posts', $Controller->viewBuilder()->getTemplatePath()); + + $request = new ServerRequest([ + 'url' => 'pages/home', + 'params' => [ + 'prefix' => false, + ], + ]); + $Controller = new PagesController($request); + $Controller->getEventManager()->on('Controller.beforeRender', function (EventInterface $e): void { + $e->setResult($e->getSubject()->getResponse()); + }); + $Controller->render(); + $this->assertSame('Pages', $Controller->viewBuilder()->getTemplatePath()); + } + + /** + * Test the components() method. + */ + public function testComponents(): void + { + $request = new ServerRequest(['url' => '/']); + + $controller = new TestController($request); + $this->assertInstanceOf(ComponentRegistry::class, $controller->components()); + + $result = $controller->components(); + $this->assertSame($result, $controller->components()); + } + + /** + * Test adding a component + */ + public function testLoadComponent(): void + { + $request = new ServerRequest(['url' => '/']); + + $controller = new TestController($request); + $result = $controller->loadComponent('FormProtection'); + $this->assertInstanceOf(FormProtectionComponent::class, $result); + $this->assertSame($result, $controller->FormProtection); + + $registry = $controller->components(); + $this->assertTrue(isset($registry->FormProtection)); + } + + /** + * Test adding a component that is a duplicate. + */ + public function testLoadComponentDuplicate(): void + { + $request = new ServerRequest(['url' => '/']); + + $controller = new TestController($request); + $this->assertNotEmpty($controller->loadComponent('FormProtection')); + $this->assertNotEmpty($controller->loadComponent('FormProtection')); + try { + $controller->loadComponent('FormProtection', ['bad' => 'settings']); + $this->fail('No exception'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('The `FormProtection` alias has already been loaded', $e->getMessage()); + } + } + + /** + * Test adding a component with container passed to controller + */ + public function testLoadComponentWithContainer(): void + { + $container = new Container(); + $container->add(FlashComponent::class, function (ComponentRegistry $registry, array $config) { + return new FlashComponent($registry, $config); + }) + ->addArgument(ComponentRegistry::class) + ->addArgument(['key' => 'customFlash']); + + $request = new ServerRequest(['url' => '/']); + + $controller = new TestController($request); + $result = $controller->loadComponent('Flash'); + $this->assertInstanceOf(FlashComponent::class, $result); + $this->assertSame($result, $controller->Flash); + + $registry = $controller->components(); + $this->assertTrue(isset($registry->Flash)); + } + + /** + * Test the isAction method. + */ + public function testIsAction(): void + { + $request = new ServerRequest(['url' => '/']); + $controller = new TestController($request); + + $this->assertFalse($controller->isAction('redirect')); + $this->assertFalse($controller->isAction('beforeFilter')); + $this->assertTrue($controller->isAction('index')); + } + + /** + * Test that view variables are being set after the beforeRender event gets dispatched + */ + public function testBeforeRenderViewVariables(): void + { + $controller = new AdminPostsController(new ServerRequest()); + + $controller->getEventManager()->on('Controller.beforeRender', function (EventInterface $event): void { + /** @var \Cake\Controller\Controller $controller */ + $controller = $event->getSubject(); + + $controller->set('testVariable', 'test'); + }); + + $controller->dispatchEvent('Controller.beforeRender'); + $view = $controller->createView(); + + $this->assertNotEmpty('testVariable', $view->get('testVariable')); + } + + /** + * Test that render()'s arguments are available in beforeRender() through view builder. + */ + public function testBeforeRenderTemplateAndLayout(): void + { + $Controller = new Controller(new ServerRequest()); + $Controller->getEventManager()->on('Controller.beforeRender', function ($event): void { + $this->assertSame( + '/Element/test_element', + $event->getSubject()->viewBuilder()->getTemplate(), + ); + $this->assertSame( + 'default', + $event->getSubject()->viewBuilder()->getLayout(), + ); + + $event->getSubject()->viewBuilder() + ->setTemplatePath('Posts') + ->setTemplate('index'); + }); + + $result = $Controller->render('/Element/test_element', 'default'); + $this->assertMatchesRegularExpression('/posts index/', (string)$result); + } + + /** + * Test name getter and setter. + */ + public function testName(): void + { + $controller = new AdminPostsController(new ServerRequest()); + $this->assertSame('Posts', $controller->getName()); + + $this->assertSame($controller, $controller->setName('Articles')); + $this->assertSame('Articles', $controller->getName()); + } + + /** + * Test plugin getter and setter. + */ + public function testPlugin(): void + { + $controller = new AdminPostsController(new ServerRequest()); + $this->assertNull($controller->getPlugin()); + + $this->assertSame($controller, $controller->setPlugin('Articles')); + $this->assertSame('Articles', $controller->getPlugin()); + } + + /** + * Test request getter and setter. + */ + public function testRequest(): void + { + $controller = new AdminPostsController(new ServerRequest()); + $this->assertInstanceOf(ServerRequest::class, $controller->getRequest()); + + $request = new ServerRequest([ + 'params' => [ + 'plugin' => 'Posts', + 'pass' => [ + 'foo', + 'bar', + ], + ], + ]); + $this->assertSame($controller, $controller->setRequest($request)); + $this->assertSame($request, $controller->getRequest()); + + $this->assertSame('Posts', $controller->getRequest()->getParam('plugin')); + $this->assertEquals(['foo', 'bar'], $controller->getRequest()->getParam('pass')); + } + + /** + * Test response getter and setter. + */ + public function testResponse(): void + { + $controller = new AdminPostsController(new ServerRequest()); + $this->assertInstanceOf(Response::class, $controller->getResponse()); + + $response = new Response(); + $this->assertSame($controller, $controller->setResponse($response)); + $this->assertSame($response, $controller->getResponse()); + } + + /** + * Test autoRender getter and setter. + */ + public function testAutoRender(): void + { + $controller = new AdminPostsController(new ServerRequest()); + $this->assertTrue($controller->isAutoRenderEnabled()); + + $this->assertSame($controller, $controller->disableAutoRender()); + $this->assertFalse($controller->isAutoRenderEnabled()); + + $this->assertSame($controller, $controller->enableAutoRender()); + $this->assertTrue($controller->isAutoRenderEnabled()); + } +} diff --git a/tests/TestCase/Controller/Exception/AuthSecurityExceptionTest.php b/tests/TestCase/Controller/Exception/AuthSecurityExceptionTest.php new file mode 100644 index 00000000000..808ee2192f1 --- /dev/null +++ b/tests/TestCase/Controller/Exception/AuthSecurityExceptionTest.php @@ -0,0 +1,56 @@ +deprecated(function (): void { + $this->authSecurityException = new AuthSecurityException(); + }); + } + + /** + * Test the getType() function. + */ + public function testGetType(): void + { + $this->assertSame( + 'auth', + $this->authSecurityException->getType(), + '::getType should always return the type of `auth`.', + ); + } +} diff --git a/tests/TestCase/Controller/Exception/SecurityExceptionTest.php b/tests/TestCase/Controller/Exception/SecurityExceptionTest.php new file mode 100644 index 00000000000..05b12c60852 --- /dev/null +++ b/tests/TestCase/Controller/Exception/SecurityExceptionTest.php @@ -0,0 +1,85 @@ +deprecated(function (): void { + $this->securityException = new SecurityException(); + }); + } + + /** + * Test the getType() function. + */ + public function testGetType(): void + { + $type = $this->securityException->getType(); + $this->assertSame( + 'secure', + $type, + '::getType should always return the type of `secure`.', + ); + } + + /** + * Test the setMessage() function. + */ + public function testSetMessage(): void + { + $sampleMessage = 'foo'; + $this->securityException->setMessage($sampleMessage); + $this->assertSame( + $sampleMessage, + $this->securityException->getMessage(), + '::getMessage should always return the message set.', + ); + } + + /** + * Test the setReason() and corresponding getReason() function. + */ + public function testSetGetReason(): void + { + $sampleReason = 'canary'; + $this->securityException->setReason($sampleReason); + $this->assertSame( + $sampleReason, + $this->securityException->getReason(), + '::getReason should always return the reason set.', + ); + } +} diff --git a/tests/TestCase/Core/AppTest.php b/tests/TestCase/Core/AppTest.php new file mode 100644 index 00000000000..28c101ba919 --- /dev/null +++ b/tests/TestCase/Core/AppTest.php @@ -0,0 +1,303 @@ +clearPlugins(); + } + + /** + * testClassName + * + * $checkCake and $existsInCake are derived from the input parameters + * + * @param string $class Class name + * @param string $type Class type + * @param string $suffix Class suffix + * @param bool $existsInBase Whether class exists in base. + * @param mixed $expected Expected value. + */ + #[DataProvider('classNameProvider')] + public function testClassName($class, $type, $suffix = '', $existsInBase = false, $expected = false): void + { + static::setAppNamespace(); + $i = 0; + TestApp::$existsInBaseCallback = function ($name, $namespace) use ($existsInBase, $class, $expected, &$i) { + if ($i++ === 0) { + return $existsInBase; + } + $checkCake = (!$existsInBase || strpos('.', $class)); + if ($checkCake) { + return (bool)$expected; + } + + return false; + }; + $return = TestApp::className($class, $type, $suffix); + $this->assertSame($expected === false ? null : $expected, $return); + } + + public function testClassNameWithFqcn(): void + { + $this->assertSame(TestCase::class, App::className(TestCase::class)); + $this->assertNull(App::className('\Foo')); + } + + /** + * @link https://github.com/cakephp/cakephp/issues/16258 + */ + public function testClassNameWithAppNamespaceUnset(): void + { + Configure::delete('App.namespace'); + + $result = App::className('Mysql', 'Database/Driver'); + $this->assertSame(Mysql::class, $result); + } + + /** + * testShortName + * + * @param string $class Class name + * @param string $type Class type + * @param string $suffix Class suffix + * @param mixed $expected Expected value. + */ + #[DataProvider('shortNameProvider')] + public function testShortName($class, $type, $suffix = '', $expected = false): void + { + static::setAppNamespace(); + + $return = TestApp::shortName($class, $type, $suffix); + $this->assertSame($expected, $return); + } + + /** + * testShortNameWithNestedAppNamespace + */ + public function testShortNameWithNestedAppNamespace(): void + { + static::setAppNamespace('TestApp/Nested'); + + $return = TestApp::shortName( + 'TestApp/Nested/Controller/PagesController', + 'Controller', + 'Controller', + ); + $this->assertSame('Pages', $return); + + static::setAppNamespace(); + } + + /** + * @link https://github.com/cakephp/cakephp/issues/15415 + */ + public function testShortNameWithAppNamespaceUnset(): void + { + Configure::delete('App.namespace'); + + $result = App::shortName(Mysql::class, 'Database/Driver'); + $this->assertSame('Mysql', $result); + } + + /** + * classNameProvider + * + * Return test permutations for testClassName method. Format: + * className + * type + * suffix + * existsInBase (Base meaning App or plugin namespace) + * expected return value + * + * @return array + */ + public static function classNameProvider(): array + { + return [ + ['Does', 'Not', 'Exist'], + + ['Exists', 'In', 'App', true, 'TestApp\In\ExistsApp'], + ['Also/Exists', 'In', 'App', true, 'TestApp\In\Also\ExistsApp'], + ['Also', 'Exists/In', 'App', true, 'TestApp\Exists\In\AlsoApp'], + ['Also', 'Exists/In/Subfolder', 'App', true, 'TestApp\Exists\In\Subfolder\AlsoApp'], + ['No', 'Suffix', '', true, 'TestApp\Suffix\No'], + + ['MyPlugin.Exists', 'In', 'Suffix', true, 'MyPlugin\In\ExistsSuffix'], + ['MyPlugin.Also/Exists', 'In', 'Suffix', true, 'MyPlugin\In\Also\ExistsSuffix'], + ['MyPlugin.Also', 'Exists/In', 'Suffix', true, 'MyPlugin\Exists\In\AlsoSuffix'], + ['MyPlugin.Also', 'Exists/In/Subfolder', 'Suffix', true, 'MyPlugin\Exists\In\Subfolder\AlsoSuffix'], + ['MyPlugin.No', 'Suffix', '', true, 'MyPlugin\Suffix\No'], + + ['Vend/MPlugin.Exists', 'In', 'Suffix', true, 'Vend\MPlugin\In\ExistsSuffix'], + ['Vend/MPlugin.Also/Exists', 'In', 'Suffix', true, 'Vend\MPlugin\In\Also\ExistsSuffix'], + ['Vend/MPlugin.Also', 'Exists/In', 'Suffix', true, 'Vend\MPlugin\Exists\In\AlsoSuffix'], + ['Vend/MPlugin.Also', 'Exists/In/Subfolder', 'Suffix', true, 'Vend\MPlugin\Exists\In\Subfolder\AlsoSuffix'], + ['Vend/MPlugin.No', 'Suffix', '', true, 'Vend\MPlugin\Suffix\No'], + + ['Exists', 'In', 'Cake', false, 'Cake\In\ExistsCake'], + ['Also/Exists', 'In', 'Cake', false, 'Cake\In\Also\ExistsCake'], + ['Also', 'Exists/In', 'Cake', false, 'Cake\Exists\In\AlsoCake'], + ['Also', 'Exists/In/Subfolder', 'Cake', false, 'Cake\Exists\In\Subfolder\AlsoCake'], + ['No', 'Suffix', '', false, 'Cake\Suffix\No'], + + // Realistic examples returning nothing + ['App', 'Core', 'Suffix'], + ['Auth', 'Controller/Component'], + ['Unknown', 'Controller', 'Controller'], + + // Real examples returning class names + ['App', 'Core', '', false, App::class], + ['Auth', 'Controller/Component', 'Component', false, 'Cake\Controller\Component\AuthComponent'], + ['File', 'Cache/Engine', 'Engine', false, FileEngine::class], + ['Command', 'Shell/Task', 'Task', false, 'Cake\Shell\Task\CommandTask'], + ['Upgrade/Locations', 'Shell/Task', 'Task', false, 'Cake\Shell\Task\Upgrade\LocationsTask'], + ['Pages', 'Controller', 'Controller', true, PagesController::class], + ]; + } + + /** + * pluginSplitNameProvider + * + * Return test permutations for testClassName method. Format: + * className + * type + * suffix + * expected return value + * + * @return array + */ + public static function shortNameProvider(): array + { + return [ + ['TestApp\In\ExistsApp', 'In', 'App', 'Exists'], + ['TestApp\In\Also\ExistsApp', 'In', 'App', 'Also/Exists'], + ['TestApp\Exists\In\AlsoApp', 'Exists/In', 'App', 'Also'], + ['TestApp\Exists\In\Subfolder\AlsoApp', 'Exists/In/Subfolder', 'App', 'Also'], + ['TestApp\Suffix\No', 'Suffix', '', 'No'], + + ['MyPlugin\In\ExistsSuffix', 'In', 'Suffix', 'MyPlugin.Exists'], + ['MyPlugin\In\Also\ExistsSuffix', 'In', 'Suffix', 'MyPlugin.Also/Exists'], + ['MyPlugin\Exists\In\AlsoSuffix', 'Exists/In', 'Suffix', 'MyPlugin.Also'], + ['MyPlugin\Exists\In\Subfolder\AlsoSuffix', 'Exists/In/Subfolder', 'Suffix', 'MyPlugin.Also'], + ['MyPlugin\Suffix\No', 'Suffix', '', 'MyPlugin.No'], + + ['Vend\MPlugin\In\ExistsSuffix', 'In', 'Suffix', 'Vend/MPlugin.Exists'], + ['Vend\MPlugin\In\Also\ExistsSuffix', 'In', 'Suffix', 'Vend/MPlugin.Also/Exists'], + ['Vend\MPlugin\Exists\In\AlsoSuffix', 'Exists/In', 'Suffix', 'Vend/MPlugin.Also'], + ['Vend\MPlugin\Exists\In\Subfolder\AlsoSuffix', 'Exists/In/Subfolder', 'Suffix', 'Vend/MPlugin.Also'], + ['Vend\MPlugin\Suffix\No', 'Suffix', '', 'Vend/MPlugin.No'], + + ['Cake\In\ExistsCake', 'In', 'Cake', 'Exists'], + ['Cake\In\Also\ExistsCake', 'In', 'Cake', 'Also/Exists'], + ['Cake\Exists\In\AlsoCake', 'Exists/In', 'Cake', 'Also'], + ['Cake\Exists\In\Subfolder\AlsoCake', 'Exists/In/Subfolder', 'Cake', 'Also'], + ['Cake\Suffix\No', 'Suffix', '', 'No'], + + ['Muffin\Webservice\Webservice\EndpointWebservice', 'Webservice', 'Webservice', 'Muffin/Webservice.Endpoint'], + + // Real examples returning class names + [App::class, 'Core', '', 'App'], + ['Cake\Controller\Component\AuthComponent', 'Controller/Component', 'Component', 'Auth'], + [FileEngine::class, 'Cache/Engine', 'Engine', 'File'], + ['Cake\Shell\Task\CommandTask', 'Shell/Task', 'Task', 'Command'], + ['Cake\Shell\Task\Upgrade\LocationsTask', 'Shell/Task', 'Task', 'Upgrade/Locations'], + [PagesController::class, 'Controller', 'Controller', 'Pages'], + ]; + } + + /** + * test classPath() with a plugin. + */ + public function testClassPathWithPlugins(): void + { + $basepath = TEST_APP . 'Plugin' . DS; + $this->loadPlugins(['TestPlugin', 'Company/TestPluginThree']); + + $result = App::classPath('Controller', 'TestPlugin'); + $this->assertPathEquals($basepath . 'TestPlugin' . DS . 'src' . DS . 'Controller' . DS, $result[0]); + + $result = App::classPath('Controller', 'Company/TestPluginThree'); + $expected = $basepath . 'Company' . DS . 'TestPluginThree' . DS . 'src' . DS . 'Controller' . DS; + $this->assertPathEquals($expected, $result[0]); + } + + /** + * test path() with a plugin. + */ + public function testPathWithPlugins(): void + { + $basepath = TEST_APP . 'Plugin' . DS; + $this->loadPlugins(['TestPlugin', 'Company/TestPluginThree']); + + $result = App::path('locales', 'TestPlugin'); + $this->assertPathEquals($basepath . 'TestPlugin' . DS . 'resources' . DS . 'locales' . DS, $result[0]); + + $result = App::path('locales', 'Company/TestPluginThree'); + $expected = $basepath . 'Company' . DS . 'TestPluginThree' . DS . 'resources' . DS . 'locales' . DS; + $this->assertPathEquals($expected, $result[0]); + } + + public function testPathWithPluginsException(): void + { + $this->expectException(CakeException::class); + + App::path('invalid', 'TestPlugin'); + } + + /** + * testCore method + */ + public function testCore(): void + { + $model = App::core('Model'); + $this->assertEquals([CAKE . 'Model' . DS], $model); + + $view = App::core('View'); + $this->assertEquals([CAKE . 'View' . DS], $view); + + $controller = App::core('Controller'); + $this->assertEquals([CAKE . 'Controller' . DS], $controller); + + $component = App::core('Controller/Component'); + $this->assertEquals([CAKE . 'Controller' . DS . 'Component' . DS], str_replace('/', DS, $component)); + + $auth = App::core('Controller/Component/Auth'); + $this->assertEquals([CAKE . 'Controller' . DS . 'Component' . DS . 'Auth' . DS], str_replace('/', DS, $auth)); + + $datasource = App::core('Model/Datasource'); + $this->assertEquals([CAKE . 'Model' . DS . 'Datasource' . DS], str_replace('/', DS, $datasource)); + } +} diff --git a/tests/TestCase/Core/Attribute/Configure/FakeClient.php b/tests/TestCase/Core/Attribute/Configure/FakeClient.php new file mode 100644 index 00000000000..ed2d169db82 --- /dev/null +++ b/tests/TestCase/Core/Attribute/Configure/FakeClient.php @@ -0,0 +1,18 @@ +delegate(new ReflectionContainer()); + $client = $container->get(FakeClient::class); + $this->assertInstanceOf(FakeClient::class, $client); + $this->assertSame($apiKey, $client->apiKey); + } + + /** + * @return void + */ + public function testMissingConfig(): void + { + $container = new Container(); + $container->delegate(new ReflectionContainer()); + $this->expectException(TypeError::class); + $container->get(FakeClient::class); + } +} diff --git a/tests/TestCase/Core/BasePluginApplicationTrait.php b/tests/TestCase/Core/BasePluginApplicationTrait.php new file mode 100644 index 00000000000..00cfae06007 --- /dev/null +++ b/tests/TestCase/Core/BasePluginApplicationTrait.php @@ -0,0 +1,67 @@ +clearPlugins(); + } + + /** + * testConfigForRoutesAndBootstrap + */ + public function testConfigForRoutesAndBootstrap(): void + { + $plugin = new BasePlugin([ + 'bootstrap' => false, + 'routes' => false, + ]); + + $this->assertFalse($plugin->isEnabled('routes')); + $this->assertFalse($plugin->isEnabled('bootstrap')); + $this->assertTrue($plugin->isEnabled('console')); + $this->assertTrue($plugin->isEnabled('middleware')); + $this->assertTrue($plugin->isEnabled('services')); + } + + public function testGetName(): void + { + $plugin = new TestPlugin(); + $this->assertSame('TestPlugin', $plugin->getName()); + + $plugin = new TestPluginThreePlugin(); + $this->assertSame('Company/TestPluginThree', $plugin->getName()); + } + + public function testGetNameOption(): void + { + $plugin = new TestPlugin(['name' => 'Elephants']); + $this->assertSame('Elephants', $plugin->getName()); + } + + public function testMiddleware(): void + { + $plugin = new BasePlugin(); + $middleware = new MiddlewareQueue(); + $this->assertSame($middleware, $plugin->middleware($middleware)); + } + + public function testConsole(): void + { + $plugin = new BasePlugin(); + $commands = new CommandCollection(); + $this->assertSame($commands, $plugin->console($commands)); + } + + #[DoesNotPerformAssertions] + public function testServices(): void + { + $plugin = new BasePlugin(); + $container = new Container(); + $plugin->services($container); + } + + public function testConsoleFind(): void + { + $plugin = new TestPlugin(); + Plugin::getCollection()->add($plugin); + + $result = $plugin->console(new CommandCollection()); + + $this->assertTrue($result->has('sample'), 'Should have plugin command added'); + $this->assertTrue($result->has('test_plugin.sample'), 'Should have long plugin name'); + + $this->assertTrue($result->has('example'), 'Should have plugin shell added'); + $this->assertTrue($result->has('test_plugin.example'), 'Should have long plugin name'); + } + + public function testBootstrap(): void + { + $app = new class implements PluginApplicationInterface { + use BasePluginApplicationTrait; + }; + $plugin = new TestPlugin(); + + $this->assertFalse(Configure::check('PluginTest.test_plugin.bootstrap')); + $plugin->bootstrap($app); + $this->assertTrue(Configure::check('PluginTest.test_plugin.bootstrap')); + } + + /** + * No errors should be emitted when a plugin doesn't have a bootstrap file. + */ + public function testBootstrapSkipMissingFile(): void + { + $app = new class implements PluginApplicationInterface { + use BasePluginApplicationTrait; + }; + $plugin = new BasePlugin(); + $plugin->bootstrap($app); + $this->assertTrue(true); + } + + /** + * No errors should be emitted when a plugin doesn't have a routes file. + */ + public function testRoutesSkipMissingFile(): void + { + $plugin = new BasePlugin(); + $routeBuilder = new RouteBuilder(new RouteCollection(), '/'); + $plugin->routes($routeBuilder); + $this->assertTrue(true); + } + + public function testConstructorArguments(): void + { + $plugin = new BasePlugin([ + 'routes' => false, + 'bootstrap' => false, + 'console' => false, + 'middleware' => false, + 'templatePath' => '/plates/', + ]); + $this->assertFalse($plugin->isEnabled('routes')); + $this->assertFalse($plugin->isEnabled('bootstrap')); + $this->assertFalse($plugin->isEnabled('console')); + $this->assertFalse($plugin->isEnabled('middleware')); + + $this->assertSame('/plates/', $plugin->getTemplatePath()); + } + + public function testGetPathBaseClass(): void + { + $plugin = new BasePlugin(); + + $expected = CAKE . 'Core' . DS; + $this->assertSame($expected, $plugin->getPath()); + $this->assertSame($expected . 'config' . DS, $plugin->getConfigPath()); + $this->assertSame($expected . 'src' . DS, $plugin->getClassPath()); + $this->assertSame($expected . 'templates' . DS, $plugin->getTemplatePath()); + } + + public function testGetPathOptionValue(): void + { + $plugin = new BasePlugin(['path' => '/some/path']); + $expected = '/some/path'; + $this->assertSame($expected, $plugin->getPath()); + $this->assertSame($expected . 'config' . DS, $plugin->getConfigPath()); + $this->assertSame($expected . 'src' . DS, $plugin->getClassPath()); + $this->assertSame($expected . 'templates' . DS, $plugin->getTemplatePath()); + } + + public function testGetPathSubclass(): void + { + $plugin = new TestPlugin(); + $expected = TEST_APP . 'Plugin' . DS . 'TestPlugin' . DS; + $this->assertSame($expected, $plugin->getPath()); + $this->assertSame($expected . 'config' . DS, $plugin->getConfigPath()); + $this->assertSame($expected . 'src' . DS, $plugin->getClassPath()); + $this->assertSame($expected . 'templates' . DS, $plugin->getTemplatePath()); + } + + public function testEventsAreRegistered(): void + { + static::setAppNamespace(); + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/cakes']); + $request = $request->withAttribute('params', [ + 'controller' => 'Cakes', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + ]); + + $basePlugin = new class extends BasePlugin + { + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + $eventManager->on('testTrue', function ($event) { + return true; + }); + + return $eventManager; + } + }; + + $app = new class (dirname(__DIR__, 2)) extends BaseApplication + { + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + $app = $app->addPlugin($basePlugin); + $app->handle($request); + $this->assertNotEmpty($app->getEventManager()->listeners('testTrue')); + } + + public function testConsoleEventsAreRegistered(): void + { + static::setAppNamespace(); + $basePlugin = new class extends BasePlugin + { + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + $eventManager->on('testTrue', function ($event) { + return true; + }); + + return $eventManager; + } + }; + + $app = new class (dirname(__DIR__, 2)) extends BaseApplication + { + public function routes(RouteBuilder $routes): void + { + } + + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + $app = $app->addPlugin($basePlugin); + $output = new StubConsoleOutput(); + $consoleIo = Mockery::mock(ConsoleIo::class, [$output, $output, null, null]) + ->shouldAllowMockingMethod('in') + ->makePartial(); + $runner = new CommandRunner($app); + $runner->run(['cake', 'version'], $consoleIo); + $this->assertNotEmpty($app->getEventManager()->listeners('testTrue')); + } +} diff --git a/tests/TestCase/Core/Configure/Engine/IniConfigTest.php b/tests/TestCase/Core/Configure/Engine/IniConfigTest.php new file mode 100644 index 00000000000..0397d609dfd --- /dev/null +++ b/tests/TestCase/Core/Configure/Engine/IniConfigTest.php @@ -0,0 +1,258 @@ + [ + 'two' => 'value', + 'three' => [ + 'four' => 'value four', + ], + 'is_null' => null, + 'bool_false' => false, + 'bool_true' => true, + ], + 'Asset' => [ + 'timestamp' => 'force', + ], + ]; + + /** + * @var string + */ + protected $path; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->path = CONFIG; + } + + /** + * test construct + */ + public function testConstruct(): void + { + $engine = new IniConfig($this->path); + $config = $engine->read('acl'); + + $this->assertArrayHasKey('admin', $config); + $this->assertTrue(isset($config['paul']['groups'])); + $this->assertSame('ads', $config['admin']['deny']); + } + + /** + * Test reading files. + */ + public function testRead(): void + { + $engine = new IniConfig($this->path); + $config = $engine->read('nested'); + $this->assertTrue($config['bools']['test_on']); + + $config = $engine->read('nested'); + $this->assertTrue($config['bools']['test_on']); + } + + /** + * No other sections should exist. + */ + public function testReadOnlyOneSection(): void + { + $engine = new IniConfig($this->path, 'admin'); + $config = $engine->read('acl'); + + $this->assertArrayHasKey('groups', $config); + $this->assertSame('administrators', $config['groups']); + } + + /** + * Test without section. + */ + public function testReadWithoutSection(): void + { + $engine = new IniConfig($this->path); + $config = $engine->read('no_section'); + + $expected = [ + 'some_key' => 'some_value', + 'bool_key' => true, + ]; + $this->assertEquals($expected, $config); + } + + /** + * Test that names with .'s get exploded into arrays. + */ + public function testReadValuesWithDots(): void + { + $engine = new IniConfig($this->path); + $config = $engine->read('nested'); + + $this->assertTrue(isset($config['database']['db']['username'])); + $this->assertSame('mark', $config['database']['db']['username']); + $this->assertSame('3', $config['nesting']['one']['two']['three']); + $this->assertFalse(isset($config['database.db.username'])); + $this->assertFalse(isset($config['database']['db.username'])); + } + + /** + * Test boolean reading. + */ + public function testBooleanReading(): void + { + $engine = new IniConfig($this->path); + $config = $engine->read('nested'); + + $this->assertTrue($config['bools']['test_on']); + $this->assertFalse($config['bools']['test_off']); + + $this->assertTrue($config['bools']['test_yes']); + $this->assertFalse($config['bools']['test_no']); + + $this->assertTrue($config['bools']['test_true']); + $this->assertFalse($config['bools']['test_false']); + + $this->assertFalse($config['bools']['test_null']); + } + + /** + * Test an exception is thrown by reading files that exist without .ini extension. + */ + public function testReadWithExistentFileWithoutExtension(): void + { + $this->expectException(CakeException::class); + $engine = new IniConfig($this->path); + $engine->read('no_ini_extension'); + } + + /** + * Test an exception is thrown by reading files that don't exist. + */ + public function testReadWithNonExistentFile(): void + { + $this->expectException(CakeException::class); + $engine = new IniConfig($this->path); + $engine->read('fake_values'); + } + + /** + * Test reading an empty file. + */ + public function testReadEmptyFile(): void + { + $engine = new IniConfig($this->path); + $config = $engine->read('empty'); + $this->assertEquals([], $config); + } + + /** + * Test reading keys with ../ doesn't work. + */ + public function testReadWithDots(): void + { + $this->expectException(CakeException::class); + $engine = new IniConfig($this->path); + $engine->read('../empty'); + } + + /** + * Test reading from plugins. + */ + public function testReadPluginValue(): void + { + $this->loadPlugins(['TestPlugin']); + $engine = new IniConfig($this->path); + $result = $engine->read('TestPlugin.nested'); + + $this->assertTrue(isset($result['database']['db']['username'])); + $this->assertSame('bar', $result['database']['db']['username']); + $this->assertFalse(isset($result['database.db.username'])); + $this->assertFalse(isset($result['database']['db.username'])); + + $result = $engine->read('TestPlugin.nested'); + $this->assertSame('foo', $result['database']['db']['password']); + $this->clearPlugins(); + } + + /** + * Test dump method. + */ + public function testDump(): void + { + $engine = new IniConfig(TMP); + $result = $engine->dump('test', $this->testData); + $this->assertGreaterThan(0, $result); + + $expected = <<assertTextEquals($expected, $result); + + $result = $engine->dump('test', $this->testData); + $this->assertGreaterThan(0, $result); + + $contents = file_get_contents($file); + $this->assertTextEquals($expected, $contents); + unlink($file); + } + + /** + * Test that dump() makes files read() can read. + */ + public function testDumpRead(): void + { + $engine = new IniConfig(TMP); + $engine->dump('test', $this->testData); + $result = $engine->read('test'); + unlink(TMP . 'test.ini'); + + $expected = $this->testData; + $expected['One']['is_null'] = false; + + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Core/Configure/Engine/JsonConfigTest.php b/tests/TestCase/Core/Configure/Engine/JsonConfigTest.php new file mode 100644 index 00000000000..62be7b8e3fe --- /dev/null +++ b/tests/TestCase/Core/Configure/Engine/JsonConfigTest.php @@ -0,0 +1,186 @@ + [ + 'two' => 'value', + 'three' => [ + 'four' => 'value four', + ], + 'is_null' => null, + 'bool_false' => false, + 'bool_true' => true, + ], + 'Asset' => [ + 'timestamp' => 'force', + ], + ]; + + /** + * Setup. + */ + protected function setUp(): void + { + parent::setUp(); + $this->path = CONFIG; + } + + /** + * Test reading files. + */ + public function testRead(): void + { + $engine = new JsonConfig($this->path); + $values = $engine->read('json_test'); + $this->assertSame('value', $values['Json']); + $this->assertSame('buried', $values['Deep']['Deeper']['Deepest']); + } + + /** + * Test an exception is thrown by reading files that exist without .php extension. + */ + public function testReadWithExistentFileWithoutExtension(): void + { + $this->expectException(CakeException::class); + $engine = new JsonConfig($this->path); + $engine->read('no_json_extension'); + } + + /** + * Test an exception is thrown by reading files that don't exist. + */ + public function testReadWithNonExistentFile(): void + { + $this->expectException(CakeException::class); + $engine = new JsonConfig($this->path); + $engine->read('fake_values'); + } + + /** + * Test reading an empty file. + */ + public function testReadEmptyFile(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('config file `empty.json`'); + $engine = new JsonConfig($this->path); + $engine->read('empty'); + } + + /** + * Test an exception is thrown by reading files that contain invalid JSON. + */ + public function testReadWithInvalidJson(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Error parsing JSON string fetched from config file `invalid.json`'); + $engine = new JsonConfig($this->path); + $engine->read('invalid'); + } + + /** + * Test reading keys with ../ doesn't work. + */ + public function testReadWithDots(): void + { + $this->expectException(CakeException::class); + $engine = new JsonConfig($this->path); + $engine->read('../empty'); + } + + /** + * Test reading from plugins. + */ + public function testReadPluginValue(): void + { + $this->loadPlugins(['TestPlugin']); + $engine = new JsonConfig($this->path); + $result = $engine->read('TestPlugin.load'); + $this->assertArrayHasKey('plugin_load', $result); + + $this->clearPlugins(); + } + + /** + * Test dumping data to JSON format. + */ + public function testDump(): void + { + $engine = new JsonConfig(TMP); + $result = $engine->dump('test', $this->testData); + $this->assertGreaterThan(0, $result); + $expected = '{ + "One": { + "two": "value", + "three": { + "four": "value four" + }, + "is_null": null, + "bool_false": false, + "bool_true": true + }, + "Asset": { + "timestamp": "force" + } +}'; + $file = TMP . 'test.json'; + $contents = file_get_contents($file); + + unlink($file); + $this->assertTextEquals($expected, $contents); + + $result = $engine->dump('test', $this->testData); + $this->assertGreaterThan(0, $result); + + $contents = file_get_contents($file); + $this->assertTextEquals($expected, $contents); + unlink($file); + } + + /** + * Test that dump() makes files read() can read. + */ + public function testDumpRead(): void + { + $engine = new JsonConfig(TMP); + $engine->dump('test', $this->testData); + $result = $engine->read('test'); + unlink(TMP . 'test.json'); + + $this->assertEquals($this->testData, $result); + } +} diff --git a/tests/TestCase/Core/Configure/Engine/PhpConfigTest.php b/tests/TestCase/Core/Configure/Engine/PhpConfigTest.php new file mode 100644 index 00000000000..0c007829014 --- /dev/null +++ b/tests/TestCase/Core/Configure/Engine/PhpConfigTest.php @@ -0,0 +1,162 @@ + [ + 'two' => 'value', + 'three' => [ + 'four' => 'value four', + ], + 'is_null' => null, + 'bool_false' => false, + 'bool_true' => true, + ], + 'Asset' => [ + 'timestamp' => 'force', + ], + ]; + + /** + * Setup. + */ + protected function setUp(): void + { + parent::setUp(); + $this->path = CONFIG; + } + + /** + * Test reading files. + */ + public function testRead(): void + { + $engine = new PhpConfig($this->path); + $values = $engine->read('var_test'); + $this->assertSame('value', $values['Read']); + $this->assertSame('buried', $values['Deep']['Deeper']['Deepest']); + } + + /** + * Test an exception is thrown by reading files that exist without .php extension. + */ + public function testReadWithExistentFileWithoutExtension(): void + { + $this->expectException(CakeException::class); + $engine = new PhpConfig($this->path); + $engine->read('no_php_extension'); + } + + /** + * Test an exception is thrown by reading files that don't exist. + */ + public function testReadWithNonExistentFile(): void + { + $this->expectException(CakeException::class); + $engine = new PhpConfig($this->path); + $engine->read('fake_values'); + } + + /** + * Test reading an empty file. + */ + public function testReadEmptyFile(): void + { + $this->expectException(CakeException::class); + $engine = new PhpConfig($this->path); + $engine->read('empty'); + } + + /** + * Test reading keys with ../ doesn't work. + */ + public function testReadWithDots(): void + { + $this->expectException(CakeException::class); + $engine = new PhpConfig($this->path); + $engine->read('../empty'); + } + + /** + * Test reading from plugins. + */ + public function testReadPluginValue(): void + { + $this->loadPlugins(['TestPlugin']); + $engine = new PhpConfig($this->path); + $result = $engine->read('TestPlugin.load'); + $this->assertArrayHasKey('plugin_load', $result); + + $this->clearPlugins(); + } + + /** + * Test dumping data to PHP format. + */ + public function testDump(): void + { + $engine = new PhpConfig(TMP); + $result = $engine->dump('test', $this->testData); + $this->assertGreaterThan(0, $result); + $expected = trim(file_get_contents(CONFIG . 'dump_test.txt')); + + $file = TMP . 'test.php'; + $contents = file_get_contents($file); + + unlink($file); + $this->assertTextEquals($expected, $contents); + + $result = $engine->dump('test', $this->testData); + $this->assertGreaterThan(0, $result); + + $contents = file_get_contents($file); + $this->assertTextEquals($expected, $contents); + unlink($file); + } + + /** + * Test that dump() makes files read() can read. + */ + public function testDumpRead(): void + { + $engine = new PhpConfig(TMP); + $engine->dump('test', $this->testData); + $result = $engine->read('test'); + unlink(TMP . 'test.php'); + + $this->assertEquals($this->testData, $result); + } +} diff --git a/tests/TestCase/Core/ConfigureTest.php b/tests/TestCase/Core/ConfigureTest.php new file mode 100644 index 00000000000..775d0a81440 --- /dev/null +++ b/tests/TestCase/Core/ConfigureTest.php @@ -0,0 +1,580 @@ +assertSame($expected, $result); + } + + /** + * testReadOrFail method + */ + public function testReadOrFailThrowingException(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Expected configuration key `This.Key.Does.Not.exist` not found'); + Configure::readOrFail('This.Key.Does.Not.exist'); + } + + /** + * testRead method + */ + public function testRead(): void + { + $expected = 'ok'; + Configure::write('level1.level2.level3_1', $expected); + Configure::write('level1.level2.level3_2', 'something_else'); + $result = Configure::read('level1.level2.level3_1'); + $this->assertSame($expected, $result); + + $result = Configure::read('level1.level2.level3_2'); + $this->assertSame('something_else', $result); + + $result = Configure::read('debug'); + $this->assertGreaterThanOrEqual(0, $result); + + $result = Configure::read(); + $this->assertIsArray($result); + $this->assertArrayHasKey('debug', $result); + $this->assertArrayHasKey('level1', $result); + + $result = Configure::read('something_I_just_made_up_now'); + $this->assertNull($result, 'Missing key should return null.'); + + $default = 'default'; + $result = Configure::read('something_I_just_made_up_now', $default); + $this->assertSame($default, $result); + + $default = ['default']; + $result = Configure::read('something_I_just_made_up_now', $default); + $this->assertEquals($default, $result); + } + + /** + * testWrite method + */ + public function testWrite(): void + { + Configure::write('SomeName.someKey', 'myvalue'); + $result = Configure::read('SomeName.someKey'); + $this->assertSame('myvalue', $result); + + Configure::write('SomeName.someKey'); + $result = Configure::read('SomeName.someKey'); + $this->assertNull($result); + + $expected = ['One' => ['Two' => ['Three' => ['Four' => ['Five' => 'cool']]]]]; + Configure::write('Key', $expected); + + $result = Configure::read('Key'); + $this->assertEquals($expected, $result); + + $result = Configure::read('Key.One'); + $this->assertEquals($expected['One'], $result); + + $result = Configure::read('Key.One.Two'); + $this->assertEquals($expected['One']['Two'], $result); + + $result = Configure::read('Key.One.Two.Three.Four.Five'); + $this->assertSame('cool', $result); + + Configure::write('one.two.three.four', '4'); + $result = Configure::read('one.two.three.four'); + $this->assertSame('4', $result); + } + + /** + * test setting display_errors with debug. + */ + public function testDebugSettingDisplayErrors(): void + { + Configure::write('debug', false); + $result = ini_get('display_errors'); + $this->assertSame('0', $result); + + Configure::write('debug', true); + $result = ini_get('display_errors'); + $this->assertSame('1', $result); + } + + /** + * testDelete method + */ + public function testDelete(): void + { + Configure::write('SomeName.someKey', 'myvalue'); + $result = Configure::read('SomeName.someKey'); + $this->assertSame('myvalue', $result); + + Configure::delete('SomeName.someKey'); + $result = Configure::read('SomeName.someKey'); + $this->assertNull($result); + + Configure::write('SomeName', ['someKey' => 'myvalue', 'otherKey' => 'otherValue']); + + $result = Configure::read('SomeName.someKey'); + $this->assertSame('myvalue', $result); + + $result = Configure::read('SomeName.otherKey'); + $this->assertSame('otherValue', $result); + + Configure::delete('SomeName'); + + $result = Configure::read('SomeName.someKey'); + $this->assertNull($result); + + $result = Configure::read('SomeName.otherKey'); + $this->assertNull($result); + } + + /** + * testCheck method + */ + public function testCheck(): void + { + Configure::write('ConfigureTestCase', 'value'); + $this->assertTrue(Configure::check('ConfigureTestCase')); + + $this->assertFalse(Configure::check('NotExistingConfigureTestCase')); + } + + /** + * testCheckingSavedEmpty method + */ + public function testCheckingSavedEmpty(): void + { + Configure::write('ConfigureTestCase', 0); + $this->assertTrue(Configure::check('ConfigureTestCase')); + + Configure::write('ConfigureTestCase', '0'); + $this->assertTrue(Configure::check('ConfigureTestCase')); + + Configure::write('ConfigureTestCase', false); + $this->assertTrue(Configure::check('ConfigureTestCase')); + + Configure::write('ConfigureTestCase'); + $this->assertFalse(Configure::check('ConfigureTestCase')); + } + + /** + * testCheckKeyWithSpaces method + */ + public function testCheckKeyWithSpaces(): void + { + Configure::write('Configure Test', 'test'); + $this->assertTrue(Configure::check('Configure Test')); + Configure::delete('Configure Test'); + + Configure::write('Configure Test.Test Case', 'test'); + $this->assertTrue(Configure::check('Configure Test.Test Case')); + } + + /** + * testCheckEmpty + */ + public function testCheckEmpty(): void + { + $this->assertFalse(Configure::check('')); + } + + /** + * testLoad method + */ + public function testLoadExceptionOnNonExistentFile(): void + { + $this->expectException(CakeException::class); + Configure::config('test', new PhpConfig()); + Configure::load('nonexistent_configuration_file', 'test'); + } + + /** + * test load() with invalid config engine + */ + public function testLoadExceptionOnNonExistentEngine(): void + { + $this->expectException(CakeException::class); + Configure::load('nonexistent_configuration_file', 'nonexistent_configuration_engine'); + } + + /** + * test load method for default config creation + */ + public function testLoadDefaultConfig(): void + { + try { + Configure::load('nonexistent_configuration_file'); + } catch (Exception) { + $this->assertTrue(Configure::isConfigured('default')); + $this->assertFalse(Configure::isConfigured('nonexistent_configuration_file')); + } + } + + /** + * test load with merging + */ + public function testLoadWithMerge(): void + { + Configure::config('test', new PhpConfig(CONFIG)); + + $result = Configure::load('var_test', 'test'); + $this->assertTrue($result); + + $this->assertSame('value', Configure::read('Read')); + + $result = Configure::load('var_test2', 'test', true); + $this->assertTrue($result); + + $this->assertSame('value2', Configure::read('Read')); + $this->assertSame('buried2', Configure::read('Deep.Second.SecondDeepest')); + $this->assertSame('buried', Configure::read('Deep.Deeper.Deepest')); + $this->assertSame('Overwrite', Configure::read('TestAcl.classname')); + $this->assertSame('one', Configure::read('TestAcl.custom')); + } + + /** + * test loading with overwrite + */ + public function testLoadNoMerge(): void + { + Configure::config('test', new PhpConfig(CONFIG)); + + $result = Configure::load('var_test', 'test'); + $this->assertTrue($result); + + $this->assertSame('value', Configure::read('Read')); + + $result = Configure::load('var_test2', 'test', false); + $this->assertTrue($result); + + $this->assertSame('value2', Configure::read('Read')); + $this->assertSame('buried2', Configure::read('Deep.Second.SecondDeepest')); + $this->assertNull(Configure::read('Deep.Deeper.Deepest')); + } + + /** + * Test load() replacing existing data + */ + public function testLoadWithExistingData(): void + { + Configure::config('test', new PhpConfig(CONFIG)); + Configure::write('my_key', 'value'); + + Configure::load('var_test', 'test'); + $this->assertSame('value', Configure::read('my_key'), 'Should not overwrite existing data.'); + $this->assertSame('value', Configure::read('Read'), 'Should load new data.'); + } + + /** + * Test load() merging on top of existing data + */ + public function testLoadMergeWithExistingData(): void + { + Configure::config('test', new PhpConfig()); + Configure::write('my_key', 'value'); + Configure::write('Read', 'old'); + Configure::write('Deep.old', 'old'); + Configure::write('TestAcl.classname', 'old'); + + Configure::load('var_test', 'test', true); + $this->assertSame('value', Configure::read('Read'), 'Should load new data.'); + $this->assertSame('buried', Configure::read('Deep.Deeper.Deepest'), 'Should load new data'); + $this->assertSame('old', Configure::read('Deep.old'), 'Should not destroy old data.'); + $this->assertSame('value', Configure::read('my_key'), 'Should not destroy data.'); + $this->assertSame('Original', Configure::read('TestAcl.classname'), 'No arrays'); + } + + /** + * testLoad method + */ + public function testLoadPlugin(): void + { + Configure::config('test', new PhpConfig()); + $this->loadPlugins(['TestPlugin']); + $result = Configure::load('TestPlugin.load', 'test'); + $this->assertTrue($result); + $expected = '/test_app/Plugin/TestPlugin/Config/load.php'; + $config = Configure::read('plugin_load'); + $this->assertSame($expected, $config); + + $result = Configure::load('TestPlugin.more.load', 'test'); + $this->assertTrue($result); + $expected = '/test_app/Plugin/TestPlugin/Config/more.load.php'; + $config = Configure::read('plugin_more_load'); + $this->assertSame($expected, $config); + $this->clearPlugins(); + } + + /** + * testStore method + */ + public function testStoreAndRestore(): void + { + Cache::enable(); + Cache::setConfig('configure', [ + 'className' => 'File', + 'path' => TMP . 'tests', + ]); + + Configure::write('Testing', 'yummy'); + $this->assertTrue(Configure::store('store_test', 'configure')); + + Configure::delete('Testing'); + $this->assertNull(Configure::read('Testing')); + + Configure::restore('store_test', 'configure'); + $this->assertSame('yummy', Configure::read('Testing')); + + Cache::delete('store_test', 'configure'); + Cache::drop('configure'); + } + + /** + * test that store and restore only store/restore the provided data. + */ + public function testStoreAndRestoreWithData(): void + { + Cache::enable(); + Cache::setConfig('configure', [ + 'className' => 'File', + 'path' => TMP . 'tests', + ]); + + Configure::write('testing', 'value'); + Configure::store('store_test', 'configure', ['store_test' => 'one']); + Configure::delete('testing'); + $this->assertNull(Configure::read('store_test'), "Calling store with data shouldn't modify runtime."); + + Configure::restore('store_test', 'configure'); + $this->assertSame('one', Configure::read('store_test')); + $this->assertNull(Configure::read('testing'), 'Values that were not stored are not restored.'); + + Cache::delete('store_test', 'configure'); + Cache::drop('configure'); + } + + /** + * testVersion method + */ + public function testVersion(): void + { + $original = Configure::version(); + $this->assertTrue(version_compare($original, '4.0', '>=')); + + Configure::write('Cake.version', 'banana'); + $this->assertSame('banana', Configure::version()); + + Configure::delete('Cake.version'); + $this->assertSame($original, Configure::version()); + } + + /** + * Tests adding new engines. + */ + public function testEngineSetup(): void + { + $engine = new PhpConfig(); + Configure::config('test', $engine); + $configured = Configure::configured(); + + $this->assertContains('test', $configured); + + $this->assertTrue(Configure::isConfigured('test')); + $this->assertFalse(Configure::isConfigured('fake_garbage')); + + $this->assertTrue(Configure::drop('test')); + $this->assertFalse(Configure::drop('test'), 'dropping things that do not exist should return false.'); + } + + /** + * Tests adding new engines as numeric strings. + */ + public function testEngineSetupNumeric(): void + { + $engine = new PhpConfig(); + Configure::config('123', $engine); + $configured = Configure::configured(); + + $this->assertContains('123', $configured); + + $this->assertTrue(Configure::isConfigured('123')); + + $this->assertTrue(Configure::drop('123')); + $this->assertFalse(Configure::drop('123'), 'dropping things that do not exist should return false.'); + } + + /** + * Test that clear wipes all values. + */ + public function testClear(): void + { + Configure::write('test', 'value'); + Configure::clear(); + $this->assertNull(Configure::read('debug')); + $this->assertNull(Configure::read('test')); + } + + public function testDumpNoAdapter(): void + { + $this->expectException(CakeException::class); + Configure::dump(TMP . 'test.php', 'does_not_exist'); + } + + /** + * test dump integrated with the PhpConfig. + */ + public function testDump(): void + { + Configure::config('test_Engine', new PhpConfig(TMP)); + + $result = Configure::dump('config_test', 'test_Engine'); + $this->assertGreaterThan(0, $result); + $result = file_get_contents(TMP . 'config_test.php'); + $this->assertStringContainsString('assertStringContainsString('return ', $result); + if (file_exists(TMP . 'config_test.php')) { + unlink(TMP . 'config_test.php'); + } + } + + /** + * Test dumping only some of the data. + */ + public function testDumpPartial(): void + { + Configure::config('test_Engine', new PhpConfig(TMP)); + Configure::write('Error', ['test' => 'value']); + + $result = Configure::dump('config_test', 'test_Engine', ['Error']); + $this->assertGreaterThan(0, $result); + $result = file_get_contents(TMP . 'config_test.php'); + $this->assertStringContainsString('assertStringContainsString('return ', $result); + $this->assertStringContainsString('Error', $result); + $this->assertStringNotContainsString('debug', $result); + + if (file_exists(TMP . 'config_test.php')) { + unlink(TMP . 'config_test.php'); + } + } + + /** + * Test the consume method. + */ + public function testConsume(): void + { + $this->assertNull(Configure::consume('DoesNotExist'), 'Should be null on empty value'); + Configure::write('Test', ['key' => 'value', 'key2' => 'value2']); + + $result = Configure::consume('Test.key'); + $this->assertSame('value', $result); + + $result = Configure::read('Test.key2'); + $this->assertSame('value2', $result, 'Other values should remain.'); + + $result = Configure::consume('Test'); + $expected = ['key2' => 'value2']; + $this->assertEquals($expected, $result); + } + + /** + * testConsumeEmpty + */ + public function testConsumeEmpty(): void + { + Configure::write('Test', ['key' => 'value', 'key2' => 'value2']); + + $result = Configure::consume(''); + $this->assertNull($result); + } + + /** + * testConsumeOrFail method + */ + public function testConsumeOrFail(): void + { + $expected = 'ok'; + Configure::write('This.Key.Exists', $expected); + $result = Configure::consumeOrFail('This.Key.Exists'); + $this->assertSame($expected, $result); + } + + /** + * testConsumeOrFail method + */ + public function testConsumeOrFailThrowingException(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Expected configuration key `This.Key.Does.Not.exist` not found'); + Configure::consumeOrFail('This.Key.Does.Not.exist'); + } +} diff --git a/tests/TestCase/Core/FunctionsGlobalTest.php b/tests/TestCase/Core/FunctionsGlobalTest.php new file mode 100644 index 00000000000..84539e94154 --- /dev/null +++ b/tests/TestCase/Core/FunctionsGlobalTest.php @@ -0,0 +1,342 @@ +assertNull(env('DOES_NOT_EXIST')); + $this->assertSame('default', env('DOES_NOT_EXIST', 'default')); + + $_ENV['DOES_EXIST'] = 'some value'; + $this->assertSame('some value', env('DOES_EXIST')); + $this->assertSame('some value', env('DOES_EXIST', 'default')); + + $_ENV['EMPTY_VALUE'] = ''; + $this->assertSame('', env('EMPTY_VALUE')); + $this->assertSame('', env('EMPTY_VALUE', 'default')); + + $_ENV['ZERO'] = '0'; + $this->assertSame('0', env('ZERO')); + $this->assertSame('0', env('ZERO', '1')); + + $this->assertSame('', env('DOCUMENT_ROOT')); + $this->assertStringContainsString('phpunit', env('PHP_SELF')); + } + + public function testEnv2(): void + { + $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->assertSame(env('SCRIPT_NAME'), '/a/test/test.php'); + $_SERVER = []; + $_ENV = []; + + $_ENV['CGI_MODE'] = 'BINARY'; + $_ENV['SCRIPT_URL'] = '/a/test/test.php'; + $this->assertSame(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->assertSame(env('TEST_ME'), 'a'); + + $_SERVER['TEST_ME'] = 'b'; + $this->assertSame(env('TEST_ME'), 'b'); + + unset($_ENV['TEST_ME']); + $this->assertSame(env('TEST_ME'), 'b'); + + $_SERVER = $server; + $_ENV = $env; + } + + /** + * Test cases for h() + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('hInputProvider')] + public function testH($value, $expected): void + { + $result = h($value); + $this->assertSame($expected, $result); + } + + public static function hInputProvider(): array + { + return [ + ['i am clean', 'i am clean'], + ['i "need" escaping', 'i "need" escaping'], + [null, null], + [1, 1], + [1.1, 1.1], + [new stdClass(), '(object)stdClass'], + [new Response(), ''], + [['clean', '"clean-me'], ['clean', '"clean-me']], + ]; + } + + public function testH2(): void + { + $string = ''; + $result = h($string); + $this->assertSame('<foo>', $result); + + $in = ['this & that', '

    Which one

    ']; + $result = h($in); + $expected = ['this & that', '<p>Which one</p>']; + $this->assertSame($expected, $result); + + $string = ' &  '; + $result = h($string); + $this->assertSame('<foo> & &nbsp;', $result); + + $string = ' &  '; + $result = h($string, false); + $this->assertSame('<foo> &  ', $result); + + $string = "An invalid\x80string"; + $result = h($string); + $this->assertStringContainsString('string', $result); + + $arr = ['', ' ']; + $result = h($arr); + $expected = [ + '<foo>', + '&nbsp;', + ]; + $this->assertSame($expected, $result); + + $arr = ['', ' ']; + $result = h($arr, false); + $expected = [ + '<foo>', + ' ', + ]; + $this->assertSame($expected, $result); + + $arr = ['f' => '', 'n' => ' ']; + $result = h($arr, false); + $expected = [ + 'f' => '<foo>', + 'n' => ' ', + ]; + $this->assertSame($expected, $result); + + $arr = ['invalid' => "\x99An invalid\x80string", 'good' => 'Good string']; + $result = h($arr); + $this->assertStringContainsString('An invalid', $result['invalid']); + $this->assertSame('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->assertSame('(object)stdClass', $result); + + $obj = new Response(['body' => 'Body content']); + $result = h($obj); + $this->assertSame('Body content', $result); + } + + /** + * Test splitting plugin names. + */ + public function testPluginSplit(): void + { + $result = pluginSplit('Something.else'); + $this->assertSame(['Something', 'else'], $result); + + $result = pluginSplit('Something.else.more.dots'); + $this->assertSame(['Something', 'else.more.dots'], $result); + + $result = pluginSplit('Somethingelse'); + $this->assertSame([null, 'Somethingelse'], $result); + + $result = pluginSplit('Something.else', true); + $this->assertSame(['Something.', 'else'], $result); + + $result = pluginSplit('Something.else.more.dots', true); + $this->assertSame(['Something.', 'else.more.dots'], $result); + + $result = pluginSplit('Post', false, 'Blog'); + $this->assertSame(['Blog', 'Post'], $result); + + $result = pluginSplit('Blog.Post', false, 'Ultimate'); + $this->assertSame(['Blog', 'Post'], $result); + } + + /** + * test namespaceSplit + */ + public function testNamespaceSplit(): void + { + $result = namespaceSplit('Something'); + $this->assertSame(['', 'Something'], $result); + + $result = namespaceSplit('\Something'); + $this->assertSame(['', 'Something'], $result); + + $result = namespaceSplit('Cake\Something'); + $this->assertSame(['Cake', 'Something'], $result); + + $result = namespaceSplit('Cake\Test\Something'); + $this->assertSame(['Cake\Test', 'Something'], $result); + } + + /** + * Test error messages coming out when deprecated level is on, manually setting the stack frame + */ + public function testDeprecationWarningEnabled(): void + { + $error = $this->captureError(E_ALL, function (): void { + deprecationWarning('4.5.0', 'This is deprecated ' . uniqid(), 2); + }); + $this->assertMatchesRegularExpression( + '/This is deprecated \w+\n(.*?)[\/\\\]FunctionsGlobalTest.php, line\: \d+/', + $error->getMessage(), + ); + } + + /** + * Test error messages coming out when deprecated level is on, not setting the stack frame manually + */ + public function testDeprecationWarningEnabledDefaultFrame(): void + { + $error = $this->captureError(E_ALL, function (): void { + deprecationWarning('5.0.0', 'This is going away too ' . uniqid()); + }); + $this->assertMatchesRegularExpression( + '/This is going away too \w+\n(.*?)[\/\\\]TestCase.php, line\: \d+/', + $error->getMessage(), + ); + } + + /** + * Test no error when deprecation matches ignore paths. + */ + public function testDeprecationWarningPathDisabled(): void + { + $this->expectNotToPerformAssertions(); + + Configure::write('Error.ignoredDeprecationPaths', ['src/TestSuite/*']); + $this->withErrorReporting(E_ALL, function (): void { + deprecationWarning('5.0.1', 'This will be gone soon'); + }); + } + + /** + * Test no error when deprecated level is off. + */ + public function testDeprecationWarningLevelDisabled(): void + { + $this->expectNotToPerformAssertions(); + + $this->withErrorReporting(E_ALL ^ E_USER_DEPRECATED, function (): void { + deprecationWarning('5.0.0', 'This is leaving'); + }); + } + + /** + * Test error messages coming out when warning level is on. + */ + public function testTriggerWarningEnabled(): void + { + $error = $this->captureError(E_ALL, function (): void { + triggerWarning('This will be gone one day'); + $this->assertTrue(true); + }); + $this->assertMatchesRegularExpression('/This will be gone one day/', $error->getMessage()); + } + + /** + * Test no error when warning level is off. + */ + public function testTriggerWarningLevelDisabled(): void + { + $this->withErrorReporting(E_ALL ^ E_USER_WARNING, function (): void { + triggerWarning('This was a mistake.'); + $this->assertTrue(true); + }); + } +} diff --git a/tests/TestCase/Core/FunctionsTest.php b/tests/TestCase/Core/FunctionsTest.php new file mode 100644 index 00000000000..ef56a748512 --- /dev/null +++ b/tests/TestCase/Core/FunctionsTest.php @@ -0,0 +1,717 @@ +assertSame('', pathCombine([])); + $this->assertSame('', pathCombine([''])); + $this->assertSame('', pathCombine(['', ''])); + $this->assertSame('/', pathCombine(['/', '/'])); + + $this->assertSame('path/to/file', pathCombine(['path', 'to', 'file'])); + $this->assertSame('path/to/file', pathCombine(['path/', 'to', 'file'])); + $this->assertSame('path/to/file', pathCombine(['path', 'to/', 'file'])); + $this->assertSame('path/to/file', pathCombine(['path/', 'to/', 'file'])); + $this->assertSame('path/to/file', pathCombine(['path/', '/to/', 'file'])); + + $this->assertSame('/path/to/file', pathCombine(['/', 'path', 'to', 'file'])); + $this->assertSame('/path/to/file', pathCombine(['/', '/path', 'to', 'file'])); + + $this->assertSame('/path/to/file/', pathCombine(['/path', 'to', 'file/'])); + $this->assertSame('/path/to/file/', pathCombine(['/path', 'to', 'file', '/'])); + $this->assertSame('/path/to/file/', pathCombine(['/path', 'to', 'file/', '/'])); + + // Test adding trailing slash + $this->assertSame('/', pathCombine([], trailing: true)); + $this->assertSame('/', pathCombine([''], trailing: true)); + $this->assertSame('/', pathCombine(['/'], trailing: true)); + $this->assertSame('/path/to/file/', pathCombine(['/path', 'to', 'file/'], trailing: true)); + $this->assertSame('/path/to/file/', pathCombine(['/path', 'to', 'file/', '/'], trailing: true)); + + // Test removing trailing slash + $this->assertSame('', pathCombine([''], trailing: false)); + $this->assertSame('', pathCombine(['/'], trailing: false)); + $this->assertSame('/path/to/file', pathCombine(['/path', 'to', 'file/'], trailing: false)); + $this->assertSame('/path/to/file', pathCombine(['/path', 'to', 'file/', '/'], trailing: false)); + + // Test Windows-style backslashes + $this->assertSame('/path/to\\file', pathCombine(['/', '\\path', 'to', '\\file'])); + $this->assertSame('/path\\to\\file/', pathCombine(['/', 'path', '\\to\\', 'file'], trailing: true)); + $this->assertSame('/path\\to\\file\\', pathCombine(['/', 'path', '\\to\\', 'file', '\\'], trailing: true)); + $this->assertSame('/path\\to\\file', pathCombine(['/', 'path', '\\to\\', 'file'], trailing: false)); + $this->assertSame('/path\\to\\file', pathCombine(['/', 'path', '\\to\\', 'file', '\\'], trailing: false)); + } + + /** + * Test cases for env() + */ + public function testEnv(): void + { + $_ENV['DOES_NOT_EXIST'] = null; + $this->assertNull(env('DOES_NOT_EXIST')); + $this->assertSame('default', env('DOES_NOT_EXIST', 'default')); + + $_ENV['DOES_EXIST'] = 'some value'; + $this->assertSame('some value', env('DOES_EXIST')); + $this->assertSame('some value', env('DOES_EXIST', 'default')); + + $_ENV['EMPTY_VALUE'] = ''; + $this->assertSame('', env('EMPTY_VALUE')); + $this->assertSame('', env('EMPTY_VALUE', 'default')); + + $_ENV['ZERO'] = '0'; + $this->assertSame('0', env('ZERO')); + $this->assertSame('0', env('ZERO', '1')); + + $_ENV['ZERO'] = 0; + $this->assertSame(0, env('ZERO')); + $this->assertSame(0, env('ZERO', 1)); + + $_ENV['ZERO'] = 0.0; + $this->assertSame(0.0, env('ZERO')); + $this->assertSame(0.0, env('ZERO', 1)); + + $this->assertSame('', env('DOCUMENT_ROOT')); + $this->assertStringContainsString('phpunit', env('PHP_SELF')); + } + + public function testEnv2(): void + { + $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->assertSame(env('SCRIPT_NAME'), '/a/test/test.php'); + $_SERVER = []; + $_ENV = []; + + $_ENV['CGI_MODE'] = 'BINARY'; + $_ENV['SCRIPT_URL'] = '/a/test/test.php'; + $this->assertSame(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->assertSame(env('TEST_ME'), 'a'); + + $_SERVER['TEST_ME'] = 'b'; + $this->assertSame(env('TEST_ME'), 'b'); + + unset($_ENV['TEST_ME']); + $this->assertSame(env('TEST_ME'), 'b'); + + $_SERVER = $server; + $_ENV = $env; + } + + /** + * Test cases for h() + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('hInputProvider')] + public function testH($value, $expected): void + { + $result = h($value); + $this->assertSame($expected, $result); + } + + public static function hInputProvider(): array + { + return [ + ['i am clean', 'i am clean'], + ['i "need" escaping', 'i "need" escaping'], + [null, null], + [1, 1], + [1.1, 1.1], + [new stdClass(), '(object)stdClass'], + [new Response(), ''], + [['clean', '"clean-me'], ['clean', '"clean-me']], + ]; + } + + public function testH2(): void + { + $string = ''; + $result = h($string); + $this->assertSame('<foo>', $result); + + $in = ['this & that', '

    Which one

    ']; + $result = h($in); + $expected = ['this & that', '<p>Which one</p>']; + $this->assertSame($expected, $result); + + $string = ' &  '; + $result = h($string); + $this->assertSame('<foo> & &nbsp;', $result); + + $string = ' &  '; + $result = h($string, false); + $this->assertSame('<foo> &  ', $result); + + $string = "An invalid\x80string"; + $result = h($string); + $this->assertStringContainsString('string', $result); + + $arr = ['', ' ']; + $result = h($arr); + $expected = [ + '<foo>', + '&nbsp;', + ]; + $this->assertSame($expected, $result); + + $arr = ['', ' ']; + $result = h($arr, false); + $expected = [ + '<foo>', + ' ', + ]; + $this->assertSame($expected, $result); + + $arr = ['f' => '', 'n' => ' ']; + $result = h($arr, false); + $expected = [ + 'f' => '<foo>', + 'n' => ' ', + ]; + $this->assertSame($expected, $result); + + $arr = ['invalid' => "\x99An invalid\x80string", 'good' => 'Good string']; + $result = h($arr); + $this->assertStringContainsString('An invalid', $result['invalid']); + $this->assertSame('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->assertSame('(object)stdClass', $result); + + $obj = new Response(['body' => 'Body content']); + $result = h($obj); + $this->assertSame('Body content', $result); + } + + /** + * Test splitting plugin names. + */ + public function testPluginSplit(): void + { + $result = pluginSplit('Something.else'); + $this->assertSame(['Something', 'else'], $result); + + $result = pluginSplit('Something.else.more.dots'); + $this->assertSame(['Something', 'else.more.dots'], $result); + + $result = pluginSplit('Somethingelse'); + $this->assertSame([null, 'Somethingelse'], $result); + + $result = pluginSplit('Something.else', true); + $this->assertSame(['Something.', 'else'], $result); + + $result = pluginSplit('Something.else.more.dots', true); + $this->assertSame(['Something.', 'else.more.dots'], $result); + + $result = pluginSplit('Post', false, 'Blog'); + $this->assertSame(['Blog', 'Post'], $result); + + $result = pluginSplit('Blog.Post', false, 'Ultimate'); + $this->assertSame(['Blog', 'Post'], $result); + } + + /** + * test namespaceSplit + */ + public function testNamespaceSplit(): void + { + $result = namespaceSplit('Something'); + $this->assertSame(['', 'Something'], $result); + + $result = namespaceSplit('\Something'); + $this->assertSame(['', 'Something'], $result); + + $result = namespaceSplit('Cake\Something'); + $this->assertSame(['Cake', 'Something'], $result); + + $result = namespaceSplit('Cake\Test\Something'); + $this->assertSame(['Cake\Test', 'Something'], $result); + } + + /** + * Test error messages coming out when deprecated level is on, manually setting the stack frame + */ + public function testDeprecationWarningEnabled(): void + { + $this->expectDeprecationMessageMatches('/Since 5.0.0: This is going away\n(.*?)[\/\\\]FunctionsTest.php, line\: \d+/', function (): void { + $this->withErrorReporting(E_ALL, function (): void { + deprecationWarning('5.0.0', 'This is going away', 2); + }); + }); + } + + /** + * Test error messages coming out when deprecated level is on, not setting the stack frame manually + */ + public function testDeprecationWarningEnabledDefaultFrame(): void + { + $this->expectDeprecationMessageMatches('/Since 5.0.0: This is going away too\n(.*?)[\/\\\]TestCase.php, line\: \d+/', function (): void { + $this->withErrorReporting(E_ALL, function (): void { + deprecationWarning('5.0.0', 'This is going away too'); + }); + }); + } + + /** + * Test no error when deprecation matches ignore paths. + */ + public function testDeprecationWarningPathDisabled(): void + { + $this->expectNotToPerformAssertions(); + + Configure::write('Error.ignoredDeprecationPaths', ['src/TestSuite/*']); + $this->withErrorReporting(E_ALL, function (): void { + deprecationWarning('5.0.0', 'This will be gone soon'); + }); + } + + /** + * Test no error when deprecated level is off. + */ + public function testDeprecationWarningLevelDisabled(): void + { + $this->expectNotToPerformAssertions(); + + $this->withErrorReporting(E_ALL ^ E_USER_DEPRECATED, function (): void { + deprecationWarning('5.0.0', 'This is leaving'); + }); + } + + /** + * Test error messages coming out when warning level is on. + */ + public function testTriggerWarningEnabled(): void + { + $this->expectWarningMessageMatches('/This will be gone one day/', function (): void { + $this->withErrorReporting(E_ALL, function (): void { + triggerWarning('This will be gone one day'); + $this->assertTrue(true); + }); + }); + } + + #[DataProvider('toStringProvider')] + public function testToString(mixed $rawValue, ?string $expected): void + { + $this->assertSame($expected, toString($rawValue)); + } + + /** + * @return array The array of test cases. + */ + public static function toStringProvider(): array + { + $stringable = new class implements Stringable { + public function __toString(): string + { + return 'stringable'; + } + }; + + return [ + // input like string + '(string) empty' => ['', ''], + '(string) space' => [' ', ' '], + '(string) dash' => ['-', '-'], + '(string) zero' => ['0', '0'], + '(string) number' => ['55', '55'], + '(string) partially2 number' => ['5x', '5x'], + // input like int + '(int) number' => [55, '55'], + '(int) negative number' => [-5, '-5'], + '(int) PHP_INT_MAX + 2' => [9223372036854775809, '9223372036854775808'], //is float: see IEEE 754 + '(int) PHP_INT_MAX + 1' => [9223372036854775808, '9223372036854775808'], //is float: see IEEE 754 + '(int) PHP_INT_MAX + 0' => [9223372036854775807, '9223372036854775807'], + '(int) PHP_INT_MAX - 1' => [9223372036854775806, '9223372036854775806'], + '(int) PHP_INT_MIN + 1' => [-9223372036854775807, '-9223372036854775807'], + '(int) PHP_INT_MIN + 0' => [-9223372036854775808, '-9223372036854775808'], + '(int) PHP_INT_MIN - 1' => [-9223372036854775809, '-9223372036854775808'], //is float: see IEEE 754 + '(int) PHP_INT_MIN - 2' => [-9223372036854775810, '-9223372036854775808'], //is float: see IEEE 754 + // input like float + '(float) zero' => [0.0, '0'], + '(float) positive' => [5.5, '5.5'], + '(float) round' => [5.0, '5'], + '(float) negative' => [-5.5, '-5.5'], + '(float) round negative' => [-5.0, '-5'], + '(float) small' => [0.000000000003, '0.000000000003'], + '(float) small2' => [64321.0000003, '64321.0000003'], + '(float) fractions' => [-9223372036778.2233, '-9223372036778.223'], //is float: see IEEE 754 + '(float) NaN' => [acos(8), null], + '(float) INF' => [INF, null], + '(float) -INF' => [-INF, null], + // boolean input types + '(bool) true' => [true, '1'], + '(bool) false' => [false, '0'], + // other input types + '(other) null' => [null, null], + '(other) empty-array' => [[], null], + '(other) int-array' => [[5], null], + '(other) string-array' => [['5'], null], + '(other) simple object' => [new stdClass(), null], + '(other) Stringable object' => [$stringable, 'stringable'], + ]; + } + + #[DataProvider('toIntProvider')] + public function testToInt(mixed $rawValue, null|int $expected): void + { + $this->assertSame($expected, toInt($rawValue)); + } + + /** + * @return array The array of test cases. + */ + public static function toIntProvider(): array + { + return [ + // string input types + '(string) empty' => ['', null], + '(string) space' => [' ', null], + '(string) null' => ['null', null], + '(string) dash' => ['-', null], + '(string) ctz' => ['čťž', null], + '(string) hex' => ['0x539', null], + '(string) binary' => ['0b10100111001', null], + '(string) scientific e' => ['1.2e+2', null], + '(string) scientific E' => ['1.2E+2', null], + '(string) octal old' => ['0123', 123], + '(string) octal new' => ['0o123', null], + '(string) decimal php74' => ['1_234_567', null], + '(string) zero' => ['0', 0], + '(string) number' => ['55', 55], + '(string) number_space_before' => [' 55', 55], + '(string) number_space_after' => ['55 ', 55], + '(string) padded number' => ['00055', 55], + '(string) padded number_space_before' => [' 00055', 55], + '(string) padded number_space_after' => ['00055 ', 55], + '(string) negative number' => ['-5', -5], + '(string) float round' => ['5.0', null], + '(string) float round negative' => ['-5.0', null], + '(string) float real' => ['5.1', null], + '(string) float round slovak' => ['5,0', null], + '(string) padded float round' => ['0005.0', null], + '(string) padded float real' => ['0005.1', null], + '(string) padded float round slovak' => ['0005,0', null], + '(string) money' => ['5 €', null], + '(string) PHP_INT_MAX + 1' => ['9223372036854775808', null], + '(string) PHP_INT_MAX + 0' => ['9223372036854775807', 9223372036854775807], + '(string) PHP_INT_MAX - 1' => ['9223372036854775806', 9223372036854775806], + '(string) PHP_INT_MIN + 1' => ['-9223372036854775807', -9223372036854775807], + '(string) PHP_INT_MIN + 0' => ['-9223372036854775808', null], + '(string) PHP_INT_MIN - 1' => ['-9223372036854775809', null], + '(string) string' => ['f', null], + '(string) partially1 number' => ['5 5', null], + '(string) partially2 number' => ['5x', null], + '(string) partially3 number' => ['x4', null], + '(string) double dot' => ['5.1.0', null], + // int input types + '(int) number' => [55, 55], + '(int) negative number' => [-5, -5], + '(int) PHP_INT_MAX + 0' => [9223372036854775807, 9223372036854775807], + '(int) PHP_INT_MAX - 1' => [9223372036854775806, 9223372036854775806], + '(int) PHP_INT_MIN + 1' => [-9223372036854775807, -9223372036854775807], + // PHP_INT_MIN is float -> PHP inconsistency https://bugs.php.net/bug.php?id=53934 + '(int) PHP_INT_MIN + 0' => [-9223372036854775808, -9223372036854775807 - 1], // ¯\_(ツ)_/¯, + '(int) PHP_INT_MIN - 1' => [-9223372036854775809, -9223372036854775807 - 1], // ¯\_(ツ)_/¯, + // float input types + '(float) zero' => [0.0, 0], + '(float) positive' => [5.5, 5], + '(float) round' => [5.0, 5], + '(float) negative' => [-5.5, -5], + '(float) round negative' => [-5.0, -5], + '(float) PHP_INT_MIN + 1' => [-9223372036854775807.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) PHP_INT_MIN + 0' => [-9223372036854775808.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) PHP_INT_MIN - 1' => [-9223372036854775809.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) 2^53 + 2' => [9007199254740994.0, 9007199254740994], + '(float) 2^53 + 1' => [9007199254740993.0, 9007199254740992], // see IEEE 754 + '(float) 2^53 + 0' => [9007199254740992.0, 9007199254740992], + '(float) 2^53 - 1' => [9007199254740991.0, 9007199254740991], + '(float) 2^53 - 2' => [9007199254740990.0, 9007199254740990], + '(float) -(2^53) + 2' => [-9007199254740990.0, -9007199254740990], + '(float) -(2^53) + 1' => [-9007199254740991.0, -9007199254740991], + '(float) -(2^53) + 0' => [-9007199254740992.0, -9007199254740992], + '(float) -(2^53) - 1' => [-9007199254740993.0, -9007199254740992], // see IEEE 754 + '(float) -(2^53) - 2' => [-9007199254740994.0, -9007199254740994], + '(float) NaN' => [acos(8), null], + '(float) INF' => [INF, null], + '(float) -INF' => [-INF, null], + // boolean input types + '(bool) true' => [true, 1], + '(bool) false' => [false, 0], + // other input types + '(other) null' => [null, null], + '(other) empty-array' => [[], null], + '(other) int-array' => [[5], null], + '(other) string-array' => [['5'], null], + '(other) simple object' => [new stdClass(), null], + ]; + } + + #[DataProvider('toIntProviderWithWarning')] + public function testToIntWithWarning(mixed $rawValue, null|int $expected): void + { + if (version_compare(PHP_VERSION, '8.5', '>=')) { + $this->expectErrorHandlerMessageMatches( + '/is not representable as an int, cast occurred/', + function () use ($expected, $rawValue): void { + $this->assertSame($expected, toInt($rawValue)); + }, + E_WARNING, + ); + } else { + $this->assertSame($expected, toInt($rawValue)); + } + } + + public static function toIntProviderWithWarning(): array + { + return [ + '(float) PHP_INT_MAX + 0' => [9223372036854775807.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) PHP_INT_MAX + 1' => [9223372036854775808.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) PHP_INT_MAX - 1' => [9223372036854775806.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(int) PHP_INT_MAX + 1' => [9223372036854775808, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + ]; + } + + #[DataProvider('toFloatProvider')] + public function testToFloat(mixed $rawValue, null|float $expected): void + { + $this->assertSame($expected, toFloat($rawValue)); + } + + /** + * @return array The array of test cases. + */ + public static function toFloatProvider(): array + { + return [ + // string input types + '(string) empty' => ['', null], + '(string) space' => [' ', null], + '(string) null' => ['null', null], + '(string) dash' => ['-', null], + '(string) ctz' => ['čťž', null], + '(string) hex' => ['0x539', null], + '(string) binary' => ['0b10100111001', null], + '(string) scientific e' => ['1.2e+2', 120.0], + '(string) scientific E' => ['1.2E+2', 120.], + '(string) octal old' => ['0123', 123.0], + '(string) octal new' => ['0o123', null], + '(string) decimal php74' => ['1_234_567', null], + '(string) zero' => ['0', 0.0], + '(string) number' => ['55', 55.0], + '(string) number_space_before' => [' 55', 55.0], + '(string) number_space_after' => ['55 ', 55.0], + '(string) padded number' => ['00055', 55.0], + '(string) padded number_space_before' => [' 00055', 55.0], + '(string) padded number_space_after' => ['00055 ', 55.0], + '(string) negative number' => ['-5', -5.0], + '(string) float round' => ['5.0', 5.0], + '(string) float round negative' => ['-5.0', -5.0], + '(string) float real' => ['5.1', 5.1], + '(string) float round slovak' => ['5,0', null], + '(string) padded float round' => ['0005.0', 5.0], + '(string) padded float real' => ['0005.1', 5.1], + '(string) padded float round slovak' => ['0005,0', null], + '(string) money' => ['5 €', null], + '(string) PHP_INT_MAX + 1' => ['9223372036854775808', PHP_INT_MAX], + '(string) PHP_INT_MAX + 0' => ['9223372036854775807', 9223372036854775807], + '(string) PHP_INT_MAX - 1' => ['9223372036854775806', 9223372036854775806], + '(string) PHP_INT_MIN + 1' => ['-9223372036854775807', -9223372036854775807], + '(string) PHP_INT_MIN + 0' => ['-9223372036854775808', -9223372036854775807], + '(string) PHP_INT_MIN - 1' => ['-9223372036854775809', -9223372036854775807], + '(string) string' => ['f', null], + '(string) partially1 number' => ['5 5', null], + '(string) partially2 number' => ['5x', null], + '(string) partially3 number' => ['x4', null], + '(string) double dot' => ['5.1.0', null], + // int input types + '(int) number' => [55, 55.0], + '(int) negative number' => [-5, -5.0], + '(int) PHP_INT_MAX + 1' => [9223372036854775808, 9223372036854775807 - 1], + '(int) PHP_INT_MAX + 0' => [9223372036854775807, 9223372036854775807], + '(int) PHP_INT_MAX - 1' => [9223372036854775806, 9223372036854775806], + '(int) PHP_INT_MIN + 1' => [-9223372036854775807, -9223372036854775807], + // PHP_INT_MIN is float -> PHP inconsistency https://bugs.php.net/bug.php?id=53934 + '(int) PHP_INT_MIN + 0' => [-9223372036854775808, -9223372036854775807 - 1], // ¯\_(ツ)_/¯, + '(int) PHP_INT_MIN - 1' => [-9223372036854775809, -9223372036854775807 - 1], // ¯\_(ツ)_/¯, + // float input types + '(float) zero' => [0.0, 0.0], + '(float) positive' => [5.5, 5.5], + '(float) round' => [5.0, 5.0], + '(float) negative' => [-5.5, -5.5], + '(float) round negative' => [-5.0, -5.0], + '(float) PHP_INT_MAX + 1' => [9223372036854775808.0, 9223372036854775807 - 1], + '(float) PHP_INT_MAX + 0' => [9223372036854775807.0, 9223372036854775807 - 1], + '(float) PHP_INT_MAX - 1' => [9223372036854775806.0, 9223372036854775807 - 1], + '(float) PHP_INT_MIN + 1' => [-9223372036854775807.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) PHP_INT_MIN + 0' => [-9223372036854775808.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) PHP_INT_MIN - 1' => [-9223372036854775809.0, -9223372036854775807 - 1], // ¯\_(ツ)_/¯ + '(float) 2^53 + 2' => [9007199254740994.0, 9007199254740994], + '(float) 2^53 + 1' => [9007199254740993.0, 9007199254740992], // see IEEE 754 + '(float) 2^53 + 0' => [9007199254740992.0, 9007199254740992], + '(float) 2^53 - 1' => [9007199254740991.0, 9007199254740991], + '(float) 2^53 - 2' => [9007199254740990.0, 9007199254740990], + '(float) -(2^53) + 2' => [-9007199254740990.0, -9007199254740990], + '(float) -(2^53) + 1' => [-9007199254740991.0, -9007199254740991], + '(float) -(2^53) + 0' => [-9007199254740992.0, -9007199254740992], + '(float) -(2^53) - 1' => [-9007199254740993.0, -9007199254740992], // see IEEE 754 + '(float) -(2^53) - 2' => [-9007199254740994.0, -9007199254740994], + '(float) NaN' => [acos(8), null], + '(float) INF' => [INF, null], + '(float) -INF' => [-INF, null], + // boolean input types + '(bool) true' => [true, 1.0], + '(bool) false' => [false, 0.0], + // other input types + '(other) null' => [null, null], + '(other) empty-array' => [[], null], + '(other) int-array' => [[5], null], + '(other) string-array' => [['5'], null], + '(other) simple object' => [new stdClass(), null], + ]; + } + + #[DataProvider('toBoolProvider')] + public function testToBool(mixed $rawValue, ?bool $expected): void + { + $this->assertSame($expected, toBool($rawValue)); + } + + /** + * @return array The array of test cases. + */ + public static function toBoolProvider(): array + { + return [ + // string input types + '(string) empty string' => ['', null], + '(string) space' => [' ', null], + '(string) some word' => ['abc', null], + '(string) double 0' => ['00', null], + '(string) single 0' => ['0', false], + '(string) false' => ['false', null], + '(string) double 1' => ['11', null], + '(string) single 1' => ['1', true], + '(string) true-string' => ['true', null], + // int input types + '(int) 0' => [0, false], + '(int) 1' => [1, true], + '(int) -1' => [-1, null], + '(int) 55' => [55, null], + '(int) negative number' => [-5, null], + // float input types + '(float) positive' => [5.5, null], + '(float) round' => [5.0, null], + '(float) 0.0' => [0.0, false], + '(float) 1.0' => [1.0, true], + '(float) NaN' => [acos(8), null], + '(float) INF' => [INF, null], + '(float) -INF' => [-INF, null], + // boolean input types + '(bool) true' => [true, true], + '(bool) false' => [false, false], + // other input types + '(other) null' => [null, null], + '(other) empty-array' => [[], null], + '(other) int-array' => [[5], null], + '(other) string-array' => [['5'], null], + '(other) simple object' => [new stdClass(), null], + ]; + } +} diff --git a/tests/TestCase/Core/InstanceConfigTraitTest.php b/tests/TestCase/Core/InstanceConfigTraitTest.php new file mode 100644 index 00000000000..5c3bcaabe3c --- /dev/null +++ b/tests/TestCase/Core/InstanceConfigTraitTest.php @@ -0,0 +1,538 @@ +object = new TestInstanceConfig(); + } + + /** + * testDefaultsAreSet + */ + public function testDefaultsAreSet(): void + { + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'nested' => 'value', + 'other' => 'value', + ], + ], + $this->object->getConfig(), + 'runtime config should match the defaults if not overridden', + ); + } + + /** + * testGetSimple + */ + public function testGetSimple(): void + { + $this->assertSame( + 'string', + $this->object->getConfig('some'), + 'should return the key value only', + ); + + $this->assertSame( + ['nested' => 'value', 'other' => 'value'], + $this->object->getConfig('a'), + 'should return the key value only', + ); + } + + /** + * testGetDot + */ + public function testGetDot(): void + { + $this->assertSame( + 'value', + $this->object->getConfig('a.nested'), + 'should return the nested value only', + ); + } + + /** + * testGetDefault + */ + public function testGetDefault(): void + { + $this->assertSame( + 'default', + $this->object->getConfig('nonexistent', 'default'), + ); + + $this->assertSame( + 'my-default', + $this->object->getConfig('nested.nonexistent', 'my-default'), + ); + } + + /** + * testSetSimple + */ + public function testSetSimple(): void + { + $this->object->setConfig('foo', 'bar'); + $this->assertSame( + 'bar', + $this->object->getConfig('foo'), + 'should return the same value just set', + ); + + $return = $this->object->setConfig('some', 'zum'); + $this->assertSame( + 'zum', + $this->object->getConfig('some'), + 'should return the overwritten value', + ); + $this->assertSame( + $this->object, + $return, + 'write operations should return the instance', + ); + + $this->assertSame( + [ + 'some' => 'zum', + 'a' => ['nested' => 'value', 'other' => 'value'], + 'foo' => 'bar', + ], + $this->object->getConfig(), + 'updates should be merged with existing config', + ); + } + + /** + * testSetNested + */ + public function testSetNested(): void + { + $this->object->setConfig('new.foo', 'bar'); + $this->assertSame( + 'bar', + $this->object->getConfig('new.foo'), + 'should return the same value just set', + ); + + $this->object->setConfig('a.nested', 'zum'); + $this->assertSame( + 'zum', + $this->object->getConfig('a.nested'), + 'should return the overwritten value', + ); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => ['nested' => 'zum', 'other' => 'value'], + 'new' => ['foo' => 'bar'], + ], + $this->object->getConfig(), + 'updates should be merged with existing config', + ); + } + + /** + * testSetNested + */ + public function testSetArray(): void + { + $this->object->setConfig(['foo' => 'bar']); + $this->assertSame( + 'bar', + $this->object->getConfig('foo'), + 'should return the same value just set', + ); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => ['nested' => 'value', 'other' => 'value'], + 'foo' => 'bar', + ], + $this->object->getConfig(), + 'updates should be merged with existing config', + ); + + $this->object->setConfig(['new.foo' => 'bar']); + $this->assertSame( + 'bar', + $this->object->getConfig('new.foo'), + 'should return the same value just set', + ); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => ['nested' => 'value', 'other' => 'value'], + 'foo' => 'bar', + 'new' => ['foo' => 'bar'], + ], + $this->object->getConfig(), + 'updates should be merged with existing config', + ); + + $this->object->setConfig(['multiple' => 'different', 'a.values.to' => 'set']); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => ['nested' => 'value', 'other' => 'value', 'values' => ['to' => 'set']], + 'foo' => 'bar', + 'new' => ['foo' => 'bar'], + 'multiple' => 'different', + ], + $this->object->getConfig(), + 'updates should be merged with existing config', + ); + } + + public function testGetConfigOrFail(): void + { + $this->object->setConfig(['foo' => 'bar']); + $this->assertSame( + 'bar', + $this->object->getConfigOrFail('foo'), + 'should return the same value just set', + ); + } + + public function testGetConfigOrFailException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected configuration `foo` not found.'); + + $this->object->getConfigOrFail('foo'); + } + + /** + * test shallow merge + */ + public function testConfigShallow(): void + { + $this->object->configShallow(['a' => ['new_nested' => true], 'new' => 'bar']); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => ['new_nested' => true], + 'new' => 'bar', + ], + $this->object->getConfig(), + 'When merging a scalar property will be overwritten with an array', + ); + } + + /** + * testSetClobber + */ + public function testSetClobber(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Cannot set `a.nested.value`'); + $this->object->setConfig(['a.nested.value' => 'not possible'], null, false); + $this->object->getConfig(); + } + + /** + * testMerge + */ + public function testMerge(): void + { + $this->object->setConfig(['a' => ['nother' => 'value']]); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'nested' => 'value', + 'other' => 'value', + 'nother' => 'value', + ], + ], + $this->object->getConfig(), + 'Merging should not delete untouched array values', + ); + } + + /** + * testMergeDotKey + */ + public function testMergeDotKey(): void + { + $this->object->setConfig('a.nother', 'value'); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'nested' => 'value', + 'other' => 'value', + 'nother' => 'value', + ], + ], + $this->object->getConfig(), + 'Should act the same as having passed the equivalent array to the config function', + ); + + $this->object->setConfig(['a.nextra' => 'value']); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'nested' => 'value', + 'other' => 'value', + 'nother' => 'value', + 'nextra' => 'value', + ], + ], + $this->object->getConfig(), + 'Merging should not delete untouched array values', + ); + } + + /** + * testSetDefaultsMerge + */ + public function testSetDefaultsMerge(): void + { + $this->object->setConfig(['a' => ['nother' => 'value']]); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'nested' => 'value', + 'other' => 'value', + 'nother' => 'value', + ], + ], + $this->object->getConfig(), + 'First access should act like any subsequent access', + ); + } + + /** + * testSetDefaultsNoMerge + */ + public function testSetDefaultsNoMerge(): void + { + $this->object->setConfig(['a' => ['nother' => 'value']], null, false); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'nother' => 'value', + ], + ], + $this->object->getConfig(), + 'If explicitly no-merge, array values should be overwritten', + ); + } + + /** + * testSetMergeNoClobber + * + * Merging offers no such protection of clobbering a value whilst implemented + * using the Hash class + */ + public function testSetMergeNoClobber(): void + { + $this->object->setConfig(['a.nested.value' => 'it is possible']); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'nested' => [ + 'value' => 'it is possible', + ], + 'other' => 'value', + ], + ], + $this->object->getConfig(), + 'When merging a scalar property will be overwritten with an array', + ); + } + + /** + * testReadOnlyConfig + */ + public function testReadOnlyConfig(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('This Instance is readonly'); + $object = new ReadOnlyTestInstanceConfig(); + + $this->assertSame( + [ + 'some' => 'string', + 'a' => ['nested' => 'value', 'other' => 'value'], + ], + $object->getConfig(), + 'default config should be returned', + ); + + $object->setConfig('throw.me', 'an exception'); + } + + public function testDeleteUsingSetConfig(): void + { + $this->object->setConfig('some'); + $this->assertNull( + $this->object->getConfig('some'), + 'setting a new key to null should have no effect', + ); + + $this->object->setConfig('a.nested'); + $this->assertNull( + $this->object->getConfig('a.nested'), + 'should delete the existing value', + ); + + $this->assertSame( + [ + 'a' => [ + 'other' => 'value', + ], + ], + $this->object->getConfig(), + 'deleted keys should not be present', + ); + } + + /** + * testDeleteSimple + */ + public function testDeleteSimple(): void + { + $this->object->deleteConfig('foo'); + $this->assertNull( + $this->object->getConfig('foo'), + 'setting a new key to null should have no effect', + ); + + $this->object->deleteConfig('some'); + $this->assertNull( + $this->object->getConfig('some'), + 'should delete the existing value', + ); + + $this->assertSame( + [ + 'a' => ['nested' => 'value', 'other' => 'value'], + ], + $this->object->getConfig(), + 'deleted keys should not be present', + ); + } + + /** + * testDeleteNested + */ + public function testDeleteNested(): void + { + $this->object->deleteConfig('new.foo'); + $this->assertNull( + $this->object->getConfig('new.foo'), + 'setting a new key to null should have no effect', + ); + + $this->object->deleteConfig('a.nested'); + $this->assertNull( + $this->object->getConfig('a.nested'), + 'should delete the existing value', + ); + $this->assertSame( + [ + 'some' => 'string', + 'a' => [ + 'other' => 'value', + ], + ], + $this->object->getConfig(), + 'deleted keys should not be present', + ); + + $this->object->deleteConfig('a.other'); + $this->assertNull( + $this->object->getConfig('a.other'), + 'should delete the existing value', + ); + $this->assertSame( + [ + 'some' => 'string', + 'a' => [], + ], + $this->object->getConfig(), + 'deleted keys should not be present', + ); + } + + /** + * testDeleteArray + */ + public function testDeleteArray(): void + { + $this->object->deleteConfig('a'); + $this->assertNull( + $this->object->getConfig('a'), + 'should delete the existing value', + ); + $this->assertSame( + [ + 'some' => 'string', + ], + $this->object->getConfig(), + 'deleted keys should not be present', + ); + } + + /** + * testDeleteClobber + */ + public function testDeleteClobber(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Cannot unset `a.nested.value.whoops`'); + $this->object->deleteConfig('a.nested.value.whoops'); + } +} diff --git a/tests/TestCase/Core/PluginCollectionTest.php b/tests/TestCase/Core/PluginCollectionTest.php new file mode 100644 index 00000000000..ac037f39b4c --- /dev/null +++ b/tests/TestCase/Core/PluginCollectionTest.php @@ -0,0 +1,309 @@ +assertCount(1, $plugins); + $this->assertTrue($plugins->has('TestPlugin')); + } + + public function testAdd(): void + { + $plugins = new PluginCollection(); + $this->assertCount(0, $plugins); + + $plugins->add(new TestPlugin()); + $this->assertCount(1, $plugins); + } + + public function testAddDuplicate(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Plugin named `TestPlugin` is already loaded'); + + $plugins = new PluginCollection(); + $plugins->add(new TestPlugin()); + + $plugins->add(new TestPlugin()); + } + + public function testAddFromConfig(): void + { + Configure::write('debug', false); + + $config = [ + 'Company/TestPluginThree', + 'TestPlugin' => ['onlyDebug' => true], + 'Nope' => ['optional' => true], + 'Named' => ['routes' => false], + ]; + + $plugins = new PluginCollection(); + $plugins->addFromConfig($config); + + $this->assertCount(2, $plugins); + $this->assertTrue($plugins->has('Company/TestPluginThree')); + $this->assertFalse($plugins->has('TestPlugin')); + $this->assertFalse($plugins->get('Named')->isEnabled('routes')); + } + + public function testAddOperations(): void + { + $plugins = new PluginCollection(); + $plugins->add(new TestPlugin()); + + $this->assertFalse($plugins->has('Nope')); + $this->assertSame($plugins, $plugins->remove('Nope')); + + $this->assertTrue($plugins->has('TestPlugin')); + $this->assertSame($plugins, $plugins->remove('TestPlugin')); + $this->assertCount(0, $plugins); + $this->assertFalse($plugins->has('TestPlugin')); + } + + public function testAddVendoredPlugin(): void + { + $plugins = new PluginCollection(); + $plugins->add(new TestPluginThreePlugin()); + + $this->assertTrue($plugins->has('Company/TestPluginThree')); + $this->assertFalse($plugins->has('TestPluginThree')); + $this->assertFalse($plugins->has('Company')); + $this->assertFalse($plugins->has('TestPlugin')); + } + + public function testHas(): void + { + $plugins = new PluginCollection(); + $this->assertFalse($plugins->has('TestPlugin')); + + $plugins->add(new TestPlugin()); + $this->assertTrue($plugins->has('TestPlugin')); + $this->assertFalse($plugins->has('Plugin')); + } + + public function testGet(): void + { + $plugins = new PluginCollection(); + $plugin = new TestPlugin(); + $plugins->add($plugin); + + $this->assertSame($plugin, $plugins->get('TestPlugin')); + } + + public function testGetAutoload(): void + { + $plugins = new PluginCollection(); + $plugin = $plugins->get('Named'); + $this->assertInstanceOf(NamedPlugin::class, $plugin); + } + + public function testGetInvalid(): void + { + $this->expectException(MissingPluginException::class); + + $plugins = new PluginCollection(); + $plugins->get('Invalid'); + } + + public function testCreate(): void + { + $plugins = new PluginCollection(); + + $plugin = $plugins->create('Named'); + $this->assertInstanceOf(NamedPlugin::class, $plugin); + + $plugin = $plugins->create('Named', ['name' => 'Granpa']); + $this->assertInstanceOf(NamedPlugin::class, $plugin); + $this->assertSame('Granpa', $plugin->getName()); + + $plugin = $plugins->create(NamedPlugin::class); + $this->assertInstanceOf(NamedPlugin::class, $plugin); + + $plugin = $plugins->create('Company/TestPluginThree'); + $this->assertInstanceOf(TestPluginThreePlugin::class, $plugin); + + $plugin = $plugins->create('TestTheme'); + $this->assertInstanceOf(BasePlugin::class, $plugin); + $this->assertSame('TestTheme', $plugin->getName()); + } + + /** + * @deprecated + */ + public function testCreateDeprecationMessage(): void + { + $this->expectDeprecationMessageMatches( + '#You can create the missing class using `bin/cake bake plugin TestPluginTwo --class-only`#', + function (): void { + $plugins = new PluginCollection(); + $plugins->create('TestPluginTwo'); + }, + ); + } + + public function testCreateException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Plugin name cannot be empty.'); + + $plugins = new PluginCollection(); + $plugins->create(''); + } + + public function testIterator(): void + { + $data = [ + new TestPlugin(), + new TestPluginThreePlugin(), + ]; + $plugins = new PluginCollection($data); + $out = []; + foreach ($plugins as $plugin) { + $this->assertInstanceOf(PluginInterface::class, $plugin); + $out[] = $plugin; + } + $this->assertSame($data, $out); + } + + public function testWith(): void + { + $plugins = new PluginCollection(); + $plugin = new TestPlugin(); + $plugin->disable('routes'); + + $pluginThree = new TestPluginThreePlugin(); + + $plugins->add($plugin); + $plugins->add($pluginThree); + + $out = []; + foreach ($plugins->with('routes') as $p) { + $out[] = $p; + } + $this->assertCount(1, $out); + $this->assertSame($pluginThree, $out[0]); + } + + /** + * Test that looping over the plugin collection during + * a with loop doesn't lose iteration state. + * + * This situation can happen when a plugin like bake + * needs to discover things inside other plugins. + */ + public function testWithInnerIteration(): void + { + $plugins = new PluginCollection(); + $plugin = new TestPlugin(); + $pluginThree = new TestPluginThreePlugin(); + + $plugins->add($plugin); + $plugins->add($pluginThree); + + $out = []; + foreach ($plugins->with('routes') as $p) { + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + foreach ($plugins as $i) { + // Do nothing, we just need to enumerate the collection + } + $out[] = $p; + } + $this->assertCount(2, $out); + $this->assertSame($plugin, $out[0]); + $this->assertSame($pluginThree, $out[1]); + } + + public function testWithInvalidHook(): void + { + $this->expectException(InvalidArgumentException::class); + + $plugins = new PluginCollection(); + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + foreach ($plugins->with('bad') as $p) { + } + } + + public function testFindPathNoConfigureData(): void + { + Configure::write('plugins', []); + $plugins = new PluginCollection(); + $path = $plugins->findPath('TestPlugin'); + + $this->assertSame(TEST_APP . 'Plugin' . DS . 'TestPlugin' . DS, $path); + } + + public function testFindPathLoadsConfigureData(): void + { + $configPath = ROOT . DS . 'cakephp-plugins.php'; + $this->skipIf(file_exists($configPath), 'cakephp-plugins.php exists, skipping overwrite'); + $file = << [ + 'TestPlugin' => '/config/path/' + ] +]; +PHP; + file_put_contents($configPath, $file); + + $plugins = new PluginCollection(); + Configure::delete('plugins'); + $path = $plugins->findPath('TestPlugin'); + unlink($configPath); + + $this->assertSame('/config/path/', $path); + } + + public function testFindPathConfigureData(): void + { + Configure::write('plugins', ['TestPlugin' => '/some/path']); + $plugins = new PluginCollection(); + $path = $plugins->findPath('TestPlugin'); + + $this->assertSame('/some/path', $path); + } + + public function testFindPathMissingPlugin(): void + { + Configure::write('plugins', []); + $plugins = new PluginCollection(); + + $this->expectException(MissingPluginException::class); + $plugins->findPath('InvalidPlugin'); + } +} diff --git a/tests/TestCase/Core/PluginConfigTest.php b/tests/TestCase/Core/PluginConfigTest.php new file mode 100644 index 00000000000..7d9c94d841e --- /dev/null +++ b/tests/TestCase/Core/PluginConfigTest.php @@ -0,0 +1,393 @@ +clearPlugins(); + $this->pluginsListPath = ROOT . DS . 'cakephp-plugins.php'; + if (file_exists($this->pluginsListPath)) { + unlink($this->pluginsListPath); + } + $this->pluginsConfigPath = CONFIG . DS . 'plugins.php'; + if (file_exists($this->pluginsConfigPath)) { + $this->originalPluginsConfigContent = file_get_contents($this->pluginsConfigPath); + } + } + + /** + * Reverts the changes done to the environment while testing + */ + protected function tearDown(): void + { + parent::tearDown(); + Configure::delete('plugins'); + $this->clearPlugins(); + if (file_exists($this->pluginsListPath)) { + unlink($this->pluginsListPath); + } + file_put_contents($this->pluginsConfigPath, $this->originalPluginsConfigContent); + } + + public function testSimpleConfig(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/config/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + Configure::delete('plugins'); + $result = [ + 'TestPlugin' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + 'OtherPlugin' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + ]; + $this->assertSame($result, PluginConfig::getAppConfig()); + } + + public function testOnlyOnePlugin(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/config/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + $result = [ + 'TestPlugin' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + 'OtherPlugin' => [ + 'isLoaded' => false, + ], + ]; + $this->assertSame($result, PluginConfig::getAppConfig()); + } + + public function testSpecificEnvironmentsAndHooks(): void + { + $file = << [ + 'OtherPlugin' => '/config/path/', + 'AnotherPlugin' => '/config/path/' + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = << ['onlyDebug' => true, 'onlyCli' => false, 'optional' => true], + 'AnotherPlugin' => ['bootstrap' => false, 'console' => false, 'middleware' => false, 'routes' => false, 'services' => false, 'events' => false], +]; +PHP; + file_put_contents($this->pluginsConfigPath, $config); + + $result = [ + 'OtherPlugin' => [ + 'isLoaded' => true, + 'onlyDebug' => true, + 'onlyCli' => false, + 'optional' => true, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + 'AnotherPlugin' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => false, + 'console' => false, + 'middleware' => false, + 'routes' => false, + 'services' => false, + 'events' => false, + ], + ]; + $this->assertSame($result, PluginConfig::getAppConfig()); + } + + public function testUnknownPlugin(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/config/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + $this->assertSame([ + 'TestPlugin' => [ + 'isLoaded' => false, + ], + 'OtherPlugin' => [ + 'isLoaded' => false, + ], + 'UnknownPlugin' => [ + 'isLoaded' => false, + 'isUnknown' => true, + ], + ], PluginConfig::getAppConfig()); + } + + public function testNoPluginConfig(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/config/path/', + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + unlink($this->pluginsConfigPath); + + $this->assertSame([ + 'TestPlugin' => [ + 'isLoaded' => false, + ], + 'OtherPlugin' => [ + 'isLoaded' => false, + ], + ], PluginConfig::getAppConfig()); + } + + public function testGetVersions(): void + { + $test = PluginConfig::getVersions(ROOT . DS . 'tests' . DS . 'composer.lock'); + $expected = [ + 'packages' => [ + 'cakephp/chronos' => '3.0.4', + 'psr/simple-cache' => '3.0.0', + ], + 'devPackages' => [ + 'cakephp/cakephp-codesniffer' => '5.1.1', + 'squizlabs/php_codesniffer' => '3.8.1', + 'theseer/tokenizer' => '1.2.2', + ], + ]; + $this->assertEquals($expected, $test); + } + + public function testSimpleConfiWithVersions(): void + { + $file = << [ + 'Chronos' => ROOT . DS . 'vendor' . DS . 'cakephp' . DS . 'chronos', + 'CodeSniffer' => ROOT . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp-codesniffer' + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + Configure::delete('plugins'); + $pathToRootVendor = ROOT . DS . 'vendor' . DS; + $result = [ + 'Chronos' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + 'packagePath' => $pathToRootVendor . 'cakephp' . DS . 'chronos', + 'package' => 'cakephp/chronos', + 'version' => '3.0.4', + 'isDevPackage' => false, + ], + 'CodeSniffer' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + 'packagePath' => $pathToRootVendor . 'cakephp' . DS . 'cakephp-codesniffer', + 'package' => 'cakephp/cakephp-codesniffer', + 'version' => '5.1.1', + 'isDevPackage' => true, + ], + ]; + $this->assertSame($result, PluginConfig::getAppConfig(ROOT . DS . 'tests' . DS . 'composer.lock')); + } + + public function testInvalidComposerLock(): void + { + $path = ROOT . DS . 'tests' . DS . 'unknown_composer.lock'; + $this->assertSame([], PluginConfig::getAppConfig($path)); + + file_put_contents($path, 'invalid-json'); + $this->assertSame([], PluginConfig::getAppConfig($path)); + unlink($path); + } + + public function testInvalidComposerJson(): void + { + $pathToTestPlugin = ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS; + $file = << [ + 'TestPlugin' => ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPlugin' . DS, + ] +]; +PHP; + file_put_contents($this->pluginsListPath, $file); + + $config = <<pluginsConfigPath, $config); + + file_put_contents($pathToTestPlugin . 'composer.json', 'invalid-json'); + + $this->assertSame([ + 'TestPlugin' => [ + 'isLoaded' => true, + 'onlyDebug' => false, + 'onlyCli' => false, + 'optional' => false, + 'bootstrap' => true, + 'console' => true, + 'middleware' => true, + 'routes' => true, + 'services' => true, + 'events' => true, + ], + ], PluginConfig::getAppConfig()); + unlink($pathToTestPlugin . 'composer.json'); + } +} diff --git a/tests/TestCase/Core/PluginTest.php b/tests/TestCase/Core/PluginTest.php new file mode 100644 index 00000000000..a409542a0d6 --- /dev/null +++ b/tests/TestCase/Core/PluginTest.php @@ -0,0 +1,145 @@ +clearPlugins(); + } + + /** + * Reverts the changes done to the environment while testing + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + } + + /** + * Tests loading a plugin with a class + */ + public function testLoadConcreteClass(): void + { + $this->loadPlugins(['TestPlugin']); + $instance = Plugin::getCollection()->get('TestPlugin'); + $this->assertSame(TestPlugin::class, $instance::class); + } + + /** + * Tests loading a plugin without a class + * + * @deprecated + */ + public function testLoadDynamicClass(): void + { + $this->deprecated(function (): void { + $this->loadPlugins(['TestPluginTwo']); + $instance = Plugin::getCollection()->get('TestPluginTwo'); + $this->assertSame(BasePlugin::class, $instance::class); + }); + } + + /** + * Tests that Plugin::path() returns the correct path for the loaded plugins + */ + public function testPath(): void + { + $this->loadPlugins(['TestPlugin', 'Company/TestPluginThree']); + $expected = TEST_APP . 'Plugin' . DS . 'TestPlugin' . DS; + $this->assertPathEquals(Plugin::path('TestPlugin'), $expected); + + $expected = TEST_APP . 'Plugin' . DS . 'Company' . DS . 'TestPluginThree' . DS; + $this->assertPathEquals(Plugin::path('Company/TestPluginThree'), $expected); + + $this->deprecated(function (): void { + $this->loadPlugins(['TestPluginTwo']); + $expected = TEST_APP . 'Plugin' . DS . 'TestPluginTwo' . DS; + $this->assertPathEquals(Plugin::path('TestPluginTwo'), $expected); + }); + } + + /** + * Tests that Plugin::path() throws an exception on unknown plugin + */ + public function testPathNotFound(): void + { + $this->expectException(MissingPluginException::class); + Plugin::path('NonExistentPlugin'); + } + + /** + * Tests that Plugin::classPath() returns the correct path for the loaded plugins + */ + public function testClassPath(): void + { + $this->loadPlugins(['TestPlugin', 'Company/TestPluginThree']); + $expected = TEST_APP . 'Plugin' . DS . 'TestPlugin' . DS . 'src' . DS; + $this->assertPathEquals(Plugin::classPath('TestPlugin'), $expected); + + $expected = TEST_APP . 'Plugin' . DS . 'Company' . DS . 'TestPluginThree' . DS . 'src' . DS; + $this->assertPathEquals(Plugin::classPath('Company/TestPluginThree'), $expected); + + $this->deprecated(function (): void { + $this->loadPlugins(['TestPluginTwo']); + $expected = TEST_APP . 'Plugin' . DS . 'TestPluginTwo' . DS . 'src' . DS; + $this->assertPathEquals(Plugin::classPath('TestPluginTwo'), $expected); + }); + } + + /** + * Tests that Plugin::templatePath() returns the correct path for the loaded plugins + */ + public function testTemplatePath(): void + { + $this->loadPlugins(['TestPlugin', 'Company/TestPluginThree']); + $expected = TEST_APP . 'Plugin' . DS . 'TestPlugin' . DS . 'templates' . DS; + $this->assertPathEquals(Plugin::templatePath('TestPlugin'), $expected); + + $expected = TEST_APP . 'Plugin' . DS . 'Company' . DS . 'TestPluginThree' . DS . 'templates' . DS; + $this->assertPathEquals(Plugin::templatePath('Company/TestPluginThree'), $expected); + + $this->deprecated(function (): void { + $this->loadPlugins(['TestPluginTwo']); + $expected = TEST_APP . 'Plugin' . DS . 'TestPluginTwo' . DS . 'templates' . DS; + $this->assertPathEquals(Plugin::templatePath('TestPluginTwo'), $expected); + }); + } + + /** + * Tests that Plugin::classPath() throws an exception on unknown plugin + */ + public function testClassPathNotFound(): void + { + $this->expectException(MissingPluginException::class); + Plugin::classPath('NonExistentPlugin'); + } +} diff --git a/tests/TestCase/Core/Retry/CommandRetryTest.php b/tests/TestCase/Core/Retry/CommandRetryTest.php new file mode 100644 index 00000000000..333564a05f0 --- /dev/null +++ b/tests/TestCase/Core/Retry/CommandRetryTest.php @@ -0,0 +1,85 @@ +assertSame(2, $retry->run($action)); + } + + /** + * Test attempts exceeded + */ + public function testExceedAttempts(): void + { + $count = 0; + $action = function () use (&$count) { + if ($count < 2) { + ++$count; + throw new Exception('this is failing'); + } + + return $count; + }; + + $strategy = new TestRetryStrategy(true); + $retry = new CommandRetry($strategy, 1); + $this->expectException(Exception::class); + $this->expectExceptionMessage('this is failing'); + $retry->run($action); + } + + /** + * Test that the strategy is respected + */ + public function testRespectStrategy(): void + { + $action = function (): void { + throw new Exception('this is failing'); + }; + + $strategy = new TestRetryStrategy(false); + $retry = new CommandRetry($strategy, 2); + $this->expectException(Exception::class); + $this->expectExceptionMessage('this is failing'); + $retry->run($action); + } +} diff --git a/tests/TestCase/Core/ServiceConfigTest.php b/tests/TestCase/Core/ServiceConfigTest.php new file mode 100644 index 00000000000..5be48095797 --- /dev/null +++ b/tests/TestCase/Core/ServiceConfigTest.php @@ -0,0 +1,53 @@ +assertSame('first-val', $config->get('first')); + $this->assertSame('nested-val', $config->get('nested.path')); + $this->assertNull($config->get('nope')); + $this->assertNull($config->get('nope')); + $this->assertSame('default', $config->get('nested.nope', 'default')); + } + + public function testHas(): void + { + Configure::write('first', 'first-val'); + Configure::write('nested.path', 'nested-val'); + Configure::write('nullval'); + $config = new ServiceConfig(); + + $this->assertFalse($config->has('nope')); + $this->assertTrue($config->has('first')); + $this->assertTrue($config->has('nested.path')); + $this->assertFalse($config->has('nullval')); + } +} diff --git a/tests/TestCase/Core/ServiceProviderTest.php b/tests/TestCase/Core/ServiceProviderTest.php new file mode 100644 index 00000000000..1e6bde13687 --- /dev/null +++ b/tests/TestCase/Core/ServiceProviderTest.php @@ -0,0 +1,58 @@ +addServiceProvider(new PersonServiceProvider()); + + $this->assertTrue( + $container->has('boot'), + 'Should have service defined in bootstrap.', + ); + $this->assertSame('boot', $container->get('boot')->name); + } + + public function testServicesHook(): void + { + $container = new Container(); + $container->addServiceProvider(new PersonServiceProvider()); + + $this->assertTrue($container->has('sally'), 'Should have service'); + $this->assertSame('sally', $container->get('sally')->name); + } + + public function testEmptyProvides(): void + { + $this->expectException(LogicException::class); + + $provider = new EmptyServiceProvider(); + $provider->provides('sally'); + } +} diff --git a/tests/TestCase/Core/StaticConfigTraitTest.php b/tests/TestCase/Core/StaticConfigTraitTest.php new file mode 100644 index 00000000000..9a24a7932d2 --- /dev/null +++ b/tests/TestCase/Core/StaticConfigTraitTest.php @@ -0,0 +1,240 @@ +subject = new class { + use StaticConfigTrait; + }; + } + + /** + * teardown method + */ + protected function tearDown(): void + { + unset($this->subject); + parent::tearDown(); + } + + /** + * Tests simple usage of parseDsn + */ + public function testSimpleParseDsn(): void + { + $className = $this->subject::class; + $this->assertSame([], $className::parseDsn('')); + } + + /** + * Tests that failing to pass a string to parseDsn will throw an exception + */ + public function testParseBadType(): void + { + $this->expectException(TypeError::class); + $className = $this->subject::class; + $className::parseDsn(['url' => 'http://:80']); + } + + public function testSetConfigValues(): void + { + $className = $this->subject::class; + $className::setConfig('foo', true); + + $result = $className::getConfigOrFail('foo'); + $this->assertTrue($result); + + $className::setConfig('bar', 'value'); + $result = $className::getConfigOrFail('bar'); + $this->assertSame('value', $result); + } + + public function testGetConfigOrFail(): void + { + $className = $this->subject::class; + $className::setConfig('foo2', ['bar' => true]); + + $result = $className::getConfigOrFail('foo2'); + $this->assertSame(['bar' => true], $result); + } + + public function testGetConfigOrFailException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected configuration `unknown` not found.'); + + $className = $this->subject::class; + $className::getConfigOrFail('unknown'); + } + + /** + * Tests parsing querystring values + */ + public function testParseDsnQuerystring(): void + { + $dsn = 'file:///?url=test'; + $expected = [ + 'className' => FileLog::class, + 'path' => '/', + 'scheme' => 'file', + 'url' => 'test', + ]; + $this->assertEquals($expected, TestLogStaticConfig::parseDsn($dsn)); + + $dsn = 'file:///?file=debug&key=value'; + $expected = [ + 'className' => FileLog::class, + 'file' => 'debug', + 'key' => 'value', + 'path' => '/', + 'scheme' => 'file', + ]; + $this->assertEquals($expected, TestLogStaticConfig::parseDsn($dsn)); + + $dsn = 'file:///tmp?file=debug&types[]=notice&types[]=info&types[]=debug'; + $expected = [ + 'className' => FileLog::class, + 'file' => 'debug', + 'path' => '/tmp', + 'scheme' => 'file', + 'types' => ['notice', 'info', 'debug'], + ]; + $this->assertEquals($expected, TestLogStaticConfig::parseDsn($dsn)); + + $dsn = 'mail:///?timeout=30&key=true&key2=false&client=null&tls=null'; + $expected = [ + 'className' => MailTransport::class, + 'client' => null, + 'key' => true, + 'key2' => false, + 'path' => '/', + 'scheme' => 'mail', + 'timeout' => '30', + 'tls' => null, + ]; + $this->assertEquals($expected, TestEmailStaticConfig::parseDsn($dsn)); + + $dsn = 'mail://true:false@null/1?timeout=30&key=true&key2=false&client=null&tls=null'; + $expected = [ + 'className' => MailTransport::class, + 'client' => null, + 'host' => 'null', + 'key' => true, + 'key2' => false, + 'password' => 'false', + 'path' => '/1', + 'scheme' => 'mail', + 'timeout' => '30', + 'tls' => null, + 'username' => 'true', + ]; + $this->assertEquals($expected, TestEmailStaticConfig::parseDsn($dsn)); + + $dsn = 'mail://user:secret@localhost:25?timeout=30&client=null&tls=null#fragment'; + $expected = [ + 'className' => MailTransport::class, + 'client' => null, + 'host' => 'localhost', + 'password' => 'secret', + 'port' => 25, + 'scheme' => 'mail', + 'timeout' => '30', + 'tls' => null, + 'username' => 'user', + 'fragment' => 'fragment', + ]; + $this->assertEquals($expected, TestEmailStaticConfig::parseDsn($dsn)); + + $dsn = 'mail://user:secret@192.168.0.1:25?timeout=30&client=null&tls=null#fragment'; + $expected = [ + 'className' => MailTransport::class, + 'client' => null, + 'host' => '192.168.0.1', + 'password' => 'secret', + 'port' => 25, + 'scheme' => 'mail', + 'timeout' => '30', + 'tls' => null, + 'username' => 'user', + 'fragment' => 'fragment', + ]; + $this->assertEquals($expected, TestEmailStaticConfig::parseDsn($dsn)); + + $dsn = 'mysql://user:secret@[2a00:1450:4002:416::200e]:3306?timeout=30&client=null&tls=null#fragment'; + $expected = [ + 'className' => 'mysql', + 'client' => null, + 'host' => '[2a00:1450:4002:416::200e]', + 'password' => 'secret', + 'port' => 3306, + 'scheme' => 'mysql', + 'timeout' => '30', + 'tls' => null, + 'username' => 'user', + 'fragment' => 'fragment', + ]; + $this->assertEquals($expected, TestEmailStaticConfig::parseDsn($dsn)); + + $dsn = 'file:///?prefix=myapp_cake_translations_&serialize=true&duration=%2B2 minutes'; + $expected = [ + 'className' => FileLog::class, + 'duration' => '+2 minutes', + 'path' => '/', + 'prefix' => 'myapp_cake_translations_', + 'scheme' => 'file', + 'serialize' => true, + ]; + $this->assertEquals($expected, TestLogStaticConfig::parseDsn($dsn)); + } + + /** + * Tests loading a single plugin + */ + public function testParseDsnPathSetting(): void + { + $dsn = 'file:///?path=/tmp/persistent/'; + $expected = [ + 'className' => FileLog::class, + 'path' => '/tmp/persistent/', + 'scheme' => 'file', + ]; + $this->assertEquals($expected, TestLogStaticConfig::parseDsn($dsn)); + } +} diff --git a/tests/TestCase/Database/ColumnSchemaAwareTypeTest.php b/tests/TestCase/Database/ColumnSchemaAwareTypeTest.php new file mode 100644 index 00000000000..51efd006aa6 --- /dev/null +++ b/tests/TestCase/Database/ColumnSchemaAwareTypeTest.php @@ -0,0 +1,69 @@ +connection = ConnectionManager::get('test'); + + TypeFactory::map('columnSchemaAwareType', ColumnSchemaAwareType::class); + } + + public function testColumnSql(): void + { + $dialect = $this->connection->getDriver()->schemaDialect(); + + $schema = new TableSchema('table', [ + 'field' => [ + 'type' => 'columnSchemaAwareType', + 'null' => false, + 'comment' => 'Lorem ipsum', + ], + ]); + $sql = $dialect->columnSql($schema, 'field'); + + if ($this->connection->getDriver() instanceof Mysql) { + $this->assertEqualsSql("field TEXT NOT NULL COMMENT 'Lorem ipsum (schema aware)'", $sql); + } else { + $this->assertEqualsSql('field TEXT NOT NULL', $sql); + } + } + + public function testColumnSqlForUnmappedType(): void + { + $map = TypeFactory::getMap(); + TypeFactory::clear(); + + $dialect = $this->connection->getDriver()->schemaDialect(); + + $schema = new TableSchema('table', [ + 'field' => [ + 'type' => 'time', + 'null' => false, + 'comment' => null, + ], + ]); + $sql = $dialect->columnSql($schema, 'field'); + + TypeFactory::setMap($map); + + $this->assertEqualsSql('field TIME NOT NULL', $sql); + } +} diff --git a/tests/TestCase/Database/ConnectionTest.php b/tests/TestCase/Database/ConnectionTest.php new file mode 100644 index 00000000000..da6e9abda00 --- /dev/null +++ b/tests/TestCase/Database/ConnectionTest.php @@ -0,0 +1,1289 @@ + + */ + protected array $fixtures = ['core.Things']; + + /** + * Where the NestedTransactionRollbackException was created. + * + * @var int + */ + protected $rollbackSourceLine = -1; + + /** + * Internal states of nested transaction. + * + * @var array + */ + protected $nestedTransactionStates = []; + + /** + * @var bool + */ + protected $logState; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var \Cake\Database\Log\QueryLogger + */ + protected $defaultLogger; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + + static::setAppNamespace(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->connection->disableSavePoints(); + + ConnectionManager::drop('test:read'); + ConnectionManager::dropAlias('test:read'); + Log::reset(); + unset($this->connection); + } + + /** + * Auxiliary method to build a mock for a driver so it can be injected into + * the connection object + * + * @return \Cake\Database\Driver + */ + protected function getDriver(): Driver + { + return new class extends Driver { + use BaseDriverTrait; + }; + } + + /** + * Tests creating a connection using no driver throws an exception + */ + public function testNoDriver(): void + { + $this->expectException(MissingDriverException::class); + $this->expectExceptionMessage('Could not find driver `` for connection ``.'); + new Connection([]); + } + + /** + * Tests creating a connection using an invalid driver throws an exception + */ + public function testEmptyDriver(): void + { + $this->expectException(Error::class); + new Connection(['driver' => false]); + } + + /** + * Tests creating a connection using an invalid driver throws an exception + */ + public function testMissingDriver(): void + { + $this->expectException(MissingDriverException::class); + $this->expectExceptionMessage('Could not find driver `\Foo\InvalidDriver` for connection ``.'); + new Connection(['driver' => '\Foo\InvalidDriver']); + } + + /** + * Tests trying to use a disabled driver throws an exception + */ + public function testDisabledDriver(): void + { + $this->expectException(MissingExtensionException::class); + $this->expectExceptionMessageMatches( + '/Database driver `.+` cannot be used due to a missing PHP extension or unmet dependency\. ' . + 'Requested by connection `custom_connection_name`/', + ); + $driver = new class extends StubDriver { + public function enabled(): bool + { + return false; + } + }; + new Connection(['driver' => $driver, 'name' => 'custom_connection_name']); + } + + /** + * Tests that the `driver` option supports the short classname/plugin syntax. + */ + public function testDriverOptionClassNameSupport(): void + { + $connection = new Connection(['driver' => 'TestDriver']); + $this->assertInstanceOf(TestDriver::class, $connection->getDriver()); + + $connection = new Connection(['driver' => 'TestPlugin.TestDriver']); + $this->assertInstanceOf(PluginTestDriver::class, $connection->getDriver()); + + [, $name] = namespaceSplit($this->connection->getDriver()::class); + $connection = new Connection(['driver' => $name]); + $this->assertInstanceOf($this->connection->getDriver()::class, $connection->getDriver()); + } + + /** + * Test providing a unique read config only creates separate drivers. + */ + public function testDifferentReadDriver(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ConnectionManager::getConfig('test') + ['read' => ['database' => 'read_test.db']]; + $connection = new Connection($config); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + $this->assertSame(Connection::ROLE_READ, $connection->getDriver(Connection::ROLE_READ)->getRole()); + $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole()); + } + + /** + * Test providing a unique write config only creates separate drivers. + */ + public function testDifferentWriteDriver(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ConnectionManager::getConfig('test') + ['write' => ['database' => 'read_test.db']]; + $connection = new Connection($config); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + $this->assertSame(Connection::ROLE_READ, $connection->getDriver(Connection::ROLE_READ)->getRole()); + $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole()); + } + + /** + * Tests that unique drivers are created if roles are specified even with same config + */ + public function testSameReadWriteConfig(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ConnectionManager::getConfig('test') + ['read' => ['database' => 'read_test.db'], 'write' => ['database' => 'read_test.db']]; + $connection = new Connection($config); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + $this->assertSame(Connection::ROLE_READ, $connection->getDriver(Connection::ROLE_READ)->getRole()); + $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole()); + } + + /** + * Tests that unique drivers are created if roles are specified even with empty config + */ + public function testEmptyReadWriteConfig(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ConnectionManager::getConfig('test') + ['read' => [], 'write' => []]; + $connection = new Connection($config); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + $this->assertSame(Connection::ROLE_READ, $connection->getDriver(Connection::ROLE_READ)->getRole()); + $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole()); + } + + /** + * Tests that unique drivers are created if roles are specified even with empty config + */ + public function testSingleEmptyReadWriteConfig(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ConnectionManager::getConfig('test') + ['read' => []]; + $connection = new Connection($config); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + $this->assertSame(Connection::ROLE_READ, $connection->getDriver(Connection::ROLE_READ)->getRole()); + $this->assertSame(Connection::ROLE_WRITE, $connection->getDriver(Connection::ROLE_WRITE)->getRole()); + } + + /** + * Test role-specific config values override defaults + */ + public function testRoleSpecificOverrides(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ConnectionManager::getConfig('test') + ['log' => true, 'write' => ['database' => 'read_test.db', 'log' => false]]; + $connection = new Connection($config); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + $this->assertNotNull($connection->getDriver(Connection::ROLE_READ)->getLogger()); + $this->assertNull($connection->getDriver(Connection::ROLE_WRITE)->getLogger()); + } + + public function testRole(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ConnectionManager::getConfig('test') + ['read' => [], 'write' => []]; + $connection = new Connection($config); + $this->assertEquals(Connection::ROLE_WRITE, $connection->role()); + + $config = ['name' => 'test:read'] + $config; + $connection = new Connection($config); + $this->assertEquals(Connection::ROLE_READ, $connection->role()); + } + + public function testDisabledReadWriteDriver(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $config = ['driver' => DisabledDriver::class] + ConnectionManager::getConfig('test'); + + $this->expectException(MissingExtensionException::class); + new Connection($config); + } + + /** + * Tests that connecting with invalid credentials or database name throws an exception + */ + public function testWrongCredentials(): void + { + $config = ConnectionManager::getConfig('test'); + $this->skipIf(isset($config['url']), 'Datasource has dsn, skipping.'); + $connection = new Connection(['database' => '/dev/nonexistent'] + ConnectionManager::getConfig('test')); + + $e = null; + try { + $connection->getDriver()->connect(); + } catch (MissingConnectionException $e) { + } + + $this->assertNotNull($e); + $this->assertStringStartsWith( + sprintf( + 'Connection to %s could not be established:', + App::shortName($connection->getDriver()::class, 'Database/Driver'), + ), + $e->getMessage(), + ); + $this->assertInstanceOf('PDOException', $e->getPrevious()); + } + + public function testConnectRetry(): void + { + $this->skipIf(!ConnectionManager::get('test')->getDriver() instanceof Sqlserver); + + $connection = new Connection(['driver' => 'RetryDriver']); + $this->assertInstanceOf(RetryDriver::class, $connection->getDriver()); + + try { + $connection->execute('SELECT 1'); + } catch (MissingConnectionException) { + $this->assertSame(4, $connection->getDriver()->getConnectRetries()); + } + } + + /** + * Tests executing a simple query using bound values + */ + public function testExecuteWithArguments(): void + { + $sql = 'SELECT 1 + ?'; + $statement = $this->connection->execute($sql, [1], ['integer']); + $result = $statement->fetchAll(); + $this->assertCount(1, $result); + $this->assertEquals([2], $result[0]); + $statement->closeCursor(); + + $sql = 'SELECT 1 + ? + ? AS total'; + $statement = $this->connection->execute($sql, [2, 3], ['integer', 'integer']); + $result = $statement->fetchAll('assoc'); + $statement->closeCursor(); + $this->assertCount(1, $result); + $this->assertEquals(['total' => 6], $result[0]); + + $sql = 'SELECT 1 + :one + :two AS total'; + $statement = $this->connection->execute($sql, ['one' => 2, 'two' => 3], ['one' => 'integer', 'two' => 'integer']); + $result = $statement->fetchAll('assoc'); + $statement->closeCursor(); + $this->assertCount(1, $result); + $this->assertEquals(['total' => 6], $result[0]); + } + + /** + * Tests executing a query with params and associated types + */ + public function testExecuteWithArgumentsAndTypes(): void + { + $sql = "SELECT '2012-01-01' = ?"; + $statement = $this->connection->execute($sql, [new DateTime('2012-01-01')], ['date']); + $result = $statement->fetch(); + $statement->closeCursor(); + $this->assertTrue((bool)$result[0]); + } + + /** + * Tests that passing a unknown value to a query throws an exception + */ + public function testExecuteWithMissingType(): void + { + $this->expectException(InvalidArgumentException::class); + $sql = 'SELECT ?'; + $this->connection->execute($sql, [new DateTime('2012-01-01')], ['bar']); + } + + /** + * Tests executing a query with no params also works + */ + public function testExecuteWithNoParams(): void + { + $sql = 'SELECT 1'; + $statement = $this->connection->execute($sql); + $result = $statement->fetch(); + $this->assertCount(1, $result); + $this->assertEquals([1], $result); + $statement->closeCursor(); + } + + /** + * Tests it is possible to insert data into a table using matching types by key name + */ + public function testInsertWithMatchingTypes(): void + { + $data = ['id' => '3', 'title' => 'a title', 'body' => 'a body']; + $result = $this->connection->insert( + 'things', + $data, + ['id' => 'integer', 'title' => 'string', 'body' => 'string'], + ); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + $result = $this->connection->execute('SELECT * from things where id = 3'); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $result->closeCursor(); + $this->assertEquals($data, $rows[0]); + } + + /** + * Tests insertQuery + */ + public function testInsertQuery(): void + { + $data = ['id' => '3', 'title' => 'a title', 'body' => 'a body']; + $query = $this->connection->insertQuery( + 'things', + $data, + ['id' => 'integer', 'title' => 'string', 'body' => 'string'], + ); + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + + $result = $this->connection->execute('SELECT * from things where id = 3'); + $row = $result->fetch('assoc'); + $result->closeCursor(); + $this->assertEquals($data, $row); + } + + /** + * Tests it is possible to insert data into a table using matching types by array position + */ + public function testInsertWithPositionalTypes(): void + { + $data = ['id' => '3', 'title' => 'a title', 'body' => 'a body']; + $result = $this->connection->insert( + 'things', + $data, + ['integer', 'string', 'string'], + ); + $result->closeCursor(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result = $this->connection->execute('SELECT * from things where id = 3'); + $rows = $result->fetchAll('assoc'); + $result->closeCursor(); + $this->assertCount(1, $rows); + $this->assertEquals($data, $rows[0]); + } + + /** + * Tests an statement class can be reused for multiple executions + */ + public function testStatementReusing(): void + { + $total = $this->connection->execute('SELECT COUNT(*) AS total FROM things'); + $result = $total->fetch('assoc'); + $this->assertEquals(2, $result['total']); + $total->closeCursor(); + + $total->execute(); + $result = $total->fetch('assoc'); + $this->assertEquals(2, $result['total']); + $total->closeCursor(); + + $result = $this->connection->execute('SELECT title, body FROM things'); + $row = $result->fetch('assoc'); + $this->assertSame('a title', $row['title']); + $this->assertSame('a body', $row['body']); + + $row = $result->fetch('assoc'); + $result->closeCursor(); + $this->assertSame('another title', $row['title']); + $this->assertSame('another body', $row['body']); + + $result->execute(); + $row = $result->fetch('assoc'); + $result->closeCursor(); + $this->assertSame('a title', $row['title']); + } + + /** + * Tests that it is possible to pass PDO constants to the underlying statement + * object for using alternate fetch types + */ + public function testStatementFetchObject(): void + { + $statement = $this->connection->execute('SELECT title, body FROM things'); + $row = $statement->fetch(PDO::FETCH_OBJ); + $this->assertSame('a title', $row->title); + $this->assertSame('a body', $row->body); + $statement->closeCursor(); + } + + /** + * Tests rows can be updated without specifying any conditions nor types + */ + public function testUpdateWithoutConditionsNorTypes(): void + { + $title = 'changed the title!'; + $body = 'changed the body!'; + $this->connection->update('things', ['title' => $title, 'body' => $body]); + $result = $this->connection->execute('SELECT * FROM things WHERE title = ? AND body = ?', [$title, $body]); + $this->assertCount(2, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests it is possible to use key => value conditions for update + */ + public function testUpdateWithConditionsNoTypes(): void + { + $title = 'changed the title!'; + $body = 'changed the body!'; + $this->connection->update('things', ['title' => $title, 'body' => $body], ['id' => 2]); + $result = $this->connection->execute('SELECT * FROM things WHERE title = ? AND body = ?', [$title, $body]); + $this->assertCount(1, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests it is possible to use key => value and string conditions for update + */ + public function testUpdateWithConditionsCombinedNoTypes(): void + { + $title = 'changed the title!'; + $body = 'changed the body!'; + $this->connection->update('things', ['title' => $title, 'body' => $body], ['id' => 2, 'body is not null']); + $result = $this->connection->execute('SELECT * FROM things WHERE title = ? AND body = ?', [$title, $body]); + $this->assertCount(1, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests you can bind types to update values + */ + public function testUpdateWithTypes(): void + { + $title = 'changed the title!'; + $body = new DateTime('2012-01-01'); + $values = compact('title', 'body'); + $this->connection->update('things', $values, [], ['body' => 'date']); + $result = $this->connection->execute('SELECT * FROM things WHERE title = :title AND body = :body', $values, ['body' => 'date']); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertSame('2012-01-01', $rows[0]['body']); + $this->assertSame('2012-01-01', $rows[1]['body']); + $result->closeCursor(); + } + + /** + * Tests you can bind types to update values + */ + public function testUpdateWithConditionsAndTypes(): void + { + $title = 'changed the title!'; + $body = new DateTime('2012-01-01'); + $values = compact('title', 'body'); + $this->connection->update('things', $values, ['id' => '1'], ['body' => 'date', 'id' => 'integer']); + $result = $this->connection->execute('SELECT * FROM things WHERE title = :title AND body = :body', $values, ['body' => 'date']); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertSame('2012-01-01', $rows[0]['body']); + $result->closeCursor(); + } + + /** + * Tests you can bind types to update values + */ + public function testUpdateQueryWithConditionsAndTypes(): void + { + $title = 'changed the title!'; + $body = new DateTime('2012-01-01'); + $values = compact('title', 'body'); + $query = $this->connection->updateQuery('things', $values, ['id' => '1'], ['body' => 'date', 'id' => 'integer']); + $query->execute()->closeCursor(); + + $result = $this->connection->execute('SELECT * FROM things WHERE title = :title AND body = :body', $values, ['body' => 'date']); + $row = $result->fetch('assoc'); + $this->assertSame('2012-01-01', $row['body']); + $result->closeCursor(); + } + + /** + * Tests delete from table with no conditions + */ + public function testDeleteNoConditions(): void + { + $this->connection->delete('things'); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests delete from table with conditions + */ + public function testDeleteWithConditions(): void + { + $this->connection->delete('things', ['id' => '1'], ['id' => 'integer']); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + $result->closeCursor(); + + $this->connection->delete('things', ['id' => '2'], ['id' => 'integer']); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests delete from table with conditions + */ + public function testDeleteQuery(): void + { + $query = $this->connection->deleteQuery('things', ['id' => '1'], ['id' => 'integer']); + $query->execute()->closeCursor(); + $result = $this->connection->execute('SELECT * FROM things'); + $result->closeCursor(); + + $query = $this->connection->deleteQuery('things')->where(['id' => 2], ['id' => 'integer']); + $query->execute()->closeCursor(); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Test basic selectQuery behavior + */ + public function testSelectQuery(): void + { + $query = $this->connection->selectQuery(['*'], 'things'); + $statement = $query->execute(); + $row = $statement->fetchAssoc(); + $statement->closeCursor(); + + $this->assertArrayHasKey('title', $row); + $this->assertArrayHasKey('body', $row); + } + + /** + * Tests that it is possible to use simple database transactions + */ + public function testSimpleTransactions(): void + { + $this->connection->begin(); + $this->assertTrue($this->connection->getDriver()->inTransaction()); + $this->connection->delete('things', ['id' => 1]); + $this->connection->rollback(); + $this->assertFalse($this->connection->getDriver()->inTransaction()); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(2, $result->fetchAll()); + $result->closeCursor(); + + $this->connection->begin(); + $this->assertTrue($this->connection->getDriver()->inTransaction()); + $this->connection->delete('things', ['id' => 1]); + $this->connection->commit(); + $this->assertFalse($this->connection->getDriver()->inTransaction()); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + } + + /** + * Tests that the destructor of Connection generates a warning log + * when transaction is not closed + */ + public function testDestructorWithUncommittedTransaction(): void + { + $driver = $this->getDriver(); + $connection = new Connection(['driver' => $driver]); + $connection->begin(); + $this->assertTrue($connection->inTransaction()); + + $logger = Mockery::mock(AbstractLogger::class); + $logger->shouldReceive('log') + ->once() + ->withArgs(function (...$args): bool { + return isset($args[0], $args[1]) + && $args[0] === 'warning' + && str_contains((string)$args[1], 'The connection is going to be closed'); + }); + + Log::setConfig('error', $logger); + + // Destroy the connection + unset($connection); + } + + /** + * Tests that it is possible to use virtualized nested transaction + * with early rollback algorithm + */ + public function testVirtualNestedTransaction(): void + { + //starting 3 virtual transaction + $this->connection->begin(); + $this->connection->begin(); + $this->connection->begin(); + $this->assertTrue($this->connection->getDriver()->inTransaction()); + + $this->connection->delete('things', ['id' => 1]); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + + $this->connection->commit(); + $this->assertTrue($this->connection->getDriver()->inTransaction()); + $this->connection->rollback(); + $this->assertFalse($this->connection->getDriver()->inTransaction()); + + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(2, $result->fetchAll()); + } + + /** + * Tests that it is possible to use virtualized nested transaction + * with early rollback algorithm + */ + public function testVirtualNestedTransaction2(): void + { + //starting 3 virtual transaction + $this->connection->begin(); + $this->connection->begin(); + $this->connection->begin(); + + $this->connection->delete('things', ['id' => 1]); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + $this->connection->rollback(); + + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(2, $result->fetchAll()); + } + + /** + * Tests that it is possible to use virtualized nested transaction + * with early rollback algorithm + */ + + public function testVirtualNestedTransaction3(): void + { + //starting 3 virtual transaction + $this->connection->begin(); + $this->connection->begin(); + $this->connection->begin(); + + $this->connection->delete('things', ['id' => 1]); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + $this->connection->commit(); + $this->connection->commit(); + $this->connection->commit(); + + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + } + + /** + * Tests that it is possible to real use nested transactions + */ + public function testSavePoints(): void + { + $this->connection->enableSavePoints(true); + $this->skipIf(!$this->connection->isSavePointsEnabled(), "Database driver doesn't support save points"); + + $this->connection->begin(); + $this->connection->delete('things', ['id' => 1]); + + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + + $this->connection->begin(); + $this->connection->delete('things', ['id' => 2]); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(0, $result->fetchAll()); + + $this->connection->rollback(); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + + $this->connection->rollback(); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(2, $result->fetchAll()); + } + + /** + * Tests that it is possible to real use nested transactions + */ + + public function testSavePoints2(): void + { + $this->connection->enableSavePoints(true); + $this->skipIf(!$this->connection->isSavePointsEnabled(), "Database driver doesn't support save points"); + + $this->connection->begin(); + $this->connection->delete('things', ['id' => 1]); + + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + + $this->connection->begin(); + $this->connection->delete('things', ['id' => 2]); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(0, $result->fetchAll()); + + $this->connection->rollback(); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + + $this->connection->commit(); + $result = $this->connection->execute('SELECT * FROM things'); + $this->assertCount(1, $result->fetchAll()); + } + + /** + * Tests inTransaction() + */ + public function testInTransaction(): void + { + $this->connection->begin(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->begin(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->commit(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->commit(); + $this->assertFalse($this->connection->inTransaction()); + + $this->assertFalse($this->connection->commit()); + $this->assertFalse($this->connection->inTransaction()); + + $this->connection->begin(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->begin(); + $this->connection->rollback(); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests inTransaction() with save points + */ + public function testInTransactionWithSavePoints(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Sqlserver, + 'SQLServer fails when this test is included.', + ); + $this->connection->enableSavePoints(false); + $this->assertFalse($this->connection->isSavePointsEnabled()); + + $this->connection->enableSavePoints(true); + $this->skipIf(!$this->connection->isSavePointsEnabled(), "Database driver doesn't support save points"); + $this->assertTrue($this->connection->isSavePointsEnabled()); + + $this->connection->begin(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->begin(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->commit(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->commit(); + $this->assertFalse($this->connection->inTransaction()); + + $this->connection->begin(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->begin(); + $this->connection->rollback(); + $this->assertTrue($this->connection->inTransaction()); + + $this->connection->rollback(); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests setting and getting the cacher object + */ + public function testGetAndSetCacher(): void + { + $cacher = new NullEngine(); + $this->connection->setCacher($cacher); + $this->assertSame($cacher, $this->connection->getCacher()); + } + + /** + * Tests that the transactional method will start and commit a transaction + * around some arbitrary function passed as argument + */ + public function testTransactionalSuccess(): void + { + $driver = $this->getDriver(); + $connection = new class (['driver' => $driver]) extends Connection { + public bool $beginIsCalled = false; + public bool $commitIsCalled = false; + + public function begin(): void + { + $this->beginIsCalled = true; + } + + public function commit(): bool + { + $this->commitIsCalled = true; + + return true; + } + }; + $result = $connection->transactional(function ($conn) use ($connection) { + $this->assertSame($connection, $conn); + + return 'thing'; + }); + $this->assertSame('thing', $result); + $this->assertTrue($connection->beginIsCalled); + $this->assertTrue($connection->commitIsCalled); + } + + /** + * Tests that the transactional method will rollback the transaction if false + * is returned from the callback + */ + public function testTransactionalFail(): void + { + $driver = $this->getDriver(); + $connection = new class (['driver' => $driver]) extends Connection { + public bool $beginIsCalled = false; + public bool $rollbackIsCalled = false; + + public function commit(): bool + { + throw new Exception('Should not be called'); + } + + public function begin(): void + { + $this->beginIsCalled = true; + } + + public function rollback(?bool $toBeginning = null): bool + { + $this->rollbackIsCalled = true; + + return true; + } + }; + $result = $connection->transactional(function ($conn) use ($connection) { + $this->assertSame($connection, $conn); + + return false; + }); + $this->assertFalse($result); + $this->assertTrue($connection->beginIsCalled); + $this->assertTrue($connection->rollbackIsCalled); + } + + /** + * Tests that the transactional method will rollback the transaction + * and throw the same exception if the callback raises one + * + * @throws \InvalidArgumentException + */ + public function testTransactionalWithException(): void + { + $this->expectException(InvalidArgumentException::class); + $driver = $this->getDriver(); + $connection = new class (['driver' => $driver]) extends Connection { + public bool $beginIsCalled = false; + public bool $rollbackIsCalled = false; + + public function commit(): bool + { + throw new Exception('Should not be called'); + } + + public function begin(): void + { + $this->beginIsCalled = true; + } + + public function rollback(?bool $toBeginning = null): bool + { + $this->rollbackIsCalled = true; + + return true; + } + }; + $connection->transactional(function ($conn) use ($connection): void { + $this->assertSame($connection, $conn); + throw new InvalidArgumentException(); + }); + $this->assertTrue($connection->beginIsCalled); + $this->assertTrue($connection->rollbackIsCalled); + } + + /** + * Tests it is possible to set a schema collection object + */ + public function testSetSchemaCollection(): void + { + $driver = $this->getDriver(); + $connection = new Connection(['driver' => $driver]); + + $schema = $connection->getSchemaCollection(); + $this->assertInstanceOf(Collection::class, $schema); + + $schema = new class ($connection) extends Collection { + }; + $connection->setSchemaCollection($schema); + $this->assertSame($schema, $connection->getSchemaCollection()); + } + + /** + * Test CachedCollection creation with default and custom cache key prefix. + */ + public function testGetCachedCollection(): void + { + $driver = $this->getDriver(); + + $connection = new Connection([ + 'driver' => $driver, + 'name' => 'default', + 'cacheMetadata' => true, + ]); + + $schema = $connection->getSchemaCollection(); + $this->assertInstanceOf(CachedCollection::class, $schema); + $this->assertSame('default_key', $schema->cacheKey('key')); + + $driver = $this->getDriver(); + $connection = new Connection([ + 'driver' => $driver, + 'name' => 'default', + 'cacheMetadata' => true, + 'cacheKeyPrefix' => 'foo', + ]); + + $schema = $connection->getSchemaCollection(); + $this->assertInstanceOf(CachedCollection::class, $schema); + $this->assertSame('foo_key', $schema->cacheKey('key')); + + // Ensure that the connection was not initialized + $this->assertFalse($connection->getDriver()->__debugInfo()['connected']); + } + + /** + * Tests that allowed nesting of commit/rollback operations doesn't + * throw any exceptions. + */ + public function testNestedTransactionRollbackExceptionNotThrown(): void + { + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return true; + }); + + return true; + }); + $this->assertFalse($this->connection->inTransaction()); + + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return true; + }); + + return false; + }); + $this->assertFalse($this->connection->inTransaction()); + + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return false; + }); + + return false; + }); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests that not allowed nesting of commit/rollback operations throws + * a NestedTransactionRollbackException. + */ + public function testNestedTransactionRollbackExceptionThrown(): void + { + $this->rollbackSourceLine = -1; + + $e = null; + try { + $this->connection->transactional(function () { + $this->connection->transactional(function () { + return false; + }); + $this->rollbackSourceLine = __LINE__ - 1; + if (PHP_VERSION_ID >= 80200) { + $this->rollbackSourceLine -= 2; + } + + return true; + }); + + $this->fail('NestedTransactionRollbackException should be thrown'); + } catch (NestedTransactionRollbackException $e) { + } + + $trace = $e->getTrace(); + $this->assertEquals(__FILE__, $trace[1]['file']); + $this->assertEquals($this->rollbackSourceLine, $trace[1]['line']); + } + + /** + * Tests more detail about that not allowed nesting of rollback/commit + * operations throws a NestedTransactionRollbackException. + */ + public function testNestedTransactionStates(): void + { + $this->rollbackSourceLine = -1; + $this->nestedTransactionStates = []; + + $e = null; + try { + $this->connection->transactional(function () { + $this->pushNestedTransactionState(); + + $this->connection->transactional(function () { + return true; + }); + + $this->connection->transactional(function () { + $this->pushNestedTransactionState(); + + $this->connection->transactional(function () { + return false; + }); + $this->rollbackSourceLine = __LINE__ - 1; + if (PHP_VERSION_ID >= 80200) { + $this->rollbackSourceLine -= 2; + } + + $this->pushNestedTransactionState(); + + return true; + }); + + $this->connection->transactional(function () { + return false; + }); + + $this->pushNestedTransactionState(); + + return true; + }); + + $this->fail('NestedTransactionRollbackException should be thrown'); + } catch (NestedTransactionRollbackException $e) { + } + + $this->pushNestedTransactionState(); + + $this->assertSame([false, false, true, true, false], $this->nestedTransactionStates); + $this->assertFalse($this->connection->inTransaction()); + + $trace = $e->getTrace(); + $this->assertEquals(__FILE__, $trace[1]['file']); + $this->assertEquals($this->rollbackSourceLine, $trace[1]['line']); + } + + /** + * Helper method to trace nested transaction states. + */ + public function pushNestedTransactionState(): void + { + $method = new ReflectionMethod($this->connection, 'wasNestedTransactionRolledback'); + $this->nestedTransactionStates[] = $method->invoke($this->connection); + } + + /** + * Tests that the connection is restablished whenever it is interrupted + * after having used the connection at least once. + */ + public function testAutomaticReconnect2(): void + { + $conn = clone $this->connection; + $statement = $conn->execute('SELECT 1'); + $statement->execute(); + $statement->closeCursor(); + + $newDriver = $this->getMockBuilder(Driver::class)->getMock(); + $prop = new ReflectionProperty($conn, 'readDriver'); + $prop->setValue($conn, $newDriver); + $prop = new ReflectionProperty($conn, 'writeDriver'); + $prop->setValue($conn, $newDriver); + + $newDriver->expects($this->exactly(2)) + ->method('execute') + ->willReturnOnConsecutiveCalls( + $this->throwException(new Exception('server gone away')), + $statement, + ); + + $res = $conn->execute('SELECT 1'); + $this->assertInstanceOf(StatementInterface::class, $res); + } + + /** + * Tests that the connection is not restablished whenever it is interrupted + * inside a transaction. + */ + public function testNoAutomaticReconnect(): void + { + $conn = clone $this->connection; + $statement = $conn->execute('SELECT 1'); + $statement->execute(); + $statement->closeCursor(); + + $conn->begin(); + + $newDriver = $this->getMockBuilder(Driver::class)->getMock(); + $prop = new ReflectionProperty($conn, 'readDriver'); + $prop->setValue($conn, $newDriver); + $prop = new ReflectionProperty($conn, 'writeDriver'); + $oldDriver = $prop->getValue($conn); + $prop->setValue($conn, $newDriver); + + $newDriver->expects($this->once()) + ->method('execute') + ->will($this->throwException(new Exception('server gone away'))); + + try { + $conn->execute('SELECT 1'); + } catch (Exception $e) { + $this->assertInstanceOf(Exception::class, $e); + $prop->setValue($conn, $oldDriver); + $conn->rollback(); + } + } + + public function testRunAndStatementIteration(): void + { + $query = new SelectQuery($this->connection); + $query->select(fields: ['field' => $query->expr('1')]); + + $statement = $this->connection->run($query); + foreach ($statement as $row) { + $this->assertEquals(['field' => 1], $row); + } + } + + /** + * Tests that role-specific configs correctly inherit from the shared config. + * + * @return void + */ + public function testRoleConfigInheritance(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + + $config = [ + 'driver' => 'Cake\Database\Driver\Sqlite', + 'database' => ':memory:', + 'username' => 'default-user', + 'password' => 'default-pass', + 'read' => [ + 'username' => 'read-user', + ], + 'write' => [ + 'database' => 'write.db', + ], + ]; + + $connection = new Connection($config); + + // read test + $readDriver = $connection->getDriver('read'); + $this->assertSame('read-user', $readDriver->config()['username'], 'Read username should be overridden.'); + $this->assertSame(':memory:', $readDriver->config()['database'], 'Read database should be inherited.'); + $this->assertSame('default-pass', $readDriver->config()['password'], 'Read password should be inherited.'); + + // write test + $writeDriver = $connection->getDriver('write'); + $this->assertSame('write.db', $writeDriver->config()['database'], 'Write database should be overridden.'); + $this->assertSame('default-user', $writeDriver->config()['username'], 'Write username should be inherited.'); + } +} diff --git a/tests/TestCase/Database/Driver/BaseDriverTrait.php b/tests/TestCase/Database/Driver/BaseDriverTrait.php new file mode 100644 index 00000000000..6f1db9afcc7 --- /dev/null +++ b/tests/TestCase/Database/Driver/BaseDriverTrait.php @@ -0,0 +1,57 @@ +pdo = Mockery::mock('PDO') + ->shouldReceive('inTransaction', 'beginTransaction') + ->getMock(); + } + + public function enabled(): bool + { + return true; + } + + public function disableForeignKeySQL(): string + { + return ''; + } + + public function enableForeignKeySQL(): string + { + return ''; + } + + public function schemaDialect(): SchemaDialect + { + return new SqliteSchemaDialect($this); + } + + public function supports(DriverFeatureEnum $feature): bool + { + return true; + } +} diff --git a/tests/TestCase/Database/Driver/MysqlTest.php b/tests/TestCase/Database/Driver/MysqlTest.php new file mode 100644 index 00000000000..0b4e094b14a --- /dev/null +++ b/tests/TestCase/Database/Driver/MysqlTest.php @@ -0,0 +1,379 @@ +skipIf(!str_contains($config['driver'], 'Mysql'), 'Not using Mysql for test config'); + } + + /** + * Test connecting to MySQL with default configuration + */ + public function testConnectionConfigDefault(): void + { + $driver = Mockery::mock(Mysql::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + $dsn = 'mysql:host=localhost;port=3306;dbname=cake;charset=utf8mb4'; + $expected = [ + 'persistent' => true, + 'host' => 'localhost', + 'username' => 'root', + 'password' => '', + 'database' => 'cake', + 'port' => '3306', + 'flags' => [], + 'encoding' => 'utf8mb4', + 'timezone' => null, + 'init' => [], + 'log' => false, + ]; + + $usebufferedQueryId = PHP_VERSION_ID < 80400 ? PDO::MYSQL_ATTR_USE_BUFFERED_QUERY : PdoMysql::ATTR_USE_BUFFERED_QUERY; + $expected['flags'] += [ + PDO::ATTR_PERSISTENT => true, + $usebufferedQueryId => true, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]; + + $driver->shouldReceive('createPdo') + ->with($dsn, $expected) + ->once() + ->andReturn(Mockery::mock(PDO::class)); + + $driver->connect(); + } + + /** + * Test connecting to MySQL with custom configuration + */ + public function testConnectionConfigCustom(): void + { + $initCommandId = PHP_VERSION_ID < 80400 ? PDO::MYSQL_ATTR_INIT_COMMAND : PdoMysql::ATTR_INIT_COMMAND; + $usebufferedQueryId = PHP_VERSION_ID < 80400 ? PDO::MYSQL_ATTR_USE_BUFFERED_QUERY : PdoMysql::ATTR_USE_BUFFERED_QUERY; + $config = [ + 'persistent' => false, + 'host' => 'foo', + 'database' => 'bar', + 'username' => 'user', + 'password' => 'pass', + 'port' => 3440, + 'flags' => [$initCommandId => 'SET NAMES utf8'], + 'encoding' => null, + 'timezone' => 'Antarctica', + 'init' => [ + 'Execute this', + 'this too', + ], + 'log' => false, + ]; + $driver = Mockery::mock(Mysql::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct($config); + $dsn = 'mysql:host=foo;port=3440;dbname=bar'; + $expected = $config; + $expected['init'][] = "SET time_zone = 'Antarctica'"; + $expected['flags'] += [ + $initCommandId => 'SET NAMES utf8', + PDO::ATTR_PERSISTENT => false, + $usebufferedQueryId => true, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]; + + $connection = Mockery::mock('PDO'); + $connection->shouldReceive('exec')->with('Execute this')->once(); + $connection->shouldReceive('exec')->with('this too')->once(); + $connection->shouldReceive('exec')->with("SET time_zone = 'Antarctica'")->once(); + + $driver->shouldReceive('createPdo') + ->with($dsn, $expected) + ->once() + ->andReturn($connection); + $driver->connect(); + } + + /** + * Test schema + */ + public function testSchema(): void + { + $connection = ConnectionManager::get('test'); + $config = ConnectionManager::getConfig('test'); + $this->assertEquals($config['database'], $connection->getDriver()->schema()); + } + + /** + * Test isConnected + */ + public function testIsConnected(): void + { + $connection = ConnectionManager::get('test'); + $connection->getDriver()->disconnect(); + $this->assertFalse($connection->getDriver()->isConnected(), 'Not connected now.'); + + $connection->getDriver()->connect(); + $this->assertTrue($connection->getDriver()->isConnected(), 'Should be connected.'); + } + + public function testRollbackTransactionAutoConnect(): void + { + $connection = ConnectionManager::get('test'); + $connection->getDriver()->disconnect(); + + $driver = $connection->getDriver(); + $this->assertFalse($driver->rollbackTransaction()); + $this->assertTrue($driver->isConnected()); + } + + public function testCommitTransactionAutoConnect(): void + { + $connection = ConnectionManager::get('test'); + $driver = $connection->getDriver(); + + $this->assertFalse($driver->commitTransaction()); + $this->assertTrue($driver->isConnected()); + } + + /** + * @param string $dbVersion + * @param string $expectedVersion + */ + #[DataProvider('versionStringProvider')] + public function testVersion($dbVersion, $expectedVersion): void + { + $connection = Mockery::mock(PDO::class); + $connection->shouldReceive('getAttribute') + ->with(PDO::ATTR_SERVER_VERSION) + ->once() + ->andReturn($dbVersion); + + $driver = Mockery::mock(Mysql::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + + $driver->shouldReceive('createPdo') + ->once() + ->andReturn($connection); + + $result = $driver->version(); + $this->assertSame($expectedVersion, $result); + } + + public static function versionStringProvider(): array + { + return [ + ['10.2.23-MariaDB', '10.2.23-MariaDB'], + ['5.5.5-10.2.23-MariaDB', '10.2.23-MariaDB'], + ['5.5.5-10.4.13-MariaDB-1:10.4.13+maria~focal', '10.4.13-MariaDB-1'], + ['8.0.0', '8.0.0'], + ]; + } + + /** + * Tests driver-specific feature support check. + */ + public function testSupports(): void + { + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf(!$driver instanceof Mysql); + + $serverType = $driver->isMariadb() ? 'mariadb' : 'mysql'; + $featureVersions = [ + 'mysql' => [ + 'json' => '5.7.0', + 'cte' => '8.0.0', + 'window' => '8.0.0', + 'intersect' => '8.0.31', + 'intersect-all' => '8.0.31', + ], + 'mariadb' => [ + 'json' => '10.2.7', + 'cte' => '10.2.1', + 'window' => '10.2.0', + 'intersect' => '10.3.0', + 'intersect-all' => '10.5.0', + ], + ]; + foreach ($featureVersions[$serverType] as $feature => $version) { + $this->assertSame( + version_compare($driver->version(), $version, '>='), + $driver->supports(DriverFeatureEnum::from($feature)), + ); + } + + $this->assertTrue($driver->supports(DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION)); + $this->assertTrue($driver->supports(DriverFeatureEnum::SAVEPOINT)); + + $this->assertFalse($driver->supports(DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS)); + } + + /** + * Tests identifier quoting + */ + public function testQuoteIdentifier(): void + { + $driver = new Mysql(); + + $result = $driver->quoteIdentifier('name'); + $expected = '`name`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.*'); + $expected = '`Model`.*'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.No_ 2'); + $expected = '`Items`.`No_ 2`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.No_ 2 thing'); + $expected = '`Items`.`No_ 2 thing`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.No_ 2 thing AS thing'); + $expected = '`Items`.`No_ 2 thing` AS `thing`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.Item Category Code = :c1'); + $expected = '`Items`.`Item Category Code` = :c1'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('MTD()'); + $expected = 'MTD()'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('(sm)'); + $expected = '(sm)'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('name AS x'); + $expected = '`name` AS `x`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.name AS x'); + $expected = '`Model`.`name` AS `x`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Function(Something.foo)'); + $expected = 'Function(`Something`.`foo`)'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Function(SubFunction(Something.foo))'); + $expected = 'Function(SubFunction(`Something`.`foo`))'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Function(Something.foo) AS x'); + $expected = 'Function(`Something`.`foo`) AS `x`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('name-with-minus'); + $expected = '`name-with-minus`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('my-name'); + $expected = '`my-name`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Foo-Model.*'); + $expected = '`Foo-Model`.*'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Team.P%'); + $expected = '`Team`.`P%`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Team.G/G'); + $expected = '`Team`.`G/G`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.name as y'); + $expected = '`Model`.`name` AS `y`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('nämé'); + $expected = '`nämé`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('aßa.nämé'); + $expected = '`aßa`.`nämé`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('aßa.*'); + $expected = '`aßa`.*'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Modeß.nämé as y'); + $expected = '`Modeß`.`nämé` AS `y`'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.näme Datum as y'); + $expected = '`Model`.`näme Datum` AS `y`'; + $this->assertEquals($expected, $result); + } + + /** + * Tests value quoting + */ + public function testQuote(): void + { + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf(!$driver instanceof Mysql); + + $result = $driver->quote('name'); + $expected = "'name'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote('Model.*'); + $expected = "'Model.*'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O'hare"); + $expected = "'O\\'hare'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O''hare"); + $expected = "'O\\'\\'hare'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O\slash"); + $expected = "'O\\\\slash'"; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Database/Driver/PostgresTest.php b/tests/TestCase/Database/Driver/PostgresTest.php new file mode 100644 index 00000000000..47a2465caed --- /dev/null +++ b/tests/TestCase/Database/Driver/PostgresTest.php @@ -0,0 +1,273 @@ +makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + $dsn = 'pgsql:host=localhost;port=5432;dbname=cake'; + $expected = [ + 'persistent' => true, + 'host' => 'localhost', + 'username' => 'root', + 'password' => '', + 'database' => 'cake', + 'schema' => 'public', + 'port' => 5432, + 'encoding' => 'utf8', + 'timezone' => null, + 'flags' => [], + 'init' => [], + 'log' => false, + 'ssl_key' => null, + 'ssl_cert' => null, + 'ssl_ca' => null, + 'ssl' => false, + 'ssl_mode' => null, + ]; + + $expected['flags'] += [ + PDO::ATTR_PERSISTENT => true, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]; + + $connection = Mockery::mock(PDO::class); + + $connection->shouldReceive('quote') + ->andReturnArg(0) + ->twice(); + + $connection->shouldReceive('exec')->with('SET NAMES utf8')->once(); + $connection->shouldReceive('exec')->with('SET search_path TO public')->once(); + + $driver->shouldReceive('createPdo') + ->with($dsn, $expected) + ->once() + ->andReturn($connection); + + $driver->connect(); + } + + /** + * Test connecting to Postgres with custom configuration + */ + public function testConnectionConfigCustom(): void + { + $config = [ + 'persistent' => false, + 'host' => 'foo', + 'database' => 'bar', + 'username' => 'user', + 'password' => 'pass', + 'port' => 3440, + 'flags' => [1 => true, 2 => false], + 'encoding' => 'a-language', + 'timezone' => 'Antarctica', + 'schema' => 'fooblic', + 'init' => ['Execute this', 'this too'], + 'log' => false, + 'ssl_key' => '/path/to/key', + 'ssl_cert' => '/path/to/crt', + 'ssl_ca' => '/path/to/ca', + 'ssl' => true, + 'ssl_mode' => 'verify-ca', + ]; + $driver = Mockery::mock(Postgres::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct($config); + $dsn = 'pgsql:host=foo;port=3440;dbname=bar;sslmode=verify-ca;sslkey=/path/to/key;sslcert=/path/to/crt;sslrootcert=/path/to/ca'; + + $expected = $config; + $expected['flags'] += [ + PDO::ATTR_PERSISTENT => false, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]; + + $connection = Mockery::mock(PDO::class); + + $connection->shouldReceive('quote') + ->andReturnArg(0) + ->times(3); + + $connection->shouldReceive('exec')->with('SET NAMES a-language')->once(); + $connection->shouldReceive('exec')->with('SET search_path TO fooblic')->once(); + $connection->shouldReceive('exec')->with('Execute this')->once(); + $connection->shouldReceive('exec')->with('this too')->once(); + $connection->shouldReceive('exec')->with('SET timezone = Antarctica')->once(); + + $driver->shouldReceive('createPdo') + ->with($dsn, $expected) + ->once() + ->andReturn($connection); + + $driver->connect(); + } + + /** + * Tests that insert queries get a "RETURNING *" string at the end + */ + public function testInsertReturning(): void + { + $driver = Mockery::mock(Postgres::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $query = $connection->insertQuery('articles', ['id' => 1, 'title' => 'foo']); + $this->assertStringEndsWith(' RETURNING *', $query->sql()); + + $query = $connection->insertQuery('articles', ['id' => 1, 'title' => 'foo']); + $query->epilog('FOO'); + $this->assertStringEndsWith(' FOO', $query->sql()); + } + + /** + * Test that having queries replace the aggregated alias field. + */ + public function testHavingReplacesAlias(): void + { + $driver = Mockery::mock(Postgres::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + $driver->shouldReceive('version')->andReturn('16.0'); + + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $query = new SelectQuery($connection); + $query + ->select([ + 'posts.author_id', + 'post_count' => $query->func()->count('posts.id'), + ]) + ->groupBy(['posts.author_id']) + ->having([$query->expr()->gte('post_count', 2, 'integer')]); + + $expected = 'SELECT posts.author_id, (COUNT(posts.id)) AS "post_count" ' . + 'GROUP BY posts.author_id HAVING COUNT(posts.id) >= :c0'; + $this->assertSame($expected, $query->sql()); + } + + /** + * Test that having queries replaces nothing if no alias is used. + */ + public function testHavingWhenNoAliasIsUsed(): void + { + $driver = Mockery::mock(Postgres::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + $driver->shouldReceive('version')->andReturn('16.0'); + + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $query = new SelectQuery($connection); + $query + ->select([ + 'posts.author_id', + 'post_count' => $query->func()->count('posts.id'), + ]) + ->groupBy(['posts.author_id']) + ->having([$query->expr()->gte('posts.author_id', 2, 'integer')]); + + $expected = 'SELECT posts.author_id, (COUNT(posts.id)) AS "post_count" ' . + 'GROUP BY posts.author_id HAVING posts.author_id >= :c0'; + $this->assertSame($expected, $query->sql()); + } + + /** + * Tests driver-specific feature support check. + */ + public function testSupports(): void + { + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf(!$driver instanceof Postgres); + + $this->assertTrue($driver->supports(DriverFeatureEnum::CTE)); + $this->assertTrue($driver->supports(DriverFeatureEnum::JSON)); + $this->assertTrue($driver->supports(DriverFeatureEnum::SAVEPOINT)); + $this->assertTrue($driver->supports(DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS)); + $this->assertTrue($driver->supports(DriverFeatureEnum::WINDOW)); + $this->assertTrue($driver->supports(DriverFeatureEnum::INTERSECT)); + $this->assertTrue($driver->supports(DriverFeatureEnum::INTERSECT_ALL)); + $this->assertTrue($driver->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY)); + + $this->assertFalse($driver->supports(DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION)); + } + + /** + * Tests value quoting + */ + public function testQuote(): void + { + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf(!$driver instanceof Postgres); + + $result = $driver->quote('name'); + $expected = "'name'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote('Model.*'); + $expected = "'Model.*'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O'hare"); + $expected = "'O''hare'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O''hare"); + $expected = "'O''''hare'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O\slash"); + $expected = "'O\slash'"; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Database/Driver/SqliteTest.php b/tests/TestCase/Database/Driver/SqliteTest.php new file mode 100644 index 00000000000..146dac7252e --- /dev/null +++ b/tests/TestCase/Database/Driver/SqliteTest.php @@ -0,0 +1,380 @@ +makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + $dsn = 'sqlite::memory:'; + $expected = [ + 'persistent' => false, + 'database' => ':memory:', + 'encoding' => 'utf8', + 'cache' => null, + 'mode' => null, + 'username' => null, + 'password' => null, + 'flags' => [], + 'init' => [], + 'mask' => 420, + 'log' => false, + ]; + + $expected['flags'] += [ + PDO::ATTR_PERSISTENT => false, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]; + $driver->shouldReceive('createPdo') + ->with($dsn, $expected) + ->once() + ->andReturn(Mockery::mock(PDO::class)); + $driver->connect(); + } + + /** + * Test connecting to Sqlite with custom configuration + */ + public function testConnectionConfigCustom(): void + { + $config = [ + 'persistent' => true, + 'host' => 'foo', + 'database' => 'bar.db', + 'flags' => [1 => true, 2 => false], + 'encoding' => 'a-language', + 'init' => ['Execute this', 'this too'], + 'mask' => 0666, + ]; + $driver = Mockery::mock(Sqlite::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct($config); + $dsn = 'sqlite:bar.db'; + + $expected = $config; + $expected += ['username' => null, 'password' => null, 'cache' => null, 'mode' => null, 'log' => false]; + $expected['flags'] += [ + PDO::ATTR_PERSISTENT => true, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]; + + $connection = Mockery::mock('PDO'); + $connection->shouldReceive('exec')->with('Execute this')->once(); + $connection->shouldReceive('exec')->with('this too')->once(); + + $driver->shouldReceive('createPdo') + ->with($dsn, $expected) + ->once() + ->andReturn($connection); + + $driver->connect(); + } + + /** + * Tests creating multiple connections to same db. + */ + public function testConnectionSharedCached(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + ConnectionManager::setConfig('test_shared_cache', [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => ':memory:', + 'cache' => 'shared', + ]); + + $connection = ConnectionManager::get('test_shared_cache'); + $this->assertSame([], $connection->getSchemaCollection()->listTables()); + + $connection->execute('CREATE TABLE test (test int);'); + $this->assertSame(['test'], $connection->getSchemaCollection()->listTables()); + + ConnectionManager::setConfig('test_shared_cache2', [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => ':memory:', + 'cache' => 'shared', + ]); + $connection = ConnectionManager::get('test_shared_cache2'); + $this->assertSame(['test'], $connection->getSchemaCollection()->listTables()); + $this->assertFileDoesNotExist('file::memory:?cache=shared'); + } + + /** + * Data provider for schemaValue() + * + * @return array + */ + public static function schemaValueProvider(): array + { + return [ + [null, 'NULL'], + [false, 'FALSE'], + [true, 'TRUE'], + [3.14159, '3.14159'], + ['33', '33'], + [66, 66], + [0, 0], + [10e5, '1000000'], + ['farts', '"farts"'], + ]; + } + + /** + * Test the schemaValue method on Driver. + * + * @param mixed $input + * @param mixed $expected + */ + #[DataProvider('schemaValueProvider')] + public function testSchemaValue($input, $expected): void + { + $mock = Mockery::mock(PDO::class) + ->shouldAllowMockingMethod('quoteIdentifier') + ->makePartial(); + $mock->shouldReceive('quote') + ->andReturnUsing(function ($value) { + return '"' . $value . '"'; + }); + + $driver = Mockery::mock(Sqlite::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + + $driver->shouldReceive('createPdo') + ->andReturn($mock); + + $this->assertEquals($expected, $driver->schemaValue($input)); + } + + /** + * Tests driver-specific feature support check. + */ + public function testSupports(): void + { + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf(!$driver instanceof Sqlite); + + $featureVersions = [ + 'cte' => '3.8.3', + 'window' => '3.28.0', + ]; + foreach ($featureVersions as $feature => $version) { + $this->assertSame( + version_compare($driver->version(), $version, '>='), + $driver->supports(DriverFeatureEnum::from($feature)), + ); + } + + $this->assertTrue($driver->supports(DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION)); + $this->assertTrue($driver->supports(DriverFeatureEnum::SAVEPOINT)); + $this->assertTrue($driver->supports(DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS)); + $this->assertTrue($driver->supports(DriverFeatureEnum::INTERSECT)); + + $this->assertFalse($driver->supports(DriverFeatureEnum::INTERSECT_ALL)); + $this->assertFalse($driver->supports(DriverFeatureEnum::JSON)); + $this->assertFalse($driver->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY)); + } + + /** + * Test of Inconsistency for JSON type field between mysql and sqlite + * + * @return void + */ + + public function testJSON(): void + { + Configure::write('ORM.mapJsonTypeForSqlite', true); + $connection = ConnectionManager::get('test'); + $this->skipIf(!($connection->getDriver() instanceof Sqlite)); + assert($connection instanceof Connection); + + $connection->execute('CREATE TABLE json_test (id INTEGER PRIMARY KEY, data JSON_TEXT);'); + $table = $this->getTableLocator()->get('json_test'); + + $data = ['foo' => 'bar', 'baz' => 1, 'qux' => ['a', 'b', 'c' => true]]; + $entity = $table->newEntity(['data' => $data]); + $table->save($entity); + $result = $table->find()->first(); + $this->assertEquals($data, $result->data); + Configure::write('ORM.mapJsonTypeForSqlite', false); + } + + /** + * Tests identifier quoting + */ + public function testQuoteIdentifier(): void + { + $driver = new Sqlite(); + + $result = $driver->quoteIdentifier('name'); + $expected = '"name"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.*'); + $expected = '"Model".*'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.No_ 2'); + $expected = '"Items"."No_ 2"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.No_ 2 thing'); + $expected = '"Items"."No_ 2 thing"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.No_ 2 thing AS thing'); + $expected = '"Items"."No_ 2 thing" AS "thing"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Items.Item Category Code = :c1'); + $expected = '"Items"."Item Category Code" = :c1'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('MTD()'); + $expected = 'MTD()'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('(sm)'); + $expected = '(sm)'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('name AS x'); + $expected = '"name" AS "x"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.name AS x'); + $expected = '"Model"."name" AS "x"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Function(Something.foo)'); + $expected = 'Function("Something"."foo")'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Function(SubFunction(Something.foo))'); + $expected = 'Function(SubFunction("Something"."foo"))'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Function(Something.foo) AS x'); + $expected = 'Function("Something"."foo") AS "x"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('name-with-minus'); + $expected = '"name-with-minus"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('my-name'); + $expected = '"my-name"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Foo-Model.*'); + $expected = '"Foo-Model".*'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Team.P%'); + $expected = '"Team"."P%"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Team.G/G'); + $expected = '"Team"."G/G"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.name as y'); + $expected = '"Model"."name" AS "y"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('nämé'); + $expected = '"nämé"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('aßa.nämé'); + $expected = '"aßa"."nämé"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('aßa.*'); + $expected = '"aßa".*'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Modeß.nämé as y'); + $expected = '"Modeß"."nämé" AS "y"'; + $this->assertEquals($expected, $result); + + $result = $driver->quoteIdentifier('Model.näme Datum as y'); + $expected = '"Model"."näme Datum" AS "y"'; + $this->assertEquals($expected, $result); + } + + /** + * Tests value quoting + */ + public function testQuote(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + $driver = new Sqlite(); + + $result = $driver->quote('name'); + $expected = "'name'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote('Model.*'); + $expected = "'Model.*'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O'hare"); + $expected = "'O''hare'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O''hare"); + $expected = "'O''''hare'"; + $this->assertEquals($expected, $result); + + $result = $driver->quote("O\slash"); + $expected = "'O\slash'"; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Database/Driver/SqlserverTest.php b/tests/TestCase/Database/Driver/SqlserverTest.php new file mode 100644 index 00000000000..cc399ce262d --- /dev/null +++ b/tests/TestCase/Database/Driver/SqlserverTest.php @@ -0,0 +1,570 @@ +missingExtension = !defined('PDO::SQLSRV_ENCODING_UTF8'); + } + + /** + * data provider for testDnsString + * + * @return array + */ + public static function dnsStringDataProvider(): array + { + return [ + [ + [ + 'app' => 'CakePHP-Testapp', + 'encoding' => '', + 'connectionPooling' => true, + 'failoverPartner' => 'failover.local', + 'loginTimeout' => 10, + 'multiSubnetFailover' => 'failover.local', + ], + 'sqlsrv:Server=localhost\SQLEXPRESS;Database=cake;MultipleActiveResultSets=false;APP=CakePHP-Testapp;ConnectionPooling=1;Failover_Partner=failover.local;LoginTimeout=10;MultiSubnetFailover=failover.local', + ], + [ + [ + 'app' => 'CakePHP-Testapp', + 'encoding' => '', + 'failoverPartner' => 'failover.local', + 'multiSubnetFailover' => 'failover.local', + ], + 'sqlsrv:Server=localhost\SQLEXPRESS;Database=cake;MultipleActiveResultSets=false;APP=CakePHP-Testapp;Failover_Partner=failover.local;MultiSubnetFailover=failover.local', + ], + [ + [ + 'encoding' => '', + ], + 'sqlsrv:Server=localhost\SQLEXPRESS;Database=cake;MultipleActiveResultSets=false', + ], + [ + [ + 'app' => 'CakePHP-Testapp', + 'encoding' => '', + 'host' => 'localhost\SQLEXPRESS', + 'port' => 9001, + ], + 'sqlsrv:Server=localhost\SQLEXPRESS,9001;Database=cake;MultipleActiveResultSets=false;APP=CakePHP-Testapp', + ], + [ + [ + 'accessToken' => 'test-token', + ], + 'sqlsrv:Server=localhost\SQLEXPRESS;Database=cake;MultipleActiveResultSets=false;AccessToken=test-token', + ], + [ + [ + 'authentication' => 'ActiveDirectoryPassword', + ], + 'sqlsrv:Server=localhost\SQLEXPRESS;Database=cake;MultipleActiveResultSets=false;Authentication=ActiveDirectoryPassword', + ], + ]; + } + + /** + * Test if all options in dns string are set + * + * @param array $constructorArgs + * @param string $dnsString + */ + #[DataProvider('dnsStringDataProvider')] + public function testDnsString($constructorArgs, $dnsString): void + { + $this->skipIf($this->missingExtension, 'pdo_sqlsrv is not installed.'); + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct($constructorArgs); + + $driver->shouldReceive('createPdo') + ->withArgs(function ($dns) use ($dnsString) { + $this->assertSame($dns, $dnsString); + + return true; + }) + ->once() + ->andReturn(Mockery::mock(PDO::class)); + + $driver->connect(); + } + + /** + * Test connecting to Sqlserver with custom configuration + */ + public function testConnectionConfigCustom(): void + { + $this->skipIf($this->missingExtension, 'pdo_sqlsrv is not installed.'); + $config = [ + 'host' => 'foo', + 'username' => 'Administrator', + 'password' => 'blablabla', + 'database' => 'bar', + 'encoding' => 'a-language', + 'flags' => [1 => true, 2 => false], + 'init' => ['Execute this', 'this too'], + 'settings' => ['config1' => 'value1', 'config2' => 'value2'], + ]; + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct($config); + $dsn = 'sqlsrv:Server=foo;Database=bar;MultipleActiveResultSets=false'; + + $expected = $config; + $expected['flags'] += [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::SQLSRV_ATTR_ENCODING => 'a-language', + ]; + $expected['attributes'] = []; + $expected['app'] = null; + $expected['connectionPooling'] = null; + $expected['failoverPartner'] = null; + $expected['loginTimeout'] = null; + $expected['multiSubnetFailover'] = null; + $expected['port'] = null; + $expected['log'] = false; + $expected['encrypt'] = null; + $expected['trustServerCertificate'] = null; + $expected['accessToken'] = null; + $expected['authentication'] = null; + + $connection = Mockery::mock(PDO::class); + + $connection->shouldReceive('quote') + ->andReturnArg(0); + + $connection->shouldReceive('exec')->with('Execute this')->once(); + $connection->shouldReceive('exec')->with('this too')->once(); + $connection->shouldReceive('exec')->with('SET config1 value1')->once(); + $connection->shouldReceive('exec')->with('SET config2 value2')->once(); + + $driver->shouldReceive('createPdo') + ->with($dsn, $expected) + ->once() + ->andReturn($connection); + + $driver->connect(); + } + + /** + * Test connecting to Sqlserver with persistent set to false + */ + public function testConnectionPersistentFalse(): void + { + $this->skipIf($this->missingExtension, 'pdo_sqlsrv is not installed.'); + + $driver = new Sqlserver([ + 'persistent' => false, + 'host' => 'shouldnotexist', + 'username' => 'Administrator', + 'password' => 'blablabla', + 'database' => 'bar', + 'loginTimeout' => 1, + ]); + + // This should not throw an InvalidArgumentException because + // persistent is false (the default). + $this->expectException(MissingConnectionException::class); + $driver->connect(); + } + + /** + * Test if attempting to connect with the driver throws an exception when + * using an invalid config setting. + */ + public function testConnectionPersistentTrueException(): void + { + $this->skipIf($this->missingExtension, 'pdo_sqlsrv is not installed.'); + + $driver = new Sqlserver([ + 'persistent' => true, + 'host' => 'shouldnotexist', + 'username' => 'Administrator', + 'password' => 'blablabla', + 'database' => 'bar', + 'loginTimeout' => 1, + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Config setting "persistent" cannot be set to true, as the Sqlserver PDO driver does not support PDO::ATTR_PERSISTENT'); + $driver->connect(); + } + + /** + * Test setting/skipping of client side buffering options based on output of + * SelectQuery::isBufferedResultsEnabled() + * + * @return void + */ + public function testPrepare(): void + { + $this->skipIf($this->missingExtension, 'pdo_sqlsrv is not installed.'); + + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + + $pdo = Mockery::mock(PDO::class)->makePartial(); + + $statement = Mockery::mock(PDOStatement::class); + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $driver->shouldReceive('getPdo') + ->andReturn($pdo); + + $pdo->shouldReceive('prepare') + ->with('', [ + PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL, + PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE => PDO::SQLSRV_CURSOR_BUFFERED, + ]) + ->andReturn($statement) + ->once(); + + $pdo->shouldReceive('prepare') + ->with('', []) + ->andReturn($statement) + ->once(); + + $pdo->shouldReceive('getAttribute') + ->with(PDO::ATTR_SERVER_VERSION) + ->andReturn('12') + ->once(); + + $query = new SelectQuery($connection); + $driver->prepare($query); + + $query->disableBufferedResults(); + $driver->prepare($query); + } + + /** + * Test select with limit only and SQLServer2012+ + */ + public function testSelectLimitVersion12(): void + { + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('version')->andReturn('12'); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->orderBy(['id']) + ->offset(10); + $this->assertSame('SELECT id, title FROM articles ORDER BY id OFFSET 10 ROWS', $query->sql()); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->orderBy(['id']) + ->limit(10) + ->offset(50); + $this->assertSame('SELECT id, title FROM articles ORDER BY id OFFSET 50 ROWS FETCH FIRST 10 ROWS ONLY', $query->sql()); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->offset(10); + $this->assertSame('SELECT id, title FROM articles ORDER BY (SELECT NULL) OFFSET 10 ROWS', $query->sql()); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->limit(10); + $this->assertSame('SELECT TOP 10 id, title FROM articles', $query->sql()); + } + + /** + * Test select with limit on lte SQLServer2008 + */ + public function testSelectLimitOldServer(): void + { + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('version')->andReturn('8'); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->limit(10); + $expected = 'SELECT TOP 10 id, title FROM articles'; + $this->assertSame($expected, $query->sql()); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->offset(10); + $identifier = '_cake_page_rownum_'; + if ($connection->getDriver()->isAutoQuotingEnabled()) { + $identifier = $connection->getDriver()->quoteIdentifier($identifier); + } + $expected = 'SELECT * FROM (SELECT id, title, (ROW_NUMBER() OVER (ORDER BY (SELECT NULL))) AS ' . $identifier . ' ' . + 'FROM articles) _cake_paging_ ' . + 'WHERE _cake_paging_._cake_page_rownum_ > 10'; + $this->assertSame($expected, $query->sql()); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->orderBy(['id']) + ->offset(10); + $expected = 'SELECT * FROM (SELECT id, title, (ROW_NUMBER() OVER (ORDER BY id)) AS ' . $identifier . ' ' . + 'FROM articles) _cake_paging_ ' . + 'WHERE _cake_paging_._cake_page_rownum_ > 10'; + $this->assertSame($expected, $query->sql()); + + $query = new SelectQuery($connection); + $query->select(['id', 'title']) + ->from('articles') + ->orderBy(['id']) + ->where(['title' => 'Something']) + ->limit(10) + ->offset(50); + $expected = 'SELECT * FROM (SELECT id, title, (ROW_NUMBER() OVER (ORDER BY id)) AS ' . $identifier . ' ' . + 'FROM articles WHERE title = :c0) _cake_paging_ ' . + 'WHERE (_cake_paging_._cake_page_rownum_ > 50 AND _cake_paging_._cake_page_rownum_ <= 60)'; + $this->assertSame($expected, $query->sql()); + + $query = new SelectQuery($connection); + $subquery = new SelectQuery($connection); + $subquery->select(1); + $query + ->select([ + 'id', + 'computed' => $subquery, + ]) + ->from('articles') + ->orderBy([ + 'computed' => 'ASC', + ]) + ->offset(10); + $expected = + 'SELECT * FROM (' . + 'SELECT id, (SELECT 1) AS computed, ' . + '(ROW_NUMBER() OVER (ORDER BY (SELECT 1) ASC)) AS _cake_page_rownum_ FROM articles' . + ') _cake_paging_ ' . + 'WHERE _cake_paging_._cake_page_rownum_ > 10'; + $this->assertSame($expected, $query->sql()); + + $subqueryA = new SelectQuery($connection); + $subqueryA + ->select('count(*)') + ->from(['a' => 'articles']) + ->where([ + 'a.id = articles.id', + 'a.published' => 'Y', + ]); + + $subqueryB = new SelectQuery($connection); + $subqueryB + ->select('count(*)') + ->from(['b' => 'articles']) + ->where([ + 'b.id = articles.id', + 'b.published' => 'N', + ]); + + $query = new SelectQuery($connection); + $query + ->select([ + 'id', + 'computedA' => $subqueryA, + 'computedB' => $subqueryB, + ]) + ->from('articles') + ->orderBy([ + 'computedA' => 'ASC', + ]) + ->offset(10); + + $this->assertSame( + 'SELECT * FROM (' . + 'SELECT id, ' . + '(SELECT count(*) FROM articles a WHERE (a.id = articles.id AND a.published = :c0)) AS computedA, ' . + '(SELECT count(*) FROM articles b WHERE (b.id = articles.id AND b.published = :c1)) AS computedB, ' . + '(ROW_NUMBER() OVER (ORDER BY (SELECT count(*) FROM articles a WHERE (a.id = articles.id AND a.published = :c2)) ASC)) AS _cake_page_rownum_ FROM articles' . + ') _cake_paging_ ' . + 'WHERE _cake_paging_._cake_page_rownum_ > 10', + $query->sql(), + ); + } + + /** + * Test that insert queries have results available to them. + */ + public function testInsertUsesOutput(): void + { + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + $connection = new Connection(['driver' => $driver, 'log' => false]); + $query = new InsertQuery($connection); + $query->insert(['title']) + ->into('articles') + ->values(['title' => 'A new article']); + $expected = 'INSERT INTO articles (title) OUTPUT INSERTED.* VALUES (:c0)'; + $this->assertSame($expected, $query->sql()); + } + + /** + * Test that having queries replace the aggregated alias field. + */ + public function testHavingReplacesAlias(): void + { + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('version')->andReturn('8'); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $query = new SelectQuery($connection); + $query + ->select([ + 'posts.author_id', + 'post_count' => $query->func()->count('posts.id'), + ]) + ->groupBy(['posts.author_id']) + ->having([$query->expr()->gte('post_count', 2, 'integer')]); + + $expected = 'SELECT posts.author_id, (COUNT(posts.id)) AS post_count ' . + 'GROUP BY posts.author_id HAVING COUNT(posts.id) >= :c0'; + $this->assertSame($expected, $query->sql()); + } + + /** + * Test that having queries replaces nothing is no alias is used. + */ + public function testHavingWhenNoAliasIsUsed(): void + { + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct([]); + $driver->shouldReceive('version')->andReturn('8'); + $driver->shouldReceive('enabled')->andReturn(true); + $driver->shouldReceive('connect')->andReturnNull(); + $driver->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + + $connection = new Connection(['driver' => $driver, 'log' => false]); + + $query = new SelectQuery($connection); + $query + ->select([ + 'posts.author_id', + 'post_count' => $query->func()->count('posts.id'), + ]) + ->groupBy(['posts.author_id']) + ->having([$query->expr()->gte('posts.author_id', 2, 'integer')]); + + $expected = 'SELECT posts.author_id, (COUNT(posts.id)) AS post_count ' . + 'GROUP BY posts.author_id HAVING posts.author_id >= :c0'; + $this->assertSame($expected, $query->sql()); + } + + public function testExceedingMaxParameters(): void + { + $connection = ConnectionManager::get('test'); + $this->skipIf(!$connection->getDriver() instanceof Sqlserver); + + $query = $connection->selectQuery() + ->from('articles') + ->whereInList('id', range(0, 2100)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Exceeded maximum number of parameters (2100) for prepared statements in Sql Server', + ); + $connection->getDriver()->prepare($query); + } + + /** + * Tests driver-specific feature support check. + */ + public function testSupports(): void + { + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf(!$driver instanceof Sqlserver); + + $this->assertTrue($driver->supports(DriverFeatureEnum::CTE)); + $this->assertTrue($driver->supports(DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION)); + $this->assertTrue($driver->supports(DriverFeatureEnum::SAVEPOINT)); + $this->assertTrue($driver->supports(DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS)); + $this->assertTrue($driver->supports(DriverFeatureEnum::WINDOW)); + $this->assertTrue($driver->supports(DriverFeatureEnum::INTERSECT)); + + $this->assertFalse($driver->supports(DriverFeatureEnum::INTERSECT_ALL)); + $this->assertFalse($driver->supports(DriverFeatureEnum::JSON)); + $this->assertFalse($driver->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY)); + } +} diff --git a/tests/TestCase/Database/DriverTest.php b/tests/TestCase/Database/DriverTest.php new file mode 100644 index 00000000000..d95e22a38d8 --- /dev/null +++ b/tests/TestCase/Database/DriverTest.php @@ -0,0 +1,466 @@ + 'Array', + 'scopes' => ['queriesLog'], + ]); + + $this->driver = Mockery::mock(StubDriver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $this->driver->__construct(); + } + + protected function tearDown(): void + { + parent::tearDown(); + Log::drop('queries'); + } + + /** + * Test if building the object throws an exception if we're not passing + * required config data. + */ + public function testConstructorException(): void + { + try { + new StubDriver(['login' => 'Bear']); + } catch (Exception $e) { + $this->assertStringContainsString( + 'Please pass "username" instead of "login" for connecting to the database', + $e->getMessage(), + ); + } + } + + /** + * Test the constructor. + */ + public function testConstructor(): void + { + $driver = new StubDriver(['quoteIdentifiers' => true]); + $this->assertTrue($driver->isAutoQuotingEnabled()); + + $driver = new StubDriver(['username' => 'GummyBear']); + $this->assertFalse($driver->isAutoQuotingEnabled()); + } + + /** + * Test schemaValue(). + * Uses a provider for all the different values we can pass to the method. + * + * @param mixed $input + */ + #[DataProvider('schemaValueProvider')] + public function testSchemaValue($input, string $expected): void + { + $result = $this->driver->schemaValue($input); + $this->assertSame($expected, $result); + } + + /** + * Test schemaValue(). + * Asserting that quote() is being called because none of the conditions were met before. + */ + public function testSchemaValueConnectionQuoting(): void + { + $value = 'string'; + + $connection = Mockery::mock(PDO::class); + $connection->shouldReceive('quote') + ->with($value, PDO::PARAM_STR) + ->once() + ->andReturn('string'); + + $this->driver->shouldReceive('createPdo') + ->once() + ->andReturn($connection); + + $this->driver->schemaValue($value); + } + + /** + * Test lastInsertId(). + */ + public function testLastInsertId(): void + { + $connection = Mockery::mock(PDO::class); + $connection->shouldReceive('lastInsertId') + ->once() + ->andReturn('all-the-bears'); + + $this->driver->shouldReceive('createPdo') + ->once() + ->andReturn($connection); + + $this->assertSame('all-the-bears', $this->driver->lastInsertId()); + } + + /** + * Test isConnected(). + */ + public function testIsConnected(): void + { + $this->assertFalse($this->driver->isConnected()); + + $connection = Mockery::mock(PDO::class); + $connection->shouldReceive('query') + ->once() + ->andReturn(Mockery::mock(PDOStatement::class)); + + $this->driver->shouldReceive('createPdo') + ->once() + ->andReturn($connection); + + $this->driver->connect(); + + $this->assertTrue($this->driver->isConnected()); + } + + /** + * test autoQuoting(). + */ + public function testAutoQuoting(): void + { + $this->assertFalse($this->driver->isAutoQuotingEnabled()); + + $this->assertSame($this->driver, $this->driver->enableAutoQuoting(true)); + $this->assertTrue($this->driver->isAutoQuotingEnabled()); + + $this->driver->disableAutoQuoting(); + $this->assertFalse($this->driver->isAutoQuotingEnabled()); + } + + /** + * Test compileQuery(). + */ + public function testCompileQuery(): void + { + $compiler = Mockery::mock(QueryCompiler::class); + $compiler->shouldReceive('compile') + ->once() + ->andReturn('1'); + + $driver = Mockery::mock(StubDriver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + $driver->shouldReceive('newCompiler') + ->once() + ->andReturn($compiler); + + $query = Mockery::mock(Query::class)->shouldIgnoreMissing(); + $query->shouldReceive('type')->andReturn('select'); + + $driver->shouldReceive('transformQuery') + ->once() + ->andReturn($query); + + $result = $driver->compileQuery($query, new ValueBinder()); + + $this->assertSame('1', $result); + } + + /** + * Test newCompiler(). + */ + public function testNewCompiler(): void + { + $this->assertInstanceOf(QueryCompiler::class, $this->driver->newCompiler()); + } + + /** + * Test newTableSchema(). + */ + public function testNewTableSchema(): void + { + $tableName = 'articles'; + $actual = $this->driver->newTableSchema($tableName); + $this->assertInstanceOf(TableSchema::class, $actual); + $this->assertSame($tableName, $actual->name()); + } + + public function testConnectRetry(): void + { + $this->skipIf(!ConnectionManager::get('test')->getDriver() instanceof Sqlserver); + + $driver = new RetryDriver(); + + try { + $driver->connect(); + } catch (MissingConnectionException) { + } + + $this->assertSame(4, $driver->getConnectRetries()); + } + + /** + * Test __destruct(). + */ + public function testDestructor(): void + { + $this->driver->__destruct(); + + $this->assertFalse($this->driver->__debugInfo()['connected']); + } + + /** + * Data provider for testSchemaValue(). + * + * @return array + */ + public static function schemaValueProvider(): array + { + return [ + [null, 'NULL'], + [false, 'FALSE'], + [true, 'TRUE'], + [1, '1'], + ['0', '0'], + ['42', '42'], + ]; + } + + /** + * Tests that queries are logged when executed without params + */ + public function testExecuteNoParams(): void + { + $statement = Mockery::mock(StatementInterface::class); + $statement->shouldReceive('queryString')->andReturn('SELECT bar FROM foo'); + $statement->shouldReceive('rowCount')->andReturn(3); + $statement->shouldReceive('execute')->andReturn(true); + $statement->shouldReceive('getBoundParams')->andReturn([]); + + $this->driver->shouldReceive('prepare') + ->once() + ->andReturn($statement); + $this->driver->setLogger(new QueryLogger(['connection' => 'test'])); + + $this->driver->execute('SELECT bar FROM foo'); + + $messages = Log::engine('queries')->read(); + $this->assertCount(1, $messages); + $this->assertMatchesRegularExpression('/^debug: connection=test role=write duration=[\d\.]+ rows=3 SELECT bar FROM foo$/', $messages[0]); + } + + /** + * Tests that queries are logged when executed with bound params + */ + public function testExecuteWithBinding(): void + { + $boundParams = []; + $statement = Mockery::mock(StatementInterface::class); + $statement->shouldReceive('rowCount')->andReturn(3); + $statement->shouldReceive('execute')->andReturn(true); + $statement->shouldReceive('queryString')->andReturn('SELECT bar FROM foo WHERE a=:a AND b=:b'); + $statement->shouldReceive('bind') + ->once() + ->andReturnUsing(function (array $params, array $types) use (&$boundParams): void { + $boundParams = [ + 'a' => (string)$params['a'], + 'b' => $params['b']->format('Y-m-d'), + ]; + }); + $statement->shouldReceive('getBoundParams') + ->andReturnUsing(function () use (&$boundParams): array { + return $boundParams; + }); + + $this->driver->setLogger(new QueryLogger(['connection' => 'test'])); + $this->driver->shouldReceive('prepare') + ->once() + ->andReturn($statement); + + $this->driver->execute( + 'SELECT bar FROM foo WHERE a=:a AND b=:b', + [ + 'a' => 1, + 'b' => new DateTime('2013-01-01'), + ], + ['b' => 'date'], + ); + + $messages = Log::engine('queries')->read(); + $this->assertCount(1, $messages); + $this->assertMatchesRegularExpression("/^debug: connection=test role=write duration=[\d\.]+ rows=3 SELECT bar FROM foo WHERE a='1' AND b='2013-01-01'$/", $messages[0]); + } + + /** + * Tests that queries are logged despite database errors + */ + public function testExecuteWithError(): void + { + $statement = Mockery::mock(StatementInterface::class); + $statement->shouldReceive('queryString')->andReturn('SELECT bar FROM foo'); + $statement->shouldReceive('rowCount')->andReturn(0); + $statement->shouldReceive('execute')->andThrow(new PDOException()); + $statement->shouldReceive('getBoundParams')->andReturn([]); + + $this->driver->setLogger(new QueryLogger(['connection' => 'test'])); + $this->driver->shouldReceive('prepare') + ->once() + ->andReturn($statement); + + try { + $this->driver->execute('SELECT foo FROM bar'); + } catch (PDOException) { + } + + $messages = Log::engine('queries')->read(); + $this->assertCount(1, $messages); + $this->assertMatchesRegularExpression('/^debug: connection=test role=write duration=\d+ rows=0 SELECT bar FROM foo$/', $messages[0]); + } + + public function testGetLoggerDefault(): void + { + $driver = Mockery::mock(StubDriver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + $this->assertNull($driver->getLogger()); + + $driver = Mockery::mock(StubDriver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(['log' => true]); + + $logger = $driver->getLogger(); + $this->assertInstanceOf(QueryLogger::class, $logger); + } + + public function testSetLogger(): void + { + $logger = new QueryLogger(); + $this->driver->setLogger($logger); + $this->assertSame($logger, $this->driver->getLogger()); + } + + public function testLogTransaction(): void + { + $pdo = Mockery::mock(PDO::class); + $pdo->shouldReceive('beginTransaction')->andReturn(true); + $pdo->shouldReceive('commit')->andReturn(true); + $pdo->shouldReceive('rollBack')->andReturn(true); + $pdo->shouldReceive('inTransaction') + ->times(5) + ->andReturn(false, true, true, false, true); + + $driver = Mockery::mock(StubDriver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(['log' => true]); + $driver->shouldReceive('getPdo') + ->andReturn($pdo); + + $driver->beginTransaction(); + $driver->beginTransaction(); //This one will not be logged + $driver->rollbackTransaction(); + + $driver->beginTransaction(); + $driver->commitTransaction(); + + $messages = Log::engine('queries')->read(); + $this->assertCount(4, $messages); + $this->assertSame('debug: connection= role= duration=0 rows=0 BEGIN', $messages[0]); + $this->assertSame('debug: connection= role= duration=0 rows=0 ROLLBACK', $messages[1]); + $this->assertSame('debug: connection= role= duration=0 rows=0 BEGIN', $messages[2]); + $this->assertSame('debug: connection= role= duration=0 rows=0 COMMIT', $messages[3]); + } + + public function testQueryException(): void + { + $this->expectException(QueryException::class); + + ConnectionManager::get('default')->execute('SELECT * FROM non_existent_table'); + } + + public function testQueryExceptionStatementExecute(): void + { + $this->expectException(QueryException::class); + + ConnectionManager::get('default')->getDriver() + ->execute('SELECT * FROM :foo', ['foo' => 'bar']); + } + + /** + * Tests that queries are logged when executed without params + */ + public function testDisableQueryLogging(): void + { + $statement = Mockery::mock(StatementInterface::class); + $statement->shouldReceive('queryString')->andReturn('SELECT bar FROM foo'); + $statement->shouldReceive('rowCount')->andReturn(3); + $statement->shouldReceive('execute')->andReturn(true); + $statement->shouldReceive('getBoundParams')->andReturn([]); + + $this->driver->shouldReceive('prepare') + ->twice() + ->andReturn($statement); + $this->driver->setLogger(new QueryLogger(['connection' => 'test'])); + + $this->driver->execute('SELECT bar FROM foo'); + + $messages = Log::engine('queries')->read(); + $this->driver->disableQueryLogging(); + + $this->driver->execute('SELECT bar FROM foo'); + $this->assertCount(1, $messages); + $this->assertMatchesRegularExpression('/^debug: connection=test role=write duration=[\d\.]+ rows=3 SELECT bar FROM foo$/', $messages[0]); + } +} diff --git a/tests/TestCase/Database/Exception/QueryExceptionTest.php b/tests/TestCase/Database/Exception/QueryExceptionTest.php new file mode 100644 index 00000000000..96696100d40 --- /dev/null +++ b/tests/TestCase/Database/Exception/QueryExceptionTest.php @@ -0,0 +1,96 @@ +assertSame('', $exception->getConnectionName()); + $this->assertSame('SELECT * FROM missing_table', $exception->getQueryString()); + $this->assertStringContainsString('Table not found', $exception->getMessage()); + $this->assertStringContainsString('SELECT * FROM missing_table', $exception->getMessage()); + $this->assertStringNotContainsString('[', $exception->getMessage()); + } + + /** + * Test exception with LoggedQuery without driver + */ + public function testWithLoggedQueryWithoutDriver(): void + { + $loggedQuery = new LoggedQuery(); + $loggedQuery->setContext([ + 'query' => 'SELECT * FROM users', + ]); + + $pdoException = new PDOException('Connection refused'); + $exception = new QueryException($loggedQuery, $pdoException); + + $this->assertSame('', $exception->getConnectionName()); + $this->assertSame('SELECT * FROM users', $exception->getQueryString()); + $this->assertStringNotContainsString('[', $exception->getMessage()); + } + + /** + * Test exception with LoggedQuery with driver includes connection name + */ + public function testWithLoggedQueryWithDriver(): void + { + $driver = ConnectionManager::get('test')->getDriver(); + + $loggedQuery = new LoggedQuery(); + $loggedQuery->setContext([ + 'query' => 'SELECT * FROM users', + 'driver' => $driver, + ]); + + $pdoException = new PDOException('Table not found'); + $exception = new QueryException($loggedQuery, $pdoException); + + $this->assertSame('test', $exception->getConnectionName()); + $this->assertSame('SELECT * FROM users', $exception->getQueryString()); + $this->assertStringContainsString('[test]', $exception->getMessage()); + $this->assertStringContainsString('Table not found', $exception->getMessage()); + } + + /** + * Test that previous exception is preserved + */ + public function testPreviousException(): void + { + $pdoException = new PDOException('Original error', 42); + $exception = new QueryException('SELECT 1', $pdoException); + + $this->assertSame($pdoException, $exception->getPrevious()); + $this->assertSame(42, $exception->getCode()); + } +} diff --git a/tests/TestCase/Database/Expression/AggregateExpressionTest.php b/tests/TestCase/Database/Expression/AggregateExpressionTest.php new file mode 100644 index 00000000000..53e4117fab5 --- /dev/null +++ b/tests/TestCase/Database/Expression/AggregateExpressionTest.php @@ -0,0 +1,192 @@ +over(); + $this->assertSame('MyFunction() OVER ()', $f->sql(new ValueBinder())); + + $f = (new AggregateExpression('MyFunction'))->over('name'); + $this->assertEqualsSql( + 'MyFunction() OVER name', + $f->sql(new ValueBinder()), + ); + } + + /** + * Tests filter() clauses. + */ + public function testFilter(): void + { + $f = (new AggregateExpression('MyFunction'))->filter(['this' => new IdentifierExpression('that')]); + $this->assertEqualsSql( + 'MyFunction() FILTER (WHERE this = that)', + $f->sql(new ValueBinder()), + ); + + $f->filter(function (QueryExpression $q) { + return $q->add(['this2' => new IdentifierExpression('that2')]); + }); + $this->assertEqualsSql( + 'MyFunction() FILTER (WHERE (this = that AND this2 = that2))', + $f->sql(new ValueBinder()), + ); + + $f->over(); + $this->assertEqualsSql( + 'MyFunction() FILTER (WHERE (this = that AND this2 = that2)) OVER ()', + $f->sql(new ValueBinder()), + ); + } + + /** + * Tests WindowInterface calls are passed to the WindowExpression + */ + public function testWindowInterface(): void + { + $f = (new AggregateExpression('MyFunction'))->partition('test'); + $this->assertEqualsSql( + 'MyFunction() OVER (PARTITION BY test)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->orderBy('test'); + $this->assertEqualsSql( + 'MyFunction() OVER (ORDER BY test)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->range(null); + $this->assertEqualsSql( + 'MyFunction() OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->range(null, null); + $this->assertEqualsSql( + 'MyFunction() OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->rows(null); + $this->assertEqualsSql( + 'MyFunction() OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->rows(null, null); + $this->assertEqualsSql( + 'MyFunction() OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->groups(null); + $this->assertEqualsSql( + 'MyFunction() OVER (GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->groups(null, null); + $this->assertEqualsSql( + 'MyFunction() OVER (GROUPS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->frame( + AggregateExpression::RANGE, + 2, + AggregateExpression::PRECEDING, + 1, + AggregateExpression::PRECEDING, + ); + $this->assertEqualsSql( + 'MyFunction() OVER (RANGE BETWEEN 2 PRECEDING AND 1 PRECEDING)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->excludeCurrent(); + $this->assertEqualsSql( + 'MyFunction() OVER ()', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->range(null)->excludeCurrent(); + $this->assertEqualsSql( + 'MyFunction() OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->range(null)->excludeGroup(); + $this->assertEqualsSql( + 'MyFunction() OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP)', + $f->sql(new ValueBinder()), + ); + + $f = (new AggregateExpression('MyFunction'))->range(null)->excludeTies(); + $this->assertEqualsSql( + 'MyFunction() OVER (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES)', + $f->sql(new ValueBinder()), + ); + } + + /** + * Tests traversing aggregate expressions. + */ + public function testTraverse(): void + { + $w = (new AggregateExpression('MyFunction')) + ->filter(['this' => true]) + ->over(); + + $expressions = []; + $w->traverse(function ($expression) use (&$expressions): void { + $expressions[] = $expression; + }); + + $this->assertEquals(new QueryExpression(['this' => true]), $expressions[0]); + $this->assertEquals(new WindowExpression(), $expressions[2]); + } + + /** + * Tests cloning aggregate expressions + */ + public function testCloning(): void + { + $a1 = (new AggregateExpression('MyFunction'))->partition('test'); + $a2 = (clone $a1)->partition('new'); + $this->assertNotSame($a1->sql(new ValueBinder()), $a2->sql(new ValueBinder())); + } +} diff --git a/tests/TestCase/Database/Expression/CaseStatementExpressionTest.php b/tests/TestCase/Database/Expression/CaseStatementExpressionTest.php new file mode 100644 index 00000000000..dc6a84ebcfc --- /dev/null +++ b/tests/TestCase/Database/Expression/CaseStatementExpressionTest.php @@ -0,0 +1,2113 @@ +when(1, 'custom') + ->then(2, 'custom') + ->else(3, 'custom'); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE CUSTOM(:param0) WHEN CUSTOM(:param1) THEN CUSTOM(:param2) ELSE CUSTOM(:param3) END', + $sql, + ); + } + + public function testExpressionTypeCastingNullValues(): void + { + TypeFactory::map('custom', CustomExpressionType::class); + + $expression = (new CaseStatementExpression(null, 'custom')) + ->when(1, 'custom') + ->then(null, 'custom') + ->else(null, 'custom'); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE CUSTOM(:param0) WHEN CUSTOM(:param1) THEN CUSTOM(:param2) ELSE CUSTOM(:param3) END', + $sql, + ); + } + + public function testExpressionTypeCastingSearchedCase(): void + { + TypeFactory::map('custom', CustomExpressionType::class); + + $expression = (new CaseStatementExpression()) + ->when(['Table.column' => true], ['Table.column' => 'custom']) + ->then(1, 'custom') + ->else(2, 'custom'); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE WHEN Table.column = (CUSTOM(:param0)) THEN CUSTOM(:param1) ELSE CUSTOM(:param2) END', + $sql, + ); + } + + public function testGetReturnType(): void + { + // all provided `then` and `else` types are the same, return + // type can be inferred + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(1, 'integer') + ->when(['Table.column_b' => true]) + ->then(2, 'integer') + ->else(3, 'integer'); + $this->assertSame('integer', $expression->getReturnType()); + + // all provided `then` an `else` types are the same, one `then` + // type is `null`, return type can be inferred + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(1) + ->when(['Table.column_b' => true]) + ->then(2, 'integer') + ->else(3, 'integer'); + $this->assertSame('integer', $expression->getReturnType()); + + // all `then` types are null, an `else` type was provided, + // return type can be inferred + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(1) + ->when(['Table.column_b' => true]) + ->then(2) + ->else(3, 'integer'); + $this->assertSame('integer', $expression->getReturnType()); + + // all provided `then` types are the same, the `else` type is + // `null`, return type can be inferred + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(1, 'integer') + ->when(['Table.column_b' => true]) + ->then(2, 'integer') + ->else(3); + $this->assertSame('integer', $expression->getReturnType()); + + // no `then` or `else` types were provided, they are all `null`, + // and will be derived from the passed value, return type can be + // inferred + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(1) + ->when(['Table.column_b' => true]) + ->then(2) + ->else(3); + $this->assertSame('integer', $expression->getReturnType()); + + // all `then` and `else` point to columns of the same type, + // return type can be inferred + $typeMap = new TypeMap([ + 'Table.column_a' => 'boolean', + 'Table.column_b' => 'boolean', + 'Table.column_c' => 'boolean', + ]); + $expression = (new CaseStatementExpression()) + ->setTypeMap($typeMap) + ->when(['Table.column_a' => true]) + ->then(new IdentifierExpression('Table.column_a')) + ->when(['Table.column_b' => true]) + ->then(new IdentifierExpression('Table.column_b')) + ->else(new IdentifierExpression('Table.column_c')); + $this->assertSame('boolean', $expression->getReturnType()); + + // all `then` and `else` use the same custom type, return type + // can be inferred + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(1, 'custom') + ->when(['Table.column_b' => true]) + ->then(2, 'custom') + ->else(3, 'custom'); + $this->assertSame('custom', $expression->getReturnType()); + + // all `then` and `else` types were provided, but an explicit + // return type was set, return type will be overwritten + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(1, 'integer') + ->when(['Table.column_b' => true]) + ->then(2, 'integer') + ->else(3, 'integer') + ->setReturnType('string'); + $this->assertSame('string', $expression->getReturnType()); + + // all `then` and `else` types are different, return type + // cannot be inferred + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->then(true) + ->when(['Table.column_b' => true]) + ->then(1) + ->else(null); + $this->assertSame('string', $expression->getReturnType()); + } + + public function testSetReturnType(): void + { + $expression = (new CaseStatementExpression())->else('1'); + $this->assertSame('string', $expression->getReturnType()); + + $expression->setReturnType('float'); + $this->assertSame('float', $expression->getReturnType()); + } + + public static function valueTypeInferenceDataProvider(): array + { + return [ + // Values that should have their type inferred because + // they will be bound by the case expression. + ['1', 'string'], + [1, 'integer'], + [1.0, 'float'], + [true, 'boolean'], + [ChronosDate::now(), 'date'], + [Chronos::now(), 'datetime'], + + // Values that should not have a type inferred, either + // because they are not bound by the case expression, + // and/or because their type is obtained differently + // (for example from a type map). + [new IdentifierExpression('Table.column'), null], + [new FunctionExpression('SUM', ['Table.column' => 'literal'], [], 'integer'), null], + [new stdClass(), null], + [null, null], + ]; + } + + /** + * @param mixed $value The value from which to infer the type. + * @param string|null $type The expected type. + */ + #[DataProvider('valueTypeInferenceDataProvider')] + public function testInferValueType($value, ?string $type): void + { + $expression = new CaseStatementExpressionStub(); + + $this->assertNull($expression->getValueType()); + + $expression = (new CaseStatementExpressionStub($value)) + ->setTypeMap(new TypeMap(['Table.column' => 'boolean'])) + ->when(1) + ->then(2); + + $this->assertSame($type, $expression->getValueType()); + } + + public static function whenTypeInferenceDataProvider(): array + { + return [ + // Values that should have their type inferred because + // they will be bound by the case expression. + ['1', 'string'], + [1, 'integer'], + [1.0, 'float'], + [true, 'boolean'], + [ChronosDate::now(), 'date'], + [Chronos::now(), 'datetime'], + + // Values that should not have a type inferred, either + // because they are not bound by the case expression, + // and/or because their type is obtained differently + // (for example from a type map). + [new IdentifierExpression('Table.column'), null], + [new FunctionExpression('SUM', ['Table.column' => 'literal'], [], 'integer'), null], + [['Table.column' => true], null], + [new stdClass(), null], + ]; + } + + /** + * @param mixed $value The value from which to infer the type. + * @param string|null $type The expected type. + */ + #[DataProvider('whenTypeInferenceDataProvider')] + public function testInferWhenType($value, ?string $type): void + { + $expression = (new CaseStatementExpressionStub()) + ->setTypeMap(new TypeMap(['Table.column' => 'boolean'])); + $expression->when(new WhenThenExpressionStub($expression->getTypeMap())); + + $this->assertNull($expression->clause('when')[0]->getWhenType()); + + $expression->clause('when')[0] + ->when($value) + ->then(1); + + $this->assertSame($type, $expression->clause('when')[0]->getWhenType()); + } + + public static function resultTypeInferenceDataProvider(): array + { + return [ + // Unless a result type has been set manually, values + // should have their type inferred when possible. + ['1', 'string'], + [1, 'integer'], + [1.0, 'float'], + [true, 'boolean'], + [ChronosDate::now(), 'date'], + [Chronos::now(), 'datetime'], + [new IdentifierExpression('Table.column'), 'boolean'], + [new FunctionExpression('SUM', ['Table.column' => 'literal'], [], 'integer'), 'integer'], + [new stdClass(), null], + [null, null], + ]; + } + + /** + * @param mixed $value The value from which to infer the type. + * @param string|null $type The expected type. + */ + #[DataProvider('resultTypeInferenceDataProvider')] + public function testInferResultType($value, ?string $type): void + { + $expression = (new CaseStatementExpressionStub()) + ->setTypeMap(new TypeMap(['Table.column' => 'boolean'])) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen; + }); + + $this->assertNull($expression->clause('when')[0]->getResultType()); + + $expression->clause('when')[0] + ->when(['Table.column' => true]) + ->then($value); + + $this->assertSame($type, $expression->clause('when')[0]->getResultType()); + } + + /** + * @param mixed $value The value from which to infer the type. + * @param string|null $type The expected type. + */ + #[DataProvider('resultTypeInferenceDataProvider')] + public function testInferElseType($value, ?string $type): void + { + $expression = new CaseStatementExpressionStub(); + + $this->assertNull($expression->getElseType()); + + $expression = (new CaseStatementExpressionStub()) + ->setTypeMap(new TypeMap(['Table.column' => 'boolean'])); + + $this->assertNull($expression->getElseType()); + + $expression->else($value); + + $this->assertSame($type, $expression->getElseType()); + } + + public function testWhenArrayValueInheritTypeMap(): void + { + $typeMap = new TypeMap([ + 'Table.column_a' => 'boolean', + 'Table.column_b' => 'string', + ]); + + $expression = (new CaseStatementExpression()) + ->setTypeMap($typeMap) + ->when(['Table.column_a' => true]) + ->then(1) + ->when(['Table.column_b' => 'foo']) + ->then(2) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => true, + 'type' => 'boolean', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'foo', + 'type' => 'string', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c3', + ], + ':c4' => [ + 'value' => 3, + 'type' => 'integer', + 'placeholder' => 'c4', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testWhenArrayValueWithExplicitTypes(): void + { + $typeMap = new TypeMap([ + 'Table.column_a' => 'boolean', + 'Table.column_b' => 'string', + ]); + + $expression = (new CaseStatementExpression()) + ->setTypeMap($typeMap) + ->when(['Table.column_a' => 123], ['Table.column_a' => 'integer']) + ->then(1) + ->when(['Table.column_b' => 'foo']) + ->then(2) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => 123, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'foo', + 'type' => 'string', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c3', + ], + ':c4' => [ + 'value' => 3, + 'type' => 'integer', + 'placeholder' => 'c4', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testWhenCallableArrayValueInheritTypeMap(): void + { + $typeMap = new TypeMap([ + 'Table.column_a' => 'boolean', + 'Table.column_b' => 'string', + ]); + + $expression = (new CaseStatementExpression()) + ->setTypeMap($typeMap) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen + ->when(['Table.column_a' => true]) + ->then(1); + }) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen + ->when(['Table.column_b' => 'foo']) + ->then(2); + }) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => true, + 'type' => 'boolean', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'foo', + 'type' => 'string', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c3', + ], + ':c4' => [ + 'value' => 3, + 'type' => 'integer', + 'placeholder' => 'c4', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testWhenCallableArrayValueWithExplicitTypes(): void + { + $typeMap = new TypeMap([ + 'Table.column_a' => 'boolean', + 'Table.column_b' => 'string', + ]); + + $expression = (new CaseStatementExpression()) + ->setTypeMap($typeMap) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen + ->when(['Table.column_a' => 123], ['Table.column_a' => 'integer']) + ->then(1); + }) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen + ->when(['Table.column_b' => 'foo']) + ->then(2); + }) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE WHEN Table.column_a = :c0 THEN :c1 WHEN Table.column_b = :c2 THEN :c3 ELSE :c4 END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => 123, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'foo', + 'type' => 'string', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c3', + ], + ':c4' => [ + 'value' => 3, + 'type' => 'integer', + 'placeholder' => 'c4', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testWhenArrayValueRequiresArrayTypeValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'When using an array for the `$when` argument, the `$type` ' . + 'argument must be an array too, `string` given.', + ); + + (new CaseStatementExpression()) + ->when(['Table.column' => 123], 'integer') + ->then(1); + } + + public function testWhenNonArrayValueRequiresStringTypeValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'When using a non-array value for the `$when` argument, ' . + 'the `$type` argument must be a string, `array` given.', + ); + + (new CaseStatementExpression()) + ->when(123, ['Table.column' => 'integer']) + ->then(1); + } + + public function testInternalTypeMapChangesAreNonPersistent(): void + { + $typeMap = new TypeMap([ + 'Table.column' => 'integer', + ]); + + $expression = (new CaseStatementExpression()) + ->setTypeMap($typeMap) + ->when(['Table.column' => 123]) + ->then(1) + ->when(['Table.column' => 'foo'], ['Table.column' => 'string']) + ->then('bar') + ->when(['Table.column' => 456]) + ->then(2); + + $valueBinder = new ValueBinder(); + $expression->sql($valueBinder); + $this->assertSame( + [ + ':c0' => [ + 'value' => 123, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'foo', + 'type' => 'string', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => 'bar', + 'type' => 'string', + 'placeholder' => 'c3', + ], + ':c4' => [ + 'value' => 456, + 'type' => 'integer', + 'placeholder' => 'c4', + ], + ':c5' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c5', + ], + ], + $valueBinder->bindings(), + ); + + $this->assertSame($typeMap, $expression->getTypeMap()); + } + + // endregion + + // region SQL injections + + public function testSqlInjectionViaTypedCaseValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression('1 THEN 1 END; DELETE * FROM foo; --', 'integer')) + ->when(1) + ->then(2); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testSqlInjectionViaUntypedCaseValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression('1 THEN 1 END; DELETE * FROM foo; --')) + ->when(1) + ->then(2); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'string', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testSqlInjectionViaTypedWhenValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression()) + ->when('1 THEN 1 END; DELETE * FROM foo; --', 'integer') + ->then(1); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE WHEN :c0 THEN :c1 ELSE NULL END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testSqlInjectionViaTypedWhenArrayValueIsNotPossible(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'When using an array for the `$when` argument, the `$type` ' . + 'argument must be an array too, `string` given.', + ); + + (new CaseStatementExpression()) + ->when(['1 THEN 1 END; DELETE * FROM foo; --' => '123'], 'integer') + ->then(1); + } + + public function testSqlInjectionViaUntypedWhenValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression()) + ->when('1 THEN 1 END; DELETE * FROM foo; --') + ->then(1); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE WHEN :c0 THEN :c1 ELSE NULL END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'string', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testSqlInjectionViaTypedThenValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression(1)) + ->when(2) + ->then('1 THEN 1 END; DELETE * FROM foo; --', 'integer'); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testSqlInjectionViaUntypedThenValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression(1)) + ->when(2) + ->then('1 THEN 1 END; DELETE * FROM foo; --'); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'string', + 'placeholder' => 'c2', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testSqlInjectionViaTypedElseValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression(1)) + ->when(2) + ->then(3) + ->else('1 THEN 1 END; DELETE * FROM foo; --', 'integer'); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE :c3 END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 3, + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'integer', + 'placeholder' => 'c3', + ], + ], + $valueBinder->bindings(), + ); + } + + public function testSqlInjectionViaUntypedElseValueIsNotPossible(): void + { + $expression = (new CaseStatementExpression(1)) + ->when(2) + ->then(3) + ->else('1 THEN 1 END; DELETE * FROM foo; --'); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE :c3 END', + $sql, + ); + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 3, + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => '1 THEN 1 END; DELETE * FROM foo; --', + 'type' => 'string', + 'placeholder' => 'c3', + ], + ], + $valueBinder->bindings(), + ); + } + + // endregion + + // region Getters + + public function testGetInvalidCaseExpressionClause(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The `$clause` argument must be one of `value`, `when`, `else`, the given value `invalid` is invalid.', + ); + + (new CaseStatementExpression())->clause('invalid'); + } + + public function testGetInvalidWhenThenExpressionClause(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The `$clause` argument must be one of `when`, `then`, the given value `invalid` is invalid.', + ); + + (new WhenThenExpression())->clause('invalid'); + } + + public function testGetValueClause(): void + { + $expression = new CaseStatementExpression(); + + $this->assertNull($expression->clause('value')); + + $expression = (new CaseStatementExpression(1)) + ->when(1) + ->then(2); + + $this->assertSame(1, $expression->clause('value')); + } + + public function testGetWhenClause(): void + { + $when = ['Table.column' => true]; + + $expression = new CaseStatementExpression(); + $this->assertSame([], $expression->clause('when')); + + $expression + ->when($when) + ->then(1); + + $this->assertCount(1, $expression->clause('when')); + $this->assertInstanceOf(WhenThenExpression::class, $expression->clause('when')[0]); + } + + public function testWhenArrayValueGetWhenClause(): void + { + $when = ['Table.column' => true]; + + $expression = new CaseStatementExpression(); + $this->assertSame([], $expression->clause('when')); + + $expression + ->when($when) + ->then(1); + + $this->assertEquals( + new QueryExpression($when), + $expression->clause('when')[0]->clause('when'), + ); + } + + public function testWhenNonArrayValueGetWhenClause(): void + { + $expression = new CaseStatementExpression(); + $this->assertSame([], $expression->clause('when')); + + $expression + ->when(1) + ->then(2); + + $this->assertSame(1, $expression->clause('when')[0]->clause('when')); + } + + public function testWhenGetThenClause(): void + { + $expression = (new CaseStatementExpression()) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen; + }); + + $this->assertNull($expression->clause('when')[0]->clause('then')); + + $expression->clause('when')[0]->then(1); + + $this->assertSame(1, $expression->clause('when')[0]->clause('then')); + } + + public function testGetElseClause(): void + { + $expression = new CaseStatementExpression(); + + $this->assertNull($expression->clause('else')); + + $expression + ->when(['Table.column' => true]) + ->then(1) + ->else(2); + + $this->assertSame(2, $expression->clause('else')); + } + + // endregion + + // region Order based syntax + + public function testWhenThenElse(): void + { + $expression = (new CaseStatementExpression()) + ->when(['Table.column_a' => true, 'Table.column_b IS' => null]) + ->then(1) + ->when(['Table.column_c' => true, 'Table.column_d IS NOT' => null]) + ->then(2) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE ' . + 'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' . + 'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' . + 'ELSE :c4 ' . + 'END', + $sql, + ); + } + + public function testWhenBeforeClosingThenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot call `when()` between `when()` and `then()`.'); + + (new CaseStatementExpression()) + ->when(['Table.column_a' => true]) + ->when(['Table.column_b' => true]); + } + + public function testElseBeforeClosingThenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot call `else()` between `when()` and `then()`.'); + + (new CaseStatementExpression()) + ->when(['Table.column' => true]) + ->else(1); + } + + public function testThenBeforeOpeningWhenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot call `then()` before `when()`.'); + + (new CaseStatementExpression()) + ->then(1); + } + + // endregion + + // region Callable syntax + + public function testWhenCallables(): void + { + $expression = (new CaseStatementExpression()) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen + ->when([ + 'Table.column_a' => true, + 'Table.column_b IS' => null, + ]) + ->then(1); + }) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen + ->when([ + 'Table.column_c' => true, + 'Table.column_d IS NOT' => null, + ]) + ->then(2); + }) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE ' . + 'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' . + 'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' . + 'ELSE :c4 ' . + 'END', + $sql, + ); + } + + public function testWhenCallablesWithCustomWhenThenExpressions(): void + { + $expression = (new CaseStatementExpression()) + ->when(function () { + return (new CustomWhenThenExpression()) + ->when([ + 'Table.column_a' => true, + 'Table.column_b IS' => null, + ]) + ->then(1); + }) + ->when(function () { + return (new CustomWhenThenExpression()) + ->when([ + 'Table.column_c' => true, + 'Table.column_d IS NOT' => null, + ]) + ->then(2); + }) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE ' . + 'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' . + 'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' . + 'ELSE :c4 ' . + 'END', + $sql, + ); + } + + public function testWhenCallablesWithInvalidReturnTypeFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + '`when()` callables must return an instance of ' . + '`\Cake\Database\Expression\WhenThenExpression`, `null` given.', + ); + + $this->deprecated(function (): void { + (new CaseStatementExpression()) + ->when(function () { + return null; + }); + }); + } + + // endregion + + // region Self-contained values + + public function testSelfContainedWhenThenExpressions(): void + { + $expression = (new CaseStatementExpression()) + ->when( + (new WhenThenExpression()) + ->when([ + 'Table.column_a' => true, + 'Table.column_b IS' => null, + ]) + ->then(1), + ) + ->when( + (new WhenThenExpression()) + ->when([ + 'Table.column_c' => true, + 'Table.column_d IS NOT' => null, + ]) + ->then(2), + ) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE ' . + 'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' . + 'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' . + 'ELSE :c4 ' . + 'END', + $sql, + ); + } + + public function testSelfContainedCustomWhenThenExpressions(): void + { + $expression = (new CaseStatementExpression()) + ->when( + (new CustomWhenThenExpression()) + ->when([ + 'Table.column_a' => true, + 'Table.column_b IS' => null, + ]) + ->then(1), + ) + ->when( + (new CustomWhenThenExpression()) + ->when([ + 'Table.column_c' => true, + 'Table.column_d IS NOT' => null, + ]) + ->then(2), + ) + ->else(3); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + $this->assertSame( + 'CASE ' . + 'WHEN (Table.column_a = :c0 AND (Table.column_b) IS NULL) THEN :c1 ' . + 'WHEN (Table.column_c = :c2 AND (Table.column_d) IS NOT NULL) THEN :c3 ' . + 'ELSE :c4 ' . + 'END', + $sql, + ); + } + + // endregion + + // region Incomplete states + + public function testCompilingEmptyCaseExpressionFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Case expression must have at least one when statement.'); + + $this->deprecated(function (): void { + (new CaseStatementExpression())->sql(new ValueBinder()); + }); + } + + public function testCompilingNonClosedWhenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.'); + + $this->deprecated(function (): void { + (new CaseStatementExpression()) + ->when(['Table.column' => true]) + ->sql(new ValueBinder()); + }); + } + + public function testCompilingWhenThenExpressionWithMissingWhenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `when()`.'); + + $this->deprecated(function (): void { + (new CaseStatementExpression()) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen->then(1); + }) + ->sql(new ValueBinder()); + }); + } + + public function testCompilingWhenThenExpressionWithMissingThenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.'); + + $this->deprecated(function (): void { + (new CaseStatementExpression()) + ->when(function (WhenThenExpression $whenThen) { + return $whenThen->when(1); + }) + ->sql(new ValueBinder()); + }); + } + + // endregion + + // region Valid values + + public static function validCaseValuesDataProvider(): array + { + return [ + [null, 'NULL', null], + ['0', null, 'string'], + [0, null, 'integer'], + [0.0, null, 'float'], + ['foo', null, 'string'], + [true, null, 'boolean'], + [Date::now(), null, 'date'], + [ChronosDate::now(), null, 'date'], + [DateTime::now(), null, 'datetime'], + [Chronos::now(), null, 'datetime'], + [new IdentifierExpression('Table.column'), 'Table.column', null], + [new QueryExpression('Table.column'), 'Table.column', null], + [ConnectionManager::get('test')->selectQuery('a'), '(SELECT a)', null], + [new TestObjectWithToString(), null, 'string'], + [new stdClass(), null, null], + ]; + } + + /** + * @param mixed $value The case value. + * @param string|null $sqlValue The expected SQL string value. + * @param string|null $type The expected bound type. + */ + #[DataProvider('validCaseValuesDataProvider')] + public function testValidCaseValue($value, ?string $sqlValue, ?string $type): void + { + $expression = (new CaseStatementExpression($value)) + ->when(1) + ->then(2); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + + if ($sqlValue) { + $this->assertEqualsSql( + "CASE {$sqlValue} WHEN :c0 THEN :c1 ELSE NULL END", + $sql, + ); + + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + $valueBinder->bindings(), + ); + } else { + $this->assertEqualsSql( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', + $sql, + ); + + $this->assertSame( + [ + ':c0' => [ + 'value' => $value, + 'type' => $type, + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ], + $valueBinder->bindings(), + ); + } + } + + public static function validWhenValuesSimpleCaseDataProvider(): array + { + return [ + ['0', null, 'string'], + [0, null, 'integer'], + [0.0, null, 'float'], + ['foo', null, 'string'], + [true, null, 'boolean'], + [new stdClass(), null, null], + [new TestObjectWithToString(), null, 'string'], + [Date::now(), null, 'date'], + [ChronosDate::now(), null, 'date'], + [DateTime::now(), null, 'datetime'], + [Chronos::now(), null, 'datetime'], + [ + new IdentifierExpression('Table.column'), + 'CASE :c0 WHEN Table.column THEN :c1 ELSE NULL END', + [ + ':c0' => [ + 'value' => true, + 'type' => 'boolean', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + ], + [ + new QueryExpression('Table.column'), + 'CASE :c0 WHEN Table.column THEN :c1 ELSE NULL END', + [ + ':c0' => [ + 'value' => true, + 'type' => 'boolean', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + ], + [ + ConnectionManager::get('test')->selectQuery('a'), + 'CASE :c0 WHEN (SELECT a) THEN :c1 ELSE NULL END', + [ + ':c0' => [ + 'value' => true, + 'type' => 'boolean', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + ], + [ + [ + 'Table.column_a' => 1, + 'Table.column_b' => 'foo', + ], + 'CASE :c0 WHEN (Table.column_a = :c1 AND Table.column_b = :c2) THEN :c3 ELSE NULL END', + [ + ':c0' => [ + 'value' => true, + 'type' => 'boolean', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'foo', + 'type' => 'string', + 'placeholder' => 'c2', + ], + ':c3' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c3', + ], + ], + ], + ]; + } + + /** + * @param mixed $value The when value. + * @param string|null $expectedSql The expected SQL string. + * @param array|string|null $typeOrBindings The expected bound type(s). + */ + #[DataProvider('validWhenValuesSimpleCaseDataProvider')] + public function testValidWhenValueSimpleCase($value, ?string $expectedSql, $typeOrBindings = null): void + { + $typeMap = new TypeMap([ + 'Table.column_a' => 'integer', + 'Table.column_b' => 'string', + ]); + $expression = (new CaseStatementExpression(true)) + ->setTypeMap($typeMap) + ->when($value) + ->then(2); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + + if ($expectedSql) { + $this->assertEqualsSql($expectedSql, $sql); + $this->assertSame($typeOrBindings, $valueBinder->bindings()); + } else { + $this->assertEqualsSql('CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', $sql); + $this->assertSame( + [ + ':c0' => [ + 'value' => true, + 'type' => 'boolean', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => $value, + 'type' => $typeOrBindings, + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ], + $valueBinder->bindings(), + ); + } + } + + public static function validWhenValuesSearchedCaseDataProvider(): array + { + return [ + ['0', null, 'string'], + [0, null, 'integer'], + [0.0, null, 'float'], + ['foo', null, 'string'], + [true, null, 'boolean'], + [new stdClass(), null, null], + [new TestObjectWithToString(), null, 'string'], + [Date::now(), null, 'date'], + [ChronosDate::now(), null, 'date'], + [DateTime::now(), null, 'datetime'], + [Chronos::now(), null, 'datetime'], + [ + new IdentifierExpression('Table.column'), + 'CASE WHEN Table.column THEN :c0 ELSE NULL END', + [ + ':c0' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ], + ], + [ + new QueryExpression('Table.column'), + 'CASE WHEN Table.column THEN :c0 ELSE NULL END', + [ + ':c0' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ], + ], + [ + ConnectionManager::get('test')->selectQuery('a'), + 'CASE WHEN (SELECT a) THEN :c0 ELSE NULL END', + [ + ':c0' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ], + ], + [ + [ + 'Table.column_a' => 1, + 'Table.column_b' => 'foo', + ], + 'CASE WHEN (Table.column_a = :c0 AND Table.column_b = :c1) THEN :c2 ELSE NULL END', + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 'foo', + 'type' => 'string', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c2', + ], + ], + ], + ]; + } + + /** + * @param mixed $value The when value. + * @param string|null $expectedSql The expected SQL string. + * @param array|string|null $typeOrBindings The expected bound type(s). + */ + #[DataProvider('validWhenValuesSearchedCaseDataProvider')] + public function testValidWhenValueSearchedCase($value, ?string $expectedSql, $typeOrBindings = null): void + { + $typeMap = new TypeMap([ + 'Table.column_a' => 'integer', + 'Table.column_b' => 'string', + ]); + $expression = (new CaseStatementExpression()) + ->setTypeMap($typeMap) + ->when($value) + ->then(2); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + + if ($expectedSql) { + $this->assertEqualsSql($expectedSql, $sql); + $this->assertSame($typeOrBindings, $valueBinder->bindings()); + } else { + $this->assertEqualsSql('CASE WHEN :c0 THEN :c1 ELSE NULL END', $sql); + $this->assertSame( + [ + ':c0' => [ + 'value' => $value, + 'type' => $typeOrBindings, + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + $valueBinder->bindings(), + ); + } + } + + public static function validThenValuesDataProvider(): array + { + return [ + [null, 'NULL', null], + ['0', null, 'string'], + [0, null, 'integer'], + [0.0, null, 'float'], + ['foo', null, 'string'], + [true, null, 'boolean'], + [Date::now(), null, 'date'], + [ChronosDate::now(), null, 'date'], + [DateTime::now(), null, 'datetime'], + [Chronos::now(), null, 'datetime'], + [new IdentifierExpression('Table.column'), 'Table.column', null], + [new QueryExpression('Table.column'), 'Table.column', null], + [ConnectionManager::get('test')->selectQuery('a'), '(SELECT a)', null], + [new TestObjectWithToString(), null, 'string'], + [new stdClass(), null, null], + ]; + } + + /** + * @param mixed $value The then value. + * @param string|null $sqlValue The expected SQL string value. + * @param string|null $type The expected bound type. + */ + #[DataProvider('validThenValuesDataProvider')] + public function testValidThenValue($value, ?string $sqlValue, ?string $type): void + { + $expression = (new CaseStatementExpression()) + ->when(1) + ->then($value); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + + if ($sqlValue) { + $this->assertEqualsSql( + "CASE WHEN :c0 THEN {$sqlValue} ELSE NULL END", + $sql, + ); + + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ], + $valueBinder->bindings(), + ); + } else { + $this->assertEqualsSql( + 'CASE WHEN :c0 THEN :c1 ELSE NULL END', + $sql, + ); + + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => $value, + 'type' => $type, + 'placeholder' => 'c1', + ], + ], + $valueBinder->bindings(), + ); + } + } + + public static function validElseValuesDataProvider(): array + { + return [ + [null, 'NULL', null], + ['0', null, 'string'], + [0, null, 'integer'], + [0.0, null, 'float'], + ['foo', null, 'string'], + [true, null, 'boolean'], + [Date::now(), null, 'date'], + [ChronosDate::now(), null, 'date'], + [DateTime::now(), null, 'datetime'], + [Chronos::now(), null, 'datetime'], + [new IdentifierExpression('Table.column'), 'Table.column', null], + [new QueryExpression('Table.column'), 'Table.column', null], + [ConnectionManager::get('test')->selectQuery('a'), '(SELECT a)', null], + [new TestObjectWithToString(), null, 'string'], + [new stdClass(), null, null], + ]; + } + + /** + * @param mixed $value The else value. + * @param string|null $sqlValue The expected SQL string value. + * @param string|null $type The expected bound type. + */ + #[DataProvider('validElseValuesDataProvider')] + public function testValidElseValue($value, ?string $sqlValue, ?string $type): void + { + $expression = (new CaseStatementExpression()) + ->when(1) + ->then(2) + ->else($value); + + $valueBinder = new ValueBinder(); + $sql = $expression->sql($valueBinder); + + if ($sqlValue) { + $this->assertEqualsSql( + "CASE WHEN :c0 THEN :c1 ELSE {$sqlValue} END", + $sql, + ); + + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ], + $valueBinder->bindings(), + ); + } else { + $this->assertEqualsSql( + 'CASE WHEN :c0 THEN :c1 ELSE :c2 END', + $sql, + ); + + $this->assertSame( + [ + ':c0' => [ + 'value' => 1, + 'type' => 'integer', + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 2, + 'type' => 'integer', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => $value, + 'type' => $type, + 'placeholder' => 'c2', + ], + ], + $valueBinder->bindings(), + ); + } + } + + // endregion + + // region Invalid values + + public static function invalidCaseValuesDataProvider(): array + { + $res = fopen('data:text/plain,123', 'rb'); + fclose($res); + + return [ + [[], 'array'], + [ + function (): void { + }, + 'Closure', + ], + [$res, 'resource (closed)'], + ]; + } + + /** + * @param mixed $value The case value. + * @param string $typeName The expected error type name. + */ + #[DataProvider('invalidCaseValuesDataProvider')] + public function testInvalidCaseValue($value, string $typeName): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The `$value` argument must be either `null`, a scalar value, an object, ' . + "or an instance of `\\Cake\\Database\\ExpressionInterface`, `{$typeName}` given.", + ); + + new CaseStatementExpression($value); + } + + public function testInvalidWhenValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The `$when` argument must be a non-empty array', + ); + + (new CaseStatementExpression()) + ->when([]) + ->then(1); + } + + public static function invalidThenValueDataProvider(): array + { + $res = fopen('data:text/plain,123', 'rb'); + fclose($res); + + return [ + [[], 'array'], + [ + function (): void { + }, + 'Closure', + ], + [$res, 'resource (closed)'], + ]; + } + + /** + * @param mixed $value The then value. + * @param string $typeName The expected error type name. + */ + #[DataProvider('invalidThenValueDataProvider')] + public function testInvalidThenValue($value, string $typeName): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The `$result` argument must be either `null`, a scalar value, an object, ' . + "or an instance of `\\Cake\\Database\\ExpressionInterface`, `{$typeName}` given.", + ); + + (new CaseStatementExpression()) + ->when(1) + ->then($value); + } + + public static function invalidThenTypeDataProvider(): array + { + $res = fopen('data:text/plain,123', 'rb'); + fclose($res); + + return [ + [1], + [1.0], + [new stdClass()], + [ + function (): void { + }, + ], + [$res], // resource (closed) + ]; + } + + /** + * @param mixed $type The then type. + */ + #[DataProvider('invalidThenTypeDataProvider')] + public function testInvalidThenType($type): void + { + $this->expectException(TypeError::class); + + (new CaseStatementExpression()) + ->when(1) + ->then(1, $type); + } + + public static function invalidElseValueDataProvider(): array + { + $res = fopen('data:text/plain,123', 'rb'); + fclose($res); + + return [ + [[], 'array'], + [ + function (): void { + }, + 'Closure', + ], + [$res, 'resource (closed)'], + ]; + } + + /** + * @param mixed $value The else value. + * @param string $typeName The expected error type name. + */ + #[DataProvider('invalidElseValueDataProvider')] + public function testInvalidElseValue($value, string $typeName): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The `$result` argument must be either `null`, a scalar value, an object, ' . + "or an instance of `\\Cake\\Database\\ExpressionInterface`, `{$typeName}` given.", + ); + + (new CaseStatementExpression()) + ->when(1) + ->then(1) + ->else($value); + } + + public static function invalidElseTypeDataProvider(): array + { + $res = fopen('data:text/plain,123', 'rb'); + fclose($res); + + return [ + [1], + [1.0], + [new stdClass()], + [ + // Closure + function (): void { + }, + ], + [ + $res, //resource (closed) + ], + ]; + } + + /** + * @param mixed $type The else type. + */ + #[DataProvider('invalidElseTypeDataProvider')] + public function testInvalidElseType($type): void + { + $this->expectException(TypeError::class); + + (new CaseStatementExpression()) + ->when(1) + ->then(1) + ->else(1, $type); + } + + // endregion + + // region Traversal + + public function testTraverse(): void + { + $value = new IdentifierExpression('Table.column'); + $conditionsA = ['Table.column_a' => true, 'Table.column_b IS' => null]; + $resultA = new QueryExpression('1'); + $conditionsB = ['Table.column_c' => true, 'Table.column_d IS NOT' => null]; + $resultB = new QueryExpression('2'); + $else = new QueryExpression('3'); + + $expression = (new CaseStatementExpression($value)) + ->when($conditionsA) + ->then($resultA) + ->when($conditionsB) + ->then($resultB) + ->else($else); + + $expressions = []; + $expression->traverse(function ($expression) use (&$expressions): void { + $expressions[] = $expression; + }); + + $this->assertCount(14, $expressions); + $this->assertInstanceOf(IdentifierExpression::class, $expressions[0]); + $this->assertSame($value, $expressions[0]); + $this->assertInstanceOf(WhenThenExpression::class, $expressions[1]); + $this->assertEquals(new QueryExpression($conditionsA), $expressions[2]); + $this->assertEquals(new ComparisonExpression('Table.column_a', true), $expressions[3]); + $this->assertSame($resultA, $expressions[6]); + $this->assertInstanceOf(WhenThenExpression::class, $expressions[7]); + $this->assertEquals(new QueryExpression($conditionsB), $expressions[8]); + $this->assertEquals(new ComparisonExpression('Table.column_c', true), $expressions[9]); + $this->assertSame($resultB, $expressions[12]); + $this->assertSame($else, $expressions[13]); + } + + public function testTraverseBeforeClosingThenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.'); + + $this->deprecated(function (): void { + $expression = (new CaseStatementExpression()) + ->when(['Table.column' => true]); + + $expression->traverse( + function (): void { + }, + ); + }); + } + + // endregion + + // region Cloning + + public function testClone(): void + { + $value = new IdentifierExpression('Table.column'); + $conditionsA = ['Table.column_a' => true, 'Table.column_b IS' => null]; + $resultA = new QueryExpression('1'); + $conditionsB = ['Table.column_c' => true, 'Table.column_d IS NOT' => null]; + $resultB = new QueryExpression('2'); + $else = new QueryExpression('3'); + + $expression = (new CaseStatementExpression($value)) + ->when($conditionsA) + ->then($resultA) + ->when($conditionsB) + ->then($resultB) + ->else($else); + $clone = clone $expression; + + $this->assertEquals($clone, $expression); + $this->assertNotSame($clone, $expression); + } + + public function testCloneBeforeClosingThenFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.'); + + $this->deprecated(function (): void { + $expression = (new CaseStatementExpression()) + ->when(['Table.column' => true]); + + clone $expression; + }); + } + + // endregion +} diff --git a/tests/TestCase/Database/Expression/CommonTableExpressionTest.php b/tests/TestCase/Database/Expression/CommonTableExpressionTest.php new file mode 100644 index 00000000000..95ff2f41144 --- /dev/null +++ b/tests/TestCase/Database/Expression/CommonTableExpressionTest.php @@ -0,0 +1,140 @@ +connection = ConnectionManager::get('test'); + } + + protected function tearDown(): void + { + parent::tearDown(); + unset($this->connection); + } + + /** + * Tests constructing CommonTableExpressions. + */ + public function testCteConstructor(): void + { + $cte = new CommonTableExpression('test', $this->connection->selectQuery()); + $this->assertEqualsSql('test AS ()', $cte->sql(new ValueBinder())); + + $cte = (new CommonTableExpression()) + ->name('test') + ->query($this->connection->selectQuery()); + $this->assertEqualsSql('test AS ()', $cte->sql(new ValueBinder())); + } + + /** + * Tests setting fields. + */ + public function testFields(): void + { + $cte = (new CommonTableExpression('test', $this->connection->selectQuery())) + ->field('col1') + ->field([new IdentifierExpression('col2')]); + $this->assertEqualsSql('test(col1, col2) AS ()', $cte->sql(new ValueBinder())); + } + + /** + * Tests setting CTE materialized + */ + public function testMaterialized(): void + { + $cte = (new CommonTableExpression('test', $this->connection->selectQuery())) + ->materialized(); + $this->assertEqualsSql('test AS MATERIALIZED ()', $cte->sql(new ValueBinder())); + + $cte->notMaterialized(); + $this->assertEqualsSql('test AS NOT MATERIALIZED ()', $cte->sql(new ValueBinder())); + } + + /** + * Tests setting CTE as recursive. + */ + public function testRecursive(): void + { + $cte = (new CommonTableExpression('test', $this->connection->selectQuery())) + ->recursive(); + $this->assertTrue($cte->isRecursive()); + } + + /** + * Tests setting query using closures. + */ + public function testQueryClosures(): void + { + $cte = new CommonTableExpression('test', function () { + return $this->connection->selectQuery(); + }); + $this->assertEqualsSql('test AS ()', $cte->sql(new ValueBinder())); + + $cte->query(function () { + return $this->connection->selectQuery('1'); + }); + $this->assertEqualsSql('test AS (SELECT 1)', $cte->sql(new ValueBinder())); + } + + /** + * Tests traversing CommonTableExpression. + */ + public function testTraverse(): void + { + $query = $this->connection->selectQuery('1'); + $cte = (new CommonTableExpression('test', $query))->field('field'); + + $expressions = []; + $cte->traverse(function ($expression) use (&$expressions): void { + $expressions[] = $expression; + }); + + $this->assertEquals(new IdentifierExpression('test'), $expressions[0]); + $this->assertEquals(new IdentifierExpression('field'), $expressions[1]); + $this->assertEquals($query, $expressions[2]); + } + + /** + * Tests cloning CommonTableExpression + */ + public function testClone(): void + { + $cte = new CommonTableExpression('test', function () { + return $this->connection->selectQuery('1'); + }); + $cte2 = (clone $cte)->name('test2'); + $this->assertNotSame($cte->sql(new ValueBinder()), $cte2->sql(new ValueBinder())); + + $cte2 = (clone $cte)->field('col1'); + $this->assertNotSame($cte->sql(new ValueBinder()), $cte2->sql(new ValueBinder())); + } +} diff --git a/tests/TestCase/Database/Expression/ComparisonExpressionTest.php b/tests/TestCase/Database/Expression/ComparisonExpressionTest.php new file mode 100644 index 00000000000..efd16cca1d1 --- /dev/null +++ b/tests/TestCase/Database/Expression/ComparisonExpressionTest.php @@ -0,0 +1,61 @@ +assertEqualsSql('field = other_field', $expr->sql(new ValueBinder())); + + $expr = new ComparisonExpression(new IdentifierExpression('field'), new IdentifierExpression('other_field')); + $this->assertEqualsSql('field = other_field', $expr->sql(new ValueBinder())); + + $expr = new ComparisonExpression(new IdentifierExpression('field'), new QueryExpression(['other_field'])); + $this->assertEqualsSql('field = (other_field)', $expr->sql(new ValueBinder())); + + $expr = new ComparisonExpression(new IdentifierExpression('field'), 'value'); + $this->assertEqualsSql('field = :c0', $expr->sql(new ValueBinder())); + + $expr = new ComparisonExpression(new QueryExpression(['field']), new IdentifierExpression('other_field')); + $this->assertEqualsSql('field = other_field', $expr->sql(new ValueBinder())); + } + + /** + * Tests that cloning Comparion instance clones it's value and field expressions. + */ + public function testClone(): void + { + $exp = new ComparisonExpression(new QueryExpression('field1'), 1, 'integer', '<'); + $exp2 = clone $exp; + + $this->assertNotSame($exp->getField(), $exp2->getField()); + } +} diff --git a/tests/TestCase/Database/Expression/FunctionExpressionTest.php b/tests/TestCase/Database/Expression/FunctionExpressionTest.php new file mode 100644 index 00000000000..2dca0d7db3a --- /dev/null +++ b/tests/TestCase/Database/Expression/FunctionExpressionTest.php @@ -0,0 +1,135 @@ +expressionClass('MyFunction'); + $this->assertSame('MyFunction()', $f->sql(new ValueBinder())); + } + + /** + * Tests generating a function one or multiple arguments and make sure + * they are correctly replaced by placeholders + */ + public function testArityMultiplePlainValues(): void + { + $f = new $this->expressionClass('MyFunction', ['foo', 'bar']); + $binder = new ValueBinder(); + $this->assertSame('MyFunction(:param0, :param1)', $f->sql($binder)); + + $this->assertSame('foo', $binder->bindings()[':param0']['value']); + $this->assertSame('bar', $binder->bindings()[':param1']['value']); + + $binder = new ValueBinder(); + $f = new $this->expressionClass('MyFunction', ['bar']); + $this->assertSame('MyFunction(:param0)', $f->sql($binder)); + $this->assertSame('bar', $binder->bindings()[':param0']['value']); + } + + /** + * Tests that it is possible to use literal strings as arguments + */ + public function testLiteralParams(): void + { + $binder = new ValueBinder(); + $f = new $this->expressionClass('MyFunction', ['foo' => 'literal', 'bar']); + $this->assertSame('MyFunction(foo, :param0)', $f->sql($binder)); + } + + /** + * Tests that it is possible to nest expression objects and pass them as arguments + * In particular nesting multiple FunctionExpression + */ + public function testFunctionNesting(): void + { + $binder = new ValueBinder(); + $f = new $this->expressionClass('MyFunction', ['foo', 'bar']); + $g = new $this->expressionClass('Wrapper', ['bar' => 'literal', $f]); + $this->assertSame('Wrapper(bar, MyFunction(:param0, :param1))', $g->sql($binder)); + } + + /** + * Tests to avoid regression, prevents double parenthesis + * In particular nesting with QueryExpression + */ + public function testFunctionNestingQueryExpression(): void + { + $binder = new ValueBinder(); + $q = new QueryExpression('a'); + $f = new $this->expressionClass('MyFunction', [$q]); + $this->assertSame('MyFunction(a)', $f->sql($binder)); + } + + /** + * Tests that passing a database query as an argument wraps the query SQL into parentheses. + */ + public function testFunctionWithDatabaseQuery(): void + { + $query = ConnectionManager::get('test') + ->selectQuery(['column']); + + $binder = new ValueBinder(); + $function = new $this->expressionClass('MyFunction', [$query]); + $this->assertSame( + 'MyFunction((SELECT column))', + preg_replace('/[`"\[\]]/', '', $function->sql($binder)), + ); + } + + /** + * Tests that it is possible to use a number as a literal in a function. + */ + public function testNumericLiteral(): void + { + $binder = new ValueBinder(); + $f = new $this->expressionClass('MyFunction', ['a_field' => 'literal', '32' => 'literal']); + $this->assertSame('MyFunction(a_field, 32)', $f->sql($binder)); + + $f = new $this->expressionClass('MyFunction', ['a_field' => 'literal', 32 => 'literal']); + $this->assertSame('MyFunction(a_field, 32)', $f->sql($binder)); + } + + /** + * Tests setReturnType() and getReturnType() + */ + public function testGetSetReturnType(): void + { + $f = new $this->expressionClass('MyFunction'); + $f = $f->setReturnType('foo'); + $this->assertInstanceOf($this->expressionClass, $f); + $this->assertSame('foo', $f->getReturnType()); + } +} diff --git a/tests/TestCase/Database/Expression/IdentifierExpressionTest.php b/tests/TestCase/Database/Expression/IdentifierExpressionTest.php new file mode 100644 index 00000000000..66902aa83ea --- /dev/null +++ b/tests/TestCase/Database/Expression/IdentifierExpressionTest.php @@ -0,0 +1,56 @@ +assertSame('foo', $expression->getIdentifier()); + $expression->setIdentifier('bar'); + $this->assertSame('bar', $expression->getIdentifier()); + } + + /** + * Tests converting to sql + */ + public function testSQL(): void + { + $expression = new IdentifierExpression('foo'); + $this->assertSame('foo', $expression->sql(new ValueBinder())); + } + + /** + * Tests setting collation. + */ + public function testCollation(): void + { + $expresssion = new IdentifierExpression('test', 'utf8_general_ci'); + $this->assertSame('test COLLATE utf8_general_ci', $expresssion->sql(new ValueBinder())); + } +} diff --git a/tests/TestCase/Database/Expression/QueryExpressionTest.php b/tests/TestCase/Database/Expression/QueryExpressionTest.php new file mode 100644 index 00000000000..3543645cef1 --- /dev/null +++ b/tests/TestCase/Database/Expression/QueryExpressionTest.php @@ -0,0 +1,305 @@ +assertSame($expr, $expr->setConjunction('+')); + $this->assertSame('+', $expr->getConjunction()); + + $result = $expr->sql($binder); + $this->assertSame('(1 + 2)', $result); + } + + /** + * Tests conditions with multi-word operators. + * + * @return void + */ + public function testMultiWordOperators(): void + { + $expr = new QueryExpression(['FUNC(Users.first + Users.last) is not' => 'me']); + $this->assertSame('FUNC(Users.first + Users.last) != :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['FUNC(Users.name + Users.id) NOT SIMILAR TO' => 'pattern']); + $this->assertSame('FUNC(Users.name + Users.id) NOT SIMILAR TO :c0', $expr->sql(new ValueBinder())); + } + + /** + * Tests conditions with symbol operators. + * + * @return void + */ + public function testSymbolOperators(): void + { + $expr = new QueryExpression(['Users.name =' => 'pattern']); + $this->assertSame('Users.name = :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name !=' => 'pattern']); + $this->assertSame('Users.name != :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name <>' => 'pattern']); + $this->assertSame('Users.name <> :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name >=' => 'pattern']); + $this->assertSame('Users.name >= :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name <=' => 'pattern']); + $this->assertSame('Users.name <= :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name !~' => 'pattern']); + $this->assertSame('Users.name !~ :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name *' => 'pattern']); + $this->assertSame('Users.name * :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name -' => 'pattern']); + $this->assertSame('Users.name - :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name \\' => 'pattern']); + $this->assertSame('Users.name \\ :c0', $expr->sql(new ValueBinder())); + + $expr = new QueryExpression(['Users.name @>' => 'pattern']); + $this->assertSame('Users.name @> :c0', $expr->sql(new ValueBinder())); + } + + /** + * Test and() and or() calls work transparently + */ + public function testAndOrCalls(): void + { + $expr = new QueryExpression(); + $expected = QueryExpression::class; + $this->assertInstanceOf($expected, $expr->and([])); + $this->assertInstanceOf($expected, $expr->or([])); + } + + /** + * Test SQL generation with one element + */ + public function testSqlGenerationOneClause(): void + { + $expr = new QueryExpression(); + $binder = new ValueBinder(); + $expr->add(['Users.username' => 'sally'], ['Users.username' => 'string']); + + $result = $expr->sql($binder); + $this->assertSame('Users.username = :c0', $result); + } + + /** + * Test SQL generation with many elements + */ + public function testSqlGenerationMultipleClauses(): void + { + $expr = new QueryExpression(); + $binder = new ValueBinder(); + $expr->add( + [ + 'Users.username' => 'sally', + 'Users.active' => 1, + ], + [ + 'Users.username' => 'string', + 'Users.active' => 'boolean', + ], + ); + + $result = $expr->sql($binder); + $this->assertSame('(Users.username = :c0 AND Users.active = :c1)', $result); + } + + /** + * Test that empty expressions don't emit invalid SQL. + */ + public function testSqlWhenEmpty(): void + { + $expr = new QueryExpression(); + $binder = new ValueBinder(); + $result = $expr->sql($binder); + $this->assertSame('', $result); + } + + /** + * Test deep cloning of expression trees. + */ + public function testDeepCloning(): void + { + $expr = new QueryExpression(); + $expr = $expr->add(new QueryExpression('1 + 1')) + ->isNull('deleted') + ->like('title', 'things%'); + + $dupe = clone $expr; + $this->assertEquals($dupe, $expr); + $this->assertNotSame($dupe, $expr); + $originalParts = []; + $expr->iterateParts(function ($part) use (&$originalParts): void { + $originalParts[] = $part; + }); + $dupe->iterateParts(function ($part, $i) use ($originalParts): void { + $this->assertNotSame($originalParts[$i], $part); + }); + } + + /** + * Tests the hasNestedExpression() function + */ + public function testHasNestedExpression(): void + { + $expr = new QueryExpression(); + $this->assertFalse($expr->hasNestedExpression()); + + $expr->add(['a' => 'b']); + $this->assertTrue($expr->hasNestedExpression()); + + $expr = new QueryExpression(); + $expr->add('a = b'); + $this->assertFalse($expr->hasNestedExpression()); + + $expr->add(new QueryExpression('1 = 1')); + $this->assertTrue($expr->hasNestedExpression()); + } + + /** + * Returns the list of specific comparison methods + * + * @return array + */ + public static function methodsProvider(): array + { + return [ + ['eq'], ['notEq'], ['gt'], ['lt'], ['gte'], ['lte'], ['like'], + ['notLike'], ['in'], ['notIn'], + ]; + } + + /** + * Tests that the query expression uses the type map when the + * specific comparison functions are used. + */ + #[DataProvider('methodsProvider')] + public function testTypeMapUsage(string $method): void + { + $expr = new QueryExpression([], ['created' => 'date']); + $expr->{$method}('created', 'foo'); + + $binder = new ValueBinder(); + $expr->sql($binder); + $bindings = $binder->bindings(); + $type = current($bindings)['type']; + + $this->assertSame('date', $type); + } + + /** + * Tests that creating query expressions with either the + * array notation or using the combinators will produce a + * zero-count expression object. + * + * @see https://github.com/cakephp/cakephp/issues/12081 + */ + public function testEmptyOr(): void + { + $expr = new QueryExpression(); + $expr = $expr->or([]); + $expr = $expr->or([]); + $this->assertCount(0, $expr); + + $expr = new QueryExpression(['OR' => []]); + $this->assertCount(0, $expr); + } + + /** + * Tests that both conditions are generated for notInOrNull(). + */ + public function testNotInOrNull(): void + { + $expr = new QueryExpression(); + $expr->notInOrNull('test', ['one', 'two']); + $this->assertEqualsSql( + '(test NOT IN (:c0,:c1) OR (test) IS NULL)', + $expr->sql(new ValueBinder()), + ); + } + + public function testCaseWithoutValue(): void + { + $expression = (new QueryExpression()) + ->case() + ->when(1) + ->then(2); + + $this->assertEqualsSql( + 'CASE WHEN :c0 THEN :c1 ELSE NULL END', + $expression->sql(new ValueBinder()), + ); + } + + public function testCaseWithNullValue(): void + { + $expression = (new QueryExpression()) + ->case(null) + ->when(1) + ->then('Yes'); + + $this->assertEqualsSql( + 'CASE NULL WHEN :c0 THEN :c1 ELSE NULL END', + $expression->sql(new ValueBinder()), + ); + } + + public function testCaseWithValueAndType(): void + { + $expression = (new QueryExpression()) + ->case('1', 'integer') + ->when(1) + ->then('Yes'); + + $valueBinder = new ValueBinder(); + + $this->assertEqualsSql( + 'CASE :c0 WHEN :c1 THEN :c2 ELSE NULL END', + $expression->sql($valueBinder), + ); + + $this->assertSame( + [ + 'value' => '1', + 'type' => 'integer', + 'placeholder' => 'c0', + ], + $valueBinder->bindings()[':c0'], + ); + } +} diff --git a/tests/TestCase/Database/Expression/StringExpressionTest.php b/tests/TestCase/Database/Expression/StringExpressionTest.php new file mode 100644 index 00000000000..97e749d53cd --- /dev/null +++ b/tests/TestCase/Database/Expression/StringExpressionTest.php @@ -0,0 +1,36 @@ +assertSame(':c0 COLLATE utf8_general_ci', $expr->sql($binder)); + $this->assertSame('testString', $binder->bindings()[':c0']['value']); + $this->assertSame('string', $binder->bindings()[':c0']['type']); + } +} diff --git a/tests/TestCase/Database/Expression/TupleComparisonTest.php b/tests/TestCase/Database/Expression/TupleComparisonTest.php new file mode 100644 index 00000000000..4c417c93b8b --- /dev/null +++ b/tests/TestCase/Database/Expression/TupleComparisonTest.php @@ -0,0 +1,171 @@ +assertSame('(field1, field2) = (:tuple0, :tuple1)', $f->sql($binder)); + $this->assertSame(1, $binder->bindings()[':tuple0']['value']); + $this->assertSame(2, $binder->bindings()[':tuple1']['value']); + $this->assertSame('integer', $binder->bindings()[':tuple0']['type']); + $this->assertSame('integer', $binder->bindings()[':tuple1']['type']); + } + + /** + * Tests generating tuples in the fields side containing expressions + */ + public function testTupleWithExpressionFields(): void + { + $field1 = new QueryExpression(['a' => 1]); + $f = new TupleComparison([$field1, 'field2'], [4, 5], ['integer', 'integer'], '>'); + $binder = new ValueBinder(); + $this->assertSame('(a = :c0, field2) > (:tuple1, :tuple2)', $f->sql($binder)); + $this->assertSame(1, $binder->bindings()[':c0']['value']); + $this->assertSame(4, $binder->bindings()[':tuple1']['value']); + $this->assertSame(5, $binder->bindings()[':tuple2']['value']); + } + + /** + * Tests generating tuples in the values side containing expressions + */ + public function testTupleWithExpressionValues(): void + { + $value1 = new QueryExpression(['a' => 1]); + $f = new TupleComparison(['field1', 'field2'], [$value1, 2], ['integer', 'integer'], '='); + $binder = new ValueBinder(); + $this->assertSame('(field1, field2) = (a = :c0, :tuple1)', $f->sql($binder)); + $this->assertSame(1, $binder->bindings()[':c0']['value']); + $this->assertSame(2, $binder->bindings()[':tuple1']['value']); + } + + /** + * Tests generating tuples using the IN conjunction + */ + public function testTupleWithInComparison(): void + { + $f = new TupleComparison( + ['field1', 'field2'], + [[1, 2], [3, 4]], + ['integer', 'integer'], + 'IN', + ); + $binder = new ValueBinder(); + $this->assertSame('(field1, field2) IN ((:tuple0,:tuple1), (:tuple2,:tuple3))', $f->sql($binder)); + $this->assertSame(1, $binder->bindings()[':tuple0']['value']); + $this->assertSame(2, $binder->bindings()[':tuple1']['value']); + $this->assertSame(3, $binder->bindings()[':tuple2']['value']); + $this->assertSame(4, $binder->bindings()[':tuple3']['value']); + } + + /** + * Tests traversing + */ + public function testTraverse(): void + { + $value1 = new QueryExpression(['a' => 1]); + $field2 = new QueryExpression(['b' => 2]); + $f = new TupleComparison(['field1', $field2], [$value1, 2], ['integer', 'integer'], '='); + $expressions = []; + + $collector = function ($e) use (&$expressions): void { + $expressions[] = $e; + }; + + $f->traverse($collector); + $this->assertCount(4, $expressions); + $this->assertSame($field2, $expressions[0]); + $this->assertSame($value1, $expressions[2]); + + $f = new TupleComparison( + ['field1', $field2], + [[1, 2], [3, $value1]], + ['integer', 'integer'], + 'IN', + ); + $expressions = []; + $f->traverse($collector); + $this->assertCount(4, $expressions); + $this->assertSame($field2, $expressions[0]); + $this->assertSame($value1, $expressions[2]); + } + + /** + * Tests that a single ExpressionInterface can be used as the value for + * comparison + */ + public function testValueAsSingleExpression(): void + { + $value = new QueryExpression('SELECT 1, 1'); + $f = new TupleComparison(['field1', 'field2'], $value); + $binder = new ValueBinder(); + $this->assertSame('(field1, field2) = (SELECT 1, 1)', $f->sql($binder)); + } + + /** + * Tests that a single ExpressionInterface can be used as the field for + * comparison + */ + public function testFieldAsSingleExpression(): void + { + $value = [1, 1]; + $f = new TupleComparison(new QueryExpression('a, b'), $value); + $binder = new ValueBinder(); + $this->assertSame('(a, b) = (:tuple0, :tuple1)', $f->sql($binder)); + } + + public function testMultiTupleComparisonRequiresMultiTupleValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Multi-tuple comparisons require a multi-tuple value, single-tuple given.'); + + new TupleComparison( + ['field1', 'field2'], + [1, 1], + ['integer', 'integer'], + 'IN', + ); + } + + public function testSingleTupleComparisonRequiresSingleTupleValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Single-tuple comparisons require a single-tuple value, multi-tuple given.'); + + new TupleComparison( + ['field1', 'field2'], + [[1, 1], [2, 2]], + ['integer', 'integer'], + '=', + ); + } +} diff --git a/tests/TestCase/Database/Expression/WindowExpressionTest.php b/tests/TestCase/Database/Expression/WindowExpressionTest.php new file mode 100644 index 00000000000..47a018538d7 --- /dev/null +++ b/tests/TestCase/Database/Expression/WindowExpressionTest.php @@ -0,0 +1,458 @@ +assertSame('', $w->sql(new ValueBinder())); + + $w->partition('')->orderBy([]); + $this->assertSame('', $w->sql(new ValueBinder())); + } + + /** + * Tests windows with partitions + */ + public function testPartitions(): void + { + $w = (new WindowExpression())->partition('test'); + $this->assertEqualsSql( + 'PARTITION BY test', + $w->sql(new ValueBinder()), + ); + + $w->partition(new IdentifierExpression('identifier')); + $this->assertEqualsSql( + 'PARTITION BY test, identifier', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->partition(new AggregateExpression('MyAggregate', ['param'])); + $this->assertEqualsSql( + 'PARTITION BY MyAggregate(:param0)', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->partition(function (QueryExpression $expr) { + return $expr->add(new AggregateExpression('MyAggregate', ['param'])); + }); + $this->assertEqualsSql( + 'PARTITION BY MyAggregate(:param0)', + $w->sql(new ValueBinder()), + ); + } + + /** + * Tests windows with order by + */ + public function testOrderBy(): void + { + $w = (new WindowExpression())->orderBy('test'); + $this->assertEqualsSql( + 'ORDER BY test', + $w->sql(new ValueBinder()), + ); + + $w->orderBy(['test2' => 'DESC']); + $this->assertEqualsSql( + 'ORDER BY test, test2 DESC', + $w->sql(new ValueBinder()), + ); + + $w->partition('test'); + $this->assertEqualsSql( + 'PARTITION BY test ORDER BY test, test2 DESC', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression()) + ->orderBy(function () { + return 'test'; + }) + ->orderBy(function (QueryExpression $expr) { + return [$expr->add('test2'), new OrderClauseExpression(new IdentifierExpression('test3'), 'DESC')]; + }); + $this->assertEqualsSql( + 'ORDER BY test, test2, test3 DESC', + $w->sql(new ValueBinder()), + ); + } + + public function testOrderDeprecated(): void + { + $this->deprecated(function (): void { + $w = (new WindowExpression())->order('test'); + $this->assertEqualsSql( + 'ORDER BY test', + $w->sql(new ValueBinder()), + ); + }); + } + + /** + * Tests windows with range frames + */ + public function testRange(): void + { + $w = (new WindowExpression())->range(null); + $this->assertEqualsSql( + 'RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(0); + $this->assertEqualsSql( + 'RANGE BETWEEN CURRENT ROW AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(2); + $this->assertEqualsSql( + 'RANGE BETWEEN 2 PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(null, null); + $this->assertEqualsSql( + 'RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(0, null); + $this->assertEqualsSql( + 'RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(0, 0); + $this->assertEqualsSql( + 'RANGE BETWEEN CURRENT ROW AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(1, 2); + $this->assertEqualsSql( + 'RANGE BETWEEN 1 PRECEDING AND 2 FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range("'1 day'", "'10 days'"); + $this->assertRegExpSql( + "RANGE BETWEEN '1 day' PRECEDING AND '10 days' FOLLOWING", + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(new QueryExpression("'1 day'"), new QueryExpression("'10 days'")); + $this->assertRegExpSql( + "RANGE BETWEEN '1 day' PRECEDING AND '10 days' FOLLOWING", + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->frame( + WindowExpression::RANGE, + 2, + WindowExpression::PRECEDING, + 1, + WindowExpression::PRECEDING, + ); + $this->assertEqualsSql( + 'RANGE BETWEEN 2 PRECEDING AND 1 PRECEDING', + $w->sql(new ValueBinder()), + ); + } + + /** + * Tests windows with rows frames + */ + public function testRows(): void + { + $w = (new WindowExpression())->rows(null); + $this->assertEqualsSql( + 'ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->rows(0); + $this->assertEqualsSql( + 'ROWS BETWEEN CURRENT ROW AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->rows(2); + $this->assertEqualsSql( + 'ROWS BETWEEN 2 PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->rows(null, null); + $this->assertEqualsSql( + 'ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->rows(0, null); + $this->assertEqualsSql( + 'ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->rows(0, 0); + $this->assertEqualsSql( + 'ROWS BETWEEN CURRENT ROW AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->rows(1, 2); + $this->assertEqualsSql( + 'ROWS BETWEEN 1 PRECEDING AND 2 FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->frame( + WindowExpression::ROWS, + 2, + WindowExpression::PRECEDING, + 1, + WindowExpression::PRECEDING, + ); + $b = new ValueBinder(); + $this->assertEqualsSql( + 'ROWS BETWEEN 2 PRECEDING AND 1 PRECEDING', + $w->sql($b), + ); + } + + /** + * Tests windows with groups frames + */ + public function testGroups(): void + { + $w = (new WindowExpression())->groups(null); + $this->assertEqualsSql( + 'GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->groups(0); + $this->assertEqualsSql( + 'GROUPS BETWEEN CURRENT ROW AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->groups(2); + $this->assertEqualsSql( + 'GROUPS BETWEEN 2 PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->groups(null, null); + $this->assertEqualsSql( + 'GROUPS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->groups(0, null); + $this->assertEqualsSql( + 'GROUPS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->groups(0, 0); + $this->assertEqualsSql( + 'GROUPS BETWEEN CURRENT ROW AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->groups(1, 2); + $this->assertEqualsSql( + 'GROUPS BETWEEN 1 PRECEDING AND 2 FOLLOWING', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->frame( + WindowExpression::GROUPS, + 2, + WindowExpression::PRECEDING, + 1, + WindowExpression::PRECEDING, + ); + $b = new ValueBinder(); + $this->assertEqualsSql( + 'GROUPS BETWEEN 2 PRECEDING AND 1 PRECEDING', + $w->sql($b), + ); + } + + /** + * Tests windows with frame exclusion + */ + public function testExclusion(): void + { + $w = (new WindowExpression())->excludeCurrent(); + $this->assertEqualsSql( + '', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(null)->excludeCurrent(); + $this->assertEqualsSql( + 'RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(null)->excludeGroup(); + $this->assertEqualsSql( + 'RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->range(null)->excludeTies(); + $this->assertEqualsSql( + 'RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES', + $w->sql(new ValueBinder()), + ); + } + + /** + * Tests windows with partition, order and frames + */ + public function testCombined(): void + { + $w = (new WindowExpression())->partition('test')->range(null); + $this->assertEqualsSql( + 'PARTITION BY test RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->orderBy('test')->range(null); + $this->assertEqualsSql( + 'ORDER BY test RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->partition('test')->orderBy('test')->range(null); + $this->assertEqualsSql( + 'PARTITION BY test ORDER BY test RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW', + $w->sql(new ValueBinder()), + ); + + $w = (new WindowExpression())->partition('test')->orderBy('test')->range(null)->excludeCurrent(); + $this->assertEqualsSql( + 'PARTITION BY test ORDER BY test RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW', + $w->sql(new ValueBinder()), + ); + } + + /** + * Tests named windows + */ + public function testNamedWindow(): void + { + $w = new WindowExpression(); + $this->assertFalse($w->isNamedOnly()); + + $w->name('name'); + $this->assertTrue($w->isNamedOnly()); + $this->assertEqualsSql( + 'name', + $w->sql(new ValueBinder()), + ); + + $w->name('new_name'); + $this->assertEqualsSql( + 'new_name', + $w->sql(new ValueBinder()), + ); + + $w->orderBy('test'); + $this->assertFalse($w->isNamedOnly()); + $this->assertEqualsSql( + 'new_name ORDER BY test', + $w->sql(new ValueBinder()), + ); + } + + /** + * Tests traversing window expressions. + */ + public function testTraverse(): void + { + $w = (new WindowExpression('test1')) + ->partition('test2') + ->orderBy('test3') + ->range(new QueryExpression("'1 day'")); + + $expressions = []; + $w->traverse(function ($expression) use (&$expressions): void { + $expressions[] = $expression; + }); + + $this->assertEquals(new IdentifierExpression('test1'), $expressions[0]); + $this->assertEquals(new IdentifierExpression('test2'), $expressions[1]); + $this->assertEquals((new OrderByExpression())->add('test3'), $expressions[2]); + $this->assertEquals(new QueryExpression("'1 day'"), $expressions[3]); + + $w->range(new QueryExpression("'1 day'"), new QueryExpression("'10 days'")); + + $expressions = []; + $w->traverse(function ($expression) use (&$expressions): void { + $expressions[] = $expression; + }); + + $this->assertEquals(new QueryExpression("'1 day'"), $expressions[3]); + $this->assertEquals(new QueryExpression("'10 days'"), $expressions[4]); + } + + /** + * Tests cloning window expressions + */ + public function testCloning(): void + { + $w1 = (new WindowExpression())->name('test'); + $w2 = (clone $w1)->name('test2'); + $this->assertNotSame($w1->sql(new ValueBinder()), $w2->sql(new ValueBinder())); + + $w1 = (new WindowExpression())->partition('test'); + $w2 = (clone $w1)->partition('new'); + $this->assertNotSame($w1->sql(new ValueBinder()), $w2->sql(new ValueBinder())); + + $w1 = (new WindowExpression())->orderBy('test'); + $w2 = (clone $w1)->orderBy('new'); + $this->assertNotSame($w1->sql(new ValueBinder()), $w2->sql(new ValueBinder())); + + $w1 = (new WindowExpression())->rows(0, null); + $w2 = (clone $w1)->rows(0, 0); + $this->assertNotSame($w1->sql(new ValueBinder()), $w2->sql(new ValueBinder())); + } +} diff --git a/tests/TestCase/Database/ExpressionTypeCastingIntegrationTest.php b/tests/TestCase/Database/ExpressionTypeCastingIntegrationTest.php new file mode 100644 index 00000000000..3dc95618e62 --- /dev/null +++ b/tests/TestCase/Database/ExpressionTypeCastingIntegrationTest.php @@ -0,0 +1,173 @@ +connection = ConnectionManager::get('test'); + $this->skipIf($this->connection->getDriver() instanceof Sqlserver, 'This tests uses functions specific to other drivers'); + TypeFactory::map('ordered_uuid', OrderedUuidType::class); + } + + protected function _insert(): void + { + $query = $this->connection->insertQuery(); + $query + ->insert(['id', 'published', 'name'], ['id' => 'ordered_uuid']) + ->into('ordered_uuid_items') + ->values(['id' => '481fc6d0-b920-43e0-a40d-6d1740cf8569', 'published' => 0, 'name' => 'Item 1']) + ->values(['id' => '48298a29-81c0-4c26-a7fb-413140cf8569', 'published' => 0, 'name' => 'Item 2']) + ->values(['id' => '482b7756-8da0-419a-b21f-27da40cf8569', 'published' => 0, 'name' => 'Item 3']); + + $query->execute(); + } + + /** + * Tests inserting a value that is to be converted to an expression + * automatically + */ + public function testInsert(): void + { + $this->_insert(); + $query = $this->connection->selectQuery('id', 'ordered_uuid_items') + ->orderBy('id') + ->setDefaultTypes(['id' => 'ordered_uuid']); + + $query->setSelectTypeMap($query->getTypeMap()); + $results = $query->execute()->fetchAll('assoc'); + + $this->assertEquals(new UuidValue('419a8da0482b7756b21f27da40cf8569'), $results[0]['id']); + $this->assertEquals(new UuidValue('43e0b920481fc6d0a40d6d1740cf8569'), $results[1]['id']); + $this->assertEquals(new UuidValue('4c2681c048298a29a7fb413140cf8569'), $results[2]['id']); + } + + /** + * Test selecting with a custom expression type using conditions + */ + public function testSelectWithConditions(): void + { + $this->_insert(); + $result = $this->connection->selectQuery('id', 'ordered_uuid_items') + ->where(['id' => '48298a29-81c0-4c26-a7fb-413140cf8569'], ['id' => 'ordered_uuid']) + ->execute() + ->fetchAll('assoc'); + + $this->assertCount(1, $result); + $this->assertSame('4c2681c048298a29a7fb413140cf8569', $result[0]['id']); + } + + /** + * Tests Select using value object in conditions + */ + public function testSelectWithConditionsValueObject(): void + { + $this->_insert(); + $result = $this->connection->selectQuery('id', 'ordered_uuid_items') + ->where(['id' => new UuidValue('48298a29-81c0-4c26-a7fb-413140cf8569')], ['id' => 'ordered_uuid']) + ->execute() + ->fetchAll('assoc'); + + $this->assertCount(1, $result); + $this->assertSame('4c2681c048298a29a7fb413140cf8569', $result[0]['id']); + } + + /** + * Tests using the expression type in with an IN condition + * + * @var string + */ + public function testSelectWithInCondition(): void + { + $this->_insert(); + $result = $this->connection->selectQuery('id', 'ordered_uuid_items') + ->where( + ['id' => ['48298a29-81c0-4c26-a7fb-413140cf8569', '482b7756-8da0-419a-b21f-27da40cf8569']], + ['id' => 'ordered_uuid[]'], + ) + ->orderBy('id') + ->execute() + ->fetchAll('assoc'); + + $this->assertCount(2, $result); + $this->assertSame('419a8da0482b7756b21f27da40cf8569', $result[0]['id']); + $this->assertSame('419a8da0482b7756b21f27da40cf8569', $result[0]['id']); + } + + /** + * Tests using an expression type in a between condition + */ + public function testSelectWithBetween(): void + { + $this->_insert(); + $result = $this->connection->selectQuery(fields: 'id', table: 'ordered_uuid_items') + ->where(function (QueryExpression $exp) { + return $exp->between( + 'id', + '482b7756-8da0-419a-b21f-27da40cf8569', + '48298a29-81c0-4c26-a7fb-413140cf8569', + 'ordered_uuid', + ); + }) + ->execute() + ->fetchAll('assoc'); + + $this->assertCount(3, $result); + } + + /** + * Tests using an expression type inside a function expression + */ + public function testSelectWithFunction(): void + { + $this->_insert(); + $result = $this->connection->selectQuery(fields: 'id', table: 'ordered_uuid_items') + ->where(function (QueryExpression $exp, Query $q) { + return $exp->eq( + 'id', + $q->func()->concat(['48298a29-81c0-4c26-a7fb', '-413140cf8569'], []), + 'ordered_uuid', + ); + }) + ->execute() + ->fetchAll('assoc'); + + $this->assertCount(1, $result); + $this->assertSame('4c2681c048298a29a7fb413140cf8569', $result[0]['id']); + } +} diff --git a/tests/TestCase/Database/ExpressionTypeCastingTest.php b/tests/TestCase/Database/ExpressionTypeCastingTest.php new file mode 100644 index 00000000000..b1541ba371a --- /dev/null +++ b/tests/TestCase/Database/ExpressionTypeCastingTest.php @@ -0,0 +1,149 @@ +sql($binder); + $this->assertSame('field = (CONCAT(:param0, :param1))', $sql); + $this->assertSame('the thing', $binder->bindings()[':param0']['value']); + + $found = false; + $comparison->traverse(function ($exp) use (&$found): void { + $found = $exp instanceof FunctionExpression; + }); + $this->assertTrue($found, 'The expression is not included in the tree'); + } + + /** + * Tests that the Comparison expression can handle values convertible to + * expressions + */ + public function testComparisonMultiple(): void + { + $comparison = new ComparisonExpression('field', ['2', '3'], 'test[]', 'IN'); + $binder = new ValueBinder(); + $sql = $comparison->sql($binder); + $this->assertSame('field IN (CONCAT(:param0, :param1),CONCAT(:param2, :param3))', $sql); + $this->assertSame('2', $binder->bindings()[':param0']['value']); + $this->assertSame('3', $binder->bindings()[':param2']['value']); + + $found = false; + $comparison->traverse(function ($exp) use (&$found): void { + $found = $exp instanceof FunctionExpression; + }); + $this->assertTrue($found, 'The expression is not included in the tree'); + } + + /** + * Tests that the Between expression casts values to expressions correctly + */ + public function testBetween(): void + { + $between = new BetweenExpression('field', 'from', 'to', 'test'); + $binder = new ValueBinder(); + $sql = $between->sql($binder); + $this->assertSame('field BETWEEN CONCAT(:param0, :param1) AND CONCAT(:param2, :param3)', $sql); + $this->assertSame('from', $binder->bindings()[':param0']['value']); + $this->assertSame('to', $binder->bindings()[':param2']['value']); + + $expressions = []; + $between->traverse(function ($exp) use (&$expressions): void { + $expressions[] = $exp instanceof FunctionExpression ? 1 : 0; + }); + + $result = array_sum($expressions); + $this->assertSame(2, $result, 'Missing expressions in the tree'); + } + + /** + * Tests that values in FunctionExpressions are converted to expressions correctly + */ + public function testFunctionExpression(): void + { + $function = new FunctionExpression('DATE', ['2016-01'], ['test']); + $binder = new ValueBinder(); + $sql = $function->sql($binder); + $this->assertSame('DATE(CONCAT(:param0, :param1))', $sql); + $this->assertSame('2016-01', $binder->bindings()[':param0']['value']); + + $expressions = []; + $function->traverse(function ($exp) use (&$expressions): void { + $expressions[] = $exp instanceof FunctionExpression ? 1 : 0; + }); + + $result = array_sum($expressions); + $this->assertSame(1, $result, 'Missing expressions in the tree'); + } + + /** + * Tests that values in ValuesExpression are converted to expressions correctly + */ + public function testValuesExpression(): void + { + $values = new ValuesExpression(['title'], new TypeMap(['title' => 'test'])); + $values->add(['title' => 'foo']); + $values->add(['title' => 'bar']); + + $binder = new ValueBinder(); + $sql = $values->sql($binder); + $this->assertSame( + ' VALUES ((CONCAT(:param0, :param1))), ((CONCAT(:param2, :param3)))', + $sql, + ); + $this->assertSame('foo', $binder->bindings()[':param0']['value']); + $this->assertSame('bar', $binder->bindings()[':param2']['value']); + + $expressions = []; + $values->traverse(function ($exp) use (&$expressions): void { + $expressions[] = $exp instanceof FunctionExpression ? 1 : 0; + }); + + $result = array_sum($expressions); + $this->assertSame(2, $result, 'Missing expressions in the tree'); + } +} diff --git a/tests/TestCase/Database/FunctionsBuilderTest.php b/tests/TestCase/Database/FunctionsBuilderTest.php new file mode 100644 index 00000000000..082c74f10ee --- /dev/null +++ b/tests/TestCase/Database/FunctionsBuilderTest.php @@ -0,0 +1,307 @@ +functions = new FunctionsBuilder(); + } + + /** + * Tests generating a generic function call + */ + public function testArbitrary(): void + { + $function = $this->functions->MyFunc(['b' => 'literal']); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('MyFunc', $function->getName()); + $this->assertSame('MyFunc(b)', $function->sql(new ValueBinder())); + + $function = $this->functions->MyFunc(['b'], ['string'], 'integer'); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a generic aggregate call + */ + public function testArbitraryAggregate(): void + { + $function = $this->functions->aggregate('MyFunc', ['b' => 'literal']); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('MyFunc', $function->getName()); + $this->assertSame('MyFunc(b)', $function->sql(new ValueBinder())); + + $function = $this->functions->aggregate('MyFunc', ['b'], ['string'], 'integer'); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a SUM() function + */ + public function testSum(): void + { + $function = $this->functions->sum('total'); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('SUM(total)', $function->sql(new ValueBinder())); + $this->assertSame('float', $function->getReturnType()); + + $function = $this->functions->sum('total', ['integer']); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('SUM(total)', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a AVG() function + */ + public function testAvg(): void + { + $function = $this->functions->avg('salary'); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('AVG(salary)', $function->sql(new ValueBinder())); + $this->assertSame('float', $function->getReturnType()); + } + + /** + * Tests generating a MAX() function + */ + public function testMax(): void + { + $function = $this->functions->max('total'); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('MAX(total)', $function->sql(new ValueBinder())); + $this->assertSame('float', $function->getReturnType()); + + $function = $this->functions->max('created', ['datetime']); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('MAX(created)', $function->sql(new ValueBinder())); + $this->assertSame('datetime', $function->getReturnType()); + } + + /** + * Tests generating a MIN() function + */ + public function testMin(): void + { + $function = $this->functions->min('created', ['date']); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('MIN(created)', $function->sql(new ValueBinder())); + $this->assertSame('date', $function->getReturnType()); + } + + /** + * Tests generating a COUNT() function + */ + public function testCount(): void + { + $function = $this->functions->count('*'); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('COUNT(*)', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a CONCAT() function + */ + public function testConcat(): void + { + $function = $this->functions->concat(['title' => 'literal', ' is a string']); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('CONCAT(title, :param0)', $function->sql(new ValueBinder())); + $this->assertSame('string', $function->getReturnType()); + } + + /** + * Tests generating a COALESCE() function + */ + public function testCoalesce(): void + { + $function = $this->functions->coalesce(['NULL' => 'literal', '1', 'a'], ['a' => 'date']); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('COALESCE(NULL, :param0, :param1)', $function->sql(new ValueBinder())); + $this->assertSame('date', $function->getReturnType()); + } + + /** + * Tests generating a CAST() function + */ + public function testCast(): void + { + $function = $this->functions->cast('field', 'varchar'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('CAST(field AS varchar)', $function->sql(new ValueBinder())); + $this->assertSame('string', $function->getReturnType()); + + $function = $this->functions->cast($this->functions->now(), 'varchar'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('CAST(NOW() AS varchar)', $function->sql(new ValueBinder())); + $this->assertSame('string', $function->getReturnType()); + } + + /** + * Tests generating a NOW(), CURRENT_TIME() and CURRENT_DATE() function + */ + public function testNow(): void + { + $function = $this->functions->now(); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('NOW()', $function->sql(new ValueBinder())); + $this->assertSame('datetime', $function->getReturnType()); + + $function = $this->functions->now('date'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('CURRENT_DATE()', $function->sql(new ValueBinder())); + $this->assertSame('date', $function->getReturnType()); + + $function = $this->functions->now('time'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('CURRENT_TIME()', $function->sql(new ValueBinder())); + $this->assertSame('time', $function->getReturnType()); + } + + /** + * Tests generating a EXTRACT() function + */ + public function testExtract(): void + { + $function = $this->functions->extract('day', 'created'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('EXTRACT(day FROM created)', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + + $function = $this->functions->datePart('year', 'modified'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('EXTRACT(year FROM modified)', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a DATE_ADD() function + */ + public function testDateAdd(): void + { + $function = $this->functions->dateAdd('created', -3, 'day'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('DATE_ADD(created, INTERVAL -3 day)', $function->sql(new ValueBinder())); + $this->assertSame('datetime', $function->getReturnType()); + + $function = $this->functions->dateAdd(new IdentifierExpression('created'), -3, 'day'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('DATE_ADD(created, INTERVAL -3 day)', $function->sql(new ValueBinder())); + } + + /** + * Tests generating a DAYOFWEEK() function + */ + public function testDayOfWeek(): void + { + $function = $this->functions->dayOfWeek('created'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('DAYOFWEEK(created)', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + + $function = $this->functions->weekday('created'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('DAYOFWEEK(created)', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a RAND() function + */ + public function testRand(): void + { + $function = $this->functions->rand(); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('RAND()', $function->sql(new ValueBinder())); + $this->assertSame('float', $function->getReturnType()); + } + + /** + * Tests generating a ROW_NUMBER() window function + */ + public function testRowNumber(): void + { + $function = $this->functions->rowNumber(); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('ROW_NUMBER() OVER ()', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a LAG() window function + */ + public function testLag(): void + { + $function = $this->functions->lag('field', 1); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('LAG(field, 1) OVER ()', $function->sql(new ValueBinder())); + $this->assertSame('float', $function->getReturnType()); + + $function = $this->functions->lag('field', 1, 10, 'integer'); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('LAG(field, 1, :param0) OVER ()', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a LAG() window function + */ + public function testLead(): void + { + $function = $this->functions->lead('field', 1); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('LEAD(field, 1) OVER ()', $function->sql(new ValueBinder())); + $this->assertSame('float', $function->getReturnType()); + + $function = $this->functions->lead('field', 1, 10, 'integer'); + $this->assertInstanceOf(AggregateExpression::class, $function); + $this->assertSame('LEAD(field, 1, :param0) OVER ()', $function->sql(new ValueBinder())); + $this->assertSame('integer', $function->getReturnType()); + } + + /** + * Tests generating a JSON_VALUE() function + */ + public function testJsonValue(): void + { + $function = $this->functions->jsonValue('field', '$'); + $this->assertInstanceOf(FunctionExpression::class, $function); + $this->assertSame('JSON_VALUE(field, :param0)', $function->sql(new ValueBinder())); + $this->assertSame('string', $function->getReturnType()); + } +} diff --git a/tests/TestCase/Database/Log/LoggedQueryTest.php b/tests/TestCase/Database/Log/LoggedQueryTest.php new file mode 100644 index 00000000000..3f13dc66855 --- /dev/null +++ b/tests/TestCase/Database/Log/LoggedQueryTest.php @@ -0,0 +1,255 @@ +driver = ConnectionManager::get('test')->getDriver(); + + if ($this->driver instanceof Sqlserver) { + $this->true = '1'; + $this->false = '0'; + } + } + + /** + * Tests that LoggedQuery can be converted to string + */ + public function testStringConversion(): void + { + $logged = new LoggedQuery(); + $logged->setContext(['query' => 'SELECT foo FROM bar']); + $this->assertSame('SELECT foo FROM bar', (string)$logged); + } + + /** + * Tests that query placeholders are replaced when logged + */ + public function testStringInterpolation(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'driver' => $this->driver, + 'query' => 'SELECT a FROM b where a = :p1 AND b = :p2 AND c = :p3 AND d = :p4 AND e = :p5 AND f = :p6', + 'params' => ['p1' => 'string', 'p3' => null, 'p2' => 3, 'p4' => true, 'p5' => false, 'p6' => 0], + ]); + + $expected = "SELECT a FROM b where a = 'string' AND b = 3 AND c = NULL AND d = $this->true AND e = $this->false AND f = 0"; + $this->assertSame($expected, (string)$query); + } + + /** + * Tests that positional placeholders are replaced when logging a query + */ + public function testStringInterpolationNotNamed(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'driver' => $this->driver, + 'query' => 'SELECT a FROM b where a = ? AND b = ? AND c = ? AND d = ? AND e = ? AND f = ?', + 'params' => ['string', '3', null, true, false, 0], + ]); + + $expected = "SELECT a FROM b where a = 'string' AND b = '3' AND c = NULL AND d = $this->true AND e = $this->false AND f = 0"; + $this->assertSame($expected, (string)$query); + } + + /** + * Tests that repeated placeholders are correctly replaced + */ + public function testStringInterpolationDuplicate(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1 AND b = :p1 AND c = :p2 AND d = :p2', + 'params' => ['p1' => 'string', 'p2' => 3], + ]); + + $expected = "SELECT a FROM b where a = 'string' AND b = 'string' AND c = 3 AND d = 3"; + $this->assertSame($expected, (string)$query); + } + + /** + * Tests that named placeholders + */ + public function testStringInterpolationNamed(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1 AND b = :p11 AND c = :p20 AND d = :p2', + 'params' => ['p11' => 'test', 'p1' => 'string', 'p2' => 3, 'p20' => 5], + ]); + + $expected = "SELECT a FROM b where a = 'string' AND b = 'test' AND c = 5 AND d = 3"; + $this->assertSame($expected, (string)$query); + } + + /** + * Tests that placeholders are replaced with correctly escaped strings + */ + public function testStringInterpolationSpecialChars(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1 AND b = :p2 AND c = :p3 AND d = :p4', + 'params' => ['p1' => '$2y$10$dUAIj', 'p2' => '$0.23', 'p3' => 'a\\0b\\1c\\d', 'p4' => "a'b"], + ]); + + $expected = "SELECT a FROM b where a = '\$2y\$10\$dUAIj' AND b = '\$0.23' AND c = 'a\\\\0b\\\\1c\\\\d' AND d = 'a''b'"; + $this->assertSame($expected, (string)$query); + } + + /** + * Tests that query placeholders are replaced when logged + */ + public function testBinaryInterpolation(): void + { + $query = new LoggedQuery(); + $uuid = str_replace('-', '', Text::uuid()); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1', + 'params' => ['p1' => hex2bin($uuid)], + ]); + + $expected = "SELECT a FROM b where a = '{$uuid}'"; + $this->assertSame($expected, (string)$query); + } + + /** + * Tests that unknown possible binary data is not replaced to hex. + */ + public function testBinaryInterpolationIgnored(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1', + 'params' => ['p1' => "a\tz"], + ]); + + $expected = "SELECT a FROM b where a = 'a\tz'"; + $this->assertSame($expected, (string)$query); + } + + public function testGetContext(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1', + 'numRows' => 10, + 'took' => 15, + ]); + + $expected = [ + 'query' => 'SELECT a FROM b where a = :p1', + 'numRows' => 10, + 'took' => 15.0, + 'role' => '', + ]; + $this->assertSame($expected, $query->getContext()); + } + + public function testGetContextWithDriver(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1', + 'numRows' => 10, + 'took' => 15, + 'driver' => $this->driver, + ]); + + $context = $query->getContext(); + $this->assertSame('SELECT a FROM b where a = :p1', $context['query']); + $this->assertSame(10, $context['numRows']); + $this->assertSame(15.0, $context['took']); + $this->assertSame('test', $context['connection']); + } + + public function testSetContext(): void + { + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1', + 'lol' => 'nope', + 'connection' => $this->driver, + ]); + + $expected = [ + 'query' => 'SELECT a FROM b where a = :p1', + 'numRows' => 0, + 'took' => 0.0, + 'role' => '', + ]; + $this->assertSame($expected, $query->getContext()); + } + + public function testGetConnectionName(): void + { + $query = new LoggedQuery(); + $this->assertSame('', $query->getConnectionName()); + + $query->setContext([ + 'driver' => $this->driver, + ]); + $this->assertSame('test', $query->getConnectionName()); + } + + public function testJsonSerialize(): void + { + $error = new Exception('You fail!'); + + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = :p1', + 'params' => ['p1' => '$2y$10$dUAIj'], + 'numRows' => 4, + 'error' => $error, + ]); + + $expected = json_encode([ + 'query' => 'SELECT a FROM b where a = :p1', + 'numRows' => 4, + 'params' => ['p1' => '$2y$10$dUAIj'], + 'took' => 0, + 'error' => [ + 'class' => $error::class, + 'message' => $error->getMessage(), + 'code' => $error->getCode(), + ], + ]); + + $this->assertEquals($expected, json_encode($query)); + } +} diff --git a/tests/TestCase/Database/Log/QueryLoggerTest.php b/tests/TestCase/Database/Log/QueryLoggerTest.php new file mode 100644 index 00000000000..3b00b124a4d --- /dev/null +++ b/tests/TestCase/Database/Log/QueryLoggerTest.php @@ -0,0 +1,117 @@ + '']); + $query = new LoggedQuery(); + $query->setContext([ + 'query' => 'SELECT a FROM b where a = ? AND b = ? AND c = ?', + 'params' => ['string', '3', null], + ]); + + Log::setConfig('queryLoggerTest', [ + 'className' => 'Array', + 'scopes' => ['queriesLog'], + ]); + Log::setConfig('newScope', [ + 'className' => 'Array', + 'scopes' => ['cake.database.queries'], + ]); + Log::setConfig('queryLoggerTest2', [ + 'className' => 'Array', + 'scopes' => ['foo'], + ]); + $logger->log(LogLevel::DEBUG, $query, compact('query')); + + $this->assertCount(1, Log::engine('queryLoggerTest')->read()); + $this->assertCount(1, Log::engine('newScope')->read()); + $this->assertCount(0, Log::engine('queryLoggerTest2')->read()); + } + + /** + * Tests that passed Stringable also work. + */ + public function testLogFunctionStringable(): void + { + Log::setConfig('queryLoggerTest', [ + 'className' => 'Array', + 'scopes' => ['queriesLog'], + ]); + + $logger = new QueryLogger(['connection' => '']); + + $stringable = new class implements Stringable + { + public function __toString(): string + { + return 'FooBar'; + } + }; + + $logger->log(LogLevel::DEBUG, $stringable, ['query' => null]); + $logs = Log::engine('queryLoggerTest')->read(); + $this->assertCount(1, $logs); + $this->assertStringContainsString('FooBar', $logs[0]); + } + + /** + * Tests that the connection name is logged with the query. + */ + public function testLogConnection(): void + { + $logger = new QueryLogger(['connection' => 'test']); + $query = new LoggedQuery(); + $query->setContext(['query' => 'SELECT a']); + + Log::setConfig('queryLoggerTest', [ + 'className' => 'Array', + 'scopes' => ['queriesLog'], + ]); + $logger->log(LogLevel::DEBUG, '', compact('query')); + + $this->assertStringContainsString('connection=test role= duration=', current(Log::engine('queryLoggerTest')->read())); + } +} diff --git a/tests/TestCase/Database/Query/DeleteQueryTest.php b/tests/TestCase/Database/Query/DeleteQueryTest.php new file mode 100644 index 00000000000..297b63bd648 --- /dev/null +++ b/tests/TestCase/Database/Query/DeleteQueryTest.php @@ -0,0 +1,233 @@ +connection = ConnectionManager::get('test'); + $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->connection->getDriver()->enableAutoQuoting($this->autoQuote); + unset($this->connection); + } + + /** + * Test a basic delete using from() + */ + public function testDeleteWithFrom(): void + { + $query = new DeleteQuery($this->connection); + + $query->delete() + ->from('authors') + ->where('1 = 1'); + + $result = $query->sql(); + $this->assertQuotedQuery('DELETE FROM ', $result, !$this->autoQuote); + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertSame(self::AUTHOR_COUNT, $result->rowCount()); + $result->closeCursor(); + } + + /** + * Test delete with from and alias. + */ + public function testDeleteWithAliasedFrom(): void + { + $query = new DeleteQuery($this->connection); + + $query->delete() + ->from(['a ' => 'authors']) + ->where(['a.id !=' => 99]); + + $result = $query->sql(); + $this->assertQuotedQuery('DELETE FROM WHERE != :c0', $result, !$this->autoQuote); + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertSame(self::AUTHOR_COUNT, $result->rowCount()); + $result->closeCursor(); + } + + /** + * Test a basic delete with no from() call. + */ + public function testDeleteNoFrom(): void + { + $query = new DeleteQuery($this->connection); + + $query->delete('authors') + ->where('1 = 1'); + + $result = $query->sql(); + $this->assertQuotedQuery('DELETE FROM ', $result, !$this->autoQuote); + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertSame(self::AUTHOR_COUNT, $result->rowCount()); + $result->closeCursor(); + } + + /** + * Tests that delete queries that contain joins do trigger a notice, + * warning about possible incompatibilities with aliases being removed + * from the conditions. + */ + public function testDeleteRemovingAliasesCanBreakJoins(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Aliases are being removed from conditions for UPDATE/DELETE queries, this can break references to joined tables.'); + $query = new DeleteQuery($this->connection); + + $query + ->delete('authors') + ->from(['a ' => 'authors']) + ->innerJoin('articles') + ->where(['a.id' => 1]); + + $query->sql(); + } + + /** + * Tests that aliases are stripped from delete query conditions + * where possible. + */ + public function testDeleteStripAliasesFromConditions(): void + { + $query = new DeleteQuery($this->connection); + + $query + ->delete() + ->from(['a' => 'authors']) + ->where([ + 'OR' => [ + 'a.id' => 1, + 'a.name IS' => null, + 'a.email IS NOT' => null, + 'AND' => [ + 'b.name NOT IN' => ['foo', 'bar'], + 'OR' => [ + $query->expr()->eq(new IdentifierExpression('c.name'), 'zap'), + 'd.name' => 'baz', + (new SelectQuery($this->connection))->select(['e.name'])->where(['e.name' => 'oof']), + ], + ], + ], + ]); + + $this->assertQuotedQuery( + 'DELETE FROM WHERE \(' . + ' = :c0 OR \(\) IS NULL OR \(\) IS NOT NULL OR \(' . + ' NOT IN \(:c1,:c2\) AND \(' . + ' = :c3 OR = :c4 OR \(SELECT \. WHERE \. = :c5\)' . + '\)' . + '\)' . + '\)', + $query->sql(), + !$this->autoQuote, + ); + } + + /** + * Test that epilog() will actually append a string to a delete query + */ + public function testAppendDelete(): void + { + $query = new DeleteQuery($this->connection); + $sql = $query + ->delete('articles') + ->where(['id' => 1]) + ->epilog('RETURNING id') + ->sql(); + $this->assertStringContainsString('DELETE FROM', $sql); + $this->assertStringContainsString('WHERE', $sql); + $this->assertSame(' RETURNING id', substr($sql, -13)); + } + + /** + * Test use of modifiers in a DELETE query + * + * Testing the generated SQL since the modifiers are usually different per driver + */ + public function testDeleteModifiers(): void + { + $query = new DeleteQuery($this->connection); + $result = $query->delete() + ->from('authors') + ->where('1 = 1') + ->modifier('IGNORE'); + $this->assertQuotedQuery( + 'DELETE IGNORE FROM WHERE 1 = 1', + $result->sql(), + !$this->autoQuote, + ); + + $query = new DeleteQuery($this->connection); + $result = $query->delete() + ->from('authors') + ->where('1 = 1') + ->modifier(['IGNORE', 'QUICK']); + $this->assertQuotedQuery( + 'DELETE IGNORE QUICK FROM WHERE 1 = 1', + $result->sql(), + !$this->autoQuote, + ); + } +} diff --git a/tests/TestCase/Database/Query/InsertQueryTest.php b/tests/TestCase/Database/Query/InsertQueryTest.php new file mode 100644 index 00000000000..28f5e9c6af4 --- /dev/null +++ b/tests/TestCase/Database/Query/InsertQueryTest.php @@ -0,0 +1,493 @@ +connection = ConnectionManager::get('test'); + $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->connection->getDriver()->enableAutoQuoting($this->autoQuote); + unset($this->connection); + } + + /** + * You cannot call values() before insert() it causes all sorts of pain. + */ + public function testInsertValuesBeforeInsertFailure(): void + { + $this->expectException(DatabaseException::class); + $query = new InsertQuery($this->connection); + $query->values([ + 'id' => 1, + 'title' => 'mark', + 'body' => 'test insert', + ]); + } + + /** + * Inserting nothing should not generate an error. + */ + public function testInsertNothing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least 1 column is required to perform an insert.'); + $query = new InsertQuery($this->connection); + $query->insert([]); + } + + /** + * Test insert() with no into() + */ + public function testInsertNoInto(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Could not compile insert query. No table was specified'); + $query = new InsertQuery($this->connection); + $query->insert(['title', 'body'])->sql(); + } + + /** + * Test insert overwrites values + */ + public function testInsertOverwritesValues(): void + { + $query = new InsertQuery($this->connection); + $query->insert(['title', 'body']) + ->insert(['title']) + ->into('articles') + ->values([ + 'title' => 'mark', + ]); + + $result = $query->sql(); + $this->assertQuotedQuery( + 'INSERT INTO \(\) (OUTPUT INSERTED\.\* )?' . + 'VALUES \(:c0\)', + $result, + !$this->autoQuote, + ); + } + + /** + * Test inserting a single row. + */ + public function testInsertSimple(): void + { + $query = new InsertQuery($this->connection); + $query->insert(['title', 'body']) + ->into('articles') + ->values([ + 'title' => 'mark', + 'body' => 'test insert', + ]); + $result = $query->sql(); + $this->assertQuotedQuery( + 'INSERT INTO <articles> \(<title>, <body>\) (OUTPUT INSERTED\.\* )?' . + 'VALUES \(:c0, :c1\)', + $result, + !$this->autoQuote, + ); + + $result = $query->execute(); + $result->closeCursor(); + + //PDO_SQLSRV returns -1 for successful inserts when using INSERT ... OUTPUT + if (!$this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame(1, $result->rowCount(), '1 row should be inserted'); + } + + $expected = [ + [ + 'id' => 4, + 'author_id' => null, + 'title' => 'mark', + 'body' => 'test insert', + 'published' => 'N', + ], + ]; + $this->assertTable('articles', 1, $expected, ['id >=' => 4]); + } + + /** + * Test insert queries quote integer column names + */ + public function testInsertQuoteColumns(): void + { + $query = new InsertQuery($this->connection); + $query->insert([123]) + ->into('articles') + ->values([ + '123' => 'mark', + ]); + $result = $query->sql(); + $this->assertQuotedQuery( + 'INSERT INTO <articles> \(<123>\) (OUTPUT INSERTED\.\* )?' . + 'VALUES \(:c0\)', + $result, + !$this->autoQuote, + ); + } + + /** + * Test an insert when not all the listed fields are provided. + * Columns should be matched up where possible. + */ + public function testInsertSparseRow(): void + { + $query = new InsertQuery($this->connection); + $query->insert(['title', 'body']) + ->into('articles') + ->values([ + 'title' => 'mark', + ]); + $result = $query->sql(); + $this->assertQuotedQuery( + 'INSERT INTO <articles> \(<title>, <body>\) (OUTPUT INSERTED\.\* )?' . + 'VALUES \(:c0, :c1\)', + $result, + !$this->autoQuote, + ); + + $result = $query->execute(); + $result->closeCursor(); + + //PDO_SQLSRV returns -1 for successful inserts when using INSERT ... OUTPUT + if (!$this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame(1, $result->rowCount(), '1 row should be inserted'); + } + + $expected = [ + [ + 'id' => 4, + 'author_id' => null, + 'title' => 'mark', + 'body' => null, + 'published' => 'N', + ], + ]; + $this->assertTable('articles', 1, $expected, ['id >=' => 4]); + } + + /** + * Test inserting multiple rows with sparse data. + */ + public function testInsertMultipleRowsSparse(): void + { + $query = new InsertQuery($this->connection); + $query->insert(['title', 'body']) + ->into('articles') + ->values([ + 'body' => 'test insert', + ]) + ->values([ + 'title' => 'jose', + ]); + + $result = $query->execute(); + $result->closeCursor(); + + //PDO_SQLSRV returns -1 for successful inserts when using INSERT ... OUTPUT + if (!$this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame(2, $result->rowCount(), '2 rows should be inserted'); + } + + $expected = [ + [ + 'id' => 4, + 'author_id' => null, + 'title' => null, + 'body' => 'test insert', + 'published' => 'N', + ], + [ + 'id' => 5, + 'author_id' => null, + 'title' => 'jose', + 'body' => null, + 'published' => 'N', + ], + ]; + $this->assertTable('articles', 2, $expected, ['id >=' => 4]); + } + + /** + * Test that INSERT INTO ... SELECT works. + */ + public function testInsertFromSelect(): void + { + $select = (new SelectQuery($this->connection))->select(['name', "'some text'", 99]) + ->from('authors') + ->where(['id' => 1]); + + $query = new InsertQuery($this->connection); + $query->insert( + ['title', 'body', 'author_id'], + ['title' => 'string', 'body' => 'string', 'author_id' => 'integer'], + ) + ->into('articles') + ->values($select); + + $result = $query->sql(); + $this->assertQuotedQuery( + 'INSERT INTO <articles> \(<title>, <body>, <author_id>\) (OUTPUT INSERTED\.\* )?SELECT', + $result, + !$this->autoQuote, + ); + $this->assertQuotedQuery( + "SELECT <name>, 'some text', 99 FROM <authors>", + $result, + !$this->autoQuote, + ); + $result = $query->execute(); + $result->closeCursor(); + + //PDO_SQLSRV returns -1 for successful inserts when using INSERT ... OUTPUT + if (!$this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame(1, $result->rowCount()); + } + + $result = (new SelectQuery($this->connection))->select('*') + ->from('articles') + ->where(['author_id' => 99]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $expected = [ + 'id' => 4, + 'title' => 'mariano', + 'body' => 'some text', + 'author_id' => 99, + 'published' => 'N', + ]; + $this->assertEquals($expected, $rows[0]); + } + + /** + * Test that an exception is raised when mixing query + array types. + */ + public function testInsertFailureMixingTypesArrayFirst(): void + { + $this->expectException(DatabaseException::class); + $query = new InsertQuery($this->connection); + $query->insert(['name']) + ->into('articles') + ->values(['name' => 'mark']) + ->values(new InsertQuery($this->connection)); + } + + /** + * Test that an exception is raised when mixing query + array types. + */ + public function testInsertFailureMixingTypesQueryFirst(): void + { + $this->expectException(DatabaseException::class); + $query = new InsertQuery($this->connection); + $query->insert(['name']) + ->into('articles') + ->values(new InsertQuery($this->connection)) + ->values(['name' => 'mark']); + } + + /** + * Test that insert can use expression objects as values. + */ + public function testInsertExpressionValues(): void + { + $query = new InsertQuery($this->connection); + $query->insert(['title', 'author_id']) + ->into('articles') + ->values(['title' => $query->expr("SELECT 'jose'"), 'author_id' => 99]); + + $result = $query->execute(); + $result->closeCursor(); + + //PDO_SQLSRV returns -1 for successful inserts when using INSERT ... OUTPUT + if (!$this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame(1, $result->rowCount()); + } + + $result = (new SelectQuery($this->connection))->select('*') + ->from('articles') + ->where(['author_id' => 99]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $expected = [ + 'id' => 4, + 'title' => 'jose', + 'body' => null, + 'author_id' => '99', + 'published' => 'N', + ]; + $this->assertEquals($expected, $rows[0]); + + $subquery = new SelectQuery($this->connection); + $subquery->select(['name']) + ->from('authors') + ->where(['id' => 1]); + + $query = new InsertQuery($this->connection); + $query->insert(['title', 'author_id']) + ->into('articles') + ->values(['title' => $subquery, 'author_id' => 100]); + $result = $query->execute(); + //PDO_SQLSRV returns -1 for successful inserts when using INSERT ... OUTPUT + if (!$this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame(1, $result->rowCount()); + } + $result->closeCursor(); + + $result = (new SelectQuery($this->connection))->select('*') + ->from('articles') + ->where(['author_id' => 100]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $expected = [ + 'id' => 5, + 'title' => 'mariano', + 'body' => null, + 'author_id' => '100', + 'published' => 'N', + ]; + $this->assertEquals($expected, $rows[0]); + } + + /** + * Test use of modifiers in a INSERT query + * + * Testing the generated SQL since the modifiers are usually different per driver + */ + public function testInsertModifiers(): void + { + $query = new InsertQuery($this->connection); + $result = $query + ->insert(['title']) + ->into('articles') + ->values(['title' => 'foo']) + ->modifier('IGNORE'); + $this->assertQuotedQuery( + 'INSERT IGNORE INTO <articles> \(<title>\) (OUTPUT INSERTED\.\* )?', + $result->sql(), + !$this->autoQuote, + ); + + $query = new InsertQuery($this->connection); + $result = $query + ->insert(['title']) + ->into('articles') + ->values(['title' => 'foo']) + ->modifier(['IGNORE', 'LOW_PRIORITY']); + $this->assertQuotedQuery( + 'INSERT IGNORE LOW_PRIORITY INTO <articles> \(<title>\) (OUTPUT INSERTED\.\* )?', + $result->sql(), + !$this->autoQuote, + ); + } + + public function testCloneValuesExpression(): void + { + $query = new InsertQuery($this->connection); + $query + ->insert(['column']) + ->into('table') + ->values(['column' => $query->expr('value')]); + + $clause = $query->clause('values'); + $clauseClone = (clone $query)->clause('values'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + /** + * Test that epilog() will actually append a string to an insert query + */ + public function testAppendInsert(): void + { + $query = new InsertQuery($this->connection); + $sql = $query + ->insert(['id', 'title']) + ->into('articles') + ->values([1, 'a title']) + ->epilog('RETURNING id') + ->sql(); + $this->assertStringContainsString('INSERT', $sql); + $this->assertStringContainsString('INTO', $sql); + $this->assertStringContainsString('VALUES', $sql); + $this->assertSame(' RETURNING id', substr($sql, -13)); + } + + /** + * Tests that insert query parts get quoted automatically + */ + public function testQuotingInsert(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new InsertQuery($this->connection); + $sql = $query->insert(['bar', 'baz']) + ->into('foo') + ->sql(); + $this->assertQuotedQuery('INSERT INTO <foo> \(<bar>, <baz>\)', $sql); + + $query = new InsertQuery($this->connection); + $sql = $query->insert([$query->expr('bar')]) + ->into('foo') + ->sql(); + $this->assertQuotedQuery('INSERT INTO <foo> \(\(bar\)\)', $sql); + } +} diff --git a/tests/TestCase/Database/Query/SelectQueryTest.php b/tests/TestCase/Database/Query/SelectQueryTest.php new file mode 100644 index 00000000000..7822e1f8254 --- /dev/null +++ b/tests/TestCase/Database/Query/SelectQueryTest.php @@ -0,0 +1,4455 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Query; + +use ArrayIterator; +use Cake\Database\Connection; +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Postgres; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; +use Cake\Database\DriverFeatureEnum; +use Cake\Database\Exception\DatabaseException; +use Cake\Database\Expression\CommonTableExpression; +use Cake\Database\Expression\IdentifierExpression; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Expression\StringExpression; +use Cake\Database\Expression\TupleComparison; +use Cake\Database\Expression\WindowExpression; +use Cake\Database\ExpressionInterface; +use Cake\Database\Query; +use Cake\Database\Query\SelectQuery; +use Cake\Database\StatementInterface; +use Cake\Database\TypeFactory; +use Cake\Database\TypeMap; +use Cake\Database\ValueBinder; +use Cake\Datasource\ConnectionManager; +use Cake\I18n\DateTime; +use Cake\Test\TestCase\Database\QueryAssertsTrait; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use ReflectionProperty; +use stdClass; +use TestApp\Database\Type\BarType; +use function Cake\Collection\collection; + +/** + * Tests SelectQuery class + */ +class SelectQueryTest extends TestCase +{ + use QueryAssertsTrait; + + protected array $fixtures = [ + 'core.Articles', + 'core.Authors', + 'core.Comments', + 'core.Profiles', + 'core.MenuLinkTrees', + ]; + + /** + * @var int + */ + public const ARTICLE_COUNT = 3; + /** + * @var int + */ + public const AUTHOR_COUNT = 4; + /** + * @var int + */ + public const COMMENT_COUNT = 6; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var bool + */ + protected $autoQuote; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->connection->getDriver()->enableAutoQuoting($this->autoQuote); + unset($this->connection); + } + + /** + * Tests that it is possible to obtain expression results from a query + */ + public function testSelectFieldsOnly(): void + { + $this->connection->getDriver()->enableAutoQuoting(false); + $query = new SelectQuery($this->connection); + $result = $query->select('1 + 1')->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertEquals([2], $result->fetch()); + $result->closeCursor(); + + //This new field should be appended + $result = $query->select(['1 + 3'])->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertEquals([2, 4], $result->fetch()); + $result->closeCursor(); + + //This should now overwrite all previous fields + $result = $query->select(['1 + 2', '1 + 5'], true)->execute(); + $this->assertEquals([3, 6], $result->fetch()); + $result->closeCursor(); + } + + /** + * Tests that it is possible to pass a closure as fields in select() + */ + public function testSelectClosure(): void + { + $this->connection->getDriver()->enableAutoQuoting(false); + $query = new SelectQuery($this->connection); + $result = $query->select(function ($q) use ($query) { + $this->assertSame($query, $q); + + return ['1 + 2', '1 + 5']; + })->execute(); + $this->assertEquals([3, 6], $result->fetch()); + $result->closeCursor(); + } + + /** + * Tests it is possible to select fields from tables with no conditions + */ + public function testSelectFieldsFromTable(): void + { + $query = new SelectQuery($this->connection); + $result = $query->select(['body', 'author_id'])->from('articles')->execute(); + $this->assertEquals(['body' => 'First Article Body', 'author_id' => 1], $result->fetch('assoc')); + $this->assertEquals(['body' => 'Second Article Body', 'author_id' => 3], $result->fetch('assoc')); + $result->closeCursor(); + + //Append more tables to next execution + $result = $query->select('name')->from(['authors'])->orderBy(['name' => 'desc', 'articles.id' => 'asc'])->execute(); + $this->assertEquals(['body' => 'First Article Body', 'author_id' => 1, 'name' => 'nate'], $result->fetch('assoc')); + $this->assertEquals(['body' => 'Second Article Body', 'author_id' => 3, 'name' => 'nate'], $result->fetch('assoc')); + $this->assertEquals(['body' => 'Third Article Body', 'author_id' => 1, 'name' => 'nate'], $result->fetch('assoc')); + $result->closeCursor(); + + // Overwrite tables and only fetch from authors + $result = $query->select('name', true)->from('authors', true)->orderBy(['name' => 'desc'], true)->execute(); + $this->assertSame(['nate'], $result->fetch()); + $this->assertSame(['mariano'], $result->fetch()); + $result->closeCursor(); + } + + /** + * Tests it is possible to select aliased fields + */ + public function testSelectAliasedFieldsFromTable(): void + { + $query = new SelectQuery($this->connection); + $result = $query->select(['text' => 'comment', 'article_id'])->from('comments')->execute(); + $this->assertEquals(['text' => 'First Comment for First Article', 'article_id' => 1], $result->fetch('assoc')); + $this->assertEquals(['text' => 'Second Comment for First Article', 'article_id' => 1], $result->fetch('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query->select(['text' => 'comment', 'article' => 'article_id'])->from('comments')->execute(); + $this->assertEquals(['text' => 'First Comment for First Article', 'article' => 1], $result->fetch('assoc')); + $this->assertEquals(['text' => 'Second Comment for First Article', 'article' => 1], $result->fetch('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $query->select(['text' => 'comment'])->select(['article_id', 'foo' => 'comment']); + $result = $query->from('comments')->execute(); + $this->assertEquals( + ['foo' => 'First Comment for First Article', 'text' => 'First Comment for First Article', 'article_id' => 1], + $result->fetch('assoc'), + ); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $exp = $query->expr('1 + 1'); + $comp = $query->expr(['article_id +' => 2]); + $result = $query->select(['text' => 'comment', 'two' => $exp, 'three' => $comp]) + ->from('comments')->execute(); + $this->assertEquals(['text' => 'First Comment for First Article', 'two' => 2, 'three' => 3], $result->fetch('assoc')); + $result->closeCursor(); + } + + /** + * Tests that tables can also be aliased and referenced in the select clause using such alias + */ + public function testSelectAliasedTables(): void + { + $query = new SelectQuery($this->connection); + $result = $query->select(['text' => 'a.body', 'a.author_id']) + ->from(['a' => 'articles'])->execute(); + + $this->assertEquals(['text' => 'First Article Body', 'author_id' => 1], $result->fetch('assoc')); + $this->assertEquals(['text' => 'Second Article Body', 'author_id' => 3], $result->fetch('assoc')); + $result->closeCursor(); + + $result = $query->select(['name' => 'b.name'])->from(['b' => 'authors']) + ->orderBy(['text' => 'desc', 'name' => 'desc']) + ->execute(); + $this->assertEquals( + ['text' => 'Third Article Body', 'author_id' => 1, 'name' => 'nate'], + $result->fetch('assoc'), + ); + $this->assertEquals( + ['text' => 'Third Article Body', 'author_id' => 1, 'name' => 'mariano'], + $result->fetch('assoc'), + ); + $result->closeCursor(); + } + + /** + * Tests it is possible to add joins to a select query + */ + public function testSelectWithJoins(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title', 'name']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => $query->expr()->equalFields('author_id', 'a.id')]) + ->orderBy(['title' => 'asc']) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(3, $rows); + $this->assertEquals(['title' => 'First Article', 'name' => 'mariano'], $rows[0]); + $this->assertEquals(['title' => 'Second Article', 'name' => 'larry'], $rows[1]); + $result->closeCursor(); + + $result = $query->join('authors', [], true)->execute(); + $this->assertCount(12, $result->fetchAll(), 'Cross join results in 12 records'); + $result->closeCursor(); + + $result = $query->join([ + ['table' => 'authors', 'type' => 'INNER', 'conditions' => $query->expr()->equalFields('author_id', 'authors.id')], + ], [], true)->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(3, $rows); + $this->assertEquals(['title' => 'First Article', 'name' => 'mariano'], $rows[0]); + $this->assertEquals(['title' => 'Second Article', 'name' => 'larry'], $rows[1]); + $result->closeCursor(); + } + + /** + * Tests it is possible to add joins to a select query using array or expression as conditions + */ + public function testSelectWithJoinsConditions(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title', 'name']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => [$query->expr()->equalFields('author_id ', 'a.id')]]) + ->orderBy(['title' => 'asc']) + ->execute(); + $this->assertEquals(['title' => 'First Article', 'name' => 'mariano'], $result->fetch('assoc')); + $this->assertEquals(['title' => 'Second Article', 'name' => 'larry'], $result->fetch('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $conditions = $query->expr()->equalFields('author_id', 'a.id'); + $result = $query + ->select(['title', 'name']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => $conditions]) + ->orderBy(['title' => 'asc']) + ->execute(); + $this->assertEquals(['title' => 'First Article', 'name' => 'mariano'], $result->fetch('assoc')); + $this->assertEquals(['title' => 'Second Article', 'name' => 'larry'], $result->fetch('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $time = new DateTime('2007-03-18 10:45:23'); + $types = ['created' => 'datetime']; + $result = $query + ->select(['title', 'comment' => 'c.comment']) + ->from('articles') + ->join(['table' => 'comments', 'alias' => 'c', 'conditions' => ['created' => $time]], $types) + ->execute(); + $this->assertEquals(['title' => 'First Article', 'comment' => 'First Comment for First Article'], $result->fetch('assoc')); + $result->closeCursor(); + } + + /** + * Tests that joins can be aliased using array keys + */ + public function testSelectAliasedJoins(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title', 'name']) + ->from('articles') + ->join(['a' => 'authors']) + ->orderBy(['name' => 'desc', 'articles.id' => 'asc']) + ->execute(); + $this->assertEquals(['title' => 'First Article', 'name' => 'nate'], $result->fetch('assoc')); + $this->assertEquals(['title' => 'Second Article', 'name' => 'nate'], $result->fetch('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $conditions = $query->expr('author_id = a.id'); + $result = $query + ->select(['title', 'name']) + ->from('articles') + ->join(['a' => ['table' => 'authors', 'conditions' => $conditions]]) + ->orderBy(['title' => 'asc']) + ->execute(); + $this->assertEquals(['title' => 'First Article', 'name' => 'mariano'], $result->fetch('assoc')); + $this->assertEquals(['title' => 'Second Article', 'name' => 'larry'], $result->fetch('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $time = new DateTime('2007-03-18 10:45:23'); + $types = ['created' => 'datetime']; + $result = $query + ->select(['title', 'name' => 'c.comment']) + ->from('articles') + ->join(['c' => ['table' => 'comments', 'conditions' => ['created' => $time]]], $types) + ->execute(); + $this->assertEquals(['title' => 'First Article', 'name' => 'First Comment for First Article'], $result->fetch('assoc')); + $result->closeCursor(); + } + + /** + * Tests the leftJoin method + */ + public function testSelectLeftJoin(): void + { + $query = new SelectQuery($this->connection); + $time = new DateTime('2007-03-18 10:45:23'); + $types = ['created' => 'datetime']; + $result = $query + ->select(['title', 'name' => 'c.comment']) + ->from('articles') + ->leftJoin(['c' => 'comments'], ['created <' => $time], $types) + ->execute(); + $this->assertEquals(['title' => 'First Article', 'name' => null], $result->fetch('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title', 'name' => 'c.comment']) + ->from('articles') + ->leftJoin(['c' => 'comments'], ['created >' => $time], $types) + ->orderBy(['created' => 'asc']) + ->execute(); + $this->assertEquals( + ['title' => 'First Article', 'name' => 'Second Comment for First Article'], + $result->fetch('assoc'), + ); + $result->closeCursor(); + } + + /** + * Tests the innerJoin method + */ + public function testSelectInnerJoin(): void + { + $query = new SelectQuery($this->connection); + $time = new DateTime('2007-03-18 10:45:23'); + $types = ['created' => 'datetime']; + $statement = $query + ->select(['title', 'name' => 'c.comment']) + ->from('articles') + ->innerJoin(['c' => 'comments'], ['created <' => $time], $types) + ->execute(); + $this->assertCount(0, $statement->fetchAll()); + $statement->closeCursor(); + } + + /** + * Tests the rightJoin method + */ + public function testSelectRightJoin(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Sqlite, + 'SQLite does not support RIGHT joins', + ); + $query = new SelectQuery($this->connection); + $time = new DateTime('2007-03-18 10:45:23'); + $types = ['created' => 'datetime']; + $result = $query + ->select(['title', 'name' => 'c.comment']) + ->from('articles') + ->rightJoin(['c' => 'comments'], ['created <' => $time], $types) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(6, $rows); + $this->assertEquals( + ['title' => null, 'name' => 'First Comment for First Article'], + $rows[0], + ); + $result->closeCursor(); + } + + /** + * Tests that it is possible to pass a callable as conditions for a join + */ + public function testSelectJoinWithCallback(): void + { + $query = new SelectQuery($this->connection); + $types = ['created' => 'datetime']; + $result = $query + ->select(['title', 'name' => 'c.comment']) + ->from('articles') + ->innerJoin(['c' => 'comments'], function ($exp, $q) use ($query, $types) { + $this->assertSame($q, $query); + $exp->add(['created <' => new DateTime('2007-03-18 10:45:23')], $types); + + return $exp; + }) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that it is possible to pass a callable as conditions for a join + */ + public function testSelectJoinWithCallback2(): void + { + $query = new SelectQuery($this->connection); + $types = ['created' => 'datetime']; + $result = $query + ->select(['name', 'commentary' => 'comments.comment']) + ->from('authors') + ->innerJoin('comments', function ($exp, $q) use ($query, $types) { + $this->assertSame($q, $query); + $exp->add(['created' => new DateTime('2007-03-18 10:47:23')], $types); + + return $exp; + }) + ->execute(); + $this->assertEquals( + ['name' => 'mariano', 'commentary' => 'Second Comment for First Article'], + $result->fetch('assoc'), + ); + $result->closeCursor(); + } + + /** + * Tests it is possible to filter a query by using simple AND joined conditions + */ + public function testSelectSimpleWhere(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['id' => 1, 'title' => 'First Article']) + ->execute(); + $this->assertCount(1, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['id' => 100], ['id' => 'integer']) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorMoreThan(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['comment']) + ->from('comments') + ->where(['id >' => 4]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['comment' => 'First Comment for Second Article'], $rows[0]); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorLessThan(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['id <' => 2]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['title' => 'First Article'], $rows[0]); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorLessThanEqual(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['id <=' => 2]) + ->execute(); + $this->assertCount(2, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorMoreThanEqual(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['id >=' => 1]) + ->execute(); + $this->assertCount(3, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorNotEqual(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['id !=' => 2]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['title' => 'First Article'], $rows[0]); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorLike(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['title LIKE' => 'First Article']) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['title' => 'First Article'], $rows[0]); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorLikeExpansion(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['title like' => '%Article%']) + ->execute(); + $this->assertCount(3, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operators and scalar values works + */ + public function testSelectWhereOperatorNotLike(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(['title not like' => '%Article%']) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Test that unary expressions in selects are built correctly. + */ + public function testSelectWhereUnary(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('articles') + ->where([ + 'title is not' => null, + 'user_id is' => null, + ]) + ->sql(); + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE \(\(<title>\) IS NOT NULL AND \(<user_id>\) IS NULL\)', + $result, + !$this->autoQuote, + ); + } + + /** + * Tests selecting with conditions and specifying types for those + */ + public function testSelectWhereTypes(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['created' => new DateTime('2007-03-18 10:45:23')], ['created' => 'datetime']) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['created >' => new DateTime('2007-03-18 10:46:00')], ['created' => 'datetime']) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(5, $rows); + $this->assertEquals(['id' => 2], $rows[0]); + $this->assertEquals(['id' => 3], $rows[1]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where( + [ + 'created >' => new DateTime('2007-03-18 10:40:00'), + 'created <' => new DateTime('2007-03-18 10:46:00'), + ], + ['created' => 'datetime'], + ) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where( + [ + 'id' => '3', + 'created <' => new DateTime('2013-01-01 12:00'), + ], + ['created' => 'datetime', 'id' => 'integer'], + ) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 3], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where( + [ + 'id' => '1', + 'created <' => new DateTime('2013-01-01 12:00'), + ], + ['created' => 'datetime', 'id' => 'integer'], + ) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + } + + /** + * Tests Query::whereNull() + */ + public function testSelectWhereNull(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id', 'parent_id']) + ->from('menu_link_trees') + ->whereNull(['parent_id']) + ->execute(); + $this->assertCount(5, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('menu_link_trees') + ->whereNull((new SelectQuery($this->connection))->select('parent_id')) + ->execute(); + $this->assertCount(5, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('menu_link_trees') + ->whereNull('parent_id') + ->execute(); + $this->assertCount(5, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests Query::whereNotNull() + */ + public function testSelectWhereNotNull(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id', 'parent_id']) + ->from('menu_link_trees') + ->whereNotNull(['parent_id']) + ->execute(); + $this->assertCount(13, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('menu_link_trees') + ->whereNotNull((new SelectQuery($this->connection))->select('parent_id')) + ->execute(); + $this->assertCount(13, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('menu_link_trees') + ->whereNotNull('parent_id') + ->execute(); + $this->assertCount(13, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that passing an array type to any where condition will replace + * the passed array accordingly as a proper IN condition + */ + public function testSelectWhereArrayType(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['id' => ['1', '3']], ['id' => 'integer[]']) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $this->assertEquals(['id' => 3], $rows[1]); + $result->closeCursor(); + } + + /** + * Tests that passing an empty array type to any where condition will not + * result in a SQL error, but an internal exception + */ + public function testSelectWhereArrayTypeEmpty(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Impossible to generate condition with empty list of values for field'); + $query = new SelectQuery($this->connection); + $query + ->select(['id']) + ->from('comments') + ->where(['id' => []], ['id' => 'integer[]']) + ->execute(); + } + + /** + * Tests exception message for impossible condition when using an expression + */ + public function testSelectWhereArrayTypeEmptyWithExpression(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('with empty list of values for field (SELECT 1)'); + $query = new SelectQuery($this->connection); + $query + ->select(['id']) + ->from('comments') + ->where(function ($exp, $q) { + return $exp->in($q->expr('SELECT 1'), []); + }) + ->execute(); + } + + /** + * Tests that Query::andWhere() can be used to concatenate conditions with AND + */ + public function testSelectAndWhere(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['created' => new DateTime('2007-03-18 10:45:23')], ['created' => 'datetime']) + ->andWhere(['id' => 1]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['created' => new DateTime('2007-03-18 10:50:55')], ['created' => 'datetime']) + ->andWhere(['id' => 2]) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that Query::andWhere() can be used without calling where() before + */ + public function testSelectAndWhereNoPreviousCondition(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->andWhere(['created' => new DateTime('2007-03-18 10:45:23')], ['created' => 'datetime']) + ->andWhere(['id' => 1]) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + } + + /** + * Tests that it is possible to pass a closure to where() to build a set of + * conditions and return the expression to be used + */ + public function testSelectWhereUsingClosure(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->eq('id', 1); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp + ->eq('id', 1) + ->eq('created', new DateTime('2007-03-18 10:45:23'), 'datetime'); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp + ->eq('id', 1) + ->eq('created', new DateTime('2021-12-30 15:00'), 'datetime'); + }) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests generating tuples in the values side containing closure expressions + */ + public function testTupleWithClosureExpression(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('comments') + ->where([ + 'OR' => [ + 'id' => 1, + function (ExpressionInterface $exp) { + return $exp->eq('id', 2); + }, + ], + ]); + + $result = $query->sql(); + $this->assertQuotedQuery( + 'SELECT <id> FROM <comments> WHERE \(<id> = :c0 OR <id> = :c1\)', + $result, + !$this->autoQuote, + ); + } + + /** + * Tests that it is possible to pass a closure to andWhere() to build a set of + * conditions and return the expression to be used + */ + public function testSelectAndWhereUsingClosure(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['id' => '1']) + ->andWhere(function (ExpressionInterface $exp) { + return $exp->eq('created', new DateTime('2007-03-18 10:45:23'), 'datetime'); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['id' => '1']) + ->andWhere(function (ExpressionInterface $exp) { + return $exp->eq('created', new DateTime('2022-12-21 12:00'), 'datetime'); + }) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that expression objects can be used as the field in a comparison + * and the values will be bound correctly to the query + */ + public function testSelectWhereUsingExpressionInField(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + $field = clone $exp; + $field->add('SELECT min(id) FROM comments'); + + return $exp + ->eq($field, 100, 'integer'); + }) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests using where conditions with operator methods + */ + public function testSelectWhereOperatorMethods(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->gt('id', 1); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['title' => 'Second Article'], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->lt('id', 2); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['title' => 'First Article'], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->lte('id', 2); + }) + ->execute(); + $this->assertCount(2, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->gte('id', 1); + }) + ->execute(); + $this->assertCount(3, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->lte('id', 1); + }) + ->execute(); + $this->assertCount(1, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->notEq('id', 2); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['title' => 'First Article'], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->like('title', 'First Article'); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['title' => 'First Article'], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->like('title', '%Article%'); + }) + ->execute(); + $this->assertCount(3, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->notLike('title', '%Article%'); + }) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->isNull('published'); + }) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->isNotNull('published'); + }) + ->execute(); + $this->assertCount(6, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->in('published', ['Y', 'N']); + }) + ->execute(); + $this->assertCount(6, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->in( + 'created', + [new DateTime('2007-03-18 10:45:23'), new DateTime('2007-03-18 10:47:23')], + 'datetime', + ); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $this->assertEquals(['id' => 2], $rows[1]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->notIn( + 'created', + [new DateTime('2007-03-18 10:45:23'), new DateTime('2007-03-18 10:47:23')], + 'datetime', + ); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(4, $rows); + $this->assertEquals(['id' => 3], $rows[0]); + $result->closeCursor(); + } + + /** + * Tests that calling "in" and "notIn" will cast the passed values to an array + */ + public function testInValueCast(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->in('created', '2007-03-18 10:45:23', 'datetime'); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->notIn('created', '2007-03-18 10:45:23', 'datetime'); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(5, $rows); + $this->assertEquals(['id' => 2], $rows[0]); + $this->assertEquals(['id' => 3], $rows[1]); + $this->assertEquals(['id' => 4], $rows[2]); + $this->assertEquals(['id' => 5], $rows[3]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function ($exp, $q) { + return $exp->in( + 'created', + $q->expr("'2007-03-18 10:45:23'"), + 'datetime', + ); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function ($exp, $q) { + return $exp->notIn( + 'created', + $q->expr("'2007-03-18 10:45:23'"), + 'datetime', + ); + }) + ->execute(); + $this->assertCount(5, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that calling "in" and "notIn" will cast the passed values to an array + */ + public function testInValueCast2(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['created IN' => '2007-03-18 10:45:23']) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(['created NOT IN' => '2007-03-18 10:45:23']) + ->execute(); + $this->assertCount(5, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that IN clauses generate correct placeholders + */ + public function testInClausePlaceholderGeneration(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('comments') + ->where(['id IN' => [1, 2]]) + ->sql(); + $bindings = $query->getValueBinder()->bindings(); + $this->assertArrayHasKey(':c0', $bindings); + $this->assertSame('c0', $bindings[':c0']['placeholder']); + $this->assertArrayHasKey(':c1', $bindings); + $this->assertSame('c1', $bindings[':c1']['placeholder']); + } + + /** + * Tests where() with callable types. + */ + public function testWhereCallables(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->where([ + 'id' => 'Cake\Error\Debugger::dump', + 'title' => ['Cake\Error\Debugger', 'dump'], + 'author_id' => function (ExpressionInterface $exp) { + return 1; + }, + ]); + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE \(<id> = :c0 AND <title> = :c1 AND <author_id> = :c2\)', + $query->sql(), + !$this->autoQuote, + ); + } + + /** + * Tests that empty values don't set where clauses. + */ + public function testWhereEmptyValues(): void + { + $query = new SelectQuery($this->connection); + $query->from('comments') + ->where(''); + + $this->assertCount(0, $query->clause('where')); + + $query->where([]); + $this->assertCount(0, $query->clause('where')); + } + + /** + * Tests that it is possible to use a between expression + * in a where condition + */ + public function testWhereWithBetween(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->between('id', 5, 6, 'integer'); + }) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(5, $rows[0]['id']); + + $this->assertEquals(6, $rows[1]['id']); + $result->closeCursor(); + } + + /** + * Tests that it is possible to use a between expression + * in a where condition with a complex data type + */ + public function testWhereWithBetweenComplex(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + $from = new DateTime('2007-03-18 10:51:00'); + $to = new DateTime('2007-03-18 10:54:00'); + + return $exp->between('created', $from, $to, 'datetime'); + }) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(4, $rows[0]['id']); + + $this->assertEquals(5, $rows[1]['id']); + $result->closeCursor(); + } + + /** + * Tests that it is possible to use an expression object + * as the field for a between expression + */ + public function testWhereWithBetweenWithExpressionField(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function ($exp, $q) { + $field = $q->func()->coalesce([new IdentifierExpression('id'), 1 => 'literal']); + + return $exp->between($field, 5, 6, 'integer'); + }) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(5, $rows[0]['id']); + + $this->assertEquals(6, $rows[1]['id']); + $result->closeCursor(); + } + + /** + * Tests that it is possible to use an expression object + * as any of the parts of the between expression + */ + public function testWhereWithBetweenWithExpressionParts(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp, Query $q) { + $from = $q->expr("'2007-03-18 10:51:00'"); + $to = $q->expr("'2007-03-18 10:54:00'"); + + return $exp->between('created', $from, $to); + }) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(4, $rows[0]['id']); + + $this->assertEquals(5, $rows[1]['id']); + $result->closeCursor(); + } + + /** + * Tests nesting query expressions both using arrays and closures + */ + public function testSelectExpressionComposition(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + $and = $exp->and(['id' => 2, 'id >' => 1]); + + return $exp->add($and); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 2], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + $and = $exp->and(['id' => 2, 'id <' => 2]); + + return $exp->add($and); + }) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + $and = $exp->and(function ($and) { + return $and->eq('id', 1)->gt('id', 0); + }); + + return $exp->add($and); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + $or = $exp->or(['id' => 1]); + $and = $exp->and(['id >' => 2, 'id <' => 4]); + + return $or->add($and); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $this->assertEquals(['id' => 3], $rows[1]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + $or = $exp->or(function ($or) { + return $or->eq('id', 1)->eq('id', 2); + }); + + return $or; + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $this->assertEquals(['id' => 2], $rows[1]); + $result->closeCursor(); + } + + /** + * Tests that conditions can be nested with an unary operator using the array notation + * and the not() method + */ + public function testSelectWhereNot(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->not( + $exp->and(['id' => 2, 'created' => new DateTime('2007-03-18 10:47:23')], ['created' => 'datetime']), + ); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(5, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $this->assertEquals(['id' => 3], $rows[1]); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->where(function (ExpressionInterface $exp) { + return $exp->not( + $exp->and(['id' => 2, 'created' => new DateTime('2012-12-21 12:00')], ['created' => 'datetime']), + ); + }) + ->execute(); + $this->assertCount(6, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that conditions can be nested with an unary operator using the array notation + * and the not() method + */ + public function testSelectWhereNot2(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('articles') + ->where([ + 'not' => ['or' => ['id' => 1, 'id >' => 2], 'id' => 3], + ]) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $this->assertEquals(['id' => 2], $rows[1]); + $result->closeCursor(); + } + + /** + * Tests whereInArray() and its input types. + */ + public function testWhereInArray(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->whereInList('id', [2, 3]) + ->orderBy(['id']); + + $sql = $query->sql(); + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE <id> IN \\(:c0,:c1\\)', + $sql, + !$this->autoQuote, + ); + + $result = $query->execute()->fetchAll('assoc'); + $this->assertEquals(['id' => '2'], $result[0]); + } + + /** + * Tests whereInArray() and empty array input. + */ + public function testWhereInArrayEmpty(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->whereInList('id', [], ['allowEmpty' => true]); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE 1=0', + $query->sql(), + !$this->autoQuote, + ); + + $statement = $query->execute(); + $this->assertFalse($statement->fetch('assoc')); + $statement->closeCursor(); + } + + /** + * Tests whereNotInList() and its input types. + */ + public function testWhereNotInList(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->whereNotInList('id', [1, 3]); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE <id> NOT IN \\(:c0,:c1\\)', + $query->sql(), + !$this->autoQuote, + ); + + $result = $query->execute()->fetchAll('assoc'); + $this->assertEquals(['id' => '2'], $result[0]); + } + + /** + * Tests whereNotInList() and empty array input. + */ + public function testWhereNotInListEmpty(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->whereNotInList('id', [], ['allowEmpty' => true]) + ->orderBy(['id']); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE \(<id>\) IS NOT NULL', + $query->sql(), + !$this->autoQuote, + ); + + $result = $query->execute()->fetchAll('assoc'); + $this->assertEquals(['id' => '1'], $result[0]); + } + + /** + * Tests whereNotInListOrNull() and its input types. + */ + public function testWhereNotInListOrNull(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->whereNotInListOrNull('id', [1, 3]); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE \\(<id> NOT IN \\(:c0,:c1\\) OR \\(<id>\\) IS NULL\\)', + $query->sql(), + !$this->autoQuote, + ); + + $result = $query->execute()->fetchAll('assoc'); + $this->assertEquals(['id' => '2'], $result[0]); + } + + /** + * Tests whereNotInListOrNull() and empty array input. + */ + public function testWhereNotInListOrNullEmpty(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->whereNotInListOrNull('id', [], ['allowEmpty' => true]) + ->orderBy(['id']); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE \(<id>\) IS NOT NULL', + $query->sql(), + !$this->autoQuote, + ); + + $result = $query->execute()->fetchAll('assoc'); + $this->assertEquals(['id' => '1'], $result[0]); + } + + /** + * Tests orderBy() method both with simple fields and expressions + */ + public function testSelectOrderBy(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->orderBy(['id' => 'desc']) + ->execute(); + $this->assertEquals(['id' => 6], $result->fetch('assoc')); + $this->assertEquals(['id' => 5], $result->fetch('assoc')); + $this->assertEquals(['id' => 4], $result->fetch('assoc')); + $result->closeCursor(); + + $result = $query->orderBy(['id' => 'asc'])->execute(); + $this->assertEquals(['id' => 1], $result->fetch('assoc')); + $this->assertEquals(['id' => 2], $result->fetch('assoc')); + $this->assertEquals(['id' => 3], $result->fetch('assoc')); + $result->closeCursor(); + + $result = $query->orderBy(['comment' => 'asc'])->execute(); + $this->assertEquals(['id' => 1], $result->fetch('assoc')); + $this->assertEquals(['id' => 2], $result->fetch('assoc')); + $this->assertEquals(['id' => 3], $result->fetch('assoc')); + $result->closeCursor(); + + $result = $query->orderBy(['comment' => 'asc'], true)->execute(); + $this->assertEquals(['id' => 1], $result->fetch('assoc')); + $this->assertEquals(['id' => 5], $result->fetch('assoc')); + $this->assertEquals(['id' => 4], $result->fetch('assoc')); + $result->closeCursor(); + + $result = $query->orderBy(['user_id' => 'asc', 'created' => 'desc'], true) + ->execute(); + $this->assertEquals(['id' => 5], $result->fetch('assoc')); + $this->assertEquals(['id' => 4], $result->fetch('assoc')); + $this->assertEquals(['id' => 3], $result->fetch('assoc')); + $result->closeCursor(); + + $expression = $query->expr(['(id + :offset) % 2']); + $result = $query + ->orderBy([$expression, 'id' => 'desc'], true) + ->bind(':offset', 1) + ->execute(); + $this->assertEquals(['id' => 5], $result->fetch('assoc')); + $this->assertEquals(['id' => 3], $result->fetch('assoc')); + $this->assertEquals(['id' => 1], $result->fetch('assoc')); + $result->closeCursor(); + + $result = $query + ->orderBy($expression, true) + ->orderBy(['id' => 'asc']) + ->bind(':offset', 1) + ->execute(); + $this->assertEquals(['id' => 1], $result->fetch('assoc')); + $this->assertEquals(['id' => 3], $result->fetch('assoc')); + $this->assertEquals(['id' => 5], $result->fetch('assoc')); + $result->closeCursor(); + } + + public function testSelectOrderDeprecated(): void + { + $this->deprecated(function (): void { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->order(['id' => 'desc']) + ->execute(); + $this->assertEquals([6, 5, 4, 3, 2, 1], array_column($result->fetchAll('assoc'), 'id')); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id']) + ->from('comments') + ->orderDesc('id') + ->execute(); + $this->assertEquals([6, 5, 4, 3, 2, 1], array_column($result->fetchAll('assoc'), 'id')); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['user_id']) + ->from('comments') + ->orderAsc('user_id') + ->execute(); + $this->assertEquals([1, 1, 1, 2, 2, 4], array_column($result->fetchAll('assoc'), 'user_id')); + }); + } + + /** + * Test that orderBy() being a string works. + */ + public function testSelectOrderByString(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderBy('id asc'); + $result = $query->execute(); + $this->assertEquals(['id' => 1], $result->fetch('assoc')); + $this->assertEquals(['id' => 2], $result->fetch('assoc')); + $this->assertEquals(['id' => 3], $result->fetch('assoc')); + $result->closeCursor(); + } + + /** + * Test exception for orderBy() with an associative array which contains extra values. + */ + public function testSelectOrderByAssociativeArrayContainingExtraExpressions(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "Passing extra expressions by associative array (`'id' => 'desc -- Comment'`) " . + 'is not allowed to avoid potential SQL injection. ' . + 'Use QueryExpression or numeric array instead.', + ); + + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderBy([ + 'id' => 'desc -- Comment', + ]); + } + + /** + * Tests that orderBy() works with closures. + */ + public function testSelectOrderByClosure(): void + { + $query = new SelectQuery($this->connection); + $query + ->select('*') + ->from('articles') + ->orderBy(function ($exp, $q) use ($query) { + $this->assertInstanceOf(QueryExpression::class, $exp); + $this->assertSame($query, $q); + + return ['id' => 'ASC']; + }); + + $this->assertQuotedQuery( + 'SELECT \* FROM <articles> ORDER BY <id> ASC', + $query->sql(), + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $query + ->select('*') + ->from('articles') + ->orderBy(function (ExpressionInterface $exp) { + return [$exp->add(['id % 2 = 0']), 'title' => 'ASC']; + }); + + $this->assertQuotedQuery( + 'SELECT \* FROM <articles> ORDER BY id % 2 = 0, <title> ASC', + $query->sql(), + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $query + ->select('*') + ->from('articles') + ->orderBy(function (ExpressionInterface $exp) { + return $exp->add('a + b'); + }); + + $this->assertQuotedQuery( + 'SELECT \* FROM <articles> ORDER BY a \+ b', + $query->sql(), + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $query + ->select('*') + ->from('articles') + ->orderBy(function ($exp, $q) { + return $q->func()->sum('a'); + }); + + $this->assertQuotedQuery( + 'SELECT \* FROM <articles> ORDER BY SUM\(a\)', + $query->sql(), + !$this->autoQuote, + ); + } + + /** + * Test orderByAsc() and its input types. + */ + public function testSelectOrderByAsc(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderByAsc('id'); + + $sql = $query->sql(); + $result = $query->execute()->fetchAll('assoc'); + $expected = [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ]; + $this->assertEquals($expected, $result); + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> ORDER BY <id> ASC', + $sql, + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderByAsc($query->func()->concat(['id' => 'identifier', '3'])); + + $result = $query->execute()->fetchAll('assoc'); + $expected = [ + ['id' => 1], + ['id' => 2], + ['id' => 3], + ]; + $this->assertEquals($expected, $result); + + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderByAsc(function (QueryExpression $exp, Query $query) { + return $exp + ->case() + ->when(['author_id' => 1]) + ->then(1) + ->else($query->identifier('id')); + }) + ->orderByAsc('id'); + $sql = $query->sql(); + $result = $query->execute()->fetchAll('assoc'); + $expected = [ + ['id' => 1], + ['id' => 3], + ['id' => 2], + ]; + $this->assertEquals($expected, $result); + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> ORDER BY CASE WHEN <author_id> = :c0 THEN :c1 ELSE <id> END ASC, <id> ASC', + $sql, + !$this->autoQuote, + ); + } + + /** + * Test orderByDesc() and its input types. + */ + public function testSelectOrderByDesc(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderByDesc('id'); + $sql = $query->sql(); + $result = $query->execute()->fetchAll('assoc'); + $expected = [ + ['id' => 3], + ['id' => 2], + ['id' => 1], + ]; + $this->assertEquals($expected, $result); + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> ORDER BY <id> DESC', + $sql, + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderByDesc($query->func()->concat(['id' => 'identifier', '3'])); + + $result = $query->execute()->fetchAll('assoc'); + $expected = [ + ['id' => 3], + ['id' => 2], + ['id' => 1], + ]; + $this->assertEquals($expected, $result); + + $query = new SelectQuery($this->connection); + $query->select(['id']) + ->from('articles') + ->orderByDesc(function (QueryExpression $exp, Query $query) { + return $exp + ->case() + ->when(['author_id' => 1]) + ->then(1) + ->else($query->identifier('id')); + }) + ->orderByDesc('id'); + $sql = $query->sql(); + $result = $query->execute()->fetchAll('assoc'); + $expected = [ + ['id' => 2], + ['id' => 3], + ['id' => 1], + ]; + $this->assertEquals($expected, $result); + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> ORDER BY CASE WHEN <author_id> = :c0 THEN :c1 ELSE <id> END DESC, <id> DESC', + $sql, + !$this->autoQuote, + ); + } + + /** + * Tests that group by fields can be passed similar to select fields + * and that it sends the correct query to the database + */ + public function testSelectGroupBy(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['total' => 'count(author_id)', 'author_id']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => 'author_id = a.id']) + ->groupBy('author_id') + ->orderBy(['total' => 'desc']) + ->execute(); + $expected = [['total' => 2, 'author_id' => 1], ['total' => '1', 'author_id' => 3]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $result = $query->select(['total' => 'count(title)', 'name'], true) + ->groupBy(['name'], true) + ->orderBy(['total' => 'asc']) + ->execute(); + $expected = [['total' => 1, 'name' => 'larry'], ['total' => 2, 'name' => 'mariano']]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $result = $query->select(['articles.id']) + ->groupBy(['articles.id']) + ->execute(); + $this->assertCount(3, $result->fetchAll()); + } + + public function testSelectGroupDeprecated(): void + { + $this->deprecated(function (): void { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['total' => 'count(author_id)', 'author_id']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => 'author_id = a.id']) + ->group('author_id') + ->orderBy(['total' => 'desc']) + ->execute(); + $expected = [['total' => 2, 'author_id' => 1], ['total' => '1', 'author_id' => 3]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + }); + } + + /** + * Tests that it is possible to select distinct rows + */ + public function testSelectDistinct(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['author_id']) + ->from(['a' => 'articles']) + ->execute(); + $this->assertCount(3, $result->fetchAll()); + + $result = $query->distinct()->execute(); + $this->assertCount(2, $result->fetchAll()); + + $result = $query->select(['id'])->distinct(false)->execute(); + $this->assertCount(3, $result->fetchAll()); + } + + /** + * Tests distinct on a specific column reduces rows based on that column. + */ + public function testSelectDistinctON(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['author_id']) + ->distinct(['author_id']) + ->from(['a' => 'articles']) + ->orderBy(['author_id' => 'ASC']) + ->execute(); + $results = $result->fetchAll('assoc'); + $this->assertCount(2, $results); + $this->assertEquals( + [3, 1], + collection($results)->sortBy('author_id')->extract('author_id')->toList(), + ); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['author_id']) + ->distinct('author_id') + ->from(['a' => 'articles']) + ->orderBy(['author_id' => 'ASC']) + ->execute(); + $results = $result->fetchAll('assoc'); + $this->assertCount(2, $results); + $this->assertEquals( + [3, 1], + collection($results)->sortBy('author_id')->extract('author_id')->toList(), + ); + } + + /** + * Test use of modifiers in the query + * + * Testing the generated SQL since the modifiers are usually different per driver + */ + public function testSelectModifiers(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['city', 'state', 'country']) + ->from(['addresses']) + ->modifier('DISTINCTROW'); + $this->assertQuotedQuery( + 'SELECT DISTINCTROW <city>, <state>, <country> FROM <addresses>', + $result->sql(), + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['city', 'state', 'country']) + ->from(['addresses']) + ->modifier(['DISTINCTROW', 'SQL_NO_CACHE']); + $this->assertQuotedQuery( + 'SELECT DISTINCTROW SQL_NO_CACHE <city>, <state>, <country> FROM <addresses>', + $result->sql(), + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['city', 'state', 'country']) + ->from(['addresses']) + ->modifier('DISTINCTROW') + ->modifier('SQL_NO_CACHE'); + $this->assertQuotedQuery( + 'SELECT DISTINCTROW SQL_NO_CACHE <city>, <state>, <country> FROM <addresses>', + $result->sql(), + true, + ); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['city', 'state', 'country']) + ->from(['addresses']) + ->modifier(['TOP 10']); + $this->assertQuotedQuery( + 'SELECT TOP 10 <city>, <state>, <country> FROM <addresses>', + $result->sql(), + !$this->autoQuote, + ); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['city', 'state', 'country']) + ->from(['addresses']) + ->modifier($query->expr('EXPRESSION')); + $this->assertQuotedQuery( + 'SELECT EXPRESSION <city>, <state>, <country> FROM <addresses>', + $result->sql(), + !$this->autoQuote, + ); + } + + /** + * Tests that having() behaves pretty much the same as the where() method + */ + public function testSelectHaving(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['total' => 'count(author_id)', 'author_id']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => $query->expr()->equalFields('author_id', 'a.id')]) + ->groupBy('author_id') + ->having(['count(author_id) <' => 2], ['count(author_id)' => 'integer']) + ->execute(); + $expected = [['total' => 1, 'author_id' => 3]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $result = $query->having(['count(author_id)' => 2], ['count(author_id)' => 'integer'], true) + ->execute(); + $expected = [['total' => 2, 'author_id' => 1]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $result = $query->having(function ($e) { + return $e->add('count(author_id) = 1 + 1'); + }, [], true) + ->execute(); + $expected = [['total' => 2, 'author_id' => 1]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + } + + /** + * Tests that Query::andHaving() can be used to concatenate conditions with AND + * in the having clause + */ + public function testSelectAndHaving(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['total' => 'count(author_id)', 'author_id']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => $query->expr()->equalFields('author_id', 'a.id')]) + ->groupBy('author_id') + ->having(['count(author_id) >' => 2], ['count(author_id)' => 'integer']) + ->andHaving(['count(author_id) <' => 2], ['count(author_id)' => 'integer']) + ->execute(); + $this->assertCount(0, $result->fetchAll()); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['total' => 'count(author_id)', 'author_id']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => $query->expr()->equalFields('author_id', 'a.id')]) + ->groupBy('author_id') + ->having(['count(author_id)' => 2], ['count(author_id)' => 'integer']) + ->andHaving(['count(author_id) >' => 1], ['count(author_id)' => 'integer']) + ->execute(); + $expected = [['total' => 2, 'author_id' => 1]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['total' => 'count(author_id)', 'author_id']) + ->from('articles') + ->join(['table' => 'authors', 'alias' => 'a', 'conditions' => $query->expr()->equalFields('author_id', 'a.id')]) + ->groupBy('author_id') + ->andHaving(function ($e) { + return $e->add('count(author_id) = 2 - 1'); + }) + ->execute(); + $expected = [['total' => 1, 'author_id' => 3]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + } + + /** + * Test having casing with string expressions + */ + public function testHavingAliasCasingStringExpression(): void + { + $this->skipIf($this->autoQuote, 'Does not work when autoquoting is enabled.'); + $query = new SelectQuery($this->connection); + $query + ->select(['id']) + ->from(['Authors' => 'authors']) + ->where([ + 'FUNC( Authors.id) =' => 1, + 'FUNC( Authors.id) IS NOT' => null, + ]) + ->having(['COUNT(DISTINCT Authors.id) =' => 1]); + + $this->assertSame( + 'SELECT id FROM authors Authors WHERE ' . + '(FUNC( Authors.id) = :c0 AND (FUNC( Authors.id)) IS NOT NULL) ' . + 'HAVING COUNT(DISTINCT Authors.id) = :c1', + trim($query->sql()), + ); + } + + /** + * Tests selecting rows using a limit clause + */ + public function testSelectLimit(): void + { + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('articles')->limit(1)->execute(); + $this->assertCount(1, $result->fetchAll()); + + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('articles')->limit(2)->execute(); + $this->assertCount(2, $result->fetchAll()); + } + + /** + * Tests selecting rows combining a limit and offset clause + */ + public function testSelectOffset(): void + { + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('comments') + ->limit(1) + ->offset(0) + ->orderBy(['id' => 'ASC']) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('comments') + ->limit(1) + ->offset(1) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 2], $rows[0]); + + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('comments') + ->limit(1) + ->offset(2) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 3], $rows[0]); + + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('articles') + ->orderBy(['id' => 'DESC']) + ->limit(1) + ->offset(0) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 3], $rows[0]); + + $result = $query->limit(2)->offset(1)->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 2], $rows[0]); + $this->assertEquals(['id' => 1], $rows[1]); + + $query = new SelectQuery($this->connection); + $query->select('id')->from('comments') + ->limit(1) + ->offset(1) + ->execute() + ->closeCursor(); + + $reflect = new ReflectionProperty($query, '_dirty'); + $this->assertFalse($reflect->getValue($query)); + + $query->offset(2); + $this->assertTrue($reflect->getValue($query)); + } + + /** + * Test Pages number. + */ + public function testPageShouldStartAtOne(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Pages must start at 1.'); + + $query = new SelectQuery($this->connection); + $query->from('comments')->page(0); + } + + /** + * Test selecting rows using the page() method. + */ + public function testSelectPage(): void + { + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('comments') + ->limit(1) + ->page(1) + ->execute(); + + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + + $query = new SelectQuery($this->connection); + $result = $query->select('id')->from('comments') + ->limit(1) + ->page(2) + ->orderBy(['id' => 'asc']) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['id' => 2], $rows[0]); + + $query = new SelectQuery($this->connection); + $query->select('id')->from('comments')->page(3, 10); + $this->assertEquals(10, $query->clause('limit')); + $this->assertEquals(20, $query->clause('offset')); + + $query = new SelectQuery($this->connection); + $query->select('id')->from('comments')->page(1); + $this->assertEquals(25, $query->clause('limit')); + $this->assertEquals(0, $query->clause('offset')); + + $query->select('id')->from('comments')->page(2); + $this->assertEquals(25, $query->clause('limit')); + $this->assertEquals(25, $query->clause('offset')); + } + + /** + * Test selecting rows using the page() method and ordering the results + * by a calculated column. + */ + public function testSelectPageWithOrder(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select([ + 'id', + 'ids_added' => $query->expr()->add('(user_id + article_id)'), + ]) + ->from('comments') + ->orderBy(['ids_added' => 'asc']) + ->limit(2) + ->page(3) + ->execute(); + $this->assertEquals( + [ + ['id' => '6', 'ids_added' => '4'], + ['id' => '2', 'ids_added' => '5'], + ], + $result->fetchAll('assoc'), + ); + } + + /** + * Tests that Query objects can be included inside the select clause + * and be used as a normal field, including binding any passed parameter + */ + public function testSubqueryInSelect(): void + { + $query = new SelectQuery($this->connection); + $subquery = (new SelectQuery($this->connection)) + ->select('name') + ->from(['b' => 'authors']) + ->where([$query->expr()->equalFields('b.id', 'a.id')]); + $result = $query + ->select(['id', 'name' => $subquery]) + ->from(['a' => 'comments'])->execute(); + + $expected = [ + ['id' => 1, 'name' => 'mariano'], + ['id' => 2, 'name' => 'nate'], + ['id' => 3, 'name' => 'larry'], + ['id' => 4, 'name' => 'garrett'], + ['id' => 5, 'name' => null], + ['id' => 6, 'name' => null], + ]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $query = new SelectQuery($this->connection); + $subquery = (new SelectQuery($this->connection)) + ->select('name') + ->from(['b' => 'authors']) + ->where(['name' => 'mariano'], ['name' => 'string']); + $result = $query + ->select(['id', 'name' => $subquery]) + ->from(['a' => 'articles'])->execute(); + + $expected = [ + ['id' => 1, 'name' => 'mariano'], + ['id' => 2, 'name' => 'mariano'], + ['id' => 3, 'name' => 'mariano'], + ]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + } + + /** + * Tests that Query objects can be included inside the from clause + * and be used as a normal table, including binding any passed parameter + */ + public function testSuqueryInFrom(): void + { + $query = new SelectQuery($this->connection); + $subquery = (new SelectQuery($this->connection)) + ->select(['id', 'comment']) + ->from('comments') + ->where(['created >' => new DateTime('2007-03-18 10:45:23')], ['created' => 'datetime']); + $result = $query + ->select(['say' => 'comment']) + ->from(['b' => $subquery]) + ->where(['id !=' => 3]) + ->execute(); + + $expected = [ + ['say' => 'Second Comment for First Article'], + ['say' => 'Fourth Comment for First Article'], + ['say' => 'First Comment for Second Article'], + ['say' => 'Second Comment for Second Article'], + ]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + } + + /** + * Tests that Query objects can be included inside the where clause + * and be used as a normal condition, including binding any passed parameter + */ + public function testSubqueryInWhere(): void + { + $query = new SelectQuery($this->connection); + $subquery = (new SelectQuery($this->connection)) + ->select(['id']) + ->from('authors') + ->where(['id' => 1]); + $result = $query + ->select(['name']) + ->from(['authors']) + ->where(['id !=' => $subquery]) + ->execute(); + + $expected = [ + ['name' => 'nate'], + ['name' => 'larry'], + ['name' => 'garrett'], + ]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + $result->closeCursor(); + + $query = new SelectQuery($this->connection); + $subquery = (new SelectQuery($this->connection)) + ->select(['id']) + ->from('comments') + ->where(['created >' => new DateTime('2007-03-18 10:45:23')], ['created' => 'datetime']); + $result = $query + ->select(['name']) + ->from(['authors']) + ->where(['id not in' => $subquery]) + ->execute(); + + $expected = [ + ['name' => 'mariano'], + ]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + $result->closeCursor(); + } + + /** + * Tests that Query objects can be included inside the where clause + * and be used as a EXISTS and NOT EXISTS conditions + */ + public function testSubqueryExistsWhere(): void + { + $query = new SelectQuery($this->connection); + $subQuery = (new SelectQuery($this->connection)) + ->select(['id']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->equalFields('authors.id', 'articles.author_id'); + }); + $result = $query + ->select(['id']) + ->from('authors') + ->where(function ($exp) use ($subQuery) { + return $exp->exists($subQuery); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 1], $rows[0]); + $this->assertEquals(['id' => 3], $rows[1]); + + $query = new SelectQuery($this->connection); + $subQuery = (new SelectQuery($this->connection)) + ->select(['id']) + ->from('articles') + ->where(function (ExpressionInterface $exp) { + return $exp->equalFields('authors.id', 'articles.author_id'); + }); + $result = $query + ->select(['id']) + ->from('authors') + ->where(function ($exp) use ($subQuery) { + return $exp->notExists($subQuery); + }) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(2, $rows); + $this->assertEquals(['id' => 2], $rows[0]); + $this->assertEquals(['id' => 4], $rows[1]); + } + + /** + * Tests that it is possible to use a subquery in a join clause + */ + public function testSubqueryInJoin(): void + { + $subquery = (new SelectQuery($this->connection))->select('*')->from('authors'); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['title', 'name']) + ->from('articles') + ->join(['b' => $subquery]) + ->execute(); + $this->assertCount(self::ARTICLE_COUNT * self::AUTHOR_COUNT, $result->fetchAll(), 'Cross join causes multiplication'); + $result->closeCursor(); + + $subquery->where(['id' => 1]); + $result = $query->execute(); + $this->assertCount(3, $result->fetchAll()); + $result->closeCursor(); + + $query->join(['b' => ['table' => $subquery, 'conditions' => [$query->expr()->equalFields('b.id', 'articles.id')]]], [], true); + $result = $query->execute(); + $this->assertCount(1, $result->fetchAll()); + $result->closeCursor(); + } + + /** + * Tests that it is possible to one or multiple UNION statements in a query + */ + public function testUnion(): void + { + $union = (new SelectQuery($this->connection))->select(['id', 'title'])->from(['a' => 'articles']); + $query = new SelectQuery($this->connection); + $result = $query->select(['id', 'comment']) + ->from(['c' => 'comments']) + ->union($union) + ->execute(); + $rows = $result->fetchAll(); + $this->assertCount(self::COMMENT_COUNT + self::ARTICLE_COUNT, $rows); + $result->closeCursor(); + + $union->select(['foo' => 'id', 'bar' => 'title']); + $union = (new SelectQuery($this->connection)) + ->select(['id', 'name', 'other' => 'id', 'nameish' => 'name']) + ->from(['b' => 'authors']) + ->where(['id ' => 1]) + ->orderBy(['id' => 'desc']); + + $query->select(['foo' => 'id', 'bar' => 'comment'])->union($union); + $result = $query->execute(); + $rows2 = $result->fetchAll(); + $this->assertCount(self::COMMENT_COUNT + self::AUTHOR_COUNT, $rows2); + $this->assertNotEquals($rows, $rows2); + $result->closeCursor(); + + $union = (new SelectQuery($this->connection)) + ->select(['id', 'title']) + ->from(['c' => 'articles']); + $query->select(['id', 'comment'], true)->union($union, true); + $result = $query->execute(); + $rows3 = $result->fetchAll(); + $this->assertCount(self::COMMENT_COUNT + self::ARTICLE_COUNT, $rows3); + $this->assertEquals($rows, $rows3); + $result->closeCursor(); + } + + /** + * Tests that it is possible to run unions with order by statements + */ + public function testUnionOrderBy(): void + { + $this->skipIf( + !$this->connection->getDriver()->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY), + 'Driver does not support ORDER BY on UNIONed queries.', + ); + + $union = (new SelectQuery($this->connection)) + ->select(['id', 'title']) + ->from(['a' => 'articles']) + ->orderBy(['a.id' => 'asc']); + + $query = new SelectQuery($this->connection); + $result = $query->select(['id', 'comment']) + ->from(['c' => 'comments']) + ->orderBy(['c.id' => 'asc']) + ->union($union) + ->execute(); + $this->assertCount(self::COMMENT_COUNT + self::ARTICLE_COUNT, $result->fetchAll()); + } + + /** + * Tests that UNION ALL can be built by setting the second param of union() to true + */ + public function testUnionAll(): void + { + $union = (new SelectQuery($this->connection))->select(['id', 'title'])->from(['a' => 'articles']); + $query = new SelectQuery($this->connection); + $result = $query->select(['id', 'comment']) + ->from(['c' => 'comments']) + ->union($union) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(self::ARTICLE_COUNT + self::COMMENT_COUNT, $rows); + $result->closeCursor(); + + $union->select(['foo' => 'id', 'bar' => 'title']); + $union = (new SelectQuery($this->connection)) + ->select(['id', 'name', 'other' => 'id', 'nameish' => 'name']) + ->from(['b' => 'authors']) + ->where(['id ' => 1]) + ->orderBy(['id' => 'desc']); + + $query->select(['foo' => 'id', 'bar' => 'comment'])->unionAll($union); + $result = $query->execute(); + $rows2 = $result->fetchAll(); + $this->assertCount(1 + self::COMMENT_COUNT + self::ARTICLE_COUNT, $rows2); + $this->assertNotEquals($rows, $rows2); + $result->closeCursor(); + } + + /** + * Tests that it is possible to one or multiple INTERSECT statements in a query + */ + public function testIntersect(): void + { + $this->skipIf( + !$this->connection->getDriver()->supports(DriverFeatureEnum::INTERSECT), + 'Driver does not support INTERSECT clause.', + ); + + $intersect = (new SelectQuery($this->connection))->select(['id', 'comment'])->from(['c' => 'comments'])->where(['article_id' => 1]); + $query = new SelectQuery($this->connection); + $result = $query->select(['id', 'comment']) + ->from(['c' => 'comments']) + ->intersect($intersect) + ->execute(); + $rows = $result->fetchAll(); + + $this->assertCount(count($intersect->execute()->fetchAll()), $rows); + $result->closeCursor(); + + $intersect->select(['foo' => 'id', 'bar' => 'comment']); + $intersect = (new SelectQuery($this->connection)) + ->select(['id', 'comment', 'other' => 'id', 'nameish' => 'comment']) + ->from(['c' => 'comments']) + ->where($intersect->expr()->like('comment', '%First%')) + ->orderBy(['id' => 'desc']); + $expectedCount = count($query->select(['foo' => 'id', 'bar' => 'comment'])->execute()->fetchAll()); + $query->select(['foo' => 'id', 'bar' => 'comment'])->intersect($intersect); + $result = $query->execute(); + $rows2 = $result->fetchAll(); + + $this->assertCount($expectedCount, $rows2); + $this->assertNotEquals($rows, $rows2); + $result->closeCursor(); + + $intersect = (new SelectQuery($this->connection)) + ->select(['id', 'comment']) + ->where(['article_id' => 1]) + ->from(['c' => 'comments']); + $query->select(['id', 'comment'], true)->intersect($intersect, true); + $result = $query->execute(); + $rows3 = $result->fetchAll(); + + $this->assertCount(count($intersect->execute()->fetchAll()), $rows3); + $this->assertEquals($rows, $rows3); + $result->closeCursor(); + } + + /** + * Tests that it is possible to run intersects with order by statements + */ + public function testIntersectOrderBy(): void + { + $this->skipIf( + !$this->connection->getDriver()->supports(DriverFeatureEnum::INTERSECT), + 'Driver does not support INTERSECT clause.', + ); + $this->skipIf( + !$this->connection->getDriver()->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY), + 'Driver does not support ORDER BY on INTERSECTed queries.', + ); + $intersect = (new SelectQuery($this->connection)) + ->select(['id', 'comment']) + ->from(['c' => 'comments']) + ->where(['article_id' => 1]) + ->orderBy(['c.id' => 'asc']); + + $query = new SelectQuery($this->connection); + $result = $query->select(['id', 'comment']) + ->from(['c' => 'comments']) + ->orderBy(['c.id' => 'asc']) + ->intersect($intersect) + ->execute(); + + $this->assertCount(count($intersect->execute()->fetchAll()), $result->fetchAll()); + } + + /** + * Tests that INTERSECT ALL can be built + */ + public function testIntersectAll(): void + { + $this->skipIf( + !$this->connection->getDriver()->supports(DriverFeatureEnum::INTERSECT_ALL), + 'Driver does not support INTERSECT ALL clause.', + ); + $intersect = (new SelectQuery($this->connection))->select(['id', 'comment'])->from(['c' => 'comments'])->where(['article_id' => 1]); + $query = new SelectQuery($this->connection); + $result = $query->select(['id', 'comment']) + ->from(['c' => 'comments']) + ->intersectAll($intersect) + ->execute(); + $rows = $result->fetchAll('assoc'); + + $this->assertCount(count($intersect->execute()->fetchAll()), $rows); + $result->closeCursor(); + + $intersect = (new SelectQuery($this->connection)) + ->select(['article_id', 'user_id']) + ->from(['c' => 'comments']) + ->where($intersect->expr()->like('comment', '%First%')) + ->orderBy(['id' => 'desc']); + $expectedCount = count($intersect->execute()->fetchAll()); + $query->select(['article_id', 'user_id'], true)->intersectAll($intersect, true); + $result = $query->execute(); + $rows2 = $result->fetchAll(); + + $this->assertCount($expectedCount, $rows2); + $this->assertNotEquals($rows, $rows2); + $result->closeCursor(); + } + + /** + * Tests stacking decorators for results and resetting the list of decorators + */ + public function testDecorateResults(): void + { + $query = new SelectQuery($this->connection); + $result = $query + ->select(['id', 'title']) + ->from('articles') + ->orderBy(['id' => 'ASC']) + ->decorateResults(function ($row) { + $row['modified_id'] = $row['id'] + 1; + + return $row; + }) + ->all(); + + foreach ($result as $row) { + $this->assertEquals($row['id'] + 1, $row['modified_id']); + } + + $result = $query + ->decorateResults(function ($row) { + $row['modified_id']--; + + return $row; + }) + ->all(); + + foreach ($result as $row) { + $this->assertEquals($row['id'], $row['modified_id']); + } + + $result = $query + ->decorateResults(function ($row) { + $row['foo'] = 'bar'; + + return $row; + }, true) + ->all(); + + foreach ($result as $row) { + $this->assertSame('bar', $row['foo']); + $this->assertArrayNotHasKey('modified_id', $row); + } + + $result = $query->decorateResults(null, true)->all(); + foreach ($result as $row) { + $this->assertArrayNotHasKey('foo', $row); + $this->assertArrayNotHasKey('modified_id', $row); + } + } + + public function testAll(): void + { + $query = new SelectQuery($this->connection); + + $query + ->select(['id', 'title']) + ->from('articles') + ->decorateResults(function ($row) { + $row['generated'] = 'test'; + + return $row; + }) + ->all(); + + $count = 0; + foreach ($query->all() as $row) { + ++$count; + $this->assertArrayHasKey('generated', $row); + $this->assertSame('test', $row['generated']); + } + $this->assertSame(3, $count); + + $this->connection->execute('DELETE FROM articles WHERE author_id = 3')->closeCursor(); + + // Verify results are cached when query not marked dirty + $count = 0; + foreach ($query->all() as $row) { + ++$count; + } + $this->assertSame(3, $count); + + // Mark query as dirty + $query->select(['id'], true); + + // Verify query is run again + $count = 0; + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + foreach ($query->all() as $row) { + ++$count; + } + $this->assertSame(2, $count); + } + + public function testGetIterator(): void + { + $query = new SelectQuery($this->connection); + + $query + ->select(['id', 'title']) + ->from('articles') + ->decorateResults(function ($row) { + $row['generated'] = 'test'; + + return $row; + }); + + $count = 0; + foreach ($query as $row) { + ++$count; + $this->assertArrayHasKey('generated', $row); + $this->assertSame('test', $row['generated']); + } + $this->assertSame(3, $count); + + $iterable = $query->getIterator(); + $this->assertInstanceOf(ArrayIterator::class, $iterable); + + $this->connection->execute('DELETE FROM articles WHERE author_id = 3')->closeCursor(); + + $query->disableBufferedResults(); + + $count = 0; + foreach ($query as $row) { + ++$count; + $this->assertArrayHasKey('generated', $row); + $this->assertSame('test', $row['generated']); + } + $this->assertSame(2, $count); + + $iterable = $query->getIterator(); + $this->assertInstanceOf(StatementInterface::class, $iterable); + } + + /** + * Tests that functions are correctly transformed and their parameters are bound + */ + public function testSQLFunctions(): void + { + $query = new SelectQuery($this->connection); + $result = $query->select( + function ($q) { + return ['total' => $q->func()->count('*')]; + }, + ) + ->from('comments') + ->execute(); + $expected = [['total' => 6]]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $query = new SelectQuery($this->connection); + $result = $query->select([ + 'c' => $query->func()->concat(['comment' => 'literal', ' is appended']), + ]) + ->from('comments') + ->orderBy(['c' => 'ASC']) + ->limit(1) + ->execute(); + $expected = [ + ['c' => 'First Comment for First Article is appended'], + ]; + $this->assertEquals($expected, $result->fetchAll('assoc')); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['d' => $query->func()->dateDiff(['2012-01-05', '2012-01-02'])]) + ->execute() + ->fetchAll('assoc'); + $this->assertEquals(3, abs((int)$result[0]['d'])); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['d' => $query->func()->now('date')]) + ->execute(); + + $result = $result->fetchAll('assoc'); + $this->assertEquals([['d' => date('Y-m-d')]], $result); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['d' => $query->func()->now('time')]) + ->execute(); + + $this->assertWithinRange( + date('U'), + (new DateTime($result->fetchAll('assoc')[0]['d']))->format('U'), + 10, + ); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['d' => $query->func()->now()]) + ->execute(); + $this->assertWithinRange( + date('U'), + (new DateTime($result->fetchAll('assoc')[0]['d']))->format('U'), + 10, + ); + + $query = new SelectQuery($this->connection); + $result = $query + ->select([ + 'd' => $query->func()->datePart('day', 'created'), + 'm' => $query->func()->datePart('month', 'created'), + 'y' => $query->func()->datePart('year', 'created'), + 'de' => $query->func()->extract('day', 'created'), + 'me' => $query->func()->extract('month', 'created'), + 'ye' => $query->func()->extract('year', 'created'), + 'wd' => $query->func()->weekday('created'), + 'dow' => $query->func()->dayOfWeek('created'), + 'addDays' => $query->func()->dateAdd('created', 2, 'day'), + 'substractYears' => $query->func()->dateAdd('created', -2, 'year'), + ]) + ->from('comments') + ->where(['created' => '2007-03-18 10:45:23']) + ->execute() + ->fetchAll('assoc'); + + $result[0]['addDays'] = substr($result[0]['addDays'], 0, 10); + $result[0]['substractYears'] = substr($result[0]['substractYears'], 0, 10); + + $expected = [ + 'd' => 18, + 'm' => 3, + 'y' => 2007, + 'de' => 18, + 'me' => 3, + 'ye' => 2007, + 'wd' => 1, // Sunday + 'dow' => 1, + 'addDays' => '2007-03-20', + 'substractYears' => '2005-03-18', + ]; + + $driver = $this->connection->getDriver(); + if ($driver instanceof Sqlite) { + $expected = [ + 'd' => '18', + 'm' => '03', + 'y' => '2007', + 'de' => '18', + 'me' => '03', + 'ye' => '2007', + ] + $expected; + } elseif ($driver instanceof Postgres || $driver instanceof Sqlserver) { + $expected = array_map(function (int|string $value) { + return (string)$value; + }, $expected); + } + + $this->assertSame($expected, $result[0]); + } + + /** + * Tests that the values in tuple comparison expression are being bound correctly, + * specifically for dialects that translate tuple comparisons. + * + * @see \Cake\Database\Driver\TupleComparisonTranslatorTrait::_transformTupleComparison() + * @see \Cake\Database\Driver\Sqlite::_expressionTranslators() + * @see \Cake\Database\Driver\Sqlserver::_expressionTranslators() + */ + public function testTupleComparisonValuesAreBeingBoundCorrectly(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Sqlserver, + 'This test fails sporadically in SQLServer', + ); + + $query = (new SelectQuery($this->connection)) + ->select('*') + ->from('profiles') + ->where( + new TupleComparison( + ['id', 'user_id'], + [[1, 1]], + ['integer', 'integer'], + 'IN', + ), + ); + + $result = $query->all()[0]; + + $bindings = array_values($query->getValueBinder()->bindings()); + $this->assertCount(2, $bindings); + $this->assertSame(1, $bindings[0]['value']); + $this->assertSame('integer', $bindings[0]['type']); + $this->assertSame(1, $bindings[1]['value']); + $this->assertSame('integer', $bindings[1]['type']); + + $this->assertSame(1, $result['id']); + $this->assertSame(1, $result['user_id']); + } + + /** + * Tests that the values in tuple comparison expressions are being bound as expected + * when types are omitted, specifically for dialects that translate tuple comparisons. + * + * @see \Cake\Database\Driver\TupleComparisonTranslatorTrait::_transformTupleComparison() + * @see \Cake\Database\Driver\Sqlite::_expressionTranslators() + * @see \Cake\Database\Driver\Sqlserver::_expressionTranslators() + */ + public function testTupleComparisonTypesCanBeOmitted(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Sqlserver, + 'This test fails sporadically in SQLServer', + ); + + $query = (new SelectQuery($this->connection)) + ->select('*') + ->from('profiles') + ->where( + new TupleComparison( + ['id', 'user_id'], + [[1, 1]], + [], + 'IN', + ), + ); + + $result = $query->all()[0]; + + $bindings = array_values($query->getValueBinder()->bindings()); + $this->assertCount(2, $bindings); + $this->assertSame(1, $bindings[0]['value']); + $this->assertNull($bindings[0]['type']); + $this->assertSame(1, $bindings[1]['value']); + $this->assertNull($bindings[1]['type']); + + $this->assertSame(1, $result['id']); + $this->assertSame(1, $result['user_id']); + } + + /** + * Tests that default types are passed to functions accepting a $types param + */ + public function testDefaultTypes(): void + { + $query = new SelectQuery($this->connection); + $this->assertEquals([], $query->getDefaultTypes()); + $types = ['id' => 'integer', 'created' => 'datetime']; + $this->assertSame($query, $query->setDefaultTypes($types)); + $this->assertSame($types, $query->getDefaultTypes()); + + $results = $query->select(['id', 'comment']) + ->from('comments') + ->where(['created >=' => new DateTime('2007-03-18 10:55:00')]) + ->execute(); + $expected = [['id' => '6', 'comment' => 'Second Comment for Second Article']]; + $this->assertEquals($expected, $results->fetchAll('assoc')); + + // Now test default can be overridden + $types = ['created' => 'date']; + $results = $query + ->where(['created >=' => new DateTime('2007-03-18 10:50:00')], $types, true) + ->execute(); + $this->assertCount(6, $results->fetchAll(), 'All 6 rows should match.'); + } + + /** + * Tests parameter binding + */ + public function testBind(): void + { + $query = new SelectQuery($this->connection); + $results = $query->select(['id', 'comment']) + ->from('comments') + ->where(['created BETWEEN :foo AND :bar']) + ->bind(':foo', new DateTime('2007-03-18 10:50:00'), 'datetime') + ->bind(':bar', new DateTime('2007-03-18 10:52:00'), 'datetime') + ->execute(); + $expected = [['id' => '4', 'comment' => 'Fourth Comment for First Article']]; + $this->assertEquals($expected, $results->fetchAll('assoc')); + + $query = new SelectQuery($this->connection); + $results = $query->select(['id', 'comment']) + ->from('comments') + ->where(['created BETWEEN :foo AND :bar']) + ->bind(':foo', '2007-03-18 10:50:00') + ->bind(':bar', '2007-03-18 10:52:00') + ->execute(); + $this->assertEquals($expected, $results->fetchAll('assoc')); + } + + /** + * Test that epilog() will actually append a string to a select query + */ + public function testAppendSelect(): void + { + $query = new SelectQuery($this->connection); + $sql = $query + ->select(['id', 'title']) + ->from('articles') + ->where(['id' => 1]) + ->epilog('FOR UPDATE') + ->sql(); + $this->assertStringContainsString('SELECT', $sql); + $this->assertStringContainsString('FROM', $sql); + $this->assertStringContainsString('WHERE', $sql); + $this->assertSame(' FOR UPDATE', substr($sql, -11)); + } + + /** + * Tests automatic identifier quoting in the select clause + */ + public function testQuotingSelectFieldsAndAlias(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new SelectQuery($this->connection); + $sql = $query->select(['something'])->sql(); + $this->assertQuotedQuery('SELECT <something>$', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select(['foo' => 'something'])->sql(); + $this->assertQuotedQuery('SELECT <something> AS <foo>$', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select(['foo' => 1])->sql(); + $this->assertQuotedQuery('SELECT 1 AS <foo>$', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select(['foo' => '1 + 1'])->sql(); + $this->assertQuotedQuery('SELECT <1 \+ 1> AS <foo>$', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select(['foo' => $query->expr('1 + 1')])->sql(); + $this->assertQuotedQuery('SELECT \(1 \+ 1\) AS <foo>$', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select(['foo' => new IdentifierExpression('bar')])->sql(); + $this->assertQuotedQuery('<bar>', $sql); + } + + /** + * Tests automatic identifier quoting in the from clause + */ + public function testQuotingFromAndAlias(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->from(['something'])->sql(); + $this->assertQuotedQuery('FROM <something>', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->from(['foo' => 'something'])->sql(); + $this->assertQuotedQuery('FROM <something> <foo>$', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->from(['foo' => $query->expr('bar')])->sql(); + $this->assertQuotedQuery('FROM \(bar\) <foo>$', $sql); + } + + /** + * Tests automatic identifier quoting for DISTINCT ON + */ + public function testQuotingDistinctOn(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->distinct(['something'])->sql(); + $this->assertQuotedQuery('<something>', $sql); + } + + /** + * Tests automatic identifier quoting in the join clause + */ + public function testQuotingJoinsAndAlias(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->join(['something'])->sql(); + $this->assertQuotedQuery('JOIN <something>', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->join(['foo' => 'something'])->sql(); + $this->assertQuotedQuery('JOIN <something> <foo>', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->join(['foo' => $query->expr('bar')])->sql(); + $this->assertQuotedQuery('JOIN \(bar\) <foo>', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->join([ + 'alias' => 'orders', + 'table' => 'Order', + 'conditions' => ['1 = 1'], + ])->sql(); + $this->assertQuotedQuery('JOIN <Order> <orders> ON 1 = 1', $sql); + } + + /** + * Tests automatic identifier quoting in the group by clause + */ + public function testQuotingGroupBy(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->groupBy(['something'])->sql(); + $this->assertQuotedQuery('GROUP BY <something>', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->groupBy([$query->expr('bar')])->sql(); + $this->assertQuotedQuery('GROUP BY \(bar\)', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*')->groupBy([new IdentifierExpression('bar')])->sql(); + $this->assertQuotedQuery('GROUP BY \(<bar>\)', $sql); + } + + /** + * Tests automatic identifier quoting strings inside conditions expressions + */ + public function testQuotingExpressions(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new SelectQuery($this->connection); + $sql = $query->select('*') + ->where(['something' => 'value']) + ->sql(); + $this->assertQuotedQuery('WHERE <something> = :c0', $sql); + + $query = new SelectQuery($this->connection); + $sql = $query->select('*') + ->where([ + 'something' => 'value', + 'OR' => ['foo' => 'bar', 'baz' => 'cake'], + ]) + ->sql(); + $this->assertQuotedQuery('<something> = :c0 AND', $sql); + $this->assertQuotedQuery('\(<foo> = :c1 OR <baz> = :c2\)', $sql); + } + + /** + * Tests converting a query to a string + */ + public function testToString(): void + { + $query = new SelectQuery($this->connection); + $query + ->select(['title']) + ->from('articles'); + $result = (string)$query; + $this->assertQuotedQuery('SELECT <title> FROM <articles>', $result, !$this->autoQuote); + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $query = (new SelectQuery($this->connection))->select('*') + ->from('articles') + ->setDefaultTypes(['id' => 'integer']) + ->where(['id' => '1']); + + $expected = [ + '(help)' => 'This is a Query object, to get the results execute or iterate it.', + 'sql' => $query->sql(), + 'params' => [ + ':c0' => ['value' => '1', 'type' => 'integer', 'placeholder' => 'c0'], + ], + 'role' => Connection::ROLE_WRITE, + 'defaultTypes' => ['id' => 'integer'], + 'decorators' => 0, + 'executed' => false, + ]; + $result = $query->__debugInfo(); + $this->assertEquals($expected, $result); + + $query->execute(); + $expected = [ + '(help)' => 'This is a Query object, to get the results execute or iterate it.', + 'sql' => $query->sql(), + 'params' => [ + ':c0' => ['value' => '1', 'type' => 'integer', 'placeholder' => 'c0'], + ], + 'role' => Connection::ROLE_WRITE, + 'defaultTypes' => ['id' => 'integer'], + 'decorators' => 0, + 'executed' => true, + ]; + $result = $query->__debugInfo(); + $this->assertEquals($expected, $result); + } + + /** + * Tests that it is possible to pass ExpressionInterface to isNull and isNotNull + */ + public function testIsNullWithExpressions(): void + { + $query = new SelectQuery($this->connection); + $subquery = (new SelectQuery($this->connection)) + ->select(['id']) + ->from('authors') + ->where(['id' => 1]); + + $result = $query + ->select(['name']) + ->from(['authors']) + ->where(function ($exp) use ($subquery) { + return $exp->isNotNull($subquery); + }) + ->execute(); + $this->assertNotEmpty($result->fetchAll('assoc')); + + $result = (new SelectQuery($this->connection)) + ->select(['name']) + ->from(['authors']) + ->where(function ($exp) use ($subquery) { + return $exp->isNull($subquery); + }) + ->execute(); + $this->assertEmpty($result->fetchAll('assoc')); + } + + /** + * Tests that strings passed to isNull and isNotNull will be treated as identifiers + * when using autoQuoting + */ + public function testIsNullAutoQuoting(): void + { + $this->connection->getDriver()->enableAutoQuoting(true); + $query = new SelectQuery($this->connection); + $query->select('*')->from('things')->where(function (ExpressionInterface $exp) { + return $exp->isNull('field'); + }); + $this->assertQuotedQuery('WHERE \(<field>\) IS NULL', $query->sql()); + + $query = new SelectQuery($this->connection); + $query->select('*')->from('things')->where(function (ExpressionInterface $exp) { + return $exp->isNotNull('field'); + }); + $this->assertQuotedQuery('WHERE \(<field>\) IS NOT NULL', $query->sql()); + } + + /** + * Tests that using the IS operator will automatically translate to the best + * possible operator depending on the passed value + */ + public function testDirectIsNull(): void + { + $sql = (new SelectQuery($this->connection)) + ->select(['name']) + ->from(['authors']) + ->where(['name IS' => null]) + ->sql(); + $this->assertQuotedQuery('WHERE \(<name>\) IS NULL', $sql, !$this->autoQuote); + + $result = (new SelectQuery($this->connection)) + ->select(['name']) + ->from(['authors']) + ->where(['name IS' => 'larry']) + ->execute(); + $rows = $result->fetchAll('assoc'); + $this->assertCount(1, $rows); + $this->assertEquals(['name' => 'larry'], $rows[0]); + } + + /** + * Tests that using the wrong NULL operator will throw meaningful exception instead of + * cloaking as always-empty result set. + */ + public function testIsNullInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expression `name` has invalid `null` value. If `null` is a valid value, operator (IS, IS NOT) is missing.'); + + (new SelectQuery($this->connection)) + ->select(['name']) + ->from(['authors']) + ->where(['name' => null]) + ->sql(); + } + + /** + * Tests that using the wrong NULL operator will throw meaningful exception instead of + * cloaking as always-empty result set. + */ + public function testIsNotNullInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + (new SelectQuery($this->connection)) + ->select(['name']) + ->from(['authors']) + ->where(['name !=' => null]) + ->sql(); + } + + /** + * Tests that using the IS NOT operator will automatically translate to the best + * possible operator depending on the passed value + */ + public function testDirectIsNotNull(): void + { + $sql = (new SelectQuery($this->connection)) + ->select(['name']) + ->from(['authors']) + ->where(['name IS NOT' => null]) + ->sql(); + $this->assertQuotedQuery('WHERE \(<name>\) IS NOT NULL', $sql, !$this->autoQuote); + + $results = (new SelectQuery($this->connection)) + ->select(['name']) + ->from(['authors']) + ->where(['name IS NOT' => 'larry']) + ->execute() + ->fetchAll('assoc'); + + $this->assertCount(3, $results); + $this->assertNotEquals(['name' => 'larry'], $results[0]); + } + + public function testCloneWithExpression(): void + { + $query = new SelectQuery($this->connection); + $query + ->with( + new CommonTableExpression( + 'cte', + new SelectQuery($this->connection), + ), + ) + ->with(function (CommonTableExpression $cte, Query $query) { + return $cte + ->name('cte2') + ->query($query); + }); + + $clause = $query->clause('with'); + $clauseClone = (clone $query)->clause('with'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneSelectExpression(): void + { + $query = new SelectQuery($this->connection); + $query + ->select($query->expr('select')) + ->select(['alias' => $query->expr('select')]); + + $clause = $query->clause('select'); + $clauseClone = (clone $query)->clause('select'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneDistinctExpression(): void + { + $query = new SelectQuery($this->connection); + $query->distinct($query->expr('distinct')); + + $clause = $query->clause('distinct'); + $clauseClone = (clone $query)->clause('distinct'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneModifierExpression(): void + { + $query = new SelectQuery($this->connection); + $query->modifier($query->expr('modifier')); + + $clause = $query->clause('modifier'); + $clauseClone = (clone $query)->clause('modifier'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneFromExpression(): void + { + $query = new SelectQuery($this->connection); + $query->from(['alias' => new SelectQuery($this->connection)]); + + $clause = $query->clause('from'); + $clauseClone = (clone $query)->clause('from'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneJoinExpression(): void + { + $query = new SelectQuery($this->connection); + $query + ->innerJoin( + ['alias_inner' => new SelectQuery($this->connection)], + ['alias_inner.fk = parent.pk'], + ) + ->leftJoin( + ['alias_left' => new SelectQuery($this->connection)], + ['alias_left.fk = parent.pk'], + ) + ->rightJoin( + ['alias_right' => new SelectQuery($this->connection)], + ['alias_right.fk = parent.pk'], + ); + + $clause = $query->clause('join'); + $clauseClone = (clone $query)->clause('join'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value['table'], $clauseClone[$key]['table']); + $this->assertNotSame($value['table'], $clauseClone[$key]['table']); + + $this->assertEquals($value['conditions'], $clauseClone[$key]['conditions']); + $this->assertNotSame($value['conditions'], $clauseClone[$key]['conditions']); + } + } + + public function testCloneWhereExpression(): void + { + $query = new SelectQuery($this->connection); + $query + ->where($query->expr('where')) + ->where(['field' => $query->expr('where')]); + + $clause = $query->clause('where'); + $clauseClone = (clone $query)->clause('where'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneGroupExpression(): void + { + $query = new SelectQuery($this->connection); + $query->groupBy($query->expr('group')); + + $clause = $query->clause('group'); + $clauseClone = (clone $query)->clause('group'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneHavingExpression(): void + { + $query = new SelectQuery($this->connection); + $query->having($query->expr('having')); + + $clause = $query->clause('having'); + $clauseClone = (clone $query)->clause('having'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneWindowExpression(): void + { + $query = new SelectQuery($this->connection); + $query + ->window('window1', new WindowExpression()) + ->window('window2', function (WindowExpression $window) { + return $window; + }); + + $clause = $query->clause('window'); + $clauseClone = (clone $query)->clause('window'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value['name'], $clauseClone[$key]['name']); + $this->assertNotSame($value['name'], $clauseClone[$key]['name']); + + $this->assertEquals($value['window'], $clauseClone[$key]['window']); + $this->assertNotSame($value['window'], $clauseClone[$key]['window']); + } + } + + public function testCloneOrderExpression(): void + { + $query = new SelectQuery($this->connection); + $query + ->orderBy($query->expr('order')) + ->orderByAsc($query->expr('order_asc')) + ->orderByDesc($query->expr('order_desc')); + + $clause = $query->clause('order'); + $clauseClone = (clone $query)->clause('order'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneLimitExpression(): void + { + $query = new SelectQuery($this->connection); + $query->limit($query->expr('1')); + + $clause = $query->clause('limit'); + $clauseClone = (clone $query)->clause('limit'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneOffsetExpression(): void + { + $query = new SelectQuery($this->connection); + $query->offset($query->expr('1')); + + $clause = $query->clause('offset'); + $clauseClone = (clone $query)->clause('offset'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneUnionExpression(): void + { + $query = new SelectQuery($this->connection); + $query->where(['id' => 1]); + + $query2 = new SelectQuery($this->connection); + $query2->where(['id' => 2]); + + $query->union($query2); + + $clause = $query->clause('union'); + $clauseClone = (clone $query)->clause('union'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value['query'], $clauseClone[$key]['query']); + $this->assertNotSame($value['query'], $clauseClone[$key]['query']); + } + } + + public function testCloneEpilogExpression(): void + { + $query = new SelectQuery($this->connection); + $query->epilog($query->expr('epilog')); + + $clause = $query->clause('epilog'); + $clauseClone = (clone $query)->clause('epilog'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + /** + * Test that cloning goes deep. + */ + public function testDeepClone(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id', 'title' => $query->func()->concat(['title' => 'literal', 'test'])]) + ->from('articles') + ->where(['Articles.id' => 1]) + ->offset(10) + ->limit(1) + ->orderBy(['Articles.id' => 'DESC']); + $dupe = clone $query; + + $this->assertEquals($query->clause('where'), $dupe->clause('where')); + $this->assertNotSame($query->clause('where'), $dupe->clause('where')); + $dupe->where(['Articles.title' => 'thinger']); + $this->assertNotEquals($query->clause('where'), $dupe->clause('where')); + + $this->assertNotSame( + $query->clause('select')['title'], + $dupe->clause('select')['title'], + ); + $this->assertEquals($query->clause('order'), $dupe->clause('order')); + $this->assertNotSame($query->clause('order'), $dupe->clause('order')); + + $query->orderBy(['Articles.title' => 'ASC']); + $this->assertNotEquals($query->clause('order'), $dupe->clause('order')); + + $this->assertNotSame( + $query->getSelectTypeMap(), + $dupe->getSelectTypeMap(), + ); + } + + /** + * Tests the selectTypeMap method + */ + public function testSelectTypeMap(): void + { + $query = new SelectQuery($this->connection); + $typeMap = $query->getSelectTypeMap(); + $this->assertInstanceOf(TypeMap::class, $typeMap); + $another = clone $typeMap; + $query->setSelectTypeMap($another); + $this->assertSame($another, $query->getSelectTypeMap()); + + $query->setSelectTypeMap(['myid' => 'integer']); + $this->assertSame('integer', $query->getSelectTypeMap()->type('myid')); + } + + /** + * Tests the automatic type conversion for the fields in the result + */ + public function testSelectTypeConversion(): void + { + TypeFactory::set('custom_datetime', new BarType('custom_datetime')); + + $query = new SelectQuery($this->connection); + $query + ->select(['id', 'comment', 'the_date' => 'created', 'updated']) + ->from('comments') + ->limit(1) + ->getSelectTypeMap() + ->setTypes([ + 'id' => 'integer', + 'the_date' => 'datetime', + 'updated' => 'custom_datetime', + ]); + + $result = $query->execute()->fetchAll('assoc'); + $this->assertIsInt($result[0]['id']); + $this->assertInstanceOf(DateTime::class, $result[0]['the_date']); + $this->assertInstanceOf(DateTime::class, $result[0]['updated']); + } + + /** + * Test removeJoin(). + */ + public function testRemoveJoin(): void + { + $query = new SelectQuery($this->connection); + $query->select(['id', 'title']) + ->from('articles') + ->join(['authors' => [ + 'type' => 'INNER', + 'conditions' => ['articles.author_id = authors.id'], + ]]); + $this->assertArrayHasKey('authors', $query->clause('join')); + + $this->assertSame($query, $query->removeJoin('authors')); + $this->assertArrayNotHasKey('authors', $query->clause('join')); + } + + /** + * Tests that types in the type map are used in the + * specific comparison functions when using a callable + */ + public function testBetweenExpressionAndTypeMap(): void + { + $query = new SelectQuery($this->connection); + $query->select('id') + ->from('comments') + ->setDefaultTypes(['created' => 'datetime']) + ->where(function ($expr) { + $from = new DateTime('2007-03-18 10:45:00'); + $to = new DateTime('2007-03-18 10:48:00'); + + return $expr->between('created', $from, $to); + }); + $this->assertCount(2, $query->execute()->fetchAll()); + } + + /** + * Test automatic results casting + */ + public function testCastResults(): void + { + $query = new SelectQuery($this->connection); + $fields = [ + 'user_id' => 'integer', + 'is_active' => 'boolean', + ]; + $typeMap = new TypeMap($fields + ['a' => 'integer']); + $results = $query + ->select(array_keys($fields)) + ->select(['a' => 'is_active']) + ->from('profiles') + ->setSelectTypeMap($typeMap) + ->where(['user_id' => 1]) + ->execute() + ->fetchAll('assoc'); + $this->assertSame([['user_id' => 1, 'is_active' => false, 'a' => 0]], $results); + } + + /** + * Test disabling type casting + */ + public function testCastResultsDisable(): void + { + $query = new SelectQuery($this->connection); + $typeMap = new TypeMap(['a' => 'datetime']); + $results = $query + ->select(['a' => 'id']) + ->from('profiles') + ->setSelectTypeMap($typeMap) + ->limit(1) + ->disableResultsCasting() + ->execute() + ->fetchAll('assoc'); + $this->assertEquals([['a' => '1']], $results); + } + + /** + * Test obtaining the current results casting mode. + */ + public function testObtainingResultsCastingMode(): void + { + $query = new SelectQuery($this->connection); + + $this->assertTrue($query->isResultsCastingEnabled()); + + $query->disableResultsCasting(); + $this->assertFalse($query->isResultsCastingEnabled()); + } + + /** + * Test that type conversion is only applied once. + */ + public function testAllNoDuplicateTypeCasting(): void + { + $this->skipIf($this->autoQuote, 'Produces bad SQL in postgres with autoQuoting'); + $query = new SelectQuery($this->connection); + $query + ->select('1.5 AS a') + ->setSelectTypeMap(new TypeMap(['a' => 'integer'])); + + // Convert to an array and make the query dirty again. + $result = $query->execute()->fetchAll('assoc'); + $this->assertEquals([['a' => 1]], $result); + + $query->setSelectTypeMap(new TypeMap(['a' => 'float'])); + // Get results a second time. + $result = $query->execute()->fetchAll('assoc'); + + // Had the type casting being remembered from the first time, + // The value would be a truncated float (1.0) + $this->assertEquals([['a' => 1.5]], $result); + } + + /** + * Test that calling fetchAssoc return an associated array. + * + * @throws \Exception + */ + public function testFetchAssoc(): void + { + $query = new SelectQuery($this->connection); + $fields = [ + 'id' => 'integer', + 'user_id' => 'integer', + 'is_active' => 'boolean', + ]; + $typeMap = new TypeMap($fields); + $statement = $query + ->select([ + 'id', + 'user_id', + 'is_active', + ]) + ->from('profiles') + ->setSelectTypeMap($typeMap) + ->limit(1) + ->execute(); + + $this->assertSame(['id' => 1, 'user_id' => 1, 'is_active' => false], $statement->fetchAssoc()); + $statement->closeCursor(); + } + + /** + * Test that calling fetchAssoc return an empty associated array. + * + * @throws \Exception + */ + public function testFetchAssocWithEmptyResult(): void + { + $query = new SelectQuery($this->connection); + + $results = $query + ->select(['id']) + ->from('profiles') + ->where(['id' => -1]) + ->execute() + ->fetchAssoc(); + $this->assertSame([], $results); + } + + /** + * Test that calling fetch with with FETCH_TYPE_OBJ return stdClass object. + * + * @throws \Exception + */ + public function testFetchObjects(): void + { + $query = new SelectQuery($this->connection); + $stmt = $query->select([ + 'id', + 'user_id', + 'is_active', + ]) + ->from('profiles') + ->limit(1) + ->execute(); + $results = $stmt->fetch('obj'); + $stmt->closeCursor(); + + $this->assertInstanceOf(stdClass::class, $results); + } + + /** + * Test that fetchColumn() will return the correct value at $position. + * + * @throws \Exception + */ + public function testFetchColumn(): void + { + $query = new SelectQuery($this->connection); + $fields = [ + 'integer', + 'integer', + 'boolean', + ]; + $typeMap = new TypeMap($fields); + $query + ->select([ + 'id', + 'user_id', + 'is_active', + ]) + ->from('profiles') + ->setSelectTypeMap($typeMap) + ->where(['id' => 2]) + ->limit(1); + $statement = $query->execute(); + $results = $statement->fetchColumn(0); + $this->assertSame(2, $results); + $statement->closeCursor(); + + $statement = $query->execute(); + $results = $statement->fetchColumn(1); + $this->assertSame(2, $results); + $statement->closeCursor(); + + $statement = $query->execute(); + $results = $statement->fetchColumn(2); + $this->assertSame(false, $results); + $statement->closeCursor(); + } + + /** + * Test that fetchColumn() will return false if $position is not set. + * + * @throws \Exception + */ + public function testFetchColumnReturnsFalse(): void + { + $query = new SelectQuery($this->connection); + $fields = [ + 'integer', + 'integer', + 'boolean', + ]; + $typeMap = new TypeMap($fields); + $query + ->select([ + 'id', + 'user_id', + 'is_active', + ]) + ->from('profiles') + ->setSelectTypeMap($typeMap) + ->where(['id' => 2]) + ->limit(1); + $statement = $query->execute(); + $results = $statement->fetchColumn(3); + $this->assertFalse($results); + $statement->closeCursor(); + } + + /** + * Tests that query expressions can be used for ordering. + */ + public function testOrderBySubquery(): void + { + $this->autoQuote = true; + $this->connection->getDriver()->enableAutoQuoting($this->autoQuote); + + $connection = $this->connection; + + $query = new SelectQuery($connection); + + $stmt = $connection->update('articles', ['published' => 'N'], ['id' => 3]); + $stmt->closeCursor(); + + $subquery = new SelectQuery($connection); + $subquery + ->select( + $subquery->expr()->case()->when(['a.published' => 'N'])->then(1)->else(0), + ) + ->from(['a' => 'articles']) + ->where([ + 'a.id = articles.id', + ]); + + $query + ->select(['id']) + ->from('articles') + ->orderByDesc($subquery) + ->orderByAsc('id') + ->setSelectTypeMap(new TypeMap([ + 'id' => 'integer', + ])); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> ORDER BY \(' . + 'SELECT \(CASE WHEN <a>\.<published> = \:c0 THEN \:c1 ELSE \:c2 END\) ' . + 'FROM <articles> <a> ' . + 'WHERE a\.id = articles\.id' . + '\) DESC, <id> ASC', + $query->sql(), + !$this->autoQuote, + ); + + $this->assertEquals( + [ + [ + 'id' => 3, + ], + [ + 'id' => 1, + ], + [ + 'id' => 2, + ], + ], + $query->execute()->fetchAll('assoc'), + ); + } + + /** + * Test that reusing expressions will duplicate bindings and run successfully. + * + * This replicates what the SQL Server driver would do for <= SQL Server 2008 + * when ordering on fields that are expressions. + * + * @see \Cake\Database\Driver\Sqlserver::_pagingSubquery() + */ + public function testReusingExpressions(): void + { + $connection = $this->connection; + + $query = new SelectQuery($connection); + + $stmt = $connection->update('articles', ['published' => 'N'], ['id' => 3]); + $stmt->closeCursor(); + + $subqueryA = new SelectQuery($connection); + $subqueryA + ->select('count(*)') + ->from(['a' => 'articles']) + ->where([ + 'a.id = articles.id', + 'a.published' => 'Y', + ]); + + $subqueryB = new SelectQuery($connection); + $subqueryB + ->select('count(*)') + ->from(['b' => 'articles']) + ->where([ + 'b.id = articles.id', + 'b.published' => 'N', + ]); + + $query + ->select([ + 'id', + 'computedA' => $subqueryA, + 'computedB' => $subqueryB, + ]) + ->from('articles') + ->orderByDesc($subqueryB) + ->orderByAsc('id') + ->setSelectTypeMap(new TypeMap([ + 'id' => 'integer', + 'computedA' => 'integer', + 'computedB' => 'integer', + ])); + + $this->assertQuotedQuery( + 'SELECT <id>, ' . + '\(SELECT count\(\*\) FROM <articles> <a> WHERE \(a\.id = articles\.id AND <a>\.<published> = :c0\)\) AS <computedA>, ' . + '\(SELECT count\(\*\) FROM <articles> <b> WHERE \(b\.id = articles\.id AND <b>\.<published> = :c1\)\) AS <computedB> ' . + 'FROM <articles> ' . + 'ORDER BY \(' . + 'SELECT count\(\*\) FROM <articles> <b> WHERE \(b\.id = articles\.id AND <b>\.<published> = :c2\)' . + '\) DESC, <id> ASC', + $query->sql(), + !$this->autoQuote, + ); + + $this->assertSame( + [ + [ + 'id' => 3, + 'computedA' => 0, + 'computedB' => 1, + ], + [ + 'id' => 1, + 'computedA' => 1, + 'computedB' => 0, + ], + [ + 'id' => 2, + 'computedA' => 1, + 'computedB' => 0, + ], + ], + $query->execute()->fetchAll('assoc'), + ); + + $this->assertSame( + [ + ':c0' => [ + 'value' => 'Y', + 'type' => null, + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 'N', + 'type' => null, + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'N', + 'type' => null, + 'placeholder' => 'c2', + ], + ], + $query->getValueBinder()->bindings(), + ); + } + + /** + * Tests creating StringExpression. + */ + public function testStringExpression(): void + { + $driver = $this->connection->getDriver(); + $collation = null; + if ($driver instanceof Mysql) { + if (version_compare($this->connection->getDriver()->version(), '5.7.0', '<')) { + $collation = 'utf8_general_ci'; + } else { + $collation = 'utf8mb4_general_ci'; + } + } elseif ($driver instanceof Postgres) { + $collation = 'en_US.utf8'; + } elseif ($driver instanceof Sqlite) { + $collation = 'BINARY'; + } elseif ($driver instanceof Sqlserver) { + $collation = 'Latin1_general_CI_AI'; + } + + $query = new SelectQuery($this->connection); + if ($driver instanceof Postgres) { + // Older postgres versions throw an error on the parameter type without a cast + $query->select(['test_string' => $query->func()->cast(new StringExpression('testString', $collation), 'text')]); + $expected = "SELECT \(CAST\(:c0 COLLATE \"{$collation}\" AS text\)\) AS <test_string>"; + } else { + $query->select(['test_string' => new StringExpression('testString', $collation)]); + $expected = "SELECT \(:c0 COLLATE {$collation}\) AS <test_string>"; + } + $this->assertRegExpSql($expected, $query->sql(new ValueBinder()), !$this->autoQuote); + + $statement = $query->execute(); + $this->assertSame('testString', $statement->fetchColumn(0)); + $statement->closeCursor(); + } + + /** + * Tests setting identifier collation. + */ + public function testIdentifierCollation(): void + { + $driver = $this->connection->getDriver(); + $collation = null; + if ($driver instanceof Mysql) { + if (version_compare($this->connection->getDriver()->version(), '5.7.0', '<')) { + $collation = 'utf8_general_ci'; + } else { + $collation = 'utf8mb4_general_ci'; + } + } elseif ($driver instanceof Postgres) { + $collation = 'en_US.utf8'; + } elseif ($driver instanceof Sqlite) { + $collation = 'BINARY'; + } elseif ($driver instanceof Sqlserver) { + $collation = 'Latin1_general_CI_AI'; + } + + $query = (new SelectQuery($this->connection)) + ->select(['test_string' => new IdentifierExpression('title', $collation)]) + ->from('articles') + ->where(['id' => 1]); + + if ($driver instanceof Postgres) { + // Older postgres versions throw an error on the parameter type without a cast + $expected = "SELECT \(<title> COLLATE \"{$collation}\"\) AS <test_string>"; + } else { + $expected = "SELECT \(<title> COLLATE {$collation}\) AS <test_string>"; + } + $this->assertRegExpSql($expected, $query->sql(new ValueBinder()), !$this->autoQuote); + + $statement = $query->execute(); + $this->assertSame('First Article', $statement->fetchColumn(0)); + $statement->closeCursor(); + } + + /** + * Simple expressions from the point of view of the query expression + * object are expressions where the field contains one space at most. + */ + public function testOperatorsInSimpleConditionsAreCaseInsensitive(): void + { + $query = (new SelectQuery($this->connection)) + ->select('id') + ->from('articles') + ->where(['id in' => [1, 2, 3]]); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE <id> IN \(:c0,:c1,:c2\)', + $query->sql(), + !$this->autoQuote, + ); + + $query = (new SelectQuery($this->connection)) + ->select('id') + ->from('articles') + ->where(['id IN' => [1, 2, 3]]); + + $this->assertQuotedQuery( + 'SELECT <id> FROM <articles> WHERE <id> IN \(:c0,:c1,:c2\)', + $query->sql(), + !$this->autoQuote, + ); + } + + /** + * Complex expressions from the point of view of the query expression + * object are expressions where the field contains multiple spaces. + */ + public function testOperatorsInComplexConditionsAreCaseInsensitive(): void + { + $this->skipIf($this->autoQuote, 'Does not work when autoquoting is enabled.'); + + $query = (new SelectQuery($this->connection)) + ->select('id') + ->from('profiles') + ->where(['CONCAT(first_name, " ", last_name) in' => ['foo bar', 'baz 42']]); + + $this->assertSame( + 'SELECT id FROM profiles WHERE CONCAT\(first_name, " ", last_name\) in \(:c0,:c1\)', + $query->sql(), + ); + + $query = (new SelectQuery($this->connection)) + ->select('id') + ->from('profiles') + ->where(['CONCAT(first_name, " ", last_name) IN' => ['foo bar', 'baz 42']]); + + $this->assertSame( + 'SELECT id FROM profiles WHERE CONCAT\(first_name, " ", last_name\) in \(:c0,:c1\)', + $query->sql(), + ); + + $query = (new SelectQuery($this->connection)) + ->select('id') + ->from('profiles') + ->where(['CONCAT(first_name, " ", last_name) not in' => ['foo bar', 'baz 42']]); + + $this->assertSame( + 'SELECT id FROM profiles WHERE CONCAT\(first_name, " ", last_name\) not in \(:c0,:c1\)', + $query->sql(), + ); + + $query = (new SelectQuery($this->connection)) + ->select('id') + ->from('profiles') + ->where(['CONCAT(first_name, " ", last_name) NOT IN' => ['foo bar', 'baz 42']]); + + $this->assertSame( + 'SELECT id FROM profiles WHERE CONCAT\(first_name, " ", last_name\) not in \(:c0,:c1\)', + $query->sql(), + ); + } +} diff --git a/tests/TestCase/Database/Query/UpdateQueryTest.php b/tests/TestCase/Database/Query/UpdateQueryTest.php new file mode 100644 index 00000000000..5890ce9f59a --- /dev/null +++ b/tests/TestCase/Database/Query/UpdateQueryTest.php @@ -0,0 +1,536 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Query; + +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Postgres; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; +use Cake\Database\Exception\DatabaseException; +use Cake\Database\Expression\IdentifierExpression; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\ExpressionInterface; +use Cake\Database\Query\SelectQuery; +use Cake\Database\Query\UpdateQuery; +use Cake\Database\StatementInterface; +use Cake\Database\ValueBinder; +use Cake\Datasource\ConnectionManager; +use Cake\Test\TestCase\Database\QueryAssertsTrait; +use Cake\TestSuite\TestCase; +use DateTime; +use Mockery; + +/** + * Tests UpdateQuery class + */ +class UpdateQueryTest extends TestCase +{ + use QueryAssertsTrait; + + protected array $fixtures = [ + 'core.Articles', + 'core.Authors', + 'core.Comments', + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var bool + */ + protected $autoQuote; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->connection->getDriver()->enableAutoQuoting($this->autoQuote); + unset($this->connection); + } + + /** + * Test a simple update. + */ + public function testUpdateSimple(): void + { + $query = new UpdateQuery($this->connection); + $query->update('authors') + ->set('name', 'mark') + ->where(['id' => 1]); + $result = $query->sql(); + $this->assertQuotedQuery('UPDATE <authors> SET <name> = :', $result, !$this->autoQuote); + + $result = $query->execute(); + $this->assertSame(1, $result->rowCount()); + $result->closeCursor(); + } + + /** + * Test query construction with fields containing spaces. + */ + public function testUpdateSpaceColumnNames(): void + { + $data = [ + 'Column with spaces' => '1', + 'Column_without_spaces' => '1', + ]; + + $query = new UpdateQuery($this->connection); + $query->update('example') + ->set($data) + ->where(['id' => 1]); + + $result = $query->sql(); + $this->assertQuotedQuery( + 'UPDATE <example> SET <Column with spaces> = :c0 , <Column_without_spaces> = :c1', + $result, + !$this->autoQuote, + ); + } + + /** + * Test update with multiple fields. + */ + public function testUpdateMultipleFields(): void + { + $query = new UpdateQuery($this->connection); + $query->update('articles') + ->set('title', 'mark', 'string') + ->set('body', 'some text', 'string') + ->where(['id' => 1]); + $result = $query->sql(); + + $this->assertQuotedQuery( + 'UPDATE <articles> SET <title> = :c0 , <body> = :c1', + $result, + !$this->autoQuote, + ); + + $this->assertQuotedQuery(' WHERE <id> = :c2$', $result, !$this->autoQuote); + $result = $query->execute(); + $this->assertSame(1, $result->rowCount()); + $result->closeCursor(); + } + + /** + * Test updating multiple fields with an array. + */ + public function testUpdateMultipleFieldsArray(): void + { + $query = new UpdateQuery($this->connection); + $query->update('articles') + ->set([ + 'title' => 'mark', + 'body' => 'some text', + ], ['title' => 'string', 'body' => 'string']) + ->where(['id' => 1]); + $result = $query->sql(); + + $this->assertQuotedQuery( + 'UPDATE <articles> SET <title> = :c0 , <body> = :c1', + $result, + !$this->autoQuote, + ); + $this->assertQuotedQuery('WHERE <id> = :', $result, !$this->autoQuote); + + $result = $query->execute(); + $this->assertSame(1, $result->rowCount()); + $result->closeCursor(); + } + + /** + * Test updates with an expression. + */ + public function testUpdateWithExpression(): void + { + $query = new UpdateQuery($this->connection); + + $expr = $query->expr()->equalFields('article_id', 'user_id'); + + $query->update('comments') + ->set($expr) + ->where(['id' => 1]); + $result = $query->sql(); + + $this->assertQuotedQuery( + 'UPDATE <comments> SET <article_id> = <user_id> WHERE <id> = :', + $result, + !$this->autoQuote, + ); + + $result = $query->execute(); + $this->assertSame(1, $result->rowCount()); + $result->closeCursor(); + } + + /** + * Tests update with subquery that references itself + */ + public function testUpdateSubquery(): void + { + $this->skipIf($this->connection->getDriver() instanceof Mysql); + + $subquery = new SelectQuery($this->connection); + $subquery + ->select('created') + ->from(['c' => 'comments']) + ->where(['c.id' => new IdentifierExpression('comments.id')]); + + $query = new UpdateQuery($this->connection); + $query->update('comments') + ->set('updated', $subquery); + + $this->assertEqualsSql( + 'UPDATE comments SET updated = (SELECT created FROM comments c WHERE c.id = comments.id)', + $query->sql(new ValueBinder()), + ); + + $result = $query->execute(); + $this->assertSame(6, $result->rowCount()); + $result->closeCursor(); + + $result = (new SelectQuery($this->connection))->select(['created', 'updated'])->from('comments')->execute(); + foreach ($result->fetchAll('assoc') as $row) { + $this->assertSame($row['created'], $row['updated']); + } + $result->closeCursor(); + } + + /** + * Test update with array fields and types. + */ + public function testUpdateArrayFields(): void + { + $query = new UpdateQuery($this->connection); + $date = new DateTime(); + $query->update('comments') + ->set(['comment' => 'mark', 'created' => $date], ['created' => 'date']) + ->where(['id' => 1]); + $result = $query->sql(); + + $this->assertQuotedQuery( + 'UPDATE <comments> SET <comment> = :c0 , <created> = :c1', + $result, + !$this->autoQuote, + ); + + $this->assertQuotedQuery(' WHERE <id> = :c2$', $result, !$this->autoQuote); + $result = $query->execute(); + $this->assertSame(1, $result->rowCount()); + + $query = new SelectQuery($this->connection); + $result = $query->select('created')->from('comments')->where(['id' => 1])->execute(); + $result = $result->fetchAll('assoc')[0]['created']; + $this->assertStringStartsWith($date->format('Y-m-d'), $result); + } + + /** + * Test update with callable in set + */ + public function testUpdateSetCallable(): void + { + $query = new UpdateQuery($this->connection); + $date = new DateTime(); + $query->update('comments') + ->set(function ($exp) use ($date) { + return $exp + ->eq('comment', 'mark') + ->eq('created', $date, 'date'); + }) + ->where(['id' => 1]); + $result = $query->sql(); + + $this->assertQuotedQuery( + 'UPDATE <comments> SET <comment> = :c0 , <created> = :c1', + $result, + !$this->autoQuote, + ); + + $this->assertQuotedQuery(' WHERE <id> = :c2$', $result, !$this->autoQuote); + $result = $query->execute(); + $this->assertSame(1, $result->rowCount()); + } + + /** + * Ensure that queries build when they contain expressions. + */ + public function testUpdateExpression(): void + { + $expression = new QueryExpression(['post_count = post_count + 10']); + $query = new UpdateQuery($this->connection); + $query + ->update('counter_cache_users') + ->set($expression) + ->where(['id' => 1]); + $this->assertStringContainsString( + 'SET post_count = post_count + 10', + $query->sql(), + ); + } + + /** + * Tests that aliases are stripped from update query conditions + * where possible. + */ + public function testUpdateStripAliasesFromConditions(): void + { + $query = new UpdateQuery($this->connection); + + $query + ->update('authors') + ->set(['name' => 'name']) + ->where([ + 'OR' => [ + 'a.id' => 1, + 'a.name IS' => null, + 'a.email IS NOT' => null, + 'AND' => [ + 'b.name NOT IN' => ['foo', 'bar'], + 'OR' => [ + $query->expr()->eq(new IdentifierExpression('c.name'), 'zap'), + 'd.name' => 'baz', + (new SelectQuery($this->connection))->select(['e.name'])->where(['e.name' => 'oof']), + ], + ], + ], + ]); + + $this->assertQuotedQuery( + 'UPDATE <authors> SET <name> = :c0 WHERE \(' . + '<id> = :c1 OR \(<name>\) IS NULL OR \(<email>\) IS NOT NULL OR \(' . + '<name> NOT IN \(:c2,:c3\) AND \(' . + '<name> = :c4 OR <name> = :c5 OR \(SELECT <e>\.<name> WHERE <e>\.<name> = :c6\)' . + '\)' . + '\)' . + '\)', + $query->sql(), + !$this->autoQuote, + ); + } + + /** + * Tests that update queries that contain joins do trigger a notice, + * warning about possible incompatibilities with aliases being removed + * from the conditions. + */ + public function testUpdateRemovingAliasesCanBreakJoins(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Aliases are being removed from conditions for UPDATE/DELETE queries, this can break references to joined tables.'); + $query = new UpdateQuery($this->connection); + + $query + ->update('authors') + ->set(['name' => 'name']) + ->innerJoin('articles') + ->where(['a.id' => 1]); + + $query->sql(); + } + + /** + * Test that epilog() will actually append a string to an update query + */ + public function testAppendUpdate(): void + { + $query = new UpdateQuery($this->connection); + $sql = $query + ->update('articles') + ->set(['title' => 'foo']) + ->where(['id' => 1]) + ->epilog('RETURNING id') + ->sql(); + $this->assertStringContainsString('UPDATE', $sql); + $this->assertStringContainsString('SET', $sql); + $this->assertStringContainsString('WHERE', $sql); + $this->assertSame(' RETURNING id', substr($sql, -13)); + } + + /** + * Performs the simple update query and verifies the row count. + */ + public function testRowCountAndClose(): void + { + $statementMock = Mockery::mock(StatementInterface::class); + $statementMock->shouldReceive('rowCount') + ->once() + ->andReturn(500); + $statementMock->shouldReceive('closeCursor') + ->once(); + + $queryMock = Mockery::mock(UpdateQuery::class) + ->makePartial() + ->shouldIgnoreMissing(); + $queryMock->__construct($this->connection); + $queryMock->shouldReceive('execute') + ->once() + ->andReturn($statementMock); + + $rowCount = $queryMock->update('authors') + ->set('name', 'mark') + ->where(['id' => 1]) + ->rowCountAndClose(); + + $this->assertEquals(500, $rowCount); + } + + public function testCloneUpdateExpression(): void + { + $query = new UpdateQuery($this->connection); + $query->update($query->expr('update')); + + $clause = $query->clause('update'); + $clauseClone = (clone $query)->clause('update'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneSetExpression(): void + { + $query = new UpdateQuery($this->connection); + $query + ->update('table') + ->set(['column' => $query->expr('value')]); + + $clause = $query->clause('set'); + $clauseClone = (clone $query)->clause('set'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + /** + * Test use of modifiers in a UPDATE query + * + * Testing the generated SQL since the modifiers are usually different per driver + */ + public function testUpdateModifiers(): void + { + $query = new UpdateQuery($this->connection); + $result = $query + ->update('authors') + ->set('name', 'mark') + ->modifier('TOP 10 PERCENT'); + $this->assertQuotedQuery( + 'UPDATE TOP 10 PERCENT <authors> SET <name> = :c0', + $result->sql(), + !$this->autoQuote, + ); + + $query = new UpdateQuery($this->connection); + $result = $query + ->update('authors') + ->set('name', 'mark') + ->modifier(['TOP 10 PERCENT', 'FOO']); + $this->assertQuotedQuery( + 'UPDATE TOP 10 PERCENT FOO <authors> SET <name> = :c0', + $result->sql(), + !$this->autoQuote, + ); + + $query = new UpdateQuery($this->connection); + $result = $query + ->update('authors') + ->set('name', 'mark') + ->modifier([$query->expr('TOP 10 PERCENT')]); + $this->assertQuotedQuery( + 'UPDATE TOP 10 PERCENT <authors> SET <name> = :c0', + $result->sql(), + !$this->autoQuote, + ); + } + + /** + * Tests that the JSON type can save and get data symmetrically + */ + public function testSymmetricJsonType(): void + { + $query = new UpdateQuery($this->connection); + $query + ->update('comments') + ->set('comment', ['a' => 'b', 'c' => true], 'json') + ->where(['id' => 1]); + $query->execute()->closeCursor(); + + $query = new SelectQuery($this->connection); + $query + ->select(['comment']) + ->from('comments') + ->where(['id' => 1]) + ->getSelectTypeMap()->setTypes(['comment' => 'json']); + + $result = $query->execute(); + $comment = $result->fetchAll('assoc')[0]['comment']; + $result->closeCursor(); + $this->assertSame(['a' => 'b', 'c' => true], $comment); + } + + /** + * Test jsonValue() Expression + */ + public function testJsonValue(): void + { + $skip = false; + $driver = $this->connection->getDriver(); + $version = $this->connection->getDriver()->version(); + if ($driver instanceof Postgres && version_compare($version, '12.0.0', '<')) { + $skip = true; + } elseif ($driver instanceof Mysql && version_compare($version, '8.0.21', '<')) { + $skip = true; + } elseif ($driver instanceof Sqlserver && version_compare($version, '13', '<')) { + $skip = true; + } elseif ($driver instanceof Sqlite && version_compare($version, '3.19', '<')) { + $skip = true; + } + $this->skipIf($skip, 'The current database backend does not support JSON value operations'); + + $query = new UpdateQuery($this->connection); + $query + ->update('comments') + ->set('comment', ['a' => ['a1' => 'b1'], 'c' => true, 'scores' => [25, 36, 48]], 'json') + ->where(['id' => 1]); + $query->execute()->closeCursor(); + + $query = new SelectQuery($this->connection); + $query + ->select(['score' => $query->func()->jsonValue('comment', '$.scores[1]')]) + ->from('comments') + ->where(['id' => 1]) + ->getSelectTypeMap()->setTypes(['score' => 'integer']); + + $result = $query->execute(); + $comment = $result->fetchAll('assoc')[0]['score']; + $result->closeCursor(); + $this->assertSame(36, $comment); + } +} diff --git a/tests/TestCase/Database/QueryAssertsTrait.php b/tests/TestCase/Database/QueryAssertsTrait.php new file mode 100644 index 00000000000..48c02cd17aa --- /dev/null +++ b/tests/TestCase/Database/QueryAssertsTrait.php @@ -0,0 +1,62 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database; + +use Cake\Database\Query\SelectQuery; + +trait QueryAssertsTrait +{ + /** + * 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 + * @param string $query the result to compare against + * @param bool $optional + */ + public function assertQuotedQuery($pattern, $query, $optional = false): void + { + if ($optional) { + $optional = '?'; + } + $pattern = str_replace('<', '[`"\[]' . $optional, $pattern); + $pattern = str_replace('>', '[`"\]]' . $optional, $pattern); + $this->assertMatchesRegularExpression('#' . $pattern . '#', $query); + } + + /** + * Assertion for comparing a table's contents with what is in it. + * + * @param string $table + * @param int $count + * @param array $rows + * @param array $conditions + */ + public function assertTable($table, $count, $rows, $conditions = []): void + { + $result = (new SelectQuery($this->connection))->select('*') + ->from($table) + ->where($conditions) + ->execute(); + $results = $result->fetchAll('assoc'); + $this->assertCount($count, $results, 'Row count is incorrect'); + $this->assertEquals($rows, $results); + $result->closeCursor(); + } +} diff --git a/tests/TestCase/Database/QueryCompilerTest.php b/tests/TestCase/Database/QueryCompilerTest.php new file mode 100644 index 00000000000..fc5c8816518 --- /dev/null +++ b/tests/TestCase/Database/QueryCompilerTest.php @@ -0,0 +1,297 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @since 5.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database; + +use Cake\Database\Connection; +use Cake\Database\Driver\Sqlserver; +use Cake\Database\DriverFeatureEnum; +use Cake\Database\Query; +use Cake\Database\QueryCompiler; +use Cake\Database\StatementInterface; +use Cake\Database\ValueBinder; +use Cake\Datasource\ConnectionInterface; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; + +/** + * Tests Query class + */ +class QueryCompilerTest extends TestCase +{ + use QueryAssertsTrait; + + protected array $fixtures = [ + 'core.Articles', + ]; + + protected Connection|ConnectionInterface $connection; + + protected QueryCompiler $compiler; + + protected ValueBinder $binder; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->compiler = $this->connection->getDriver()->newCompiler(); + $this->binder = new ValueBinder(); + } + + protected function tearDown(): void + { + parent::tearDown(); + unset($this->compiler); + unset($this->binder); + } + + protected function newQuery(string $type): Query + { + return match ($type) { + Query::TYPE_SELECT => new Query\SelectQuery($this->connection), + Query::TYPE_INSERT => new Query\InsertQuery($this->connection), + Query::TYPE_UPDATE => new Query\UpdateQuery($this->connection), + Query::TYPE_DELETE => new Query\DeleteQuery($this->connection), + }; + } + + public function testSelectFrom(): void + { + /** @var \Cake\Database\Query\SelectQuery $query */ + $query = $this->newQuery(Query::TYPE_SELECT); + $query = $query->select('*') + ->from('articles'); + $result = $this->compiler->compile($query, $this->binder); + $this->assertSame('SELECT * FROM articles', $result); + + $result = $query->all(); + $this->assertCount(3, $result); + } + + public function testSelectWhere(): void + { + /** @var \Cake\Database\Query\SelectQuery $query */ + $query = $this->newQuery(Query::TYPE_SELECT); + $query = $query->select('*') + ->from('articles') + ->where(['author_id' => 1]); + $result = $this->compiler->compile($query, $this->binder); + $this->assertSame('SELECT * FROM articles WHERE author_id = :c0', $result); + + $result = $query->all(); + $this->assertCount(2, $result); + } + + public function testSelectWithComment(): void + { + /** @var \Cake\Database\Query\SelectQuery $query */ + $query = $this->newQuery(Query::TYPE_SELECT); + $query = $query->select('*') + ->from('articles') + ->comment('This is a test'); + $result = $this->compiler->compile($query, $this->binder); + $this->assertSame('/* This is a test */ SELECT * FROM articles', $result); + + $result = $query->all(); + $this->assertCount(3, $result); + } + + public function testSelectWithHint(): void + { + /** @var \Cake\Database\Query\SelectQuery $query */ + $query = $this->newQuery(Query::TYPE_SELECT); + $query = $query->select('*') + ->from('articles') + ->optimizerHint(['TEST_HINT1(param)', 'TEST_HINT2(param)']); + $result = $this->compiler->compile($query, $this->binder); + + if ($query->getDriver()->supports(DriverFeatureEnum::OPTIMIZER_HINT_COMMENT)) { + $this->assertSame('SELECT /*+ TEST_HINT1(param) TEST_HINT2(param) */ * FROM articles', $result); + } else { + $this->assertSame('SELECT * FROM articles', $result); + } + + $result = $query->all(); + $this->assertCount(3, $result); + } + + public function testInsert(): void + { + /** @var \Cake\Database\Query\InsertQuery $query */ + $query = $this->newQuery(Query::TYPE_INSERT); + $query = $query->insert(['title']) + ->into('articles') + ->values(['title' => 'A new article']); + $result = $this->compiler->compile($query, $this->binder); + + if ($this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame('INSERT INTO articles (title) OUTPUT INSERTED.* VALUES (:c0)', $result); + } else { + $this->assertSame('INSERT INTO articles (title) VALUES (:c0)', $result); + } + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testInsertWithComment(): void + { + /** @var \Cake\Database\Query\InsertQuery $query */ + $query = $this->newQuery(Query::TYPE_INSERT); + $query = $query->insert(['title']) + ->into('articles') + ->values(['title' => 'A new article']) + ->comment('This is a test'); + $result = $this->compiler->compile($query, $this->binder); + + if ($this->connection->getDriver() instanceof Sqlserver) { + $this->assertSame('/* This is a test */ INSERT INTO articles (title) OUTPUT INSERTED.* VALUES (:c0)', $result); + } else { + $this->assertSame('/* This is a test */ INSERT INTO articles (title) VALUES (:c0)', $result); + } + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testInsertWithHint(): void + { + /** @var \Cake\Database\Query\InsertQuery $query */ + $query = $this->newQuery(Query::TYPE_INSERT); + $query = $query->insert(['title']) + ->into('articles') + ->values(['title' => 'A new article']) + ->optimizerHint(['TEST_HINT1(param)', 'TEST_HINT2(param)']); + $result = $this->compiler->compile($query, $this->binder); + + if ($query->getDriver()->supports(DriverFeatureEnum::OPTIMIZER_HINT_COMMENT)) { + $this->assertSame('INSERT /*+ TEST_HINT1(param) TEST_HINT2(param) */ INTO articles (title) VALUES (:c0)', $result); + } elseif ($query->getDriver() instanceof Sqlserver) { + $this->assertSame('INSERT INTO articles (title) OUTPUT INSERTED.* VALUES (:c0)', $result); + } else { + $this->assertSame('INSERT INTO articles (title) VALUES (:c0)', $result); + } + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testUpdate(): void + { + /** @var \Cake\Database\Query\UpdateQuery $query */ + $query = $this->newQuery(Query::TYPE_UPDATE); + $query = $query->update('articles') + ->set('title', 'mark') + ->where(['id' => 1]); + $result = $this->compiler->compile($query, $this->binder); + $this->assertSame('UPDATE articles SET title = :c0 WHERE id = :c1', $result); + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testUpdateWithOptimizer(): void + { + /** @var \Cake\Database\Query\UpdateQuery $query */ + $query = $this->newQuery(Query::TYPE_UPDATE); + $query = $query->update('articles') + ->set('title', 'mark') + ->where(['id' => 1]) + ->comment('This is a test'); + $result = $this->compiler->compile($query, $this->binder); + $this->assertSame('/* This is a test */ UPDATE articles SET title = :c0 WHERE id = :c1', $result); + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testUpdateWitOptimizerhHint(): void + { + /** @var \Cake\Database\Query\UpdateQuery $query */ + $query = $this->newQuery(Query::TYPE_UPDATE); + $query = $query->update('articles') + ->set('title', 'mark') + ->where(['id' => 1]) + ->optimizerHint(['TEST_HINT1(param)', 'TEST_HINT2(param)']); + $result = $this->compiler->compile($query, $this->binder); + + if ($query->getDriver()->supports(DriverFeatureEnum::OPTIMIZER_HINT_COMMENT)) { + $this->assertSame('UPDATE /*+ TEST_HINT1(param) TEST_HINT2(param) */ articles SET title = :c0 WHERE id = :c1', $result); + } else { + $this->assertSame('UPDATE articles SET title = :c0 WHERE id = :c1', $result); + } + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testDelete(): void + { + /** @var \Cake\Database\Query\DeleteQuery $query */ + $query = $this->newQuery(Query::TYPE_DELETE); + $query = $query->delete() + ->from('articles') + ->where(['id !=' => 1]); + $result = $this->compiler->compile($query, $this->binder); + $this->assertSame('DELETE FROM articles WHERE id != :c0', $result); + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testDeleteWithComment(): void + { + /** @var \Cake\Database\Query\DeleteQuery $query */ + $query = $this->newQuery(Query::TYPE_DELETE); + $query = $query->delete() + ->from('articles') + ->where(['id !=' => 1]) + ->comment('This is a test'); + $result = $this->compiler->compile($query, $this->binder); + $this->assertSame('/* This is a test */ DELETE FROM articles WHERE id != :c0', $result); + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } + + public function testDeleteWithOptimizerHint(): void + { + /** @var \Cake\Database\Query\DeleteQuery $query */ + $query = $this->newQuery(Query::TYPE_DELETE); + $query = $query->delete() + ->from('articles') + ->where(['id !=' => 1]) + ->optimizerHint(['TEST_HINT1(param)', 'TEST_HINT2(param)']); + $result = $this->compiler->compile($query, $this->binder); + + if ($query->getDriver()->supports(DriverFeatureEnum::OPTIMIZER_HINT_COMMENT)) { + $this->assertSame('DELETE /*+ TEST_HINT1(param) TEST_HINT2(param) */ FROM articles WHERE id != :c0', $result); + } else { + $this->assertSame('DELETE FROM articles WHERE id != :c0', $result); + } + + $result = $query->execute(); + $this->assertInstanceOf(StatementInterface::class, $result); + $result->closeCursor(); + } +} diff --git a/tests/TestCase/Database/QueryTest.php b/tests/TestCase/Database/QueryTest.php new file mode 100644 index 00000000000..d2439e7fe71 --- /dev/null +++ b/tests/TestCase/Database/QueryTest.php @@ -0,0 +1,367 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database; + +use Cake\Database\Connection; +use Cake\Database\Expression\CommonTableExpression; +use Cake\Database\Expression\IdentifierExpression; +use Cake\Database\ExpressionInterface; +use Cake\Database\Query; +use Cake\Database\ValueBinder; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; + +/** + * Tests Query class + */ +class QueryTest extends TestCase +{ + use QueryAssertsTrait; + + protected array $fixtures = [ + 'core.Articles', + 'core.Authors', + 'core.Comments', + 'core.Profiles', + 'core.MenuLinkTrees', + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var bool + */ + protected $autoQuote; + + protected Query $query; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled(); + $this->query = $this->newQuery(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->connection->getDriver()->enableAutoQuoting($this->autoQuote); + unset($this->query); + unset($this->connection); + } + + public function testConnectionRoles(): void + { + // Defaults to write role + $this->assertSame(Connection::ROLE_WRITE, $this->connection->insertQuery()->getConnectionRole()); + + $selectQuery = $this->connection->selectQuery(); + $this->assertSame(Connection::ROLE_WRITE, $selectQuery->getConnectionRole()); + + // Can set read role for select queries + $this->assertSame(Connection::ROLE_READ, $selectQuery->setConnectionRole(Connection::ROLE_READ)->getConnectionRole()); + + // Can set read role for select queries + $this->assertSame(Connection::ROLE_READ, $selectQuery->useReadRole()->getConnectionRole()); + + // Can set write role for select queries + $this->assertSame(Connection::ROLE_WRITE, $selectQuery->useWriteRole()->getConnectionRole()); + } + + protected function newQuery(): Query + { + return new class ($this->connection) extends Query + { + }; + } + + /** + * Tests that empty values don't set where clauses. + */ + public function testWhereEmptyValues(): void + { + $this->query->from('comments') + ->where(''); + + $this->assertCount(0, $this->query->clause('where')); + + $this->query->where([]); + $this->assertCount(0, $this->query->clause('where')); + } + + /** + * Tests that the identifier method creates an expression object. + */ + public function testIdentifierExpression(): void + { + /** @var \Cake\Database\Expression\IdentifierExpression $identifier */ + $identifier = $this->query->identifier('foo'); + + $this->assertInstanceOf(IdentifierExpression::class, $identifier); + $this->assertSame('foo', $identifier->getIdentifier()); + } + + /** + * Tests the interface contract of identifier + */ + public function testIdentifierInterface(): void + { + $identifier = $this->query->identifier('description'); + + $this->assertInstanceOf(ExpressionInterface::class, $identifier); + $this->assertSame('description', $identifier->getIdentifier()); + + $identifier->setIdentifier('title'); + $this->assertSame('title', $identifier->getIdentifier()); + } + + /** + * Tests __debugInfo on incomplete query + */ + public function testDebugInfoIncompleteQuery(): void + { + $this->query = $this->newQuery() + ->from(['articles']); + $result = $this->query->__debugInfo(); + $this->assertStringContainsString('incomplete', $result['sql']); + $this->assertSame([], $result['params']); + } + + public function testCloneWithExpression(): void + { + $this->query + ->with( + new CommonTableExpression( + 'cte', + $this->newQuery(), + ), + ) + ->with(function (CommonTableExpression $cte, Query $query) { + return $cte + ->name('cte2') + ->query($query); + }); + + $clause = $this->query->clause('with'); + $clauseClone = (clone $this->query)->clause('with'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneModifierExpression(): void + { + $this->query->modifier($this->query->expr('modifier')); + + $clause = $this->query->clause('modifier'); + $clauseClone = (clone $this->query)->clause('modifier'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneFromExpression(): void + { + $this->query->from(['alias' => $this->newQuery()]); + + $clause = $this->query->clause('from'); + $clauseClone = (clone $this->query)->clause('from'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value, $clauseClone[$key]); + $this->assertNotSame($value, $clauseClone[$key]); + } + } + + public function testCloneJoinExpression(): void + { + $this->query + ->innerJoin( + ['alias_inner' => $this->newQuery()], + ['alias_inner.fk = parent.pk'], + ) + ->leftJoin( + ['alias_left' => $this->newQuery()], + ['alias_left.fk = parent.pk'], + ) + ->rightJoin( + ['alias_right' => $this->newQuery()], + ['alias_right.fk = parent.pk'], + ); + + $clause = $this->query->clause('join'); + $clauseClone = (clone $this->query)->clause('join'); + + $this->assertIsArray($clause); + + foreach ($clause as $key => $value) { + $this->assertEquals($value['table'], $clauseClone[$key]['table']); + $this->assertNotSame($value['table'], $clauseClone[$key]['table']); + + $this->assertEquals($value['conditions'], $clauseClone[$key]['conditions']); + $this->assertNotSame($value['conditions'], $clauseClone[$key]['conditions']); + } + } + + public function testCloneWhereExpression(): void + { + $this->query + ->where($this->query->expr('where')) + ->where(['field' => $this->query->expr('where')]); + + $clause = $this->query->clause('where'); + $clauseClone = (clone $this->query)->clause('where'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneOrderExpression(): void + { + $this->query + ->orderBy($this->query->expr('order')) + ->orderByAsc($this->query->expr('order_asc')) + ->orderByDesc($this->query->expr('order_desc')); + + $clause = $this->query->clause('order'); + $clauseClone = (clone $this->query)->clause('order'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneLimitExpression(): void + { + $this->query->limit($this->query->expr('1')); + + $clause = $this->query->clause('limit'); + $clauseClone = (clone $this->query)->clause('limit'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneOffsetExpression(): void + { + $this->query->offset($this->query->expr('1')); + + $clause = $this->query->clause('offset'); + $clauseClone = (clone $this->query)->clause('offset'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + public function testCloneEpilogExpression(): void + { + $this->query->epilog($this->query->expr('epilog')); + + $clause = $this->query->clause('epilog'); + $clauseClone = (clone $this->query)->clause('epilog'); + + $this->assertInstanceOf(ExpressionInterface::class, $clause); + + $this->assertEquals($clause, $clauseClone); + $this->assertNotSame($clause, $clauseClone); + } + + /** + * Test getValueBinder() + */ + public function testGetValueBinder(): void + { + $this->assertInstanceOf(ValueBinder::class, $this->query->getValueBinder()); + } + + /** + * Test that reading an undefined clause does not emit an error. + */ + public function testClauseUndefined(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `nope` clause is not defined. Valid clauses are: `comment`, `delete`, `update`'); + + $this->assertEmpty($this->query->clause('where')); + $this->query->clause('nope'); + } + + public function testOptimizerHintClause(): void + { + $this->query->optimizerHint('single_hint()'); + $this->assertSame(['single_hint()'], $this->query->clause('optimizerHint')); + + $this->query->optimizerHint(['array_hint()', 'array_hint()']); + $this->assertSame(['single_hint()', 'array_hint()', 'array_hint()'], $this->query->clause('optimizerHint')); + + $this->query->optimizerHint('single_hint()', true); + $this->assertSame(['single_hint()'], $this->query->clause('optimizerHint')); + + $this->query->optimizerHint(['array_hint()', 'array_hint()'], true); + $this->assertSame(['array_hint()', 'array_hint()'], $this->query->clause('optimizerHint')); + } + + public function testWithClause(): void + { + $cte1 = new CommonTableExpression(); + $cte2 = new CommonTableExpression(); + + $this->query->with($cte1); + $this->assertSame([$cte1], $this->query->clause('with')); + + $this->query->with([$cte2, fn($query) => $cte1]); + $this->assertSame([$cte1, $cte2, $cte1], $this->query->clause('with')); + + $this->query->with($cte1, true); + $this->assertSame([$cte1], $this->query->clause('with')); + + $this->query->with([$cte2, fn($query) => $cte1], true); + $this->assertSame([$cte2, $cte1], $this->query->clause('with')); + } + + /** + * Test that calling newExpr() emits a deprecation warning. + * + * @deprecated + */ + public function testNewExprDeprecation(): void + { + $this->deprecated(function (): void { + $this->query->newExpr(); + }); + } +} diff --git a/tests/TestCase/Database/QueryTests/AggregatesQueryTest.php b/tests/TestCase/Database/QueryTests/AggregatesQueryTest.php new file mode 100644 index 00000000000..27e8c4272ed --- /dev/null +++ b/tests/TestCase/Database/QueryTests/AggregatesQueryTest.php @@ -0,0 +1,73 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The Open Group Test Suite License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.1.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\QueryTests; + +use Cake\Database\Driver\Postgres; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Query\SelectQuery; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; + +/** + * Tests AggregateExpression queries + */ +class AggregatesQueryTest extends TestCase +{ + protected array $fixtures = [ + 'core.Comments', + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var bool + */ + protected $skipTests = false; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + } + + protected function tearDown(): void + { + parent::tearDown(); + } + + /** + * Tests filtering aggregate function rows. + */ + public function testFilters(): void + { + $skip = !($this->connection->getDriver() instanceof Postgres); + if ($this->connection->getDriver() instanceof Sqlite) { + $skip = version_compare($this->connection->getDriver()->version(), '3.30.0', '<'); + } + $this->skipif($skip); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['num_rows' => $query->func()->count('*')->filter(['article_id' => 2])]) + ->from('comments') + ->execute() + ->fetchAll('assoc'); + $this->assertSame(2, (int)$result[0]['num_rows']); + } +} diff --git a/tests/TestCase/Database/QueryTests/CaseExpressionQueryTest.php b/tests/TestCase/Database/QueryTests/CaseExpressionQueryTest.php new file mode 100644 index 00000000000..649e70fd5db --- /dev/null +++ b/tests/TestCase/Database/QueryTests/CaseExpressionQueryTest.php @@ -0,0 +1,316 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\QueryTests; + +use Cake\Database\Driver\Postgres; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Query; +use Cake\Database\Query\SelectQuery; +use Cake\Database\Query\UpdateQuery; +use Cake\Database\StatementInterface; +use Cake\Database\TypeMap; +use Cake\Datasource\ConnectionManager; +use Cake\Test\Fixture\ArticlesFixture; +use Cake\Test\Fixture\CommentsFixture; +use Cake\Test\Fixture\ProductsFixture; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +class CaseExpressionQueryTest extends TestCase +{ + protected array $fixtures = [ + ArticlesFixture::class, + CommentsFixture::class, + ProductsFixture::class, + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var \Cake\Database\Query\SelectQuery + */ + protected $query; + + protected function setUp(): void + { + parent::setUp(); + + $this->connection = ConnectionManager::get('test'); + $this->query = new SelectQuery($this->connection); + } + + protected function tearDown(): void + { + parent::tearDown(); + + unset($this->query); + unset($this->connection); + } + + public function testSimpleCase(): void + { + $query = $this->query + ->select(function (Query $query) { + return [ + 'name', + 'category_name' => $query->expr() + ->case($query->identifier('products.category')) + ->when(1) + ->then('Touring') + ->when(2) + ->then('Urban') + ->else('Other'), + ]; + }) + ->from('products') + ->orderByAsc('category') + ->orderByAsc('name'); + + $expected = [ + [ + 'name' => 'First product', + 'category_name' => 'Touring', + ], + [ + 'name' => 'Second product', + 'category_name' => 'Urban', + ], + [ + 'name' => 'Third product', + 'category_name' => 'Other', + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testSearchedCase(): void + { + $typeMap = new TypeMap([ + 'price' => 'integer', + ]); + + $query = $this->query + ->select(function (Query $query) { + return [ + 'name', + 'price', + 'price_range' => $query->expr() + ->case() + ->when(['price <' => 20]) + ->then('Under $20') + ->when(['price >=' => 20, 'price <' => 30]) + ->then('Under $30') + ->else('$30 and above'), + ]; + }) + ->from('products') + ->orderByAsc('price') + ->orderByAsc('name') + ->setSelectTypeMap($typeMap); + + $expected = [ + [ + 'name' => 'First product', + 'price' => 10, + 'price_range' => 'Under $20', + ], + [ + 'name' => 'Second product', + 'price' => 20, + 'price_range' => 'Under $30', + ], + [ + 'name' => 'Third product', + 'price' => 30, + 'price_range' => '$30 and above', + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testOrderByCase(): void + { + $typeMap = new TypeMap([ + 'article_id' => 'integer', + 'user_id' => 'integer', + ]); + + $query = $this->query + ->select(['article_id', 'user_id']) + ->from('comments') + ->orderByAsc('comments.article_id') + ->orderByDesc(function (QueryExpression $exp, Query $query) { + return $query->expr() + ->case($query->identifier('comments.article_id')) + ->when(1) + ->then($query->identifier('comments.user_id')); + }) + ->orderByAsc(function (QueryExpression $exp, Query $query) { + return $query->expr() + ->case($query->identifier('comments.article_id')) + ->when(2) + ->then($query->identifier('comments.user_id')); + }) + ->setSelectTypeMap($typeMap); + + $expected = [ + [ + 'article_id' => 1, + 'user_id' => 4, + ], + [ + 'article_id' => 1, + 'user_id' => 2, + ], + [ + 'article_id' => 1, + 'user_id' => 1, + ], + [ + 'article_id' => 1, + 'user_id' => 1, + ], + [ + 'article_id' => 2, + 'user_id' => 1, + ], + [ + 'article_id' => 2, + 'user_id' => 2, + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testHavingByCase(): void + { + $query = $this->query + ->select(['articles.title']) + ->from('articles') + ->leftJoin('comments', ['comments.article_id = articles.id']) + ->groupBy(['articles.id', 'articles.title']) + ->having(function (QueryExpression $exp, Query $query) { + $expression = $query->expr() + ->case() + ->when(['comments.published' => 'Y']) + ->then(1); + + if ($query->getConnection()->getDriver() instanceof Postgres) { + $expression = $query->func()->cast($expression, 'integer'); + } + + return $exp->gt( + $query->func()->sum($expression), + 2, + 'integer', + ); + }); + + $expected = [ + [ + 'title' => 'First Article', + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testUpdateFromCase(): void + { + $query = $this->query + ->select(['count' => $this->query->func()->count('*')]) + ->from('comments') + ->where(['comments.published' => 'Y']); + + $this->assertSame(5, (int)$query->execute()->fetch()[0]); + + $query->where(['comments.published' => 'N'], [], true); + + $this->assertSame(1, (int)$query->execute()->fetch()[0]); + + $query = (new UpdateQuery($this->connection)) + ->update('comments') + ->set([ + 'published' => + $this->query->expr() + ->case() + ->when(['published' => 'Y']) + ->then('N') + ->else('Y'), + ]) + ->where(['1 = 1']) + ->execute(); + + $query = (new SelectQuery($this->connection)) + ->select(['count' => $this->query->func()->count('*')]) + ->from('comments') + ->where(['comments.published' => 'Y']); + + $this->assertSame(1, (int)$query->execute()->fetch()[0]); + + $query->where(['comments.published' => 'N'], [], true); + $this->assertSame(5, (int)$query->execute()->fetch()[0]); + } + + public static function bindingValueDataProvider(): array + { + return [ + ['1', 3], + ['2', 4], + ]; + } + + /** + * @param string $when The `WHEN` value. + * @param int $result The result value. + */ + #[DataProvider('bindingValueDataProvider')] + public function testBindValues(string $when, int $result): void + { + $value = '1'; + $then = '3'; + $else = '4'; + + $typeMap = new TypeMap([ + 'val' => 'integer', + ]); + + $query = $this->query + ->select(function (Query $query) { + return [ + 'val' => $query->expr() + ->case($query->expr(':value')) + ->when($query->expr(':when')) + ->then($query->expr(':then')) + ->else($query->expr(':else')), + ]; + }) + ->from('products') + ->bind(':value', $value, 'integer') + ->bind(':when', $when, 'integer') + ->bind(':then', $then, 'integer') + ->bind(':else', $else, 'integer') + ->setSelectTypeMap($typeMap); + + $expected = [ + 'val' => $result, + ]; + $this->assertSame($expected, $query->execute()->fetch(StatementInterface::FETCH_TYPE_ASSOC)); + } +} diff --git a/tests/TestCase/Database/QueryTests/CommonTableExpressionQueryTest.php b/tests/TestCase/Database/QueryTests/CommonTableExpressionQueryTest.php new file mode 100644 index 00000000000..960400e349f --- /dev/null +++ b/tests/TestCase/Database/QueryTests/CommonTableExpressionQueryTest.php @@ -0,0 +1,411 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.1.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\QueryTests; + +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; +use Cake\Database\DriverFeatureEnum; +use Cake\Database\Expression\CommonTableExpression; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Query; +use Cake\Database\Query\SelectQuery; +use Cake\Database\ValueBinder; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; + +class CommonTableExpressionQueryTest extends TestCase +{ + /** + * @inheritDoc + */ + protected array $fixtures = [ + 'core.Articles', + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var bool + */ + protected $autoQuote; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled(); + + $this->skipIf( + !$this->connection->getDriver()->supports(DriverFeatureEnum::CTE), + 'The current driver does not support common table expressions.', + ); + } + + protected function tearDown(): void + { + parent::tearDown(); + unset($this->connection); + } + + /** + * Tests with() sql generation. + */ + public function testWithCte(): void + { + $query = $this->connection->selectQuery() + ->with(new CommonTableExpression('cte', function () { + return $this->connection->selectQuery(fields: ['col' => 1]); + })) + ->select('col') + ->from('cte'); + + $this->assertRegExpSql( + 'WITH <cte> AS \(SELECT 1 AS <col>\) SELECT <col> FROM <cte>', + $query->sql(new ValueBinder()), + !$this->autoQuote, + ); + + $expected = [ + [ + 'col' => '1', + ], + ]; + + $result = $query->execute(); + $this->assertEquals($expected, $result->fetchAll('assoc')); + $result->closeCursor(); + } + + /** + * Tests calling with() with overwrite clears other CTEs. + */ + public function testWithCteOverwrite(): void + { + $query = $this->connection->selectQuery() + ->with(new CommonTableExpression('cte', function () { + return $this->connection->selectQuery(['col' => '1']); + })) + ->select('col') + ->from('cte'); + + $this->assertEqualsSql( + 'WITH cte AS (SELECT 1 AS col) SELECT col FROM cte', + $query->sql(new ValueBinder()), + ); + + $query + ->with(new CommonTableExpression('cte2', $this->connection->selectQuery()), true) + ->from('cte2', true); + $this->assertEqualsSql( + 'WITH cte2 AS () SELECT col FROM cte2', + $query->sql(new ValueBinder()), + ); + } + + /** + * Tests recursive CTE. + */ + public function testWithRecursiveCte(): void + { + $query = $this->connection->selectQuery() + ->with(function (CommonTableExpression $cte, SelectQuery $query) { + $anchorQuery = $query->select(1); + + $recursiveQuery = $query->getConnection() + ->selectQuery(function (Query $query) { + return $query->expr('col + 1'); + }, 'cte') + ->where(['col !=' => 3], ['col' => 'integer']); + + $cteQuery = $anchorQuery->unionAll($recursiveQuery); + + return $cte + ->name('cte') + ->field(['col']) + ->query($cteQuery) + ->recursive(); + }) + ->select('col') + ->from('cte'); + + if ($this->connection->getDriver() instanceof Sqlserver) { + $expectedSql = + 'WITH cte(col) AS ' . + "(SELECT 1\nUNION ALL SELECT (col + 1) FROM cte WHERE col != :c0) " . + 'SELECT col FROM cte'; + } elseif ($this->connection->getDriver() instanceof Sqlite) { + $expectedSql = + 'WITH RECURSIVE cte(col) AS ' . + "(SELECT 1\nUNION ALL SELECT (col + 1) FROM cte WHERE col != :c0) " . + 'SELECT col FROM cte'; + } else { + $expectedSql = + 'WITH RECURSIVE cte(col) AS ' . + "((SELECT 1)\nUNION ALL (SELECT (col + 1) FROM cte WHERE col != :c0)) " . + 'SELECT col FROM cte'; + } + $this->assertEqualsSql( + $expectedSql, + $query->sql(new ValueBinder()), + ); + + $expected = [ + [ + 'col' => '1', + ], + [ + 'col' => '2', + ], + [ + 'col' => '3', + ], + ]; + + $result = $query->execute(); + $this->assertEquals($expected, $result->fetchAll('assoc')); + $result->closeCursor(); + } + + /** + * Test inserting from CTE. + */ + public function testWithInsertQuery(): void + { + $this->skipIf( + ($this->connection->getDriver() instanceof Mysql), + '`WITH ... INSERT INTO` syntax is not supported in MySQL.', + ); + + // test initial state + $result = $this->connection->selectQuery(fields: '*', table: 'articles') + ->where(['id' => 4]) + ->execute(); + $this->assertFalse($result->fetch('assoc')); + $result->closeCursor(); + + $query = $this->connection + ->insertQuery() + ->with(function (CommonTableExpression $cte, SelectQuery $query) { + return $cte + ->name('cte') + ->field(['title', 'body']) + ->query($query->expr("SELECT 'Fourth Article', 'Fourth Article Body'")); + }) + ->insert(['title', 'body']) + ->into('articles') + ->values( + $this->connection + ->selectQuery(fields: '*', table: 'cte'), + ); + + $this->assertRegExpSql( + "WITH <cte>\(<title>, <body>\) AS \(SELECT 'Fourth Article', 'Fourth Article Body'\) " . + 'INSERT INTO <articles> \(<title>, <body>\)', + $query->sql(new ValueBinder()), + !$this->autoQuote, + ); + + // run insert + $query->execute()->closeCursor(); + + $expected = [ + 'id' => '4', + 'author_id' => null, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'published' => 'N', + ]; + + // test updated state + $result = $this->connection->selectQuery('*', 'articles') + ->where(['id' => 4]) + ->execute(); + $this->assertEquals($expected, $result->fetch('assoc')); + $result->closeCursor(); + } + + /** + * Tests inserting from CTE as values list. + */ + public function testWithInInsertWithValuesQuery(): void + { + $this->skipIf( + ($this->connection->getDriver() instanceof Sqlserver), + '`INSERT INTO ... WITH` syntax is not supported in SQL Server.', + ); + + $query = $this->connection->insertQuery(table: 'articles') + ->insert(['title', 'body']) + ->values( + $this->connection->selectQuery() + ->with(function (CommonTableExpression $cte, SelectQuery $query) { + return $cte + ->name('cte') + ->field(['title', 'body']) + ->query($query->expr("SELECT 'Fourth Article', 'Fourth Article Body'")); + }) + ->select('*') + ->from('cte'), + ); + + $this->assertRegExpSql( + 'INSERT INTO <articles> \(<title>, <body>\) ' . + "WITH <cte>\(<title>, <body>\) AS \(SELECT 'Fourth Article', 'Fourth Article Body'\) SELECT \* FROM <cte>", + $query->sql(new ValueBinder()), + !$this->autoQuote, + ); + + // run insert + $query->execute()->closeCursor(); + + $expected = [ + 'id' => '4', + 'author_id' => null, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'published' => 'N', + ]; + + // test updated state + $result = $this->connection->selectQuery(fields: '*', table: 'articles') + ->where(['id' => 4]) + ->execute(); + $this->assertEquals($expected, $result->fetch('assoc')); + $result->closeCursor(); + } + + /** + * Tests updating from CTE. + */ + public function testWithInUpdateQuery(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Mysql && $this->connection->getDriver()->isMariadb(), + 'MariaDB does not support CTEs in UPDATE query.', + ); + + // test initial state + $result = $this->connection->selectQuery(fields: ['count' => 'COUNT(*)'], table: 'articles') + ->where(['published' => 'Y']) + ->execute(); + $this->assertEquals(['count' => '3'], $result->fetch('assoc')); + $result->closeCursor(); + + $query = $this->connection->updateQuery() + ->with(function (CommonTableExpression $cte, SelectQuery $query) { + $cteQuery = $query + ->select('articles.id') + ->from('articles') + ->where(['articles.id !=' => 1]); + + return $cte + ->name('cte') + ->query($cteQuery); + }) + ->update('articles') + ->set('published', 'N') + ->where(function (QueryExpression $exp, Query $query) { + return $exp->in( + 'articles.id', + $query + ->getConnection() + ->selectQuery('cte.id', 'cte'), + ); + }); + + $this->assertEqualsSql( + 'WITH cte AS (SELECT articles.id FROM articles WHERE articles.id != :c0) ' . + 'UPDATE articles SET published = :c1 WHERE id IN (SELECT cte.id FROM cte)', + $query->sql(new ValueBinder()), + ); + + // run update + $query->execute()->closeCursor(); + + // test updated state + $result = $this->connection->selectQuery(['count' => 'COUNT(*)'], 'articles') + ->where(['published' => 'Y']) + ->execute(); + $this->assertEquals(['count' => '1'], $result->fetch('assoc')); + $result->closeCursor(); + } + + /** + * Tests deleting from CTE. + */ + public function testWithInDeleteQuery(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Mysql && $this->connection->getDriver()->isMariadb(), + 'MariaDB does not support CTEs in DELETE query.', + ); + + // test initial state + $result = $this->connection + ->selectQuery(['count' => 'COUNT(*)'], 'articles') + ->execute(); + $this->assertEquals(['count' => '3'], $result->fetch('assoc')); + $result->closeCursor(); + + $query = $this->connection->deleteQuery() + ->with(function (CommonTableExpression $cte, SelectQuery $query) { + $query->select('articles.id') + ->from('articles') + ->where(['articles.id !=' => 1]); + + return $cte + ->name('cte') + ->query($query); + }) + ->from(['a' => 'articles']) + ->where(function (QueryExpression $exp, Query $query) { + return $exp->in( + 'a.id', + $query + ->getConnection() + ->selectQuery('cte.id', 'cte'), + ); + }); + + $this->assertEqualsSql( + 'WITH cte AS (SELECT articles.id FROM articles WHERE articles.id != :c0) ' . + 'DELETE FROM articles WHERE id IN (SELECT cte.id FROM cte)', + $query->sql(new ValueBinder()), + ); + + // run delete + $query->execute()->closeCursor(); + + $expected = [ + 'id' => '1', + 'author_id' => '1', + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + ]; + + // test updated state + $result = $this->connection->selectQuery('*', 'articles') + ->execute(); + $this->assertEquals($expected, $result->fetch('assoc')); + $result->closeCursor(); + } +} diff --git a/tests/TestCase/Database/QueryTests/TupleComparisonQueryTest.php b/tests/TestCase/Database/QueryTests/TupleComparisonQueryTest.php new file mode 100644 index 00000000000..278c755f5f7 --- /dev/null +++ b/tests/TestCase/Database/QueryTests/TupleComparisonQueryTest.php @@ -0,0 +1,314 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace Cake\Test\TestCase\Database\QueryTests; + +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Postgres; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; +use Cake\Database\Expression\TupleComparison; +use Cake\Database\Query\SelectQuery; +use Cake\Database\StatementInterface; +use Cake\Database\TypeMap; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PDOException; + +/** + * Tuple comparison query tests. + * + * These tests are specifically relevant in the context of Sqlite and + * Sqlserver, for which the tuple comparison will be transformed when + * composite fields are used. + * + * @see \Cake\Database\Driver\TupleComparisonTranslatorTrait::_transformTupleComparison() + */ +class TupleComparisonQueryTest extends TestCase +{ + /** + * @inheritDoc + */ + protected array $fixtures = [ + 'core.Articles', + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var \Cake\Database\Query\SelectQuery + */ + protected $query; + + protected function setUp(): void + { + parent::setUp(); + + $this->connection = ConnectionManager::get('test'); + $this->query = new SelectQuery($this->connection); + } + + protected function tearDown(): void + { + parent::tearDown(); + + unset($this->query); + unset($this->connection); + } + + public function testTransformWithInvalidOperator(): void + { + $driver = $this->connection->getDriver(); + if ( + $driver instanceof Sqlite || + $driver instanceof Sqlserver + ) { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Tuple comparison transform only supports the `IN` and `=` operators, `NOT IN` given.', + ); + } else { + $this->markTestSkipped('Tuple comparisons are only being transformed for Sqlite and Sqlserver.'); + } + + $this->query + ->select(['articles.id', 'articles.author_id']) + ->from('articles') + ->where([ + new TupleComparison( + ['articles.id', 'articles.author_id'], + $this->connection->selectQuery( + ['ArticlesAlias.id', 'ArticlesAlias.author_id'], + ['ArticlesAlias' => 'articles'], + ) + ->where(['ArticlesAlias.author_id' => 1]), + [], + 'NOT IN', + ), + ]) + ->orderByAsc('articles.id') + ->execute(); + } + + public function testInWithMultiResultSubquery(): void + { + $typeMap = new TypeMap([ + 'id' => 'integer', + 'author_id' => 'integer', + ]); + + $query = $this->query + ->select(['articles.id', 'articles.author_id']) + ->from('articles') + ->where([ + new TupleComparison( + ['articles.id', 'articles.author_id'], + $this->connection->selectQuery( + ['ArticlesAlias.id', 'ArticlesAlias.author_id'], + ['ArticlesAlias' => 'articles'], + ) + ->where(['ArticlesAlias.author_id' => 1]), + [], + 'IN', + ), + ]) + ->orderByAsc('articles.id') + ->setSelectTypeMap($typeMap); + + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + ], + [ + 'id' => 3, + 'author_id' => 1, + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testInWithSingleResultSubquery(): void + { + $typeMap = new TypeMap([ + 'id' => 'integer', + 'author_id' => 'integer', + ]); + + $query = $this->query + ->select(['articles.id', 'articles.author_id']) + ->from('articles') + ->where([ + new TupleComparison( + ['articles.id', 'articles.author_id'], + $this->connection->selectQuery( + ['ArticlesAlias.id', 'ArticlesAlias.author_id'], + ['ArticlesAlias' => 'articles'], + ) + ->where(['ArticlesAlias.id' => 1]), + [], + 'IN', + ), + ]) + ->setSelectTypeMap($typeMap); + + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testInWithMultiArrayValues(): void + { + $typeMap = new TypeMap([ + 'id' => 'integer', + 'author_id' => 'integer', + ]); + + $query = $this->query + ->select(['articles.id', 'articles.author_id']) + ->from('articles') + ->where([ + new TupleComparison( + ['articles.id', 'articles.author_id'], + [[1, 1], [3, 1]], + ['integer', 'integer'], + 'IN', + ), + ]) + ->orderByAsc('articles.id') + ->setSelectTypeMap($typeMap); + + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + ], + [ + 'id' => 3, + 'author_id' => 1, + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testEqualWithMultiResultSubquery(): void + { + $driver = $this->connection->getDriver(); + if ( + $driver instanceof Mysql || + $driver instanceof Postgres + ) { + $this->expectException(PDOException::class); + $this->expectExceptionMessageMatches('/cardinality violation/i'); + } else { + // Due to the way tuple comparisons are being translated, the DBMS will + // not run into a cardinality violation scenario. + $this->markTestSkipped( + 'Sqlite and Sqlserver currently do not fail with subqueries returning incompatible results.', + ); + } + + $this->query + ->select(['articles.id', 'articles.author_id']) + ->from('articles') + ->where([ + new TupleComparison( + ['articles.id', 'articles.author_id'], + $this->connection->selectQuery( + ['ArticlesAlias.id', 'ArticlesAlias.author_id'], + ['ArticlesAlias' => 'articles'], + ) + ->where(['ArticlesAlias.author_id' => 1]), + [], + '=', + ), + ]) + ->orderByAsc('articles.id') + ->execute(); + } + + public function testEqualWithSingleResultSubquery(): void + { + $typeMap = new TypeMap([ + 'id' => 'integer', + 'author_id' => 'integer', + ]); + + $query = $this->query + ->select(['articles.id', 'articles.author_id']) + ->from('articles') + ->where([ + new TupleComparison( + ['articles.id', 'articles.author_id'], + $this->connection->selectQuery( + fields: ['ArticlesAlias.id', 'ArticlesAlias.author_id'], + table: ['ArticlesAlias' => 'articles'], + ) + ->where(['ArticlesAlias.id' => 1]), + [], + '=', + ), + ]) + ->setSelectTypeMap($typeMap); + + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } + + public function testEqualWithSingleArrayValue(): void + { + $typeMap = new TypeMap([ + 'id' => 'integer', + 'author_id' => 'integer', + ]); + + $query = $this->query + ->select(['articles.id', 'articles.author_id']) + ->from('articles') + ->where([ + new TupleComparison( + ['articles.id', 'articles.author_id'], + [1, 1], + ['integer', 'integer'], + '=', + ), + ]) + ->orderByAsc('articles.id') + ->setSelectTypeMap($typeMap); + + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + ], + ]; + $this->assertSame($expected, $query->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC)); + } +} diff --git a/tests/TestCase/Database/QueryTests/WindowQueryTest.php b/tests/TestCase/Database/QueryTests/WindowQueryTest.php new file mode 100644 index 00000000000..b9b0bf7724f --- /dev/null +++ b/tests/TestCase/Database/QueryTests/WindowQueryTest.php @@ -0,0 +1,187 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The Open Group Test Suite License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.1.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\QueryTests; + +use Cake\Core\Exception\CakeException; +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; +use Cake\Database\DriverFeatureEnum; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Expression\WindowExpression; +use Cake\Database\Query\SelectQuery; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; + +/** + * Tests WindowExpression class + */ +class WindowQueryTest extends TestCase +{ + protected array $fixtures = [ + 'core.Comments', + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var bool + */ + protected $autoQuote; + + /** + * @var bool + */ + protected $skipTests = false; + + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->autoQuote = $this->connection->getDriver()->isAutoQuotingEnabled(); + + $driver = $this->connection->getDriver(); + if ( + $driver instanceof Mysql || + $driver instanceof Sqlite + ) { + $this->skipTests = !$this->connection->getDriver()->supports(DriverFeatureEnum::WINDOW); + } else { + $this->skipTests = false; + } + } + + protected function tearDown(): void + { + parent::tearDown(); + } + + /** + * Tests window sql generation. + */ + public function testWindowSql(): void + { + $query = new SelectQuery($this->connection); + $sql = $query + ->select('*') + ->window('name', new WindowExpression()) + ->sql(); + $this->assertRegExpSql('SELECT \* WINDOW <name> AS \(\)', $sql, !$this->autoQuote); + + $sql = $query + ->window('name2', new WindowExpression('name')) + ->sql(); + $this->assertRegExpSql('SELECT \* WINDOW <name> AS \(\), <name2> AS \(<name>\)', $sql, !$this->autoQuote); + + $sql = $query + ->window('name', function ($window, $query) { + return $window->name('name3'); + }, true) + ->sql(); + $this->assertEqualsSql('SELECT * WINDOW name AS (name3)', $sql); + } + + public function testMissingWindow(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('You must return a `WindowExpression`'); + (new SelectQuery($this->connection))->window('name', function () { + return new QueryExpression(); + }); + } + + public function testPartitions(): void + { + $this->skipIf($this->skipTests); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['num_rows' => $query->func()->count('*')->over()]) + ->from('comments') + ->execute() + ->fetchAll(); + $this->assertCount(6, $result); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['num_rows' => $query->func()->count('*')->partition('article_id')]) + ->from('comments') + ->orderBy(['article_id']) + ->execute() + ->fetchAll('assoc'); + $this->assertEquals(4, $result[0]['num_rows']); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['num_rows' => $query->func()->count('*')->partition('article_id')->orderBy('updated')]) + ->from('comments') + ->orderBy(['updated']) + ->execute() + ->fetchAll('assoc'); + $this->assertEquals(1, $result[0]['num_rows']); + $this->assertEquals(4, $result[3]['num_rows']); + $this->assertEquals(1, $result[4]['num_rows']); + } + + /** + * Tests adding named windows to the query. + */ + public function testNamedWindow(): void + { + $skip = $this->skipTests; + if (!$skip) { + $skip = $this->connection->getDriver() instanceof Sqlserver; + } + $this->skipIf($skip); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['num_rows' => $query->func()->count('*')->over('window1')]) + ->from('comments') + ->window('window1', (new WindowExpression())->partition('article_id')) + ->orderBy(['article_id']) + ->execute() + ->fetchAll('assoc'); + $this->assertEquals(4, $result[0]['num_rows']); + } + + public function testWindowChaining(): void + { + $skip = $this->skipTests; + if (!$skip) { + $driver = $this->connection->getDriver(); + $skip = $driver instanceof Sqlserver; + if ($driver instanceof Sqlite) { + $skip = version_compare($driver->version(), '3.28.0', '<'); + } + } + $this->skipIf($skip); + + $query = new SelectQuery($this->connection); + $result = $query + ->select(['num_rows' => $query->func()->count('*')->over('window2')]) + ->from('comments') + ->window('window1', (new WindowExpression())->partition('article_id')) + ->window('window2', new WindowExpression('window1')) + ->orderBy(['article_id']) + ->execute() + ->fetchAll('assoc'); + $this->assertEquals(4, $result[0]['num_rows']); + } +} diff --git a/tests/TestCase/Database/Schema/CollectionTest.php b/tests/TestCase/Database/Schema/CollectionTest.php new file mode 100644 index 00000000000..cc3ac842193 --- /dev/null +++ b/tests/TestCase/Database/Schema/CollectionTest.php @@ -0,0 +1,110 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Cache\Cache; +use Cake\Database\Connection; +use Cake\Database\Exception\DatabaseException; +use Cake\Database\Schema\Collection; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; + +/** + * Test case for Collection + */ +class CollectionTest extends TestCase +{ + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var array<string> + */ + protected array $fixtures = [ + 'core.Users', + ]; + + /** + * Setup function + */ + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + Cache::clear('_cake_model_'); + Cache::enable(); + } + + /** + * Teardown function + */ + protected function tearDown(): void + { + $this->connection->cacheMetadata(false); + parent::tearDown(); + unset($this->connection); + } + + /** + * Test that describing nonexistent tables fails. + * + * Tests for positive describe() calls are in each platformSchema + * test case. + */ + public function testDescribeIncorrectTable(): void + { + $this->expectException(DatabaseException::class); + $schema = new Collection($this->connection); + $this->assertNull($schema->describe('derp')); + } + + /** + * Tests that schema metadata is cached + */ + public function testDescribeCache(): void + { + $this->connection->cacheMetadata('_cake_model_'); + $schema = $this->connection->getSchemaCollection(); + $table = $schema->describe('users'); + + Cache::delete('test_users', '_cake_model_'); + $this->connection->cacheMetadata(true); + $schema = $this->connection->getSchemaCollection(); + + $result = $schema->describe('users'); + $this->assertEquals($table, $result); + + $result = Cache::read('test_users', '_cake_model_'); + $this->assertEquals($table, $result); + } + + #[DoesNotPerformAssertions] + public function testListTables(): void + { + $config = $this->connection->config(); + $driver = new $config['driver']($config); + $connection = new Connection([ + 'driver' => $driver, + ]); + + $collection = new Collection($connection); + $collection->listTables(); + } +} diff --git a/tests/TestCase/Database/Schema/ColumnTest.php b/tests/TestCase/Database/Schema/ColumnTest.php new file mode 100644 index 00000000000..b8c596bca95 --- /dev/null +++ b/tests/TestCase/Database/Schema/ColumnTest.php @@ -0,0 +1,238 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Schema\Column; +use Cake\Database\Schema\PostgresSchemaDialect; +use Cake\Database\Schema\TableSchemaInterface; +use Cake\Database\TypeFactory; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; +use TestApp\Database\Type\IntType; + +class ColumnTest extends TestCase +{ + public function testSetName(): void + { + $column = new Column('body', 'string'); + $this->assertEquals('body', $column->getName()); + + $column->setName('id'); + $this->assertSame('id', $column->getName()); + } + + public function testSetType(): void + { + $column = new Column('body', 'string'); + $this->assertEquals(TableSchemaInterface::TYPE_STRING, $column->getType()); + + $column->setType('integer'); + $this->assertSame('integer', $column->getType()); + + // Types are not validated, so that we can preserve types we don't have specific handling + // for, as drivers and dialects can implement their own types. + $column->setType('imaginary'); + $this->assertSame('imaginary', $column->getType()); + } + + public function testSetBaseTypeExplicit(): void + { + $column = new Column('body', 'string'); + $this->assertEquals(TableSchemaInterface::TYPE_STRING, $column->getBaseType()); + + $column + ->setType('fancy-int') + ->setBaseType('integer'); + $this->assertSame('fancy-int', $column->getType()); + $this->assertSame('integer', $column->getBaseType()); + } + + public function testGetBaseTypeInferredFromTypeFactory(): void + { + TypeFactory::map('int', IntType::class); + $column = new Column('int_val', 'int'); + $this->assertEquals(TableSchemaInterface::TYPE_INTEGER, $column->getBaseType()); + $this->assertEquals('int', $column->getType()); + } + + public function testSetLength(): void + { + $column = new Column('body', 'string'); + $this->assertNull($column->getLength()); + + $column->setLength(255); + $this->assertSame(255, $column->getLength()); + } + + public function testSetNull(): void + { + $column = new Column('body', 'string'); + $this->assertFalse($column->isNull()); + $this->assertNull($column->getNull()); + + $column->setNull(false); + $this->assertFalse($column->isNull()); + $this->assertFalse($column->getNull()); + + $column->setNull(true); + $this->assertTrue($column->isNull()); + $this->assertTrue($column->getNull()); + } + + public function testSetDefault(): void + { + $column = new Column('body', 'string'); + $this->assertNull($column->getDefault()); + + $column->setDefault('default_value'); + $this->assertSame('default_value', $column->getDefault()); + } + + public function testSetGenerated(): void + { + $column = new Column('body', 'integer'); + $this->assertNull($column->getGenerated()); + + $column->setGenerated('by default'); + $this->assertEquals('by default', $column->getGenerated()); + + $column->setGenerated(PostgresSchemaDialect::GENERATED_BY_DEFAULT); + $this->assertEquals(PostgresSchemaDialect::GENERATED_BY_DEFAULT, $column->getGenerated()); + } + + public function testSetIdentity(): void + { + $column = new Column('body', 'string'); + $this->assertFalse($column->isIdentity()); + + $column->setIdentity(true); + $this->assertTrue($column->isIdentity()); + $this->assertTrue($column->getIdentity()); + + $column->setIdentity(false); + $this->assertFalse($column->isIdentity()); + $this->assertFalse($column->getIdentity()); + } + + public function testSetOnUpdate(): void + { + $column = new Column('body', 'string'); + $this->assertNull($column->getOnUpdate()); + + $column->setOnUpdate('CURRENT_TIMESTAMP'); + $this->assertSame('CURRENT_TIMESTAMP', $column->getOnUpdate()); + } + + public function testSetAttributesThrowsExceptionIfOptionIsNotString(): void + { + $column = new Column('body', 'string'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"0" is not a valid column option.'); + + $column->setAttributes(['identity']); + } + + public function testSetAfter(): void + { + $column = new Column('body', 'string'); + $this->assertNull($column->getAfter()); + + $column->setAfter('previous_column'); + $this->assertSame('previous_column', $column->getAfter()); + } + + public function testSetComment(): void + { + $column = new Column('body', 'string'); + $this->assertNull($column->getComment()); + + $column->setComment('This is a comment'); + $this->assertSame('This is a comment', $column->getComment()); + } + + public function testSetUnsigned(): void + { + $column = new Column('body', 'integer'); + $this->assertNull($column->getUnsigned()); + + $column->setUnsigned(false); + $this->assertFalse($column->getUnsigned()); + + $column->setUnsigned(true); + $this->assertTrue($column->getUnsigned()); + $this->assertTrue($column->isUnsigned()); + $this->assertFalse($column->isSigned()); + } + + public function testSetAttributesIdentity(): void + { + $column = new Column('body', 'string'); + $this->assertFalse($column->isNull()); + $this->assertFalse($column->isIdentity()); + + $column->setAttributes(['identity' => true]); + $this->assertFalse($column->isNull()); + $this->assertTrue($column->isIdentity()); + } + + public function testSetCollation(): void + { + $column = new Column('body', 'string'); + $this->assertNull($column->getCollate()); + + $column->setCollate('utf8mb4_general_ci'); + $this->assertSame('utf8mb4_general_ci', $column->getCollate()); + } + + public function testSetSrid(): void + { + $column = new Column('body', 'string'); + $this->assertNull($column->getSrid()); + + $column->setSrid(4326); + $this->assertSame(4326, $column->getSrid()); + } + + public function testSetAttributes(): void + { + $column = new Column('body', 'string'); + $options = [ + 'type' => 'string', + 'length' => 255, + 'null' => false, + 'default' => 'default_value', + 'collate' => 'utf8mb4_general_ci', + ]; + $column->setAttributes($options); + + $this->assertSame(255, $column->getLength()); + $this->assertFalse($column->isNull()); + $this->assertSame('default_value', $column->getDefault()); + $this->assertSame('utf8mb4_general_ci', $column->getCollate()); + } + + public static function toArrayDataProvider(): array + { + return [ + 'datetime null' => ['datetime', null, 'datetime'], + 'datetime' => ['datetime', 0, 'datetime'], + 'datetimefractional' => ['datetime', 6, 'datetimefractional'], + 'timestamp null' => ['timestamp', null, 'timestamp'], + 'timestamp' => ['timestamp', 0, 'timestamp'], + 'timestampfractional' => ['timestamp', 6, 'timestampfractional'], + ]; + } + + #[DataProvider('toArrayDataProvider')] + public function testToArrayDatetimeToDatetimeFractional(string $inType, $precision, $outType): void + { + $column = new Column('created', $inType, precision: $precision); + $result = $column->toArray(); + + $this->assertSame($outType, $result['type']); + $this->assertSame($precision, $result['precision']); + } +} diff --git a/tests/TestCase/Database/Schema/ConstraintTest.php b/tests/TestCase/Database/Schema/ConstraintTest.php new file mode 100644 index 00000000000..356f0e267f0 --- /dev/null +++ b/tests/TestCase/Database/Schema/ConstraintTest.php @@ -0,0 +1,47 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Schema\Constraint; +use Cake\TestSuite\TestCase; + +/** + * Tests for the Constraint class. + */ +class ConstraintTest extends TestCase +{ + public function testSetType(): void + { + $index = new Constraint('id_pk', columns: ['id'], type: Constraint::PRIMARY); + $this->assertSame(Constraint::PRIMARY, $index->getType()); + + $index = new Constraint('title_idx', columns: ['title'], type: Constraint::UNIQUE); + $this->assertSame(Constraint::UNIQUE, $index->getType()); + + // types are not checked. + $index->setType('check'); + $this->assertSame('check', $index->getType()); + } + + public function testSetColumns(): void + { + $index = new Constraint('title_idx', [], type: Constraint::PRIMARY); + $this->assertSame([], $index->getColumns()); + + $index->setColumns(['title']); + $this->assertSame(['title'], $index->getColumns()); + + $index->setColumns(['title', 'name']); + $this->assertSame(['title', 'name'], $index->getColumns()); + } + + public function testSetName(): void + { + $index = new Constraint('title_idx', ['title'], type: Constraint::PRIMARY); + $this->assertSame('title_idx', $index->getName()); + + $index->setName('my_index'); + $this->assertSame('my_index', $index->getName()); + } +} diff --git a/tests/TestCase/Database/Schema/ForeignKeyTest.php b/tests/TestCase/Database/Schema/ForeignKeyTest.php new file mode 100644 index 00000000000..bcf9da831ba --- /dev/null +++ b/tests/TestCase/Database/Schema/ForeignKeyTest.php @@ -0,0 +1,128 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Schema\ForeignKey; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; + +/** + * Tests for the ForeignKey class. + */ +class ForeignKeyTest extends TestCase +{ + public function testSetType(): void + { + $key = new ForeignKey('user_fk', ['user_id'], 'users', ['id']); + $this->assertSame(ForeignKey::FOREIGN, $key->getType()); + + // types are not checked. + $key->setType('derp'); + $this->assertSame('derp', $key->getType()); + } + + public function testSetColumns(): void + { + $key = new ForeignKey('title_idx', []); + $this->assertSame([], $key->getColumns()); + + $key->setColumns(['title']); + $this->assertSame(['title'], $key->getColumns()); + + $key->setColumns(['title', 'name']); + $this->assertSame(['title', 'name'], $key->getColumns()); + } + + public function testSetName(): void + { + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + $this->assertSame('title_idx', $key->getName()); + + $key->setName('my_index'); + $this->assertSame('my_index', $key->getName()); + } + + public function testSetReferencedTable(): void + { + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + $this->assertSame('users', $key->getReferencedTable()); + + $key->setReferencedTable('users_new'); + $this->assertSame('users_new', $key->getReferencedTable()); + } + + public function testSetReferencedColumns(): void + { + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + $this->assertSame(['id'], $key->getReferencedColumns()); + + $key->setReferencedColumns(['id', 'name']); + $this->assertSame(['id', 'name'], $key->getReferencedColumns()); + } + + public function testSetDelete(): void + { + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + $this->assertSame(ForeignKey::NO_ACTION, $key->getDelete()); + + $key->setDelete(ForeignKey::CASCADE); + $this->assertSame(ForeignKey::CASCADE, $key->getDelete()); + + $key->setDelete(ForeignKey::RESTRICT); + $this->assertSame(ForeignKey::RESTRICT, $key->getDelete()); + } + + public function testSetDeleteValidateConstructor(): void + { + $this->expectException(InvalidArgumentException::class); + new ForeignKey('title_idx', ['title'], 'users', ['id'], delete: 'invalid'); + } + + public function testSetUpdateValidateConstructor(): void + { + $this->expectException(InvalidArgumentException::class); + new ForeignKey('title_idx', ['title'], 'users', ['id'], update: 'invalid'); + } + + public function testSetOnUpdateValidateSetter(): void + { + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + + $key->setUpdate(ForeignKey::CASCADE); + $this->assertEquals(ForeignKey::CASCADE, $key->getUpdate()); + + $this->expectException(InvalidArgumentException::class); + $key->setUpdate('invalid'); + } + + public function testSetOnDeleteValidateSetter(): void + { + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + + $this->expectException(InvalidArgumentException::class); + $key->setDelete('invalid'); + } + + public function testSetDeferrable(): void + { + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + $this->assertNull($key->getDeferrable()); + + $key->setDeferrable(ForeignKey::DEFERRED); + $this->assertEquals(ForeignKey::DEFERRED, $key->getDeferrable()); + + $key->setDeferrable(ForeignKey::IMMEDIATE); + $this->assertEquals(ForeignKey::IMMEDIATE, $key->getDeferrable()); + + $key->setDeferrable(ForeignKey::NOT_DEFERRED); + $this->assertEquals(ForeignKey::NOT_DEFERRED, $key->getDeferrable()); + } + + public function testSetDeferrableInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $key = new ForeignKey('title_idx', ['title'], 'users', ['id']); + $key->setDeferrable('invalid'); + } +} diff --git a/tests/TestCase/Database/Schema/IndexTest.php b/tests/TestCase/Database/Schema/IndexTest.php new file mode 100644 index 00000000000..06997416dda --- /dev/null +++ b/tests/TestCase/Database/Schema/IndexTest.php @@ -0,0 +1,112 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Schema\Index; +use Cake\TestSuite\TestCase; + +/** + * Tests for the Index class. + */ +class IndexTest extends TestCase +{ + public function testSetType(): void + { + $index = new Index('title_idx', ['title']); + $this->assertSame(Index::INDEX, $index->getType()); + + $index->setType(Index::FULLTEXT); + $this->assertSame(Index::FULLTEXT, $index->getType()); + + $index->setType(Index::INDEX); + $this->assertSame(Index::INDEX, $index->getType()); + } + + public function testSetColumns(): void + { + $index = new Index('title_idx', []); + $this->assertSame([], $index->getColumns()); + + $index->setColumns(['title']); + $this->assertSame(['title'], $index->getColumns()); + + $index->setColumns(['title', 'name']); + $this->assertSame(['title', 'name'], $index->getColumns()); + } + + public function testSetName(): void + { + $index = new Index('title_idx', ['title']); + $this->assertSame('title_idx', $index->getName()); + + $index->setName('my_index'); + $this->assertSame('my_index', $index->getName()); + } + + public function testSetLength(): void + { + $index = new Index('title_idx', ['title']); + $this->assertNull($index->getLength()); + + $index->setLength(255); + $this->assertSame(255, $index->getLength()); + + // MySQL supports per-column limits for indexes. + $index->setLength(['title' => 100, 'name' => 50]); + $this->assertSame(['title' => 100, 'name' => 50], $index->getLength()); + } + + public function testSetOrder(): void + { + $index = new Index('title_idx', ['title']); + $this->assertNull($index->getOrder()); + + $index->setOrder(['title' => 'ASC']); + $this->assertSame(['title' => 'ASC'], $index->getOrder()); + + $index->setOrder(['title' => 'ASC', 'name' => 'DESC']); + $this->assertSame(['title' => 'ASC', 'name' => 'DESC'], $index->getOrder()); + } + + public function testSetInclude(): void + { + $index = new Index('title_idx', ['title']); + $this->assertNull($index->getInclude()); + + $index->setInclude(['title']); + $this->assertSame(['title'], $index->getInclude()); + + $index->setInclude(['title', 'name']); + $this->assertSame(['title', 'name'], $index->getInclude()); + + $index->setInclude(['title', 'name']); + $this->assertSame(['title', 'name'], $index->getInclude()); + } + + public function testSetWhere(): void + { + $index = new Index('title_idx', ['title']); + $this->assertNull($index->getWhere()); + + $index->setWhere('status = 1'); + $this->assertSame('status = 1', $index->getWhere()); + + $index->setWhere('status = 1 AND type = "active"'); + $this->assertSame('status = 1 AND type = "active"', $index->getWhere()); + } + + public function testSetAttributes(): void + { + $index = new Index('title_idx', ['title']); + $attrs = [ + 'name' => 'index-name', + 'columns' => ['title', 'name'], + ]; + $index->setAttributes($attrs); + foreach ($attrs as $key => $value) { + $method = 'get' . ucfirst($key); + $this->assertSame($value, $index->{$method}()); + } + } +} diff --git a/tests/TestCase/Database/Schema/MysqlSchemaDialectTest.php b/tests/TestCase/Database/Schema/MysqlSchemaDialectTest.php new file mode 100644 index 00000000000..a77fc7a6761 --- /dev/null +++ b/tests/TestCase/Database/Schema/MysqlSchemaDialectTest.php @@ -0,0 +1,2100 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Connection; +use Cake\Database\Driver; +use Cake\Database\Driver\Mysql; +use Cake\Database\DriverFeatureEnum; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Schema\CheckConstraint; +use Cake\Database\Schema\Collection as SchemaCollection; +use Cake\Database\Schema\ForeignKey; +use Cake\Database\Schema\MysqlSchemaDialect; +use Cake\Database\Schema\TableSchema; +use Cake\Database\Schema\UniqueKey; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use Exception; +use Mockery; +use PDO; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test case for MySQL Schema Dialect. + */ +class MysqlSchemaDialectTest extends TestCase +{ + protected PDO $pdo; + + /** + * Helper method for skipping tests that need a real connection. + */ + protected function _needsConnection(): void + { + $config = ConnectionManager::getConfig('test'); + $this->skipIf(!str_contains($config['driver'], 'Mysql'), 'Not using Mysql for test config'); + } + + /** + * Data provider for convert column testing + * + * @return array + */ + public static function convertColumnProvider(): array + { + return [ + [ + 'DATETIME', + ['type' => 'datetime', 'length' => null], + ], + [ + 'DATETIME(0)', + ['type' => 'datetime', 'length' => null], + ], + [ + 'DATETIME(6)', + ['type' => 'datetimefractional', 'length' => null, 'precision' => 6], + ], + [ + 'DATE', + ['type' => 'date', 'length' => null], + ], + [ + 'TIME', + ['type' => 'time', 'length' => null], + ], + [ + 'YEAR', + ['type' => 'year', 'length' => null], + ], + [ + 'TIMESTAMP', + ['type' => 'timestamp', 'length' => null], + ], + [ + 'TIMESTAMP(0)', + ['type' => 'timestamp', 'length' => null], + ], + [ + 'TIMESTAMP(6)', + ['type' => 'timestampfractional', 'length' => null, 'precision' => 6], + ], + [ + 'TINYINT(1)', + ['type' => 'boolean', 'length' => null], + ], + [ + 'TINYINT(1) UNSIGNED', + ['type' => 'boolean', 'length' => null], + ], + [ + 'TINYINT(3)', + ['type' => 'tinyinteger', 'length' => null, 'unsigned' => false], + ], + [ + 'TINYINT(3) UNSIGNED', + ['type' => 'tinyinteger', 'length' => null, 'unsigned' => true], + ], + [ + 'SMALLINT(4)', + ['type' => 'smallinteger', 'length' => null, 'unsigned' => false], + ], + [ + 'SMALLINT(4) UNSIGNED', + ['type' => 'smallinteger', 'length' => null, 'unsigned' => true], + ], + [ + 'INTEGER(11)', + ['type' => 'integer', 'length' => null, 'unsigned' => false], + ], + [ + 'MEDIUMINT(11)', + ['type' => 'integer', 'length' => null, 'unsigned' => false], + ], + [ + 'INTEGER(11) UNSIGNED', + ['type' => 'integer', 'length' => null, 'unsigned' => true], + ], + [ + 'BIGINT', + ['type' => 'biginteger', 'length' => null, 'unsigned' => false], + ], + [ + 'BIGINT UNSIGNED', + ['type' => 'biginteger', 'length' => null, 'unsigned' => true], + ], + [ + 'VARCHAR(255)', + ['type' => 'string', 'length' => 255, 'collate' => 'utf8_general_ci'], + ], + [ + 'CHAR(25)', + ['type' => 'char', 'length' => 25], + ], + [ + 'CHAR(36)', + ['type' => 'uuid', 'length' => null], + ], + [ + 'UUID', + ['type' => 'nativeuuid', 'length' => null], + ], + [ + 'BINARY(16)', + ['type' => 'binaryuuid', 'length' => null], + ], + [ + 'BINARY(1)', + ['type' => 'binary', 'length' => 1, 'fixed' => true], + ], + [ + 'BINARY(20)', + ['type' => 'binary', 'length' => 20, 'fixed' => true], + ], + [ + 'VARBINARY(20)', + ['type' => 'binary', 'length' => 20], + ], + [ + 'TEXT', + ['type' => 'text', 'length' => null, 'collate' => 'utf8_general_ci'], + ], + [ + 'TINYTEXT', + ['type' => 'text', 'length' => TableSchema::LENGTH_TINY, 'collate' => 'utf8_general_ci'], + ], + [ + 'MEDIUMTEXT', + ['type' => 'text', 'length' => TableSchema::LENGTH_MEDIUM, 'collate' => 'utf8_general_ci'], + ], + [ + 'LONGTEXT', + ['type' => 'text', 'length' => TableSchema::LENGTH_LONG, 'collate' => 'utf8_general_ci'], + ], + [ + 'TINYBLOB', + ['type' => 'binary', 'length' => TableSchema::LENGTH_TINY], + ], + [ + 'BLOB', + ['type' => 'binary', 'length' => null], + ], + [ + 'MEDIUMBLOB', + ['type' => 'binary', 'length' => TableSchema::LENGTH_MEDIUM], + ], + [ + 'LONGBLOB', + ['type' => 'binary', 'length' => TableSchema::LENGTH_LONG], + ], + [ + 'FLOAT', + ['type' => 'float', 'length' => null, 'precision' => null, 'unsigned' => false], + ], + [ + 'FLOAT(24)', + ['type' => 'float', 'length' => 24, 'precision' => 0, 'unsigned' => false], + ], + [ + 'DOUBLE', + ['type' => 'float', 'length' => null, 'precision' => null, 'unsigned' => false], + ], + [ + 'DOUBLE UNSIGNED', + ['type' => 'float', 'length' => null, 'precision' => null, 'unsigned' => true], + ], + [ + 'DECIMAL(11,2) UNSIGNED', + ['type' => 'decimal', 'length' => 11, 'precision' => 2, 'unsigned' => true], + ], + [ + 'DECIMAL(11,2)', + ['type' => 'decimal', 'length' => 11, 'precision' => 2, 'unsigned' => false], + ], + [ + 'DECIMAL(5,2)', + ['type' => 'decimal', 'length' => 5, 'precision' => 2, 'unsigned' => false], + ], + [ + 'FLOAT(11,2)', + ['type' => 'float', 'length' => 11, 'precision' => 2, 'unsigned' => false], + ], + [ + 'FLOAT(11,2) UNSIGNED', + ['type' => 'float', 'length' => 11, 'precision' => 2, 'unsigned' => true], + ], + [ + 'DOUBLE(10,4)', + ['type' => 'float', 'length' => 10, 'precision' => 4, 'unsigned' => false], + ], + [ + 'DOUBLE(10,4) UNSIGNED', + ['type' => 'float', 'length' => 10, 'precision' => 4, 'unsigned' => true], + ], + [ + 'JSON', + ['type' => 'json', 'length' => null], + ], + [ + 'GEOMETRY', + ['type' => 'geometry', 'length' => null], + ], + [ + 'POINT', + ['type' => 'point', 'length' => null], + ], + [ + 'LINESTRING', + ['type' => 'linestring', 'length' => null], + ], + [ + 'POLYGON', + ['type' => 'polygon', 'length' => null], + ], + [ + 'BIT(1)', + ['type' => 'bit', 'length' => 1], + ], + [ + 'BIT(8)', + ['type' => 'bit', 'length' => 8], + ], + [ + 'BIT(64)', + ['type' => 'bit', 'length' => 64], + ], + ]; + } + + /** + * Test parsing MySQL column types from field description. + */ + #[DataProvider('convertColumnProvider')] + public function testConvertColumn(string $type, array $expected): void + { + $field = [ + 'Field' => 'field', + 'Type' => $type, + 'Null' => 'YES', + 'Default' => 'Default value', + 'Collation' => 'utf8_general_ci', + 'Comment' => 'Comment section', + ]; + $expected += [ + 'null' => true, + 'default' => 'Default value', + 'comment' => 'Comment section', + ]; + $driver = $this->createStub(Mysql::class); + $dialect = new MysqlSchemaDialect($driver); + + $table = new TableSchema('table'); + $dialect->convertColumnDescription($table, $field); + + $actual = array_intersect_key($table->getColumn('field'), $expected); + ksort($expected); + ksort($actual); + $this->assertSame($expected, $actual); + } + + public function testConvertColumnBlobDefault(): void + { + $field = [ + 'Field' => 'field', + 'Type' => 'binary', + 'Null' => 'YES', + 'Default' => "_utf8mb4\\'abc\\'", + 'Collation' => 'utf8_general_ci', + 'Comment' => 'Comment section', + ]; + $driver = $this->createStub(Mysql::class); + $dialect = new MysqlSchemaDialect($driver); + + $table = new TableSchema('table'); + $dialect->convertColumnDescription($table, $field); + + $actual = $table->getColumn('field'); + $this->assertSame('abc', $actual['default']); + } + + /** + * Helper method for testing methods. + * + * @param \Cake\Datasource\ConnectionInterface $connection + */ + protected function _createTables($connection): void + { + $this->_needsConnection(); + $connection->execute('DROP TABLE IF EXISTS schema_articles'); + $connection->execute('DROP TABLE IF EXISTS schema_authors'); + $connection->execute('DROP TABLE IF EXISTS schema_json'); + $connection->execute('DROP VIEW IF EXISTS schema_articles_v'); + + $table = <<<SQL + CREATE TABLE schema_authors ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50), + bio TEXT, + created DATETIME + )ENGINE=InnoDB +SQL; + $connection->execute($table); + + $table = <<<SQL + CREATE TABLE schema_articles ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(20) COMMENT 'A title', + body TEXT, + author_id INT NOT NULL, + unique_id INT NOT NULL, + published BOOLEAN DEFAULT 0, + allow_comments TINYINT(1) DEFAULT 0, + location POINT, + year_type YEAR, + config JSON, + created DATETIME, + created_with_precision DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), + updated DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY `author_idx` (`author_id`), + CONSTRAINT `length_idx` UNIQUE KEY(`title`(4)), + FOREIGN KEY `author_idx` (`author_id`) REFERENCES `schema_authors`(`id`) ON UPDATE CASCADE ON DELETE RESTRICT, + UNIQUE INDEX `unique_id_idx` (`unique_id`) + ) ENGINE=InnoDB COLLATE=utf8_general_ci +SQL; + $connection->execute($table); + + $table = <<<SQL + CREATE OR REPLACE VIEW schema_articles_v + AS SELECT 1 +SQL; + $connection->execute($table); + + if ($connection->getDriver()->supports(DriverFeatureEnum::JSON)) { + $table = <<<SQL + CREATE TABLE schema_json ( + id INT PRIMARY KEY AUTO_INCREMENT, + data JSON NOT NULL + ) +SQL; + $connection->execute($table); + } + } + + /** + * Integration test for SchemaCollection & MysqlSchemaDialect. + */ + public function testListTables(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + $schema = new SchemaCollection($connection); + + $result = $schema->listTables(); + $this->assertIsArray($result); + $this->assertContains('schema_articles', $result); + $this->assertContains('schema_articles_v', $result); + $this->assertContains('schema_authors', $result); + + $resultNoViews = $schema->listTablesWithoutViews(); + $this->assertIsArray($resultNoViews); + $this->assertNotContains('schema_articles_v', $resultNoViews); + $this->assertContains('schema_articles', $resultNoViews); + } + + /** + * Test describing a table with MySQL + */ + public function testDescribeTable(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_articles'); + $this->assertInstanceOf(TableSchema::class, $result); + $expected = [ + 'id' => [ + 'type' => 'biginteger', + 'null' => false, + 'unsigned' => false, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'autoIncrement' => true, + 'generated' => null, + ], + 'title' => [ + 'type' => 'string', + 'null' => true, + 'default' => null, + 'length' => 20, + 'precision' => null, + 'comment' => 'A title', + 'collate' => 'utf8_general_ci', + ], + 'body' => [ + 'type' => 'text', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'collate' => 'utf8_general_ci', + ], + 'author_id' => [ + 'type' => 'integer', + 'null' => false, + 'unsigned' => false, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'unique_id' => [ + 'type' => 'integer', + 'null' => false, + 'unsigned' => false, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'published' => [ + 'type' => 'boolean', + 'null' => true, + 'default' => 0, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'allow_comments' => [ + 'type' => 'boolean', + 'null' => true, + 'default' => 0, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'location' => [ + 'type' => 'point', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'srid' => null, + ], + 'year_type' => [ + 'type' => 'year', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'config' => [ + 'type' => 'json', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'created' => [ + 'type' => 'datetime', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'onUpdate' => null, + ], + 'created_with_precision' => [ + 'type' => 'datetimefractional', + 'null' => true, + 'default' => 'CURRENT_TIMESTAMP(3)', + 'length' => null, + 'precision' => 3, + 'comment' => null, + 'onUpdate' => null, + ], + 'updated' => [ + 'type' => 'datetime', + 'null' => true, + 'default' => 'CURRENT_TIMESTAMP', + 'length' => null, + 'precision' => null, + 'comment' => '', + 'onUpdate' => 'CURRENT_TIMESTAMP', + ], + ]; + + $driver = ConnectionManager::get('test')->getDriver(); + if ($driver->isMariaDb()) { + $expected['created_with_precision']['default'] = 'current_timestamp(3)'; + $expected['created_with_precision']['comment'] = ''; + + // MariaDb aliases JSON to LONGTEXT + // https://mariadb.com/kb/en/json/ + $expected['config']['type'] = 'text'; + $expected['config']['length'] = 4294967295; + $expected['config']['comment'] = ''; + $expected['config']['collate'] = 'utf8mb4_bin'; + } + // MariaDB 10.5+ and MySQL 8.0.30+ use utf8mb3 alias instead of utf8 + if ( + ($driver->isMariaDb() && version_compare($driver->version(), '10.5.0', '>=')) || + (!$driver->isMariaDb() && version_compare($driver->version(), '8.0.30', '>=')) + ) { + $expected['title']['collate'] = 'utf8mb3_general_ci'; + $expected['body']['collate'] = 'utf8mb3_general_ci'; + } + + $this->assertEquals(['id'], $result->getPrimaryKey()); + foreach ($expected as $field => $definition) { + $this->assertEquals( + $definition, + $result->getColumn($field), + 'Field definition does not match for ' . $field, + ); + + // Integration test for column() method. + $col = $result->column($field); + $this->assertEquals($definition['type'], $col->getType()); + $this->assertEquals($definition['null'], $col->getNull()); + $this->assertEquals($definition['length'], $col->getLength()); + $this->assertEquals($definition['default'], $col->getDefault()); + $this->assertEquals($definition['precision'], $col->getPrecision()); + $this->assertEquals($definition['comment'], $col->getComment()); + if (isset($definition['onUpdate'])) { + $this->assertEquals($definition['onUpdate'], $col->getOnUpdate()); + } else { + $this->assertNull($col->getOnUpdate()); + } + if (isset($definition['collate'])) { + $this->assertEquals($definition['collate'], $col->getCollate()); + } else { + $this->assertNull($col->getCollate()); + } + if (isset($definition['autoIncrement'])) { + $this->assertEquals($definition['autoIncrement'], $col->getIdentity()); + } else { + $this->assertFalse($col->getIdentity()); + } + } + + $columns = $dialect->describeColumns('schema_articles'); + foreach ($columns as $column) { + $this->assertArrayHasKey($column['name'], $expected); + $expectedItem = $expected[$column['name']]; + $expectedFields = array_intersect_key($expectedItem, $column); + $resultFields = array_intersect_key($column, $expectedFields); + $this->assertEquals($expectedFields, $resultFields); + } + } + + /** + * Test describing a table with MySQL + */ + public function testDescribeTableDatabasePrefix(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $config = $connection->getDriver()->config(); + $dialect = $connection->getDriver()->schemaDialect(); + + $result = $dialect->describe($config['database'] . '.schema_articles'); + $this->assertInstanceOf(TableSchema::class, $result); + } + + /** + * Test that schema reflection works for geosptial columns. + */ + public function testDescribeTableGeometry(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $driver = $connection->getDriver(); + + // MySQL 8.0.1 adds srid support while 8.0.13 adds default support + $hasGeometry = !$driver->isMariaDb() && version_compare($driver->version(), '8.0.13', '>='); + $this->skipIf(!$hasGeometry, 'This test requires geometry type with srid support.'); + + $table = <<<SQL +CREATE TABLE schema_geometry ( + id INTEGER, + geo_line LINESTRING, + geo_geometry GEOMETRY SRID 0, + geo_point POINT DEFAULT (ST_GeometryFromText('POINT(10 10)')) SRID 4236, + geo_polygon POLYGON SRID 4236 +) +SQL; + $connection->execute($table); + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_geometry'); + $connection->execute('DROP TABLE schema_geometry'); + + $expected = [ + 'id' => [ + 'type' => 'integer', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'unsigned' => false, + 'comment' => '', + 'autoIncrement' => null, + 'generated' => null, + ], + 'geo_line' => [ + 'type' => 'linestring', + 'null' => true, + 'default' => null, + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => 0, + ], + 'geo_geometry' => [ + 'type' => 'geometry', + 'null' => true, + 'default' => null, + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => null, + ], + 'geo_point' => [ + 'type' => 'point', + 'null' => true, + 'default' => "st_geometryfromtext('POINT(10 10)')", + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => 4236, + ], + 'geo_polygon' => [ + 'type' => 'polygon', + 'null' => true, + 'default' => null, + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => 4236, + ], + ]; + foreach ($expected as $field => $definition) { + $this->assertEquals($definition, $result->getColumn($field), "Mismatch in {$field} column"); + } + } + + /** + * MariaDB does not support setting SRID on geometry types. + */ + public function testDescribeTableGeometryNoSrid(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + + $table = <<<SQL +CREATE TABLE schema_geometry ( + id INTEGER, + geo_line LINESTRING, + geo_geometry GEOMETRY, + geo_point POINT, + geo_polygon POLYGON +) +SQL; + $connection->execute($table); + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_geometry'); + $connection->execute('DROP TABLE schema_geometry'); + + $expected = [ + 'id' => [ + 'type' => 'integer', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'unsigned' => false, + 'comment' => '', + 'autoIncrement' => null, + 'generated' => null, + ], + 'geo_line' => [ + 'type' => 'linestring', + 'null' => true, + 'default' => null, + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => null, + ], + 'geo_geometry' => [ + 'type' => 'geometry', + 'null' => true, + 'default' => null, + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => null, + ], + 'geo_point' => [ + 'type' => 'point', + 'null' => true, + 'default' => null, + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => null, + ], + 'geo_polygon' => [ + 'type' => 'polygon', + 'null' => true, + 'default' => null, + 'precision' => null, + 'length' => null, + 'comment' => '', + 'srid' => null, + ], + ]; + foreach ($expected as $field => $definition) { + $this->assertEquals($definition, $result->getColumn($field), "Mismatch in {$field} column"); + } + } + + /** + * Test describing a table with indexes in MySQL + */ + public function testDescribeTableIndexes(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $database = $connection->getDriver()->config()['database']; + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_articles'); + $this->assertInstanceOf(TableSchema::class, $result); + + $expected = [ + 'primary' => [ + 'type' => 'primary', + 'columns' => ['id'], + ], + 'length_idx' => [ + 'type' => 'unique', + 'columns' => ['title'], + 'length' => [ + 'title' => 4, + ], + ], + 'schema_articles_ibfk_1' => [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['schema_authors', 'id'], + 'update' => 'cascade', + 'delete' => 'restrict', + 'deferrable' => null, + ], + 'unique_id_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'unique_id', + ], + 'length' => [], + ], + 'author_idx' => [ + 'type' => 'index', + 'columns' => ['author_id'], + 'length' => [], + ], + ]; + + $this->assertEquals($expected['primary'], $result->getConstraint('primary')); + $primary = $result->constraint('primary'); + $this->assertEquals($expected['primary']['columns'], $primary->getColumns()); + $this->assertEquals('primary', $primary->getName()); + + $this->assertEquals($expected['length_idx'], $result->getConstraint('length_idx')); + $key = $result->constraint('length_idx'); + $this->assertEquals('length_idx', $key->getName()); + $this->assertEquals($expected['length_idx']['columns'], $key->getColumns()); + $this->assertEquals(['title' => 4], $key->getLength()); + + if (ConnectionManager::get('test')->getDriver()->isMariadb()) { + $this->assertEquals($expected['schema_articles_ibfk_1'], $result->getConstraint('author_idx')); + } else { + $this->assertEquals($expected['schema_articles_ibfk_1'], $result->getConstraint('schema_articles_ibfk_1')); + } + $this->assertEquals($expected['unique_id_idx'], $result->getConstraint('unique_id_idx')); + $key = $result->constraint('unique_id_idx'); + $this->assertEquals('unique_id_idx', $key->getName()); + $this->assertEquals($expected['unique_id_idx']['columns'], $key->getColumns()); + $this->assertSame([], $key->getLength(), 'length should be an empty array as it has been set.'); + + $this->assertCount(1, $result->indexes()); + $this->assertEquals($expected['author_idx'], $result->getIndex('author_idx')); + + // Compare with describeIndexes() which includes indexes + uniques + $indexes = $dialect->describeIndexes('schema_articles'); + $prefixed = $dialect->describeIndexes("{$database}.schema_articles"); + $this->assertEquals($indexes, $prefixed, 'prefixed tables should work'); + + foreach ($indexes as $index) { + $this->assertArrayHasKey($index['name'], $expected); + $expectedItem = $expected[$index['name']]; + $expectedFields = array_intersect_key($expectedItem, $index); + $resultFields = array_intersect_key($index, $expectedFields); + + $this->assertNotEmpty($resultFields); + $this->assertEquals($expectedFields, $resultFields); + + // describeIndexes will return primary keys, and unique indexes which are + if (in_array($index['type'], [TableSchema::INDEX_INDEX, TableSchema::INDEX_FULLTEXT], true)) { + // Compare with the index() method as well. + $indexObject = $result->index($index['name']); + } else { + // Compare with the constraint() method as well. + $indexObject = $result->constraint($index['name']); + } + foreach ($expectedFields as $key => $value) { + if ($key == 'length' && !method_exists($indexObject, 'getLength')) { + $this->assertEmpty($value, 'length should not be present in in this type'); + continue; + } + $this->assertEquals($value, $indexObject->{'get' . ucfirst($key)}()); + } + } + + // Compare describeForeignKeys() + $keys = $dialect->describeForeignKeys('schema_articles'); + $prefixed = $dialect->describeForeignKeys("{$database}.schema_articles"); + $this->assertEquals($keys, $prefixed, 'prefixed tables should work'); + + $isMariaDb = ConnectionManager::get('test')->getDriver()->isMariaDb(); + foreach ($keys as $foreignKey) { + $name = $foreignKey['name']; + if ($name === 'author_idx' && $isMariaDb) { + $name = 'schema_articles_ibfk_1'; + } + $this->assertArrayHasKey($name, $expected); + $expectedItem = $expected[$name]; + $expectedFields = array_intersect_key($expectedItem, $foreignKey); + $resultFields = array_intersect_key($foreignKey, $expectedFields); + + $this->assertNotEmpty($resultFields); + $this->assertEquals($expectedFields, $resultFields); + + // Compare with the constraint() method as well. + $indexObject = $result->constraint($foreignKey['name']); + foreach ($expectedItem as $key => $value) { + $this->assertInstanceOf(ForeignKey::class, $indexObject); + if ($key == 'references') { + $this->assertEquals($value[0], $indexObject->getReferencedTable()); + $this->assertEquals((array)$value[1], $indexObject->getReferencedColumns()); + continue; + } + if ($key === 'length' && !($indexObject instanceof UniqueKey)) { + $this->assertEquals([], $value); + continue; + } + $this->assertEquals($value, $indexObject->{'get' . ucfirst($key)}()); + } + } + } + + /** + * Test describing a table with conditional constraints + */ + public function testDescribeTableConditionalConstraint(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $connection->execute('DROP TABLE IF EXISTS conditional_constraint'); + $table = <<<SQL +CREATE TABLE conditional_constraint ( + id INT AUTO_INCREMENT PRIMARY KEY, + config_id INT UNSIGNED NOT NULL, + status ENUM ('new', 'processing', 'completed', 'failed') DEFAULT 'new' NOT NULL, + CONSTRAINT unique_index UNIQUE (config_id, ( + (CASE WHEN ((`status` = "new") OR (`status` = "processing")) THEN `status` END) + )) +); +SQL; + try { + $connection->execute($table); + } catch (Exception) { + $this->markTestSkipped('Could not create table with conditional constraint'); + } + $schema = new SchemaCollection($connection); + $result = $schema->describe('conditional_constraint'); + $connection->execute('DROP TABLE IF EXISTS conditional_constraint'); + + $constraint = $result->getConstraint('unique_index'); + $this->assertNotEmpty($constraint); + $this->assertEquals('unique', $constraint['type']); + $this->assertEquals(['config_id'], $constraint['columns']); + } + + public function testDescribeTableFunctionalIndex(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $connection->execute('DROP TABLE IF EXISTS functional_index'); + $table = <<<SQL +CREATE TABLE functional_index ( + id INT AUTO_INCREMENT PRIMARY KEY, + properties JSON, + child_ids VARCHAR(400) GENERATED ALWAYS AS ( + properties->>'$.children[*].id' + ) VIRTUAL +); +SQL; + $index = <<<SQL +CREATE INDEX child_ids_idx ON functional_index ((CAST(child_ids AS UNSIGNED ARRAY))); +SQL; + try { + $connection->execute($table); + $connection->execute($index); + } catch (Exception) { + $this->markTestSkipped('Could not create table with functional index'); + } + $schema = new SchemaCollection($connection); + $result = $schema->describe('functional_index'); + $connection->execute('DROP TABLE IF EXISTS functional_index'); + + $column = $result->getColumn('child_ids'); + $this->assertNotEmpty($column, 'Virtual property column should be reflected'); + $this->assertEquals('string', $column['type']); + + $index = $result->getIndex('child_ids_idx'); + $this->assertNotEmpty($index); + $this->assertEquals('index', $index['type']); + $this->assertEquals([], $index['columns']); + } + + public function testDescribeTableCheckConstraints(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $driver = $connection->getDriver(); + $this->skipIf(!$driver->supports(DriverFeatureEnum::CHECK_CONSTRAINTS), 'This test requires check constraint support'); + + $connection->execute('DROP TABLE IF EXISTS schema_constraints'); + $table = <<<SQL +CREATE TABLE schema_constraints ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + age INT, + CONSTRAINT age_check CHECK (age >= 18) +) Engine=InnoDB; +SQL; + $connection->execute($table); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_constraints'); + + $constraint = $result->getConstraint('age_check'); + $this->assertStringContainsString('`age` >= 18', $constraint['expression']); + + $key = $result->constraint('age_check'); + assert($key instanceof CheckConstraint); + $this->assertEquals('age_check', $key->getName()); + $this->assertStringContainsString('`age` >= 18', $key->getExpression()); + + $connection->execute('DROP TABLE IF EXISTS schema_constraints'); + } + + /** + * Test describing a table creates options + */ + public function testDescribeTableOptions(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describeOptions('schema_articles'); + $this->assertArrayHasKey('engine', $result); + $this->assertArrayHasKey('collation', $result); + } + + public function testDescribeNonPrimaryAutoIncrement(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + + $sql = <<<SQL +CREATE TABLE `odd_primary_key` ( +`id` BIGINT UNSIGNED NOT NULL, +`other_field` INTEGER NOT NULL AUTO_INCREMENT, +PRIMARY KEY (`id`), +UNIQUE KEY `other_field` (`other_field`) +) +SQL; + $connection->execute($sql); + $schema = new SchemaCollection($connection); + $table = $schema->describe('odd_primary_key'); + $connection->execute('DROP TABLE odd_primary_key'); + + $column = $table->getColumn('other_field'); + $this->assertTrue($column['autoIncrement']); + } + + /** + * Test that DECIMAL columns are correctly reflected with their precision and scale values. + * Regression test for issue where DECIMAL(5,2) was being read back as DECIMAL(10,2). + */ + public function testDescribeDecimalPrecisionReflection(): void + { + $connection = ConnectionManager::get('test'); + $this->_needsConnection(); + + $connection->execute('DROP TABLE IF EXISTS test_decimal_precision'); + + $table = <<<SQL + CREATE TABLE test_decimal_precision ( + id INT PRIMARY KEY AUTO_INCREMENT, + amount_small DECIMAL(5,2) NOT NULL, + amount_medium DECIMAL(10,4) NOT NULL, + amount_large DECIMAL(15,6) NOT NULL + ) ENGINE=InnoDB +SQL; + $connection->execute($table); + + try { + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('test_decimal_precision'); + + $amountSmall = $result->getColumn('amount_small'); + $this->assertEquals('decimal', $amountSmall['type'], 'Type should be decimal'); + $this->assertEquals(5, $amountSmall['length'], 'Length should be 5 for DECIMAL(5,2)'); + $this->assertEquals(2, $amountSmall['precision'], 'Precision should be 2 for DECIMAL(5,2)'); + + $amountMedium = $result->getColumn('amount_medium'); + $this->assertEquals('decimal', $amountMedium['type'], 'Type should be decimal'); + $this->assertEquals(10, $amountMedium['length'], 'Length should be 10 for DECIMAL(10,4)'); + $this->assertEquals(4, $amountMedium['precision'], 'Precision should be 4 for DECIMAL(10,4)'); + + $amountLarge = $result->getColumn('amount_large'); + $this->assertEquals('decimal', $amountLarge['type'], 'Type should be decimal'); + $this->assertEquals(15, $amountLarge['length'], 'Length should be 15 for DECIMAL(15,6)'); + $this->assertEquals(6, $amountLarge['precision'], 'Precision should be 6 for DECIMAL(15,6)'); + } finally { + $connection->execute('DROP TABLE IF EXISTS test_decimal_precision'); + } + } + + /** + * Column provider for creating column sql + * + * @return array + */ + public static function columnSqlProvider(): array + { + return [ + // Unknown column type is preserved. + [ + 'title', + ['type' => 'foobar', 'length' => 25, 'null' => true, 'default' => null], + '`title` FOOBAR(25)', + ], + // strings + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => true, 'default' => null], + '`title` VARCHAR(25)', + ], + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => false], + '`title` VARCHAR(25) NOT NULL', + ], + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => true, 'default' => 'ignored'], + "`title` VARCHAR(25) DEFAULT 'ignored'", + ], + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => true, 'default' => ''], + "`title` VARCHAR(25) DEFAULT ''", + ], + [ + 'role', + ['type' => 'string', 'length' => 10, 'null' => false, 'default' => 'admin'], + "`role` VARCHAR(10) NOT NULL DEFAULT 'admin'", + ], + [ + 'role', + ['type' => 'string', 'length' => 10, 'null' => false, 'default' => new QueryExpression("'admin'")], + "`role` VARCHAR(10) NOT NULL DEFAULT 'admin'", + ], + [ + 'id', + ['type' => 'char', 'length' => 32, 'fixed' => true, 'null' => false], + '`id` CHAR(32) NOT NULL', + ], + [ + 'title', + ['type' => 'string'], + '`title` VARCHAR(255)', + ], + [ + 'id', + ['type' => 'uuid'], + '`id` CHAR(36)', + ], + [ + 'id', + ['type' => 'uuid', 'collate' => 'ascii_general_ci'], + '`id` CHAR(36) COLLATE ascii_general_ci', + ], + [ + 'id', + ['type' => 'nativeuuid'], + '`id` UUID', + ], + [ + 'id', + ['type' => 'char', 'length' => 36], + '`id` CHAR(36)', + ], + [ + 'id', + ['type' => 'binaryuuid'], + '`id` BINARY(16)', + ], + [ + 'title', + ['type' => 'string', 'length' => 255, 'null' => false, 'collate' => 'utf8_unicode_ci'], + '`title` VARCHAR(255) COLLATE utf8_unicode_ci NOT NULL', + ], + // Text + [ + 'body', + ['type' => 'text', 'null' => false], + '`body` TEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'null' => false, 'default' => 'abc'], + "`body` TEXT NOT NULL DEFAULT ('abc')", + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_TINY, 'null' => false], + '`body` TINYTEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_MEDIUM, 'null' => false], + '`body` MEDIUMTEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_LONG, 'null' => false], + '`body` LONGTEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'null' => false, 'collate' => 'utf8_unicode_ci'], + '`body` TEXT COLLATE utf8_unicode_ci NOT NULL', + ], + // JSON + [ + 'config', + ['type' => 'json', 'null' => false], + '`config` JSON NOT NULL', + ], + [ + 'config', + ['type' => 'json', 'null' => false, 'default' => '{"key":"val"}'], + '`config` JSON NOT NULL DEFAULT (\'{"key":"val"}\')', + ], + [ + 'config', + ['type' => 'json', 'default' => new QueryExpression('\'{"key":"v"}\'')], + '`config` JSON DEFAULT (\'{"key":"v"}\')', + ], + // Blob / binary + [ + 'body', + ['type' => 'binary', 'null' => false], + '`body` BLOB NOT NULL', + ], + [ + 'body', + ['type' => 'binary', 'null' => false, 'default' => 'abc'], + "`body` BLOB NOT NULL DEFAULT ('abc')", + ], + [ + 'body', + ['type' => 'binary', 'length' => TableSchema::LENGTH_TINY, 'null' => false], + '`body` TINYBLOB NOT NULL', + ], + [ + 'body', + ['type' => 'binary', 'length' => TableSchema::LENGTH_MEDIUM, 'null' => false], + '`body` MEDIUMBLOB NOT NULL', + ], + [ + 'body', + ['type' => 'binary', 'length' => TableSchema::LENGTH_LONG, 'null' => false], + '`body` LONGBLOB NOT NULL', + ], + [ + 'bytes', + ['type' => 'binary', 'length' => 5], + '`bytes` VARBINARY(5)', + ], + [ + 'bit', + ['type' => 'binary', 'length' => 1], + '`bit` VARBINARY(1)', + ], + // Fixed binary (BINARY vs VARBINARY) + [ + 'hash', + ['type' => 'binary', 'length' => 20, 'fixed' => true], + '`hash` BINARY(20)', + ], + // Integers + [ + 'post_id', + ['type' => 'tinyinteger'], + '`post_id` TINYINT', + ], + [ + 'post_id', + ['type' => 'tinyinteger', 'unsigned' => true], + '`post_id` TINYINT UNSIGNED', + ], + [ + 'post_id', + ['type' => 'smallinteger'], + '`post_id` SMALLINT', + ], + [ + 'post_id', + ['type' => 'smallinteger', 'unsigned' => true], + '`post_id` SMALLINT UNSIGNED', + ], + [ + 'post_id', + ['type' => 'integer'], + '`post_id` INTEGER', + ], + [ + 'post_id', + ['type' => 'integer', 'unsigned' => true], + '`post_id` INTEGER UNSIGNED', + ], + [ + 'post_id', + ['type' => 'biginteger'], + '`post_id` BIGINT', + ], + [ + 'post_id', + ['type' => 'biginteger', 'unsigned' => true], + '`post_id` BIGINT UNSIGNED', + ], + [ + 'post_id', + ['type' => 'integer', 'autoIncrement' => true], + '`post_id` INTEGER AUTO_INCREMENT', + ], + [ + 'post_id', + ['type' => 'integer', 'null' => false, 'autoIncrement' => false], + '`post_id` INTEGER NOT NULL', + ], + [ + 'post_id', + ['type' => 'biginteger', 'autoIncrement' => true], + '`post_id` BIGINT AUTO_INCREMENT', + ], + // Decimal + [ + 'value', + ['type' => 'decimal'], + '`value` DECIMAL', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 11, 'unsigned' => true], + '`value` DECIMAL(11) UNSIGNED', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 12, 'precision' => 5], + '`value` DECIMAL(12,5)', + ], + // Float + [ + 'value', + ['type' => 'float', 'unsigned'], + '`value` FLOAT', + ], + [ + 'value', + ['type' => 'float', 'unsigned' => true], + '`value` FLOAT UNSIGNED', + ], + [ + 'latitude', + ['type' => 'float', 'length' => 53, 'null' => true, 'default' => null, 'unsigned' => true], + '`latitude` FLOAT(53) UNSIGNED', + ], + [ + 'value', + ['type' => 'float', 'length' => 11, 'precision' => 3], + '`value` FLOAT(11,3)', + ], + // Boolean + [ + 'checked', + ['type' => 'boolean', 'default' => false], + '`checked` BOOLEAN DEFAULT FALSE', + ], + [ + 'checked', + ['type' => 'boolean', 'default' => false, 'null' => false], + '`checked` BOOLEAN NOT NULL DEFAULT FALSE', + ], + [ + 'checked', + ['type' => 'boolean', 'default' => true, 'null' => false], + '`checked` BOOLEAN NOT NULL DEFAULT TRUE', + ], + [ + 'checked', + ['type' => 'boolean', 'default' => false, 'null' => true], + '`checked` BOOLEAN DEFAULT FALSE', + ], + // datetimes + [ + 'created', + ['type' => 'datetime', 'comment' => 'Created timestamp'], + "`created` DATETIME COMMENT 'Created timestamp'", + ], + // numeric comment test - regression test for migrations#889 + [ + 'status_code', + ['type' => 'integer', 'comment' => '404'], + "`status_code` INTEGER COMMENT '404'", + ], + [ + 'version', + ['type' => 'string', 'length' => 10, 'comment' => '1.0'], + "`version` VARCHAR(10) COMMENT '1.0'", + ], + [ + 'created', + ['type' => 'datetime', 'null' => false, 'default' => 'current_timestamp'], + '`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'created', + ['type' => 'datetime', 'null' => false, 'default' => new QueryExpression('now()')], + '`created` DATETIME NOT NULL DEFAULT now()', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => '2016-12-07 23:04:00'], + "`open_date` DATETIME NOT NULL DEFAULT '2016-12-07 23:04:00'", + ], + [ + 'created_with_precision', + ['type' => 'datetimefractional', 'precision' => 3, 'null' => false, 'default' => 'current_timestamp'], + '`created_with_precision` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)', + ], + // Date & Time + [ + 'start_date', + ['type' => 'date'], + '`start_date` DATE', + ], + [ + 'start_time', + ['type' => 'time'], + '`start_time` TIME', + ], + // timestamps + [ + 'created', + ['type' => 'timestamp', 'null' => true], + '`created` TIMESTAMP NULL', + ], + [ + 'created', + ['type' => 'timestamp', 'null' => false, 'default' => 'current_timestamp'], + '`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'created', + ['type' => 'timestamp', 'null' => false, 'default' => 'current_timestamp()'], + '`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'open_date', + ['type' => 'timestamp', 'null' => false, 'default' => '2016-12-07 23:04:00'], + "`open_date` TIMESTAMP NOT NULL DEFAULT '2016-12-07 23:04:00'", + ], + [ + 'created_with_precision', + ['type' => 'timestampfractional', 'precision' => 3, 'null' => false, 'default' => 'current_timestamp'], + '`created_with_precision` TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)', + ], + [ + 'updated', + [ + 'type' => 'timestamp', + 'null' => false, + 'default' => 'CURRENT_TIMESTAMP', + 'onUpdate' => 'CURRENT_TIMESTAMP', + ], + '`updated` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + ], + // Geospatial types + [ + 'g', + ['type' => 'geometry'], + '`g` GEOMETRY', + ], + [ + 'g', + ['type' => 'geometry', 'null' => false, 'srid' => 4326], + '`g` GEOMETRY NOT NULL SRID 4326', + ], + [ + 'p', + ['type' => 'point'], + '`p` POINT', + ], + [ + 'p', + ['type' => 'point', 'null' => false, 'srid' => 4326], + '`p` POINT NOT NULL SRID 4326', + ], + [ + 'l', + ['type' => 'linestring'], + '`l` LINESTRING', + ], + [ + 'l', + ['type' => 'linestring', 'null' => false, 'srid' => 4326], + '`l` LINESTRING NOT NULL SRID 4326', + ], + [ + 'p', + ['type' => 'polygon'], + '`p` POLYGON', + ], + [ + 'p', + ['type' => 'polygon', 'default' => 'POLYGON((30 10,40 40,20 40,10 20,30 10))'], + "`p` POLYGON DEFAULT ('POLYGON((30 10,40 40,20 40,10 20,30 10))')", + ], + [ + 'p', + ['type' => 'polygon', 'null' => false, 'srid' => 4326], + '`p` POLYGON NOT NULL SRID 4326', + ], + // Bit + [ + 'active', + ['type' => 'bit', 'length' => 1], + '`active` BIT(1)', + ], + [ + 'flags', + ['type' => 'bit', 'length' => 8, 'null' => false], + '`flags` BIT(8) NOT NULL', + ], + [ + 'permissions', + ['type' => 'bit', 'length' => 64], + '`permissions` BIT(64)', + ], + ]; + } + + /** + * Test generating column definitions + */ + #[DataProvider('columnSqlProvider')] + public function testColumnSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $dialect = new MysqlSchemaDialect($driver); + + $table = (new TableSchema('articles'))->addColumn($name, $data); + $this->assertEquals($expected, $dialect->columnSql($table, $name)); + + $data['name'] = $name; + $this->assertEquals($expected, $dialect->columnDefinitionSql($data)); + } + + /** + * Provide data for testing constraintSql + * + * @return array + */ + public static function constraintSqlProvider(): array + { + return [ + [ + 'primary', + ['type' => 'primary', 'columns' => ['title']], + 'PRIMARY KEY (`title`)', + ], + [ + 'unique_idx', + ['type' => 'unique', 'columns' => ['title', 'author_id']], + 'UNIQUE KEY `unique_idx` (`title`, `author_id`)', + ], + [ + 'length_idx', + [ + 'type' => 'unique', + 'columns' => ['author_id', 'title'], + 'length' => ['author_id' => 5, 'title' => 4], + ], + 'UNIQUE KEY `length_idx` (`author_id`(5), `title`(4))', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id']], + 'CONSTRAINT `author_id_idx` FOREIGN KEY (`author_id`) ' . + 'REFERENCES `authors` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'cascade'], + 'CONSTRAINT `author_id_idx` FOREIGN KEY (`author_id`) ' . + 'REFERENCES `authors` (`id`) ON UPDATE CASCADE ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'restrict'], + 'CONSTRAINT `author_id_idx` FOREIGN KEY (`author_id`) ' . + 'REFERENCES `authors` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'setNull'], + 'CONSTRAINT `author_id_idx` FOREIGN KEY (`author_id`) ' . + 'REFERENCES `authors` (`id`) ON UPDATE SET NULL ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'noAction'], + 'CONSTRAINT `author_id_idx` FOREIGN KEY (`author_id`) ' . + 'REFERENCES `authors` (`id`) ON UPDATE NO ACTION ON DELETE RESTRICT', + ], + [ + 'author_id_check', + ['type' => 'check', 'expression' => 'author_id > 0'], + 'CONSTRAINT `author_id_check` CHECK (author_id > 0)', + ], + ]; + } + + /** + * Test the constraintSql method. + */ + #[DataProvider('constraintSqlProvider')] + public function testConstraintSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new MysqlSchemaDialect($driver); + + $table = (new TableSchema('articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addConstraint($name, $data); + + $this->assertEquals($expected, $schema->constraintSql($table, $name)); + } + + /** + * Test provider for indexSql() + * + * @return array + */ + public static function indexSqlProvider(): array + { + return [ + [ + 'key_key', + ['type' => 'index', 'columns' => ['author_id']], + 'KEY `key_key` (`author_id`)', + ], + [ + 'full_text', + ['type' => 'fulltext', 'columns' => ['title']], + 'FULLTEXT KEY `full_text` (`title`)', + ], + ]; + } + + /** + * Test the indexSql method. + */ + #[DataProvider('indexSqlProvider')] + public function testIndexSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new MysqlSchemaDialect($driver); + + $table = (new TableSchema('articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addIndex($name, $data); + + $this->assertEquals($expected, $schema->indexSql($table, $name)); + } + + /** + * Test the addConstraintSql method. + */ + public function testAddConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('posts')) + ->addColumn('author_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_name', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('author_fk', [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + ]) + ->addConstraint('category_fk', [ + 'type' => 'foreign', + 'columns' => ['category_id', 'category_name'], + 'references' => ['categories', ['id', 'name']], + 'update' => 'cascade', + 'delete' => 'cascade', + ]); + + $expected = [ + 'ALTER TABLE `posts` ADD CONSTRAINT `author_fk` FOREIGN KEY (`author_id`) REFERENCES `authors` (`id`) ON UPDATE CASCADE ON DELETE CASCADE;', + 'ALTER TABLE `posts` ADD CONSTRAINT `category_fk` FOREIGN KEY (`category_id`, `category_name`) REFERENCES `categories` (`id`, `name`) ON UPDATE CASCADE ON DELETE CASCADE;', + ]; + $result = $table->addConstraintSql($connection); + $this->assertCount(2, $result); + $this->assertEquals($expected, $result); + } + + /** + * Test the dropConstraintSql method. + */ + public function testDropConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('posts')) + ->addColumn('author_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_name', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('author_fk', [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + ]) + ->addConstraint('category_fk', [ + 'type' => 'foreign', + 'columns' => ['category_id', 'category_name'], + 'references' => ['categories', ['id', 'name']], + 'update' => 'cascade', + 'delete' => 'cascade', + ]); + + $expected = [ + 'ALTER TABLE `posts` DROP FOREIGN KEY `author_fk`;', + 'ALTER TABLE `posts` DROP FOREIGN KEY `category_fk`;', + ]; + $result = $table->dropConstraintSql($connection); + $this->assertCount(2, $result); + $this->assertEquals($expected, $result); + } + + /** + * Test generating a column that is a primary key. + */ + public function testColumnSqlPrimaryKey(): void + { + $driver = $this->_getMockedDriver(); + $schema = new MysqlSchemaDialect($driver); + + $table = new TableSchema('articles'); + $table->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + $result = $schema->columnSql($table, 'id'); + $this->assertSame('`id` INTEGER NOT NULL AUTO_INCREMENT', $result); + + $table = new TableSchema('articles'); + $table->addColumn('id', [ + 'type' => 'biginteger', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + $result = $schema->columnSql($table, 'id'); + $this->assertSame('`id` BIGINT NOT NULL AUTO_INCREMENT', $result); + } + + /** + * Integration test for converting a Schema\Table into MySQL table creates. + */ + public function testCreateSql(): void + { + $driver = $this->_getMockedDriver('5.6.0'); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('posts'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('title', [ + 'type' => 'string', + 'null' => false, + 'comment' => 'The title', + ]) + ->addColumn('body', [ + 'type' => 'text', + 'comment' => '', + ]) + ->addColumn('data', [ + 'type' => 'json', + ]) + ->addColumn('hash', [ + 'type' => 'char', + 'fixed' => true, + 'length' => 40, + 'collate' => 'latin1_bin', + 'null' => false, + ]) + ->addColumn('created', 'datetime') + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]) + ->setOptions([ + 'engine' => 'InnoDB', + 'charset' => 'utf8', + 'collate' => 'utf8_general_ci', + ]); + + $expected = <<<SQL +CREATE TABLE `posts` ( +`id` INTEGER NOT NULL AUTO_INCREMENT, +`title` VARCHAR(255) NOT NULL COMMENT 'The title', +`body` TEXT, +`data` LONGTEXT, +`hash` CHAR(40) COLLATE latin1_bin NOT NULL, +`created` DATETIME, +PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + } + + /** + * Integration test for converting a Schema\Table with native JSON + */ + public function testCreateSqlJson(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $this->pdo->shouldReceive('getAttribute') + ->andReturn('5.7.0'); + + $table = (new TableSchema('posts'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('data', [ + 'type' => 'json', + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]) + ->setOptions([ + 'engine' => 'InnoDB', + 'charset' => 'utf8', + 'collate' => 'utf8_general_ci', + ]); + + $expected = <<<SQL +CREATE TABLE `posts` ( +`id` INTEGER NOT NULL AUTO_INCREMENT, +`data` JSON, +PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + } + + /** + * Tests creating temporary tables + */ + public function testCreateTemporary(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + $table = (new TableSchema('schema_articles'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]); + $table->setTemporary(true); + $sql = $table->createSql($connection); + $this->assertStringContainsString('CREATE TEMPORARY TABLE', $sql[0]); + } + + /** + * Test primary key generation & auto-increment. + */ + public function testCreateSqlCompositeIntegerKey(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('articles_tags')) + ->addColumn('article_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('tag_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['article_id', 'tag_id'], + ]); + + $expected = <<<SQL +CREATE TABLE `articles_tags` ( +`article_id` INTEGER NOT NULL, +`tag_id` INTEGER NOT NULL, +PRIMARY KEY (`article_id`, `tag_id`) +) +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + + $table = (new TableSchema('composite_key')) + ->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + 'autoIncrement' => true, + ]) + ->addColumn('account_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id', 'account_id'], + ]); + + $expected = <<<SQL +CREATE TABLE `composite_key` ( +`id` INTEGER NOT NULL AUTO_INCREMENT, +`account_id` INTEGER NOT NULL, +PRIMARY KEY (`id`, `account_id`) +) +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + } + + /** + * test dropSql + */ + public function testDropSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('articles'); + $result = $table->dropSql($connection); + $this->assertCount(1, $result); + $this->assertSame('DROP TABLE `articles`', $result[0]); + } + + /** + * Test truncateSql() + */ + public function testTruncateSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('articles'); + $result = $table->truncateSql($connection); + $this->assertCount(1, $result); + $this->assertSame('TRUNCATE TABLE `articles`', $result[0]); + } + + /** + * Test that constructing a schema dialect connects the driver. + */ + public function testConstructConnectsDriver(): void + { + $driver = Mockery::mock(Driver::class)->shouldIgnoreMissing(); + $driver->shouldReceive('connect')->once(); + new MysqlSchemaDialect($driver); + } + + /** + * Tests JSON column parsing on MySQL 5.7+ + */ + public function testDescribeJson(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + $this->skipIf(!$connection->getDriver()->supports(DriverFeatureEnum::JSON), 'Does not support native json'); + $this->skipIf($connection->getDriver()->isMariadb(), 'MariaDb internally uses TEXT for JSON columns'); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_json'); + $this->assertInstanceOf(TableSchema::class, $result); + $expected = [ + 'type' => 'json', + 'null' => false, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ]; + $this->assertEquals( + $expected, + $result->getColumn('data'), + 'Field definition does not match for data', + ); + } + + /** + * Get a schema instance with a mocked driver/pdo instances + */ + protected function _getMockedDriver($version = '8.0.7'): Driver + { + $this->_needsConnection(); + + $this->pdo = Mockery::mock(PDOMocked::class); + $this->pdo->shouldReceive('quote') + ->andReturnUsing(function ($value) { + return "'{$value}'"; + }); + + $driver = Mockery::mock(Mysql::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + + $driver->shouldReceive('createPdo') + ->andReturn($this->pdo); + + $driver->shouldReceive('version') + ->andReturn($version); + + $driver->connect(); + + return $driver; + } +} + +// phpcs:disable +class PDOMocked extends PDO +{ + public function quoteIdentifier(): void {} +} +// phpcs:enable diff --git a/tests/TestCase/Database/Schema/PostgresSchemaDialectTest.php b/tests/TestCase/Database/Schema/PostgresSchemaDialectTest.php new file mode 100644 index 00000000000..fc6e7373d82 --- /dev/null +++ b/tests/TestCase/Database/Schema/PostgresSchemaDialectTest.php @@ -0,0 +1,2034 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Connection; +use Cake\Database\Driver; +use Cake\Database\Driver\Postgres; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Schema\CheckConstraint; +use Cake\Database\Schema\Collection as SchemaCollection; +use Cake\Database\Schema\ForeignKey; +use Cake\Database\Schema\PostgresSchemaDialect; +use Cake\Database\Schema\TableSchema; +use Cake\Database\Schema\UniqueKey; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use Exception; +use Mockery; +use PDO; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Postgres schema test case. + */ +class PostgresSchemaDialectTest extends TestCase +{ + /** + * Helper method for skipping tests that need a real connection. + */ + protected function _needsConnection(): void + { + $config = ConnectionManager::getConfig('test'); + $this->skipIf(!str_contains($config['driver'], 'Postgres'), 'Not using Postgres for test config'); + } + + /** + * Helper method for testing methods. + * + * @param \Cake\Datasource\ConnectionInterface $connection + */ + protected function _createTables($connection): void + { + $this->_needsConnection(); + + $connection->execute('DROP VIEW IF EXISTS schema_articles_v'); + $connection->execute('DROP TABLE IF EXISTS schema_articles'); + $connection->execute('DROP TABLE IF EXISTS schema_authors'); + + $table = <<<SQL +CREATE TABLE schema_authors ( +id SERIAL, +name VARCHAR(50) DEFAULT 'bob', +bio DATE, +position INT DEFAULT 1, +created TIMESTAMP, +PRIMARY KEY (id), +CONSTRAINT "unique_position" UNIQUE ("position") +) +SQL; + $connection->execute($table); + + $table = <<<SQL +CREATE TABLE schema_articles ( +id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY , +title VARCHAR(20), +body TEXT, +author_id INTEGER NOT NULL, +unique_id INTEGER NOT NULL, +published BOOLEAN DEFAULT false, +views SMALLINT DEFAULT 0, +readingtime TIME, +data JSONB, +valid_period INTERVAL, +average_note DECIMAL(4,2), +average_income NUMERIC(10,2), +created TIMESTAMP, +created_without_precision TIMESTAMP(0), +created_with_precision TIMESTAMP(3), +created_with_timezone timestamp with time zone, +CONSTRAINT "content_idx" UNIQUE ("title", "body"), +CONSTRAINT "author_idx" FOREIGN KEY ("author_id") + REFERENCES "schema_authors" ("id") ON DELETE RESTRICT ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED, +CONSTRAINT "author_idx_immediate" FOREIGN KEY ("author_id") + REFERENCES "schema_authors" ("id") ON DELETE RESTRICT ON UPDATE CASCADE + DEFERRABLE INITIALLY IMMEDIATE, +CONSTRAINT "author_idx_not" FOREIGN KEY ("author_id") + REFERENCES "schema_authors" ("id") ON DELETE RESTRICT ON UPDATE CASCADE + NOT DEFERRABLE, +CONSTRAINT "author_id_value_check" CHECK (author_id > 0) +) +SQL; + $connection->execute($table); + $connection->execute('COMMENT ON COLUMN "schema_articles"."title" IS \'a title\''); + $connection->execute('CREATE INDEX "author_idx" ON "schema_articles" ("author_id")'); + $connection->execute('CREATE UNIQUE INDEX "unique_id_idx" ON "schema_articles" ("unique_id")'); + + $table = <<<SQL +CREATE VIEW schema_articles_v AS +SELECT * FROM schema_articles +SQL; + $connection->execute($table); + } + + protected function assertConstraint(array $expected, string $name, TableSchema $table): void + { + $constraint = $table->constraint($name); + foreach ($expected as $key => $value) { + if ($key == 'references') { + assert($constraint instanceof ForeignKey); + $this->assertEquals($value[0], $constraint->getReferencedTable()); + $this->assertEquals((array)$value[1], $constraint->getReferencedColumns()); + continue; + } + if ($key === 'constraint' || ($key === 'length' && !($constraint instanceof UniqueKey))) { + continue; + } + $this->assertEquals($value, $constraint->{'get' . ucfirst($key)}(), "Mismatch in {$name} constraint for {$key}"); + } + } + + /** + * Data provider for convert column testing + * + * @return array + */ + public static function convertColumnProvider(): array + { + return [ + // Timestamp + [ + ['type' => 'TIMESTAMP', 'datetime_precision' => 6], + ['type' => 'timestampfractional', 'length' => null, 'precision' => 6], + ], + [ + ['type' => 'TIMESTAMP', 'datetime_precision' => 0], + ['type' => 'timestamp', 'length' => null, 'precision' => 0], + ], + [ + ['type' => 'TIMESTAMP WITHOUT TIME ZONE', 'datetime_precision' => 6], + ['type' => 'timestampfractional', 'length' => null, 'precision' => 6], + ], + [ + ['type' => 'TIMESTAMP WITH TIME ZONE', 'datetime_precision' => 6], + ['type' => 'timestamptimezone', 'length' => null, 'precision' => 6], + ], + [ + ['type' => 'TIMESTAMPTZ', 'datetime_precision' => 6], + ['type' => 'timestamptimezone', 'length' => null, 'precision' => 6], + ], + // Date & time + [ + ['type' => 'DATE'], + ['type' => 'date', 'length' => null], + ], + [ + ['type' => 'TIME'], + ['type' => 'time', 'length' => null], + ], + [ + ['type' => 'TIME WITHOUT TIME ZONE'], + ['type' => 'time', 'length' => null], + ], + [ + ['type' => 'INTERVAL'], + ['type' => 'interval', 'length' => null], + ], + // Integer + [ + ['type' => 'SMALLINT'], + ['type' => 'smallinteger', 'length' => 5], + ], + [ + ['type' => 'INTEGER'], + ['type' => 'integer', 'length' => 10], + ], + [ + ['type' => 'SERIAL'], + ['type' => 'integer', 'length' => 10], + ], + [ + ['type' => 'BIGINT'], + ['type' => 'biginteger', 'length' => 20], + ], + [ + ['type' => 'BIGSERIAL'], + ['type' => 'biginteger', 'length' => 20], + ], + // Decimal + [ + ['type' => 'NUMERIC'], + ['type' => 'decimal', 'length' => null, 'precision' => null], + ], + [ + ['type' => 'NUMERIC', 'default' => 'NULL::numeric'], + ['type' => 'decimal', 'length' => null, 'precision' => null, 'default' => null], + ], + [ + ['type' => 'DECIMAL(10,2)', 'column_precision' => 10, 'column_scale' => 2], + ['type' => 'decimal', 'length' => 10, 'precision' => 2], + ], + // String + [ + ['type' => 'VARCHAR'], + ['type' => 'string', 'length' => null, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'VARCHAR(10)'], + ['type' => 'string', 'length' => 10, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'CHARACTER VARYING'], + ['type' => 'string', 'length' => null, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'CHARACTER VARYING(10)'], + ['type' => 'string', 'length' => 10, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'CHARACTER VARYING(255)', 'default' => 'NULL::character varying'], + ['type' => 'string', 'length' => 255, 'default' => null, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'CHAR(10)'], + ['type' => 'char', 'length' => 10, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'CHAR(36)'], + ['type' => 'char', 'length' => 36, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'CHARACTER(10)'], + ['type' => 'string', 'length' => 10, 'collate' => 'ja_JP.utf8'], + ], + [ + ['type' => 'MONEY'], + ['type' => 'string', 'length' => null], + ], + // UUID + [ + ['type' => 'UUID'], + ['type' => 'uuid', 'length' => null], + ], + // Text + [ + ['type' => 'TEXT'], + ['type' => 'text', 'length' => null, 'collate' => 'ja_JP.utf8'], + ], + // Blob + [ + ['type' => 'BYTEA'], + ['type' => 'binary', 'length' => null], + ], + // Float + [ + ['type' => 'REAL'], + ['type' => 'float', 'length' => null], + ], + [ + ['type' => 'DOUBLE PRECISION'], + ['type' => 'float', 'length' => null], + ], + // JSON + [ + ['type' => 'JSON'], + ['type' => 'json', 'length' => null], + ], + [ + ['type' => 'JSONB'], + ['type' => 'json', 'length' => null], + ], + // Geospatial + [ + ['type' => 'GEOMETRY'], + ['type' => 'geometry', 'length' => null], + ], + [ + ['type' => 'GEOGRAPHY'], + ['type' => 'geography', 'length' => null], + ], + // network addresses + [ + ['type' => 'CIDR'], + ['type' => 'cidr', 'length' => null], + ], + [ + ['type' => 'inet'], + ['type' => 'inet', 'length' => null], + ], + [ + ['type' => 'macaddr'], + ['type' => 'macaddr', 'length' => null], + ], + ]; + } + + /** + * Test parsing Postgres column types from field description. + */ + #[DataProvider('convertColumnProvider')] + public function testConvertColumn(array $field, array $expected): void + { + $field += [ + 'name' => 'field', + 'null' => 'YES', + 'default' => 'Default value', + 'comment' => 'Comment section', + 'char_length' => null, + 'column_precision' => null, + 'column_scale' => null, + 'collation_name' => 'ja_JP.utf8', + ]; + $expected += [ + 'null' => true, + 'default' => 'Default value', + 'comment' => 'Comment section', + ]; + + $driver = $this->createStub(Postgres::class); + $dialect = new PostgresSchemaDialect($driver); + + $table = new TableSchema('table'); + $dialect->convertColumnDescription($table, $field); + + $actual = array_intersect_key($table->getColumn('field'), $expected); + ksort($expected); + ksort($actual); + $this->assertSame($expected, $actual); + } + + /** + * Test listing tables with Postgres + */ + public function testListTables(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + $schema = new SchemaCollection($connection); + + $result = $schema->listTables(); + $this->assertIsArray($result); + $this->assertContains('schema_articles', $result); + $this->assertContains('schema_articles_v', $result); + $this->assertContains('schema_authors', $result); + + $resultNoViews = $schema->listTablesWithoutViews(); + $this->assertIsArray($resultNoViews); + $this->assertNotContains('schema_articles_v', $resultNoViews); + $this->assertContains('schema_articles', $resultNoViews); + } + + /** + * Test that describe accepts tablenames containing `schema.table`. + */ + public function testDescribeWithSchemaName(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('public.schema_articles'); + $this->assertEquals(['id'], $result->getPrimaryKey()); + $this->assertSame('schema_articles', $result->name()); + $this->assertCount(1, $result->indexes()); + } + + /** + * Test describing a table with Postgres + */ + public function testDescribeTable(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_articles'); + $expected = [ + 'id' => [ + 'type' => 'biginteger', + 'null' => false, + 'default' => null, + 'length' => 20, + 'precision' => null, + 'unsigned' => null, + 'comment' => null, + 'autoIncrement' => true, + 'generated' => 'BY DEFAULT', + ], + 'title' => [ + 'type' => 'string', + 'null' => true, + 'default' => null, + 'length' => 20, + 'precision' => null, + 'comment' => 'a title', + 'collate' => null, + ], + 'body' => [ + 'type' => 'text', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'collate' => null, + ], + 'author_id' => [ + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => 10, + 'precision' => null, + 'unsigned' => null, + 'comment' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'unique_id' => [ + 'type' => 'integer', + 'null' => false, + 'unsigned' => null, + 'default' => null, + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'published' => [ + 'type' => 'boolean', + 'null' => true, + 'default' => 0, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'views' => [ + 'type' => 'smallinteger', + 'null' => true, + 'default' => 0, + 'length' => 5, + 'precision' => null, + 'unsigned' => null, + 'comment' => null, + 'autoIncrement' => null, + ], + 'readingtime' => [ + 'type' => 'time', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'data' => [ + 'type' => 'json', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'valid_period' => [ + 'type' => 'interval', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'average_note' => [ + 'type' => 'decimal', + 'null' => true, + 'default' => null, + 'length' => 4, + 'precision' => 2, + 'unsigned' => null, + 'comment' => null, + ], + 'average_income' => [ + 'type' => 'decimal', + 'null' => true, + 'default' => null, + 'length' => 10, + 'precision' => 2, + 'unsigned' => null, + 'comment' => null, + ], + 'created' => [ + 'type' => 'timestampfractional', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 6, + 'comment' => null, + 'onUpdate' => null, + ], + 'created_without_precision' => [ + 'type' => 'timestamp', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 0, + 'comment' => null, + 'onUpdate' => null, + ], + 'created_with_precision' => [ + 'type' => 'timestampfractional', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 3, + 'comment' => null, + 'onUpdate' => null, + ], + 'created_with_timezone' => [ + 'type' => 'timestamptimezone', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 6, + 'comment' => null, + 'onUpdate' => null, + ], + ]; + $this->assertEquals(['id'], $result->getPrimaryKey()); + foreach ($expected as $field => $definition) { + $this->assertEquals($definition, $result->getColumn($field)); + } + + // Compare with describeColumns as well + // The array API has more data available. + $expected['id']['generated'] = 'BY DEFAULT'; + + $columns = $dialect->describeColumns('schema_articles'); + foreach ($columns as $column) { + $this->assertArrayHasKey($column['name'], $expected); + $expectedItem = $expected[$column['name']]; + $expectedFields = array_intersect_key($expectedItem, $column); + $resultFields = array_intersect_key($column, $expectedFields); + $this->assertEquals($expectedFields, $resultFields, 'difference in ' . $column['name']); + + // Integration test for column() method. + $col = $result->column($column['name']); + $this->assertEquals($column['type'], $col->getType()); + $this->assertEquals($column['null'], $col->getNull()); + $this->assertEquals($column['length'], $col->getLength()); + $this->assertEquals($column['default'], $col->getDefault()); + $this->assertEquals($column['comment'], $col->getComment()); + + if (isset($column['precision'])) { + $this->assertEquals($column['precision'], $col->getPrecision()); + } + if (isset($column['onUpdate'])) { + $this->assertEquals($column['onUpdate'], $col->getOnUpdate()); + } else { + $this->assertNull($col->getOnUpdate()); + } + if (isset($column['collate'])) { + $this->assertEquals($column['collate'], $col->getCollate()); + } else { + $this->assertNull($col->getCollate()); + } + if (isset($column['autoIncrement'])) { + $this->assertEquals($column['autoIncrement'], $col->getIdentity()); + } else { + $this->assertFalse($col->getIdentity()); + } + } + } + + /** + * Test describing a table with postgres and composite keys + */ + public function testDescribeTableCompositeKey(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $sql = <<<SQL +CREATE TABLE schema_composite ( + "id" SERIAL, + "site_id" INTEGER NOT NULL, + "name" VARCHAR(255), + PRIMARY KEY("id", "site_id") +); +SQL; + $connection->execute($sql); + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_composite'); + $connection->execute('DROP TABLE schema_composite'); + + $this->assertEquals(['id', 'site_id'], $result->getPrimaryKey()); + $this->assertTrue($result->getColumn('id')['autoIncrement'], 'id should be autoincrement'); + $this->assertFalse($result->getColumn('site_id')['autoIncrement'], 'site_id should not be autoincrement'); + } + + /** + * Test describing a table with citext columns + */ + public function testDescribeTableCiText(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + + $sql = 'CREATE EXTENSION IF NOT EXISTS citext'; + $connection->execute($sql); + + $sql = <<<SQL +CREATE TABLE schema_citext ( + "id" SERIAL, + "slug" CITEXT NOT NULL, + "name" VARCHAR(255), + PRIMARY KEY("id") +); +SQL; + $connection->execute($sql); + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_citext'); + $connection->execute('DROP TABLE schema_citext'); + + $expected = [ + 'type' => 'citext', + 'null' => false, + 'default' => null, + 'comment' => null, + 'length' => null, + 'precision' => null, + ]; + $this->assertEquals($expected, $result->getColumn('slug')); + } + + /** + * Test describing a table containing defaults with Postgres + */ + public function testDescribeTableWithDefaults(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_authors'); + $expected = [ + 'id' => [ + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => 10, + 'precision' => null, + 'unsigned' => null, + 'comment' => null, + 'autoIncrement' => true, + 'generated' => null, + ], + 'name' => [ + 'type' => 'string', + 'null' => true, + 'default' => 'bob', + 'length' => 50, + 'precision' => null, + 'comment' => null, + 'collate' => null, + ], + 'bio' => [ + 'type' => 'date', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'position' => [ + 'type' => 'integer', + 'null' => true, + 'default' => '1', + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'unsigned' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'created' => [ + 'type' => 'timestampfractional', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 6, + 'comment' => null, + 'onUpdate' => null, + ], + ]; + $this->assertEquals(['id'], $result->getPrimaryKey()); + foreach ($expected as $field => $definition) { + $this->assertEquals($definition, $result->getColumn($field), "Mismatch in {$field} column"); + } + } + + public function testDescribeTableGeospatialTypes(): void + { + $this->_needsConnection(); + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + + try { + $connection->execute('CREATE EXTENSION IF NOT EXISTS postgis'); + } catch (Exception) { + $this->markTestSkipped('PostGIS extension is not available'); + } + + // GEOMETRY defaults to srid 0 while GEOGRAPHY defaults to srid 4326 + $sql = <<<SQL + CREATE TABLE ref_table ( + geometry_geometry GEOMETRY, + geometry_point GEOMETRY(POINT), + geometry_point_4236 GEOMETRY(POINT, 4236), + geography_geometry GEOGRAPHY, + geography_point GEOGRAPHY(POINT), + geography_point_0 GEOGRAPHY(POINT, 0) + ); + SQL; + + $connection->execute('DROP TABLE IF EXISTS ref_table'); + $connection->execute($sql); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('ref_table'); + + $connection->execute('DROP TABLE ref_table'); + + $expected = [ + 'geometry_geometry' => [ + 'type' => 'geometry', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'srid' => null, + ], + 'geometry_point' => [ + 'type' => 'geometry', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'srid' => null, + ], + 'geometry_point_4236' => [ + 'type' => 'geometry', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'srid' => null, + ], + 'geography_geometry' => [ + 'type' => 'geography', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'geography_point' => [ + 'type' => 'geography', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'geography_point_0' => [ + 'type' => 'geography', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + ]; + + foreach ($expected as $field => $definition) { + $this->assertEquals($definition, $result->getColumn($field), "Mismatch in {$field} column"); + } + } + + /** + * Test describing a table with containing keywords + */ + public function testDescribeTableConstraintsWithKeywords(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_authors'); + $this->assertInstanceOf(TableSchema::class, $result); + $expected = [ + 'primary' => [ + 'type' => 'primary', + 'columns' => ['id'], + 'constraint' => 'schema_authors_pkey', + ], + 'unique_position' => [ + 'type' => 'unique', + 'columns' => ['position'], + 'length' => [], + ], + ]; + $this->assertCount(2, $result->constraints()); + $this->assertEquals($expected['primary'], $result->getConstraint('primary')); + $this->assertConstraint($expected['primary'], 'primary', $result); + + $this->assertEquals($expected['unique_position'], $result->getConstraint('unique_position')); + $this->assertConstraint($expected['unique_position'], 'unique_position', $result); + } + + public function testDescribeTableConstraintsColumnOrdering(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + + $queries = [ + 'CREATE TABLE ref_table (id INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY, ' . + 'field1 integer NOT NULL, field2 integer NOT NULL)', + 'CREATE TABLE table_two ( + id SERIAL NOT NULL, field1 INTEGER NOT NULL, field2 integer NOT NULL, ref_table_id INTEGER NOT NULL + )', + 'CREATE UNIQUE INDEX ON ref_table (id, field1)', + 'CREATE UNIQUE INDEX ON ref_table (field2, id)', + 'ALTER TABLE table_two ADD CONSTRAINT test_constraint ' . + 'FOREIGN KEY (ref_table_id, field1) REFERENCES ref_table(id, field1)', + 'ALTER TABLE table_two ADD CONSTRAINT reverse_constraint ' . + 'FOREIGN KEY (field2, ref_table_id) REFERENCES ref_table(field2, id)', + ]; + foreach ($queries as $query) { + $connection->execute($query); + } + $schema = new SchemaCollection($connection); + + $result = $schema->describe('table_two'); + + $connection->execute('DROP TABLE table_two'); + $connection->execute('DROP TABLE ref_table'); + + $this->assertCount(2, $result->constraints()); + $constraint = $result->getConstraint('test_constraint'); + $this->assertSame(['ref_table_id', 'field1'], $constraint['columns']); + $this->assertSame(['ref_table', ['id', 'field1']], $constraint['references']); + $this->assertConstraint($constraint, 'test_constraint', $result); + + $constraint = $result->getConstraint('reverse_constraint'); + $this->assertSame(['field2', 'ref_table_id'], $constraint['columns']); + $this->assertSame(['ref_table', ['field2', 'id']], $constraint['references']); + $this->assertConstraint($constraint, 'reverse_constraint', $result); + } + + public function testDescribeTableCheckConstraints(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_articles'); + + $constraint = $result->getConstraint('author_id_value_check'); + $this->assertSame('author_id > 0', $constraint['expression']); + + $constraint = $result->constraint('author_id_value_check'); + assert($constraint instanceof CheckConstraint); + $this->assertSame('author_id_value_check', $constraint->getName()); + $this->assertSame('author_id > 0', $constraint->getExpression()); + } + + /** + * Test describing a table with indexes + */ + public function testDescribeTableIndexes(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_articles'); + $this->assertInstanceOf(TableSchema::class, $result); + + $this->assertCount(7, $result->constraints()); + $expected = [ + 'primary' => [ + 'type' => 'primary', + 'columns' => ['id'], + 'constraint' => 'schema_articles_pkey', + ], + 'content_idx' => [ + 'type' => 'unique', + 'columns' => ['title', 'body'], + 'length' => [], + ], + 'author_idx' => [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['schema_authors', 'id'], + 'update' => 'cascade', + 'delete' => 'restrict', + 'deferrable' => ForeignKey::DEFERRED, + ], + 'author_idx_immediate' => [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['schema_authors', 'id'], + 'update' => 'cascade', + 'delete' => 'restrict', + 'deferrable' => ForeignKey::IMMEDIATE, + ], + 'author_idx_not' => [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['schema_authors', 'id'], + 'update' => 'cascade', + 'delete' => 'restrict', + 'deferrable' => ForeignKey::NOT_DEFERRED, + ], + 'unique_id_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'unique_id', + ], + 'length' => [], + ], + 'author_id_value_check' => [ + 'type' => 'check', + 'expression' => 'author_id > 0', + ], + ]; + foreach ($expected as $name => $expectedItem) { + // Compare both the array API and the Schema\Constraint API. + $this->assertEquals($expectedItem, $result->getConstraint($name), "mismatch in {$name} constraint"); + $this->assertConstraint($expectedItem, $name, $result); + } + + $this->assertCount(1, $result->indexes()); + $authorIdx = [ + 'type' => 'index', + 'columns' => ['author_id'], + 'length' => [], + ]; + $this->assertEquals($authorIdx, $result->getIndex('author_idx')); + + // Compare describeForeignKeys() + $keys = $dialect->describeForeignKeys('schema_articles'); + foreach ($keys as $foreignKey) { + $name = $foreignKey['name']; + $this->assertArrayHasKey($name, $expected); + $expectedItem = $expected[$name]; + $expectedFields = array_intersect_key($expectedItem, $foreignKey); + $resultFields = array_intersect_key($foreignKey, $expectedFields); + + $this->assertNotEmpty($resultFields); + $this->assertEquals($expectedFields, $resultFields); + $this->assertConstraint($expectedItem, $name, $result); + } + $expected['author_idx'] = $authorIdx; + $expected['primary']['constraint'] = 'schema_articles_pkey'; + + // Compare with describeIndexes() which includes indexes + uniques + $indexes = $dialect->describeIndexes('schema_articles'); + foreach ($indexes as $index) { + $name = $index['name']; + $this->assertArrayHasKey($name, $expected); + $expectedItem = $expected[$name]; + $expectedFields = array_intersect_key($expectedItem, $index); + $resultFields = array_intersect_key($index, $expectedFields); + + $this->assertNotEmpty($resultFields); + $this->assertEquals($expectedFields, $resultFields); + if ($index['type'] === 'index') { + $indexObj = $result->index($name); + } else { + $indexObj = $result->constraint($name); + } + foreach ($expectedFields as $key => $value) { + if ($key === 'constraint') { + $this->assertEquals($value, $indexObj->getName()); + continue; + } + if ($key === 'length' && !($indexObj instanceof UniqueKey)) { + $this->assertEquals([], $value); + continue; + } + $this->assertEquals($value, $indexObj->{'get' . ucfirst($key)}()); + } + } + } + + /** + * Test describing a table with indexes with nulls first + */ + public function testDescribeTableIndexesNullsFirst(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $connection->execute('DROP TABLE IF EXISTS schema_index'); + + $table = <<<SQL +CREATE TABLE schema_index ( + id serial NOT NULL, + user_id integer NOT NULL, + group_id integer NOT NULL, + grade double precision +) +WITH ( + OIDS=FALSE +) +SQL; + $connection->execute($table); + + $index = <<<SQL +CREATE INDEX schema_index_nulls + ON schema_index + USING btree + (group_id, grade DESC NULLS FIRST); +SQL; + $connection->execute($index); + $schema = new SchemaCollection($connection); + + $result = $schema->describe('schema_index'); + $this->assertCount(1, $result->indexes()); + $expected = [ + 'type' => 'index', + 'columns' => ['group_id', 'grade'], + 'length' => [], + ]; + $this->assertEquals($expected, $result->getIndex('schema_index_nulls')); + $connection->execute('DROP TABLE schema_index'); + } + + /** + * Test describing a table with postgres function defaults + */ + public function testDescribeTableFunctionDefaultValue(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $sql = <<<SQL +CREATE TABLE schema_function_defaults ( + "id" SERIAL, + year INT DEFAULT DATE_PART('year'::text, NOW()), + PRIMARY KEY("id") +); +SQL; + $connection->execute($sql); + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_function_defaults'); + $connection->execute('DROP TABLE schema_function_defaults'); + + $expected = [ + 'type' => 'integer', + 'default' => "date_part('year'::text, now())", + 'null' => true, + 'precision' => null, + 'length' => 10, + 'comment' => null, + 'unsigned' => null, + 'autoIncrement' => null, + 'generated' => null, + ]; + $this->assertEquals($expected, $result->getColumn('year')); + } + + /** + * Column provider for creating column sql + * + * @return array + */ + public static function columnSqlProvider(): array + { + return [ + // Unknown column type is preserved. + [ + 'title', + ['type' => 'foobar', 'length' => 25, 'null' => true, 'default' => null], + '"title" FOOBAR(25) DEFAULT NULL', + ], + // strings + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => false], + '"title" VARCHAR(25) NOT NULL', + ], + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => true, 'default' => 'ignored'], + '"title" VARCHAR(25) DEFAULT \'ignored\'', + ], + [ + 'id', + ['type' => 'char', 'length' => 32, 'null' => false], + '"id" CHAR(32) NOT NULL', + ], + [ + 'title', + ['type' => 'string', 'length' => 36, 'null' => false], + '"title" VARCHAR(36) NOT NULL', + ], + [ + 'id', + ['type' => 'uuid', 'length' => 36, 'null' => false], + '"id" UUID NOT NULL', + ], + [ + 'id', + ['type' => 'nativeuuid', 'length' => null, 'null' => false], + '"id" UUID NOT NULL', + ], + [ + 'id', + ['type' => 'binaryuuid', 'length' => null, 'null' => false], + '"id" UUID NOT NULL', + ], + [ + 'role', + ['type' => 'string', 'length' => 10, 'null' => false, 'default' => 'admin'], + '"role" VARCHAR(10) NOT NULL DEFAULT \'admin\'', + ], + [ + 'title', + ['type' => 'string'], + '"title" VARCHAR', + ], + [ + 'title', + ['type' => 'string', 'length' => 36], + '"title" VARCHAR(36)', + ], + [ + 'title', + ['type' => 'string', 'length' => 255, 'null' => false, 'collate' => 'C'], + '"title" VARCHAR(255) COLLATE "C" NOT NULL', + ], + [ + 'slug', + ['type' => 'citext', 'length' => null], + '"slug" CITEXT', + ], + // Text + [ + 'body', + ['type' => 'text', 'null' => false], + '"body" TEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_TINY, 'null' => false], + sprintf('"body" VARCHAR(%s) NOT NULL', TableSchema::LENGTH_TINY), + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_MEDIUM, 'null' => false], + '"body" TEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_LONG, 'null' => false], + '"body" TEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'null' => false, 'collate' => 'C'], + '"body" TEXT COLLATE "C" NOT NULL', + ], + // JSON + [ + 'config', + ['type' => 'json', 'null' => false], + '"config" JSONB NOT NULL', + ], + [ + 'config', + ['type' => 'json', 'null' => false, 'default' => new QueryExpression("'{}'::jsonb")], + '"config" JSONB NOT NULL DEFAULT \'{}\'::jsonb', + ], + // Integers + [ + 'post_id', + ['type' => 'tinyinteger', 'length' => 11], + '"post_id" SMALLINT', + ], + [ + 'post_id', + ['type' => 'smallinteger', 'length' => 11], + '"post_id" SMALLINT', + ], + [ + 'post_id', + ['type' => 'integer', 'length' => 11], + '"post_id" INT', + ], + [ + 'post_id', + ['type' => 'biginteger', 'length' => 20], + '"post_id" BIGINT', + ], + [ + 'post_id', + ['type' => 'integer', 'autoIncrement' => true, 'length' => 11], + '"post_id" INT GENERATED BY DEFAULT AS IDENTITY', + ], + [ + 'post_id', + ['type' => 'integer', 'autoIncrement' => true, 'generated' => 'ALWAYS'], + '"post_id" INT GENERATED ALWAYS AS IDENTITY', + ], + [ + 'post_id', + ['type' => 'biginteger', 'autoIncrement' => true, 'length' => 20], + '"post_id" BIGINT GENERATED BY DEFAULT AS IDENTITY', + ], + [ + 'post_id', + ['type' => 'biginteger', 'autoIncrement' => true, 'length' => 20, 'generated' => 'ALWAYS'], + '"post_id" BIGINT GENERATED ALWAYS AS IDENTITY', + ], + // Decimal + [ + 'value', + ['type' => 'decimal'], + '"value" DECIMAL', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 11], + '"value" DECIMAL(11,0)', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 12, 'precision' => 5], + '"value" DECIMAL(12,5)', + ], + // Float + [ + 'value', + ['type' => 'float'], + '"value" FLOAT', + ], + [ + 'value', + ['type' => 'float', 'length' => 11, 'precision' => 3], + '"value" FLOAT(3)', + ], + // Binary + [ + 'img', + ['type' => 'binary'], + '"img" BYTEA', + ], + // Boolean + [ + 'checked', + ['type' => 'boolean', 'default' => false], + '"checked" BOOLEAN DEFAULT FALSE', + ], + [ + 'checked', + ['type' => 'boolean', 'default' => true, 'null' => false], + '"checked" BOOLEAN NOT NULL DEFAULT TRUE', + ], + // Boolean + [ + 'checked', + ['type' => 'boolean', 'default' => 0], + '"checked" BOOLEAN DEFAULT FALSE', + ], + [ + 'checked', + ['type' => 'boolean', 'default' => 1, 'null' => false], + '"checked" BOOLEAN NOT NULL DEFAULT TRUE', + ], + // Date & Time + [ + 'start_date', + ['type' => 'date'], + '"start_date" DATE', + ], + [ + 'start_time', + ['type' => 'time'], + '"start_time" TIME', + ], + // Datetime + [ + 'created', + ['type' => 'datetime', 'null' => true], + '"created" TIMESTAMP DEFAULT NULL', + ], + [ + 'created_without_precision', + ['type' => 'datetime', 'precision' => 0], + '"created_without_precision" TIMESTAMP(0)', + ], + [ + 'created_without_precision', + ['type' => 'datetimefractional', 'precision' => 0], + '"created_without_precision" TIMESTAMP(0)', + ], + [ + 'created_with_precision', + ['type' => 'datetimefractional', 'precision' => 3], + '"created_with_precision" TIMESTAMP(3)', + ], + // Timestamp + [ + 'created', + ['type' => 'timestamp', 'null' => true], + '"created" TIMESTAMP DEFAULT NULL', + ], + [ + 'created_without_precision', + ['type' => 'timestamp', 'precision' => 0], + '"created_without_precision" TIMESTAMP(0)', + ], + [ + 'created_without_precision', + ['type' => 'timestampfractional', 'precision' => 0], + '"created_without_precision" TIMESTAMP(0)', + ], + [ + 'created_with_precision', + ['type' => 'timestampfractional', 'precision' => 3], + '"created_with_precision" TIMESTAMP(3)', + ], + [ + 'open_date', + ['type' => 'timestampfractional', 'null' => false, 'default' => '2016-12-07 23:04:00'], + '"open_date" TIMESTAMP NOT NULL DEFAULT \'2016-12-07 23:04:00\'', + ], + [ + 'null_date', + ['type' => 'timestampfractional', 'null' => true], + '"null_date" TIMESTAMP DEFAULT NULL', + ], + [ + 'current_timestamp', + ['type' => 'timestamp', 'null' => false, 'default' => 'CURRENT_TIMESTAMP'], + '"current_timestamp" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'current_timestamp', + ['type' => 'timestamp', 'default' => new QueryExpression('CURRENT_TIMESTAMP')], + '"current_timestamp" TIMESTAMP DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'current_timestamp_fractional', + ['type' => 'timestampfractional', 'null' => false, 'default' => 'CURRENT_TIMESTAMP'], + '"current_timestamp_fractional" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', + ], + // Geospatial + [ + 'g', + ['type' => 'geometry'], + '"g" GEOGRAPHY(GEOMETRY, 4326)', + ], + [ + 'g', + ['type' => 'geometry', 'null' => false, 'srid' => 4326], + '"g" GEOGRAPHY(GEOMETRY, 4326) NOT NULL', + ], + [ + 'p', + ['type' => 'point'], + '"p" GEOGRAPHY(POINT, 4326)', + ], + [ + 'p', + ['type' => 'point', 'null' => false, 'srid' => 4326], + '"p" GEOGRAPHY(POINT, 4326) NOT NULL', + ], + [ + 'l', + ['type' => 'linestring'], + '"l" GEOGRAPHY(LINESTRING, 4326)', + ], + [ + 'l', + ['type' => 'linestring', 'null' => false, 'srid' => 4326], + '"l" GEOGRAPHY(LINESTRING, 4326) NOT NULL', + ], + [ + 'p', + ['type' => 'polygon'], + '"p" GEOGRAPHY(POLYGON, 4326)', + ], + [ + 'p', + ['type' => 'polygon', 'null' => false, 'srid' => 4326], + '"p" GEOGRAPHY(POLYGON, 4326) NOT NULL', + ], + // Network address types + [ + 'network', + ['type' => 'cidr', 'null' => false], + '"network" CIDR NOT NULL', + ], + [ + 'network', + ['type' => 'inet', 'null' => false], + '"network" INET NOT NULL', + ], + [ + 'network', + ['type' => 'macaddr', 'null' => false], + '"network" MACADDR NOT NULL', + ], + ]; + } + + /** + * Test generating column definitions + */ + #[DataProvider('columnSqlProvider')] + public function testColumnSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new PostgresSchemaDialect($driver); + + $table = (new TableSchema('schema_articles'))->addColumn($name, $data); + $this->assertEquals($expected, $schema->columnSql($table, $name)); + + $data['name'] = $name; + $this->assertEquals($expected, $schema->columnDefinitionSql($data)); + } + + /** + * Test generating a column that is a primary key. + */ + public function testColumnSqlPrimaryKey(): void + { + $driver = $this->_getMockedDriver(); + $schema = new PostgresSchemaDialect($driver); + + $table = new TableSchema('schema_articles'); + $table->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + + $result = $schema->columnSql($table, 'id'); + $this->assertSame('"id" INT NOT NULL GENERATED BY DEFAULT AS IDENTITY', $result); + } + + /** + * Provide data for testing constraintSql + * + * @return array + */ + public static function constraintSqlProvider(): array + { + return [ + [ + 'primary', + ['type' => 'primary', 'columns' => ['title']], + 'PRIMARY KEY ("title")', + ], + [ + 'unique_idx', + ['type' => 'unique', 'columns' => ['title', 'author_id']], + 'CONSTRAINT "unique_idx" UNIQUE ("title", "author_id")', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id']], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE RESTRICT ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'cascade'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE CASCADE ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'restrict'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE RESTRICT ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'setNull'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE SET NULL ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'noAction'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE', + ], + [ + 'author_id_idx', + [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'noAction', + 'deferrable' => ForeignKey::DEFERRED, + ], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED', + ], + [ + 'author_id_check', + ['type' => 'check', 'expression' => 'author_id > 0'], + 'CONSTRAINT "author_id_check" CHECK (author_id > 0)', + ], + ]; + } + + /** + * Test the constraintSql method. + */ + #[DataProvider('constraintSqlProvider')] + public function testConstraintSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new PostgresSchemaDialect($driver); + + $table = (new TableSchema('schema_articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addConstraint($name, $data); + + $this->assertTextEquals($expected, $schema->constraintSql($table, $name)); + } + + /** + * Provide data for testing constraintSql + * + * @return array + */ + public static function indexSqlProvider(): array + { + return [ + [ + 'title_idx', + ['type' => 'index', 'columns' => ['title']], + 'CREATE INDEX "title_idx" ON "schema_articles" ("title")', + ], + [ + 'author_idx', + ['type' => 'index', 'columns' => ['author_id'], 'include' => ['title']], + 'CREATE INDEX "author_idx" ON "schema_articles" ("author_id") INCLUDE ("title")', + ], + ]; + } + + /** + * Test the indexSql method. + */ + #[DataProvider('indexSqlProvider')] + public function testIndexSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new PostgresSchemaDialect($driver); + + $table = (new TableSchema('schema_articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addIndex($name, $data); + + $this->assertTextEquals($expected, $schema->indexSql($table, $name)); + } + + /** + * Test the addConstraintSql method. + */ + public function testAddConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('posts')) + ->addColumn('author_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_name', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('author_fk', [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + ]) + ->addConstraint('category_fk', [ + 'type' => 'foreign', + 'columns' => ['category_id', 'category_name'], + 'references' => ['categories', ['id', 'name']], + 'update' => 'cascade', + 'delete' => 'cascade', + ]); + + $expected = [ + 'ALTER TABLE "posts" ADD CONSTRAINT "author_fk" FOREIGN KEY ("author_id") REFERENCES "authors" ("id") ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY IMMEDIATE;', + 'ALTER TABLE "posts" ADD CONSTRAINT "category_fk" FOREIGN KEY ("category_id", "category_name") REFERENCES "categories" ("id", "name") ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY IMMEDIATE;', + ]; + $result = $table->addConstraintSql($connection); + $this->assertCount(2, $result); + $this->assertEquals($expected, $result); + } + + /** + * Test the dropConstraintSql method. + */ + public function testDropConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('posts')) + ->addColumn('author_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_name', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('author_fk', [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + ]) + ->addConstraint('category_fk', [ + 'type' => 'foreign', + 'columns' => ['category_id', 'category_name'], + 'references' => ['categories', ['id', 'name']], + 'update' => 'cascade', + 'delete' => 'cascade', + ]); + + $expected = [ + 'ALTER TABLE "posts" DROP CONSTRAINT "author_fk";', + 'ALTER TABLE "posts" DROP CONSTRAINT "category_fk";', + ]; + $result = $table->dropConstraintSql($connection); + $this->assertCount(2, $result); + $this->assertEquals($expected, $result); + } + + /** + * Integration test for converting a Schema\Table into MySQL table creates. + */ + public function testCreateSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('schema_articles'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('title', [ + 'type' => 'string', + 'null' => false, + 'comment' => 'This is the title', + ]) + ->addColumn('body', ['type' => 'text']) + ->addColumn('data', ['type' => 'json']) + ->addColumn('hash', [ + 'type' => 'char', + 'length' => 40, + 'collate' => 'C', + 'null' => false, + ]) + ->addColumn('created', 'timestamp') + ->addColumn('created_without_precision', ['type' => 'timestamp', 'precision' => 0]) + ->addColumn('created_with_precision', ['type' => 'timestampfractional', 'precision' => 6]) + ->addColumn('created_with_timezone', ['type' => 'timestamptimezone', 'precision' => 6]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]) + ->addIndex('title_idx', [ + 'type' => 'index', + 'columns' => ['title'], + ]); + + $expected = <<<SQL +CREATE TABLE "schema_articles" ( +"id" INT NOT NULL GENERATED BY DEFAULT AS IDENTITY, +"title" VARCHAR NOT NULL, +"body" TEXT, +"data" JSONB, +"hash" CHAR(40) COLLATE "C" NOT NULL, +"created" TIMESTAMP, +"created_without_precision" TIMESTAMP(0), +"created_with_precision" TIMESTAMP(6), +"created_with_timezone" TIMESTAMPTZ(6), +PRIMARY KEY ("id") +) +SQL; + $result = $table->createSql($connection); + + $this->assertCount(3, $result); + $this->assertTextEquals($expected, $result[0]); + $this->assertSame( + 'CREATE INDEX "title_idx" ON "schema_articles" ("title")', + $result[1], + ); + $this->assertSame( + 'COMMENT ON COLUMN "schema_articles"."title" IS \'This is the title\'', + $result[2], + ); + } + + public function testCreateSqlGeospacialTypes(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('ref_table')) + ->addColumn('geometry', [ + 'type' => 'geometry', + ]) + ->addColumn('geometry_0', [ + 'type' => 'geometry', + 'srid' => 0, + ]) + ->addColumn('point', [ + 'type' => 'point', + ]) + ->addColumn('point_0', [ + 'type' => 'point', + 'srid' => 0, + ]); + + $expected = <<<SQL + CREATE TABLE "ref_table" ( + "geometry" GEOGRAPHY(GEOMETRY, 4326), + "geometry_0" GEOGRAPHY(GEOMETRY, 0), + "point" GEOGRAPHY(POINT, 4326), + "point_0" GEOGRAPHY(POINT, 0) + ) + SQL; + + $result = $table->createSql($connection); + + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + } + + /** + * Tests creating tables in postgres schema + */ + public function testCreateInSchema(): void + { + $driver = $this->_getMockedDriver(['schema' => 'notpublic']); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('schema_articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ]); + $sql = $table->createSql($connection); + $this->assertStringContainsString('CREATE TABLE "notpublic"."schema_articles"', $sql[0]); + } + + /** + * Tests creating temporary tables + */ + public function testCreateTemporary(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + $table = (new TableSchema('schema_articles'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]); + $table->setTemporary(true); + $sql = $table->createSql($connection); + $this->assertStringContainsString('CREATE TEMPORARY TABLE', $sql[0]); + } + + /** + * Test primary key generation & auto-increment. + */ + public function testCreateSqlCompositeIntegerKey(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('articles_tags')) + ->addColumn('article_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('tag_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['article_id', 'tag_id'], + ]); + + $expected = <<<SQL +CREATE TABLE "articles_tags" ( +"article_id" INT NOT NULL, +"tag_id" INT NOT NULL, +PRIMARY KEY ("article_id", "tag_id") +) +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + + $table = (new TableSchema('composite_key')) + ->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + 'autoIncrement' => true, + ]) + ->addColumn('account_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id', 'account_id'], + ]); + + $expected = <<<SQL +CREATE TABLE "composite_key" ( +"id" INT NOT NULL GENERATED BY DEFAULT AS IDENTITY, +"account_id" INT NOT NULL, +PRIMARY KEY ("id", "account_id") +) +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + } + + /** + * test dropSql + */ + public function testDropSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('schema_articles'); + $result = $table->dropSql($connection); + $this->assertCount(1, $result); + $this->assertSame('DROP TABLE "schema_articles" CASCADE', $result[0]); + } + + /** + * Test truncateSql() + */ + public function testTruncateSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('schema_articles'); + $table->addColumn('id', 'integer') + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + $result = $table->truncateSql($connection); + $this->assertCount(1, $result); + $this->assertSame('TRUNCATE "schema_articles" RESTART IDENTITY CASCADE', $result[0]); + } + + public function testDescribeIndexSql(): void + { + $driver = $this->createStub(Postgres::class); + $dialect = new PostgresSchemaDialect($driver); + + $result = $dialect->describeIndexSql('schema_name.table_name', []); + $this->assertEquals(['schema_name', 'table_name'], $result[1]); + + $result = $dialect->describeIndexSql('table_name', ['schema' => 'schema_name']); + $this->assertEquals(['schema_name', 'table_name'], $result[1]); + } + + public function testDescribeIndexIncludedFields(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $sql = <<<SQL +CREATE TABLE schema_index_include ( + "id" INT NOT NULL GENERATED ALWAYS AS IDENTITY, + "site_id" INTEGER NOT NULL, + "name" VARCHAR(255), + PRIMARY KEY("id") +); +SQL; + $connection->execute($sql); + + $sql = 'CREATE INDEX site_id_name ON schema_index_include (site_id) INCLUDE (name)'; + $connection->execute($sql); + + $dialect = new PostgresSchemaDialect($connection->getDriver()); + $indexExists = $dialect->hasIndex('schema_index_include', ['site_id']); + $indexExistsName = $dialect->hasIndex('schema_index_include', name: 'site_id_name'); + $indexes = $dialect->describeIndexes('schema_index_include'); + $connection->execute('DROP TABLE schema_index_include'); + + $this->assertTrue($indexExists); + $this->assertTrue($indexExistsName); + $this->assertCount(2, $indexes); + $this->assertEquals(['id'], $indexes[0]['columns']); + $this->assertEquals(['site_id'], $indexes[1]['columns']); + $this->assertEquals(['name'], $indexes[1]['include']); + } + + public function testDescribeForeignKeySql(): void + { + $driver = $this->createStub(Postgres::class); + $dialect = new PostgresSchemaDialect($driver); + + $result = $dialect->describeForeignKeySql('schema_name.table_name', []); + $this->assertEquals(['schema_name', 'table_name'], $result[1]); + + $result = $dialect->describeForeignKeySql('table_name', ['schema' => 'schema_name']); + $this->assertEquals(['schema_name', 'table_name'], $result[1]); + } + + /** + * Test that hasIndex considers constraint keys. + * This test is for postgres out of convenience, as not all databases + * provide names for primary keys. + */ + public function testHasIndexPrimaryKeyName(): void + { + $this->_needsConnection(); + + $connection = ConnectionManager::get('test'); + $dialect = new PostgresSchemaDialect($connection->getDriver()); + $this->assertTrue($dialect->hasIndex('schema_authors', [], 'schema_authors_pkey')); + } + + /** + * Get a schema instance with a mocked driver/pdo instances + */ + protected function _getMockedDriver(array $config = []): Driver + { + $this->_needsConnection(); + + $mock = Mockery::mock(PDO::class); + $mock->shouldReceive('quote') + ->andReturnUsing(function ($value) { + return "'{$value}'"; + }); + $mock->shouldReceive('exec') + ->zeroOrMoreTimes() + ->andReturn(0); + + $driver = Mockery::mock(Postgres::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct($config); + + $driver->shouldReceive('createPdo') + ->andReturn($mock); + + $driver->shouldReceive('version') + ->andReturnUsing(function () { + return '10.0.0'; + }); + + $driver->connect(); + + return $driver; + } +} diff --git a/tests/TestCase/Database/Schema/SchemaDialectTest.php b/tests/TestCase/Database/Schema/SchemaDialectTest.php new file mode 100644 index 00000000000..0ec1c6acde2 --- /dev/null +++ b/tests/TestCase/Database/Schema/SchemaDialectTest.php @@ -0,0 +1,259 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Exception\DatabaseException; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use TestApp\Database\Schema\CompatDialect; + +/** + * Test case for SchemaDialect methods + * that can pass tests across all drivers + */ +class SchemaDialectTest extends TestCase +{ + /** + * @var array<string> + */ + protected array $fixtures = [ + 'core.Products', + 'core.Users', + 'core.Orders', + ]; + + /** + * @var \Cake\Database\Schema\SchemaDialect + */ + protected $dialect; + + /** + * Setup function + */ + public function setUp(): void + { + parent::setUp(); + $this->dialect = ConnectionManager::get('test')->getDriver()->schemaDialect(); + } + + /** + * Test that describing nonexistent tables fails. + */ + public function testDescribeIncorrectTable(): void + { + $this->expectException(DatabaseException::class); + $this->assertNull($this->dialect->describe('derp')); + } + + public function testListTables(): void + { + $result = $this->dialect->listTables(); + $this->assertNotEmpty($result); + $this->assertContains('users', $result); + } + + public function testListTablesWithoutViews(): void + { + $result = $this->dialect->listTablesWithoutViews(); + $this->assertNotEmpty($result); + $this->assertContains('users', $result); + } + + public function testDescribe(): void + { + $result = $this->dialect->describe('users'); + $this->assertNotEmpty($result); + $this->assertEquals('users', $result->name()); + $this->assertTrue($result->hasColumn('username')); + } + + public function testDescribeColumns(): void + { + $result = $this->dialect->describeColumns('users'); + $this->assertCount(5, $result); + foreach ($result as $column) { + // Validate the interface for column array shape + $this->assertArrayHasKey('name', $column); + $this->assertTrue(is_string($column['name'])); + + $this->assertArrayHasKey('type', $column); + $this->assertTrue(is_string($column['type'])); + + $this->assertArrayHasKey('length', $column); + $this->assertTrue(is_int($column['length']) || $column['length'] === null); + + $this->assertArrayHasKey('default', $column); + + $this->assertArrayHasKey('null', $column); + $this->assertTrue(is_bool($column['null'])); + + $this->assertArrayHasKey('comment', $column); + $this->assertTrue(is_string($column['comment']) || $column['comment'] === null); + } + } + + public function testDescribeIndexes(): void + { + $result = $this->dialect->describeIndexes('orders'); + // TODO(mark) this should be 2 once all dialects implement describeIndexes + // This is the ideal place to return primary key indexes/constraints + // as describeForeignKey and describeColumns are not good fits. + // $this->assertCount(1, $result); + foreach ($result as $index) { + // Validate the interface for column array shape + $this->assertArrayHasKey('name', $index); + $this->assertTrue(is_string($index['name'])); + + $this->assertArrayHasKey('type', $index); + $this->assertTrue(is_string($index['type'])); + + $this->assertArrayHasKey('length', $index); + + $this->assertArrayHasKey('columns', $index); + $this->assertTrue(is_array($index['columns'])); + } + } + + public function testDescribeForeignKeys(): void + { + $result = $this->dialect->describeForeignKeys('orders'); + $this->assertCount(1, $result); + foreach ($result as $key) { + // Validate the interface for column array shape + $this->assertArrayHasKey('name', $key); + $this->assertTrue(is_string($key['name'])); + + $this->assertArrayHasKey('type', $key); + $this->assertTrue(is_string($key['type'])); + + $this->assertArrayHasKey('columns', $key); + $this->assertTrue(is_array($key['columns'])); + + $this->assertArrayHasKey('references', $key); + $this->assertTrue(is_array($key['references'])); + + $this->assertArrayHasKey('update', $key); + $this->assertTrue(is_string($key['update']) || $key['update'] === null); + $this->assertArrayHasKey('delete', $key); + $this->assertTrue(is_string($key['delete']) || $key['delete'] === null); + } + } + + public function testDescribeOptions(): void + { + $result = $this->dialect->describeOptions('orders'); + $this->assertTrue(is_array($result)); + } + + public function testDescribeOptionsMysql(): void + { + $this->skipIf(!(ConnectionManager::get('test')->getDriver() instanceof Mysql), 'requires mysql'); + $result = $this->dialect->describeOptions('orders'); + $this->assertTrue(is_array($result)); + $this->assertArrayHasKey('engine', $result); + $this->assertArrayHasKey('collation', $result); + } + + public function testHasColumn(): void + { + $this->assertFalse($this->dialect->hasColumn('orders', 'nope')); + $this->assertFalse($this->dialect->hasColumn('orders', '')); + $this->assertFalse($this->dialect->hasColumn('invalid', 'also invalid')); + + $this->assertTrue($this->dialect->hasColumn('users', 'username')); + $this->assertFalse($this->dialect->hasColumn('users', 'USERNAME')); + } + + public function testHasTable(): void + { + $this->assertFalse($this->dialect->hasTable('nope')); + $this->assertFalse($this->dialect->hasTable('USERS')); + $this->assertFalse($this->dialect->hasTable('user')); + $this->assertTrue($this->dialect->hasTable('users'), 'Should exist in implicit default.'); + } + + public function testHasTableWithSchema(): void + { + $connection = ConnectionManager::get('test'); + $driver = $connection->getDriver(); + $this->skipIf($driver instanceof Sqlite, 'sqlite does not support schemas'); + $this->skipIf($driver instanceof MySql, 'mysql fails because db is missing.'); + + $schema = $connection->config()['schema'] ?? null; + $this->assertTrue($this->dialect->hasTable('users'), 'Should exist in implicit schema'); + $this->assertTrue($this->dialect->hasTable('users', $schema), 'Should exist in default schema'); + $this->assertFalse($this->dialect->hasTable('users', 'other'), 'Should not exist on other schema'); + } + + public function testHasIndex(): void + { + $this->assertFalse($this->dialect->hasIndex('orders', ['product_category'])); + + // Columns are reversed + $this->assertFalse($this->dialect->hasIndex('orders', ['product_id', 'product_category'])); + + // Name is wrong + $this->assertFalse($this->dialect->hasIndex('orders', ['product_category', 'product_id'], 'product_category_index')); + + $this->assertTrue($this->dialect->hasIndex('orders', ['product_category', 'product_id'])); + $this->assertTrue($this->dialect->hasIndex('orders', ['product_category', 'product_id'], 'product_category')); + $this->assertTrue($this->dialect->hasIndex('orders', [], 'product_category')); + } + + public function testHasForeignKey(): void + { + // Columns are missing and reversed + $this->assertFalse($this->dialect->hasForeignKey('orders', ['product_category'])); + $this->assertFalse($this->dialect->hasForeignKey('orders', ['product_id', 'product_category'])); + + $this->assertTrue($this->dialect->hasForeignKey('orders', ['product_category', 'product_id'])); + } + + public function testHasForeignKeyNamed(): void + { + // TODO this could be resolved if we use the key reflection logic from phinx/migrations + // that logic parses the SQL of the key to extract and preserve the name. + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf($driver instanceof Mysql, 'mysql tests fail when this runs'); + + // Name is wrong + $this->assertFalse($this->dialect->hasForeignKey('orders', ['product_category', 'product_id'], 'product_category_index')); + + $this->assertTrue($this->dialect->hasForeignKey('orders', ['product_category', 'product_id'], 'product_category_fk')); + $this->assertTrue($this->dialect->hasForeignKey('orders', [], 'product_category_fk')); + } + + /** + * Test that SchemaDialect implementations without describeColumns etc + * implemented still work with describe(). + */ + public function testBackwardsCompatibility(): void + { + $this->deprecated(function (): void { + /** @var \Cake\Database\Driver $driver */ + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf(!($driver instanceof Sqlite), 'requires sqlite connection'); + $dialect = new CompatDialect($driver); + $table = $dialect->describe('orders'); + $this->assertNotEmpty($table->columns()); + $this->assertNotEmpty($table->indexes()); + $this->assertNotEmpty($table->constraints()); + }); + } +} diff --git a/tests/TestCase/Database/Schema/SqliteSchemaDialectTest.php b/tests/TestCase/Database/Schema/SqliteSchemaDialectTest.php new file mode 100644 index 00000000000..2ea1eaebd18 --- /dev/null +++ b/tests/TestCase/Database/Schema/SqliteSchemaDialectTest.php @@ -0,0 +1,1689 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Connection; +use Cake\Database\Driver; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Schema\CheckConstraint; +use Cake\Database\Schema\Collection as SchemaCollection; +use Cake\Database\Schema\Constraint; +use Cake\Database\Schema\ForeignKey; +use Cake\Database\Schema\SqliteSchemaDialect; +use Cake\Database\Schema\TableSchema; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use Mockery; +use PDO; +use PDOStatement; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test case for Sqlite Schema Dialect. + */ +class SqliteSchemaDialectTest extends TestCase +{ + protected PDO $pdo; + + protected function tearDown(): void + { + parent::tearDown(); + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get('test'); + if ($connection->getDriver() instanceof Sqlite) { + $connection->execute('DROP VIEW IF EXISTS view_schema_articles'); + $connection->execute('DROP TABLE IF EXISTS schema_articles'); + $connection->execute('DROP TABLE IF EXISTS schema_authors'); + $connection->execute('DROP TABLE IF EXISTS schema_no_rowid_pk'); + $connection->execute('DROP TABLE IF EXISTS schema_unique_constraint_variations'); + $connection->execute('DROP TABLE IF EXISTS schema_foreign_key_variations'); + $connection->execute('DROP TABLE IF EXISTS schema_composite'); + } + } + + /** + * Helper method for skipping tests that need a real connection. + */ + protected function _needsConnection(): void + { + $config = ConnectionManager::getConfig('test'); + $this->skipIf(!str_contains($config['driver'], 'Sqlite'), 'Not using Sqlite for test config'); + } + + /** + * Data provider for convert column testing + * + * @return array + */ + public static function convertColumnProvider(): array + { + return [ + [ + 'DATETIME', + ['type' => 'datetime', 'length' => null], + ], + [ + 'DATE', + ['type' => 'date', 'length' => null], + ], + [ + 'TIME', + ['type' => 'time', 'length' => null], + ], + [ + 'BOOLEAN', + ['type' => 'boolean', 'length' => null, 'default' => 0], + ], + [ + 'BIGINT', + ['type' => 'biginteger', 'length' => null, 'unsigned' => false], + ], + [ + 'UNSIGNED BIGINT', + ['type' => 'biginteger', 'length' => null, 'unsigned' => true], + ], + [ + 'VARCHAR(255)', + ['type' => 'string', 'length' => 255], + ], + [ + 'CHAR(25)', + ['type' => 'char', 'length' => 25], + ], + [ + 'CHAR(36)', + ['type' => 'uuid', 'length' => null], + ], + [ + 'BINARY(16)', + ['type' => 'binaryuuid', 'length' => null], + ], + [ + 'BINARY(1)', + ['type' => 'binary', 'length' => 1], + ], + [ + 'BLOB', + ['type' => 'binary', 'length' => null], + ], + [ + 'INTEGER(11)', + ['type' => 'integer', 'length' => 11, 'unsigned' => false], + ], + [ + 'UNSIGNED INTEGER(11)', + ['type' => 'integer', 'length' => 11, 'unsigned' => true], + ], + [ + 'TINYINT(3)', + ['type' => 'tinyinteger', 'length' => 3, 'unsigned' => false], + ], + [ + 'UNSIGNED TINYINT(3)', + ['type' => 'tinyinteger', 'length' => 3, 'unsigned' => true], + ], + [ + 'SMALLINT(5)', + ['type' => 'smallinteger', 'length' => 5, 'unsigned' => false], + ], + [ + 'UNSIGNED SMALLINT(5)', + ['type' => 'smallinteger', 'length' => 5, 'unsigned' => true], + ], + [ + 'MEDIUMINT(10)', + ['type' => 'integer', 'length' => 10, 'unsigned' => false], + ], + [ + 'FLOAT', + ['type' => 'float', 'length' => null, 'precision' => null, 'unsigned' => false], + ], + [ + 'DOUBLE', + ['type' => 'float', 'length' => null, 'precision' => null, 'unsigned' => false], + ], + [ + 'UNSIGNED DOUBLE', + ['type' => 'float', 'length' => null, 'precision' => null, 'unsigned' => true], + ], + [ + 'REAL', + ['type' => 'float', 'length' => null, 'precision' => null, 'unsigned' => false], + ], + [ + 'DECIMAL(11,2)', + ['type' => 'decimal', 'length' => 11, 'precision' => 2, 'unsigned' => false], + ], + [ + 'UNSIGNED DECIMAL(11,2)', + ['type' => 'decimal', 'length' => 11, 'precision' => 2, 'unsigned' => true], + ], + [ + 'UUID_TEXT', + ['type' => 'uuid', 'length' => null], + ], + [ + 'UUID_BLOB', + ['type' => 'binaryuuid', 'length' => null], + ], + [ + 'GEOMETRY_TEXT', + ['type' => 'geometry', 'length' => null], + ], + [ + 'POINT_TEXT', + ['type' => 'point', 'length' => null], + ], + [ + 'LINESTRING_TEXT', + ['type' => 'linestring', 'length' => null], + ], + [ + 'POLYGON_TEXT', + ['type' => 'polygon', 'length' => null], + ], + ]; + } + + /** + * Test parsing SQLite column types from field description. + */ + #[DataProvider('convertColumnProvider')] + public function testConvertColumn(string $type, array $expected): void + { + $field = [ + 'pk' => false, + 'name' => 'field', + 'type' => $type, + 'notnull' => false, + 'dflt_value' => 'Default value', + ]; + $expected += [ + 'null' => true, + 'default' => 'Default value', + 'comment' => null, + ]; + + $driver = $this->createStub(Sqlite::class); + $dialect = new SqliteSchemaDialect($driver); + + $table = new TableSchema('table'); + $dialect->convertColumnDescription($table, $field); + + $actual = array_intersect_key($table->getColumn('field'), $expected); + ksort($expected); + ksort($actual); + $this->assertSame($expected, $actual); + } + + /** + * Tests converting multiple rows into a primary constraint with multiple + * columns + */ + public function testConvertCompositePrimaryKey(): void + { + $driver = $this->createStub(Sqlite::class); + $dialect = new SqliteSchemaDialect($driver); + + $field1 = [ + 'pk' => true, + 'name' => 'field1', + 'type' => 'INTEGER(11)', + 'notnull' => false, + 'dflt_value' => 0, + ]; + $field2 = [ + 'pk' => true, + 'name' => 'field2', + 'type' => 'INTEGER(11)', + 'notnull' => false, + 'dflt_value' => 1, + ]; + + $table = new TableSchema('table'); + $dialect->convertColumnDescription($table, $field1); + $dialect->convertColumnDescription($table, $field2); + $this->assertEquals(['field1', 'field2'], $table->getPrimaryKey()); + } + + /** + * Creates tables for testing listTables/describe() + * + * @param \Cake\Database\Connection $connection + */ + protected function _createTables($connection): void + { + $this->_needsConnection(); + + $schema = new SchemaCollection($connection); + $result = $schema->listTables(); + if ( + in_array('schema_articles', $result) && + in_array('schema_authors', $result) + ) { + return; + } + + $table = <<<SQL +CREATE TABLE schema_authors ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +name VARCHAR(50), +bio TEXT, +created DATETIME +) +SQL; + $connection->execute($table); + + $table = <<<SQL +CREATE TABLE schema_articles ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +title VARCHAR(20) DEFAULT 'Let ''em eat cake', +body TEXT, +author_id INT(11) NOT NULL, +unique_id UNSIGNED INTEGER NOT NULL, +published BOOLEAN DEFAULT 0, +reviewed BOOLEAN DEFAULT TRUE, +created DATETIME, +field1 VARCHAR(10) DEFAULT NULL, +field2 VARCHAR(10) DEFAULT 'NULL', +location POINT_TEXT, +CONSTRAINT "title_idx" UNIQUE ("title", "body") +CONSTRAINT "author_fk" FOREIGN KEY ("author_id") REFERENCES "schema_authors" ("id") ON UPDATE CASCADE ON DELETE RESTRICT +CONSTRAINT "author_value_chk" CHECK (author_id > 0) +); +SQL; + $connection->execute($table); + $connection->execute('CREATE INDEX "created_idx" ON "schema_articles" ("created")'); + $connection->execute('CREATE UNIQUE INDEX "unique_id_idx" ON "schema_articles" ("unique_id")'); + + $table = <<<SQL +CREATE TABLE schema_no_rowid_pk ( +id INT PRIMARY KEY +); +SQL; + $connection->execute($table); + + $table = <<<SQL +CREATE TABLE schema_unique_constraint_variations ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +no_quotes INTEGER, +'single_''quotes' INTEGER, +"double_""quotes" INTEGER, +`tick_``quotes` INTEGER, +[bracket_[quotes] INTEGER, +foo INTEGER, +bar INTEGER, +baz INTEGER, +zap INTEGER, +CONSTRAINT no_quotes_idx UNIQUE (no_quotes) +CONSTRAINT duplicate_idx UNIQUE (no_quotes) +CONSTRAINT 'single_''quotes_idx' UNIQUE ('single_''quotes') +CONSTRAINT "double_""quotes_idx" UNIQUE ("double_""quotes") +CONSTRAINT `tick_``quotes_idx` UNIQUE (`tick_``quotes`) +CONSTRAINT [bracket_[quotes_idx] UNIQUE ([bracket_[quotes]) +CONSTraint + a_cat_walked_over_my_keyboard_idx UNIque + ( id ,'foo', + "bar", `baz`, + [zap]) +); +SQL; + $connection->execute($table); + + $table = <<<SQL +CREATE TABLE schema_foreign_key_variations ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +author_id INT(11), +author_name VARCHAR(50), +CONSTRAINT author_fk FOREIGN KEY (author_id) REFERENCES schema_authors (id) ON UPDATE CASCADE ON DELETE RESTRICT +CONSTRAINT multi_col_author_fk FOREIGN KEY (author_id, author_name) REFERENCES schema_authors (id, name) ON UPDATE CASCADE +); +SQL; + $connection->execute($table); + + $sql = <<<SQL +CREATE TABLE schema_composite ( + "id" INTEGER NOT NULL, + "site_id" INTEGER NOT NULL, + "name" VARCHAR(255), + PRIMARY KEY("id", "site_id") +); +SQL; + + $connection->execute($sql); + + $view = <<<SQL +CREATE VIEW view_schema_articles AS + SELECT count(*) as total FROM schema_articles +SQL; + + $connection->execute($view); + } + + /** + * Test SchemaCollection listing tables with Sqlite + */ + public function testListTables(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + $schema = new SchemaCollection($connection); + + $result = $schema->listTables(); + $this->assertIsArray($result); + $this->assertContains('schema_articles', $result); + $this->assertContains('schema_authors', $result); + $this->assertContains('view_schema_articles', $result); + + $resultNoViews = $schema->listTablesWithoutViews(); + $this->assertIsArray($resultNoViews); + $this->assertContains('schema_authors', $resultNoViews); + $this->assertContains('schema_articles', $resultNoViews); + $this->assertNotContains('view_schema_articles', $resultNoViews); + } + + /** + * Test describing a table with Sqlite + */ + public function testDescribeTable(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_articles'); + $expected = [ + 'id' => [ + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'unsigned' => false, + 'autoIncrement' => true, + 'generated' => null, + ], + 'title' => [ + 'type' => 'string', + 'null' => true, + 'default' => "Let 'em eat cake", + 'length' => 20, + 'precision' => null, + 'comment' => null, + 'collate' => null, + ], + 'body' => [ + 'type' => 'text', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'collate' => null, + ], + 'author_id' => [ + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => 11, + 'unsigned' => false, + 'precision' => null, + 'comment' => null, + 'autoIncrement' => false, + 'generated' => null, + ], + 'published' => [ + 'type' => 'boolean', + 'null' => true, + 'default' => 0, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'reviewed' => [ + 'type' => 'boolean', + 'null' => true, + 'default' => 1, + 'length' => null, + 'precision' => null, + 'comment' => null, + ], + 'created' => [ + 'type' => 'datetime', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'onUpdate' => null, + ], + 'field1' => [ + 'type' => 'string', + 'null' => true, + 'default' => null, + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'collate' => null, + ], + 'field2' => [ + 'type' => 'string', + 'null' => true, + 'default' => 'NULL', + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'collate' => null, + ], + 'location' => [ + 'type' => 'point', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'srid' => null, + ], + ]; + $this->assertInstanceOf(TableSchema::class, $result); + $this->assertEquals(['id'], $result->getPrimaryKey()); + foreach ($expected as $field => $definition) { + $testColumn = $result->getColumn($field); + $this->assertNotEmpty($testColumn); + ksort($testColumn); + ksort($definition); + $this->assertSame($definition, $testColumn, "Difference in {$field}"); + } + } + + /** + * Tests SQLite views + */ + public function testDescribeView(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('view_schema_articles'); + $expected = [ + 'total' => [ + 'type' => 'text', + 'length' => null, + 'null' => true, + 'default' => null, + 'collate' => null, + 'precision' => null, + 'comment' => null, + ], + ]; + $this->assertInstanceOf(TableSchema::class, $result); + foreach ($expected as $field => $definition) { + $this->assertSame($definition, $result->getColumn($field)); + } + } + + /** + * Test describing a table with Sqlite and composite keys + * + * Composite keys in SQLite are never autoincrement, and shouldn't be marked + * as such. + */ + public function testDescribeTableCompositeKey(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_composite'); + + $this->assertEquals(['id', 'site_id'], $result->getPrimaryKey()); + $this->assertFalse($result->getColumn('site_id')['autoIncrement'], 'site_id should not be autoincrement'); + $this->assertFalse($result->getColumn('id')['autoIncrement'], 'id should not be autoincrement'); + } + + /** + * Test describing a table with indexes + */ + public function testDescribeTableIndexes(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_articles'); + + // Includes unique and primary keys. + $indexes = $dialect->describeIndexes('schema_articles'); + $this->assertCount(4, $indexes); + + // Compare with the index() API. + foreach ($indexes as $index) { + if ($index['type'] !== TableSchema::INDEX_INDEX) { + continue; + } + $indexObj = $result->index($index['name']); + foreach ($index as $key => $value) { + $this->assertEquals($value, $indexObj->{'get' . ucfirst($key)}()); + } + } + + $foreignKeys = $dialect->describeForeignKeys('schema_articles'); + $this->assertCount(1, $foreignKeys); + + $this->assertInstanceOf(TableSchema::class, $result); + $expected = [ + 'primary' => [ + 'type' => 'primary', + 'columns' => ['id'], + ], + 'title_idx' => [ + 'type' => 'unique', + 'columns' => ['title', 'body'], + 'length' => [], + ], + 'author_fk' => [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['schema_authors', 'id'], + 'update' => 'cascade', + 'delete' => 'restrict', + 'deferrable' => null, + ], + 'unique_id_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'unique_id', + ], + 'length' => [], + ], + 'author_value_chk' => [ + 'type' => 'check', + 'expression' => 'author_id > 0', + ], + ]; + $this->assertCount(5, $result->constraints()); + $this->assertEquals($expected['primary'], $result->getConstraint('primary')); + + $primary = $result->constraint('primary'); + $this->assertInstanceOf(Constraint::class, $primary); + $this->assertSame('primary', $primary->getName()); + $this->assertSame($expected['primary']['columns'], $primary->getColumns()); + + $check = $result->constraint('author_value_chk'); + $this->assertInstanceOf(CheckConstraint::class, $check); + $this->assertSame('author_value_chk', $check->getName()); + $this->assertSame($expected['author_value_chk']['expression'], $check->getExpression()); + + $this->assertEquals( + $expected['author_fk'], + $result->getConstraint('author_fk'), + ); + $expectedAuthorIdFk = $expected['author_fk']; + $expectedAuthorIdFk['name'] = 'author_fk'; + $this->assertEquals($expectedAuthorIdFk, $foreignKeys[0]); + + $foreignKey = $result->constraint('author_fk'); + $this->assertInstanceOf(ForeignKey::class, $foreignKey); + $this->assertSame('author_fk', $foreignKey->getName()); + $this->assertSame($foreignKeys[0]['columns'], $foreignKey->getColumns()); + $this->assertSame($foreignKeys[0]['references'][0], $foreignKey->getReferencedTable()); + $this->assertSame((array)$foreignKeys[0]['references'][1], $foreignKey->getReferencedColumns()); + $this->assertSame($foreignKeys[0]['update'], $foreignKey->getUpdate()); + $this->assertSame($foreignKeys[0]['delete'], $foreignKey->getDelete()); + + $this->assertEquals( + $expected['title_idx'], + $result->getConstraint('title_idx'), + ); + $this->assertEquals($expected['unique_id_idx'], $result->getConstraint('unique_id_idx')); + + // Compare with describeIndexes() & constraint() result + $this->assertEquals($expected['unique_id_idx'] + ['name' => 'unique_id_idx'], $indexes[0]); + $unique = $result->constraint('unique_id_idx'); + $this->assertInstanceOf(Constraint::class, $unique); + $this->assertSame(Constraint::UNIQUE, $unique->getType()); + $this->assertSame('unique_id_idx', $unique->getName()); + $this->assertSame($expected['unique_id_idx']['columns'], $unique->getColumns()); + + $this->assertCount(1, $result->indexes()); + $expected = [ + 'type' => 'index', + 'columns' => ['created'], + 'length' => [], + ]; + $this->assertEquals($expected, $result->getIndex('created_idx')); + + // Compare with describeIndexes result + $expected['name'] = 'created_idx'; + $this->assertEquals($expected, $indexes[1]); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_no_rowid_pk'); + $this->assertInstanceOf(TableSchema::class, $result); + + $this->assertSame(['sqlite_autoindex_schema_no_rowid_pk_1'], $result->constraints()); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_unique_constraint_variations'); + $this->assertInstanceOf(TableSchema::class, $result); + + $expected = [ + 'primary' => [ + 'type' => 'primary', + 'columns' => [ + 'id', + ], + 'length' => [], + ], + 'a_cat_walked_over_my_keyboard_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'id', + 'foo', + 'bar', + 'baz', + 'zap', + ], + 'length' => [], + ], + 'bracket_[quotes_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'bracket_[quotes', + ], + 'length' => [], + ], + 'tick_`quotes_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'tick_`quotes', + ], + 'length' => [], + ], + 'double_"quotes_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'double_"quotes', + ], + 'length' => [], + ], + "single_'quotes_idx" => [ + 'type' => 'unique', + 'columns' => [ + "single_'quotes", + ], + 'length' => [], + ], + 'no_quotes_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'no_quotes', + ], + 'length' => [], + ], + ]; + $this->assertCount(7, $result->constraints()); + foreach ($expected as $name => $attrs) { + $constraint = $result->constraint($name); + $this->assertSame($name, $constraint->getName()); + $this->assertSame($attrs['columns'], $constraint->getColumns()); + } + + // Because all our 'constraints' are unique indexes + // they are treated as indexes by the basic reflection API + $indexes = $dialect->describeIndexes('schema_unique_constraint_variations'); + $this->assertCount(7, $indexes); + foreach ($indexes as $index) { + $name = $index['name']; + $expectedIndex = $expected[$name]; + + // Add the name to the expected data. + $expectedIndex['name'] = $name; + + $this->assertNotEmpty($expectedIndex, 'Could not find expected for ' . $name); + $this->assertEquals($expectedIndex, $index); + } + + // No indexes in the TableSchema API + $this->assertEmpty($result->indexes()); + } + + public function testDescribeIndexesTextPrimaryKey(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $dialect = $connection->getDriver()->schemaDialect(); + + $connection->execute('create table if not exists t(a text primary key)'); + $indexes = $dialect->describeIndexes('t'); + $table = $dialect->describe('t'); + $connection->execute('drop table t'); + + $this->assertCount(1, $indexes); + $primary = $indexes[0]; + $this->assertEquals('sqlite_autoindex_t_1', $primary['name']); + $this->assertEquals(TableSchema::CONSTRAINT_PRIMARY, $primary['type']); + $this->assertEquals(['a'], $primary['columns']); + + $primary = $table->constraint('sqlite_autoindex_t_1'); + $this->assertEquals('sqlite_autoindex_t_1', $primary->getName()); + $this->assertEquals(TableSchema::CONSTRAINT_PRIMARY, $primary->getType()); + $this->assertEquals(['a'], $primary->getColumns()); + } + + /** + * Test describing a table with foreign keys + */ + public function testDescribeTableForeignKeys(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + /** @var \Cake\Database\Schema\SqliteSchemaDialect $dialect */ + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_foreign_key_variations'); + $this->assertInstanceOf(TableSchema::class, $result); + + $expected = [ + 'primary' => [ + 'type' => 'primary', + 'columns' => ['id'], + ], + 'multi_col_author_fk' => [ + 'type' => 'foreign', + 'columns' => [ + 'author_id', + 'author_name', + ], + 'delete' => 'noAction', + 'update' => 'cascade', + 'deferrable' => null, + 'references' => [ + 'schema_authors', + ['id', 'name'], + ], + ], + 'author_fk' => [ + 'type' => 'foreign', + 'columns' => [ + 'author_id', + ], + 'delete' => 'restrict', + 'update' => 'cascade', + 'deferrable' => null, + 'references' => [ + 'schema_authors', + 'id', + ], + ], + ]; + foreach ($expected as $name => $constraint) { + $this->assertSame($constraint, $result->getConstraint($name), "does not match {$name} constraint"); + } + $this->assertCount(3, $result->constraints()); + + $foreignKeys = $dialect->describeForeignKeys('schema_foreign_key_variations'); + $this->assertCount(2, $foreignKeys); + foreach ($foreignKeys as $foreignKey) { + $expectedForeignKey = $expected[$foreignKey['name']]; + $expectedForeignKey['name'] = $foreignKey['name']; + $this->assertEquals($expectedForeignKey, $foreignKey); + + $key = $result->constraint($foreignKey['name']); + assert($key instanceof ForeignKey); + $this->assertSame($expectedForeignKey['name'], $key->getName()); + $this->assertSame($expectedForeignKey['columns'], $key->getColumns()); + $this->assertSame($expectedForeignKey['references'][0], $key->getReferencedTable()); + $this->assertSame((array)$expectedForeignKey['references'][1], $key->getReferencedColumns()); + $this->assertSame($expectedForeignKey['update'], $key->getUpdate()); + $this->assertSame($expectedForeignKey['delete'], $key->getDelete()); + } + } + + public function testDescribeColumns(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + /** @var \Cake\Database\Schema\SqliteSchemaDialect $dialect */ + $dialect = $connection->getDriver()->schemaDialect(); + + $result = $dialect->describeColumns('schema_articles'); + $expected = [ + [ + 'name' => 'id', + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => null, + 'comment' => null, + 'unsigned' => false, + 'autoIncrement' => true, + ], + [ + 'name' => 'title', + 'type' => 'string', + 'null' => true, + 'default' => "Let 'em eat cake", + 'length' => 20, + 'comment' => null, + ], + [ + 'name' => 'body', + 'type' => 'text', + 'null' => true, + 'default' => null, + 'length' => null, + 'comment' => null, + ], + [ + 'name' => 'author_id', + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => 11, + 'unsigned' => false, + 'comment' => null, + ], + [ + 'name' => 'unique_id', + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => null, + 'unsigned' => true, + 'comment' => null, + ], + [ + 'name' => 'published', + 'type' => 'boolean', + 'null' => true, + 'default' => 0, + 'length' => null, + 'comment' => null, + ], + [ + 'name' => 'reviewed', + 'type' => 'boolean', + 'null' => true, + 'default' => true, + 'length' => null, + 'comment' => null, + ], + [ + 'name' => 'created', + 'type' => 'datetime', + 'null' => true, + 'default' => null, + 'length' => null, + 'comment' => null, + ], + [ + 'name' => 'field1', + 'type' => 'string', + 'null' => true, + 'default' => null, + 'length' => 10, + 'comment' => null, + ], + [ + 'name' => 'field2', + 'type' => 'string', + 'null' => true, + 'default' => 'NULL', + 'length' => 10, + 'comment' => null, + ], + [ + 'name' => 'location', + 'type' => 'point', + 'null' => true, + 'default' => null, + 'length' => null, + 'comment' => null, + ], + ]; + $this->assertEquals($expected, $result); + + // Test overlap with TableSchema + $schema = $dialect->describe('schema_articles'); + foreach ($expected as $field) { + $schemaField = $schema->getColumn($field['name']); + $schemaAttrs = array_intersect_key($schemaField, $field); + $expectedAttrs = array_intersect_key($field, $schemaAttrs); + $this->assertEquals($expectedAttrs, $schemaAttrs); + + // Integration test for column() method. + $col = $schema->column($field['name']); + $this->assertEquals($field['type'], $col->getType()); + $this->assertEquals($field['null'], $col->getNull()); + $this->assertEquals($field['length'], $col->getLength()); + $this->assertEquals($field['default'], $col->getDefault()); + $this->assertEquals($field['comment'], $col->getComment()); + if (isset($field['autoIncrement'])) { + $this->assertEquals($field['autoIncrement'], $col->getIdentity()); + } else { + $this->assertFalse($col->getIdentity()); + } + } + } + + public function testDescribeTableCheckConstraints(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_articles'); + + $constraint = $result->getConstraint('author_value_chk'); + $this->assertSame('author_id > 0', $constraint['expression']); + + $constraint = $result->constraint('author_value_chk'); + assert($constraint instanceof CheckConstraint); + $this->assertSame('author_value_chk', $constraint->getName()); + $this->assertSame('author_id > 0', $constraint->getExpression()); + } + + /** + * Column provider for creating column sql + * + * @return array + */ + public static function columnSqlProvider(): array + { + return [ + // Unknown column type is preserved. + [ + 'title', + ['type' => 'foobar', 'length' => 25, 'null' => true, 'default' => null], + '"title" FOOBAR(25)', + ], + // strings + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => false], + '"title" VARCHAR(25) NOT NULL', + ], + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => true, 'default' => 'ignored'], + '"title" VARCHAR(25) DEFAULT "ignored"', + ], + [ + 'id', + ['type' => 'string', 'length' => 32, 'null' => false], + '"id" VARCHAR(32) NOT NULL', + ], + [ + 'role', + ['type' => 'string', 'length' => 10, 'null' => false, 'default' => 'admin'], + '"role" VARCHAR(10) NOT NULL DEFAULT "admin"', + ], + [ + 'title', + ['type' => 'string'], + '"title" VARCHAR', + ], + [ + 'id', + ['type' => 'uuid'], + '"id" CHAR(36)', + ], + [ + 'id', + ['type' => 'binaryuuid'], + '"id" BINARY(16)', + ], + // Text + [ + 'body', + ['type' => 'text', 'null' => false, 'comment' => 'a comment'], + '"body" TEXT NOT NULL /* a comment */', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_TINY, 'null' => false], + '"body" VARCHAR(' . TableSchema::LENGTH_TINY . ') NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_MEDIUM, 'null' => false], + '"body" TEXT NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_LONG, 'null' => false], + '"body" TEXT NOT NULL', + ], + // Integers + [ + 'post_id', + ['type' => 'smallinteger', 'length' => 5, 'unsigned' => false], + '"post_id" SMALLINT(5)', + ], + [ + 'post_id', + ['type' => 'smallinteger', 'length' => 5, 'unsigned' => true], + '"post_id" UNSIGNED SMALLINT(5)', + ], + [ + 'post_id', + ['type' => 'tinyinteger', 'length' => 3, 'unsigned' => false], + '"post_id" TINYINT(3)', + ], + [ + 'post_id', + ['type' => 'tinyinteger', 'length' => 3, 'unsigned' => true], + '"post_id" UNSIGNED TINYINT(3)', + ], + [ + 'post_id', + ['type' => 'integer', 'length' => 11, 'unsigned' => false], + '"post_id" INTEGER(11)', + ], + [ + 'post_id', + ['type' => 'integer', 'length' => 11, 'unsigned' => true], + '"post_id" UNSIGNED INTEGER(11)', + ], + [ + 'post_id', + ['type' => 'biginteger', 'length' => 20, 'unsigned' => false], + '"post_id" BIGINT', + ], + [ + 'post_id', + ['type' => 'biginteger', 'length' => 20, 'unsigned' => true], + '"post_id" UNSIGNED BIGINT', + ], + // Decimal + [ + 'value', + ['type' => 'decimal', 'unsigned' => false], + '"value" DECIMAL', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 11, 'unsigned' => false], + '"value" DECIMAL(11,0)', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 11, 'unsigned' => true], + '"value" UNSIGNED DECIMAL(11,0)', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 12, 'precision' => 5, 'unsigned' => false], + '"value" DECIMAL(12,5)', + ], + // Float + [ + 'value', + ['type' => 'float'], + '"value" FLOAT', + ], + [ + 'value', + ['type' => 'float', 'length' => 11, 'precision' => 3, 'unsigned' => false], + '"value" FLOAT(11,3)', + ], + [ + 'value', + ['type' => 'float', 'length' => 11, 'precision' => 3, 'unsigned' => true], + '"value" UNSIGNED FLOAT(11,3)', + ], + // Boolean + [ + 'checked', + ['type' => 'boolean', 'null' => true, 'default' => false], + '"checked" BOOLEAN DEFAULT FALSE', + ], + [ + 'checked', + ['type' => 'boolean', 'default' => true, 'null' => false], + '"checked" BOOLEAN NOT NULL DEFAULT TRUE', + ], + // datetimes + [ + 'created', + ['type' => 'datetime'], + '"created" DATETIME', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => '2016-12-07 23:04:00'], + '"open_date" DATETIME NOT NULL DEFAULT "2016-12-07 23:04:00"', + ], + [ + 'created', + ['type' => 'datetime', 'default' => new QueryExpression('CURRENT_TIMESTAMP')], + '"created" DATETIME DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'created', + ['type' => 'datetime', 'default' => new QueryExpression('now()')], + '"created" DATETIME DEFAULT now()', + ], + // Date & Time + [ + 'start_date', + ['type' => 'date'], + '"start_date" DATE', + ], + [ + 'start_time', + ['type' => 'time'], + '"start_time" TIME', + ], + // timestamps + [ + 'created', + ['type' => 'timestamp', 'null' => true], + '"created" TIMESTAMP DEFAULT NULL', + ], + // Geospatial types + [ + 'g', + ['type' => 'geometry'], + '"g" GEOMETRY_TEXT', + ], + [ + 'g', + ['type' => 'geometry', 'null' => false, 'srid' => 4326], + '"g" GEOMETRY_TEXT NOT NULL', + ], + [ + 'p', + ['type' => 'point'], + '"p" POINT_TEXT', + ], + [ + 'p', + ['type' => 'point', 'null' => false, 'srid' => 4326], + '"p" POINT_TEXT NOT NULL', + ], + [ + 'l', + ['type' => 'linestring'], + '"l" LINESTRING_TEXT', + ], + [ + 'l', + ['type' => 'linestring', 'null' => false, 'srid' => 4326], + '"l" LINESTRING_TEXT NOT NULL', + ], + [ + 'p', + ['type' => 'polygon'], + '"p" POLYGON_TEXT', + ], + [ + 'p', + ['type' => 'polygon', 'null' => false, 'srid' => 4326], + '"p" POLYGON_TEXT NOT NULL', + ], + ]; + } + + /** + * Test the addConstraintSql method. + */ + public function testAddConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('posts'); + + $result = $table->addConstraintSql($connection); + $this->assertEmpty($result); + } + + /** + * Test the dropConstraintSql method. + */ + public function testDropConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('posts'); + $result = $table->dropConstraintSql($connection); + $this->assertEmpty($result); + } + + /** + * Test generating column definitions + */ + #[DataProvider('columnSqlProvider')] + public function testColumnSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $dialect = new SqliteSchemaDialect($driver); + + $table = (new TableSchema('articles'))->addColumn($name, $data); + $this->assertEquals($expected, $dialect->columnSql($table, $name)); + + $data['name'] = $name; + $this->assertEquals($expected, $dialect->columnDefinitionSql($data)); + } + + /** + * Test generating a column that is a primary key. + */ + public function testColumnSqlPrimaryKey(): void + { + $driver = $this->_getMockedDriver(); + $schema = new SqliteSchemaDialect($driver); + + $table = new TableSchema('articles'); + $table->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + 'length' => 11, + 'unsigned' => true, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + $result = $schema->columnSql($table, 'id'); + $this->assertSame('"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT', $result); + + $result = $schema->constraintSql($table, 'primary'); + $this->assertSame('', $result, 'Integer primary keys are special in sqlite.'); + } + + /** + * Test generating a bigint column that is a primary key. + */ + public function testColumnSqlPrimaryKeyBigInt(): void + { + $driver = $this->_getMockedDriver(); + $schema = new SqliteSchemaDialect($driver); + + $table = new TableSchema('articles'); + $table->addColumn('id', [ + 'type' => 'biginteger', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + $result = $schema->columnSql($table, 'id'); + $this->assertSame($result, '"id" BIGINT NOT NULL'); + + $result = $schema->constraintSql($table, 'primary'); + $this->assertSame('CONSTRAINT "primary" PRIMARY KEY ("id")', $result, 'Bigint primary keys are not special.'); + } + + /** + * Provide data for testing constraintSql + * + * @return array + */ + public static function constraintSqlProvider(): array + { + return [ + [ + 'primary', + ['type' => 'primary', 'columns' => ['title']], + 'CONSTRAINT "primary" PRIMARY KEY ("title")', + ], + [ + 'unique_idx', + ['type' => 'unique', 'columns' => ['title', 'author_id']], + 'CONSTRAINT "unique_idx" UNIQUE ("title", "author_id")', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id']], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE RESTRICT ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'cascade'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE CASCADE ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'restrict'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE RESTRICT ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'setNull'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE SET NULL ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'noAction'], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT', + ], + [ + 'author_id_idx', + [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'noAction', + 'deferrable' => ForeignKey::DEFERRED, + ], + 'CONSTRAINT "author_id_idx" FOREIGN KEY ("author_id") ' . + 'REFERENCES "authors" ("id") ON UPDATE NO ACTION ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED', + ], + [ + 'author_id_check', + ['type' => 'check', 'expression' => 'author_id > 0'], + 'CONSTRAINT "author_id_check" CHECK (author_id > 0)', + ], + ]; + } + + /** + * Test the constraintSql method. + */ + #[DataProvider('constraintSqlProvider')] + public function testConstraintSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new SqliteSchemaDialect($driver); + + $table = (new TableSchema('articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addConstraint($name, $data); + + $this->assertEquals($expected, $schema->constraintSql($table, $name)); + } + + /** + * Provide data for testing indexSql + * + * @return array + */ + public static function indexSqlProvider(): array + { + return [ + [ + 'author_idx', + ['type' => 'index', 'columns' => ['title', 'author_id']], + 'CREATE INDEX "author_idx" ON "articles" ("title", "author_id")', + ], + ]; + } + + /** + * Test the indexSql method. + */ + #[DataProvider('indexSqlProvider')] + public function testIndexSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new SqliteSchemaDialect($driver); + + $table = (new TableSchema('articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addIndex($name, $data); + + $this->assertEquals($expected, $schema->indexSql($table, $name)); + } + + /** + * Integration test for converting a Schema\Table into MySQL table creates. + */ + public function testCreateSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('articles'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('title', [ + 'type' => 'string', + 'null' => false, + ]) + ->addColumn('body', ['type' => 'text']) + ->addColumn('data', ['type' => 'json']) + ->addColumn('created', 'datetime') + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]) + ->addIndex('title_idx', [ + 'type' => 'index', + 'columns' => ['title'], + ]); + + $expected = <<<SQL +CREATE TABLE "articles" ( +"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, +"title" VARCHAR NOT NULL, +"body" TEXT, +"data" TEXT, +"created" DATETIME +) +SQL; + $result = $table->createSql($connection); + $this->assertCount(2, $result); + $this->assertTextEquals($expected, $result[0]); + $this->assertSame( + 'CREATE INDEX "title_idx" ON "articles" ("title")', + $result[1], + ); + } + + /** + * Tests creating temporary tables + */ + public function testCreateTemporary(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + $table = (new TableSchema('schema_articles'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]); + $table->setTemporary(true); + $sql = $table->createSql($connection); + $this->assertStringContainsString('CREATE TEMPORARY TABLE', $sql[0]); + } + + /** + * Test primary key generation & auto-increment. + */ + public function testCreateSqlCompositeIntegerKey(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('articles_tags')) + ->addColumn('article_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('tag_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['article_id', 'tag_id'], + ]); + + $expected = <<<SQL +CREATE TABLE "articles_tags" ( +"article_id" INTEGER NOT NULL, +"tag_id" INTEGER NOT NULL, +CONSTRAINT "primary" PRIMARY KEY ("article_id", "tag_id") +) +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + + // Sqlite only supports AUTO_INCREMENT on single column primary + // keys. Ensure that schema data follows the limitations of Sqlite. + $table = (new TableSchema('composite_key')) + ->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + 'autoIncrement' => true, + ]) + ->addColumn('account_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id', 'account_id'], + ]); + + $expected = <<<SQL +CREATE TABLE "composite_key" ( +"id" INTEGER NOT NULL, +"account_id" INTEGER NOT NULL, +CONSTRAINT "primary" PRIMARY KEY ("id", "account_id") +) +SQL; + $result = $table->createSql($connection); + $this->assertCount(1, $result); + $this->assertTextEquals($expected, $result[0]); + } + + /** + * test dropSql + */ + public function testDropSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('articles'); + $result = $table->dropSql($connection); + $this->assertCount(1, $result); + $this->assertSame('DROP TABLE "articles"', $result[0]); + } + + /** + * Test truncateSql() + */ + public function testTruncateSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $statement = Mockery::mock(PDOStatement::class); + $this->pdo->shouldReceive('prepare') + ->with('SELECT 1 FROM sqlite_master WHERE name = "sqlite_sequence"') + ->once() + ->andReturn($statement); + $statement->shouldReceive('fetch') + ->once() + ->andReturn(['1']); + $statement->shouldReceive('execute')->andReturn(true); + + $table = new TableSchema('articles'); + $result = $table->truncateSql($connection); + $this->assertCount(2, $result); + $this->assertSame('DELETE FROM sqlite_sequence WHERE name="articles"', $result[0]); + $this->assertSame('DELETE FROM "articles"', $result[1]); + } + + /** + * Test truncateSql() with no sequences + */ + public function testTruncateSqlNoSequences(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $statement = Mockery::mock(PDOStatement::class); + $this->pdo->shouldReceive('prepare') + ->with('SELECT 1 FROM sqlite_master WHERE name = "sqlite_sequence"') + ->once() + ->andReturn($statement); + $statement->shouldReceive('fetch') + ->once() + ->andReturn(false); + $statement->shouldReceive('execute')->andReturn(true); + + $table = new TableSchema('articles'); + $result = $table->truncateSql($connection); + $this->assertCount(1, $result); + $this->assertSame('DELETE FROM "articles"', $result[0]); + } + + /** + * Get a schema instance with a mocked driver/pdo instances + */ + protected function _getMockedDriver(): Driver + { + $this->_needsConnection(); + + $this->pdo = Mockery::mock(PDO::class); + $this->pdo->shouldReceive('quote') + ->andReturnUsing(function ($value) { + return '"' . $value . '"'; + }); + + $driver = Mockery::mock(Sqlite::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + + $driver->shouldReceive('createPdo') + ->andReturn($this->pdo); + + $driver->connect(); + + return $driver; + } +} diff --git a/tests/TestCase/Database/Schema/SqlserverSchemaDialectTest.php b/tests/TestCase/Database/Schema/SqlserverSchemaDialectTest.php new file mode 100644 index 00000000000..84fe8cee517 --- /dev/null +++ b/tests/TestCase/Database/Schema/SqlserverSchemaDialectTest.php @@ -0,0 +1,1419 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Connection; +use Cake\Database\Driver; +use Cake\Database\Driver\Sqlserver; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\Schema\Collection as SchemaCollection; +use Cake\Database\Schema\SqlserverSchemaDialect; +use Cake\Database\Schema\TableSchema; +use Cake\Datasource\ConnectionInterface; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use Mockery; +use PDO; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * SQL Server schema test case. + */ +class SqlserverSchemaDialectTest extends TestCase +{ + /** + * Helper method for skipping tests that need a real connection. + */ + protected function _needsConnection(): void + { + $config = ConnectionManager::getConfig('test'); + $this->skipIf(!str_contains($config['driver'], 'Sqlserver'), 'Not using Sqlserver for test config'); + } + + /** + * Helper method for testing methods. + */ + protected function _createTables(ConnectionInterface $connection): void + { + $this->_needsConnection(); + + $connection->execute("IF OBJECT_ID('schema_articles_v', 'V') IS NOT NULL DROP VIEW schema_articles_v"); + $connection->execute("IF OBJECT_ID('schema_articles', 'U') IS NOT NULL DROP TABLE schema_articles"); + $connection->execute("IF OBJECT_ID('schema_authors', 'U') IS NOT NULL DROP TABLE schema_authors"); + + $table = <<<SQL +CREATE TABLE schema_authors ( +id int IDENTITY(1,1) PRIMARY KEY, +name VARCHAR(50), +bio DATE, +created DATETIME +) +SQL; + $connection->execute($table); + + $table = <<<SQL +CREATE TABLE schema_articles ( +id BIGINT PRIMARY KEY, +title NVARCHAR(20) COLLATE Japanese_Unicode_CI_AI DEFAULT N'無題' COLLATE Japanese_Unicode_CI_AI, +body NVARCHAR(1000) DEFAULT N'本文なし', +author_id INTEGER NOT NULL, +unique_id INTEGER NOT NULL, +published BIT DEFAULT 0, +views SMALLINT DEFAULT 0, +created DATETIME, +created2 DATETIME2, +created2_with_default DATETIME2 DEFAULT SYSDATETIME(), +created2_with_precision DATETIME2(3), +created2_without_precision DATETIME2(0), +field1 VARCHAR(10) DEFAULT NULL, +field2 VARCHAR(10) DEFAULT 'NULL', +field3 VARCHAR(10) DEFAULT 'O''hare', +CONSTRAINT [content_idx] UNIQUE ([title], [body]), +CONSTRAINT [author_idx] FOREIGN KEY ([author_id]) REFERENCES [schema_authors] ([id]) ON DELETE CASCADE ON UPDATE CASCADE, +INDEX [unique_id_idx] UNIQUE ([unique_id]) +) +SQL; + $connection->execute($table); + + $comment = <<<SQL + EXECUTE sp_addextendedproperty + N'MS_Description', N'is published or not', + N'SCHEMA', N'dbo', + N'TABLE', N'schema_articles', + N'COLUMN', N'published'; +SQL; + $connection->execute($comment); + $connection->execute('CREATE INDEX [author_idx] ON [schema_articles] ([author_id])'); + + $table = <<<SQL +CREATE VIEW schema_articles_v AS +SELECT * FROM schema_articles +SQL; + $connection->execute($table); + } + + /** + * Data provider for convert column testing + * + * @return array + */ + public static function convertColumnProvider(): array + { + return [ + [ + 'DATETIME', + null, + null, + 3, + ['type' => 'datetime', 'length' => null, 'precision' => null], + ], + [ + 'DATETIME2', + null, + null, + 7, + ['type' => 'datetimefractional', 'length' => null, 'precision' => 7], + ], + [ + 'DATETIME2', + null, + null, + 0, + ['type' => 'datetime', 'length' => null, 'precision' => 0], + ], + [ + 'DATE', + null, + null, + null, + ['type' => 'date', 'length' => null], + ], + [ + 'TIME', + null, + null, + null, + ['type' => 'time', 'length' => null], + ], + [ + 'TINYINT', + null, + 2, + null, + ['type' => 'tinyinteger', 'length' => 2], + ], + [ + 'TINYINT', + null, + null, + null, + ['type' => 'tinyinteger', 'length' => 3], + ], + [ + 'SMALLINT', + null, + 3, + null, + ['type' => 'smallinteger', 'length' => 3], + ], + [ + 'SMALLINT', + null, + null, + null, + ['type' => 'smallinteger', 'length' => 5], + ], + [ + 'INTEGER', + null, + null, + null, + ['type' => 'integer', 'length' => 10], + ], + [ + 'INTEGER', + null, + 8, + null, + ['type' => 'integer', 'length' => 8], + ], + [ + 'BIGINT', + null, + null, + null, + ['type' => 'biginteger', 'length' => 20], + ], + [ + 'NUMERIC', + null, + 15, + 5, + ['type' => 'decimal', 'length' => 15, 'precision' => 5], + ], + [ + 'DECIMAL', + null, + 11, + 3, + ['type' => 'decimal', 'length' => 11, 'precision' => 3], + ], + [ + 'MONEY', + null, + null, + null, + ['type' => 'decimal', 'length' => null, 'precision' => null], + ], + [ + 'VARCHAR', + null, + null, + null, + ['type' => 'string', 'length' => 255, 'collate' => 'Japanese_Unicode_CI_AI'], + ], + [ + 'VARCHAR', + 10, + null, + null, + ['type' => 'string', 'length' => 10, 'collate' => 'Japanese_Unicode_CI_AI'], + ], + [ + 'NVARCHAR', + 50, + null, + null, + // Sqlserver returns double lengths for unicode columns + ['type' => 'string', 'length' => 25, 'collate' => 'Japanese_Unicode_CI_AI'], + ], + [ + 'CHAR', + 10, + null, + null, + ['type' => 'char', 'length' => 10, 'collate' => 'Japanese_Unicode_CI_AI'], + ], + [ + 'NCHAR', + 10, + null, + null, + // SQLServer returns double length for unicode columns. + ['type' => 'char', 'length' => 5, 'collate' => 'Japanese_Unicode_CI_AI'], + ], + [ + 'UNIQUEIDENTIFIER', + null, + null, + null, + ['type' => 'uuid'], + ], + [ + 'TEXT', + null, + null, + null, + ['type' => 'text', 'length' => null, 'collate' => 'Japanese_Unicode_CI_AI'], + ], + [ + 'REAL', + null, + null, + null, + ['type' => 'float', 'length' => null], + ], + [ + 'VARCHAR', + -1, + null, + null, + ['type' => 'text', 'length' => null, 'collate' => 'Japanese_Unicode_CI_AI'], + ], + [ + 'IMAGE', + 10, + null, + null, + ['type' => 'binary', 'length' => 10], + ], + [ + 'BINARY', + 20, + null, + null, + ['type' => 'binary', 'length' => 20], + ], + [ + 'VARBINARY', + 30, + null, + null, + ['type' => 'binary', 'length' => 30], + ], + [ + 'VARBINARY', + -1, + null, + null, + ['type' => 'binary', 'length' => TableSchema::LENGTH_LONG], + ], + // Geospatial types + [ + 'GEOMETRY', + null, + null, + null, + ['type' => 'geometry', 'null' => true], + ], + [ + 'GEOGRAPHY', + null, + null, + null, + ['type' => 'point', 'null' => true], + ], + ]; + } + + /** + * Test parsing Sqlserver column types from field description. + */ + #[DataProvider('convertColumnProvider')] + public function testConvertColumn(string $type, ?int $length, ?int $precision, ?int $scale, array $expected): void + { + $field = [ + 'name' => 'field', + 'type' => $type, + 'null' => '1', + 'default' => 'Default value', + 'char_length' => $length, + 'precision' => $precision, + 'scale' => $scale, + 'collation_name' => 'Japanese_Unicode_CI_AI', + ]; + $expected += [ + 'null' => true, + 'default' => 'Default value', + ]; + + $driver = $this->createStub(Sqlserver::class); + $dialect = new SqlserverSchemaDialect($driver); + + $table = new TableSchema('table'); + $dialect->convertColumnDescription($table, $field); + + $actual = array_intersect_key($table->getColumn('field'), $expected); + ksort($expected); + ksort($actual); + $this->assertSame($expected, $actual); + } + + /** + * Test listing tables with Sqlserver + */ + public function testListTables(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + $schema = new SchemaCollection($connection); + + $result = $schema->listTables(); + $this->assertIsArray($result); + $this->assertContains('schema_articles', $result); + $this->assertContains('schema_articles_v', $result); + $this->assertContains('schema_authors', $result); + + $resultNoViews = $schema->listTablesWithoutViews(); + $this->assertIsArray($resultNoViews); + $this->assertNotContains('schema_articles_v', $resultNoViews); + $this->assertContains('schema_articles', $resultNoViews); + } + + /** + * Test describing a table with Sqlserver + */ + public function testDescribeTable(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_articles'); + $expected = [ + 'id' => [ + 'type' => 'biginteger', + 'null' => false, + 'default' => null, + 'length' => 19, + 'precision' => null, + 'unsigned' => null, + 'comment' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'title' => [ + 'type' => 'string', + 'null' => true, + 'default' => '無題', + 'length' => 20, + 'precision' => null, + 'comment' => null, + 'collate' => 'Japanese_Unicode_CI_AI', + ], + 'body' => [ + 'type' => 'string', + 'null' => true, + 'default' => '本文なし', + 'length' => 1000, + 'precision' => null, + 'comment' => null, + 'collate' => 'SQL_Latin1_General_CP1_CI_AS', + ], + 'author_id' => [ + 'type' => 'integer', + 'null' => false, + 'default' => null, + 'length' => 10, + 'precision' => null, + 'unsigned' => null, + 'comment' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'unique_id' => [ + 'type' => 'integer', + 'null' => false, + 'unsigned' => null, + 'default' => null, + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'autoIncrement' => null, + 'generated' => null, + ], + 'published' => [ + 'type' => 'boolean', + 'null' => true, + 'default' => 0, + 'length' => null, + 'precision' => null, + 'comment' => 'is published or not', + ], + 'views' => [ + 'type' => 'smallinteger', + 'null' => true, + 'default' => 0, + 'length' => 5, + 'precision' => null, + 'unsigned' => null, + 'comment' => null, + 'autoIncrement' => null, + ], + 'created' => [ + 'type' => 'datetime', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => null, + 'comment' => null, + 'onUpdate' => null, + ], + 'created2' => [ + 'type' => 'datetimefractional', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 7, + 'comment' => null, + 'onUpdate' => null, + ], + 'created2_with_default' => [ + 'type' => 'datetimefractional', + 'null' => true, + 'default' => 'sysdatetime()', + 'length' => null, + 'precision' => 7, + 'comment' => null, + 'onUpdate' => null, + ], + 'created2_with_precision' => [ + 'type' => 'datetimefractional', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 3, + 'comment' => null, + 'onUpdate' => null, + ], + 'created2_without_precision' => [ + 'type' => 'datetime', + 'null' => true, + 'default' => null, + 'length' => null, + 'precision' => 0, + 'comment' => null, + 'onUpdate' => null, + ], + 'field1' => [ + 'type' => 'string', + 'null' => true, + 'default' => null, + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'collate' => 'SQL_Latin1_General_CP1_CI_AS', + ], + 'field2' => [ + 'type' => 'string', + 'null' => true, + 'default' => 'NULL', + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'collate' => 'SQL_Latin1_General_CP1_CI_AS', + ], + 'field3' => [ + 'type' => 'string', + 'null' => true, + 'default' => "O'hare", + 'length' => 10, + 'precision' => null, + 'comment' => null, + 'collate' => 'SQL_Latin1_General_CP1_CI_AS', + ], + ]; + $this->assertEquals(['id'], $result->getPrimaryKey()); + foreach ($expected as $field => $definition) { + $column = $result->getColumn($field); + $this->assertEquals($definition, $column, 'Failed to match field ' . $field); + $this->assertSame($definition['length'], $column['length']); + $this->assertSame($definition['precision'], $column['precision']); + } + + // Compare with describeColumns() + $columns = $dialect->describeColumns('schema_articles'); + foreach ($columns as $column) { + $name = $column['name']; + $this->assertArrayHasKey($name, $expected); + $expectedItem = $expected[$name]; + $expectedFields = array_intersect_key($expectedItem, $column); + $resultFields = array_intersect_key($column, $expectedFields); + + $this->assertNotEmpty($resultFields); + $this->assertEquals($expectedFields, $resultFields); + } + } + + /** + * Test describing a table with postgres and composite keys + */ + public function testDescribeTableCompositeKey(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $sql = <<<SQL +CREATE TABLE schema_composite ( + [id] INTEGER IDENTITY(1, 1), + [site_id] INTEGER NOT NULL, + [name] VARCHAR(255), + PRIMARY KEY([id], [site_id]) +); +SQL; + $connection->execute($sql); + $schema = new SchemaCollection($connection); + $result = $schema->describe('schema_composite'); + $connection->execute('DROP TABLE schema_composite'); + + $this->assertEquals(['id', 'site_id'], $result->getPrimaryKey()); + $this->assertFalse($result->getColumn('site_id')['autoIncrement'], 'site_id should not be autoincrement'); + $this->assertTrue($result->getColumn('id')['autoIncrement'], 'id should be autoincrement'); + } + + /** + * Test that describe accepts tablenames containing `schema.table`. + */ + public function testDescribeWithSchemaName(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $schema = new SchemaCollection($connection); + $result = $schema->describe('dbo.schema_articles'); + $this->assertEquals(['id'], $result->getPrimaryKey()); + $this->assertSame('schema_articles', $result->name()); + } + + /** + * Test describing a table with indexes + */ + public function testDescribeTableIndexes(): void + { + $connection = ConnectionManager::get('test'); + $this->_createTables($connection); + + $dialect = $connection->getDriver()->schemaDialect(); + $result = $dialect->describe('schema_articles'); + + $this->assertInstanceOf(TableSchema::class, $result); + $this->assertCount(4, $result->constraints()); + $expected = [ + 'primary' => [ + 'type' => 'primary', + 'columns' => ['id'], + ], + 'content_idx' => [ + 'type' => 'unique', + 'columns' => ['title', 'body'], + 'length' => [], + ], + 'author_idx' => [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['schema_authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + 'deferrable' => null, + ], + 'unique_id_idx' => [ + 'type' => 'unique', + 'columns' => [ + 'unique_id', + ], + 'length' => [], + ], + ]; + $primary = $result->getConstraint('primary'); + $this->assertTrue(str_starts_with($primary['constraint'], 'PK__schema_a__')); + unset($primary['constraint']); + $this->assertEquals($expected['primary'], $primary); + + $this->assertEquals($expected['content_idx'], $result->getConstraint('content_idx')); + $this->assertEquals($expected['author_idx'], $result->getConstraint('author_idx')); + $this->assertEquals($expected['unique_id_idx'], $result->getConstraint('unique_id_idx')); + + // Compare describeForeignKeys() + $keys = $dialect->describeForeignKeys('schema_articles'); + $this->assertCount(1, $keys); + foreach ($keys as $foreignKey) { + $name = $foreignKey['name']; + $this->assertArrayHasKey($name, $expected); + $expectedItem = $expected[$name]; + $expectedFields = array_intersect_key($expectedItem, $foreignKey); + $resultFields = array_intersect_key($foreignKey, $expectedFields); + + $this->assertNotEmpty($resultFields); + $this->assertEquals($expectedFields, $resultFields); + } + + $this->assertCount(1, $result->indexes()); + $authorIdx = [ + 'type' => 'index', + 'columns' => ['author_id'], + 'length' => [], + ]; + $this->assertEquals($authorIdx, $result->getIndex('author_idx')); + + // Compare with describeIndexes() which includes indexes + uniques + $expected['author_idx'] = $authorIdx; + + $indexes = $dialect->describeIndexes('schema_articles'); + $this->assertCount(4, $indexes); + foreach ($indexes as $index) { + $name = $index['name']; + $this->assertArrayHasKey($name, $expected); + $expectedItem = $expected[$name]; + $expectedFields = array_intersect_key($expectedItem, $index); + $resultFields = array_intersect_key($index, $expectedFields); + + if (isset($index['constraint'])) { + $this->assertStringStartsWith('PK__schema', $index['constraint']); + } + + $this->assertNotEmpty($resultFields); + $this->assertEquals($expectedFields, $resultFields); + } + } + + /** + * Ensure that included columns are included in reflection results + */ + public function testDescribeIndexIncludedFields(): void + { + $this->_needsConnection(); + $connection = ConnectionManager::get('test'); + $sql = <<<SQL +CREATE TABLE schema_index_include ( + "id" INTEGER NOT NULL, + "site_id" INTEGER NOT NULL, + "name" VARCHAR(255), + PRIMARY KEY("id") +); +SQL; + $connection->execute($sql); + + $sql = 'CREATE INDEX [site_id_name] ON [schema_index_include] ([site_id]) INCLUDE ([name])'; + $connection->execute($sql); + + $dialect = new SqlserverSchemaDialect($connection->getDriver()); + $indexExists = $dialect->hasIndex('schema_index_include', ['site_id']); + $indexExistsName = $dialect->hasIndex('schema_index_include', name: 'site_id_name'); + $indexes = $dialect->describeIndexes('schema_index_include'); + $connection->execute('DROP TABLE schema_index_include'); + + $this->assertTrue($indexExists); + $this->assertTrue($indexExistsName); + $this->assertCount(2, $indexes); + $this->assertEquals(['id'], $indexes[0]['columns']); + $this->assertEquals(['site_id'], $indexes[1]['columns']); + $this->assertEquals(['name'], $indexes[1]['include']); + } + + /** + * Column provider for creating column sql + * + * @return array + */ + public static function columnSqlProvider(): array + { + return [ + // Unknown column type is preserved. + [ + 'title', + ['type' => 'foobar', 'length' => 25, 'null' => true, 'default' => null], + '[title] FOOBAR(25) DEFAULT NULL', + ], + // strings + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => false], + '[title] NVARCHAR(25) NOT NULL', + ], + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => true, 'default' => 'ignored'], + "[title] NVARCHAR(25) DEFAULT 'ignored'", + ], + [ + 'id', + ['type' => 'char', 'length' => 16, 'null' => false], + '[id] NCHAR(16) NOT NULL', + ], + [ + 'id', + ['type' => 'uuid', 'null' => false], + '[id] UNIQUEIDENTIFIER NOT NULL', + ], + [ + 'id', + ['type' => 'nativeuuid', 'null' => false], + '[id] UNIQUEIDENTIFIER NOT NULL', + ], + [ + 'id', + ['type' => 'binaryuuid', 'null' => false], + '[id] UNIQUEIDENTIFIER NOT NULL', + ], + [ + 'role', + ['type' => 'string', 'length' => 10, 'null' => false, 'default' => 'admin'], + "[role] NVARCHAR(10) NOT NULL DEFAULT 'admin'", + ], + [ + 'title', + ['type' => 'string'], + '[title] NVARCHAR(255)', + ], + [ + 'title', + ['type' => 'string', 'length' => 25, 'null' => false, 'collate' => 'Japanese_Unicode_CI_AI'], + '[title] NVARCHAR(25) COLLATE Japanese_Unicode_CI_AI NOT NULL', + ], + // Text + [ + 'body', + ['type' => 'text', 'null' => false], + '[body] NVARCHAR(MAX) NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_TINY, 'null' => false], + sprintf('[body] NVARCHAR(%s) NOT NULL', TableSchema::LENGTH_TINY), + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_MEDIUM, 'null' => false], + '[body] NVARCHAR(MAX) NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'length' => TableSchema::LENGTH_LONG, 'null' => false], + '[body] NVARCHAR(MAX) NOT NULL', + ], + [ + 'body', + ['type' => 'text', 'null' => false, 'collate' => 'Japanese_Unicode_CI_AI'], + '[body] NVARCHAR(MAX) COLLATE Japanese_Unicode_CI_AI NOT NULL', + ], + // Integers + [ + 'post_id', + ['type' => 'smallinteger', 'length' => 11], + '[post_id] SMALLINT', + ], + [ + 'post_id', + ['type' => 'tinyinteger', 'length' => 11], + '[post_id] TINYINT', + ], + [ + 'post_id', + ['type' => 'integer', 'length' => 11], + '[post_id] INTEGER', + ], + [ + 'post_id', + ['type' => 'biginteger', 'length' => 20], + '[post_id] BIGINT', + ], + // Decimal + [ + 'value', + ['type' => 'decimal'], + '[value] DECIMAL', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 11], + '[value] DECIMAL(11,0)', + ], + [ + 'value', + ['type' => 'decimal', 'length' => 12, 'precision' => 5], + '[value] DECIMAL(12,5)', + ], + // Float + [ + 'value', + ['type' => 'float'], + '[value] FLOAT', + ], + [ + 'value', + ['type' => 'float', 'length' => 11, 'precision' => 3], + '[value] FLOAT(3)', + ], + // Binary + [ + 'img', + ['type' => 'binary', 'length' => null], + '[img] VARBINARY(MAX)', + ], + [ + 'img', + ['type' => 'binary', 'length' => TableSchema::LENGTH_TINY], + sprintf('[img] VARBINARY(%s)', TableSchema::LENGTH_TINY), + ], + [ + 'img', + ['type' => 'binary', 'length' => TableSchema::LENGTH_MEDIUM], + '[img] VARBINARY(MAX)', + ], + [ + 'img', + ['type' => 'binary', 'length' => TableSchema::LENGTH_LONG], + '[img] VARBINARY(MAX)', + ], + [ + 'bytes', + ['type' => 'binary', 'length' => 5], + '[bytes] VARBINARY(5)', + ], + [ + 'bytes', + ['type' => 'binary', 'length' => 1], + '[bytes] BINARY(1)', + ], + // Boolean + [ + 'checked', + ['type' => 'boolean', 'default' => false], + '[checked] BIT DEFAULT 0', + ], + [ + 'checked', + ['type' => 'boolean', 'default' => true, 'null' => false], + '[checked] BIT NOT NULL DEFAULT 1', + ], + // Datetime + [ + 'created', + ['type' => 'datetime'], + '[created] DATETIME2', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => '2016-12-07 23:04:00'], + "[open_date] DATETIME2 NOT NULL DEFAULT '2016-12-07 23:04:00'", + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => 'current_timestamp'], + '[open_date] DATETIME2 NOT NULL DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => 'getdate()'], + '[open_date] DATETIME2 NOT NULL DEFAULT GETDATE()', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => 'getutcdate()'], + '[open_date] DATETIME2 NOT NULL DEFAULT GETUTCDATE()', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => 'sysdatetime()'], + '[open_date] DATETIME2 NOT NULL DEFAULT SYSDATETIME()', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => 'sysutcdatetime()'], + '[open_date] DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => 'sysdatetimeoffset()'], + '[open_date] DATETIME2 NOT NULL DEFAULT SYSDATETIMEOFFSET()', + ], + [ + 'open_date', + ['type' => 'datetime', 'null' => false, 'default' => new QueryExpression('getutcdate()')], + '[open_date] DATETIME2 NOT NULL DEFAULT getutcdate()', + ], + [ + 'null_date', + ['type' => 'datetime', 'null' => true, 'default' => 'current_timestamp'], + '[null_date] DATETIME2 DEFAULT CURRENT_TIMESTAMP', + ], + [ + 'null_date', + ['type' => 'datetime', 'null' => true], + '[null_date] DATETIME2 DEFAULT NULL', + ], + // Date & Time + [ + 'start_date', + ['type' => 'date'], + '[start_date] DATE', + ], + [ + 'start_time', + ['type' => 'time'], + '[start_time] TIME', + ], + // Timestamp + [ + 'created', + ['type' => 'timestamp', 'null' => true], + '[created] DATETIME2 DEFAULT NULL', + ], + // Geospatial + [ + 'g', + ['type' => 'geometry'], + '[g] GEOMETRY', + ], + [ + 'g', + ['type' => 'geometry', 'null' => false, 'srid' => 4326], + '[g] GEOMETRY NOT NULL', + ], + [ + 'p', + ['type' => 'point'], + '[p] GEOGRAPHY', + ], + [ + 'p', + ['type' => 'point', 'null' => false, 'srid' => 4326], + '[p] GEOGRAPHY NOT NULL', + ], + [ + 'l', + ['type' => 'linestring'], + '[l] GEOGRAPHY', + ], + [ + 'l', + ['type' => 'linestring', 'null' => false, 'srid' => 4326], + '[l] GEOGRAPHY NOT NULL', + ], + [ + 'p', + ['type' => 'polygon'], + '[p] GEOGRAPHY', + ], + [ + 'p', + ['type' => 'polygon', 'null' => false, 'srid' => 4326], + '[p] GEOGRAPHY NOT NULL', + ], + ]; + } + + /** + * Test generating column definitions + */ + #[DataProvider('columnSqlProvider')] + public function testColumnSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new SqlserverSchemaDialect($driver); + + $table = (new TableSchema('schema_articles'))->addColumn($name, $data); + $this->assertEquals($expected, $schema->columnSql($table, $name)); + + $data['name'] = $name; + $this->assertEquals($expected, $schema->columnDefinitionSql($data)); + } + + /** + * Provide data for testing constraintSql + * + * @return array + */ + public static function constraintSqlProvider(): array + { + return [ + [ + 'primary', + ['type' => 'primary', 'columns' => ['title']], + 'PRIMARY KEY ([title])', + ], + [ + 'unique_idx', + ['type' => 'unique', 'columns' => ['title', 'author_id']], + 'CONSTRAINT [unique_idx] UNIQUE ([title], [author_id])', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id']], + 'CONSTRAINT [author_id_idx] FOREIGN KEY ([author_id]) ' . + 'REFERENCES [authors] ([id]) ON UPDATE NO ACTION ON DELETE NO ACTION', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'cascade'], + 'CONSTRAINT [author_id_idx] FOREIGN KEY ([author_id]) ' . + 'REFERENCES [authors] ([id]) ON UPDATE CASCADE ON DELETE NO ACTION', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'setDefault'], + 'CONSTRAINT [author_id_idx] FOREIGN KEY ([author_id]) ' . + 'REFERENCES [authors] ([id]) ON UPDATE SET DEFAULT ON DELETE NO ACTION', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'setNull'], + 'CONSTRAINT [author_id_idx] FOREIGN KEY ([author_id]) ' . + 'REFERENCES [authors] ([id]) ON UPDATE SET NULL ON DELETE NO ACTION', + ], + [ + 'author_id_idx', + ['type' => 'foreign', 'columns' => ['author_id'], 'references' => ['authors', 'id'], 'update' => 'noAction'], + 'CONSTRAINT [author_id_idx] FOREIGN KEY ([author_id]) ' . + 'REFERENCES [authors] ([id]) ON UPDATE NO ACTION ON DELETE NO ACTION', + ], + ]; + } + + /** + * Test the constraintSql method. + */ + #[DataProvider('constraintSqlProvider')] + public function testConstraintSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new SqlserverSchemaDialect($driver); + + $table = (new TableSchema('schema_articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addConstraint($name, $data); + + $this->assertEquals($expected, $schema->constraintSql($table, $name)); + } + + /** + * Test the addConstraintSql method. + */ + public function testAddConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('posts')) + ->addColumn('author_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_name', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('author_fk', [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + ]) + ->addConstraint('category_fk', [ + 'type' => 'foreign', + 'columns' => ['category_id', 'category_name'], + 'references' => ['categories', ['id', 'name']], + 'update' => 'cascade', + 'delete' => 'cascade', + ]); + + $expected = [ + 'ALTER TABLE [posts] ADD CONSTRAINT [author_fk] FOREIGN KEY ([author_id]) REFERENCES [authors] ([id]) ON UPDATE CASCADE ON DELETE CASCADE;', + 'ALTER TABLE [posts] ADD CONSTRAINT [category_fk] FOREIGN KEY ([category_id], [category_name]) REFERENCES [categories] ([id], [name]) ON UPDATE CASCADE ON DELETE CASCADE;', + ]; + $result = $table->addConstraintSql($connection); + $this->assertCount(2, $result); + $this->assertEquals($expected, $result); + } + + /** + * Test the dropConstraintSql method. + */ + public function testDropConstraintSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('posts')) + ->addColumn('author_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('category_name', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addConstraint('author_fk', [ + 'type' => 'foreign', + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + ]) + ->addConstraint('category_fk', [ + 'type' => 'foreign', + 'columns' => ['category_id', 'category_name'], + 'references' => ['categories', ['id', 'name']], + 'update' => 'cascade', + 'delete' => 'cascade', + ]); + + $expected = [ + 'ALTER TABLE [posts] DROP CONSTRAINT [author_fk];', + 'ALTER TABLE [posts] DROP CONSTRAINT [category_fk];', + ]; + $result = $table->dropConstraintSql($connection); + $this->assertCount(2, $result); + $this->assertEquals($expected, $result); + } + + /** + * Provide data for testing constraintSql + * + * @return array + */ + public static function indexSqlProvider(): array + { + return [ + [ + 'title_idx', + ['type' => 'index', 'columns' => ['title']], + 'CREATE INDEX [title_idx] ON [schema_articles] ([title])', + ], + [ + 'author_idx', + ['type' => 'index', 'columns' => ['author_id'], 'include' => ['title']], + 'CREATE INDEX [author_idx] ON [schema_articles] ([author_id]) INCLUDE ([title])', + ], + ]; + } + + /** + * Test the indexSql method. + */ + #[DataProvider('indexSqlProvider')] + public function testIndexSql(string $name, array $data, string $expected): void + { + $driver = $this->_getMockedDriver(); + $schema = new SqlserverSchemaDialect($driver); + + $table = (new TableSchema('schema_articles'))->addColumn('title', [ + 'type' => 'string', + 'length' => 255, + ])->addColumn('author_id', [ + 'type' => 'integer', + ])->addIndex($name, $data); + + $this->assertTextEquals($expected, $schema->indexSql($table, $name)); + } + + /** + * Integration test for converting a Schema\Table into MySQL table creates. + */ + public function testCreateSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = (new TableSchema('schema_articles'))->addColumn('id', [ + 'type' => 'integer', + 'null' => false, + ]) + ->addColumn('title', [ + 'type' => 'string', + 'null' => false, + ]) + ->addColumn('body', ['type' => 'text']) + ->addColumn('data', ['type' => 'json']) + ->addColumn('hash', [ + 'type' => 'char', + 'length' => 40, + 'collate' => 'Latin1_General_BIN', + 'null' => false, + ]) + ->addColumn('created', 'datetime') + ->addColumn('created_with_default', [ + 'type' => 'datetime', + 'default' => 'sysdatetime()', + ]) + ->addColumn('created_with_precision', [ + 'type' => 'datetime', + 'precision' => 3, + ]) + ->addColumn('created_without_precision', [ + 'type' => 'datetime', + 'precision' => 0, + ]) + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]) + ->addIndex('title_idx', [ + 'type' => 'index', + 'columns' => ['title'], + ]); + + $expected = <<<SQL +CREATE TABLE [schema_articles] ( +[id] INTEGER IDENTITY(1, 1) NOT NULL, +[title] NVARCHAR(255) NOT NULL, +[body] NVARCHAR(MAX), +[data] NVARCHAR(MAX), +[hash] NCHAR(40) COLLATE Latin1_General_BIN NOT NULL, +[created] DATETIME2, +[created_with_default] DATETIME2 DEFAULT SYSDATETIME(), +[created_with_precision] DATETIME2(3), +[created_without_precision] DATETIME2(0), +PRIMARY KEY ([id]) +) +SQL; + $result = $table->createSql($connection); + + $this->assertCount(2, $result); + $this->assertSame(str_replace("\r\n", "\n", $expected), str_replace("\r\n", "\n", $result[0])); + $this->assertSame( + 'CREATE INDEX [title_idx] ON [schema_articles] ([title])', + $result[1], + ); + } + + /** + * test dropSql + */ + public function testDropSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('schema_articles'); + $result = $table->dropSql($connection); + $this->assertCount(1, $result); + $this->assertSame('DROP TABLE [schema_articles]', $result[0]); + } + + /** + * Test truncateSql() + */ + public function testTruncateSql(): void + { + $driver = $this->_getMockedDriver(); + $connection = Mockery::mock(Connection::class)->makePartial(); + $connection->shouldReceive('getWriteDriver') + ->andReturn($driver); + + $table = new TableSchema('schema_articles'); + $table->addColumn('id', 'integer') + ->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + $result = $table->truncateSql($connection); + $this->assertCount(2, $result); + $this->assertSame('DELETE FROM [schema_articles]', $result[0]); + $this->assertSame( + "IF EXISTS (SELECT * FROM sys.identity_columns WHERE OBJECT_NAME(OBJECT_ID) = 'schema_articles' AND last_value IS NOT NULL) " . + "DBCC CHECKIDENT('schema_articles', RESEED, 0)", + $result[1], + ); + } + + public function testCreateTableColumnComment(): void + { + $this->_needsConnection(); + + $columns = [ + 'id' => [ + 'type' => 'biginteger', + 'default' => null, + 'null' => false, + 'length' => 19, + 'precision' => null, + 'unsigned' => null, + 'autoIncrement' => true, + 'comment' => null, + ], + 'title' => [ + 'type' => 'string', + 'length' => 20, + 'null' => true, + 'precision' => null, + 'comment' => null, + ], + 'body' => [ + 'type' => 'string', + 'null' => true, + 'length' => 1000, + 'precision' => null, + 'comment' => 'the body field', + ], + ]; + $schema = new TableSchema('schema_comment'); + foreach ($columns as $name => $column) { + $schema->addColumn($name, $column); + } + $connection = ConnectionManager::get('test'); + $sql = $schema->createSql($connection); + $comment = $sql[1]; + $this->assertStringContainsString('the body field', $comment); + $this->assertStringContainsString("EXEC sp_addextendedproperty N'MS_Description'", $comment); + } + + /** + * Get a schema instance with a mocked driver/pdo instances + */ + protected function _getMockedDriver(): Driver + { + $this->_needsConnection(); + + $mock = Mockery::mock(PDO::class); + $mock->shouldReceive('quote') + ->andReturnUsing(function ($value) { + return "'{$value}'"; + }); + + $driver = Mockery::mock(Sqlserver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $driver->__construct(); + + $driver->shouldReceive('createPdo') + ->andReturn($mock); + + $driver->connect(); + + return $driver; + } +} diff --git a/tests/TestCase/Database/Schema/TableSchemaTest.php b/tests/TestCase/Database/Schema/TableSchemaTest.php new file mode 100644 index 00000000000..87308e9d072 --- /dev/null +++ b/tests/TestCase/Database/Schema/TableSchemaTest.php @@ -0,0 +1,800 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Driver\Postgres; +use Cake\Database\Exception\DatabaseException; +use Cake\Database\Schema\CheckConstraint; +use Cake\Database\Schema\ForeignKey; +use Cake\Database\Schema\TableSchema; +use Cake\Database\TypeFactory; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Database\Type\IntType; + +/** + * Test case for Table + */ +class TableSchemaTest extends TestCase +{ + protected array $fixtures = [ + 'core.Articles', + 'core.Tags', + 'core.ArticlesTags', + 'core.Products', + 'core.Orders', + ]; + + protected $_map; + + protected function setUp(): void + { + $this->_map = TypeFactory::getMap(); + parent::setUp(); + } + + protected function tearDown(): void + { + TypeFactory::clear(); + TypeFactory::setMap($this->_map); + parent::tearDown(); + } + + /** + * Test construction with columns + */ + public function testConstructWithColumns(): void + { + $columns = [ + 'id' => [ + 'type' => 'integer', + 'length' => 11, + ], + 'title' => [ + 'type' => 'string', + 'length' => 255, + ], + ]; + $table = new TableSchema('articles', $columns); + $this->assertEquals(['id', 'title'], $table->columns()); + } + + /** + * Test hasAutoincrement() method. + */ + public function testHasAutoincrement(): void + { + $schema = new TableSchema('articles', [ + 'title' => 'string', + ]); + $this->assertFalse($schema->hasAutoincrement()); + + $schema->addColumn('id', [ + 'type' => 'integer', + 'autoIncrement' => true, + ]); + $this->assertTrue($schema->hasAutoincrement()); + } + + /** + * Test adding columns. + */ + public function testAddColumn(): void + { + $table = new TableSchema('articles'); + $result = $table->addColumn('title', [ + 'type' => 'string', + 'length' => 25, + 'null' => false, + ]); + $this->assertSame($table, $result); + $this->assertEquals(['title'], $table->columns()); + + $result = $table->addColumn('body', 'text'); + $this->assertSame($table, $result); + $this->assertEquals(['title', 'body'], $table->columns()); + + $col = $table->column('title'); + $this->assertEquals('title', $col->getName()); + $this->assertEquals('string', $col->getType()); + $this->assertEquals(25, $col->getLength()); + $this->assertFalse($col->getNull()); + } + + /** + * Test hasColumn() method. + */ + public function testHasColumn(): void + { + $schema = new TableSchema('articles', [ + 'title' => 'string', + ]); + + $this->assertTrue($schema->hasColumn('title')); + $this->assertFalse($schema->hasColumn('body')); + } + + public function testGetColumnMissing(): void + { + $table = new TableSchema('articles'); + $table->addColumn('title', [ + 'type' => 'string', + 'length' => 25, + 'null' => false, + ]); + $this->assertNull($table->getColumn('not there')); + + $this->expectException(DatabaseException::class); + $table->column('not there'); + } + + /** + * Test removing columns. + */ + public function testRemoveColumn(): void + { + $table = new TableSchema('articles'); + $result = $table->addColumn('title', [ + 'type' => 'string', + 'length' => 25, + 'null' => false, + ])->removeColumn('title') + ->removeColumn('unknown'); + + $this->assertSame($table, $result); + $this->assertEquals([], $table->columns()); + $this->assertNull($table->getColumn('title')); + $this->assertSame([], $table->typeMap()); + } + + /** + * Test isNullable method + */ + public function testIsNullable(): void + { + $table = new TableSchema('articles'); + $table->addColumn('title', [ + 'type' => 'string', + 'length' => 25, + 'null' => false, + ])->addColumn('tagline', [ + 'type' => 'string', + 'length' => 25, + 'null' => true, + ]); + $this->assertFalse($table->isNullable('title')); + $this->assertTrue($table->isNullable('tagline')); + $this->assertTrue($table->isNullable('missing')); + } + + /** + * Test columnType method + */ + public function testColumnType(): void + { + $table = new TableSchema('articles'); + $table->addColumn('title', [ + 'type' => 'string', + 'length' => 25, + 'null' => false, + ]); + $this->assertSame('string', $table->getColumnType('title')); + $this->assertNull($table->getColumnType('not there')); + } + + /** + * Test setColumnType setter method + */ + public function testSetColumnType(): void + { + $table = new TableSchema('articles'); + $table->addColumn('title', [ + 'type' => 'integer', + 'length' => 25, + 'null' => false, + ]); + $this->assertSame('integer', $table->getColumnType('title')); + $this->assertSame('integer', $table->baseColumnType('title')); + + $table->setColumnType('title', 'json'); + $this->assertSame('json', $table->getColumnType('title')); + $this->assertSame('json', $table->baseColumnType('title')); + } + + /** + * Tests getting the baseType as configured when creating the column + */ + public function testBaseColumnType(): void + { + $table = new TableSchema('articles'); + $table->addColumn('title', [ + 'type' => 'json', + 'baseType' => 'text', + 'length' => 25, + 'null' => false, + ]); + $this->assertSame('json', $table->getColumnType('title')); + $this->assertSame('text', $table->baseColumnType('title')); + } + + /** + * Tests getting the base type as it is returned by the Type class + */ + public function testBaseColumnTypeInherited(): void + { + TypeFactory::map('int', IntType::class); + $table = new TableSchema('articles'); + $table->addColumn('thing', [ + 'type' => 'int', + 'null' => false, + ]); + $this->assertSame('int', $table->getColumnType('thing')); + $this->assertSame('integer', $table->baseColumnType('thing')); + } + + /** + * Attribute keys should be filtered and have defaults set. + */ + public function testAddColumnFiltersAttributes(): void + { + $table = new TableSchema('articles'); + $table->addColumn('title', [ + 'type' => 'string', + ]); + $result = $table->getColumn('title'); + $expected = [ + 'type' => 'string', + 'length' => null, + 'precision' => null, + 'default' => null, + 'null' => null, + 'comment' => null, + 'collate' => null, + ]; + $this->assertEquals($expected, $result); + $column = $table->column('title'); + $this->assertSame($expected['type'], $column->getType()); + + $table->addColumn('author_id', [ + 'type' => 'integer', + ]); + $result = $table->getColumn('author_id'); + $expected = [ + 'type' => 'integer', + 'length' => null, + 'precision' => null, + 'default' => null, + 'null' => null, + 'comment' => null, + 'autoIncrement' => false, + 'generated' => null, + 'unsigned' => null, + ]; + $this->assertEquals($expected, $result); + $column = $table->column('author_id'); + $this->assertSame($expected['type'], $column->getType()); + + $table->addColumn('amount', [ + 'type' => 'decimal', + 'length' => 10, + 'precision' => 3, + ]); + $result = $table->getColumn('amount'); + $expected = [ + 'type' => 'decimal', + 'length' => 10, + 'precision' => 3, + 'default' => null, + 'null' => null, + 'comment' => null, + 'unsigned' => null, + ]; + $this->assertEquals($expected, $result); + $column = $table->column('amount'); + $this->assertSame($expected['type'], $column->getType()); + $this->assertSame($expected['length'], $column->getLength()); + $this->assertSame($expected['precision'], $column->getPrecision()); + } + + /** + * Test reading default values. + */ + public function testDefaultValues(): void + { + $table = new TableSchema('articles'); + $table->addColumn('id', [ + 'type' => 'integer', + 'default' => 0, + ])->addColumn('title', [ + 'type' => 'string', + 'default' => 'A title', + ])->addColumn('name', [ + 'type' => 'string', + 'null' => false, + 'default' => null, + ])->addColumn('body', [ + 'type' => 'text', + 'null' => true, + 'default' => null, + ])->addColumn('hash', [ + 'type' => 'char', + 'default' => '098f6bcd4621d373cade4e832627b4f6', + 'length' => 32, + ]); + $result = $table->defaultValues(); + $expected = [ + 'id' => 0, + 'title' => 'A title', + 'body' => null, + 'hash' => '098f6bcd4621d373cade4e832627b4f6', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test adding an constraint. + */ + public function testAddConstraint(): void + { + $table = new TableSchema('articles'); + $table->addColumn('id', [ + 'type' => 'integer', + ]); + $result = $table->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + 'constraint' => 'postgres_name', + ]); + $this->assertSame($result, $table); + $this->assertEquals(['primary'], $table->constraints()); + + // TODO make the constraint name work for postgres_name too. + $primary = $table->constraint('primary'); + $this->assertEquals('postgres_name', $primary->getName(), 'constraint objects should preserve the name'); + } + + /** + * Test adding an constraint with an overlapping unique index + */ + public function testAddConstraintOverwriteUniqueIndex(): void + { + $table = new TableSchema('articles'); + $table->addColumn('project_id', [ + 'type' => 'integer', + 'default' => null, + 'limit' => 11, + 'null' => false, + ])->addColumn('id', [ + 'type' => 'integer', + 'autoIncrement' => true, + 'limit' => 11, + ])->addColumn('user_id', [ + 'type' => 'integer', + 'default' => null, + 'limit' => 11, + 'null' => false, + ])->addConstraint('users_idx', [ + 'type' => 'unique', + 'columns' => ['project_id', 'user_id'], + ])->addConstraint('users_idx', [ + 'type' => 'foreign', + 'references' => ['users', 'project_id', 'id'], + 'columns' => ['project_id', 'user_id'], + ]); + $this->assertEquals(['users_idx'], $table->constraints()); + } + + /** + * Test adding a check constraint. + */ + public function testAddConstraintCheck(): void + { + $table = new TableSchema('articles'); + $table->addColumn('age', [ + 'type' => 'integer', + ]); + $result = $table->addConstraint('age_check', [ + 'type' => 'check', + 'expression' => 'age > 19', + ]); + $this->assertSame($result, $table); + $this->assertEquals(['age_check'], $table->constraints()); + + $check = $table->getConstraint('age_check'); + $this->assertEquals('age > 19', $check['expression']); + + $check = $table->constraint('age_check'); + assert($check instanceof CheckConstraint); + $this->assertEquals('age_check', $check->getName()); + $this->assertEquals('age > 19', $check->getExpression()); + } + + /** + * Dataprovider for invalid addConstraint calls. + * + * @return array + */ + public static function addConstraintErrorProvider(): array + { + return [ + // No properties + [[]], + // Empty columns + [['columns' => '', 'type' => TableSchema::CONSTRAINT_UNIQUE]], + [['columns' => [], 'type' => TableSchema::CONSTRAINT_UNIQUE]], + // Missing column + [['columns' => ['derp'], 'type' => TableSchema::CONSTRAINT_UNIQUE]], + // Invalid type + [['columns' => 'author_id', 'type' => 'derp']], + ]; + } + + /** + * Test that an exception is raised when constraints + * are added for fields that do not exist. + */ + #[DataProvider('addConstraintErrorProvider')] + public function testAddConstraintError(array $props): void + { + $this->expectException(DatabaseException::class); + $table = new TableSchema('articles'); + $table->addColumn('author_id', 'integer'); + $table->addConstraint('author_idx', $props); + } + + /** + * Test adding an index. + */ + public function testAddIndex(): void + { + $table = new TableSchema('articles'); + $table->addColumn('title', [ + 'type' => 'string', + ]); + $result = $table->addIndex('faster', [ + 'type' => 'index', + 'columns' => ['title'], + ])->addIndex('no_columns', 'index'); + $this->assertSame($result, $table); + $this->assertEquals(['faster', 'no_columns'], $table->indexes()); + + $index = $table->index('faster'); + $this->assertEquals('faster', $index->getName()); + $this->assertEquals(['title'], $index->getColumns()); + $this->assertEquals(TableSchema::INDEX_INDEX, $index->getType()); + + $noCols = $table->index('no_columns'); + $this->assertEquals([], $noCols->getColumns()); + } + + /** + * Dataprovider for invalid addIndex calls + * + * @return array + */ + public static function addIndexErrorProvider(): array + { + return [ + // Empty + [[]], + // Invalid type + [['columns' => 'author_id', 'type' => 'derp']], + // Missing column + [['columns' => ['not_there'], 'type' => TableSchema::INDEX_INDEX]], + ]; + } + + /** + * Test that an exception is raised when indexes + * are added for fields that do not exist. + */ + #[DataProvider('addIndexErrorProvider')] + public function testAddIndexError(array $props): void + { + $this->expectException(DatabaseException::class); + $table = new TableSchema('articles'); + $table->addColumn('author_id', 'integer'); + $table->addIndex('author_idx', $props); + } + + /** + * Test adding different kinds of indexes. + */ + public function testAddIndexTypes(): void + { + $table = new TableSchema('articles'); + $table->addColumn('id', 'integer') + ->addColumn('title', 'string') + ->addColumn('author_id', 'integer'); + + $table->addIndex('author_idx', [ + 'columns' => ['author_id'], + 'type' => 'index', + ])->addIndex('texty', [ + 'type' => 'fulltext', + 'columns' => ['title'], + ]); + + $this->assertEquals( + ['author_idx', 'texty'], + $table->indexes(), + ); + } + + /** + * Test getting the primary key. + */ + public function testPrimaryKey(): void + { + $table = new TableSchema('articles'); + $table->addColumn('id', 'integer') + ->addColumn('title', 'string') + ->addColumn('author_id', 'integer') + ->addConstraint('author_idx', [ + 'columns' => ['author_id'], + 'type' => 'unique', + ])->addConstraint('primary', [ + 'type' => 'primary', + 'columns' => ['id'], + ]); + $this->assertEquals(['id'], $table->getPrimaryKey()); + + $table = new TableSchema('articles'); + $table->addColumn('id', 'integer') + ->addColumn('title', 'string') + ->addColumn('author_id', 'integer'); + $this->assertEquals([], $table->getPrimaryKey()); + } + + /** + * Test the setOptions/getOptions methods. + */ + public function testOptions(): void + { + $table = new TableSchema('articles'); + $options = [ + 'engine' => 'InnoDB', + ]; + $return = $table->setOptions($options); + $this->assertInstanceOf(TableSchema::class, $return); + $this->assertEquals($options, $table->getOptions()); + } + + /** + * Add a basic foreign key constraint. + */ + public function testAddConstraintForeignKey(): void + { + $table = new TableSchema('articles'); + $table->addColumn('author_id', 'integer') + ->addConstraint('author_id_idx', [ + 'type' => TableSchema::CONSTRAINT_FOREIGN, + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + ]); + $this->assertEquals(['author_id_idx'], $table->constraints()); + } + + /** + * Test single column foreign keys constraint support + */ + public function testConstraintForeignKey(): void + { + $table = $this->getTableLocator()->get('ArticlesTags'); + $driver = $table->getConnection()->getDriver(); + + $name = 'tag_id_fk'; + $compositeConstraint = $table->getSchema()->getConstraint($name); + $expected = [ + 'type' => 'foreign', + 'columns' => ['tag_id'], + 'references' => ['tags', 'id'], + 'update' => 'cascade', + 'delete' => 'cascade', + 'deferrable' => null, + ]; + // Postgres reflection always includes deferrable state. + if ($driver instanceof Postgres) { + $expected['deferrable'] = ForeignKey::IMMEDIATE; + } + $this->assertEquals($expected, $compositeConstraint); + + $expectedSubstring = "CONSTRAINT <{$name}> FOREIGN KEY \\(<tag_id>\\) REFERENCES <tags> \\(<id>\\)"; + $this->assertQuotedQuery($expectedSubstring, $table->getSchema()->createSql(ConnectionManager::get('test'))[0]); + } + + /** + * Test the behavior of getConstraint() and constraint() when the constraint is not defined. + */ + public function testGetConstraintMissing(): void + { + $table = new TableSchema('articles'); + $table->addColumn('author_id', 'integer'); + + $this->assertNull($table->getConstraint('not there')); + + $this->expectException(DatabaseException::class); + $table->constraint('not there'); + } + + /** + * Test composite foreign keys support + */ + public function testConstraintForeignKeyTwoColumns(): void + { + $this->getTableLocator()->clear(); + $table = $this->getTableLocator()->get('Orders'); + $connection = $table->getConnection(); + $this->skipIf( + $connection->getDriver() instanceof Postgres, + 'Constraints get dropped in postgres for some reason', + ); + + $name = 'product_category_fk'; + $compositeConstraint = $table->getSchema()->getConstraint($name); + $expected = [ + 'type' => 'foreign', + 'columns' => [ + 'product_category', + 'product_id', + ], + 'references' => [ + 'products', + ['category', 'id'], + ], + 'update' => 'cascade', + 'delete' => 'cascade', + 'deferrable' => null, + ]; + $this->assertEquals($expected, $compositeConstraint); + + $expectedSubstring = "CONSTRAINT <{$name}> FOREIGN KEY \\(<product_category>, <product_id>\\)" . + ' REFERENCES <products> \(<category>, <id>\)'; + + $this->assertQuotedQuery($expectedSubstring, $table->getSchema()->createSql(ConnectionManager::get('test'))[0]); + } + + /** + * Provider for exceptionally bad foreign key data. + * + * @return array + */ + public static function badForeignKeyProvider(): array + { + return [ + 'references is bad' => [[ + 'type' => TableSchema::CONSTRAINT_FOREIGN, + 'columns' => ['author_id'], + 'references' => ['authors'], + 'delete' => 'derp', + ]], + 'bad update value' => [[ + 'type' => TableSchema::CONSTRAINT_FOREIGN, + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'update' => 'derp', + ]], + 'bad delete value' => [[ + 'type' => TableSchema::CONSTRAINT_FOREIGN, + 'columns' => ['author_id'], + 'references' => ['authors', 'id'], + 'delete' => 'derp', + ]], + ]; + } + + /** + * Add a foreign key constraint with bad data + */ + #[DataProvider('badForeignKeyProvider')] + public function testAddConstraintForeignKeyBadData(array $data): void + { + $this->expectException(DatabaseException::class); + $table = new TableSchema('articles'); + $table->addColumn('author_id', 'integer') + ->addConstraint('author_id_idx', $data); + } + + /** + * Tests the setTemporary() & isTemporary() method + */ + public function testSetTemporary(): void + { + $table = new TableSchema('articles'); + $this->assertFalse($table->isTemporary()); + $this->assertSame($table, $table->setTemporary(true)); + $this->assertTrue($table->isTemporary()); + + $table->setTemporary(false); + $this->assertFalse($table->isTemporary()); + } + + /** + * Test that unserialization handles data from previous versions of CakePHP. + * + * @return void + */ + public function testUnserializeCompat(): void + { + // Serialized state from <5.3 where _columns, _indexes, and _constraints contained array data. + $state = <<<'STATE' + O:32:"Cake\Database\Schema\TableSchema":7:{s:9:"�*�_table";s:8:"articles";s:11:"�*�_columns";a:6:{s:2:"id";a:9:{s:4:"type";s:7:"integer";s:6:"length";i:10;s:13:"autoIncrement";b:1;s:7:"default";N;s:4:"null";b:0;s:7:"comment";N;s:8:"baseType";N;s:9:"precision";N;s:8:"unsigned";N;}s:5:"title";a:9:{s:4:"type";s:6:"string";s:6:"length";i:255;s:7:"default";N;s:4:"null";b:0;s:7:"collate";N;s:7:"comment";N;s:8:"baseType";N;s:9:"precision";N;s:5:"fixed";N;}s:7:"excerpt";a:8:{s:4:"type";s:4:"text";s:6:"length";N;s:7:"default";N;s:4:"null";b:0;s:7:"collate";N;s:7:"comment";N;s:8:"baseType";N;s:9:"precision";N;}s:6:"rating";a:9:{s:4:"type";s:7:"integer";s:6:"length";i:10;s:7:"default";N;s:4:"null";b:0;s:7:"comment";N;s:8:"baseType";N;s:9:"precision";N;s:8:"unsigned";N;s:13:"autoIncrement";N;}s:7:"content";a:8:{s:4:"type";s:4:"text";s:6:"length";N;s:7:"default";N;s:4:"null";b:0;s:7:"collate";N;s:7:"comment";N;s:8:"baseType";N;s:9:"precision";N;}s:4:"name";a:9:{s:4:"type";s:6:"string";s:6:"length";i:255;s:7:"default";N;s:4:"null";b:0;s:7:"collate";N;s:7:"comment";N;s:8:"baseType";N;s:9:"precision";N;s:5:"fixed";N;}}s:11:"�*�_typeMap";a:6:{s:2:"id";s:7:"integer";s:5:"title";s:6:"string";s:7:"excerpt";s:4:"text";s:6:"rating";s:7:"integer";s:7:"content";s:4:"text";s:4:"name";s:6:"string";}s:11:"�*�_indexes";a:2:{s:12:"rating_index";a:3:{s:4:"type";s:5:"index";s:7:"columns";a:1:{i:0;s:6:"rating";}s:6:"length";a:0:{}}s:7:"by_name";a:3:{s:4:"type";s:5:"index";s:7:"columns";a:1:{i:0;s:4:"name";}s:6:"length";a:0:{}}}s:15:"�*�_constraints";a:1:{s:7:"primary";a:3:{s:4:"type";s:7:"primary";s:7:"columns";a:1:{i:0;s:2:"id";}s:6:"length";a:0:{}}}s:11:"�*�_options";a:0:{}s:13:"�*�_temporary";b:0;} + STATE; + $schema = unserialize(trim($state)); + + $this->assertInstanceOf(TableSchema::class, $schema); + $this->assertEquals('articles', $schema->name()); + $this->assertCount(6, $schema->columns()); + $this->assertCount(2, $schema->indexes()); + $this->assertCount(1, $schema->constraints()); + $this->assertEquals('string', $schema->column('title')->getType()); + $this->assertEquals('string', $schema->getColumn('title')['type']); + $this->assertEquals(['id'], $schema->constraint('primary')->getColumns()); + $this->assertEquals(['id'], $schema->getConstraint('primary')['columns']); + $this->assertEquals(['id'], $schema->getPrimaryKey()); + $this->assertEquals(['name'], $schema->index('by_name')->getColumns()); + + // Serialize and unserialize to ensure current objects also work. + $serialized = serialize($schema); + $restored = unserialize($serialized); + $this->assertEquals($schema, $restored); + } + + /** + * Test that float values for length and precision are cast to int. + * + * Some database drivers return numeric metadata as floats (e.g., SQLite). + * PHP 8.4 is stricter about implicit float-to-int conversions, so we need + * to explicitly cast these values. + */ + public function testAddColumnWithFloatLengthAndPrecision(): void + { + $table = new TableSchema('articles'); + $table->addColumn('amount', [ + 'type' => 'decimal', + 'length' => 10.0, + 'precision' => 2.0, + ]); + + $column = $table->column('amount'); + $this->assertSame(10, $column->getLength()); + $this->assertSame(2, $column->getPrecision()); + } + + /** + * 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 + * @param string $query the result to compare against + * @param bool $optional + */ + public function assertQuotedQuery($pattern, $query, $optional = false): void + { + if ($optional) { + $optional = '?'; + } + $pattern = str_replace('<', '[`"\[]' . $optional, $pattern); + $pattern = str_replace('>', '[`"\]]' . $optional, $pattern); + $this->assertMatchesRegularExpression('#' . $pattern . '#', $query); + } +} diff --git a/tests/TestCase/Database/Schema/UniqueKeyTest.php b/tests/TestCase/Database/Schema/UniqueKeyTest.php new file mode 100644 index 00000000000..a00015e9fac --- /dev/null +++ b/tests/TestCase/Database/Schema/UniqueKeyTest.php @@ -0,0 +1,53 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Database\Schema; + +use Cake\Database\Schema\UniqueKey; +use Cake\TestSuite\TestCase; + +/** + * Tests for the UniqueKey class. + */ +class UniqueKeyTest extends TestCase +{ + public function testSetType(): void + { + $index = new UniqueKey('title_idx', ['title']); + $this->assertSame(UniqueKey::UNIQUE, $index->getType()); + + // types are not checked. + $index->setType('check'); + $this->assertSame('check', $index->getType()); + } + + public function testSetColumns(): void + { + $index = new UniqueKey('title_idx', []); + $this->assertSame([], $index->getColumns()); + + $index->setColumns(['title']); + $this->assertSame(['title'], $index->getColumns()); + + $index->setColumns(['title', 'name']); + $this->assertSame(['title', 'name'], $index->getColumns()); + } + + public function testSetName(): void + { + $index = new UniqueKey('title_idx', ['title']); + $this->assertSame('title_idx', $index->getName()); + + $index->setName('my_index'); + $this->assertSame('my_index', $index->getName()); + } + + public function testSetLength(): void + { + $index = new UniqueKey('title_idx', ['title']); + $this->assertNull($index->getLength()); + + $index->setLength(['title' => 10]); + $this->assertSame(['title' => 10], $index->getLength()); + } +} diff --git a/tests/TestCase/Database/SchemaCacheTest.php b/tests/TestCase/Database/SchemaCacheTest.php new file mode 100644 index 00000000000..5f9462f609c --- /dev/null +++ b/tests/TestCase/Database/SchemaCacheTest.php @@ -0,0 +1,170 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.6.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database; + +use Cake\Cache\Cache; +use Cake\Database\Schema\CachedCollection; +use Cake\Database\SchemaCache; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; + +/** + * SchemaCache test. + */ +class SchemaCacheTest extends TestCase +{ + /** + * Fixtures. + * + * @var array<string> + */ + protected array $fixtures = ['core.Articles', 'core.Tags']; + + /** + * Cache Engine Mock + * + * @var \Cake\Cache\CacheEngine + */ + protected $cache; + + /** + * @var \Cake\Datasource\ConnectionInterface + */ + protected $connection; + + /** + * setup method + */ + protected function setUp(): void + { + parent::setUp(); + + Cache::setConfig('orm_cache', ['className' => 'Array']); + $this->cache = Cache::pool('orm_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); + + $ormCache = new SchemaCache($this->connection); + $ormCache->clear(); + + $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); + + $ormCache = new SchemaCache($this->connection); + $ormCache->build(); + + $this->assertInstanceOf(CachedCollection::class, $this->connection->getSchemaCollection()); + } + + /** + * Test build() with no args. + */ + public function testBuildNoArgs(): void + { + $ormCache = new SchemaCache($this->connection); + $ormCache->build(); + + $this->assertNotEmpty($this->cache->get('test_articles')); + } + + /** + * Test build() with one arg. + */ + public function testBuildNamedModel(): void + { + $ormCache = new SchemaCache($this->connection); + $ormCache->build('articles'); + + $this->assertNotEmpty($this->cache->get('test_articles')); + } + + /** + * Test build() overwrites cached data. + */ + public function testBuildOverwritesExistingData(): void + { + $this->cache->set('test_articles', 'dummy data'); + + $ormCache = new SchemaCache($this->connection); + $ormCache->build('articles'); + + $this->assertNotSame('dummy data', $this->cache->get('test_articles')); + } + + /** + * Test clear() with no args. + */ + public function testClearNoArgs(): void + { + $this->cache->set('test_articles', 'dummy data'); + + $ormCache = new SchemaCache($this->connection); + $ormCache->clear(); + $this->assertFalse($this->cache->has('test_articles')); + } + + /** + * Test clear() with a model name. + */ + public function testClearNamedModel(): void + { + $this->cache->set('test_articles', 'dummy data'); + + $ormCache = new SchemaCache($this->connection); + $ormCache->clear('articles'); + $this->assertFalse($this->cache->has('test_articles')); + } + + /** + * Tests getting a schema config from a connection instance + */ + public function testGetSchemaWithConnectionInstance(): void + { + $ormCache = new SchemaCache($this->connection); + $result = $ormCache->getSchema($this->connection); + + $this->assertInstanceOf(CachedCollection::class, $result); + } +} diff --git a/tests/TestCase/Database/Type/BinaryTypeTest.php b/tests/TestCase/Database/Type/BinaryTypeTest.php new file mode 100644 index 00000000000..90d3f364f63 --- /dev/null +++ b/tests/TestCase/Database/Type/BinaryTypeTest.php @@ -0,0 +1,97 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Core\Exception\CakeException; +use Cake\Database\Driver; +use Cake\Database\TypeFactory; +use Cake\TestSuite\TestCase; +use PDO; + +/** + * Test for the Binary type. + */ +class BinaryTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\BinaryType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = TypeFactory::build('binary'); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + + $result = $this->type->toPHP('some data', $this->driver); + $this->assertIsResource($result); + + $fh = fopen(__FILE__, 'r'); + $result = $this->type->toPHP($fh, $this->driver); + $this->assertSame($fh, $result); + fclose($fh); + } + + /** + * Test exceptions on invalid data. + */ + public function testToPHPFailure(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unable to convert `array` into binary.'); + $this->type->toPHP([], $this->driver); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $value = 'some data'; + $result = $this->type->toDatabase($value, $this->driver); + $this->assertSame($value, $result); + + $fh = fopen(__FILE__, 'r'); + $result = $this->type->toDatabase($fh, $this->driver); + $this->assertSame($fh, $result); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_LOB, $this->type->toStatement('', $this->driver)); + } +} diff --git a/tests/TestCase/Database/Type/BinaryUuidTypeTest.php b/tests/TestCase/Database/Type/BinaryUuidTypeTest.php new file mode 100644 index 00000000000..3ba276198dc --- /dev/null +++ b/tests/TestCase/Database/Type/BinaryUuidTypeTest.php @@ -0,0 +1,118 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.6.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Core\Exception\CakeException; +use Cake\Database\Driver; +use Cake\Database\Type\BinaryUuidType; +use Cake\TestSuite\TestCase; +use Cake\Utility\Text; +use PDO; + +/** + * Test for the Binary uuid type. + */ +class BinaryUuidTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\BinaryUuidType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new BinaryUuidType(); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + + $result = $this->type->toPHP(Text::uuid(), $this->driver); + $uuidRegex = '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/'; + preg_match_all( + $uuidRegex, + $result, + $matches, + ); + + $result = $matches[0]; + $this->assertSame(count($result), 2); + + $fh = fopen(__FILE__, 'r'); + $result = $this->type->toPHP($fh, $this->driver); + $this->assertSame($fh, $result); + $this->assertIsResource($result); + fclose($fh); + } + + /** + * Test exceptions on invalid data. + */ + public function testToPHPFailure(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unable to convert array into binary uuid.'); + + $this->type->toPHP([], $this->driver); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $fh = fopen(__FILE__, 'r'); + $result = $this->type->toDatabase($fh, $this->driver); + $this->assertSame($fh, $result); + + $value = Text::uuid(); + $result = $this->type->toDatabase($value, $this->driver); + $this->assertSame(str_replace('-', '', $value), unpack('H*', $result)[1]); + } + + /** + * Test converting to database format fails + */ + public function testToDatabaseInvalid(): void + { + $value = 'mUMPWUxCpaCi685A9fEwJZ'; + $result = $this->type->toDatabase($value, $this->driver); + $this->assertNull($result); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_LOB, $this->type->toStatement('', $this->driver)); + } +} diff --git a/tests/TestCase/Database/Type/BoolTypeTest.php b/tests/TestCase/Database/Type/BoolTypeTest.php new file mode 100644 index 00000000000..4074d92b152 --- /dev/null +++ b/tests/TestCase/Database/Type/BoolTypeTest.php @@ -0,0 +1,165 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.1.7 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\TypeFactory; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PDO; + +/** + * Test for the Boolean type. + */ +class BoolTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\BoolType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = TypeFactory::build('boolean'); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $this->assertNull($this->type->toDatabase(null, $this->driver)); + $this->assertTrue($this->type->toDatabase(true, $this->driver)); + $this->assertFalse($this->type->toDatabase(false, $this->driver)); + $this->assertTrue($this->type->toDatabase(1, $this->driver)); + $this->assertFalse($this->type->toDatabase(0, $this->driver)); + $this->assertTrue($this->type->toDatabase('1', $this->driver)); + $this->assertFalse($this->type->toDatabase('0', $this->driver)); + } + + /** + * Test converting an array to boolean results in an exception + */ + public function testToDatabaseInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->type->toDatabase([1, 2], $this->driver); + } + + /** + * Tests that passing an invalid value will throw an exception + */ + public function testToDatabaseInvalidArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->type->toDatabase([1, 2, 3], $this->driver); + } + + /** + * Test converting string booleans to PHP values. + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + $this->assertTrue($this->type->toPHP(1, $this->driver)); + $this->assertTrue($this->type->toPHP('1', $this->driver)); + $this->assertTrue($this->type->toPHP('TRUE', $this->driver)); + $this->assertTrue($this->type->toPHP('true', $this->driver)); + $this->assertTrue($this->type->toPHP(true, $this->driver)); + + $this->assertFalse($this->type->toPHP(0, $this->driver)); + $this->assertFalse($this->type->toPHP('0', $this->driver)); + $this->assertFalse($this->type->toPHP('FALSE', $this->driver)); + $this->assertFalse($this->type->toPHP('false', $this->driver)); + $this->assertFalse($this->type->toPHP(false, $this->driver)); + } + + /** + * Test converting string booleans to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => 'true', + 'c' => 'TRUE', + 'd' => 'false', + 'e' => 'FALSE', + 'f' => '0', + 'g' => '1', + 'h' => true, + 'i' => false, + ]; + $expected = [ + 'a' => null, + 'b' => true, + 'c' => true, + 'd' => false, + 'e' => false, + 'f' => false, + 'g' => true, + 'h' => true, + 'i' => false, + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test marshalling booleans + */ + public function testMarshal(): void + { + $this->assertNull($this->type->marshal(null)); + $this->assertTrue($this->type->marshal(true)); + $this->assertTrue($this->type->marshal(1)); + $this->assertTrue($this->type->marshal('1')); + $this->assertTrue($this->type->marshal('true')); + $this->assertTrue($this->type->marshal('on')); + + $this->assertFalse($this->type->marshal(false)); + $this->assertFalse($this->type->marshal('false')); + $this->assertFalse($this->type->marshal('0')); + $this->assertFalse($this->type->marshal(0)); + $this->assertFalse($this->type->marshal('off')); + $this->assertNull($this->type->marshal('')); + $this->assertNull($this->type->marshal('not empty')); + $this->assertNull($this->type->marshal(['2', '3'])); + } + + /** + * Test converting booleans to PDO types. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_NULL, $this->type->toStatement(null, $this->driver)); + $this->assertSame(PDO::PARAM_BOOL, $this->type->toStatement(true, $this->driver)); + $this->assertSame(PDO::PARAM_BOOL, $this->type->toStatement(false, $this->driver)); + } +} diff --git a/tests/TestCase/Database/Type/DateTimeFractionalTypeTest.php b/tests/TestCase/Database/Type/DateTimeFractionalTypeTest.php new file mode 100644 index 00000000000..b79a189dc57 --- /dev/null +++ b/tests/TestCase/Database/Type/DateTimeFractionalTypeTest.php @@ -0,0 +1,417 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\Type\DateTimeFractionalType; +use Cake\I18n\DateTime; +use Cake\TestSuite\TestCase; +use DateTimeZone; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test for the DateTime type. + */ +class DateTimeFractionalTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\DateTimeFractionalType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new DateTimeFractionalType(); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHPEmpty(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + $this->assertNull($this->type->toPHP('0000-00-00 00:00:00', $this->driver)); + $this->assertNull($this->type->toPHP('0000-00-00 00:00:00.000000', $this->driver)); + } + + /** + * Test toPHP + */ + public function testToPHPString(): void + { + $result = $this->type->toPHP('2001-01-04 12:13:14.123456', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('12', $result->format('H')); + $this->assertSame('13', $result->format('i')); + $this->assertSame('14', $result->format('s')); + $this->assertSame('123456', $result->format('u')); + + // test extra fractional second past microseconds being ignored + $result = $this->type->toPHP('2001-01-04 12:13:14.1234567', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('123456', $result->format('u')); + + $result = $this->type->toPHP('1401906995.123456', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2014', $result->format('Y')); + $this->assertSame('06', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('18', $result->format('H')); + $this->assertSame('36', $result->format('i')); + $this->assertSame('35', $result->format('s')); + $this->assertSame('123456', $result->format('u')); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toPHP('2001-01-04 12:00:00.123456', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('06', $result->format('H')); + $this->assertSame('30', $result->format('i')); + $this->assertSame('00', $result->format('s')); + $this->assertSame('123456', $result->format('u')); + } + + /** + * Test converting string datetimes to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => '2001-01-04 12:13:14', + 'c' => '2001-01-04 12:13:14.123', + 'd' => '2001-01-04 12:13:14.123456', + // test extra fractional second past microseconds being ignored + 'e' => '2001-01-04 12:13:14.1234567', + ]; + $expected = [ + 'a' => null, + 'b' => new DateTime('2001-01-04 12:13:14'), + 'c' => new DateTime('2001-01-04 12:13:14.123'), + 'd' => new DateTime('2001-01-04 12:13:14.123456'), + 'e' => new DateTime('2001-01-04 12:13:14.123456'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $values = [ + 'a' => null, + 'b' => '2001-01-04 12:13:14', + 'c' => '2001-01-04 12:13:14.123', + 'd' => '2001-01-04 12:13:14.123456', + ]; + $expected = [ + 'a' => null, + 'b' => new DateTime('2001-01-04 06:43:14'), + 'c' => new DateTime('2001-01-04 06:43:14.123'), + 'd' => new DateTime('2001-01-04 06:43:14.123456'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format with microseconds + */ + public function testToDatabase(): void + { + $value = '2001-01-04 12:13:14.123456'; + $result = $this->type->toDatabase($value, $this->driver); + $this->assertSame($value, $result); + + // test extra fractional second past microseconds being ignored + $date = new DateTime('2013-08-12 15:16:17.1234567'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.123456', $result); + + $date = new DateTime('2013-08-12 15:16:17.123456'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.123456', $result); + + $tz = $date->getTimezone(); + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.123456', $result); + $this->assertEquals($tz, $date->getTimezone()); + + $this->type->setDatabaseTimezone(new DateTimeZone('Asia/Kolkata')); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.123456', $result); + $this->type->setDatabaseTimezone(null); + + $date = new DateTime('2013-08-12 15:16:17.123456'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.123456', $result); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.123456', $result); + $this->type->setDatabaseTimezone(null); + + $date = 1401906995.123; + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2014-06-04 18:36:35.123000', $result); + } + + /** + * Test converting to database format without microseconds + */ + public function testToDatabaseNoMicroseconds(): void + { + $date = new DateTime('2013-08-12 15:16:17'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.000000', $result); + + $tz = $date->getTimezone(); + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.000000', $result); + $this->assertEquals($tz, $date->getTimezone()); + + $this->type->setDatabaseTimezone(new DateTimeZone('Asia/Kolkata')); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.000000', $result); + $this->type->setDatabaseTimezone(null); + + $date = new DateTime('2013-08-12 15:16:17'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.000000', $result); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.000000', $result); + $this->type->setDatabaseTimezone(null); + + $date = 1401906995; + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2014-06-04 18:36:35.000000', $result); + } + + /** + * Data provider for marshal() with microseconds + * + * @return array + */ + public static function marshalProvider(): array + { + return [ + // invalid types. + [null, null], + [false, null], + [true, null], + ['', null], + ['derpy', null], + ['2013-nope!', null], + ['2014-02-14T13:14:15.1234567', null], + ['2017-04-05T17:18:00.1234567+00:00', null], + + // valid string types + ['2014-02-14', new DateTime('2014-02-14 00:00:00')], + ['2014-02-14 12:02', new DateTime('2014-02-14 12:02')], + ['2014-02-14 12:02:12', new DateTime('2014-02-14 12:02:12')], + ['2014-02-14 00:00:00.123456', new DateTime('2014-02-14 00:00:00.123456')], + ['2014-02-14 13:14:15.123456', new DateTime('2014-02-14 13:14:15.123456')], + ['2014-02-14T13:14', new DateTime('2014-02-14T13:14:00')], + ['2014-02-14T13:14:12', new DateTime('2014-02-14T13:14:12')], + ['2014-02-14T13:14:15.123456', new DateTime('2014-02-14T13:14:15.123456')], + ['2017-04-05T17:18:00.123456+00:00', new DateTime('2017-04-05T17:18:00.123456+00:00')], + + // valid array types + [ + ['year' => '', 'month' => '', 'day' => '', 'hour' => '', 'minute' => '', 'second' => '', 'microsecond' => ''], + null, + ], + [ + ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15, 'microsecond' => 123456], + new DateTime('2014-02-14 13:14:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, 'microsecond' => 123456, + 'meridian' => 'am', + ], + new DateTime('2014-02-14 01:14:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 12, 'minute' => 04, 'second' => 15, 'microsecond' => 123456, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 12:04:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, 'microsecond' => 123456, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 13:14:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 12, 'minute' => 30, 'microsecond' => 123456, 'timezone' => 'Europe/Paris', + ], + new DateTime('2014-02-14 11:30:00.123456', 'UTC'), + ], + ]; + } + + /** + * test marshalling data. + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('marshalProvider')] + public function testMarshal($value, $expected): void + { + $result = $this->type->marshal($value); + if (is_object($expected)) { + $this->assertEquals($expected, $result); + } else { + $this->assertSame($expected, $result); + } + } + + /** + * Data provider for marshal() without microseconds + * + * @return array + */ + public static function marshalProviderWithoutMicroseconds(): array + { + return [ + // invalid types. + [null, null], + [false, null], + [true, null], + ['', null], + ['derpy', null], + ['2013-nope!', null], + + // valid string types + ['1392387900', new DateTime('@1392387900')], + [1392387900, new DateTime('@1392387900')], + ['2014-02-14 00:00:00', new DateTime('2014-02-14 00:00:00')], + ['2014-02-14 13:14:15', new DateTime('2014-02-14 13:14:15')], + ['2014-02-14T13:14:15', new DateTime('2014-02-14T13:14:15')], + ['2017-04-05T17:18:00+00:00', new DateTime('2017-04-05T17:18:00+00:00')], + + // valid array types + [ + ['year' => '', 'month' => '', 'day' => '', 'hour' => '', 'minute' => '', 'second' => ''], + null, + ], + [ + ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15], + new DateTime('2014-02-14 13:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'am', + ], + new DateTime('2014-02-14 01:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 12, 'minute' => 04, 'second' => 15, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 12:04:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 13:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + ], + new DateTime('2014-02-14 00:00:00'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 12, 'minute' => 30, 'timezone' => 'Europe/Paris', + ], + new DateTime('2014-02-14 11:30:00', 'UTC'), + ], + + // Invalid array types + [ + ['year' => 'farts', 'month' => 'derp'], + null, + ], + [ + ['year' => 'farts', 'month' => 'derp', 'day' => 'farts'], + null, + ], + [ + [ + 'year' => '2014', 'month' => '02', 'day' => '14', + 'hour' => 'farts', 'minute' => 'farts', + ], + null, + ], + ]; + } + + /** + * test marshalling data. + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('marshalProvider')] + public function testMarshalWithoutMicroseconds($value, $expected): void + { + $result = $this->type->marshal($value); + if (is_object($expected)) { + $this->assertEquals($expected, $result); + } else { + $this->assertSame($expected, $result); + } + } +} diff --git a/tests/TestCase/Database/Type/DateTimeTimezoneTypeTest.php b/tests/TestCase/Database/Type/DateTimeTimezoneTypeTest.php new file mode 100644 index 00000000000..f888acd60fd --- /dev/null +++ b/tests/TestCase/Database/Type/DateTimeTimezoneTypeTest.php @@ -0,0 +1,442 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\Type\DateTimeTimezoneType; +use Cake\I18n\DateTime; +use Cake\TestSuite\TestCase; +use DateTimeZone; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test for the DateTimeTimezone type. + */ +class DateTimeTimezoneTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\DateTimeTimezoneType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new DateTimeTimezoneType(); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHPEmpty(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + $this->assertNull($this->type->toPHP('0000-00-00 00:00:00', $this->driver)); + $this->assertNull($this->type->toPHP('0000-00-00 00:00:00.000000', $this->driver)); + $this->assertNull($this->type->toPHP('0000-00-00 00:00:00.000000+000', $this->driver)); + } + + /** + * Test toPHP + */ + public function testToPHPString(): void + { + $result = $this->type->toPHP('2001-01-04 12:13:14.123456+02:00', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('10', $result->format('H')); + $this->assertSame('13', $result->format('i')); + $this->assertSame('14', $result->format('s')); + $this->assertSame('123456', $result->format('u')); + $this->assertSame('+00:00', $result->format('P')); + + // test extra fractional second past microseconds being ignored + $result = $this->type->toPHP('2001-01-04 12:13:14.1234567+02:00', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('123456', $result->format('u')); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toPHP('2001-01-04 12:00:00.123456', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('06', $result->format('H')); + $this->assertSame('30', $result->format('i')); + $this->assertSame('00', $result->format('s')); + $this->assertSame('123456', $result->format('u')); + $this->assertSame('+00:00', $result->format('P')); + } + + /** + * Test toPHP keeping database time zone + */ + public function testToPHPStringKeepDatabaseTimezone(): void + { + $this->type->setKeepDatabaseTimezone(true); + $result = $this->type->toPHP('2001-01-04 12:13:14.123456+02:00', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('12', $result->format('H')); + $this->assertSame('13', $result->format('i')); + $this->assertSame('14', $result->format('s')); + $this->assertSame('123456', $result->format('u')); + $this->assertSame('+02:00', $result->format('P')); + $this->type->setKeepDatabaseTimezone(false); + } + + /** + * Test converting string datetimes to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => '2001-01-04 12:13:14', + 'c' => '2001-01-04 12:13:14.123', + 'd' => '2001-01-04 12:13:14.123456', + // test extra fractional second past microseconds being ignored + 'e' => '2001-01-04 12:13:14.1234567', + 'f' => '2001-01-04 12:13:14.123456+02:00', + ]; + $expected = [ + 'a' => null, + 'b' => new DateTime('2001-01-04 12:13:14'), + 'c' => new DateTime('2001-01-04 12:13:14.123'), + 'd' => new DateTime('2001-01-04 12:13:14.123456'), + 'e' => new DateTime('2001-01-04 12:13:14.123456'), + 'f' => new DateTime('2001-01-04 10:13:14.123456+00:00'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $values = [ + 'a' => null, + 'b' => '2001-01-04 12:13:14', + 'c' => '2001-01-04 12:13:14.123', + 'd' => '2001-01-04 12:13:14.123456', + ]; + $expected = [ + 'a' => null, + 'b' => new DateTime('2001-01-04 06:43:14'), + 'c' => new DateTime('2001-01-04 06:43:14.123'), + 'd' => new DateTime('2001-01-04 06:43:14.123456'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format with microseconds + */ + public function testToDatabase(): void + { + $value = '2001-01-04 12:13:14.123456'; + $result = $this->type->toDatabase($value, $this->driver); + $this->assertSame($value, $result); + + // test extra fractional second past microseconds being ignored + $date = new DateTime('2013-08-12 15:16:17.1234567'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.123456+00:00', $result); + + $date = new DateTime('2013-08-12 15:16:17.123456'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.123456+00:00', $result); + + $date = new DateTime('2013-08-12 15:16:17.123456+02:00'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.123456+02:00', $result); + + $date = new DateTime('2013-08-12 15:16:17.123456'); + + $tz = $date->getTimezone(); + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.123456+05:30', $result); + $this->assertEquals($tz, $date->getTimezone()); + + $this->type->setDatabaseTimezone(new DateTimeZone('Asia/Kolkata')); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.123456+05:30', $result); + $this->type->setDatabaseTimezone(null); + + $date = new DateTime('2013-08-12 15:16:17.123456'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.123456+00:00', $result); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.123456+05:30', $result); + $this->type->setDatabaseTimezone(null); + } + + /** + * Test converting to database format without microseconds + */ + public function testToDatabaseNoMicroseconds(): void + { + $date = new DateTime('2013-08-12 15:16:17'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.000000+00:00', $result); + + $date = new DateTime('2013-08-12 15:16:17+02:00'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.000000+02:00', $result); + + $date = new DateTime('2013-08-12 15:16:17'); + + $tz = $date->getTimezone(); + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.000000+05:30', $result); + $this->assertEquals($tz, $date->getTimezone()); + + $this->type->setDatabaseTimezone(new DateTimeZone('Asia/Kolkata')); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.000000+05:30', $result); + $this->type->setDatabaseTimezone(null); + + $date = new DateTime('2013-08-12 15:16:17'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17.000000+00:00', $result); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17.000000+05:30', $result); + $this->type->setDatabaseTimezone(null); + + $date = 1401906995; + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2014-06-04 18:36:35.000000+00:00', $result); + } + + /** + * Data provider for marshal() with microseconds + * + * @return array + */ + public static function marshalProvider(): array + { + return [ + // invalid types. + [null, null], + [false, null], + [true, null], + ['', null], + ['derpy', null], + ['2013-nope!', null], + ['2014-02-14T13:14:15.1234567', null], + ['2017-04-05T17:18:00.1234567+00:00', null], + + // valid string types + ['2014-02-14', new DateTime('2014-02-14 00:00:00')], + ['2014-02-14 12:02', new DateTime('2014-02-14 12:02')], + ['2014-02-14 12:02:12', new DateTime('2014-02-14 12:02:12')], + ['2014-02-14 00:00:00.123456', new DateTime('2014-02-14 00:00:00.123456')], + ['2014-02-14 13:14:15.123456', new DateTime('2014-02-14 13:14:15.123456')], + ['2014-02-14T13:14', new DateTime('2014-02-14T13:14:00')], + ['2014-02-14T13:14:12', new DateTime('2014-02-14T13:14:12')], + ['2014-02-14T13:14:15.123456', new DateTime('2014-02-14T13:14:15.123456')], + ['2017-04-05T17:18:00.123456+02:00', new DateTime('2017-04-05T17:18:00.123456+02:00')], + ['2017-04-05T17:18:00.123456+0200', new DateTime('2017-04-05T17:18:00.123456+02:00')], + ['2017-04-05T17:18:00.123456 Europe/Paris', new DateTime('2017-04-05T17:18:00.123456+02:00')], + + // valid array types + [ + ['year' => '', 'month' => '', 'day' => '', 'hour' => '', 'minute' => '', 'second' => '', 'microsecond' => ''], + null, + ], + [ + ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15, 'microsecond' => 123456], + new DateTime('2014-02-14 13:14:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, 'microsecond' => 123456, + 'meridian' => 'am', + ], + new DateTime('2014-02-14 01:14:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 12, 'minute' => 04, 'second' => 15, 'microsecond' => 123456, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 12:04:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, 'microsecond' => 123456, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 13:14:15.123456'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 12, 'minute' => 30, 'microsecond' => 123456, 'timezone' => 'Europe/Paris', + ], + new DateTime('2014-02-14 11:30:00.123456', 'UTC'), + ], + ]; + } + + /** + * test marshalling data. + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('marshalProvider')] + public function testMarshal($value, $expected): void + { + $result = $this->type->marshal($value); + if (is_object($expected)) { + $this->assertEquals($expected, $result); + } else { + $this->assertSame($expected, $result); + } + } + + /** + * Data provider for marshal() without microseconds + * + * @return array + */ + public static function marshalProviderWithoutMicroseconds(): array + { + return [ + // invalid types. + [null, null], + [false, null], + [true, null], + ['', null], + ['derpy', null], + ['2013-nope!', null], + + // valid string types + ['1392387900', new DateTime('@1392387900')], + [1392387900, new DateTime('@1392387900')], + ['2014-02-14', new DateTime('2014-02-14 00:00:00')], + ['2014-02-14 00:00:00', new DateTime('2014-02-14 00:00:00')], + ['2014-02-14 13:14:15', new DateTime('2014-02-14 13:14:15')], + ['2014-02-14T13:14:15', new DateTime('2014-02-14T13:14:15')], + ['2017-04-05T17:18:00+02:00', new DateTime('2017-04-05T17:18:00+02:00')], + + // valid array types + [ + ['year' => '', 'month' => '', 'day' => '', 'hour' => '', 'minute' => '', 'second' => ''], + null, + ], + [ + ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15], + new DateTime('2014-02-14 13:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'am', + ], + new DateTime('2014-02-14 01:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 12, 'minute' => 04, 'second' => 15, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 12:04:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 13:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + ], + new DateTime('2014-02-14 00:00:00'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 12, 'minute' => 30, 'timezone' => 'Europe/Paris', + ], + new DateTime('2014-02-14 11:30:00', 'UTC'), + ], + + // Invalid array types + [ + ['year' => 'farts', 'month' => 'derp'], + null, + ], + [ + ['year' => 'farts', 'month' => 'derp', 'day' => 'farts'], + null, + ], + [ + [ + 'year' => '2014', 'month' => '02', 'day' => '14', + 'hour' => 'farts', 'minute' => 'farts', + ], + null, + ], + ]; + } + + /** + * test marshalling data. + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('marshalProviderWithoutMicroseconds')] + public function testMarshalWithoutMicroseconds($value, $expected): void + { + $result = $this->type->marshal($value); + if (is_object($expected)) { + $this->assertEquals($expected, $result); + } else { + $this->assertSame($expected, $result); + } + } +} diff --git a/tests/TestCase/Database/Type/DateTimeTypeTest.php b/tests/TestCase/Database/Type/DateTimeTypeTest.php new file mode 100644 index 00000000000..a4b9c5f59bc --- /dev/null +++ b/tests/TestCase/Database/Type/DateTimeTypeTest.php @@ -0,0 +1,434 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Chronos\ChronosDate; +use Cake\Database\Driver; +use Cake\Database\Type\DateTimeType; +use Cake\I18n\Date; +use Cake\I18n\DateTime; +use Cake\TestSuite\TestCase; +use DateTime as NativeDateTime; +use DateTimeImmutable; +use DateTimeZone; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test for the DateTime type. + */ +class DateTimeTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\DateTimeType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Original type map + * + * @var array + */ + protected $_originalMap = []; + + /** + * @var string + */ + protected $originalTimeZone; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new DateTimeType(); + $this->driver = $this->createStub(Driver::class); + + $this->originalTimeZone = date_default_timezone_get(); + } + + /** + * Reset timezone to its initial value + * + * @return void + */ + protected function tearDown(): void + { + parent::tearDown(); + date_default_timezone_set($this->originalTimeZone); + } + + /** + * Test getDateTimeClassName + */ + public function testGetDateTimeClassName(): void + { + $this->assertSame(DateTime::class, $this->type->getDateTimeClassName()); + } + + /** + * Test toPHP + */ + public function testToPHPEmpty(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + $this->assertNull($this->type->toPHP('0000-00-00 00:00:00', $this->driver)); + } + + /** + * Test toPHP + */ + public function testToPHPString(): void + { + $result = $this->type->toPHP('2001-01-04 12:13:14', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('12', $result->format('H')); + $this->assertSame('13', $result->format('i')); + $this->assertSame('14', $result->format('s')); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toPHP('2001-01-04 12:00:00', $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + $this->assertSame('06', $result->format('H')); + $this->assertSame('30', $result->format('i')); + $this->assertSame('00', $result->format('s')); + } + + /** + * Test converting string datetimes to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => 978610394, + 'c' => '2001-01-04 12:13:14', + ]; + $expected = [ + 'a' => null, + 'b' => new DateTime('2001-01-04 12:13:14'), + 'c' => new DateTime('2001-01-04 12:13:14'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $values = [ + 'a' => null, + 'b' => '2001-01-04 12:13:14', + ]; + $expected = [ + 'a' => null, + 'b' => new DateTime('2001-01-04 06:43:14'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test datetime parsing when value include milliseconds. + * + * Postgres includes milliseconds in timestamp columns, + * data from those columns should work. + */ + public function testToPHPIncludingMilliseconds(): void + { + $in = '2014-03-24 20:44:36.315113'; + $result = $this->type->toPHP($in, $this->driver); + $this->assertInstanceOf(DateTime::class, $result); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $value = '2001-01-04 12:13:14'; + $result = $this->type->toDatabase($value, $this->driver); + $this->assertSame($value, $result); + + $date = new DateTime('2013-08-12 15:16:17'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17', $result); + + $tz = $date->getTimezone(); + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17', $result); + $this->assertEquals($tz, $date->getTimezone()); + + $this->type->setDatabaseTimezone(new DateTimeZone('Asia/Kolkata')); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17', $result); + $this->type->setDatabaseTimezone(null); + + $date = new DateTime('2013-08-12 15:16:17'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 15:16:17', $result); + + $this->type->setDatabaseTimezone('Asia/Kolkata'); // UTC+5:30 + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12 20:46:17', $result); + $this->type->setDatabaseTimezone(null); + + $date = 1401906995; + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2014-06-04 18:36:35', $result); + + $date = new Date('2024-01-27'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2024-01-27 00:00:00', $result); + } + + /** + * Data provider for marshal() + * + * @return array + */ + public static function marshalProvider(): array + { + return [ + // invalid types. + [null, null], + [false, null], + [true, null], + ['', null], + ['derpy', null], + ['2013-nope!', null], + + // valid string types + ['1392387900', new DateTime('@1392387900')], + [1392387900, new DateTime('@1392387900')], + ['2014-02-14', new DateTime('2014-02-14 00:00:00')], + ['2014-02-14 12:02', new DateTime('2014-02-14 12:02')], + ['2014-02-14 00:00:00', new DateTime('2014-02-14 00:00:00')], + ['2014-02-14 13:14:15', new DateTime('2014-02-14 13:14:15')], + ['2014-02-14T13:14', new DateTime('2014-02-14T13:14:00')], + ['2014-02-14T13:14:15', new DateTime('2014-02-14T13:14:15')], + ['2017-04-05T17:18:00+00:00', new DateTime('2017-04-05T17:18:00+00:00')], + ['2017-04-05T17:18:00+00:00', new DateTime('2017-04-05T17:18:00+00:00')], + ['2024-03-02 15:46:00.000000', new DateTime('2024-03-02T15:46:00+00:00')], + + [new DateTime('2017-04-05T17:18:00+00:00'), new DateTime('2017-04-05T17:18:00+00:00')], + [new NativeDateTime('2017-04-05T17:18:00+00:00'), new NativeDateTime('2017-04-05T17:18:00+00:00')], + [new DateTimeImmutable('2017-04-05T17:18:00+00:00'), new DateTimeImmutable('2017-04-05T17:18:00+00:00')], + + // valid array types + [ + ['year' => '', 'month' => '', 'day' => '', 'hour' => '', 'minute' => '', 'second' => ''], + null, + ], + [ + ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15], + new DateTime('2014-02-14 13:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'am', + ], + new DateTime('2014-02-14 01:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 12, 'minute' => 04, 'second' => 15, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 12:04:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'pm', + ], + new DateTime('2014-02-14 13:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + ], + new DateTime('2014-02-14 00:00:00'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 12, 'minute' => 30, 'timezone' => 'Europe/Paris', + ], + new DateTime('2014-02-14 11:30:00', 'UTC'), + ], + + // Invalid array types + [ + ['year' => 'farts', 'month' => 'derp'], + null, + ], + [ + ['year' => 'farts', 'month' => 'derp', 'day' => 'farts'], + null, + ], + [ + [ + 'year' => '2014', 'month' => '02', 'day' => '14', + 'hour' => 'farts', 'minute' => 'farts', + ], + null, + ], + ]; + } + + /** + * test marshalling data. + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('marshalProvider')] + public function testMarshal($value, $expected): void + { + $result = $this->type->marshal($value); + if (is_object($expected)) { + $this->assertEquals($expected, $result); + } else { + $this->assertSame($expected, $result); + } + } + + /** + * test marshalling data with different timezone + */ + public function testMarshalWithTimezone(): void + { + date_default_timezone_set('Europe/Vienna'); + $value = DateTime::now(); + $result = $this->type->marshal($value); + $this->assertEquals($value, $result); + } + + /** + * Test that the marshalled datetime instance always has the system's default timezone. + */ + public function testMarshalDateTimeInstance(): void + { + $expected = new DateTime('2020-05-01 23:28:00', 'Europe/Paris'); + + $result = $this->type->marshal($expected); + $this->assertEquals('UTC', $result->getTimezone()->getName()); + $this->assertEquals($expected->toDateTimeString(), $result->addHours(2)->toDateTimeString()); + $this->assertEquals('Europe/Paris', $expected->getTimezone()->getName()); + } + + public function testMarshalWithUserTimezone(): void + { + $this->type->setUserTimezone('+0200'); + + $value = '2020-05-01 23:28:00'; + $expected = new DateTime($value); + + $result = $this->type->marshal($value); + $this->assertEquals('UTC', $result->getTimezone()->getName()); + $this->assertEquals($expected, $result->addHours(2)); + + $expected = new DateTime('2020-05-01 21:28:00', 'UTC'); + $result = $this->type->marshal([ + 'year' => 2020, 'month' => 5, 'day' => 1, + 'hour' => 23, 'minute' => 28, 'second' => 0, + ]); + $this->assertEquals('UTC', $result->getTimezone()->getName()); + $this->assertEquals($expected, $result); + + $this->type->setUserTimezone(null); + } + + /** + * Test that useLocaleParser() can disable locale parsing. + */ + public function testLocaleParserDisable(): void + { + $expected = new DateTime('13-10-2013 23:28:00'); + $this->type->useLocaleParser(); + $result = $this->type->marshal('10/13/2013 11:28pm'); + $this->assertEquals($expected, $result); + + $this->type->useLocaleParser(false); + $result = $this->type->marshal('10/13/2013 11:28pm'); + $this->assertNotEquals($expected, $result); + } + + /** + * Tests marshalling dates using the locale aware parser + */ + public function testMarshalWithLocaleParsing(): void + { + $this->type->useLocaleParser(); + + $expected = new DateTime('13-10-2013 23:28:00'); + $result = $this->type->marshal('10/13/2013 11:28pm'); + $this->assertEquals($expected, $result); + + $this->assertNull($this->type->marshal('11/derp/2013 11:28pm')); + + $this->type->setUserTimezone('+0200'); + $result = $this->type->marshal('10/13/2013 11:28pm'); + $this->assertEquals('UTC', $result->getTimezone()->getName()); + $this->assertEquals($expected, $result->addHours(2)); + $this->type->setUserTimezone(null); + + $this->type->useLocaleParser(false); + } + + /** + * Tests marshalling dates using the locale aware parser and custom format + */ + public function testMarshalWithLocaleParsingWithFormat(): void + { + $this->type->useLocaleParser()->setLocaleFormat('dd MMM, y hh:mma'); + + $expected = new DateTime('13-10-2013 13:54:00'); + $result = $this->type->marshal('13 Oct, 2013 01:54pm'); + $this->assertEquals($expected, $result); + } + + /** + * Test marshaling date into datetime type + */ + public function testMarshalDateWithTimezone(): void + { + date_default_timezone_set('Europe/Vienna'); + $value = new ChronosDate('2023-04-26'); + + $result = $this->type->marshal($value); + $this->assertEquals($value->format('Y-m-d'), $result->format('Y-m-d')); + $this->assertEquals('Europe/Vienna', $result->getTimezone()->getName()); + } +} diff --git a/tests/TestCase/Database/Type/DateTypeTest.php b/tests/TestCase/Database/Type/DateTypeTest.php new file mode 100644 index 00000000000..d1828b9c443 --- /dev/null +++ b/tests/TestCase/Database/Type/DateTypeTest.php @@ -0,0 +1,269 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Chronos\ChronosDate; +use Cake\Database\Driver; +use Cake\Database\Type\DateType; +use Cake\I18n\Date; +use Cake\I18n\DateTime; +use Cake\TestSuite\TestCase; +use DateTime as NativeDateTime; +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test for the Date type. + */ +class DateTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\DateType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * @var string + */ + protected $originalTimeZone; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new DateType(); + $this->driver = $this->createStub(Driver::class); + + $this->originalTimeZone = date_default_timezone_get(); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + date_default_timezone_set($this->originalTimeZone); + } + + /** + * Test getDateClassName + */ + public function testGetDateClassName(): void + { + $this->assertSame(Date::class, $this->type->getDateClassName()); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + $this->assertNull($this->type->toPHP('0000-00-00', $this->driver)); + + $result = $this->type->toPHP('2001-01-04', $this->driver); + $this->assertInstanceOf(Date::class, $result); + $this->assertSame('2001', $result->format('Y')); + $this->assertSame('01', $result->format('m')); + $this->assertSame('04', $result->format('d')); + } + + /** + * Test converting string dates to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => 978606794, + 'c' => '2001-01-04', + 'd' => '2001-01-04 12:13:14.12345', + ]; + $expected = [ + 'a' => null, + 'b' => new Date('2001-01-04'), + 'c' => new Date('2001-01-04'), + 'd' => new Date('2001-01-04'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $value = '2001-01-04'; + $result = $this->type->toDatabase($value, $this->driver); + $this->assertSame($value, $result); + + $date = new Date('2013-08-12'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12', $result); + + $date = new DateTime('2013-08-12 15:16:18'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('2013-08-12', $result); + } + + /** + * Data provider for marshal() + * + * @return array + */ + public static function marshalProvider(): array + { + $date = new Date('@1392387900'); + $data = [ + // invalid types. + [null, null], + [false, null], + [true, null], + ['', null], + ['derpy', null], + ['2013-nope!', null], + ['2014-02-14 13:14:15', null], + + // valid string types + ['1392387900', $date], + [1392387900, $date], + ['2014-02-14', new Date('2014-02-14')], + + [new Date('2014-02-14'), new Date('2014-02-14')], + [new NativeDateTime('2014-02-14'), new Date('2014-02-14')], + [new DateTimeImmutable('2014-02-14'), new Date('2014-02-14')], + + // valid array types + [ + ['year' => '', 'month' => '', 'day' => ''], + null, + ], + [ + ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15], + new Date('2014-02-14'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'am', + ], + new Date('2014-02-14'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'pm', + ], + new Date('2014-02-14'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + ], + new Date('2014-02-14'), + ], + [ + [ + 'year' => '2014', 'month' => '02', 'day' => '14', + 'hour' => 'farts', 'minute' => 'farts', + ], + new Date('2014-02-14'), + ], + // [ + // new ChronosDate('2023-04-26'), + // new ChronosDate('2023-04-26'), + // ], + + // Invalid array types + [ + ['year' => 'farts', 'month' => 'derp'], + null, + ], + [ + ['year' => 'farts', 'month' => 'derp', 'day' => 'farts'], + null, + ], + ]; + + return $data; + } + + /** + * test marshaling data. + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('marshalProvider')] + public function testMarshal($value, $expected): void + { + $result = $this->type->marshal($value); + $this->assertEquals($expected, $result); + } + + /** + * test marshaling data with different timezone + */ + public function testMarshalWithTimezone(): void + { + date_default_timezone_set('Europe/Vienna'); + $value = new Date('2023-04-26'); + $expected = new Date('2023-04-26'); + + $result = $this->type->marshal($value); + $this->assertEquals($expected, $result); + } + + /** + * Tests marshalling dates using the locale aware parser + */ + public function testMarshalWithLocaleParsing(): void + { + $this->type->useLocaleParser(); + $this->assertNull($this->type->marshal('11/derp/2013')); + + $expected = new ChronosDate('13-10-2013'); + $result = $this->type->marshal('10/13/2013'); + $this->assertSame($expected->format('Y-m-d'), $result->format('Y-m-d')); + } + + /** + * Tests marshalling dates using the locale aware parser and custom format + */ + public function testMarshalWithLocaleParsingWithFormat(): void + { + $this->type->useLocaleParser()->setLocaleFormat('dd MMM, y'); + $this->assertNull($this->type->marshal('11/derp/2013')); + + $expected = new ChronosDate('13-10-2013'); + $result = $this->type->marshal('13 Oct, 2013'); + $this->assertSame($expected->format('Y-m-d'), $result->format('Y-m-d')); + } +} diff --git a/tests/TestCase/Database/Type/DecimalTypeTest.php b/tests/TestCase/Database/Type/DecimalTypeTest.php new file mode 100644 index 00000000000..8d760044928 --- /dev/null +++ b/tests/TestCase/Database/Type/DecimalTypeTest.php @@ -0,0 +1,247 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Core\Exception\CakeException; +use Cake\Database\Driver; +use Cake\Database\Type\DecimalType; +use Cake\I18n\I18n; +use Cake\Test\TestCase\Database\Driver\BaseDriverTrait; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PDO; + +/** + * Test for the Decimal type. + */ +class DecimalTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\DecimalType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * @var string + */ + protected $numberClass; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new DecimalType(); + $this->driver = new class extends Driver { + use BaseDriverTrait; + }; + $this->numberClass = DecimalType::$numberClass; + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + I18n::setLocale(I18n::getDefaultLocale()); + DecimalType::$numberClass = $this->numberClass; + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + + $result = $this->type->toPHP('2', $this->driver); + $this->assertSame('2', $result); + + $result = $this->type->toPHP('15.3', $this->driver); + $this->assertSame('15.3', $result); + } + + /** + * Test converting string decimals to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => '2.3', + 'c' => '15', + 'd' => '0.0', + ]; + $expected = [ + 'a' => null, + 'b' => 2.3, + 'c' => 15, + 'd' => 0.0, + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $result = $this->type->toDatabase('', $this->driver); + $this->assertNull($result); + + $result = $this->type->toDatabase(null, $this->driver); + $this->assertNull($result); + + $result = $this->type->toDatabase(2, $this->driver); + $this->assertSame(2, $result); + + $result = $this->type->toDatabase(2.99, $this->driver); + $this->assertSame(2.99, $result); + + $result = $this->type->toDatabase('2.51', $this->driver); + $this->assertSame('2.51', $result); + + $result = $this->type->toDatabase(0.123456789, $this->driver); + $this->assertSame(0.123456789, $result); + + $result = $this->type->toDatabase('1234567890123456789.2', $this->driver); + $this->assertSame('1234567890123456789.2', $result); + + $result = $this->type->toDatabase(1234567890123456789.2, $this->driver); + $this->assertSame('1.2345678901235E+18', (string)$result); + } + + /** + * Arrays are invalid. + */ + public function testToDatabaseInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->type->toDatabase(['3', '4'], $this->driver); + } + + /** + * Non numeric strings are invalid. + */ + public function testToDatabaseInvalid2(): void + { + $this->expectException(InvalidArgumentException::class); + $this->type->toDatabase('some data', $this->driver); + } + + /** + * Test marshalling + */ + public function testMarshal(): void + { + $result = $this->type->marshal('some data'); + $this->assertNull($result); + + $result = $this->type->marshal(''); + $this->assertNull($result); + + $result = $this->type->marshal('2.51'); + $this->assertSame('2.51', $result); + + // allow custom decimal format (https://github.com/cakephp/cakephp/issues/12800) + $result = $this->type->marshal('1 230,73'); + $this->assertSame('1 230,73', $result); + + $result = $this->type->marshal('3.5 bears'); + $this->assertNull($result); + + $result = $this->type->marshal(['3', '4']); + $this->assertNull($result); + + $result = $this->type->marshal('0.1234567890123456789'); + $this->assertSame('0.1234567890123456789', $result); + + // This test is to indicate the problem that will occur if you use + // float/double values which get converted to scientific notation by PHP. + // To avoid this problem always using strings to indicate decimals values. + $result = $this->type->marshal(1234567890123456789.2); + $this->assertSame('1.2345678901235E+18', $result); + } + + /** + * Tests marshalling numbers using the locale aware parser + */ + public function testMarshalWithLocaleParsing(): void + { + $this->type->useLocaleParser(); + + I18n::setLocale('de_DE'); + $expected = '1234.53'; + $result = $this->type->marshal('1.234,53'); + $this->assertSame($expected, $result); + + I18n::setLocale('en_US'); + $expected = '1234'; + $result = $this->type->marshal('1,234'); + $this->assertSame($expected, $result); + + I18n::setLocale('pt_BR'); + $expected = '5987123.231'; + $result = $this->type->marshal('5.987.123,231'); + $this->assertSame($expected, $result); + + $this->type->useLocaleParser(false); + } + + /** + * test marshal() number in the danish locale which uses . for thousands separator. + */ + public function testMarshalWithLocaleParsingDanish(): void + { + $this->type->useLocaleParser(); + + I18n::setLocale('da_DK'); + $expected = '47500'; + $result = $this->type->marshal('47.500'); + $this->assertSame($expected, $result); + + $this->type->useLocaleParser(false); + } + + /** + * Test that exceptions are raised on invalid parsers. + */ + public function testUseLocaleParsingInvalid(): void + { + $this->expectException(CakeException::class); + DecimalType::$numberClass = 'stdClass'; + $this->type->useLocaleParser(); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_STR, $this->type->toStatement('', $this->driver)); + } +} diff --git a/tests/TestCase/Database/Type/EnumTypeTest.php b/tests/TestCase/Database/Type/EnumTypeTest.php new file mode 100644 index 00000000000..45a8036a187 --- /dev/null +++ b/tests/TestCase/Database/Type/EnumTypeTest.php @@ -0,0 +1,329 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Exception\DatabaseException; +use Cake\Database\Type\EnumType; +use Cake\Database\TypeFactory; +use Cake\Datasource\ConnectionManager; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PDO; +use TestApp\Model\Entity\Article; +use TestApp\Model\Enum\ArticleStatus; +use TestApp\Model\Enum\Gender; +use TestApp\Model\Enum\NonBacked; +use TestApp\Model\Enum\Priority; +use ValueError; + +/** + * Test for the String type. + */ +class EnumTypeTest extends TestCase +{ + /** + * @inheritDoc + */ + protected array $fixtures = ['core.Articles', 'core.FeaturedTags']; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Original type map + * + * @var array + */ + protected $_originalMap; + + /** + * @var \Cake\Database\Type\EnumType + */ + protected $stringType; + + /** + * @var \Cake\Database\Type\EnumType + */ + protected $intType; + + /** + * @var \Cake\ORM\Table + */ + protected $Articles; + + /** + * @var \Cake\ORM\Table + */ + protected $FeaturedTags; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->driver = ConnectionManager::get('test')->getDriver(); + + $this->_originalMap = TypeFactory::getMap(); + $this->stringType = TypeFactory::build(EnumType::from(ArticleStatus::class)); + $this->intType = TypeFactory::build(EnumType::from(Priority::class)); + + $this->Articles = $this->getTableLocator()->get('Articles'); + $this->Articles->getSchema()->setColumnType('published', EnumType::from(ArticleStatus::class)); + + $this->FeaturedTags = $this->getTableLocator()->get('FeaturedTags'); + $this->FeaturedTags->getSchema()->setColumnType('priority', EnumType::from(Priority::class)); + } + + /** + * Restores Type class state + */ + protected function tearDown(): void + { + parent::tearDown(); + + TypeFactory::setMap($this->_originalMap); + } + + /** + * Check that 2nd argument must be a valid backed enum + */ + public function testInvalidEnumClass(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unable to use `TestApp\Model\Entity\Article` for type `invalid`. Class "TestApp\Model\Entity\Article" is not an enum'); + new EnumType('invalid', Article::class); + } + + /** + * Check that 2nd argument must be a valid backed enum + */ + public function testInvalidEnumInvalidClass(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unable to use `App\Foo` for type `invalid`. Class "App\Foo" does not exist'); + new EnumType('invalid', 'App\Foo'); + } + + /** + * Check that 2nd argument must be a valid backed enum + */ + public function testInvalidEnumWithoutBackingType(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unable to use enum `TestApp\Model\Enum\NonBacked` for type `invalid`, must be a backed enum'); + + new EnumType('invalid', NonBacked::class); + } + + /** + * Check get enum class string + */ + public function testGetEnumClassString(): void + { + $this->assertSame(ArticleStatus::class, $this->stringType->getEnumClassName()); + $this->assertSame(Priority::class, $this->intType->getEnumClassName()); + } + + /** + * Test converting enums to database format + */ + public function testToDatabaseEnum(): void + { + $this->assertNull($this->stringType->toDatabase(null, $this->driver)); + $this->assertSame('Y', $this->stringType->toDatabase(ArticleStatus::Published, $this->driver)); + $this->assertSame(3, $this->intType->toDatabase(Priority::High, $this->driver)); + } + + public function testToDatabaseValidValue(): void + { + $this->assertSame('Y', $this->stringType->toDatabase(ArticleStatus::Published->value, $this->driver)); + $this->assertSame(3, $this->intType->toDatabase(Priority::High->value, $this->driver)); + $this->assertSame(3, $this->intType->toDatabase('3', $this->driver)); + } + + public function testToDatabaseInValidValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`invalid` is not a valid value for `TestApp\Model\Enum\ArticleStatus`'); + $this->stringType->toDatabase('invalid', $this->driver); + } + + public function testToDatabaseInValidValueType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Given value `1` of type `int` does not match associated `string` backed enum in `TestApp\Model\Enum\ArticleStatus`'); + $this->stringType->toDatabase(1, $this->driver); + } + + /** + * Test toPHP with string backed enum + */ + public function testToPHPStringEnum(): void + { + $this->assertNull($this->stringType->toPHP(null, $this->driver)); + $this->assertSame(ArticleStatus::Published, $this->stringType->toPHP('Y', $this->driver)); + } + + /** + * Test toPHP with integer backed enum + */ + public function testToPHPIntEnum(): void + { + $this->assertNull($this->intType->toPHP(null, $this->driver)); + $this->assertSame(Priority::High, $this->intType->toPHP(3, $this->driver)); + $this->assertSame(Priority::High, $this->intType->toPHP('3', $this->driver)); + } + + public function testToPHPInvalidEnumValue(): void + { + $this->expectException(ValueError::class); + $this->stringType->toPHP('Z', $this->driver); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_STR, $this->stringType->toStatement('Y', $this->driver)); + $this->assertSame(PDO::PARAM_INT, $this->intType->toStatement(1, $this->driver)); + } + + /** + * Test marshalling with string backed enum + */ + public function testMarshalString(): void + { + $this->assertNull($this->stringType->marshal(null)); + $this->assertNull($this->stringType->marshal('')); + $this->assertSame(ArticleStatus::Published, $this->stringType->marshal('Y')); + $this->assertSame(ArticleStatus::Published, $this->stringType->marshal(ArticleStatus::Published)); + + $genderType = TypeFactory::build(EnumType::from(Gender::class)); + $this->assertSame(Gender::NoSelection, $genderType->marshal('')); + + // Invalid value returns null + $this->assertNull($this->stringType->marshal('X')); + $this->assertNull($this->stringType->marshal(1)); + } + + /** + * Test marshalling with integer backed enum + */ + public function testMarshalInteger(): void + { + $this->assertNull($this->intType->marshal(null)); + $this->assertNull($this->intType->marshal('')); + $this->assertSame(Priority::Low, $this->intType->marshal(1)); + $this->assertSame(Priority::Low, $this->intType->marshal('1')); + $this->assertSame(Priority::Medium, $this->intType->marshal(Priority::Medium)); + + // Invalid value returns null + $this->assertNull($this->intType->marshal(10)); + $this->assertNull($this->intType->marshal('Y')); + } + + /** + * Check adding entity fields with a string backed enum instance + */ + public function testDtringEnumField(): void + { + /** @var \TestApp\Model\Entity\Article $entity */ + $entity = $this->Articles->newEntity([ + 'author_id' => 1, + 'title' => 'My Title', + 'body' => 'My post', + 'published' => ArticleStatus::Published, + ]); + $saved = $this->Articles->save($entity); + $this->assertNotFalse($saved); + $this->assertSame(ArticleStatus::Published, $entity->published); + + $this->assertSame(ArticleStatus::Published, $this->Articles->get(4)->published); + } + + /** + * Check adding entity fields with scalar value representing string backed enum + */ + public function testStringEnumFieldWithBackingType(): void + { + /** @var \TestApp\Model\Entity\Article $entity */ + $entity = $this->Articles->newEntity([ + 'author_id' => 1, + 'title' => 'My Title', + 'body' => 'My post', + 'published' => 'Y', + ]); + $saved = $this->Articles->save($entity); + $this->assertNotFalse($saved); + $this->assertSame(ArticleStatus::Published, $entity->published); + + $this->assertSame(ArticleStatus::Published, $this->Articles->get(4)->published); + } + + /** + * Check adding entity fields with an integer backed enum instance + */ + public function testIntEnumField(): void + { + /** @var \Cake\Datasource\EntityInterface $entity */ + $entity = $this->FeaturedTags->newEntity([ + 'tag_id' => 4, + 'priority' => Priority::Medium, + ]); + $saved = $this->FeaturedTags->save($entity); + $this->assertNotFalse($saved); + $this->assertSame(Priority::Medium, $entity->priority); + + $this->assertSame(Priority::Medium, $this->FeaturedTags->get(4)->priority); + } + + /** + * Check adding entity fields with scalar value representing integer backed enum + */ + public function testIntEnumFieldWithBackingType(): void + { + /** @var \Cake\Datasource\EntityInterface $entity */ + $entity = $this->FeaturedTags->newEntity([ + 'tag_id' => 4, + 'priority' => 2, + ]); + $saved = $this->FeaturedTags->save($entity); + $this->assertNotFalse($saved); + $this->assertSame(Priority::Medium, $entity->priority); + + $this->assertSame(Priority::Medium, $this->FeaturedTags->get(4)->priority); + } + + /** + * Check updating an entity via an string enum instance + */ + public function testUpdateEnumField(): void + { + $this->assertSame(ArticleStatus::Published, $this->Articles->get(1)->published); + + $entity = $this->Articles->get(1); + $entity->published = ArticleStatus::Unpublished; + $this->Articles->save($entity); + $this->assertSame(ArticleStatus::Unpublished, $entity->published); + + $this->assertSame(ArticleStatus::Unpublished, $this->Articles->get(1)->published); + } +} diff --git a/tests/TestCase/Database/Type/FloatTypeTest.php b/tests/TestCase/Database/Type/FloatTypeTest.php new file mode 100644 index 00000000000..805f65ba663 --- /dev/null +++ b/tests/TestCase/Database/Type/FloatTypeTest.php @@ -0,0 +1,195 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\Exception\DatabaseException; +use Cake\Database\Type\FloatType; +use Cake\I18n\I18n; +use Cake\TestSuite\TestCase; +use PDO; + +/** + * Test for the Float type. + */ +class FloatTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\FloatType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * @var string + */ + protected $numberClass; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new FloatType(); + $this->driver = $this->createStub(Driver::class); + $this->numberClass = FloatType::$numberClass; + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + I18n::setLocale(I18n::getDefaultLocale()); + FloatType::$numberClass = $this->numberClass; + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + + $result = $this->type->toPHP('2', $this->driver); + $this->assertSame(2.0, $result); + + $result = $this->type->toPHP('15.3', $this->driver); + $this->assertSame(15.3, $result); + } + + /** + * Test converting string float to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => '2.3', + 'c' => '15', + 'd' => '0.0', + ]; + $expected = [ + 'a' => null, + 'b' => 2.3, + 'c' => 15, + 'd' => 0.0, + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $result = $this->type->toDatabase('', $this->driver); + $this->assertNull($result); + + $result = $this->type->toDatabase(null, $this->driver); + $this->assertNull($result); + + $result = $this->type->toDatabase('some data', $this->driver); + $this->assertSame(0.0, $result); + + $result = $this->type->toDatabase(2, $this->driver); + $this->assertSame(2.0, $result); + + $result = $this->type->toDatabase('2.51', $this->driver); + $this->assertSame(2.51, $result); + + $result = $this->type->toDatabase(['3', '4'], $this->driver); + $this->assertSame(1.0, $result); + } + + /** + * Test marshalling + */ + public function testMarshal(): void + { + $result = $this->type->marshal('some data'); + $this->assertNull($result); + + $result = $this->type->marshal(''); + $this->assertNull($result); + + $result = $this->type->marshal('2.51'); + $this->assertSame(2.51, $result); + + // allow custom decimal format (https://github.com/cakephp/cakephp/issues/12800) + $result = $this->type->marshal('1 230,73'); + $this->assertSame('1 230,73', $result); + + $result = $this->type->marshal('3.5 bears'); + $this->assertNull($result); + + $result = $this->type->marshal(['3', '4']); + $this->assertNull($result); + } + + /** + * Tests marshalling numbers using the locale aware parser + */ + public function testMarshalWithLocaleParsing(): void + { + $this->type->useLocaleParser(); + + I18n::setLocale('de_DE'); + $expected = 1234.53; + $result = $this->type->marshal('1.234,53'); + $this->assertSame($expected, $result); + + I18n::setLocale('en_US'); + $expected = 1234.0; + $result = $this->type->marshal('1,234'); + $this->assertSame($expected, $result); + + I18n::setLocale('pt_BR'); + $expected = 5987123.231; + $result = $this->type->marshal('5.987.123,231'); + $this->assertSame($expected, $result); + + $this->type->useLocaleParser(false); + } + + /** + * Test that exceptions are raised on invalid parsers. + */ + public function testUseLocaleParsingInvalid(): void + { + $this->expectException(DatabaseException::class); + FloatType::$numberClass = 'stdClass'; + $this->type->useLocaleParser(); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_STR, $this->type->toStatement('', $this->driver)); + } +} diff --git a/tests/TestCase/Database/Type/IntegerTypeTest.php b/tests/TestCase/Database/Type/IntegerTypeTest.php new file mode 100644 index 00000000000..398a6cb4b66 --- /dev/null +++ b/tests/TestCase/Database/Type/IntegerTypeTest.php @@ -0,0 +1,210 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\TypeFactory; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PDO; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test for the Integer type. + */ +class IntegerTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\IntegerType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = TypeFactory::build('integer'); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + + $result = $this->type->toPHP('2', $this->driver); + $this->assertSame(2, $result); + + $result = $this->type->toPHP('2.3', $this->driver); + $this->assertSame(2, $result); + + $result = $this->type->toPHP('-2', $this->driver); + $this->assertSame(-2, $result); + + $result = $this->type->toPHP(10, $this->driver); + $this->assertSame(10, $result); + } + + /** + * Test converting string float to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => '2.3', + 'c' => '15', + 'd' => '0.0', + 'e' => 10, + ]; + $expected = [ + 'a' => null, + 'b' => 2, + 'c' => 15, + 'd' => 0, + 'e' => 10, + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test to make sure the method throws an exception for invalid integer values. + */ + public function testInvalidManyToPHP(): void + { + $this->expectException(InvalidArgumentException::class); + $values = [ + 'a' => null, + 'b' => '2.3', + 'c' => '15', + 'd' => '0.0', + 'e' => 10, + 'f' => '6a88accf-a34e-4dd9-ade0-8d255ccaecbe', + ]; + $expected = [ + 'a' => null, + 'b' => 2, + 'c' => 15, + 'd' => 0, + 'e' => 10, + 'f' => '6a88accf-a34e-4dd9-ade0-8d255ccaecbe', + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $this->assertNull($this->type->toDatabase(null, $this->driver)); + + $result = $this->type->toDatabase(2, $this->driver); + $this->assertSame(2, $result); + + $result = $this->type->toDatabase('2', $this->driver); + $this->assertSame(2, $result); + } + + /** + * Invalid Integer Data Provider + * + * @return array + */ + public static function invalidIntegerProvider(): array + { + return [ + 'array' => [['3', '4']], + 'non-numeric-string' => ['some-data'], + 'uuid' => ['6a88accf-a34e-4dd9-ade0-8d255ccaecbe'], + ]; + } + + /** + * Tests that passing an invalid value will throw an exception + * + * @param mixed $value Invalid value to test against the database type. + */ + #[DataProvider('invalidIntegerProvider')] + public function testToDatabaseInvalid($value): void + { + $this->expectException(InvalidArgumentException::class); + $this->type->toDatabase($value, $this->driver); + } + + /** + * Test marshalling + */ + public function testMarshal(): void + { + $result = $this->type->marshal('some data'); + $this->assertNull($result); + + $result = $this->type->marshal(''); + $this->assertNull($result); + + $result = $this->type->marshal('0'); + $this->assertSame(0, $result); + + $result = $this->type->marshal('105'); + $this->assertSame(105, $result); + + $result = $this->type->marshal(105); + $this->assertSame(105, $result); + + $result = $this->type->marshal('-105'); + $this->assertSame(-105, $result); + + $result = $this->type->marshal(-105); + $this->assertSame(-105, $result); + + $result = $this->type->marshal('1.25'); + $this->assertSame(1, $result); + + $result = $this->type->marshal('2 monkeys'); + $this->assertNull($result); + + $result = $this->type->marshal(['3', '4']); + $this->assertNull($result); + + $result = $this->type->marshal('+0123.45e2'); + $this->assertSame(12345, $result); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_INT, $this->type->toStatement('', $this->driver)); + } +} diff --git a/tests/TestCase/Database/Type/JsonTypeTest.php b/tests/TestCase/Database/Type/JsonTypeTest.php new file mode 100644 index 00000000000..d27332e9a9d --- /dev/null +++ b/tests/TestCase/Database/Type/JsonTypeTest.php @@ -0,0 +1,158 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\Type\JsonType; +use Cake\Database\TypeFactory; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PDO; +use stdClass; + +/** + * Test for the String type. + */ +class JsonTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\JsonType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = TypeFactory::build('json'); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + $this->assertSame('word', $this->type->toPHP(json_encode('word'), $this->driver)); + $this->assertSame(2.123, $this->type->toPHP(json_encode(2.123), $this->driver)); + } + + /** + * Test converting JSON strings to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => json_encode([1, 2, 3]), + 'c' => json_encode('123'), + 'd' => json_encode(2.3), + ]; + $expected = [ + 'a' => null, + 'b' => [1, 2, 3], + 'c' => 123, + 'd' => 2.3, + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $this->assertNull($this->type->toDatabase(null, $this->driver)); + $this->assertSame(json_encode('word'), $this->type->toDatabase('word', $this->driver)); + $this->assertSame(json_encode(2.123), $this->type->toDatabase(2.123, $this->driver)); + $this->assertSame(json_encode(['a' => 'b']), $this->type->toDatabase(['a' => 'b'], $this->driver)); + } + + /** + * Tests that passing an invalid value will throw an exception + */ + public function testToDatabaseInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $value = fopen(__FILE__, 'r'); + $this->type->toDatabase($value, $this->driver); + } + + /** + * Test marshalling + */ + public function testMarshal(): void + { + $this->assertNull($this->type->marshal(null)); + $this->assertSame('', $this->type->marshal('')); + $this->assertSame('word', $this->type->marshal('word')); + $this->assertSame(2.123, $this->type->marshal(2.123)); + $this->assertSame([1, 2, 3], $this->type->marshal([1, 2, 3])); + $this->assertSame(['a' => 1, 2, 3], $this->type->marshal(['a' => 1, 2, 3])); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_STR, $this->type->toStatement('', $this->driver)); + } + + /** + * Test encoding options + * + * @return void + */ + public function testEncodingOptions(): void + { + // New instance to prevent others tests breaking + $instance = new JsonType(); + $instance->setEncodingOptions(JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $result = $instance->toDatabase(['é', 'https://cakephp.org/'], $this->driver); + $this->assertSame('["é","https://cakephp.org/"]', $result); + } + + /** + * Test decoding options + * + * @return void + */ + public function testDecodingOptions(): void + { + // New instance to prevent others tests breaking + $instance = new JsonType(); + $instance->setDecodingOptions(0); + + $result = $instance->toPHP('{"foo":"bar"}', $this->driver); + $expected = new stdClass(); + $expected->foo = 'bar'; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Database/Type/StringTypeTest.php b/tests/TestCase/Database/Type/StringTypeTest.php new file mode 100644 index 00000000000..9df90275fe6 --- /dev/null +++ b/tests/TestCase/Database/Type/StringTypeTest.php @@ -0,0 +1,103 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.1.7 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\TypeFactory; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use Mockery; +use PDO; + +/** + * Test for the String type. + */ +class StringTypeTest extends TestCase +{ + /** + * @var \Cake\Database\TypeInterface + */ + protected $type; + + /** + * @var \Cake\Database\Driver|\PHPUnit\Framework\MockObject\MockObject + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = TypeFactory::build('string'); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + $this->assertSame('word', $this->type->toPHP('word', $this->driver)); + $this->assertSame('2.123', $this->type->toPHP(2.123, $this->driver)); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $obj = Mockery::spy('StdClass'); + + $this->assertNull($this->type->toDatabase(null, $this->driver)); + $this->assertSame('word', $this->type->toDatabase('word', $this->driver)); + $this->assertSame('2.123', $this->type->toDatabase(2.123, $this->driver)); + $this->assertSame((string)$obj, $this->type->toDatabase($obj, $this->driver)); + + $obj->shouldHaveReceived('__toString'); + } + + /** + * Tests that passing an invalid value will throw an exception + */ + public function testToDatabaseInvalidArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->type->toDatabase([1, 2, 3], $this->driver); + } + + /** + * Test marshalling + */ + public function testMarshal(): void + { + $this->assertNull($this->type->marshal(null)); + $this->assertNull($this->type->marshal([1, 2, 3])); + $this->assertSame('word', $this->type->marshal('word')); + $this->assertSame('2.123', $this->type->marshal(2.123)); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_STR, $this->type->toStatement('', $this->driver)); + } +} diff --git a/tests/TestCase/Database/Type/TimeTypeTest.php b/tests/TestCase/Database/Type/TimeTypeTest.php new file mode 100644 index 00000000000..0a7b5f59dca --- /dev/null +++ b/tests/TestCase/Database/Type/TimeTypeTest.php @@ -0,0 +1,277 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Chronos\ChronosTime; +use Cake\Core\Exception\CakeException; +use Cake\Database\Driver; +use Cake\Database\Type\TimeType; +use Cake\I18n\DateTime; +use Cake\I18n\I18n; +use Cake\I18n\Time; +use Cake\TestSuite\TestCase; +use DateTime as NativeDateTime; +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Test for the Time type. + */ +class TimeTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\TimeType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = new TimeType(); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + I18n::setLocale(I18n::getDefaultLocale()); + } + + /** + * Test getTimeClassName + */ + public function testGetTimeClassName(): void + { + $this->assertSame(Time::class, $this->type->getTimeClassName()); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + + $result = $this->type->toPHP('00:00:00', $this->driver); + $this->assertSame('00', $result->format('s')); + + $result = $this->type->toPHP('00:00:15', $this->driver); + $this->assertSame('15', $result->format('s')); + + $result = $this->type->toPHP('16:30:15', $this->driver); + $this->assertInstanceOf(Time::class, $result); + $this->assertSame('16', $result->format('H')); + $this->assertSame('30', $result->format('i')); + $this->assertSame('15', $result->format('s')); + } + + /** + * Test converting string times to PHP values. + */ + public function testManyToPHP(): void + { + $values = [ + 'a' => null, + 'b' => '01:30:13', + ]; + $expected = [ + 'a' => null, + 'b' => new Time('01:30:13'), + ]; + $this->assertEquals( + $expected, + $this->type->manyToPHP($values, array_keys($values), $this->driver), + ); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $value = '16:30:15'; + $result = $this->type->toDatabase($value, $this->driver); + $this->assertSame($value, $result); + + $date = new DateTime('16:30:15'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('16:30:15', $result); + + $date = new DateTime('2013-08-12 15:16:18'); + $result = $this->type->toDatabase($date, $this->driver); + $this->assertSame('15:16:18', $result); + } + + /** + * Data provider for marshal() + * + * @return array + */ + public static function marshalProvider(): array + { + return [ + // invalid types. + [null, null], + [false, null], + [true, null], + ['', null], + ['derpy', null], + ['16-nope!', null], + ['2014-02-14 13:14:15', null], + + // valid string types + ['13:10:10', new Time('13:10:10')], + ['14:15', new Time('14:15:00')], + + [new ChronosTime('13:10:10'), new Time('13:10:10')], + [new Time('13:10:10'), new Time('13:10:10')], + [new NativeDateTime('13:10:10'), new Time('13:10:10')], + [new DateTimeImmutable('13:10:10'), new Time('13:10:10')], + + // valid array types + [ + ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15], + new Time('13:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'am', + ], + new Time('01:14:15'), + ], + [ + [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'pm', + ], + new Time('13:14:15'), + ], + [ + [ + 'hour' => 1, 'minute' => 14, 'second' => 15, + ], + new Time('01:14:15'), + ], + [ + [ + 'hour' => 1, 'minute' => 14, + ], + new Time('01:14:00'), + ], + + // Invalid array types + [ + ['hour' => '', 'minute' => '', 'second' => ''], + null, + ], + [ + ['hour' => '', 'minute' => '', 'meridian' => ''], + null, + ], + [ + ['hour' => 'nope', 'minute' => 14, 'second' => 15], + null, + ], + [ + [ + 'year' => '2014', 'month' => '02', 'day' => '14', + 'hour' => 'nope', 'minute' => 'nope', + ], + null, + ], + ]; + } + + /** + * test marshalling data. + * + * @param mixed $value + * @param mixed $expected + */ + #[DataProvider('marshalProvider')] + public function testMarshal($value, $expected): void + { + $result = $this->type->marshal($value); + if (is_object($expected)) { + $this->assertEquals($expected, $result); + } else { + $this->assertSame($expected, $result); + } + } + + /** + * Tests marshalling times using the locale aware parser + */ + public function testMarshalWithLocaleParsing(): void + { + $expected = new Time('23:23:00'); + $result = $this->type->useLocaleParser()->marshal('11:23pm'); + $this->assertSame($expected->format('H:i'), $result->format('H:i')); + $this->assertNull($this->type->marshal('derp:23')); + } + + /** + * Tests marshalling times in denmark. + */ + public function testMarshalWithLocaleParsingDanishLocale(): void + { + $original = setlocale(LC_COLLATE, '0'); + $updated = setlocale(LC_COLLATE, 'da_DK.utf8'); + setlocale(LC_COLLATE, $original); + $this->skipIf($updated === false, 'Could not set locale to da_DK.utf8, skipping test.'); + + I18n::setLocale('da_DK'); + $expected = new Time('03:20:00'); + $result = $this->type->useLocaleParser()->marshal('03.20'); + $this->assertSame($expected->format('H:i'), $result->format('H:i')); + } + + /** + * Tests marshalling dates using the locale aware parser and custom format + */ + public function testMarshalWithLocaleParsingWithFormat(): void + { + $this->type->useLocaleParser()->setLocaleFormat('hh:mm a'); + + $expected = new Time('13:54:00'); + $result = $this->type->marshal('01:54 pm'); + $this->assertEquals($expected, $result); + } + + public function testUseLocaleParserException(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('You must install the `cakephp/i18n` package to use locale aware parsing.'); + + $type = new TimeType('time', ChronosTime::class); + $type->useLocaleParser(); + } +} diff --git a/tests/TestCase/Database/Type/UuidTypeTest.php b/tests/TestCase/Database/Type/UuidTypeTest.php new file mode 100644 index 00000000000..7b996434be1 --- /dev/null +++ b/tests/TestCase/Database/Type/UuidTypeTest.php @@ -0,0 +1,115 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database\Type; + +use Cake\Database\Driver; +use Cake\Database\TypeFactory; +use Cake\TestSuite\TestCase; +use PDO; + +/** + * Test for the Uuid type. + */ +class UuidTypeTest extends TestCase +{ + /** + * @var \Cake\Database\Type\UuidType + */ + protected $type; + + /** + * @var \Cake\Database\Driver + */ + protected $driver; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->type = TypeFactory::build('uuid'); + $this->driver = $this->createStub(Driver::class); + } + + /** + * Test toPHP + */ + public function testToPHP(): void + { + $this->assertNull($this->type->toPHP(null, $this->driver)); + + $result = $this->type->toPHP('some data', $this->driver); + $this->assertSame('some data', $result); + + $result = $this->type->toPHP(2, $this->driver); + $this->assertSame('2', $result); + } + + /** + * Test converting to database format + */ + public function testToDatabase(): void + { + $result = $this->type->toDatabase('some data', $this->driver); + $this->assertSame('some data', $result); + + $result = $this->type->toDatabase(2, $this->driver); + $this->assertSame('2', $result); + + $result = $this->type->toDatabase(null, $this->driver); + $this->assertNull($result); + + $result = $this->type->toDatabase('', $this->driver); + $this->assertNull($result); + + $result = $this->type->toDatabase(false, $this->driver); + $this->assertNull($result); + } + + /** + * Test that the PDO binding type is correct. + */ + public function testToStatement(): void + { + $this->assertSame(PDO::PARAM_STR, $this->type->toStatement('', $this->driver)); + } + + /** + * Test generating new ids + */ + public function testNewId(): void + { + $one = $this->type->newId(); + $two = $this->type->newId(); + + $this->assertNotEquals($one, $two, 'Should be different values'); + $this->assertMatchesRegularExpression('/^[a-f0-9-]+$/', $one, 'Should quack like a uuid'); + $this->assertMatchesRegularExpression('/^[a-f0-9-]+$/', $two, 'Should quack like a uuid'); + } + + /** + * Tests that marshalling an empty string results in null + */ + public function testMarshal(): void + { + $this->assertNull($this->type->marshal('')); + $this->assertSame('2', $this->type->marshal(2)); + $this->assertSame('word', $this->type->marshal('word')); + $this->assertNull($this->type->marshal([1, 2])); + } +} diff --git a/tests/TestCase/Database/TypeFactoryTest.php b/tests/TestCase/Database/TypeFactoryTest.php new file mode 100644 index 00000000000..92b8bebd70c --- /dev/null +++ b/tests/TestCase/Database/TypeFactoryTest.php @@ -0,0 +1,289 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database; + +use Cake\Database\Driver; +use Cake\Database\Type\StringType; +use Cake\Database\TypeFactory; +use Cake\Database\TypeInterface; +use Cake\TestSuite\TestCase; +use PDO; +use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Database\Type\BarType; +use TestApp\Database\Type\FooType; + +/** + * Tests TypeFactory class + */ +class TypeFactoryTest extends TestCase +{ + /** + * Original type map + * + * @var array<string, class-string<\Cake\Database\TypeInterface>> + */ + protected $_originalMap = []; + + /** + * Backup original Type class state + */ + protected function setUp(): void + { + $this->_originalMap = TypeFactory::getMap(); + parent::setUp(); + } + + /** + * Restores Type class state + */ + protected function tearDown(): void + { + parent::tearDown(); + + TypeFactory::setMap($this->_originalMap); + } + + /** + * Tests Type class is able to instantiate basic types + */ + #[DataProvider('basicTypesProvider')] + public function testBuildBasicTypes(string $name): void + { + $type = TypeFactory::build($name); + $this->assertInstanceOf(TypeInterface::class, $type); + $this->assertEquals($name, $type->getName()); + $this->assertEquals($name, $type->getBaseType()); + } + + /** + * provides a basics type list to be used as data provided for a test + * + * @return array + */ + public static function basicTypesProvider(): array + { + return [ + ['biginteger'], + ['binary'], + ['boolean'], + ['cidr'], + ['date'], + ['datetime'], + ['datetimefractional'], + ['decimal'], + ['float'], + ['geography'], + ['geometry'], + ['inet'], + ['integer'], + ['macaddr'], + ['nativeuuid'], + ['point'], + ['smallinteger'], + ['string'], + ['text'], + ['time'], + ['tinyinteger'], + ['uuid'], + ['year'], + ]; + } + + /** + * Tests trying to build an unknown type throws exception + */ + public function testBuildUnknownType(): void + { + $type = TypeFactory::build('foo'); + $this->assertSame('foo', $type->getName()); + $this->assertSame('foo', $type->getBaseType()); + $this->assertInstanceOf(StringType::class, $type); + } + + /** + * Tests that once a type with a name is instantiated, the reference is kept + * for future use + */ + public function testInstanceRecycling(): void + { + $type = TypeFactory::build('integer'); + $this->assertSame($type, TypeFactory::build('integer')); + } + + /** + * Tests new types can be registered and built + */ + public function testMapAndBuild(): void + { + $map = TypeFactory::getMap(); + $this->assertNotEmpty($map); + $this->assertArrayNotHasKey('foo', $map); + + $fooType = FooType::class; + TypeFactory::map('foo', $fooType); + $map = TypeFactory::getMap(); + $this->assertSame($fooType, $map['foo']); + $this->assertSame($fooType, TypeFactory::getMapped('foo')); + + TypeFactory::map('foo2', $fooType); + $map = TypeFactory::getMap(); + $this->assertSame($fooType, $map['foo2']); + $this->assertSame($fooType, TypeFactory::getMapped('foo2')); + + $type = TypeFactory::build('foo2'); + $this->assertInstanceOf($fooType, $type); + } + + /** + * Test that getMap() with argument triggers deprecation + */ + public function testGetMapDeprecation(): void + { + $this->deprecated(function (): void { + TypeFactory::map('test', FooType::class); + $result = TypeFactory::getMap('test'); + $this->assertSame(FooType::class, $result); + }); + } + + /** + * Tests new types set with set() are not returned by getMap() + * as the constructor of a Type could be arbitrary. + */ + public function testSetAndGetMap(): void + { + $types = TypeFactory::buildAll(); + $this->assertFalse(isset($types['foo'])); + + TypeFactory::set('foo', new FooType()); + $this->assertNull(TypeFactory::getMapped('foo')); + + $result = TypeFactory::build('foo'); + $this->assertInstanceOf(FooType::class, $result); + } + + /** + * Tests new types set with set() are returned by buildAll() + */ + public function testSetAndBuild(): void + { + $types = TypeFactory::buildAll(); + $this->assertFalse(isset($types['foo'])); + + TypeFactory::set('foo', new FooType()); + $types = TypeFactory::buildAll(); + $this->assertTrue(isset($types['foo'])); + } + + /** + * Tests overwriting type map works for building + */ + public function testReMapAndBuild(): void + { + $fooType = FooType::class; + TypeFactory::map('foo', $fooType); + $type = TypeFactory::build('foo'); + $this->assertInstanceOf($fooType, $type); + + $barType = BarType::class; + TypeFactory::map('foo', $barType); + $type = TypeFactory::build('foo'); + $this->assertInstanceOf($barType, $type); + } + + /** + * Tests clear function in conjunction with map + */ + public function testClear(): void + { + $map = TypeFactory::getMap(); + $this->assertNotEmpty($map); + + $type = TypeFactory::build('float'); + TypeFactory::clear(); + + $this->assertEmpty(TypeFactory::getMap()); + TypeFactory::setMap($map); + $newMap = TypeFactory::getMap(); + + $this->assertEquals(array_keys($map), array_keys($newMap)); + $this->assertSame($map['integer'], $newMap['integer']); + $this->assertEquals($type, TypeFactory::build('float')); + } + + /** + * Tests bigintegers from database are converted correctly to PHP + */ + public function testBigintegerToPHP(): void + { + $this->skipIf( + PHP_INT_SIZE === 4, + 'This test requires a php version compiled for 64 bits', + ); + $type = TypeFactory::build('biginteger'); + $integer = time() * time(); + $driver = $this->createStub(Driver::class); + $this->assertSame($integer, $type->toPHP($integer, $driver)); + $this->assertSame($integer, $type->toPHP('' . $integer, $driver)); + $this->assertSame(3, $type->toPHP(3.57, $driver)); + } + + /** + * Tests bigintegers from PHP are converted correctly to statement value + */ + public function testBigintegerToStatement(): void + { + $type = TypeFactory::build('biginteger'); + $integer = time() * time(); + $driver = $this->createStub(Driver::class); + $this->assertSame(PDO::PARAM_INT, $type->toStatement($integer, $driver)); + } + + /** + * Tests decimal from database are converted correctly to PHP + */ + public function testDecimalToPHP(): void + { + $type = TypeFactory::build('decimal'); + $driver = $this->createStub(Driver::class); + + $this->assertSame('3.14159', $type->toPHP('3.14159', $driver)); + $this->assertSame('3.14159', $type->toPHP(3.14159, $driver)); + $this->assertSame('3', $type->toPHP(3, $driver)); + } + + /** + * Tests integers from PHP are converted correctly to statement value + */ + public function testDecimalToStatement(): void + { + $type = TypeFactory::build('decimal'); + $string = '12.55'; + $driver = $this->createStub(Driver::class); + $this->assertSame(PDO::PARAM_STR, $type->toStatement($string, $driver)); + } + + /** + * Test setting instances into the factory/registry. + */ + public function testSet(): void + { + $instance = $this->createStub(TypeInterface::class); + TypeFactory::set('random', $instance); + $this->assertSame($instance, TypeFactory::build('random')); + } +} diff --git a/tests/TestCase/Database/ValueBinderTest.php b/tests/TestCase/Database/ValueBinderTest.php new file mode 100644 index 00000000000..51db8a28a12 --- /dev/null +++ b/tests/TestCase/Database/ValueBinderTest.php @@ -0,0 +1,181 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.3.12 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Database; + +use Cake\Database\StatementInterface; +use Cake\Database\ValueBinder; +use Cake\TestSuite\TestCase; +use Mockery; + +/** + * Tests ValueBinder class + */ +class ValueBinderTest extends TestCase +{ + /** + * test the bind method + */ + public function testBind(): void + { + $valueBinder = new ValueBinder(); + $valueBinder->bind(':c0', 'value0'); + $valueBinder->bind(':c1', 1, 'int'); + $valueBinder->bind(':c2', 'value2'); + + $this->assertCount(3, $valueBinder->bindings()); + + $expected = [ + ':c0' => [ + 'value' => 'value0', + 'type' => null, + 'placeholder' => 'c0', + ], + ':c1' => [ + 'value' => 1, + 'type' => 'int', + 'placeholder' => 'c1', + ], + ':c2' => [ + 'value' => 'value2', + 'type' => null, + 'placeholder' => 'c2', + ], + ]; + + $bindings = $valueBinder->bindings(); + $this->assertEquals($expected, $bindings); + } + + /** + * test the placeholder method + */ + public function testPlaceholder(): void + { + $valueBinder = new ValueBinder(); + $result = $valueBinder->placeholder('?'); + $this->assertSame('?', $result); + + $valueBinder = new ValueBinder(); + $result = $valueBinder->placeholder(':param'); + $this->assertSame(':param', $result); + + $valueBinder = new ValueBinder(); + $result = $valueBinder->placeholder('p'); + $this->assertSame(':p0', $result); + $result = $valueBinder->placeholder('p'); + $this->assertSame(':p1', $result); + $result = $valueBinder->placeholder('c'); + $this->assertSame(':c2', $result); + } + + public function testGenerateManyNamed(): void + { + $valueBinder = new ValueBinder(); + $values = [ + 'value0', + 'value1', + ]; + + $expected = [ + ':c0', + ':c1', + ]; + $placeholders = $valueBinder->generateManyNamed($values); + $this->assertEquals($expected, $placeholders); + } + + /** + * test the reset method + */ + public function testReset(): void + { + $valueBinder = new ValueBinder(); + $valueBinder->bind(':c0', 'value0'); + $valueBinder->bind(':c1', 'value1'); + + $this->assertCount(2, $valueBinder->bindings()); + $valueBinder->reset(); + $this->assertCount(0, $valueBinder->bindings()); + + $placeholder = $valueBinder->placeholder('c'); + $this->assertSame(':c0', $placeholder); + } + + /** + * test the resetCount method + */ + public function testResetCount(): void + { + $valueBinder = new ValueBinder(); + + // Ensure the _bindings array IS NOT affected by resetCount + $valueBinder->bind(':c0', 'value0'); + $valueBinder->bind(':c1', 'value1'); + $this->assertCount(2, $valueBinder->bindings()); + + // Ensure the placeholder generation IS affected by resetCount + $valueBinder->placeholder('param'); + $valueBinder->placeholder('param'); + $result = $valueBinder->placeholder('param'); + $this->assertSame(':param2', $result); + + $valueBinder->resetCount(); + + $placeholder = $valueBinder->placeholder('param'); + $this->assertSame(':param0', $placeholder); + $this->assertCount(2, $valueBinder->bindings()); + } + + /** + * tests the attachTo method + */ + public function testAttachTo(): void + { + $statementMock = Mockery::spy(StatementInterface::class); + + $valueBinder = new ValueBinder(); + $valueBinder->attachTo($statementMock); //empty array shouldn't call statement + $valueBinder->bind(':c0', 'value0', 'string'); + $valueBinder->bind(':c1', 'value1', 'string'); + $valueBinder->attachTo($statementMock); + + $statementMock + ->shouldHaveReceived('bindValue') + ->with('c0', 'value0', 'string') + ->once(); + + $statementMock + ->shouldHaveReceived('bindValue') + ->with('c1', 'value1', 'string') + ->once(); + } + + /** + * test the __debugInfo method + */ + public function testDebugInfo(): void + { + $valueBinder = new ValueBinder(); + + $valueBinder->bind(':c0', 'value0'); + $valueBinder->bind(':c1', 'value1'); + + $data = $valueBinder->__debugInfo(); + $this->assertArrayHasKey('bindings', $data); + $this->assertArrayHasKey(':c0', $data['bindings']); + } +} diff --git a/tests/TestCase/Datasource/ConnectionManagerTest.php b/tests/TestCase/Datasource/ConnectionManagerTest.php new file mode 100644 index 00000000000..3e617f38a27 --- /dev/null +++ b/tests/TestCase/Datasource/ConnectionManagerTest.php @@ -0,0 +1,457 @@ +<?php +declare(strict_types=1); + +/** + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 1.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource; + +use BadMethodCallException; +use Cake\Core\Exception\CakeException; +use Cake\Database\Connection; +use Cake\Database\Driver\Mysql; +use Cake\Database\Driver\Sqlite; +use Cake\Database\Driver\Sqlserver; +use Cake\Datasource\ConnectionManager; +use Cake\Datasource\Exception\MissingDatasourceException; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Datasource\FakeConnection; +use TestPlugin\Datasource\TestSource; + +/** + * ConnectionManager Test + */ +class ConnectionManagerTest extends TestCase +{ + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + ConnectionManager::drop('test_variant'); + ConnectionManager::dropAlias('other_name'); + ConnectionManager::dropAlias('test2'); + } + + /** + * Data provider for valid config data sets. + * + * @return array + */ + public static function configProvider(): array + { + return [ + 'Array of data using classname key.' => [[ + 'className' => FakeConnection::class, + 'instance' => 'Sqlite', + 'database' => ':memory:', + ]], + 'Direct instance' => [new FakeConnection()], + ]; + } + + /** + * Test the various valid config() calls. + * + * @param \Cake\Datasource\ConnectionInterface|array $settings + */ + #[DataProvider('configProvider')] + public function testConfigVariants($settings): void + { + $this->assertNotContains('test_variant', ConnectionManager::configured(), 'test_variant config should not exist.'); + ConnectionManager::setConfig('test_variant', $settings); + + $ds = ConnectionManager::get('test_variant'); + $this->assertInstanceOf(FakeConnection::class, $ds); + $this->assertContains('test_variant', ConnectionManager::configured()); + } + + /** + * Test invalid classes cause exceptions + */ + public function testConfigInvalidOptions(): void + { + $this->expectException(MissingDatasourceException::class); + ConnectionManager::setConfig('test_variant', [ + 'className' => 'Herp\Derp', + ]); + ConnectionManager::get('test_variant'); + } + + /** + * Test for errors on duplicate config. + */ + public function testConfigDuplicateConfig(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot reconfigure existing key `test_variant`'); + $settings = [ + 'className' => FakeConnection::class, + 'database' => ':memory:', + ]; + ConnectionManager::setConfig('test_variant', $settings); + ConnectionManager::setConfig('test_variant', $settings); + } + + /** + * Test get() failing on missing config. + */ + public function testGetFailOnMissingConfig(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('The datasource configuration `test_variant` was not found.'); + ConnectionManager::get('test_variant'); + } + + /** + * Test loading configured connections. + */ + public function testGet(): void + { + $config = ConnectionManager::getConfig('test'); + $this->skipIf(empty($config), 'No test config, skipping'); + + $ds = ConnectionManager::get('test'); + $this->assertSame($ds, ConnectionManager::get('test')); + $this->assertInstanceOf(Connection::class, $ds); + $this->assertSame('test', $ds->configName()); + } + + /** + * Test loading connections without aliases + */ + public function testGetNoAlias(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('The datasource configuration `other_name` was not found.'); + $config = ConnectionManager::getConfig('test'); + $this->skipIf(empty($config), 'No test config, skipping'); + + ConnectionManager::alias('test', 'other_name'); + ConnectionManager::get('other_name', false); + } + + /** + * Test that configured() finds configured sources. + */ + public function testConfigured(): void + { + ConnectionManager::setConfig('test_variant', [ + 'className' => FakeConnection::class, + 'database' => ':memory:', + ]); + $results = ConnectionManager::configured(); + $this->assertContains('test_variant', $results); + } + + /** + * testGetPluginDataSource method + */ + public function testGetPluginDataSource(): void + { + $this->loadPlugins(['TestPlugin']); + $name = 'test_variant'; + $config = ['className' => 'TestPlugin.TestSource', 'foo' => 'bar']; + ConnectionManager::setConfig($name, $config); + $connection = ConnectionManager::get($name); + + $this->assertInstanceOf(TestSource::class, $connection); + unset($config['className']); + $this->assertSame($config + ['name' => 'test_variant'], $connection->config()); + } + + /** + * Tests that a connection configuration can be deleted in runtime + */ + public function testDrop(): void + { + ConnectionManager::setConfig('test_variant', [ + 'className' => FakeConnection::class, + 'database' => ':memory:', + ]); + $result = ConnectionManager::configured(); + $this->assertContains('test_variant', $result); + + $this->assertTrue(ConnectionManager::drop('test_variant')); + $result = ConnectionManager::configured(); + $this->assertNotContains('test_variant', $result); + + $this->assertFalse(ConnectionManager::drop('probably_does_not_exist'), 'Should return false on failure.'); + } + + public function testAliases(): void + { + $this->assertSame(['default' => 'test'], ConnectionManager::aliases()); + ConnectionManager::alias('test', 'test2'); + $this->assertSame(['default' => 'test', 'test2' => 'test'], ConnectionManager::aliases()); + ConnectionManager::dropAlias('test2'); + $this->assertSame(['default' => 'test'], ConnectionManager::aliases()); + } + + /** + * Test aliasing connections. + */ + public function testAlias(): void + { + ConnectionManager::setConfig('test_variant', [ + 'className' => FakeConnection::class, + 'database' => ':memory:', + ]); + ConnectionManager::alias('test_variant', 'other_name'); + $result = ConnectionManager::get('test_variant'); + $this->assertSame($result, ConnectionManager::get('other_name')); + } + + /** + * provider for DSN strings. + * + * @return array + */ + public static function dsnProvider(): array + { + return [ + 'no user' => [ + 'mysql://localhost:3306/database', + [ + 'className' => Connection::class, + 'driver' => Mysql::class, + 'host' => 'localhost', + 'database' => 'database', + 'port' => 3306, + 'scheme' => 'mysql', + ], + ], + 'subdomain host' => [ + 'mysql://my.host-name.com:3306/database', + [ + 'className' => Connection::class, + 'driver' => Mysql::class, + 'host' => 'my.host-name.com', + 'database' => 'database', + 'port' => 3306, + 'scheme' => 'mysql', + ], + ], + 'user & pass' => [ + 'mysql://root:secret@localhost:3306/database?log=1', + [ + 'scheme' => 'mysql', + 'className' => Connection::class, + 'driver' => Mysql::class, + 'host' => 'localhost', + 'username' => 'root', + 'password' => 'secret', + 'port' => 3306, + 'database' => 'database', + 'log' => '1', + ], + ], + 'no password' => [ + 'mysql://user@localhost:3306/database', + [ + 'className' => Connection::class, + 'driver' => Mysql::class, + 'host' => 'localhost', + 'database' => 'database', + 'port' => 3306, + 'scheme' => 'mysql', + 'username' => 'user', + ], + ], + 'empty password' => [ + 'mysql://user:@localhost:3306/database', + [ + 'className' => Connection::class, + 'driver' => Mysql::class, + 'host' => 'localhost', + 'database' => 'database', + 'port' => 3306, + 'scheme' => 'mysql', + 'username' => 'user', + 'password' => '', + ], + ], + 'sqlite memory' => [ + 'sqlite:///:memory:', + [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => ':memory:', + 'scheme' => 'sqlite', + ], + ], + 'sqlite path' => [ + 'sqlite:////absolute/path', + [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => '/absolute/path', + 'scheme' => 'sqlite', + ], + ], + 'sqlite database query' => [ + 'sqlite:///?database=:memory:', + [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => ':memory:', + 'scheme' => 'sqlite', + ], + ], + 'sqlserver' => [ + 'sqlserver://sa:Password12!@.\SQL2012SP1/cakephp?MultipleActiveResultSets=false', + [ + 'className' => Connection::class, + 'driver' => Sqlserver::class, + 'host' => '.\SQL2012SP1', + 'MultipleActiveResultSets' => false, + 'password' => 'Password12!', + 'database' => 'cakephp', + 'scheme' => 'sqlserver', + 'username' => 'sa', + ], + ], + 'sqllocaldb' => [ + 'sqlserver://username:password@(localdb)\.\DeptSharedLocalDB/database', + [ + 'className' => Connection::class, + 'driver' => Sqlserver::class, + 'host' => '(localdb)\.\DeptSharedLocalDB', + 'password' => 'password', + 'database' => 'database', + 'scheme' => 'sqlserver', + 'username' => 'username', + ], + ], + 'classname query arg' => [ + 'mysql://localhost/database?className=Custom\Driver', + [ + 'className' => Connection::class, + 'database' => 'database', + 'driver' => 'Custom\Driver', + 'host' => 'localhost', + 'scheme' => 'mysql', + ], + ], + 'classname and port' => [ + 'mysql://localhost:3306/database?className=Custom\Driver', + [ + 'className' => Connection::class, + 'database' => 'database', + 'driver' => 'Custom\Driver', + 'host' => 'localhost', + 'scheme' => 'mysql', + 'port' => 3306, + ], + ], + 'custom connection class' => [ + 'Cake\Database\Connection://localhost:3306/database?driver=Cake\Database\Driver\Mysql', + [ + 'className' => Connection::class, + 'database' => 'database', + 'driver' => Mysql::class, + 'host' => 'localhost', + 'scheme' => Connection::class, + 'port' => 3306, + ], + ], + 'complex password' => [ + 'mysql://user:/?#][{}$%20@!@localhost:3306/database?log=1"eIdentifiers=1', + [ + 'className' => Connection::class, + 'database' => 'database', + 'driver' => Mysql::class, + 'host' => 'localhost', + 'password' => '/?#][{}$%20@!', + 'port' => 3306, + 'scheme' => 'mysql', + 'username' => 'user', + 'log' => 1, + 'quoteIdentifiers' => 1, + ], + ], + ]; + } + + /** + * Test parseDsn method. + */ + #[DataProvider('dsnProvider')] + public function testParseDsn(string $dsn, array $expected): void + { + $result = ConnectionManager::parseDsn($dsn); + $this->assertEquals($expected, $result); + } + + /** + * Test parseDsn invalid. + */ + public function testParseDsnInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The DSN string `bagof:nope` could not be parsed.'); + ConnectionManager::parseDsn('bagof:nope'); + } + + /** + * Tests that directly setting an instance in a config, will not return a different + * instance later on + */ + public function testConfigWithObject(): void + { + $connection = new FakeConnection(); + ConnectionManager::setConfig('test_variant', $connection); + $this->assertSame($connection, ConnectionManager::get('test_variant')); + } + + /** + * Tests configuring an instance with a callable + */ + public function testConfigWithCallable(): void + { + $connection = new FakeConnection(); + $callable = function ($alias) use ($connection) { + $this->assertSame('test_variant', $alias); + + return $connection; + }; + + ConnectionManager::setConfig('test_variant', $callable); + $this->assertSame($connection, ConnectionManager::get('test_variant')); + } + + /** + * Tests that setting a config will also correctly set the name for the connection + */ + public function testSetConfigName(): void + { + //Set with explicit name + ConnectionManager::setConfig('test_variant', [ + 'className' => FakeConnection::class, + 'database' => ':memory:', + ]); + $result = ConnectionManager::get('test_variant'); + $this->assertSame('test_variant', $result->configName()); + + ConnectionManager::drop('test_variant'); + ConnectionManager::setConfig([ + 'test_variant' => [ + 'className' => FakeConnection::class, + 'database' => ':memory:', + ], + ]); + $result = ConnectionManager::get('test_variant'); + $this->assertSame('test_variant', $result->configName()); + } +} diff --git a/tests/TestCase/Datasource/FactoryLocatorTest.php b/tests/TestCase/Datasource/FactoryLocatorTest.php new file mode 100644 index 00000000000..f43a51fe724 --- /dev/null +++ b/tests/TestCase/Datasource/FactoryLocatorTest.php @@ -0,0 +1,76 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource; + +use Cake\Datasource\FactoryLocator; +use Cake\Datasource\Locator\LocatorInterface; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use TestApp\Datasource\StubFactory; + +/** + * FactoryLocatorTest test case + */ +class FactoryLocatorTest extends TestCase +{ + /** + * Test get factory + */ + public function testGet(): void + { + $factory = FactoryLocator::get('Table'); + $this->assertTrue(is_callable($factory) || $factory instanceof LocatorInterface); + } + + /** + * Test get nonexistent factory + */ + public function testGetNonExistent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown repository type `Test`. Make sure you register a type before trying to use it.'); + FactoryLocator::get('Test'); + } + + /** + * test add() + */ + public function testAdd(): void + { + FactoryLocator::add('MyType', new StubFactory()); + $this->assertInstanceOf(LocatorInterface::class, FactoryLocator::get('MyType')); + } + + /** + * test drop() + */ + public function testDrop(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown repository type `Test`. Make sure you register a type before trying to use it.'); + FactoryLocator::drop('Test'); + + FactoryLocator::get('Test'); + } + + protected function tearDown(): void + { + FactoryLocator::drop('Test'); + FactoryLocator::drop('MyType'); + + parent::tearDown(); + } +} diff --git a/tests/TestCase/Datasource/ModelAwareTraitTest.php b/tests/TestCase/Datasource/ModelAwareTraitTest.php new file mode 100644 index 00000000000..758d914450f --- /dev/null +++ b/tests/TestCase/Datasource/ModelAwareTraitTest.php @@ -0,0 +1,181 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource; + +use Cake\Datasource\Exception\MissingModelException; +use Cake\Datasource\FactoryLocator; +use Cake\Datasource\RepositoryInterface; +use Cake\ORM\Table; +use Cake\TestSuite\TestCase; +use TestApp\Datasource\StubFactory; +use TestApp\Model\Table\PaginatorPostsTable; +use TestApp\Stub\Stub; +use TestPlugin\Model\Table\CommentsTable; +use UnexpectedValueException; + +/** + * ModelAwareTrait test case + */ +class ModelAwareTraitTest extends TestCase +{ + protected function tearDown(): void + { + parent::tearDown(); + + FactoryLocator::drop('Test'); + } + + /** + * Test set modelClass + */ + public function testSetModelClass(): void + { + $stub = new Stub(); + $this->assertNull($stub->getModelClass()); + + $stub->setProps('StubArticles'); + $this->assertSame('StubArticles', $stub->getModelClass()); + } + + public function testFetchModel(): void + { + $stub = new Stub(); + $stub->setProps('Articles'); + $stub->setModelType('Table'); + + $result = $stub->fetchModel(); + $this->assertInstanceOf(Table::class, $result); + $this->assertNull($stub->Articles); + + $result = $stub->fetchModel('Comments'); + $this->assertInstanceOf(Table::class, $result); + $this->assertNull($stub->Comments); + + $result = $stub->fetchModel(PaginatorPostsTable::class); + $this->assertInstanceOf(PaginatorPostsTable::class, $result); + $this->assertSame('PaginatorPosts', $result->getAlias()); + $this->assertNull($stub->PaginatorPosts); + } + + /** + * Test that calling fetchModel() without $modelClass argument when default + * $modelClass property is empty generates exception. + */ + public function testFetchModelException(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Default modelClass is empty'); + + $stub = new Stub(); + $stub->setProps(''); + $stub->setModelType('Table'); + + $stub->fetchModel(); + } + + /** + * test MissingModelException being thrown + */ + public function testFetchModelMissingModelException(): void + { + $this->expectException(MissingModelException::class); + $this->expectExceptionMessage('Model class "Magic" of type "Test" could not be found.'); + $stub = new Stub(); + + $locator = new StubFactory(); + FactoryLocator::add('Test', $locator); + $stub->fetchModel('Magic', 'Test'); + } + + /** + * test fetchModel() with plugin prefixed models + */ + public function testFetchModelPlugin(): void + { + $stub = new Stub(); + $stub->setProps('Articles'); + $stub->setModelType('Table'); + + $result = $stub->fetchModel('TestPlugin.Comments'); + $this->assertInstanceOf(CommentsTable::class, $result); + $this->assertNull($stub->Comments); + } + + /** + * test alternate model factories. + */ + public function testModelFactory(): void + { + $stub = new Stub(); + $stub->setProps('Articles'); + + $table = new class extends Table { + public function getAlias(): string + { + return 'Magic'; + } + }; + + $locator = new StubFactory(); + $locator->set('Magic', $table); + $stub->modelFactory('Table', $locator); + + $result = $stub->fetchModel('Magic', 'Table'); + $this->assertInstanceOf(RepositoryInterface::class, $result); + $this->assertSame('Magic', $result->getAlias()); + + $locator = new StubFactory(); + $table2 = new class extends Table { + public function getAlias(): string + { + return 'Foo'; + } + }; + $locator->set('Foo', $table2); + + $stub->modelFactory('MyType', $locator); + $result = $stub->fetchModel('Foo', 'MyType'); + $this->assertInstanceOf(RepositoryInterface::class, $result); + $this->assertSame('Foo', $result->getAlias()); + } + + /** + * test getModelType() and setModelType() + */ + public function testGetSetModelType(): void + { + $stub = new Stub(); + $stub->setProps('Articles'); + + $stub->setModelType('Test'); + $this->assertSame('Test', $stub->getModelType()); + } + + /** + * test MissingModelException being thrown + */ + public function testLoadModelMissingModelException(): void + { + $this->expectException(MissingModelException::class); + $this->expectExceptionMessage('Model class "Magic" of type "Test" could not be found.'); + $stub = new Stub(); + + $locator = new StubFactory(); + FactoryLocator::add('Test', $locator); + + $stub->fetchModel('Magic', 'Test'); + } +} diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php new file mode 100644 index 00000000000..df2fa711a16 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorSortFieldTest.php @@ -0,0 +1,643 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource\Paging; + +use Cake\Datasource\Paging\NumericPaginator; +use Cake\Datasource\Paging\SortableFieldsBuilder; +use Cake\Datasource\Paging\SortField; +use Cake\ORM\Table; +use Cake\TestSuite\TestCase; + +/** + * NumericPaginator SortField Integration Test Case + */ +class NumericPaginatorSortFieldTest extends TestCase +{ + /** + * @var array<string> + */ + protected array $fixtures = [ + 'core.Articles', + 'core.Authors', + ]; + + /** + * @var \Cake\ORM\Table + */ + protected Table $table; + + /** + * @var \Cake\Datasource\Paging\NumericPaginator + */ + protected NumericPaginator $paginator; + + /** + * setUp method + * + * @return void + */ + public function setUp(): void + { + parent::setUp(); + $this->table = $this->getTableLocator()->get('Articles'); + $this->paginator = new NumericPaginator(); + } + + /** + * Test paginator with SortField objects for default ascending sort + * + * @return void + */ + public function testPaginateWithSortFieldAscending(): void + { + $params = [ + 'sort' => 'newest', + ]; + + $settings = [ + 'sortableFields' => [ + 'newest' => [ + SortField::asc('title'), + SortField::desc('published'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // When no direction specified, should use SortField defaults + $expected = [ + 'Articles.title' => 'asc', + 'Articles.published' => 'desc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with SortField objects when direction is explicitly specified + * + * @return void + */ + public function testPaginateWithSortFieldExplicitDirection(): void + { + $params = [ + 'sort' => 'newest', + 'direction' => 'desc', + ]; + + $settings = [ + 'sortableFields' => [ + 'newest' => [ + SortField::asc('title'), + SortField::desc('published'), + ], + ], + ]; + + //On desc + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // When direction is explicitly specified, toggleable fields should use it + $expected = [ + 'Articles.title' => 'desc', + 'Articles.published' => 'asc',// Reverse desc + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + //On asc + $params = [ + 'sort' => 'newest', + 'direction' => 'asc', + ]; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // When direction is explicitly specified, toggleable fields should use it + $expected = [ + 'Articles.title' => 'asc', + 'Articles.published' => 'desc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with locked SortField objects + * + * @return void + */ + public function testPaginateWithLockedSortField(): void + { + $params = [ + 'sort' => 'popular', + 'direction' => 'asc', // Try to override the locked direction + ]; + + $settings = [ + 'sortableFields' => [ + 'popular' => [ + SortField::desc('published', locked: true), + SortField::asc('title'), + ], + ], + ]; + + //On asc + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Locked field should always use its locked direction + $expected = [ + 'Articles.published' => 'desc', // Locked, ignores requested 'asc' + 'Articles.title' => 'asc', // Toggleable, uses requested 'asc' + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + //On Desc + $params = [ + 'sort' => 'popular', + 'direction' => 'desc', + ]; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Locked field should always use its locked direction + $expected = [ + 'Articles.published' => 'desc', // Locked, ignores requested 'asc' + 'Articles.title' => 'desc', // Toggleable, uses requested 'desc' + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with mixed SortField objects and strings for backward compatibility + * + * @return void + */ + public function testPaginateWithMixedSortFieldAndStrings(): void + { + $params = [ + 'sort' => 'mixed', + 'direction' => 'desc', + ]; + + $settings = [ + 'sortableFields' => [ + 'mixed' => [ + SortField::desc('published'), + 'author_id', // String field for BC + SortField::asc('title', locked: true), + ], + ], + ]; + //On desc + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'asc', // Reverse desc + 'Articles.author_id' => 'desc', // String field uses requested direction + 'Articles.title' => 'asc', // Locked field ignores requested direction + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + + //On asc + $params = [ + 'sort' => 'mixed', + 'direction' => 'asc', + ]; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.author_id' => 'asc', // String field uses requested direction + 'Articles.title' => 'asc', // Locked field ignores requested direction + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test complex real-world scenario with multiple sort maps + * + * @return void + */ + public function testComplexRealWorldScenario(): void + { + $settings = [ + 'sortableFields' => [ + 'relevance' => [ + SortField::desc('published', locked: true), + SortField::desc('author_id'), + ], + 'newest' => [ + SortField::desc('published'), + SortField::asc('title'), + ], + 'alphabetical' => [ + SortField::asc('title'), + ], + 'author' => [ + 'author_id', + SortField::asc('title'), + ], + ], + ]; + + // Test relevance sort (with locked field) + $params = ['sort' => 'relevance', 'direction' => 'asc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', // Locked, ignores 'asc' + 'Articles.author_id' => 'desc', + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test relevance sort (with locked field) + $params = ['sort' => 'relevance', 'direction' => 'desc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', // Locked at desc + 'Articles.author_id' => 'asc', // Reverse desc + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test newest sort without explicit direction + $params = ['sort' => 'newest']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test that invalid sort keys are handled correctly with SortField + * + * @return void + */ + public function testInvalidSortKeyWithSortField(): void + { + $params = [ + 'sort' => 'invalid_key', + 'direction' => 'asc', + ]; + + $settings = [ + 'sortableFields' => [ + 'newest' => [ + SortField::desc('published'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + + // Invalid sort key should result in no sorting + $pagingParams = $result->pagingParams(); + $this->assertNull($pagingParams['sort']); + $this->assertNull($pagingParams['direction']); + } + + /** + * Test paginator with SortField array preset methods + * + * @return void + */ + public function testPaginateWithFactoryPresets(): void + { + $params = [ + 'sort' => 'newest', + ]; + + $settings = [ + 'sortableFields' => [ + 'newest' => [ + SortField::desc('published'), + SortField::asc('title'), + ], + 'oldest' => [ + SortField::asc('published'), + ], + 'alphabetical' => [ + SortField::asc('title'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with SortField array configuration + * + * @return void + */ + public function testPaginateWithFactoryFluentInterface(): void + { + $params = [ + 'sort' => 'custom', + 'direction' => 'asc', + ]; + + $settings = [ + 'sortableFields' => [ + 'custom' => [ + SortField::desc('published'), + SortField::asc('author_id', locked: true), + SortField::asc('title'), + ], + ], + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', // Toggleable, uses requested 'asc' + 'Articles.author_id' => 'asc', // Locked, ignores requested direction + 'Articles.title' => 'asc', // Toggleable, uses requested 'asc' + ]; + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + //Reverse on desc + $params = [ + 'sort' => 'custom', + 'direction' => 'desc', + ]; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'asc', // Reversed on desc + 'Articles.author_id' => 'asc', // Locked, ignores requested direction + 'Articles.title' => 'desc', // Toggleable, uses requested 'desc' + ]; + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with complete sorts built using SortField arrays + * + * @return void + */ + public function testPaginateWithFactoryBuildMap(): void + { + $sorts = [ + 'newest' => [SortField::desc('published')], + 'popular' => [SortField::desc('published', locked: true)], + 'alphabetical' => [SortField::asc('title')], + ]; + + $params = [ + 'sort' => 'popular', + 'direction' => 'asc', // Try to override locked direction + ]; + + $settings = [ + 'sortableFields' => $sorts, + ]; + + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Popular preset is locked to desc + $expected = [ + 'Articles.published' => 'desc', + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test paginator with SortableFieldsBuilder + * + * @return void + */ + public function testSortsFactory(): void + { + $factory = new SortableFieldsBuilder(); + $factory + ->add( + 'newest', + SortField::desc('published'), + SortField::asc('title'), + ) + ->add( + 'popular', + SortField::desc('published', locked: true), + SortField::desc('author_id'), + ) + ->add( + 'alphabetical', + SortField::asc('title'), + ); + + $settings = [ + 'sortableFields' => $factory->toArray(), + ]; + + // Test newest sort + $params = ['sort' => 'newest']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.title' => 'asc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test popular sort with locked field and initial desc + $params = ['sort' => 'popular', 'direction' => 'asc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', // Locked + 'Articles.author_id' => 'desc', // DESC is defined as defaultDirection(initial) + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test popular sort with locked field and reversed desc (desc direction) + $params = ['sort' => 'popular', 'direction' => 'desc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + 'Articles.author_id' => 'asc',//Reverse on direction desc + ]; + + $this->assertEquals('popular', $pagingParams['sort']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test combined sort format with factory + * + * @return void + */ + + /** + * Test SortableFieldsBuilder shorthand where key is used as field + * + * @return void + */ + public function testSortsFactoryShorthand(): void + { + $factory = new SortableFieldsBuilder(); + $factory + ->add('title') // Shorthand - uses 'title' as field + ->add('published') // Shorthand - uses 'published' as field + ->add('author_id'); // Shorthand - uses 'author_id' as field + + $settings = [ + 'sortableFields' => $factory->toArray(), + ]; + + // Test title sort + $params = ['sort' => 'title', 'direction' => 'desc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.title' => 'desc', + ]; + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test published sort + $params = ['sort' => 'published', 'direction' => 'asc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'asc', + ]; + + $this->assertEquals('published', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } + + /** + * Test passing SortableFieldsBuilder instance directly to paginate + * + * @return void + */ + public function testSortableFieldsBuilderInstance(): void + { + $builder = SortableFieldsBuilder::create([ + 'name' => 'Articles.title', + 'newest' => [SortField::desc('Articles.published')], + ]); + + $settings = [ + 'sortableFields' => $builder, + ]; + + // Test with builder instance + $params = ['sort' => 'name', 'direction' => 'asc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.title' => 'asc', + ]; + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test newest sort + $params = ['sort' => 'newest']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'desc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + + // Test newest sort on desc + $params = ['sort' => 'newest', 'direction' => 'desc']; + $result = $this->paginator->paginate($this->table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $expected = [ + 'Articles.published' => 'asc', + ]; + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals($expected, $pagingParams['completeSort']); + } +} diff --git a/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php new file mode 100644 index 00000000000..fea5421da75 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/NumericPaginatorTest.php @@ -0,0 +1,645 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.5.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource\Paging; + +use Cake\Core\Exception\CakeException; +use Cake\Datasource\Paging\SortField; +use Cake\ORM\Entity; +use Cake\TestSuite\TestCase; + +class NumericPaginatorTest extends TestCase +{ + use PaginatorTestTrait; + + /** + * fixtures property + * + * @var array<string> + */ + protected array $fixtures = [ + 'core.Posts', 'core.Articles', 'core.Tags', 'core.ArticlesTags', + 'core.Authors', 'core.AuthorsTags', + ]; + + /** + * test paginate() and custom find, to make sure the correct count is returned. + */ + public function testPaginateCustomFind(): void + { + $titleExtractor = function ($result) { + $ids = []; + foreach ($result as $record) { + $ids[] = $record->title; + } + + return $ids; + }; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + $data = ['author_id' => 3, 'title' => 'Fourth Post', 'body' => 'Article Body, unpublished', 'published' => 'N']; + $result = $table->save(new Entity($data)); + $this->assertNotEmpty($result); + + $result = $this->Paginator->paginate($table); + $this->assertCount(4, $result, '4 rows should come back'); + $this->assertEquals(['First Post', 'Second Post', 'Third Post', 'Fourth Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(4, $pagingParams['count']); + $this->assertSame(4, $pagingParams['totalCount']); + + $settings = ['finder' => 'published']; + $result = $this->Paginator->paginate($table, [], $settings); + $this->assertCount(3, $result, '3 rows should come back'); + $this->assertEquals(['First Post', 'Second Post', 'Third Post'], $titleExtractor($result)); + + $settings = ['finder' => 'published']; + $result = $this->Paginator->paginate($table->find(), [], $settings); + $this->assertCount(3, $result, '3 rows should come back'); + $this->assertEquals(['First Post', 'Second Post', 'Third Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(3, $pagingParams['count']); + $this->assertSame(3, $pagingParams['totalCount']); + + $settings = ['finder' => 'published', 'limit' => 2, 'page' => 2]; + $result = $this->Paginator->paginate($table, [], $settings); + $this->assertCount(1, $result, '1 rows should come back'); + $this->assertEquals(['Third Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(1, $pagingParams['count']); + $this->assertSame(3, $pagingParams['totalCount']); + $this->assertSame(2, $pagingParams['pageCount']); + + $settings = ['finder' => 'published', 'limit' => 2]; + $result = $this->Paginator->paginate($table, [], $settings); + $this->assertCount(2, $result, '2 rows should come back'); + $this->assertEquals(['First Post', 'Second Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(2, $pagingParams['count']); + $this->assertSame(3, $pagingParams['totalCount']); + $this->assertSame(2, $pagingParams['pageCount']); + $this->assertTrue($pagingParams['hasNextPage']); + $this->assertFalse($pagingParams['hasPrevPage']); + $this->assertSame(2, $pagingParams['perPage']); + $this->assertNull($pagingParams['limit']); + } + + /** + * Test that special paginate types are called and that the type param doesn't leak out into defaults or options. + */ + public function testPaginateCustomFinder(): void + { + $settings = [ + 'PaginatorPosts' => [ + 'finder' => 'published', + 'maxLimit' => 10, + ], + ]; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + $this->assertSame(3, $table->find('published')->count()); + $table->updateAll(['published' => 'N'], ['id' => 2]); + + $result = $this->Paginator->paginate($table, [], $settings); + $pagingParams = $result->pagingParams(); + + $this->assertSame(1, $pagingParams['start']); + $this->assertSame(2, $pagingParams['end']); + $this->assertSame(2, $pagingParams['totalCount']); + $this->assertFalse($pagingParams['hasNextPage']); + } + + /** + * test direction setting. + */ + public function testPaginateDefaultDirection(): void + { + $settings = [ + 'PaginatorPosts' => [ + 'order' => ['Other.title' => 'ASC'], + ], + ]; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + + $result = $this->Paginator->paginate($table, [], $settings); + $pagingParams = $result->pagingParams(); + + $this->assertSame('Other.title', $pagingParams['sort']); + $this->assertNull($pagingParams['direction']); + } + + /** + * https://github.com/cakephp/cakephp/issues/16909 + * + * @return void + */ + public function testPaginateOrderWithNumericKeyAndSortSpecified(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage( + 'The `order` config must be an associative array.' + . ' Found invalid value with numeric key: `PaginatorPosts.title ASC`', + ); + + $settings = [ + 'PaginatorPosts' => [ + 'order' => ['PaginatorPosts.title ASC'], + ], + ]; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + + $this->Paginator->paginate($table, ['sort' => 'title'], $settings); + } + + public function testDeprecationWarningForExtraSettings(): void + { + $this->expectWarningMessageMatches( + '/Passing query options as paginator settings is no longer supported/', + function (): void { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $this->Paginator->paginate($table, [], ['fields' => ['title']]); + }, + ); + } + + /** + * Test sorts with simple 1:1 mapping + */ + public function testSortMapSimpleMapping(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => [ + 'name' => 'PaginatorPosts.title', + 'content' => 'PaginatorPosts.body', + ], + ]; + + // Test sorting by mapped key 'name' + $params = ['sort' => 'name', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + + // Test sorting by mapped key 'content' with desc direction + $params = ['sort' => 'content', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('content', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); + } + + /** + * Test sorts with shorthand numeric array syntax for 1:1 mapping + */ + public function testSortMapShorthandSyntax(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => [ + 'title', + 'body', + 'name' => 'PaginatorPosts.title', + ], + ]; + + // Test sorting by shorthand mapped key 'title' + $params = ['sort' => 'title', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + // Shorthand fields still get prefixed with table name for actual query + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + + // Test sorting by shorthand mapped key 'body' + $params = ['sort' => 'body', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('body', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + // Shorthand fields still get prefixed with table name for actual query + $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); + + // Test that regular mapping still works alongside shorthand + $params = ['sort' => 'name', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + } + + /** + * Test sorts with multi-column sorting + */ + public function testSortMapMultiColumnSorting(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => [ + 'titleauthor' => ['PaginatorPosts.title', 'PaginatorPosts.author_id'], + 'relevance' => ['PaginatorPosts.id', 'PaginatorPosts.body'], + ], + ]; + + // Test multi-column sorting with 'titleauthor' + $params = ['sort' => 'titleauthor', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('titleauthor', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals([ + 'PaginatorPosts.title' => 'desc', + 'PaginatorPosts.author_id' => 'desc', + ], $pagingParams['completeSort']); + + // Test multi-column sorting with 'relevance' + $params = ['sort' => 'relevance', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('relevance', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals([ + 'PaginatorPosts.id' => 'asc', + 'PaginatorPosts.body' => 'asc', + ], $pagingParams['completeSort']); + } + + /** + * Test sorts with fixed direction sorting + */ + public function testSortMapFixedDirectionSorting(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => [ + 'fresh' => [ + 'PaginatorPosts.title', + SortField::desc('PaginatorPosts.body', true), + ], + 'popularity' => [ + SortField::desc('PaginatorPosts.id', true), + SortField::asc('PaginatorPosts.author_id', true), + ], + ], + ]; + + // Test 'fresh' with mixed directions (querystring direction for title, locked desc for body) + $params = ['sort' => 'fresh', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('fresh', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); // The first non-locked field's direction + $this->assertEquals([ + 'PaginatorPosts.title' => 'asc', // Uses querystring direction + 'PaginatorPosts.body' => 'desc', // Locked direction + ], $pagingParams['completeSort']); + + // Test 'popularity' with all locked directions + $params = ['sort' => 'popularity', 'direction' => 'asc']; // Direction should be ignored + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('popularity', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', + 'PaginatorPosts.author_id' => 'asc', + ], $pagingParams['completeSort']); + } + + /** + * Test sorts with toggleable default directions and locked directions + */ + public function testSortMapToggleableAndLockedDirections(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => [ + 'custom' => [ + 'PaginatorPosts.title' => 'asc', // Default asc, can toggle + 'PaginatorPosts.body' => 'desc', // Default desc, can toggle + ], + 'locked' => [ + SortField::desc('PaginatorPosts.id', true), + 'PaginatorPosts.author_id' => 'asc', // Default asc, can toggle + ], + ], + ]; + + // Test 'custom' with default directions (no direction in query) + $params = ['sort' => 'custom']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.title' => 'asc', // Uses default + 'PaginatorPosts.body' => 'desc', // Uses default + ], $pagingParams['completeSort']); + + // Test 'custom' with asc direction (should use defaults as-is) + $params = ['sort' => 'custom', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.title' => 'asc', // Default is asc + 'PaginatorPosts.body' => 'desc', // Default is desc + ], $pagingParams['completeSort']); + + // Test 'custom' with desc direction (should invert all defaults) + $params = ['sort' => 'custom', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.title' => 'desc', // Default was asc, inverted to desc + 'PaginatorPosts.body' => 'asc', // Default was desc, inverted to asc + ], $pagingParams['completeSort']); + + // Test 'locked' with asc direction (uses defaults) + $params = ['sort' => 'locked', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', // Locked, never changes + 'PaginatorPosts.author_id' => 'asc', // Default asc + ], $pagingParams['completeSort']); + + // Test 'locked' with desc direction (inverts toggleable fields) + $params = ['sort' => 'locked', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', // Locked, never changes + 'PaginatorPosts.author_id' => 'desc', // Default asc, inverted to desc + ], $pagingParams['completeSort']); + } + + /** + * Test that unmapped keys are rejected when sorts is defined + */ + public function testSortMapRejectsUnmappedKeys(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => [ + 'name' => 'PaginatorPosts.title', + ], + ]; + + // Try to sort by unmapped field + $params = ['sort' => 'body', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + // Sort should be cleared as it's not in sorts + $this->assertNull($pagingParams['sort']); + $this->assertNull($pagingParams['direction']); + $this->assertEquals([], $pagingParams['completeSort']); + } + + /** + * Test backward compatibility when sorts is not configured + */ + public function testBackwardCompatibilityWithoutSortMap(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + + // Test without sorts - should work as before + $params = ['sort' => 'title', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); + } + + /** + * Test sorts with association sorting + */ + public function testSortMapWithAssociations(): void + { + $table = $this->getTableLocator()->get('Articles'); + // Association is already set up in the Articles table + + $settings = [ + 'sortableFields' => [ + 'author' => 'Authors.name', + 'author_article' => ['Authors.name', 'Articles.title'], + ], + ]; + + // Test association field mapping + $params = ['sort' => 'author', 'direction' => 'asc']; + $query = $table->find()->contain(['Authors']); + $result = $this->Paginator->paginate($query, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('author', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['Authors.name' => 'asc'], $pagingParams['completeSort']); + } + + /** + * Test sorts configuration with callable factory + */ + public function testSortsWithCallableFactory(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => function ($factory) { + return $factory + ->add('name', SortField::asc('PaginatorPosts.title')) + ->add( + 'popularity', + SortField::desc('PaginatorPosts.id', locked: true), + SortField::asc('PaginatorPosts.title'), + ) + ->add('newest', SortField::desc('PaginatorPosts.id')) + ->add('simple_author', 'PaginatorPosts.author_id'); + }, + ]; + + // Test sorting by mapped key 'name' + $params = ['sort' => 'name', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); + + // Test multi-field sorting with locked direction + $params = ['sort' => 'popularity', 'direction' => 'asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('popularity', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', // Locked direction + 'PaginatorPosts.title' => 'asc', // Uses requested direction + ], $pagingParams['completeSort']); + + // Test simple field mapping (string provided) + $params = ['sort' => 'simple_author', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('simple_author', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.author_id' => 'desc'], $pagingParams['completeSort']); + } + + /** + * Test combined sort-direction parameter format (e.g., 'title-asc') + */ + public function testCombinedSortDirectionFormat(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + + // Test ascending with combined format + $params = ['sort' => 'title-asc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'asc'], $pagingParams['completeSort']); + + // Test descending with combined format + $params = ['sort' => 'body-desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('body', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.body' => 'desc'], $pagingParams['completeSort']); + + // Test that traditional format still works + $params = ['sort' => 'title', 'direction' => 'desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); + + // Test combined format with hyphenated field names + $params = ['sort' => 'author_id-desc']; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('author_id', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.author_id' => 'desc'], $pagingParams['completeSort']); + } + + /** + * Test combined sort format with sortableFields + */ + public function testCombinedSortFormatWithSortableFields(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + $settings = [ + 'sortableFields' => [ + 'name' => 'PaginatorPosts.title', + 'content' => 'PaginatorPosts.body', + 'newest' => [ + SortField::desc('PaginatorPosts.id', locked: true), + 'PaginatorPosts.title', + ], + 'custom' => [ + 'PaginatorPosts.author_id' => 'asc', // Toggleable default + 'PaginatorPosts.body' => 'desc', // Toggleable default + ], + ], + ]; + + // Test simple mapping with combined format + $params = ['sort' => 'name-desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('name', $pagingParams['sort']); + $this->assertEquals('desc', $pagingParams['direction']); + $this->assertEquals(['PaginatorPosts.title' => 'desc'], $pagingParams['completeSort']); + + // Test that unmapped fields with combined format are still rejected + $params = ['sort' => 'unmapped-asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertNull($pagingParams['sort']); + $this->assertNull($pagingParams['direction']); + $this->assertEquals([], $pagingParams['completeSort']); + + // Test multi-field mapping with combined format (locked field) + $params = ['sort' => 'newest-asc']; // Direction should apply to non-locked fields + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('newest', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.id' => 'desc', // Locked direction + 'PaginatorPosts.title' => 'asc', // Uses combined format direction + ], $pagingParams['completeSort']); + + // Test toggleable defaults with combined format - asc (uses defaults) + $params = ['sort' => 'custom-asc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.author_id' => 'asc', // Default is asc + 'PaginatorPosts.body' => 'desc', // Default is desc + ], $pagingParams['completeSort']); + + // Test toggleable defaults with combined format - desc (inverts all) + $params = ['sort' => 'custom-desc']; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('custom', $pagingParams['sort']); + $this->assertEquals([ + 'PaginatorPosts.author_id' => 'desc', // Default asc, inverted to desc + 'PaginatorPosts.body' => 'asc', // Default desc, inverted to asc + ], $pagingParams['completeSort']); + } +} diff --git a/tests/TestCase/Datasource/Paging/PaginatedResultSetTest.php b/tests/TestCase/Datasource/Paging/PaginatedResultSetTest.php new file mode 100644 index 00000000000..8679f70d644 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/PaginatedResultSetTest.php @@ -0,0 +1,93 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.0.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource\Paging; + +use ArrayIterator; +use Cake\Collection\Collection; +use Cake\Datasource\Paging\PaginatedResultSet; +use Cake\Datasource\ResultSetInterface; +use Cake\ORM\ResultSet; +use Cake\TestSuite\TestCase; +use Mockery; +use function Cake\Collection\collection; + +class PaginatedResultSetTest extends TestCase +{ + public function testItems(): void + { + $resultSet = new class ([]) extends ResultSet { + }; + $paginatedResults = new PaginatedResultSet( + $resultSet, + [], + ); + + $this->assertInstanceOf(ResultSetInterface::class, $paginatedResults->items()); + } + + public function testToArray(): void + { + $paginatedResults = new PaginatedResultSet(new Collection([1, 2, 3]), []); + + $out = $paginatedResults->toArray(); + $this->assertSame([1, 2, 3], $out); + } + + public function testCall(): void + { + $resultSet = Mockery::mock(ResultSet::class) + ->shouldReceive('extract') + ->with('foo') + ->once() + ->andReturn(collection(['bar'])) + ->getMock(); + + $paginatedResults = new PaginatedResultSet( + $resultSet, + [], + ); + + $this->deprecated(function () use ($paginatedResults): void { + $result = $paginatedResults->extract('foo')->toList(); + $this->assertEquals(['bar'], $result); + }); + } + + public function testJsonEncode(): void + { + $paginatedResults = new PaginatedResultSet( + new ArrayIterator([1 => 'a', 2 => 'b', 3 => 'c']), + [], + ); + + $this->assertEquals('{"1":"a","2":"b","3":"c"}', json_encode($paginatedResults)); + } + + public function testSerialization(): void + { + $paginatedResults = new PaginatedResultSet( + new ArrayIterator([1 => 'a', 2 => 'b', 3 => 'c']), + ['foo' => 'bar'], + ); + + $serialized = serialize($paginatedResults); + $unserialized = unserialize($serialized); + + $this->assertEquals($paginatedResults->pagingParams(), $unserialized->pagingParams()); + $this->assertEquals($paginatedResults->items(), $unserialized->items()); + } +} diff --git a/tests/TestCase/Datasource/Paging/PaginatorTestTrait.php b/tests/TestCase/Datasource/Paging/PaginatorTestTrait.php new file mode 100644 index 00000000000..e907b1bd5b2 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/PaginatorTestTrait.php @@ -0,0 +1,1283 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.9.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource\Paging; + +use Cake\Core\Configure; +use Cake\Datasource\ConnectionManager; +use Cake\Datasource\EntityInterface; +use Cake\Datasource\Paging\Exception\PageOutOfBoundsException; +use Cake\Datasource\Paging\NumericPaginator; +use Cake\Datasource\RepositoryInterface; +use Cake\ORM\Query\SelectQuery; +use Cake\ORM\ResultSet; +use Mockery; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Model\Table\PaginatorPostsTable; + +trait PaginatorTestTrait +{ + /** + * @var \Cake\Datasource\Paging\NumericPaginator + */ + protected $Paginator; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + + Configure::write('App.namespace', 'TestApp'); + + $this->Paginator = new class extends NumericPaginator { + public function getDefaults(string $alias, array $settings): array + { + return parent::getDefaults($alias, $settings); + } + + public function mergeOptions(array $params, array $settings): array + { + return parent::mergeOptions($params, $settings); + } + + public function validateSort(RepositoryInterface $object, array $options): array + { + return parent::validateSort($object, $options); + } + + public function checkLimit(array $options): array + { + return parent::checkLimit($options); + } + }; + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->getTableLocator()->clear(); + } + + /** + * Test that non-numeric values are rejected for page, and limit + */ + #[AllowMockObjectsWithoutExpectations] + public function testPageParamCasting(): void + { + $query = $this->_getMockFindQuery(); + + $post = $this->createStub(RepositoryInterface::class); + $post->method('getAlias')->willReturn('Posts'); + $post->method('find')->willReturn($query); + + $params = ['page' => '1 " onclick="alert(\'xss\');">']; + $settings = ['limit' => 1, 'maxLimit' => 10]; + $result = $this->Paginator->paginate($post, $params, $settings); + $pagingParams = $result->pagingParams(); + $this->assertSame(1, $pagingParams['currentPage'], 'XSS exploit opened'); + } + + /** + * test that unknown keys in the default settings are **not** + * passed to the find operations. + */ + public function testPaginateExtraParams(): void + { + $params = ['page' => '-1']; + $settings = [ + 'PaginatorPosts' => [ + 'maxLimit' => 10, + 'order' => ['PaginatorPosts.id' => 'ASC'], + ], + ]; + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 10, + 'order' => ['PaginatorPosts.id' => 'ASC'], + 'page' => 1, + ]); + + $this->Paginator->paginate($table, $params, $settings); + } + + /** + * Test to make sure options get sent to custom finder methods via paginate + */ + public function testPaginateCustomFinderOptions(): void + { + $settings = [ + 'PaginatorPosts' => [ + 'finder' => ['author' => ['authorId' => 1]], + ], + ]; + $table = $this->getTableLocator()->get('PaginatorPosts'); + + $expected = $table + ->find('author', ...[ + 'conditions' => [ + 'PaginatorPosts.author_id' => 1, + ], + ]) + ->count(); + $result = $this->Paginator->paginate($table, [], $settings)->count(); + + $this->assertEquals($expected, $result); + } + + /** + * Test that nested eager loaders don't trigger invalid SQL errors. + */ + public function testPaginateNestedEagerLoader(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + $tags->associations()->remove('Authors'); + $tags->belongsToMany('Authors'); + $articles->getEventManager()->on('Model.beforeFind', function ($event, $query): void { + $query->matching('Tags', function ($q) { + return $q->matching('Authors', function ($q) { + return $q->where(['Authors.name' => 'larry']); + }); + }); + }); + $results = $this->Paginator->paginate($articles); + $result = $results->items()->first(); + $this->assertInstanceOf(EntityInterface::class, $result); + $this->assertInstanceOf(EntityInterface::class, $result->_matchingData['Tags']); + $this->assertInstanceOf(EntityInterface::class, $result->_matchingData['Authors']); + } + + /** + * test that flat default pagination parameters work. + */ + public function testDefaultPaginateParams(): void + { + $settings = [ + 'order' => ['PaginatorPosts.id' => 'DESC'], + 'maxLimit' => 10, + ]; + + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 10, + 'page' => 1, + 'order' => ['PaginatorPosts.id' => 'DESC'], + ]); + + $result = $this->Paginator->paginate($table, [], $settings)->pagingParams(); + + $this->assertEquals('PaginatorPosts.id', $result['sort']); + $this->assertEquals('DESC', $result['direction']); + } + + /** + * Tests that flat default pagination parameters work for multi order. + */ + public function testDefaultPaginateParamsMultiOrder(): void + { + $settings = [ + 'order' => ['PaginatorPosts.id' => 'DESC', 'PaginatorPosts.title' => 'ASC'], + ]; + + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 20, + 'page' => 1, + 'order' => $settings['order'], + ]); + + $result = $this->Paginator->paginate($table, [], $settings); + + $pagingParams = $result->pagingParams(); + $this->assertEquals('PaginatorPosts.id', $pagingParams['sortDefault']); + $this->assertEquals('DESC', $pagingParams['directionDefault']); + } + + /** + * test that default sort and default direction are injected into request + */ + public function testDefaultPaginateParamsIntoRequest(): void + { + $settings = [ + 'order' => ['PaginatorPosts.id' => 'DESC'], + 'maxLimit' => 10, + ]; + + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 10, + 'page' => 1, + 'order' => ['PaginatorPosts.id' => 'DESC'], + ]); + + $result = $this->Paginator->paginate($table, [], $settings); + $pagingParams = $result->pagingParams(); + $this->assertEquals('PaginatorPosts.id', $pagingParams['sortDefault']); + $this->assertEquals('DESC', $pagingParams['directionDefault']); + } + + /** + * test that option merging prefers specific models + */ + public function testMergeOptionsModelSpecific(): void + { + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + 'Posts' => [ + 'page' => 1, + 'limit' => 10, + 'maxLimit' => 50, + ], + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'scope' => null, + ]; + $defaults = $this->Paginator->getDefaults('Silly', $settings); + $result = $this->Paginator->mergeOptions([], $defaults); + $this->assertEquals($settings + [ + 'sortableFields' => null, + 'finder' => 'all', + ], $result); + + $defaults = $this->Paginator->getDefaults('Posts', $settings); + $result = $this->Paginator->mergeOptions([], $defaults); + $expected = [ + 'page' => 1, + 'limit' => 10, + 'maxLimit' => 50, + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * test mergeOptions with custom scope + */ + public function testMergeOptionsCustomScope(): void + { + $params = [ + 'page' => 10, + 'limit' => 10, + 'scope' => [ + 'page' => 2, + 'limit' => 5, + ], + ]; + + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions($params, $defaults); + $expected = [ + 'page' => 10, + 'limit' => 10, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + 'scope' => 'nonexistent', + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions($params, $defaults); + $expected = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'scope' => 'nonexistent', + 'sortableFields' => null, + ]; + $this->assertEquals($expected, $result); + + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + 'scope' => 'scope', + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions($params, $defaults); + $expected = [ + 'page' => 2, + 'limit' => 5, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'scope' => 'scope', + 'sortableFields' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * test mergeOptions with customFind key + */ + public function testMergeOptionsCustomFindKey(): void + { + $params = [ + 'page' => 10, + 'limit' => 10, + ]; + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions($params, $defaults); + $expected = [ + 'page' => 10, + 'limit' => 10, + 'maxLimit' => 100, + 'finder' => 'myCustomFind', + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * test merging options from the querystring. + */ + public function testMergeOptionsQueryString(): void + { + $params = [ + 'page' => 99, + 'limit' => 75, + ]; + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions($params, $defaults); + $expected = [ + 'page' => 99, + 'limit' => 75, + 'maxLimit' => 100, + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * test that the default allowedParameters doesn't let people screw with things they should not be allowed to. + */ + public function testMergeOptionsDefaultAllowedParameters(): void + { + $params = [ + 'page' => 10, + 'limit' => 10, + 'fields' => ['bad.stuff'], + 'recursive' => 1000, + 'conditions' => ['bad.stuff'], + 'contain' => ['bad'], + ]; + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions($params, $defaults); + $expected = [ + 'page' => 10, + 'limit' => 10, + 'maxLimit' => 100, + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * test that modifying allowed parameters works. + */ + public function testMergeOptionsExtraAllowedParameters(): void + { + $params = [ + 'page' => 10, + 'limit' => 10, + 'fields' => ['bad.stuff'], + 'recursive' => 1000, + 'conditions' => ['bad.stuff'], + 'contain' => ['bad'], + ]; + $settings = [ + 'page' => 1, + 'limit' => 20, + 'maxLimit' => 100, + ]; + + $this->Paginator->setConfig('allowedParameters', ['fields']); + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions($params, $defaults); + $expected = [ + 'page' => 10, + 'limit' => 10, + 'maxLimit' => 100, + 'fields' => ['bad.stuff'], + 'allowedParameters' => ['limit', 'sort', 'page', 'direction', 'fields'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * test mergeOptions with limit > maxLimit in code. + */ + public function testMergeOptionsMaxLimit(): void + { + $settings = [ + 'limit' => 200, + 'paramType' => 'named', + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions([], $defaults); + $expected = [ + 'page' => 1, + 'limit' => 100, + 'maxLimit' => 100, + 'paramType' => 'named', + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + + $settings = [ + 'maxLimit' => 10, + 'paramType' => 'named', + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions([], $defaults); + $expected = [ + 'page' => 1, + 'limit' => 10, + 'maxLimit' => 10, + 'paramType' => 'named', + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * test getDefaults with limit > maxLimit in code. + */ + public function testGetDefaultMaxLimit(): void + { + $settings = [ + 'page' => 1, + 'limit' => 2, + 'maxLimit' => 10, + 'order' => [ + 'Users.username' => 'asc', + ], + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions([], $defaults); + $expected = [ + 'page' => 1, + 'limit' => 2, + 'maxLimit' => 10, + 'order' => [ + 'Users.username' => 'asc', + ], + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + + $settings = [ + 'page' => 1, + 'limit' => 100, + 'maxLimit' => 10, + 'order' => [ + 'Users.username' => 'asc', + ], + ]; + $defaults = $this->Paginator->getDefaults('Post', $settings); + $result = $this->Paginator->mergeOptions([], $defaults); + $expected = [ + 'page' => 1, + 'limit' => 10, + 'maxLimit' => 10, + 'order' => [ + 'Users.username' => 'asc', + ], + 'allowedParameters' => ['limit', 'sort', 'page', 'direction'], + 'sortableFields' => null, + 'finder' => 'all', + 'scope' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * Integration test to ensure that validateSort is being used by paginate() + */ + public function testValidateSortInvalid(): void + { + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 20, + 'page' => 1, + 'order' => ['PaginatorPosts.id' => 'asc'], + ]); + + $params = [ + 'page' => 1, + 'sort' => 'id', + 'direction' => 'herp', + ]; + $result = $this->Paginator->paginate($table, $params); + $pagingParams = $result->pagingParams(); + $this->assertEquals('id', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + } + + /** + * test that invalid directions are ignored. + */ + public function testValidateSortInvalidDirection(): void + { + $model = $this->createStub(RepositoryInterface::class); + $model->method('getAlias')->willReturn('model'); + $model->method('hasField')->willReturn(true); + + $options = ['sort' => 'something', 'direction' => 'boogers']; + $result = $this->Paginator->validateSort($model, $options); + + $this->assertEquals('asc', $result['order']['model.something']); + } + + /** + * Test that "sort" and "direction" in paging params is properly set based + * on initial value of "order" in paging settings. + */ + public function testValidaSortInitialSortAndDirection(): void + { + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 20, + 'page' => 1, + 'order' => ['PaginatorPosts.id' => 'asc'], + ]); + + $options = [ + 'order' => [ + 'id' => 'asc', + ], + 'sortableFields' => ['id'], + ]; + $result = $this->Paginator->paginate($table, [], $options); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('id', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + } + + /** + * Test that "sort" and "direction" in paging params is properly set based + * on initial value of "order" in paging settings. + */ + public function testValidateSortAndDirectionAliased(): void + { + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 20, + 'page' => 1, + 'order' => ['PaginatorPosts.title' => 'asc'], + ]); + + $options = [ + 'order' => [ + 'Articles.title' => 'desc', + ], + ]; + $queryParams = [ + 'page' => 1, + 'sort' => 'title', + 'direction' => 'asc', + ]; + + $result = $this->Paginator->paginate($table, $queryParams, $options); + $pagingParams = $result->pagingParams(); + + $this->assertEquals('title', $pagingParams['sort']); + $this->assertEquals('asc', $pagingParams['direction']); + + $this->assertEquals('Articles.title', $pagingParams['sortDefault']); + $this->assertEquals('desc', $pagingParams['directionDefault']); + } + + /** + * testValidateSortRetainsOriginalSortValue + * + * @see https://github.com/cakephp/cakephp/issues/11740 + */ + public function testValidateSortRetainsOriginalSortValue(): void + { + $table = $this->_getMockPosts(['selectQuery']); + $query = $this->_getMockFindQuery(); + + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 20, + 'page' => 1, + 'order' => ['PaginatorPosts.id' => 'asc'], + ]); + + $params = [ + 'page' => 1, + 'sort' => 'id', + 'direction' => 'herp', + ]; + $options = [ + 'sortableFields' => ['id'], + ]; + $result = $this->Paginator->paginate($table, $params, $options); + $pagingParams = $result->pagingParams(); + $this->assertEquals('id', $pagingParams['sort']); + } + + /** + * Test that a really large page number gets clamped to the max page size. + */ + public function testOutOfRangePageNumberGetsClamped(): void + { + $params['page'] = 3000; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + try { + $this->Paginator->paginate($table, $params); + $this->fail('No exception raised'); + } catch (PageOutOfBoundsException $exception) { + $this->assertEquals( + 'Page number `3000` could not be found.', + $exception->getMessage(), + ); + + $attributes = $exception->getAttributes(); + $this->assertSame(3000, $attributes['requestedPage']); + $this->assertArrayHasKey('pagingParams', $attributes); + } + } + + /** + * Test that a really REALLY large page number gets clamped to the max page size. + */ + public function testOutOfVeryBigPageNumberGetsClamped(): void + { + $this->expectException(PageOutOfBoundsException::class); + $params = [ + 'page' => '3000000000000000000', + ]; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + $this->Paginator->paginate($table, $params); + } + + /** + * test that fields not in sortableFields won't be part of order conditions. + */ + public function testValidateAllowedSortFailure(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'sort' => 'body', + 'direction' => 'asc', + 'sortableFields' => ['title', 'id'], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $this->assertEquals([], $result['order']); + } + + /** + * test that fields in the sortableFields are not validated + */ + public function testValidateAllowedSortTrusted(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'sort' => 'body', + 'direction' => 'asc', + 'allowedsort' => ['body'], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $expected = ['model.body' => 'asc']; + $this->assertEquals( + $expected, + $result['order'], + 'Trusted fields in schema should be prefixed', + ); + } + + /** + * test that sortableFields as empty array does not allow any sorting + */ + public function testValidateAllowedSortEmpty(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'order' => [ + 'body' => 'asc', + 'foo.bar' => 'asc', + ], + 'sort' => 'body', + 'direction' => 'asc', + 'sortableFields' => [], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $this->assertSame([], $result['order'], 'No sort should be applied'); + } + + /** + * test that fields in the sortableFields are not validated + */ + public function testValidateAllowedSortNotInSchema(): void + { + $model = Mockery::mock(RepositoryInterface::class); + $model->shouldReceive('getAlias')->andReturn('model'); + $model->shouldReceive('hasField')->andReturn(false); + + $options = [ + 'sort' => 'score', + 'direction' => 'asc', + 'sortableFields' => ['score'], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $expected = ['score' => 'asc']; + $this->assertEquals( + $expected, + $result['order'], + 'Trusted fields not in schema should not be altered', + ); + } + + /** + * test that multiple fields in the sortableFields are not validated and properly aliased. + */ + public function testValidateAllowedSortMultiple(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'order' => [ + 'body' => 'asc', + 'foo.bar' => 'asc', + ], + 'sortableFields' => ['body', 'foo.bar'], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $expected = [ + 'model.body' => 'asc', + 'foo.bar' => 'asc', + ]; + $this->assertEquals($expected, $result['order']); + } + + /** + * @param string $modelAlias Model alias to use. + * @return \Cake\Datasource\RepositoryInterface + */ + protected function mockAliasHasFieldModel($modelAlias = 'model') + { + $model = Mockery::mock(RepositoryInterface::class); + $model->shouldReceive('getAlias')->andReturn($modelAlias); + $model->shouldReceive('hasField')->andReturn(true); + + return $model; + } + + /** + * test that multiple sort works. + */ + public function testValidateSortMultiple(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'order' => [ + 'author_id' => 'asc', + 'title' => 'asc', + ], + ]; + $result = $this->Paginator->validateSort($model, $options); + $expected = [ + 'model.author_id' => 'asc', + 'model.title' => 'asc', + ]; + + $this->assertEquals($expected, $result['order']); + } + + /** + * test that multiple sort adds in query data. + */ + public function testValidateSortMultipleWithQuery(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'sort' => 'created', + 'direction' => 'desc', + 'order' => [ + 'author_id' => 'asc', + 'title' => 'asc', + ], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $expected = [ + 'model.created' => 'desc', + 'model.author_id' => 'asc', + 'model.title' => 'asc', + ]; + $this->assertEquals($expected, $result['order']); + + $options = [ + 'sort' => 'title', + 'direction' => 'desc', + 'order' => [ + 'author_id' => 'asc', + 'title' => 'asc', + ], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $expected = [ + 'model.title' => 'desc', + 'model.author_id' => 'asc', + ]; + $this->assertEquals($expected, $result['order']); + } + + /** + * Tests that sort query string and model prefixes default match on assoc merging. + */ + public function testValidateSortMultipleWithQueryAndAliasedDefault(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'sort' => 'created', + 'direction' => 'desc', + 'order' => [ + 'model.created' => 'asc', + ], + ]; + $result = $this->Paginator->validateSort($model, $options); + + $expected = [ + 'sort' => 'created', + 'order' => [ + 'model.created' => 'desc', + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests that order strings can used by Paginator + */ + public function testValidateSortWithString(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'order' => 'model.author_id DESC', + ]; + $result = $this->Paginator->validateSort($model, $options); + $expected = 'model.author_id DESC'; + + $this->assertEquals($expected, $result['order']); + } + + /** + * Test that no sort doesn't trigger an error. + */ + public function testValidateSortNoSort(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = [ + 'direction' => 'asc', + 'sortableFields' => ['title', 'id'], + ]; + $result = $this->Paginator->validateSort($model, $options); + $this->assertEquals([], $result['order']); + } + + /** + * Test sorting with incorrect aliases on valid fields. + */ + public function testValidateSortInvalidAlias(): void + { + $model = $this->mockAliasHasFieldModel(); + + $options = ['sort' => 'Derp.id']; + $result = $this->Paginator->validateSort($model, $options); + $this->assertEquals([], $result['order']); + } + + /** + * Test that unprefixed sortableFields work correctly with prefixed default order. + * + * When controller sets prefixed order like `['Articles.modified' => 'DESC']` + * and sortableFields uses unprefixed names like `['modified']`, sort toggling + * should work correctly without duplicating the field in the order array. + */ + public function testValidateSortUnprefixedFieldsWithPrefixedDefaultOrder(): void + { + $model = $this->mockAliasHasFieldModel(); + + // Controller has prefixed default order but unprefixed sortableFields + $options = [ + 'sort' => 'created', + 'direction' => 'asc', + 'order' => [ + 'model.created' => 'DESC', + ], + 'sortableFields' => ['created'], + ]; + + $result = $this->Paginator->validateSort($model, $options); + + // The sort should work - unprefixed 'created' from sortableFields should + // be recognized as matching 'model.created' from default order + $expected = [ + 'model.created' => 'asc', + ]; + $this->assertSame($expected, $result['order']); + $this->assertSame('created', $result['sort']); + } + + /** + * @return array + */ + public static function checkLimitProvider(): array + { + return [ + 'out of bounds' => [ + ['limit' => 1000000, 'maxLimit' => 100], + 100, + ], + 'limit is nan' => [ + ['limit' => 'sheep!', 'maxLimit' => 100], + 1, + ], + 'negative limit' => [ + ['limit' => '-1', 'maxLimit' => 100], + 1, + ], + 'unset limit' => [ + ['limit' => null, 'maxLimit' => 100], + 1, + ], + 'limit = 0' => [ + ['limit' => 0, 'maxLimit' => 100], + 1, + ], + 'limit = 0 v2' => [ + ['limit' => 0, 'maxLimit' => 0], + 1, + ], + 'limit = null' => [ + ['limit' => null, 'maxLimit' => 0], + 1, + ], + 'bad input, results in 1' => [ + ['limit' => null, 'maxLimit' => null], + 1, + ], + 'bad input, results in 1 v2' => [ + ['limit' => false, 'maxLimit' => false], + 1, + ], + ]; + } + + /** + * test that maxLimit is respected + */ + #[DataProvider('checkLimitProvider')] + public function testCheckLimit(array $input, int $expected): void + { + $result = $this->Paginator->checkLimit($input); + $this->assertSame($expected, $result['limit']); + } + + /** + * Integration test for checkLimit() being applied inside paginate() + */ + public function testPaginateMaxLimit(): void + { + $table = $this->getTableLocator()->get('PaginatorPosts'); + + $settings = [ + 'maxLimit' => 100, + ]; + $params = [ + 'limit' => '1000', + ]; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + $this->assertEquals(100, $pagingParams['limit']); + $this->assertEquals(100, $pagingParams['perPage']); + + $params = [ + 'limit' => '10', + ]; + $result = $this->Paginator->paginate($table, $params, $settings); + $pagingParams = $result->pagingParams(); + $this->assertEquals(10, $pagingParams['limit']); + $this->assertEquals(10, $pagingParams['perPage']); + } + + /** + * Tests that it is possible to pass an already made query object to + * paginate() + */ + public function testPaginateQuery(): void + { + $params = ['page' => '-1']; + $settings = [ + 'PaginatorPosts' => [ + 'maxLimit' => 10, + 'order' => ['PaginatorPosts.id' => 'ASC'], + ], + ]; + $table = $this->_getMockPosts(['find']); + $query = $this->_getMockFindQuery($table); + $table->expects($this->never())->method('find'); + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 10, + 'order' => ['PaginatorPosts.id' => 'ASC'], + 'page' => 1, + ]); + $this->Paginator->paginate($query, $params, $settings); + } + + /** + * test paginate() with bind() + */ + public function testPaginateQueryWithBindValue(): void + { + $config = ConnectionManager::getConfig('test'); + $this->skipIf(str_contains($config['driver'], 'Sqlserver'), 'Test temporarily broken in SQLServer'); + $table = $this->getTableLocator()->get('PaginatorPosts'); + $query = $table->find() + ->where(['PaginatorPosts.author_id BETWEEN :start AND :end']) + ->bind(':start', 1) + ->bind(':end', 2); + $results = $this->Paginator->paginate($query, []); + + $result = $results->toArray(); + $this->assertCount(2, $result); + $this->assertEquals('First Post', $result[0]->title); + $this->assertEquals('Third Post', $result[1]->title); + } + + /** + * Tests that passing a query object with a limit clause set will + * overwrite it with the passed defaults. + */ + public function testPaginateQueryWithLimit(): void + { + $params = ['page' => '-1']; + $settings = [ + 'PaginatorPosts' => [ + 'maxLimit' => 10, + 'limit' => 5, + 'order' => ['PaginatorPosts.id' => 'ASC'], + ], + ]; + $table = $this->_getMockPosts(['find']); + $query = $this->_getMockFindQuery($table); + $query->limit(2); + $table->expects($this->never())->method('find'); + $query->shouldReceive('applyOptions') + ->once() + ->with([ + 'limit' => 5, + 'order' => ['PaginatorPosts.id' => 'ASC'], + 'page' => 1, + ]); + $this->Paginator->paginate($query, $params, $settings); + } + + /** + * Helper method for making mocks. + * + * @param array $methods + * @return \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject + */ + protected function _getMockPosts($methods = []) + { + return $this->getMockBuilder(PaginatorPostsTable::class) + ->onlyMethods($methods) + ->setConstructorArgs([[ + 'connection' => ConnectionManager::get('test'), + 'alias' => 'PaginatorPosts', + 'schema' => [ + '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']]], + ], + ]]) + ->getMock(); + } + + /** + * Helper method for mocking queries. + * + * @param RepositoryInterface|null $table + * @return \Cake\ORM\Query\SelectQuery|\Mockery\MockInterface + * @throws \PHPUnit\Framework\MockObject\Exception + */ + protected function _getMockFindQuery($table = null) + { + $query = Mockery::mock(SelectQuery::class) + ->makePartial(); + + $results = $this->createStub(ResultSet::class); + + $query->shouldReceive('count')->andReturn(2); + $query->shouldReceive('all')->andReturn($results); + + if ($table) { + $query->setRepository($table); + } + + return $query; + } +} diff --git a/tests/TestCase/Datasource/Paging/SimplePaginatorTest.php b/tests/TestCase/Datasource/Paging/SimplePaginatorTest.php new file mode 100644 index 00000000000..84e47c32c0a --- /dev/null +++ b/tests/TestCase/Datasource/Paging/SimplePaginatorTest.php @@ -0,0 +1,141 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.9.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource\Paging; + +use Cake\Core\Configure; +use Cake\Datasource\Paging\SimplePaginator; +use Cake\Datasource\RepositoryInterface; +use Cake\ORM\Entity; + +class SimplePaginatorTest extends NumericPaginatorTest +{ + protected function setUp(): void + { + parent::setUp(); + + Configure::write('App.namespace', 'TestApp'); + + $this->Paginator = new class extends SimplePaginator { + public function getDefaults(string $alias, array $settings): array + { + return parent::getDefaults($alias, $settings); + } + + public function mergeOptions(array $params, array $settings): array + { + return parent::mergeOptions($params, $settings); + } + + public function validateSort(RepositoryInterface $object, array $options): array + { + return parent::validateSort($object, $options); + } + + public function checkLimit(array $options): array + { + return parent::checkLimit($options); + } + }; + } + + /** + * test paginate() and custom find, to make sure the correct count is returned. + */ + public function testPaginateCustomFind(): void + { + $titleExtractor = function ($result) { + $ids = []; + foreach ($result as $record) { + $ids[] = $record->title; + } + + return $ids; + }; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + $data = ['author_id' => 3, 'title' => 'Fourth Post', 'body' => 'Article Body, unpublished', 'published' => 'N']; + $result = $table->save(new Entity($data)); + $this->assertNotEmpty($result); + + $result = $this->Paginator->paginate($table); + $this->assertCount(4, $result, '4 rows should come back'); + $this->assertEquals(['First Post', 'Second Post', 'Third Post', 'Fourth Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(1, $pagingParams['currentPage']); + $this->assertNull($pagingParams['totalCount']); + + $settings = ['finder' => 'published']; + $result = $this->Paginator->paginate($table, [], $settings); + $this->assertCount(3, $result, '3 rows should come back'); + $this->assertEquals(['First Post', 'Second Post', 'Third Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(1, $pagingParams['currentPage']); + $this->assertNull($pagingParams['totalCount']); + + $settings = ['finder' => 'published', 'limit' => 2, 'page' => 2]; + $result = $this->Paginator->paginate($table, [], $settings); + $this->assertCount(1, $result, '1 rows should come back'); + $this->assertEquals(['Third Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(2, $pagingParams['currentPage']); + $this->assertNull($pagingParams['totalCount']); + $this->assertNull($pagingParams['pageCount']); + + $settings = ['finder' => 'published', 'limit' => 2]; + $result = $this->Paginator->paginate($table, [], $settings); + $this->assertCount(2, $result, '2 rows should come back'); + $this->assertEquals(['First Post', 'Second Post'], $titleExtractor($result)); + + $pagingParams = $result->pagingParams(); + $this->assertSame(1, $pagingParams['currentPage']); + $this->assertNull($pagingParams['totalCount']); + $this->assertNull($pagingParams['pageCount']); + $this->assertTrue($pagingParams['hasNextPage']); + $this->assertFalse($pagingParams['hasPrevPage']); + $this->assertSame(2, $pagingParams['perPage']); + $this->assertNull($pagingParams['limit']); + } + + /** + * Test that special paginate types are called and that the type param doesn't leak out into defaults or options. + */ + public function testPaginateCustomFinder(): void + { + $settings = [ + 'PaginatorPosts' => [ + 'finder' => 'published', + 'maxLimit' => 10, + ], + ]; + + $table = $this->getTableLocator()->get('PaginatorPosts'); + $this->assertSame(3, $table->find('published')->count()); + $table->updateAll(['published' => 'N'], ['id' => 2]); + + $result = $this->Paginator->paginate($table, [], $settings); + $pagingParams = $result->pagingParams(); + + $this->assertSame(1, $pagingParams['start']); + $this->assertSame(2, $pagingParams['end']); + $this->assertSame(2, count($result)); + $this->assertSame(2, $pagingParams['count']); + $this->assertFalse($pagingParams['hasNextPage']); + } +} diff --git a/tests/TestCase/Datasource/Paging/SortFieldTest.php b/tests/TestCase/Datasource/Paging/SortFieldTest.php new file mode 100644 index 00000000000..4307e01c4b0 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/SortFieldTest.php @@ -0,0 +1,226 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource\Paging; + +use Cake\Datasource\Paging\SortField; +use Cake\TestSuite\TestCase; + +/** + * SortField Test Case + */ +class SortFieldTest extends TestCase +{ + /** + * Test constructor and getters + * + * @return void + */ + public function testConstructorAndGetters(): void + { + $field = new SortField('created', SortField::DESC, false); + $this->assertSame('created', $field->getField()); + $this->assertFalse($field->isLocked()); + + $lockedField = new SortField('score', SortField::ASC, true); + $this->assertSame('score', $lockedField->getField()); + $this->assertTrue($lockedField->isLocked()); + } + + /** + * Test asc() static factory method + * + * @return void + */ + public function testAscFactory(): void + { + $field = SortField::asc('title'); + $this->assertSame('title', $field->getField()); + $this->assertFalse($field->isLocked()); + + // Should use default direction when no direction specified + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, false)); + + // Should allow override when direction is specified + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test desc() static factory method + * + * @return void + */ + public function testDescFactory(): void + { + $field = SortField::desc('created'); + $this->assertSame('created', $field->getField()); + $this->assertFalse($field->isLocked()); + + // Should use default direction when no direction specified or ASC + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, true)); + + //Should reverse direction because defaultDirection (initial) is DESC + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test asc() with locked parameter + * + * @return void + */ + public function testAscFactoryLocked(): void + { + $field = SortField::asc('score', locked: true); + $this->assertSame('score', $field->getField()); + $this->assertTrue($field->isLocked()); + + // Should always return locked direction regardless of request + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, false)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test desc() with locked parameter + * + * @return void + */ + public function testDescFactoryLocked(): void + { + $field = SortField::desc('score', locked: true); + $this->assertSame('score', $field->getField()); + $this->assertTrue($field->isLocked()); + + // Should always return locked direction regardless of request + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, true)); + } + + /** + * Test getDirection() with no default direction + * + * @return void + */ + public function testGetDirectionNoDefault(): void + { + $field = new SortField('name', null, false); + + // Should use requested direction when no default is set + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, false)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, true)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test getDirection() with default direction ASC + * + * @return void + */ + public function testGetDirectionWithDefaultAsc(): void + { + $field = new SortField('created', SortField::ASC, false); + + // Should use default when direction not specified + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, false)); + + // Should use requested direction when explicitly specified + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, true)); + $this->assertSame(SortField::DESC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test getDirection() with default direction DESC + * + * @return void + */ + public function testGetDirectionWithDefaultDesc(): void + { + $field = new SortField('created', SortField::DESC, false); + + // Should use default when direction not specified + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, false)); + + //Should reverse direction because defaultDirection (initial) is DESC + $this->assertSame(SortField::DESC, $field->getDirection(SortField::ASC, true)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, true)); + } + + /** + * Test locked field behavior + * + * @return void + */ + public function testLockedFieldBehavior(): void + { + $field = new SortField('priority', SortField::ASC, true); + + // Locked field should always return its default direction + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, false)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::DESC, true)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::ASC, $field->getDirection(SortField::ASC, true)); + } + + /** + * Test usage examples from the documentation + * + * @return void + */ + public function testUsageExamples(): void + { + // Example sortMap configuration + $sortMap = [ + 'newest' => [ + SortField::desc('created'), // Default desc, toggleable + SortField::asc('title'), // Default asc, toggleable + ], + 'popular' => [ + SortField::desc('score', locked: true), // Always desc + 'author', // Still support strings for BC + ], + ]; + + // Test 'newest' configuration + $newestFields = $sortMap['newest']; + + $createdField = $newestFields[0]; + $this->assertInstanceOf(SortField::class, $createdField); + $this->assertSame('created', $createdField->getField()); + $this->assertSame(SortField::DESC, $createdField->getDirection(SortField::ASC, false)); + $this->assertSame(SortField::DESC, $createdField->getDirection(SortField::ASC, true)); + //Should reverse direction because defaultDirection (initial) is DESC + $this->assertSame(SortField::ASC, $createdField->getDirection(SortField::DESC, true)); + + $titleField = $newestFields[1]; + $this->assertInstanceOf(SortField::class, $titleField); + $this->assertSame('title', $titleField->getField()); + $this->assertSame(SortField::ASC, $titleField->getDirection(SortField::DESC, false)); + $this->assertSame(SortField::DESC, $titleField->getDirection(SortField::DESC, true)); + + // Test 'popular' configuration + $popularFields = $sortMap['popular']; + + $scoreField = $popularFields[0]; + $this->assertInstanceOf(SortField::class, $scoreField); + $this->assertSame('score', $scoreField->getField()); + $this->assertSame(SortField::DESC, $scoreField->getDirection(SortField::ASC, true)); + $this->assertTrue($scoreField->isLocked()); + + // Test backward compatibility with string + $this->assertSame('author', $popularFields[1]); + } +} diff --git a/tests/TestCase/Datasource/Paging/SortableFieldsBuilderTest.php b/tests/TestCase/Datasource/Paging/SortableFieldsBuilderTest.php new file mode 100644 index 00000000000..aa0c69a01d7 --- /dev/null +++ b/tests/TestCase/Datasource/Paging/SortableFieldsBuilderTest.php @@ -0,0 +1,463 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource\Paging; + +use Cake\Datasource\Paging\SortableFieldsBuilder; +use Cake\Datasource\Paging\SortField; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; + +/** + * SortableFieldsBuilder Test Case + */ +class SortableFieldsBuilderTest extends TestCase +{ + /** + * Test basic add() functionality + * + * @return void + */ + public function testAdd(): void + { + $factory = new SortableFieldsBuilder(); + $factory->add('newest', SortField::desc('created')); + $sorts = $factory->toArray(); + + $this->assertArrayHasKey('newest', $sorts); + $this->assertIsArray($sorts['newest']); + $this->assertCount(1, $sorts['newest']); + $this->assertInstanceOf(SortField::class, $sorts['newest'][0]); + } + + /** + * Test add() with multiple fields + * + * @return void + */ + public function testAddMultipleFields(): void + { + $factory = new SortableFieldsBuilder(); + $factory->add('relevance', SortField::desc('score'), SortField::asc('title')); + $sorts = $factory->toArray(); + + $this->assertIsArray($sorts['relevance']); + $this->assertCount(2, $sorts['relevance']); + $this->assertInstanceOf(SortField::class, $sorts['relevance'][0]); + $this->assertInstanceOf(SortField::class, $sorts['relevance'][1]); + } + + /** + * Test add() with string fields + * + * @return void + */ + public function testAddStringFields(): void + { + $factory = new SortableFieldsBuilder(); + $factory->add('simple', 'title', 'created'); + $sorts = $factory->toArray(); + + $this->assertIsArray($sorts['simple']); + $this->assertCount(2, $sorts['simple']); + $this->assertSame('title', $sorts['simple'][0]); + $this->assertSame('created', $sorts['simple'][1]); + } + + /** + * Test add() with no fields (shorthand) + * + * @return void + */ + public function testAddShorthand(): void + { + $factory = new SortableFieldsBuilder(); + $factory->add('title'); + $sorts = $factory->toArray(); + + $this->assertIsArray($sorts['title']); + $this->assertCount(1, $sorts['title']); + $this->assertSame('title', $sorts['title'][0]); + } + + /** + * Test create() with null returns null + * + * @return void + */ + public function testCreateWithNull(): void + { + $builder = SortableFieldsBuilder::create(null); + $this->assertNull($builder); + } + + /** + * Test create() from simple array + * + * @return void + */ + public function testCreateFromSimpleArray(): void + { + $builder = SortableFieldsBuilder::create(['title', 'created', 'author_id']); + $this->assertInstanceOf(SortableFieldsBuilder::class, $builder); + + $map = $builder->toArray(); + $this->assertArrayHasKey('title', $map); + $this->assertArrayHasKey('created', $map); + $this->assertArrayHasKey('author_id', $map); + } + + /** + * Test create() from associative map + * + * @return void + */ + public function testCreateFromMap(): void + { + $config = [ + 'name' => 'Users.name', + 'newest' => [SortField::desc('created')], + ]; + + $builder = SortableFieldsBuilder::create($config); + $this->assertInstanceOf(SortableFieldsBuilder::class, $builder); + + $map = $builder->toArray(); + $this->assertSame('Users.name', $map['name']); + $this->assertInstanceOf(SortField::class, $map['newest'][0]); + } + + /** + * Test create() from callable + * + * @return void + */ + public function testCreateFromCallable(): void + { + $builder = SortableFieldsBuilder::create(function ($factory) { + return $factory + ->add('name', SortField::asc('Users.name')) + ->add('newest', SortField::desc('created')); + }); + + $this->assertInstanceOf(SortableFieldsBuilder::class, $builder); + $map = $builder->toArray(); + $this->assertArrayHasKey('name', $map); + $this->assertArrayHasKey('newest', $map); + } + + /** + * Test resolve() with simple string mapping + * + * @return void + */ + public function testResolveSimpleMapping(): void + { + $builder = SortableFieldsBuilder::create([ + 'name' => 'Users.name', + ]); + $this->assertNotNull($builder); + + $result = $builder->resolve('name', 'asc', true); + $this->assertSame(['Users.name' => 'asc'], $result); + + $result = $builder->resolve('name', 'desc', true); + $this->assertSame(['Users.name' => 'desc'], $result); + } + + /** + * Test resolve() with invalid key returns null + * + * @return void + */ + public function testResolveInvalidKey(): void + { + $builder = SortableFieldsBuilder::create([ + 'name' => 'Users.name', + ]); + $this->assertNotNull($builder); + + $result = $builder->resolve('invalid', 'asc', true); + $this->assertNull($result); + } + + /** + * Test resolve() with multi-column array + * + * @return void + */ + public function testResolveMultiColumn(): void + { + $builder = SortableFieldsBuilder::create([ + 'newest' => ['created', 'title'], + ]); + $this->assertNotNull($builder); + + $result = $builder->resolve('newest', 'desc', true); + $expected = [ + 'created' => 'desc', + 'title' => 'desc', + ]; + $this->assertSame($expected, $result); + } + + /** + * Test resolve() with SortField objects + * + * @return void + */ + public function testResolveWithSortFieldObjects(): void + { + $builder = SortableFieldsBuilder::create([ + 'popular' => [ + SortField::desc('score'), + SortField::asc('title'), + ], + ]); + $this->assertNotNull($builder); + + // Without direction specified - use defaults + $result = $builder->resolve('popular', 'asc', false); + $expected = [ + 'score' => 'desc', + 'title' => 'asc', + ]; + $this->assertSame($expected, $result); + + // With direction specified (ASC) - toggleable fields use it + $result = $builder->resolve('popular', 'asc', true); + $this->assertSame($expected, $result); + + // With direction specified (DESC) - toggleable fields use it + $result = $builder->resolve('popular', 'desc', true); + $expected = [ + 'score' => 'asc', //reversed defaultDirect(DESC) + 'title' => 'desc', + ]; + $this->assertSame($expected, $result); + } + + /** + * Test resolve() with locked SortField + * + * @return void + */ + public function testResolveWithLockedSortField(): void + { + $builder = SortableFieldsBuilder::create([ + 'relevance' => [ + SortField::desc('score', locked: true), + SortField::asc('title'), + ], + ]); + $this->assertNotNull($builder); + + // Try to override locked field with asc + $result = $builder->resolve('relevance', 'asc', true); + $expected = [ + 'score' => 'desc', // Locked, stays desc + 'title' => 'asc', // Toggleable, uses requested + ]; + $this->assertSame($expected, $result); + } + + /** + * Test resolve() with default directions when not specified + * + * @return void + */ + public function testResolveWithDefaultDirections(): void + { + $builder = SortableFieldsBuilder::create([ + 'custom' => [ + 'title' => 'asc', + 'created' => 'desc', + ], + ]); + $this->assertNotNull($builder); + + // No direction specified - use defaults + $result = $builder->resolve('custom', 'asc', false); + $expected = [ + 'title' => 'asc', + 'created' => 'desc', + ]; + $this->assertSame($expected, $result); + + // Direction 'asc' specified - use defaults as-is + $result = $builder->resolve('custom', 'asc', true); + $expected = [ + 'title' => 'asc', + 'created' => 'desc', + ]; + $this->assertSame($expected, $result); + + // Direction 'desc' specified - invert all defaults + $result = $builder->resolve('custom', 'desc', true); + $expected = [ + 'title' => 'desc', // default asc, inverted to desc + 'created' => 'asc', // default desc, inverted to asc + ]; + $this->assertSame($expected, $result); + } + + /** + * Test resolve() with simple array format + * + * @return void + */ + public function testResolveWithSimpleArray(): void + { + $builder = SortableFieldsBuilder::create(['title', 'created', 'author_id']); + $this->assertNotNull($builder); + + $result = $builder->resolve('title', 'asc', true); + $this->assertSame(['title' => 'asc'], $result); + + $result = $builder->resolve('created', 'desc', true); + $this->assertSame(['created' => 'desc'], $result); + + $result = $builder->resolve('invalid', 'asc', true); + $this->assertNull($result); + } + + /** + * Test resolve() with empty array uses key as field + * + * @return void + */ + public function testResolveWithEmptyArray(): void + { + $builder = new SortableFieldsBuilder(); + $builder->add('title'); // Adds empty array + + $result = $builder->resolve('title', 'asc', true); + $this->assertSame(['title' => 'asc'], $result); + } + + /** + * Test fromArray() static method with simple array format + * + * @return void + */ + public function testFromArrayWithSimpleFormat(): void + { + $builder = SortableFieldsBuilder::fromArray(['title', 'created']); + $map = $builder->toArray(); + + $this->assertArrayHasKey('title', $map); + $this->assertArrayHasKey('created', $map); + $this->assertSame(['title'], $map['title']); + $this->assertSame(['created'], $map['created']); + } + + /** + * Test fromArray() static method with associative map format + * + * @return void + */ + public function testFromArrayWithMapFormat(): void + { + $config = [ + 'name' => 'Users.name', + 'newest' => ['created', 'title'], + ]; + + $builder = SortableFieldsBuilder::fromArray($config); + $map = $builder->toArray(); + + $this->assertSame('Users.name', $map['name']); + $this->assertSame(['created', 'title'], $map['newest']); + } + + /** + * Test fromArray() with SortField object (not in array) + * + * @return void + */ + public function testFromArrayWithSortFieldObject(): void + { + $config = [ + 'newest' => SortField::desc('created'), + ]; + + $builder = SortableFieldsBuilder::fromArray($config); + $map = $builder->toArray(); + + $this->assertIsArray($map['newest']); + $this->assertCount(1, $map['newest']); + $this->assertInstanceOf(SortField::class, $map['newest'][0]); + } + + /** + * Test fromArray() with mixed format (numeric and string keys) + * + * @return void + */ + public function testFromArrayWithMixedFormat(): void + { + $config = [ + 'title', + 'name' => 'Users.name', + 'created', + ]; + + $builder = SortableFieldsBuilder::fromArray($config); + $map = $builder->toArray(); + + $this->assertArrayHasKey('title', $map); + $this->assertArrayHasKey('name', $map); + $this->assertArrayHasKey('created', $map); + $this->assertSame('Users.name', $map['name']); + $this->assertIsArray($map['title']); + $this->assertIsArray($map['created']); + } + + /** + * Test fromArray() with invalid type throws exception + * + * @return void + */ + public function testFromArrayWithInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid sortable field value type for key `invalid`. Expected string, array, or SortField, got `int`.'); + + $config = [ + 'invalid' => 123, + ]; + + SortableFieldsBuilder::fromArray($config); + } + + /** + * Test fromCallable() static method + * + * @return void + */ + public function testFromCallable(): void + { + $builder = SortableFieldsBuilder::fromCallable(function ($factory) { + return $factory + ->add('name', 'Users.name') + ->add('newest', SortField::desc('created')); + }); + + $map = $builder->toArray(); + $this->assertArrayHasKey('name', $map); + $this->assertArrayHasKey('newest', $map); + } +} diff --git a/tests/TestCase/Datasource/QueryCacherTest.php b/tests/TestCase/Datasource/QueryCacherTest.php new file mode 100644 index 00000000000..9ddda1f1d43 --- /dev/null +++ b/tests/TestCase/Datasource/QueryCacherTest.php @@ -0,0 +1,125 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource; + +use Cake\Cache\Cache; +use Cake\Core\Exception\CakeException; +use Cake\Datasource\QueryCacher; +use Cake\TestSuite\TestCase; +use stdClass; + +/** + * Query cacher test + */ +class QueryCacherTest extends TestCase +{ + /** + * @var \Cake\Cache\CacheEngineInterface + */ + protected $engine; + + /** + * Setup method + */ + protected function setUp(): void + { + parent::setUp(); + Cache::setConfig('queryCache', ['className' => 'Array']); + $this->engine = Cache::pool('queryCache'); + Cache::enable(); + } + + /** + * Teardown method + */ + protected function tearDown(): void + { + parent::tearDown(); + Cache::drop('queryCache'); + } + + /** + * Test fetching with a function to generate the key. + */ + public function testFetchFunctionKey(): void + { + $this->engine->set('my_key', 'A winner'); + $query = new stdClass(); + + $cacher = new QueryCacher(function ($q) use ($query) { + $this->assertSame($query, $q); + + return 'my_key'; + }, 'queryCache'); + + $result = $cacher->fetch($query); + $this->assertSame('A winner', $result); + } + + /** + * Test fetching with a function to generate the key but the function is poop. + */ + public function testFetchFunctionKeyNoString(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Cache key functions must return a string. Got false.'); + $this->engine->set('my_key', 'A winner'); + $query = new stdClass(); + + $cacher = new QueryCacher(function ($q) { + return false; + }, 'queryCache'); + + $cacher->fetch($query); + } + + /** + * Test fetching with a cache instance. + */ + public function testFetchCacheHitStringEngine(): void + { + $this->engine->set('my_key', 'A winner'); + $cacher = new QueryCacher('my_key', 'queryCache'); + $query = new stdClass(); + $result = $cacher->fetch($query); + $this->assertSame('A winner', $result); + } + + /** + * Test fetching with a cache hit. + */ + public function testFetchCacheHit(): void + { + $this->engine->set('my_key', 'A winner'); + $cacher = new QueryCacher('my_key', $this->engine); + $query = new stdClass(); + $result = $cacher->fetch($query); + $this->assertSame('A winner', $result); + } + + /** + * Test fetching with a cache miss. + */ + public function testFetchCacheMiss(): void + { + $this->engine->set('my_key', false); + $cacher = new QueryCacher('my_key', $this->engine); + $query = new stdClass(); + $result = $cacher->fetch($query); + $this->assertNull($result, 'Cache miss should not have an isset() return.'); + } +} diff --git a/tests/TestCase/Datasource/ResultSetDecoratorTest.php b/tests/TestCase/Datasource/ResultSetDecoratorTest.php new file mode 100644 index 00000000000..78e615b7be7 --- /dev/null +++ b/tests/TestCase/Datasource/ResultSetDecoratorTest.php @@ -0,0 +1,107 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource; + +use ArrayIterator; +use Cake\Core\Configure; +use Cake\Datasource\ResultSetDecorator; +use Cake\TestSuite\TestCase; + +/** + * Tests ResultSetDecorator class + */ +class ResultSetDecoratorTest extends TestCase +{ + /** + * Tests the decorator can wrap a simple iterator + */ + public function testDecorateSimpleIterator(): void + { + $data = new ArrayIterator([1, 2, 3]); + $decorator = new ResultSetDecorator($data); + $this->assertEquals([1, 2, 3], iterator_to_array($decorator)); + } + + /** + * Tests it toArray() method + */ + public function testToArray(): void + { + $data = new ArrayIterator([1, 2, 3]); + $decorator = new ResultSetDecorator($data); + $this->assertEquals([1, 2, 3], $decorator->toArray()); + } + + /** + * Tests JSON encoding method + */ + public function testToJson(): void + { + $data = new ArrayIterator([1, 2, 3]); + $decorator = new ResultSetDecorator($data); + $this->assertEquals(json_encode([1, 2, 3]), json_encode($decorator)); + } + + /** + * Tests serializing and unserializing the decorator + */ + public function testSerialization(): void + { + $data = new ArrayIterator([1, 2, 3]); + $decorator = new ResultSetDecorator($data); + $serialized = serialize($decorator); + $this->assertEquals([1, 2, 3], unserialize($serialized)->toArray()); + } + + /** + * Test the first() method which is part of the ResultSet duck type. + */ + public function testFirst(): void + { + $data = new ArrayIterator([1, 2, 3]); + $decorator = new ResultSetDecorator($data); + + $this->assertSame(1, $decorator->first()); + $this->assertSame(1, $decorator->first()); + } + + /** + * Test the count() method which is part of the ResultSet duck type. + */ + public function testCount(): void + { + $data = new ArrayIterator([1, 2, 3]); + $decorator = new ResultSetDecorator($data); + + $this->assertSame(3, $decorator->count()); + $this->assertCount(3, $decorator); + } + + /** + * Test the __debugInfo() method which is used by DebugKit + */ + public function testDebugInfo(): void + { + Configure::write('App.ResultSetDebugLimit', 2); + $data = new ArrayIterator([1, 2, 3]); + $decorator = new ResultSetDecorator($data); + $this->assertEquals([ + 'count' => 3, + 'items' => [1, 2], + ], $decorator->__debugInfo()); + } +} diff --git a/tests/TestCase/Datasource/RuleInvokerTest.php b/tests/TestCase/Datasource/RuleInvokerTest.php new file mode 100644 index 00000000000..e1599082f36 --- /dev/null +++ b/tests/TestCase/Datasource/RuleInvokerTest.php @@ -0,0 +1,50 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource; + +use Cake\Datasource\RuleInvoker; +use Cake\ORM\Entity; +use Cake\ORM\Rule\ValidCount; +use PHPUnit\Framework\TestCase; + +class RuleInvokerTest extends TestCase +{ + public function testInvoke(): void + { + $entity = new Entity([ + 'players' => 1, + ]); + + $rule = new ValidCount('players'); + $rulesInvoker = new RuleInvoker( + $rule, + 'countPlayers', + [ + 'count' => 2, + 'errorField' => 'player_id', + 'message' => function ($entity, $options) { + return 'Player count should be ' . $options['count'] . ' not ' . $entity->get('players'); + }, + ], + ); + $rulesInvoker->__invoke($entity, []); + $this->assertEquals( + ['countPlayers' => 'Player count should be 2 not 1'], + $entity->getError('player_id'), + ); + } +} diff --git a/tests/TestCase/Datasource/RulesCheckerTest.php b/tests/TestCase/Datasource/RulesCheckerTest.php new file mode 100644 index 00000000000..a19559a681c --- /dev/null +++ b/tests/TestCase/Datasource/RulesCheckerTest.php @@ -0,0 +1,333 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.7 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Datasource; + +use Cake\Core\Exception\CakeException; +use Cake\Datasource\RulesChecker; +use Cake\ORM\Entity; +use Cake\TestSuite\TestCase; + +/** + * Tests the integration between the ORM and the domain checker + */ +class RulesCheckerTest extends TestCase +{ + /** + * Test adding rule for create and update + */ + public function testAddingRule(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->add( + function () { + return false; + }, + 'ruleName', + ['errorField' => 'name'], + ); + + // Rules added with `add()` do not apply to delete operations + $this->assertTrue($rules->check($entity, RulesChecker::DELETE)); + $this->assertEmpty($entity->getErrors()); + + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + $this->assertEquals(['ruleName' => 'invalid'], $entity->getError('name')); + + $entity->clean(); + $this->assertFalse($rules->check($entity, RulesChecker::UPDATE)); + $this->assertEquals(['ruleName' => 'invalid'], $entity->getError('name')); + } + + /** + * Test adding rule for update mode + */ + public function testAddingRuleDeleteMode(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->addDelete( + function () { + return false; + }, + 'ruleName', + ['errorField' => 'name'], + ); + + $this->assertTrue($rules->check($entity, RulesChecker::CREATE)); + $this->assertEmpty($entity->getErrors()); + $this->assertTrue($rules->check($entity, RulesChecker::UPDATE)); + $this->assertEmpty($entity->getErrors()); + + $this->assertFalse($rules->check($entity, RulesChecker::DELETE)); + $this->assertEquals(['ruleName' => 'invalid'], $entity->getError('name')); + } + + /** + * Test adding rule for update mode + */ + public function testAddingRuleUpdateMode(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->addUpdate( + function () { + return false; + }, + 'ruleName', + ['errorField' => 'name'], + ); + + $this->assertTrue($rules->check($entity, RulesChecker::CREATE)); + $this->assertEmpty($entity->getErrors()); + $this->assertTrue($rules->check($entity, RulesChecker::DELETE)); + $this->assertEmpty($entity->getErrors()); + + $this->assertFalse($rules->check($entity, RulesChecker::UPDATE)); + $this->assertEquals(['ruleName' => 'invalid'], $entity->getError('name')); + } + + /** + * Test adding rule for create mode + */ + public function testAddingRuleCreateMode(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->addCreate( + function () { + return false; + }, + 'ruleName', + ['errorField' => 'name'], + ); + + $this->assertTrue($rules->check($entity, RulesChecker::UPDATE)); + $this->assertEmpty($entity->getErrors()); + $this->assertTrue($rules->check($entity, RulesChecker::DELETE)); + $this->assertEmpty($entity->getErrors()); + + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + $this->assertEquals(['ruleName' => 'invalid'], $entity->getError('name')); + } + + /** + * Test adding rule with name + */ + public function testAddingRuleWithName(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->add( + function () { + return false; + }, + 'ruleName', + ['errorField' => 'name'], + ); + + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + $this->assertEquals(['ruleName' => 'invalid'], $entity->getError('name')); + } + + /** + * Test that returned error messages work. + */ + public function testAddWithErrorMessage(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->add( + function () { + return 'worst thing ever'; + }, + ['errorField' => 'name'], + ); + + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + $this->assertEquals(['worst thing ever'], $entity->getError('name')); + } + + /** + * Test that returned error messages work. + */ + public function testAddWithMessageOption(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->add( + function () { + return false; + }, + ['message' => 'this is bad', 'errorField' => 'name'], + ); + + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + $this->assertEquals(['this is bad'], $entity->getError('name')); + } + + /** + * Test that returned error messages work. + */ + public function testAddWithoutFields(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->add(function () { + return false; + }); + + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + // When no errorField is specified, errors are set on '_rule' field + $this->assertEquals(['_rule' => ['invalid']], $entity->getErrors()); + } + + public function testRemove(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $rules = new RulesChecker(); + $rules->add( + function () { + return false; + }, + 'ruleName', + ); + + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + + $rules->remove('ruleName'); + $this->assertTrue($rules->check($entity, RulesChecker::CREATE)); + } + + public function testRemoveCreate(): void + { + $rules = new RulesChecker(); + $rules->addCreate( + function () { + return false; + }, + 'ruleName', + ); + + $entity = new Entity(); + $this->assertFalse($rules->check($entity, RulesChecker::CREATE)); + + $rules->removeCreate('ruleName'); + $this->assertTrue($rules->check($entity, RulesChecker::CREATE)); + } + + public function testRemoveUpdate(): void + { + $rules = new RulesChecker(); + $rules->addUpdate( + function () { + return false; + }, + 'ruleName', + ); + + $entity = new Entity(); + $this->assertFalse($rules->check($entity, RulesChecker::UPDATE)); + + $rules->removeUpdate('ruleName'); + $this->assertTrue($rules->check($entity, RulesChecker::UPDATE)); + } + + public function testRemoveDelete(): void + { + $rules = new RulesChecker(); + $rules->addDelete( + function () { + return false; + }, + 'ruleName', + ); + + $entity = new Entity(); + $this->assertFalse($rules->check($entity, RulesChecker::DELETE)); + + $rules->removeDelete('ruleName'); + $this->assertTrue($rules->check($entity, RulesChecker::DELETE)); + } + + public function testAddDuplicateName(): void + { + $rules = new RulesChecker(); + $rules->add(fn() => false, 'myUniqueName'); + + $this->expectException(CakeException::class); + $rules->add(fn() => true, 'myUniqueName'); + $this->fail('Exception not thrown'); + } + + public function testAddCreateDuplicateName(): void + { + $rules = new RulesChecker(); + $rules->addCreate(fn() => false, 'myUniqueName'); + + $this->expectException(CakeException::class); + $rules->addCreate(fn() => true, 'myUniqueName'); + $this->fail('Exception not thrown'); + } + + public function testAddUpdateDuplicateName(): void + { + $rules = new RulesChecker(); + $rules->addUpdate(fn() => false, 'myUniqueName'); + + $this->expectException(CakeException::class); + $rules->addUpdate(fn() => true, 'myUniqueName'); + $this->fail('Exception not thrown'); + } + + public function testAddDeleteDuplicateName(): void + { + $rules = new RulesChecker(); + $rules->addDelete(fn() => false, 'myUniqueName'); + + $this->expectException(CakeException::class); + $rules->addDelete(fn() => true, 'myUniqueName'); + $this->fail('Exception not thrown'); + } +} diff --git a/tests/TestCase/Error/Debug/ConsoleFormatterTest.php b/tests/TestCase/Error/Debug/ConsoleFormatterTest.php new file mode 100644 index 00000000000..131c4fb3a16 --- /dev/null +++ b/tests/TestCase/Error/Debug/ConsoleFormatterTest.php @@ -0,0 +1,74 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 4.1.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error\Debug; + +use Cake\Error\Debug\ArrayItemNode; +use Cake\Error\Debug\ArrayNode; +use Cake\Error\Debug\ClassNode; +use Cake\Error\Debug\ConsoleFormatter; +use Cake\Error\Debug\PropertyNode; +use Cake\Error\Debug\ReferenceNode; +use Cake\Error\Debug\ScalarNode; +use Cake\Error\Debug\SpecialNode; +use Cake\TestSuite\TestCase; + +/** + * ConsoleFormatterTest + */ +class ConsoleFormatterTest extends TestCase +{ + /** + * Test dumping a graph that contains all possible nodes. + */ + public function testDump(): void + { + $node = new ClassNode('MyObject', 1); + $node->addProperty(new PropertyNode('stringProp', 'public', new ScalarNode('string', 'value'))); + $node->addProperty(new PropertyNode('intProp', 'protected', new ScalarNode('int', 1))); + $node->addProperty(new PropertyNode('floatProp', 'protected', new ScalarNode('float', 1.1))); + $node->addProperty(new PropertyNode('boolProp', 'protected', new ScalarNode('bool', true))); + $node->addProperty(new PropertyNode('nullProp', 'private', new ScalarNode('null', null))); + $arrayNode = new ArrayNode([ + new ArrayItemNode(new ScalarNode('string', ''), new SpecialNode('too much')), + new ArrayItemNode(new ScalarNode('int', 1), new ReferenceNode('MyObject', 1)), + ]); + $node->addProperty(new PropertyNode('arrayProp', 'public', $arrayNode)); + + $formatter = new ConsoleFormatter(); + $result = $formatter->dump($node); + + $this->assertStringContainsString("\033[1;33m", $result, 'Should contain yellow code'); + $this->assertStringContainsString("\033[0;32m", $result, 'Should contain green code'); + $this->assertStringContainsString("\033[1;34m", $result, 'Should contain blue code'); + $this->assertStringContainsString("\033[0;36m", $result, 'Should contain cyan code'); + $expected = <<<TEXT +object(MyObject) id:1 { + stringProp => 'value' + protected intProp => (int) 1 + protected floatProp => (float) 1.1 + protected boolProp => true + private nullProp => null + arrayProp => [ + '' => too much, + (int) 1 => object(MyObject) id:1 {} + ] +} +TEXT; + $noescape = preg_replace('/\\033\[\d\;\d+\;m([^\\\\]+)\\033\[0m/', '$1', $expected); + $this->assertSame($expected, $noescape); + } +} diff --git a/tests/TestCase/Error/Debug/HtmlFormatterTest.php b/tests/TestCase/Error/Debug/HtmlFormatterTest.php new file mode 100644 index 00000000000..6a174945d41 --- /dev/null +++ b/tests/TestCase/Error/Debug/HtmlFormatterTest.php @@ -0,0 +1,87 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 4.1.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error\Debug; + +use Cake\Error\Debug\ArrayItemNode; +use Cake\Error\Debug\ArrayNode; +use Cake\Error\Debug\ClassNode; +use Cake\Error\Debug\HtmlFormatter; +use Cake\Error\Debug\PropertyNode; +use Cake\Error\Debug\ReferenceNode; +use Cake\Error\Debug\ScalarNode; +use Cake\Error\Debug\SpecialNode; +use Cake\TestSuite\TestCase; +use DomDocument; + +/** + * HtmlFormatterTest + */ +class HtmlFormatterTest extends TestCase +{ + /** + * Test dumping a graph that contains all possible nodes. + */ + public function testDump(): void + { + $node = new ClassNode('MyObject', 1); + $node->addProperty(new PropertyNode('stringProp', 'public', new ScalarNode('string', 'value'))); + $node->addProperty(new PropertyNode('intProp', 'protected', new ScalarNode('int', 1))); + $node->addProperty(new PropertyNode('floatProp', 'protected', new ScalarNode('float', 1.1))); + $node->addProperty(new PropertyNode('boolProp', 'protected', new ScalarNode('bool', true))); + $node->addProperty(new PropertyNode('nullProp', 'private', new ScalarNode('null', null))); + $arrayNode = new ArrayNode([ + new ArrayItemNode(new ScalarNode('string', ''), new SpecialNode('too much')), + new ArrayItemNode(new ScalarNode('int', 1), new ReferenceNode('MyObject', 1)), + ]); + $node->addProperty(new PropertyNode('arrayProp', 'public', $arrayNode)); + + $formatter = new HtmlFormatter(); + $result = $formatter->dump($node); + + // Check important classnames + $this->assertStringContainsString('class="cake-debug-const"', $result); + $this->assertStringContainsString('class="cake-debug-string"', $result); + $this->assertStringContainsString('class="cake-debug-number"', $result); + $this->assertStringContainsString('class="cake-debug-array-items"', $result); + $this->assertStringContainsString('class="cake-debug-array-item"', $result); + $this->assertStringContainsString('class="cake-debug-array"', $result); + $this->assertStringContainsString('class="cake-debug-object"', $result); + $this->assertStringContainsString('class="cake-debug-object-props"', $result); + $this->assertStringContainsString('class="cake-debug-special"', $result); + $this->assertStringContainsString('class="cake-debug-ref"', $result); + + // Check valid HTML + $dom = new DomDocument(); + $dom->loadHtml($result); + $this->assertGreaterThan(0, count($dom->childNodes)); + + $expected = <<<TEXT +object(MyObject) id:1 { + stringProp => 'value' + protected intProp => (int) 1 + protected floatProp => (float) 1.1 + protected boolProp => true + private nullProp => null + arrayProp => [ + '' => too much, + (int) 1 => object(MyObject) id: 1 {}, + ] +} +TEXT; + $this->assertStringContainsString($expected, strip_tags($result)); + } +} diff --git a/tests/TestCase/Error/DebuggerTest.php b/tests/TestCase/Error/DebuggerTest.php new file mode 100644 index 00000000000..1887ef5d0de --- /dev/null +++ b/tests/TestCase/Error/DebuggerTest.php @@ -0,0 +1,874 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 1.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error; + +use Cake\Controller\Controller; +use Cake\Core\Configure; +use Cake\Error\Debug\ConsoleFormatter; +use Cake\Error\Debug\HtmlFormatter; +use Cake\Error\Debug\NodeInterface; +use Cake\Error\Debug\ScalarNode; +use Cake\Error\Debug\SpecialNode; +use Cake\Error\Debug\TextFormatter; +use Cake\Error\Debugger; +use Cake\Form\Form; +use Cake\Http\ServerRequest; +use Cake\Log\Log; +use Cake\ORM\Table; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use MyClass; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use RuntimeException; +use SplFixedArray; +use stdClass; +use TestApp\Error\TestDebugger; +use TestApp\Error\Thing\DebuggableThing; +use TestApp\Error\Thing\SecurityThing; +use TestApp\Utility\ThrowsDebugInfo; + +/** + * DebuggerTest class + * + * !!! Be careful with changing code below as it may + * !!! change line numbers which are used in the tests + */ +#[AllowMockObjectsWithoutExpectations] +class DebuggerTest extends TestCase +{ + /** + * @var bool + */ + protected $restoreError = false; + + /** + * setUp method + */ + protected function setUp(): void + { + parent::setUp(); + Configure::write('debug', true); + Log::drop('stderr'); + Log::drop('stdout'); + Debugger::configInstance('exportFormatter', TextFormatter::class); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + if ($this->restoreError) { + restore_error_handler(); + } + } + + /** + * testDocRef method + */ + public function testDocRef(): void + { + ini_set('docref_root', ''); + $this->assertEquals(ini_get('docref_root'), ''); + // Force a new instance. + Debugger::getInstance(TestDebugger::class); + Debugger::getInstance(Debugger::class); + + $this->assertEquals(ini_get('docref_root'), 'https://secure.php.net/'); + } + + /** + * test Excerpt writing + */ + public function testExcerpt(): void + { + $result = Debugger::excerpt(__FILE__, __LINE__ - 1, 2); + $this->assertIsArray($result); + $this->assertCount(5, $result); + $this->assertMatchesRegularExpression('/function(.+)testExcerpt/', $result[1]); + + $result = Debugger::excerpt(__FILE__, 2, 2); + $this->assertIsArray($result); + $this->assertCount(4, $result); + + $this->skipIf(defined('HHVM_VERSION'), 'HHVM does not highlight php code'); + // Due to different highlight_string() function behavior, see. https://3v4l.org/HcfBN. Since 8.3, it wraps it around <pre> + $pattern = version_compare(PHP_VERSION, '8.3', '<') + ? '/<code>.*?<span style\="color\: \#\d+">.*?<\?php/' + : '/<pre>.*?<code style\="color\: \#\d+">.*?<span style\="color\: \#[a-zA-Z0-9]+">.*?<\?php/'; + $this->assertMatchesRegularExpression($pattern, $result[0]); + + $result = Debugger::excerpt(__FILE__, 11, 2); + $this->assertCount(5, $result); + + $pattern = '/<span style\="color\: \#\d{6}">.*?<\/span>/'; + $this->assertMatchesRegularExpression($pattern, $result[0]); + + $return = Debugger::excerpt('[internal]', 2, 2); + $this->assertEmpty($return); + + $result = Debugger::excerpt(__FILE__, __LINE__, 5); + $this->assertCount(11, $result); + $this->assertStringContainsString('Debugger', $result[5]); + $this->assertStringContainsString('excerpt', $result[5]); + $this->assertStringContainsString('__FILE__', $result[5]); + + $result = Debugger::excerpt(__FILE__, 1, 2); + $this->assertCount(3, $result); + + $lastLine = count(explode("\n", file_get_contents(__FILE__))); + $result = Debugger::excerpt(__FILE__, $lastLine, 2); + $this->assertCount(3, $result); + } + + /** + * testTrimPath method + */ + public function testTrimPath(): void + { + $this->assertSame('APP/', Debugger::trimPath(APP)); + $this->assertSame('CORE' . DS . 'src' . DS, Debugger::trimPath(CAKE)); + $this->assertSame('Some/Other/Path', Debugger::trimPath('Some/Other/Path')); + } + + /** + * testExportVar method + */ + public function testExportVar(): void + { + $std = new stdClass(); + $std->int = 2; + $std->float = 1.333; + $std->string = ' '; + + $result = Debugger::exportVar($std); + $expected = <<<TEXT +object(stdClass) id:0 { + int => (int) 2 + float => (float) 1.333 + string => ' ' +} +TEXT; + $this->assertTextEquals($expected, $result); + + $Controller = new Controller(new ServerRequest()); + $Controller->viewBuilder()->setHelpers(['Html', 'Form']); + $View = $Controller->createView(); + + $result = Debugger::exportVar($View); + $expected = <<<TEXT +object(Cake\View\View) id:0 { + [protected] _helpers => object(Cake\View\HelperRegistry) id:1 {} + [protected] Blocks => object(Cake\View\ViewBlock) id:2 {} + [protected] plugin => null + [protected] name => '' + [protected] helpers => [ + 'Html' => [ + '' => [maximum depth reached] + ], + 'Form' => [ + '' => [maximum depth reached] + ] + ] + [protected] templatePath => '' + [protected] template => '' + [protected] layout => 'default' + [protected] layoutPath => '' + [protected] autoLayout => true + [protected] viewVars => [] + [protected] _ext => '.php' + [protected] subDir => '' + [protected] theme => null + [protected] request => object(Cake\Http\ServerRequest) id:3 {} + [protected] response => object(Cake\Http\Response) id:4 {} + [protected] elementCache => 'default' + [protected] configMergeStrategy => 'deep' + [protected] _passedVars => [ + (int) 0 => 'viewVars', + (int) 1 => 'autoLayout', + (int) 2 => 'helpers', + (int) 3 => 'template', + (int) 4 => 'layout', + (int) 5 => 'name', + (int) 6 => 'theme', + (int) 7 => 'layoutPath', + (int) 8 => 'templatePath', + (int) 9 => 'plugin', + (int) 10 => 'configMergeStrategy' + ] + [protected] _defaultConfig => [] + [protected] _paths => [] + [protected] _pathsForPlugin => [] + [protected] _parents => [] + [protected] _current => '' + [protected] _currentType => '' + [protected] _stack => [] + [protected] _viewBlockClass => 'Cake\View\ViewBlock' + [protected] _eventManager => object(Cake\Event\EventManager) id:5 {} + [protected] _eventClass => 'Cake\Event\Event' + [protected] _config => [] + [protected] _configInitialized => true +} +TEXT; + $this->assertTextEquals($expected, $result); + + $data = [ + 1 => 'Index one', + 5 => 'Index five', + ]; + $result = Debugger::exportVar($data); + $expected = <<<TEXT +[ + (int) 1 => 'Index one', + (int) 5 => 'Index five' +] +TEXT; + $this->assertTextEquals($expected, $result); + + $data = [ + 'key' => [ + 'value', + ], + ]; + $result = Debugger::exportVar($data, 1); + $expected = <<<TEXT +[ + 'key' => [ + '' => [maximum depth reached] + ] +] +TEXT; + $this->assertTextEquals($expected, $result); + + $data = false; + $result = Debugger::exportVar($data); + $expected = <<<TEXT +false +TEXT; + $this->assertTextEquals($expected, $result); + + $file = fopen('php://output', 'w'); + fclose($file); + $result = Debugger::exportVar($file); + $this->assertStringContainsString('(resource (closed)) Resource id #', $result); + } + + public function testExportVarTypedProperty(): void + { + // This is gross but was simpler than adding a fixture file. + // phpcs:ignore + eval('class MyClass { private string $field; }'); + /** @phpstan-ignore-next-line */ + $obj = new MyClass(); + $out = Debugger::exportVar($obj); + $this->assertTextContains('field => [uninitialized]', $out); + } + + /** + * Test exporting various kinds of false. + */ + public function testExportVarZero(): void + { + $data = [ + 'nothing' => '', + 'null' => null, + 'false' => false, + 'szero' => '0', + 'zero' => 0, + ]; + $result = Debugger::exportVar($data); + $expected = <<<TEXT +[ + 'nothing' => '', + 'null' => null, + 'false' => false, + 'szero' => '0', + 'zero' => (int) 0 +] +TEXT; + $this->assertTextEquals($expected, $result); + } + + /** + * test exportVar with cyclic objects. + */ + public function testExportVarCyclicRef(): void + { + $parent = new stdClass(); + $parent->name = 'cake'; + $middle = new stdClass(); + $parent->child = $middle; + + $middle->name = 'php'; + $middle->child = $parent; + + $result = Debugger::exportVar($parent, 6); + $expected = <<<TEXT +object(stdClass) id:0 { + name => 'cake' + child => object(stdClass) id:1 { + name => 'php' + child => object(stdClass) id:0 {} + } +} +TEXT; + $this->assertTextEquals($expected, $result); + } + + /** + * test exportVar with array objects + */ + public function testExportVarSplFixedArray(): void + { + $this->skipIf( + version_compare(PHP_VERSION, '8.3', '>='), + 'Due to different get_object_vars() function behavior used in Debugger::exportObject()', // see. https://3v4l.org/DWpRl + ); + $subject = new SplFixedArray(2); + $subject[0] = 'red'; + $subject[1] = 'blue'; + + $result = Debugger::exportVar($subject, 6); + $this->assertStringContainsString('object(SplFixedArray) id:0 {', $result); + } + + /** + * Tests plain text variable export. + */ + public function testExportVarAsPlainText(): void + { + Debugger::configInstance('exportFormatter'); + $result = Debugger::exportVarAsPlainText(123); + $this->assertSame('(int) 123', $result); + + Debugger::configInstance('exportFormatter', ConsoleFormatter::class); + $result = Debugger::exportVarAsPlainText(123); + $this->assertSame('(int) 123', $result); + } + + /** + * test exportVar with cyclic objects. + */ + public function testExportVarDebugInfo(): void + { + $form = new Form(); + + $result = Debugger::exportVar($form, 6); + $this->assertStringContainsString("'_schema' => [", $result, 'Has debuginfo keys'); + $this->assertStringContainsString("'_validator' => [", $result); + } + + /** + * Test exportVar with an exception during __debugInfo() + */ + public function testExportVarInvalidDebugInfo(): void + { + $this->skipIf(extension_loaded('xdebug'), 'Throwing exceptions inside __debugInfo breaks with xDebug'); + $result = Debugger::exportVar(new ThrowsDebugInfo()); + $expected = '(unable to export object: from __debugInfo)'; + $this->assertTextEquals($expected, $result); + } + + /** + * Test exportVar with a mock + */ + public function testExportVarMockObject(): void + { + $result = Debugger::exportVar($this->getMockBuilder(Table::class)->getMock()); + $this->assertStringStartsWith('object(Mock', $result); + $this->assertStringContainsString('_Table_', $result); + } + + /** + * Text exportVarAsNodes() + */ + public function testExportVarAsNodes(): void + { + $data = [ + 1 => 'Index one', + 5 => 'Index five', + ]; + $result = Debugger::exportVarAsNodes($data); + $this->assertInstanceOf(NodeInterface::class, $result); + $this->assertCount(2, $result->getChildren()); + + /** @var \Cake\Error\Debug\ArrayItemNode $item */ + $item = $result->getChildren()[0]; + $key = new ScalarNode('int', 1); + $this->assertEquals($key, $item->getKey()); + $value = new ScalarNode('string', 'Index one'); + $this->assertEquals($value, $item->getValue()); + + $data = [ + 'key' => [ + 'value', + ], + ]; + $result = Debugger::exportVarAsNodes($data, 1); + + $item = $result->getChildren()[0]; + $nestedItem = $item->getValue()->getChildren()[0]; + $expected = new SpecialNode('[maximum depth reached]'); + $this->assertEquals($expected, $nestedItem->getValue()); + } + + /** + * testLog method + */ + public function testLog(): void + { + Log::setConfig('test', [ + 'className' => 'Array', + ]); + Debugger::log('cool'); + Debugger::log(['whatever', 'here']); + + $messages = Log::engine('test')->read(); + $this->assertCount(2, $messages); + $this->assertStringContainsString('DebuggerTest->testLog', $messages[0]); + $this->assertStringContainsString('cool', $messages[0]); + + $this->assertStringContainsString('DebuggerTest->testLog', $messages[1]); + $this->assertStringContainsString('[main]', $messages[1]); + $this->assertStringContainsString("'whatever'", $messages[1]); + $this->assertStringContainsString("'here'", $messages[1]); + + Log::drop('test'); + } + + /** + * Tests that logging does not apply formatting. + */ + public function testLogShouldNotApplyFormatting(): void + { + Log::setConfig('test', [ + 'className' => 'Array', + ]); + + Debugger::configInstance('exportFormatter'); + Debugger::log(123); + $messages = implode('', Log::engine('test')->read()); + Log::engine('test')->clear(); + $this->assertStringContainsString('(int) 123', $messages); + $this->assertStringNotContainsString("\033[0m", $messages); + + Debugger::configInstance('exportFormatter', HtmlFormatter::class); + Debugger::log(123); + $messages = implode('', Log::engine('test')->read()); + Log::engine('test')->clear(); + $this->assertStringContainsString('(int) 123', $messages); + $this->assertStringNotContainsString('<style', $messages); + + Debugger::configInstance('exportFormatter', ConsoleFormatter::class); + Debugger::log(123); + $messages = implode('', Log::engine('test')->read()); + Log::engine('test')->clear(); + $this->assertStringContainsString('(int) 123', $messages); + $this->assertStringNotContainsString("\033[0m", $messages); + + Log::drop('test'); + } + + /** + * test log() depth + */ + public function testLogDepth(): void + { + Log::setConfig('test', [ + 'className' => 'Array', + ]); + $veryRandomName = [ + 'test' => ['key' => 'val'], + ]; + Debugger::log($veryRandomName, 'debug', 0); + + $messages = Log::engine('test')->read(); + $this->assertStringContainsString('DebuggerTest->testLogDepth', $messages[0]); + $this->assertStringContainsString('test', $messages[0]); + $this->assertStringNotContainsString('veryRandomName', $messages[0]); + } + + /** + * testDump method + */ + public function testDump(): void + { + $var = ['People' => [ + [ + 'name' => 'joeseph', + 'coat' => 'technicolor', + 'hair_color' => 'brown', + ], + [ + 'name' => 'Shaft', + 'coat' => 'black', + 'hair' => 'black', + ], + ]]; + ob_start(); + Debugger::dump($var); + $result = ob_get_clean(); + + $open = "\n"; + $close = "\n\n"; + $expected = <<<TEXT +{$open}[ + 'People' => [ + (int) 0 => [ + 'name' => 'joeseph', + 'coat' => 'technicolor', + 'hair_color' => 'brown' + ], + (int) 1 => [ + 'name' => 'Shaft', + 'coat' => 'black', + 'hair' => 'black' + ] + ] +]{$close} +TEXT; + $this->assertTextEquals($expected, $result); + + ob_start(); + Debugger::dump($var, 1); + $result = ob_get_clean(); + + $expected = <<<TEXT +{$open}[ + 'People' => [ + '' => [maximum depth reached] + ] +]{$close} +TEXT; + $this->assertTextEquals($expected, $result); + } + + /** + * test getInstance. + */ + public function testGetInstance(): void + { + $result = Debugger::getInstance(); + $exporter = $result->getConfig('exportFormatter'); + + $this->assertInstanceOf(Debugger::class, $result); + + $result = Debugger::getInstance(TestDebugger::class); + $this->assertInstanceOf(TestDebugger::class, $result); + + $result = Debugger::getInstance(); + $this->assertInstanceOf(TestDebugger::class, $result); + + $result = Debugger::getInstance(Debugger::class); + $this->assertInstanceOf(Debugger::class, $result); + $result->setConfig('exportFormatter', $exporter); + } + + /** + * Test that exportVar() will stop traversing recursive arrays. + */ + public function testExportVarRecursion(): void + { + $array = []; + $array['foo'] = &$array; + + $output = Debugger::exportVar($array); + $this->assertMatchesRegularExpression("/'foo' => \[\s+'' \=\> \[maximum depth reached\]/", $output); + } + + /** + * test trace exclude + */ + public function testTraceExclude(): void + { + $result = Debugger::trace(); + $this->assertMatchesRegularExpression('/^Cake\\\Test\\\TestCase\\\Error\\\DebuggerTest..testTraceExclude/m', $result); + + $result = Debugger::trace([ + 'exclude' => ['Cake\Test\TestCase\Error\DebuggerTest->testTraceExclude'], + ]); + $this->assertDoesNotMatchRegularExpression('/^Cake\\\Test\\\TestCase\\\Error\\\DebuggerTest..testTraceExclude/m', $result); + } + + public function testTraceShortPoints(): void + { + $result = Debugger::trace(['format' => 'shortPoints']); + $this->assertIsArray($result); + $this->assertEquals( + 'CORE' . DS . 'vendor' . DS . 'phpunit' . DS . 'phpunit' . DS . 'src' . DS . + 'Framework' . DS . 'TestCase.php', + $result[0]['file'], + ); + } + + protected function _makeException(): RuntimeException + { + return new RuntimeException('testing'); + } + + /** + * Test stack frame comparisons. + */ + public function testGetUniqueFrames(): void + { + $parent = new RuntimeException('parent'); + $child = $this->_makeException(); + + $result = Debugger::getUniqueFrames($child, $parent); + $this->assertCount(1, $result); + $this->assertEquals(__LINE__ - 4, $result[0]['line']); + + $result = Debugger::getUniqueFrames($child, null); + $this->assertGreaterThan(1, count($result)); + } + + /** + * Tests that __debugInfo is used when available + */ + public function testDebugInfo(): void + { + $object = new DebuggableThing(); + $result = Debugger::exportVar($object, 2); + $expected = <<<eos +object(TestApp\Error\Thing\DebuggableThing) id:0 { + 'foo' => 'bar' + 'inner' => object(TestApp\Error\Thing\DebuggableThing) id:1 {} +} +eos; + $this->assertSame($expected, $result); + } + + /** + * Tests reading the output mask settings. + */ + public function testSetOutputMask(): void + { + Debugger::setOutputMask(['password' => '[**********]']); + $this->assertEquals(['password' => '[**********]'], Debugger::outputMask()); + Debugger::setOutputMask(['serial' => 'XXXXXX']); + $this->assertEquals(['password' => '[**********]', 'serial' => 'XXXXXX'], Debugger::outputMask()); + Debugger::setOutputMask([], false); + $this->assertSame([], Debugger::outputMask()); + } + + /** + * Test configure based output mask configuration + */ + public function testConfigureOutputMask(): void + { + Configure::write('Debugger.outputMask', ['wow' => 'xxx']); + Debugger::getInstance(TestDebugger::class); + Debugger::getInstance(Debugger::class); + + $result = Debugger::exportVar(['wow' => 'pass1234']); + $this->assertStringContainsString('xxx', $result); + $this->assertStringNotContainsString('pass1234', $result); + } + + /** + * Tests the masking of an array key. + */ + public function testMaskArray(): void + { + Debugger::setOutputMask(['password' => '[**********]']); + $result = Debugger::exportVar(['password' => 'pass1234']); + $expected = "['password'=>'[**********]']"; + $this->assertSame($expected, preg_replace('/\s+/', '', $result)); + } + + /** + * Tests the masking of an array key. + */ + public function testMaskObject(): void + { + Debugger::setOutputMask(['password' => '[**********]']); + $object = new SecurityThing(); + $result = Debugger::exportVar($object); + $expected = "object(TestApp\\Error\\Thing\\SecurityThing)id:0{password=>'[**********]'}"; + $this->assertSame($expected, preg_replace('/\s+/', '', $result)); + } + + /** + * test testPrintVar() + */ + public function testPrintVar(): void + { + ob_start(); + Debugger::printVar('this-is-a-test', ['file' => __FILE__, 'line' => __LINE__], false); + $result = ob_get_clean(); + $expectedText = <<<EXPECTED +%s (line %d) +########## DEBUG ########## +'this-is-a-test' +########################### + +EXPECTED; + $expected = sprintf($expectedText, Debugger::trimPath(__FILE__), __LINE__ - 9); + + $this->assertSame($expected, $result); + + ob_start(); + $value = '<div>this-is-a-test</div>'; + Debugger::printVar($value, ['file' => __FILE__, 'line' => __LINE__], true); + $result = ob_get_clean(); + $this->assertStringContainsString(''<div>this-is-a-test</div>'', $result); + + ob_start(); + Debugger::printVar('<div>this-is-a-test</div>', ['file' => __FILE__, 'line' => __LINE__], true); + $result = ob_get_clean(); + $expected = <<<EXPECTED +<div class="cake-debug-output cake-debug" style="direction:ltr"> +<span><strong>%s</strong> (line <strong>%d</strong>)</span> +<div class="cake-debug"><span class="cake-debug-string">'<div>this-is-a-test</div>'</span></div> +</div> +EXPECTED; + $expected = sprintf($expected, Debugger::trimPath(__FILE__), __LINE__ - 8); + $this->assertSame($expected, $result); + + ob_start(); + Debugger::printVar('<div>this-is-a-test</div>', [], true); + $result = ob_get_clean(); + $expected = <<<EXPECTED +<div class="cake-debug-output cake-debug" style="direction:ltr"> + +<div class="cake-debug"><span class="cake-debug-string">'<div>this-is-a-test</div>'</span></div> +</div> +EXPECTED; + $this->assertSame($expected, $result); + + ob_start(); + Debugger::printVar('<div>this-is-a-test</div>', ['file' => __FILE__, 'line' => __LINE__], false); + $result = ob_get_clean(); + $expected = <<<EXPECTED +%s (line %d) +########## DEBUG ########## +'<div>this-is-a-test</div>' +########################### + +EXPECTED; + $expected = sprintf($expected, Debugger::trimPath(__FILE__), __LINE__ - 9); + $this->assertSame($expected, $result); + + ob_start(); + Debugger::printVar('<div>this-is-a-test</div>'); + $result = ob_get_clean(); + $expected = <<<EXPECTED + +########## DEBUG ########## +'<div>this-is-a-test</div>' +########################### + +EXPECTED; + $this->assertSame($expected, $result); + } + + /** + * test formatHtmlMessage + */ + public function testFormatHtmlMessage(): void + { + $output = Debugger::formatHtmlMessage('Some `code` to `replace`'); + $this->assertSame('Some <code>`code`</code> to <code>`replace`</code>', $output); + + $output = Debugger::formatHtmlMessage("Some `co\nde` to `replace`\nmore"); + $this->assertSame("Some <code>`co<br />\nde`</code> to <code>`replace`</code><br />\nmore", $output); + + $output = Debugger::formatHtmlMessage("Some `code` to <script>alert(\"test\")</script>\nmore"); + $this->assertSame( + "Some <code>`code`</code> to <script>alert("test")</script><br />\nmore", + $output, + ); + } + + /** + * test choosing an unknown editor + */ + public function testSetEditorInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + Debugger::setEditor('nope'); + } + + /** + * test choosing a default editor + */ + public function testSetEditorPredefined(): void + { + Debugger::setEditor('phpstorm'); + Debugger::setEditor('macvim'); + Debugger::setEditor('sublime'); + Debugger::setEditor('emacs'); + // No exceptions raised. + $this->assertTrue(true); + } + + /** + * Test configure based editor setup + */ + public function testConfigureEditor(): void + { + Configure::write('Debugger.editor', 'emacs'); + Debugger::getInstance(TestDebugger::class); + Debugger::getInstance(Debugger::class); + + $result = Debugger::editorUrl('file.php', 123); + $this->assertStringContainsString('emacs://', $result); + } + + /** + * test using a valid editor. + */ + public function testEditorUrlValid(): void + { + Debugger::addEditor('open', 'open://{file}:{line}'); + Debugger::setEditor('open'); + $this->assertSame('open://test.php:123', Debugger::editorUrl('test.php', 123)); + } + + /** + * test using a valid editor. + */ + public function testEditorUrlClosure(): void + { + Debugger::addEditor('open', function (string $file, int $line) { + return "{$file}/{$line}"; + }); + Debugger::setEditor('open'); + $this->assertSame('test.php/123', Debugger::editorUrl('test.php', 123)); + } + + /** + * Test editorBasePath configuration for path remapping + */ + public function testEditorUrlWithBasePath(): void + { + // Set up editor base path replacement + Configure::write('Debugger.editorBasePath', '/home/user/projects/myapp'); + Debugger::getInstance(TestDebugger::class); + Debugger::getInstance(Debugger::class); + + Debugger::setEditor('phpstorm'); + + // Test that ROOT is replaced with the configured base path + $file = ROOT . '/src/Controller/UsersController.php'; + $result = Debugger::editorUrl($file, 10); + + $expected = 'phpstorm://open?file=/home/user/projects/myapp/src/Controller/UsersController.php&line=10'; + $this->assertSame($expected, $result); + } +} diff --git a/tests/TestCase/Error/ErrorLoggerTest.php b/tests/TestCase/Error/ErrorLoggerTest.php new file mode 100644 index 00000000000..375d3544eb5 --- /dev/null +++ b/tests/TestCase/Error/ErrorLoggerTest.php @@ -0,0 +1,123 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 5.3.3 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error; + +use Cake\Database\Driver\Mysql; +use Cake\Database\Exception\QueryException; +use Cake\Database\Log\LoggedQuery; +use Cake\Error\ErrorLogger; +use Cake\Log\Log; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PDOException; +use TestApp\Log\Engine\TestAppLog; + +/** + * ErrorLogger Test + */ +class ErrorLoggerTest extends TestCase +{ + /** + * @var \Cake\Error\ErrorLogger + */ + protected ErrorLogger $logger; + + public function setUp(): void + { + parent::setUp(); + $this->logger = new ErrorLogger(); + } + + public function tearDown(): void + { + parent::tearDown(); + Log::drop('test_error'); + } + + public function testLogExceptionWithQueryException(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + + $driver = $this->createStub(Mysql::class); + $driver->method('config')->willReturn(['name' => 'test_connection']); + + $query = new LoggedQuery(); + $query->setContext(['query' => 'SELECT 1', 'driver' => $driver]); + + $exception = new QueryException($query, new PDOException('SQLSTATE[42000]: Syntax error')); + + $this->logger->logException($exception); + + $logs = Log::engine('test_error')->read(); + $this->assertNotEmpty($logs); + $this->assertStringContainsString('SQLSTATE[42000]', $logs[0]); + } + + public function testLogExceptionWithRegularException(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + + $exception = new InvalidArgumentException('Something went wrong'); + + $this->logger->logException($exception); + + $logs = Log::engine('test_error')->read(); + $this->assertNotEmpty($logs); + $this->assertStringContainsString('Something went wrong', $logs[0]); + } + + public function testLogExceptionContextWithQueryException(): void + { + Log::setConfig('test_error', [ + 'className' => TestAppLog::class, + ]); + + $driver = $this->createStub(Mysql::class); + $driver->method('config')->willReturn(['name' => 'my_connection']); + + $query = new LoggedQuery(); + $query->setContext(['query' => 'SELECT 1', 'driver' => $driver]); + + $exception = new QueryException($query, new PDOException('Test error')); + + $this->logger->logException($exception); + + /** @var \TestApp\Log\Engine\TestAppLog $engine */ + $engine = Log::engine('test_error'); + $this->assertArrayHasKey('connection', $engine->passedScope); + $this->assertSame('my_connection', $engine->passedScope['connection']); + } + + public function testLogExceptionContextWithRegularException(): void + { + Log::setConfig('test_error', [ + 'className' => TestAppLog::class, + ]); + + $exception = new InvalidArgumentException('Test'); + + $this->logger->logException($exception); + + /** @var \TestApp\Log\Engine\TestAppLog $engine */ + $engine = Log::engine('test_error'); + $this->assertArrayNotHasKey('connection', $engine->passedScope); + } +} diff --git a/tests/TestCase/Error/ErrorTrapTest.php b/tests/TestCase/Error/ErrorTrapTest.php new file mode 100644 index 00000000000..459e947e5b3 --- /dev/null +++ b/tests/TestCase/Error/ErrorTrapTest.php @@ -0,0 +1,306 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 4.4.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error; + +use Cake\Console\TestSuite\StubConsoleOutput; +use Cake\Core\Configure; +use Cake\Error\ErrorLogger; +use Cake\Error\ErrorTrap; +use Cake\Error\FatalErrorException; +use Cake\Error\PhpError; +use Cake\Error\Renderer\ConsoleErrorRenderer; +use Cake\Error\Renderer\HtmlErrorRenderer; +use Cake\Error\Renderer\TextErrorRenderer; +use Cake\Log\Log; +use Cake\Routing\Router; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +class ErrorTrapTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Log::drop('test_error'); + Router::reload(); + } + + public function testConfigErrorRendererFallback(): void + { + $trap = new ErrorTrap(['errorRenderer' => null]); + $this->assertInstanceOf(ConsoleErrorRenderer::class, $trap->renderer()); + } + + public function testConfigErrorRenderer(): void + { + $trap = new ErrorTrap(['errorRenderer' => HtmlErrorRenderer::class]); + $this->assertInstanceOf(HtmlErrorRenderer::class, $trap->renderer()); + } + + public function testConfigRendererHandleUnsafeOverwrite(): void + { + $trap = new ErrorTrap(); + $trap->deleteConfig('errorRenderer'); + $this->assertInstanceOf(ConsoleErrorRenderer::class, $trap->renderer()); + } + + public function testLoggerConfig(): void + { + $trap = new ErrorTrap(['logger' => ErrorLogger::class]); + $this->assertInstanceOf(ErrorLogger::class, $trap->logger()); + } + + public function testLoggerHandleUnsafeOverwrite(): void + { + $trap = new ErrorTrap(); + $trap->deleteConfig('logger'); + $this->assertInstanceOf(ErrorLogger::class, $trap->logger()); + } + + public function testRegisterAndRendering(): void + { + $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]); + $trap->register(); + ob_start(); + trigger_error('Oh no it was bad', E_USER_NOTICE); + $output = ob_get_clean(); + restore_error_handler(); + + $this->assertStringContainsString('Oh no it was bad', $output); + } + + public function testRegisterAndHandleFatalUserError(): void + { + $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]); + $trap->register(); + $this->deprecated(function (): void { + try { + trigger_error('Oh no it was bad', E_USER_ERROR); + $this->fail('Should raise a fatal error'); + } catch (FatalErrorException $e) { + $this->assertEquals('Oh no it was bad', $e->getMessage()); + $this->assertEquals(E_USER_ERROR, $e->getCode()); + } finally { + restore_error_handler(); + } + }, E_DEPRECATED, '8.4'); + } + + public static function logLevelProvider(): array + { + return [ + // PHP error level, expected log level + [E_USER_WARNING, 'warning'], + [E_USER_NOTICE, 'notice'], + // Log level is notice on windows because windows log levels are different. + [E_USER_DEPRECATED, DS === '\\' ? 'notice' : 'debug'], + ]; + } + + #[DataProvider('logLevelProvider')] + public function testHandleErrorLoggingLevel($level, $logLevel): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + $trap = new ErrorTrap([ + 'errorRenderer' => TextErrorRenderer::class, + ]); + $trap->register(); + + ob_start(); + trigger_error('Oh no it was bad', $level); + ob_get_clean(); + restore_error_handler(); + + $logs = Log::engine('test_error')->read(); + $this->assertStringContainsString('Oh no it was bad', $logs[0]); + $this->assertStringContainsString($logLevel, $logs[0]); + } + + public function testHandleErrorLogTrace(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + $trap = new ErrorTrap([ + 'errorRenderer' => TextErrorRenderer::class, + 'trace' => true, + ]); + $trap->register(); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_WARNING); + ob_get_clean(); + restore_error_handler(); + + $logs = Log::engine('test_error')->read(); + $this->assertStringContainsString('Oh no it was bad', $logs[0]); + $this->assertStringContainsString('Trace:', $logs[0]); + $this->assertStringContainsString('ErrorTrapTest->testHandleErrorLogTrace', $logs[0]); + } + + public function testHandleErrorNoLog(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + $trap = new ErrorTrap([ + 'log' => false, + 'errorRenderer' => TextErrorRenderer::class, + ]); + $trap->register(); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_WARNING); + ob_get_clean(); + restore_error_handler(); + + $logs = Log::engine('test_error')->read(); + $this->assertEmpty($logs); + } + + public function testConsoleRenderingNoTrace(): void + { + $stub = new StubConsoleOutput(); + $trap = new ErrorTrap([ + 'errorRenderer' => ConsoleErrorRenderer::class, + 'trace' => false, + 'stderr' => $stub, + ]); + $trap->register(); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_NOTICE); + ob_get_clean(); + restore_error_handler(); + + $out = $stub->messages(); + $this->assertStringContainsString('Oh no it was bad', $out[0]); + $this->assertStringNotContainsString('Trace', $out[0]); + } + + public function testConsoleRenderingWithTrace(): void + { + $stub = new StubConsoleOutput(); + $trap = new ErrorTrap([ + 'errorRenderer' => ConsoleErrorRenderer::class, + 'trace' => true, + 'stderr' => $stub, + ]); + $trap->register(); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_NOTICE); + ob_get_clean(); + restore_error_handler(); + + $out = $stub->messages(); + $this->assertStringContainsString('Oh no it was bad', $out[0]); + $this->assertStringContainsString('Trace', $out[0]); + $this->assertStringContainsString('ErrorTrapTest->testConsoleRenderingWithTrace', $out[0]); + } + + public function testRegisterNoOutputDebug(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + Configure::write('debug', false); + $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]); + $trap->register(); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_NOTICE); + $output = ob_get_clean(); + restore_error_handler(); + $this->assertSame('', $output); + } + + public function testRegisterIgnoredDeprecations(): void + { + $trap = new ErrorTrap([ + 'errorRenderer' => TextErrorRenderer::class, + 'trace' => false, + ]); + $trap->register(); + + ob_start(); + Configure::write('Error.ignoredDeprecationPaths', [ + 'tests/TestCase/Error/ErrorTrap*', + ]); + trigger_error('Should be ignored', E_USER_DEPRECATED); + + Configure::write('Error.ignoredDeprecationPaths', []); + trigger_error('Not ignored', E_USER_DEPRECATED); + + $output = ob_get_clean(); + restore_error_handler(); + + $this->assertStringNotContainsString('Should be ignored', $output); + $this->assertStringContainsString('Not ignored', $output); + } + + public function testEventTriggered(): void + { + $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]); + $trap->register(); + $trap->getEventManager()->on('Error.beforeRender', function ($event, PhpError $error): void { + $this->assertEquals(E_USER_NOTICE, $error->getCode()); + $this->assertStringContainsString('Oh no it was bad', $error->getMessage()); + }); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_NOTICE); + $out = ob_get_clean(); + restore_error_handler(); + $this->assertNotEmpty($out); + } + + public function testEventTriggeredAbortRender(): void + { + $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]); + $trap->register(); + $trap->getEventManager()->on('Error.beforeRender', function ($event, PhpError $error): void { + $this->assertEquals(E_USER_NOTICE, $error->getCode()); + $this->assertStringContainsString('Oh no it was bad', $error->getMessage()); + $event->stopPropagation(); + }); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_NOTICE); + $out = ob_get_clean(); + restore_error_handler(); + $this->assertSame('', $out); + } + + public function testEventReturnResponse(): void + { + $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]); + $trap->register(); + $trap->getEventManager()->on('Error.beforeRender', function ($event, PhpError $error): void { + $event->setResult("This ain't so bad"); + }); + + ob_start(); + trigger_error('Oh no it was bad', E_USER_NOTICE); + $out = ob_get_clean(); + restore_error_handler(); + $this->assertSame("This ain't so bad", $out); + } +} diff --git a/tests/TestCase/Error/ExceptionTrapTest.php b/tests/TestCase/Error/ExceptionTrapTest.php new file mode 100644 index 00000000000..b20647af06d --- /dev/null +++ b/tests/TestCase/Error/ExceptionTrapTest.php @@ -0,0 +1,439 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 4.4.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error; + +use Cake\Console\TestSuite\StubConsoleOutput; +use Cake\Error\ErrorLogger; +use Cake\Error\ExceptionTrap; +use Cake\Error\Renderer\ConsoleExceptionRenderer; +use Cake\Error\Renderer\TextExceptionRenderer; +use Cake\Error\Renderer\WebExceptionRenderer; +use Cake\Event\EventInterface; +use Cake\Http\Exception\MissingControllerException; +use Cake\Http\Exception\NotFoundException; +use Cake\Http\ServerRequest; +use Cake\Log\Log; +use Cake\TestSuite\TestCase; +use Cake\Utility\Text; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use RuntimeException; +use Throwable; + +class ExceptionTrapTest extends TestCase +{ + private string $memoryLimit; + + private $triggered = false; + + protected function setUp(): void + { + parent::setUp(); + $this->memoryLimit = ini_get('memory_limit'); + } + + protected function tearDown(): void + { + parent::tearDown(); + Log::reset(); + ini_set('memory_limit', $this->memoryLimit); + } + + public function testConfigExceptionRendererFallback(): void + { + $output = new StubConsoleOutput(); + $trap = new ExceptionTrap(['exceptionRenderer' => null, 'stderr' => $output]); + $error = new InvalidArgumentException('nope'); + $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error)); + } + + public function testConfigExceptionRenderer(): void + { + $trap = new ExceptionTrap(['exceptionRenderer' => WebExceptionRenderer::class]); + $error = new InvalidArgumentException('nope'); + $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error)); + } + + public function testConfigExceptionRendererFactory(): void + { + $trap = new ExceptionTrap(['exceptionRenderer' => function ($err, $req) { + return new WebExceptionRenderer($err, $req); + }]); + $error = new InvalidArgumentException('nope'); + $this->assertInstanceOf(WebExceptionRenderer::class, $trap->renderer($error)); + } + + public function testConfigRendererHandleUnsafeOverwrite(): void + { + $output = new StubConsoleOutput(); + $trap = new ExceptionTrap(['stderr' => $output]); + $trap->deleteConfig('exceptionRenderer'); + $error = new InvalidArgumentException('nope'); + $this->assertInstanceOf(ConsoleExceptionRenderer::class, $trap->renderer($error)); + } + + public function testLoggerConfig(): void + { + $trap = new ExceptionTrap(['logger' => ErrorLogger::class]); + $this->assertInstanceOf(ErrorLogger::class, $trap->logger()); + } + + public function testLoggerHandleUnsafeOverwrite(): void + { + $trap = new ExceptionTrap(); + $trap->deleteConfig('logger'); + $this->assertInstanceOf(ErrorLogger::class, $trap->logger()); + } + + public function testHandleExceptionText(): void + { + $trap = new ExceptionTrap([ + 'exceptionRenderer' => TextExceptionRenderer::class, + ]); + $error = new InvalidArgumentException('nope'); + + ob_start(); + $trap->handleException($error); + $out = ob_get_clean(); + + $this->assertStringContainsString('nope', $out); + $this->assertStringContainsString('ExceptionTrapTest', $out); + } + + public function testHandleExceptionConsoleRenderingNoStack(): void + { + $output = new StubConsoleOutput(); + $trap = new ExceptionTrap([ + 'exceptionRenderer' => ConsoleExceptionRenderer::class, + 'stderr' => $output, + ]); + $error = new InvalidArgumentException('nope'); + + $trap->handleException($error); + $out = $output->messages(); + + $this->assertStringContainsString('nope', $out[0]); + $this->assertStringNotContainsString('Stack', $out[0]); + } + + public function testHandleExceptionConsoleRenderingWithStack(): void + { + $output = new StubConsoleOutput(); + $trap = new ExceptionTrap([ + 'exceptionRenderer' => ConsoleExceptionRenderer::class, + 'stderr' => $output, + 'trace' => true, + ]); + $error = new InvalidArgumentException('nope'); + + $trap->handleException($error); + $out = $output->messages(); + + $this->assertStringContainsString('nope', $out[0]); + $this->assertStringContainsString('Stack', $out[0]); + $this->assertStringContainsString('->testHandleExceptionConsoleRenderingWithStack', $out[0]); + } + + public function testHandleExceptionConsoleRenderingWithPrevious(): void + { + $output = new StubConsoleOutput(); + $trap = new ExceptionTrap([ + 'exceptionRenderer' => ConsoleExceptionRenderer::class, + 'stderr' => $output, + 'trace' => true, + ]); + $previous = new RuntimeException('underlying error'); + $error = new InvalidArgumentException('nope', 0, $previous); + + $trap->handleException($error); + $out = $output->messages(); + + $this->assertStringContainsString('nope', $out[0]); + $this->assertStringContainsString('Caused by [RuntimeException] underlying error', $out[0]); + $this->assertEquals(2, substr_count($out[0], 'Stack Trace')); + } + + public function testHandleExceptionConsoleWithAttributes(): void + { + $output = new StubConsoleOutput(); + $trap = new ExceptionTrap([ + 'exceptionRenderer' => ConsoleExceptionRenderer::class, + 'stderr' => $output, + ]); + $error = new MissingControllerException(['name' => 'Articles']); + + $trap->handleException($error); + $out = $output->messages(); + + $this->assertStringContainsString('Controller class `Articles`', $out[0]); + $this->assertStringContainsString('Exception Attributes', $out[0]); + $this->assertStringContainsString('Articles', $out[0]); + } + + /** + * Test integration with HTML exception rendering + * + * Run in a separate process because HTML output writes headers. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testHandleExceptionHtmlRendering(): void + { + $trap = new ExceptionTrap([ + 'exceptionRenderer' => WebExceptionRenderer::class, + ]); + $error = new InvalidArgumentException('nope'); + + ob_start(); + $trap->handleException($error); + $out = ob_get_clean(); + + $this->assertStringContainsString('<!DOCTYPE', $out); + $this->assertStringContainsString('<html', $out); + $this->assertStringContainsString('nope', $out); + $this->assertStringContainsString('class="stack-frame-header"', $out); + $this->assertStringContainsString('Toggle Arguments', $out); + } + + public function testLogException(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + $trap = new ExceptionTrap(); + $error = new InvalidArgumentException('nope'); + $trap->logException($error); + + $logs = Log::engine('test_error')->read(); + $this->assertStringContainsString('nope', $logs[0]); + } + + public function testLogExceptionConfigOff(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + $trap = new ExceptionTrap(['log' => false]); + $error = new InvalidArgumentException('nope'); + $trap->logException($error); + + $logs = Log::engine('test_error')->read(); + $this->assertEmpty($logs); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSkipLogException(): void + { + Log::setConfig('test_error', [ + 'className' => 'Array', + ]); + $trap = new ExceptionTrap([ + 'exceptionRenderer' => WebExceptionRenderer::class, + 'skipLog' => [InvalidArgumentException::class], + ]); + + $trap->getEventManager()->on('Exception.beforeRender', function (): void { + $this->triggered = true; + }); + + ob_start(); + $trap->handleException(new InvalidArgumentException('nope')); + ob_get_clean(); + + $logs = Log::engine('test_error')->read(); + $this->assertCount(1, $logs); + $this->assertStringContainsString('MissingTemplateException - Failed to render', $logs[0]); + $this->assertTrue($this->triggered, 'Should have triggered event when skipping logging.'); + } + + public function testEventTriggered(): void + { + $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]); + $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error): void { + $this->assertEquals(100, $error->getCode()); + $this->assertStringContainsString('nope', $error->getMessage()); + }); + $error = new InvalidArgumentException('nope', 100); + + ob_start(); + $trap->handleException($error); + $out = ob_get_clean(); + + $this->assertNotEmpty($out); + } + + public function testBeforeRenderEventAborted(): void + { + $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]); + $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error, ?ServerRequest $req): void { + $this->assertEquals(100, $error->getCode()); + $this->assertStringContainsString('nope', $error->getMessage()); + $event->stopPropagation(); + }); + $error = new InvalidArgumentException('nope', 100); + + ob_start(); + $trap->handleException($error); + $out = ob_get_clean(); + + $this->assertSame('', $out); + } + + public function testBeforeRenderEventExceptionChanged(): void + { + $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]); + $trap->getEventManager()->on('Exception.beforeRender', function ($event, Throwable $error, ?ServerRequest $req): void { + $event->setData('exception', new NotFoundException()); + }); + $error = new InvalidArgumentException('nope', 100); + + ob_start(); + $trap->handleException($error); + $out = ob_get_clean(); + + $this->assertStringContainsString('404 : Not Found', $out); + } + + public function testBeforeRenderEventReturnResponse(): void + { + $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]); + $trap->getEventManager()->on('Exception.beforeRender', function (EventInterface $event, Throwable $error, ?ServerRequest $req): void { + $event->setResult('Here B Erroz'); + }); + + ob_start(); + $trap->handleException(new NotFoundException()); + $out = ob_get_clean(); + + $this->assertSame('Here B Erroz', $out); + } + + public function testHandleShutdownNoOp(): void + { + $trap = new ExceptionTrap([ + 'exceptionRenderer' => TextExceptionRenderer::class, + ]); + ob_start(); + $trap->handleShutdown(); + $out = ob_get_clean(); + + $this->assertEmpty($out); + } + + public function testHandleFatalShutdownNoError(): void + { + $trap = new ExceptionTrap([ + 'exceptionRenderer' => TextExceptionRenderer::class, + ]); + error_clear_last(); + ob_start(); + $trap->handleShutdown(); + $out = ob_get_clean(); + + $this->assertSame('', $out); + } + + public function testHandleFatalErrorText(): void + { + $trap = new ExceptionTrap([ + 'exceptionRenderer' => TextExceptionRenderer::class, + ]); + ob_start(); + $trap->handleFatalError(E_USER_ERROR, 'Something bad', __FILE__, __LINE__); + $out = ob_get_clean(); + + $this->assertStringContainsString('500 : Fatal Error', $out); + $this->assertStringContainsString('Something bad', $out); + $this->assertStringContainsString(__FILE__, $out); + } + + public function testHandleFatalErrorWhenCompileError(): void + { + $trap = new ExceptionTrap([ + 'exceptionRenderer' => TextExceptionRenderer::class, + ]); + ob_start(); + $trap->handleFatalError(E_COMPILE_ERROR, 'Compile error', __FILE__, __LINE__); + $out = ob_get_clean(); + + $this->assertStringContainsString('500 : Fatal Error', $out); + $this->assertStringContainsString('Compile error', $out); + $this->assertStringContainsString(__FILE__, $out); + } + + /** + * Test integration with HTML rendering for fatal errors + * + * Run in a separate process because HTML output writes headers. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testHandleFatalErrorHtmlRendering(): void + { + $trap = new ExceptionTrap([ + 'exceptionRenderer' => WebExceptionRenderer::class, + ]); + + ob_start(); + $trap->handleFatalError(E_USER_ERROR, 'Something bad', __FILE__, __LINE__); + $out = ob_get_clean(); + + $this->assertStringContainsString('<!DOCTYPE', $out); + $this->assertStringContainsString('<html', $out); + $this->assertStringContainsString('Something bad', $out); + $this->assertStringContainsString(__FILE__, $out); + } + + /** + * Data provider for memory limit increase + */ + public static function initialMemoryProvider(): array + { + return [ + ['256M'], + ['1G'], + ]; + } + + #[DataProvider('initialMemoryProvider')] + public function testIncreaseMemoryLimit($initial): void + { + ini_set('memory_limit', $initial); + $this->assertEquals($initial, ini_get('memory_limit')); + + $trap = new ExceptionTrap([ + 'exceptionRenderer' => TextExceptionRenderer::class, + ]); + $trap->increaseMemoryLimit(4 * 1024); + $initialBytes = Text::parseFileSize($initial, false); + $result = Text::parseFileSize(ini_get('memory_limit'), false); + $this->assertWithinRange($initialBytes + (4 * 1024 * 1024), $result, 1024); + } + + public function testSingleton(): void + { + $trap = new ExceptionTrap(); + $trap->register(); + $this->assertSame($trap, ExceptionTrap::instance()); + + $trap->unregister(); + $this->assertNull(ExceptionTrap::instance()); + } +} diff --git a/tests/TestCase/Error/FunctionsTest.php b/tests/TestCase/Error/FunctionsTest.php new file mode 100644 index 00000000000..52579bfe169 --- /dev/null +++ b/tests/TestCase/Error/FunctionsTest.php @@ -0,0 +1,192 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.5.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error; + +use Cake\Error\Debugger; +use Cake\TestSuite\TestCase; +use function Cake\Core\pj; +use function Cake\Core\pr; +use function Cake\Error\debug; +use function Cake\Error\stackTrace; + +/** + * FunctionsTest class + */ +class FunctionsTest extends TestCase +{ + /** + * test debug() + */ + public function testDebug(): void + { + ob_start(); + $this->assertSame('this-is-a-test', debug('this-is-a-test', false)); + $result = ob_get_clean(); + $expectedText = <<<EXPECTED +%s (line %d) +########## DEBUG ########## +'this-is-a-test' +########################### + +EXPECTED; + $expected = sprintf($expectedText, Debugger::trimPath(__FILE__), __LINE__ - 9); + + $this->assertSame($expected, $result); + + ob_start(); + $value = '<div>this-is-a-test</div>'; + $this->assertSame($value, debug($value, true)); + $result = ob_get_clean(); + $this->assertStringContainsString('<div class="cake-debug-output', $result); + $this->assertStringContainsString('this-is-a-test', $result); + + ob_start(); + debug('<div>this-is-a-test</div>', true, true); + $result = ob_get_clean(); + $expected = <<<EXPECTED +<div class="cake-debug-output cake-debug" style="direction:ltr"> +<span><strong>%s</strong> (line <strong>%d</strong>)</span> +EXPECTED; + $expected = sprintf($expected, Debugger::trimPath(__FILE__), __LINE__ - 6); + $this->assertStringContainsString($expected, $result); + + ob_start(); + debug('<div>this-is-a-test</div>', true, false); + $result = ob_get_clean(); + $this->assertStringNotContainsString('(line', $result); + } + + /** + * test pr() + */ + public function testPr(): void + { + ob_start(); + $this->assertTrue(pr(true)); + $result = ob_get_clean(); + $expected = "\n1\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + $this->assertFalse(pr(false)); + $result = ob_get_clean(); + $expected = "\n\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + $this->assertNull(pr(null)); + $result = ob_get_clean(); + $expected = "\n\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + $this->assertSame(123, pr(123)); + $result = ob_get_clean(); + $expected = "\n123\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + pr('123'); + $result = ob_get_clean(); + $expected = "\n123\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + pr('this is a test'); + $result = ob_get_clean(); + $expected = "\nthis is a test\n\n"; + $this->assertSame($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->assertSame($expected, $result); + } + + /** + * test pj() + */ + public function testPj(): void + { + ob_start(); + $this->assertTrue(pj(true)); + $result = ob_get_clean(); + $expected = "\ntrue\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + $this->assertFalse(pj(false)); + $result = ob_get_clean(); + $expected = "\nfalse\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + $this->assertNull(pj(null)); + $result = ob_get_clean(); + $expected = "\nnull\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + $this->assertSame(123, pj(123)); + $result = ob_get_clean(); + $expected = "\n123\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + pj('123'); + $result = ob_get_clean(); + $expected = "\n\"123\"\n\n"; + $this->assertSame($expected, $result); + + ob_start(); + pj('this is a test'); + $result = ob_get_clean(); + $expected = "\n\"this is a test\"\n\n"; + $this->assertSame($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->assertSame($expected, $result); + } + + /** + * Tests that the stackTrace() method is a shortcut for Debugger::trace() + */ + public function testStackTrace(): void + { + ob_start(); + // phpcs:ignore + stackTrace(); $expected = Debugger::trace(); + $result = ob_get_clean(); + $this->assertSame($expected, $result); + + $opts = ['args' => true]; + ob_start(); + // phpcs:ignore + stackTrace($opts); $expected = Debugger::trace($opts); + $result = ob_get_clean(); + $this->assertSame($expected, $result); + + $opts = ['format' => 'array']; + $trace = Debugger::trace($opts); + $this->assertEmpty(array_column($trace, 'args')); + } +} diff --git a/tests/TestCase/Error/Middleware/ErrorHandlerMiddlewareTest.php b/tests/TestCase/Error/Middleware/ErrorHandlerMiddlewareTest.php new file mode 100644 index 00000000000..2892486384e --- /dev/null +++ b/tests/TestCase/Error/Middleware/ErrorHandlerMiddlewareTest.php @@ -0,0 +1,434 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error\Middleware; + +use Cake\Core\Configure; +use Cake\Datasource\Exception\RecordNotFoundException; +use Cake\Error\ExceptionRendererInterface; +use Cake\Error\ExceptionTrap; +use Cake\Error\Middleware\ErrorHandlerMiddleware; +use Cake\Error\Renderer\WebExceptionRenderer; +use Cake\Event\EventInterface; +use Cake\Event\EventManager; +use Cake\Http\Exception\MissingControllerException; +use Cake\Http\Exception\NotFoundException; +use Cake\Http\Exception\RedirectException; +use Cake\Http\Exception\ServiceUnavailableException; +use Cake\Http\Response; +use Cake\Http\ServerRequestFactory; +use Cake\Log\Log; +use Cake\Routing\Router; +use Cake\TestSuite\TestCase; +use Error; +use LogicException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use TestApp\Application; +use TestApp\Http\TestRequestHandler; +use Throwable; + +/** + * Test for ErrorHandlerMiddleware + */ +class ErrorHandlerMiddlewareTest extends TestCase +{ + /** + * @var \Cake\Log\Engine\ArrayLog + */ + protected $logger; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + + static::setAppNamespace(); + + Log::reset(); + Log::setConfig('error_test', [ + 'className' => 'Array', + ]); + $this->logger = Log::engine('error_test'); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + Log::drop('error_test'); + } + + /** + * Test returning a response works ok. + */ + public function testNoErrorResponse(): void + { + $request = ServerRequestFactory::fromGlobals(); + + $middleware = new ErrorHandlerMiddleware(); + $result = $middleware->process($request, new TestRequestHandler()); + $this->assertInstanceOf(Response::class, $result); + $this->assertCount(0, $this->logger->read()); + } + + /** + * Test using a factory method to make a renderer. + */ + public function testRendererFactory(): void + { + $request = ServerRequestFactory::fromGlobals(); + + $factory = function ($exception) { + $this->assertInstanceOf('LogicException', $exception); + + return new class implements ExceptionRendererInterface + { + public function render(): Response + { + return new Response(); + } + + public function write(string|ResponseInterface $output): void + { + } + }; + }; + $middleware = new ErrorHandlerMiddleware(new ExceptionTrap([ + 'exceptionRenderer' => $factory, + ])); + $handler = new TestRequestHandler(function (): void { + throw new LogicException('Something bad'); + }); + $middleware->process($request, $handler); + } + + /** + * Test rendering an error page + */ + public function testHandleException(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new ErrorHandlerMiddleware(); + $handler = new TestRequestHandler(function (): void { + throw new NotFoundException('whoops'); + }); + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(404, $result->getStatusCode()); + $this->assertStringContainsString('was not found', '' . $result->getBody()); + } + + /** + * Test rendering an error page with an exception trap + */ + public function testHandleExceptionWithExceptionTrap(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new ErrorHandlerMiddleware(new ExceptionTrap([ + 'exceptionRenderer' => WebExceptionRenderer::class, + ])); + $handler = new TestRequestHandler(function (): void { + throw new NotFoundException('whoops'); + }); + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(404, $result->getStatusCode()); + $this->assertStringContainsString('was not found', '' . $result->getBody()); + } + + /** + * Test creating a redirect response + */ + public function testHandleRedirectException(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new ErrorHandlerMiddleware(); + $handler = new TestRequestHandler(function (): void { + throw new RedirectException('http://example.org/login'); + }); + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame(302, $result->getStatusCode()); + $this->assertEmpty((string)$result->getBody()); + $expected = [ + 'location' => ['http://example.org/login'], + ]; + $this->assertSame($expected, $result->getHeaders()); + } + + /** + * Test creating a redirect response + */ + public function testHandleRedirectExceptionHeaders(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new ErrorHandlerMiddleware(); + $handler = new TestRequestHandler(function (): void { + $err = new RedirectException('http://example.org/login', 301, ['Constructor' => 'yes', 'Method' => 'yes']); + throw $err; + }); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame(301, $result->getStatusCode()); + $this->assertEmpty('' . $result->getBody()); + $expected = [ + 'location' => ['http://example.org/login'], + 'Constructor' => ['yes'], + 'Method' => ['yes'], + ]; + $this->assertEquals($expected, $result->getHeaders()); + } + + /** + * Test rendering an error page holds onto the original request. + */ + public function testHandleExceptionPreserveRequest(): void + { + $request = ServerRequestFactory::fromGlobals(); + $request = $request->withHeader('Accept', 'application/json'); + + $middleware = new ErrorHandlerMiddleware(); + $handler = new TestRequestHandler(function (): void { + throw new NotFoundException('whoops'); + }); + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(404, $result->getStatusCode()); + $this->assertStringContainsString('"message": "whoops"', (string)$result->getBody()); + $this->assertStringContainsString('application/json', $result->getHeaderLine('Content-type')); + } + + /** + * Test handling PHP 7's Error instance. + */ + public function testHandlePHP7Error(): void + { + $middleware = new ErrorHandlerMiddleware(); + $request = ServerRequestFactory::fromGlobals(); + $error = new Error(); + + $result = $middleware->handleException($error, $request); + $this->assertInstanceOf(Response::class, $result); + } + + /** + * Test rendering an error page logs errors + */ + public function testHandleExceptionLogAndTrace(): void + { + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_URI' => '/target/url', + 'HTTP_REFERER' => '/other/path', + ]); + $middleware = new ErrorHandlerMiddleware(['log' => true, 'trace' => true]); + $handler = new TestRequestHandler(function (): void { + throw new NotFoundException('Kaboom!'); + }); + $result = $middleware->process($request, $handler); + $this->assertSame(404, $result->getStatusCode()); + $this->assertStringContainsString('was not found', '' . $result->getBody()); + + $logs = $this->logger->read(); + $this->assertCount(1, $logs); + $this->assertStringContainsString('error', $logs[0]); + $this->assertStringContainsString('[Cake\Http\Exception\NotFoundException] Kaboom!', $logs[0]); + $this->assertStringContainsString( + str_replace('/', DS, 'vendor/phpunit/phpunit/src/Framework/TestCase.php'), + $logs[0], + ); + $this->assertStringContainsString('Request URL: /target/url', $logs[0]); + $this->assertStringContainsString('Referer URL: /other/path', $logs[0]); + $this->assertStringNotContainsString('Previous:', $logs[0]); + } + + /** + * Test rendering an error page logs errors with previous + */ + public function testHandleExceptionLogAndTraceWithPrevious(): void + { + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_URI' => '/target/url', + 'HTTP_REFERER' => '/other/path', + ]); + $middleware = new ErrorHandlerMiddleware(['log' => true, 'trace' => true]); + $handler = new TestRequestHandler(function ($req): void { + $previous = new RecordNotFoundException('Previous logged'); + throw new NotFoundException('Kaboom!', null, $previous); + }); + $result = $middleware->process($request, $handler); + $this->assertSame(404, $result->getStatusCode()); + $this->assertStringContainsString('was not found', '' . $result->getBody()); + + $logs = $this->logger->read(); + $this->assertCount(1, $logs); + $this->assertStringContainsString('error', $logs[0]); + $this->assertStringContainsString('[Cake\Http\Exception\NotFoundException] Kaboom!', $logs[0]); + $this->assertStringContainsString( + 'Caused by: [Cake\Datasource\Exception\RecordNotFoundException]', + $logs[0], + ); + $this->assertStringContainsString( + str_replace('/', DS, 'vendor/phpunit/phpunit/src/Framework/TestCase.php'), + $logs[0], + ); + $this->assertStringContainsString('Request URL: /target/url', $logs[0]); + $this->assertStringContainsString('Referer URL: /other/path', $logs[0]); + } + + /** + * Test rendering an error page skips logging for specific classes + */ + public function testHandleExceptionSkipLog(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new ErrorHandlerMiddleware([ + 'log' => true, + 'skipLog' => [NotFoundException::class], + ]); + $handler = new TestRequestHandler(function (): void { + throw new NotFoundException('Kaboom!'); + }); + $result = $middleware->process($request, $handler); + $this->assertSame(404, $result->getStatusCode()); + $this->assertStringContainsString('was not found', '' . $result->getBody()); + + $this->assertCount(0, $this->logger->read()); + } + + /** + * Test rendering an error page logs exception attributes + */ + public function testHandleExceptionLogAttributes(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new ErrorHandlerMiddleware(['log' => true]); + $handler = new TestRequestHandler(function (): void { + throw new MissingControllerException(['controller' => 'Articles']); + }); + $result = $middleware->process($request, $handler); + $this->assertSame(404, $result->getStatusCode()); + + $logs = $this->logger->read(); + $this->assertStringContainsString( + '[Cake\Http\Exception\MissingControllerException] Controller class `Articles` could not be found.', + $logs[0], + ); + $this->assertStringContainsString('Exception Attributes:', $logs[0]); + $this->assertStringContainsString("'controller' => 'Articles'", $logs[0]); + $this->assertStringContainsString('Request URL:', $logs[0]); + } + + public function testExceptionBeforeRenderEvent(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new ErrorHandlerMiddleware(new ExceptionTrap([ + 'exceptionRenderer' => WebExceptionRenderer::class, + ])); + $handler = new TestRequestHandler(function (): void { + throw new NotFoundException('whoops'); + }); + + EventManager::instance()->on( + 'Exception.beforeRender', + function (EventInterface $event, Throwable $e, ServerRequestInterface $req): void { + $event->setResult('Response string from event'); + }, + ); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('Response string from event', (string)$result->getBody()); + } + + /** + * Test handling an error and having rendering fail. + */ + public function testHandleExceptionRenderingFails(): void + { + $request = ServerRequestFactory::fromGlobals(); + + $factory = function () { + return new class implements ExceptionRendererInterface + { + public function render(): ResponseInterface|string + { + throw new LogicException('Rendering failed'); + } + + public function write(string|ResponseInterface $output): void + { + } + }; + }; + $middleware = new ErrorHandlerMiddleware(new ExceptionTrap([ + 'exceptionRenderer' => $factory, + ])); + $handler = new TestRequestHandler(function (): void { + throw new ServiceUnavailableException('whoops'); + }); + $response = $middleware->process($request, $handler); + $this->assertSame(500, $response->getStatusCode()); + $this->assertSame('An Internal Server Error Occurred', '' . $response->getBody()); + } + + /** + * Test that the middleware loads routes if not already loaded, which is the + * case when an exception occurs before RoutingMiddleware is run. + * + * @return void + */ + public function testRoutesLoading(): void + { + $request = ServerRequestFactory::fromGlobals(); + $app = new Application(CONFIG); + $middleware = new ErrorHandlerMiddleware( + new ExceptionTrap([ + 'exceptionRenderer' => WebExceptionRenderer::class, + ]), + $app, + ); + + $this->assertSame([], Router::routes()); + + $middleware->process($request, $app); + $this->assertNotEmpty(Router::routes()); + } + + /** + * Test exception args are not ignored in php7.4 with debug enabled. + */ + public function testExceptionArgs(): void + { + // Force exception_ignore_args to true for test + ini_set('zend.exception_ignore_args', '1'); + + // Debug disabled + Configure::write('debug', false); + new ErrorHandlerMiddleware(); + $this->assertSame('1', ini_get('zend.exception_ignore_args')); + + // Debug enabled + Configure::write('debug', true); + new ErrorHandlerMiddleware(); + $this->assertSame('0', ini_get('zend.exception_ignore_args')); + } +} diff --git a/tests/TestCase/Error/PhpErrorTest.php b/tests/TestCase/Error/PhpErrorTest.php new file mode 100644 index 00000000000..2b143bab4ba --- /dev/null +++ b/tests/TestCase/Error/PhpErrorTest.php @@ -0,0 +1,77 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.4.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error; + +use Cake\Error\PhpError; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +class PhpErrorTest extends TestCase +{ + public function testBasicGetters(): void + { + $error = new PhpError(E_ERROR, 'something bad'); + $this->assertEquals(E_ERROR, $error->getCode()); + $this->assertEquals('something bad', $error->getMessage()); + $this->assertNull($error->getFile()); + $this->assertNull($error->getLine()); + $this->assertEquals([], $error->getTrace()); + $this->assertEquals('', $error->getTraceAsString()); + } + + public static function errorCodeProvider(): array + { + // [php error code, label, log-level] + $return = [ + [E_ERROR, 'error', LOG_ERR], + [E_WARNING, 'warning', LOG_WARNING], + [E_NOTICE, 'notice', LOG_NOTICE], + [E_USER_DEPRECATED, 'deprecated', LOG_NOTICE], + ]; + + if (version_compare(PHP_VERSION, '8.4.0-dev', '<')) { + $return[] = [E_STRICT, 'strict', LOG_NOTICE]; + } + + return $return; + } + + #[DataProvider('errorCodeProvider')] + public function testMappings($phpCode, $label, $logLevel): void + { + $error = new PhpError($phpCode, 'something bad'); + $this->assertEquals($phpCode, $error->getCode()); + $this->assertEquals($label, $error->getLabel()); + $this->assertEquals($logLevel, $error->getLogLevel()); + } + + public function testGetTraceAsString(): void + { + $trace = [ + ['file' => 'a.php', 'line' => 10, 'reference' => 'TestObject::a()'], + ['file' => 'b.php', 'line' => 5, 'reference' => '[main]'], + ]; + $error = new PhpError(E_ERROR, 'something bad', __FILE__, __LINE__, $trace); + $this->assertEquals($trace, $error->getTrace()); + $expected = [ + 'TestObject::a() a.php, line 10', + '[main] b.php, line 5', + ]; + $this->assertEquals(implode("\n", $expected), $error->getTraceAsString()); + $this->assertEquals('error', $error->getLabel()); + } +} diff --git a/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php b/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php new file mode 100644 index 00000000000..b41a1c17163 --- /dev/null +++ b/tests/TestCase/Error/Renderer/WebExceptionRendererTest.php @@ -0,0 +1,1032 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 2.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Error; + +use Cake\Controller\Controller; +use Cake\Controller\ErrorController; +use Cake\Controller\Exception\InvalidParameterException; +use Cake\Controller\Exception\MissingActionException; +use Cake\Controller\Exception\MissingComponentException; +use Cake\Core\Configure; +use Cake\Core\Exception\CakeException; +use Cake\Core\Exception\MissingPluginException; +use Cake\Database\Driver; +use Cake\Database\Exception\QueryException; +use Cake\Database\Log\LoggedQuery; +use Cake\Datasource\Exception\MissingDatasourceConfigException; +use Cake\Datasource\Exception\MissingDatasourceException; +use Cake\Error\Renderer\WebExceptionRenderer; +use Cake\Event\EventInterface; +use Cake\Event\EventManager; +use Cake\Http\Exception\HttpException; +use Cake\Http\Exception\InternalErrorException; +use Cake\Http\Exception\MethodNotAllowedException; +use Cake\Http\Exception\MissingControllerException; +use Cake\Http\Exception\NotFoundException; +use Cake\Http\Response; +use Cake\Http\ServerRequest; +use Cake\Mailer\Exception\MissingActionException as MissingMailerActionException; +use Cake\ORM\Exception\MissingBehaviorException; +use Cake\Routing\Router; +use Cake\TestSuite\TestCase; +use Cake\Utility\Exception\XmlException; +use Cake\View\Exception\MissingHelperException; +use Cake\View\Exception\MissingLayoutException; +use Cake\View\Exception\MissingTemplateException; +use Exception; +use Mockery; +use OutOfBoundsException; +use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionMethod; +use RuntimeException; +use TestApp\Controller\Admin\ErrorController as PrefixErrorController; +use TestApp\Error\Exception\MissingWidgetThing; +use TestApp\Error\Exception\MissingWidgetThingException; +use TestApp\Error\Renderer\MyCustomExceptionRenderer; +use TestApp\Error\Renderer\TestAppsExceptionRenderer; +use TestPlugin\Controller\ErrorController as PluginErrorController; +use function Cake\Core\h; + +class WebExceptionRendererTest extends TestCase +{ + /** + * @var bool + */ + protected $restoreError = false; + + /** + * @var bool + */ + protected $called; + + /** + * setup create a request object to get out of router later. + */ + protected function setUp(): void + { + parent::setUp(); + Configure::write('Config.language', 'eng'); + Router::reload(); + + $request = new ServerRequest(['base' => '']); + Router::setRequest($request); + Configure::write('debug', true); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + if ($this->restoreError) { + restore_error_handler(); + } + } + + public function testControllerInstanceForPrefixedRequest(): void + { + $this->setAppNamespace('TestApp'); + + $exception = new NotFoundException('Page not found'); + $request = new ServerRequest(); + $request = $request + ->withParam('controller', 'Articles') + ->withParam('prefix', 'Admin'); + + $ExceptionRenderer = new MyCustomExceptionRenderer($exception, $request); + + $this->assertInstanceOf( + PrefixErrorController::class, + $ExceptionRenderer->__debugInfo()['controller'], + ); + } + + /** + * Test that prefixed controllers in plugins use the plugin + * error controller if it exists. + * + * @return void + */ + public function testControllerInstanceForPluginPrefixedRequest(): void + { + $this->loadPlugins(['TestPlugin']); + $this->setAppNamespace('TestApp'); + + $exception = new NotFoundException('Page not found'); + $request = new ServerRequest(); + $request = $request + ->withParam('controller', 'Comments') + ->withParam('plugin', 'TestPlugin') + ->withParam('prefix', 'Admin'); + + $ExceptionRenderer = new MyCustomExceptionRenderer($exception, $request); + + $this->assertInstanceOf( + PluginErrorController::class, + $ExceptionRenderer->__debugInfo()['controller'], + ); + } + + /** + * testTemplatePath + */ + public function testTemplatePath(): void + { + $request = (new ServerRequest()) + ->withParam('controller', 'Foo') + ->withParam('action', 'bar'); + $exception = new NotFoundException(); + $ExceptionRenderer = new WebExceptionRenderer($exception, $request); + + $ExceptionRenderer->render(); + $controller = $ExceptionRenderer->__debugInfo()['controller']; + $this->assertSame('error400', $controller->viewBuilder()->getTemplate()); + $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath()); + + $request = $request->withParam('prefix', 'Admin'); + $exception = new MissingActionException(['controller' => 'Foo', 'action' => 'bar']); + + $ExceptionRenderer = new WebExceptionRenderer($exception, $request); + + $ExceptionRenderer->render(); + $controller = $ExceptionRenderer->__debugInfo()['controller']; + $this->assertSame('missingAction', $controller->viewBuilder()->getTemplate()); + $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath()); + + Configure::write('debug', false); + $ExceptionRenderer = new WebExceptionRenderer($exception, $request); + + $ExceptionRenderer->render(); + $controller = $ExceptionRenderer->__debugInfo()['controller']; + $this->assertSame('error400', $controller->viewBuilder()->getTemplate()); + $this->assertSame( + 'Admin' . DIRECTORY_SEPARATOR . 'Error', + $controller->viewBuilder()->getTemplatePath(), + ); + } + + /** + * Test that NotFoundException with prefix renders error400 when prefix templates don't exist + * Regression test for issue #17599 + */ + public function testNotFoundExceptionWithPrefixWithoutPrefixTemplate(): void + { + Configure::write('debug', false); + + // Create a request with a prefix that doesn't have error templates + $request = (new ServerRequest()) + ->withParam('controller', 'Foo') + ->withParam('action', 'bar') + ->withParam('prefix', 'CustomPrefix'); // This prefix doesn't have error templates + + $exception = new NotFoundException('Page not found'); + $renderer = new WebExceptionRenderer($exception, $request); + + $result = $renderer->render(); + + // Should use error400 template, not error500 + $controller = $renderer->__debugInfo()['controller']; + $this->assertSame('error400', $controller->viewBuilder()->getTemplate()); + $this->assertSame(404, $result->getStatusCode()); + } + + /** + * test that methods declared in an WebExceptionRenderer subclass are not converted + * into error400 when debug > 0 + */ + public function testSubclassMethodsNotBeingConvertedToError(): void + { + $exception = new MissingWidgetThingException('Widget not found'); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $result = $ExceptionRenderer->render(); + + $this->assertSame('widget thing is missing', (string)$result->getBody()); + } + + /** + * test that subclass methods are not converted when debug = 0 + */ + public function testSubclassMethodsNotBeingConvertedDebug0(): void + { + Configure::write('debug', false); + $exception = new MissingWidgetThingException('Widget not found'); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $result = $ExceptionRenderer->render(); + + $this->assertSame( + 'missingWidgetThing', + $ExceptionRenderer->__debugInfo()['method'], + ); + $this->assertSame( + 'widget thing is missing', + (string)$result->getBody(), + 'Method declared in subclass converted to error400', + ); + } + + /** + * test that WebExceptionRenderer subclasses properly convert framework errors. + */ + public function testSubclassConvertingFrameworkErrors(): void + { + Configure::write('debug', false); + + $exception = new MissingControllerException('PostsController'); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $result = $ExceptionRenderer->render(); + + $this->assertMatchesRegularExpression( + '/Not Found/', + (string)$result->getBody(), + 'Method declared in error handler not converted to error400. %s', + ); + } + + /** + * test things in the constructor. + */ + public function testConstruction(): void + { + $exception = new NotFoundException('Page not found'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $this->assertInstanceOf( + ErrorController::class, + $ExceptionRenderer->__debugInfo()['controller'], + ); + $this->assertEquals($exception, $ExceptionRenderer->__debugInfo()['error']); + } + + /** + * test that exception message gets coerced when debug = 0 + */ + public function testExceptionMessageCoercion(): void + { + Configure::write('debug', false); + $exception = new MissingActionException('Secret info not to be leaked'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $this->assertInstanceOf( + ErrorController::class, + $ExceptionRenderer->__debugInfo()['controller'], + ); + $this->assertEquals($exception, $ExceptionRenderer->__debugInfo()['error']); + + $result = (string)$ExceptionRenderer->render()->getBody(); + + $this->assertSame('error400', $ExceptionRenderer->__debugInfo()['template']); + $this->assertStringContainsString('Not Found', $result); + $this->assertStringNotContainsString('Secret info not to be leaked', $result); + } + + /** + * test that helpers in custom CakeErrorController are not lost + */ + public function testCakeErrorHelpersNotLost(): void + { + static::setAppNamespace(); + $exception = new NotFoundException(); + $renderer = new TestAppsExceptionRenderer($exception); + + $result = $renderer->render(); + $this->assertStringContainsString('<b>peeled</b>', (string)$result->getBody()); + } + + /** + * test that unknown exception types with valid status codes are treated correctly. + */ + public function testUnknownExceptionTypeWithExceptionThatHasA400Code(): void + { + $exception = new MissingWidgetThingException('coding fail.'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + $response = $ExceptionRenderer->render(); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertFalse(method_exists($ExceptionRenderer, 'missingWidgetThing'), 'no method should exist.'); + $this->assertStringContainsString('coding fail', (string)$response->getBody(), 'Text should show up.'); + } + + /** + * test that unknown exception types with valid status codes are treated correctly. + */ + public function testUnknownExceptionTypeWithNoCodeIsA500(): void + { + $exception = new OutOfBoundsException('foul ball.'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + $result = $ExceptionRenderer->render(); + + $this->assertSame(500, $result->getStatusCode()); + $this->assertStringContainsString('foul ball.', (string)$result->getBody(), 'Text should show up as its debug mode.'); + } + + /** + * test that unknown exceptions have messages ignored. + */ + public function testUnknownExceptionInProduction(): void + { + Configure::write('debug', false); + + $exception = new OutOfBoundsException('foul ball.'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $response = $ExceptionRenderer->render(); + $result = (string)$response->getBody(); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertStringNotContainsString('foul ball.', $result, 'Text should no show up.'); + $this->assertStringContainsString('Internal Error', $result, 'Generic message only.'); + } + + /** + * test that unknown exception types with valid status codes are treated correctly. + */ + public function testUnknownExceptionTypeWithCodeHigherThan500(): void + { + $exception = new HttpException('foul ball.', 501); + $ExceptionRenderer = new WebExceptionRenderer($exception); + $response = $ExceptionRenderer->render(); + $result = (string)$response->getBody(); + + $this->assertSame(501, $response->getStatusCode()); + $this->assertStringContainsString('foul ball.', $result, 'Text should show up as its debug mode.'); + } + + /** + * testerror400 method + */ + public function testError400(): void + { + Router::reload(); + + $request = new ServerRequest(['url' => 'posts/view/1000']); + Router::setRequest($request); + + $exception = new NotFoundException('Custom message'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $response = $ExceptionRenderer->render(); + $result = (string)$response->getBody(); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertStringContainsString('<h2>Custom message</h2>', $result); + $this->assertMatchesRegularExpression("/<strong>'.*?\/posts\/view\/1000'<\/strong>/", $result); + } + + /** + * testerror400 method when returning as JSON + */ + public function testError400AsJson(): void + { + Router::reload(); + + $request = new ServerRequest(['url' => 'posts/view/1000?sort=title&direction=desc']); + $request = $request->withHeader('Accept', 'application/json'); + $request = $request->withHeader('Content-Type', 'application/json'); + Router::setRequest($request); + + $exception = new NotFoundException('Custom message'); + $exceptionLine = __LINE__ - 1; + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $response = $ExceptionRenderer->render(); + $result = (string)$response->getBody(); + $expected = [ + 'message' => 'Custom message', + 'url' => '/posts/view/1000?sort=title&direction=desc', + 'code' => 404, + 'file' => __FILE__, + 'line' => $exceptionLine, + ]; + $this->assertEquals($expected, json_decode($result, true)); + $this->assertSame(404, $response->getStatusCode()); + } + + /** + * test that error400 only modifies the messages on Cake Exceptions. + */ + public function testError400OnlyChangingCakeException(): void + { + Configure::write('debug', false); + + $exception = new NotFoundException('Custom message'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $result = $ExceptionRenderer->render(); + $this->assertStringContainsString('Custom message', (string)$result->getBody()); + + $exception = new MissingActionException(['controller' => 'PostsController', 'action' => 'index']); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $result = $ExceptionRenderer->render(); + $this->assertStringContainsString('Not Found', (string)$result->getBody()); + } + + /** + * test that error400 doesn't expose XSS + */ + public function testError400NoInjection(): void + { + Router::reload(); + + $request = new ServerRequest(['url' => 'pages/<span id=333>pink</span></id><script>document.body.style.background = t=document.getElementById(333).innerHTML;window.alert(t);</script>']); + Router::setRequest($request); + + $exception = new NotFoundException('Custom message'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $result = (string)$ExceptionRenderer->render()->getBody(); + + $this->assertStringNotContainsString('<script>document', $result); + $this->assertStringNotContainsString('alert(t);</script>', $result); + } + + /** + * testError500 method + */ + public function testError500Message(): void + { + $exception = new InternalErrorException('An Internal Error Has Occurred.'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $response = $ExceptionRenderer->render(); + $result = (string)$response->getBody(); + $this->assertSame(500, $response->getStatusCode()); + $this->assertStringContainsString('<h2>An Internal Error Has Occurred.</h2>', $result); + $this->assertStringContainsString('An Internal Error Has Occurred.</p>', $result); + } + + /** + * testExceptionResponseHeader method + */ + public function testExceptionResponseHeader(): void + { + $exception = new MethodNotAllowedException('Only allowing POST and DELETE'); + $exception->setHeader('Allow', ['POST', 'DELETE']); + $ExceptionRenderer = new WebExceptionRenderer($exception); + + $result = $ExceptionRenderer->render(); + $this->assertTrue($result->hasHeader('Allow')); + $this->assertSame('POST,DELETE', $result->getHeaderLine('Allow')); + + $exception->setHeaders(['Allow' => 'GET']); + $result = $ExceptionRenderer->render(); + $this->assertTrue($result->hasHeader('Allow')); + $this->assertSame('GET', $result->getHeaderLine('Allow')); + } + + /** + * testMissingController method + */ + public function testMissingController(): void + { + $exception = new MissingControllerException([ + 'controller' => 'Posts', + 'prefix' => '', + 'plugin' => '', + ]); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $result = (string)$ExceptionRenderer->render()->getBody(); + + $this->assertSame( + 'missingController', + $ExceptionRenderer->__debugInfo()['template'], + ); + $this->assertStringContainsString('Missing Controller', $result); + $this->assertStringContainsString('<em>PostsController</em>', $result); + } + + /** + * test missingController method + */ + public function testMissingControllerLowerCase(): void + { + $exception = new MissingControllerException([ + 'controller' => 'posts', + 'prefix' => '', + 'plugin' => '', + ]); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $result = (string)$ExceptionRenderer->render()->getBody(); + + $this->assertSame( + 'missingController', + $ExceptionRenderer->__debugInfo()['template'], + ); + $this->assertStringContainsString('Missing Controller', $result); + $this->assertStringContainsString('<em>PostsController</em>', $result); + } + + /** + * Returns an array of tests to run for the various Cake Exception classes. + * + * @return array + */ + public static function exceptionProvider(): array + { + return [ + [ + new MissingActionException([ + 'controller' => 'PostsController', + 'action' => 'index', + 'prefix' => '', + 'plugin' => '', + ]), + [ + '/Missing Method in PostsController/', + '/<em>PostsController::index\(\)<\/em>/', + ], + 404, + ], + [ + new InvalidParameterException([ + 'template' => 'failed_coercion', + 'passed' => 'test', + 'type' => 'float', + 'parameter' => 'age', + 'controller' => 'TestController', + 'action' => 'checkAge', + 'prefix' => null, + 'plugin' => null, + ]), + ['/The passed parameter or parameter type is invalid in <em>TestController::checkAge\(\)/'], + 404, + ], + [ + new MissingActionException([ + 'controller' => 'PostsController', + 'action' => 'index', + 'prefix' => '', + 'plugin' => '', + ]), + [ + '/Missing Method in PostsController/', + '/<em>PostsController::index\(\)<\/em>/', + ], + 404, + ], + [ + new MissingTemplateException(['file' => '/posts/about.ctp']), + [ + "/posts\/about.ctp/", + ], + 500, + ], + [ + new MissingLayoutException(['file' => 'layouts/my_layout.ctp']), + [ + '/Missing Layout/', + "/layouts\/my_layout.ctp/", + ], + 500, + ], + [ + new MissingHelperException(['class' => 'MyCustomHelper']), + [ + '/Missing Helper/', + '/<em>MyCustomHelper<\/em> could not be found./', + '/Create the class <em>MyCustomHelper<\/em> below in file:/', + '/(\/|\\\)MyCustomHelper.php/', + ], + 500, + ], + [ + new MissingBehaviorException(['class' => 'MyCustomBehavior']), + [ + '/Missing Behavior/', + '/Create the class <em>MyCustomBehavior<\/em> below in file:/', + '/(\/|\\\)MyCustomBehavior.php/', + ], + 500, + ], + [ + new MissingComponentException(['class' => 'SideboxComponent']), + [ + '/Missing Component/', + '/Create the class <em>SideboxComponent<\/em> below in file:/', + '/(\/|\\\)SideboxComponent.php/', + ], + 500, + ], + [ + new MissingDatasourceConfigException(['name' => 'MyDatasourceConfig']), + [ + '/Missing Datasource Configuration/', + '/<em>MyDatasourceConfig<\/em> was not found/', + ], + 500, + ], + [ + new MissingDatasourceException(['class' => 'MyDatasource', 'plugin' => 'MyPlugin']), + [ + '/Missing Datasource/', + '/<em>MyPlugin.MyDatasource<\/em> could not be found./', + ], + 500, + ], + [ + new MissingMailerActionException([ + 'mailer' => 'UserMailer', + 'action' => 'welcome', + 'prefix' => '', + 'plugin' => '', + ]), + [ + '/Missing Method in UserMailer/', + '/<em>UserMailer::welcome\(\)<\/em>/', + ], + 500, + ], + [ + new Exception('boom'), + [ + '/Internal Error/', + ], + 500, + ], + [ + new RuntimeException('another boom'), + [ + '/Internal Error/', + ], + 500, + ], + [ + new CakeException('base class'), + ['/Internal Error/'], + 500, + ], + [ + new HttpException('Network Authentication Required', 511), + ['/Network Authentication Required/'], + 511, + ], + ]; + } + + /** + * Test the various Cake Exception sub classes + */ + #[DataProvider('exceptionProvider')] + public function testCakeExceptionHandling(Exception $exception, array $patterns, int $code): void + { + $exceptionRenderer = new WebExceptionRenderer($exception); + $response = $exceptionRenderer->render(); + + $this->assertEquals($code, $response->getStatusCode()); + $body = (string)$response->getBody(); + foreach ($patterns as $pattern) { + $this->assertMatchesRegularExpression($pattern, $body); + } + } + + /** + * Test that class names not ending in Exception are not mangled. + */ + public function testExceptionNameMangling(): void + { + $this->deprecated(function (): void { + $exceptionRenderer = new MyCustomExceptionRenderer(new MissingWidgetThing()); + + $result = (string)$exceptionRenderer->render()->getBody(); + $this->assertStringContainsString('widget thing is missing', $result); + + // Custom method should be called even when debug is off. + Configure::write('debug', false); + $exceptionRenderer = new MyCustomExceptionRenderer(new MissingWidgetThing()); + + $result = (string)$exceptionRenderer->render()->getBody(); + $this->assertStringContainsString('widget thing is missing', $result); + }); + } + + /** + * Test exceptions being raised when helpers are missing. + */ + public function testMissingRenderSafe(): void + { + $exception = new MissingHelperException(['class' => 'Fail']); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $controller = Mockery::mock(Controller::class)->makePartial(); + $controller->shouldReceive('render') + ->with('missingHelper') + ->once() + ->andThrow($exception); + $controller->shouldReceive('getRequest') + ->times(1) + ->andReturn(new ServerRequest()); + $controller->shouldReceive('getResponse') + ->times(2) + ->andReturn(new Response()); + + $ExceptionRenderer->setController($controller); + + $response = $ExceptionRenderer->render(); + $helpers = $controller->viewBuilder()->getHelpers(); + sort($helpers); + $this->assertEquals([], $helpers); + $this->assertStringContainsString('Helper class `Fail`', (string)$response->getBody()); + } + + /** + * Test that exceptions in beforeRender() are handled by outputMessageSafe + */ + public function testRenderExceptionInBeforeRender(): void + { + $exception = new NotFoundException('Not there, sorry'); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $request = new ServerRequest(); + $controller = new class ($request) extends Controller { + public function beforeRender(EventInterface $event): never + { + throw new NotFoundException('Not there, sorry'); + } + }; + + $ExceptionRenderer->setController($controller); + + $response = $ExceptionRenderer->render(); + $this->assertStringContainsString('Not there, sorry', (string)$response->getBody()); + } + + /** + * Test that missing layoutPath don't cause other fatal errors. + */ + public function testMissingLayoutPathRenderSafe(): void + { + $this->called = false; + $exception = new NotFoundException(); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $controller = new Controller(new ServerRequest()); + $controller->viewBuilder()->setHelpers(['Fail', 'Boom']); + $controller->getEventManager()->on( + 'Controller.beforeRender', + function (EventInterface $event): void { + $this->called = true; + $event->getSubject()->viewBuilder()->setLayoutPath('boom'); + }, + ); + $controller->setRequest(new ServerRequest()); + $ExceptionRenderer->setController($controller); + + $response = $ExceptionRenderer->render(); + $this->assertSame('text/html', $response->getType()); + $this->assertStringContainsString('Not Found', (string)$response->getBody()); + $this->assertTrue($this->called, 'Listener added was not triggered.'); + $this->assertSame('', $controller->viewBuilder()->getLayoutPath()); + $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath()); + } + + /** + * Test that missing layout don't cause other fatal errors. + */ + public function testMissingLayoutRenderSafe(): void + { + $this->called = false; + $exception = new NotFoundException(); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + + $controller = new Controller(new ServerRequest()); + $controller->getEventManager()->on( + 'Controller.beforeRender', + function (EventInterface $event): void { + $this->called = true; + $event->getSubject()->viewBuilder()->setTemplatePath('Error'); + $event->getSubject()->viewBuilder()->setLayout('does-not-exist'); + }, + ); + $controller->setRequest(new ServerRequest()); + $ExceptionRenderer->setController($controller); + + $response = $ExceptionRenderer->render(); + $this->assertSame('text/html', $response->getType()); + $this->assertStringContainsString('Not Found', (string)$response->getBody()); + $this->assertTrue($this->called, 'Listener added was not triggered.'); + $this->assertSame('', $controller->viewBuilder()->getLayoutPath()); + $this->assertSame('Error', $controller->viewBuilder()->getTemplatePath()); + } + + /** + * Test that missing plugin disables Controller::$plugin if the two are the same plugin. + */ + public function testMissingPluginRenderSafe(): void + { + $exception = new NotFoundException(); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + $pluginException = new MissingPluginException(['plugin' => 'TestPlugin']); + + $controller = Mockery::mock(Controller::class)->makePartial(); + $controller->shouldReceive('render') + ->with('error400') + ->once() + ->andThrow($pluginException); + $controller->shouldReceive('getRequest') + ->times(1) + ->andReturn(new ServerRequest()); + $controller->shouldReceive('getResponse') + ->times(2) + ->andReturn(new Response()); + $controller->setPlugin('TestPlugin'); + $ExceptionRenderer->setController($controller); + + $response = $ExceptionRenderer->render(); + $body = (string)$response->getBody(); + $this->assertStringNotContainsString('test plugin error500', $body); + $this->assertStringContainsString('Not Found', $body); + } + + /** + * Test that missing plugin doesn't disable Controller::$plugin if the two aren't the same plugin. + */ + public function testMissingPluginRenderSafeWithPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + $exception = new NotFoundException(); + $ExceptionRenderer = new MyCustomExceptionRenderer($exception); + $innerException = new MissingPluginException(['plugin' => 'TestPluginTwo']); + + $controller = Mockery::mock(Controller::class)->makePartial(); + $controller->shouldReceive('render') + ->with('error400') + ->once() + ->andThrow($innerException); + $controller->shouldReceive('getRequest') + ->times(1) + ->andReturn(new ServerRequest()); + $controller->shouldReceive('getResponse') + ->times(2) + ->andReturn(new Response()); + $controller->setPlugin('TestPlugin'); + $ExceptionRenderer->setController($controller); + + $response = $ExceptionRenderer->render(); + $body = (string)$response->getBody(); + $this->assertStringContainsString('test plugin error500', $body); + $this->assertStringContainsString('Not Found', $body); + } + + /** + * Test that exceptions can be rendered when a request hasn't been registered + * with Router + */ + public function testRenderWithNoRequest(): void + { + Router::reload(); + $this->assertNull(Router::getRequest()); + + $exception = new Exception('Terrible'); + $ExceptionRenderer = new WebExceptionRenderer($exception); + $result = $ExceptionRenderer->render(); + + $this->assertStringContainsString('Internal Error', (string)$result->getBody()); + $this->assertSame(500, $result->getStatusCode()); + } + + /** + * Test that router request parameters are applied when the passed + * request has no params. + */ + public function testRenderInheritRoutingParams(): void + { + $routerRequest = new ServerRequest([ + 'params' => [ + 'controller' => 'Articles', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + '_ext' => 'json', + ], + ]); + // Simulate a request having routing applied and stored in router + Router::setRequest($routerRequest); + + $exceptionRenderer = new WebExceptionRenderer(new Exception('Terrible'), new ServerRequest()); + $exceptionRenderer->render(); + $properties = $exceptionRenderer->__debugInfo(); + + /** @var \Cake\Http\ServerRequest $request */ + $request = $properties['controller']->getRequest(); + foreach (['controller', 'action', '_ext'] as $key) { + $this->assertSame($routerRequest->getParam($key), $request->getParam($key)); + } + } + + /** + * Test that rendering exceptions triggers shutdown events. + */ + public function testRenderShutdownEvents(): void + { + $fired = []; + $listener = function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }; + $events = EventManager::instance(); + $events->on('Controller.shutdown', $listener); + + $exception = new Exception('Terrible'); + $renderer = new WebExceptionRenderer($exception); + $renderer->render(); + + $expected = ['Controller.shutdown']; + $this->assertEquals($expected, $fired); + } + + /** + * test that subclass methods fire shutdown events. + */ + public function testSubclassTriggerShutdownEvents(): void + { + $fired = []; + $listener = function (EventInterface $event) use (&$fired): void { + $fired[] = $event->getName(); + }; + $events = EventManager::instance(); + $events->on('Controller.shutdown', $listener); + + $exception = new MissingWidgetThingException('Widget not found'); + $renderer = new MyCustomExceptionRenderer($exception); + $renderer->render(); + + $expected = ['Controller.shutdown']; + $this->assertEquals($expected, $fired); + } + + /** + * Tests the output of rendering a PDOException + */ + public function testPDOException(): void + { + $loggedQuery = new LoggedQuery(); + $loggedQuery->setContext([ + 'query' => 'SELECT * from poo_query < 5 and :seven', + 'driver' => $this->createStub(Driver::class), + 'params' => ['seven' => 7], + ]); + $exception = new QueryException($loggedQuery, new PDOException()); + + $ExceptionRenderer = new WebExceptionRenderer($exception); + $response = $ExceptionRenderer->render(); + + $this->assertSame(500, $response->getStatusCode()); + $result = (string)$response->getBody(); + $this->assertStringContainsString('Database Error', $result); + $this->assertStringContainsString('SQL Query', $result); + $this->assertStringContainsString(h('SELECT * from poo_query < 5 and 7'), $result); + } + + /** + * Tests for customzing responses using methods of ErrorController. + * + * @return void + */ + public function testExceptionWithMatchingControllerMethod(): void + { + $exception = new MissingWidgetThingException(); + $exceptionRenderer = new TestAppsExceptionRenderer($exception); + + $result = (string)$exceptionRenderer->render()->getBody(); + $this->assertStringContainsString('template for TestApp\Error\Exception\MissingWidgetThingException was rendered', $result); + + $exception = new XmlException(); + $exceptionRenderer = new TestAppsExceptionRenderer($exception); + + $result = (string)$exceptionRenderer->render()->getBody(); + $this->assertStringContainsString('<xml>rendered xml exception</xml>', $result); + } + + public function testDeprecatedHttpErrorCodeMapping(): void + { + $this->deprecated(function (): void { + $exception = new MissingWidgetThing(); + $exceptionRenderer = new MyCustomExceptionRenderer($exception); + + $reflectedMethod = new ReflectionMethod($exceptionRenderer, 'getHttpCode'); + + $this->assertSame(404, $reflectedMethod->invoke($exceptionRenderer, $exception)); + }); + } +} diff --git a/tests/TestCase/Event/Decorator/ConditionDecoratorTest.php b/tests/TestCase/Event/Decorator/ConditionDecoratorTest.php new file mode 100644 index 00000000000..5c777bbfc5f --- /dev/null +++ b/tests/TestCase/Event/Decorator/ConditionDecoratorTest.php @@ -0,0 +1,110 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 3.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Event\Decorator; + +use Cake\Event\Decorator\ConditionDecorator; +use Cake\Event\Event; +use Cake\Event\EventInterface; +use Cake\Event\EventManager; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; + +/** + * Tests the Cake\Event\Event class functionality + */ +class ConditionDecoratorTest extends TestCase +{ + /** + * testCanTriggerIf + */ + public function testCanTriggerIf(): void + { + $callable = function (EventInterface $event) { + return 'success'; + }; + + $decorator = new ConditionDecorator($callable, [ + 'if' => function (EventInterface $event) { + return $event->getData('canTrigger'); + }, + ]); + + $event = new Event('decorator.test', $this); + $this->assertFalse($decorator->canTrigger($event)); + + $result = $decorator($event); + $this->assertNull($result); + + $event = new Event('decorator.test', $this, ['canTrigger' => true]); + $this->assertTrue($decorator->canTrigger($event)); + + $result = $decorator($event); + $this->assertSame('success', $result); + } + + /** + * testCascadingEvents + */ + public function testCascadingEvents(): void + { + $callable = function (EventInterface $event) { + $event->setData('counter', $event->getData('counter') + 1); + + return $event; + }; + + $listener1 = new ConditionDecorator($callable, [ + 'if' => function (EventInterface $event) { + return false; + }, + ]); + + $listener2 = function (EventInterface $event): void { + $event->setData('counter', $event->getData('counter') + 1); + $event->setResult(false); + }; + + EventManager::instance()->on('decorator.test2', $listener1); + EventManager::instance()->on('decorator.test2', $listener2); + + $event = new Event('decorator.test2', $this, [ + 'counter' => 1, + ]); + + EventManager::instance()->dispatch($event); + $this->assertSame(2, $event->getData('counter')); + } + + /** + * testCallableRuntimeException + */ + public function testCallableRuntimeException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cake\Event\Decorator\ConditionDecorator the `if` condition is not a callable!'); + $callable = function (EventInterface $event) { + return 'success'; + }; + + $decorator = new ConditionDecorator($callable, [ + 'if' => 'not a callable', + ]); + + $event = new Event('decorator.test', $this, []); + $decorator($event); + } +} diff --git a/tests/TestCase/Event/Decorator/SubjectFilterDecoratorTest.php b/tests/TestCase/Event/Decorator/SubjectFilterDecoratorTest.php new file mode 100644 index 00000000000..f20a3fd0ef4 --- /dev/null +++ b/tests/TestCase/Event/Decorator/SubjectFilterDecoratorTest.php @@ -0,0 +1,53 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 3.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Event\Decorator; + +use Cake\Event\Decorator\SubjectFilterDecorator; +use Cake\Event\Event; +use Cake\Event\EventInterface; +use Cake\TestSuite\TestCase; + +/** + * Tests the Cake\Event\Event class functionality + */ +class SubjectFilterDecoratorTest extends TestCase +{ + /** + * testCanTrigger + */ + public function testCanTrigger(): void + { + $event = new Event('decorator.test', $this); + $callable = function (EventInterface $event) { + return 'success'; + }; + + $decorator = new SubjectFilterDecorator($callable, [ + 'allowedSubject' => self::class, + ]); + + $this->assertTrue($decorator->canTrigger($event)); + $this->assertSame('success', $decorator($event)); + + $decorator = new SubjectFilterDecorator($callable, [ + 'allowedSubject' => '\Some\Other\Class', + ]); + + $this->assertFalse($decorator->canTrigger($event)); + $this->assertSame(null, $decorator($event)); + } +} diff --git a/tests/TestCase/Event/EventDispatcherTraitTest.php b/tests/TestCase/Event/EventDispatcherTraitTest.php new file mode 100644 index 00000000000..9a5c4695b2f --- /dev/null +++ b/tests/TestCase/Event/EventDispatcherTraitTest.php @@ -0,0 +1,65 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Event; + +use Cake\Event\Event; +use Cake\Event\EventDispatcherTrait; +use Cake\Event\EventManager; +use Cake\TestSuite\TestCase; + +/** + * EventDispatcherTrait test case + */ +class EventDispatcherTraitTest extends TestCase +{ + /** + * @var \Cake\Event\EventDispatcherTrait + */ + protected $subject; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + + $this->subject = new class { + use EventDispatcherTrait; + }; + } + + /** + * testGetEventManager + */ + public function testGetEventManager(): void + { + $this->assertInstanceOf(EventManager::class, $this->subject->getEventManager()); + } + + /** + * testDispatchEvent + */ + public function testDispatchEvent(): void + { + $event = $this->subject->dispatchEvent('some.event', ['foo' => 'bar']); + + $this->assertInstanceOf(Event::class, $event); + $this->assertSame($this->subject, $event->getSubject()); + $this->assertSame('some.event', $event->getName()); + $this->assertEquals(['foo' => 'bar'], $event->getData()); + } +} diff --git a/tests/TestCase/Event/EventListTest.php b/tests/TestCase/Event/EventListTest.php new file mode 100644 index 00000000000..e22b6788e50 --- /dev/null +++ b/tests/TestCase/Event/EventListTest.php @@ -0,0 +1,83 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 3.3.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Event; + +use Cake\Event\Event; +use Cake\Event\EventList; +use Cake\TestSuite\TestCase; + +/** + * Tests the Cake\Event\EvenList class functionality + */ +class EventListTest extends TestCase +{ + /** + * testAddEventAndFlush + */ + public function testAddEventAndFlush(): void + { + $eventList = new EventList(); + $event = new Event('my_event', $this); + $event2 = new Event('my_second_event', $this); + + $eventList->add($event); + $eventList->add($event2); + $this->assertCount(2, $eventList); + + $events = iterator_to_array($eventList); + $this->assertEquals($events[0], $event); + $this->assertEquals($events[1], $event2); + + $eventList->flush(); + + $this->assertCount(0, $eventList); + } + + /** + * Testing implemented \ArrayAccess and \Count methods + * + * @deprecated + */ + public function testArrayAccess(): void + { + $this->deprecated(function (): void { + $eventList = new EventList(); + $event = new Event('my_event', $this); + $event2 = new Event('my_second_event', $this); + + $eventList->add($event); + $eventList->add($event2); + $this->assertCount(2, $eventList); + + $this->assertTrue($eventList->hasEvent('my_event')); + $this->assertFalse($eventList->hasEvent('does-not-exist')); + + $this->assertEquals($eventList->offsetGet(0), $event); + $this->assertEquals($eventList->offsetGet(1), $event2); + $this->assertTrue($eventList->offsetExists(0)); + $this->assertTrue($eventList->offsetExists(1)); + $this->assertFalse($eventList->offsetExists(2)); + + $eventList->offsetUnset(1); + $this->assertCount(1, $eventList); + + $eventList->flush(); + + $this->assertCount(0, $eventList); + }); + } +} diff --git a/tests/TestCase/Event/EventManagerTest.php b/tests/TestCase/Event/EventManagerTest.php new file mode 100644 index 00000000000..95f9a251a5b --- /dev/null +++ b/tests/TestCase/Event/EventManagerTest.php @@ -0,0 +1,932 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 2.1.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Event; + +use Cake\Event\Event; +use Cake\Event\EventInterface; +use Cake\Event\EventList; +use Cake\Event\EventListenerInterface; +use Cake\Event\EventManager; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use Mockery; +use TestApp\TestCase\Event\CustomTestEventListenerInterface; +use TestApp\TestCase\Event\EventTestListener; + +/** + * Tests the Cake\Event\EventManager class functionality + */ +class EventManagerTest extends TestCase +{ + /** + * Test attach() with a listener interface. + */ + public function testAttachListener(): void + { + $manager = new EventManager(); + $listener = new CustomTestEventListenerInterface(); + $manager->on($listener); + $expected = [ + ['callable' => $listener->listenerFunction(...)], + ]; + $this->assertEquals($expected, $manager->listeners('fake.event')); + + $expected = [ + ['callable' => $listener->thirdListenerFunction(...)], + ]; + $this->assertEquals($expected, $manager->listeners('closure.event')); + } + + /** + * Tests attached event listeners for matching key pattern + */ + public function testMatchingListeners(): void + { + $manager = new EventManager(); + $manager->on('fake.event', 'strlen'); + $manager->on('real.event', 'strlen'); + $manager->on('test.event', 'strlen'); + $manager->on('event.test', 'strlen'); + + $this->assertArrayHasKey('fake.event', $manager->matchingListeners('fake.event')); + $this->assertArrayHasKey('real.event', $manager->matchingListeners('real.event')); + $this->assertArrayHasKey('test.event', $manager->matchingListeners('test.event')); + $this->assertArrayHasKey('event.test', $manager->matchingListeners('event.test')); + + $this->assertArrayHasKey('fake.event', $manager->matchingListeners('fake')); + $this->assertArrayHasKey('real.event', $manager->matchingListeners('real')); + $this->assertArrayHasKey('test.event', $manager->matchingListeners('test')); + $this->assertArrayHasKey('event.test', $manager->matchingListeners('test')); + $this->assertArrayHasKey('fake.event', $manager->matchingListeners('event')); + $this->assertArrayHasKey('real.event', $manager->matchingListeners('event')); + $this->assertArrayHasKey('test.event', $manager->matchingListeners('event')); + $this->assertArrayHasKey('event.test', $manager->matchingListeners('event')); + $this->assertArrayHasKey('fake.event', $manager->matchingListeners('.event')); + $this->assertArrayHasKey('real.event', $manager->matchingListeners('.event')); + $this->assertArrayHasKey('test.event', $manager->matchingListeners('.event')); + $this->assertArrayHasKey('test.event', $manager->matchingListeners('test.')); + $this->assertArrayHasKey('event.test', $manager->matchingListeners('.test')); + + $this->assertEmpty($manager->matchingListeners('/test')); + $this->assertEmpty($manager->matchingListeners('test/')); + $this->assertEmpty($manager->matchingListeners('/test/')); + $this->assertEmpty($manager->matchingListeners('test$')); + $this->assertEmpty($manager->matchingListeners('ev.nt')); + $this->assertEmpty($manager->matchingListeners('^test')); + $this->assertEmpty($manager->matchingListeners('^event')); + $this->assertEmpty($manager->matchingListeners('*event')); + $this->assertEmpty($manager->matchingListeners('event*')); + $this->assertEmpty($manager->matchingListeners('foo')); + + $expected = ['fake.event', 'real.event', 'test.event', 'event.test']; + $result = $manager->matchingListeners('event'); + $this->assertNotEmpty($result); + $this->assertSame($expected, array_keys($result)); + + $expected = ['fake.event', 'real.event', 'test.event']; + $result = $manager->matchingListeners('.event'); + $this->assertNotEmpty($result); + $this->assertSame($expected, array_keys($result)); + + $expected = ['test.event', 'event.test']; + $result = $manager->matchingListeners('test'); + $this->assertNotEmpty($result); + $this->assertSame($expected, array_keys($result)); + + $expected = ['test.event']; + $result = $manager->matchingListeners('test.'); + $this->assertNotEmpty($result); + $this->assertSame($expected, array_keys($result)); + + $expected = ['event.test']; + $result = $manager->matchingListeners('.test'); + $this->assertNotEmpty($result); + $this->assertSame($expected, array_keys($result)); + } + + /** + * Test the on() method for basic callable types. + */ + public function testOn(): void + { + $manager = new EventManager(); + $manager->on('my.event', 'substr'); + $expected = [ + ['callable' => substr(...)], + ]; + $this->assertEquals($expected, $manager->listeners('my.event')); + + $manager->on('my.event', ['priority' => 1], 'strpos'); + $expected = [ + ['callable' => strpos(...)], + ['callable' => substr(...)], + ]; + $this->assertEquals($expected, $manager->listeners('my.event')); + + $listener = new CustomTestEventListenerInterface(); + $manager->on($listener); + $expected = [ + ['callable' => $listener->listenerFunction(...)], + ]; + $this->assertEquals($expected, $manager->listeners('fake.event')); + } + + public function testOnInvalidCall(): void + { + $manager = new EventManager(); + + $this->expectException(InvalidArgumentException::class); + $manager->on('my.event'); + } + + /** + * Tests off'ing an event from a event key queue + */ + public function testOff(): void + { + $manager = new EventManager(); + $manager->on('fake.event', 'strlen'); + $manager->on('another.event', strlen(...)); + $manager->on('another.event', ['priority' => 1], 'substr'); + + $manager->off('fake.event', strlen(...)); + $this->assertEquals([], $manager->listeners('fake.event')); + + $manager->off('another.event', 'strlen'); + $expected = [ + ['callable' => substr(...)], + ]; + $this->assertEquals($expected, $manager->listeners('another.event')); + + $manager->off('another.event', 'substr'); + $this->assertEquals([], $manager->listeners('another.event')); + } + + /** + * Tests off'ing an event from all event queues + */ + public function testOffFromAll(): void + { + $manager = new EventManager(); + $callable = function (): void { + }; + $manager->on('fake.event', $callable); + $manager->on('another.event', $callable); + $manager->on('another.event', ['priority' => 1], 'substr'); + + $manager->off($callable); + $expected = [ + ['callable' => substr(...)], + ]; + $this->assertEquals($expected, $manager->listeners('another.event')); + $this->assertEquals([], $manager->listeners('fake.event')); + } + + /** + * Tests off'ing all listeners for an event + */ + public function testRemoveAllListeners(): void + { + $manager = new EventManager(); + $manager->on('fake.event', 'strlen'); + + $manager->on('another.event', ['priority' => 1], 'substr'); + + $manager->off('fake.event'); + + $expected = [ + ['callable' => substr(...)], + ]; + $this->assertEquals($expected, $manager->listeners('another.event')); + $this->assertEquals([], $manager->listeners('fake.event')); + } + + /** + * Tests event dispatching + * + * @triggers fake.event + */ + public function testDispatch(): void + { + $manager = Mockery::mock(EventManager::class)->makePartial(); + $listener = new class implements EventListenerInterface { + public array $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + } + + public function implementedEvents(): array + { + return []; + } + }; + $anotherListener = new class implements EventListenerInterface { + public array $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + } + + public function implementedEvents(): array + { + return []; + } + }; + $manager->on('fake.event', $listener->listenerFunction(...)); + $manager->on('fake.event', $anotherListener->listenerFunction(...)); + $event = new Event('fake.event'); + + $manager->dispatch($event); + $this->assertEquals(['listenerFunction'], $listener->callList); + $this->assertEquals(['listenerFunction'], $anotherListener->callList); + } + + /** + * Tests event dispatching using event key name + */ + public function testDispatchWithKeyName(): void + { + $manager = new EventManager(); + $listener = new EventTestListener(); + $manager->on('fake.event', $listener->listenerFunction(...)); + $event = 'fake.event'; + $manager->dispatch($event); + + $expected = ['listenerFunction']; + $this->assertEquals($expected, $listener->callList); + } + + /** + * Tests event dispatching with a return value + * + * @triggers fake.event + */ + public function testDispatchReturnValue(): void + { + $manager = new EventManager(); + $listener = new class implements EventListenerInterface { + public array $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + + $event->setResult('something special'); + } + + public function implementedEvents(): array + { + return []; + } + }; + $anotherListener = new class implements EventListenerInterface { + public array $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + } + + public function implementedEvents(): array + { + return []; + } + }; + $manager->on('fake.event', $listener->listenerFunction(...)); + $manager->on('fake.event', $anotherListener->listenerFunction(...)); + $event = new Event('fake.event'); + + $manager->dispatch($event); + $this->assertSame('something special', $event->getResult()); + $this->assertEquals(['listenerFunction'], $listener->callList); + $this->assertEquals(['listenerFunction'], $anotherListener->callList); + } + + /** + * Tests that returning false in a callback stops the event + * + * @triggers fake.event + */ + public function testDispatchFalseStopsEvent(): void + { + $manager = new EventManager(); + $listener = new class implements EventListenerInterface { + public array $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + + $event->setResult(false); + } + + public function implementedEvents(): array + { + return []; + } + }; + $anotherListener = new class implements EventListenerInterface { + public array $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + } + + public function implementedEvents(): array + { + return []; + } + }; + $manager->on('fake.event', $listener->listenerFunction(...)); + $manager->on('fake.event', $anotherListener->listenerFunction(...)); + $event = new Event('fake.event'); + + $manager->dispatch($event); + $this->assertTrue($event->isStopped()); + $this->assertEquals(['listenerFunction'], $listener->callList); + $this->assertEquals([], $anotherListener->callList); + } + + /** + * Tests event dispatching using priorities + * + * @triggers fake.event + */ + public function testDispatchPrioritized(): void + { + $manager = new EventManager(); + $listener = new EventTestListener(); + $manager->on('fake.event', $listener->listenerFunction(...)); + $manager->on('fake.event', ['priority' => 5], $listener->secondListenerFunction(...)); + $event = new Event('fake.event'); + $manager->dispatch($event); + + $expected = ['secondListenerFunction', 'listenerFunction']; + $this->assertEquals($expected, $listener->callList); + } + + /** + * Tests subscribing a listener object and firing the events it subscribed to + * + * @triggers fake.event + * @triggers another.event $this, array(some => data) + */ + public function testOnSubscriber(): void + { + $manager = new EventManager(); + $listener = new CustomTestEventListenerInterface(); + $manager->on($listener); + + $event = new Event('fake.event'); + $manager->dispatch($event); + $this->assertEquals(['listenerFunction'], $listener->callList); + + $event = new Event('another.event', $this, ['some' => 'data']); + $manager->dispatch($event); + $this->assertEquals(['listenerFunction','secondListenerFunction'], $listener->callList); + } + + /** + * Test implementedEvents binding multiple callbacks to the same event name. + * + * @triggers multiple.handlers + */ + public function testOnSubscriberMultiple(): void + { + $manager = new EventManager(); + $listener = new class extends CustomTestEventListenerInterface { + public $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + } + + public function secondListenerFunction(EventInterface $event): void + { + $this->callList[] = 'secondListenerFunction'; + } + }; + $manager->on($listener); + $event = new Event('multiple.handlers'); + $manager->dispatch($event); + $this->assertEquals(['listenerFunction','secondListenerFunction'], $listener->callList); + } + + /** + * Tests subscribing an invokable listener class + */ + public function testOnListenerClass(): void + { + $manager = new EventManager(); + $listener = new class implements EventListenerInterface { + public array $callList = []; + + final public function __invoke(EventInterface $event): void + { + $this->callList[] = __FUNCTION__; + } + + public function implementedEvents(): array + { + return [ + 'some.event' => $this, + ]; + } + }; + $manager->on($listener); + + $event = new Event('some.event'); + $manager->dispatch($event); + + $this->assertEquals(['__invoke'], $listener->callList); + } + + /** + * Tests subscribing a listener object and firing the events it subscribed to + */ + public function testDetachSubscriber(): void + { + $manager = new EventManager(); + $listener = new class extends CustomTestEventListenerInterface { + }; + $manager->on($listener); + $expected = [ + ['callable' => $listener->secondListenerFunction(...)], + ]; + $this->assertEquals($expected, $manager->listeners('another.event')); + $expected = [ + ['callable' => $listener->listenerFunction(...)], + ]; + $this->assertEquals($expected, $manager->listeners('fake.event')); + $manager->off($listener); + $this->assertEquals([], $manager->listeners('fake.event')); + $this->assertEquals([], $manager->listeners('another.event')); + } + + /** + * Tests that it is possible to get/set the manager singleton + */ + public function testGlobalDispatcherGetter(): void + { + $this->assertInstanceOf(EventManager::class, EventManager::instance()); + $manager = new EventManager(); + + EventManager::instance($manager); + $this->assertSame($manager, EventManager::instance()); + } + + /** + * Tests that the global event manager gets the event too from any other manager + * + * @triggers fake.event + */ + public function testDispatchWithGlobal(): void + { + $eventListener = new class implements EventListenerInterface { + public array $callList = []; + + public function listenerFunction(EventInterface $event): void + { + $this->callList[] = 'listenerFunction'; + } + + public function implementedEvents(): array + { + return [ + 'fake.event' => 'listenerFunction', + ]; + } + }; + + $generalManager = (new EventManager())->trackEvents(true)->setEventList(new EventList()); + $manager = new EventManager(); + $event = new Event('fake.event'); + EventManager::instance($generalManager); + $manager->on($eventListener); + $manager->dispatch($event); + + $this->assertEquals(['listenerFunction'], $eventListener->callList); + $this->assertTrue($generalManager->getEventList()->hasEvent('fake.event')); + } + + /** + * Tests that stopping an event will not notify the rest of the listeners + * + * @triggers fake.event + */ + public function testStopPropagation(): void + { + $generalManager = new class extends EventManager + { + public function prioritisedListeners(string $name): array + { + return []; + } + }; + $manager = new EventManager(); + $listener = new EventTestListener(); + + EventManager::instance($generalManager); + $manager->on('fake.event', $listener->listenerFunction(...)); + $manager->on('fake.event', ['priority' => 8], $listener->stopListener(...)); + $manager->on('fake.event', ['priority' => 5], $listener->secondListenerFunction(...)); + $event = new Event('fake.event'); + $manager->dispatch($event); + + $expected = ['secondListenerFunction']; + $this->assertEquals($expected, $listener->callList); + EventManager::instance(new EventManager()); + } + + /** + * Tests event dispatching using priorities + * + * @triggers fake.event + */ + public function testDispatchPrioritizedWithGlobal(): void + { + $listener = new CustomTestEventListenerInterface(); + $generalManager = new class ($listener) extends EventManager + { + public function __construct(public CustomTestEventListenerInterface $listener) + { + } + + public function prioritisedListeners(string $name): array + { + return [11 => [ + ['callable' => $this->listener->secondListenerFunction(...)], + ]]; + } + }; + $manager = new EventManager(); + $event = new Event('fake.event'); + + EventManager::instance($generalManager); + $manager->on('fake.event', $listener->listenerFunction(...)); + $manager->on('fake.event', ['priority' => 15], $listener->thirdListenerFunction(...)); + + $manager->dispatch($event); + + $expected = ['listenerFunction', 'secondListenerFunction', 'thirdListenerFunction']; + $this->assertEquals($expected, $listener->callList); + } + + /** + * Tests event dispatching using priorities + * + * @triggers fake.event + */ + public function testDispatchGlobalBeforeLocal(): void + { + $listener = new CustomTestEventListenerInterface(); + $generalManager = new class ($listener) extends EventManager + { + public function __construct(public CustomTestEventListenerInterface $listener) + { + } + + public function prioritisedListeners(string $name): array + { + return [10 => [ + ['callable' => $this->listener->listenerFunction(...)], + ]]; + } + }; + $manager = new EventManager(); + $event = new Event('fake.event'); + + EventManager::instance($generalManager); + $manager->on('fake.event', $listener->secondListenerFunction(...)); + + $manager->dispatch($event); + $expected = ['listenerFunction', 'secondListenerFunction']; + $this->assertEquals($expected, $listener->callList); + } + + /** + * test callback + */ + public function onMyEvent(EventInterface $event): void + { + $event->setData('callback', 'ok'); + } + + /** + * Tests events dispatched by a local manager can be handled by + * handler registered in the global event manager + * + * @triggers my_event $manager + */ + public function testDispatchLocalHandledByGlobal(): void + { + $callback = $this->onMyEvent(...); + EventManager::instance()->on('my_event', $callback); + $manager = new EventManager(); + $event = new Event('my_event', $manager); + $manager->dispatch($event); + $this->assertSame('ok', $event->getData('callback')); + } + + /** + * Test that events are dispatched properly when there are global and local + * listeners at the same priority. + * + * @triggers fake.event $this + */ + public function testDispatchWithGlobalAndLocalEvents(): void + { + $listener = new CustomTestEventListenerInterface(); + EventManager::instance()->on($listener); + $listener2 = new EventTestListener(); + $manager = new EventManager(); + $manager->on('fake.event', $listener2->listenerFunction(...)); + + $manager->dispatch(new Event('fake.event', $this)); + $this->assertEquals(['listenerFunction'], $listener->callList); + $this->assertEquals(['listenerFunction'], $listener2->callList); + } + + /** + * Test getting a list of dispatched events from the manager. + * + * @triggers my_event $this + * @triggers my_second_event $this + */ + public function testGetDispatchedEvents(): void + { + $eventList = new EventList(); + $event = new Event('my_event', $this); + $event2 = new Event('my_second_event', $this); + + $manager = new EventManager(); + $manager->setEventList($eventList); + $manager->dispatch($event); + $manager->dispatch($event2); + + $result = $manager->getEventList(); + $this->assertInstanceOf(EventList::class, $result); + $result = iterator_to_array($result); + $this->assertEquals($result[0], $event); + $this->assertEquals($result[1], $event2); + + $manager->getEventList()->flush(); + $result = $manager->getEventList(); + $this->assertCount(0, $result); + + $manager->unsetEventList(); + $manager->dispatch($event); + $manager->dispatch($event2); + + $result = $manager->getEventList(); + $this->assertNull($result); + } + + /** + * Test that locally dispatched events are also added to the global manager's event list + * + * @triggers Event $this + */ + public function testGetLocallyDispatchedEventsFromGlobal(): void + { + $localList = new EventList(); + $globalList = new EventList(); + + $globalManager = EventManager::instance(); + $globalManager->setEventList($globalList); + + $localManager = new EventManager(); + $localManager->setEventList($localList); + + $globalEvent = new Event('GlobalEvent', $this); + $globalManager->dispatch($globalEvent); + + $localEvent = new Event('LocalEvent', $this); + $localManager->dispatch($localEvent); + + $this->assertTrue($globalList->hasEvent('GlobalEvent')); + $this->assertFalse($localList->hasEvent('GlobalEvent')); + $this->assertTrue($localList->hasEvent('LocalEvent')); + $this->assertTrue($globalList->hasEvent('LocalEvent')); + } + + /** + * Test isTrackingEvents + */ + public function testIsTrackingEvents(): void + { + $this->assertFalse(EventManager::instance()->isTrackingEvents()); + + $manager = new EventManager(); + $manager->setEventList(new EventList()); + + $this->assertTrue($manager->isTrackingEvents()); + + $manager->trackEvents(false); + + $this->assertFalse($manager->isTrackingEvents()); + } + + public function testDebugInfo(): void + { + $eventManager = new EventManager(); + + $this->assertSame( + [ + '_listeners' => [], + '_isGlobal' => false, + '_trackEvents' => false, + '_generalManager' => '(object) EventManager', + '_dispatchedEvents' => null, + ], + $eventManager->__debugInfo(), + ); + + $eventManager->setEventList(new EventList()); + $eventManager->addEventToList(new Event('Foo', $this)); + $this->assertSame( + [ + '_listeners' => [], + '_isGlobal' => false, + '_trackEvents' => true, + '_generalManager' => '(object) EventManager', + '_dispatchedEvents' => [ + 'Foo with subject Cake\Test\TestCase\Event\EventManagerTest', + ], + ], + $eventManager->__debugInfo(), + ); + + $eventManager->unsetEventList(); + + $func = function (): void { + }; + $eventManager->on('foo', $func); + + $this->assertSame( + [ + '_listeners' => [ + 'foo' => '1 listener(s)', + ], + '_isGlobal' => false, + '_trackEvents' => false, + '_generalManager' => '(object) EventManager', + '_dispatchedEvents' => null, + ], + $eventManager->__debugInfo(), + ); + + $eventManager->off('foo', $func); + + $this->assertSame( + [ + '_listeners' => [ + 'foo' => '0 listener(s)', + ], + '_isGlobal' => false, + '_trackEvents' => false, + '_generalManager' => '(object) EventManager', + '_dispatchedEvents' => null, + ], + $eventManager->__debugInfo(), + ); + + $eventManager->on('bar', function (): void { + }); + $eventManager->on('bar', function (): void { + }); + $eventManager->on('bar', function (): void { + }); + $eventManager->on('baz', function (): void { + }); + + $this->assertSame( + [ + '_listeners' => [ + 'foo' => '0 listener(s)', + 'bar' => '3 listener(s)', + 'baz' => '1 listener(s)', + ], + '_isGlobal' => false, + '_trackEvents' => false, + '_generalManager' => '(object) EventManager', + '_dispatchedEvents' => null, + ], + $eventManager->__debugInfo(), + ); + } + + /** + * test debugInfo with an event list. + */ + public function testDebugInfoEventList(): void + { + $eventList = new EventList(); + $eventManager = new EventManager(); + $eventManager->setEventList($eventList); + $eventManager->on('example', function (): void { + }); + $eventManager->dispatch('example'); + + $this->assertSame( + [ + '_listeners' => [ + 'example' => '1 listener(s)', + ], + '_isGlobal' => false, + '_trackEvents' => true, + '_generalManager' => '(object) EventManager', + '_dispatchedEvents' => [ + 'example with no subject', + ], + ], + $eventManager->__debugInfo(), + ); + } + + /** + * Test that chainable methods return correct values. + */ + public function testChainableMethods(): void + { + $eventManager = new EventManager(); + + $listener = new class implements EventListenerInterface { + public function implementedEvents(): array + { + return []; + } + }; + $callable = function (): void { + }; + + $returnValue = $eventManager->on($listener); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->on('foo', $callable); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->on('foo', [], $callable); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->off($listener); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->off('foo', $listener); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->off('foo', $callable); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->off('foo'); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->setEventList(new EventList()); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->addEventToList(new Event('foo')); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->trackEvents(true); + $this->assertSame($eventManager, $returnValue); + + $returnValue = $eventManager->unsetEventList(); + $this->assertSame($eventManager, $returnValue); + } + + /** + * Test that an event without a subject can be dispatched and + * displays a deprecation warning if the listener returns a value. + */ + public function testEventWithoutSubjectWorksWithReturningListener(): void + { + $eventManager = new EventManager(); + $eventManager->on('example', function (): string { + return 'example event called'; + }); + $result = ''; + $this->deprecated(function () use (&$eventManager, &$result): void { + $result = $eventManager->dispatch(new Event('example')); + }); + $this->assertEquals('example event called', $result->getResult()); + } +} diff --git a/tests/TestCase/Event/EventTest.php b/tests/TestCase/Event/EventTest.php new file mode 100644 index 00000000000..166eb247697 --- /dev/null +++ b/tests/TestCase/Event/EventTest.php @@ -0,0 +1,99 @@ +<?php +declare(strict_types=1); + +/** + * EventTest file + * + * Test Case for Event class + * + * CakePHP : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP Project + * @since 2.1.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Event; + +use Cake\Core\Exception\CakeException; +use Cake\Event\Event; +use Cake\TestSuite\TestCase; + +/** + * Tests the Cake\Event\Event class functionality + */ +class EventTest extends TestCase +{ + /** + * Tests the name() method + * + * @triggers fake.event + */ + public function testName(): void + { + $event = new Event('fake.event'); + $this->assertSame('fake.event', $event->getName()); + } + + /** + * Tests the subject() method + * + * @triggers fake.event $this + * @triggers fake.event + */ + public function testSubject(): void + { + $event = new Event('fake.event', $this); + $this->assertSame($this, $event->getSubject()); + + $this->expectException(CakeException::class); + $this->expectExceptionMessage('No subject set for this event'); + + $event = new Event('fake.event'); + $this->assertNull($event->getSubject()); + } + + /** + * Tests the event propagation stopping property + * + * @triggers fake.event + */ + public function testPropagation(): void + { + $event = new Event('fake.event'); + $this->assertFalse($event->isStopped()); + $event->stopPropagation(); + $this->assertTrue($event->isStopped()); + } + + /** + * Tests that it is possible to get/set custom data in a event + * + * @triggers fake.event $this, array('some' => 'data') + */ + public function testEventData(): void + { + $event = new Event('fake.event', $this, ['some' => 'data']); + $this->assertEquals(['some' => 'data'], $event->getData()); + + $this->assertSame('data', $event->getData('some')); + $this->assertNull($event->getData('undef')); + } + + /** + * Tests that it is possible to get the name and subject directly + * + * @triggers fake.event $this + */ + public function testEventDirectPropertyAccess(): void + { + $event = new Event('fake.event', $this); + $this->assertEquals($this, $event->getSubject()); + $this->assertSame('fake.event', $event->getName()); + } +} diff --git a/tests/TestCase/ExceptionsTest.php b/tests/TestCase/ExceptionsTest.php new file mode 100644 index 00000000000..33dbb0d82e8 --- /dev/null +++ b/tests/TestCase/ExceptionsTest.php @@ -0,0 +1,232 @@ +<?php +declare(strict_types=1); + +/** + * ExceptionsTest file + * + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.5.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase; + +use Cake\Console\Exception\ConsoleException; +use Cake\Console\Exception\MissingHelperException; +use Cake\Console\Exception\StopException; +use Cake\Controller\Exception\FormProtectionException; +use Cake\Controller\Exception\MissingActionException; +use Cake\Controller\Exception\MissingComponentException; +use Cake\Core\Exception\CakeException; +use Cake\Core\Exception\MissingPluginException; +use Cake\Database\Exception\DatabaseException; +use Cake\Database\Exception\MissingConnectionException; +use Cake\Database\Exception\MissingDriverException; +use Cake\Database\Exception\MissingExtensionException; +use Cake\Database\Exception\NestedTransactionRollbackException; +use Cake\Datasource\Exception\InvalidPrimaryKeyException; +use Cake\Datasource\Exception\MissingDatasourceConfigException; +use Cake\Datasource\Exception\MissingDatasourceException; +use Cake\Datasource\Exception\MissingModelException; +use Cake\Datasource\Exception\RecordNotFoundException; +use Cake\Datasource\Paging\Exception\PageOutOfBoundsException; +use Cake\Error\FatalErrorException; +use Cake\Http\Exception\BadRequestException; +use Cake\Http\Exception\ConflictException; +use Cake\Http\Exception\ForbiddenException; +use Cake\Http\Exception\GoneException; +use Cake\Http\Exception\HttpException; +use Cake\Http\Exception\InternalErrorException; +use Cake\Http\Exception\InvalidCsrfTokenException; +use Cake\Http\Exception\MethodNotAllowedException; +use Cake\Http\Exception\MissingControllerException; +use Cake\Http\Exception\NotAcceptableException; +use Cake\Http\Exception\NotFoundException; +use Cake\Http\Exception\NotImplementedException; +use Cake\Http\Exception\ServiceUnavailableException; +use Cake\Http\Exception\UnauthorizedException; +use Cake\Http\Exception\UnavailableForLegalReasonsException; +use Cake\Http\Exception\UnprocessableContentException; +use Cake\Mailer\Exception\MissingActionException as MailerMissingActionException; +use Cake\Mailer\Exception\MissingMailerException; +use Cake\Network\Exception\SocketException; +use Cake\ORM\Entity; +use Cake\ORM\Exception\MissingBehaviorException; +use Cake\ORM\Exception\MissingEntityException; +use Cake\ORM\Exception\MissingTableClassException; +use Cake\ORM\Exception\PersistenceFailedException; +use Cake\ORM\Exception\RolledbackTransactionException; +use Cake\Routing\Exception\DuplicateNamedRouteException; +use Cake\Routing\Exception\MissingRouteException; +use Cake\TestSuite\TestCase; +use Cake\Utility\Exception\XmlException; +use Cake\View\Exception\MissingCellException; +use Cake\View\Exception\MissingCellTemplateException; +use Cake\View\Exception\MissingElementException; +use Cake\View\Exception\MissingHelperException as ViewMissingHelperException; +use Cake\View\Exception\MissingLayoutException; +use Cake\View\Exception\MissingTemplateException; +use Cake\View\Exception\MissingViewException; +use Exception; +use PHPUnit\Framework\Attributes\DataProvider; + +class ExceptionsTest extends TestCase +{ + /** + * Tests simple exceptions work. + * + * @param string $class The exception class name + * @param int $defaultCode The default exception code + */ + #[DataProvider('exceptionProvider')] + public function testSimpleException($class, $defaultCode): void + { + $previous = new Exception(); + + /** @var \Exception $exception */ + $exception = new $class('message', 100, $previous); + $this->assertSame('message', $exception->getMessage()); + $this->assertSame(100, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + + $exception = new $class('message', null, $previous); + $this->assertSame('message', $exception->getMessage()); + $this->assertSame($defaultCode, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } + + /** + * Tests FatalErrorException works. + */ + public function testFatalErrorException(): void + { + $previous = new Exception(); + + $exception = new FatalErrorException('message', 100, __FILE__, 1, $previous); + $this->assertSame('message', $exception->getMessage()); + $this->assertSame(100, $exception->getCode()); + $this->assertSame(__FILE__, $exception->getFile()); + $this->assertSame(1, $exception->getLine()); + $this->assertSame($previous, $exception->getPrevious()); + } + + /** + * Tests PersistenceFailedException works. + */ + public function testPersistenceFailedException(): void + { + $previous = new Exception(); + $entity = new Entity(); + + $exception = new PersistenceFailedException($entity, 'message', 100, $previous); + $this->assertSame('message', $exception->getMessage()); + $this->assertSame(100, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame($entity, $exception->getEntity()); + } + + /** + * Test the template exceptions + */ + public function testMissingTemplateExceptions(): void + { + $previous = new Exception(); + + $error = new MissingTemplateException('view.ctp', ['path/a/', 'path/b/'], 100, $previous); + $this->assertStringContainsString('Template file `view.ctp` could not be found', $error->getMessage()); + $this->assertStringContainsString('- `path/a/view.ctp`', $error->getMessage()); + $this->assertSame($previous, $error->getPrevious()); + $this->assertSame(100, $error->getCode()); + $attributes = $error->getAttributes(); + $this->assertArrayHasKey('file', $attributes); + $this->assertArrayHasKey('paths', $attributes); + + $error = new MissingLayoutException('default.ctp', ['path/a/', 'path/b/'], 100, $previous); + $this->assertStringContainsString('Layout file `default.ctp` could not be found', $error->getMessage()); + $this->assertStringContainsString('- `path/a/default.ctp`', $error->getMessage()); + $this->assertSame($previous, $error->getPrevious()); + $this->assertSame(100, $error->getCode()); + + $error = new MissingElementException('view.ctp', ['path/a/', 'path/b/'], 100, $previous); + $this->assertStringContainsString('Element file `view.ctp` could not be found', $error->getMessage()); + $this->assertStringContainsString('- `path/a/view.ctp`', $error->getMessage()); + $this->assertSame($previous, $error->getPrevious()); + $this->assertSame(100, $error->getCode()); + + $error = new MissingCellTemplateException('Articles', 'view.ctp', ['path/a/', 'path/b/'], 100, $previous); + $this->assertStringContainsString('Cell template file `view.ctp` could not be found', $error->getMessage()); + $this->assertStringContainsString('- `path/a/view.ctp`', $error->getMessage()); + $this->assertSame($previous, $error->getPrevious()); + $this->assertSame(100, $error->getCode()); + $attributes = $error->getAttributes(); + $this->assertArrayHasKey('name', $attributes); + $this->assertArrayHasKey('file', $attributes); + $this->assertArrayHasKey('paths', $attributes); + } + + /** + * Provides pairs of exception name and default code. + * + * @return array + */ + public static function exceptionProvider(): array + { + return [ + [ConsoleException::class, 1], + [MissingHelperException::class, 1], + [StopException::class, 1], + [FormProtectionException::class, 400], + [MissingActionException::class, 404], + [MissingComponentException::class, 0], + [CakeException::class, 0], + [MissingPluginException::class, 0], + [DatabaseException::class, 0], + [MissingConnectionException::class, 0], + [MissingDriverException::class, 0], + [MissingExtensionException::class, 0], + [NestedTransactionRollbackException::class, 0], + [InvalidPrimaryKeyException::class, 0], + [MissingDatasourceConfigException::class, 0], + [MissingDatasourceException::class, 0], + [MissingModelException::class, 0], + [RecordNotFoundException::class, 404], + [PageOutOfBoundsException::class, 404], + [MailerMissingActionException::class, 0], + [MissingMailerException::class, 0], + [BadRequestException::class, 400], + [ConflictException::class, 409], + [ForbiddenException::class, 403], + [GoneException::class, 410], + [HttpException::class, 500], + [InternalErrorException::class, 500], + [InvalidCsrfTokenException::class, 403], + [MethodNotAllowedException::class, 405], + [MissingControllerException::class, 404], + [NotAcceptableException::class, 406], + [NotFoundException::class, 404], + [NotImplementedException::class, 501], + [ServiceUnavailableException::class, 503], + [UnauthorizedException::class, 401], + [UnavailableForLegalReasonsException::class, 451], + [UnprocessableContentException::class, 422], + [SocketException::class, 0], + [MissingBehaviorException::class, 0], + [MissingEntityException::class, 0], + [MissingTableClassException::class, 0], + [RolledbackTransactionException::class, 0], + [DuplicateNamedRouteException::class, 0], + [MissingRouteException::class, 404], + [XmlException::class, 0], + [MissingCellException::class, 0], + [ViewMissingHelperException::class, 0], + [MissingViewException::class, 0], + ]; + } +} diff --git a/tests/TestCase/Form/FormProtectorTest.php b/tests/TestCase/Form/FormProtectorTest.php new file mode 100644 index 00000000000..de68d6b97e4 --- /dev/null +++ b/tests/TestCase/Form/FormProtectorTest.php @@ -0,0 +1,843 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Form; + +use Cake\Core\Configure; +use Cake\Form\FormProtector; +use Cake\TestSuite\TestCase; +use Cake\Utility\Security; + +/** + * FormProtectorTest class + */ +class FormProtectorTest extends TestCase +{ + /** + * @var string + */ + protected $url = '/articles/index'; + + /** + * @var string + */ + protected $sessionId = 'cli'; + + protected function setUp(): void + { + parent::setUp(); + + Security::setSalt('foo!'); + + // $this->protector = new FormProtector('http://localhost/articles/index', 'cli'); + } + + /** + * Helper function for validation. + * + * @param array $data + * @param string|null $errorMessage + */ + protected function validate(array $data, ?string $errorMessage = null): void + { + $protector = new FormProtector(); + $result = $protector->validate($data, $this->url, $this->sessionId); + + if ($errorMessage === null) { + $this->assertTrue($result); + } else { + $this->assertFalse($result); + $this->assertSame($errorMessage, $protector->getError()); + } + } + + /** + * testValidate method + * + * Simple hash validation test + */ + public function testValidate(): void + { + $fields = '4697b45f7f430ff3ab73018c20f315eecb0ba5a6%3AModel.valid'; + $unlocked = ''; + $debug = ''; + + $data = [ + 'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateNoUnlockedInRequestData method + * + * Test that validate fails if you are missing unlocked in request data. + */ + public function testValidateNoUnlockedInRequestData(): void + { + $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877%3AModel.valid'; + + $data = [ + 'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'], + '_Token' => compact('fields'), + ]; + + $this->validate($data, '`_Token.unlocked` was not found in request data.'); + } + + /** + * testValidateFormHacking method + * + * Test that validate fails if any of its required fields are missing. + */ + public function testValidateFormHacking(): void + { + $unlocked = ''; + + $data = [ + 'Model' => ['username' => 'nate', 'password' => 'foo', 'valid' => '0'], + '_Token' => compact('unlocked'), + ]; + + $this->validate($data, '`_Token.fields` was not found in request data.'); + } + + /** + * testValidateEmptyForm method + * + * Test that validate fails if empty form is submitted. + */ + public function testValidateEmptyForm(): void + { + $this->validate([], '`_Token` was not found in request data.'); + } + + /** + * testValidate array fields method + * + * Test that validate fails if empty form is submitted. + */ + public function testValidateInvalidFields(): void + { + $data = [ + '_Token' => [ + 'debug' => '', + 'unlocked' => '', + 'fields' => [], + ], + ]; + $this->validate($data, '`_Token.fields` is invalid.'); + } + + /** + * testValidateObjectDeserialize + * + * Test that objects can't be passed into the serialized string. This was a vector for RFI and LFI + * attacks. Thanks to Felix Wilhelm + */ + public function testValidateObjectDeserialize(): void + { + $fields = 'a5475372b40f6e3ccbf9f8af191f20e1642fd877'; + $unlocked = ''; + $debug = urlencode(json_encode([ + '/articles/index', + ['Model.password', 'Model.username', 'Model.valid'], + [], + ])); + + // a corrupted serialized object, so we can see if it ever gets to deserialize + $attack = 'O:3:"App":1:{s:5:"__map";a:1:{s:3:"foo";s:7:"Hacked!";s:1:"fail"}}'; + $fields .= urlencode(':' . str_rot13($attack)); + + $data = [ + 'Model' => ['username' => 'mark', 'password' => 'foo', 'valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $protector = new FormProtector(); + $result = $protector->validate($data, $this->url, $this->sessionId); + $this->assertFalse($result); + } + + /** + * testValidateArray method + * + * Tests validation of checkbox arrays. + */ + public function testValidateArray(): void + { + $fields = 'f95b472a63f1d883b9eaacaf8a8e36e325e3fe82%3A'; + $unlocked = ''; + $debug = urlencode(json_encode([ + 'some-action', + [], + [], + ])); + + $data = [ + 'Model' => ['multi_field' => ['1', '3']], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + + $data = [ + 'Model' => ['multi_field' => [12 => '1', 20 => '3']], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateIntFieldName method + * + * Tests validation of integer field names. + */ + public function testValidateIntFieldName(): void + { + $fields = '11f87a5962db9ac26405e460cd3063bb6ff76cf8%3A'; + $unlocked = ''; + $debug = urlencode(json_encode([ + 'some-action', + [], + [], + ])); + + $data = [ + 1 => 'value,', + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateNoModel method + */ + public function testValidateNoModel(): void + { + $fields = 'a2a942f587deb20e90241c51b59d901d8a7f796b%3A'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'anything' => 'some_data', + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data); + } + + /** + * test validate uses full URL + */ + public function testValidateSubdirectory(): void + { + $this->url = '/subdir' . $this->url; + + $fields = 'cc9b6af3f33147235ae8f8037b0a71399a2425f2%3A'; + $unlocked = ''; + $debug = ''; + + $data = [ + 'Model' => ['username' => '', 'password' => ''], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data); + } + + /** + * testValidateComplex method + * + * Tests hash validation for multiple records, including locked fields. + */ + public function testValidateComplex(): void + { + $fields = 'b00b7e5c2e3bf8bc474fb7cfde6f9c2aa06ab9bc%3AAddresses.0.id%7CAddresses.1.id'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'Addresses' => [ + '0' => [ + 'id' => '123456', 'title' => '', 'first_name' => '', 'last_name' => '', + 'address' => '', 'city' => '', 'phone' => '', 'primary' => '', + ], + '1' => [ + 'id' => '654321', 'title' => '', 'first_name' => '', 'last_name' => '', + 'address' => '', 'city' => '', 'phone' => '', 'primary' => '', + ], + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateMultipleSelect method + * + * Test ValidatePost with multiple select elements. + */ + public function testValidateMultipleSelect(): void + { + $fields = '28dd05f0af314050784b18b3366857e8e8c78e73%3A'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'Tag' => ['Tag' => [1, 2]], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + + $data = [ + 'Tag' => ['Tag' => [1, 2, 3]], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + + $data = [ + 'Tag' => ['Tag' => [1, 2, 3, 4]], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + + $fields = '1e4c9269b64756e9b141d364497c5f037b428a37%3A'; + $data = [ + 'User.password' => 'bar', 'User.name' => 'foo', 'User.is_valid' => '1', + 'Tag' => ['Tag' => [1]], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateCheckbox method + * + * First block tests un-checked checkbox + * Second block tests checked checkbox + */ + public function testValidateCheckbox(): void + { + $fields = '4697b45f7f430ff3ab73018c20f315eecb0ba5a6%3AModel.valid'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'Model' => ['username' => '', 'password' => '', 'valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + + $fields = '3f368401f9a8610bcace7746039651066cdcdc38%3A'; + + $data = [ + 'Model' => ['username' => '', 'password' => '', 'valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + + $data = [ + 'Model' => ['username' => '', 'password' => '', 'valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateHidden method + */ + public function testValidateHidden(): void + { + $fields = '96e61bded2b62b0c420116a0eb06a3b3acddb8f1%3AModel.hidden%7CModel.other_hidden'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'Model' => [ + 'username' => '', 'password' => '', 'hidden' => '0', + 'other_hidden' => 'some hidden value', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateDisabledFieldsInData method + * + * Test validating post data with posted unlocked fields. + */ + public function testValidateDisabledFieldsInData(): void + { + $unlocked = 'Model.username'; + $fields = ['Model.hidden', 'Model.password']; + $fields = urlencode( + hash_hmac('sha1', '/articles/index' . serialize($fields) . $unlocked . 'cli', Security::getSalt()), + ); + $debug = 'not used'; + + $data = [ + 'Model' => [ + 'username' => 'mark', + 'password' => 'sekret', + 'hidden' => '0', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data); + } + + /** + * testValidateFailNoDisabled method + * + * Test that missing 'unlocked' input causes failure. + */ + public function testValidateFailNoDisabled(): void + { + $fields = ['Model.hidden', 'Model.password', 'Model.username']; + $fields = urlencode(Security::hash(serialize($fields) . Security::getSalt())); + + $data = [ + 'Model' => [ + 'username' => 'mark', + 'password' => 'sekret', + 'hidden' => '0', + ], + '_Token' => compact('fields'), + ]; + + $this->validate($data, '`_Token.unlocked` was not found in request data.'); + } + + /** + * testValidateFailNoDebug method + * + * Test that missing 'debug' input causes failure. + */ + public function testValidateFailNoDebug(): void + { + $fields = ['Model.hidden', 'Model.password', 'Model.username']; + $fields = urlencode(Security::hash(serialize($fields) . Security::getSalt())); + $unlocked = ''; + + $data = [ + 'Model' => [ + 'username' => 'mark', + 'password' => 'sekret', + 'hidden' => '0', + ], + '_Token' => compact('fields', 'unlocked'), + ]; + + $this->validate($data, '`_Token.debug` was not found in request data.'); + } + + /** + * testValidateFailNoDebugMode method + * + * Test that missing 'debug' input is not the problem when debug mode disabled. + */ + public function testValidateFailNoDebugMode(): void + { + $fields = ['Model.hidden', 'Model.password', 'Model.username']; + $fields = urlencode(Security::hash(serialize($fields) . Security::getSalt())); + $unlocked = ''; + + $data = [ + 'Model' => [ + 'username' => 'mark', + 'password' => 'sekret', + 'hidden' => '0', + ], + '_Token' => compact('fields', 'unlocked'), + ]; + Configure::write('debug', false); + $protector = new FormProtector(); + $result = $protector->validate($data, $this->url, $this->sessionId); + $this->assertFalse($result); + } + + /** + * testValidateFailDisabledFieldTampering method + * + * Test that validate fails when unlocked fields are changed. + */ + public function testValidateFailDisabledFieldTampering(): void + { + $unlocked = 'Model.username'; + $fields = ['Model.hidden', 'Model.password']; + $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt())); + $debug = urlencode(json_encode([ + '/articles/index', + ['Model.hidden', 'Model.password'], + ['Model.username'], + ])); + + // Tamper the values. + $unlocked = 'Model.username|Model.password'; + + $data = [ + 'Model' => [ + 'username' => 'mark', + 'password' => 'sekret', + 'hidden' => '0', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data, 'Missing field `Model.password` in POST data, Unexpected unlocked field `Model.password` in POST data'); + } + + /** + * testValidateHiddenMultipleModel method + */ + public function testValidateHiddenMultipleModel(): void + { + $fields = '642b7a6db3b848fab88952b86ea36c572f93df40%3AModel.valid%7CModel2.valid%7CModel3.valid'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'Model' => ['username' => '', 'password' => '', 'valid' => '0'], + 'Model2' => ['valid' => '0'], + 'Model3' => ['valid' => '0'], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + } + + /** + * testValidateHasManyModel method + */ + public function testValidateHasManyModel(): void + { + $fields = '792324c8a374772ad82acfb28f0e77e70f8ed3af%3AModel.0.hidden%7CModel.0.valid'; + $fields .= '%7CModel.1.hidden%7CModel.1.valid'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'Model' => [ + [ + 'username' => 'username', 'password' => 'password', + 'hidden' => 'value', 'valid' => '0', + ], + [ + 'username' => 'username', 'password' => 'password', + 'hidden' => 'value', 'valid' => '0', + ], + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data); + } + + /** + * testValidateHasManyRecordsPass method + */ + public function testValidateHasManyRecordsPass(): void + { + $fields = '7f4bff67558e25ebeea44c84ea4befa8d50b080c%3AAddress.0.id%7CAddress.0.primary%7C'; + $fields .= 'Address.1.id%7CAddress.1.primary'; + $unlocked = ''; + $debug = 'not used'; + + $data = [ + 'Address' => [ + 0 => [ + 'id' => '123', + 'title' => 'home', + 'first_name' => 'Bilbo', + 'last_name' => 'Baggins', + 'address' => '23 Bag end way', + 'city' => 'the shire', + 'phone' => 'N/A', + 'primary' => '1', + ], + 1 => [ + 'id' => '124', + 'title' => 'home', + 'first_name' => 'Frodo', + 'last_name' => 'Baggins', + 'address' => '50 Bag end way', + 'city' => 'the shire', + 'phone' => 'N/A', + 'primary' => '1', + ], + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data); + } + + /** + * testValidateHasManyRecords method + * + * validate should fail, hidden fields have been changed. + */ + public function testValidateHasManyRecordsFail(): void + { + $fields = '7a203edb3d345bbf38fe0dccae960da8842e11d7%3AAddress.0.id%7CAddress.0.primary%7C'; + $fields .= 'Address.1.id%7CAddress.1.primary'; + $unlocked = ''; + $debug = urlencode(json_encode([ + '/articles/index', + [ + 'Address.0.address', + 'Address.0.city', + 'Address.0.first_name', + 'Address.0.last_name', + 'Address.0.phone', + 'Address.0.title', + 'Address.1.address', + 'Address.1.city', + 'Address.1.first_name', + 'Address.1.last_name', + 'Address.1.phone', + 'Address.1.title', + 'Address.0.id' => '123', + 'Address.0.primary' => '5', + 'Address.1.id' => '124', + 'Address.1.primary' => '1', + ], + [], + ])); + + $data = [ + 'Address' => [ + 0 => [ + 'id' => '123', + 'title' => 'home', + 'first_name' => 'Bilbo', + 'last_name' => 'Baggins', + 'address' => '23 Bag end way', + 'city' => 'the shire', + 'phone' => 'N/A', + 'primary' => '5', + ], + 1 => [ + 'id' => '124', + 'title' => 'home', + 'first_name' => 'Frodo', + 'last_name' => 'Baggins', + 'address' => '50 Bag end way', + 'city' => 'the shire', + 'phone' => 'N/A', + 'primary' => '1', + ], + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $protector = new FormProtector(); + $result = $protector->validate($data, $this->url, $this->sessionId); + $this->assertFalse($result); + } + + /** + * testValidateRadio method + * + * Test validate with radio buttons. + * + * @triggers Controller.startup $this->Controller + */ + public function testValidateRadio(): void + { + $fields = 'a709dfdee0a0cce52c4c964a1b8a56159bb081b4%3An%3A0%3A%7B%7D'; + $unlocked = ''; + $debug = urlencode(json_encode([ + '/articles/index', + [], + [], + ])); + + $data = [ + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $protector = new FormProtector(); + $result = $protector->validate($data, $this->url, $this->sessionId); + $this->assertFalse($result); + + $data = [ + '_Token' => compact('fields', 'unlocked', 'debug'), + 'Test' => ['test' => ''], + ]; + $this->validate($data); + + $data = [ + '_Token' => compact('fields', 'unlocked', 'debug'), + 'Test' => ['test' => '1'], + ]; + $this->validate($data); + + $data = [ + '_Token' => compact('fields', 'unlocked', 'debug'), + 'Test' => ['test' => '2'], + ]; + $this->validate($data); + } + + /** + * testValidateUrlAsHashInput method + * + * Test validate uses here() as a hash input. + */ + public function testValidateUrlAsHashInput(): void + { + $fields = 'de2ca3670dd06c29558dd98482c8739e86da2c7c%3A'; + $unlocked = ''; + $debug = urlencode(json_encode([ + 'another-url', + ['Model.username', 'Model.password'], + [], + ])); + + $data = [ + 'Model' => ['username' => '', 'password' => ''], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + $this->validate($data); + + $this->url = '/posts/index?page=1'; + $this->validate( + $data, + 'URL mismatch in POST data (expected `another-url` but found `/posts/index?page=1`)', + ); + + $this->url = '/posts/edit/1'; + $this->validate( + $data, + 'URL mismatch in POST data (expected `another-url` but found `/posts/edit/1`)', + ); + } + + /** + * testValidateDebugFormat method + * + * Test that debug token format is right. + */ + public function testValidateDebugFormat(): void + { + $unlocked = 'Model.username'; + $fields = ['Model.hidden', 'Model.password']; + $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt())); + $debug = urlencode(json_encode([ + '/articles/index', + ['Model.hidden', 'Model.password'], + ['Model.username'], + ['not expected'], + ])); + + $data = [ + 'Model' => [ + 'username' => 'mark', + 'password' => 'sekret', + 'hidden' => '0', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data, 'Invalid form protection debug token.'); + } + + /** + * testValidateFailTampering method + * + * Test that validate fails with tampered fields and explanation. + */ + public function testValidateFailTampering(): void + { + $unlocked = ''; + $fields = ['Model.hidden' => 'value', 'Model.id' => '1']; + $debug = urlencode(json_encode([ + '/articles/index', + $fields, + [], + ])); + $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt())); + $fields .= urlencode(':Model.hidden|Model.id'); + $data = [ + 'Model' => [ + 'hidden' => 'tampered', + 'id' => '1', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate($data, 'Tampered field `Model.hidden` in POST data (expected value `value` but found `tampered`)'); + } + + /** + * testValidateFailTamperingMutatedIntoArray method + * + * Test that validate fails with tampered fields and explanation. + */ + public function testValidateFailTamperingMutatedIntoArray(): void + { + $unlocked = ''; + $fields = ['Model.hidden' => 'value', 'Model.id' => '1']; + $debug = urlencode(json_encode([ + '/articles/index', + $fields, + [], + ])); + $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt())); + $fields .= urlencode(':Model.hidden|Model.id'); + $data = [ + 'Model' => [ + 'hidden' => ['some-key' => 'some-value'], + 'id' => '1', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + + $this->validate( + $data, + 'Unexpected field `Model.hidden.some-key` in POST data, Missing field `Model.hidden` in POST data', + ); + } + + /** + * testValidateUnexpectedDebugToken method + * + * Test that debug token should not be sent if debug is disabled. + */ + public function testValidateUnexpectedDebugToken(): void + { + $unlocked = ''; + $fields = ['Model.hidden' => 'value', 'Model.id' => '1']; + $debug = urlencode(json_encode([ + '/articles/index', + $fields, + [], + ])); + $fields = urlencode(Security::hash(serialize($fields) . $unlocked . Security::getSalt())); + $fields .= urlencode(':Model.hidden|Model.id'); + $data = [ + 'Model' => [ + 'hidden' => ['some-key' => 'some-value'], + 'id' => '1', + ], + '_Token' => compact('fields', 'unlocked', 'debug'), + ]; + Configure::write('debug', false); + $this->validate($data, 'Unexpected `_Token.debug` found in request data'); + } +} diff --git a/tests/TestCase/Form/FormTest.php b/tests/TestCase/Form/FormTest.php new file mode 100644 index 00000000000..c2060a23800 --- /dev/null +++ b/tests/TestCase/Form/FormTest.php @@ -0,0 +1,378 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Form; + +use Cake\Form\Form; +use Cake\Form\Schema; +use Cake\TestSuite\TestCase; +use Cake\Validation\Validator; +use Exception; +use TestApp\Form\AppForm; +use TestApp\Form\FormSchema; + +/** + * Form test case. + */ +class FormTest extends TestCase +{ + /** + * Test setSchema() and getSchema() + */ + public function testSetGetSchema(): void + { + $form = new Form(); + $schema = $form->getSchema(); + + $this->assertInstanceOf(Schema::class, $schema); + $this->assertSame($schema, $form->getSchema(), 'Same instance each time'); + + $schema = new Schema(); + $this->assertSame($form, $form->setSchema($schema)); + $this->assertSame($schema, $form->getSchema()); + + $form = new AppForm(); + $this->assertInstanceOf(FormSchema::class, $form->getSchema()); + } + + /** + * Test getValidator() + */ + public function testGetValidator(): void + { + $form = new Form(); + + $this->assertInstanceof(Validator::class, $form->getValidator()); + } + + /** + * Test setValidator() + */ + public function testSetValidator(): void + { + $form = new Form(); + $validator = new Validator(); + + $form->setValidator('default', $validator); + $this->assertSame($validator, $form->getValidator()); + } + + /** + * Test validate method. + */ + public function testValidate(): void + { + $form = new Form(); + $form->getValidator() + ->add('email', 'format', ['rule' => 'email']) + ->add('body', 'length', ['rule' => ['minLength', 12]]); + + $data = [ + 'email' => 'rong', + 'body' => 'too short', + ]; + $this->assertFalse($form->validate($data)); + $this->assertCount(2, $form->getErrors()); + + $data = [ + 'email' => 'test@example.com', + 'body' => 'Some content goes here', + ]; + $this->assertTrue($form->validate($data)); + $this->assertCount(0, $form->getErrors()); + } + + /** + * Test validate with custom validator + */ + public function testValidateCustomValidator(): void + { + $form = new Form(); + + $validator = clone $form->getValidator(); + $validator->add('email', 'format', ['rule' => 'email']); + + $form->setValidator('custom', $validator); + + $data = ['email' => 'wrong']; + + $this->assertFalse($form->validate($data, 'custom')); + } + + /** + * Test the get errors & get error methods. + */ + public function testGetErrors(): void + { + $form = new Form(); + $form->getValidator() + ->add('email', 'format', [ + 'message' => 'Must be a valid email', + 'rule' => 'email', + ]) + ->add('body', 'length', [ + 'message' => 'Must be so long', + 'rule' => ['minLength', 12], + ]); + + $data = [ + 'email' => 'rong', + 'body' => 'too short', + ]; + $form->validate($data); + + $errors = $form->getErrors(); + $this->assertCount(2, $errors); + $this->assertSame('Must be a valid email', $errors['email']['format']); + $this->assertSame('Must be so long', $errors['body']['length']); + + $error = $form->getError('email'); + $this->assertSame(['format' => 'Must be a valid email'], $error); + + $error = $form->getError('foo'); + $this->assertSame([], $error); + } + + /** + * Test setErrors() + */ + public function testSetErrors(): void + { + $form = new Form(); + $expected = [ + 'field_name' => ['rule_name' => 'message'], + ]; + + $form->setErrors($expected); + $this->assertSame($expected, $form->getErrors()); + } + + /** + * Test _execute is skipped on validation failure. + */ + public function testExecuteInvalid(): void + { + $this->deprecated(function (): void { + $form = new class extends Form { + protected function _execute(array $data): bool + { + throw new Exception('Should not be called'); + } + }; + $form->getValidator() + ->add('email', 'format', ['rule' => 'email']); + $data = [ + 'email' => 'rong', + ]; + + $this->assertFalse($form->execute($data)); + }); + } + + /** + * test execute() when data is valid. + */ + public function testExecuteValid(): void + { + $form = new Form(); + $form->getValidator() + ->add('email', 'format', ['rule' => 'email']); + $data = [ + 'email' => 'test@example.com', + ]; + + $this->assertTrue($form->execute($data)); + } + + /** + * test execute() when data is valid. + */ + public function testExecuteWithProcess(): void + { + $form = new class extends Form { + public function process(array $data): bool + { + return false; + } + }; + + $this->assertFalse($form->execute([])); + } + + /** + * test execute() + */ + public function testExecuteWithExecuteAndNoValidate(): void + { + $this->deprecated(function (): void { + $form = new class extends Form { + protected function _execute(array $data): bool + { + return false; + } + }; + + $this->assertFalse($form->execute([], ['validate' => false])); + }); + } + + /** + * test execute() when data is valid. + */ + public function testExecuteSkipValidation(): void + { + $form = new Form(); + $form->getValidator() + ->add('email', 'format', ['rule' => 'email']); + $data = [ + 'email' => 'wrong', + ]; + + $this->assertTrue($form->execute($data, ['validate' => false])); + } + + /** + * Test set() with one param. + */ + public function testSetOneParam(): void + { + $form = new Form(); + $data = ['test' => 'val', 'foo' => 'bar']; + $form->set($data); + $this->assertEquals($data, $form->getData()); + + $update = ['test' => 'updated']; + $form->set($update); + $this->assertSame('updated', $form->getData()['test']); + } + + /** + * test set() with 2 params + */ + public function testSetTwoParam(): void + { + $form = new Form(); + $form->set('testing', 'value'); + $this->assertEquals(['testing' => 'value'], $form->getData()); + } + + /** + * test chainable set() + */ + public function testSetChained(): void + { + $form = new Form(); + $result = $form->set('testing', 'value') + ->set('foo', 'bar'); + $this->assertSame($form, $result); + $this->assertEquals(['testing' => 'value', 'foo' => 'bar'], $form->getData()); + } + + /** + * Test setting and getting form data. + */ + public function testDataSetGet(): void + { + $form = new Form(); + $expected = ['title' => 'title', 'is_published' => true]; + $form->setData(['title' => 'title', 'is_published' => true]); + + $this->assertSame($expected, $form->getData()); + $this->assertSame('title', $form->getData('title')); + $this->assertNull($form->getData('nonexistent')); + } + + /** + * test __debugInfo + */ + public function testDebugInfo(): void + { + $form = new Form(); + $result = $form->__debugInfo(); + $this->assertArrayHasKey('_schema', $result); + $this->assertArrayHasKey('_errors', $result); + $this->assertArrayHasKey('_validator', $result); + $this->assertArrayHasKey('_data', $result); + } + + /** + * Test getError() with nested field validation using dot notation + */ + public function testGetErrorNestedFields(): void + { + $form = new Form(); + + $nestedValidator = new Validator(); + $nestedValidator->add('field_name', 'notBlank', [ + 'rule' => 'notBlank', + 'message' => 'This field is required', + ]); + + $form->getValidator()->addNested('Common', $nestedValidator); + + $data = [ + 'Common' => [ + 'field_name' => '', + ], + ]; + + $form->validate($data); + + // Test accessing nested errors using dot notation + $error = $form->getError('Common.field_name'); + $this->assertNotEmpty($error); + $this->assertSame('This field is required', $error['notBlank']); + + // Test accessing parent level errors + $commonErrors = $form->getError('Common'); + $this->assertNotEmpty($commonErrors); + $this->assertArrayHasKey('field_name', $commonErrors); + } + + /** + * Test getError() with deeply nested fields + */ + public function testGetErrorDeeplyNestedFields(): void + { + $form = new Form(); + + $deepValidator = new Validator(); + $deepValidator->add('deep_field', 'notBlank', [ + 'rule' => 'notBlank', + 'message' => 'Deep field is required', + ]); + + $midValidator = new Validator(); + $midValidator->addNested('level', $deepValidator); + + $form->getValidator()->addNested('parent', $midValidator); + + $data = [ + 'parent' => [ + 'level' => [ + 'deep_field' => '', + ], + ], + ]; + + $form->validate($data); + + // Test accessing deeply nested errors using dot notation + $error = $form->getError('parent.level.deep_field'); + $this->assertNotEmpty($error); + $this->assertSame('Deep field is required', $error['notBlank']); + } +} diff --git a/tests/TestCase/Form/SchemaTest.php b/tests/TestCase/Form/SchemaTest.php new file mode 100644 index 00000000000..0cc7a7ed54d --- /dev/null +++ b/tests/TestCase/Form/SchemaTest.php @@ -0,0 +1,132 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Form; + +use Cake\Form\Schema; +use Cake\TestSuite\TestCase; + +/** + * Form schema test case. + */ +class SchemaTest extends TestCase +{ + /** + * Test adding multiple fields. + */ + public function testAddingMultipleFields(): void + { + $schema = new Schema(); + $schema->addFields([ + 'email' => 'string', + 'body' => ['type' => 'string', 'length' => 1000], + ]); + $this->assertEquals(['email', 'body'], $schema->fields()); + $this->assertSame('string', $schema->field('email')['type']); + $this->assertSame('string', $schema->field('body')['type']); + } + + /** + * test adding fields. + */ + public function testAddingFields(): void + { + $schema = new Schema(); + + $res = $schema->addField('name', ['type' => 'string']); + $this->assertSame($schema, $res, 'Should be chainable'); + + $this->assertEquals(['name'], $schema->fields()); + $res = $schema->field('name'); + $expected = ['type' => 'string', 'length' => null, 'precision' => null, 'default' => null]; + $this->assertEquals($expected, $res); + + $res = $schema->addField('email', 'string'); + $this->assertSame($schema, $res, 'Should be chainable'); + + $this->assertEquals(['name', 'email'], $schema->fields()); + $res = $schema->field('email'); + $expected = ['type' => 'string', 'length' => null, 'precision' => null, 'default' => null]; + $this->assertEquals($expected, $res); + } + + /** + * test adding field whitelist attrs + */ + public function testAddingFieldsWhitelist(): void + { + $schema = new Schema(); + + $schema->addField('name', ['derp' => 'derp', 'type' => 'string']); + $expected = ['type' => 'string', 'length' => null, 'precision' => null, 'default' => null]; + $this->assertEquals($expected, $schema->field('name')); + } + + /** + * Test removing fields. + */ + public function testRemovingFields(): void + { + $schema = new Schema(); + + $schema->addField('name', ['type' => 'string']); + $this->assertEquals(['name'], $schema->fields()); + + $res = $schema->removeField('name'); + $this->assertSame($schema, $res, 'Should be chainable'); + $this->assertEquals([], $schema->fields()); + $this->assertNull($schema->field('name')); + } + + /** + * test fieldType + */ + public function testFieldType(): void + { + $schema = new Schema(); + + $schema->addField('name', 'string') + ->addField('numbery', [ + 'type' => 'decimal', + 'required' => true, + ]); + $this->assertSame('string', $schema->fieldType('name')); + $this->assertSame('decimal', $schema->fieldType('numbery')); + $this->assertNull($schema->fieldType('nope')); + } + + /** + * test __debugInfo + */ + public function testDebugInfo(): void + { + $schema = new Schema(); + + $schema->addField('name', 'string') + ->addField('numbery', [ + 'type' => 'decimal', + 'required' => true, + ]); + $result = $schema->__debugInfo(); + $expected = [ + '_fields' => [ + 'name' => ['type' => 'string', 'length' => null, 'precision' => null, 'default' => null], + 'numbery' => ['type' => 'decimal', 'length' => null, 'precision' => null, 'default' => null], + ], + ]; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Http/BaseApplicationTest.php b/tests/TestCase/Http/BaseApplicationTest.php new file mode 100644 index 00000000000..dbb35dc37d5 --- /dev/null +++ b/tests/TestCase/Http/BaseApplicationTest.php @@ -0,0 +1,343 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.5.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http; + +use Cake\Console\CommandRunner; +use Cake\Console\ConsoleIo; +use Cake\Console\TestSuite\StubConsoleOutput; +use Cake\Core\BasePlugin; +use Cake\Core\Configure; +use Cake\Core\Container; +use Cake\Core\ContainerInterface; +use Cake\Event\EventInterface; +use Cake\Event\EventManagerInterface; +use Cake\Http\BaseApplication; +use Cake\Http\MiddlewareQueue; +use Cake\Http\ServerRequest; +use Cake\Http\ServerRequestFactory; +use Cake\Routing\RouteBuilder; +use Cake\Routing\RouteCollection; +use Cake\TestSuite\TestCase; +use Mockery; +use Psr\Http\Message\ResponseInterface; +use TestPlugin\TestPluginPlugin as TestPlugin; + +/** + * Base application test. + */ +class BaseApplicationTest extends TestCase +{ + /** + * @var \Cake\Http\BaseApplication + */ + protected BaseApplication $app; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + static::setAppNamespace(); + $this->app = new class (dirname(__DIR__, 2)) extends BaseApplication + { + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + $eventManager->on('testTrue', function ($event) { + return true; + }); + + return $eventManager; + } + }; + } + + /** + * @return void + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + unset($this->app); + } + + /** + * Integration test for a simple controller. + */ + public function testHandle(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/cakes']); + $request = $request->withAttribute('params', [ + 'controller' => 'Cakes', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + ]); + + $app = $this->app; + $result = $app->handle($request); + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Hello Jane', '' . $result->getBody()); + $container = $app->getContainer(); + $this->assertSame($request, $container->get(ServerRequest::class)); + $this->assertSame($container, $container->get(ContainerInterface::class)); + } + + /** + * Ensure that plugins with no plugin class can be loaded. + * This makes adopting the new API easier + * + * @deprecated + */ + public function testAddPluginUnknownClass(): void + { + $app = $this->app; + $app->addPlugin('PluginJs'); + $plugin = $app->getPlugins()->get('PluginJs'); + $this->assertInstanceOf(BasePlugin::class, $plugin); + + $this->assertSame( + TEST_APP . 'Plugin' . DS . 'PluginJs' . DS, + $plugin->getPath(), + ); + $this->assertSame( + TEST_APP . 'Plugin' . DS . 'PluginJs' . DS . 'config' . DS, + $plugin->getConfigPath(), + ); + $this->assertSame( + TEST_APP . 'Plugin' . DS . 'PluginJs' . DS . 'src' . DS, + $plugin->getClassPath(), + ); + } + + public function testAddPluginValidShortName(): void + { + $app = $this->app; + $app->addPlugin('TestPlugin'); + + $this->assertCount(1, $app->getPlugins()); + $this->assertTrue($app->getPlugins()->has('TestPlugin')); + + $app->addPlugin('Company/TestPluginThree'); + $this->assertCount(2, $app->getPlugins()); + $this->assertTrue($app->getPlugins()->has('Company/TestPluginThree')); + } + + public function testAddPluginValid(): void + { + $app = $this->app; + $app->addPlugin(TestPlugin::class); + + $this->assertCount(1, $app->getPlugins()); + $this->assertTrue($app->getPlugins()->has('TestPlugin')); + } + + public function testPluginMiddleware(): void + { + $start = new MiddlewareQueue(); + $app = $this->app; + $app->addPlugin(TestPlugin::class); + + $after = $app->pluginMiddleware($start); + $this->assertSame($start, $after); + $this->assertCount(1, $after); + } + + public function testPluginRoutes(): void + { + $collection = new RouteCollection(); + $routes = new RouteBuilder($collection, '/'); + $app = $this->app; + $app->addPlugin(TestPlugin::class); + + $result = $app->pluginRoutes($routes); + $this->assertSame($routes, $result); + $url = [ + 'plugin' => 'TestPlugin', + 'controller' => 'TestPlugin', + 'action' => 'index', + '_method' => 'GET', + ]; + $this->assertNotEmpty($collection->match($url, [])); + } + + public function testAppBootstrapPlugins(): void + { + $app = new class (dirname(__DIR__, 2) . DS . 'test_app' . DS . 'config_plugins') extends BaseApplication + { + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + $app->bootstrap(); + $this->assertTrue($app->getPlugins()->has('TestPlugin'), 'TestPlugin was not loaded via plugins.php'); + } + + public function testPluginBootstrap(): void + { + $app = $this->app; + $app->addPlugin(TestPlugin::class); + + $this->assertFalse(Configure::check('PluginTest.test_plugin.bootstrap')); + $app->pluginBootstrap(); + $this->assertTrue(Configure::check('PluginTest.test_plugin.bootstrap')); + } + + /** + * Test that plugins loaded with addPlugin() can load additional + * plugins. + */ + public function testPluginBootstrapRecursivePlugins(): void + { + $app = $this->app; + $app->addPlugin('Named'); + // Remove the deprecated() wrapping once plugin class is added to TestPluginTwo + $this->deprecated(function () use ($app): void { + $app->pluginBootstrap(); + }); + $this->assertTrue( + Configure::check('Named.bootstrap'), + 'Plugin bootstrap should be run', + ); + $this->assertTrue( + Configure::check('PluginTest.test_plugin.bootstrap'), + 'Nested plugin should have bootstrap run', + ); + $this->assertTrue( + Configure::check('PluginTest.test_plugin_two.bootstrap'), + 'Nested plugin should have bootstrap run', + ); + } + + /** + * Tests that loading a nonexistent plugin through addOptionalPlugin() does not throw an exception + */ + public function testAddOptionalPluginLoadingNonExistentPlugin(): void + { + $app = $this->app; + $pluginCountBefore = count($app->getPlugins()); + $nonExistingPlugin = 'NonExistentPlugin'; + $app->addOptionalPlugin($nonExistingPlugin); + $pluginCountAfter = count($app->getPlugins()); + $this->assertSame($pluginCountBefore, $pluginCountAfter); + } + + /** + * Tests that loading an existing plugin through addOptionalPlugin() works + */ + public function testAddOptionalPluginLoadingNonExistentPluginValid(): void + { + $app = $this->app; + $app->addOptionalPlugin(TestPlugin::class); + + $this->assertCount(1, $app->getPlugins()); + $this->assertTrue($app->getPlugins()->has('TestPlugin')); + } + + public function testGetContainer(): void + { + $app = $this->app; + $container = $app->getContainer(); + + $this->assertInstanceOf(ContainerInterface::class, $container); + $this->assertSame($container, $app->getContainer(), 'Should return a reference'); + } + + public function testBuildContainerEvent(): void + { + $app = $this->app; + $called = false; + $app->getEventManager()->on('Application.buildContainer', function ($event, $container) use (&$called): void { + $this->assertInstanceOf(BaseApplication::class, $event->getSubject()); + $this->assertInstanceOf(ContainerInterface::class, $container); + $called = true; + }); + + $container = $app->getContainer(); + $this->assertInstanceOf(ContainerInterface::class, $container); + $this->assertTrue($called, 'Listener should be called'); + } + + public function testBuildContainerEventReplaceContainer(): void + { + $app = $this->app; + $app->getEventManager()->on('Application.buildContainer', function (EventInterface $event): void { + $new = new Container(); + $new->add('testing', 'yes'); + + $event->setResult($new); + }); + + $container = $app->getContainer(); + $this->assertInstanceOf(ContainerInterface::class, $container); + $this->assertTrue($container->has('testing')); + } + + public function testEventsAreRegistered(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/cakes']); + $request = $request->withAttribute('params', [ + 'controller' => 'Cakes', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + ]); + + $app = $this->app; + $app->handle($request); + $this->assertNotEmpty($app->getEventManager()->listeners('testTrue')); + } + + public function testConsoleEventsAreRegistered(): void + { + static::setAppNamespace(); + $app = new class (dirname(__DIR__, 2)) extends BaseApplication + { + public function routes(RouteBuilder $routes): void + { + } + + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + $eventManager->on('testTrue', function ($event) { + return true; + }); + + return $eventManager; + } + }; + $output = new StubConsoleOutput(); + $consoleIo = Mockery::mock(ConsoleIo::class, [$output, $output, null, null]) + ->shouldAllowMockingMethod('in') + ->makePartial(); + $runner = new CommandRunner($app); + $runner->run(['cake', 'version'], $consoleIo); + $this->assertNotEmpty($app->getEventManager()->listeners('testTrue')); + } +} diff --git a/tests/TestCase/Http/Client/Adapter/CurlTest.php b/tests/TestCase/Http/Client/Adapter/CurlTest.php new file mode 100644 index 00000000000..8341877bb38 --- /dev/null +++ b/tests/TestCase/Http/Client/Adapter/CurlTest.php @@ -0,0 +1,400 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.7.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Client\Adapter; + +use Cake\Http\Client\Adapter\Curl; +use Cake\Http\Client\Exception\NetworkException; +use Cake\Http\Client\Request; +use Cake\Http\Client\Response; +use Cake\TestSuite\TestCase; +use Composer\CaBundle\CaBundle; + +/** + * HTTP curl adapter test. + */ +class CurlTest extends TestCase +{ + /** + * @var \Cake\Http\Client\Adapter\Curl + */ + protected $curl; + + /** + * @var string + */ + protected $caFile; + + protected function setUp(): void + { + parent::setUp(); + $this->skipIf(!function_exists('curl_init'), 'Skipping as ext/curl is not installed.'); + + $this->curl = new Curl(); + $this->caFile = CaBundle::getBundledCaBundlePath(); + } + + /** + * Test the send method + */ + public function testSendLive(): void + { + $request = new Request('http://localhost', 'GET', [ + 'User-Agent' => 'CakePHP TestSuite', + 'Cookie' => 'testing=value', + ]); + try { + $responses = $this->curl->send($request, []); + } catch (NetworkException) { + $this->markTestSkipped('Could not connect to localhost, skipping'); + } + $this->assertCount(1, $responses); + + /** @var \Cake\Http\Response $response */ + $response = $responses[0]; + $this->assertInstanceOf(Response::class, $response); + $this->assertNotEmpty($response->getHeaders()); + } + + /** + * Test the send method + */ + public function testSendLiveResponseCheck(): void + { + $request = new Request('https://api.cakephp.org/3.0/', 'GET', [ + 'User-Agent' => 'CakePHP TestSuite', + ]); + try { + $responses = $this->curl->send($request, []); + } catch (NetworkException) { + $this->markTestSkipped('Could not connect to api.cakephp.org, skipping'); + } + $this->assertCount(1, $responses); + + /** @var \Cake\Http\Response $response */ + $response = $responses[0]; + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->hasHeader('Date')); + $this->assertTrue($response->hasHeader('Content-type')); + $this->assertStringContainsString('<html', $response->getBody()->getContents()); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsGet(): void + { + $options = [ + 'timeout' => 5, + ]; + $request = new Request( + 'http://localhost/things', + 'GET', + ['Cookie' => 'testing=value'], + ); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Cookie: testing=value', + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_HTTPGET => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_CAINFO => $this->caFile, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsGetWithBody(): void + { + $options = [ + 'timeout' => 5, + ]; + $request = new Request( + 'http://localhost/things', + 'GET', + ['Cookie' => 'testing=value'], + '{"some":"body"}', + ); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Cookie: testing=value', + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_HTTPGET => true, + CURLOPT_POSTFIELDS => '{"some":"body"}', + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_TIMEOUT => 5, + CURLOPT_CAINFO => $this->caFile, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsPost(): void + { + $options = []; + $request = new Request( + 'http://localhost/things', + 'POST', + ['Cookie' => 'testing=value'], + ['name' => 'cakephp', 'yes' => 1], + ); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Cookie: testing=value', + 'Connection: close', + 'User-Agent: CakePHP', + 'Content-Type: application/x-www-form-urlencoded', + ], + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => 'name=cakephp&yes=1', + CURLOPT_CAINFO => $this->caFile, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsPut(): void + { + $options = []; + $request = new Request( + 'http://localhost/things', + 'PUT', + ['Cookie' => 'testing=value'], + ); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Cookie: testing=value', + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_POST => true, + CURLOPT_CUSTOMREQUEST => 'PUT', + CURLOPT_CAINFO => $this->caFile, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsJsonPost(): void + { + $options = []; + $content = json_encode(['a' => 1, 'b' => 2]); + $request = new Request( + 'http://localhost/things', + 'POST', + ['Content-type' => 'application/json'], + $content, + ); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Content-type: application/json', + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $content, + CURLOPT_CAINFO => $this->caFile, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsSsl(): void + { + $options = [ + 'ssl_verify_host' => true, + 'ssl_verify_peer' => true, + 'ssl_verify_peer_name' => true, + // These options do nothing in curl. + 'ssl_verify_depth' => 9000, + 'ssl_allow_self_signed' => false, + ]; + $request = new Request('http://localhost/things', 'GET'); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_HTTPGET => true, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_CAINFO => $this->caFile, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsProxy(): void + { + $options = [ + 'proxy' => [ + 'proxy' => '127.0.0.1:8080', + 'username' => 'frodo', + 'password' => 'one_ring', + ], + ]; + $request = new Request('http://localhost/things', 'GET'); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_HTTPGET => true, + CURLOPT_CAINFO => $this->caFile, + CURLOPT_PROXY => '127.0.0.1:8080', + CURLOPT_PROXYUSERPWD => 'frodo:one_ring', + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsHead(): void + { + $options = []; + $request = new Request( + 'http://localhost/things', + 'HEAD', + ['Cookie' => 'testing=value'], + ); + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Cookie: testing=value', + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_NOBODY => true, + CURLOPT_CAINFO => $this->caFile, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsCurlOptions(): void + { + $options = [ + 'curl' => [ + CURLOPT_USERAGENT => 'Super-secret', + ], + ]; + $request = new Request('http://localhost/things', 'GET'); + $request = $request->withProtocolVersion('1.0'); + + $result = $this->curl->buildOptions($request, $options); + $expected = [ + CURLOPT_URL => 'http://localhost/things', + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => [ + 'Connection: close', + 'User-Agent: CakePHP', + ], + CURLOPT_HTTPGET => true, + CURLOPT_CAINFO => $this->caFile, + CURLOPT_USERAGENT => 'Super-secret', + ]; + $this->assertSame($expected, $result); + } + + /** + * Test that an exception is raised when timed out. + */ + public function testNetworkException(): void + { + $this->expectException(NetworkException::class); + $this->expectExceptionMessageMatches('/(Could not resolve|Resolving timed out)/'); + + $request = new Request('http://dummy/?sleep'); + $options = [ + 'timeout' => 2, + ]; + + $this->curl->send($request, $options); + } + + /** + * Test converting client options into curl ones. + */ + public function testBuildOptionsProtocolVersion(): void + { + $this->skipIf(!defined('CURL_HTTP_VERSION_2TLS'), 'Requires libcurl 7.42'); + $options = []; + $request = new Request('http://localhost/things', 'GET'); + $request = $request->withProtocolVersion('2'); + + $result = $this->curl->buildOptions($request, $options); + $this->assertSame(CURL_HTTP_VERSION_2TLS, $result[CURLOPT_HTTP_VERSION]); + } +} diff --git a/tests/TestCase/Http/Client/Adapter/StreamTest.php b/tests/TestCase/Http/Client/Adapter/StreamTest.php new file mode 100644 index 00000000000..87aeff9b52b --- /dev/null +++ b/tests/TestCase/Http/Client/Adapter/StreamTest.php @@ -0,0 +1,377 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Client\Adapter; + +use Cake\Core\Exception\CakeException; +use Cake\Http\Client\Adapter\Stream; +use Cake\Http\Client\Exception\NetworkException; +use Cake\Http\Client\Request; +use Cake\Http\Client\Response; +use Cake\TestSuite\TestCase; +use Exception; +use Mockery; +use TestApp\Http\Client\Adapter\CakeStreamWrapper; + +/** + * HTTP stream adapter test. + */ +class StreamTest extends TestCase +{ + /** + * @var \Cake\Http\Client\Adapter\Stream + */ + protected $stream; + + protected function setUp(): void + { + parent::setUp(); + $this->stream = Mockery::mock(Stream::class)->makePartial(); + $this->stream + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('_send') + ->andReturn([]); + stream_wrapper_unregister('http'); + stream_wrapper_register('http', CakeStreamWrapper::class); + } + + protected function tearDown(): void + { + parent::tearDown(); + stream_wrapper_restore('http'); + } + + /** + * Test the send method + */ + public function testSend(): void + { + $stream = new Stream(); + $request = new Request('http://localhost', 'GET', [ + 'User-Agent' => 'CakePHP TestSuite', + 'Cookie' => 'testing=value', + ]); + + try { + $responses = $stream->send($request, []); + } catch (CakeException) { + $this->markTestSkipped('Could not connect to localhost, skipping'); + } + $this->assertInstanceOf(Response::class, $responses[0]); + } + + /** + * Test the send method by using cakephp:// protocol. + */ + public function testSendByUsingCakephpProtocol(): void + { + $stream = new Stream(); + $request = new Request('http://dummy/'); + + /** @var \Cake\Http\Client\Response[] $responses */ + $responses = $stream->send($request, []); + $this->assertInstanceOf(Response::class, $responses[0]); + + $this->assertSame(20000, strlen($responses[0]->getStringBody())); + } + + /** + * Test stream error_handler cleanup when wrapper throws exception + */ + public function testSendWrapperException(): void + { + $stream = new Stream(); + $request = new Request('http://throw_exception/'); + + $currentHandler = set_error_handler(function (): void { + }); + restore_error_handler(); + + try { + $stream->send($request, []); + } catch (Exception) { + } + + $newHandler = set_error_handler(function (): void { + }); + restore_error_handler(); + + $this->assertEquals($currentHandler, $newHandler); + } + + /** + * Test building the context headers + */ + public function testBuildingContextHeader(): void + { + $request = new Request( + 'http://localhost', + 'GET', + [ + 'User-Agent' => 'CakePHP TestSuite', + 'Content-Type' => 'application/json', + 'Cookie' => 'a=b; c=do%20it', + ], + ); + + $options = [ + 'redirect' => 20, + ]; + $this->stream->send($request, $options); + $result = $this->stream->contextOptions(); + $expected = [ + 'User-Agent: CakePHP TestSuite', + 'Content-Type: application/json', + 'Cookie: a=b; c=do%20it', + 'Connection: close', + ]; + $this->assertSame(implode("\r\n", $expected), $result['header']); + $this->assertSame(0, $result['max_redirects']); + $this->assertTrue($result['ignore_errors']); + } + + /** + * Test send() + context options with string content. + */ + public function testSendContextContentString(): void + { + $content = json_encode(['a' => 'b']); + $request = new Request( + 'http://localhost', + 'GET', + ['Content-Type' => 'application/json'], + $content, + ); + + $options = [ + 'redirect' => 20, + ]; + $this->stream->send($request, $options); + $result = $this->stream->contextOptions(); + $expected = [ + 'Content-Type: application/json', + 'Connection: close', + 'User-Agent: CakePHP', + ]; + $this->assertSame(implode("\r\n", $expected), $result['header']); + $this->assertEquals($content, $result['content']); + } + + /** + * Test send() + context options with array content. + */ + public function testSendContextContentArray(): void + { + $request = new Request( + 'http://localhost', + 'GET', + [], + ['a' => 'my value'], + ); + + $this->stream->send($request, []); + $result = $this->stream->contextOptions(); + $expected = [ + 'Connection: close', + 'User-Agent: CakePHP', + 'Content-Type: application/x-www-form-urlencoded', + ]; + $this->assertStringStartsWith(implode("\r\n", $expected), $result['header']); + $this->assertStringContainsString('a=my+value', $result['content']); + $this->assertStringContainsString('my+value', $result['content']); + } + + /** + * Test send() + context options with array content. + */ + public function testSendContextContentArrayFiles(): void + { + $request = new Request( + 'http://localhost', + 'GET', + [], + ['upload' => fopen(CORE_PATH . 'VERSION.txt', 'r')], + ); + + $this->stream->send($request, []); + $result = $this->stream->contextOptions(); + $this->assertStringContainsString('Content-Type: multipart/form-data', $result['header']); + $this->assertStringContainsString("Connection: close\r\n", $result['header']); + $this->assertStringContainsString('User-Agent: CakePHP', $result['header']); + $this->assertStringContainsString('name="upload"', $result['content']); + $this->assertStringContainsString('filename="VERSION.txt"', $result['content']); + } + + /** + * Test send() + context options for SSL. + */ + public function testSendContextSsl(): void + { + $request = new Request('https://localhost.com/test.html'); + $options = [ + 'ssl_verify_host' => true, + 'ssl_verify_peer' => true, + 'ssl_verify_peer_name' => true, + 'ssl_verify_depth' => 9000, + 'ssl_allow_self_signed' => false, + 'proxy' => [ + 'proxy' => '127.0.0.1:8080', + ], + ]; + + $this->stream->send($request, $options); + $result = $this->stream->contextOptions(); + $expected = [ + 'peer_name' => 'localhost.com', + 'verify_peer' => true, + 'verify_peer_name' => true, + 'verify_depth' => 9000, + 'allow_self_signed' => false, + 'proxy' => '127.0.0.1:8080', + ]; + foreach ($expected as $k => $v) { + $this->assertEquals($v, $result[$k]); + } + $this->assertIsReadable($result['cafile']); + } + + /** + * Test send() + context options for SSL. + */ + public function testSendContextSslNoVerifyPeerName(): void + { + $request = new Request('https://localhost.com/test.html'); + $options = [ + 'ssl_verify_host' => true, + 'ssl_verify_peer' => true, + 'ssl_verify_peer_name' => false, + 'ssl_verify_depth' => 9000, + 'ssl_allow_self_signed' => false, + 'proxy' => [ + 'proxy' => '127.0.0.1:8080', + ], + ]; + + $this->stream->send($request, $options); + $result = $this->stream->contextOptions(); + $expected = [ + 'peer_name' => 'localhost.com', + 'verify_peer' => true, + 'verify_peer_name' => false, + 'verify_depth' => 9000, + 'allow_self_signed' => false, + 'proxy' => '127.0.0.1:8080', + ]; + foreach ($expected as $k => $v) { + $this->assertEquals($v, $result[$k]); + } + $this->assertIsReadable($result['cafile']); + } + + /** + * The PHP stream API returns ALL the headers for ALL the requests when + * there are redirects. + */ + public function testCreateResponseWithRedirects(): void + { + $headers = [ + 'HTTP/1.1 302 Found', + 'Date: Mon, 31 Dec 2012 16:53:16 GMT', + 'Server: Apache/2.2.22 (Unix) DAV/2 PHP/5.4.9 mod_ssl/2.2.22 OpenSSL/0.9.8r', + 'X-Powered-By: PHP/5.4.9', + 'Location: http://localhost/cake3/tasks/second', + 'Content-Length: 0', + 'Connection: close', + 'Content-Type: text/html; charset=UTF-8', + 'Set-Cookie: first=value', + 'HTTP/1.1 302 Found', + 'Date: Mon, 31 Dec 2012 16:53:16 GMT', + 'Server: Apache/2.2.22 (Unix) DAV/2 PHP/5.4.9 mod_ssl/2.2.22 OpenSSL/0.9.8r', + 'X-Powered-By: PHP/5.4.9', + 'Location: http://localhost/cake3/tasks/third', + 'Content-Length: 0', + 'Connection: close', + 'Content-Type: text/html; charset=UTF-8', + 'Set-Cookie: second=val', + 'HTTP/1.1 200 OK', + 'Date: Mon, 31 Dec 2012 16:53:16 GMT', + 'Server: Apache/2.2.22 (Unix) DAV/2 PHP/5.4.9 mod_ssl/2.2.22 OpenSSL/0.9.8r', + 'X-Powered-By: PHP/5.4.9', + 'Content-Length: 22', + 'Connection: close', + 'Content-Type: text/html; charset=UTF-8', + 'Set-Cookie: third=works', + ]; + $content = 'This is the third page'; + + /** @var \Cake\Http\Client\Response[] $responses */ + $responses = $this->stream->createResponses($headers, $content); + $this->assertCount(3, $responses); + $this->assertSame('close', $responses[0]->getHeaderLine('Connection')); + $this->assertSame('', (string)$responses[0]->getBody()); + $this->assertSame('', (string)$responses[1]->getBody()); + $this->assertSame($content, (string)$responses[2]->getBody()); + + $this->assertSame(302, $responses[0]->getStatusCode()); + $this->assertSame(302, $responses[1]->getStatusCode()); + $this->assertSame(200, $responses[2]->getStatusCode()); + + $this->assertSame('value', $responses[0]->getCookie('first')); + $this->assertNull($responses[0]->getCookie('second')); + $this->assertNull($responses[0]->getCookie('third')); + + $this->assertNull($responses[1]->getCookie('first')); + $this->assertSame('val', $responses[1]->getCookie('second')); + $this->assertNull($responses[1]->getCookie('third')); + + $this->assertNull($responses[2]->getCookie('first')); + $this->assertNull($responses[2]->getCookie('second')); + $this->assertSame('works', $responses[2]->getCookie('third')); + } + + /** + * Test that no exception is radied when not timed out. + */ + public function testKeepDeadline(): void + { + $request = new Request('http://dummy/?sleep'); + $options = [ + 'timeout' => 5, + ]; + + $t = microtime(true); + $stream = new Stream(); + $stream->send($request, $options); + $this->assertLessThan(5, microtime(true) - $t); + } + + /** + * Test that an exception is raised when timed out. + */ + public function testMissDeadline(): void + { + $request = new Request('http://dummy/?sleep'); + $options = [ + 'timeout' => 2, + ]; + + $stream = new Stream(); + + $this->expectException(NetworkException::class); + $this->expectExceptionMessage('Connection timed out http://dummy/?sleep'); + + $stream->send($request, $options); + } +} diff --git a/tests/TestCase/Http/Client/Auth/DigestTest.php b/tests/TestCase/Http/Client/Auth/DigestTest.php new file mode 100644 index 00000000000..d6d9a8530f0 --- /dev/null +++ b/tests/TestCase/Http/Client/Auth/DigestTest.php @@ -0,0 +1,371 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Client\Auth; + +use Cake\Http\Client; +use Cake\Http\Client\Auth\Digest; +use Cake\Http\Client\Request; +use Cake\Http\Client\Response; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Digest authentication test + */ +class DigestTest extends TestCase +{ + /** + * @var \Cake\Http\Client + */ + protected $client; + + /** + * @var \Cake\Http\Client\Auth\Digest + */ + protected $auth; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + + $this->client = $this->getClientMock(); + $this->auth = $this->getDigestMock(); + } + + /** + * @return Digest + */ + protected function getDigestMock(): Digest + { + return new class ($this->client) extends Digest + { + public function generateCnonce(): string + { + return 'cnonce'; + } + }; + } + + /** + * @return \Cake\Http\Client + */ + protected function getClientMock() + { + $client = Mockery::mock(Client::class) + ->makePartial() + ->shouldIgnoreMissing(); + $client->__construct(); + + return $client; + } + + /** + * test getting data from additional request method + */ + public function testRealmAndNonceFromExtraRequest(): void + { + $headers = [ + 'WWW-Authenticate: Digest realm="The batcave",nonce="4cded326c6c51"', + ]; + + $response = new Response($headers, ''); + $this->client->shouldReceive('send') + ->once() + ->andReturn($response); + + $auth = ['username' => 'admin', 'password' => '1234']; + $request = new Request('http://example.com/some/path', Request::METHOD_GET); + $request = $this->auth->authentication($request, $auth); + + $result = $request->getHeaderLine('Authorization'); + $this->assertStringContainsString('Digest', $result); + $this->assertStringContainsString('realm="The batcave"', $result); + $this->assertStringContainsString('nonce="4cded326c6c51"', $result); + $this->assertStringContainsString('response="a21a874c0b29165929f5d24d1aad2c47"', $result); + $this->assertStringContainsString('uri="/some/path"', $result); + $this->assertStringNotContainsString('qop=', $result); + $this->assertStringNotContainsString('nc=', $result); + } + + /** + * testQopAuth method + */ + public function testQopAuth(): void + { + $headers = [ + 'WWW-Authenticate: Digest realm="The batcave",nonce="4cded326c6c51",qop="auth"', + ]; + + $response = new Response($headers, ''); + $this->client->shouldReceive('send') + ->once() + ->andReturn($response); + $auth = ['username' => 'admin', 'password' => '1234']; + $request = new Request('http://example.com/some/path', Request::METHOD_GET); + $request = $this->auth->authentication($request, $auth); + $result = $request->getHeaderLine('Authorization'); + + $this->assertStringContainsString('qop=auth', $result); + $this->assertStringContainsString('nc=00000001', $result); + $this->assertMatchesRegularExpression('/cnonce="[a-z0-9]+"/', $result); + } + + /** + * testQopAuthInt method + */ + public function testQopAuthInt(): void + { + $headers = [ + 'WWW-Authenticate: Digest realm="The batcave",nonce="4cded326c6c51",qop="auth-int"', + ]; + + $response = new Response($headers, ''); + $this->client->shouldReceive('send') + ->once() + ->andReturn($response); + + $auth = ['username' => 'admin', 'password' => '1234']; + $request = new Request('http://example.com/some/path', Request::METHOD_GET); + $request = $this->auth->authentication($request, $auth); + $result = $request->getHeaderLine('Authorization'); + $this->assertStringContainsString('qop=auth-int', $result); + $this->assertStringContainsString('nc=00000001', $result); + $this->assertMatchesRegularExpression('/cnonce="[a-z0-9]+"/', $result); + } + + /** + * testQopAuthInt method + */ + public function testQopFailure(): void + { + $headers = [ + 'WWW-Authenticate: Digest realm="The batcave",nonce="4cded326c6c51",qop="wrong"', + ]; + + $response = new Response($headers, ''); + $this->client->shouldReceive('send') + ->once() + ->andReturn($response); + + $auth = ['username' => 'admin', 'password' => '1234']; + $request = new Request('http://example.com/some/path', Request::METHOD_GET); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid QOP parameter. Valid types are: auth,auth-int'); + $this->auth->authentication($request, $auth); + } + + /** + * testOpaque method + */ + public function testOpaque(): void + { + $headers = [ + 'WWW-Authenticate: Digest realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ]; + + $response = new Response($headers, ''); + $this->client->shouldReceive('send') + ->once() + ->andReturn($response); + + $auth = ['username' => 'admin', 'password' => '1234']; + $request = new Request('http://example.com/some/path', Request::METHOD_GET); + $request = $this->auth->authentication($request, $auth); + $result = $request->getHeaderLine('Authorization'); + + $this->assertStringContainsString('opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', $result); + } + + /** + * Data provider for testAlgorithms + * + * @return array[] + */ + public static function algorithmsProvider(): array + { + return [ + [ + 'ALGORITHM: MD5 QOP: none', + ['WWW-Authenticate: Digest algorithm="MD5", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="MD5", response="a21a874c0b29165929f5d24d1aad2c47", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: MD5-sess QOP: none', + ['WWW-Authenticate: Digest algorithm="MD5-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="MD5-sess", nc=00000001, cnonce="cnonce", response="6807a3326271bd172439d17c2d03d295", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-256 QOP: none', + ['WWW-Authenticate: Digest algorithm="SHA-256", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-256", response="65d00137c82412c7421ec9c8c08dccbbac667a1dedbae7db9cd888980e7af112", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-256-sess QOP: none', + ['WWW-Authenticate: Digest algorithm="SHA-256-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-256-sess", nc=00000001, cnonce="cnonce", response="a954ffbe615b56aa16e9a7f62ea34f4a4833bb75b670f73863e2209862d0fedf", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-512-256 QOP: none', + ['WWW-Authenticate: Digest algorithm="SHA-512-256", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-512-256", response="112b7ab122e7be8b9b5e7f32b8e4d9d4f651a53a783f1a1f267434b51f54e3cd", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-512-256-sess QOP: none', + ['WWW-Authenticate: Digest algorithm="SHA-512-256-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-512-256-sess", nc=00000001, cnonce="cnonce", response="d0a3b5b3d10b585911a9f5fd4ec4bbe691124e5920d371e699203906ba65376f", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: MD5 QOP: auth', + ['WWW-Authenticate: Digest algorithm="MD5", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="MD5", qop=auth, nc=00000001, cnonce="cnonce", response="716e45bf26c8abfa957d6799a34cc60f", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: MD5-sess QOP: auth', + ['WWW-Authenticate: Digest algorithm="MD5-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="MD5-sess", qop=auth, nc=00000001, cnonce="cnonce", response="1dfe066896bfab45282f088a390abe35", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-256 QOP: auth', + ['WWW-Authenticate: Digest algorithm="SHA-256", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-256", qop=auth, nc=00000001, cnonce="cnonce", response="f2bf2df206fd8b244d20540a5b294e5af7c7839615230acce240e3954bae781a", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-256-sess QOP: auth', + ['WWW-Authenticate: Digest algorithm="SHA-256-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-256-sess", qop=auth, nc=00000001, cnonce="cnonce", response="9e912a2d25b9ed4d3f66bdc5f011d8be04d8971a18993adc845a0f4e5c486546", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-512-256 QOP: auth', + ['WWW-Authenticate: Digest algorithm="SHA-512-256", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-512-256", qop=auth, nc=00000001, cnonce="cnonce", response="7569d573a117016388393fd682cbeb49a0a1af62366511a4e0d29b753ccf5e83", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-512-256-sess QOP: auth', + ['WWW-Authenticate: Digest algorithm="SHA-512-256-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth"'], + Request::METHOD_GET, + [], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-512-256-sess", qop=auth, nc=00000001, cnonce="cnonce", response="e100bc5a33a1c24943d5876bc2cf37cc45e1cde06069ed8b33a75fe032351e01", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: MD5 QOP: auth-int', + ['WWW-Authenticate: Digest algorithm="MD5", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth-int"'], + Request::METHOD_POST, + ['test' => 'test'], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="MD5", qop=auth-int, nc=00000001, cnonce="cnonce", response="476738bf56cf2f24173902adfa55d236", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: MD5-sess QOP: auth-int', + ['WWW-Authenticate: Digest algorithm="MD5-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth-int"'], + Request::METHOD_POST, + ['test' => 'test'], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="MD5-sess", qop=auth-int, nc=00000001, cnonce="cnonce", response="beee6427899606fb6b3e09bb71b57c79", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-256 QOP: auth-int', + ['WWW-Authenticate: Digest algorithm="SHA-256", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth-int"'], + Request::METHOD_POST, + ['test' => 'test'], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-256", qop=auth-int, nc=00000001, cnonce="cnonce", response="ca8f61f4d637343befeeb6282dc0302ebfc20ff974ff92b11c7e88836422f230", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-256-sess QOP: auth-int', + ['WWW-Authenticate: Digest algorithm="SHA-256-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth-int"'], + Request::METHOD_POST, + ['test' => 'test'], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-256-sess", qop=auth-int, nc=00000001, cnonce="cnonce", response="a2340619cd74256bc058bf2ea0c9fd62a27f9bf62fc295b8b3e94eab441a73d1", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-512-256 QOP: auth-int', + ['WWW-Authenticate: Digest algorithm="SHA-512-256", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth-int"'], + Request::METHOD_POST, + ['test' => 'test'], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-512-256", qop=auth-int, nc=00000001, cnonce="cnonce", response="055498e5d59601bea5c735a084fa74a0d33ebde3b5788057ce8380f892e99cec", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + [ + 'ALGORITHM: SHA-512-256-sess QOP: auth-int', + ['WWW-Authenticate: Digest algorithm="SHA-512-256-sess", realm="The batcave",nonce="4cded326c6c51",opaque="d8ea7aa61a1693024c4cc3a516f49b3c",qop="auth-int"'], + Request::METHOD_POST, + ['test' => 'test'], + 'Digest username="admin", realm="The batcave", nonce="4cded326c6c51", uri="/some/path", algorithm="SHA-512-256-sess", qop=auth-int, nc=00000001, cnonce="cnonce", response="9dbc89190bfe55eec14b1d444e1922d016d20dad461a1e9d25121c0db0024d3d", opaque="d8ea7aa61a1693024c4cc3a516f49b3c"', + ], + ]; + } + + /** + * testAlgorithms method + * + * @return void + */ + #[DataProvider('algorithmsProvider')] + public function testAlgorithms($message, $headers, $method, $data, $expected): void + { + $response = new Response($headers, ''); + $this->client->shouldReceive('send') + ->once() + ->andReturn($response); + $auth = ['username' => 'admin', 'password' => '1234']; + $request = new Request('http://example.com/some/path', $method, [], $data); + $request = $this->auth->authentication($request, $auth); + $result = $request->getHeaderLine('Authorization'); + + $this->assertSame($expected, $result, $message); + } + + public function testAlgorithmException(): void + { + $headers = [ + 'WWW-Authenticate: Digest algorithm="WRONG",realm="The batcave",nonce="4cded326c6c51"', + ]; + + $response = new Response($headers, ''); + $this->client->shouldReceive('send') + ->once() + ->andReturn($response); + + $auth = ['username' => 'admin', 'password' => '1234']; + $request = new Request('http://example.com/some/path', Request::METHOD_GET); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Algorithm. Valid ones are: MD5,SHA-256,SHA-512-256,MD5-sess,SHA-256-sess,SHA-512-256-sess'); + $this->auth->authentication($request, $auth); + } +} diff --git a/tests/TestCase/Http/Client/Auth/OauthTest.php b/tests/TestCase/Http/Client/Auth/OauthTest.php new file mode 100644 index 00000000000..11b9f96f821 --- /dev/null +++ b/tests/TestCase/Http/Client/Auth/OauthTest.php @@ -0,0 +1,579 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Client\Auth; + +use Cake\Core\Exception\CakeException; +use Cake\Http\Client\Auth\Oauth; +use Cake\Http\Client\Request; +use Cake\TestSuite\TestCase; + +/** + * Oauth test. + */ +class OauthTest extends TestCase +{ + private string $privateKeyString; + + private string $privateKeyStringEnc; + + /** + * Setup + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->privateKeyString = file_get_contents(TEST_APP . DS . 'config' . DS . 'key.pem'); + $this->privateKeyStringEnc = file_get_contents(TEST_APP . DS . 'config' . DS . 'key_with_passphrase.pem'); + } + + public function testExceptionUnknownSigningMethod(): void + { + $this->expectException(CakeException::class); + $auth = new Oauth(); + $creds = [ + 'consumerSecret' => 'it is secret', + 'consumerKey' => 'a key', + 'token' => 'a token value', + 'tokenSecret' => 'also secret', + 'method' => 'silly goose', + ]; + $request = new Request(); + $auth->authentication($request, $creds); + } + + /** + * Test plain-text signing. + */ + public function testPlainTextSigning(): void + { + $auth = new Oauth(); + $creds = [ + 'consumerSecret' => 'it is secret', + 'consumerKey' => 'a key', + 'token' => 'a token value', + 'tokenSecret' => 'also secret', + 'method' => 'plaintext', + ]; + $request = new Request(); + $request = $auth->authentication($request, $creds); + + $result = $request->getHeaderLine('Authorization'); + $this->assertStringContainsString('OAuth', $result); + $this->assertStringContainsString('oauth_version="1.0"', $result); + $this->assertStringContainsString('oauth_token="a%20token%20value"', $result); + $this->assertStringContainsString('oauth_consumer_key="a%20key"', $result); + $this->assertStringContainsString('oauth_signature_method="PLAINTEXT"', $result); + $this->assertStringContainsString('oauth_signature="it%20is%20secret%26also%20secret"', $result); + $this->assertStringContainsString('oauth_timestamp=', $result); + $this->assertStringContainsString('oauth_nonce=', $result); + } + + /** + * Test that baseString() normalizes the URL. + */ + public function testBaseStringNormalizeUrl(): void + { + $request = new Request('HTTP://exAmple.com:80/parts/foo'); + + $auth = new Oauth(); + $creds = []; + $result = $auth->baseString($request, $creds); + $this->assertStringContainsString('GET&', $result, 'method was missing.'); + $this->assertStringContainsString('http%3A%2F%2Fexample.com%2Fparts%2Ffoo', $result); + } + + /** + * Test that the query string is stripped from the normalized host. + */ + public function testBaseStringWithQueryString(): void + { + $request = new Request('http://example.com/search?q=pogo&cat=2'); + + $auth = new Oauth(); + $values = [ + 'oauth_version' => '1.0', + 'oauth_nonce' => uniqid(), + 'oauth_timestamp' => time(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_token' => 'token', + 'oauth_consumer_key' => 'consumer-key', + ]; + $result = $auth->baseString($request, $values); + $this->assertStringContainsString('GET&', $result, 'method was missing.'); + $this->assertStringContainsString( + 'http%3A%2F%2Fexample.com%2Fsearch&', + $result, + ); + $this->assertStringContainsString( + 'cat%3D2%26oauth_consumer_key%3Dconsumer-key' . + '%26oauth_nonce%3D' . $values['oauth_nonce'] . + '%26oauth_signature_method%3DHMAC-SHA1' . + '%26oauth_timestamp%3D' . $values['oauth_timestamp'] . + '%26oauth_token%3Dtoken' . + '%26oauth_version%3D1.0' . + '%26q%3Dpogo', + $result, + ); + } + + /** + * Ensure that post data is sorted and encoded. + * + * Keys with array values have to be serialized using + * a more standard HTTP approach. PHP flavored HTTP + * is not part of the Oauth spec. + * + * See Normalize Request Parameters (section 9.1.1) + */ + public function testBaseStringWithPostDataNestedArrays(): void + { + $request = new Request( + 'http://example.com/search?q=pogo', + Request::METHOD_POST, + [], + [ + 'search' => [ + 'filters' => [ + 'field' => 'date', + 'value' => 'one two', + ], + ], + ], + ); + + $auth = new Oauth(); + $values = [ + 'oauth_version' => '1.0', + 'oauth_nonce' => uniqid(), + 'oauth_timestamp' => time(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_token' => 'token', + 'oauth_consumer_key' => 'consumer-key', + ]; + $result = $auth->baseString($request, $values); + + $this->assertStringContainsString('POST&', $result, 'method was missing.'); + $this->assertStringContainsString( + 'http%3A%2F%2Fexample.com%2Fsearch&', + $result, + ); + $this->assertStringContainsString( + '&oauth_consumer_key%3Dconsumer-key' . + '%26oauth_nonce%3D' . $values['oauth_nonce'] . + '%26oauth_signature_method%3DHMAC-SHA1' . + '%26oauth_timestamp%3D' . $values['oauth_timestamp'] . + '%26oauth_token%3Dtoken' . + '%26oauth_version%3D1.0' . + '%26q%3Dpogo' . + '%26search%5Bfilters%5D%5Bfield%5D%3Ddate' . + '%26search%5Bfilters%5D%5Bvalue%5D%3Done%20two', + $result, + ); + } + + /** + * Ensure that post data is sorted and encoded. + * + * Keys with array values have to be serialized using + * a more standard HTTP approach. PHP flavored HTTP + * is not part of the Oauth spec. + * + * See Normalize Request Parameters (section 9.1.1) + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testBaseStringWithPostData(): void + { + $request = new Request( + 'http://example.com/search?q=pogo', + Request::METHOD_POST, + [], + [ + 'address' => 'post', + 'zed' => 'last', + 'tags' => ['oauth', 'cake'], + ], + ); + + $auth = new Oauth(); + $values = [ + 'oauth_version' => '1.0', + 'oauth_nonce' => uniqid(), + 'oauth_timestamp' => time(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_token' => 'token', + 'oauth_consumer_key' => 'consumer-key', + ]; + $result = $auth->baseString($request, $values); + + $this->assertStringContainsString('POST&', $result, 'method was missing.'); + $this->assertStringContainsString( + 'http%3A%2F%2Fexample.com%2Fsearch&', + $result, + ); + $this->assertStringContainsString( + '&address%3Dpost' . + '%26oauth_consumer_key%3Dconsumer-key' . + '%26oauth_nonce%3D' . $values['oauth_nonce'] . + '%26oauth_signature_method%3DHMAC-SHA1' . + '%26oauth_timestamp%3D' . $values['oauth_timestamp'] . + '%26oauth_token%3Dtoken' . + '%26oauth_version%3D1.0' . + '%26q%3Dpogo' . + '%26tags%3Dcake' . + '%26tags%3Doauth' . + '%26zed%3Dlast', + $result, + ); + } + + /** + * Ensure that non-urlencoded post data is not included. + * + * Keys with array values have to be serialized using + * a more standard HTTP approach. PHP flavored HTTP + * is not part of the Oauth spec. + * + * See Normalize Request Parameters (section 9.1.1) + */ + public function testBaseStringWithXmlPostData(): void + { + $request = new Request( + 'http://example.com/search?q=pogo', + Request::METHOD_POST, + [ + 'Content-Type' => 'application/xml', + ], + '<xml>stuff</xml>', + ); + + $auth = new Oauth(); + $values = [ + 'oauth_version' => '1.0', + 'oauth_nonce' => uniqid(), + 'oauth_timestamp' => time(), + 'oauth_signature_method' => 'HMAC-SHA1', + 'oauth_token' => 'token', + 'oauth_consumer_key' => 'consumer-key', + ]; + $result = $auth->baseString($request, $values); + + $this->assertStringContainsString('POST&', $result, 'method was missing.'); + $this->assertStringContainsString( + 'http%3A%2F%2Fexample.com%2Fsearch&', + $result, + ); + $this->assertStringContainsString( + 'oauth_consumer_key%3Dconsumer-key' . + '%26oauth_nonce%3D' . $values['oauth_nonce'] . + '%26oauth_signature_method%3DHMAC-SHA1' . + '%26oauth_timestamp%3D' . $values['oauth_timestamp'] . + '%26oauth_token%3Dtoken' . + '%26oauth_version%3D1.0' . + '%26q%3Dpogo', + $result, + ); + } + + /** + * Test HMAC-SHA1 signing + * + * Hash result + parameters taken from + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testHmacSigning(): void + { + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacation.jpg', 'size' => 'original'], + ); + + $options = [ + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'consumerSecret' => 'kd94hf93k423kf44', + 'tokenSecret' => 'pfkkdhi9sl3r4s00', + 'token' => 'nnch734d00sl2jdk', + 'nonce' => 'kllo9940pd9333jh', + 'timestamp' => '1191242096', + ]; + $auth = new Oauth(); + $request = $auth->authentication($request, $options); + + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + } + + /** + * Test HMAC-SHA1 signing with a base64 consumer key + */ + public function testHmacBase64Signing(): void + { + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + ); + + $options = [ + 'consumerKey' => 'ZHBmNDNmM3AybDRrM2wwMw==', + 'consumerSecret' => 'kd94hf93k423kf44', + 'tokenSecret' => 'pfkkdhi9sl3r4s00', + 'token' => 'nnch734d00sl2jdk', + 'nonce' => 'kllo9940pd9333jh', + 'timestamp' => '1191242096', + ]; + $auth = new Oauth(); + $request = $auth->authentication($request, $options); + + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + } + + /** + * Test RSA-SHA1 signing with a private key string + * + * Hash result + parameters taken from + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testRsaSigningString(): void + { + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacaction.jpg', 'size' => 'original'], + ); + $privateKey = $this->privateKeyString; + + $options = [ + 'method' => 'RSA-SHA1', + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'nonce' => '13917289812797014437', + 'timestamp' => '1196666512', + 'privateKey' => $privateKey, + ]; + $auth = new Oauth(); + try { + $request = $auth->authentication($request, $options); + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + } catch (CakeException $e) { + // Handle 22.04 + OpenSSL bug. This should be safe to remove in the future. + if (str_contains($e->getMessage(), 'unexpected eof while reading')) { + $this->markTestSkipped('Skipping because of OpenSSL bug.'); + } + throw $e; + } + } + + public function testRsaSigningInvalidKey(): void + { + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacaction.jpg', 'size' => 'original'], + ); + + $options = [ + 'method' => 'RSA-SHA1', + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'nonce' => '13917289812797014437', + 'timestamp' => '1196666512', + 'privateKey' => 'not a private key', + ]; + $auth = new Oauth(); + $this->expectException(CakeException::class); + $this->expectExceptionMessage('openssl error'); + $auth->authentication($request, $options); + } + + /** + * Test RSA-SHA1 signing with a private key file + * + * Hash result + parameters taken from + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testRsaSigningFile(): void + { + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacaction.jpg', 'size' => 'original'], + ); + $privateKey = fopen(TEST_APP . DS . 'config' . DS . 'key.pem', 'r'); + + $options = [ + 'method' => 'RSA-SHA1', + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'nonce' => '13917289812797014437', + 'timestamp' => '1196666512', + 'privateKey' => $privateKey, + ]; + $auth = new Oauth(); + $request = $auth->authentication($request, $options); + + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + } + + /** + * Test RSA-SHA1 signing with a private key file passphrase string + * + * Hash result + parameters taken from + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testRsaSigningWithPassphraseString(): void + { + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacaction.jpg', 'size' => 'original'], + ); + $privateKey = fopen(TEST_APP . DS . 'config' . DS . 'key_with_passphrase.pem', 'r'); + $passphrase = 'fancy-cakephp-passphrase'; + + $options = [ + 'method' => 'RSA-SHA1', + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'nonce' => '13917289812797014437', + 'timestamp' => '1196666512', + 'privateKey' => $privateKey, + 'privateKeyPassphrase' => $passphrase, + ]; + $auth = new Oauth(); + $request = $auth->authentication($request, $options); + + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + } + + /** + * Test RSA-SHA1 signing with a private key string and passphrase string + * + * Hash result + parameters taken from + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testRsaSigningStringWithPassphraseString(): void + { + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacaction.jpg', 'size' => 'original'], + ); + $privateKey = $this->privateKeyStringEnc; + $passphrase = 'fancy-cakephp-passphrase'; + + $options = [ + 'method' => 'RSA-SHA1', + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'nonce' => '13917289812797014437', + 'timestamp' => '1196666512', + 'privateKey' => $privateKey, + 'privateKeyPassphrase' => $passphrase, + ]; + $auth = new Oauth(); + $request = $auth->authentication($request, $options); + + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + } + + /** + * Test RSA-SHA1 signing with passphrase file + * + * Hash result + parameters taken from + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testRsaSigningWithPassphraseFile(): void + { + $this->skipIf(PHP_EOL !== "\n", 'Just the line ending "\n" is supported. You can run the test again e.g. on a linux system.'); + + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacaction.jpg', 'size' => 'original'], + ); + $privateKey = fopen(TEST_APP . DS . 'config' . DS . 'key_with_passphrase.pem', 'r'); + $passphrase = fopen(TEST_APP . DS . 'config' . DS . 'key_passphrase_lf', 'r'); + + $options = [ + 'method' => 'RSA-SHA1', + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'nonce' => '13917289812797014437', + 'timestamp' => '1196666512', + 'privateKey' => $privateKey, + 'privateKeyPassphrase' => $passphrase, + ]; + $auth = new Oauth(); + $request = $auth->authentication($request, $options); + + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + $expected = 0; + $this->assertSame($expected, ftell($passphrase)); + } + + /** + * Test RSA-SHA1 signing with a private key string and passphrase file + * + * Hash result + parameters taken from + * https://wiki.oauth.net/w/page/12238556/TestCases + */ + public function testRsaSigningStringWithPassphraseFile(): void + { + $this->skipIf(PHP_EOL !== "\n", 'Just the line ending "\n" is supported. You can run the test again e.g. on a linux system.'); + + $request = new Request( + 'http://photos.example.net/photos', + 'GET', + [], + ['file' => 'vacaction.jpg', 'size' => 'original'], + ); + $privateKey = $this->privateKeyStringEnc; + $passphrase = fopen(TEST_APP . DS . 'config' . DS . 'key_passphrase_lf', 'r'); + + $options = [ + 'method' => 'RSA-SHA1', + 'consumerKey' => 'dpf43f3p2l4k3l03', + 'nonce' => '13917289812797014437', + 'timestamp' => '1196666512', + 'privateKey' => $privateKey, + 'privateKeyPassphrase' => $passphrase, + ]; + $auth = new Oauth(); + $request = $auth->authentication($request, $options); + + $result = $request->getHeaderLine('Authorization'); + $this->assertSignatureFormat($result); + $expected = 0; + $this->assertSame($expected, ftell($passphrase)); + } + + protected function assertSignatureFormat($result) + { + $this->assertMatchesRegularExpression( + '/oauth_signature="[a-zA-Z0-9\/=+]+"/', + urldecode($result), + ); + } +} diff --git a/tests/TestCase/Http/Client/FormDataTest.php b/tests/TestCase/Http/Client/FormDataTest.php new file mode 100644 index 00000000000..9cc70a20e0b --- /dev/null +++ b/tests/TestCase/Http/Client/FormDataTest.php @@ -0,0 +1,233 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Client; + +use Cake\Http\Client\FormData; +use Cake\TestSuite\TestCase; +use Laminas\Diactoros\UploadedFile; + +/** + * Test case for FormData. + */ +class FormDataTest extends TestCase +{ + /** + * Test getting the boundary. + */ + public function testBoundary(): void + { + $data = new FormData(); + $result = $data->boundary(); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $result); + + $result2 = $data->boundary(); + $this->assertSame($result, $result2); + } + + /** + * test adding parts returns this. + */ + public function testAddReturnThis(): void + { + $data = new FormData(); + $return = $data->add('test', 'value'); + $this->assertSame($data, $return); + } + + /** + * Test adding parts that are simple. + */ + public function testAddSimple(): void + { + $data = new FormData(); + $data->add('test', 'value') + ->add('empty', '') + ->add('int', 1) + ->add('float', 2.3) + ->add('password', '@secret'); + + $this->assertCount(5, $data); + $result = (string)$data; + $expected = 'test=value&empty=&int=1&float=2.3&password=%40secret'; + $this->assertSame($expected, $result); + } + + /** + * Test addMany method. + */ + public function testAddMany(): void + { + $data = new FormData(); + $array = [ + 'key' => 'value', + 'empty' => '', + 'int' => '1', + 'float' => '2.3', + ]; + $data->addMany($array); + $this->assertCount(4, $data); + $result = (string)$data; + $expected = 'key=value&empty=&int=1&float=2.3'; + $this->assertSame($expected, $result); + } + + /** + * Test adding a part object. + */ + public function testAddPartObject(): void + { + $data = new FormData(); + $boundary = $data->boundary(); + + $part = $data->newPart('test', 'value'); + $part->contentId('abc123'); + $data->add($part); + + $this->assertTrue($data->isMultipart()); + $this->assertFalse($data->hasFile()); + $this->assertCount(1, $data, 'Should have 1 part'); + $expected = [ + '--' . $boundary, + 'Content-Disposition: form-data; name="test"', + 'Content-ID: <abc123>', + '', + 'value', + '--' . $boundary . '--', + '', + ]; + $this->assertSame(implode("\r\n", $expected), (string)$data); + } + + /** + * Test adding parts that are arrays. + */ + public function testAddArray(): void + { + $data = new FormData(); + $data->add('Article', [ + 'title' => 'first post', + 'published' => 'Y', + 'tags' => ['blog', 'cakephp'], + ]); + $result = (string)$data; + $expected = 'Article%5Btitle%5D=first+post&Article%5Bpublished%5D=Y&' . + 'Article%5Btags%5D%5B0%5D=blog&Article%5Btags%5D%5B1%5D=cakephp'; + $this->assertSame($expected, $result); + } + + /** + * Test adding a part with a file in it. + */ + public function testAddFile(): void + { + $file = CORE_PATH . 'VERSION.txt'; + $contents = file_get_contents($file); + + $data = new FormData(); + $data->addFile('upload', fopen($file, 'r')); + $boundary = $data->boundary(); + $result = (string)$data; + + $expected = [ + '--' . $boundary, + 'Content-Disposition: form-data; name="upload"; filename="VERSION.txt"', + 'Content-Type: text/plain; charset=us-ascii', + '', + $contents, + '--' . $boundary . '--', + '', + ]; + $this->assertSame(implode("\r\n", $expected), $result); + } + + /** + * Test adding a part with a filehandle. + */ + public function testAddFileHandle(): void + { + $file = CORE_PATH . 'VERSION.txt'; + $fh = fopen($file, 'r'); + + $data = new FormData(); + $data->add('upload', $fh); + $boundary = $data->boundary(); + $result = (string)$data; + + rewind($fh); + $contents = stream_get_contents($fh); + + $expected = [ + '--' . $boundary, + 'Content-Disposition: form-data; name="upload"; filename="VERSION.txt"', + 'Content-Type: text/plain; charset=us-ascii', + '', + $contents, + '--' . $boundary . '--', + '', + ]; + $this->assertSame(implode("\r\n", $expected), $result); + } + + /** + * Test adding a part with a UploadedFileInterface instance. + */ + public function testAddFileUploadedFile(): void + { + $file = new UploadedFile( + CORE_PATH . 'VERSION.txt', + filesize(CORE_PATH . 'VERSION.txt'), + 0, + 'VERSION.txt', + 'text/plain', + ); + + $data = new FormData(); + $data->add('upload', $file); + $boundary = $data->boundary(); + $result = (string)$data; + + $expected = [ + '--' . $boundary, + 'Content-Disposition: form-data; name="upload"; filename="VERSION.txt"', + 'Content-Type: text/plain', + '', + (string)$file->getStream(), + '--' . $boundary . '--', + '', + ]; + $this->assertSame(implode("\r\n", $expected), $result); + } + + /** + * Test contentType method. + */ + public function testContentType(): void + { + $data = new FormData(); + $data->add('key', 'value'); + $result = $data->contentType(); + $expected = 'application/x-www-form-urlencoded'; + $this->assertSame($expected, $result); + + $file = CORE_PATH . 'VERSION.txt'; + $data = new FormData(); + $data->addFile('upload', fopen($file, 'r')); + $boundary = $data->boundary(); + $result = $data->contentType(); + $expected = 'multipart/form-data; boundary=' . $boundary; + $this->assertSame($expected, $result); + } +} diff --git a/tests/TestCase/Http/Client/RequestTest.php b/tests/TestCase/Http/Client/RequestTest.php new file mode 100644 index 00000000000..eed0ff5b3a3 --- /dev/null +++ b/tests/TestCase/Http/Client/RequestTest.php @@ -0,0 +1,172 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Client; + +use Cake\Http\Client\Request; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * HTTP request test. + */ +class RequestTest extends TestCase +{ + /** + * test string ata, header and constructor + */ + public function testConstructorStringData(): void + { + $headers = [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer valid-token', + ]; + $data = ['a' => 'b', 'c' => 'd']; + $request = new Request('http://example.com', 'POST', $headers, json_encode($data)); + + $this->assertSame('http://example.com', (string)$request->getUri()); + $this->assertStringContainsString($request->getMethod(), 'POST'); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame(json_encode($data), $request->getBody()->__toString()); + } + + /** + * @param array $headers The HTTP headers to set. + * @param array|string|null $data The request body to use. + * @param string $method The HTTP method to use. + */ + #[DataProvider('additionProvider')] + public function testMethods(array $headers, $data, $method): void + { + $request = new Request('http://example.com', $method, $headers, $data); + + $this->assertSame($request->getMethod(), $method); + $this->assertSame('http://example.com', (string)$request->getUri()); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame(json_encode($data), $request->getBody()->__toString()); + } + + public static function additionProvider(): array + { + $headers = [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer valid-token', + ]; + $data = ['a' => 'b', 'c' => 'd']; + + return [ + [$headers, $data, Request::METHOD_POST], + [$headers, $data, Request::METHOD_GET], + [$headers, $data, Request::METHOD_PUT], + [$headers, $data, Request::METHOD_DELETE], + ]; + } + + /** + * test array data, header and constructor + */ + public function testConstructorArrayData(): void + { + $headers = [ + 'Authorization' => 'Bearer valid-token', + ]; + $data = ['a' => 'b', 'c' => 'd']; + $request = new Request('http://example.com', 'POST', $headers, $data); + + $this->assertSame('http://example.com', (string)$request->getUri()); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + $this->assertSame('a=b&c=d', $request->getBody()->__toString()); + + $headers = [ + 'Content-Type' => 'application/xml', + ]; + $data = ['tag' => 'value']; + $request = new Request('http://example.com', 'POST', $headers, $data); + + $this->assertSame('application/xml', $request->getHeaderLine('Content-Type')); + $this->assertSame('value', $request->getBody()->__toString()); + } + + /** + * test nested array data for encoding of brackets, header and constructor + */ + public function testConstructorArrayNestedData(): void + { + $headers = [ + 'Authorization' => 'Bearer valid-token', + ]; + $data = ['a' => 'b', 'c' => ['foo', 'bar']]; + $request = new Request('http://example.com', 'POST', $headers, $data); + + $this->assertSame('http://example.com', (string)$request->getUri()); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + $this->assertSame('a=b&c%5B0%5D=foo&c%5B1%5D=bar', $request->getBody()->__toString()); + } + + /** + * test body method. + */ + public function testBody(): void + { + $data = '{"json":"data"}'; + $request = new Request('', Request::METHOD_GET, [], $data); + + $this->assertSame($data, $request->getBody()->__toString()); + } + + /** + * test body method with array payload + */ + public function testBodyArray(): void + { + $data = [ + 'a' => 'b', + 'c' => 'd', + 'e' => ['f', 'g'], + ]; + $request = new Request('', Request::METHOD_GET, [], $data); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('content-type')); + $this->assertSame( + 'a=b&c=d&e%5B0%5D=f&e%5B1%5D=g', + $request->getBody()->__toString(), + 'Body should be serialized', + ); + } + + /** + * Test that body() modifies the PSR7 stream + */ + public function testBodyInteroperability(): void + { + $request = new Request(); + $this->assertSame('', $request->getBody()->__toString()); + + $data = '{"json":"data"}'; + $request = new Request('', Request::METHOD_GET, [], $data); + $this->assertSame($data, $request->getBody()->__toString()); + } + + /** + * Test the default headers + */ + public function testDefaultHeaders(): void + { + $request = new Request(); + $this->assertSame('CakePHP', $request->getHeaderLine('User-Agent')); + $this->assertSame('close', $request->getHeaderLine('Connection')); + } +} diff --git a/tests/TestCase/Http/Client/ResponseTest.php b/tests/TestCase/Http/Client/ResponseTest.php new file mode 100644 index 00000000000..3314247eb7a --- /dev/null +++ b/tests/TestCase/Http/Client/ResponseTest.php @@ -0,0 +1,435 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Client; + +use Cake\Http\Client\Response; +use Cake\Http\Cookie\CookieCollection; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * HTTP response test. + */ +class ResponseTest extends TestCase +{ + /** + * Test parsing headers and reading with PSR7 methods. + */ + public function testHeaderParsingPsr7(): void + { + $headers = [ + 'HTTP/1.0 200 OK', + 'Content-Type : text/html;charset="UTF-8"', + 'date: Tue, 25 Dec 2012 04:43:47 GMT', + ]; + $response = new Response($headers, 'winner!'); + + $this->assertSame('1.0', $response->getProtocolVersion()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('OK', $response->getReasonPhrase()); + $this->assertSame( + 'text/html;charset="UTF-8"', + $response->getHeaderLine('content-type'), + ); + $this->assertSame( + 'Tue, 25 Dec 2012 04:43:47 GMT', + $response->getHeaderLine('Date'), + ); + $this->assertSame('winner!', '' . $response->getBody()); + } + + /** + * Test parsing headers and capturing content + */ + public function testHeaderParsing(): void + { + $headers = [ + 'HTTP/1.0 200 OK', + 'Content-Type : text/html;charset="UTF-8"', + 'date: Tue, 25 Dec 2012 04:43:47 GMT', + ]; + $response = new Response($headers, 'ok'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('1.0', $response->getProtocolVersion()); + $this->assertSame( + 'text/html;charset="UTF-8"', + $response->getHeaderLine('content-type'), + ); + $this->assertSame( + 'Tue, 25 Dec 2012 04:43:47 GMT', + $response->getHeaderLine('Date'), + ); + + $this->assertSame( + 'text/html;charset="UTF-8"', + $response->getHeaderLine('Content-Type'), + ); + + $headers = [ + 'HTTP/1.0 200', + ]; + $response = new Response($headers, 'ok'); + + $this->assertSame('1.0', $response->getProtocolVersion()); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test getStringBody() + */ + public function getStringBody(): void + { + $response = new Response([], 'string'); + + $this->assertSame('string', $response->getStringBody()); + } + + /** + * Test accessor for JSON + */ + public function testBodyJson(): void + { + $data = [ + 'property' => 'value', + ]; + $encoded = json_encode($data); + $response = new Response([], $encoded); + $this->assertSame($data['property'], $response->getJson()['property']); + + $data = ''; + $response = new Response([], $data); + $this->assertNull($response->getJson()); + + $data = json_encode([]); + $response = new Response([], $data); + $this->assertIsArray($response->getJson()); + + $data = json_encode(null); + $response = new Response([], $data); + $this->assertNull($response->getJson()); + + $data = json_encode(false); + $response = new Response([], $data); + $this->assertFalse($response->getJson()); + + $data = json_encode(''); + $response = new Response([], $data); + $this->assertSame('', $response->getJson()); + } + + /** + * Test accessor for JSON when set with PSR7 methods. + */ + public function testBodyJsonPsr7(): void + { + $data = [ + 'property' => 'value', + ]; + $encoded = json_encode($data); + $response = new Response([], ''); + $response->getBody()->write($encoded); + $this->assertEquals($data, $response->getJson()); + } + + /** + * Test accessor for XML + */ + public function testBodyXml(): void + { + $data = <<<XML +<?xml version="1.0" encoding="utf-8"?> +<root> + <test>Test</test> +</root> +XML; + $response = new Response([], $data); + $this->assertSame('Test', (string)$response->getXml()->test); + + $data = ''; + $response = new Response([], $data); + $this->assertNull($response->getXml()); + } + + /** + * Test isOk() + */ + public function testIsOk(): void + { + $headers = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + ]; + $response = new Response($headers, 'ok'); + $this->assertTrue($response->isOk()); + + $headers = [ + 'HTTP/1.1 201 Created', + 'Content-Type: text/html', + ]; + $response = new Response($headers, 'ok'); + $this->assertTrue($response->isOk()); + + $headers = [ + 'HTTP/1.1 202 Accepted', + 'Content-Type: text/html', + ]; + $response = new Response($headers, 'ok'); + $this->assertTrue($response->isOk()); + + $headers = [ + 'HTTP/1.1 203 Non-Authoritative Information', + 'Content-Type: text/html', + ]; + $response = new Response($headers, 'ok'); + $this->assertTrue($response->isOk()); + + $headers = [ + 'HTTP/1.1 204 No Content', + 'Content-Type: text/html', + ]; + $response = new Response($headers, 'ok'); + $this->assertTrue($response->isOk()); + + $headers = [ + 'HTTP/1.1 301 Moved Permanently', + 'Content-Type: text/html', + ]; + $response = new Response($headers, ''); + $this->assertTrue($response->isOk()); + + $headers = [ + 'HTTP/1.0 404 Not Found', + 'Content-Type: text/html', + ]; + $response = new Response($headers, ''); + $this->assertFalse($response->isOk()); + } + + /** + * provider for isSuccess. + * + * @return array + */ + public static function isSuccessProvider(): array + { + return [ + [ + true, + new Response([ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + ], 'ok'), + ], + [ + true, + new Response([ + 'HTTP/1.1 201 Created', + 'Content-Type: text/html', + ], 'ok'), + ], + [ + true, + new Response([ + 'HTTP/1.1 202 Accepted', + 'Content-Type: text/html', + ], 'ok'), + ], + [ + true, + new Response([ + 'HTTP/1.1 203 Non-Authoritative Information', + 'Content-Type: text/html', + ], 'ok'), + ], + [ + true, + new Response([ + 'HTTP/1.1 204 No Content', + 'Content-Type: text/html', + ], ''), + ], + [ + false, + new Response([ + 'HTTP/1.1 301 Moved Permanently', + 'Content-Type: text/html', + ], ''), + ], + [ + false, + new Response([ + 'HTTP/1.0 404 Not Found', + 'Content-Type: text/html', + ], ''), + ], + ]; + } + + /** + * Test isSuccess() + */ + #[DataProvider('isSuccessProvider')] + public function testIsSuccess(bool $expected, Response $response): void + { + $this->assertEquals($expected, $response->isSuccess()); + } + + /** + * Test isRedirect() + */ + public function testIsRedirect(): void + { + $headers = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/html', + ]; + $response = new Response($headers, 'ok'); + $this->assertFalse($response->isRedirect()); + + $headers = [ + 'HTTP/1.1 301 Moved Permanently', + 'Location: /', + 'Content-Type: text/html', + ]; + $response = new Response($headers, ''); + $this->assertTrue($response->isRedirect()); + + $headers = [ + 'HTTP/1.0 404 Not Found', + 'Content-Type: text/html', + ]; + $response = new Response($headers, ''); + $this->assertFalse($response->isRedirect()); + } + + /** + * Test accessing cookies through the PSR7-like methods + */ + public function testGetCookies(): void + { + $headers = [ + 'HTTP/1.0 200 Ok', + 'Set-Cookie: test=value', + 'Set-Cookie: session=123abc', + 'Set-Cookie: expiring=soon; Expires=Wed, 09-Jun-2021 10:18:14 GMT; Path=/; HttpOnly; Secure;', + ]; + $response = new Response($headers, ''); + + $this->assertNull($response->getCookie('undef')); + $this->assertSame('value', $response->getCookie('test')); + $this->assertSame('soon', $response->getCookie('expiring')); + + $result = $response->getCookieData('expiring'); + $this->assertSame('soon', $result['value']); + $this->assertTrue($result['httponly']); + $this->assertTrue($result['secure']); + $this->assertSame( + strtotime('Wed, 09-Jun-2021 10:18:14 GMT'), + $result['expires'], + ); + $this->assertSame('/', $result['path']); + + $result = $response->getCookies(); + $this->assertCount(3, $result); + $this->assertArrayHasKey('test', $result); + $this->assertArrayHasKey('session', $result); + $this->assertArrayHasKey('expiring', $result); + } + + /** + * Test accessing cookie collection + */ + public function testGetCookieCollection(): void + { + $headers = [ + 'HTTP/1.0 200 Ok', + 'Set-Cookie: test=value', + 'Set-Cookie: session=123abc', + 'Set-Cookie: expiring=soon; Expires=Wed, 09-Jun-2021 10:18:14 GMT; Path=/; HttpOnly; Secure;', + ]; + $response = new Response($headers, ''); + + $cookies = $response->getCookieCollection(); + $this->assertInstanceOf(CookieCollection::class, $cookies); + $this->assertTrue($cookies->has('test')); + $this->assertTrue($cookies->has('session')); + $this->assertTrue($cookies->has('expiring')); + $this->assertSame('123abc', $cookies->get('session')->getValue()); + } + + /** + * Test statusCode() + */ + public function testGetStatusCode(): void + { + $headers = [ + 'HTTP/1.0 404 Not Found', + 'Content-Type: text/html', + ]; + $response = new Response($headers, ''); + $this->assertSame(404, $response->getStatusCode()); + } + + /** + * Test reading the encoding out. + */ + public function testGetEncoding(): void + { + $headers = [ + 'HTTP/1.0 200 Ok', + ]; + $response = new Response($headers, ''); + $this->assertNull($response->getEncoding()); + + $headers = [ + 'HTTP/1.0 200 Ok', + 'Content-Type: text/html', + ]; + $response = new Response($headers, ''); + $this->assertNull($response->getEncoding()); + + $headers = [ + 'HTTP/1.0 200 Ok', + 'Content-Type: text/html; charset="UTF-8"', + ]; + $response = new Response($headers, ''); + $this->assertSame('UTF-8', $response->getEncoding()); + + $headers = [ + 'HTTP/1.0 200 Ok', + "Content-Type: text/html; charset='ISO-8859-1'", + ]; + $response = new Response($headers, ''); + $this->assertSame('ISO-8859-1', $response->getEncoding()); + } + + /** + * Test that gzip responses are automatically decompressed. + */ + public function testAutoDecodeGzipBody(): void + { + $headers = [ + 'HTTP/1.0 200 OK', + 'Content-Encoding: gzip', + 'Content-Length: 32', + 'Content-Type: text/html; charset=UTF-8', + ]; + $body = base64_decode('H4sIAAAAAAAAA/NIzcnJVyjPL8pJUQQAlRmFGwwAAAA='); + $response = new Response($headers, $body); + $this->assertSame('Hello world!', $response->getBody()->getContents()); + } +} diff --git a/tests/TestCase/Http/ClientTest.php b/tests/TestCase/Http/ClientTest.php new file mode 100644 index 00000000000..a6af135cf47 --- /dev/null +++ b/tests/TestCase/Http/ClientTest.php @@ -0,0 +1,1252 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.0.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http; + +use Cake\Core\Exception\CakeException; +use Cake\Http\Client; +use Cake\Http\Client\Adapter\Stream; +use Cake\Http\Client\ClientEvent; +use Cake\Http\Client\Exception\MissingResponseException; +use Cake\Http\Client\Request; +use Cake\Http\Client\Response; +use Cake\Http\Cookie\Cookie; +use Cake\Http\Cookie\CookieCollection; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use Laminas\Diactoros\Request as LaminasRequest; +use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * HTTP client test. + */ +class ClientTest extends TestCase +{ + protected function tearDown(): void + { + parent::tearDown(); + + Client::clearMockResponses(); + } + + /** + * Test storing config options and modifying them. + */ + public function testConstructConfig(): void + { + $config = [ + 'scheme' => 'http', + 'host' => 'example.org', + 'basePath' => '/api/v1', + ]; + $http = new Client($config); + $result = $http->getConfig(); + foreach ($config as $key => $val) { + $this->assertEquals($val, $result[$key]); + } + + $result = $http->setConfig([ + 'auth' => ['username' => 'mark', 'password' => 'secret'], + ]); + $this->assertSame($result, $http); + + $result = $http->getConfig(); + $expected = [ + 'scheme' => 'http', + 'host' => 'example.org', + 'auth' => ['username' => 'mark', 'password' => 'secret'], + 'protocolVersion' => '1.1', + ]; + foreach ($expected as $key => $val) { + $this->assertEquals($val, $result[$key]); + } + } + + /** + * Data provider for buildUrl() tests + * + * @return array + */ + public static function urlProvider(): array + { + return [ + [ + 'http://example.com/test.html', + 'http://example.com/test.html', + [], + null, + 'Null options', + ], + [ + 'http://example.com/test.html', + 'http://example.com/test.html', + [], + [], + 'Simple string', + ], + [ + 'http://example.com/test.html', + '/test.html', + [], + ['host' => 'example.com'], + 'host name option', + ], + [ + 'https://example.com/test.html', + '/test.html', + [], + ['host' => 'example.com', 'scheme' => 'https'], + 'HTTPS', + ], + [ + 'https://example.com/api/v1/foo/test.html', + '/foo/test.html', + [], + ['host' => 'example.com', 'scheme' => 'https', 'basePath' => '/api/v1'], + 'Base path included', + ], + [ + 'https://example.com/api/v1/foo/test.html', + '/foo/test.html', + [], + ['host' => 'example.com', 'scheme' => 'https', 'basePath' => '/api/v1/'], + 'Base path with trailing forward slash', + ], + [ + 'https://example.com/api/v1/foo/test.html', + '/foo/test.html', + [], + ['host' => 'example.com', 'scheme' => 'https', 'basePath' => 'api/v1/'], + 'Base path with no prepended forward slash', + ], + [ + 'http://example.com:8080/test.html', + '/test.html', + [], + ['host' => 'example.com', 'port' => '8080'], + 'Non standard port', + ], + [ + 'http://example.com/test.html', + '/test.html', + [], + ['host' => 'example.com', 'port' => '80'], + 'standard port, does not display', + ], + [ + 'https://example.com/test.html', + '/test.html', + [], + ['host' => 'example.com', 'scheme' => 'https', 'port' => '443'], + 'standard port, does not display', + ], + [ + 'http://example.com/test.html', + 'http://example.com/test.html', + [], + ['host' => 'example.com', 'scheme' => 'https'], + 'options do not duplicate', + ], + [ + 'http://example.com/search?q=hi%20there&cat%5Bid%5D%5B0%5D=2&cat%5Bid%5D%5B1%5D=3', + 'http://example.com/search', + ['q' => 'hi there', 'cat' => ['id' => [2, 3]]], + [], + 'query string data.', + ], + [ + 'http://example.com/search?q=hi+there&id=12', + 'http://example.com/search?q=hi+there', + ['id' => '12'], + [], + 'query string data with some already on the url.', + ], + [ + 'http://example.com/test.html', + '//test.html', + [], + [ + 'scheme' => 'http', + 'host' => 'example.com', + 'protocolRelative' => false, + ], + 'url with a double slash', + ], + [ + 'http://example.com/test.html', + '//example.com/test.html', + [], + [ + 'scheme' => 'http', + 'protocolRelative' => true, + ], + 'protocol relative url', + ], + [ + 'https://example.com/operations?%24filter=operation_id%20eq%2012', + 'https://example.com/operations', + ['$filter' => 'operation_id eq 12'], + [], + 'check the RFC 3986 query encoding', + ], + ]; + } + + #[DataProvider('urlProvider')] + public function testBuildUrl(string $expected, string $url, array $query, ?array $opts, string $type): void + { + $http = new Client(); + + $result = $http->buildUrl($url, $query, (array)$opts); + $this->assertEquals($expected, $result); + } + + /** + * test simple get request with headers & cookies. + */ + public function testGetSimpleWithHeadersAndCookies(): void + { + $response = new Response(); + + $headers = [ + 'User-Agent' => 'Cake', + 'Connection' => 'close', + 'Content-Type' => 'application/x-www-form-urlencoded', + ]; + $cookies = [ + 'split' => 'value', + ]; + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) use ($headers) { + $this->assertInstanceOf(Request::class, $request); + $this->assertSame(Request::METHOD_GET, $request->getMethod()); + $this->assertSame('2', $request->getProtocolVersion()); + $this->assertSame('http://cakephp.org/test.html', $request->getUri() . ''); + $this->assertSame('split=value', $request->getHeaderLine('Cookie')); + $this->assertSame($headers['Content-Type'], $request->getHeaderLine('content-type')); + $this->assertSame($headers['Connection'], $request->getHeaderLine('connection')); + + return true; + }) + ->andReturn([$response]); + + $http = new Client(['adapter' => $mock, 'protocolVersion' => '2']); + $result = $http->get('http://cakephp.org/test.html', [], [ + 'headers' => $headers, + 'cookies' => $cookies, + ]); + $this->assertSame($result, $response); + } + + /** + * test get request with no data + */ + public function testGetNoData(): void + { + $response = new Response(); + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) { + $this->assertSame(Request::METHOD_GET, $request->getMethod()); + $this->assertEmpty($request->getHeaderLine('Content-Type'), 'Should have no content-type set'); + $this->assertSame( + 'http://cakephp.org/search', + $request->getUri() . '', + ); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $result = $http->get('/search'); + $this->assertSame($result, $response); + } + + /** + * test get request with querystring data + */ + public function testGetQuerystring(): void + { + $response = new Response(); + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) { + $this->assertSame(Request::METHOD_GET, $request->getMethod()); + $this->assertSame( + 'http://cakephp.org/search?q=hi%20there&Category%5Bid%5D%5B0%5D=2&Category%5Bid%5D%5B1%5D=3', + $request->getUri() . '', + ); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $result = $http->get('/search', [ + 'q' => 'hi there', + 'Category' => ['id' => [2, 3]], + ]); + $this->assertSame($result, $response); + } + + /** + * test get request with string of query data. + */ + public function testGetQuerystringString(): void + { + $response = new Response(); + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) { + $this->assertSame( + 'http://cakephp.org/search?q=hi+there&Category%5Bid%5D%5B0%5D=2&Category%5Bid%5D%5B1%5D=3', + $request->getUri() . '', + ); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $data = [ + 'q' => 'hi there', + 'Category' => ['id' => [2, 3]], + ]; + $result = $http->get('/search', http_build_query($data)); + $this->assertSame($response, $result); + } + + /** + * Test a GET with a request body. Services like + * elasticsearch use this feature. + */ + public function testGetWithContent(): void + { + $response = new Response(); + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) { + $this->assertSame(Request::METHOD_GET, $request->getMethod()); + $this->assertSame('http://cakephp.org/search', '' . $request->getUri()); + $this->assertSame('some data', '' . $request->getBody()); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $result = $http->get('/search', [ + '_content' => 'some data', + ]); + $this->assertSame($result, $response); + } + + /** + * Test invalid authentication types throw exceptions. + */ + public function testInvalidAuthenticationType(): void + { + $this->expectException(CakeException::class); + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send')->never(); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $http->get('/', [], [ + 'auth' => ['type' => 'horribly broken'], + ]); + } + + /** + * Test setting basic authentication with get + */ + public function testGetWithAuthenticationAndProxy(): void + { + $response = new Response(); + + $mock = Mockery::mock(Stream::class); + $headers = [ + 'Authorization' => 'Basic ' . base64_encode('mark:secret'), + 'Proxy-Authorization' => 'Basic ' . base64_encode('mark:pass'), + ]; + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) use ($headers) { + $this->assertSame(Request::METHOD_GET, $request->getMethod()); + $this->assertSame('http://cakephp.org/', '' . $request->getUri()); + $this->assertSame($headers['Authorization'], $request->getHeaderLine('Authorization')); + $this->assertSame($headers['Proxy-Authorization'], $request->getHeaderLine('Proxy-Authorization')); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $result = $http->get('/', [], [ + 'auth' => ['username' => 'mark', 'password' => 'secret'], + 'proxy' => ['username' => 'mark', 'password' => 'pass'], + ]); + $this->assertSame($result, $response); + } + + /** + * Return a list of HTTP methods. + * + * @return array + */ + public static function methodProvider(): array + { + return [ + [Request::METHOD_GET], + [Request::METHOD_POST], + [Request::METHOD_PUT], + [Request::METHOD_DELETE], + [Request::METHOD_PATCH], + [Request::METHOD_OPTIONS], + [Request::METHOD_TRACE], + ]; + } + + /** + * test simple POST request. + */ + #[DataProvider('methodProvider')] + public function testMethodsSimple(string $method): void + { + $response = new Response(); + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) use ($method) { + $this->assertInstanceOf(Request::class, $request); + $this->assertEquals($method, $request->getMethod()); + $this->assertSame('http://cakephp.org/projects/add', '' . $request->getUri()); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $result = $http->{$method}('/projects/add'); + $this->assertSame($result, $response); + } + + /** + * Provider for testing the type option. + * + * @return array + */ + public static function typeProvider(): array + { + return [ + ['application/json', 'application/json'], + ['json', 'application/json'], + ['xml', 'application/xml'], + ['application/xml', 'application/xml'], + ]; + } + + /** + * Test that using the 'type' option sets the correct headers + */ + #[DataProvider('typeProvider')] + public function testPostWithTypeKey(string $type, string $mime): void + { + $response = new Response(); + $data = 'some data'; + $headers = [ + 'Content-Type' => $mime, + 'Accept' => $mime, + ]; + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) use ($headers) { + $this->assertSame(Request::METHOD_POST, $request->getMethod()); + $this->assertEquals($headers['Content-Type'], $request->getHeaderLine('Content-Type')); + $this->assertEquals($headers['Accept'], $request->getHeaderLine('Accept')); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $http->post('/projects/add', $data, ['type' => $type]); + } + + public function testPostWithContentType(): void + { + $response = new Response(); + $headers = [ + 'Content-Type' => 'application/octet-stream', + ]; + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) use ($headers) { + $this->assertSame(Request::METHOD_POST, $request->getMethod()); + $this->assertEquals($headers['Content-Type'], $request->getHeaderLine('Content-Type')); + $this->assertEquals('', (string)$request->getBody()); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'adapter' => $mock, + ]); + $http->post( + 'https://example.org/2/files/upload', + [], + [ + 'headers' => [ + 'Content-Type' => 'application/octet-stream', + ], + ], + ); + } + + public function testPostWithZero(): void + { + $response = new Response(); + $headers = [ + 'Content-Type' => 'application/octet-stream', + ]; + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) use ($headers) { + $this->assertSame(Request::METHOD_POST, $request->getMethod()); + $this->assertEquals($headers['Content-Type'], $request->getHeaderLine('Content-Type')); + $this->assertEquals('0', (string)$request->getBody()); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'adapter' => $mock, + ]); + $http->post( + 'https://example.org/2/files/upload', + '0', + [ + 'headers' => [ + 'Content-Type' => 'application/octet-stream', + ], + ], + ); + } + + /** + * Test that string payloads with no content type have a default content-type set. + */ + public function testPostWithStringDataDefaultsToFormEncoding(): void + { + $response = new Response(); + $data = 'some=value&more=data'; + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->times(3) + ->withArgs(function ($request) use ($data) { + $this->assertSame($data, '' . $request->getBody()); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('content-type')); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $http->post('/projects/add', $data); + $http->put('/projects/add', $data); + $http->delete('/projects/add', $data); + } + + /** + * Test that exceptions are raised on invalid types. + */ + public function testExceptionOnUnknownType(): void + { + $this->expectException(CakeException::class); + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send')->never(); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $http->post('/projects/add', 'it works', ['type' => 'invalid']); + } + + /** + * Test that Client stores cookies + */ + public function testCookieStorage(): void + { + $adapter = Mockery::mock(Stream::class); + + $headers = [ + 'HTTP/1.0 200 Ok', + 'Set-Cookie: first=1', + 'Set-Cookie: expiring=now; Expires=Wed, 09-Jun-1999 10:18:14 GMT', + ]; + $response = new Response($headers, ''); + $adapter->shouldReceive('send') + ->once() + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $adapter, + ]); + + $http->get('/projects'); + $cookies = $http->cookies(); + $this->assertCount(1, $cookies); + $this->assertTrue($cookies->has('first')); + $this->assertFalse($cookies->has('expiring')); + } + + /** + * Test cookieJar config option. + */ + public function testCookieJar(): void + { + $jar = new CookieCollection(); + $http = new Client([ + 'cookieJar' => $jar, + ]); + + $this->assertSame($jar, $http->cookies()); + } + + /** + * Test addCookie() method. + */ + public function testAddCookie(): void + { + $client = new Client(); + $cookie = new Cookie('foo', '', null, '/', 'example.com'); + + $this->assertFalse($client->cookies()->has('foo')); + + $client->addCookie($cookie); + $this->assertTrue($client->cookies()->has('foo')); + } + + /** + * Test addCookie() method without a domain. + */ + public function testAddCookieWithoutDomain(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cookie must have a domain and a path set.'); + $client = new Client(); + $cookie = new Cookie('foo', '', null, '/', ''); + + $this->assertFalse($client->cookies()->has('foo')); + + $client->addCookie($cookie); + $this->assertTrue($client->cookies()->has('foo')); + } + + /** + * Test addCookie() method without a path. + */ + public function testAddCookieWithoutPath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cookie must have a domain and a path set.'); + $client = new Client(); + $cookie = new Cookie('foo', '', null, '', 'example.com'); + + $this->assertFalse($client->cookies()->has('foo')); + + $client->addCookie($cookie); + $this->assertTrue($client->cookies()->has('foo')); + } + + /** + * test head request with querystring data + */ + public function testHeadQuerystring(): void + { + $response = new Response(); + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) { + $this->assertInstanceOf(Request::class, $request); + $this->assertSame(Request::METHOD_HEAD, $request->getMethod()); + $this->assertSame('http://cakephp.org/search?q=hi%20there', '' . $request->getUri()); + + return true; + }) + ->andReturn([$response]); + + $http = new Client([ + 'host' => 'cakephp.org', + 'adapter' => $mock, + ]); + $result = $http->head('/search', [ + 'q' => 'hi there', + ]); + $this->assertSame($result, $response); + } + + /** + * test redirects + */ + public function testRedirects(): void + { + $url = 'http://cakephp.org'; + + $adapter = Mockery::mock(Client\Adapter\Stream::class); + + $redirect = new Response([ + 'HTTP/1.0 301', + 'Location: http://cakephp.org/redirect1?foo=bar', + 'Set-Cookie: redirect1=true;path=/', + ]); + + $redirect2 = new Response([ + 'HTTP/1.0 301', + 'Location: /redirect2#foo', + 'Set-Cookie: redirect2=true;path=/', + ]); + + $response = new Response([ + 'HTTP/1.0 200', + ]); + + $adapter->shouldReceive('send') + ->withArgs(function (Request $request, $options) use ($url) { + $this->assertSame($url, (string)$request->getUri()); + $this->assertArrayNotHasKey('redirect', $options); + + return true; + }) + ->andReturn([$redirect]) + ->once(); + + $adapter->shouldReceive('send') + ->withArgs(function (Request $request, $options) use ($url) { + $this->assertSame($url . '/redirect1?foo=bar', (string)$request->getUri()); + $this->assertArrayNotHasKey('redirect', $options); + + return true; + }) + ->andReturn([$redirect2]) + ->once(); + + $adapter->shouldReceive('send') + ->withArgs(function (Request $request, $options) use ($url) { + $this->assertSame($url . '/redirect2#foo', (string)$request->getUri()); + $this->assertSame([], $options); + + return true; + }) + ->andReturn([$response]) + ->once(); + + $client = new Client([ + 'adapter' => $adapter, + ]); + + $result = $client->send(new Request($url), [ + 'redirect' => 10, + ]); + + $this->assertInstanceOf(Response::class, $result); + $this->assertTrue($result->isOk()); + $cookies = $client->cookies(); + + $this->assertTrue($cookies->has('redirect1')); + $this->assertTrue($cookies->has('redirect2')); + } + + /** + * testSendRequest + */ + public function testSendRequest(): void + { + $response = new Response(); + + $headers = [ + 'User-Agent' => 'Cake', + 'Connection' => 'close', + 'Content-Type' => 'application/x-www-form-urlencoded', + ]; + + $mock = Mockery::mock(Stream::class); + $mock->shouldReceive('send') + ->once() + ->withArgs(function ($request) use ($headers) { + $this->assertInstanceOf(LaminasRequest::class, $request); + $this->assertSame(Request::METHOD_GET, $request->getMethod()); + $this->assertSame('http://cakephp.org/test.html', $request->getUri() . ''); + $this->assertSame($headers['Content-Type'], $request->getHeaderLine('content-type')); + $this->assertSame($headers['Connection'], $request->getHeaderLine('connection')); + + return true; + }) + ->andReturn([$response]); + + $http = new Client(['adapter' => $mock]); + $request = new LaminasRequest( + 'http://cakephp.org/test.html', + Request::METHOD_GET, + 'php://temp', + $headers, + ); + $result = $http->sendRequest($request); + + $this->assertSame($result, $response); + } + + public function testBeforeSend(): void + { + $eventTriggered = false; + $client = new Client(); + $client->getEventManager()->on( + 'HttpClient.beforeSend', + function (ClientEvent $event, Request $request, array $adapterOptions, int $redirects) use (&$eventTriggered): void { + $eventTriggered = true; + }, + ); + + Client::addMockResponse('GET', 'http://foo.test', new Response(body: 'test')); + + $response = $client->get('http://foo.test', options: ['some' => 'thing']); + $this->assertSame('test', $response->getStringBody()); + $this->assertTrue($eventTriggered); + } + + public function testBeforeSendModifyRequest(): void + { + $client = new Client(); + + $client->getEventManager()->on( + 'HttpClient.beforeSend', + function (ClientEvent $event, Request $request, array $adapterOptions, int $redirects): void { + $event->setRequest(new Request('http://bar.test')); + $event->setAdapterOptions(['some' => 'value']); + }, + ); + + Client::addMockResponse( + 'GET', + 'http://bar.test', + new Response(body: 'other'), + ['match' => function (Request $request, array $options) { + $this->assertSame(['some' => 'value'], $options); + + return true; + }], + ); + + $response = $client->get('http://foo.test'); + $this->assertSame('other', $response->getStringBody()); + } + + public function testBeforeSendReturnResponse(): void + { + $client = new Client(); + + $client->getEventManager()->on( + 'HttpClient.beforeSend', + function (ClientEvent $event, Request $request, array $adapterOptions, int $redirects): void { + $event->setResult(new Response(body: 'short circuit')); + }, + ); + + $client->getEventManager()->on( + 'HttpClient.afterSend', + function (ClientEvent $event, Request $request, array $adapterOptions, int $redirects): void { + $this->assertFalse($event->getData('requestSent')); + }, + ); + + $response = $client->get('http://foo.test'); + $this->assertSame('short circuit', $response->getStringBody()); + } + + public function testAfterSendModifyResponse(): void + { + $client = new Client(); + + $client->getEventManager()->on( + 'HttpClient.afterSend', + function (ClientEvent $event, Request $request, array $adapterOptions, int $redirects): void { + $event->setResult(new Response(body: 'modified response')); + }, + ); + + Client::addMockResponse('GET', 'http://foo.test', new Response(body: 'response text')); + + $response = $client->get('http://foo.test'); + $this->assertSame('modified response', $response->getStringBody()); + } + + /** + * test redirect across sub domains + */ + public function testRedirectDifferentSubDomains(): void + { + $adapter = Mockery::mock(Client\Adapter\Stream::class); + + $url = 'http://auth.example.org'; + + $redirect = new Response([ + 'HTTP/1.0 301', + 'Location: http://backstage.example.org', + ]); + $response = new Response([ + 'HTTP/1.0 200', + ]); + + $adapter->shouldReceive('send') + ->andReturn([$redirect]) + ->once(); + + $adapter->shouldReceive('send') + ->withArgs(function ($request) { + $this->assertSame('http://backstage.example.org', (string)$request->getUri()); + $this->assertSame('session=backend', $request->getHeaderLine('Cookie')); + + return true; + }) + ->andReturn([$response]) + ->once(); + + $client = new Client([ + 'adapter' => $adapter, + ]); + $client->addCookie(new Cookie('session', 'backend', null, '/', 'backstage.example.org')); + $client->addCookie(new Cookie('session', 'authz', null, '/', 'auth.example.org')); + + $result = $client->send(new Request($url), [ + 'redirect' => 10, + ]); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame($response, $result); + } + + /** + * Scheme is set when passed to client in string + */ + public function testCreateFromUrlSetsScheme(): void + { + $client = Client::createFromUrl('https://example.co/'); + $this->assertSame('https', $client->getConfig('scheme')); + } + + /** + * Host is set when passed to client in string + */ + public function testCreateFromUrlSetsHost(): void + { + $client = Client::createFromUrl('https://example.co/'); + $this->assertSame('example.co', $client->getConfig('host')); + } + + /** + * basePath is set when passed to client in string + */ + public function testCreateFromUrlSetsBasePath(): void + { + $client = Client::createFromUrl('https://example.co/api/v1'); + $this->assertSame('/api/v1', $client->getConfig('basePath')); + } + + /** + * Test exception is thrown when URL cannot be parsed + */ + public function testCreateFromUrlThrowsInvalidExceptionWhenUrlCannotBeParsed(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('String `htps://` did not parse.'); + Client::createFromUrl('htps://'); + } + + /** + * Port is set when passed to client in string + */ + public function testCreateFromUrlSetsPort(): void + { + $client = Client::createFromUrl('https://example.co:8765/'); + $this->assertSame(8765, $client->getConfig('port')); + } + + /** + * Test exception is throw when no scheme is provided. + */ + public function testCreateFromUrlThrowsInvalidArgumentExceptionWhenNoSchemeProvided(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The URL was parsed but did not contain a scheme or host'); + Client::createFromUrl('example.co'); + } + + /** + * Test exception is thrown if passed URL has no domain + */ + public function testCreateFromUrlThrowsInvalidArgumentExceptionWhenNoDomainProvided(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The URL was parsed but did not contain a scheme or host'); + Client::createFromUrl('/api/v1'); + } + + /** + * Test that the passed parsed URL parts won't override other constructor defaults + * or add undefined configuration + */ + public function testCreateFromUrlOnlySetSchemePortHostBasePath(): void + { + $client = Client::createFromUrl('http://example.co:80/some/uri/?foo=bar'); + $config = $client->getConfig(); + $expected = [ + 'auth' => null, + 'adapter' => null, + 'host' => 'example.co', + 'port' => 80, + 'scheme' => 'http', + 'basePath' => '/some/uri/', + 'timeout' => 30, + 'ssl_verify_peer' => true, + 'ssl_verify_peer_name' => true, + 'ssl_verify_depth' => 5, + 'ssl_verify_host' => true, + 'redirect' => false, + 'protocolVersion' => '1.1', + ]; + $this->assertSame($expected, $config); + } + + /** + * Test adding and sending to a mocked URL. + */ + public function testAddMockResponseSimpleMatch(): void + { + $stub = new Response(['HTTP/1.0 200'], 'hello world'); + Client::addMockResponse('POST', 'http://example.com/path', $stub); + + $client = new Client(); + $response = $client->post('http://example.com/path'); + $this->assertSame($stub, $response); + } + + /** + * When there are multiple matches for a URL the responses should + * be used in a cycle. + */ + public function testAddMockResponseMultipleMatches(): void + { + $one = new Response(['HTTP/1.0 200'], 'one'); + Client::addMockResponse('GET', 'http://example.com/info', $one); + + $two = new Response(['HTTP/1.0 200'], 'two'); + Client::addMockResponse('GET', 'http://example.com/info', $two); + + $client = new Client(); + + $response = $client->get('http://example.com/info'); + $this->assertSame($one, $response); + + $response = $client->get('http://example.com/info'); + $this->assertSame($two, $response); + + $response = $client->get('http://example.com/info'); + $this->assertSame($one, $response); + } + + /** + * When there are multiple matches with custom match functions + */ + public function testAddMockResponseMultipleMatchesCustom(): void + { + $one = new Response(['HTTP/1.0 200'], 'one'); + Client::addMockResponse('GET', 'http://example.com/info', $one, [ + 'match' => function ($request) { + return false; + }, + ]); + + $two = new Response(['HTTP/1.0 200'], 'two'); + Client::addMockResponse('GET', 'http://example.com/info', $two); + + $client = new Client(); + + $response = $client->get('http://example.com/info'); + $this->assertSame($two, $response); + + $response = $client->get('http://example.com/info'); + $this->assertSame($two, $response); + } + + /** + * Mock match failures should result in the request being sent + */ + public function testAddMockResponseMethodMatchFailure(): void + { + $stub = new Response(['HTTP/1.0 200'], 'hello world'); + Client::addMockResponse('POST', 'http://example.com/path', $stub); + + $client = new Client(); + $this->expectException(MissingResponseException::class); + $this->expectExceptionMessage('Unable to find a mock'); + + $client->get('http://example.com/path'); + } + + /** + * Trailing /* patterns should work + */ + public function testAddMockResponseGlobMatch(): void + { + $stub = new Response(['HTTP/1.0 200'], 'hello world'); + Client::addMockResponse('POST', 'http://example.com/path/*', $stub); + + $client = new Client(); + $response = $client->post('http://example.com/path/more/thing'); + $this->assertSame($stub, $response); + + $client = new Client(); + $response = $client->post('http://example.com/path/?query=value'); + $this->assertSame($stub, $response); + } + + /** + * Custom match methods must be closures + */ + public function testAddMockResponseInvalidMatch(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `match` option must be a `Closure`.'); + + $stub = new Response(['HTTP/1.0 200'], 'hello world'); + Client::addMockResponse('POST', 'http://example.com/path', $stub, [ + 'match' => 'oops', + ]); + } + + /** + * Custom matchers should get a request. + */ + public function testAddMockResponseCustomMatch(): void + { + $stub = new Response(['HTTP/1.0 200'], 'hello world'); + Client::addMockResponse('POST', 'http://example.com/path', $stub, [ + 'match' => function ($request) { + $this->assertInstanceOf(Request::class, $request); + $uri = $request->getUri(); + $this->assertEquals('/path', $uri->getPath()); + $this->assertEquals('example.com', $uri->getHost()); + + return true; + }, + ]); + + $client = new Client(); + $response = $client->post('http://example.com/path'); + + $this->assertSame($stub, $response); + } + + /** + * Custom matchers can fail the match + */ + public function testAddMockResponseCustomNoMatch(): void + { + $stub = new Response(['HTTP/1.0 200'], 'hello world'); + Client::addMockResponse('POST', 'http://example.com/path', $stub, [ + 'match' => function () { + return false; + }, + ]); + + $client = new Client(); + $this->expectException(MissingResponseException::class); + $this->expectExceptionMessage('Unable to find a mock'); + + $client->post('http://example.com/path'); + } + + /** + * Custom matchers must return a boolean + */ + public function testAddMockResponseCustomInvalidDecision(): void + { + $stub = new Response(['HTTP/1.0 200'], 'hello world'); + Client::addMockResponse('POST', 'http://example.com/path', $stub, [ + 'match' => function ($request) { + return 'invalid'; + }, + ]); + + $client = new Client(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Match callback must'); + + $client->post('http://example.com/path'); + } +} diff --git a/tests/TestCase/Http/ContentTypeNegotiationTest.php b/tests/TestCase/Http/ContentTypeNegotiationTest.php new file mode 100644 index 00000000000..ba4c442e0e0 --- /dev/null +++ b/tests/TestCase/Http/ContentTypeNegotiationTest.php @@ -0,0 +1,186 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Http; + +use Cake\Http\ContentTypeNegotiation; +use Cake\Http\ServerRequest; +use Cake\TestSuite\TestCase; + +class ContentTypeNegotiationTest extends TestCase +{ + public function testPreferredTypeNoAccept(): void + { + $request = new ServerRequest([ + 'url' => '/dashboard', + ]); + $content = new ContentTypeNegotiation(); + $this->assertNull($content->preferredType($request)); + + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => '', + ], + ]); + $this->assertNull($content->preferredType($request)); + } + + public function testPreferredTypeFirefoxHtml(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + ], + ]); + $this->assertEquals('text/html', $content->preferredType($request)); + $this->assertEquals('text/html', $content->preferredType($request, ['text/html', 'application/xml'])); + $this->assertEquals('application/xml', $content->preferredType($request, ['application/xml'])); + $this->assertNull($content->preferredType($request, ['application/json'])); + } + + public function testPreferredTypeFirstMatch(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'application/json', + ], + ]); + $this->assertEquals('application/json', $content->preferredType($request)); + + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'application/json,application/xml', + ], + ]); + $this->assertEquals('application/json', $content->preferredType($request)); + } + + public function testPreferredTypeQualValue(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'text/xml,application/xml,application/xhtml+xml,' . + 'text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5', + ], + ]); + $this->assertEquals('text/xml', $content->preferredType($request)); + + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'text/plain;q=0.8,application/json;q=0.9', + ], + ]); + $this->assertEquals('application/json', $content->preferredType($request)); + } + + public function testPreferredTypeSimple(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'application/json', + ], + ]); + $this->assertNull($content->preferredType($request, ['text/html'])); + + $request = $request->withEnv('HTTP_ACCEPT', 'application/json'); + $this->assertEquals( + 'application/json', + $content->preferredType($request, ['text/html', 'application/json']), + ); + } + + public function testParseAccept(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'application/json;q=0.5,application/xml;q=0.6,application/pdf;q=0.3', + ], + ]); + $result = $content->parseAccept($request); + $expected = [ + '0.6' => ['application/xml'], + '0.5' => ['application/json'], + '0.3' => ['application/pdf'], + ]; + $this->assertEquals($expected, $result); + + $request = $request->withEnv( + 'HTTP_ACCEPT', + 'application/pdf;q=0.3,application/json;q=0.5,application/xml;q=0.5', + ); + $result = $content->parseAccept($request); + $expected = [ + '0.5' => ['application/json', 'application/xml'], + '0.3' => ['application/pdf'], + ]; + $this->assertEquals($expected, $result, 'Sorting is incorrect.'); + } + + public function testParseAcceptLanguage(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT_LANGUAGE' => '', + ], + ]); + $this->assertEmpty($content->parseAcceptLanguage($request)); + + $request = $request->withEnv('HTTP_ACCEPT_LANGUAGE', 'es_mx,en_ca'); + $expected = [ + '1.0' => ['es_mx', 'en_ca'], + ]; + $this->assertEquals($expected, $content->parseAcceptLanguage($request)); + + $request = $request->withEnv('HTTP_ACCEPT_LANGUAGE', 'en-US,en;q=0.8,pt-BR;q=0.6,pt;q=0.4'); + $expected = [ + '1.0' => ['en-US'], + '0.8' => ['en'], + '0.6' => ['pt-BR'], + '0.4' => ['pt'], + ]; + $this->assertEquals($expected, $content->parseAcceptLanguage($request)); + } + + public function testAcceptLanguage(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT_LANGUAGE' => 'en_US,en_CA', + ], + ]); + $this->assertFalse($content->acceptLanguage($request, 'es-mx')); + $this->assertTrue($content->acceptLanguage($request, 'en-ca')); + $this->assertTrue($content->acceptLanguage($request, 'en-CA'), 'Input code is lowercased'); + $this->assertFalse($content->acceptLanguage($request, 'en_CA'), 'Input code not normalized'); + } + + public function testAcceptedLanguage(): void + { + $content = new ContentTypeNegotiation(); + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT_LANGUAGE' => 'pt-BR;q=0.6,en_US,en_CA;q=0.8', + ], + ]); + $expected = ['en-us', 'en-ca', 'pt-br']; + $this->assertEquals($expected, $content->acceptedLanguages($request)); + } +} diff --git a/tests/TestCase/Http/Cookie/CookieCollectionTest.php b/tests/TestCase/Http/Cookie/CookieCollectionTest.php new file mode 100644 index 00000000000..92a8b6665de --- /dev/null +++ b/tests/TestCase/Http/Cookie/CookieCollectionTest.php @@ -0,0 +1,518 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.5.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Cookie; + +use Cake\Http\Client\Request as ClientRequest; +use Cake\Http\Cookie\Cookie; +use Cake\Http\Cookie\CookieCollection; +use Cake\Http\Response; +use Cake\Http\ServerRequest; +use Cake\TestSuite\TestCase; +use Cake\Utility\Security; +use DateTime; +use InvalidArgumentException; + +/** + * Cookie collection test. + */ +class CookieCollectionTest extends TestCase +{ + /** + * Test constructor + */ + public function testConstructorWithEmptyArray(): void + { + $collection = new CookieCollection([]); + $this->assertCount(0, $collection); + } + + /** + * Test valid cookies + */ + public function testConstructorWithCookieArray(): void + { + $cookies = [ + new Cookie('one', 'one'), + new Cookie('two', 'two'), + ]; + + $collection = new CookieCollection($cookies); + $this->assertCount(2, $collection); + } + + /** + * Test iteration + */ + public function testIteration(): void + { + $cookies = [ + new Cookie('remember_me', 'a'), + new Cookie('gtm', 'b'), + new Cookie('three', 'tree'), + ]; + + $collection = new CookieCollection($cookies); + $names = []; + foreach ($collection as $cookie) { + $names[] = $cookie->getName(); + } + $this->assertSame(['remember_me', 'gtm', 'three'], $names); + } + + /** + * Test adding cookies + */ + public function testAdd(): void + { + $cookies = []; + + $collection = new CookieCollection($cookies); + $this->assertCount(0, $collection); + + $remember = new Cookie('remember_me', 'a'); + $new = $collection->add($remember); + $this->assertNotSame($new, $collection->add($remember)); + $this->assertCount(0, $collection, 'Original instance not modified'); + $this->assertCount(1, $new); + $this->assertFalse($collection->has('remember_me'), 'Original instance not modified'); + $this->assertTrue($new->has('remember_me')); + $this->assertSame($remember, $new->get('remember_me')); + } + + /** + * Cookie collections need to support duplicate cookie names because + * of use cases in Http\Client + */ + public function testAddDuplicates(): void + { + $remember = new Cookie('remember_me', 'yes'); + $rememberNo = new Cookie('remember_me', 'no', null, '/path2'); + $this->assertNotEquals($remember->getId(), $rememberNo->getId(), 'Cookies should have different ids'); + + $collection = new CookieCollection([]); + $new = $collection->add($remember)->add($rememberNo); + + $this->assertCount(2, $new, 'Cookies with different ids create duplicates.'); + $this->assertNotSame($new, $collection); + $this->assertSame($remember, $new->get('remember_me'), 'get() fetches first cookie'); + } + + /** + * Test has() + */ + public function testHas(): void + { + $cookies = [ + new Cookie('remember_me', 'a'), + new Cookie('gtm', 'b'), + ]; + + $collection = new CookieCollection($cookies); + $this->assertFalse($collection->has('nope')); + $this->assertTrue($collection->has('remember_me')); + $this->assertTrue($collection->has('REMEMBER_me'), 'case insensitive cookie names'); + } + + /** + * Tests the magic __isset() and __get() methods + */ + public function testMagicIssetAndGet(): void + { + $cookies = [ + new Cookie('remember_me', 'a'), + new Cookie('gtm', 'b'), + ]; + + $collection = new CookieCollection($cookies); + + $this->assertFalse(isset($collection->nope)); + $this->assertTrue(isset($collection->remember_me)); + $this->assertTrue(isset($collection->REMEMBER_me)); + + $this->assertEquals('a', $collection->remember_me->getValue()); + $this->assertEquals('b', $collection->GTM->getValue()); + $this->assertNull($collection->nope); + } + + /** + * Test removing cookies + */ + public function testRemove(): void + { + $cookies = [ + new Cookie('remember_me', 'a'), + new Cookie('gtm', 'b'), + ]; + + $collection = new CookieCollection($cookies); + $this->assertInstanceOf(Cookie::class, $collection->get('REMEMBER_me'), 'case insensitive cookie names'); + $new = $collection->remove('remember_me'); + $this->assertTrue($collection->has('remember_me'), 'old instance not modified'); + + $this->assertNotSame($new, $collection); + $this->assertFalse($new->has('remember_me'), 'should be removed'); + + $this->expectException(InvalidArgumentException::class); + $new->get('remember_me'); + } + + /** + * Test getting cookies by name + */ + public function testGetByName(): void + { + $cookies = [ + new Cookie('remember_me', 'a'), + new Cookie('gtm', 'b'), + ]; + + $collection = new CookieCollection($cookies); + $this->assertFalse($collection->has('nope')); + $this->assertInstanceOf(Cookie::class, $collection->get('REMEMBER_me'), 'case insensitive cookie names'); + $this->assertInstanceOf(Cookie::class, $collection->get('remember_me')); + $this->assertSame($cookies[0], $collection->get('remember_me')); + } + + /** + * Test that the constructor takes only an array of objects implementing + * the CookieInterface + */ + public function testConstructorWithInvalidCookieObjects(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected `Cake\Http\Cookie\CookieCollection[]` as $cookies but instead got `array` at index 1'); + $array = [ + new Cookie('one', 'one'), + [], + ]; + + new CookieCollection($array); + } + + /** + * Test adding cookies from a response. + */ + public function testAddFromResponse(): void + { + $collection = new CookieCollection(); + $request = new ServerRequest([ + 'url' => '/app', + ]); + $response = (new Response()) + ->withAddedHeader('Set-Cookie', 'test=value') + ->withAddedHeader('Set-Cookie', 'expiring=soon; Expires=Mon, 09-Jun-2031 10:18:14 GMT; Path=/; HttpOnly; Secure;') + ->withAddedHeader('Set-Cookie', 'session=123abc; Domain=www.example.com') + ->withAddedHeader('Set-Cookie', 'maxage=value; Max-Age=60; Expires=Mon, 09-Jun-2031 10:18:14 GMT;'); + $new = $collection->addFromResponse($response, $request); + $this->assertNotSame($new, $collection, 'Should clone collection'); + + $this->assertTrue($new->has('test')); + $this->assertTrue($new->has('session')); + $this->assertTrue($new->has('expiring')); + $this->assertSame('value', $new->get('test')->getValue()); + $this->assertSame('123abc', $new->get('session')->getValue()); + $this->assertSame('soon', $new->get('expiring')->getValue()); + $this->assertSame('value', $new->get('maxage')->getValue()); + + $this->assertSame('/app', $new->get('test')->getPath(), 'cookies should inherit request path'); + $this->assertSame('/', $new->get('expiring')->getPath(), 'path attribute should be used.'); + + $this->assertNull($new->get('test')->getExpiry(), 'No expiry'); + $this->assertSame( + '2031-06-09 10:18:14', + $new->get('expiring')->getExpiry()->format('Y-m-d H:i:s'), + 'Has expiry', + ); + $session = $new->get('session'); + $this->assertNull($session->getExpiry(), 'No expiry'); + $this->assertSame('www.example.com', $session->getDomain(), 'Has domain'); + + $maxage = $new->get('maxage'); + $this->assertLessThanOrEqual( + (new DateTime('60 seconds'))->format('Y-m-d H:i:s'), + $maxage->getExpiry()->format('Y-m-d H:i:s'), + 'Has max age', + ); + } + + /** + * Test adding cookies that contain URL encoded data + */ + public function testAddFromResponseValueUrldecodeData(): void + { + $collection = new CookieCollection(); + $request = new ServerRequest([ + 'url' => '/app', + ]); + $response = (new Response()) + ->withAddedHeader('Set-Cookie', 'test=val%3Bue; Path=/example; Secure;'); + $new = $collection->addFromResponse($response, $request); + $this->assertTrue($new->has('test')); + + $test = $new->get('test'); + $this->assertSame('val;ue', $test->getValue()); + $this->assertSame('/example', $test->getPath()); + } + + /** + * Test adding cookies from a response ignores empty headers + */ + public function testAddFromResponseIgnoreEmpty(): void + { + $collection = new CookieCollection(); + $request = new ServerRequest([ + 'url' => '/app', + ]); + $response = (new Response()) + ->withAddedHeader('Set-Cookie', ''); + $new = $collection->addFromResponse($response, $request); + $this->assertCount(0, $new, 'no cookies parsed'); + } + + /** + * Test adding cookies from a response ignores expired cookies + */ + public function testAddFromResponseIgnoreExpired(): void + { + $collection = new CookieCollection(); + $request = new ServerRequest([ + 'url' => '/app', + ]); + $response = (new Response()) + ->withAddedHeader('Set-Cookie', 'test=value') + ->withAddedHeader('Set-Cookie', 'expired=soon; Expires=Wed, 09-Jun-2012 10:18:14 GMT; Path=/;'); + $new = $collection->addFromResponse($response, $request); + $this->assertFalse($new->has('expired'), 'Should drop expired cookies'); + } + + /** + * Test adding cookies from a response removes existing cookies if + * the new response marks them as expired. + */ + public function testAddFromResponseRemoveExpired(): void + { + $collection = new CookieCollection([ + new Cookie('expired', 'not yet', null, '/', 'example.com'), + ]); + $request = new ServerRequest([ + 'url' => '/app', + 'environment' => [ + 'HTTP_HOST' => 'example.com', + ], + ]); + $response = (new Response()) + ->withAddedHeader('Set-Cookie', 'test=value') + ->withAddedHeader('Set-Cookie', 'expired=soon; Expires=Wed, 09-Jun-2012 10:18:14 GMT; Path=/;'); + $new = $collection->addFromResponse($response, $request); + $this->assertFalse($new->has('expired'), 'Should drop expired cookies'); + } + + /** + * Test adding cookies from a response with bad expires values + */ + public function testAddFromResponseInvalidExpires(): void + { + $collection = new CookieCollection(); + $request = new ServerRequest([ + 'url' => '/app', + ]); + $response = (new Response()) + ->withAddedHeader('Set-Cookie', 'test=value') + ->withAddedHeader('Set-Cookie', 'expired=no; Expires=1w; Path=/; HttpOnly; Secure;'); + $new = $collection->addFromResponse($response, $request); + $this->assertTrue($new->has('test')); + $this->assertTrue($new->has('expired')); + $expired = $new->get('expired'); + $this->assertNull($expired->getExpiry()); + } + + /** + * Test adding cookies from responses updates cookie values. + */ + public function testAddFromResponseUpdateExisting(): void + { + $collection = new CookieCollection([ + new Cookie('key', 'old value', null, '/', 'example.com'), + ]); + $request = new ServerRequest([ + 'url' => '/', + 'environment' => [ + 'HTTP_HOST' => 'example.com', + ], + ]); + $response = (new Response())->withAddedHeader('Set-Cookie', 'key=new value'); + $new = $collection->addFromResponse($response, $request); + $this->assertTrue($new->has('key')); + $this->assertSame('new value', $new->get('key')->getValue()); + } + + /** + * Test adding cookies from the collection to request. + */ + public function testAddToRequest(): void + { + $collection = new CookieCollection(); + $collection = $collection + ->add(new Cookie('default', '1', null, '/', 'example.com')) + ->add(new Cookie('api', 'A', null, '/api', 'example.com')) + ->add(new Cookie('blog', 'b', null, '/blog', 'blog.example.com')) + ->add(new Cookie('expired', 'ex', new DateTime('-2 seconds'), '/', 'example.com')); + $request = new ClientRequest('http://example.com/api'); + $request = $collection->addToRequest($request); + $this->assertSame('default=1; api=A', $request->getHeaderLine('Cookie')); + + $request = new ClientRequest('http://example.com/'); + $request = $collection->addToRequest($request); + $this->assertSame('default=1', $request->getHeaderLine('Cookie')); + + $request = new ClientRequest('http://example.com'); + $request = $collection->addToRequest($request); + $this->assertSame('default=1', $request->getHeaderLine('Cookie')); + + $request = new ClientRequest('http://example.com/blog'); + $request = $collection->addToRequest($request); + $this->assertSame('default=1', $request->getHeaderLine('Cookie'), 'domain matching should apply'); + + $request = new ClientRequest('http://foo.blog.example.com/blog'); + $request = $collection->addToRequest($request); + $this->assertSame('default=1; blog=b', $request->getHeaderLine('Cookie')); + } + + /** + * Test adding no cookies + */ + public function testAddToRequestNoCookies(): void + { + $collection = new CookieCollection(); + $request = new ClientRequest('http://example.com/api'); + $request = $collection->addToRequest($request); + $this->assertFalse($request->hasHeader('Cookie'), 'No header should be set.'); + } + + /** + * Testing the cookie size limit warning + */ + public function testCookieSizeWarning(): void + { + $this->expectWarningMessageMatches('/The cookie `default` exceeds the recommended maximum cookie length of 4096 bytes.*/', function (): void { + $string = Security::insecureRandomBytes(9000); + $collection = new CookieCollection(); + $collection = $collection + ->add(new Cookie('default', $string, null, '/', 'example.com')); + $request = new ClientRequest('http://example.com/api'); + $collection->addToRequest($request); + }); + } + + /** + * Test adding cookies from the collection to request. + */ + public function testAddToRequestExtraCookies(): void + { + $collection = new CookieCollection(); + $collection = $collection + ->add(new Cookie('api', 'A', null, '/api', 'example.com')) + ->add(new Cookie('blog', 'b', null, '/blog', 'blog.example.com')) + ->add(new Cookie('expired', 'ex', new DateTime('-2 seconds'), '/', 'example.com')); + $request = new ClientRequest('http://example.com/api'); + $request = $collection->addToRequest($request, ['b' => 'B']); + $this->assertSame('b=B; api=A', $request->getHeaderLine('Cookie')); + + $request = new ClientRequest('http://example.com/api'); + $request = $collection->addToRequest($request, ['api' => 'custom']); + $this->assertSame('api=custom', $request->getHeaderLine('Cookie'), 'Extra cookies overwrite values in jar'); + } + + /** + * Test adding cookies ignores leading dot + */ + public function testAddToRequestLeadingDot(): void + { + $collection = new CookieCollection(); + $collection = $collection + ->add(new Cookie('public', 'b', null, '/', '.example.com')); + $request = new ClientRequest('http://example.com/blog'); + $request = $collection->addToRequest($request); + $this->assertSame('public=b', $request->getHeaderLine('Cookie')); + } + + /** + * Test adding cookies checks the secure crumb + */ + public function testAddToRequestSecureCrumb(): void + { + $collection = new CookieCollection(); + $collection = $collection + ->add(new Cookie('secret', 'A', null, '/', 'example.com', true)) + ->add(new Cookie('public', 'b', null, '/', '.example.com', false)); + $request = new ClientRequest('https://example.com/api'); + $request = $collection->addToRequest($request); + $this->assertSame('secret=A; public=b', $request->getHeaderLine('Cookie')); + + // no HTTPS set. + $request = new ClientRequest('http://example.com/api'); + $request = $collection->addToRequest($request); + $this->assertSame('public=b', $request->getHeaderLine('Cookie')); + } + + /** + * test createFromHeader() building cookies from a header string. + */ + public function testCreateFromHeader(): void + { + $header = [ + 'http=name; HttpOnly; Secure;', + 'expires=expiring; Expires=Mon, 17-Apr-2023 10:22:22; Path=/api; HttpOnly; Secure;', + 'expired=expired; version=1; Expires=Wed, 15-Jun-2015 10:22:22;', + 'invalid=invalid-secure; Expires=Mon, 17-Apr-2023 10:22:22; Secure=true; SameSite=none', + '7=numeric', + ]; + $cookies = CookieCollection::createFromHeader($header); + $this->assertCount(4, $cookies); + $this->assertTrue($cookies->has('http')); + $this->assertTrue($cookies->has('expires')); + $this->assertFalse($cookies->has('version')); + $this->assertTrue($cookies->has('expired'), 'Expired cookies should be present'); + $this->assertFalse($cookies->has('invalid'), 'Invalid cookies should not be present'); + $this->assertTrue($cookies->has('7')); + } + + /** + * test createFromServerRequest() building cookies from a header string. + */ + public function testCreateFromServerRequest(): void + { + $request = new ServerRequest([ + 'cookies' => [ + 'name' => 'val', + 'cakephp' => 'rocks', + '123' => 'a integer key cookie', + ], + ]); + $cookies = CookieCollection::createFromServerRequest($request); + $this->assertCount(3, $cookies); + $this->assertTrue($cookies->has('name')); + $this->assertTrue($cookies->has('cakephp')); + $this->assertTrue($cookies->has('123')); + + $cookie = $cookies->get('name'); + $this->assertSame('val', $cookie->getValue()); + $this->assertSame('/', $cookie->getPath()); + $this->assertSame('', $cookie->getDomain(), 'No domain on request cookies'); + + $cookie = $cookies->get('123'); + $this->assertSame('a integer key cookie', $cookie->getValue()); + } +} diff --git a/tests/TestCase/Http/Cookie/CookieTest.php b/tests/TestCase/Http/Cookie/CookieTest.php new file mode 100644 index 00000000000..5737fd243d0 --- /dev/null +++ b/tests/TestCase/Http/Cookie/CookieTest.php @@ -0,0 +1,500 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.5.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Cookie; + +use Cake\Chronos\Chronos; +use Cake\Http\Cookie\Cookie; +use Cake\Http\Cookie\CookieInterface; +use Cake\Http\Cookie\SameSiteEnum; +use Cake\TestSuite\TestCase; +use DateTimeInterface; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use ValueError; + +/** + * HTTP cookies test. + */ +class CookieTest extends TestCase +{ + /** + * Generate invalid cookie names. + * + * @return array + */ + public static function invalidNameProvider(): array + { + return [ + ['no='], + ["no\rnewline"], + ["no\nnewline"], + ["no\ttab"], + ['no,comma'], + ['no;semi'], + ]; + } + + /** + * Test invalid cookie name + */ + #[DataProvider('invalidNameProvider')] + public function testValidateNameInvalidChars(string $name): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('contains invalid characters.'); + new Cookie($name, 'value'); + } + + /** + * Test empty cookie name + */ + public function testValidateNameEmptyName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The cookie name cannot be empty.'); + new Cookie('', ''); + } + + /** + * Tests the header value + */ + public function testToHeaderValue(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $result = $cookie->toHeaderValue(); + $this->assertSame('cakephp=cakephp-rocks; path=/', $result); + + $date = Chronos::createFromFormat('m/d/Y h:i:s', '12/1/2027 12:00:00'); + + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $cookie = $cookie->withDomain('cakephp.org') + ->withExpiry($date) + ->withHttpOnly(true) + ->withSameSite(CookieInterface::SAMESITE_STRICT) + ->withSecure(true); + $result = $cookie->toHeaderValue(); + + $expected = 'cakephp=cakephp-rocks; expires=Wed, 01-Dec-2027 12:00:00 GMT; path=/; domain=cakephp.org; samesite=Strict; secure; httponly'; + $this->assertSame($expected, $result); + } + + /** + * Test getting the value from the cookie + */ + public function testGetValue(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $result = $cookie->getValue(); + $this->assertSame('cakephp-rocks', $result); + + $cookie = new Cookie('cakephp', ''); + $result = $cookie->getValue(); + $this->assertSame('', $result); + } + + /** + * Test setting values in cookies + */ + public function testWithValue(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withValue('new'); + $this->assertNotSame($new, $cookie, 'Should make a clone'); + $this->assertSame('cakephp-rocks', $cookie->getValue(), 'old instance not modified'); + $this->assertSame('new', $new->getValue()); + } + + /** + * Test setting domain in cookies + */ + public function testWithDomain(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withDomain('example.com'); + $this->assertNotSame($new, $cookie, 'Should make a clone'); + $this->assertStringNotContainsString('example.com', $cookie->toHeaderValue(), 'old instance not modified'); + $this->assertStringContainsString('domain=example.com', $new->toHeaderValue()); + } + + /** + * Test setting path in cookies + */ + public function testWithPath(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withPath('/api'); + $this->assertNotSame($new, $cookie, 'Should make a clone'); + $this->assertStringNotContainsString('path=/api', $cookie->toHeaderValue(), 'old instance not modified'); + $this->assertStringContainsString('path=/api', $new->toHeaderValue()); + } + + /** + * Test setting SameSite in cookies + */ + public function testWithSameSite(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withSameSite(CookieInterface::SAMESITE_LAX); + $this->assertNotSame($new, $cookie, 'Should make a clone'); + $this->assertStringNotContainsString('samesite=Lax', $cookie->toHeaderValue(), 'old instance not modified'); + $this->assertStringContainsString('samesite=Lax', $new->toHeaderValue()); + + $new = $cookie->withSameSite(SameSiteEnum::STRICT); + $this->assertStringContainsString('samesite=Strict', $new->toHeaderValue()); + } + + /** + * Test setting SameSite in cookies + */ + public function testWithSameSiteException(): void + { + $this->expectException(ValueError::class); + + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $cookie->withSameSite('invalid'); + } + + public function testGetSameSite(): void + { + $cookie = new Cookie(name: 'cakephp', value: 'cakephp-rocks', sameSite: 'NONE'); + $this->assertSame(SameSiteEnum::NONE, $cookie->getSameSite()); + } + + /** + * Test default path in cookies + */ + public function testDefaultPath(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $this->assertStringContainsString('path=/', $cookie->toHeaderValue()); + } + + /** + * Test setting httponly in cookies + */ + public function testWithHttpOnly(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withHttpOnly(true); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertFalse($cookie->isHttpOnly()); + $this->assertTrue($new->isHttpOnly()); + } + + /** + * Test setting secure in cookies + */ + public function testWithSecure(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withSecure(true); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertFalse($cookie->isSecure()); + $this->assertTrue($new->isSecure()); + } + + /** + * Test the never expiry method + */ + public function testWithNeverExpire(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withNeverExpire(); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertStringContainsString('01-Jan-2038', $new->toHeaderValue()); + } + + /** + * Test the expired method + */ + public function testWithExpired(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withExpired(); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertStringNotContainsString('expiry', $cookie->toHeaderValue()); + + $this->assertStringContainsString('01-Jan-1970', $new->toHeaderValue()); + } + + /** + * Test the expired method + */ + public function testWithExpiredNotUtc(): void + { + date_default_timezone_set('Europe/Paris'); + + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withExpired(); + date_default_timezone_set('UTC'); + + $this->assertStringContainsString('01-Jan-1970 00:00:01 GMT+0000', $new->toHeaderValue()); + } + + /** + * Test the withExpiry method + */ + public function testWithExpiry(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withExpiry(Chronos::createFromDate(2022, 6, 15)); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertStringNotContainsString('expires', $cookie->toHeaderValue()); + + $this->assertStringContainsString('expires=Wed, 15-Jun-2022', $new->toHeaderValue()); + } + + /** + * Test the withExpiry method changes timezone + */ + public function testWithExpiryChangesTimezone(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $date = Chronos::createFromDate(2022, 6, 15); + $date = $date->setTimezone('America/New_York'); + + $new = $cookie->withExpiry($date); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertStringNotContainsString('expires', $cookie->toHeaderValue()); + + $this->assertStringContainsString('expires=Wed, 15-Jun-2022', $new->toHeaderValue()); + $this->assertStringContainsString('GMT', $new->toHeaderValue()); + $this->assertSame((int)$date->format('U'), $new->getExpiresTimestamp()); + } + + /** + * Test the isExpired method + */ + public function testIsExpired(): void + { + $date = Chronos::now(); + $cookie = new Cookie('cakephp', 'yay'); + $this->assertFalse($cookie->isExpired($date)); + + $cookie = new Cookie('cakephp', 'yay', $date); + $this->assertFalse($cookie->isExpired($date), 'same time, not expired'); + + $date = $date->modify('+10 seconds'); + $this->assertTrue($cookie->isExpired($date), 'future now'); + + $date = $date->modify('-1 minute'); + $this->assertFalse($cookie->isExpired($date), 'expires later'); + } + + /** + * Test the withName method + */ + public function testWithName(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $new = $cookie->withName('user'); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertNotSame('user', $cookie->getName()); + $this->assertSame('user', $new->getName()); + } + + /** + * Test the withAddedValue method + */ + public function testWithAddedValue(): void + { + $cookie = new Cookie('cakephp', '{"type":"mvc", "icing": true}'); + $new = $cookie->withAddedValue('type', 'mvc') + ->withAddedValue('user.name', 'mark'); + $this->assertNotSame($new, $cookie, 'Should clone'); + $this->assertNull($cookie->read('user.name')); + $this->assertSame('mvc', $new->read('type')); + $this->assertSame('mark', $new->read('user.name')); + } + + /** + * Test the withoutAddedValue method + */ + public function testWithoutAddedValue(): void + { + $cookie = new Cookie('cakephp', '{"type":"mvc", "user": {"name":"mark"}}'); + $new = $cookie->withoutAddedValue('type') + ->withoutAddedValue('user.name'); + $this->assertNotSame($new, $cookie, 'Should clone'); + + $this->assertNotNull($cookie->read('type')); + $this->assertNull($new->read('type')); + $this->assertNull($new->read('user.name')); + } + + /** + * Test check() with serialized source data. + */ + public function testCheckStringSourceData(): void + { + $cookie = new Cookie('cakephp', '{"type":"mvc", "user": {"name":"mark"}}'); + $this->assertTrue($cookie->check('type')); + $this->assertTrue($cookie->check('user.name')); + $this->assertFalse($cookie->check('nope')); + $this->assertFalse($cookie->check('user.nope')); + } + + /** + * Test check() with array source data. + */ + public function testCheckArraySourceData(): void + { + $data = [ + 'type' => 'mvc', + 'user' => ['name' => 'mark'], + ]; + $cookie = new Cookie('cakephp', $data); + $this->assertTrue($cookie->check('type')); + $this->assertTrue($cookie->check('user.name')); + $this->assertFalse($cookie->check('nope')); + $this->assertFalse($cookie->check('user.nope')); + } + + /** + * test read() and set on different types + */ + public function testReadExpandsOnDemand(): void + { + $data = [ + 'username' => 'florian', + 'profile' => [ + 'profession' => 'developer', + ], + ]; + $cookie = new Cookie('cakephp', json_encode($data)); + $this->assertFalse($cookie->isExpanded()); + $this->assertSame('developer', $cookie->read('profile.profession')); + $this->assertTrue($cookie->isExpanded(), 'Cookies expand when read.'); + + $cookie = $cookie->withValue(json_encode($data)); + $this->assertTrue($cookie->check('profile.profession'), 'Cookies expand when read.'); + $this->assertTrue($cookie->isExpanded()); + + $cookie = $cookie->withValue(json_encode($data)) + ->withAddedValue('face', 'punch'); + $this->assertTrue($cookie->isExpanded()); + $this->assertSame('punch', $cookie->read('face')); + } + + /** + * test read() on structured data. + */ + public function testReadComplexData(): void + { + $data = [ + 'username' => 'florian', + 'profile' => [ + 'profession' => 'developer', + ], + ]; + $cookie = new Cookie('cakephp', $data); + + $result = $cookie->getValue(); + $this->assertEquals($data, $result); + + $result = $cookie->read('foo'); + $this->assertNull($result); + + $result = $cookie->read(); + $this->assertEquals($data, $result); + + $result = $cookie->read('profile.profession'); + $this->assertSame('developer', $result); + } + + /** + * Test reading complex data serialized in 1.x and early 2.x + */ + public function testReadLegacyComplexData(): void + { + $data = 'key|value,key2|value2'; + $cookie = new Cookie('cakephp', $data); + $this->assertSame('value', $cookie->read('key')); + $this->assertNull($cookie->read('nope')); + } + + /** + * Test that toHeaderValue() collapses data. + */ + public function testToHeaderValueCollapsesComplexData(): void + { + $data = [ + 'username' => 'florian', + 'profile' => [ + 'profession' => 'developer', + ], + ]; + $cookie = new Cookie('cakephp', $data); + $this->assertSame('developer', $cookie->read('profile.profession')); + + $expected = '{"username":"florian","profile":{"profession":"developer"}}'; + $this->assertStringContainsString(urlencode($expected), $cookie->toHeaderValue()); + } + + /** + * Tests getting the id + */ + public function testGetId(): void + { + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $this->assertSame('cakephp;;/', $cookie->getId()); + + $cookie = new Cookie('CAKEPHP', 'cakephp-rocks'); + $this->assertSame('CAKEPHP;;/', $cookie->getId()); + + $cookie = new Cookie('test', 'val', null, '/path', 'example.com'); + $this->assertSame('test;example.com;/path', $cookie->getId()); + } + + public function testCreateFromHeaderStringInvalidSamesite(): void + { + $header = 'cakephp=cakephp-rocks; expires=Wed, 01-Dec-2027 12:00:00 GMT; path=/; domain=cakephp.org; samesite=invalid; secure; httponly'; + $result = Cookie::createFromHeaderString($header); + + // Ignore invalid value when parsing headers + // https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-4.1 + $this->assertNull($result->getSameSite()); + } + + public function testCreateFromHeaderStringEmptyValue(): void + { + // Invalid cookie with no = separator or value. + $header = 'cakephp; expires=Wed, 01-Dec-2027 12:00:00 GMT; path=/; domain=cakephp.org;'; + $result = Cookie::createFromHeaderString($header); + + $this->assertSame('', $result->getValue()); + } + + public function testDefaults(): void + { + Cookie::setDefaults(['path' => '/cakephp', 'expires' => time()]); + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $this->assertSame('/cakephp', $cookie->getPath()); + $this->assertInstanceOf(DateTimeInterface::class, $cookie->getExpiry()); + + Cookie::setDefaults(['path' => '/', 'expires' => null]); + $cookie = new Cookie('cakephp', 'cakephp-rocks'); + $this->assertSame('/', $cookie->getPath()); + $this->assertNull($cookie->getExpiry()); + } + + public function testInvalidSameSiteForDefaults(): void + { + $this->expectException(ValueError::class); + + Cookie::setDefaults(['samesite' => 'ompalompa']); + new Cookie('cakephp', 'cakephp-rocks'); + } +} diff --git a/tests/TestCase/Http/CorsBuilderTest.php b/tests/TestCase/Http/CorsBuilderTest.php new file mode 100644 index 00000000000..71c741bb6ba --- /dev/null +++ b/tests/TestCase/Http/CorsBuilderTest.php @@ -0,0 +1,177 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Http; + +use Cake\Http\CorsBuilder; +use Cake\Http\Response; +use Cake\TestSuite\TestCase; + +class CorsBuilderTest extends TestCase +{ + /** + * test allowOrigin() setting allow-origin + */ + public function testAllowOriginNoOrigin(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, ''); + $this->assertSame($builder, $builder->allowOrigin(['*.example.com', '*.foo.com'])); + $this->assertNoHeader($builder->build(), 'Access-Control-Origin'); + } + + /** + * test allowOrigin() setting allow-origin + */ + public function testAllowOrigin(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://www.example.com'); + $this->assertSame($builder, $builder->allowOrigin('*')); + $this->assertHeader('*', $builder->build(), 'Access-Control-Allow-Origin'); + + $response = new Response(); + $builder = new CorsBuilder($response, 'http://www.example.com'); + $this->assertSame($builder, $builder->allowOrigin(['*.example.com', '*.foo.com'])); + $builder->build(); + $this->assertHeader('http://www.example.com', $builder->build(), 'Access-Control-Allow-Origin'); + + $response = new Response(); + $builder = new CorsBuilder($response, 'http://www.example.com'); + $this->assertSame($builder, $builder->allowOrigin('*.example.com')); + $this->assertHeader('http://www.example.com', $builder->build(), 'Access-Control-Allow-Origin'); + } + + /** + * test allowOrigin() with SSL + */ + public function testAllowOriginSsl(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'https://www.example.com', true); + $this->assertSame($builder, $builder->allowOrigin('http://example.com')); + $this->assertNoHeader($response, 'Access-Control-Allow-Origin'); + + $response = new Response(); + $builder = new CorsBuilder($response, 'http://www.example.com', true); + $this->assertSame($builder, $builder->allowOrigin('https://example.com')); + $this->assertNoHeader($builder->build(), 'Access-Control-Allow-Origin'); + + $response = new Response(); + $builder = new CorsBuilder($response, 'http://www.example.com'); + $this->assertSame($builder, $builder->allowOrigin('https://example.com')); + $this->assertNoHeader($builder->build(), 'Access-Control-Allow-Origin'); + } + + public function testAllowMethods(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://example.com'); + $builder->allowOrigin('*'); + $this->assertSame($builder, $builder->allowMethods(['GET', 'POST'])); + $this->assertHeader('GET, POST', $builder->build(), 'Access-Control-Allow-Methods'); + } + + public function testAllowCredentials(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://example.com'); + $builder->allowOrigin('*'); + $this->assertSame($builder, $builder->allowCredentials()); + $this->assertHeader('true', $builder->build(), 'Access-Control-Allow-Credentials'); + } + + public function testAllowHeaders(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://example.com'); + $builder->allowOrigin('*'); + $this->assertSame($builder, $builder->allowHeaders(['Content-Type', 'Accept'])); + $this->assertHeader('Content-Type, Accept', $builder->build(), 'Access-Control-Allow-Headers'); + } + + public function testExposeHeaders(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://example.com'); + $builder->allowOrigin('*'); + $this->assertSame($builder, $builder->exposeHeaders(['Content-Type', 'Accept'])); + $this->assertHeader('Content-Type, Accept', $builder->build(), 'Access-Control-Expose-Headers'); + } + + public function testMaxAge(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://example.com'); + $builder->allowOrigin('*'); + $this->assertSame($builder, $builder->maxAge(300)); + $this->assertHeader('300', $builder->build(), 'Access-Control-Max-Age'); + } + + /** + * When no origin is allowed, none of the other headers should be applied. + */ + public function testNoAllowedOriginNoHeadersSet(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://example.com'); + $response = $builder->allowCredentials() + ->allowMethods(['GET', 'POST']) + ->allowHeaders(['Content-Type']) + ->exposeHeaders(['X-CSRF-Token']) + ->maxAge(300) + ->build(); + $this->assertNoHeader($response, 'Access-Control-Allow-Origin'); + $this->assertNoHeader($response, 'Access-Control-Allow-Headers'); + $this->assertNoHeader($response, 'Access-Control-Expose-Headers'); + $this->assertNoHeader($response, 'Access-Control-Allow-Methods'); + $this->assertNoHeader($response, 'Access-Control-Allow-Authentication'); + $this->assertNoHeader($response, 'Access-Control-Max-Age'); + } + + /** + * When an invalid origin is used, none of the other headers should be applied. + */ + public function testInvalidAllowedOriginNoHeadersSet(): void + { + $response = new Response(); + $builder = new CorsBuilder($response, 'http://example.com'); + $response = $builder->allowOrigin(['http://google.com']) + ->allowCredentials() + ->allowMethods(['GET', 'POST']) + ->allowHeaders(['Content-Type']) + ->exposeHeaders(['X-CSRF-Token']) + ->maxAge(300) + ->build(); + $this->assertNoHeader($response, 'Access-Control-Allow-Origin'); + $this->assertNoHeader($response, 'Access-Control-Allow-Headers'); + $this->assertNoHeader($response, 'Access-Control-Expose-Headers'); + $this->assertNoHeader($response, 'Access-Control-Allow-Methods'); + $this->assertNoHeader($response, 'Access-Control-Allow-Authentication'); + $this->assertNoHeader($response, 'Access-Control-Max-Age'); + } + + /** + * Helper for checking header values. + * + * @param string $expected The expected value + * @param \Cake\Http\Response $response The Response object. + * @param string $header The header key to check + */ + protected function assertHeader($expected, Response $response, $header): void + { + $this->assertTrue($response->hasHeader($header), 'Header key not found.'); + $this->assertSame($expected, $response->getHeaderLine($header), 'Header value not found.'); + } + + /** + * Helper for checking header values. + * + * @param \Cake\Http\Response $response The Response object. + * @param string $header The header key to check + */ + protected function assertNoHeader(Response $response, $header): void + { + $this->assertFalse($response->hasHeader($header), 'Header key was found.'); + } +} diff --git a/tests/TestCase/Http/FlashMessageTest.php b/tests/TestCase/Http/FlashMessageTest.php new file mode 100644 index 00000000000..a57c4ee2813 --- /dev/null +++ b/tests/TestCase/Http/FlashMessageTest.php @@ -0,0 +1,356 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 4.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http; + +use Cake\Http\FlashMessage; +use Cake\Http\Session; +use Cake\TestSuite\TestCase; +use Exception; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * FlashMessageTest class + */ +class FlashMessageTest extends TestCase +{ + /** + * @var \Cake\Http\FlashMessage + */ + protected $Flash; + + /** + * @var \Cake\Http\Session + */ + protected $Session; + + protected function setUp(): void + { + parent::setUp(); + + static::setAppNamespace(); + $this->Session = new Session(); + $this->Flash = new FlashMessage($this->Session); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->Session->destroy(); + } + + public function testSet(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->set('This is a test message'); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set( + 'This is a test message', + ['element' => 'test', 'params' => ['foo' => 'bar']], + ); + $expected[] = [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/test', + 'params' => ['foo' => 'bar'], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is a test message', ['element' => 'MyPlugin.alert']); + $expected[] = [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'MyPlugin.flash/alert', + 'params' => [], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is a test message', ['key' => 'foobar']); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'foobar', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.foobar'); + $this->assertEquals($expected, $result); + } + + public function testDefaultParamsOverriding(): void + { + $this->Flash = new FlashMessage( + $this->Session, + ['params' => ['foo' => 'bar']], + ); + + $this->Flash->set( + 'This is a test message', + ['params' => ['username' => 'ADmad']], + ); + $expected[] = [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => ['username' => 'ADmad'], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + public function testDuplicateIgnored(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->setConfig('duplicate', false); + $this->Flash->set('This test message should appear once only'); + $this->Flash->set('This test message should appear once only'); + $result = $this->Session->read('Flash.flash'); + $this->assertCount(1, $result); + } + + public function testSetEscape(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->set( + 'This is a <b>test</b> message', + ['escape' => false, 'params' => ['foo' => 'bar']], + ); + $expected = [ + [ + 'message' => 'This is a <b>test</b> message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => ['foo' => 'bar', 'escape' => false], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set( + 'This is a test message', + ['key' => 'escaped', 'escape' => false, 'params' => ['foo' => 'bar', 'escape' => true]], + ); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'escaped', + 'element' => 'flash/default', + 'params' => ['foo' => 'bar', 'escape' => true], + ], + ]; + $result = $this->Session->read('Flash.escaped'); + $this->assertEquals($expected, $result); + } + + public function testSetWithClear(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->set('This is a test message'); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->set('This is another test message', ['clear' => true]); + $expected = [ + [ + 'message' => 'This is another test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + public function testSetWithPlugin(): void + { + $this->Flash->set('This is a test message', ['plugin' => 'FooBar']); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'FooBar.flash/default', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + // Value of 'plugin' will override the plugin name used in 'element' + $this->Flash->set('This is a test message', [ + 'key' => 'msg', + 'element' => 'Plugin.success', + 'plugin' => 'FooBar', + ]); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'msg', + 'element' => 'FooBar.flash/success', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.msg'); + $this->assertEquals($expected, $result); + } + + public function testSetExceptionMessage(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->setExceptionMessage(new Exception('This is a test message', 404)); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/error', + 'params' => ['code' => 404], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + + $this->Flash->setExceptionMessage( + new Exception('This is a test message'), + ['element' => 'default', 'clear' => true], + ); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/default', + 'params' => ['code' => null], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + public function testSetWithConstructorConfiguration(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $flash = new FlashMessage($this->Session, ['element' => 'test']); + $flash->set('This is a test message'); + $expected = [ + [ + 'message' => 'This is a test message', + 'key' => 'flash', + 'element' => 'flash/test', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + #[DataProvider('convenienceMethods')] + public function testConvenienceMethods(string $type): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->{$type}('It worked'); + $expected = [ + [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'flash/' . $type, + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + public static function convenienceMethods(): array + { + return [ + ['success'], + ['error'], + ['warning'], + ['info'], + ]; + } + + public function testSuccessWithClear(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + $this->Flash->success('It worked'); + $expected = [ + [ + 'message' => 'It worked', + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + $this->Flash->success('It worked too', ['clear' => true]); + $expected = [ + [ + 'message' => 'It worked too', + 'key' => 'flash', + 'element' => 'flash/success', + 'params' => [], + ], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result); + } + + public function testError(): void + { + $this->assertNull($this->Session->read('Flash.flash')); + + $this->Flash->error('It did not work', ['element' => 'error_thing']); + + $expected[] = [ + 'message' => 'It did not work', + 'key' => 'flash', + 'element' => 'flash/error', + 'params' => [], + ]; + $result = $this->Session->read('Flash.flash'); + $this->assertEquals($expected, $result, 'Element is ignored in convenience method call.'); + } +} diff --git a/tests/TestCase/Http/HeaderUtilityTest.php b/tests/TestCase/Http/HeaderUtilityTest.php new file mode 100644 index 00000000000..400aac4ec06 --- /dev/null +++ b/tests/TestCase/Http/HeaderUtilityTest.php @@ -0,0 +1,128 @@ +<?php +declare(strict_types=1); + +namespace Cake\Test\TestCase\Http; + +use Cake\Http\HeaderUtility; +use Cake\Http\Response; +use Cake\Http\ServerRequest; +use Cake\TestSuite\TestCase; + +class HeaderUtilityTest extends TestCase +{ + /** + * Tests getting a parsed representation of a Link header + */ + public function testParseLinks(): void + { + $response = new Response(); + $this->assertFalse($response->hasHeader('Link')); + + $new = $response->withAddedLink('http://example.com'); + $this->assertSame('<http://example.com>', $new->getHeaderLine('Link')); + $expected = [ + ['link' => 'http://example.com'], + ]; + $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link'))); + + $new = $response->withAddedLink('http://example.com/苗条'); + $this->assertSame('<http://example.com/苗条>', $new->getHeaderLine('Link')); + $expected = [ + ['link' => 'http://example.com/苗条'], + ]; + $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link'))); + + $new = $response->withAddedLink('http://example.com', ['rel' => 'prev']); + $this->assertSame('<http://example.com>; rel="prev"', $new->getHeaderLine('Link')); + $expected = [ + [ + 'link' => 'http://example.com', + 'rel' => 'prev', + ], + ]; + $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link'))); + + $new = $response->withAddedLink('http://example.com', ['rel' => 'prev', 'results' => 'true']); + $this->assertSame('<http://example.com>; rel="prev"; results="true"', $new->getHeaderLine('Link')); + $expected = [ + [ + 'link' => 'http://example.com', + 'rel' => 'prev', + 'results' => 'true', + ], + ]; + $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link'))); + + $new = $response + ->withAddedLink('http://example.com/1', ['rel' => 'prev']) + ->withAddedLink('http://example.com/3', ['rel' => 'next']); + $this->assertSame('<http://example.com/1>; rel="prev",<http://example.com/3>; rel="next"', $new->getHeaderLine('Link')); + $expected = [ + [ + 'link' => 'http://example.com/1', + 'rel' => 'prev', + ], + [ + 'link' => 'http://example.com/3', + 'rel' => 'next', + ], + ]; + $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link'))); + + $encodedLinkHeader = "</extended-attr-example>; rel=start; title*=UTF-8'en'%E2%91%A0%E2%93%AB%E2%85%93%E3%8F%A8%E2%99%B3%F0%9D%84%9E%CE%BB"; + $new = $response + ->withHeader('Link', $encodedLinkHeader); + $this->assertSame($encodedLinkHeader, $new->getHeaderLine('Link')); + $expected = [ + [ + 'link' => '/extended-attr-example', + 'rel' => 'start', + 'title*' => [ + 'language' => 'en', + 'encoding' => 'UTF-8', + 'value' => '①⓫⅓㏨♳𝄞λ', + ], + ], + ]; + $this->assertSame($expected, HeaderUtility::parseLinks($new->getHeader('Link'))); + } + + public function testParseAccept(): void + { + $request = new ServerRequest([ + 'url' => '/dashboard', + 'environment' => [ + 'HTTP_ACCEPT' => 'application/json;q=0.5,application/xml;q=0.6,application/pdf;q=0.3', + ], + ]); + $result = HeaderUtility::parseAccept($request->getHeaderLine('Accept')); + $expected = [ + '0.6' => ['application/xml'], + '0.5' => ['application/json'], + '0.3' => ['application/pdf'], + ]; + $this->assertEquals($expected, $result); + } + + public function testParseWwwAuthenticate(): void + { + $result = HeaderUtility::parseWwwAuthenticate('Digest realm="The batcave",nonce="4cded326c6c51"'); + $expected = [ + 'realm' => 'The batcave', + 'nonce' => '4cded326c6c51', + ]; + $this->assertEquals($expected, $result); + } + + public function testWwwAuthenticateWithAlgo(): void + { + $result = HeaderUtility::parseWwwAuthenticate('Digest qop="auth", realm="shellyplus1pm-44179393e8a8", nonce="63f8c86f", algorithm=SHA-256'); + $expected = [ + 'qop' => 'auth', + 'realm' => 'shellyplus1pm-44179393e8a8', + 'nonce' => '63f8c86f', + 'algorithm' => 'SHA-256', + ]; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Http/Middleware/BodyParserMiddlewareTest.php b/tests/TestCase/Http/Middleware/BodyParserMiddlewareTest.php new file mode 100644 index 00000000000..20b0a67b6e8 --- /dev/null +++ b/tests/TestCase/Http/Middleware/BodyParserMiddlewareTest.php @@ -0,0 +1,423 @@ +<?php +declare(strict_types=1); + +/** + * CakePHP(tm) : Rapid Development Framework (https://cakephp.org) + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 3.5.0 + * @license https://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Http\Middleware; + +use Cake\Http\Exception\BadRequestException; +use Cake\Http\Middleware\BodyParserMiddleware; +use Cake\Http\Response; +use Cake\Http\ServerRequest; +use Cake\TestSuite\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Http\TestRequestHandler; + +/** + * Test for BodyParser + */ +class BodyParserMiddlewareTest extends TestCase +{ + /** + * Data provider for HTTP method tests. + * + * HEAD and GET do not populate $_POST or request->data. + * + * @return array + */ + public static function safeHttpMethodProvider(): array + { + return [ + ['GET'], + ['HEAD'], + ]; + } + + /** + * Data provider for HTTP methods that can contain request bodies. + * + * @return array + */ + public static function httpMethodProvider(): array + { + return [ + ['PATCH'], ['PUT'], ['POST'], ['DELETE'], + ]; + } + + /** + * Data provider for JSON scalar and how it should be parsed + * + * @return array + */ + public static function jsonScalarValues(): array + { + return [ + ['', []], // Requests without body + ['true', [true]], + ['false', [false]], + ['0', [0]], + ['0.1', [0.1]], + ['"cake"', ['cake']], + ['null', []], + ]; + } + + /** + * test constructor options + */ + public function testConstructorMethodsOption(): void + { + $parser = new BodyParserMiddleware(['methods' => ['PUT']]); + $this->assertEquals(['PUT'], $parser->getMethods()); + } + + /** + * test constructor options + */ + public function testConstructorXmlOption(): void + { + $parser = new BodyParserMiddleware(['json' => false]); + $this->assertEquals([], $parser->getParsers(), 'Xml off by default'); + + $parser = new BodyParserMiddleware(['json' => false, 'xml' => false]); + $this->assertEquals([], $parser->getParsers(), 'No Xml types set.'); + + $parser = new BodyParserMiddleware(['json' => false, 'xml' => true]); + $this->assertEquals( + ['application/xml', 'text/xml'], + array_keys($parser->getParsers()), + 'Default XML parsers are not set.', + ); + } + + /** + * test constructor options + */ + public function testConstructorJsonOption(): void + { + $parser = new BodyParserMiddleware(['json' => false]); + $this->assertEquals([], $parser->getParsers(), 'No JSON types set.'); + + $parser = new BodyParserMiddleware([]); + $this->assertEquals( + ['application/json', 'text/json'], + array_keys($parser->getParsers()), + 'Default JSON parsers are not set.', + ); + } + + /** + * test setMethods() + */ + public function testSetMethodsReturn(): void + { + $parser = new BodyParserMiddleware(); + $this->assertSame($parser, $parser->setMethods(['PUT'])); + $this->assertEquals(['PUT'], $parser->getMethods()); + } + + /** + * test addParser() + */ + public function testAddParserReturn(): void + { + $parser = new BodyParserMiddleware(['json' => false]); + $f1 = function (string $body) { + return json_decode($body, true); + }; + $this->assertSame($parser, $parser->addParser(['application/json'], $f1)); + } + + /** + * test last parser defined wins + */ + public function testAddParserOverwrite(): void + { + $parser = new BodyParserMiddleware(['json' => false]); + + $f1 = function (string $body) { + return json_decode($body, true); + }; + $f2 = function (string $body) { + return ['overridden']; + }; + $parser->addParser(['application/json'], $f1); + $parser->addParser(['application/json'], $f2); + + $this->assertSame(['application/json' => $f2], $parser->getParsers()); + } + + /** + * test skipping parsing on unknown type + */ + #[DataProvider('httpMethodProvider')] + public function testInvokeMismatchedType(string $method): void + { + $parser = new BodyParserMiddleware(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'CONTENT_TYPE' => 'text/csv', + ], + 'input' => 'a,b,c', + ]); + $handler = new TestRequestHandler(function ($req) { + $this->assertEquals([], $req->getParsedBody()); + + return new Response(); + }); + $parser->process($request, $handler); + } + + /** + * test parsing on valid http method + */ + #[DataProvider('httpMethodProvider')] + public function testInvokeCaseInsensitiveContentType(string $method): void + { + $parser = new BodyParserMiddleware(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'CONTENT_TYPE' => 'ApPlIcAtIoN/JSoN', + ], + 'input' => '{"title": "yay"}', + ]); + $handler = new TestRequestHandler(function ($req) { + $this->assertEquals(['title' => 'yay'], $req->getParsedBody()); + + return new Response(); + }); + $parser->process($request, $handler); + } + + /** + * test parsing on valid http method + */ + #[DataProvider('httpMethodProvider')] + public function testInvokeParse(string $method): void + { + $parser = new BodyParserMiddleware(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'CONTENT_TYPE' => 'application/json', + ], + 'input' => '{"title": "yay"}', + ]); + $handler = new TestRequestHandler(function ($req) { + $this->assertEquals(['title' => 'yay'], $req->getParsedBody()); + + return new Response(); + }); + $parser->process($request, $handler); + } + + /** + * test parsing on valid http method with charset + */ + public function testInvokeParseStripCharset(): void + { + $parser = new BodyParserMiddleware(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/json; charset=utf-8', + ], + 'input' => '{"title": "yay"}', + ]); + $handler = new TestRequestHandler(function ($req) { + $this->assertEquals(['title' => 'yay'], $req->getParsedBody()); + + return new Response(); + }); + $parser->process($request, $handler); + } + + /** + * test parsing on ignored http method + */ + #[DataProvider('safeHttpMethodProvider')] + public function testInvokeNoParseOnSafe(string $method): void + { + $parser = new BodyParserMiddleware(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'CONTENT_TYPE' => 'application/json', + ], + 'input' => '{"title": "yay"}', + ]); + $handler = new TestRequestHandler(function ($req) { + $this->assertEquals([], $req->getParsedBody()); + + return new Response(); + }); + $parser->process($request, $handler); + } + + /** + * test parsing XML bodies. + */ + public function testInvokeXml(): void + { + $xml = <<<XML +<?xml version="1.0" encoding="utf-8"?> +<article> + <title>yay + +XML; + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/xml', + ], + 'input' => $xml, + ]); + $handler = new TestRequestHandler(function ($req) { + $expected = [ + 'article' => ['title' => 'yay'], + ]; + $this->assertEquals($expected, $req->getParsedBody()); + + return new Response(); + }); + $parser = new BodyParserMiddleware(['xml' => true]); + $parser->process($request, $handler); + } + + /** + * Test that CDATA is removed in XML data. + */ + public function testInvokeXmlCdata(): void + { + $xml = << +
    + 1 + <![CDATA[first]]> +
    +XML; + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/xml', + ], + 'input' => $xml, + ]); + $handler = new TestRequestHandler(function ($req) { + $expected = [ + 'article' => [ + 'id' => 1, + 'title' => 'first', + ], + ]; + $this->assertEquals($expected, $req->getParsedBody()); + + return new Response(); + }); + $parser = new BodyParserMiddleware(['xml' => true]); + $parser->process($request, $handler); + } + + /** + * Test that internal entity recursion is ignored. + */ + public function testInvokeXmlInternalEntities(): void + { + $xml = << + + + + + + + + + +]> + + &item8; + +XML; + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/xml', + ], + 'input' => $xml, + ]); + $handler = new TestRequestHandler(function ($req) { + $this->assertEquals([], $req->getParsedBody()); + + return new Response(); + }); + $parser = new BodyParserMiddleware(['xml' => true]); + $parser->process($request, $handler); + } + + /** + * test parsing non array/object values on JSON + * + * @param mixed $expected + */ + #[DataProvider('jsonScalarValues')] + public function testInvokeParseNoArray(string $body, $expected): void + { + $parser = new BodyParserMiddleware(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/json', + ], + 'input' => $body, + ]); + $handler = new TestRequestHandler(function ($req) use ($expected) { + $this->assertSame($expected, $req->getParsedBody()); + + return new Response(); + }); + $parser->process($request, $handler); + } + + /** + * test parsing fails will raise a bad request. + */ + public function testInvokeParseInvalidJson(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/json', + ], + 'input' => 'lol', + ]); + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + $this->expectException(BadRequestException::class); + $parser = new BodyParserMiddleware(); + $parser->process($request, $handler); + } +} diff --git a/tests/TestCase/Http/Middleware/CspMiddlewareTest.php b/tests/TestCase/Http/Middleware/CspMiddlewareTest.php new file mode 100644 index 00000000000..7f7c35f2d71 --- /dev/null +++ b/tests/TestCase/Http/Middleware/CspMiddlewareTest.php @@ -0,0 +1,140 @@ + [ + 'allow' => [ + 'https://www.google-analytics.com', + ], + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + ]); + + $response = $middleware->process($request, $this->_getRequestHandler()); + $policy = $response->getHeaderLine('Content-Security-Policy'); + + $expected = "script-src 'self' https://www.google-analytics.com"; + $this->assertStringContainsString($expected, $policy); + $this->assertStringNotContainsString('nonce-', $policy); + } + + /** + * test process adding request attributes for nonces + */ + public function testProcessAddNonceAttributes(): void + { + $request = new ServerRequest(); + + $policy = [ + 'script-src' => [ + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + 'style-src' => [ + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + ]; + $middleware = new CspMiddleware($policy, [ + 'scriptNonce' => true, + 'styleNonce' => true, + ]); + + $handler = new TestRequestHandler(function ($request) { + $this->assertNotEmpty($request->getAttribute('cspScriptNonce')); + $this->assertNotEmpty($request->getAttribute('cspStyleNonce')); + + return new Response(); + }); + + $response = $middleware->process($request, $handler); + $policy = $response->getHeaderLine('Content-Security-Policy'); + $expected = [ + "script-src 'self' 'nonce-", + "style-src 'self' 'nonce-", + ]; + + $this->assertNotEmpty($policy); + foreach ($expected as $match) { + $this->assertStringContainsString($match, $policy); + } + } + + /** + * testPassingACSPBuilderInstance + */ + public function testPassingACSPBuilderInstance(): void + { + $request = new ServerRequest(); + + $config = [ + 'script-src' => [ + 'allow' => [ + 'https://www.google-analytics.com', + ], + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + ]; + + $cspBuilder = new CSPBuilder($config); + $middleware = new CspMiddleware($cspBuilder); + + $response = $middleware->process($request, $this->_getRequestHandler()); + $policy = $response->getHeaderLine('Content-Security-Policy'); + $expected = "script-src 'self' https://www.google-analytics.com"; + + $this->assertStringContainsString($expected, $policy); + } +} diff --git a/tests/TestCase/Http/Middleware/CsrfProtectionMiddlewareTest.php b/tests/TestCase/Http/Middleware/CsrfProtectionMiddlewareTest.php new file mode 100644 index 00000000000..0b7efc9a7e2 --- /dev/null +++ b/tests/TestCase/Http/Middleware/CsrfProtectionMiddlewareTest.php @@ -0,0 +1,649 @@ +data. + * + * @return array + */ + public static function safeHttpMethodProvider(): array + { + return [ + ['GET'], + ['HEAD'], + ]; + } + + /** + * Data provider for HTTP methods that can contain request bodies. + * + * @return array + */ + public static function httpMethodProvider(): array + { + return [ + ['OPTIONS'], ['PATCH'], ['PUT'], ['POST'], ['DELETE'], ['PURGE'], ['INVALIDMETHOD'], + ]; + } + + /** + * Provides the request handler + */ + protected function _getRequestHandler(): RequestHandlerInterface + { + return new TestRequestHandler(function () { + return new Response(); + }); + } + + /** + * Test setting the cookie value + */ + public function testSettingCookie(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + 'webroot' => '/dir/', + ]); + + /** @var \Cake\Http\ServerRequest $updatedRequest */ + $updatedRequest = null; + $handler = new TestRequestHandler(function ($request) use (&$updatedRequest) { + $updatedRequest = $request; + + return new Response(); + }); + + $middleware = new CsrfProtectionMiddleware(); + $response = $middleware->process($request, $handler); + + $cookie = $response->getCookie('csrfToken'); + $this->assertNotEmpty($cookie, 'Should set a token.'); + $this->assertMatchesRegularExpression('/^[a-z0-9\/+]+={0,2}$/i', $cookie['value'], 'Should look like base64.'); + $this->assertSame(0, $cookie['expires'], 'session duration.'); + $this->assertFalse($cookie['secure']); + $this->assertFalse($cookie['httponly']); + $this->assertSame('/dir/', $cookie['path'], 'session path.'); + $requestAttr = $updatedRequest->getAttribute('csrfToken'); + + $this->assertNotEquals($cookie['value'], $requestAttr); + $this->assertEquals(strlen($cookie['value']) * 2, strlen($requestAttr)); + $this->assertMatchesRegularExpression('/^[A-Z0-9\/+]+=*$/i', $requestAttr); + } + + public function testSettingCookieWithCustomOptions(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + 'webroot' => '/', + ]); + + /** @var \Cake\Http\ServerRequest $updatedRequest */ + $updatedRequest = null; + $handler = new TestRequestHandler(function ($request) use (&$updatedRequest) { + $updatedRequest = $request; + + return new Response(); + }); + + $middleware = new CsrfProtectionMiddleware([ + 'secure' => true, + 'httponly' => true, + 'samesite' => CookieInterface::SAMESITE_LAX, + ]); + $response = $middleware->process($request, $handler); + + $cookie = $response->getCookie('csrfToken'); + $this->assertTrue($cookie['secure']); + $this->assertTrue($cookie['httponly']); + $this->assertSame(CookieInterface::SAMESITE_LAX, $cookie['samesite']); + } + + /** + * Test setting request attribute based on old cookie value. + */ + public function testRequestAttributeCompatWithOldToken(): void + { + $middleware = new CsrfProtectionMiddleware(); + $oldToken = $this->createOldToken(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'cookies' => ['csrfToken' => $oldToken], + ]); + + /** @var \Cake\Http\ServerRequest $updatedRequest */ + $updatedRequest = null; + $handler = new TestRequestHandler(function ($request) use (&$updatedRequest) { + $updatedRequest = $request; + + return new Response(); + }); + $response = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $response); + + $requestAttr = $updatedRequest->getAttribute('csrfToken'); + $this->assertSame($requestAttr, $oldToken, 'Request attribute should match the token.'); + } + + /** + * Test that the CSRF tokens are not required for idempotent operations + */ + #[DataProvider('safeHttpMethodProvider')] + public function testSafeMethodNoCsrfRequired(string $method): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => 'nope', + ], + 'cookies' => ['csrfToken' => 'testing123'], + ]); + + // No exception means the test is valid + $middleware = new CsrfProtectionMiddleware(); + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Test that the CSRF tokens are regenerated when token is not valid + * + * @return void + */ + public function testRegenerateTokenOnGetWithInvalidData(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + 'cookies' => ['csrfToken' => "\x20\x26"], + ]); + + $middleware = new CsrfProtectionMiddleware(); + /** @var \Cake\Http\Response $response */ + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + $this->assertGreaterThan(32, strlen($response->getCookie('csrfToken')['value'])); + } + + /** + * Test that the CSRF tokens are set for redirect responses + */ + public function testRedirectResponseCookies(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + ]); + $handler = new TestRequestHandler(function () { + return new RedirectResponse('/'); + }); + + $middleware = new CsrfProtectionMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertStringContainsString('csrfToken=', $response->getHeaderLine('Set-Cookie')); + } + + /** + * Test that double applying CSRF causes a failure. + */ + public function testDoubleApplicationFailure(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + ]); + $request = $request->withAttribute('csrfToken', 'not-good'); + $handler = new TestRequestHandler(function () { + return new RedirectResponse('/'); + }); + + $middleware = new CsrfProtectionMiddleware(); + $this->expectException(CakeException::class); + $middleware->process($request, $handler); + } + + /** + * Test that the CSRF tokens are set for diactoros responses + */ + public function testDiactorosResponseCookies(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + ]); + $handler = new TestRequestHandler(function () { + return new DiactorosResponse(); + }); + + $middleware = new CsrfProtectionMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertStringContainsString('csrfToken=', $response->getHeaderLine('Set-Cookie')); + } + + /** + * Test that the X-CSRF-Token works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenInHeaderCompat(string $method): void + { + $middleware = new CsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => $token, + ], + 'post' => ['a' => 'b'], + 'cookies' => ['csrfToken' => $token], + ]); + $response = new Response(); + + // No exception means the test is valid + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Test that the X-CSRF-Token works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenInHeader(string $method): void + { + $middleware = new CsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $salted = $middleware->saltToken($token); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => $salted, + ], + 'post' => ['a' => 'b'], + 'cookies' => ['csrfToken' => $token], + ]); + $response = new Response(); + + // No exception means the test is valid + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Test that the X-CSRF-Token works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testInvalidTokenInHeader(string $method): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => 'nope', + ], + 'post' => ['a' => 'b'], + 'cookies' => ['csrfToken' => 'testing123'], + ]); + + $middleware = new CsrfProtectionMiddleware(); + + try { + $middleware->process($request, $this->_getRequestHandler()); + + $this->fail(); + } catch (InvalidCsrfTokenException $exception) { + $responseHeaders = $exception->getHeaders(); + + $this->assertArrayHasKey('Set-Cookie', $responseHeaders); + + $cookie = Cookie::createFromHeaderString($responseHeaders['Set-Cookie']); + $this->assertSame('csrfToken', $cookie->getName(), 'Should automatically delete cookie with invalid CSRF token'); + $this->assertTrue($cookie->isExpired(), 'Should automatically delete cookie with invalid CSRF token'); + } + } + + /** + * Test that request data works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenRequestDataCompat(string $method): void + { + $middleware = new CsrfProtectionMiddleware(); + $token = $this->createOldToken(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => $token], + 'cookies' => ['csrfToken' => $token], + ]); + + $handler = new TestRequestHandler(function ($request) { + $this->assertNull($request->getData('_csrfToken')); + + return new Response(); + }); + + // No exception means everything is OK + $middleware->process($request, $handler); + } + + /** + * Test that request data works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenRequestDataSalted(string $method): void + { + $middleware = new CsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $salted = $middleware->saltToken($token); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => $salted], + 'cookies' => ['csrfToken' => $token], + ]); + + $handler = new TestRequestHandler(function ($request) { + $this->assertNull($request->getData('_csrfToken')); + + return new Response(); + }); + + // No exception means everything is OK + $middleware->process($request, $handler); + } + + /** + * Test that invalid string cookies are rejected. + * + * @return void + */ + public function testInvalidTokenStringCookies(): void + { + $this->expectException(InvalidCsrfTokenException::class); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + 'post' => ['_csrfToken' => ["\x20\x26"]], + 'cookies' => ['csrfToken' => ["\x20\x26"]], + ]); + $middleware = new CsrfProtectionMiddleware(); + $middleware->process($request, $this->_getRequestHandler()); + } + + /** + * Test that empty value cookies are rejected + * + * @return void + */ + public function testInvalidTokenEmptyStringCookies(): void + { + $this->expectException(InvalidCsrfTokenException::class); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + 'post' => ['_csrfToken' => '*(&'], + // Invalid data that can't be base64 decoded. + 'cookies' => ['csrfToken' => '*(&'], + ]); + $middleware = new CsrfProtectionMiddleware(); + $middleware->process($request, $this->_getRequestHandler()); + } + + /** + * Test that request non string cookies are ignored. + */ + public function testInvalidTokenNonStringCookies(): void + { + $this->expectException(InvalidCsrfTokenException::class); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + 'post' => ['_csrfToken' => ['nope']], + 'cookies' => ['csrfToken' => ['nope']], + ]); + $middleware = new CsrfProtectionMiddleware(); + $middleware->process($request, $this->_getRequestHandler()); + } + + /** + * Test that request data works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testInvalidTokenRequestData(string $method): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => 'nope'], + 'cookies' => ['csrfToken' => 'testing123'], + ]); + + $middleware = new CsrfProtectionMiddleware(); + + try { + $middleware->process($request, $this->_getRequestHandler()); + + $this->fail(); + } catch (InvalidCsrfTokenException $exception) { + $responseHeaders = $exception->getHeaders(); + $this->assertArrayHasKey('Set-Cookie', $responseHeaders); + + $cookie = Cookie::createFromHeaderString($responseHeaders['Set-Cookie']); + $this->assertSame('csrfToken', $cookie->getName(), 'Should automatically delete cookie with invalid CSRF token'); + $this->assertTrue($cookie->isExpired(), 'Should automatically delete cookie with invalid CSRF token'); + } + } + + /** + * Test that tokens cannot be simple matches and must pass our hmac. + */ + public function testInvalidTokenIncorrectOrigin(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + 'post' => ['_csrfToken' => 'this is a match'], + 'cookies' => ['csrfToken' => 'this is a match'], + ]); + + $middleware = new CsrfProtectionMiddleware(); + + $this->expectException(InvalidCsrfTokenException::class); + $middleware->process($request, $this->_getRequestHandler()); + } + + /** + * Test that missing post field fails + */ + public function testInvalidTokenRequestDataMissing(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + 'post' => [], + 'cookies' => ['csrfToken' => 'testing123'], + ]); + + $middleware = new CsrfProtectionMiddleware(); + $this->expectException(InvalidCsrfTokenException::class); + $middleware->process($request, $this->_getRequestHandler()); + } + + /** + * Test that missing header and cookie fails + */ + #[DataProvider('httpMethodProvider')] + public function testInvalidTokenMissingCookie(string $method): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => 'could-be-valid'], + 'cookies' => [], + ]); + + $middleware = new CsrfProtectionMiddleware(); + + try { + $middleware->process($request, $this->_getRequestHandler()); + + $this->fail(); + } catch (InvalidCsrfTokenException $exception) { + $responseHeaders = $exception->getHeaders(); + $this->assertEmpty($responseHeaders, 'Should not send any header'); + } + } + + /** + * Test that the configuration options work. + */ + public function testConfigurationCookieCreate(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + 'webroot' => '/dir/', + ]); + + $middleware = new CsrfProtectionMiddleware([ + 'cookieName' => 'token', + 'expiry' => '+1 hour', + 'secure' => true, + 'httponly' => true, + 'samesite' => CookieInterface::SAMESITE_STRICT, + ]); + $response = $middleware->process($request, $this->_getRequestHandler()); + + $this->assertEmpty($response->getCookie('csrfToken')); + $cookie = $response->getCookie('token'); + $this->assertNotEmpty($cookie, 'Should set a token.'); + $this->assertMatchesRegularExpression('/^[a-z0-9\/+]+={0,2}$/i', $cookie['value'], 'Should look like base64.'); + $this->assertWithinRange(strtotime('+1 hour'), $cookie['expires'], 1, 'session duration.'); + $this->assertSame('/dir/', $cookie['path'], 'session path.'); + $this->assertTrue($cookie['secure'], 'cookie security flag missing'); + $this->assertTrue($cookie['httponly'], 'cookie httponly flag missing'); + $this->assertSame(CookieInterface::SAMESITE_STRICT, $cookie['samesite'], 'samesite attribute missing'); + } + + /** + * Test that the configuration options work. + * + * There should be no exception thrown. + */ + public function testConfigurationValidate(): void + { + $middleware = new CsrfProtectionMiddleware([ + 'cookieName' => 'token', + 'field' => 'token', + 'expiry' => 90, + ]); + $token = $middleware->createToken(); + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'POST'], + 'cookies' => ['csrfToken' => 'nope', 'token' => $token], + 'post' => ['_csrfToken' => 'no match', 'token' => $token], + ]); + $response = new Response(); + + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + public function testSkippingTokenCheckUsingSkipCheckCallback(): void + { + $request = new ServerRequest([ + 'post' => [ + '_csrfToken' => 'foo', + ], + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ]); + $response = new Response(); + + $middleware = new CsrfProtectionMiddleware(); + $middleware->skipCheckCallback(function (ServerRequestInterface $request) { + $this->assertSame('POST', $request->getServerParams()['REQUEST_METHOD']); + + return true; + }); + + $handler = new TestRequestHandler(function ($request) { + $this->assertEmpty($request->getParsedBody()); + + return new Response(); + }); + + $response = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Ensure salting is not consistent + */ + public function testSaltToken(): void + { + $middleware = new CsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $results = []; + for ($i = 0; $i < 10; $i++) { + $results[] = $middleware->saltToken($token); + } + $this->assertCount(10, array_unique($results)); + } +} diff --git a/tests/TestCase/Http/Middleware/EncryptedCookieMiddlewareTest.php b/tests/TestCase/Http/Middleware/EncryptedCookieMiddlewareTest.php new file mode 100644 index 00000000000..4050d1e1802 --- /dev/null +++ b/tests/TestCase/Http/Middleware/EncryptedCookieMiddlewareTest.php @@ -0,0 +1,163 @@ +_encrypt('secret data', 'aes'); + + $this->middleware = new EncryptedCookieMiddleware( + ['secret', 'ninja'], + $this->_getCookieEncryptionKey(), + 'aes', + ); + } + + /** + * Test decoding request cookies + */ + public function testDecodeRequestCookies(): void + { + $request = new ServerRequest(['url' => '/cookies/nom']); + $request = $request->withCookieParams([ + 'plain' => 'always plain', + 'secret' => $this->_encrypt('decoded', 'aes'), + ]); + $this->assertNotEquals('decoded', $request->getCookie('decoded')); + + $handler = new TestRequestHandler(function ($req) { + $this->assertSame('decoded', $req->getCookie('secret')); + $this->assertSame('always plain', $req->getCookie('plain')); + + return (new Response())->withHeader('called', 'yes'); + }); + $response = $this->middleware->process($request, $handler); + $this->assertSame('yes', $response->getHeaderLine('called'), 'Inner middleware not invoked'); + } + + /** + * Test decoding malformed cookies + * + * @param string $cookie + */ + #[DataProvider('malformedCookies')] + public function testDecodeMalformedCookies($cookie): void + { + $request = new ServerRequest(['url' => '/cookies/nom']); + $request = $request->withCookieParams(['secret' => $cookie]); + + $handler = new TestRequestHandler(function ($req) { + $this->assertSame('', $req->getCookie('secret')); + + return new Response(); + }); + $middleware = new EncryptedCookieMiddleware( + ['secret'], + $this->_getCookieEncryptionKey(), + 'aes', + ); + $middleware->process($request, $handler); + } + + /** + * Data provider for malformed cookies. + * + * @return array + */ + public static function malformedCookies(): array + { + return [ + 'empty' => [''], + 'wrong prefix' => [substr_replace(static::$encryptedString, 'foo', 0, 3)], + 'altered' => [str_replace('M', 'A', static::$encryptedString)], + 'invalid chars' => [str_replace('M', 'M#', static::$encryptedString)], + ]; + } + + /** + * Test encoding cookies in the set-cookie header. + */ + public function testEncodeResponseSetCookieHeader(): void + { + $request = new ServerRequest(['url' => '/cookies/nom']); + $handler = new TestRequestHandler(function ($req) { + return (new Response())->withAddedHeader('Set-Cookie', 'secret=be%20quiet') + ->withAddedHeader('Set-Cookie', 'plain=in%20clear') + ->withAddedHeader('Set-Cookie', 'ninja=shuriken'); + }); + $response = $this->middleware->process($request, $handler); + $this->assertStringNotContainsString('ninja=shuriken', $response->getHeaderLine('Set-Cookie')); + $this->assertStringContainsString('plain=in%20clear', $response->getHeaderLine('Set-Cookie')); + + $cookies = CookieCollection::createFromHeader($response->getHeader('Set-Cookie')); + $this->assertTrue($cookies->has('ninja')); + $this->assertSame( + 'shuriken', + $this->_decrypt($cookies->get('ninja')->getValue(), 'aes'), + ); + } + + /** + * Test encoding cookies in the cookie collection. + */ + public function testEncodeResponseCookieData(): void + { + $request = new ServerRequest(['url' => '/cookies/nom']); + $handler = new TestRequestHandler(function ($req) { + return (new Response())->withCookie(new Cookie('secret', 'be quiet')) + ->withCookie(new Cookie('plain', 'in clear')) + ->withCookie(new Cookie('ninja', 'shuriken')); + }); + $response = $this->middleware->process($request, $handler); + $this->assertNotSame('shuriken', $response->getCookie('ninja')); + $this->assertSame( + 'shuriken', + $this->_decrypt($response->getCookie('ninja')['value'], 'aes'), + ); + } +} diff --git a/tests/TestCase/Http/Middleware/HttpsEnforcerMiddlewareTest.php b/tests/TestCase/Http/Middleware/HttpsEnforcerMiddlewareTest.php new file mode 100644 index 00000000000..92043e1f995 --- /dev/null +++ b/tests/TestCase/Http/Middleware/HttpsEnforcerMiddlewareTest.php @@ -0,0 +1,256 @@ +withUri($uri); + + $handler = new TestRequestHandler(function ($req) { + return new Response(['body' => 'success']); + }); + + $middleware = new HttpsEnforcerMiddleware(); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('success', (string)$result->getBody()); + } + + public function testHstsResponse(): void + { + $uri = new Uri('https://localhost/foo'); + $request = new ServerRequest(); + $request = $request->withUri($uri); + + $handler = new TestRequestHandler(function ($req) { + return new Response(['body' => 'success']); + }); + + $middleware = new HttpsEnforcerMiddleware(['hsts' => ['maxAge' => 63072000]]); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('max-age=63072000', $result->getHeaderLine('strict-transport-security')); + } + + public function testHstsResponseWithDirectives(): void + { + $uri = new Uri('https://localhost/foo'); + $request = new ServerRequest(); + $request = $request->withUri($uri); + + $handler = new TestRequestHandler(function ($req) { + return new Response(['body' => 'success']); + }); + + $middleware = new HttpsEnforcerMiddleware(['hsts' => ['maxAge' => 63072000, 'includeSubDomains' => true, 'preload' => true]]); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('max-age=63072000; includeSubDomains; preload', $result->getHeaderLine('strict-transport-security')); + } + + public function testHstsResponseInvalidConfig(): void + { + $uri = new Uri('https://localhost/foo'); + $request = new ServerRequest(); + $request = $request->withUri($uri); + + $handler = new TestRequestHandler(function ($req) { + return new Response(['body' => 'success']); + }); + + $middleware = new HttpsEnforcerMiddleware(['hsts' => true]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('The `hsts` config must be an array.'); + $middleware->process($request, $handler); + } + + public function testRedirect(): void + { + $uri = new Uri('http://localhost/foo'); + $request = new ServerRequest(); + $request = $request->withUri($uri)->withMethod('GET'); + + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + + $middleware = new HttpsEnforcerMiddleware(); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(RedirectResponse::class, $result); + $this->assertSame(301, $result->getStatusCode()); + $this->assertEquals(['location' => ['https://localhost/foo']], $result->getHeaders()); + + $middleware = new HttpsEnforcerMiddleware([ + 'statusCode' => 302, + 'headers' => ['X-Foo' => 'bar'], + ]); + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(RedirectResponse::class, $result); + $this->assertSame(302, $result->getStatusCode()); + $this->assertEquals( + [ + 'location' => ['https://localhost/foo'], + 'X-Foo' => ['bar'], + ], + $result->getHeaders(), + ); + } + + public function testRedirectBasePath(): void + { + $request = new ServerRequest([ + 'url' => '/articles', + 'base' => '/base', + 'method' => 'GET', + ]); + + $handler = new TestRequestHandler(function () { + return new Response(); + }); + + $middleware = new HttpsEnforcerMiddleware(); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(RedirectResponse::class, $result); + $this->assertSame(301, $result->getStatusCode()); + $this->assertEquals(['location' => ['https://localhost/base/articles']], $result->getHeaders()); + } + + /** + * Test that exception is thrown when redirect is disabled. + */ + public function testNoRedirectException(): void + { + $this->expectException(BadRequestException::class); + + $uri = new Uri('http://localhost/foo'); + $request = new ServerRequest(); + $request = $request->withUri($uri); + + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + + $middleware = new HttpsEnforcerMiddleware(['redirect' => false]); + $middleware->process($request, $handler); + } + + /** + * Test that exception is thrown for non GET request even if redirect is enabled. + */ + public function testExceptionForNonGetRequest(): void + { + $this->expectException(BadRequestException::class); + + $uri = new Uri('http://localhost/foo'); + $request = new ServerRequest(); + $request = $request->withUri($uri)->withMethod('POST'); + + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + + $middleware = new HttpsEnforcerMiddleware(['redirect' => true]); + $middleware->process($request, $handler); + } + + /** + * Test that HTTPS check is skipped when debug is on. + */ + public function testNoCheckWithDebugOn(): void + { + Configure::write('debug', true); + + $uri = new Uri('http://localhost/foo'); + $request = new ServerRequest(); + $request = $request->withUri($uri); + + $handler = new TestRequestHandler(function ($req) { + return new Response(['body' => 'skipped']); + }); + + $middleware = new HttpsEnforcerMiddleware(); + + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + $this->assertSame('skipped', (string)$result->getBody()); + } + + /** + * Test that setting trustedProxies works correctly + */ + public function testTrustedProxies(): void + { + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/webroot', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/posts/add', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ]; + $request = ServerRequestFactory::fromGlobals($server); + + $handler = new TestRequestHandler(function () { + return new Response(); + }); + + $middleware = new HttpsEnforcerMiddleware(); + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(RedirectResponse::class, $result); + + $middleware = new HttpsEnforcerMiddleware(['trustedProxies' => []]); + $result = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $result); + } +} diff --git a/tests/TestCase/Http/Middleware/RateLimitMiddlewareTest.php b/tests/TestCase/Http/Middleware/RateLimitMiddlewareTest.php new file mode 100644 index 00000000000..eea39e43007 --- /dev/null +++ b/tests/TestCase/Http/Middleware/RateLimitMiddlewareTest.php @@ -0,0 +1,348 @@ + 'Array', + ]); + + $this->handler = Mockery::mock(RequestHandlerInterface::class); + $this->handler->shouldReceive('handle') + ->andReturn(new Response()); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + parent::tearDown(); + Cache::drop('rate_limit_test'); + } + + /** + * Test basic rate limiting + * + * @return void + */ + public function testBasicRateLimit(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 2, + 'window' => 60, + 'cache' => 'rate_limit_test', + ]); + + $request = new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1'], + ]); + + // First request should pass + $response = $middleware->process($request, $this->handler); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals('2', $response->getHeaderLine('X-RateLimit-Limit')); + $this->assertEquals('1', $response->getHeaderLine('X-RateLimit-Remaining')); + + // Second request should pass + $response = $middleware->process($request, $this->handler); + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals('0', $response->getHeaderLine('X-RateLimit-Remaining')); + + // Third request should fail + $this->expectException(TooManyRequestsException::class); + $this->expectExceptionMessage('Rate limit exceeded. Please try again later.'); + $middleware->process($request, $this->handler); + } + + /** + * Test IP-based identification + * + * @return void + */ + public function testIpIdentification(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 1, + 'window' => 60, + 'identifier' => 'ip', + 'cache' => 'rate_limit_test', + ]); + + $request1 = new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '192.168.1.1'], + ]); + $request2 = new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '192.168.1.2'], + ]); + + // Different IPs should have separate limits + $middleware->process($request1, $this->handler); + $middleware->process($request2, $this->handler); + + // Second request from first IP should fail + $this->expectException(TooManyRequestsException::class); + $middleware->process($request1, $this->handler); + } + + /** + * Test skip check callback + * + * @return void + */ + public function testSkipCheck(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 1, + 'window' => 60, + 'cache' => 'rate_limit_test', + 'skipCheck' => function ($request) { + return $request->getParam('action') === 'health'; + }, + ]); + + $request = new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1'], + 'params' => ['action' => 'health'], + ]); + + // Should not be rate limited + $middleware->process($request, $this->handler); + $middleware->process($request, $this->handler); + $middleware->process($request, $this->handler); + + // No exception thrown + $this->assertTrue(true); + } + + /** + * Test custom identifier callback + * + * @return void + */ + public function testCustomIdentifier(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 1, + 'window' => 60, + 'cache' => 'rate_limit_test', + 'identifierCallback' => function ($request) { + return $request->getHeaderLine('X-API-Key'); + }, + ]); + + $request1 = (new ServerRequest())->withHeader('X-API-Key', 'key1'); + $request2 = (new ServerRequest())->withHeader('X-API-Key', 'key2'); + + // Different API keys should have separate limits + $middleware->process($request1, $this->handler); + $middleware->process($request2, $this->handler); + + // Second request with same key should fail + $this->expectException(TooManyRequestsException::class); + $middleware->process($request1, $this->handler); + } + + /** + * Test cost callback + * + * @return void + */ + public function testCostCallback(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 10, + 'window' => 60, + 'cache' => 'rate_limit_test', + 'costCallback' => function ($request) { + return $request->getMethod() === 'POST' ? 5 : 1; + }, + ]); + + $getRequest = new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1', 'REQUEST_METHOD' => 'GET'], + ]); + $postRequest = new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1', 'REQUEST_METHOD' => 'POST'], + ]); + + // GET request costs 1 + $response = $middleware->process($getRequest, $this->handler); + $this->assertEquals('9', $response->getHeaderLine('X-RateLimit-Remaining')); + + // POST request costs 5 + $response = $middleware->process($postRequest, $this->handler); + $this->assertEquals('4', $response->getHeaderLine('X-RateLimit-Remaining')); + } + + /** + * Test headers are not added when disabled + * + * @return void + */ + public function testNoHeaders(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 10, + 'window' => 60, + 'cache' => 'rate_limit_test', + 'headers' => false, + ]); + + $request = new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1'], + ]); + + $response = $middleware->process($request, $this->handler); + $this->assertFalse($response->hasHeader('X-RateLimit-Limit')); + $this->assertFalse($response->hasHeader('X-RateLimit-Remaining')); + $this->assertFalse($response->hasHeader('X-RateLimit-Reset')); + } + + /** + * Test user-based identification + * + * @return void + */ + public function testUserIdentification(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 1, + 'window' => 60, + 'identifier' => 'user', + 'cache' => 'rate_limit_test', + ]); + + $identity = new stdClass(); + $identity->id = '123'; + + $request = (new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1'], + ]))->withAttribute('identity', $identity); + + $response = $middleware->process($request, $this->handler); + $this->assertEquals('0', $response->getHeaderLine('X-RateLimit-Remaining')); + + // Second request should fail + $this->expectException(TooManyRequestsException::class); + $middleware->process($request, $this->handler); + } + + /** + * Test route-based identification + * + * @return void + */ + public function testRouteIdentification(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 1, + 'window' => 60, + 'identifier' => 'route', + 'cache' => 'rate_limit_test', + ]); + + $request = (new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1'], + ]))->withAttribute('params', [ + 'controller' => 'Users', + 'action' => 'login', + ]); + + $response = $middleware->process($request, $this->handler); + $this->assertEquals('0', $response->getHeaderLine('X-RateLimit-Remaining')); + + // Different route should work + $request2 = (new ServerRequest([ + 'environment' => ['REMOTE_ADDR' => '127.0.0.1'], + ]))->withAttribute('params', [ + 'controller' => 'Users', + 'action' => 'logout', + ]); + + $middleware->process($request2, $this->handler); + + // Same route should fail + $this->expectException(TooManyRequestsException::class); + $middleware->process($request, $this->handler); + } + + /** + * Test proxy headers for IP detection + * + * @return void + */ + public function testProxyHeaders(): void + { + $middleware = new RateLimitMiddleware([ + 'limit' => 1, + 'window' => 60, + 'cache' => 'rate_limit_test', + ]); + + // Test Cloudflare header + $request = new ServerRequest([ + 'environment' => [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_CF_CONNECTING_IP' => '192.168.1.100', + ], + ]); + $middleware->process($request, $this->handler); + + // Test X-Forwarded-For + $request2 = new ServerRequest([ + 'environment' => [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '192.168.1.101, 10.0.0.1', + ], + ]); + $middleware->process($request2, $this->handler); + + // Different IPs should work + $this->assertTrue(true); + } +} diff --git a/tests/TestCase/Http/Middleware/SecurityHeadersMiddlewareTest.php b/tests/TestCase/Http/Middleware/SecurityHeadersMiddlewareTest.php new file mode 100644 index 00000000000..2a534457abd --- /dev/null +++ b/tests/TestCase/Http/Middleware/SecurityHeadersMiddlewareTest.php @@ -0,0 +1,89 @@ + '/', + ]); + $handler = new TestRequestHandler(function ($req) { + return new Response(); + }); + + $middleware = new SecurityHeadersMiddleware(); + $middleware + ->setCrossDomainPolicy() + ->setReferrerPolicy() + ->setXFrameOptions() + ->setXssProtection() + ->noOpen() + ->noSniff() + ->setPermissionsPolicy('camera=(), geolocation=(), microphone=()'); + + $expected = [ + 'x-permitted-cross-domain-policies' => ['all'], + 'x-xss-protection' => ['1; mode=block'], + 'referrer-policy' => ['same-origin'], + 'x-frame-options' => ['sameorigin'], + 'x-download-options' => ['noopen'], + 'x-content-type-options' => ['nosniff'], + 'permissions-policy' => ['camera=(), geolocation=(), microphone=()'], + ]; + + $result = $middleware->process($request, $handler); + $this->assertEquals($expected, $result->getHeaders()); + } + + /** + * Testing that the URL is required when option is `allow-from` + */ + public function testInvalidArgumentExceptionForsetXFrameOptionsUrl(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The 2nd arg $url can not be empty when `allow-from` is used'); + $middleware = new SecurityHeadersMiddleware(); + $middleware->setXFrameOptions('allow-from'); + } + + /** + * Testing the protected checkValues() method that is used by most of the + * methods in the test to avoid passing an invalid argument. + */ + public function testCheckValues(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid arg `INVALID-VALUE!`, use one of these: `all`, `none`, `master-only`, `by-content-type`, `by-ftp-filename`'); + $middleware = new SecurityHeadersMiddleware(); + $middleware->setCrossDomainPolicy('INVALID-VALUE!'); + } +} diff --git a/tests/TestCase/Http/Middleware/SessionCsrfProtectionMiddlewareTest.php b/tests/TestCase/Http/Middleware/SessionCsrfProtectionMiddlewareTest.php new file mode 100644 index 00000000000..08d3fd1f37c --- /dev/null +++ b/tests/TestCase/Http/Middleware/SessionCsrfProtectionMiddlewareTest.php @@ -0,0 +1,432 @@ +data. + * + * @return array + */ + public static function safeHttpMethodProvider(): array + { + return [ + ['GET'], + ['HEAD'], + ]; + } + + /** + * Data provider for HTTP methods that can contain request bodies. + * + * @return array + */ + public static function httpMethodProvider(): array + { + return [ + ['OPTIONS'], ['PATCH'], ['PUT'], ['POST'], ['DELETE'], ['PURGE'], ['INVALIDMETHOD'], + ]; + } + + /** + * Provides the request handler + */ + protected function _getRequestHandler(): RequestHandlerInterface + { + return new TestRequestHandler(function () { + return new Response(); + }); + } + + /** + * Test setting the cookie value + */ + public function testSettingTokenInSession(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + 'webroot' => '/dir/', + ]); + + /** @var \Cake\Http\ServerRequest|null $updatedRequest */ + $updatedRequest = null; + $handler = new TestRequestHandler(function ($request) use (&$updatedRequest) { + $updatedRequest = $request; + + return new Response(); + }); + + $middleware = new SessionCsrfProtectionMiddleware(); + $response = $middleware->process($request, $handler); + + $this->assertInstanceOf(Response::class, $response); + $token = $updatedRequest->getSession()->read('csrfToken'); + $this->assertNotEmpty($token, 'Should set a token.'); + $this->assertMatchesRegularExpression('/^[A-Z0-9+\/]+=*$/i', $token, 'Should look like base64 data.'); + $requestAttr = $updatedRequest->getAttribute('csrfToken'); + $this->assertNotEquals($token, $requestAttr); + $this->assertEquals(strlen($token) * 2, strlen($requestAttr)); + $this->assertMatchesRegularExpression('/^[A-Z0-9\/+]+=*$/i', $requestAttr); + } + + /** + * Test that the CSRF tokens are not required for idempotent operations + */ + #[DataProvider('safeHttpMethodProvider')] + public function testSafeMethodNoCsrfRequired(string $method): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => 'nope', + ], + ]); + $request->getSession()->write('csrfToken', 'testing123'); + + // No exception means the test is valid + $middleware = new SessionCsrfProtectionMiddleware(); + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Test that the X-CSRF-Token works with the various http methods. + * + * Ensure unsalted tokens work. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenInHeaderBackwardsCompat(string $method): void + { + $middleware = new SessionCsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => $token, + ], + 'post' => ['a' => 'b'], + ]); + $request->getSession()->write('csrfToken', $token); + $response = new Response(); + + // No exception means the test is valid + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Test that the X-CSRF-Token works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenInHeader(string $method): void + { + $middleware = new SessionCsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $salted = $middleware->saltToken($token); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => $salted, + ], + 'post' => ['a' => 'b'], + ]); + $request->getSession()->write('csrfToken', $token); + $response = new Response(); + + // No exception means the test is valid + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Test that the X-CSRF-Token works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testInvalidTokenInHeader(string $method): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + 'HTTP_X_CSRF_TOKEN' => 'nope', + ], + 'post' => ['a' => 'b'], + ]); + $request->getSession()->write('csrfToken', 'testing123'); + + $middleware = new SessionCsrfProtectionMiddleware(); + + try { + $middleware->process($request, $this->_getRequestHandler()); + + $this->fail(); + } catch (InvalidCsrfTokenException) { + $token = $request->getSession()->read('csrfToken'); + $this->assertSame('testing123', $token, 'session token should not change.'); + } + } + + /** + * Test that request data works with the various http methods. + * + * Ensure unsalted tokens are still accepted. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenInRequestDataBackwardsCompat(string $method): void + { + $middleware = new SessionCsrfProtectionMiddleware(); + $token = $middleware->createToken(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => $token], + ]); + $request->getSession()->write('csrfToken', $token); + + $handler = new TestRequestHandler(function ($request) { + $this->assertNull($request->getData('_csrfToken')); + + return new Response(); + }); + + // No exception means everything is OK + $middleware->process($request, $handler); + } + + /** + * Test that request data works with the various http methods. + * + * Ensure salted tokens are accepted. + */ + #[DataProvider('httpMethodProvider')] + public function testValidTokenInRequestData(string $method): void + { + $middleware = new SessionCsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $salted = $middleware->saltToken($token); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => $salted], + ]); + $request->getSession()->write('csrfToken', $token); + + $handler = new TestRequestHandler(function ($request) { + $this->assertNull($request->getData('_csrfToken')); + + return new Response(); + }); + + // No exception means everything is OK + $middleware->process($request, $handler); + } + + /** + * Test that request data works with the various http methods. + */ + #[DataProvider('httpMethodProvider')] + public function testInvalidTokenRequestData(string $method): void + { + $middleware = new SessionCsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => 'nope'], + ]); + $request->getSession()->write('csrfToken', $token); + + $middleware = new SessionCsrfProtectionMiddleware(); + + $this->expectException(InvalidCsrfTokenException::class); + $middleware->process($request, $this->_getRequestHandler()); + } + + /** + * Test that missing post field fails + */ + public function testInvalidTokenRequestDataMissing(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + 'post' => [], + ]); + $request->getSession()->write('csrfToken', 'testing123'); + + $middleware = new SessionCsrfProtectionMiddleware(); + $this->expectException(InvalidCsrfTokenException::class); + $middleware->process($request, $this->_getRequestHandler()); + } + + /** + * Test that missing session key fails + */ + #[DataProvider('httpMethodProvider')] + public function testInvalidTokenMissingSession(string $method): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => $method, + ], + 'post' => ['_csrfToken' => 'could-be-valid'], + 'cookies' => [], + ]); + + $middleware = new SessionCsrfProtectionMiddleware(); + + try { + $middleware->process($request, $this->_getRequestHandler()); + + $this->fail(); + } catch (InvalidCsrfTokenException) { + $token = $request->getSession()->read('csrfToken'); + $this->assertNotEmpty($token, 'Should set a token in the session on failure.'); + } + } + + /** + * Test that the configuration options work. + */ + public function testConfigurationCookieCreate(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'GET'], + 'webroot' => '/dir/', + ]); + + $middleware = new SessionCsrfProtectionMiddleware([ + 'key' => 'csrf', + ]); + $middleware->process($request, $this->_getRequestHandler()); + + $session = $request->getSession(); + $this->assertEmpty($session->read('csrfToken')); + $token = $session->read('csrf'); + $this->assertNotEmpty($token, 'Should set a token.'); + $this->assertMatchesRegularExpression('/^[A-Z0-9\/+]+=*$/i', $token, 'Should look like base64 data.'); + } + + /** + * Test that the configuration options work. + * + * There should be no exception thrown. + */ + public function testConfigurationValidate(): void + { + $middleware = new SessionCsrfProtectionMiddleware([ + 'key' => 'csrf', + 'field' => 'token', + ]); + $token = $middleware->createToken(); + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'POST'], + 'post' => ['_csrfToken' => 'no match', 'token' => $token], + ]); + $request->getSession()->write('csrf', $token); + + $response = $middleware->process($request, $this->_getRequestHandler()); + $this->assertInstanceOf(Response::class, $response); + } + + public function testSkippingTokenCheckUsingSkipCheckCallback(): void + { + $request = new ServerRequest([ + 'post' => [ + '_csrfToken' => 'foo', + ], + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ]); + $request->getSession()->write('csrfToken', 'foo'); + + $middleware = new SessionCsrfProtectionMiddleware(); + $middleware->skipCheckCallback(function (ServerRequestInterface $request) { + $this->assertSame('POST', $request->getServerParams()['REQUEST_METHOD']); + + return true; + }); + + $handler = new TestRequestHandler(function ($request) { + $this->assertEmpty($request->getParsedBody()); + + return new Response(); + }); + + $response = $middleware->process($request, $handler); + $this->assertInstanceOf(Response::class, $response); + } + + /** + * Ensure salting is not consistent + */ + public function testSaltToken(): void + { + $middleware = new SessionCsrfProtectionMiddleware(); + $token = $middleware->createToken(); + $results = []; + for ($i = 0; $i < 10; $i++) { + $results[] = $middleware->saltToken($token); + } + $this->assertCount(10, array_unique($results)); + } + + /** + * Ensure that tokens can be regenerated + */ + public function testRegenerateToken(): void + { + $request = new ServerRequest([ + 'url' => '/articles/', + ]); + $updated = SessionCsrfProtectionMiddleware::replaceToken($request); + $this->assertNotSame($request, $updated); + + $session = $updated->getSession()->read('csrfToken'); + $this->assertNotEmpty($session); + $attribute = $updated->getAttribute('csrfToken'); + $this->assertNotEmpty($attribute); + $this->assertNotEquals($session, $attribute, 'Should not be equal because of salting'); + + $updated = SessionCsrfProtectionMiddleware::replaceToken($request, 'custom-key'); + $this->assertNotSame($request, $updated); + $this->assertNotEmpty($updated->getSession()->read('custom-key')); + $this->assertNotEmpty($updated->getAttribute('custom-key')); + } +} diff --git a/tests/TestCase/Http/MiddlewareApplicationTest.php b/tests/TestCase/Http/MiddlewareApplicationTest.php new file mode 100644 index 00000000000..ad22e3694fc --- /dev/null +++ b/tests/TestCase/Http/MiddlewareApplicationTest.php @@ -0,0 +1,67 @@ + '/cakes']); + $request = $request->withAttribute('params', [ + 'controller' => 'Cakes', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + ]); + + $app = new class extends MiddlewareApplication { + public function bootstrap(): void + { + } + + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + $result = $app->handle($request); + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame('Not found', '' . $result->getBody()); + $this->assertSame(404, $result->getStatusCode()); + } +} diff --git a/tests/TestCase/Http/MiddlewareQueueTest.php b/tests/TestCase/Http/MiddlewareQueueTest.php new file mode 100644 index 00000000000..0593a1048fe --- /dev/null +++ b/tests/TestCase/Http/MiddlewareQueueTest.php @@ -0,0 +1,406 @@ +previousNamespace = static::setAppNamespace('TestApp'); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + static::setAppNamespace($this->previousNamespace); + } + + public function testConstructorAddingMiddleware(): void + { + $cb = function (): void { + }; + $queue = new MiddlewareQueue([$cb]); + $this->assertCount(1, $queue); + $this->assertSame($cb, $queue->current()->getCallable()); + } + + /** + * Test get() + */ + public function testGet(): void + { + $queue = new MiddlewareQueue(); + $cb = function (): void { + }; + $queue->add($cb); + $this->assertSame($cb, $queue->current()->getCallable()); + } + + /** + * Test that current() throws exception for invalid current position. + */ + public function testGetException(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Invalid current position (0)'); + + $queue = new MiddlewareQueue(); + $queue->current(); + } + + /** + * Test the return value of add() + */ + public function testAddReturn(): void + { + $queue = new MiddlewareQueue(); + $cb = function (): void { + }; + $this->assertSame($queue, $queue->add($cb)); + } + + /** + * Test the add orders correctly + */ + public function testAddOrdering(): void + { + $one = function (): void { + }; + $two = function (): void { + }; + + $queue = new MiddlewareQueue(); + $this->assertCount(0, $queue); + + $queue->add($one); + $this->assertCount(1, $queue); + + $queue->add($two); + $this->assertCount(2, $queue); + + $this->assertSame($one, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($two, $queue->current()->getCallable()); + } + + /** + * Test the prepend can be chained + */ + public function testPrependReturn(): void + { + $cb = function (): void { + }; + $queue = new MiddlewareQueue(); + $this->assertSame($queue, $queue->prepend($cb)); + } + + /** + * Test the prepend orders correctly. + */ + public function testPrependOrdering(): void + { + $one = function (): void { + }; + $two = function (): void { + }; + + $queue = new MiddlewareQueue(); + $this->assertCount(0, $queue); + + $queue->add($one); + $this->assertCount(1, $queue); + + $queue->prepend($two); + $this->assertCount(2, $queue); + + $this->assertSame($two, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($one, $queue->current()->getCallable()); + } + + /** + * Test updating queue using class name + */ + public function testAddingPrependingUsingString(): void + { + $queue = new MiddlewareQueue(); + $queue->add('Sample'); + $queue->prepend(SampleMiddleware::class); + + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + } + + /** + * Test updating queue using array + */ + public function testAddingPrependingUsingArray(): void + { + $one = function (): void { + }; + + $queue = new MiddlewareQueue(); + $queue->add([$one]); + $queue->prepend([SampleMiddleware::class]); + + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + $queue->next(); + $this->assertSame($one, $queue->current()->getCallable()); + } + + /** + * Test insertAt ordering + */ + public function testInsertAt(): void + { + $one = function (): void { + }; + $two = function (): void { + }; + $three = function (): void { + }; + $four = new SampleMiddleware(); + + $queue = new MiddlewareQueue(); + $queue->add($one)->add($two)->insertAt(0, $three)->insertAt(2, $four); + $this->assertSame($three, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($one, $queue->current()->getCallable()); + $queue->next(); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + $queue->next(); + $this->assertSame($two, $queue->current()->getCallable()); + + $queue = new MiddlewareQueue(); + $queue->add($one)->add($two)->insertAt(1, $three); + $this->assertSame($one, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($three, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($two, $queue->current()->getCallable()); + } + + /** + * Test insertAt out of the existing range + */ + public function testInsertAtOutOfBounds(): void + { + $one = function (): void { + }; + $two = function (): void { + }; + + $queue = new MiddlewareQueue(); + $queue->add($one)->insertAt(99, $two); + + $this->assertCount(2, $queue); + $this->assertSame($one, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($two, $queue->current()->getCallable()); + } + + /** + * Test insertAt with a negative index + */ + public function testInsertAtNegative(): void + { + $one = function (): void { + }; + $two = function (): void { + }; + $three = new SampleMiddleware(); + + $queue = new MiddlewareQueue(); + $queue->add($one)->insertAt(-1, $two)->insertAt(-1, $three); + + $this->assertCount(3, $queue); + $this->assertSame($two, $queue->current()->getCallable()); + $queue->next(); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + $queue->next(); + $this->assertSame($one, $queue->current()->getCallable()); + } + + /** + * Test insertBefore + */ + public function testInsertBefore(): void + { + $one = function (): void { + }; + $two = new SampleMiddleware(); + $three = function (): void { + }; + $four = new DumbMiddleware(); + + $queue = new MiddlewareQueue(); + $queue->add($one)->add($two)->insertBefore(SampleMiddleware::class, $three)->insertBefore(SampleMiddleware::class, $four); + + $this->assertCount(4, $queue); + $this->assertSame($one, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($three, $queue->current()->getCallable()); + $queue->next(); + $this->assertInstanceOf(DumbMiddleware::class, $queue->current()); + $queue->next(); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + + $two = SampleMiddleware::class; + $queue = new MiddlewareQueue(); + $queue + ->add($one) + ->add($two) + ->insertBefore(SampleMiddleware::class, $three); + + $this->assertCount(3, $queue); + $this->assertSame($one, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($three, $queue->current()->getCallable()); + $queue->next(); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + } + + /** + * Test insertBefore an invalid classname + */ + public function testInsertBeforeInvalid(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No middleware matching `InvalidClassName` could be found.'); + $one = function (): void { + }; + $two = new SampleMiddleware(); + $three = function (): void { + }; + $queue = new MiddlewareQueue(); + $queue->add($one)->add($two)->insertBefore('InvalidClassName', $three); + } + + /** + * Test insertAfter + */ + public function testInsertAfter(): void + { + $one = new SampleMiddleware(); + $two = function (): void { + }; + $three = function (): void { + }; + $four = new DumbMiddleware(); + $queue = new MiddlewareQueue(); + $queue + ->add($one) + ->add($two) + ->insertAfter(SampleMiddleware::class, $three) + ->insertAfter(SampleMiddleware::class, $four); + + $this->assertCount(4, $queue); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + $queue->next(); + $this->assertInstanceOf(DumbMiddleware::class, $queue->current()); + $queue->next(); + $this->assertSame($three, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($two, $queue->current()->getCallable()); + + $one = 'Sample'; + $queue = new MiddlewareQueue(); + $queue + ->add($one) + ->add($two) + ->insertAfter('Sample', $three); + + $this->assertCount(3, $queue); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + $queue->next(); + $this->assertSame($three, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($two, $queue->current()->getCallable()); + } + + /** + * Test insertAfter an invalid classname + */ + public function testInsertAfterInvalid(): void + { + $one = new SampleMiddleware(); + $two = function (): void { + }; + $three = function (): void { + }; + $queue = new MiddlewareQueue(); + $queue->add($one)->add($two)->insertAfter('InvalidClass', $three); + + $this->assertCount(3, $queue); + $this->assertInstanceOf(SampleMiddleware::class, $queue->current()); + $queue->next(); + $this->assertSame($two, $queue->current()->getCallable()); + $queue->next(); + $this->assertSame($three, $queue->current()->getCallable()); + } + + /** + * Make sure middlewares provided via DI are the same object + */ + public function testDIContainer(): void + { + $container = new Container(); + $middleware = new SampleMiddleware(); + $container->add(SampleMiddleware::class, $middleware); + $queue = new MiddlewareQueue([], $container); + $queue->add(SampleMiddleware::class); + $this->assertSame($middleware, $queue->current()); + } + + /** + * Make sure an exception is thrown for unknown middlewares + */ + public function testDIContainerNotResolvable(): void + { + $container = new Container(); + $queue = new MiddlewareQueue([], $container); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Middleware `UnresolvableMiddleware` was not found.'); + $queue->add('UnresolvableMiddleware'); + $queue->current(); + } +} diff --git a/tests/TestCase/Http/MimeTypeTest.php b/tests/TestCase/Http/MimeTypeTest.php new file mode 100644 index 00000000000..75c18bcf42d --- /dev/null +++ b/tests/TestCase/Http/MimeTypeTest.php @@ -0,0 +1,75 @@ +assertSame(['text/html', '*/*'], MimeType::getMimeTypes('html')); + $this->assertSame(['application/json'], MimeType::getMimeTypes('json')); + $this->assertNull(MimeType::getMimeTypes('unknown')); + } + + public function testGetMimeTypesAll(): void + { + $list = MimeType::getMimeTypes(); + $this->assertNotEmpty($list); + } + + public function testGetMimeType(): void + { + $this->assertSame('text/html', MimeType::getMimeType('html')); + $this->assertSame('application/json', MimeType::getMimeType('json')); + $this->assertNull(MimeType::getMimeType('unknown')); + } + + public function testAddMimeTypes(): void + { + MimeType::addMimeTypes('html', 'foo/bar'); + $this->assertContains('foo/bar', MimeType::getMimeTypes('html')); + + MimeType::addMimeTypes('newext', ['application/new', 'text/new']); + $this->assertSame(['application/new', 'text/new'], MimeType::getMimeTypes('newext')); + } + + public function testSetMimeTypes(): void + { + MimeType::setMimeTypes('html', 'application/xhtml+xml'); + $this->assertSame(['application/xhtml+xml'], MimeType::getMimeTypes('html')); + MimeType::setMimeTypes('html', ['text/html', '*/*']); + + MimeType::setMimeTypes('newext', ['application/new', 'text/new']); + $this->assertSame(['application/new', 'text/new'], MimeType::getMimeTypes('newext')); + } + + public function testGetExtension(): void + { + $this->assertSame('html', MimeType::getExtension('text/html')); + $this->assertSame('json', MimeType::getExtension('application/json')); + $this->assertNull(MimeType::getExtension('unknown/mime')); + } + + public function testGetMimeTypeForFile(): void + { + $this->assertSame('application/json', MimeType::getMimeTypeForFile(CONFIG . 'json_test.json')); + $this->assertSame('text/plain; charset=us-ascii', MimeType::getMimeTypeForFile(CONFIG . 'no_section.ini')); + } +} diff --git a/tests/TestCase/Http/RateLimit/SlidingWindowRateLimiterTest.php b/tests/TestCase/Http/RateLimit/SlidingWindowRateLimiterTest.php new file mode 100644 index 00000000000..0d1714c9017 --- /dev/null +++ b/tests/TestCase/Http/RateLimit/SlidingWindowRateLimiterTest.php @@ -0,0 +1,258 @@ + 'Array', + ]); + + $this->cache = Cache::pool('rate_limiter_test'); + $this->limiter = new SlidingWindowRateLimiter($this->cache); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + parent::tearDown(); + Cache::drop('rate_limiter_test'); + } + + /** + * Test basic rate limiting + * + * @return void + */ + public function testBasicAttempt(): void + { + $result = $this->limiter->attempt('test_user', 5, 60); + + $this->assertTrue($result['allowed']); + $this->assertEquals(5, $result['limit']); + $this->assertEquals(4, $result['remaining']); + $this->assertGreaterThan(time(), $result['reset']); + } + + /** + * Test rate limit exceeded + * + * @return void + */ + public function testRateLimitExceeded(): void + { + $identifier = 'test_user_limit'; + $limit = 3; + $window = 60; + + // Use up the limit + for ($i = 0; $i < $limit; $i++) { + $result = $this->limiter->attempt($identifier, $limit, $window); + $this->assertTrue($result['allowed']); + $this->assertEquals($limit - $i - 1, $result['remaining']); + } + + // Next attempt should fail + $result = $this->limiter->attempt($identifier, $limit, $window); + $this->assertFalse($result['allowed']); + $this->assertEquals(0, $result['remaining']); + } + + /** + * Test different identifiers have separate limits + * + * @return void + */ + public function testSeparateIdentifiers(): void + { + $result1 = $this->limiter->attempt('user1', 1, 60); + $this->assertTrue($result1['allowed']); + $this->assertEquals(0, $result1['remaining']); + + $result2 = $this->limiter->attempt('user2', 1, 60); + $this->assertTrue($result2['allowed']); + $this->assertEquals(0, $result2['remaining']); + + // First user should be blocked + $result3 = $this->limiter->attempt('user1', 1, 60); + $this->assertFalse($result3['allowed']); + + // Second user should still work + $result4 = $this->limiter->attempt('user2', 1, 60); + $this->assertFalse($result4['allowed']); + } + + /** + * Test cost parameter + * + * @return void + */ + public function testCost(): void + { + $identifier = 'test_cost'; + $limit = 10; + $window = 60; + + // First request with cost 3 + $result = $this->limiter->attempt($identifier, $limit, $window, 3); + $this->assertTrue($result['allowed']); + $this->assertEquals(7, $result['remaining']); + + // Second request with cost 5 + $result = $this->limiter->attempt($identifier, $limit, $window, 5); + $this->assertTrue($result['allowed']); + $this->assertEquals(2, $result['remaining']); + + // Third request with cost 3 should fail + $result = $this->limiter->attempt($identifier, $limit, $window, 3); + $this->assertFalse($result['allowed']); + $this->assertEquals(2, $result['remaining']); + + // But cost 2 should work + $result = $this->limiter->attempt($identifier, $limit, $window, 2); + $this->assertTrue($result['allowed']); + $this->assertEquals(0, $result['remaining']); + } + + /** + * Test reset functionality + * + * @return void + */ + public function testReset(): void + { + $identifier = 'test_reset'; + $limit = 2; + $window = 60; + + // Use up limit + $this->limiter->attempt($identifier, $limit, $window); + $this->limiter->attempt($identifier, $limit, $window); + + $result = $this->limiter->attempt($identifier, $limit, $window); + $this->assertFalse($result['allowed']); + + // Reset the limit + $this->limiter->reset($identifier); + + // Should be able to make requests again + $result = $this->limiter->attempt($identifier, $limit, $window); + $this->assertTrue($result['allowed']); + $this->assertEquals(1, $result['remaining']); + } + + /** + * Test sliding window behavior + * + * @return void + */ + public function testSlidingWindowDecay(): void + { + $identifier = 'test_sliding'; + $limit = 10; + $window = 2; // 2 second window for testing + + // Make initial request + $result = $this->limiter->attempt($identifier, $limit, $window, 5); + $this->assertTrue($result['allowed']); + $this->assertEquals(5, $result['remaining']); + + // Wait for half the window + sleep(1); + + // The count should have decayed by ~50% + $result = $this->limiter->attempt($identifier, $limit, $window, 1); + $this->assertTrue($result['allowed']); + // Due to decay, remaining should be more than 4 + $this->assertGreaterThan(4, $result['remaining']); + } + + /** + * Test window expiration + * + * @return void + */ + public function testWindowExpiration(): void + { + $identifier = 'test_expiration'; + $limit = 1; + $window = 1; // 1 second window + + // Use up the limit + $result = $this->limiter->attempt($identifier, $limit, $window); + $this->assertTrue($result['allowed']); + + // Should be blocked + $result = $this->limiter->attempt($identifier, $limit, $window); + $this->assertFalse($result['allowed']); + + // Wait for window to expire + sleep(2); + + // Should be allowed again + $result = $this->limiter->attempt($identifier, $limit, $window); + $this->assertTrue($result['allowed']); + } + + /** + * Test reset time calculation + * + * @return void + */ + public function testResetTime(): void + { + $identifier = 'test_reset_time'; + $limit = 5; + $window = 60; + $startTime = time(); + + $result = $this->limiter->attempt($identifier, $limit, $window); + + $this->assertGreaterThanOrEqual($startTime + $window, $result['reset']); + $this->assertLessThanOrEqual($startTime + $window + 1, $result['reset']); + } +} diff --git a/tests/TestCase/Http/RequestFactoryTest.php b/tests/TestCase/Http/RequestFactoryTest.php new file mode 100644 index 00000000000..8948fe1e7ce --- /dev/null +++ b/tests/TestCase/Http/RequestFactoryTest.php @@ -0,0 +1,43 @@ +createRequest('POST', 'http://example.com'); + + $this->assertSame('http://example.com', (string)$request->getUri()); + $this->assertStringContainsString($request->getMethod(), 'POST'); + + $uri = new Uri('http://example.com'); + $request = $factory->createRequest('GET', $uri); + + $this->assertSame($uri, $request->getUri()); + $this->assertStringContainsString($request->getMethod(), 'GET'); + } +} diff --git a/tests/TestCase/Http/ResponseEmitterTest.php b/tests/TestCase/Http/ResponseEmitterTest.php new file mode 100644 index 00000000000..ab00e265809 --- /dev/null +++ b/tests/TestCase/Http/ResponseEmitterTest.php @@ -0,0 +1,369 @@ +emitter = Mockery::mock(ResponseEmitter::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $this->emitter->__construct(); + + $this->emitter->shouldReceive('setCookie') + ->zeroOrMoreTimes() + ->andReturnUsing(function ($cookie) { + if (is_string($cookie)) { + $cookie = Cookie::createFromHeaderString($cookie, ['path' => '']); + } + + $GLOBALS['mockedCookies'][] = ['name' => $cookie->getName(), 'value' => $cookie->getValue()] + + $cookie->getOptions(); + + return true; + }); + } + + /** + * teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($GLOBALS['mockedHeadersSent']); + } + + /** + * Test emitting simple responses. + */ + public function testEmitResponseSimple(): void + { + $response = (new Response()) + ->withStatus(201) + ->withHeader('Content-Type', 'text/html') + ->withHeader('Location', 'http://example.com/cake/1'); + $response->getBody()->write('It worked'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('It worked', $out); + $expected = [ + 'HTTP/1.1 201 Created', + 'Content-Type: text/html', + 'Location: http://example.com/cake/1', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test emitting a no-content response + */ + public function testEmitNoContentResponse(): void + { + $response = (new Response()) + ->withHeader('X-testing', 'value') + ->withStatus(204); + $response->getBody()->write('It worked'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('', $out); + $expected = [ + 'HTTP/1.1 204 No Content', + 'X-testing: value', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test emitting responses with array cookes + */ + public function testEmitResponseArrayCookies(): void + { + $response = (new Response()) + ->withCookie(new Cookie('simple', 'val', null, '/', '', true)) + ->withAddedHeader('Set-Cookie', 'google=not=nice;Path=/accounts; HttpOnly') + ->withHeader('Content-Type', 'text/plain'); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('ok', $out); + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/plain', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + $expected = [ + [ + 'name' => 'simple', + 'value' => 'val', + 'path' => '/', + 'expires' => 0, + 'domain' => '', + 'secure' => true, + 'httponly' => false, + ], + [ + 'name' => 'google', + 'value' => 'not=nice', + 'path' => '/accounts', + 'expires' => 0, + 'domain' => '', + 'secure' => false, + 'httponly' => true, + ], + ]; + $this->assertEquals($expected, $GLOBALS['mockedCookies']); + } + + /** + * Test emitting responses with cookies + */ + public function testEmitResponseCookies(): void + { + $response = (new Response()) + ->withAddedHeader('Set-Cookie', "simple=val;\tSecure") + ->withAddedHeader('Set-Cookie', 'people=jim,jack,jonny";";Path=/accounts') + ->withAddedHeader('Set-Cookie', 'google=not=nice;Path=/accounts; HttpOnly; samesite=Strict') + ->withAddedHeader('Set-Cookie', 'a=b; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Domain=www.example.com;') + ->withAddedHeader('Set-Cookie', 'list%5B%5D=a%20b%20c') + ->withHeader('Content-Type', 'text/plain'); + $response->getBody()->write('ok'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('ok', $out); + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/plain', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + $expected = [ + [ + 'name' => 'simple', + 'value' => 'val', + 'path' => '', + 'expires' => 0, + 'domain' => '', + 'secure' => true, + 'httponly' => false, + ], + [ + 'name' => 'people', + 'value' => 'jim,jack,jonny";"', + 'path' => '/accounts', + 'expires' => 0, + 'domain' => '', + 'secure' => false, + 'httponly' => false, + ], + [ + 'name' => 'google', + 'value' => 'not=nice', + 'path' => '/accounts', + 'expires' => 0, + 'domain' => '', + 'secure' => false, + 'httponly' => true, + 'samesite' => 'Strict', + ], + [ + 'name' => 'a', + 'value' => 'b', + 'path' => '', + 'expires' => 1610576581, + 'domain' => 'www.example.com', + 'secure' => false, + 'httponly' => false, + ], + [ + 'name' => 'list[]', + 'value' => 'a b c', + 'path' => '', + 'expires' => 0, + 'domain' => '', + 'secure' => false, + 'httponly' => false, + ], + ]; + $this->assertEquals($expected, $GLOBALS['mockedCookies']); + } + + /** + * Test emitting responses using callback streams. + * + * We use callback streams for closure based responses. + */ + public function testEmitResponseCallbackStream(): void + { + $stream = new CallbackStream(function (): void { + echo 'It worked'; + }); + $response = (new Response()) + ->withStatus(201) + ->withBody($stream) + ->withHeader('Content-Type', 'text/plain'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('It worked', $out); + $expected = [ + 'HTTP/1.1 201 Created', + 'Content-Type: text/plain', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test valid body ranges. + */ + public function testEmitResponseBodyRange(): void + { + $response = (new Response()) + ->withHeader('Content-Type', 'text/plain') + ->withHeader('Content-Range', 'bytes 1-4/9'); + $response->getBody()->write('It worked'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('t wo', $out); + $expected = [ + 'HTTP/1.1 200 OK', + 'Content-Type: text/plain', + 'Content-Range: bytes 1-4/9', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } + + /** + * Test valid body ranges. + */ + public function testEmitResponseBodyRangeComplete(): void + { + $response = (new Response()) + ->withHeader('Content-Type', 'text/plain') + ->withHeader('Content-Range', 'bytes 0-20/9'); + $response->getBody()->write('It worked'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('It worked', $out); + } + + /** + * Test out of bounds body ranges. + */ + public function testEmitResponseBodyRangeOverflow(): void + { + $response = (new Response()) + ->withHeader('Content-Type', 'text/plain') + ->withHeader('Content-Range', 'bytes 5-20/9'); + $response->getBody()->write('It worked'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('rked', $out); + } + + /** + * Test malformed content-range header + */ + public function testEmitResponseBodyRangeMalformed(): void + { + $response = (new Response()) + ->withHeader('Content-Type', 'text/plain') + ->withHeader('Content-Range', 'bytes 9-ba/a'); + $response->getBody()->write('It worked'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('It worked', $out); + } + + /** + * Test callback streams returning content and ranges + */ + public function testEmitResponseBodyRangeCallbackStream(): void + { + $stream = new CallbackStream(function () { + return 'It worked'; + }); + $response = (new Response()) + ->withStatus(201) + ->withBody($stream) + ->withHeader('Content-Range', 'bytes 1-4/9') + ->withHeader('Content-Type', 'text/plain'); + + ob_start(); + $this->emitter->emit($response); + $out = ob_get_clean(); + + $this->assertSame('t wo', $out); + $expected = [ + 'HTTP/1.1 201 Created', + 'Content-Range: bytes 1-4/9', + 'Content-Type: text/plain', + ]; + $this->assertEquals($expected, $GLOBALS['mockedHeaders']); + } +} diff --git a/tests/TestCase/Http/ResponseFactoryTest.php b/tests/TestCase/Http/ResponseFactoryTest.php new file mode 100644 index 00000000000..bc3a6cc68ba --- /dev/null +++ b/tests/TestCase/Http/ResponseFactoryTest.php @@ -0,0 +1,35 @@ +createResponse(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('OK', $response->getReasonPhrase()); + } +} diff --git a/tests/TestCase/Http/ResponseTest.php b/tests/TestCase/Http/ResponseTest.php new file mode 100644 index 00000000000..2ff4f0a4c2c --- /dev/null +++ b/tests/TestCase/Http/ResponseTest.php @@ -0,0 +1,1575 @@ +server = $_SERVER; + } + + /** + * teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + $_SERVER = $this->server; + unset($GLOBALS['mockedHeadersSent']); + } + + /** + * Tests the request object constructor + */ + public function testConstruct(): void + { + $response = new Response(); + $this->assertSame('', (string)$response->getBody()); + $this->assertSame('UTF-8', $response->getCharset()); + $this->assertSame('text/html', $response->getType()); + $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); + $this->assertSame(200, $response->getStatusCode()); + + $options = [ + 'body' => 'This is the body', + 'charset' => 'my-custom-charset', + 'type' => 'mp3', + 'status' => 203, + ]; + $response = new Response($options); + $this->assertSame('This is the body', (string)$response->getBody()); + $this->assertSame('my-custom-charset', $response->getCharset()); + $this->assertSame('audio/mpeg', $response->getType()); + $this->assertSame('audio/mpeg', $response->getHeaderLine('Content-Type')); + $this->assertSame(203, $response->getStatusCode()); + } + + /** + * Tests the getCharset/withCharset methods + */ + public function testWithCharset(): void + { + $response = new Response(); + $this->assertSame('text/html; charset=UTF-8', $response->getHeaderLine('Content-Type')); + + $new = $response->withCharset('iso-8859-1'); + $this->assertStringNotContainsString('iso', $response->getHeaderLine('Content-Type'), 'Old instance not changed'); + $this->assertSame('iso-8859-1', $new->getCharset()); + + $this->assertSame('text/html; charset=iso-8859-1', $new->getHeaderLine('Content-Type')); + } + + /** + * Tests the getType method + */ + public function testGetType(): void + { + $response = new Response(); + $this->assertSame('text/html', $response->getType()); + + $this->assertSame( + 'application/pdf', + $response->withType('pdf')->getType(), + ); + $this->assertSame( + 'custom/stuff', + $response->withType('custom/stuff')->getType(), + ); + $this->assertSame( + 'application/json', + $response->withType('json')->getType(), + ); + } + + public function testSetTypeMap(): void + { + $response = new Response(); + $response->setTypeMap('ical', 'text/calendar'); + + $response = $response->withType('ical')->getType(); + $this->assertSame('text/calendar', $response); + } + + public function testSetTypeMapAsArray(): void + { + $response = new Response(); + $response->setTypeMap('ical', ['text/calendar']); + + $response = $response->withType('ical')->getType(); + $this->assertSame('text/calendar', $response); + } + + /** + * Tests the withType method + */ + public function testWithTypeAlias(): void + { + $response = new Response(); + $this->assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + 'Default content-type should match', + ); + + $new = $response->withType('pdf'); + $this->assertNotSame($new, $response, 'Should be a new instance'); + + $this->assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + 'Original object should not be modified', + ); + $this->assertSame('application/pdf', $new->getHeaderLine('Content-Type')); + + $json = $new->withType('json'); + $this->assertSame('application/json', $json->getHeaderLine('Content-Type')); + $this->assertSame('application/json', $json->getType()); + } + + /** + * test withType() and full mime-types + */ + public function withTypeFull(): void + { + $response = new Response(); + $this->assertSame( + 'application/json', + $response->withType('application/json')->getHeaderLine('Content-Type'), + 'Should not add charset to explicit type', + ); + $this->assertSame( + 'custom/stuff', + $response->withType('custom/stuff')->getHeaderLine('Content-Type'), + 'Should allow arbitrary types', + ); + $this->assertSame( + 'text/html; charset=UTF-8', + $response->withType('text/html; charset=UTF-8')->getHeaderLine('Content-Type'), + 'Should allow charset types', + ); + } + + /** + * Test that an invalid type raises an exception + */ + public function testWithTypeInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`beans` is an invalid content type'); + $response = new Response(); + $response->withType('beans'); + } + + /** + * Data provider for content type tests. + * + * @return array + */ + public static function charsetTypeProvider(): array + { + return [ + ['mp3', 'audio/mpeg'], + ['js', 'application/javascript; charset=UTF-8'], + ['xml', 'application/xml; charset=UTF-8'], + ['txt', 'text/plain; charset=UTF-8'], + ]; + } + + /** + * Test that setting certain status codes clears the status code. + */ + public function testWithStatusClearsContentType(): void + { + $response = new Response(); + $new = $response->withType('pdf') + ->withStatus(204); + $this->assertFalse($new->hasHeader('Content-Type')); + $this->assertSame('', $new->getType()); + $this->assertSame(204, $new->getStatusCode(), 'Status code should clear content-type'); + + $response = new Response(); + $new = $response->withStatus(304) + ->withType('pdf'); + $this->assertSame('', $new->getType()); + $this->assertFalse( + $new->hasHeader('Content-Type'), + 'Type should not be retained because of status code.', + ); + + $response = new Response(); + $new = $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(204); + $this->assertFalse($new->hasHeader('Content-Type'), 'Should clear direct header'); + $this->assertSame('', $new->getType()); + } + + /** + * Test that setting status codes doesn't overwrite content-type + */ + public function testWithStatusDoesNotChangeContentType(): void + { + $response = new Response(); + $new = $response->withHeader('Content-Type', 'application/json') + ->withStatus(403); + $this->assertSame('application/json', $new->getHeaderLine('Content-Type')); + $this->assertSame(403, $new->getStatusCode()); + + $response = new Response(); + $new = $response->withStatus(403) + ->withHeader('Content-Type', 'application/json'); + $this->assertSame('application/json', $new->getHeaderLine('Content-Type')); + $this->assertSame(403, $new->getStatusCode()); + $this->assertSame('application/json', $new->getType()); + } + + /** + * Tests the withDisabledCache method + */ + public function testWithDisabledCache(): void + { + $response = new Response(); + $expected = [ + 'Expires' => ['Mon, 26 Jul 1997 05:00:00 GMT'], + 'Last-Modified' => [DateTime::parse(time())->toRfc7231String()], + 'Cache-Control' => ['no-store, no-cache, must-revalidate, post-check=0, pre-check=0'], + 'Content-Type' => ['text/html; charset=UTF-8'], + ]; + $new = $response->withDisabledCache(); + $this->assertFalse($response->hasHeader('Expires'), 'Old instance not mutated.'); + + $this->assertEquals($expected, $new->getHeaders()); + } + + /** + * Tests the withCache method + */ + public function testWithCache(): void + { + $response = new Response(); + $since = time(); + $time = $since; + + $new = $response->withCache($since, $time); + $this->assertFalse($response->hasHeader('Date')); + $this->assertFalse($response->hasHeader('Last-Modified')); + + $this->assertSame(DateTime::parse($since)->toRfc7231String(), $new->getHeaderLine('Date')); + $this->assertSame(DateTime::parse($since)->toRfc7231String(), $new->getHeaderLine('Last-Modified')); + $this->assertSame(DateTime::parse($time)->toRfc7231String(), $new->getHeaderLine('Expires')); + $this->assertSame('public, max-age=0', $new->getHeaderLine('Cache-Control')); + } + + /** + * Tests the compress method + */ + public function testCompress(): void + { + $response = new Response(); + if (ini_get('zlib.output_compression') === '1' || !extension_loaded('zlib')) { + $this->assertFalse($response->compress()); + $this->markTestSkipped('Is not possible to test output compression'); + } + + $_SERVER['HTTP_ACCEPT_ENCODING'] = ''; + $result = $response->compress(); + $this->assertFalse($result); + + $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip'; + $result = $response->compress(); + $this->assertTrue($result); + $this->assertContains('ob_gzhandler', ob_list_handlers()); + + ob_get_clean(); + } + + /** + * Tests the withDownload method + */ + public function testWithDownload(): void + { + $response = new Response(); + $new = $response->withDownload('myfile.mp3'); + $this->assertFalse($response->hasHeader('Content-Disposition'), 'No mutation'); + + $expected = 'attachment; filename="myfile.mp3"'; + $this->assertSame($expected, $new->getHeaderLine('Content-Disposition')); + } + + /** + * Tests the mapType method + */ + public function testMapType(): void + { + $response = new Response(); + $this->assertSame('wav', $response->mapType('audio/x-wav')); + $this->assertSame('pdf', $response->mapType('application/pdf')); + $this->assertSame('xml', $response->mapType('text/xml')); + $this->assertSame('html', $response->mapType('*/*')); + $this->assertSame('csv', $response->mapType('application/vnd.ms-excel')); + + $expected = ['json', 'xhtml', 'css']; + $result = $response->mapType(['application/json', 'application/xhtml+xml', 'text/css']); + $this->assertEquals($expected, $result); + + $array = [ + '1.0' => [ + 'text/csv', + 'text/xml', + ], + '0.8' => [ + 'application/json', + ], + '0.7' => [ + 'application/xml', + ], + ]; + $expected = [ + '1.0' => [ + 'csv', + 'xml', + ], + '0.8' => [ + 'json', + ], + '0.7' => [ + 'xml', + ], + ]; + $result = $response->mapType($array); + $this->assertEquals($expected, $result); + } + + /** + * Tests the outputCompressed method + */ + public function testOutputCompressed(): void + { + $response = new Response(); + + $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip'; + $result = $response->outputCompressed(); + $this->assertFalse($result); + + $_SERVER['HTTP_ACCEPT_ENCODING'] = ''; + $result = $response->outputCompressed(); + $this->assertFalse($result); + + if (!extension_loaded('zlib')) { + $this->markTestSkipped('Skipping further tests for outputCompressed as zlib extension is not loaded'); + } + + if (ini_get('zlib.output_compression') !== '1') { + ob_start('ob_gzhandler'); + } + $_SERVER['HTTP_ACCEPT_ENCODING'] = 'gzip'; + $result = $response->outputCompressed(); + $this->assertTrue($result); + + $_SERVER['HTTP_ACCEPT_ENCODING'] = ''; + $result = $response->outputCompressed(); + $this->assertFalse($result); + if (ini_get('zlib.output_compression') !== '1') { + ob_get_clean(); + } + } + + /** + * Tests settings the content length + */ + public function testWithLength(): void + { + $response = new Response(); + $this->assertFalse($response->hasHeader('Content-Length')); + + $new = $response->withLength(100); + $this->assertFalse($response->hasHeader('Content-Length'), 'Old instance not modified'); + + $this->assertSame('100', $new->getHeaderLine('Content-Length')); + } + + /** + * Tests settings the link + */ + public function testWithAddedLink(): void + { + $response = new Response(); + $this->assertFalse($response->hasHeader('Link')); + + $new = $response->withAddedLink('http://example.com', ['rel' => 'prev']); + $this->assertFalse($response->hasHeader('Link'), 'Old instance not modified'); + $this->assertSame('; rel="prev"', $new->getHeaderLine('Link')); + + $new = $response->withAddedLink('http://example.com'); + $this->assertSame('', $new->getHeaderLine('Link')); + + $new = $response->withAddedLink('http://example.com?p=1', ['rel' => 'prev']) + ->withAddedLink('http://example.com?p=2', ['rel' => 'next', 'foo' => 'bar']); + $this->assertSame('; rel="prev",; rel="next"; foo="bar"', $new->getHeaderLine('Link')); + } + + /** + * Tests the withExpires method + */ + public function testWithExpires(): void + { + $response = new Response(); + $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); + + $new = $response->withExpires($now); + $this->assertFalse($response->hasHeader('Expires')); + $this->assertSame($now->toRfc7231String(), $new->getHeaderLine('Expires')); + + $now = time(); + $new = $response->withExpires($now); + $this->assertSame(DateTime::parse($now)->toRfc7231String(), $new->getHeaderLine('Expires')); + + $time = new DateTime('2024-01-01'); + $new = $response->withExpires('2024-01-01'); + $this->assertSame($time->toRfc7231String(), $new->getHeaderLine('Expires')); + } + + /** + * Tests the withModified method + */ + public function testWithModified(): void + { + $response = new Response(); + $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); + $new = $response->withModified($now); + $this->assertFalse($response->hasHeader('Last-Modified')); + $this->assertSame($now->toRfc7231String(), $new->getHeaderLine('Last-Modified')); + + $now = time(); + $new = $response->withModified($now); + $this->assertSame(DateTime::parse($now)->toRfc7231String(), $new->getHeaderLine('Last-Modified')); + + $now = new DateTime(); + $new = $response->withModified($now); + $this->assertSame($now->toRfc7231String(), $new->getHeaderLine('Last-Modified')); + + $time = new DateTime('2024-01-01'); + $new = $response->withModified('2024-01-01'); + $this->assertSame($time->toRfc7231String(), $new->getHeaderLine('Last-Modified')); + } + + /** + * Tests withSharable() + */ + public function testWithSharable(): void + { + $response = new Response(); + $new = $response->withSharable(true); + $this->assertFalse($response->hasHeader('Cache-Control'), 'old instance unchanged'); + $this->assertSame('public', $new->getHeaderLine('Cache-Control')); + + $new = $response->withSharable(false); + $this->assertSame('private', $new->getHeaderLine('Cache-Control')); + + $new = $response->withSharable(true, 3600); + $this->assertSame('public, max-age=3600', $new->getHeaderLine('Cache-Control')); + + $new = $response->withSharable(false, 3600); + $this->assertSame('private, max-age=3600', $new->getHeaderLine('Cache-Control')); + } + + /** + * Tests withMaxAge() + */ + public function testWithMaxAge(): void + { + $response = new Response(); + $this->assertFalse($response->hasHeader('Cache-Control')); + + $new = $response->withMaxAge(3600); + $this->assertSame('max-age=3600', $new->getHeaderLine('Cache-Control')); + + $new = $response->withMaxAge(3600) + ->withSharable(false); + $this->assertSame('max-age=3600, private', $new->getHeaderLine('Cache-Control')); + } + + /** + * Tests setting of s-maxage Cache-Control directive + */ + public function testWithSharedMaxAge(): void + { + $response = new Response(); + $new = $response->withSharedMaxAge(3600); + + $this->assertFalse($response->hasHeader('Cache-Control')); + $this->assertSame('s-maxage=3600', $new->getHeaderLine('Cache-Control')); + + $new = $response->withSharedMaxAge(3600)->withSharable(true); + $this->assertSame('s-maxage=3600, public', $new->getHeaderLine('Cache-Control')); + } + + /** + * Tests setting of must-revalidate Cache-Control directive + */ + public function testWithMustRevalidate(): void + { + $response = new Response(); + $this->assertFalse($response->hasHeader('Cache-Control')); + + $new = $response->withMustRevalidate(true); + $this->assertFalse($response->hasHeader('Cache-Control')); + $this->assertSame('must-revalidate', $new->getHeaderLine('Cache-Control')); + + $new = $new->withMustRevalidate(false); + $this->assertEmpty($new->getHeaderLine('Cache-Control')); + } + + /** + * Tests withVary() + */ + public function testWithVary(): void + { + $response = new Response(); + $new = $response->withVary('Accept-encoding'); + + $this->assertFalse($response->hasHeader('Vary')); + $this->assertSame('Accept-encoding', $new->getHeaderLine('Vary')); + + $new = $response->withVary(['Accept-encoding', 'Accept-language']); + $this->assertFalse($response->hasHeader('Vary')); + $this->assertSame('Accept-encoding,Accept-language', $new->getHeaderLine('Vary')); + } + + /** + * Tests withEtag() + */ + public function testWithEtag(): void + { + $response = new Response(); + $new = $response->withEtag('something'); + + $this->assertFalse($response->hasHeader('Etag')); + $this->assertSame('"something"', $new->getHeaderLine('Etag')); + + $new = $response->withEtag('something', true); + $this->assertSame('W/"something"', $new->getHeaderLine('Etag')); + } + + /** + * Tests withNotModified() + */ + public function testWithNotModified(): void + { + $response = new Response(['body' => 'something']); + $response = $response->withLength(100) + ->withStatus(200) + ->withHeader('Last-Modified', 'value') + ->withHeader('Content-Language', 'en-EN') + ->withHeader('X-things', 'things') + ->withType('application/json'); + + $new = $response->withNotModified(); + $this->assertTrue($response->hasHeader('Content-Language'), 'old instance not changed'); + $this->assertTrue($response->hasHeader('Content-Length'), 'old instance not changed'); + + $this->assertFalse($new->hasHeader('Content-Type')); + $this->assertFalse($new->hasHeader('Content-Length')); + $this->assertFalse($new->hasHeader('Content-Language')); + $this->assertFalse($new->hasHeader('Last-Modified')); + + $this->assertSame('things', $new->getHeaderLine('X-things'), 'Other headers are retained'); + $this->assertSame(304, $new->getStatusCode()); + $this->assertSame('', $new->getBody()->getContents()); + } + + /** + * Test checkNotModified method + */ + public function testCheckNotModifiedByEtagStar(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('If-None-Match', '*'); + + $response = new Response(); + $response = $response->withEtag('something') + ->withHeader('Content-Length', 99); + $this->assertTrue($response->isNotModified($request)); + } + + /** + * Test checkNotModified method + */ + public function testCheckNotModifiedByEtagExact(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('If-None-Match', 'W/"something", "other"'); + + $response = new Response(); + $response = $response->withEtag('something', true) + ->withHeader('Content-Length', 99); + $this->assertTrue($response->isNotModified($request)); + } + + /** + * Test checkNotModified method + */ + public function testCheckNotModifiedByEtagAndTime(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('If-Modified-Since', '2012-01-01 00:00:00') + ->withHeader('If-None-Match', 'W/"something", "other"'); + + $response = new Response(); + $response = $response->withModified('2012-01-01 00:00:00') + ->withEtag('something', true) + ->withHeader('Content-Length', 99); + $this->assertTrue($response->isNotModified($request)); + } + + /** + * Test checkNotModified method + */ + public function testCheckNotModifiedByEtagAndTimeMismatch(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('If-Modified-Since', '2012-01-01 00:00:00') + ->withHeader('If-None-Match', 'W/"something", "other"'); + + $response = new Response(); + $response = $response->withModified('2012-01-01 00:00:01') + ->withEtag('something', true) + ->withHeader('Content-Length', 99); + $this->assertFalse($response->isNotModified($request)); + } + + /** + * Test checkNotModified method + */ + public function testCheckNotModifiedByEtagMismatch(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('If-Modified-Since', '2012-01-01 00:00:00') + ->withHeader('If-None-Match', 'W/"something-else", "other"'); + + $response = new Response(); + $response = $response->withModified('2012-01-01 00:00:00') + ->withEtag('something', true) + ->withHeader('Content-Length', 99); + $this->assertFalse($response->isNotModified($request)); + } + + /** + * Test checkNotModified method + */ + public function testCheckNotModifiedByTime(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('If-Modified-Since', '2012-01-01 00:00:00'); + + $response = new Response(); + $response = $response->withModified('2012-01-01 00:00:00') + ->withHeader('Content-Length', 99); + $this->assertTrue($response->isNotModified($request)); + } + + /** + * Test checkNotModified method + */ + public function testCheckNotModifiedNoHints(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('If-None-Match', 'W/"something", "other"') + ->withHeader('If-Modified-Since', '2012-01-01 00:00:00'); + $response = new Response(); + $this->assertFalse($response->isNotModified($request)); + } + + /** + * Test setting cookies with no value + */ + public function testWithCookieEmpty(): void + { + $response = new Response(); + $new = $response->withCookie(new Cookie('testing')); + $this->assertNull($response->getCookie('testing'), 'withCookie does not mutate'); + + $expected = [ + 'name' => 'testing', + 'value' => '', + 'expires' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => false, + ]; + $result = $new->getCookie('testing'); + $this->assertEquals($expected, $result); + } + + /** + * Test setting cookies with scalar values + */ + public function testWithCookieScalar(): void + { + $response = new Response(); + $new = $response->withCookie(new Cookie('testing', 'abc123')); + $this->assertNull($response->getCookie('testing'), 'withCookie does not mutate'); + $this->assertSame('abc123', $new->getCookie('testing')['value']); + + $new = $response->withCookie(new Cookie('testing', 0.99)); + $this->assertEquals(0.99, $new->getCookie('testing')['value']); + + $new = $response->withCookie(new Cookie('testing', 99)); + $this->assertEquals(99, $new->getCookie('testing')['value']); + + $new = $response->withCookie(new Cookie('testing', false)); + $this->assertSame('', $new->getCookie('testing')['value']); + + $new = $response->withCookie(new Cookie('testing', true)); + $this->assertSame('1', $new->getCookie('testing')['value']); + } + + /** + * Test withCookie() and duplicate data + * + * @throws \Exception + */ + public function testWithDuplicateCookie(): void + { + $expiry = new DateTimeImmutable('+24 hours'); + + $response = new Response(); + $cookie = new Cookie( + 'testing', + '[a,b,c]', + $expiry, + '/test', + '', + true, + ); + + $new = $response->withCookie($cookie); + $this->assertNull($response->getCookie('testing'), 'withCookie does not mutate'); + + $expected = [ + 'name' => 'testing', + 'value' => '[a,b,c]', + 'expires' => $expiry, + 'path' => '/test', + 'domain' => '', + 'secure' => true, + 'httponly' => false, + ]; + + // Match the date time formatting to Response::convertCookieToArray + $expected['expires'] = $expiry->format('U'); + + $this->assertEquals($expected, $new->getCookie('testing')); + } + + /** + * Test withCookie() and a cookie instance + */ + public function testWithCookieObject(): void + { + $response = new Response(); + $cookie = new Cookie('yay', 'a value'); + $new = $response->withCookie($cookie); + $this->assertNull($response->getCookie('yay'), 'withCookie does not mutate'); + + $this->assertNotEmpty($new->getCookie('yay')); + $this->assertSame($cookie, $new->getCookieCollection()->get('yay')); + } + + public function testWithExpiredCookieScalar(): void + { + $response = new Response(); + $response = $response->withCookie(new Cookie('testing', 'abc123')); + $this->assertSame('abc123', $response->getCookie('testing')['value']); + + $new = $response->withExpiredCookie(new Cookie('testing')); + + $this->assertSame(0, $response->getCookie('testing')['expires']); + $this->assertLessThan(DateTime::createFromTimestamp(1), (string)$new->getCookie('testing')['expires']); + } + + /** + * @throws \Exception If DateImmutable emits an error. + */ + public function testWithExpiredCookieOptions(): void + { + $options = [ + 'name' => 'testing', + 'value' => 'abc123', + 'options' => [ + 'domain' => 'cakephp.org', + 'path' => '/custompath/', + 'secure' => true, + 'httponly' => true, + 'expires' => new DateTimeImmutable('+14 days'), + ], + ]; + + $cookie = Cookie::create( + $options['name'], + $options['value'], + $options['options'], + ); + + $response = new Response(); + $response = $response->withCookie($cookie); + + $options['options']['expires'] = $options['options']['expires']->format('U'); + $expected = ['name' => $options['name'], 'value' => $options['value']] + $options['options']; + $this->assertEquals($expected, $response->getCookie('testing')); + + $expiredCookie = $response->withExpiredCookie($cookie); + + $this->assertSame($expected['expires'], (string)$response->getCookie('testing')['expires']); + $this->assertLessThan(DateTime::createFromTimestamp(1), (string)$expiredCookie->getCookie('testing')['expires']); + } + + public function testWithExpiredCookieObject(): void + { + $response = new Response(); + $cookie = new Cookie('yay', 'a value'); + $response = $response->withCookie($cookie); + $this->assertSame('a value', $response->getCookie('yay')['value']); + + $new = $response->withExpiredCookie($cookie); + + $this->assertSame(0, $response->getCookie('yay')['expires']); + $this->assertSame(1, $new->getCookie('yay')['expires']); + } + + public function testWithExpiredCookieNotUtc(): void + { + date_default_timezone_set('Europe/Paris'); + + $response = new Response(); + $cookie = new Cookie('yay', 'a value'); + $response = $response->withExpiredCookie($cookie); + date_default_timezone_set('UTC'); + + $this->assertSame(1, $response->getCookie('yay')['expires']); + } + + /** + * Test getCookies() and array data. + */ + public function testGetCookies(): void + { + $response = new Response(); + $new = $response->withCookie(new Cookie('testing', 'a')) + ->withCookie(new Cookie('test2', 'b', null, '/test', '', true)); + $expected = [ + 'testing' => [ + 'name' => 'testing', + 'value' => 'a', + 'expires' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => false, + ], + 'test2' => [ + 'name' => 'test2', + 'value' => 'b', + 'expires' => 0, + 'path' => '/test', + 'domain' => '', + 'secure' => true, + 'httponly' => false, + ], + ]; + $this->assertEquals($expected, $new->getCookies()); + } + + /** + * Test getCookies() and array data. + */ + public function testGetCookiesArrayValue(): void + { + $response = new Response(); + $cookie = (new Cookie('urmc')) + ->withValue(['user_id' => 1, 'token' => 'abc123']) + ->withHttpOnly(true); + + $new = $response->withCookie($cookie); + $expected = [ + 'urmc' => [ + 'name' => 'urmc', + 'value' => '{"user_id":1,"token":"abc123"}', + 'expires' => 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => true, + ], + ]; + $this->assertEquals($expected, $new->getCookies()); + } + + /** + * Test getCookieCollection() as array data + */ + public function testGetCookieCollection(): void + { + $response = new Response(); + $new = $response->withCookie(new Cookie('testing', 'a')) + ->withCookie(new Cookie('test2', 'b', null, '/test', '', true)); + $cookies = $response->getCookieCollection(); + $this->assertInstanceOf(CookieCollection::class, $cookies); + $this->assertCount(0, $cookies, 'Original response not mutated'); + + $cookies = $new->getCookieCollection(); + $this->assertInstanceOf(CookieCollection::class, $cookies); + $this->assertCount(2, $cookies); + + $this->assertTrue($cookies->has('testing')); + $this->assertTrue($cookies->has('test2')); + } + + /** + * Test withCookieCollection() + */ + public function testWithCookieCollection(): void + { + $response = new Response(); + $collection = new CookieCollection([new Cookie('foo', 'bar')]); + $newResponse = $response->withCookieCollection($collection); + + $this->assertNotSame($response, $newResponse); + $this->assertNotSame($response->getCookieCollection(), $newResponse->getCookieCollection()); + $this->assertSame($newResponse->getCookie('foo')['value'], 'bar'); + } + + /** + * Test that cors() returns a builder. + */ + public function testCors(): void + { + $request = new ServerRequest([ + 'environment' => ['HTTP_ORIGIN' => 'http://example.com'], + ]); + $response = new Response(); + $builder = $response->cors($request); + $this->assertInstanceOf(CorsBuilder::class, $builder); + $this->assertSame($response, $builder->build(), 'Empty builder returns same object'); + } + + /** + * test withFile() not found + */ + public function testWithFileNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('The requested file /some/missing/folder/file.jpg was not found'); + + $response = new Response(); + $response->withFile('/some/missing/folder/file.jpg'); + } + + /** + * test withFile() not found + */ + public function testWithFileNotFoundNoDebug(): void + { + Configure::write('debug', 0); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('The requested file was not found'); + $response = new Response(); + $response->withFile('/some/missing/folder/file.jpg'); + } + + /** + * Provider for various kinds of unacceptable files. + * + * @return array + */ + public static function invalidFileProvider(): array + { + return [ + ['my/../cat.gif', 'The requested file contains `..` and will not be read.'], + ['my\..\cat.gif', 'The requested file contains `..` and will not be read.'], + ['my/ca..t.gif', 'my/ca..t.gif was not found or not readable'], + ['my/ca..t/image.gif', 'my/ca..t/image.gif was not found or not readable'], + ]; + } + + /** + * test withFile and invalid paths + */ + #[DataProvider('invalidFileProvider')] + public function testWithFileInvalidPath(string $path, string $expectedMessage): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage($expectedMessage); + + $response = new Response(); + $response->withFile($path); + } + + /** + * test withFile() + download & name + */ + public function testWithFileDownloadAndName(): void + { + $response = new Response(); + $new = $response->withFile( + TEST_APP . 'vendor' . DS . 'css' . DS . 'test_asset.css', + [ + 'name' => 'something_special.css', + 'download' => true, + ], + ); + $this->assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + 'No mutation', + ); + $this->assertSame( + 'text/css; charset=UTF-8', + $new->getHeaderLine('Content-Type'), + ); + $this->assertSame( + 'attachment; filename="something_special.css"', + $new->getHeaderLine('Content-Disposition'), + ); + $this->assertSame('bytes', $new->getHeaderLine('Accept-Ranges')); + $this->assertSame('binary', $new->getHeaderLine('Content-Transfer-Encoding')); + $body = $new->getBody(); + $this->assertInstanceOf(Stream::class, $body); + + $expected = '/* this is the test asset css file */'; + $this->assertSame($expected, trim($body->getContents())); + $file = $new->getFile()->openFile(); + $this->assertSame($expected, trim($file->fread(100))); + } + + /** + * test withFile() + a generic agent + */ + public function testWithFileUnknownFileTypeGeneric(): void + { + $response = new Response(); + file_put_contents(TMP . 'empty', ''); + + $new = $response->withFile(TMP . 'empty'); + $this->assertSame('application/x-empty; charset=binary', $new->getHeaderLine('Content-Type')); + $this->assertSame( + 'attachment; filename="empty"', + $new->getHeaderLine('Content-Disposition'), + ); + $this->assertSame( + 'binary', + $new->getHeaderLine('Content-Transfer-Encoding'), + ); + $this->assertSame('bytes', $new->getHeaderLine('Accept-Ranges')); + $body = $new->getBody(); + $expected = ''; + $this->assertSame($expected, $body->getContents()); + } + + /** + * test withFile() + no download + */ + public function testWithFileNoDownload(): void + { + $response = new Response(); + $new = $response->withFile(CONFIG . 'no_section.ini', [ + 'download' => false, + ]); + $this->assertSame( + 'text/plain; charset=us-ascii', + $new->getHeaderLine('Content-Type'), + ); + $this->assertFalse($new->hasHeader('Content-Disposition')); + $this->assertFalse($new->hasHeader('Content-Transfer-Encoding')); + } + + /** + * Test that uppercase extensions result in correct content-types + */ + public function testWithFileUpperExtension(): void + { + $response = new Response(); + $new = $response->withFile(TEST_APP . 'vendor/img/test_2.JPG'); + $this->assertSame('image/jpeg', $new->getHeaderLine('Content-Type')); + } + + /** + * A data provider for testing various ranges + * + * @return array + */ + public static function rangeProvider(): array + { + return [ + // suffix-byte-range + [ + 'bytes=-25', 25, 'bytes 13-37/38', + ], + + [ + 'bytes=0-', 38, 'bytes 0-37/38', + ], + + [ + 'bytes=10-', 28, 'bytes 10-37/38', + ], + + [ + 'bytes=10-20', 11, 'bytes 10-20/38', + ], + + // Spaced out + [ + 'bytes = 10 - 20', 11, 'bytes 10-20/38', + ], + ]; + } + + /** + * Test withFile() & the various range offset types. + */ + #[DataProvider('rangeProvider')] + public function testWithFileRangeOffsets(string $range, int $length, string $offsetResponse): void + { + $_SERVER['HTTP_RANGE'] = $range; + $response = new Response(); + $new = $response->withFile( + TEST_APP . 'vendor' . DS . 'css' . DS . 'test_asset.css', + ['download' => true], + ); + $this->assertSame( + 'attachment; filename="test_asset.css"', + $new->getHeaderLine('Content-Disposition'), + ); + $this->assertSame('binary', $new->getHeaderLine('Content-Transfer-Encoding')); + $this->assertSame('bytes', $new->getHeaderLine('Accept-Ranges')); + $this->assertEquals($length, $new->getHeaderLine('Content-Length')); + $this->assertEquals($offsetResponse, $new->getHeaderLine('Content-Range')); + } + + /** + * Test withFile() fetching ranges from a file. + */ + public function testWithFileRange(): void + { + $_SERVER['HTTP_RANGE'] = 'bytes=8-25'; + $response = new Response(); + $new = $response->withFile( + TEST_APP . 'vendor' . DS . 'css' . DS . 'test_asset.css', + ['download' => true], + ); + + $this->assertSame( + 'attachment; filename="test_asset.css"', + $new->getHeaderLine('Content-Disposition'), + ); + $this->assertSame('binary', $new->getHeaderLine('Content-Transfer-Encoding')); + $this->assertSame('bytes', $new->getHeaderLine('Accept-Ranges')); + $this->assertSame('18', $new->getHeaderLine('Content-Length')); + $this->assertSame('bytes 8-25/38', $new->getHeaderLine('Content-Range')); + $this->assertSame(206, $new->getStatusCode()); + } + + /** + * Provider for invalid range header values. + * + * @return array + */ + public static function invalidFileRangeProvider(): array + { + return [ + // malformed range + [ + 'bytes=0,38', + ], + + // malformed punctuation + [ + 'bytes: 0 - 38', + ], + ]; + } + + /** + * Test withFile() and invalid ranges + */ + #[DataProvider('invalidFileRangeProvider')] + public function testWithFileInvalidRange(string $range): void + { + $_SERVER['HTTP_RANGE'] = $range; + $response = new Response(); + $new = $response->withFile( + TEST_APP . 'vendor' . DS . 'css' . DS . 'test_asset.css', + ['download' => true], + ); + + $this->assertSame( + 'attachment; filename="test_asset.css"', + $new->getHeaderLine('Content-Disposition'), + ); + $this->assertSame('binary', $new->getHeaderLine('Content-Transfer-Encoding')); + $this->assertSame('bytes', $new->getHeaderLine('Accept-Ranges')); + $this->assertSame('38', $new->getHeaderLine('Content-Length')); + $this->assertSame('bytes 0-37/38', $new->getHeaderLine('Content-Range')); + $this->assertSame(206, $new->getStatusCode()); + } + + /** + * Test withFile() and a reversed range + */ + public function testWithFileReversedRange(): void + { + $_SERVER['HTTP_RANGE'] = 'bytes=30-2'; + $response = new Response(); + $new = $response->withFile( + TEST_APP . 'vendor' . DS . 'css' . DS . 'test_asset.css', + ['download' => true], + ); + + $this->assertSame( + 'attachment; filename="test_asset.css"', + $new->getHeaderLine('Content-Disposition'), + ); + $this->assertSame('binary', $new->getHeaderLine('Content-Transfer-Encoding')); + $this->assertSame('bytes', $new->getHeaderLine('Accept-Ranges')); + $this->assertSame('bytes 0-37/38', $new->getHeaderLine('Content-Range')); + $this->assertSame(416, $new->getStatusCode()); + } + + /** + * Test the withLocation method. + */ + public function testWithLocation(): void + { + $response = new Response(); + $this->assertSame('', $response->getHeaderLine('Location'), 'No header should be set.'); + $new = $response->withLocation('http://example.org'); + + $this->assertNotSame($new, $response); + $this->assertSame('', $response->getHeaderLine('Location'), 'No header should be set'); + $this->assertSame('http://example.org', $new->getHeaderLine('Location'), 'Header should be set'); + $this->assertSame(302, $new->getStatusCode(), 'Status should be updated'); + } + + /** + * Test get protocol version. + */ + public function getProtocolVersion(): void + { + $response = new Response(); + $version = $response->getProtocolVersion(); + $this->assertSame('1.1', $version); + } + + /** + * Test with protocol. + */ + public function testWithProtocol(): void + { + $response = new Response(); + $version = $response->getProtocolVersion(); + $this->assertSame('1.1', $version); + $response2 = $response->withProtocolVersion('1.0'); + $version = $response2->getProtocolVersion(); + $this->assertSame('1.0', $version); + $version = $response->getProtocolVersion(); + $this->assertSame('1.1', $version); + $this->assertNotEquals($response, $response2); + } + + /** + * Test with status code. + */ + public function testWithStatusCode(): void + { + $response = new Response(); + $statusCode = $response->getStatusCode(); + $this->assertSame(200, $statusCode); + + $response2 = $response->withStatus(404); + $statusCode = $response2->getStatusCode(); + $this->assertSame(404, $statusCode); + + $statusCode = $response->getStatusCode(); + $this->assertSame(200, $statusCode); + $this->assertNotEquals($response, $response2); + + $response3 = $response->withStatus(111); + $this->assertSame(111, $response3->getStatusCode()); + $this->assertSame('', $response3->getReasonPhrase()); + } + + /** + * Test invalid status codes + */ + public function testWithStatusInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid status code: 1001. Use a valid HTTP status code in range 1xx - 5xx.'); + $response = new Response(); + $response->withStatus(1001); + } + + /** + * Test get reason phrase. + */ + public function testGetReasonPhrase(): void + { + $response = new Response(); + $this->assertSame('OK', $response->getReasonPhrase()); + + $response = $response->withStatus(404); + $reasonPhrase = $response->getReasonPhrase(); + $this->assertSame('Not Found', $reasonPhrase); + } + + /** + * Test with body. + */ + public function testWithBody(): void + { + $response = new Response(); + $body = $response->getBody(); + $body->rewind(); + $result = $body->getContents(); + $this->assertSame('', $result); + + $stream = new Stream('php://memory', 'wb+'); + $stream->write('test1'); + + $response2 = $response->withBody($stream); + $body = $response2->getBody(); + $body->rewind(); + $result = $body->getContents(); + $this->assertSame('test1', $result); + + $body = $response->getBody(); + $body->rewind(); + $result = $body->getContents(); + $this->assertSame('', $result); + } + + /** + * Test with string body. + */ + public function testWithStringBody(): void + { + $response = new Response(); + $newResponse = $response->withStringBody('Foo'); + $body = $newResponse->getBody(); + $this->assertSame('Foo', (string)$body); + $this->assertNotSame($response, $newResponse); + + $response = new Response(); + $newResponse = $response->withStringBody(''); + $body = $newResponse->getBody(); + $this->assertSame('', (string)$body); + $this->assertNotSame($response, $newResponse); + + $response = new Response(); + $newResponse = $response->withStringBody(null); + $body = $newResponse->getBody(); + $this->assertSame('', (string)$body); + $this->assertNotSame($response, $newResponse); + + $response = new Response(); + $newResponse = $response->withStringBody('1337'); + $body = $newResponse->getBody(); + $this->assertSame('1337', (string)$body); + $this->assertNotSame($response, $newResponse); + } + + /** + * Test get Body. + */ + public function testGetBody(): void + { + $response = new Response(); + $stream = $response->getBody(); + $this->assertInstanceOf(StreamInterface::class, $stream); + } + + /** + * Test with header. + */ + public function testWithHeader(): void + { + $response = new Response(); + $response2 = $response->withHeader('Accept', 'application/json'); + $result = $response2->getHeaders(); + $expected = [ + 'Content-Type' => ['text/html; charset=UTF-8'], + 'Accept' => ['application/json'], + ]; + $this->assertEquals($expected, $result); + + $this->assertFalse($response->hasHeader('Accept')); + } + + /** + * Test get headers. + */ + public function testGetHeaders(): void + { + $response = new Response(); + $headers = $response->getHeaders(); + + $response = $response->withAddedHeader('Location', 'localhost'); + $response = $response->withAddedHeader('Accept', 'application/json'); + $headers = $response->getHeaders(); + + $expected = [ + 'Content-Type' => ['text/html; charset=UTF-8'], + 'Location' => ['localhost'], + 'Accept' => ['application/json'], + ]; + + $this->assertEquals($expected, $headers); + } + + /** + * Test without header. + */ + public function testWithoutHeader(): void + { + $response = new Response(); + $response = $response->withAddedHeader('Location', 'localhost'); + $response = $response->withAddedHeader('Accept', 'application/json'); + + $response2 = $response->withoutHeader('Location'); + $headers = $response2->getHeaders(); + + $expected = [ + 'Content-Type' => ['text/html; charset=UTF-8'], + 'Accept' => ['application/json'], + ]; + + $this->assertEquals($expected, $headers); + } + + /** + * Test get header. + */ + public function testGetHeader(): void + { + $response = new Response(); + $response = $response->withAddedHeader('Location', 'localhost'); + + $result = $response->getHeader('Location'); + $this->assertEquals(['localhost'], $result); + + $result = $response->getHeader('location'); + $this->assertEquals(['localhost'], $result); + + $result = $response->getHeader('does-not-exist'); + $this->assertEquals([], $result); + } + + /** + * Test get header line. + */ + public function testGetHeaderLine(): void + { + $response = new Response(); + $headers = $response->getHeaderLine('Accept'); + $this->assertSame('', $headers); + + $response = $response->withAddedHeader('Accept', 'application/json'); + $response = $response->withAddedHeader('Accept', 'application/xml'); + + $result = $response->getHeaderLine('Accept'); + $expected = 'application/json,application/xml'; + $this->assertSame($expected, $result); + $result = $response->getHeaderLine('accept'); + $this->assertSame($expected, $result); + } + + /** + * Test has header. + */ + public function testHasHeader(): void + { + $response = new Response(); + $response = $response->withAddedHeader('Location', 'localhost'); + + $this->assertTrue($response->hasHeader('Location')); + $this->assertTrue($response->hasHeader('location')); + $this->assertTrue($response->hasHeader('locATIon')); + + $this->assertFalse($response->hasHeader('Accept')); + $this->assertFalse($response->hasHeader('accept')); + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $response = new Response(); + $response = $response->withStringBody('Foo'); + $result = $response->__debugInfo(); + + $expected = [ + 'status' => 200, + 'contentType' => 'text/html', + 'headers' => [ + 'Content-Type' => ['text/html; charset=UTF-8'], + ], + 'file' => null, + 'fileRange' => [], + 'cookies' => new CookieCollection(), + 'cacheDirectives' => [], + 'body' => 'Foo', + ]; + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/Http/RunnerTest.php b/tests/TestCase/Http/RunnerTest.php new file mode 100644 index 00000000000..88e53d96527 --- /dev/null +++ b/tests/TestCase/Http/RunnerTest.php @@ -0,0 +1,160 @@ +queue = new MiddlewareQueue(); + + $this->ok = function ($request, $handler) { + return $handler->handle($request->withAttribute('ok', true)); + }; + $this->pass = function ($request, $handler) { + return $handler->handle($request->withAttribute('pass', true)); + }; + $this->fail = function ($request, $handler): void { + throw new RuntimeException('A bad thing'); + }; + } + + /** + * Test running a single middleware object. + */ + public function testRunSingle(): void + { + $this->queue->add($this->ok); + $req = new ServerRequest(); + + $runner = new Runner(); + $result = $runner->run($this->queue, $req); + $this->assertInstanceof(ResponseInterface::class, $result); + } + + /** + * Test that middleware is run in sequence + */ + public function testRunSequencing(): void + { + $log = []; + $one = function ($request, $handler) use (&$log) { + $log[] = 'one'; + + return $handler->handle($request); + }; + $two = function ($request, $handler) use (&$log) { + $log[] = 'two'; + + return $handler->handle($request); + }; + $three = function ($request, $handler) use (&$log) { + $log[] = 'three'; + + return $handler->handle($request); + }; + $this->queue->add($one)->add($two)->add($three); + $runner = new Runner(); + + $req = new ServerRequest(); + $result = $runner->run($this->queue, $req); + $this->assertInstanceof(Response::class, $result); + + $expected = ['one', 'two', 'three']; + $this->assertEquals($expected, $log); + } + + /** + * Test that exceptions bubble up. + */ + public function testRunExceptionInMiddleware(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('A bad thing'); + $this->queue->add($this->ok)->add($this->fail); + $req = new ServerRequest(); + + $runner = new Runner(); + $runner->run($this->queue, $req); + } + + public function testRunSetRouterContext(): void + { + $attributes = []; + + $this->queue + ->add(function ($request, $handler) use (&$attributes) { + try { + return $handler->handle($request); + } catch (Throwable) { + $request = Router::getRequest(); + + $attributes['pass'] = $request->getAttribute('pass'); + $attributes['ok'] = $request->getAttribute('ok'); + } + + return new Response(); + }) + ->add($this->ok) + ->add($this->pass) + ->add($this->fail); + $runner = new Runner(); + $app = new Application(CONFIG); + + $runner->run($this->queue, new ServerRequest(), $app); + $this->assertSame(['pass' => true, 'ok' => true], $attributes); + } +} diff --git a/tests/TestCase/Http/ServerRequestFactoryTest.php b/tests/TestCase/Http/ServerRequestFactoryTest.php new file mode 100644 index 00000000000..8eed8585d0c --- /dev/null +++ b/tests/TestCase/Http/ServerRequestFactoryTest.php @@ -0,0 +1,1405 @@ + 'custom', + ]; + $files = [ + 'image' => [ + 'tmp_name' => __FILE__, + 'error' => 0, + 'name' => 'cats.png', + 'type' => 'image/png', + 'size' => 2112, + ], + ]; + $cookies = ['key' => 'value']; + $query = ['query' => 'string']; + $res = ServerRequestFactory::fromGlobals([], $query, $post, $cookies, $files); + $this->assertSame($cookies['key'], $res->getCookie('key')); + $this->assertSame($query['query'], $res->getQuery('query')); + $this->assertArrayHasKey('title', $res->getData()); + $this->assertArrayHasKey('image', $res->getData()); + $this->assertCount(1, $res->getUploadedFiles()); + + /** @var \Psr\Http\Message\UploadedFileInterface $expected */ + $expected = $res->getData('image'); + $this->assertInstanceOf(UploadedFileInterface::class, $expected); + $this->assertSame($files['image']['size'], $expected->getSize()); + $this->assertSame($files['image']['error'], $expected->getError()); + $this->assertSame($files['image']['name'], $expected->getClientFilename()); + $this->assertSame($files['image']['type'], $expected->getClientMediaType()); + } + + public function testFromGlobalsUriScheme(): void + { + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/webroot', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/posts/add', + 'HTTP_X_FORWARDED_PROTO' => 'https', + ]; + $request = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('http', $request->scheme()); + $this->assertSame('http', $request->getUri()->getScheme()); + + $request->setTrustedProxies([]); + // Yeah even setting an empty list of proxies does the trick. + $this->assertSame('https', $request->scheme()); + $this->assertSame('https', $request->getUri()->getScheme()); + } + + /** + * Test fromGlobals includes the session + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testFromGlobalsUrlSession(): void + { + Configure::write('App.base', '/basedir'); + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/branches/1.2.x.x/webroot', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/posts/add', + ]; + $res = ServerRequestFactory::fromGlobals($server); + $session = $res->getAttribute('session'); + $this->assertInstanceOf(Session::class, $session); + $this->assertSame('/basedir/', ini_get('session.cookie_path'), 'Needs trailing / for cookie to work'); + } + + /** + * Test fromGlobals with App.base defined. + */ + public function testFromGlobalsUrlBaseDefined(): void + { + Configure::write('App.base', 'basedir'); + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/branches/1.2.x.x/webroot', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/posts/add', + ]; + $res = ServerRequestFactory::fromGlobals($server); + $this->assertSame('basedir', $res->getAttribute('base')); + $this->assertSame('basedir/', $res->getAttribute('webroot')); + $this->assertSame('/posts/add', $res->getUri()->getPath()); + } + + /** + * Test fromGlobals with urlencoded path separators + */ + public function testFromGlobalsUrlEncoded(): void + { + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/branches/webroot', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/posts%2fadd', + ]; + $res = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('', $res->getAttribute('base')); + $this->assertSame('/', $res->getAttribute('webroot')); + $this->assertSame('/posts%2fadd', $res->getUri()->getPath()); + } + + /** + * Test fromGlobals with mod-rewrite server configuration. + */ + public function testFromGlobalsUrlModRewrite(): void + { + Configure::write('App.baseUrl', false); + + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/branches', + 'PHP_SELF' => '/urlencode me/webroot/index.php', + 'REQUEST_URI' => '/posts/view/1', + ]; + $res = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('/urlencode%20me', $res->getAttribute('base')); + $this->assertSame('/urlencode%20me/', $res->getAttribute('webroot')); + $this->assertSame('/posts/view/1', $res->getUri()->getPath()); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/cake/repo/branches', + 'PHP_SELF' => '/1.2.x.x/webroot/index.php', + 'REQUEST_URI' => '/posts/view/1', + ]); + $this->assertSame('/1.2.x.x', $request->getAttribute('base')); + $this->assertSame('/1.2.x.x/', $request->getAttribute('webroot')); + $this->assertSame('/posts/view/1', $request->getRequestTarget()); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/cake/repo/branches/1.2.x.x/test/', + 'PHP_SELF' => '/webroot/index.php', + ]); + + $this->assertSame('', $request->getAttribute('base')); + $this->assertSame('/', $request->getAttribute('webroot')); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/some/apps/where', + 'PHP_SELF' => '/webroot/index.php', + ]); + + $this->assertSame('', $request->getAttribute('base')); + $this->assertSame('/', $request->getAttribute('webroot')); + + Configure::write('App.dir', 'auth'); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/cake/repo/branches', + 'PHP_SELF' => '/demos/webroot/index.php', + ]); + + $this->assertSame('/demos', $request->getAttribute('base')); + $this->assertSame('/demos/', $request->getAttribute('webroot')); + + Configure::write('App.dir', 'code'); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'PHP_SELF' => '/clients/PewterReport/webroot/index.php', + ]); + + $this->assertSame('/clients/PewterReport', $request->getAttribute('base')); + $this->assertSame('/clients/PewterReport/', $request->getAttribute('webroot')); + } + + /** + * Test baseUrl with ModRewrite alias + */ + public function testBaseUrlwithModRewriteAlias(): void + { + Configure::write('App.base', '/control'); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/home/aplusnur/public_html', + 'PHP_SELF' => '/control/index.php', + ]); + + $this->assertSame('/control', $request->getAttribute('base')); + $this->assertSame('/control/', $request->getAttribute('webroot')); + + Configure::write('App.base', false); + Configure::write('App.dir', 'affiliate'); + Configure::write('App.webroot', 'newaffiliate'); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/var/www/abtravaff/html', + 'PHP_SELF' => '/newaffiliate/index.php', + ]); + + $this->assertSame('', $request->getAttribute('base')); + $this->assertSame('/', $request->getAttribute('webroot')); + } + + /** + * Test base, webroot, URL and here parsing when there is URL rewriting but + * CakePHP gets called with index.php in URL nonetheless. + * + * The request instance generated should not have index.php stripped from its Uri. + * + * Tests uri with + * + * - index.php/ + * - index.php/apples/ + * - index.php?%3C%3E? + * - index.php/bananas/eat/tasty_banana + */ + public function testBaseUrlWithModRewriteAndIndexPhp(): void + { + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/cakephp/webroot', + 'PHP_SELF' => '/cakephp/webroot/index.php', + ]); + + $this->assertSame('/cakephp', $request->getAttribute('base')); + $this->assertSame('/cakephp/', $request->getAttribute('webroot')); + $this->assertSame('/', $request->getRequestTarget()); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_URI' => '/cakephp/index.php/', + 'PHP_SELF' => '/cakephp/webroot/index.php/', + 'PATH_INFO' => '/', + ]); + + $this->assertSame('/cakephp', $request->getAttribute('base')); + $this->assertSame('/cakephp/', $request->getAttribute('webroot')); + $this->assertSame('/index.php/', $request->getRequestTarget()); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_URI' => '/cakephp/index.php/apples', + 'PHP_SELF' => '/cakephp/webroot/index.php/apples', + 'PATH_INFO' => '/apples', + ]); + + $this->assertSame('/cakephp', $request->getAttribute('base')); + $this->assertSame('/cakephp/', $request->getAttribute('webroot')); + $this->assertSame('/index.php/apples', $request->getRequestTarget()); + + $request = ServerRequestFactory::fromGlobals([ + 'QUERY_STRING' => '%3C%3E?', + 'REQUEST_URI' => '/cakephp/index.php?%3C%3E?', + 'PHP_SELF' => '/cakephp/webroot/index.php', + 'SCRIPT_NAME' => '/filepath/cakephp/webroot/index.php', + ]); + + $this->assertSame('/cakephp', $request->getAttribute('base')); + $this->assertSame('/cakephp/', $request->getAttribute('webroot')); + $this->assertSame('/index.php?%3C%3E?', $request->getRequestTarget()); + $this->assertSame('%3C%3E?', $request->getUri()->getQuery()); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_URI' => '/cakephp/index.php/bananas/eat/tasty_banana', + 'PHP_SELF' => '/cakephp/webroot/index.php/bananas/eat/tasty_banana', + 'PATH_INFO' => '/bananas/eat/tasty_banana', + ]); + + $this->assertSame('/cakephp', $request->getAttribute('base')); + $this->assertSame('/cakephp/', $request->getAttribute('webroot')); + $this->assertSame('/index.php/bananas/eat/tasty_banana', $request->getRequestTarget()); + } + + /** + * Test fromGlobals with mod-rewrite in the root dir. + */ + public function testFromGlobalsUrlModRewriteRootDir(): void + { + $server = [ + 'DOCUMENT_ROOT' => '/cake/repo/branches/1.2.x.x/webroot', + 'PHP_SELF' => '/index.php', + 'REQUEST_URI' => '/posts/add', + ]; + $res = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('', $res->getAttribute('base')); + $this->assertSame('/', $res->getAttribute('webroot')); + $this->assertSame('/posts/add', $res->getUri()->getPath()); + } + + /** + * Test fromGlobals with App.baseUrl defined implying no + * mod-rewrite and no virtual path. + */ + public function testFromGlobalsUrlNoModRewriteWebrootDir(): void + { + Configure::write('App', [ + 'dir' => 'app', + 'webroot' => 'www', + 'base' => false, + 'baseUrl' => '/cake/index.php', + ]); + $server = [ + 'DOCUMENT_ROOT' => '/Users/markstory/Sites', + 'SCRIPT_FILENAME' => '/Users/markstory/Sites/cake/www/index.php', + 'PHP_SELF' => '/cake/www/index.php/posts/index', + 'REQUEST_URI' => '/cake/www/index.php', + ]; + $res = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('/cake/www/', $res->getAttribute('webroot')); + $this->assertSame('/cake/index.php', $res->getAttribute('base')); + $this->assertSame('/', $res->getUri()->getPath()); + } + + /** + * Test fromGlobals with App.baseUrl defined implying no + * mod-rewrite + */ + public function testFromGlobalsUrlNoModRewrite(): void + { + Configure::write('App', [ + 'dir' => 'app', + 'webroot' => 'webroot', + 'base' => false, + 'baseUrl' => '/cake/index.php', + ]); + $server = [ + 'DOCUMENT_ROOT' => '/Users/markstory/Sites', + 'SCRIPT_FILENAME' => '/Users/markstory/Sites/cake/index.php', + 'PHP_SELF' => '/cake/index.php/posts/index', + 'REQUEST_URI' => '/cake/index.php/posts/index', + ]; + $res = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('/cake/webroot/', $res->getAttribute('webroot')); + $this->assertSame('/cake/index.php', $res->getAttribute('base')); + $this->assertSame('/posts/index', $res->getUri()->getPath()); + } + + /** + * Test fromGlobals with App.baseUrl defined implying no + * mod-rewrite in the root dir. + */ + public function testFromGlobalsUrlNoModRewriteRootDir(): void + { + Configure::write('App', [ + 'dir' => 'cake', + 'webroot' => 'webroot', + 'base' => false, + 'baseUrl' => '/index.php', + ]); + $server = [ + 'DOCUMENT_ROOT' => '/Users/markstory/Sites/cake', + 'SCRIPT_FILENAME' => '/Users/markstory/Sites/cake/index.php', + 'PHP_SELF' => '/index.php/posts/add', + 'REQUEST_URI' => '/index.php/posts/add', + ]; + $res = ServerRequestFactory::fromGlobals($server); + + $this->assertSame('/webroot/', $res->getAttribute('webroot')); + $this->assertSame('/index.php', $res->getAttribute('base')); + $this->assertSame('/posts/add', $res->getUri()->getPath()); + } + + /** + * Check that a sub-directory containing app|webroot doesn't get mishandled when re-writing is off. + */ + public function testBaseUrlWithAppAndWebrootInDirname(): void + { + Configure::write('App.baseUrl', '/approval/index.php'); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/Users/markstory/Sites/', + 'SCRIPT_FILENAME' => '/Users/markstory/Sites/approval/index.php', + ]); + $this->assertSame('/approval/index.php', $request->getAttribute('base')); + $this->assertSame('/approval/webroot/', $request->getAttribute('webroot')); + + Configure::write('App.baseUrl', '/webrootable/index.php'); + + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/Users/markstory/Sites/', + 'SCRIPT_FILENAME' => '/Users/markstory/Sites/webrootable/index.php', + ]); + $this->assertSame('/webrootable/index.php', $request->getAttribute('base')); + $this->assertSame('/webrootable/webroot/', $request->getAttribute('webroot')); + } + + /** + * Test baseUrl and webroot with baseUrl + */ + public function testBaseUrlAndWebrootWithBaseUrl(): void + { + Configure::write('App.dir', 'App'); + Configure::write('App.baseUrl', '/App/webroot/index.php'); + + $request = ServerRequestFactory::fromGlobals(); + $this->assertSame('/App/webroot/index.php', $request->getAttribute('base')); + $this->assertSame('/App/webroot/', $request->getAttribute('webroot')); + + Configure::write('App.baseUrl', '/App/webroot/test.php'); + $request = ServerRequestFactory::fromGlobals(); + $this->assertSame('/App/webroot/test.php', $request->getAttribute('base')); + $this->assertSame('/App/webroot/', $request->getAttribute('webroot')); + + Configure::write('App.baseUrl', '/App/index.php'); + $request = ServerRequestFactory::fromGlobals(); + $this->assertSame('/App/index.php', $request->getAttribute('base')); + $this->assertSame('/App/webroot/', $request->getAttribute('webroot')); + + Configure::write('App.baseUrl', '/CakeBB/App/webroot/index.php'); + $request = ServerRequestFactory::fromGlobals(); + $this->assertSame('/CakeBB/App/webroot/index.php', $request->getAttribute('base')); + $this->assertSame('/CakeBB/App/webroot/', $request->getAttribute('webroot')); + + Configure::write('App.baseUrl', '/CakeBB/App/index.php'); + $request = ServerRequestFactory::fromGlobals(); + + $this->assertSame('/CakeBB/App/index.php', $request->getAttribute('base')); + $this->assertSame('/CakeBB/App/webroot/', $request->getAttribute('webroot')); + + Configure::write('App.baseUrl', '/CakeBB/index.php'); + $request = ServerRequestFactory::fromGlobals(); + + $this->assertSame('/CakeBB/index.php', $request->getAttribute('base')); + $this->assertSame('/CakeBB/webroot/', $request->getAttribute('webroot')); + + Configure::write('App.baseUrl', '/dbhauser/index.php'); + $request = ServerRequestFactory::fromGlobals([ + 'DOCUMENT_ROOT' => '/kunden/homepages/4/d181710652/htdocs/joomla', + 'SCRIPT_FILENAME' => '/kunden/homepages/4/d181710652/htdocs/joomla/dbhauser/index.php', + ]); + + $this->assertSame('/dbhauser/index.php', $request->getAttribute('base')); + $this->assertSame('/dbhauser/webroot/', $request->getAttribute('webroot')); + } + + /** + * Test that a request with a . in the main GET parameter is filtered out. + * PHP changes GET parameter keys containing dots to _. + */ + public function testGetParamsWithDot(): void + { + $request = ServerRequestFactory::fromGlobals([ + 'PHP_SELF' => '/webroot/index.php', + 'REQUEST_URI' => '/posts/index/add.add', + ]); + $this->assertSame('', $request->getAttribute('base')); + $this->assertEquals([], $request->getQueryParams()); + + $request = ServerRequestFactory::fromGlobals([ + 'PHP_SELF' => '/cake_dev/webroot/index.php', + 'REQUEST_URI' => '/cake_dev/posts/index/add.add', + ]); + $this->assertSame('/cake_dev', $request->getAttribute('base')); + $this->assertEquals([], $request->getQueryParams()); + } + + /** + * Test that a request with urlencoded bits in the main GET parameter are filtered out. + */ + public function testGetParamWithUrlencodedElement(): void + { + $request = ServerRequestFactory::fromGlobals([ + 'PHP_SELF' => '/webroot/index.php', + 'REQUEST_URI' => '/posts/add/%E2%88%82%E2%88%82', + ]); + $this->assertSame('', $request->getAttribute('base')); + $this->assertEquals([], $request->getQueryParams()); + + $request = ServerRequestFactory::fromGlobals([ + 'PHP_SELF' => '/cake_dev/webroot/index.php', + 'REQUEST_URI' => '/cake_dev/posts/add/%E2%88%82%E2%88%82', + ]); + $this->assertSame('/cake_dev', $request->getAttribute('base')); + $this->assertEquals([], $request->getQueryParams()); + } + + /** + * Generator for environment configurations + * + * @return array Environment array + */ + public static function environmentGenerator(): array + { + return [ + [ + 'IIS - No rewrite base path', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SCRIPT_NAME' => '/index.php', + 'PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot', + 'QUERY_STRING' => '', + 'REQUEST_URI' => '/index.php', + 'URL' => '/index.php', + 'SCRIPT_FILENAME' => 'C:\\Inetpub\\wwwroot\\index.php', + 'ORIG_PATH_INFO' => '/index.php', + 'PATH_INFO' => '', + 'ORIG_PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot\\index.php', + 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', + 'PHP_SELF' => '/index.php', + ], + ], + [ + 'base' => '/index.php', + 'webroot' => '/webroot/', + 'url' => '', + ], + ], + [ + 'IIS - No rewrite with path, no PHP_SELF', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/index.php?', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'QUERY_STRING' => '/posts/add', + 'REQUEST_URI' => '/index.php?/posts/add', + 'PHP_SELF' => '', + 'URL' => '/index.php?/posts/add', + 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', + 'argv' => ['/posts/add'], + 'argc' => 1, + ], + ], + [ + 'url' => 'posts/add', + 'base' => '/index.php?', + 'webroot' => '/webroot/', + ], + ], + [ + 'IIS - No rewrite sub dir 2', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/site/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SCRIPT_NAME' => '/site/index.php', + 'PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot', + 'QUERY_STRING' => '', + 'REQUEST_URI' => '/site/index.php', + 'URL' => '/site/index.php', + 'SCRIPT_FILENAME' => 'C:\\Inetpub\\wwwroot\\site\\index.php', + 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', + 'PHP_SELF' => '/site/index.php', + 'argv' => [], + 'argc' => 0, + ], + ], + [ + 'url' => '', + 'base' => '/site/index.php', + 'webroot' => '/site/webroot/', + ], + ], + [ + 'IIS - No rewrite sub dir 2 with path', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/site/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SCRIPT_NAME' => '/site/index.php', + 'PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot', + 'QUERY_STRING' => '/posts/add', + 'REQUEST_URI' => '/site/index.php/posts/add', + 'URL' => '/site/index.php/posts/add', + 'ORIG_PATH_TRANSLATED' => 'C:\\Inetpub\\wwwroot\\site\\index.php', + 'DOCUMENT_ROOT' => 'C:\\Inetpub\\wwwroot', + 'PHP_SELF' => '/site/index.php/posts/add', + 'argv' => ['/posts/add'], + 'argc' => 1, + ], + ], + [ + 'url' => 'posts/add', + 'base' => '/site/index.php', + 'webroot' => '/site/webroot/', + ], + ], + [ + 'Apache - No rewrite, document root set to webroot, requesting path', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/App/webroot', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/App/webroot/index.php', + 'QUERY_STRING' => '', + 'REQUEST_URI' => '/index.php/posts/index', + 'SCRIPT_NAME' => '/index.php', + 'PATH_INFO' => '/posts/index', + 'PHP_SELF' => '/index.php/posts/index', + ], + ], + [ + 'url' => 'posts/index', + 'base' => '/index.php', + 'webroot' => '/', + ], + ], + [ + 'Apache - No rewrite, document root set to webroot, requesting root', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/App/webroot', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/App/webroot/index.php', + 'QUERY_STRING' => '', + 'REQUEST_URI' => '/index.php', + 'SCRIPT_NAME' => '/index.php', + 'PATH_INFO' => '', + 'PHP_SELF' => '/index.php', + ], + ], + [ + 'url' => '', + 'base' => '/index.php', + 'webroot' => '/', + ], + ], + [ + 'Apache - No rewrite, document root set above top level cake dir, requesting path', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/site/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', + 'REQUEST_URI' => '/site/index.php/posts/index', + 'SCRIPT_NAME' => '/site/index.php', + 'PATH_INFO' => '/posts/index', + 'PHP_SELF' => '/site/index.php/posts/index', + ], + ], + [ + 'url' => 'posts/index', + 'base' => '/site/index.php', + 'webroot' => '/site/webroot/', + ], + ], + [ + 'Apache - No rewrite, document root set above top level cake dir, request root, no PATH_INFO', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/site/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', + 'REQUEST_URI' => '/site/index.php/', + 'SCRIPT_NAME' => '/site/index.php', + 'PHP_SELF' => '/site/index.php/', + ], + ], + [ + 'url' => '', + 'base' => '/site/index.php', + 'webroot' => '/site/webroot/', + ], + ], + [ + 'Apache - No rewrite, document root set above top level cake dir, request path, with GET', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => '/site/index.php', + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'GET' => ['a' => 'b', 'c' => 'd'], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', + 'REQUEST_URI' => '/site/index.php/posts/index?a=b&c=d', + 'SCRIPT_NAME' => '/site/index.php', + 'PATH_INFO' => '/posts/index', + 'PHP_SELF' => '/site/index.php/posts/index', + 'QUERY_STRING' => 'a=b&c=d', + ], + ], + [ + 'urlParams' => ['a' => 'b', 'c' => 'd'], + 'url' => 'posts/index', + 'base' => '/site/index.php', + 'webroot' => '/site/webroot/', + ], + ], + [ + 'Apache - w/rewrite, document root set above top level cake dir, request root, no PATH_INFO', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => false, + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', + 'REQUEST_URI' => '/site/', + 'SCRIPT_NAME' => '/site/webroot/index.php', + 'PHP_SELF' => '/site/webroot/index.php', + ], + ], + [ + 'url' => '', + 'base' => '/site', + 'webroot' => '/site/', + ], + ], + [ + 'Apache - w/rewrite, document root above top level cake dir, request root, no PATH_INFO/REQUEST_URI', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => false, + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', + 'SCRIPT_NAME' => '/site/webroot/index.php', + 'PHP_SELF' => '/site/webroot/index.php', + 'PATH_INFO' => null, + 'REQUEST_URI' => null, + ], + ], + [ + 'url' => '', + 'base' => '/site', + 'webroot' => '/site/', + ], + ], + [ + 'Apache - w/rewrite, document root set to webroot, request root, no PATH_INFO/REQUEST_URI', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => false, + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/webroot', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/webroot/index.php', + 'SCRIPT_NAME' => '/index.php', + 'PHP_SELF' => '/index.php', + 'PATH_INFO' => null, + 'REQUEST_URI' => null, + ], + ], + [ + 'url' => '', + 'base' => '', + 'webroot' => '/', + ], + ], + [ + 'Apache - w/rewrite, document root set above top level cake dir, request root, absolute REQUEST_URI', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => false, + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/index.php', + 'REQUEST_URI' => '/site/posts/index', + 'SCRIPT_NAME' => '/site/webroot/index.php', + 'PHP_SELF' => '/site/webroot/index.php', + ], + ], + [ + 'url' => 'posts/index', + 'base' => '/site', + 'webroot' => '/site/', + ], + ], + [ + 'Nginx - w/rewrite, document root set to webroot, request root, no PATH_INFO', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => false, + 'dir' => 'TestApp', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents/site/webroot', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/webroot/index.php', + 'SCRIPT_NAME' => '/index.php', + 'PHP_SELF' => '/index.php', + 'PATH_INFO' => null, + 'REQUEST_URI' => '/posts/add', + ], + ], + [ + 'url' => 'posts/add', + 'base' => '', + 'webroot' => '/', + 'urlParams' => [], + ], + ], + [ + 'Nginx - w/rewrite, document root set above top level cake dir, request root, no PATH_INFO, base parameter set', + [ + 'App' => [ + 'base' => false, + 'baseUrl' => false, + 'dir' => 'app', + 'webroot' => 'webroot', + ], + 'SERVER' => [ + 'SERVER_NAME' => 'localhost', + 'DOCUMENT_ROOT' => '/Library/WebServer/Documents', + 'SCRIPT_FILENAME' => '/Library/WebServer/Documents/site/App/webroot/index.php', + 'SCRIPT_NAME' => '/site/app/webroot/index.php', + 'PHP_SELF' => '/site/webroot/index.php', + 'PATH_INFO' => null, + 'REQUEST_URI' => '/site/posts/add', + ], + ], + [ + 'url' => 'posts/add', + 'base' => '/site', + 'webroot' => '/site/', + 'urlParams' => [], + ], + ], + ]; + } + + /** + * Test environment detection + * + * @param string $name + * @param array $data + * @param array $expected + */ + #[DataProvider('environmentGenerator')] + public function testEnvironmentDetection($name, $data, $expected): void + { + if (isset($data['App'])) { + Configure::write('App', $data['App']); + } + + $request = ServerRequestFactory::fromGlobals( + $data['SERVER'] ?? null, + $data['GET'] ?? null, + ); + $uri = $request->getUri(); + + $this->assertSame('/' . $expected['url'], $uri->getPath(), 'Uri->getPath() is incorrect'); + $this->assertEquals($expected['base'], $request->getAttribute('base'), 'base is incorrect'); + $this->assertEquals($expected['webroot'], $request->getAttribute('webroot'), 'webroot is incorrect'); + + if (isset($expected['urlParams'])) { + $this->assertEquals($expected['urlParams'], $request->getQueryParams(), 'GET param mismatch'); + } + } + + public function testFormUrlEncodedBodyParsing(): void + { + $data = [ + 'Article' => ['title'], + ]; + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'PUT', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded; charset=UTF-8', + 'CAKEPHP_INPUT' => 'Article[]=title', + ]); + $this->assertEquals($data, $request->getData()); + + $data = ['one' => 1, 'two' => 'three']; + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'PUT', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded; charset=UTF-8', + 'CAKEPHP_INPUT' => 'one=1&two=three', + ]); + $this->assertEquals($data, $request->getData()); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'DELETE', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded; charset=UTF-8', + 'CAKEPHP_INPUT' => 'Article[title]=Testing&action=update', + ]); + $expected = [ + 'Article' => ['title' => 'Testing'], + 'action' => 'update', + ]; + $this->assertEquals($expected, $request->getData()); + + $data = [ + 'Article' => ['title'], + 'Tag' => ['Tag' => [1, 2]], + ]; + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'PATCH', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded; charset=UTF-8', + 'CAKEPHP_INPUT' => 'Article[]=title&Tag[Tag][]=1&Tag[Tag][]=2', + ]); + $this->assertEquals($data, $request->getData()); + } + + /** + * Test method overrides coming in from POST data. + */ + public function testMethodOverrides(): void + { + $post = ['_method' => 'POST']; + $request = ServerRequestFactory::fromGlobals([], [], $post); + $this->assertSame('POST', $request->getEnv('REQUEST_METHOD')); + + $post = ['_method' => 'DELETE']; + $request = ServerRequestFactory::fromGlobals([], [], $post); + $this->assertSame('DELETE', $request->getEnv('REQUEST_METHOD')); + + $request = ServerRequestFactory::fromGlobals(['HTTP_X_HTTP_METHOD_OVERRIDE' => 'PUT']); + $this->assertSame('PUT', $request->getEnv('REQUEST_METHOD')); + + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_METHOD' => 'POST'], + [], + ['_method' => 'PUT'], + ); + $this->assertSame('PUT', $request->getEnv('REQUEST_METHOD')); + $this->assertSame('POST', $request->getEnv('ORIGINAL_REQUEST_METHOD')); + } + + /** + * Test getServerParams + */ + public function testGetServerParams(): void + { + $vars = [ + 'REQUEST_METHOD' => 'PUT', + 'HTTPS' => 'on', + ]; + + $request = ServerRequestFactory::fromGlobals($vars); + $expected = $vars + [ + 'CONTENT_TYPE' => null, + 'HTTP_CONTENT_TYPE' => null, + 'ORIGINAL_REQUEST_METHOD' => 'PUT', + 'HTTP_HOST' => 'localhost', + ]; + $this->assertSame($expected, $request->getServerParams()); + } + + /** + * Tests that overriding the method to GET will clean all request + * data, to better simulate a GET request. + */ + public function testMethodOverrideEmptyParsedBody(): void + { + $body = ['_method' => 'GET', 'foo' => 'bar']; + $request = ServerRequestFactory::fromGlobals( + ['REQUEST_METHOD' => 'POST'], + [], + $body, + ); + $this->assertEmpty($request->getParsedBody()); + + $request = ServerRequestFactory::fromGlobals( + [ + 'REQUEST_METHOD' => 'POST', + 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'GET', + ], + [], + ['foo' => 'bar'], + ); + $this->assertEmpty($request->getParsedBody()); + } + + /** + * Tests the default file upload merging behavior. + */ + public function testFromGlobalsWithFiles(): void + { + $files = [ + 'file' => [ + 'name' => 'file.txt', + 'type' => 'text/plain', + 'tmp_name' => __FILE__, + 'error' => 0, + 'size' => 1234, + ], + ]; + $request = ServerRequestFactory::fromGlobals(null, null, null, null, $files); + + /** @var \Laminas\Diactoros\UploadedFile $expected */ + $expected = $request->getData('file'); + $this->assertSame($files['file']['size'], $expected->getSize()); + $this->assertSame($files['file']['error'], $expected->getError()); + $this->assertSame($files['file']['name'], $expected->getClientFilename()); + $this->assertSame($files['file']['type'], $expected->getClientMediaType()); + } + + /** + * Test processing files with `file` field names. + */ + public function testFilesNested(): void + { + $files = [ + 'image_main' => [ + 'name' => ['file' => 'born on.txt'], + 'type' => ['file' => 'text/plain'], + 'tmp_name' => ['file' => __FILE__], + 'error' => ['file' => 0], + 'size' => ['file' => 17178], + ], + 0 => [ + 'name' => ['image' => 'scratch.text'], + 'type' => ['image' => 'text/plain'], + 'tmp_name' => ['image' => __FILE__], + 'error' => ['image' => 0], + 'size' => ['image' => 1490], + ], + 'pictures' => [ + 'name' => [ + 0 => ['file' => 'a-file.png'], + 1 => ['file' => 'a-moose.png'], + ], + 'type' => [ + 0 => ['file' => 'image/png'], + 1 => ['file' => 'image/jpg'], + ], + 'tmp_name' => [ + 0 => ['file' => __FILE__], + 1 => ['file' => __FILE__], + ], + 'error' => [ + 0 => ['file' => 0], + 1 => ['file' => 0], + ], + 'size' => [ + 0 => ['file' => 17188], + 1 => ['file' => 2010], + ], + ], + ]; + + $post = [ + 'pictures' => [ + 0 => ['name' => 'A cat'], + 1 => ['name' => 'A moose'], + ], + 0 => [ + 'name' => 'A dog', + ], + ]; + + $request = ServerRequestFactory::fromGlobals(null, null, $post, null, $files); + $expected = [ + 'image_main' => [ + 'file' => new UploadedFile( + __FILE__, + 17178, + 0, + 'born on.txt', + 'text/plain', + ), + ], + 'pictures' => [ + 0 => [ + 'name' => 'A cat', + 'file' => new UploadedFile( + __FILE__, + 17188, + 0, + 'a-file.png', + 'image/png', + ), + ], + 1 => [ + 'name' => 'A moose', + 'file' => new UploadedFile( + __FILE__, + 2010, + 0, + 'a-moose.png', + 'image/jpg', + ), + ], + ], + 0 => [ + 'name' => 'A dog', + 'image' => new UploadedFile( + __FILE__, + 1490, + 0, + 'scratch.text', + 'text/plain', + ), + ], + ]; + $this->assertEquals($expected, $request->getData()); + + $uploads = $request->getUploadedFiles(); + $this->assertCount(3, $uploads); + $this->assertArrayHasKey(0, $uploads); + $this->assertSame('scratch.text', $uploads[0]['image']->getClientFilename()); + + $this->assertArrayHasKey('pictures', $uploads); + $this->assertSame('a-file.png', $uploads['pictures'][0]['file']->getClientFilename()); + $this->assertSame('a-moose.png', $uploads['pictures'][1]['file']->getClientFilename()); + + $this->assertArrayHasKey('image_main', $uploads); + $this->assertSame('born on.txt', $uploads['image_main']['file']->getClientFilename()); + } + + /** + * Test processing a file input with no .'s in it. + */ + public function testFilesFlat(): void + { + $files = [ + 'birth_cert' => [ + 'name' => 'born on.txt', + 'type' => 'application/octet-stream', + 'tmp_name' => __FILE__, + 'error' => 0, + 'size' => 123, + ], + ]; + + $request = ServerRequestFactory::fromGlobals([], [], [], [], $files); + $this->assertInstanceOf(UploadedFileInterface::class, $request->getData()['birth_cert']); + + $uploads = $request->getUploadedFiles(); + $this->assertCount(1, $uploads); + $this->assertArrayHasKey('birth_cert', $uploads); + $this->assertSame('born on.txt', $uploads['birth_cert']->getClientFilename()); + $this->assertSame(0, $uploads['birth_cert']->getError()); + $this->assertSame('application/octet-stream', $uploads['birth_cert']->getClientMediaType()); + $this->assertSame(123, $uploads['birth_cert']->getSize()); + } + + /** + * Test that files in the 0th index work. + */ + public function testFilesZeroithIndex(): void + { + $files = [ + 0 => [ + 'name' => 'cake_sqlserver_patch.patch', + 'type' => 'text/plain', + 'tmp_name' => __FILE__, + 'error' => 0, + 'size' => 6271, + ], + ]; + + $request = ServerRequestFactory::fromGlobals([], [], [], [], $files); + $this->assertInstanceOf(UploadedFileInterface::class, $request->getData()[0]); + + $uploads = $request->getUploadedFiles(); + $this->assertCount(1, $uploads); + $this->assertSame($files[0]['name'], $uploads[0]->getClientFilename()); + } + + /** + * Tests that file uploads are merged into the post data as objects instead of as arrays. + */ + public function testFilesAsObjectsInRequestData(): void + { + $files = [ + 'flat' => [ + 'name' => 'flat.txt', + 'type' => 'text/plain', + 'tmp_name' => __FILE__, + 'error' => 0, + 'size' => 1, + ], + 'nested' => [ + 'name' => ['file' => 'nested.txt'], + 'type' => ['file' => 'text/plain'], + 'tmp_name' => ['file' => __FILE__], + 'error' => ['file' => 0], + 'size' => ['file' => 12], + ], + 0 => [ + 'name' => 'numeric.txt', + 'type' => 'text/plain', + 'tmp_name' => __FILE__, + 'error' => 0, + 'size' => 123, + ], + 1 => [ + 'name' => ['file' => 'numeric-nested.txt'], + 'type' => ['file' => 'text/plain'], + 'tmp_name' => ['file' => __FILE__], + 'error' => ['file' => 0], + 'size' => ['file' => 1234], + ], + 'deep' => [ + 'name' => [ + 0 => ['file' => 'deep-1.txt'], + 1 => ['file' => 'deep-2.txt'], + ], + 'type' => [ + 0 => ['file' => 'text/plain'], + 1 => ['file' => 'text/plain'], + ], + 'tmp_name' => [ + 0 => ['file' => __FILE__], + 1 => ['file' => __FILE__], + ], + 'error' => [ + 0 => ['file' => 0], + 1 => ['file' => 0], + ], + 'size' => [ + 0 => ['file' => 12345], + 1 => ['file' => 123456], + ], + ], + ]; + + $post = [ + 'flat' => ['existing'], + 'nested' => [ + 'name' => 'nested', + 'file' => ['existing'], + ], + 'deep' => [ + 0 => [ + 'name' => 'deep 1', + 'file' => ['existing'], + ], + 1 => [ + 'name' => 'deep 2', + ], + ], + 1 => [ + 'name' => 'numeric nested', + ], + ]; + + $expected = [ + 'flat' => new UploadedFile( + __FILE__, + 1, + 0, + 'flat.txt', + 'text/plain', + ), + 'nested' => [ + 'name' => 'nested', + 'file' => new UploadedFile( + __FILE__, + 12, + 0, + 'nested.txt', + 'text/plain', + ), + ], + 'deep' => [ + 0 => [ + 'name' => 'deep 1', + 'file' => new UploadedFile( + __FILE__, + 12345, + 0, + 'deep-1.txt', + 'text/plain', + ), + ], + 1 => [ + 'name' => 'deep 2', + 'file' => new UploadedFile( + __FILE__, + 123456, + 0, + 'deep-2.txt', + 'text/plain', + ), + ], + ], + 0 => new UploadedFile( + __FILE__, + 123, + 0, + 'numeric.txt', + 'text/plain', + ), + 1 => [ + 'name' => 'numeric nested', + 'file' => new UploadedFile( + __FILE__, + 1234, + 0, + 'numeric-nested.txt', + 'text/plain', + ), + ], + ]; + + $request = ServerRequestFactory::fromGlobals([], [], $post, [], $files); + + $this->assertEquals($expected, $request->getData()); + } + + /** + * Test passing invalid files list structure. + */ + public function testFilesWithInvalidStructure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid value in files specification'); + + ServerRequestFactory::fromGlobals([], [], [], [], [ + [ + 'invalid' => [ + 'data', + ], + ], + ]); + } + + public function testCreateServerRequest(): void + { + $factory = new ServerRequestFactory(); + $request = $factory->createServerRequest('GET', 'https://cakephp.org/team', ['foo' => 'bar']); + + $this->assertInstanceOf(ServerRequest::class, $request); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('/team', $request->getRequestTarget()); + $expected = ['foo' => 'bar', 'REQUEST_METHOD' => 'GET']; + $this->assertEquals($expected, $request->getServerParams()); + } +} diff --git a/tests/TestCase/Http/ServerRequestTest.php b/tests/TestCase/Http/ServerRequestTest.php new file mode 100644 index 00000000000..8d743790822 --- /dev/null +++ b/tests/TestCase/Http/ServerRequestTest.php @@ -0,0 +1,1991 @@ +addDetector('controller', function ($request, $name) { + return $request->getParam('controller') === $name; + }); + + $request = $request->withParam('controller', 'cake'); + $this->assertTrue($request->is('controller', 'cake')); + $this->assertFalse($request->is('controller', 'nonExistingController')); + $this->assertTrue($request->isController('cake')); + $this->assertFalse($request->isController('nonExistingController')); + } + + /** + * Test the header detector. + */ + public function testHeaderDetector(): void + { + $request = new ServerRequest(); + $request->addDetector('host', ['header' => ['host' => 'cakephp.org']]); + + $request = $request->withEnv('HTTP_HOST', 'cakephp.org'); + $this->assertTrue($request->is('host')); + + $request = $request->withEnv('HTTP_HOST', 'php.net'); + $this->assertFalse($request->is('host')); + } + + /** + * Test the accept header detector. + */ + public function testExtensionDetector(): void + { + $request = new ServerRequest(); + $request = $request->withParam('_ext', 'json'); + $this->assertTrue($request->is('json')); + + $request = new ServerRequest(); + $request = $request->withParam('_ext', 'xml'); + $this->assertFalse($request->is('json')); + } + + /** + * Test the accept header detector. + */ + public function testAcceptHeaderDetector(): void + { + $request = new ServerRequest(); + $request = $request->withEnv('HTTP_ACCEPT', 'application/json, text/plain, */*'); + $this->assertTrue($request->is('json')); + + $request = new ServerRequest(); + $request = $request->withEnv('HTTP_ACCEPT', 'text/plain, */*'); + $this->assertFalse($request->is('json')); + + $request = new ServerRequest(); + $request = $request->withEnv('HTTP_ACCEPT', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'); + $this->assertFalse($request->is('json')); + $this->assertFalse($request->is('xml')); + $this->assertFalse($request->is('xml')); + } + + public function testConstructor(): void + { + $request = new ServerRequest(); + $this->assertInstanceOf(FlashMessage::class, $request->getAttribute('flash')); + } + + /** + * Test construction with query data + */ + public function testConstructionQueryData(): void + { + $data = [ + 'query' => [ + 'one' => 'param', + 'two' => 'banana', + ], + 'url' => 'some/path', + ]; + $request = new ServerRequest($data); + $this->assertSame('param', $request->getQuery('one')); + $this->assertEquals($data['query'], $request->getQueryParams()); + $this->assertSame('/some/path', $request->getRequestTarget()); + } + + /** + * Test constructing with a string url. + */ + public function testConstructStringUrlIgnoreServer(): void + { + $request = new ServerRequest([ + 'url' => '/articles/view/1', + 'environment' => ['REQUEST_URI' => '/some/other/path'], + ]); + $this->assertSame('/articles/view/1', $request->getUri()->getPath()); + + $request = new ServerRequest(['url' => '/']); + $this->assertSame('/', $request->getUri()->getPath()); + } + + /** + * Test that querystring args provided in the URL string are parsed. + */ + public function testQueryStringParsingFromInputUrl(): void + { + $request = new ServerRequest(['url' => 'some/path?one=something&two=else']); + $expected = ['one' => 'something', 'two' => 'else']; + $this->assertEquals($expected, $request->getQueryParams()); + $this->assertSame('/some/path', $request->getUri()->getPath()); + $this->assertSame('one=something&two=else', $request->getUri()->getQuery()); + } + + /** + * Test that querystrings are handled correctly. + */ + public function testQueryStringAndNamedParams(): void + { + $config = ['environment' => ['REQUEST_URI' => '/tasks/index?ts=123456']]; + $request = new ServerRequest($config); + $this->assertSame('/tasks/index', $request->getRequestTarget()); + + $config = ['environment' => ['REQUEST_URI' => '/some/path?url=http://cakephp.org']]; + $request = new ServerRequest($config); + $this->assertSame('/some/path', $request->getRequestTarget()); + + $config = ['environment' => [ + 'REQUEST_URI' => Configure::read('App.fullBaseUrl') . '/other/path?url=http://cakephp.org', + ]]; + $request = new ServerRequest($config); + $this->assertSame('/other/path', $request->getRequestTarget()); + } + + /** + * Test that URL in path is handled correctly. + */ + public function testUrlInPath(): void + { + $config = ['environment' => ['REQUEST_URI' => '/jump/http://cakephp.org']]; + $request = new ServerRequest($config); + $this->assertSame('/jump/http://cakephp.org', $request->getRequestTarget()); + + $config = ['environment' => [ + 'REQUEST_URI' => Configure::read('App.fullBaseUrl') . '/jump/http://cakephp.org', + ]]; + $request = new ServerRequest($config); + $this->assertSame('/jump/http://cakephp.org', $request->getRequestTarget()); + } + + /** + * Test getPath(). + */ + public function testGetPath(): void + { + $request = new ServerRequest(['url' => '']); + $this->assertSame('/', $request->getPath()); + + $request = new ServerRequest(['url' => 'some/path?one=something&two=else']); + $this->assertSame('/some/path', $request->getPath()); + + $request = $request->withRequestTarget('/foo/bar?x=y'); + $this->assertSame('/foo/bar', $request->getPath()); + } + + /** + * Test parsing POST data into the object. + */ + public function testPostParsing(): void + { + $post = [ + 'Article' => ['title'], + ]; + $request = new ServerRequest(compact('post')); + $this->assertEquals($post, $request->getData()); + + $post = ['one' => 1, 'two' => 'three']; + $request = new ServerRequest(compact('post')); + $this->assertEquals($post, $request->getData()); + + $post = [ + 'Article' => ['title' => 'Testing'], + 'action' => 'update', + ]; + $request = new ServerRequest(compact('post')); + $this->assertEquals($post, $request->getData()); + } + + /** + * Test that the constructor uses uploaded file objects + * if they are present. This could happen in test scenarios. + */ + public function testFilesObject(): void + { + $file = new UploadedFile( + __FILE__, + 123, + UPLOAD_ERR_OK, + 'test.php', + 'text/plain', + ); + $request = new ServerRequest(['files' => ['avatar' => $file]]); + $this->assertSame(['avatar' => $file], $request->getUploadedFiles()); + } + + /** + * Test passing an empty files list. + */ + public function testFilesWithEmptyList(): void + { + $request = new ServerRequest([ + 'files' => [], + ]); + + $this->assertEmpty($request->getData()); + $this->assertEmpty($request->getUploadedFiles()); + } + + /** + * Test replacing files. + */ + public function testWithUploadedFiles(): void + { + $file = new UploadedFile( + __FILE__, + 123, + UPLOAD_ERR_OK, + 'test.php', + 'text/plain', + ); + $request = new ServerRequest(); + $new = $request->withUploadedFiles(['picture' => $file]); + + $this->assertSame([], $request->getUploadedFiles()); + $this->assertNotSame($new, $request); + $this->assertSame(['picture' => $file], $new->getUploadedFiles()); + } + + /** + * Test getting a single file + */ + public function testGetUploadedFile(): void + { + $file = new UploadedFile( + __FILE__, + 123, + UPLOAD_ERR_OK, + 'test.php', + 'text/plain', + ); + $request = new ServerRequest(); + $new = $request->withUploadedFiles(['picture' => $file]); + $this->assertNull($new->getUploadedFile('')); + $this->assertSame($file, $new->getUploadedFile('picture')); + + $new = $request->withUploadedFiles([ + 'pictures' => [ + [ + 'image' => $file, + ], + ], + ]); + $this->assertNull($new->getUploadedFile('pictures')); + $this->assertNull($new->getUploadedFile('pictures.0')); + $this->assertNull($new->getUploadedFile('pictures.1')); + $this->assertSame($file, $new->getUploadedFile('pictures.0.image')); + } + + /** + * Test replacing files with an invalid file + */ + public function testWithUploadedFilesInvalidFile(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file at `avatar`'); + $request = new ServerRequest(); + $request->withUploadedFiles(['avatar' => 'not a file']); + } + + /** + * Test replacing files with an invalid file + */ + public function testWithUploadedFilesInvalidFileNested(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file at `user.avatar`'); + $request = new ServerRequest(); + $request->withUploadedFiles(['user' => ['avatar' => 'not a file']]); + } + + /** + * Test the clientIp method. + */ + public function testClientIp(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_X_FORWARDED_FOR' => '192.168.1.5, 10.0.1.1, proxy.com, real.ip', + 'HTTP_X_REAL_IP' => '192.168.1.1', + 'HTTP_CLIENT_IP' => '192.168.1.2', + 'REMOTE_ADDR' => '192.168.1.3', + ]]); + + $request->trustProxy = true; + $this->assertSame('real.ip', $request->clientIp()); + + $request = $request->withEnv('HTTP_X_FORWARDED_FOR', ''); + $this->assertSame('192.168.1.1', $request->clientIp()); + + $request = $request->withEnv('HTTP_X_REAL_IP', ''); + $this->assertSame('192.168.1.2', $request->clientIp()); + + $request->trustProxy = false; + $this->assertSame('192.168.1.3', $request->clientIp()); + + $request = $request->withEnv('HTTP_X_FORWARDED_FOR', ''); + $this->assertSame('192.168.1.3', $request->clientIp()); + + $request = $request->withEnv('HTTP_CLIENT_IP', ''); + $this->assertSame('192.168.1.3', $request->clientIp()); + } + + /** + * test clientIp method with trusted proxies + */ + public function testClientIpWithTrustedProxies(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_X_FORWARDED_FOR' => 'real.ip, 192.168.1.0, 192.168.1.2, 192.168.1.3', + 'HTTP_X_REAL_IP' => '192.168.1.1', + 'HTTP_CLIENT_IP' => '192.168.1.2', + 'REMOTE_ADDR' => '192.168.1.4', + ]]); + + $request->setTrustedProxies([ + '192.168.1.0', + '192.168.1.1', + '192.168.1.2', + '192.168.1.3', + ]); + + $this->assertSame('real.ip', $request->clientIp()); + + $request = $request->withEnv( + 'HTTP_X_FORWARDED_FOR', + 'spoof.fake.ip, real.ip, 192.168.1.0, 192.168.1.2, 192.168.1.3', + ); + $this->assertSame('192.168.1.3', $request->clientIp()); + + $request = $request->withEnv('HTTP_X_FORWARDED_FOR', ''); + $this->assertSame('192.168.1.1', $request->clientIp()); + + $request->trustProxy = false; + $this->assertSame('192.168.1.4', $request->clientIp()); + } + + /** + * Test the referrer function. + */ + public function testReferer(): void + { + $request = new ServerRequest(['webroot' => '/']); + + $request = $request->withEnv('HTTP_REFERER', 'http://cakephp.org'); + $result = $request->referer(false); + $this->assertSame('http://cakephp.org', $result); + + $request = $request->withEnv('HTTP_REFERER', ''); + $result = $request->referer(true); + $this->assertNull($result); + + $result = $request->referer(false); + $this->assertNull($result); + + $request = $request->withEnv('HTTP_REFERER', Configure::read('App.fullBaseUrl') . '/some/path'); + $result = $request->referer(); + $this->assertSame('/some/path', $result); + + $request = $request->withEnv('HTTP_REFERER', Configure::read('App.fullBaseUrl') . '///cakephp.org/'); + $result = $request->referer(); + $this->assertSame('/', $result); // Avoid returning scheme-relative URLs. + + $request = $request->withEnv('HTTP_REFERER', Configure::read('App.fullBaseUrl') . '/0'); + $result = $request->referer(); + $this->assertSame('/0', $result); + + $request = $request->withEnv('HTTP_REFERER', Configure::read('App.fullBaseUrl') . '/'); + $result = $request->referer(); + $this->assertSame('/', $result); + + $request = $request->withEnv('HTTP_REFERER', Configure::read('App.fullBaseUrl') . '/some/path'); + $result = $request->referer(false); + $this->assertSame(Configure::read('App.fullBaseUrl') . '/some/path', $result); + } + + /** + * Test referer() with a base path that duplicates the + * first segment. + */ + public function testRefererBasePath(): void + { + $request = new ServerRequest([ + 'url' => '/waves/users/login', + 'webroot' => '/waves/', + 'base' => '/waves', + ]); + $request = $request->withEnv('HTTP_REFERER', Configure::read('App.fullBaseUrl') . '/waves/waves/add'); + + $result = $request->referer(); + $this->assertSame('/waves/add', $result); + } + + /** + * test the simple uses of is() + */ + public function testIsHttpMethods(): void + { + $request = new ServerRequest(); + + $request = $request->withEnv('REQUEST_METHOD', 'GET'); + $this->assertTrue($request->is('get')); + + $request = $request->withEnv('REQUEST_METHOD', 'POST'); + $this->assertTrue($request->is('POST')); + + $request = $request->withEnv('REQUEST_METHOD', 'PUT'); + $this->assertTrue($request->is('put')); + $this->assertFalse($request->is('get')); + + $request = $request->withEnv('REQUEST_METHOD', 'DELETE'); + $this->assertTrue($request->is('delete')); + $this->assertTrue($request->isDelete()); + + $request = $request->withEnv('REQUEST_METHOD', 'delete'); + $this->assertFalse($request->is('delete')); + } + + public function testExceptionForInvalidType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No detector set for type `nonexistent`'); + + $request = new ServerRequest(); + + $this->assertFalse($request->is('nonexistent')); + } + + /** + * Test is() with JSON and XML. + */ + public function testIsJsonAndXml(): void + { + $request = new ServerRequest(); + $request = $request->withEnv('HTTP_ACCEPT', 'application/json, text/plain, */*'); + $this->assertTrue($request->is('json')); + + $request = new ServerRequest(); + $request = $request->withEnv('HTTP_ACCEPT', 'application/xml, text/plain, */*'); + $this->assertTrue($request->is('xml')); + + $request = new ServerRequest(); + $request = $request->withEnv('HTTP_ACCEPT', 'text/xml, */*'); + $this->assertTrue($request->is('xml')); + } + + /** + * Test is() with multiple types. + */ + public function testIsMultiple(): void + { + $request = new ServerRequest(); + + $request = $request->withEnv('REQUEST_METHOD', 'GET'); + $this->assertTrue($request->is(['get', 'post'])); + + $request = $request->withEnv('REQUEST_METHOD', 'POST'); + $this->assertTrue($request->is(['get', 'post'])); + + $request = $request->withEnv('REQUEST_METHOD', 'PUT'); + $this->assertFalse($request->is(['get', 'post'])); + } + + /** + * Test isAll() + */ + public function testIsAll(): void + { + $request = new ServerRequest(); + + $request = $request->withEnv('HTTP_X_REQUESTED_WITH', 'XMLHttpRequest'); + $request = $request->withEnv('REQUEST_METHOD', 'GET'); + + $this->assertTrue($request->isAll(['ajax', 'get'])); + $this->assertFalse($request->isAll(['post', 'get'])); + $this->assertFalse($request->isAll(['ajax', 'post'])); + } + + /** + * Test getMethod() + */ + public function testGetMethod(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'delete'], + ]); + $this->assertSame('delete', $request->getMethod()); + } + + /** + * Test withMethod() + */ + public function testWithMethod(): void + { + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'delete'], + ]); + $new = $request->withMethod('put'); + $this->assertNotSame($new, $request); + $this->assertSame('delete', $request->getMethod()); + $this->assertSame('put', $new->getMethod()); + } + + /** + * Test withMethod() and invalid data + */ + public function testWithMethodInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported HTTP method `no good` provided'); + $request = new ServerRequest([ + 'environment' => ['REQUEST_METHOD' => 'delete'], + ]); + $request->withMethod('no good'); + } + + /** + * Test getProtocolVersion() + */ + public function testGetProtocolVersion(): void + { + $request = new ServerRequest(); + $this->assertSame('1.1', $request->getProtocolVersion()); + + // SERVER var. + $request = new ServerRequest([ + 'environment' => ['SERVER_PROTOCOL' => 'HTTP/1.0'], + ]); + $this->assertSame('1.0', $request->getProtocolVersion()); + } + + /** + * Test withProtocolVersion() + */ + public function testWithProtocolVersion(): void + { + $request = new ServerRequest(); + $new = $request->withProtocolVersion('1.0'); + $this->assertNotSame($new, $request); + $this->assertSame('1.1', $request->getProtocolVersion()); + $this->assertSame('1.0', $new->getProtocolVersion()); + } + + /** + * Test withProtocolVersion() and invalid data + */ + public function testWithProtocolVersionInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported protocol version `no good` provided'); + $request = new ServerRequest(); + $request->withProtocolVersion('no good'); + } + + /** + * Test host retrieval. + */ + public function testHost(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'HTTP_X_FORWARDED_HOST' => 'cakephp.org', + ]]); + $this->assertSame('localhost', $request->host()); + + $request->trustProxy = true; + $this->assertSame('cakephp.org', $request->host()); + } + + /** + * test port retrieval. + */ + public function testPort(): void + { + $request = new ServerRequest(['environment' => ['SERVER_PORT' => '80']]); + + $this->assertSame('80', $request->port()); + + $request = $request->withEnv('SERVER_PORT', '443'); + $request = $request->withEnv('HTTP_X_FORWARDED_PORT', '80'); + $this->assertSame('443', $request->port()); + + $request->trustProxy = true; + $this->assertSame('80', $request->port()); + } + + /** + * test domain retrieval. + */ + public function testDomain(): void + { + $request = new ServerRequest(['environment' => ['HTTP_HOST' => 'something.example.com']]); + + $this->assertSame('example.com', $request->domain()); + + $request = $request->withEnv('HTTP_HOST', 'something.example.co.uk'); + $this->assertSame('example.co.uk', $request->domain(2)); + } + + /** + * Test scheme() method. + */ + public function testScheme(): void + { + $request = new ServerRequest(['environment' => ['HTTPS' => 'on']]); + + $this->assertSame('https', $request->scheme()); + + $request = $request->withEnv('HTTPS', ''); + $this->assertSame('http', $request->scheme()); + + $request = $request->withEnv('HTTP_X_FORWARDED_PROTO', 'https'); + $request->trustProxy = true; + $this->assertSame('https', $request->scheme()); + } + + /** + * test getting subdomains for a host. + */ + public function testSubdomain(): void + { + $request = new ServerRequest(['environment' => ['HTTP_HOST' => 'something.example.com']]); + + $this->assertEquals(['something'], $request->subdomains()); + + $request = $request->withEnv('HTTP_HOST', 'www.something.example.com'); + $this->assertEquals(['www', 'something'], $request->subdomains()); + + $request = $request->withEnv('HTTP_HOST', 'www.something.example.co.uk'); + $this->assertEquals(['www', 'something'], $request->subdomains(2)); + + $request = $request->withEnv('HTTP_HOST', 'example.co.uk'); + $this->assertEquals([], $request->subdomains(2)); + } + + /** + * Test AJAX, flash and friends + */ + public function testIsAjax(): void + { + $request = new ServerRequest(); + + $request = $request->withEnv('HTTP_X_REQUESTED_WITH', 'XMLHttpRequest'); + $this->assertTrue($request->is('ajax')); + + $request = $request->withEnv('HTTP_X_REQUESTED_WITH', 'XMLHTTPREQUEST'); + $this->assertFalse($request->is('ajax')); + $this->assertFalse($request->isAjax()); + } + + /** + * Test __call exceptions + */ + public function testMagicCallExceptionOnUnknownMethod(): void + { + $this->expectException(BadMethodCallException::class); + $request = new ServerRequest(); + $request->IamABanana(); + } + + /** + * Test is(ssl) + */ + public function testIsSsl(): void + { + $request = new ServerRequest(); + + $request = $request->withEnv('HTTPS', 'on'); + $this->assertTrue($request->is('https')); + + $request = $request->withEnv('HTTPS', '1'); + $this->assertTrue($request->is('https')); + + $request = $request->withEnv('HTTPS', 'I am not empty'); + $this->assertFalse($request->is('https')); + + $request = $request->withEnv('HTTPS', 'off'); + $this->assertFalse($request->is('https')); + + $request = $request->withEnv('HTTPS', ''); + $this->assertFalse($request->is('https')); + } + + /** + * Test adding detectors and having them work. + */ + public function testAddDetector(): void + { + $request = new ServerRequest(); + + ServerRequest::addDetector('closure', function ($request) { + return true; + }); + $this->assertTrue($request->is('closure')); + + ServerRequest::addDetector('get', function ($request) { + return $request->getEnv('REQUEST_METHOD') === 'GET'; + }); + $request = $request->withEnv('REQUEST_METHOD', 'GET'); + $this->assertTrue($request->is('get')); + + ServerRequest::addDetector('compare', ['env' => 'TEST_VAR', 'value' => 'something']); + + $request = $request->withEnv('TEST_VAR', 'something'); + $this->assertTrue($request->is('compare'), 'Value match failed.'); + + $request = $request->withEnv('TEST_VAR', 'wrong'); + $this->assertFalse($request->is('compare'), 'Value mis-match failed.'); + + ServerRequest::addDetector('compareCamelCase', ['env' => 'TEST_VAR', 'value' => 'foo']); + + $request = $request->withEnv('TEST_VAR', 'foo'); + $this->assertTrue($request->is('compareCamelCase'), 'Value match failed.'); + $this->assertTrue($request->is('comparecamelcase'), 'detectors should be case insensitive'); + $this->assertTrue($request->is('COMPARECAMELCASE'), 'detectors should be case insensitive'); + + $request = $request->withEnv('TEST_VAR', 'not foo'); + $this->assertFalse($request->is('compareCamelCase'), 'Value match failed.'); + $this->assertFalse($request->is('comparecamelcase'), 'detectors should be case insensitive'); + $this->assertFalse($request->is('COMPARECAMELCASE'), 'detectors should be case insensitive'); + + ServerRequest::addDetector('banana', ['env' => 'TEST_VAR', 'pattern' => '/^ban.*$/']); + $request = $request->withEnv('TEST_VAR', 'banana'); + $this->assertTrue($request->isBanana()); + + $request = $request->withEnv('TEST_VAR', 'wrong value'); + $this->assertFalse($request->isBanana()); + + ServerRequest::addDetector('mobile', ['env' => 'HTTP_USER_AGENT', 'options' => ['Imagination']]); + $request = $request->withEnv('HTTP_USER_AGENT', 'Imagination land'); + $this->assertTrue($request->isMobile()); + + ServerRequest::addDetector('index', ['param' => 'action', 'value' => 'index']); + + $request = $request->withParam('action', 'index'); + $request->clearDetectorCache(); + $this->assertTrue($request->isIndex()); + + $request = $request->withParam('action', 'add'); + $request->clearDetectorCache(); + $this->assertFalse($request->isIndex()); + + ServerRequest::addDetector('withParams', function ($request, array $params) { + foreach ($params as $name => $value) { + if ($request->getParam($name) != $value) { + return false; + } + } + + return true; + }); + + $request = $request->withParam('controller', 'Pages')->withParam('action', 'index'); + $request->clearDetectorCache(); + $this->assertTrue($request->isWithParams(['controller' => 'Pages', 'action' => 'index'])); + + $request = $request->withParam('controller', 'Posts'); + $request->clearDetectorCache(); + $this->assertFalse($request->isWithParams(['controller' => 'Pages', 'action' => 'index'])); + + ServerRequest::addDetector('callme', function ($request) { + return $request->getAttribute('return'); + }); + $request = $request->withAttribute('return', true); + $request->clearDetectorCache(); + $this->assertTrue($request->isCallMe()); + + ServerRequest::addDetector('extension', ['param' => '_ext', 'options' => ['pdf', 'png', 'txt']]); + $request = $request->withParam('_ext', 'pdf'); + $request->clearDetectorCache(); + $this->assertTrue($request->is('extension')); + + $request = $request->withParam('_ext', 'exe'); + $request->clearDetectorCache(); + $this->assertFalse($request->isExtension()); + } + + /** + * Test getting headers + */ + public function testHeader(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_4; en-ca) AppleWebKit/534.8+ (KHTML, like Gecko) Version/5.0 Safari/533.16', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => '1337', + 'HTTP_CONTENT_MD5' => 'abc123', + ]]); + + $this->assertEquals($request->getEnv('HTTP_HOST'), $request->getHeaderLine('host')); + $this->assertEquals($request->getEnv('HTTP_USER_AGENT'), $request->getHeaderLine('User-Agent')); + $this->assertEquals($request->getEnv('CONTENT_LENGTH'), $request->getHeaderLine('content-length')); + $this->assertEquals($request->getEnv('CONTENT_TYPE'), $request->getHeaderLine('content-type')); + $this->assertEquals($request->getEnv('HTTP_CONTENT_MD5'), $request->getHeaderLine('content-md5')); + } + + /** + * Test getting headers with psr7 methods + */ + public function testGetHeaders(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => 1337, + 'HTTP_CONTENT_MD5' => 'abc123', + 'HTTP_DOUBLE' => ['a', 'b'], + ]]); + $headers = $request->getHeaders(); + $expected = [ + 'Host' => ['localhost'], + 'Content-Type' => ['application/json'], + 'Content-Length' => [1337], + 'Content-Md5' => ['abc123'], + 'Double' => ['a', 'b'], + ]; + $this->assertEquals($expected, $headers); + } + + /** + * Test hasHeader + */ + public function testHasHeader(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => 1337, + 'HTTP_CONTENT_MD5' => 'abc123', + 'HTTP_DOUBLE' => ['a', 'b'], + ]]); + $this->assertTrue($request->hasHeader('Host')); + $this->assertTrue($request->hasHeader('Content-Type')); + $this->assertTrue($request->hasHeader('Content-MD5')); + $this->assertTrue($request->hasHeader('Double')); + $this->assertFalse($request->hasHeader('Authorization')); + } + + /** + * Test getting headers with psr7 methods + */ + public function testGetHeader(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => 1337, + 'HTTP_CONTENT_MD5' => 'abc123', + 'HTTP_DOUBLE' => ['a', 'b'], + ]]); + $this->assertEquals([], $request->getHeader('Not-there')); + + $expected = [$request->getEnv('HTTP_HOST')]; + $this->assertEquals($expected, $request->getHeader('Host')); + $this->assertEquals($expected, $request->getHeader('host')); + $this->assertEquals($expected, $request->getHeader('HOST')); + $this->assertEquals(['a', 'b'], $request->getHeader('Double')); + } + + /** + * Test getting headers with psr7 methods + */ + public function testGetHeaderLine(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => '1337', + 'HTTP_CONTENT_MD5' => 'abc123', + 'HTTP_DOUBLE' => ['a', 'b'], + ]]); + $this->assertSame('', $request->getHeaderLine('Authorization')); + + $expected = $request->getEnv('CONTENT_LENGTH'); + $this->assertEquals($expected, $request->getHeaderLine('Content-Length')); + $this->assertEquals($expected, $request->getHeaderLine('content-Length')); + $this->assertEquals($expected, $request->getHeaderLine('ConTent-LenGth')); + $this->assertSame('a, b', $request->getHeaderLine('Double')); + } + + /** + * Test setting a header. + */ + public function testWithHeader(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => '1337', + 'HTTP_CONTENT_MD5' => 'abc123', + 'HTTP_DOUBLE' => ['a', 'b'], + ]]); + $new = $request->withHeader('Content-Length', '999'); + $this->assertNotSame($new, $request); + + $this->assertSame('1337', $request->getHeaderLine('Content-length'), 'old request is unchanged'); + $this->assertSame('999', $new->getHeaderLine('Content-length'), 'new request is correct'); + + $new = $request->withHeader('Double', ['a']); + $this->assertEquals(['a'], $new->getHeader('Double'), 'List values are overwritten'); + } + + /** + * Test adding a header. + */ + public function testWithAddedHeader(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => 1337, + 'HTTP_CONTENT_MD5' => 'abc123', + 'HTTP_DOUBLE' => ['a', 'b'], + ]]); + $new = $request->withAddedHeader('Double', 'c'); + $this->assertNotSame($new, $request); + + $this->assertSame('a, b', $request->getHeaderLine('Double'), 'old request is unchanged'); + $this->assertSame('a, b, c', $new->getHeaderLine('Double'), 'new request is correct'); + + $new = $request->withAddedHeader('Content-Length', 777); + $this->assertEquals([1337, 777], $new->getHeader('Content-Length'), 'scalar values are appended'); + + $new = $request->withAddedHeader('Content-Length', [123, 456]); + $this->assertEquals([1337, 123, 456], $new->getHeader('Content-Length'), 'List values are merged'); + } + + /** + * Test removing a header. + */ + public function testWithoutHeader(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_HOST' => 'localhost', + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => 1337, + 'HTTP_CONTENT_MD5' => 'abc123', + 'HTTP_DOUBLE' => ['a', 'b'], + ]]); + $new = $request->withoutHeader('Content-Length'); + $this->assertNotSame($new, $request); + + $this->assertSame('1337', $request->getHeaderLine('Content-length'), 'old request is unchanged'); + $this->assertSame('', $new->getHeaderLine('Content-length'), 'new request is correct'); + } + + /** + * Test accepts() with and without parameters + */ + public function testAccepts(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_ACCEPT' => 'text/xml,application/xml;q=0.9,application/xhtml+xml,text/html,text/plain,image/png', + ]]); + + $result = $request->accepts(); + $expected = [ + 'text/xml', 'application/xhtml+xml', 'text/html', 'text/plain', 'image/png', 'application/xml', + ]; + $this->assertEquals($expected, $result, 'Content types differ.'); + + $result = $request->accepts('text/html'); + $this->assertTrue($result); + + $result = $request->accepts('image/gif'); + $this->assertFalse($result); + } + + /** + * Test that accept header types are trimmed for comparisons. + */ + public function testAcceptWithWhitespace(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_ACCEPT' => 'text/xml , text/html , text/plain,image/png', + ]]); + $result = $request->accepts(); + $expected = [ + 'text/xml', 'text/html', 'text/plain', 'image/png', + ]; + $this->assertEquals($expected, $result, 'Content types differ.'); + + $this->assertTrue($request->accepts('text/html')); + } + + /** + * Content types from accepts() should respect the client's q preference values. + */ + public function testAcceptWithQvalueSorting(): void + { + $request = new ServerRequest(['environment' => [ + 'HTTP_ACCEPT' => 'text/html;q=0.8,application/json;q=0.7,application/xml;q=1.0', + ]]); + $result = $request->accepts(); + $expected = ['application/xml', 'text/html', 'application/json']; + $this->assertEquals($expected, $result); + } + + /** + * Test the getQuery() method + */ + public function testGetQuery(): void + { + $array = [ + 'query' => [ + 'foo' => 'bar', + 'zero' => '0', + 'test' => [ + 'foo', 'bar', + ], + ], + ]; + $request = new ServerRequest($array); + + $this->assertEquals([ + 'foo' => 'bar', + 'zero' => '0', + 'test' => [ + 'foo', 'bar', + ], + ], $request->getQuery()); + + $this->assertSame('bar', $request->getQuery('foo')); + $this->assertSame('0', $request->getQuery('zero')); + $this->assertNull($request->getQuery('imaginary')); + $this->assertSame('default', $request->getQuery('imaginary', 'default')); + $this->assertFalse($request->getQuery('imaginary', false)); + + $this->assertSame(['foo', 'bar'], $request->getQuery('test')); + $this->assertSame('bar', $request->getQuery('test.1')); + $this->assertNull($request->getQuery('test.2')); + $this->assertSame('default', $request->getQuery('test.2', 'default')); + } + + /** + * Test getQueryParams + */ + public function testGetQueryParams(): void + { + $get = [ + 'test' => ['foo', 'bar'], + 'key' => 'value', + ]; + + $request = new ServerRequest([ + 'query' => $get, + ]); + $this->assertSame($get, $request->getQueryParams()); + } + + /** + * Test withQueryParams and immutability + */ + public function testWithQueryParams(): void + { + $get = [ + 'test' => ['foo', 'bar'], + 'key' => 'value', + ]; + + $request = new ServerRequest([ + 'query' => $get, + ]); + $new = $request->withQueryParams(['new' => 'data']); + $this->assertSame($get, $request->getQueryParams()); + $this->assertSame(['new' => 'data'], $new->getQueryParams()); + } + + /** + * Test getFilteredQueryParams() + */ + public function testGetFilteredQueryParams(): void + { + $get = [ + 'test' => ['foo', 'bar'], + 'key' => 'value', + ]; + + $only = [ + 'test' => ['foo', 'bar'], + ]; + + $exclude = [ + 'key' => 'value', + ]; + + $request = new ServerRequest([ + 'query' => $get, + ]); + + $this->assertSame($only, $request->getFilteredQueryParams(['test'])); + $this->assertSame($only, $request->getFilteredQueryParams(only: ['test'])); + $this->assertSame($exclude, $request->getFilteredQueryParams(exclude: ['test'])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Specify either `$only` or `$exclude`, not both.'); + + $request->getFilteredQueryParams(only: ['test'], exclude: ['test']); + } + + /** + * Test using param() + */ + public function testReadingParams(): void + { + $request = new ServerRequest([ + 'params' => [ + 'controller' => 'Posts', + 'admin' => true, + 'truthy' => 1, + 'zero' => '0', + ], + ]); + $this->assertNull($request->getParam('not_set')); + $this->assertTrue($request->getParam('admin')); + $this->assertSame(1, $request->getParam('truthy')); + $this->assertSame('Posts', $request->getParam('controller')); + $this->assertSame('0', $request->getParam('zero')); + } + + /** + * Test the data() method reading + */ + public function testGetData(): void + { + $post = [ + 'Model' => [ + 'field' => 'value', + ], + ]; + $request = new ServerRequest(compact('post')); + $this->assertEquals($post['Model'], $request->getData('Model')); + + $this->assertEquals($post, $request->getData()); + $this->assertNull($request->getData('Model.imaginary')); + + $this->assertSame('value', $request->getData('Model.field', 'default')); + $this->assertSame('default', $request->getData('Model.imaginary', 'default')); + } + + /** + * Test setting post data to a string throws exception. + */ + public function testInvalidStringData(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`post` key must be an array, object or null. Got `string` instead.'); + + $post = 'strange, but could happen'; + new ServerRequest(compact('post')); + } + + /** + * Test writing falsey values. + */ + public function testDataWritingFalsey(): void + { + $request = new ServerRequest(); + + $request = $request->withData('Post.null', null); + $this->assertNull($request->getData('Post.null')); + + $request = $request->withData('Post.false', false); + $this->assertFalse($request->getData('Post.false')); + + $request = $request->withData('Post.zero', 0); + $this->assertSame(0, $request->getData('Post.zero')); + + $request = $request->withData('Post.empty', ''); + $this->assertSame('', $request->getData('Post.empty')); + } + + /** + * Test reading params + * + * @param mixed $expected + */ + #[DataProvider('paramReadingDataProvider')] + public function testGetParam(string $toRead, $expected): void + { + $request = new ServerRequest([ + 'url' => '/', + 'params' => [ + 'action' => 'index', + 'foo' => 'bar', + 'baz' => [ + 'a' => [ + 'b' => 'c', + ], + ], + 'admin' => true, + 'truthy' => 1, + 'zero' => '0', + ], + ]); + $this->assertSame($expected, $request->getParam($toRead)); + } + + public function testGetParamQueryParamsDeprecation(): void + { + $this->deprecated(function (): void { + $request = new ServerRequest([ + 'url' => '/', + 'params' => [ + 'action' => 'index', + '?' => ['foo' => 'bar'], + ], + ]); + + $this->assertSame(['foo' => 'bar'], $request->getParam('?')); + }); + } + + /** + * Test getParam returning a default value. + */ + public function testGetParamDefault(): void + { + $request = new ServerRequest([ + 'params' => [ + 'controller' => 'Articles', + 'null' => null, + ], + ]); + $this->assertSame('Articles', $request->getParam('controller', 'default')); + $this->assertSame('default', $request->getParam('null', 'default')); + $this->assertFalse($request->getParam('unset', false)); + $this->assertNull($request->getParam('unset')); + } + + /** + * Data provider for testing reading values with ServerRequest::getParam() + * + * @return array + */ + public static function paramReadingDataProvider(): array + { + return [ + [ + 'action', + 'index', + ], + [ + 'baz', + [ + 'a' => [ + 'b' => 'c', + ], + ], + ], + [ + 'baz.a.b', + 'c', + ], + [ + 'does_not_exist', + null, + ], + [ + 'admin', + true, + ], + [ + 'truthy', + 1, + ], + [ + 'zero', + '0', + ], + ]; + } + + /** + * test writing request params with param() + */ + public function testParamWriting(): void + { + $request = new ServerRequest(['url' => '/']); + $request = $request->withParam('action', 'index'); + + $this->assertInstanceOf( + ServerRequest::class, + $request->withParam('some', 'thing'), + 'Method has not returned $this', + ); + + $request = $request->withParam('Post.null', null); + $this->assertNull($request->getParam('Post.null'), 'default value should be used.'); + + $request = $request->withParam('Post.false', false); + $this->assertFalse($request->getParam('Post.false')); + + $request = $request->withParam('Post.zero', 0); + $this->assertSame(0, $request->getParam('Post.zero')); + + $request = $request->withParam('Post.empty', ''); + $this->assertSame('', $request->getParam('Post.empty')); + + $this->assertSame('index', $request->getParam('action')); + $request = $request->withParam('action', 'edit'); + $this->assertSame('edit', $request->getParam('action')); + } + + /** + * Test accept language + */ + public function testAcceptLanguage(): void + { + $request = new ServerRequest(); + + // Weird language + $request = $request->withEnv('HTTP_ACCEPT_LANGUAGE', 'inexistent,en-ca'); + $result = $request->acceptLanguage(); + $this->assertEquals(['inexistent', 'en-ca'], $result, 'Languages do not match'); + + // No qualifier + $request = $request->withEnv('HTTP_ACCEPT_LANGUAGE', 'es_mx,en_ca'); + $result = $request->acceptLanguage(); + $this->assertEquals(['es-mx', 'en-ca'], $result, 'Languages do not match'); + + // With qualifier + $request = $request->withEnv('HTTP_ACCEPT_LANGUAGE', 'en-US,en;q=0.8,pt-BR;q=0.6,pt;q=0.4'); + $result = $request->acceptLanguage(); + $this->assertEquals(['en-us', 'en', 'pt-br', 'pt'], $result, 'Languages do not match'); + + // With spaces + $request = $request->withEnv('HTTP_ACCEPT_LANGUAGE', 'da, en-gb;q=0.8, en;q=0.7'); + $result = $request->acceptLanguage(); + $this->assertEquals(['da', 'en-gb', 'en'], $result, 'Languages do not match'); + + // Checking if requested + $request = $request->withEnv('HTTP_ACCEPT_LANGUAGE', 'es_mx,en_ca'); + + $result = $request->acceptLanguage('en-ca'); + $this->assertTrue($result); + + $result = $request->acceptLanguage('en-CA'); + $this->assertTrue($result); + + $result = $request->acceptLanguage('en-us'); + $this->assertFalse($result); + + $result = $request->acceptLanguage('en-US'); + $this->assertFalse($result); + } + + /** + * Test getBody + */ + public function testGetBody(): void + { + $request = new ServerRequest([ + 'input' => 'key=val&some=data', + ]); + $result = $request->getBody(); + $this->assertInstanceOf(StreamInterface::class, $result); + $this->assertSame('key=val&some=data', $result->getContents()); + } + + /** + * Test withBody + */ + public function testWithBody(): void + { + $request = new ServerRequest([ + 'input' => 'key=val&some=data', + ]); + $body = Mockery::mock(StreamInterface::class); + $new = $request->withBody($body); + $this->assertNotSame($new, $request); + $this->assertNotSame($body, $request->getBody()); + $this->assertSame($body, $new->getBody()); + } + + /** + * Test getUri + */ + public function testGetUri(): void + { + $request = new ServerRequest(['url' => 'articles/view/3']); + $result = $request->getUri(); + $this->assertInstanceOf(UriInterface::class, $result); + $this->assertSame('/articles/view/3', $result->getPath()); + } + + /** + * Test withUri + */ + public function testWithUri(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'example.com', + ], + 'url' => 'articles/view/3', + ]); + $uri = Mockery::mock(UriInterface::class)->shouldIgnoreMissing(); + $new = $request->withUri($uri); + $this->assertNotSame($new, $request); + $this->assertNotSame($uri, $request->getUri()); + $this->assertSame($uri, $new->getUri()); + } + + /** + * Test withUri() and preserveHost + */ + public function testWithUriPreserveHost(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'localhost', + ], + 'url' => 'articles/view/3', + ]); + $uri = new Uri(); + $uri = $uri->withHost('example.com') + ->withPort(123) + ->withPath('articles/view/3'); + $new = $request->withUri($uri, false); + + $this->assertNotSame($new, $request); + $this->assertSame('example.com:123', $new->getHeaderLine('Host')); + } + + /** + * Test withUri() and preserveHost missing the host header + */ + public function testWithUriPreserveHostNoHostHeader(): void + { + $request = new ServerRequest([ + 'url' => 'articles/view/3', + ]); + $uri = new Uri(); + $uri = $uri->withHost('example.com') + ->withPort(123) + ->withPath('articles/view/3'); + $new = $request->withUri($uri, false); + + $this->assertSame('example.com:123', $new->getHeaderLine('Host')); + } + + /** + * Test the cookie() method. + */ + public function testGetCookie(): void + { + $request = new ServerRequest([ + 'cookies' => [ + 'testing' => 'A value in the cookie', + 'user' => [ + 'remember' => '1', + ], + '123' => 'a integer key cookie', + ], + ]); + $this->assertSame('A value in the cookie', $request->getCookie('testing')); + $this->assertSame('a integer key cookie', $request->getCookie('123')); + $this->assertNull($request->getCookie('not there')); + $this->assertSame('default', $request->getCookie('not there', 'default')); + + $this->assertSame('1', $request->getCookie('user.remember')); + $this->assertSame('1', $request->getCookie('user.remember', 'default')); + $this->assertSame('default', $request->getCookie('user.not there', 'default')); + } + + /** + * Test getCookieParams() + */ + public function testGetCookieParams(): void + { + $cookies = [ + 'testing' => 'A value in the cookie', + ]; + $request = new ServerRequest(['cookies' => $cookies]); + $this->assertSame($cookies, $request->getCookieParams()); + } + + /** + * Test withCookieParams() + */ + public function testWithCookieParams(): void + { + $cookies = [ + 'testing' => 'A value in the cookie', + ]; + $request = new ServerRequest(['cookies' => $cookies]); + $new = $request->withCookieParams(['remember_me' => 1]); + $this->assertNotSame($new, $request); + $this->assertSame($cookies, $request->getCookieParams()); + $this->assertSame(['remember_me' => 1], $new->getCookieParams()); + } + + /** + * Test getting a cookie collection from a request. + */ + public function testGetCookieCollection(): void + { + $cookies = [ + 'remember_me' => 1, + 'color' => 'blue', + ]; + $request = new ServerRequest(['cookies' => $cookies]); + + $cookies = $request->getCookieCollection(); + $this->assertInstanceOf(CookieCollection::class, $cookies); + $this->assertCount(2, $cookies); + $this->assertSame('1', $cookies->get('remember_me')->getValue()); + $this->assertSame('blue', $cookies->get('color')->getValue()); + } + + /** + * Test replacing cookies from a collection + */ + public function testWithCookieCollection(): void + { + $cookies = new CookieCollection([new Cookie('remember_me', 1), new Cookie('color', 'red')]); + $request = new ServerRequest(['cookies' => ['bad' => 'goaway']]); + $new = $request->withCookieCollection($cookies); + $this->assertNotSame($new, $request, 'Should clone'); + + $this->assertSame(['bad' => 'goaway'], $request->getCookieParams()); + $this->assertSame(['remember_me' => '1', 'color' => 'red'], $new->getCookieParams()); + $cookies = $new->getCookieCollection(); + $this->assertCount(2, $cookies); + $this->assertSame('red', $cookies->get('color')->getValue()); + } + + /** + * TestAllowMethod + */ + public function testAllowMethod(): void + { + $request = new ServerRequest(['environment' => [ + 'url' => '/posts/edit/1', + 'REQUEST_METHOD' => 'PUT', + ]]); + + $this->assertTrue($request->allowMethod('put')); + + $request = $request->withEnv('REQUEST_METHOD', 'DELETE'); + $this->assertTrue($request->allowMethod(['post', 'delete'])); + } + + /** + * Test allowMethod throwing exception + */ + public function testAllowMethodException(): void + { + $request = new ServerRequest([ + 'url' => '/posts/edit/1', + 'environment' => ['REQUEST_METHOD' => 'PUT'], + ]); + + try { + $request->allowMethod(['POST', 'DELETE']); + $this->fail('An expected exception has not been raised.'); + } catch (MethodNotAllowedException $e) { + $this->assertEquals(['Allow' => 'POST, DELETE'], $e->getHeaders()); + } + + $this->expectException(MethodNotAllowedException::class); + + $request->allowMethod('POST'); + } + + /** + * Tests getting the session from the request + */ + public function testGetSession(): void + { + $session = new Session(); + $request = new ServerRequest(['session' => $session]); + $this->assertSame($session, $request->getSession()); + + $request = new ServerRequest(); + $this->assertEquals($session, $request->getSession()); + } + + public function testGetFlash(): void + { + $request = new ServerRequest(); + $this->assertInstanceOf(FlashMessage::class, $request->getFlash()); + } + + /** + * Test the content type method. + */ + public function testContentType(): void + { + $request = new ServerRequest([ + 'environment' => ['HTTP_CONTENT_TYPE' => 'application/json'], + ]); + $this->assertSame('application/json', $request->contentType()); + + $request = new ServerRequest([ + 'environment' => ['HTTP_CONTENT_TYPE' => 'application/xml'], + ]); + $this->assertSame('application/xml', $request->contentType(), 'prefer non http header.'); + } + + /** + * Test updating params in a psr7 fashion. + */ + public function testWithParam(): void + { + $request = new ServerRequest([ + 'params' => ['controller' => 'Articles'], + ]); + $result = $request->withParam('action', 'view'); + $this->assertNotSame($result, $request, 'New instance should be made'); + $this->assertNull($request->getParam('action'), 'No side-effect on original'); + $this->assertSame('view', $result->getParam('action')); + + $result = $request->withParam('action', 'index') + ->withParam('plugin', 'DebugKit') + ->withParam('prefix', 'Admin'); + $this->assertNotSame($result, $request, 'New instance should be made'); + $this->assertNull($request->getParam('action'), 'No side-effect on original'); + $this->assertSame('index', $result->getParam('action')); + $this->assertSame('DebugKit', $result->getParam('plugin')); + $this->assertSame('Admin', $result->getParam('prefix')); + } + + /** + * Test getting the parsed body parameters. + */ + public function testGetParsedBody(): void + { + $data = ['title' => 'First', 'body' => 'Best Article!']; + $request = new ServerRequest(['post' => $data]); + $this->assertSame($data, $request->getParsedBody()); + + $request = new ServerRequest(); + $this->assertSame([], $request->getParsedBody()); + } + + /** + * Test replacing the parsed body parameters. + */ + public function testWithParsedBody(): void + { + $data = ['title' => 'First', 'body' => 'Best Article!']; + $request = new ServerRequest([]); + $new = $request->withParsedBody($data); + + $this->assertNotSame($request, $new); + $this->assertSame([], $request->getParsedBody()); + $this->assertSame($data, $new->getParsedBody()); + } + + /** + * Test updating POST data in a psr7 fashion. + */ + public function testWithData(): void + { + $request = new ServerRequest([ + 'post' => [ + 'Model' => [ + 'field' => 'value', + ], + ], + ]); + $result = $request->withData('Model.new_value', 'new value'); + $this->assertNull($request->getData('Model.new_value'), 'Original request should not change.'); + $this->assertNotSame($result, $request); + $this->assertSame('new value', $result->getData('Model.new_value')); + $this->assertSame('new value', $result->getData()['Model']['new_value']); + $this->assertSame('value', $result->getData('Model.field')); + } + + /** + * Test removing data from a request + */ + public function testWithoutData(): void + { + $request = new ServerRequest([ + 'post' => [ + 'Model' => [ + 'id' => 1, + 'field' => 'value', + ], + ], + ]); + $updated = $request->withoutData('Model.field'); + $this->assertNotSame($updated, $request); + $this->assertSame('value', $request->getData('Model.field'), 'Original request should not change.'); + $this->assertNull($updated->getData('Model.field'), 'data removed from updated request'); + $this->assertFalse(isset($updated->getData()['Model']['field']), 'data removed from updated request'); + } + + /** + * Test updating POST data when keys don't exist + */ + public function testWithDataMissingIntermediaryKeys(): void + { + $request = new ServerRequest([ + 'post' => [ + 'Model' => [ + 'field' => 'value', + ], + ], + ]); + $result = $request->withData('Model.field.new_value', 'new value'); + $this->assertSame( + 'new value', + $result->getData('Model.field.new_value'), + ); + $this->assertSame( + 'new value', + $result->getData()['Model']['field']['new_value'], + ); + } + + /** + * Test updating POST data with falsey values + */ + public function testWithDataFalseyValues(): void + { + $request = new ServerRequest([ + 'post' => [], + ]); + $result = $request->withData('false', false) + ->withData('null', null) + ->withData('empty_string', '') + ->withData('zero', 0) + ->withData('zero_string', '0'); + $expected = [ + 'false' => false, + 'null' => null, + 'empty_string' => '', + 'zero' => 0, + 'zero_string' => '0', + ]; + $this->assertSame($expected, $result->getData()); + } + + /** + * Test setting attributes. + */ + public function testWithAttribute(): void + { + $request = new ServerRequest([]); + $this->assertNull($request->getAttribute('key')); + $this->assertSame('default', $request->getAttribute('key', 'default')); + + $new = $request->withAttribute('key', 'value'); + $this->assertNotEquals($new, $request, 'Should be different'); + $this->assertNull($request->getAttribute('key'), 'Old instance not modified'); + $this->assertSame('value', $new->getAttribute('key')); + + $update = $new->withAttribute('key', ['complex']); + $this->assertNotEquals($update, $new, 'Should be different'); + $this->assertSame(['complex'], $update->getAttribute('key')); + } + + /** + * Test that replacing the session can be done via withAttribute() + */ + public function testWithAttributeSession(): void + { + $request = new ServerRequest([]); + $request->getSession()->write('attrKey', 'session-value'); + + $update = $request->withAttribute('session', Session::create()); + $this->assertSame('session-value', $request->getAttribute('session')->read('attrKey')); + $this->assertNotSame($request->getAttribute('session'), $update->getAttribute('session')); + $this->assertNotSame($request->getSession()->read('attrKey'), $update->getSession()->read('attrKey')); + } + + /** + * Test getting all attributes. + */ + public function testGetAttributes(): void + { + $request = new ServerRequest([]); + $new = $request->withAttribute('key', 'value') + ->withAttribute('nully', null) + ->withAttribute('falsey', false); + + $this->assertFalse($new->getAttribute('falsey')); + $this->assertNull($new->getAttribute('nully')); + $expected = [ + 'key' => 'value', + 'nully' => null, + 'falsey' => false, + 'params' => [ + 'plugin' => null, + 'controller' => null, + 'action' => null, + '_ext' => null, + 'pass' => [], + ], + 'webroot' => '', + 'base' => '', + 'here' => '/', + ]; + $this->assertEquals($expected, $new->getAttributes()); + } + + /** + * Test unsetting attributes. + */ + public function testWithoutAttribute(): void + { + $request = new ServerRequest([]); + $new = $request->withAttribute('key', 'value'); + $update = $request->withoutAttribute('key'); + + $this->assertNotEquals($update, $new, 'Should be different'); + $this->assertNull($update->getAttribute('key')); + } + + /** + * Test that withoutAttribute() cannot remove emulatedAttributes properties. + */ + #[DataProvider('emulatedPropertyProvider')] + public function testWithoutAttributesDenyEmulatedProperties(string $prop): void + { + $this->expectException(InvalidArgumentException::class); + $request = new ServerRequest([]); + $request->withoutAttribute($prop); + } + + /** + * Test the requestTarget methods. + */ + public function testWithRequestTarget(): void + { + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_URI' => '/articles/view/1', + 'QUERY_STRING' => 'comments=1&open=0', + ], + 'base' => '/basedir', + ]); + $this->assertSame( + '/articles/view/1?comments=1&open=0', + $request->getRequestTarget(), + 'Should not include basedir.', + ); + + $new = $request->withRequestTarget('/articles/view/3'); + $this->assertNotSame($new, $request); + $this->assertSame( + '/articles/view/1?comments=1&open=0', + $request->getRequestTarget(), + 'should be unchanged.', + ); + $this->assertSame('/articles/view/3', $new->getRequestTarget(), 'reflects method call'); + } + + /** + * Test withEnv() + */ + public function testWithEnv(): void + { + $request = new ServerRequest(); + + $newRequest = $request->withEnv('HTTP_HOST', 'cakephp.org'); + $this->assertNotSame($request, $newRequest); + $this->assertSame('cakephp.org', $newRequest->getEnv('HTTP_HOST')); + } + + /** + * Test getEnv() + */ + public function testGetEnv(): void + { + $request = new ServerRequest([ + 'environment' => ['TEST' => 'ing'], + ]); + + //Test default null + $this->assertNull($request->getEnv('Foo')); + + //Test default set + $this->assertSame('Bar', $request->getEnv('Foo', 'Bar')); + + //Test env() fallback + $this->assertSame('ing', $request->getEnv('test')); + } + + /** + * Test getEnv() handles array values correctly + */ + public function testGetEnvWithArrayValues(): void + { + // Test single value added via withAddedHeader + $request = new ServerRequest(); + $request = $request->withAddedHeader('X-Forwarded-For', '1.1.1.1'); + $this->assertSame('1.1.1.1', $request->getEnv('HTTP_X_FORWARDED_FOR')); + + // Test multiple values + $request = $request->withAddedHeader('X-Forwarded-For', '2.2.2.2'); + $this->assertSame('1.1.1.1, 2.2.2.2', $request->getEnv('HTTP_X_FORWARDED_FOR')); + + // Test array values in environment + $request = new ServerRequest(['environment' => [ + 'HTTP_X_CUSTOM' => ['value1', 'value2', 'value3'], + ]]); + $this->assertSame('value1, value2, value3', $request->getEnv('HTTP_X_CUSTOM')); + + // Test that clientIp() works correctly with array header values + $request = new ServerRequest(['environment' => [ + 'REMOTE_ADDR' => '127.0.0.1', + ]]); + $request->trustProxy = true; + $request = $request->withAddedHeader('X-Forwarded-For', '192.168.1.1'); + $request = $request->withAddedHeader('X-Forwarded-For', '10.0.1.1'); + $this->assertSame('10.0.1.1', $request->clientIp()); + } + + /** + * Data provider for emulated property tests. + * + * @return array + */ + public static function emulatedPropertyProvider(): array + { + return [ + ['here'], + ['params'], + ['base'], + ['webroot'], + ['session'], + ]; + } +} diff --git a/tests/TestCase/Http/ServerTest.php b/tests/TestCase/Http/ServerTest.php new file mode 100644 index 00000000000..94a2cda3321 --- /dev/null +++ b/tests/TestCase/Http/ServerTest.php @@ -0,0 +1,424 @@ +server = $_SERVER; + $this->config = dirname(__DIR__, 2) . '/test_app/config'; + $GLOBALS['mockedHeaders'] = []; + $GLOBALS['mockedHeadersSent'] = true; + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + $_SERVER = $this->server; + unset($GLOBALS['mockedHeadersSent']); + } + + /** + * test get/set on the app + */ + public function testAppGetSet(): void + { + $eventManager = new EventManager(); + $app = new class ($this->config, $eventManager) extends BaseApplication + { + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + + $server = new Server($app); + $this->assertSame($app, $server->getApp()); + $this->assertSame($app->getEventManager(), $server->getEventManager()); + } + + /** + * test run building a response + */ + public function testRunWithRequest(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('X-pass', 'request header'); + + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + $res = $server->run($request); + $this->assertSame( + 'source header', + $res->getHeaderLine('X-testing'), + 'Input response is carried through out middleware', + ); + $this->assertSame( + 'request header', + $res->getHeaderLine('X-pass'), + 'Request is used in middleware', + ); + } + + /** + * test run calling plugin hooks + */ + public function testRunCallingPluginHooks(): void + { + $request = new ServerRequest(); + $request = $request->withHeader('X-pass', 'request header'); + + /** @var \TestApp\Http\MiddlewareApplication|\Mockery\MockInterface $app */ + $app = Mockery::spy(MiddlewareApplication::class, [$this->config]) + ->makePartial(); + + $server = new Server($app); + $res = $server->run($request); + $this->assertSame( + 'source header', + $res->getHeaderLine('X-testing'), + 'Input response is carried through out middleware', + ); + $this->assertSame( + 'request header', + $res->getHeaderLine('X-pass'), + 'Request is used in middleware', + ); + + $app->shouldHaveReceived('pluginBootstrap') + ->once(); + $app->shouldHaveReceived('pluginMiddleware') + ->with(Mockery::type(MiddlewareQueue::class)) + ->once(); + } + + /** + * test run building a request from globals. + */ + public function testRunWithGlobals(): void + { + $_SERVER['HTTP_X_PASS'] = 'globalvalue'; + + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + + $res = $server->run(); + $this->assertSame( + 'globalvalue', + $res->getHeaderLine('X-pass'), + 'Default request is made from server', + ); + } + + /** + * Test middleware being invoked. + */ + public function testRunMultipleMiddlewareSuccess(): void + { + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + $res = $server->run(); + $this->assertSame('first', $res->getHeaderLine('X-First')); + $this->assertSame('second', $res->getHeaderLine('X-Second')); + } + + /** + * Test that run closes session after invoking the application (if CakePHP ServerRequest is used). + */ + public function testRunClosesSessionIfServerRequestUsed(): void + { + $sessionMock = Mockery::mock(Session::class); + $sessionMock->shouldReceive('close') + ->once(); + + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + $request = new ServerRequest(['session' => $sessionMock]); + $res = $server->run($request); + + // assert that app was executed correctly + $this->assertSame( + 200, + $res->getStatusCode(), + 'Application was expected to be executed', + ); + $this->assertSame( + 'source header', + $res->getHeaderLine('X-testing'), + 'Application was expected to be executed', + ); + } + + /** + * Test that run does not close the session if CakePHP ServerRequest is not used. + */ + public function testRunDoesNotCloseSessionIfServerRequestNotUsed(): void + { + $request = new LaminasServerRequest(); + + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + $res = $server->run($request); + + // assert that app was executed correctly + $this->assertSame( + 200, + $res->getStatusCode(), + 'Application was expected to be executed', + ); + $this->assertSame( + 'source header', + $res->getHeaderLine('X-testing'), + 'Application was expected to be executed', + ); + } + + /** + * Test that emit invokes the appropriate methods on the emitter. + */ + public function testEmit(): void + { + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + $response = $server->run(); + $final = $response + ->withHeader('X-First', 'first') + ->withHeader('X-Second', 'second'); + + $emitter = Mockery::mock(ResponseEmitter::class); + $emitter->shouldReceive('emit') + ->once() + ->with($final); + + $server->emit($final, $emitter); + } + + /** + * Test that emit invokes the appropriate methods on the emitter. + */ + public function testEmitCallbackStream(): void + { + $GLOBALS['mockedHeadersSent'] = false; + $response = new LaminasResponse('php://memory', 200, ['x-testing' => 'source header']); + $response = $response->withBody(new CallbackStream(function (): void { + echo 'body content'; + })); + + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + ob_start(); + $server->emit($response); + $result = ob_get_clean(); + $this->assertSame('body content', $result); + } + + /** + * Ensure that the Server.buildMiddleware event is fired. + */ + public function testBuildMiddlewareEvent(): void + { + $app = new MiddlewareApplication($this->config); + $server = new Server($app); + $called = false; + + $server->getEventManager()->on('Server.buildMiddleware', function (EventInterface $event, MiddlewareQueue $middlewareQueue) use (&$called): void { + $middlewareQueue->add(function ($request, $handler) use (&$called) { + $called = true; + + return $handler->handle($request); + }); + $this->middlewareQueue = $middlewareQueue; + }); + $server->run(); + $this->assertTrue($called, 'Middleware added in the event was not triggered.'); + $this->middlewareQueue->seek(3); + $this->assertInstanceOf('Closure', $this->middlewareQueue->current()->getCallable(), '2nd last middleware is a closure'); + } + + /** + * test event manager proxies to the application. + */ + public function testEventManagerProxies(): void + { + $app = new class ($this->config) extends BaseApplication + { + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + + $server = new Server($app); + $this->assertSame($app->getEventManager(), $server->getEventManager()); + } + + /** + * test event manager cannot be set on applications without events. + */ + public function testGetEventManagerNonEventedApplication(): void + { + $app = new class implements HttpApplicationInterface { + public function bootstrap(): void + { + } + + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + + public function handle(ServerRequestInterface $request): Response + { + return new Response(); + } + }; + + $server = new Server($app); + $this->assertSame(EventManager::instance(), $server->getEventManager()); + } + + /** + * test event manager cannot be set on applications without events. + */ + public function testSetEventManagerNonEventedApplication(): void + { + $app = new class implements HttpApplicationInterface { + public function bootstrap(): void + { + } + + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + + public function handle(ServerRequestInterface $request): Response + { + return new Response(); + } + }; + + $events = new EventManager(); + $server = new Server($app); + + $this->expectException(InvalidArgumentException::class); + + $server->setEventManager($events); + } + + /** + * Test server run works without an application implementing ContainerApplicationInterface + */ + public function testAppWithoutContainerApplicationInterface(): void + { + $app = new class implements HttpApplicationInterface { + public function bootstrap(): void + { + } + + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + + public function handle(ServerRequestInterface $request): Response + { + return new Response(); + } + }; + $server = new Server($app); + + $request = new ServerRequest(); + $this->assertInstanceOf(ResponseInterface::class, $server->run($request)); + } + + public function testTerminateEvent(): void + { + $request = new ServerRequest(); + $app = new MiddlewareApplication($this->config); + $app->getContainer()->add(ServerRequest::class, $request); + $server = new Server($app); + + $triggered = false; + $server->getEventManager()->on( + 'Server.terminate', + function ($event, $request, $response) use (&$triggered): void { + $triggered = true; + $this->assertInstanceOf(ServerRequest::class, $request); + $this->assertInstanceOf(Response::class, $response); + }, + ); + + $emitter = new class extends ResponseEmitter { + public function emit(ResponseInterface $response, $stream = null): bool + { + return true; + } + }; + $server->emit(new Response(), $emitter); + + $this->assertTrue($triggered); + } +} diff --git a/tests/TestCase/Http/Session/CacheSessionTest.php b/tests/TestCase/Http/Session/CacheSessionTest.php new file mode 100644 index 00000000000..e3510edfd7a --- /dev/null +++ b/tests/TestCase/Http/Session/CacheSessionTest.php @@ -0,0 +1,105 @@ + ['engine' => 'File']]); + $this->storage = new CacheSession(['config' => 'session_test']); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + Cache::clear('session_test'); + Cache::drop('session_test'); + unset($this->storage); + } + + /** + * test open + */ + public function testOpen(): void + { + $this->assertTrue($this->storage->open('', '')); + } + + /** + * test write() + */ + public function testWrite(): void + { + $this->storage->write('abc', 'Some value'); + $this->assertSame('Some value', Cache::read('abc', 'session_test'), 'Value was not written.'); + } + + /** + * test reading. + */ + public function testRead(): void + { + $this->storage->write('test_one', 'Some other value'); + $this->assertSame('Some other value', $this->storage->read('test_one'), 'Incorrect value.'); + } + + /** + * test destroy + */ + public function testDestroy(): void + { + $this->storage->write('test_one', 'Some other value'); + $this->assertTrue($this->storage->destroy('test_one'), 'Value was not deleted.'); + + $this->assertNull(Cache::read('test_one', 'session_test'), 'Value stuck around.'); + } + + /** + * Tests that a cache config is required + */ + public function testMissingConfig(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The cache configuration name to use is required'); + new CacheSession(['foo' => 'bar']); + } +} diff --git a/tests/TestCase/Http/Session/DatabaseSessionTest.php b/tests/TestCase/Http/Session/DatabaseSessionTest.php new file mode 100644 index 00000000000..84f32420eac --- /dev/null +++ b/tests/TestCase/Http/Session/DatabaseSessionTest.php @@ -0,0 +1,160 @@ +storage = new DatabaseSession(); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + unset($this->storage); + parent::tearDown(); + } + + /** + * test that constructor sets the right things up. + */ + public function testConstructionSettings(): void + { + $this->getTableLocator()->clear(); + new DatabaseSession(); + + $session = $this->getTableLocator()->get('Sessions'); + $this->assertInstanceOf(Table::class, $session); + $this->assertSame('Sessions', $session->getAlias()); + $this->assertEquals(ConnectionManager::get('test'), $session->getConnection()); + $this->assertSame('sessions', $session->getTable()); + } + + /** + * test opening the session + */ + public function testOpen(): void + { + $this->assertTrue($this->storage->open('', '')); + } + + /** + * test write() + */ + public function testWrite(): void + { + $result = $this->storage->write('foo', 'Some value'); + $this->assertTrue($result); + + $expires = $this->getTableLocator()->get('Sessions')->get('foo')->expires; + $expected = time() + ini_get('session.gc_maxlifetime'); + $this->assertWithinRange($expected, $expires, 1); + } + + /** + * testReadAndWriteWithDatabaseStorage method + */ + public function testWriteEmptySessionId(): void + { + $result = $this->storage->write('', 'This is a Test'); + $this->assertFalse($result); + } + + /** + * test read() + */ + public function testRead(): void + { + $this->storage->write('foo', 'Some value'); + + $result = $this->storage->read('foo'); + $expected = 'Some value'; + $this->assertSame($expected, $result); + + $result = $this->storage->read('made up value'); + $this->assertSame('', $result); + } + + /** + * test blowing up the session. + */ + public function testDestroy(): void + { + $this->assertTrue($this->storage->write('foo', 'Some value')); + + $this->assertTrue($this->storage->destroy('foo'), 'Destroy failed'); + $this->assertSame('', $this->storage->read('foo'), 'Value still present.'); + $this->assertTrue($this->storage->destroy('foo'), 'Destroy should always return true'); + } + + /** + * test the garbage collector + */ + public function testGc(): void + { + $this->getTableLocator()->clear(); + + $storage = new DatabaseSession(); + $storage->setTimeout(0); + $storage->write('foo', 'Some value'); + + sleep(1); + $storage->gc(0); + $this->assertSame('', $storage->read('foo')); + } + + /** + * Tests serializing an entity + */ + public function testSerializeEntity(): void + { + $entity = new Entity(); + $entity->value = 'something'; + $this->storage->write('key', serialize($entity)); + $data = $this->getTableLocator()->get('Sessions')->get('key')->data; + $this->assertSame(serialize($entity), stream_get_contents($data)); + } +} diff --git a/tests/TestCase/Http/SessionTest.php b/tests/TestCase/Http/SessionTest.php new file mode 100644 index 00000000000..1c0e5688ad5 --- /dev/null +++ b/tests/TestCase/Http/SessionTest.php @@ -0,0 +1,766 @@ +clearPlugins(); + unset($_SESSION); + } + + public function testInvalidDefaultsNameException(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Invalid session defaults name `derp`. Valid values are: php, cake, cache, database.'); + + Session::create(['defaults' => 'derp']); + } + + /** + * test setting ini properties with Session configuration. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSessionConfigIniSetting(): void + { + $_SESSION = null; + + $this->deprecated(function (): void { + $config = [ + 'cookie' => 'test', + 'checkAgent' => false, + 'timeout' => 86400, + 'ini' => [ + 'session.referer_check' => 'example.com', + 'session.use_trans_sid' => false, + ], + ]; + + Session::create($config); + }, E_DEPRECATED, '8.4'); + + $this->assertSame('', ini_get('session.use_trans_sid'), 'Ini value is incorrect'); + $this->assertSame('example.com', ini_get('session.referer_check'), 'Ini value is incorrect'); + $this->assertSame('test', ini_get('session.name'), 'Ini value is incorrect'); + } + + /** + * test default `session.gc_maxlifetime` value is set as lifetime if timeout is not present. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSessionLifetimeIsSetToDefaultPHPIni(): void + { + $_SESSION = null; + + ini_set('session.gc_maxlifetime', 1440); + + $config = ['defaults' => 'php']; + $session = new Session($config); + + $prop = new ReflectionProperty($session, '_lifetime'); + $this->assertSame(1440, $prop->getValue($session)); + } + + /** + * test setting ini properties with Session configuration with timeout set to zero. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSessionConfigTimeoutZero(): void + { + $_SESSION = null; + + ini_set('session.gc_maxlifetime', 86400); + $config = [ + 'defaults' => 'php', + 'timeout' => 0, + ]; + + Session::create($config); + $this->assertEquals(86400, ini_get('session.gc_maxlifetime'), 'ini value unchanged when timeout disabled'); + } + + /** + * test setting ini properties with Session configuration. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSessionConfigTimeout(): void + { + $_SESSION = null; + + ini_set('session.gc_maxlifetime', 86400); + $config = [ + 'defaults' => 'php', + 'timeout' => 30, + ]; + + Session::create($config); + $this->assertEquals(30 * 60, ini_get('session.gc_maxlifetime'), 'timeout should set gc maxlifetime'); + } + + /** + * test session cookie path setting + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testCookiePath(): void + { + ini_set('session.cookie_path', '/foo'); + + new Session(); + $this->assertSame('/', ini_get('session.cookie_path')); + + new Session(['cookiePath' => '/base']); + $this->assertSame('/base', ini_get('session.cookie_path')); + } + + /** + * testCheck method + */ + public function testCheck(): void + { + $session = new Session(); + $session->write('SessionTestCase', 'value'); + $this->assertTrue($session->check()); + $this->assertTrue($session->check('SessionTestCase')); + $this->assertFalse($session->check('NotExistingSessionTestCase')); + $this->assertFalse($session->check('Crazy.foo')); + $session->write('Crazy.foo', ['bar' => 'baz']); + $this->assertTrue($session->check('Crazy.foo')); + $this->assertTrue($session->check('Crazy.foo.bar')); + } + + /** + * test read with simple values + */ + public function testReadSimple(): void + { + $session = new Session(); + $session->write('testing', '1,2,3'); + $result = $session->read('testing'); + $this->assertSame('1,2,3', $result); + + $session->write('testing', ['1' => 'one', '2' => 'two', '3' => 'three']); + $result = $session->read('testing.1'); + $this->assertSame('one', $result); + + $result = $session->read('testing'); + $this->assertEquals(['1' => 'one', '2' => 'two', '3' => 'three'], $result); + + $result = $session->read(); + $this->assertArrayHasKey('testing', $result); + + $session->write('This.is.a.deep.array.my.friend', 'value'); + $result = $session->read('This.is.a.deep.array'); + $this->assertEquals(['my' => ['friend' => 'value']], $result); + } + + /** + * testReadEmpty + */ + public function testReadEmpty(): void + { + $session = new Session(); + $this->assertNull($session->read('')); + } + + /** + * test read fallback + */ + public function testReadFallback(): void + { + $_SESSION = null; + $session = new Session(); + $this->assertSame('default', $session->read('no', 'default')); + } + + /** + * Tests read() with defaulting. + */ + public function testReadDefault(): void + { + $session = new Session(); + $this->assertSame('bar', $session->read('foo', 'bar')); + } + + /** + * Tests readOrFail() + */ + public function testReadOrFail(): void + { + $session = new Session(); + $session->write('testing', '1,2,3'); + $result = $session->readOrFail('testing'); + $this->assertSame('1,2,3', $result); + + $session->write('testing', ['1' => 'one', '2' => 'two', '3' => 'three']); + $result = $session->readOrFail('testing.1'); + $this->assertSame('one', $result); + } + + /** + * Tests readOrFail() with nonexistent value + */ + public function testReadOrFailException(): void + { + $session = new Session(); + + $this->expectException(CakeException::class); + + $session->readOrFail('testing'); + } + + /** + * Test writing simple keys + */ + public function testWriteSimple(): void + { + $session = new Session(); + $session->write('', 'empty'); + $this->assertSame('empty', $session->read('')); + + $session->write('Simple', ['values']); + $this->assertEquals(['values'], $session->read('Simple')); + } + + /** + * test writing a hash of values + */ + public function testWriteArray(): void + { + $session = new Session(); + $session->write([ + 'one' => 1, + 'two' => 2, + 'three' => ['something'], + 'null' => null, + ]); + $this->assertSame(1, $session->read('one')); + $this->assertEquals(['something'], $session->read('three')); + $this->assertNull($session->read('null')); + } + + /** + * Test overwriting a string value as if it were an array. + */ + public function testWriteOverwriteStringValue(): void + { + $session = new Session(); + $session->write('Some.string', 'value'); + $this->assertSame('value', $session->read('Some.string')); + + $session->write('Some.string.array', ['values']); + $this->assertEquals(['values'], $session->read('Some.string.array')); + } + + /** + * Test consuming session data. + */ + public function testConsume(): void + { + $session = new Session(); + $session->write('Some.string', 'value'); + $session->write('Some.array', ['key1' => 'value1', 'key2' => 'value2']); + + $this->assertSame('value', $session->read('Some.string')); + + $value = $session->consume('Some.string'); + $this->assertSame('value', $value); + $this->assertFalse($session->check('Some.string')); + + $value = $session->consume(''); + $this->assertNull($value); + + $value = $session->consume('Some.array'); + $expected = ['key1' => 'value1', 'key2' => 'value2']; + $this->assertEquals($expected, $value); + $this->assertFalse($session->check('Some.array')); + } + + /** + * testId method + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testId(): void + { + $session = new Session(); + $session->start(); + $result = $session->id(); + $this->assertNotEmpty($result); + $this->assertSame(session_id(), $result); + + $session->id('MySessionId'); + $this->assertSame('MySessionId', $session->id()); + $this->assertSame('MySessionId', session_id()); + + $session->id(''); + $this->assertSame('', session_id()); + } + + /** + * testStarted method + */ + public function testStarted(): void + { + $session = new Session(); + $this->assertFalse($session->started()); + $this->assertTrue($session->start()); + $this->assertTrue($session->started()); + } + + /** + * test close method + */ + public function testCloseNotStarted(): void + { + $session = new Session(); + $this->assertTrue($session->start()); + + $session->close(); + $this->assertFalse($session->started()); + } + + /** + * testClear method + */ + public function testClear(): void + { + $session = new Session(); + $session->write('Delete.me', 'Clearing out'); + + $session->clear(); + $this->assertFalse($session->check('Delete.me')); + $this->assertFalse($session->check('Delete')); + } + + /** + * testDelete method + */ + public function testDelete(): void + { + $session = new Session(); + $session->write('Delete.me', 'Clearing out'); + $session->delete('Delete.me'); + $this->assertFalse($session->check('Delete.me')); + $this->assertTrue($session->check('Delete')); + + $session->write('Clearing.sale', 'everything must go'); + $session->delete(''); + $this->assertTrue($session->check('Clearing.sale')); + + $session->delete('Clearing'); + $this->assertFalse($session->check('Clearing.sale')); + $this->assertFalse($session->check('Clearing')); + } + + /** + * test delete + */ + public function testDeleteEmptyString(): void + { + $session = new Session(); + $session->write('', 'empty string'); + $session->delete(''); + $this->assertFalse($session->check('')); + } + + /** + * testDestroy method + */ + public function testDestroy(): void + { + $session = new Session(); + $session->start(); + $session->write('bulletProof', 'invincible'); + $session->id('foo'); + $session->destroy(); + + $this->assertFalse($session->check('bulletProof')); + } + + /** + * testCheckingSavedEmpty method + */ + public function testCheckingSavedEmpty(): void + { + $session = new Session(); + $session->write('SessionTestCase', 0); + $this->assertTrue($session->check('SessionTestCase')); + + $session->write('SessionTestCase', '0'); + $this->assertTrue($session->check('SessionTestCase')); + + $session->write('SessionTestCase', false); + $this->assertTrue($session->check('SessionTestCase')); + + $session->write('SessionTestCase'); + $this->assertFalse($session->check('SessionTestCase')); + } + + /** + * testCheckKeyWithSpaces method + */ + public function testCheckKeyWithSpaces(): void + { + $session = new Session(); + $session->write('Session Test', 'test'); + $this->assertTrue($session->check('Session Test')); + $session->delete('Session Test'); + + $session->write('Session Test.Test Case', 'test'); + $this->assertTrue($session->check('Session Test.Test Case')); + } + + /** + * testCheckEmpty + */ + public function testCheckEmpty(): void + { + $session = new Session(); + $this->assertFalse($session->check()); + } + + /** + * test key exploitation + */ + public function testKeyExploit(): void + { + $session = new Session(); + $key = "a'] = 1; phpinfo(); \$_SESSION['a"; + $session->write($key, 'haxored'); + + $result = $session->read($key); + $this->assertNull($result); + } + + /** + * testReadingSavedEmpty method + */ + public function testReadingSavedEmpty(): void + { + $session = new Session(); + $session->write('', 'empty string'); + $this->assertTrue($session->check('')); + $this->assertSame('empty string', $session->read('')); + + $session->write('SessionTestCase', 0); + $this->assertSame(0, $session->read('SessionTestCase')); + + $session->write('SessionTestCase', '0'); + $this->assertSame('0', $session->read('SessionTestCase')); + $this->assertNotSame($session->read('SessionTestCase'), 0); + + $session->write('SessionTestCase', false); + $this->assertFalse($session->read('SessionTestCase')); + + $session->write('SessionTestCase'); + $this->assertNull($session->read('SessionTestCase')); + } + + /** + * test using a handler from app/Http/Session. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testUsingAppLibsHandler(): void + { + static::setAppNamespace(); + $config = [ + 'defaults' => 'cake', + 'handler' => [ + 'engine' => 'TestAppLibSession', + 'these' => 'are', + 'a few' => 'options', + ], + ]; + + $session = Session::create($config); + $this->assertInstanceOf(TestAppLibSession::class, $session->engine()); + $this->assertSame('user', ini_get('session.save_handler')); + $this->assertEquals(['these' => 'are', 'a few' => 'options'], $session->engine()->options); + } + + /** + * test using a handler from a plugin. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testUsingPluginHandler(): void + { + static::setAppNamespace(); + $this->loadPlugins(['TestPlugin']); + + $config = [ + 'defaults' => 'cake', + 'handler' => [ + 'engine' => 'TestPlugin.TestPluginSession', + ], + ]; + + $session = Session::create($config); + $this->assertInstanceOf(TestPluginSession::class, $session->engine()); + $this->assertSame('user', ini_get('session.save_handler')); + } + + /** + * Tests that it is possible to pass an already made instance as the session engine + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testEngineWithPreMadeInstance(): void + { + static::setAppNamespace(); + $engine = new TestAppLibSession(); + $session = new Session(['handler' => ['engine' => $engine]]); + $this->assertSame($engine, $session->engine()); + + $session = new Session(); + $session->engine($engine); + $this->assertSame($engine, $session->engine()); + } + + public function testEngineIsNull(): void + { + $session = new Session(); + $this->assertNull($session->engine()); + } + + /** + * Tests instantiating a missing engine + */ + public function testBadEngine(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The class `Derp` does not exist and cannot be used as a session engine'); + $session = new Session(); + $session->engine('Derp'); + } + + /** + * Test that cookieTimeout matches timeout when unspecified. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testCookieTimeoutFallback(): void + { + $config = [ + 'defaults' => 'cake', + 'timeout' => 400, + ]; + + new Session($config); + $this->assertSame('0', ini_get('session.cookie_lifetime')); + $this->assertSame((string)(400 * 60), ini_get('session.gc_maxlifetime')); + } + + /** + * Tests that the cookie name can be changed with configuration + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSessionName(): void + { + new Session(['cookie' => 'made_up_name']); + $this->assertSame('made_up_name', session_name()); + } + + /** + * Test that a call of check() starts the session when cookies are disabled in php.ini + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testCheckStartsSessionWithCookiesDisabled(): void + { + $_COOKIE = []; + $_GET = []; + + $session = new TestWebSession([ + 'ini' => [ + 'session.use_cookies' => 0, + 'session.use_trans_sid' => 0, + ], + ]); + + $this->assertFalse($session->started()); + $session->check('something'); + $this->assertTrue($session->started()); + } + + /** + * Test that a call of check() starts the session when a cookie is already set + */ + public function testCheckStartsSessionWithCookie(): void + { + $_COOKIE[session_name()] = '123abc'; + $_GET = []; + + $session = new TestWebSession([ + 'ini' => [ + 'session.use_cookies' => 1, + 'session.use_trans_sid' => 0, + ], + ]); + + $this->assertFalse($session->started()); + $session->check('something'); + $this->assertTrue($session->started()); + } + + /** + * Test that a call of check() starts the session when the session ID is passed via URL and session.use_trans_sid is enabled + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testCheckStartsSessionWithSIDinURL(): void + { + $_COOKIE = []; + $_GET[session_name()] = '123abc'; + + $this->deprecated(function (): void { + $session = new TestWebSession([ + 'ini' => [ + 'session.use_cookies' => 1, + 'session.use_trans_sid' => 1, + ], + ]); + + $this->assertFalse($session->started()); + $session->check('something'); + $this->assertTrue($session->started()); + }, E_DEPRECATED, '8.4'); + } + + /** + * Test that a call of check() does not start the session when the session ID is passed via URL and session.use_trans_sid is disabled + */ + public function testCheckDoesntStartSessionWithoutTransSID(): void + { + $_COOKIE = []; + $_GET[session_name()] = '123abc'; + + $session = new TestWebSession([ + 'ini' => [ + 'session.use_cookies' => 1, + 'session.use_trans_sid' => 0, + ], + ]); + + $this->assertFalse($session->started()); + $session->check('something'); + $this->assertFalse($session->started()); + } + + /** + * test SameSite cookie default is applied to all presets. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSameSiteDefaultAppliedToAllPresets(): void + { + $_SESSION = null; + + ini_set('session.cookie_samesite', ''); + $method = new ReflectionMethod(Session::class, '_defaultConfig'); + + $phpConfig = $method->invoke(null, 'php'); + $this->assertSame('Lax', $phpConfig['ini']['session.cookie_samesite']); + + $cakeConfig = $method->invoke(null, 'cake'); + $this->assertSame('Lax', $cakeConfig['ini']['session.cookie_samesite']); + } + + /** + * test SameSite cookie default is not applied when already set in php.ini. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSameSiteDefaultRespectsExistingIniValue(): void + { + $_SESSION = null; + + ini_set('session.cookie_samesite', 'Strict'); + $method = new ReflectionMethod(Session::class, '_defaultConfig'); + $config = $method->invoke(null, 'cake'); + $this->assertArrayNotHasKey('session.cookie_samesite', $config['ini']); + } + + /** + * test SameSite cookie can be overridden via user config. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSameSiteDefaultCanBeOverridden(): void + { + $_SESSION = null; + + ini_set('session.cookie_samesite', ''); + new Session([ + 'defaults' => 'cake', + 'ini' => [ + 'session.cookie_samesite' => 'None', + ], + ]); + $this->assertSame('None', ini_get('session.cookie_samesite')); + } + + /** + * test setting ini properties with Session configuration after session is created before started. + */ + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testSessionConfigTimeoutUpdate(): void + { + $_SESSION = null; + + ini_set('session.gc_maxlifetime', 86400); + $config = [ + 'defaults' => 'php', + ]; + + $session = Session::create($config); + $session->setSessionLifetime(3540); // 59*60 + $this->assertEquals(59 * 60, ini_get('session.gc_maxlifetime'), 'timeout should set gc maxlifetime'); + + $session->start(); + $this->expectException(CakeException::class); + $session->setSessionLifetime(3600); // 60*60 + } +} diff --git a/tests/TestCase/Http/StreamFactoryTest.php b/tests/TestCase/Http/StreamFactoryTest.php new file mode 100644 index 00000000000..fbced5b063c --- /dev/null +++ b/tests/TestCase/Http/StreamFactoryTest.php @@ -0,0 +1,71 @@ +factory = new StreamFactory(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // phpcs:disable + @unlink($this->filename); + // phpcs:enable + } + + public function testCreateStream(): void + { + $stream = $this->factory->createStream('test'); + $this->assertSame('test', $stream->getContents()); + } + + public function testCreateStreamFile(): void + { + file_put_contents($this->filename, 'it works'); + + $stream = $this->factory->createStreamFromFile($this->filename); + $this->assertSame('it works', $stream->getContents()); + } + + public function testCreateStreamResource(): void + { + file_put_contents($this->filename, 'it works'); + $resource = fopen($this->filename, 'r'); + + $stream = $this->factory->createStreamFromResource($resource); + $this->assertSame('it works', $stream->getContents()); + + fclose($resource); + } +} diff --git a/tests/TestCase/Http/TestSuite/HttpClientTraitTest.php b/tests/TestCase/Http/TestSuite/HttpClientTraitTest.php new file mode 100644 index 00000000000..e2405bfdae8 --- /dev/null +++ b/tests/TestCase/Http/TestSuite/HttpClientTraitTest.php @@ -0,0 +1,56 @@ + + */ + public static function methodProvider(): array + { + return [ + ['Get'], + ['Post'], + ['Put'], + ['Patch'], + ['Delete'], + ]; + } + + #[DataProvider('methodProvider')] + public function testRequestMethods(string $httpMethod): void + { + $traitMethod = "mockClient{$httpMethod}"; + + $response = $this->newClientResponse(200, ['Content-Type: application/json'], '{"ok":true}'); + $this->{$traitMethod}('http://example.com', $response); + + $client = new Client(); + $result = $client->{$httpMethod}('http://example.com'); + $this->assertSame($response, $result); + } +} diff --git a/tests/TestCase/Http/UploadedFileFactoryTest.php b/tests/TestCase/Http/UploadedFileFactoryTest.php new file mode 100644 index 00000000000..be67a513390 --- /dev/null +++ b/tests/TestCase/Http/UploadedFileFactoryTest.php @@ -0,0 +1,57 @@ +factory = new UploadedFileFactory(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // phpcs:disable + @unlink($this->filename); + // phpcs:enable + } + + public function testCreateStreamResource(): void + { + file_put_contents($this->filename, 'it works'); + $stream = new Stream($this->filename); + + $uploadedFile = $this->factory->createUploadedFile($stream, null, UPLOAD_ERR_OK, 'my-name'); + $this->assertSame('my-name', $uploadedFile->getClientFilename()); + $this->assertSame($stream, $uploadedFile->getStream()); + } +} diff --git a/tests/TestCase/Http/UriFactoryTest.php b/tests/TestCase/Http/UriFactoryTest.php new file mode 100644 index 00000000000..beb47594dae --- /dev/null +++ b/tests/TestCase/Http/UriFactoryTest.php @@ -0,0 +1,48 @@ +createUri('https://cakephp.org'); + + $this->assertInstanceOf(Uri::class, $uri); + } + + public function testMarshalUriAndBaseFromSapi(): void + { + Configure::write('App.baseUrl', '/index.php'); + $result = UriFactory::marshalUriAndBaseFromSapi([]); + + $this->assertInstanceOf(Uri::class, $result['uri']); + $this->assertSame('/index.php', $result['base']); + $this->assertSame('/webroot/', $result['webroot']); + + Configure::delete('App.baseUrl'); + } +} diff --git a/tests/TestCase/Http/server_mocks.php b/tests/TestCase/Http/server_mocks.php new file mode 100644 index 00000000000..8729670aaba --- /dev/null +++ b/tests/TestCase/Http/server_mocks.php @@ -0,0 +1,17 @@ + $value) { + $output[$key] = $value; + } + $this->assertCount(4, $output); + $this->assertInstanceOf(Date::class, $output[0]); + $this->assertSame('2025-01-01 00:00:00', $output[0]->format('Y-m-d H:i:s')); + $this->assertInstanceOf(Date::class, $output[1]); + $this->assertSame('2025-01-02 00:00:00', $output[1]->format('Y-m-d H:i:s')); + $this->assertInstanceOf(Date::class, $output[3]); + $this->assertSame('2025-01-04 00:00:00', $output[3]->format('Y-m-d H:i:s')); + } +} diff --git a/tests/TestCase/I18n/DateTest.php b/tests/TestCase/I18n/DateTest.php new file mode 100644 index 00000000000..c9571ee01d5 --- /dev/null +++ b/tests/TestCase/I18n/DateTest.php @@ -0,0 +1,493 @@ +setMessages([ + '{0} ago' => '{0} ago (translated)', + ]); + + return $package; + }, 'fr_FR'); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + DateTime::setDefaultLocale(); + date_default_timezone_set('UTC'); + } + + /** + * Ensure that instances can be built from other objects. + */ + public function testConstructFromAnotherInstance(): void + { + $time = '2015-01-22'; + $frozen = new Date($time); + $subject = new Date($frozen); + $this->assertSame($time, $subject->format('Y-m-d'), 'date construction'); + } + + /** + * test formatting dates taking in account preferred i18n locale file + */ + public function testI18nFormat(): void + { + $time = new Date('Thu Jan 14 13:59:28 2010'); + $result = $time->i18nFormat(); + $expected = '1/14/10'; + $this->assertSame($expected, $result); + + $result = $time->i18nFormat('HH:mm:ss'); + $expected = '00:00:00'; + $this->assertSame($expected, $result); + + DateTime::setDefaultLocale('fr-FR'); + $result = $time->i18nFormat(IntlDateFormatter::FULL); + $result = str_replace(' à', '', $result); + $expected = 'jeudi 14 janvier 2010'; + $this->assertStringStartsWith($expected, $result); + + $result = $time->i18nFormat(IntlDateFormatter::FULL, 'es-ES'); + $this->assertStringContainsString('14 de enero de 2010', $result, 'Default locale should not be used'); + + $time = new Date('2014-01-01T00:00:00Z'); + $result = $time->i18nFormat(IntlDateFormatter::FULL, 'en-US'); + $result = preg_replace('/[\pZ\pC]/u', ' ', $result); + $this->assertStringStartsWith('Wednesday, January 1, 2014', $result); + } + + public function testDiffForHumans(): void + { + I18n::setLocale('fr_FR'); + $time = new Date('yesterday'); + $this->assertSame('1 day ago (translated)', $time->diffForHumans()); + I18n::setLocale(I18n::getDefaultLocale()); + } + + /** + * test __toString + */ + public function testToString(): void + { + $date = new Date('2015-11-06 11:32:45'); + $this->assertSame('11/6/15', (string)$date); + } + + /** + * test nice() + */ + public function testNice(): void + { + $date = new Date('2015-11-06 11:32:45'); + + $this->assertSame('Nov 6, 2015', $date->nice()); + $this->assertSame('6 nov. 2015', $date->nice('fr-FR')); + } + + /** + * test jsonSerialize() + */ + public function testJsonSerialize(): void + { + if (version_compare(INTL_ICU_VERSION, '50.0', '<')) { + $this->markTestSkipped('ICU 5x is needed'); + } + + $date = new Date('2015-11-06 11:32:45'); + $this->assertSame('"2015-11-06"', json_encode($date)); + } + + /** + * Tests change JSON encoding format + */ + public function testSetJsonEncodeFormat(): void + { + $date = new Date('2015-11-06 11:32:45'); + + Date::setJsonEncodeFormat(static function ($d) { + return $d->format(DATE_ATOM); + }); + $this->assertSame('"2015-11-06T00:00:00+00:00"', json_encode($date)); + + Date::setJsonEncodeFormat("yyyy-MM-dd'T'HH':'mm':'ssZZZZZ"); + $this->assertSame('"2015-11-06T00:00:00Z"', json_encode($date)); + } + + /** + * test parseDate() + */ + public function testParseDate(): void + { + $date = Date::parseDate('11/6/15'); + $this->assertSame('2015-11-06 00:00:00', $date->format('Y-m-d H:i:s')); + + DateTime::setDefaultLocale('fr-FR'); + $date = Date::parseDate('13 10, 2015'); + $this->assertSame('2015-10-13 00:00:00', $date->format('Y-m-d H:i:s')); + } + + /** + * Tests disabling leniency when parsing locale format. + */ + public function testLenientParseDate(): void + { + DateTime::setDefaultLocale('pt_BR'); + + DateTime::disableLenientParsing(); + $date = Date::parseDate('04/21/2013'); + $this->assertSame(null, $date); + + DateTime::enableLenientParsing(); + $date = Date::parseDate('04/21/2013'); + $this->assertSame('2014-09-04', $date->format('Y-m-d')); + } + + /** + * provider for timeAgoInWords() tests + * + * @return array + */ + public static function timeAgoProvider(): array + { + return [ + ['-1 day', '1 day ago'], + ['-2 days', '2 days ago'], + ['-1 week', '1 week ago'], + ['-2 weeks -2 days', '2 weeks, 2 days ago'], + ['+1 second', 'today'], + ['+1 minute, +10 seconds', 'today'], + ['+1 week', '1 week'], + ['+1 week 1 day', '1 week, 1 day'], + ['+2 weeks 2 day', '2 weeks, 2 days'], + ['2007-9-24', 'on 9/24/07'], + ['now', 'today'], + ]; + } + + /** + * testTimeAgoInWords method + */ + #[DataProvider('timeAgoProvider')] + public function testTimeAgoInWords(string $input, string $expected): void + { + $date = new Date($input); + $result = $date->timeAgoInWords(); + $this->assertEquals($expected, $result); + } + + /** + * test the timezone option for timeAgoInWords + */ + public function testTimeAgoInWordsTimezone(): void + { + $date = new Date('1990-07-31 20:33:00 UTC'); + $result = $date->timeAgoInWords( + [ + 'timezone' => 'America/Vancouver', + 'end' => '+1month', + 'format' => 'dd-MM-YYYY', + ], + ); + $this->assertSame('on 31-07-1990', $result); + } + + /** + * provider for timeAgo with an end date. + * + * @return array + */ + public static function timeAgoEndProvider(): array + { + return [ + [ + '+4 months +2 weeks +3 days', + '4 months, 2 weeks, 3 days', + '8 years', + ], + [ + '+4 months +2 weeks +1 day', + '4 months, 2 weeks, 1 day', + '8 years', + ], + [ + '+3 months +2 weeks', + '3 months, 2 weeks', + '8 years', + ], + [ + '+3 months +2 weeks +1 day', + '3 months, 2 weeks, 1 day', + '8 years', + ], + [ + '+1 months +1 week +1 day', + '1 month, 1 week, 1 day', + '8 years', + ], + [ + '+2 months +2 days', + '2 months, 2 days', + '+2 months +2 days', + ], + [ + '+2 months +12 days', + '2 months, 1 week, 5 days', + '3 months', + ], + ]; + } + + /** + * test the end option for timeAgoInWords + */ + #[DataProvider('timeAgoEndProvider')] + public function testTimeAgoInWordsEnd(string $input, string $expected, string $end): void + { + $time = new Date($input); + $result = $time->timeAgoInWords(['end' => $end]); + $this->assertEquals($expected, $result); + } + + /** + * test the custom string options for timeAgoInWords + */ + public function testTimeAgoInWordsCustomStrings(): void + { + $date = new Date('-8 years -4 months -2 weeks -3 days'); + $result = $date->timeAgoInWords([ + 'relativeString' => 'at least %s ago', + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years', + ]); + $expected = 'at least 8 years ago'; + $this->assertSame($expected, $result); + + $date = new Date('+4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'absoluteString' => 'exactly on %s', + 'accuracy' => ['year' => 'year'], + 'end' => '+2 months', + ]); + $expected = 'exactly on ' . $date->format('n/j/y'); + $this->assertSame($expected, $result); + } + + /** + * Test the accuracy option for timeAgoInWords() + */ + public function testDateAgoInWordsAccuracy(): void + { + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years', + ]); + $expected = '8 years'; + $this->assertSame($expected, $result); + + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'month'], + 'end' => '+10 years', + ]); + $expected = '8 years, 4 months'; + $this->assertSame($expected, $result); + + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'week'], + 'end' => '+10 years', + ]); + $expected = '8 years, 4 months, 2 weeks'; + $this->assertSame($expected, $result); + + $date = new Date('+8 years +4 months +2 weeks +3 days'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'day'], + 'end' => '+10 years', + ]); + $expected = '8 years, 4 months, 2 weeks, 3 days'; + $this->assertSame($expected, $result); + + $date = new Date('+1 years +5 weeks'); + $result = $date->timeAgoInWords([ + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years', + ]); + $expected = '1 year'; + $this->assertSame($expected, $result); + + $date = new Date('now'); + $result = $date->timeAgoInWords([ + 'accuracy' => 'day', + ]); + $expected = 'today'; + $this->assertSame($expected, $result); + } + + /** + * Test the format option of timeAgoInWords() + */ + public function testDateAgoInWordsWithFormat(): void + { + $date = new Date('2007-9-25'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertSame('on 2007-09-25', $result); + + $date = new Date('2007-9-25'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertSame('on 2007-09-25', $result); + + $date = new Date('+2 weeks +2 days'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertMatchesRegularExpression('/^2 weeks, [1|2] day(s)?$/', $result); + + $date = new Date('+2 months +2 days'); + $result = $date->timeAgoInWords(['end' => '1 month', 'format' => 'yyyy-MM-dd']); + $this->assertSame('on ' . $date->format('Y-m-d'), $result); + } + + /** + * test timeAgoInWords() with negative values. + */ + public function testDateAgoInWordsNegativeValues(): void + { + $date = new Date('-2 months -2 days'); + $result = $date->timeAgoInWords(['end' => '3 month']); + $this->assertSame('2 months, 2 days ago', $result); + + $date = new Date('-2 months -2 days'); + $result = $date->timeAgoInWords(['end' => '3 month']); + $this->assertSame('2 months, 2 days ago', $result); + + $date = new Date('-2 months -2 days'); + $result = $date->timeAgoInWords(['end' => '1 month', 'format' => 'yyyy-MM-dd']); + $this->assertSame('on ' . $date->format('Y-m-d'), $result); + + $date = new Date('-2 years -5 months -2 days'); + $result = $date->timeAgoInWords(['end' => '3 years']); + $this->assertSame('2 years, 5 months, 2 days ago', $result); + + $date = new Date('-2 weeks -2 days'); + $result = $date->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertSame('2 weeks, 2 days ago', $result); + + $date = new Date('-3 years -12 months'); + $result = $date->timeAgoInWords(); + $expected = 'on ' . $date->format('n/j/y'); + $this->assertSame($expected, $result); + + $date = new Date('-1 month -1 week -6 days'); + $result = $date->timeAgoInWords( + ['end' => '1 year', 'accuracy' => ['month' => 'month']], + ); + $this->assertSame('1 month ago', $result); + + $date = new Date('-1 years -2 weeks -3 days'); + $result = $date->timeAgoInWords( + ['accuracy' => ['year' => 'year']], + ); + $expected = 'on ' . $date->format('n/j/y'); + $this->assertSame($expected, $result); + + $date = new Date('-13 months -5 days'); + $result = $date->timeAgoInWords(['end' => '2 years']); + $this->assertSame('1 year, 1 month, 5 days ago', $result); + } + + /** + * Tests that parsing a date in a timezone other than UTC + * will not alter the date + */ + public function testParseDateDifferentTimezone(): void + { + date_default_timezone_set('Europe/Paris'); + $result = Date::parseDate('25-02-2016', 'd-M-y'); + $this->assertSame('25-02-2016', $result->format('d-m-Y')); + } + + /** + * Tests the default locale setter. + */ + public function testDefaultLocaleEffectsFormatting(): void + { + $result = Date::parseDate('12/03/2015'); + $this->assertSame('Dec 3, 2015', $result->nice()); + + DateTime::setDefaultLocale('fr-FR'); + + $result = Date::parseDate('12/03/2015'); + $this->assertSame('12 mars 2015', $result->nice()); + + $expected = 'Y-m-d'; + $result = Date::parseDate('12/03/2015'); + $this->assertSame('2015-03-12', $result->format($expected)); + } + + /** + * Test getTimestamp() method + */ + public function testGetTimestamp(): void + { + $date2000 = new Date('2000-01-01'); + $this->assertSame(946684800, $date2000->getTimestamp()); + + $date1970 = new Date('1970-01-01'); + $this->assertSame(0, $date1970->getTimestamp()); + } + + /** + * Test getTimestamp() consistency with DateTime + */ + public function testGetTimestampConsistencyWithDateTime(): void + { + $dateStr = '2024-01-15'; + $date = new Date($dateStr); + $dateTime = new DateTime($dateStr . ' 00:00:00'); + + // For the same date at midnight, timestamps should match + $this->assertSame($dateTime->getTimestamp(), $date->getTimestamp()); + } +} diff --git a/tests/TestCase/I18n/DateTimePeriodTest.php b/tests/TestCase/I18n/DateTimePeriodTest.php new file mode 100644 index 00000000000..0629e34db1e --- /dev/null +++ b/tests/TestCase/I18n/DateTimePeriodTest.php @@ -0,0 +1,41 @@ + $value) { + $output[$key] = $value; + } + $this->assertCount(4, $output); + $this->assertInstanceOf(DateTime::class, $output[0]); + $this->assertSame('2025-01-01 00:00:00', $output[0]->format('Y-m-d H:i:s')); + $this->assertInstanceOf(DateTime::class, $output[1]); + $this->assertSame('2025-01-01 01:00:00', $output[1]->format('Y-m-d H:i:s')); + } +} diff --git a/tests/TestCase/I18n/DateTimeTest.php b/tests/TestCase/I18n/DateTimeTest.php new file mode 100644 index 00000000000..60bcc923f88 --- /dev/null +++ b/tests/TestCase/I18n/DateTimeTest.php @@ -0,0 +1,898 @@ +now = DateTime::getTestNow(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + DateTime::setTestNow($this->now); + DateTime::setDefaultLocale(); + DateTime::resetToStringFormat(); + DateTime::setJsonEncodeFormat("yyyy-MM-dd'T'HH':'mm':'ssxxx"); + + date_default_timezone_set('UTC'); + I18n::setLocale(I18n::getDefaultLocale()); + } + + /** + * Ensure that instances can be built from other objects. + */ + public function testConstructFromAnotherInstance(): void + { + $time = '2015-01-22 10:33:44.123456'; + + $mut = new DateTime($time, 'America/Chicago'); + $subject = new DateTime($mut); + $this->assertSame($time, $subject->format('Y-m-d H:i:s.u'), 'time construction'); + + $mut = new Chronos($time, 'America/Chicago'); + $subject = new DateTime($mut); + $this->assertSame($time, $subject->format('Y-m-d H:i:s.u'), 'time construction'); + + $mut = new NativeDateTime($time, new DateTimeZone('America/Chicago')); + $subject = new DateTime($mut); + $this->assertSame($time, $subject->format('Y-m-d H:i:s.u'), 'time construction'); + } + + /** + * provider for timeAgoInWords() tests + * + * @return array + */ + public static function timeAgoProvider(): array + { + return [ + ['-12 seconds', '12 seconds ago'], + ['-12 minutes', '12 minutes ago'], + ['-2 hours', '2 hours ago'], + ['-1 day', '1 day ago'], + ['-2 days', '2 days ago'], + ['-2 days -3 hours', '2 days, 3 hours ago'], + ['-1 week', '1 week ago'], + ['-2 weeks -2 days', '2 weeks, 2 days ago'], + ['+1 week', '1 week'], + ['+1 week 1 day', '1 week, 1 day'], + ['+2 weeks 2 day', '2 weeks, 2 days'], + ['2007-9-24', 'on 9/24/07'], + ['now', 'just now'], + ]; + } + + /** + * testTimeAgoInWords method + */ + #[DataProvider('timeAgoProvider')] + public function testTimeAgoInWords(string $input, string $expected): void + { + $time = new DateTime($input); + $result = $time->timeAgoInWords(); + $this->assertEquals($expected, $result); + } + + /** + * provider for timeAgo with an end date. + * + * @return array + */ + public static function timeAgoEndProvider(): array + { + return [ + [ + '+4 months +2 weeks +3 days', + '4 months, 2 weeks, 3 days', + '8 years', + ], + [ + '+4 months +2 weeks +1 day', + '4 months, 2 weeks, 1 day', + '8 years', + ], + [ + '+3 months +2 weeks', + '3 months, 2 weeks', + '8 years', + ], + [ + '+3 months +2 weeks +1 day', + '3 months, 2 weeks, 1 day', + '8 years', + ], + [ + '+1 months +1 week +1 day', + '1 month, 1 week, 1 day', + '8 years', + ], + [ + '+2 months +2 days', + '2 months, 2 days', + '+2 months +2 days', + ], + [ + '+2 months +12 days', + '2 months, 1 week, 5 days', + '3 months', + ], + ]; + } + + /** + * test the timezone option for timeAgoInWords + */ + public function testTimeAgoInWordsTimezone(): void + { + $time = new DateTime('1990-07-31 20:33:00 UTC'); + $result = $time->timeAgoInWords( + [ + 'timezone' => 'America/Vancouver', + 'end' => '+1month', + 'format' => 'dd-MM-YYYY HH:mm:ss', + ], + ); + $this->assertSame('on 31-07-1990 13:33:00', $result); + } + + /** + * test the end option for timeAgoInWords + */ + #[DataProvider('timeAgoEndProvider')] + public function testTimeAgoInWordsEnd(string $input, string $expected, string $end): void + { + $time = new DateTime($input); + $result = $time->timeAgoInWords(['end' => $end]); + $this->assertEquals($expected, $result); + } + + /** + * test the custom string options for timeAgoInWords + */ + public function testTimeAgoInWordsCustomStrings(): void + { + $time = new DateTime('-8 years -4 months -2 weeks -3 days'); + $result = $time->timeAgoInWords([ + 'relativeString' => 'at least %s ago', + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years', + ]); + $expected = 'at least 8 years ago'; + $this->assertSame($expected, $result); + + $time = new DateTime('+4 months +2 weeks +3 days'); + $result = $time->timeAgoInWords([ + 'absoluteString' => 'exactly on %s', + 'accuracy' => ['year' => 'year'], + 'end' => '+2 months', + ]); + $expected = 'exactly on ' . $time->format('n/j/y'); + $this->assertSame($expected, $result); + } + + /** + * Test the accuracy option for timeAgoInWords() + */ + public function testTimeAgoInWordsAccuracy(): void + { + $time = new DateTime('+8 years +4 months +2 weeks +3 days'); + $result = $time->timeAgoInWords([ + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years', + ]); + $expected = '8 years'; + $this->assertSame($expected, $result); + + $time = new DateTime('+8 years +4 months +2 weeks +3 days'); + $result = $time->timeAgoInWords([ + 'accuracy' => ['year' => 'month'], + 'end' => '+10 years', + ]); + $expected = '8 years, 4 months'; + $this->assertSame($expected, $result); + + $time = new DateTime('+8 years +4 months +2 weeks +3 days'); + $result = $time->timeAgoInWords([ + 'accuracy' => ['year' => 'week'], + 'end' => '+10 years', + ]); + $expected = '8 years, 4 months, 2 weeks'; + $this->assertSame($expected, $result); + + $time = new DateTime('+8 years +4 months +2 weeks +3 days'); + $result = $time->timeAgoInWords([ + 'accuracy' => ['year' => 'day'], + 'end' => '+10 years', + ]); + $expected = '8 years, 4 months, 2 weeks, 3 days'; + $this->assertSame($expected, $result); + + $time = new DateTime('+1 years +5 weeks'); + $result = $time->timeAgoInWords([ + 'accuracy' => ['year' => 'year'], + 'end' => '+10 years', + ]); + $expected = '1 year'; + $this->assertSame($expected, $result); + + $time = new DateTime('+58 minutes'); + $result = $time->timeAgoInWords([ + 'accuracy' => 'hour', + ]); + $expected = 'in about an hour'; + $this->assertSame($expected, $result); + + $time = new DateTime('+23 hours'); + $result = $time->timeAgoInWords([ + 'accuracy' => 'day', + ]); + $expected = 'in about a day'; + $this->assertSame($expected, $result); + + $time = new DateTime('+20 days'); + $result = $time->timeAgoInWords(['accuracy' => 'month']); + $this->assertSame('in about a month', $result); + } + + /** + * Test the format option of timeAgoInWords() + */ + public function testTimeAgoInWordsWithFormat(): void + { + $time = new DateTime('2007-9-25'); + $result = $time->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertSame('on 2007-09-25', $result); + + $time = new DateTime('+2 weeks +2 days'); + $result = $time->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertMatchesRegularExpression('/^2 weeks, [1|2] day(s)?$/', $result); + + $time = new DateTime('+2 months +2 days'); + $result = $time->timeAgoInWords(['end' => '1 month', 'format' => 'yyyy-MM-dd']); + $this->assertSame('on ' . $time->format('Y-m-d'), $result); + } + + /** + * test timeAgoInWords() with negative values. + */ + public function testTimeAgoInWordsNegativeValues(): void + { + $time = new DateTime('-2 months -2 days'); + $result = $time->timeAgoInWords(['end' => '3 month']); + $this->assertSame('2 months, 2 days ago', $result); + + $time = new DateTime('-2 months -2 days'); + $result = $time->timeAgoInWords(['end' => '3 month']); + $this->assertSame('2 months, 2 days ago', $result); + + $time = new DateTime('-2 months -2 days'); + $result = $time->timeAgoInWords(['end' => '1 month', 'format' => 'yyyy-MM-dd']); + $this->assertSame('on ' . $time->format('Y-m-d'), $result); + + $time = new DateTime('-2 years -5 months -2 days'); + $result = $time->timeAgoInWords(['end' => '3 years']); + $this->assertSame('2 years, 5 months, 2 days ago', $result); + + $time = new DateTime('-2 weeks -2 days'); + $result = $time->timeAgoInWords(['format' => 'yyyy-MM-dd']); + $this->assertSame('2 weeks, 2 days ago', $result); + + $time = new DateTime('-3 years -12 months'); + $result = $time->timeAgoInWords(); + $expected = 'on ' . $time->format('n/j/y'); + $this->assertSame($expected, $result); + + $time = new DateTime('-1 month -1 week -6 days'); + $result = $time->timeAgoInWords( + ['end' => '1 year', 'accuracy' => ['month' => 'month']], + ); + $this->assertSame('1 month ago', $result); + + $time = new DateTime('-1 years -2 weeks -3 days'); + $result = $time->timeAgoInWords( + ['accuracy' => ['year' => 'year']], + ); + $expected = 'on ' . $time->format('n/j/y'); + $this->assertSame($expected, $result); + + $time = new DateTime('-13 months -5 days'); + $result = $time->timeAgoInWords(['end' => '2 years']); + $this->assertSame('1 year, 1 month, 5 days ago', $result); + + $time = new DateTime('-58 minutes'); + $result = $time->timeAgoInWords(['accuracy' => 'hour']); + $this->assertSame('about an hour ago', $result); + + $time = new DateTime('-23 hours'); + $result = $time->timeAgoInWords(['accuracy' => 'day']); + $this->assertSame('about a day ago', $result); + + $time = new DateTime('-20 days'); + $result = $time->timeAgoInWords(['accuracy' => 'month']); + $this->assertSame('about a month ago', $result); + } + + /** + * testNice method + */ + public function testNice(): void + { + $time = new DateTime('2014-04-20 20:00', 'UTC'); + $result = preg_replace('/[\pZ\pC]/u', ' ', $time->nice()); + $this->assertTimeFormat('Apr 20, 2014, 8:00 PM', $result); + + $result = $time->nice('America/New_York'); + $result = preg_replace('/[\pZ\pC]/u', ' ', $result); + $this->assertTimeFormat('Apr 20, 2014, 4:00 PM', $result); + $this->assertSame('UTC', $time->getTimezone()->getName()); + + $this->assertTimeFormat('20 avr. 2014 20:00', $time->nice(null, 'fr-FR')); + $this->assertTimeFormat('20 avr. 2014 16:00', $time->nice('America/New_York', 'fr-FR')); + + // Test with custom default locale + DateTime::setDefaultLocale('fr-FR'); + $this->assertTimeFormat('20 avr. 2014 20:00', $time->nice()); + } + + /** + * test formatting dates taking in account preferred i18n locale file + */ + public function testI18nFormat(): void + { + $time = new DateTime('Thu Jan 14 13:59:28 2010'); + + // Test the default format which should be SHORT + $result = preg_replace('/[\pZ\pC]/u', ' ', $time->i18nFormat()); + $this->assertTimeFormat('1/14/10, 1:59 PM', $result); + + // Test with a custom timezone + $result = $time->i18nFormat('HH:mm:ss', 'Australia/Sydney'); + $expected = '00:59:28'; + $this->assertTimeFormat($expected, $result); + + // Test using a time-specific format + $format = [IntlDateFormatter::NONE, IntlDateFormatter::SHORT]; + $result = preg_replace('/[\pZ\pC]/u', ' ', $time->i18nFormat($format)); + $this->assertTimeFormat('1:59 PM', $result); + + // Test using a specific format, timezone and locale + $result = $time->i18nFormat(IntlDateFormatter::FULL, null, 'es-ES'); + $expected = 'jueves, 14 de enero de 2010, 13:59:28 (GMT)'; + $this->assertTimeFormat($expected, $result); + + // Test with custom default locale + DateTime::setDefaultLocale('fr-FR'); + $result = $time->i18nFormat(IntlDateFormatter::FULL); + $expected = 'jeudi 14 janvier 2010 13:59:28 UTC'; + $this->assertTimeFormat($expected, $result); + + // Test with a non-gregorian calendar in locale + $result = $time->i18nFormat(IntlDateFormatter::FULL, 'Asia/Tokyo', 'ja-JP@calendar=japanese'); + $expected = '平成22年1月14日木曜日 22時59分28秒 日本標準時'; + $this->assertTimeFormat($expected, $result); + + // Test with milliseconds + $timeMillis = new DateTime('2014-07-06T13:09:01.523000+00:00'); + $result = $timeMillis->i18nFormat("yyyy-MM-dd'T'HH':'mm':'ss.SSSxxx", null, 'en-US'); + $expected = '2014-07-06T13:09:01.523+00:00'; + $this->assertSame($expected, $result); + } + + /** + * testI18nFormatUsingSystemLocale + */ + public function testI18nFormatUsingSystemLocale(): void + { + $time = new DateTime(1556864870); + I18n::setLocale('ar-u-nu-arab'); + $this->assertSame('٢٠١٩-٠٥-٠٣', $time->i18nFormat('yyyy-MM-dd')); + + I18n::setLocale('en'); + $this->assertSame('2019-05-03', $time->i18nFormat('yyyy-MM-dd')); + } + + /** + * test formatting dates with offset style timezone + * + * @see https://github.com/facebook/hhvm/issues/3637 + */ + public function testI18nFormatWithOffsetTimezone(): void + { + $time = new DateTime('2014-01-01T00:00:00+00'); + $result = $time->i18nFormat(IntlDateFormatter::FULL); + $result = preg_replace('/[\pZ\pC]/u', ' ', $result); + $expected = 'Wednesday January 1 2014 12:00:00 AM GMT'; + $this->assertTimeFormat($expected, $result); + + $time = new DateTime('2014-01-01T00:00:00+09'); + $result = $time->i18nFormat(IntlDateFormatter::FULL); + $result = preg_replace('/[\pZ\pC]/u', ' ', $result); + $expected = 'Wednesday January 1 2014 12:00:00 AM GMT+09:00'; + $this->assertTimeFormat($expected, $result); + + $time = new DateTime('2014-01-01T00:00:00-01:30'); + $result = $time->i18nFormat(IntlDateFormatter::FULL); + $result = preg_replace('/[\pZ\pC]/u', ' ', $result); + $expected = 'Wednesday January 1 2014 12:00:00 AM GMT-01:30'; + $this->assertTimeFormat($expected, $result); + + $time = new DateTime('2014-01-01T00:00Z'); + $result = $time->i18nFormat(IntlDateFormatter::FULL); + $result = preg_replace('/[\pZ\pC]/u', ' ', $result); + $expected = 'Wednesday January 1 2014 12:00:00 AM GMT'; + $this->assertTimeFormat($expected, $result); + } + + /** + * testListTimezones + */ + public function testListTimezones(): void + { + $return = DateTime::listTimezones(); + $this->assertTrue(isset($return['Asia']['Asia/Bangkok'])); + $this->assertSame('Bangkok', $return['Asia']['Asia/Bangkok']); + $this->assertTrue(isset($return['America']['America/Argentina/Buenos_Aires'])); + $this->assertSame('Argentina/Buenos_Aires', $return['America']['America/Argentina/Buenos_Aires']); + $this->assertTrue(isset($return['UTC']['UTC'])); + $this->assertArrayNotHasKey('Cuba', $return); + $this->assertArrayNotHasKey('US', $return); + + $return = DateTime::listTimezones('#^Asia/#'); + $this->assertTrue(isset($return['Asia']['Asia/Bangkok'])); + $this->assertArrayNotHasKey('Pacific', $return); + + $return = DateTime::listTimezones(null, null, ['abbr' => true]); + $this->assertTrue(isset($return['Asia']['Asia/Jakarta'])); + $this->assertSame('Jakarta - WIB', $return['Asia']['Asia/Jakarta']); + $this->assertSame('Regina - CST', $return['America']['America/Regina']); + + $return = DateTime::listTimezones(null, null, [ + 'abbr' => true, + 'before' => ' (', + 'after' => ')', + ]); + $this->assertSame('Jayapura (WIT)', $return['Asia']['Asia/Jayapura']); + $this->assertSame('Regina (CST)', $return['America']['America/Regina']); + + $return = DateTime::listTimezones('#^(America|Pacific)/#', null, false); + $this->assertArrayHasKey('America/Argentina/Buenos_Aires', $return); + $this->assertArrayHasKey('Pacific/Tahiti', $return); + + $return = DateTime::listTimezones(DateTimeZone::ASIA); + $this->assertTrue(isset($return['Asia']['Asia/Bangkok'])); + $this->assertArrayNotHasKey('Pacific', $return); + + $return = DateTime::listTimezones(DateTimeZone::PER_COUNTRY, 'US', false); + $this->assertArrayHasKey('Pacific/Honolulu', $return); + $this->assertArrayNotHasKey('Asia/Bangkok', $return); + } + + /** + * Tests that __toString uses the i18n formatter + */ + public function testToString(): void + { + $time = new DateTime('2014-04-20 22:10'); + DateTime::setDefaultLocale('fr-FR'); + DateTime::setToStringFormat(IntlDateFormatter::FULL); + $expected = 'dimanche 20 avril 2014 22:10:00 UTC'; + $this->assertTimeFormat($expected, (string)$time); + } + + /** + * Data provider for invalid values. + * + * @return array + */ + public static function invalidDataProvider(): array + { + return [ + [null], + [''], + ]; + } + + /** + * Test that invalid datetime values do not trigger errors. + * + * @param mixed $value + */ + #[DataProvider('invalidDataProvider')] + public function testToStringInvalid($value): void + { + $time = new DateTime($value); + $this->assertIsString((string)$time); + $this->assertNotEmpty((string)$time); + } + + /** + * Test that invalid datetime values do not trigger errors. + * + * @param mixed $value + */ + #[DataProvider('invalidDataProvider')] + public function testToStringInvalidFrozen($value): void + { + $time = new DateTime($value); + $this->assertIsString((string)$time); + $this->assertNotEmpty((string)$time); + } + + /** + * These invalid values are not invalid on windows :( + */ + public function testToStringInvalidZeros(): void + { + $this->skipIf(DS === '\\', 'All zeros are valid on windows.'); + $this->skipIf(PHP_INT_SIZE === 4, 'IntlDateFormatter throws exceptions on 32-bit systems'); + $time = new DateTime('0000-00-00'); + $this->assertIsString((string)$time); + $this->assertNotEmpty((string)$time); + + $time = new DateTime('0000-00-00 00:00:00'); + $this->assertIsString((string)$time); + $this->assertNotEmpty((string)$time); + } + + /** + * Tests diffForHumans + */ + public function testDiffForHumans(): void + { + $time = new DateTime('2014-04-20 10:10:10'); + + $other = new DateTime('2014-04-27 10:10:10'); + $this->assertSame('1 week before', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-21 09:10:10'); + $this->assertSame('23 hours before', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-13 09:10:10'); + $this->assertSame('1 week after', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-06 09:10:10'); + $this->assertSame('2 weeks after', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-21 10:10:10'); + $this->assertSame('1 day before', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-22 10:10:10'); + $this->assertSame('2 days before', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-20 10:11:10'); + $this->assertSame('1 minute before', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-20 10:12:10'); + $this->assertSame('2 minutes before', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-20 10:10:09'); + $this->assertSame('1 second after', $time->diffForHumans($other)); + + $other = new DateTime('2014-04-20 10:10:08'); + $this->assertSame('2 seconds after', $time->diffForHumans($other)); + } + + /** + * Tests diffForHumans absolute + */ + public function testDiffForHumansAbsolute(): void + { + DateTime::setTestNow(new DateTime('2015-12-12 10:10:10')); + $time = new DateTime('2014-04-20 10:10:10'); + $this->assertSame('1 year', $time->diffForHumans(null, true)); + + $other = new DateTime('2014-04-27 10:10:10'); + $this->assertSame('1 week', $time->diffForHumans($other, true)); + + $time = new DateTime('2016-04-20 10:10:10'); + $this->assertSame('4 months', $time->diffForHumans(null, true)); + } + + /** + * Tests diffForHumans with now + */ + public function testDiffForHumansNow(): void + { + DateTime::setTestNow(new DateTime('2015-12-12 10:10:10')); + $time = new DateTime('2014-04-20 10:10:10'); + $this->assertSame('1 year ago', $time->diffForHumans()); + + $time = new DateTime('2016-04-20 10:10:10'); + $this->assertSame('4 months from now', $time->diffForHumans()); + } + + /** + * Tests encoding a Time object as JSON + */ + public function testJsonEncode(): void + { + if (version_compare(INTL_ICU_VERSION, '50.0', '<')) { + $this->markTestSkipped('ICU 5x is needed'); + } + + $time = new DateTime('2014-04-20 10:10:10'); + $this->assertSame('"2014-04-20T10:10:10+00:00"', json_encode($time)); + + DateTime::setJsonEncodeFormat('yyyy-MM-dd HH:mm:ss'); + $this->assertSame('"2014-04-20 10:10:10"', json_encode($time)); + + DateTime::setJsonEncodeFormat(DateTime::UNIX_TIMESTAMP_FORMAT); + $this->assertSame('1397988610', json_encode($time)); + } + + /** + * Test jsonSerialize no side-effects + */ + public function testJsonEncodeSideEffectFree(): void + { + if (version_compare(INTL_ICU_VERSION, '50.0', '<')) { + $this->markTestSkipped('ICU 5x is needed'); + } + $date = new DateTime('2016-11-29 09:00:00'); + $this->assertInstanceOf('DateTimeZone', $date->timezone); + + $result = json_encode($date); + $this->assertSame('"2016-11-29T09:00:00+00:00"', $result); + $this->assertInstanceOf('DateTimeZone', $date->getTimezone()); + } + + /** + * Tests change JSON encoding format + */ + public function testSetJsonEncodeFormat(): void + { + $time = new DateTime('2014-04-20 10:10:10'); + + DateTime::setJsonEncodeFormat(static function ($t) { + return $t->format(DATE_ATOM); + }); + $this->assertSame('"2014-04-20T10:10:10+00:00"', json_encode($time)); + + DateTime::setJsonEncodeFormat("yyyy-MM-dd'T'HH':'mm':'ssZZZZZ"); + $this->assertSame('"2014-04-20T10:10:10Z"', json_encode($time)); + } + + /** + * Tests parsing a string into a Time object based on the locale format. + */ + public function testParseDateTime(): void + { + $time = DateTime::parseDateTime('01/01/1970 00:00am'); + $this->assertNotNull($time); + $this->assertSame('1970-01-01 00:00', $time->format('Y-m-d H:i')); + $this->assertSame(date_default_timezone_get(), $time->tzName); + + $time = DateTime::parseDateTime('10/13/2013 12:54am'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13 00:54', $time->format('Y-m-d H:i')); + $this->assertSame(date_default_timezone_get(), $time->tzName); + + // Default format does not include time zone in time string + // Time zone is ignored and is interpreted as default time zone + $time = DateTime::parseDateTime('10/13/2013 12:54am GMT+08:00'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13 00:54', $time->format('Y-m-d H:i')); + $this->assertSame(date_default_timezone_get(), $time->tzName); + + // Unlike DateTime constructor, the instance is not created with the time zone + // in time string but converted to default time zone. + $time = DateTime::parseDateTime('10/13/2013 12:54:00am GMT+08:00', [IntlDateFormatter::SHORT, IntlDateFormatter::FULL]); + $this->assertNotNull($time); + $this->assertSame('2013-10-12 16:54', $time->format('Y-m-d H:i')); + $this->assertSame(date_default_timezone_get(), $time->tzName); + + $time = DateTime::parseDateTime('10/13/2013 12:54am', null, 'Europe/London'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13 00:54', $time->format('Y-m-d H:i')); + $this->assertSame('Europe/London', $time->tzName); + + DateTime::setDefaultLocale('fr-FR'); + $time = DateTime::parseDateTime('13 10, 2013 12:54'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13 12:54', $time->format('Y-m-d H:i')); + + $time = DateTime::parseDateTime('13 foo 10 2013 12:54'); + $this->assertNull($time); + } + + /** + * Tests parsing a string into a Time object based on the locale format. + */ + public function testParseDate(): void + { + $time = DateTime::parseDate('10/13/2013 12:54am'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13 00:00', $time->format('Y-m-d H:i')); + + $time = DateTime::parseDate('10/13/2013'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13 00:00', $time->format('Y-m-d H:i')); + + DateTime::setDefaultLocale('fr-FR'); + $time = DateTime::parseDate('13 10, 2013 12:54'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13 00:00', $time->format('Y-m-d H:i')); + + $time = DateTime::parseDate('13 foo 10 2013 12:54'); + $this->assertNull($time); + + $time = DateTime::parseDate('13 10, 2013', 'dd M, y'); + $this->assertNotNull($time); + $this->assertSame('2013-10-13', $time->format('Y-m-d')); + } + + /** + * Tests parsing times using the parseTime function + */ + public function testParseTime(): void + { + $time = DateTime::parseTime('12:54am'); + $this->assertNotNull($time); + $this->assertSame('00:54:00', $time->format('H:i:s')); + + DateTime::setDefaultLocale('fr-FR'); + $time = DateTime::parseTime('23:54'); + $this->assertNotNull($time); + $this->assertSame('23:54:00', $time->format('H:i:s')); + + $time = DateTime::parseTime('31c2:54'); + $this->assertNull($time); + } + + /** + * Tests disabling leniency when parsing locale format. + */ + public function testLenientParseDate(): void + { + DateTime::setDefaultLocale('pt_BR'); + + DateTime::disableLenientParsing(); + $time = DateTime::parseDate('04/21/2013'); + $this->assertSame(null, $time); + + DateTime::enableLenientParsing(); + $time = DateTime::parseDate('04/21/2013'); + $this->assertSame('2014-09-04', $time->format('Y-m-d')); + } + + /** + * Tests that timeAgoInWords when using a russian locale does not break things + */ + public function testRussianTimeAgoInWords(): void + { + I18n::setLocale('ru_RU'); + $time = new DateTime('5 days ago'); + $result = $time->timeAgoInWords(); + $this->assertSame('5 days ago', $result); + } + + /** + * Tests that parsing a date respects de default timezone in PHP. + */ + public function testParseDateDifferentTimezone(): void + { + date_default_timezone_set('Europe/Paris'); + DateTime::setDefaultLocale('fr-FR'); + $result = DateTime::parseDate('12/03/2015'); + $this->assertSame('2015-03-12', $result->format('Y-m-d')); + $this->assertEquals(new DateTimeZone('Europe/Paris'), $result->tz); + } + + /** + * Tests the default locale setter. + */ + public function testGetSetDefaultLocale(): void + { + DateTime::setDefaultLocale('fr-FR'); + $this->assertSame('fr-FR', DateTime::getDefaultLocale()); + } + + /** + * Test toQuarter method + */ + public function testToQuarter(): void + { + $date = new DateTime('2007-12-25'); + $this->assertSame(4, $date->toQuarter()); + + $date = new DateTime('2007-01-01'); + $this->assertSame(1, $date->toQuarter()); + + $date = new DateTime('2007-05-15'); + $this->assertSame(2, $date->toQuarter()); + + $date = new DateTime('2007-08-20'); + $this->assertSame(3, $date->toQuarter()); + } + + /** + * Test toQuarter with deprecated range parameter + */ + public function testToQuarterWithRange(): void + { + $this->deprecated(function (): void { + $date = new DateTime('2007-12-25'); + $result = $date->toQuarter(true); + $this->assertEquals(['2007-10-01', '2007-12-31'], $result); + }); + } + + /** + * Test toQuarterRange method + */ + public function testToQuarterRange(): void + { + $date = new DateTime('2007-12-25'); + $this->assertEquals(['2007-10-01', '2007-12-31'], $date->toQuarterRange()); + + $date = new DateTime('2007-01-01'); + $this->assertEquals(['2007-01-01', '2007-03-31'], $date->toQuarterRange()); + + $date = new DateTime('2007-05-15'); + $this->assertEquals(['2007-04-01', '2007-06-30'], $date->toQuarterRange()); + + $date = new DateTime('2007-08-20'); + $this->assertEquals(['2007-07-01', '2007-09-30'], $date->toQuarterRange()); + } + + /** + * Custom assert to allow for variation in the version of the intl library, where + * some translations contain a few extra commas. + */ + public function assertTimeFormat(string $expected, string $result, string $message = ''): void + { + $expected = str_replace([',', '(', ')', ' at', ' م.', ' ه‍.ش.', ' AP', ' AH', ' SAKA', 'à '], '', $expected); + $expected = str_replace([' '], ' ', $expected); + + $result = str_replace('temps universel coordonné', 'UTC', $result); + $result = str_replace('Temps universel coordonné', 'UTC', $result); + $result = str_replace('tiempo universal coordinado', 'GMT', $result); + $result = str_replace('Coordinated Universal Time', 'GMT', $result); + + $result = str_replace([',', '(', ')', ' at', ' م.', ' ه‍.ش.', ' AP', ' AH', ' SAKA', 'à '], '', $result); + $result = str_replace([' '], ' ', $result); + + $this->assertSame($expected, $result, $message); + } +} diff --git a/tests/TestCase/I18n/Formatter/IcuFormatterTest.php b/tests/TestCase/I18n/Formatter/IcuFormatterTest.php new file mode 100644 index 00000000000..eeb7e924b38 --- /dev/null +++ b/tests/TestCase/I18n/Formatter/IcuFormatterTest.php @@ -0,0 +1,91 @@ +assertSame('', $formatter->format('en_US', '', [])); + } + + /** + * Tests that variables are interpolated correctly + */ + public function testFormatSimple(): void + { + $formatter = new IcuFormatter(); + $this->assertSame('Hello José', $formatter->format('en_US', 'Hello {0}', ['José'])); + $result = $formatter->format( + '1 Orange', + '{0, number} {1}', + [1.0, 'Orange'], + ); + $this->assertSame('1 Orange', $result); + } + + /** + * Tests that plurals can instead be selected using ICU's native selector + */ + public function testNativePluralSelection(): void + { + $formatter = new IcuFormatter(); + $locale = 'en_US'; + $string = '{0,plural,' . + '=0{No fruits.}' . + '=1{We have one fruit}' . + 'other{We have {1} fruits}' . + '}'; + + $params = [0, 0]; + $expect = 'No fruits.'; + $actual = $formatter->format($locale, $string, $params); + $this->assertSame($expect, $actual); + + $params = [1, 0]; + $expect = 'We have one fruit'; + $actual = $formatter->format($locale, $string, $params); + $this->assertSame($expect, $actual); + + $params = [10, 10]; + $expect = 'We have 10 fruits'; + $actual = $formatter->format($locale, $string, $params); + $this->assertSame($expect, $actual); + } + + /** + * Tests that passing a message in the wrong format will throw an exception + */ + public function testBadMessageFormat(): void + { + $this->expectException(Exception::class); + + $formatter = new IcuFormatter(); + $formatter->format('en_US', '{crazy format', ['some', 'vars']); + } +} diff --git a/tests/TestCase/I18n/Formatter/SprintfFormatterTest.php b/tests/TestCase/I18n/Formatter/SprintfFormatterTest.php new file mode 100644 index 00000000000..0c6147e49ac --- /dev/null +++ b/tests/TestCase/I18n/Formatter/SprintfFormatterTest.php @@ -0,0 +1,36 @@ +assertSame('Hello José', $formatter->format('en_US', 'Hello %s', ['José'])); + $this->assertSame('1 Orange', $formatter->format('en_US', '%d %s', [1, 'Orange'])); + } +} diff --git a/tests/TestCase/I18n/FunctionsTest.php b/tests/TestCase/I18n/FunctionsTest.php new file mode 100644 index 00000000000..fa9485efdf8 --- /dev/null +++ b/tests/TestCase/I18n/FunctionsTest.php @@ -0,0 +1,184 @@ +assertNull(toDateTime($rawValue, $format)); + + return; + } + $result = toDateTime($rawValue, $format); + $this->assertNotNull($result); + $this->assertEquals($expected->format($format), $result->format($format)); + } + + /** + * @return array The array of test cases. + */ + public static function toDateTimeProvider(): array + { + $date = new DateTime('2024-07-01T14:30:00Z'); + $now = $date->format(DateTimeInterface::ATOM); + $timestamp = $date->getTimestamp(); + + return [ + // DateTime input types + '(datetime) DateTime object' => [new DateTime($now), DateTimeInterface::ATOM, $date], + '(datetime) DateTimeImmutable object' => [new DateTimeImmutable($now), DateTimeInterface::ATOM, DateTime::createFromFormat(DateTimeInterface::ATOM, $now)], + + // Date input types + '(date) Date object' => [new Date($now), DateTimeInterface::ATOM, $date->setTime(0, 0, 0)], + + // string input types + '(string) valid datetime string' => [$now, DateTimeInterface::ATOM, $date], + '(string) valid datetime string with custom format' => ['01-07-2024 14:30:00', 'd-m-Y H:i:s', DateTime::createFromFormat('d-m-Y H:i:s', '01-07-2024 14:30:00')], + '(string) empty string' => ['', DateTimeInterface::ATOM, null], + '(string) space' => [' ', DateTimeInterface::ATOM, null], + '(string) non-date string' => ['abc', DateTimeInterface::ATOM, null], + '(string) double 0' => ['00', DateTimeInterface::ATOM, DateTime::createFromFormat('U', '0')], + '(string) single 0' => ['0', DateTimeInterface::ATOM, DateTime::createFromFormat('U', '0')], + '(string) false' => ['false', DateTimeInterface::ATOM, null], + '(string) true' => ['true', DateTimeInterface::ATOM, null], + '(string) partially valid date' => ['2024-07-01T14:30:00', 'Y-m-d\TH:i:s', DateTime::createFromFormat('Y-m-d\TH:i:s', '2024-07-01T14:30:00')], + + // int input types + '(int) valid timestamp' => [$timestamp, DateTimeInterface::ATOM, $date], + '(int) negative timestamp' => [-1000, DateTimeInterface::ATOM, DateTime::createFromFormat('U', '-1000')], + '(int) large timestamp' => [2147483647, DateTimeInterface::ATOM, DateTime::createFromFormat('U', '2147483647')], + '(int) zero' => [0, DateTimeInterface::ATOM, DateTime::createFromFormat('U', '0')], + + // float input types + '(float) positive' => [5.5, DateTimeInterface::ATOM, DateTime::createFromFormat('U', '5')->microsecond(500000)], + '(float) round' => [5.0, DateTimeInterface::ATOM, DateTime::createFromFormat('U', '5')], + '(float) NaN' => [NAN, DateTimeInterface::ATOM, null], + '(float) INF' => [INF, DateTimeInterface::ATOM, null], + '(float) -INF' => [-INF, DateTimeInterface::ATOM, null], + '(float) timestamp' => [$timestamp + 0.0, DateTimeInterface::ATOM, $date], + + // other input types + '(other) null' => [null, DateTimeInterface::ATOM, null], + '(other) empty array' => [[], DateTimeInterface::ATOM, null], + '(other) int array' => [[5], DateTimeInterface::ATOM, null], + '(other) string array' => [['5'], DateTimeInterface::ATOM, null], + '(other) simple object' => [new stdClass(), DateTimeInterface::ATOM, null], + + // mixed valid cases + '(mixed) DateTimeImmutable string input' => ['2024-07-01T14:30:00Z', DateTimeInterface::ATOM, DateTime::createFromFormat(DateTimeInterface::ATOM, '2024-07-01T14:30:00Z')], + '(mixed) integer string input' => ['1719844200', DateTimeInterface::ATOM, DateTime::createFromFormat('U', '1719844200')], + + // Custom format cases + '(custom format) valid date' => ['01-07-2024', 'd-m-Y', DateTime::createFromFormat('d-m-Y', '01-07-2024')], + '(custom format) valid datetime' => ['01-07-2024 14:30:00', 'd-m-Y H:i:s', DateTime::createFromFormat('d-m-Y H:i:s', '01-07-2024 14:30:00')], + '(custom format) invalid date' => ['31-02-2024', 'd-m-Y', DateTime::createFromFormat('d-m-Y', '02-03-2024')], + '(custom format) partially valid datetime' => ['01-07-2024 14:30', 'd-m-Y H:i', DateTime::createFromFormat('d-m-Y H:i', '01-07-2024 14:30')], + '(custom format) valid month/year' => ['07-2024', 'm-Y', DateTime::createFromFormat('m-Y', '07-2024')], + ]; + } + + #[DataProvider('toDateProvider')] + public function testToDate(mixed $rawValue, string $format, ?Date $expected): void + { + if ($expected === null) { + $this->assertNull(toDate($rawValue, $format)); + + return; + } + $result = toDate($rawValue, $format); + $this->assertNotNull($result); + $this->assertEquals($expected->format($format), $result->format($format)); + } + + /** + * @return array The array of test cases. + */ + public static function toDateProvider(): array + { + $date = Date::parse('2024-07-01'); + $dateTime = new DateTime('2024-07-01T00:00:00Z'); + $timestamp = $dateTime->getTimestamp(); + + return [ + // Date input types + '(date) Date object' => [Date::create(2024, 7, 1), 'Y-m-d', $date], + + // DateTime input types + '(datetime) DateTime object' => [new DateTime('2024-07-01'), 'Y-m-d', Date::create(2024, 7, 1)], + '(datetime) DateTimeImmutable object' => [new DateTimeImmutable('2024-07-01'), 'Y-m-d', Date::create(2024, 7, 1)], + + // string input types + '(string) valid date string' => ['2024-07-01', 'Y-m-d', $date], + '(string) valid date string with custom format' => ['01-07-2024', 'd-m-Y', Date::create(2024, 7, 1)], + '(string) empty string' => ['', 'Y-m-d', null], + '(string) space' => [' ', 'Y-m-d', null], + '(string) non-date string' => ['abc', 'Y-m-d', null], + '(string) false' => ['false', 'Y-m-d', null], + '(string) true' => ['true', 'Y-m-d', null], + '(string) partially valid date' => ['2024-07-01', 'Y-m-d', Date::create(2024, 7, 1)], + '(string) date with time' => ['2024-07-01T14:30:00', "Y-m-d'T'H:m:s", null], + + // int input types + '(int) valid timestamp' => [$timestamp, 'Y-m-d', Date::create(2024, 7, 1)], + '(int) negative timestamp' => [-1000, 'Y-m-d', Date::create(1969, 12, 31)], + '(int) large timestamp' => [2147483647, 'Y-m-d', Date::create(2038, 1, 19)], + '(int) zero' => [0, 'Y-m-d', Date::create(1970, 1, 1)], + + // float input types + '(float) positive' => [5.5, 'Y-m-d', Date::create(1970, 1, 1)], + '(float) round' => [5.0, 'Y-m-d', Date::create(1970, 1, 1)], + '(float) NaN' => [NAN, 'Y-m-d', null], + '(float) INF' => [INF, 'Y-m-d', null], + '(float) -INF' => [-INF, 'Y-m-d', null], + '(float) timestamp' => [$timestamp + 0.0, 'Y-m-d', Date::create(2024, 7, 1)], + + // other input types + '(other) null' => [null, 'Y-m-d', null], + '(other) empty array' => [[], 'Y-m-d', null], + '(other) int array' => [[5], 'Y-m-d', null], + '(other) string array' => [['5'], 'Y-m-d', null], + '(other) simple object' => [new stdClass(), 'Y-m-d', null], + + // mixed valid cases + '(mixed) DateTime string input' => ['2024-07-01T00:00:00Z', "Y-m-d'T'H:m:s'Z'", null], + '(mixed) integer string input' => ['1719844200', 'U', Date::create(2024, 7, 1)], + + // custom format cases + '(custom format) valid date' => ['01-07-2024', 'd-m-Y', Date::create(2024, 7, 1)], + '(custom format) valid datetime' => ['01-07-2024 14:30:00', 'd-m-Y H:i:s', Date::create(2024, 7, 1)], + '(custom format) valid month/year' => ['07-2024', 'm-Y', Date::create(2024, 7, 1)], + '(custom format) invalid date' => ['31-02-2024', 'd-m-Y', Date::create(2024, 3, 2)], + '(custom format) invalid datetime' => ['01-07-2024 14:30', 'd-m-Y H:i', Date::create(2024, 7, 1)], + ]; + } +} diff --git a/tests/TestCase/I18n/I18nTest.php b/tests/TestCase/I18n/I18nTest.php new file mode 100644 index 00000000000..1a04949632e --- /dev/null +++ b/tests/TestCase/I18n/I18nTest.php @@ -0,0 +1,917 @@ +clearPlugins(); + Cache::clear('_cake_translations_'); + } + + /** + * Tests that the default locale is set correctly + */ + public function testDefaultLocale(): void + { + $default = I18n::getDefaultLocale(); + $newLocale = 'de_DE'; + I18n::setLocale($newLocale); + $this->assertSame($newLocale, I18n::getLocale()); + $this->assertSame($default, I18n::getDefaultLocale()); + } + + /** + * Tests that a default translator is created and messages are parsed + * correctly + */ + public function testGetDefaultTranslator(): void + { + $translator = I18n::getTranslator(); + $this->assertInstanceOf(Translator::class, $translator); + $this->assertSame('%d is 1 (po translated)', $translator->translate('%d = 1')); + $this->assertSame($translator, I18n::getTranslator(), 'backwards compat works'); + } + + /** + * Tests that the translator can automatically load messages from a .mo file + */ + public function testGetTranslatorLoadMoFile(): void + { + $translator = I18n::getTranslator('default', 'es_ES'); + $this->assertSame('Plural Rule 6 (translated)', $translator->translate('Plural Rule 1')); + } + + /** + * Tests that plural rules are correctly used for the English language + * using the sprintf formatter + */ + public function testPluralSelectionSprintfFormatter(): void + { + I18n::setDefaultFormatter('sprintf'); + $translator = I18n::getTranslator(); // en_US + $result = $translator->translate('%d = 0 or > 1', ['_count' => 1, 1]); + $this->assertSame('1 is 1 (po translated)', $result); + + $result = $translator->translate('%d = 0 or > 1', ['_count' => 2, 2]); + $this->assertSame('2 is 2-4 (po translated)', $result); + } + + /** + * Tests that plural rules are correctly used for the English language + * using the basic formatter + */ + public function testPluralSelectionBasicFormatter(): void + { + $translator = I18n::getTranslator('special'); + $result = $translator->translate('There are {0} things', ['_count' => 2, 'plenty']); + $this->assertSame('There are plenty things', $result); + + $result = $translator->translate('There are {0} things', ['_count' => 1]); + $this->assertSame('There is only one', $result); + } + + /** + * Test plural rules are used for non-english languages + */ + public function testPluralSelectionRussian(): void + { + $translator = I18n::getTranslator('default', 'ru'); + $result = $translator->translate('{0} months', ['_count' => 1, 1]); + $this->assertSame('1 months ends in 1, not 11', $result); + + $result = $translator->translate('{0} months', ['_count' => 2, 2]); + $this->assertSame('2 months ends in 2-4, not 12-14', $result); + + $result = $translator->translate('{0} months', ['_count' => 7, 7]); + $this->assertSame('7 months everything else', $result); + } + + public function testPluralSelectionFrench(): void + { + $translator = I18n::getTranslator('default', 'fr'); + + $result = $translator->translate('{0} apples', ['_count' => 1, 1]); + $this->assertSame('1 pomme', $result); + + $result = $translator->translate('{0} apples', ['_count' => 2, 2]); + $this->assertSame('2 pommes', $result); + + $result = $translator->translate('{0} apples', ['_count' => 1000000, 1000000]); + $this->assertSame('1000000 de pommes', $result); + + $result = $translator->translate('{0} apples', ['_count' => 1000001, 1000001]); + $this->assertSame('1000001 pommes', $result); + + $result = $translator->translate('{0} bananas', ['_count' => 1, 1]); + $this->assertSame('1 banana fr', $result); + + $result = $translator->translate('{0} bananas', ['_count' => 2, 2]); + $this->assertSame('2 bananas fr', $result); + + $result = $translator->translate('{0} bananas', ['_count' => 1000000, 1000000]); + $this->assertSame('1000000 bananas fr', $result); + + $result = $translator->translate('{0} bananas', ['_count' => 1000001, 1000001]); + $this->assertSame('1000001 bananas fr', $result); + } + + /** + * Tests that custom translation packages can be created on the fly and used later on + */ + public function testCreateCustomTranslationPackage(): void + { + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'Cow' => 'Le moo', + ]); + + return $package; + }, 'fr_FR'); + + $translator = I18n::getTranslator('custom', 'fr_FR'); + $this->assertSame('Le moo', $translator->translate('Cow')); + } + + /** + * Tests that custom translation loaders can be created on the fly and used later on + * + * @deprecated + */ + public function testCreateCustomTranslationInvokable(): void + { + $loader = new class { + public function __invoke(): Package + { + $package = new Package('default'); + $package->setMessages([ + 'Cow' => 'Le moo', + ]); + + return $package; + } + }; + + I18n::config('custom', function ($domain, $locale) use ($loader) { + return new $loader( + $domain, + $locale, + ); + }); + + $this->deprecated(function (): void { + $translator = I18n::getTranslator('custom', 'fr_FR'); + $this->assertSame('Le moo', $translator->translate('Cow')); + }); + } + + /** + * Tests that messages can also be loaded from plugins by using the + * domain = plugin_name convention + */ + public function testPluginMessagesLoad(): void + { + $this->loadPlugins([ + 'TestPlugin' => [], + 'Company/TestPluginThree' => [], + ]); + + $translator = I18n::getTranslator('test_plugin'); + $this->assertSame( + 'Plural Rule 1 (from plugin)', + $translator->translate('Plural Rule 1'), + ); + + $translator = I18n::getTranslator('company/test_plugin_three'); + $this->assertSame( + 'String 1 (from plugin three)', + $translator->translate('String 1'), + ); + + $translator = I18n::getTranslator('company/test_plugin_three.custom'); + $this->assertSame( + 'String 2 (from plugin three)', + $translator->translate('String 2'), + ); + } + + /** + * Tests that messages from a plugin can be automatically + * overridden by messages in app + */ + public function testPluginOverride(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->loadPlugins([ + 'TestTheme' => [], + 'TestPluginTwo' => [], + ]); + }); + + $translator = I18n::getTranslator('test_theme'); + $this->assertSame( + 'translated', + $translator->translate('A Message'), + ); + + $translator = I18n::getTranslator('test_plugin_two'); + $this->assertSame( + 'Test Message (from app)', + $translator->translate('Test Message'), + ); + + $translator = I18n::getTranslator('test_plugin_two.custom'); + $this->assertSame( + 'Test Custom (from test plugin two)', + $translator->translate('Test Custom'), + ); + } + + /** + * Tests the locale method + */ + public function testGetDefaultLocale(): void + { + $this->assertSame('en_US', I18n::getLocale()); + $this->assertSame('en_US', ini_get('intl.default_locale')); + I18n::setLocale('fr_FR'); + $this->assertSame('fr_FR', I18n::getLocale()); + $this->assertSame('fr_FR', ini_get('intl.default_locale')); + } + + /** + * Tests that changing the default locale also changes the way translators + * are fetched + */ + public function testGetTranslatorByDefaultLocale(): void + { + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'Cow' => 'Le moo', + ]); + + return $package; + }, 'fr_FR'); + + I18n::setLocale('fr_FR'); + $translator = I18n::getTranslator('custom'); + $this->assertSame('Le moo', $translator->translate('Cow')); + } + + /** + * Tests the __() function + */ + public function testBasicTranslateFunction(): void + { + I18n::setDefaultFormatter('sprintf'); + $this->assertSame('%d is 1 (po translated)', __('%d = 1')); + $this->assertSame('1 is 1 (po translated)', __('%d = 1', 1)); + $this->assertSame('1 is 1 (po translated)', __('%d = 1', [1])); + $this->assertSame('The red dog, and blue cat', __('The %s dog, and %s cat', ['red', 'blue'])); + $this->assertSame('The red dog, and blue cat', __('The %s dog, and %s cat', 'red', 'blue')); + } + + /** + * Tests the __() functions with explicit null params + */ + public function testBasicTranslateFunctionsWithNullParam(): void + { + $this->assertSame('text {0}', __('text {0}')); + $this->assertSame('text ', __('text {0}', null)); + + $this->assertSame('text {0}', __n('text {0}', 'texts {0}', 1)); + $this->assertSame('text ', __n('text {0}', 'texts {0}', 1, null)); + + $this->assertSame('text {0}', __d('default', 'text {0}')); + $this->assertSame('text ', __d('default', 'text {0}', null)); + + $this->assertSame('text {0}', __dn('default', 'text {0}', 'texts {0}', 1)); + $this->assertSame('text ', __dn('default', 'text {0}', 'texts {0}', 1, null)); + + $this->assertSame('text {0}', __x('default', 'text {0}')); + $this->assertSame('text ', __x('default', 'text {0}', null)); + + $this->assertSame('text {0}', __xn('default', 'text {0}', 'texts {0}', 1)); + $this->assertSame('text ', __xn('default', 'text {0}', 'texts {0}', 1, null)); + + $this->assertSame('text {0}', __dx('default', 'words', 'text {0}')); + $this->assertSame('text ', __dx('default', 'words', 'text {0}', null)); + + $this->assertSame('text {0}', __dxn('default', 'words', 'text {0}', 'texts {0}', 1)); + $this->assertSame('text ', __dxn('default', 'words', 'text {0}', 'texts {0}', 1, null)); + } + + /** + * Tests the __() function on a plural key works + */ + public function testBasicTranslateFunctionPluralData(): void + { + I18n::setDefaultFormatter('sprintf'); + $this->assertSame('%d is 1 (po translated)', __('%d = 0 or > 1')); + } + + /** + * Tests the __n() function + */ + public function testBasicTranslatePluralFunction(): void + { + I18n::setDefaultFormatter('sprintf'); + $result = __n('singular msg', '%d = 0 or > 1', 1, 1); + $this->assertSame('1 is 1 (po translated)', $result); + + $result = __n('singular msg', '%d = 0 or > 1', 2, 2); + $this->assertSame('2 is 2-4 (po translated)', $result); + + $result = __n('%s, %s, and %s are good', '%s, %s, and %s are best', 1, ['red', 'blue', 'green']); + $this->assertSame('red, blue, and green are good', $result); + + $result = __n('%s, %s, and %s are good', '%s, %s, and %s are best', 1, 'red', 'blue', 'green'); + $this->assertSame('red, blue, and green are good', $result); + } + + /** + * Tests the __n() function on singular keys + */ + public function testBasicTranslatePluralFunctionSingularMessage(): void + { + I18n::setDefaultFormatter('sprintf'); + $result = __n('No translation needed', 'not used', 1); + $this->assertSame('No translation needed', $result); + } + + /** + * Tests the __d() function + */ + public function testBasicDomainFunction(): void + { + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'Cow' => 'Le moo', + 'The {0} is tasty' => 'The {0} is delicious', + 'Average price {0}' => 'Price Average {0}', + 'Unknown' => '', + ]); + + return $package; + }, 'en_US'); + $this->assertSame('Le moo', __d('custom', 'Cow')); + $this->assertSame('Unknown', __d('custom', 'Unknown')); + + $result = __d('custom', 'The {0} is tasty', ['fruit']); + $this->assertSame('The fruit is delicious', $result); + + $result = __d('custom', 'The {0} is tasty', 'fruit'); + $this->assertSame('The fruit is delicious', $result); + + $result = __d('custom', 'Average price {0}', ['9.99']); + $this->assertSame('Price Average 9.99', $result); + + $this->loadPlugins([ + 'Company/TestPluginThree' => [], + ]); + + $result = __d('company/test_plugin_three', 'String 1'); + $this->assertSame('String 1 (from plugin three)', $result); + + $result = __d('company/test_plugin_three.custom', 'String 2'); + $this->assertSame('String 2 (from plugin three)', $result); + } + + /** + * Tests the __dn() function + */ + public function testBasicDomainPluralFunction(): void + { + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'Cow' => 'Le Moo', + 'Cows' => [ + 'Le Moo', + 'Les Moos', + ], + '{0} years' => [ + '', + '', + ], + ]); + + return $package; + }, 'en_US'); + $this->assertSame('Le Moo', __dn('custom', 'Cow', 'Cows', 1)); + $this->assertSame('Les Moos', __dn('custom', 'Cow', 'Cows', 2)); + $this->assertSame('{0} year', __dn('custom', '{0} year', '{0} years', 1)); + $this->assertSame('{0} years', __dn('custom', '{0} year', '{0} years', 2)); + } + + /** + * Tests the __x() function + */ + public function testBasicContextFunction(): void + { + I18n::setTranslator('default', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'letter' => [ + '_context' => [ + 'character' => 'The letter {0}', + 'communication' => 'She wrote a letter to {0}', + ], + ], + 'letters' => [ + '_context' => [ + 'character' => [ + 'The letter {0}', + 'The letters {0} and {1}', + ], + 'communication' => [ + 'She wrote a letter to {0}', + 'She wrote a letter to {0} and {1}', + ], + ], + ], + ]); + + return $package; + }, 'en_US'); + + $this->assertSame('The letters A and B', __x('character', 'letters', ['A', 'B'])); + $this->assertSame('The letter A', __x('character', 'letter', ['A'])); + + $this->assertSame('The letters A and B', __x('character', 'letters', 'A', 'B')); + $this->assertSame('The letter A', __x('character', 'letter', 'A')); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __x('communication', 'letters', ['Thomas', 'Sara']), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __x('communication', 'letter', ['Thomas']), + ); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __x('communication', 'letters', 'Thomas', 'Sara'), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __x('communication', 'letter', 'Thomas'), + ); + } + + /** + * Tests the __x() function with no msgstr + */ + public function testBasicContextFunctionNoString(): void + { + I18n::setTranslator('default', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'letter' => [ + '_context' => [ + 'character' => '', + ], + ], + ]); + + return $package; + }, 'en_US'); + + $this->assertSame('letter', __x('character', 'letter')); + $this->assertSame('letter', __x('unknown', 'letter')); + } + + /** + * Tests the __x() function with an invalid context + */ + public function testBasicContextFunctionInvalidContext(): void + { + I18n::setTranslator('default', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'letter' => [ + '_context' => [ + 'noun' => 'a paper letter', + ], + ], + ]); + + return $package; + }, 'en_US'); + + $this->assertSame('letter', __x('garbage', 'letter')); + $this->assertSame('a paper letter', __('letter')); + } + + /** + * Tests the __xn() function + */ + public function testPluralContextFunction(): void + { + I18n::setTranslator('default', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'letter' => [ + '_context' => [ + 'character' => 'The letter {0}', + 'communication' => 'She wrote a letter to {0}', + ], + ], + 'letters' => [ + '_context' => [ + 'character' => [ + 'The letter {0}', + 'The letters {0} and {1}', + ], + 'communication' => [ + 'She wrote a letter to {0}', + 'She wrote a letter to {0} and {1}', + ], + ], + ], + ]); + + return $package; + }, 'en_US'); + $this->assertSame('The letters A and B', __xn('character', 'letter', 'letters', 2, ['A', 'B'])); + $this->assertSame('The letter A', __xn('character', 'letter', 'letters', 1, ['A'])); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __xn('communication', 'letter', 'letters', 2, ['Thomas', 'Sara']), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __xn('communication', 'letter', 'letters', 1, ['Thomas']), + ); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __xn('communication', 'letter', 'letters', 2, 'Thomas', 'Sara'), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __xn('communication', 'letter', 'letters', 1, 'Thomas'), + ); + } + + /** + * Tests the __dx() function + */ + public function testDomainContextFunction(): void + { + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'letter' => [ + '_context' => [ + 'character' => 'The letter {0}', + 'communication' => 'She wrote a letter to {0}', + ], + ], + 'letters' => [ + '_context' => [ + 'character' => [ + 'The letter {0}', + 'The letters {0} and {1}', + ], + 'communication' => [ + 'She wrote a letter to {0}', + 'She wrote a letter to {0} and {1}', + ], + ], + ], + ]); + + return $package; + }, 'en_US'); + + $this->assertSame('The letters A and B', __dx('custom', 'character', 'letters', ['A', 'B'])); + $this->assertSame('The letter A', __dx('custom', 'character', 'letter', ['A'])); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __dx('custom', 'communication', 'letters', ['Thomas', 'Sara']), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __dx('custom', 'communication', 'letter', ['Thomas']), + ); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __dx('custom', 'communication', 'letters', 'Thomas', 'Sara'), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __dx('custom', 'communication', 'letter', 'Thomas'), + ); + } + + /** + * Tests the __dxn() function + */ + public function testDomainPluralContextFunction(): void + { + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'letter' => [ + '_context' => [ + 'character' => 'The letter {0}', + 'communication' => 'She wrote a letter to {0}', + ], + ], + 'letters' => [ + '_context' => [ + 'character' => [ + 'The letter {0}', + 'The letters {0} and {1}', + ], + 'communication' => [ + 'She wrote a letter to {0}', + 'She wrote a letter to {0} and {1}', + ], + ], + ], + ]); + + return $package; + }, 'en_US'); + $this->assertSame( + 'The letters A and B', + __dxn('custom', 'character', 'letter', 'letters', 2, ['A', 'B']), + ); + $this->assertSame( + 'The letter A', + __dxn('custom', 'character', 'letter', 'letters', 1, ['A']), + ); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __dxn('custom', 'communication', 'letter', 'letters', 2, ['Thomas', 'Sara']), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __dxn('custom', 'communication', 'letter', 'letters', 1, ['Thomas']), + ); + + $this->assertSame( + 'She wrote a letter to Thomas and Sara', + __dxn('custom', 'communication', 'letter', 'letters', 2, 'Thomas', 'Sara'), + ); + $this->assertSame( + 'She wrote a letter to Thomas', + __dxn('custom', 'communication', 'letter', 'letters', 1, 'Thomas'), + ); + } + + /** + * Tests that translators are cached for performance + */ + public function testTranslatorCache(): void + { + $english = I18n::getTranslator(); + $spanish = I18n::getTranslator('default', 'es_ES'); + + $cached = Cache::read('translations.default.en_US', '_cake_translations_'); + $this->assertEquals($english, $cached); + + $cached = Cache::read('translations.default.es_ES', '_cake_translations_'); + $this->assertEquals($spanish, $cached); + + $this->assertSame($english, I18n::getTranslator()); + $this->assertSame($spanish, I18n::getTranslator('default', 'es_ES')); + $this->assertSame($english, I18n::getTranslator()); + } + + /** + * Tests that it is possible to register a generic translators factory for a domain + * instead of having to create them manually + */ + public function testLoaderFactory(): void + { + I18n::config('custom', function (string $name, string $locale) { + $this->assertSame('custom', $name); + $package = new Package('default'); + + if ($locale === 'fr_FR') { + $package->setMessages([ + 'Cow' => 'Le Moo', + 'Cows' => [ + 'Le Moo', + 'Les Moos', + ], + ]); + } + + if ($locale === 'es_ES') { + $package->setMessages([ + 'Cow' => 'El Moo', + 'Cows' => [ + 'El Moo', + 'Los Moos', + ], + ]); + } + + return $package; + }); + + $translator = I18n::getTranslator('custom', 'fr_FR'); + $this->assertSame('Le Moo', $translator->translate('Cow')); + $this->assertSame('Les Moos', $translator->translate('Cows', ['_count' => 2])); + + $translator = I18n::getTranslator('custom', 'es_ES'); + $this->assertSame('El Moo', $translator->translate('Cow')); + $this->assertSame('Los Moos', $translator->translate('Cows', ['_count' => 2])); + + $translator = I18n::getTranslator(); + $this->assertSame('%d is 1 (po translated)', $translator->translate('%d = 1')); + } + + /** + * Tests that it is possible to register a fallback translators factory + */ + public function testFallbackLoaderFactory(): void + { + I18n::config(TranslatorRegistry::FALLBACK_LOADER, function (string $name, string $locale) { + $package = new Package('default'); + + if ($name === 'custom') { + $package->setMessages([ + 'Cow' => 'Le Moo custom', + ]); + } else { + $package->setMessages([ + 'Cow' => 'Le Moo default', + ]); + } + + return $package; + }); + + $translator = I18n::getTranslator('custom'); + $this->assertSame('Le Moo custom', $translator->translate('Cow')); + + $translator = I18n::getTranslator(); + $this->assertSame('Le Moo default', $translator->translate('Cow')); + } + + /** + * Tests that missing translations will get fallbacked to the default translator + */ + public function testFallbackTranslator(): void + { + I18n::setTranslator('default', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'Dog' => 'Le bark', + ]); + + return $package; + }, 'fr_FR'); + + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'Cow' => 'Le moo', + ]); + + return $package; + }, 'fr_FR'); + + $translator = I18n::getTranslator('custom', 'fr_FR'); + $this->assertSame('Le moo', $translator->translate('Cow')); + $this->assertSame('Le bark', $translator->translate('Dog')); + } + + /** + * Test that the translation fallback can be disabled + */ + public function testFallbackTranslatorDisabled(): void + { + I18n::useFallback(false); + + I18n::setTranslator('default', function (): Package { + $package = new Package('default'); + $package->setMessages(['Dog' => 'Le bark']); + + return $package; + }, 'fr_FR'); + + I18n::setTranslator('custom', function (): Package { + $package = new Package('default'); + $package->setMessages(['Cow' => 'Le moo']); + + return $package; + }, 'fr_FR'); + + $translator = I18n::getTranslator('custom', 'fr_FR'); + $this->assertSame('Le moo', $translator->translate('Cow')); + $this->assertSame('Dog', $translator->translate('Dog')); + } + + /** + * Tests that it is possible to register a generic translators factory for a domain + * instead of having to create them manually + */ + public function testFallbackTranslatorWithFactory(): void + { + I18n::setTranslator('default', function (): Package { + $package = new Package('default'); + $package->setMessages([ + 'Dog' => 'Le bark', + ]); + + return $package; + }, 'fr_FR'); + I18n::config('custom', function ($name, $locale) { + $this->assertSame('custom', $name); + $package = new Package('default'); + $package->setMessages([ + 'Cow' => 'Le moo', + ]); + + return $package; + }); + + $translator = I18n::getTranslator('custom', 'fr_FR'); + $this->assertSame('Le moo', $translator->translate('Cow')); + $this->assertSame('Le bark', $translator->translate('Dog')); + } + + /** + * Tests the __() function on empty translations + */ + public function testEmptyTranslationString(): void + { + I18n::setDefaultFormatter('sprintf'); + $result = __('No translation needed'); + $this->assertSame('No translation needed', $result); + } + + /** + * Tests that a plurals from a domain get translated correctly + */ + public function testPluralTranslationsFromDomain(): void + { + I18n::setLocale('de'); + $this->assertSame('Standorte', __dn('wa', 'Location', 'Locations', 0)); + $this->assertSame('Standort', __dn('wa', 'Location', 'Locations', 1)); + $this->assertSame('Standorte', __dn('wa', 'Location', 'Locations', 2)); + } +} diff --git a/tests/TestCase/I18n/MessagesFileLoaderTest.php b/tests/TestCase/I18n/MessagesFileLoaderTest.php new file mode 100644 index 00000000000..2bc45b4017d --- /dev/null +++ b/tests/TestCase/I18n/MessagesFileLoaderTest.php @@ -0,0 +1,141 @@ +clearPlugins(); + } + + /** + * test reading file from custom locale folder + */ + public function testCustomLocalePath(): void + { + $loader = new MessagesFileLoader('default', 'en'); + $package = $loader(); + $messages = $package->getMessages(); + $this->assertSame('Po (translated)', $messages['Plural Rule 1']['_context']['']); + + Configure::write('App.paths.locales', [TEST_APP . 'custom_locale' . DS]); + $loader = new MessagesFileLoader('default', 'en'); + $package = $loader(); + $messages = $package->getMessages(); + $this->assertSame('Po (translated) from custom folder', $messages['Plural Rule 1']['_context']['']); + } + + /** + * Test reading MO files + */ + public function testLoadingMoFiles(): void + { + $loader = new MessagesFileLoader('empty', 'es', 'mo'); + $package = $loader(); + $this->assertNotFalse($package); + + $loader = new MessagesFileLoader('missing', 'es', 'mo'); + $package = $loader(); + $this->assertFalse($package); + } + + /** + * @return void + */ + public function testTranslationsFolders(): void + { + $this->loadPlugins(['Company/TestPluginThree']); + + $loader = new MessagesFileLoader('company/test_plugin_three', 'es', 'mo'); + + $result = $loader->translationsFolders(); + $this->assertCount(4, $result); + } + + /** + * Testing MessagesFileLoader::translationsFolder array sequence + */ + public function testTranslationFoldersSequence(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->loadPlugins(['TestPluginTwo']); + }); + $loader = new MessagesFileLoader('test_plugin_two', 'en'); + + $expected = [ + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS . 'LC_MESSAGES' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'TestPluginTwo' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS . 'LC_MESSAGES' . DS, + ]; + $result = $loader->translationsFolders(); + $this->assertEquals($expected, $result); + } + + public function testTranslationsFoldersGettextCompatible(): void + { + $this->loadPlugins(['Company/TestPluginThree']); + + $locale = 'en_US'; + $loader = new MessagesFileLoader('company/test_plugin_three', $locale); + + $expected = [ + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS . 'LC_MESSAGES' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . $locale . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'resources' . DS . 'locales' . DS . $locale . DS . 'LC_MESSAGES' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'Company' . DS . 'TestPluginThree' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'Company' . DS . 'TestPluginThree' . DS . 'resources' . DS . 'locales' . DS . 'en' . DS . 'LC_MESSAGES' . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'Company' . DS . 'TestPluginThree' . DS . 'resources' . DS . 'locales' . DS . $locale . DS, + ROOT . DS . 'tests' . DS . 'test_app' . DS . 'Plugin' . DS . 'Company' . DS . 'TestPluginThree' . DS . 'resources' . DS . 'locales' . DS . $locale . DS . 'LC_MESSAGES' . DS, + ]; + $result = $loader->translationsFolders(); + $this->assertEquals($expected, $result); + } + + /** + * Testing plugin override from app + */ + public function testAppOverridesPlugin(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->loadPlugins(['TestPlugin', 'TestPluginTwo']); + }); + + $loader = new MessagesFileLoader('test_plugin', 'en'); + $package = $loader(); + $messages = $package->getMessages(); + $this->assertSame('Plural Rule 1 (from plugin)', $messages['Plural Rule 1']['_context']['']); + + $loader = new MessagesFileLoader('test_plugin_two', 'en'); + $package = $loader(); + $messages = $package->getMessages(); + $this->assertSame('Test Message (from app)', $messages['Test Message']['_context']['']); + } +} diff --git a/tests/TestCase/I18n/Middleware/LocaleSelectorMiddlewareTest.php b/tests/TestCase/I18n/Middleware/LocaleSelectorMiddlewareTest.php new file mode 100644 index 00000000000..0eac199175b --- /dev/null +++ b/tests/TestCase/I18n/Middleware/LocaleSelectorMiddlewareTest.php @@ -0,0 +1,119 @@ +locale = Locale::getDefault(); + } + + /** + * Resets the default locale + */ + protected function tearDown(): void + { + parent::tearDown(); + Locale::setDefault($this->locale); + } + + /** + * The default locale should not change when there are no accepted + * locales. + */ + public function testInvokeNoAcceptedLocales(): void + { + $request = ServerRequestFactory::fromGlobals(); + $middleware = new LocaleSelectorMiddleware([]); + $middleware->process($request, new TestRequestHandler()); + $this->assertSame($this->locale, I18n::getLocale()); + + $request = ServerRequestFactory::fromGlobals(['HTTP_ACCEPT_LANGUAGE' => 'garbage']); + $middleware = new LocaleSelectorMiddleware([]); + $middleware->process($request, new TestRequestHandler()); + $this->assertSame($this->locale, I18n::getLocale()); + } + + /** + * The default locale should not change when the request locale is not accepted + */ + public function testInvokeLocaleNotAccepted(): void + { + $request = ServerRequestFactory::fromGlobals(['HTTP_ACCEPT_LANGUAGE' => 'en-GB,en;q=0.8,es;q=0.6,da;q=0.4']); + $middleware = new LocaleSelectorMiddleware(['en_CA', 'en_US', 'es']); + $middleware->process($request, new TestRequestHandler()); + $this->assertSame($this->locale, I18n::getLocale(), 'en-GB is not accepted'); + } + + /** + * The default locale should change when the request locale is accepted + */ + public function testInvokeLocaleAccepted(): void + { + $request = ServerRequestFactory::fromGlobals(['HTTP_ACCEPT_LANGUAGE' => 'es,es-ES;q=0.8,da;q=0.4']); + $middleware = new LocaleSelectorMiddleware(['en_CA', 'es']); + $middleware->process($request, new TestRequestHandler()); + $this->assertSame('es', I18n::getLocale(), 'es is accepted'); + } + + /** + * The default locale should change when the request locale has an accepted fallback option + */ + public function testInvokeLocaleAcceptedFallback(): void + { + $request = ServerRequestFactory::fromGlobals(['HTTP_ACCEPT_LANGUAGE' => 'es-ES;q=0.8,da;q=0.4']); + $middleware = new LocaleSelectorMiddleware(['en_CA', 'es']); + $middleware->process($request, new TestRequestHandler()); + $this->assertSame('es', I18n::getLocale(), 'es is accepted'); + } + + /** + * The default locale should change when the '*' is accepted + */ + public function testInvokeLocaleAcceptAll(): void + { + $middleware = new LocaleSelectorMiddleware(['*']); + + $request = ServerRequestFactory::fromGlobals(['HTTP_ACCEPT_LANGUAGE' => 'es,es-ES;q=0.8,da;q=0.4']); + $middleware->process($request, new TestRequestHandler()); + $this->assertSame('es', I18n::getLocale(), 'es is accepted'); + + $request = ServerRequestFactory::fromGlobals(['HTTP_ACCEPT_LANGUAGE' => 'en;q=0.4,es;q=0.6,da;q=0.8']); + $middleware->process($request, new TestRequestHandler()); + $this->assertSame('da', I18n::getLocale(), 'da is accepted'); + } +} diff --git a/tests/TestCase/I18n/NumberTest.php b/tests/TestCase/I18n/NumberTest.php new file mode 100644 index 00000000000..c27c09665be --- /dev/null +++ b/tests/TestCase/I18n/NumberTest.php @@ -0,0 +1,623 @@ +Number = new Number(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->Number); + I18n::setLocale(I18n::getDefaultLocale()); + Number::setDefaultCurrency(); + Number::setDefaultCurrencyFormat(); + } + + /** + * testFormatAndCurrency method + */ + public function testFormat(): void + { + $value = '100100100'; + + $result = $this->Number->format($value); + $expected = '100,100,100'; + $this->assertSame($expected, $result); + + $result = $this->Number->format($value, ['before' => '#']); + $expected = '#100,100,100'; + $this->assertSame($expected, $result); + + $result = $this->Number->format($value, ['places' => 3]); + $expected = '100,100,100.000'; + $this->assertSame($expected, $result); + + $result = $this->Number->format($value, ['locale' => 'es_VE']); + $expected = '100.100.100'; + $this->assertSame($expected, $result); + + $value = 0.00001; + $result = $this->Number->format($value, ['places' => 1, 'before' => '$']); + $expected = '$0.0'; + $this->assertSame($expected, $result); + + $value = -0.00001; + $result = $this->Number->format($value, ['places' => 1, 'before' => '$']); + $expected = '$-0.0'; + $this->assertSame($expected, $result); + + $value = 1.23; + $options = ['locale' => 'fr_FR', 'after' => ' €']; + $result = $this->Number->format($value, $options); + $expected = '1,23 €'; + $this->assertSame($expected, $result); + + $value = 1.665; + $options = ['locale' => 'cs_CZ', 'precision' => 2]; + $result = $this->Number->format($value, $options); + $expected = '1,66'; + $this->assertSame($expected, $result); + + $options = ['locale' => 'cs_CZ', 'precision' => 2, 'roundingMode' => NumberFormatter::ROUND_HALFUP]; + $result = $this->Number->format($value, $options); + $expected = '1,67'; + $this->assertSame($expected, $result); + } + + /** + * testParseFloat method + */ + public function testParseFloat(): void + { + I18n::setLocale('de_DE'); + $value = '1.234.567,891'; + $result = $this->Number->parseFloat($value); + $expected = 1234567.891; + $this->assertSame($expected, $result); + + I18n::setLocale('pt_BR'); + $value = '1.234,37'; + $result = $this->Number->parseFloat($value); + $expected = 1234.37; + $this->assertSame($expected, $result); + + $value = '1,234.37'; + $result = $this->Number->parseFloat($value, ['locale' => 'en_US']); + $expected = 1234.37; + $this->assertSame($expected, $result); + } + + /** + * testFormatDelta method + */ + public function testFormatDelta(): void + { + $value = '100100100'; + + $result = $this->Number->formatDelta($value, ['places' => 0]); + $expected = '+100,100,100'; + $this->assertSame($expected, $result); + + $result = $this->Number->formatDelta($value, ['before' => '', 'after' => '']); + $expected = '+100,100,100'; + $this->assertSame($expected, $result); + + $result = $this->Number->formatDelta($value, ['before' => '[', 'after' => ']']); + $expected = '[+100,100,100]'; + $this->assertSame($expected, $result); + + $result = $this->Number->formatDelta(-$value, ['before' => '[', 'after' => ']']); + $expected = '[-100,100,100]'; + $this->assertSame($expected, $result); + + $result = $this->Number->formatDelta(-$value, ['before' => '[ ', 'after' => ' ]']); + $expected = '[ -100,100,100 ]'; + $this->assertSame($expected, $result); + + $value = 0; + $result = $this->Number->formatDelta($value, ['places' => 1, 'before' => '[', 'after' => ']']); + $expected = '[0.0]'; + $this->assertSame($expected, $result); + + $value = 0.0001; + $result = $this->Number->formatDelta($value, ['places' => 1, 'before' => '[', 'after' => ']']); + $expected = '[0.0]'; + $this->assertSame($expected, $result); + + $value = 9876.1234; + $result = $this->Number->formatDelta($value, ['places' => 1, 'locale' => 'de_DE']); + $expected = '+9.876,1'; + $this->assertSame($expected, $result); + } + + /** + * Test currency method. + */ + public function testCurrency(): void + { + $value = '100100100'; + + $result = $this->Number->currency($value); + $expected = '$100,100,100.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'USD'); + $expected = '$100,100,100.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'EUR'); + $expected = '€100,100,100.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'EUR', ['locale' => 'de_DE']); + $expected = '100.100.100,00 €'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'USD', ['locale' => 'de_DE']); + $expected = '100.100.100,00 $'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'USD', ['locale' => 'en_US']); + $expected = '$100,100,100.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'USD', ['locale' => 'en_CA']); + $expected = 'US$100,100,100.00'; + $this->assertSame($expected, $result); + + $options = ['locale' => 'en_IN', 'pattern' => "Rs'.' #,##,###"]; + $result = $this->Number->currency($value, 'INR', $options); + $expected = 'Rs. 10,01,00,100'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'GBP'); + $expected = '£100,100,100.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'GBP', ['locale' => 'da_DK']); + $expected = '100.100.100,00 £'; + $this->assertSame($expected, $result); + + $options = ['locale' => 'fr_FR', 'pattern' => 'EUR #,###.00']; + $result = $this->Number->currency($value, 'EUR', $options); + // The following tests use regexp because whitespace used + // is inconsistent between *nix & windows. + $expected = '/^EUR\W+100\W+100\W+100,00$/'; + $this->assertMatchesRegularExpression($expected, $result); + + $options = ['locale' => 'fr_FR', 'pattern' => '#,###.00 ¤¤']; + $result = $this->Number->currency($value, 'EUR', $options); + $expected = '/^100\W+100\W+100,00\W+EUR$/'; + $this->assertMatchesRegularExpression($expected, $result); + + $options = ['locale' => 'fr_FR', 'pattern' => '#,###.00;(¤#,###.00)']; + $result = $this->Number->currency(-1235.03, 'EUR', $options); + $expected = '/^\(€1\W+235,03\)$/'; + $this->assertMatchesRegularExpression($expected, $result); + + $result = $this->Number->currency(0.5, 'USD', ['locale' => 'en_US', 'fractionSymbol' => 'c']); + $expected = '50c'; + $this->assertSame($expected, $result); + + $options = ['fractionSymbol' => ' cents']; + $result = $this->Number->currency(0.2, 'USD', $options); + $expected = '20 cents'; + $this->assertSame($expected, $result); + + $options = ['fractionSymbol' => 'cents ', 'fractionPosition' => 'before']; + $result = $this->Number->currency(0.2, null, $options); + $expected = 'cents 20'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency(0.2, 'EUR'); + $expected = '€0.20'; + $this->assertSame($expected, $result); + + $options = ['fractionSymbol' => false, 'fractionPosition' => 'before']; + $result = $this->Number->currency(0.5, null, $options); + $expected = '$0.50'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency(0, 'GBP'); + $expected = '£0.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency(0, 'GBP', ['pattern' => '¤#,###.00;¤-#,###.00']); + $expected = '£.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency(0, 'GBP', ['pattern' => '¤#,##0.00;¤-#,##0.00']); + $expected = '£0.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency(0.00000, 'GBP'); + $expected = '£0.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency('0.00000', 'GBP'); + $expected = '£0.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency('22.389', 'CAD'); + $expected = 'CA$22.39'; + $this->assertSame($expected, $result); + } + + /** + * Test currency format with places and fraction exponents. + * Places should only matter for non fraction values and vice versa. + */ + public function testCurrencyWithFractionAndPlaces(): void + { + $result = $this->Number->currency('1.23', 'EUR', ['locale' => 'de_DE', 'places' => 3]); + $expected = '1,230 €'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency('1.665', 'CZK', ['locale' => 'cs_CZ', 'places' => 2]); + $expected = '1,66 Kč'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency('1.665', 'CZK', ['locale' => 'cs_CZ', 'places' => 2, 'roundingMode' => NumberFormatter::ROUND_HALFUP]); + $expected = '1,67 Kč'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency('0.23', 'GBP', ['places' => 3, 'fractionSymbol' => 'p']); + $expected = '23p'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency('0.001', 'GBP', ['places' => 3, 'fractionSymbol' => 'p']); + $expected = '0p'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency('1.23', 'EUR', ['locale' => 'de_DE', 'precision' => 1]); + $expected = '1,2 €'; + $this->assertSame($expected, $result); + } + + /** + * Test get default currency + */ + public function testGetDefaultCurrency(): void + { + $this->assertSame('USD', $this->Number->getDefaultCurrency()); + } + + /** + * Test set default currency + */ + public function testSetDefaultCurrency(): void + { + $this->Number->setDefaultCurrency(); + I18n::setLocale('es_ES'); + $this->assertSame('EUR', $this->Number->getDefaultCurrency()); + + $this->Number->setDefaultCurrency('JPY'); + $this->assertSame('JPY', $this->Number->getDefaultCurrency()); + } + + /** + * Test get default currency format + */ + public function testGetDefaultCurrencyFormat(): void + { + $this->assertSame('currency', $this->Number->getDefaultCurrencyFormat()); + } + + /** + * Test set default currency format + */ + public function testSetDefaultCurrencyFormat(): void + { + $this->Number->setDefaultCurrencyFormat(Number::FORMAT_CURRENCY_ACCOUNTING); + $this->assertSame('currency_accounting', $this->Number->getDefaultCurrencyFormat()); + + $this->assertSame('($123.45)', $this->Number->currency(-123.45)); + } + + /** + * testCurrencyCentsNegative method + */ + public function testCurrencyCentsNegative(): void + { + $value = '-0.99'; + + $result = $this->Number->currency($value, 'EUR', ['locale' => 'de_DE']); + $expected = '-0,99 €'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'USD', ['fractionSymbol' => 'c']); + $expected = '-99c'; + $this->assertSame($expected, $result); + } + + /** + * testCurrencyZero method + */ + public function testCurrencyZero(): void + { + $value = '0'; + + $result = $this->Number->currency($value, 'USD'); + $expected = '$0.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'EUR', ['locale' => 'fr_FR']); + $expected = '0,00 €'; + $this->assertSame($expected, $result); + } + + /** + * testCurrencyOptions method + */ + public function testCurrencyOptions(): void + { + $value = '1234567.89'; + + $result = $this->Number->currency($value, null, ['before' => 'Total: ']); + $expected = 'Total: $1,234,567.89'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, null, ['after' => ' in Total']); + $expected = '$1,234,567.89 in Total'; + $this->assertSame($expected, $result); + } + + /** + * Tests that it is possible to use the international currency code instead of the whole + * when using the currency method + */ + public function testCurrencyIntlCode(): void + { + $value = '123'; + $result = $this->Number->currency($value, 'USD', ['useIntlCode' => true]); + $expected = 'USD 123.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'EUR', ['useIntlCode' => true]); + $expected = 'EUR 123.00'; + $this->assertSame($expected, $result); + + $result = $this->Number->currency($value, 'EUR', ['useIntlCode' => true, 'locale' => 'da_DK']); + $expected = '123,00 EUR'; + $this->assertSame($expected, $result); + } + + /** + * test precision() with locales + */ + public function testPrecisionLocalized(): void + { + I18n::setLocale('fr_FR'); + $result = $this->Number->precision(1.234); + $this->assertSame('1,234', $result); + } + + /** + * testToPercentage method + */ + public function testToPercentage(): void + { + $result = $this->Number->toPercentage(45, 0); + $expected = '45%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(45, 2); + $expected = '45.00%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0, 0); + $expected = '0%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0, 4); + $expected = '0.0000%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(45, 0, ['multiply' => false]); + $expected = '45%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(45, 2, ['multiply' => false]); + $expected = '45.00%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0, 0, ['multiply' => false]); + $expected = '0%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0, 4, ['multiply' => false]); + $expected = '0.0000%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0.456, 0, ['multiply' => true]); + $expected = '46%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0.456, 2, ['multiply' => true]); + $expected = '45.60%'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0.456, 2, ['locale' => 'de-DE', 'multiply' => true]); + $expected = '45,60 %'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(13, 0, ['locale' => 'fi_FI']); + $expected = '13 %'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage(0.13, 0, ['locale' => 'fi_FI', 'multiply' => true]); + $expected = '13 %'; + $this->assertSame($expected, $result); + + $result = $this->Number->toPercentage('0.13', 0, ['locale' => 'fi_FI', 'multiply' => true]); + $expected = '13 %'; + $this->assertSame($expected, $result); + } + + /** + * testToReadableSize method + */ + public function testToReadableSize(): void + { + $result = $this->Number->toReadableSize(0); + $expected = '0 Bytes'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1); + $expected = '1 Byte'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(45); + $expected = '45 Bytes'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1023); + $expected = '1,023 Bytes'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024); + $expected = '1 KB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 + 123); + $expected = '1.12 KB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 512); + $expected = '512 KB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 - 1); + $expected = '1 MB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(512.05 * 1024 * 1024); + $expected = '512.05 MB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 - 1); + $expected = '1 GB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 512); + $expected = '512 GB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 - 1); + $expected = '1 TB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 512); + $expected = '512 TB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 - 1); + $expected = '1,024 TB'; + $this->assertSame($expected, $result); + + $result = $this->Number->toReadableSize(1024 * 1024 * 1024 * 1024 * 1024 * 1024); + $expected = '1,048,576 TB'; + $this->assertSame($expected, $result); + } + + /** + * test toReadableSize() with locales + */ + public function testReadableSizeLocalized(): void + { + I18n::setLocale('fr_FR'); + $result = $this->Number->toReadableSize(1321205); + $this->assertSame('1,26 MB', $result); + + $result = $this->Number->toReadableSize(512.05 * 1024 * 1024 * 1024); + $this->assertSame('512,05 GB', $result); + } + + /** + * test config() + */ + public function testConfig(): void + { + $result = $this->Number->currency(150000, 'USD', ['locale' => 'en_US']); + $this->assertSame('$150,000.00', $result); + + Number::config('en_US', NumberFormatter::CURRENCY, [ + 'pattern' => '¤ #,##,##0', + ]); + + $result = $this->Number->currency(150000, 'USD', ['locale' => 'en_US']); + $this->assertSame('$ 1,50,000', $result); + } + + /** + * test ordinal() with locales + */ + public function testOrdinal(): void + { + I18n::setLocale('en_US'); + $result = $this->Number->ordinal(1); + $this->assertSame('1st', $result); + + $result = $this->Number->ordinal(2); + $this->assertSame('2nd', $result); + + $result = $this->Number->ordinal(2, [ + 'locale' => 'fr_FR', + ]); + $this->assertSame('2e', $result); + + $result = $this->Number->ordinal(3); + $this->assertSame('3rd', $result); + + $result = $this->Number->ordinal(4); + $this->assertSame('4th', $result); + + I18n::setLocale('fr_FR'); + $result = $this->Number->ordinal(1); + $this->assertSame('1er', $result); + + $result = $this->Number->ordinal(2); + $this->assertSame('2e', $result); + } +} diff --git a/tests/TestCase/I18n/PackageTest.php b/tests/TestCase/I18n/PackageTest.php new file mode 100644 index 00000000000..be2d91caf2b --- /dev/null +++ b/tests/TestCase/I18n/PackageTest.php @@ -0,0 +1,47 @@ + 'translation', + 'string2' => [ + 'translation singular', + 'translation plural', + ], + ]; + + $package->setMessages(['string' => $messages['string']]); + $package->addMessage('string2', $messages['string2']); + + $this->assertEquals($messages, $package->getMessages()); + } +} diff --git a/tests/TestCase/I18n/Parser/MoFileParserTest.php b/tests/TestCase/I18n/Parser/MoFileParserTest.php new file mode 100644 index 00000000000..8c9d0dff18f --- /dev/null +++ b/tests/TestCase/I18n/Parser/MoFileParserTest.php @@ -0,0 +1,191 @@ +path = Configure::read('App.paths.locales.0'); + } + + /** + * Tests parsing a file with plurals and message context + */ + public function testParse(): void + { + $parser = new MoFileParser(); + $file = $this->path . 'rule_1_mo' . DS . 'core.mo'; + $messages = $parser->parse($file); + $this->assertCount(3, $messages); + $expected = [ + '%d = 1 (from core)' => [ + '_context' => [ + '' => '%d = 1 (from core translated)', + ], + ], + '%d = 0 or > 1 (from core)' => [ + '_context' => [ + '' => [ + '%d = 1 (from core translated)', + '%d = 0 or > 1 (from core translated)', + ], + ], + ], + 'Plural Rule 1 (from core)' => [ + '_context' => [ + '' => 'Plural Rule 1 (from core translated)', + ], + ], + ]; + $this->assertEquals($expected, $messages); + } + + /** + * Tests parsing a file with single form plurals + */ + public function testParse0(): void + { + $parser = new MoFileParser(); + $file = $this->path . 'rule_0_mo' . DS . 'core.mo'; + $messages = $parser->parse($file); + $this->assertCount(4, $messages); + $expected = [ + 'Plural Rule 1 (from core)' => [ + '_context' => [ + '' => 'Plural Rule 0 (from core translated)', + ], + ], + '%d = 1 (from core)' => [ + '_context' => [ + '' => '%d ends with any # (from core translated)', + ], + ], + '%d = 0 or > 1 (from core)' => [ + '_context' => [ + '' => [ + '%d ends with any # (from core translated)', + ], + ], + ], + "new line: \nno new line: \\n" => [ + '_context' => [ + '' => "new line: \nno new line: \\n (translated)", + ], + ], + ]; + $this->assertEquals($expected, $messages); + } + + /** + * Tests parsing a file with larger plural forms + */ + public function testParse2(): void + { + $parser = new MoFileParser(); + $file = $this->path . 'rule_9_mo' . DS . 'core.mo'; + $messages = $parser->parse($file); + $this->assertCount(3, $messages); + $expected = [ + '%d = 1 (from core)' => [ + '_context' => [ + '' => '%d is 1 (from core translated)', + ], + ], + '%d = 0 or > 1 (from core)' => [ + '_context' => [ + '' => [ + '%d is 1 (from core translated)', + '%d ends in 2-4, not 12-14 (from core translated)', + '%d everything else (from core translated)', + ], + ], + ], + 'Plural Rule 1 (from core)' => [ + '_context' => [ + '' => 'Plural Rule 9 (from core translated)', + ], + ], + ]; + $this->assertEquals($expected, $messages); + } + + /** + * Tests parsing a file with plurals and message context + */ + public function testParseFull(): void + { + $parser = new MoFileParser(); + $file = $this->path . 'rule_0_mo' . DS . 'default.mo'; + $messages = $parser->parse($file); + $this->assertCount(5, $messages); + $expected = [ + 'Plural Rule 1' => [ + '_context' => [ + '' => 'Plural Rule 1 (translated)', + ], + ], + '%d = 1' => [ + '_context' => [ + 'This is the context' => 'First Context trasnlation', + 'Another Context' => '%d = 1 (translated)', + ], + ], + '%d = 0 or > 1' => [ + '_context' => [ + 'Another Context' => [ + 0 => '%d = 1 (translated)', + 1 => '%d = 0 or > 1 (translated)', + ], + ], + ], + '%-5d = 1' => [ + '_context' => [ + '' => '%-5d = 1 (translated)', + ], + ], + '%-5d = 0 or > 1' => [ + '_context' => [ + '' => [ + '%-5d = 1 (translated)', + '%-5d = 0 or > 1 (translated)', + ], + ], + ], + ]; + $this->assertEquals($expected, $messages); + } +} diff --git a/tests/TestCase/I18n/Parser/PoFileParserTest.php b/tests/TestCase/I18n/Parser/PoFileParserTest.php new file mode 100644 index 00000000000..7dc18fdb49e --- /dev/null +++ b/tests/TestCase/I18n/Parser/PoFileParserTest.php @@ -0,0 +1,231 @@ +path = Configure::read('App.paths.locales.0'); + } + + /** + * Tear down method + */ + protected function tearDown(): void + { + parent::tearDown(); + I18n::clear(); + I18n::setLocale(I18n::getDefaultLocale()); + Cache::clear('_cake_translations_'); + } + + /** + * Tests parsing a file with plurals and message context + */ + public function testParse(): void + { + $parser = new PoFileParser(); + $file = $this->path . 'rule_1_po' . DS . 'default.po'; + $messages = $parser->parse($file); + $this->assertCount(8, $messages); + $expected = [ + 'Plural Rule 1' => [ + '_context' => [ + '' => 'Plural Rule 1 (translated)', + ], + ], + '%d = 1' => [ + '_context' => [ + 'This is the context' => 'First Context translation', + 'Another Context' => '%d = 1 (translated)', + ], + ], + 'p:%d = 0 or > 1' => [ + '_context' => [ + 'Another Context' => [ + 0 => '%d = 1 (translated)', + 1 => '%d = 0 or > 1 (translated)', + ], + ], + ], + '%-5d = 1' => [ + '_context' => [ + '' => '%-5d = 1 (translated)', + ], + ], + 'p:%-5d = 0 or > 1' => [ + '_context' => [ + '' => [ + 0 => '%-5d = 1 (translated)', + 1 => '', + 2 => '', + 3 => '', + 4 => '%-5d = 0 or > 1 (translated)', + ], + ], + ], + '%d = 2' => [ + '_context' => [ + 'This is another translated context' => 'First Context translation', + ], + ], + '%-6d = 3' => [ + '_context' => [ + '' => '%-6d = 1 (translated)', + ], + ], + 'p:%-6d = 0 or > 1' => [ + '_context' => [ + '' => [ + 0 => '%-6d = 1 (translated)', + 1 => '', + 2 => '', + 3 => '', + 4 => '%-6d = 0 or > 1 (translated)', + ], + ], + ], + ]; + $this->assertEquals($expected, $messages); + } + + /** + * Tests parsing a file with multiline keys and values + */ + public function testParseMultiLine(): void + { + $parser = new PoFileParser(); + $file = $this->path . 'en' . DS . 'default.po'; + $messages = $parser->parse($file); + $this->assertCount(13, $messages); + $this->assertTextEquals("v\nsecond line", $messages["valid\nsecond line"]['_context']['']); + + $this->assertTextEquals("new line: \nno new line: \\n (translated)", $messages["new line: \nno new line: \\n"]['_context']['']); + } + + /** + * Test parsing a file with quoted strings + */ + public function testQuotedString(): void + { + $parser = new PoFileParser(); + $file = $this->path . 'en' . DS . 'default.po'; + $messages = $parser->parse($file); + + $this->assertTextEquals('this is a "quoted string" (translated)', $messages['this is a "quoted string"']['_context']['']); + } + + /** + * Test parsing a file with message context on some msgid values. + * + * This behavior is not ideal, but more thorough solutions + * would break compatibility. Perhaps this is something we can + * reconsider in 4.x + */ + public function testParseContextOnSomeMessages(): void + { + $parser = new PoFileParser(); + $file = $this->path . 'en' . DS . 'context.po'; + $messages = $parser->parse($file); + + I18n::setTranslator('default', function () use ($messages) { + $package = new Package('default'); + $package->setMessages($messages); + + return $package; + }, 'en_CA'); + + $this->assertSame('En cours', $messages['Pending']['_context']['']); + $this->assertSame('En cours - context', $messages['Pending']['_context']['Pay status']); + $this->assertSame('En resolved', $messages['Resolved']['_context']['']); + $this->assertSame('En resolved - context', $messages['Resolved']['_context']['Pay status']); + + $key = '{0,plural,=0{Je suis}=1{Je suis}=2{Nous sommes} other{Nous sommes}}'; + $this->assertStringContainsString("I've", $messages[$key]['_context']['origin']); + + // Confirm actual behavior + I18n::setLocale('en_CA'); + $this->assertSame('En cours', __('Pending')); + $this->assertSame('En cours - context', __x('Pay status', 'Pending')); + $this->assertSame('En resolved', __('Resolved')); + $this->assertSame('En resolved - context', __x('Pay status', 'Resolved')); + $this->assertSame("I've", __x('origin', $key, [1])); + $this->assertSame('We are', __x('origin', $key, [3])); + } + + /** + * Test parsing context based messages + */ + public function testParseContextMessages(): void + { + $parser = new PoFileParser(); + $file = $this->path . 'en' . DS . 'context.po'; + $messages = $parser->parse($file); + + I18n::setTranslator('default', function () use ($messages) { + $package = new Package('default'); + $package->setMessages($messages); + + return $package; + }, 'en_US'); + + // Check translated messages + I18n::setLocale('en_US'); + $this->assertSame('Titel mit Kontext', __x('context', 'title')); + $this->assertSame('Titel mit anderem Kontext', __x('another_context', 'title')); + $this->assertSame('Titel ohne Kontext', __('title')); + } + + /** + * Test parsing plurals + */ + public function testPlurals(): void + { + I18n::getTranslator('default', 'de_DE'); + + // Check translated messages + I18n::setLocale('de_DE'); + $this->assertSame('Standorte', __d('wa', 'Locations')); + I18n::setLocale('en_EN'); + $this->assertSame('Locations', __d('wa', 'Locations')); + } +} diff --git a/tests/TestCase/I18n/PluralRulesTest.php b/tests/TestCase/I18n/PluralRulesTest.php new file mode 100644 index 00000000000..b94533186a0 --- /dev/null +++ b/tests/TestCase/I18n/PluralRulesTest.php @@ -0,0 +1,139 @@ +assertEquals($expected, PluralRules::calculate($locale, $number)); + } +} diff --git a/tests/TestCase/I18n/TimeTest.php b/tests/TestCase/I18n/TimeTest.php new file mode 100644 index 00000000000..53453526b57 --- /dev/null +++ b/tests/TestCase/I18n/TimeTest.php @@ -0,0 +1,191 @@ +now = DateTime::getTestNow(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + DateTime::setTestNow($this->now); + DateTime::setDefaultLocale(); + Time::resetToStringFormat(); + + I18n::setLocale(I18n::getDefaultLocale()); + } + + public function testConstruct(): void + { + $time = '10:33:44.123456'; + + $mut = new Time($time); + $subject = new Time($mut); + $this->assertSame($time, $subject->format('H:i:s.u')); + + $mut = new Chronos($time); + $subject = new Time($mut); + $this->assertSame($time, $subject->format('H:i:s.u')); + + $mut = new DateTimeImmutable($time); + $subject = new Time($mut); + $this->assertSame($time, $subject->format('H:i:s.u')); + } + + public function testNice(): void + { + $time = new Time('20:00'); + $this->assertTimeFormat('8:00:00 PM', $time->nice()); + $this->assertTimeFormat('20:00:00', $time->nice('fr-FR')); + + // Test with custom default locale + DateTime::setDefaultLocale('fr-FR'); + $this->assertTimeFormat('20:00:00', $time->nice()); + } + + /** + * test formatting dates taking in account preferred i18n locale file + */ + public function testI18nFormat(): void + { + $time = new Time('13:59:28'); + + // Test the default format which should be SHORT + $this->assertTimeFormat('1:59 PM', $time->i18nFormat()); + + // Test using a time-specific format + $this->assertTimeFormat('1:59:28 PM', $time->i18nFormat(IntlDateFormatter::MEDIUM)); + + // Test using a specific format and locale + $this->assertTimeFormat('13:59:28', $time->i18nFormat(IntlDateFormatter::MEDIUM, 'es-ES')); + + // Test with custom default locale + DateTime::setDefaultLocale('es-ES'); + $this->assertTimeFormat('13:59:28', $time->i18nFormat(IntlDateFormatter::MEDIUM)); + } + + /** + * testI18nFormatUsingSystemLocale + */ + public function testI18nFormatUsingSystemLocale(): void + { + $time = new Time('12:00:00'); + I18n::setLocale('es'); + $this->assertTimeFormat('12:00:00', $time->i18nFormat("HH':'mm':'ss")); + } + + public function testToString(): void + { + $time = new Time('22:10'); + DateTime::setDefaultLocale('fr-FR'); + Time::setToStringFormat(IntlDateFormatter::MEDIUM); + $this->assertTimeFormat('22:10:00', (string)$time); + } + + /** + * Tests encoding a Time object as JSON + */ + public function testJsonEncode(): void + { + $time = new Time('10:10:10'); + $this->assertTimeFormat('"10:10:10"', json_encode($time)); + + Time::setJsonEncodeFormat('HH:mm:ss'); + $this->assertTimeFormat('"10:10:10"', json_encode($time)); + + Time::setJsonEncodeFormat(fn(Time $time) => 'custom format'); + $this->assertTimeFormat('"custom format"', json_encode($time)); + } + + public function testInvalidJsonEncodeFormat(): void + { + $this->expectException(InvalidArgumentException::class); + Time::setJsonEncodeFormat(DateTime::UNIX_TIMESTAMP_FORMAT); + json_encode(new Time('10:10:10')); + } + + /** + * Tests parsing times using the parseTime function + */ + public function testParseTime(): void + { + $time = Time::parseTime('12:54am'); + $this->assertNotNull($time); + $this->assertSame('00:54:00', $time->format('H:i:s')); + + $time = Time::parseTime('12:54am', IntlDateFormatter::SHORT); + $this->assertNotNull($time); + $this->assertSame('00:54:00', $time->format('H:i:s')); + + $time = Time::parseTime('12:54', "HH':'ss"); + $this->assertNotNull($time); + $this->assertSame('12:00:54', $time->format('H:i:s')); + + DateTime::setDefaultLocale('fr-FR'); + $time = Time::parseTime('23:54'); + $this->assertNotNull($time); + $this->assertSame('23:54:00', $time->format('H:i:s')); + + $time = Time::parseTime('31c2:54'); + $this->assertNull($time); + } + + /** + * Custom assert to allow for variation in the version of the intl library, where + * some translations contain a few extra commas. + */ + public function assertTimeFormat(string $expected, string $result, string $message = ''): void + { + $expected = str_replace([',', '(', ')', ' at', ' م.', ' ه‍.ش.', ' AP', ' AH', ' SAKA', 'à '], '', $expected); + $expected = str_replace([' ', ' '], ' ', $expected); + + $result = str_replace('temps universel coordonné', 'UTC', $result); + $result = str_replace('Temps universel coordonné', 'UTC', $result); + $result = str_replace('tiempo universal coordinado', 'UTC', $result); + $result = str_replace('Coordinated Universal Time', 'UTC', $result); + + $result = str_replace([',', '(', ')', ' at', ' م.', ' ه‍.ش.', ' AP', ' AH', ' SAKA', 'à '], '', $result); + $result = str_replace([' ', ' '], ' ', $result); + + $this->assertSame($expected, $result, $message); + } +} diff --git a/tests/TestCase/I18n/TranslatorRegistryTest.php b/tests/TestCase/I18n/TranslatorRegistryTest.php new file mode 100644 index 00000000000..b8f1302c14f --- /dev/null +++ b/tests/TestCase/I18n/TranslatorRegistryTest.php @@ -0,0 +1,63 @@ + [ + 'en_CA' => $package, + ], + ]); + $formatterLocator = new FormatterLocator([ + 'default' => SprintfFormatter::class, + ]); + + $cachedTranslator = new Translator('en_CA', $package, new SprintfFormatter()); + $cacheEngineNullPackage = new class ($cachedTranslator) extends TestAppCacheEngine { + public function __construct(protected Translator $translator) + { + } + + public function get($key, $default = null): mixed + { + return $this->translator; + } + }; + + $registry = new TranslatorRegistry($packageLocator, $formatterLocator, 'en_CA'); + $registry->setCacher($cacheEngineNullPackage); + + $this->assertSame($package, $registry->get('default')->getPackage()); + } +} diff --git a/tests/TestCase/Log/Engine/BaseLogTest.php b/tests/TestCase/Log/Engine/BaseLogTest.php new file mode 100644 index 00000000000..a94212649bc --- /dev/null +++ b/tests/TestCase/Log/Engine/BaseLogTest.php @@ -0,0 +1,157 @@ +logger = new TestBaseLog(); + } + + private function assertUnescapedUnicode(array $needles, string $haystack): void + { + foreach ($needles as $needle) { + $this->assertStringContainsString( + $needle, + $haystack, + 'Formatted log message does not contain unescaped unicode character.', + ); + } + } + + /** + * Tests the logging output of a single string containing unicode characters. + */ + public function testLogUnicodeString(): void + { + $this->logger->log(LogLevel::INFO, implode('', $this->testData)); + + $this->assertUnescapedUnicode($this->testData, $this->logger->getMessage()); + } + + public function testPlaceHoldersInMessage(): void + { + $context = [ + 'no-placholder' => 'no-placholder', + 'string' => 'a-string', + 'bool' => true, + 'json' => new Entity(['foo' => 'bar']), + 'array' => ['arr'], + 'array-obj' => new ArrayObject(['x' => 'y']), + 'debug-info' => ConnectionManager::get('test'), + 'obj' => function (): void { + }, + 'to-string' => new Response(['body' => 'response body']), + 'to-array' => new TypeMap(['my-type']), + ]; + $this->logger->log( + LogLevel::INFO, + '1: {string}, 2: {bool}, 3: {json}, 4: {not a placeholder}, 5: {array}, ' + . '6: {array-obj} 7: {obj}, 8: {debug-info} 9: {valid-ph-not-in-context}', + $context, + ); + + $message = $this->logger->getMessage(); + + $this->assertStringContainsString('1: a-string', $message); + $this->assertStringContainsString('2: 1', $message); + $this->assertStringContainsString('3: {"foo":"bar"}', $message); + $this->assertStringContainsString('4: {not a placeholder}', $message); + $this->assertStringContainsString('5: ["arr"]', $message); + $this->assertStringContainsString('6: {"x":"y"}', $message); + $this->assertStringContainsString('7: [unhandled value of type Closure]', $message); + $this->assertStringContainsString( + '8: ' . json_encode(ConnectionManager::get('test')->__debugInfo(), JSON_UNESCAPED_UNICODE), + $message, + ); + $this->assertStringContainsString('9: {valid-ph-not-in-context}', $message); + + $this->logger->log( + LogLevel::INFO, + '1: {to-string}', + $context, + ); + $this->assertSame('1: response body', $this->logger->getMessage()); + + $this->logger->log( + LogLevel::INFO, + 'no placeholder holders', + $context, + ); + $this->assertSame('no placeholder holders', $this->logger->getMessage()); + + $this->logger->log( + LogLevel::INFO, + '{to-array}', + $context, + ); + $this->assertSame('["my-type"]', $this->logger->getMessage()); + + $this->logger->log( + LogLevel::INFO, + '\{string}', + ['string' => 'a-string'], + ); + $this->assertSame('\{string}', $this->logger->getMessage()); + + $this->logger->log( + LogLevel::INFO, + '1: {_ph1}, 2: {0ph2}', + ['_ph1' => '1st-string', '0ph2' => '2nd-string'], + ); + $this->assertSame('1: 1st-string, 2: 2nd-string', $this->logger->getMessage()); + + $this->logger->log( + LogLevel::INFO, + '{0}', + ['val'], + ); + $this->assertSame('val', $this->logger->getMessage()); + } + + /** + * Test setting custom formatter option. + */ + public function testCustomFormatter(): void + { + $log = new TestBaseLog(['formatter' => ValidFormatter::class]); + $this->assertNotNull($log); + + $log = new TestBaseLog(['formatter' => new ValidFormatter()]); + $this->assertNotNull($log); + } +} diff --git a/tests/TestCase/Log/Engine/ConsoleLogTest.php b/tests/TestCase/Log/Engine/ConsoleLogTest.php new file mode 100644 index 00000000000..9a67dd24839 --- /dev/null +++ b/tests/TestCase/Log/Engine/ConsoleLogTest.php @@ -0,0 +1,143 @@ +'; + $output->shouldReceive('write') + ->with(Mockery::on(static function ($content) use ($message): bool { + return is_string($content) && str_contains($content, $message); + })) + ->once(); + + $log = new ConsoleLog([ + 'stream' => $output, + ]); + $log->log('error', 'oh noes'); + } + + /** + * Test writing to a file stream + */ + public function testLogToFileStream(): void + { + $filename = tempnam(sys_get_temp_dir(), 'cake_log_test'); + $log = new ConsoleLog([ + 'stream' => $filename, + ]); + $log->log('error', 'oh noes'); + $fh = fopen($filename, 'r'); + $line = fgets($fh); + $this->assertStringContainsString('error: oh noes', $line); + $this->assertMatchesRegularExpression('/2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ error: oh noes/', $line); + } + + /** + * test value of stream 'outputAs' + */ + public function testDefaultOutputAs(): void + { + $output = Mockery::mock(ConsoleOutput::class); + + $output->shouldReceive('setOutputAs') + ->with(ConsoleOutput::RAW) + ->once(); + + $log = new ConsoleLog([ + 'stream' => $output, + 'outputAs' => ConsoleOutput::RAW, + ]); + $this->assertSame(ConsoleOutput::RAW, $log->getConfig('outputAs')); + } + + /** + * test dateFormat option + */ + public function testDateFormat(): void + { + $filename = tempnam(sys_get_temp_dir(), 'cake_log_test'); + $log = new ConsoleLog([ + 'stream' => $filename, + 'formatter.dateFormat' => 'c', + ]); + $log->log('error', 'oh noes'); + $fh = fopen($filename, 'r'); + $line = fgets($fh); + $this->assertMatchesRegularExpression('/2[0-9]{3}-[0-9]+-[0-9]+T[0-9]+:[0-9]+:[0-9]+\+\d{2}:\d{2} error: oh noes/', $line); + } + + /** + * Test json formatter + */ + public function testJsonFormatter(): void + { + $filename = tempnam(sys_get_temp_dir(), 'cake_log_test'); + $log = new ConsoleLog([ + 'stream' => $filename, + 'formatter' => [ + 'className' => JsonFormatter::class, + ], + ]); + $log->log('error', 'test with newline'); + $fh = fopen($filename, 'r'); + $line = fgets($fh); + $this->assertSame(strlen($line) - 1, strpos($line, "\n")); + + $entry = json_decode($line, true); + $this->assertNotNull($entry['date']); + $this->assertSame('error', $entry['level']); + $this->assertSame('test with newline', $entry['message']); + } + + /** + * Test json formatter custom flags + */ + public function testJsonFormatterFlags(): void + { + $filename = tempnam(sys_get_temp_dir(), 'cake_log_test'); + $log = new ConsoleLog([ + 'stream' => $filename, + 'formatter' => [ + 'className' => JsonFormatter::class, + 'flags' => JSON_HEX_QUOT, + ], + ]); + $log->log('error', 'oh "{p1}"', ['p1' => 'noes']); + $fh = fopen($filename, 'r'); + $line = fgets($fh); + $this->assertStringContainsString('\u0022noes\u0022', $line); + + $entry = json_decode($line, true); + $this->assertSame('oh "noes"', $entry['message']); + } +} diff --git a/tests/TestCase/Log/Engine/FileLogTest.php b/tests/TestCase/Log/Engine/FileLogTest.php new file mode 100644 index 00000000000..8c55d581ec9 --- /dev/null +++ b/tests/TestCase/Log/Engine/FileLogTest.php @@ -0,0 +1,209 @@ +_deleteLogs(LOGS); + + $log = new FileLog(['path' => LOGS]); + $log->log('warning', 'Test warning'); + $this->assertFileExists(LOGS . 'error.log'); + + $result = file_get_contents(LOGS . 'error.log'); + $this->assertMatchesRegularExpression('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ warning: Test warning/', $result); + + $log->log('debug', 'Test warning'); + $this->assertFileExists(LOGS . 'debug.log'); + + $result = file_get_contents(LOGS . 'debug.log'); + $this->assertMatchesRegularExpression('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ debug: Test warning/', $result); + + $log->log('random', 'Test warning'); + $this->assertFileExists(LOGS . 'random.log'); + + $result = file_get_contents(LOGS . 'random.log'); + $this->assertMatchesRegularExpression('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ random: Test warning/', $result); + } + + /** + * test using the path setting to log logs in other places. + */ + public function testPathSetting(): void + { + $path = TMP . 'tests' . DS; + $this->_deleteLogs($path); + + $log = new FileLog(compact('path')); + $log->log('warning', 'Test warning'); + $this->assertFileExists($path . 'error.log'); + } + + /** + * test log rotation + */ + public function testRotation(): void + { + $path = TMP . 'tests' . DS; + $this->_deleteLogs($path); + + file_put_contents($path . 'error.log', "this text is under 35 bytes\n"); + $log = new FileLog([ + 'path' => $path, + 'size' => 35, + 'rotate' => 2, + ]); + $log->log('warning', 'Test warning one'); + $this->assertFileExists($path . 'error.log'); + + $result = file_get_contents($path . 'error.log'); + $this->assertMatchesRegularExpression('/warning: Test warning one/', $result); + $this->assertCount(0, glob($path . 'error.log.*')); + + clearstatcache(); + $log->log('warning', 'Test warning second'); + + $files = glob($path . 'error.log.*'); + $this->assertCount(1, $files); + + $result = file_get_contents($files[0]); + $this->assertMatchesRegularExpression('/this text is under 35 bytes/', $result); + $this->assertMatchesRegularExpression('/warning: Test warning one/', $result); + + sleep(1); + clearstatcache(); + $log->log('warning', 'Test warning third'); + + $result = file_get_contents($path . 'error.log'); + $this->assertMatchesRegularExpression('/warning: Test warning third/', $result); + + $files = glob($path . 'error.log.*'); + $this->assertCount(2, $files); + + $result = file_get_contents($files[0]); + $this->assertMatchesRegularExpression('/this text is under 35 bytes/', $result); + + $result = file_get_contents($files[1]); + $this->assertMatchesRegularExpression('/warning: Test warning second/', $result); + + file_put_contents($path . 'error.log.0000000000', "The oldest log file with over 35 bytes.\n"); + + sleep(1); + clearstatcache(); + $log->log('warning', 'Test warning fourth'); + + // rotate count reached so file count should not increase + $files = glob($path . 'error.log.*'); + $this->assertCount(2, $files); + + $result = file_get_contents($path . 'error.log'); + $this->assertMatchesRegularExpression('/warning: Test warning fourth/', $result); + + $result = file_get_contents(array_pop($files)); + $this->assertMatchesRegularExpression('/warning: Test warning third/', $result); + + $result = file_get_contents(array_pop($files)); + $this->assertMatchesRegularExpression('/warning: Test warning second/', $result); + + file_put_contents($path . 'debug.log', "this text is just greater than 35 bytes\n"); + $log = new FileLog([ + 'path' => $path, + 'size' => 35, + 'rotate' => 0, + ]); + file_put_contents($path . 'debug.log.0000000000', "The oldest log file with over 35 bytes.\n"); + $log->log('debug', 'Test debug'); + $this->assertFileExists($path . 'debug.log'); + + $result = file_get_contents($path . 'debug.log'); + $this->assertMatchesRegularExpression('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ debug: Test debug/', $result); + $this->assertFalse(strstr($result, 'greater than 5 bytes')); + $this->assertCount(0, glob($path . 'debug.log.*')); + } + + public function testMaskSetting(): void + { + if (DS === '\\') { + $this->markTestSkipped('File permission testing does not work on Windows.'); + } + + $path = TMP . 'tests' . DS; + $this->_deleteLogs($path); + + $log = new FileLog(['path' => $path, 'mask' => 0666]); + $log->log('warning', 'Test warning one'); + $result = substr(sprintf('%o', fileperms($path . 'error.log')), -4); + $expected = '0666'; + $this->assertSame($expected, $result); + unlink($path . 'error.log'); + + $log = new FileLog(['path' => $path, 'mask' => 0644]); + $log->log('warning', 'Test warning two'); + $result = substr(sprintf('%o', fileperms($path . 'error.log')), -4); + $expected = '0644'; + $this->assertSame($expected, $result); + unlink($path . 'error.log'); + + $log = new FileLog(['path' => $path, 'mask' => 0640]); + $log->log('warning', 'Test warning three'); + $result = substr(sprintf('%o', fileperms($path . 'error.log')), -4); + $expected = '0640'; + $this->assertSame($expected, $result); + unlink($path . 'error.log'); + } + + /** + * helper function to clears all log files in specified directory + * + * @param string $dir + */ + protected function _deleteLogs($dir): void + { + $files = array_merge(glob($dir . '*.log'), glob($dir . '*.log.*')); + foreach ($files as $file) { + unlink($file); + } + } + + /** + * test dateFormat option + */ + public function testDateFormat(): void + { + $this->_deleteLogs(LOGS); + + // 'c': ISO 8601 date (added in PHP 5) + $log = new FileLog(['path' => LOGS, 'formatter.dateFormat' => 'c']); + $log->log('warning', 'Test warning'); + + $result = file_get_contents(LOGS . 'error.log'); + $this->assertMatchesRegularExpression('/^2[0-9]{3}-[0-9]+-[0-9]+T[0-9]+:[0-9]+:[0-9]+\+\d{2}:\d{2} warning: Test warning/', $result); + } +} diff --git a/tests/TestCase/Log/Engine/SyslogLogTest.php b/tests/TestCase/Log/Engine/SyslogLogTest.php new file mode 100644 index 00000000000..a333ea4f8e8 --- /dev/null +++ b/tests/TestCase/Log/Engine/SyslogLogTest.php @@ -0,0 +1,105 @@ +makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + $log->__construct(); + $log->shouldReceive('_open')->once()->with('', LOG_ODELAY, LOG_USER); + $log->log('debug', 'message'); + + $log = Mockery::mock(SyslogLog::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + $log->__construct(); + $log->setConfig([ + 'prefix' => 'thing', + 'flag' => LOG_NDELAY, + 'facility' => LOG_MAIL, + ]); + $log->shouldReceive('_open') + ->once() + ->with('thing', LOG_NDELAY, LOG_MAIL); + $log->log('debug', 'message'); + } + + /** + * Tests that single lines are written to syslog + */ + #[DataProvider('typesProvider')] + public function testWriteOneLine(string $type, int $expected): void + { + $log = Mockery::mock(SyslogLog::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + $log->__construct(); + $log->shouldReceive('_write')->once()->with($expected, $type . ': Foo'); + $log->log($type, 'Foo'); + } + + /** + * Tests that multiple lines are split and logged separately + */ + public function testWriteMultiLine(): void + { + $log = Mockery::mock(SyslogLog::class . '[_write]'); + $log->shouldAllowMockingProtectedMethods(); + + $log->shouldReceive('_write')->with(LOG_DEBUG, 'debug: Foo')->once(); + $log->shouldReceive('_write')->with(LOG_DEBUG, 'debug: Bar')->once(); + + $log->log('debug', "Foo\nBar"); + } + + /** + * Data provider for the write function test + * + * @return array + */ + public static function typesProvider(): array + { + return [ + ['emergency', LOG_EMERG], + ['alert', LOG_ALERT], + ['critical', LOG_CRIT], + ['error', LOG_ERR], + ['warning', LOG_WARNING], + ['notice', LOG_NOTICE], + ['info', LOG_INFO], + ['debug', LOG_DEBUG], + ]; + } +} diff --git a/tests/TestCase/Log/LogTest.php b/tests/TestCase/Log/LogTest.php new file mode 100644 index 00000000000..936efc68dee --- /dev/null +++ b/tests/TestCase/Log/LogTest.php @@ -0,0 +1,674 @@ + + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://cakephp.org CakePHP(tm) Project + * @since 1.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Log; + +use BadMethodCallException; +use Cake\Core\Exception\CakeException; +use Cake\Log\Engine\FileLog; +use Cake\Log\Log; +use Cake\TestSuite\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Log\Engine\TestAppLog; +use TestPlugin\Log\Engine\TestPluginLog; + +/** + * LogTest class + */ +class LogTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + Log::reset(); + $this->clearPlugins(); + } + + protected function tearDown(): void + { + parent::tearDown(); + Log::reset(); + } + + /** + * test importing loggers from app/libs and plugins. + */ + public function testImportingLoggers(): void + { + static::setAppNamespace(); + $this->loadPlugins(['TestPlugin']); + + Log::setConfig('libtest', [ + 'engine' => 'TestApp', + ]); + Log::setConfig('plugintest', [ + 'engine' => 'TestPlugin.TestPlugin', + ]); + + $result = Log::engine('libtest'); + $this->assertInstanceOf(TestAppLog::class, $result); + $this->assertContains('libtest', Log::configured()); + + $result = Log::engine('plugintest'); + $this->assertInstanceOf(TestPluginLog::class, $result); + $this->assertContains('libtest', Log::configured()); + $this->assertContains('plugintest', Log::configured()); + + Log::write(LOG_INFO, 'TestPluginLog is not a BaseLog descendant'); + + $this->clearPlugins(); + } + + /** + * test all the errors from failed logger imports + */ + public function testImportingLoggerFailure(): void + { + $this->expectException(CakeException::class); + Log::setConfig('fail', []); + Log::engine('fail'); + } + + /** + * test config() with valid key name + */ + public function testValidKeyName(): void + { + Log::setConfig('valid', ['engine' => 'File']); + $stream = Log::engine('valid'); + $this->assertInstanceOf(FileLog::class, $stream); + } + + /** + * test config() with valid numeric key name + */ + public function testValidKeyNameNumeric(): void + { + Log::setConfig('404', ['engine' => 'File']); + $stream = Log::engine('404'); + $this->assertInstanceOf(FileLog::class, $stream); + + $configured = Log::configured(); + $this->assertSame(['404'], $configured); + } + + /** + * explicit tests for drop() + */ + public function testDrop(): void + { + Log::setConfig('file', [ + 'engine' => 'File', + 'path' => LOGS, + ]); + $result = Log::configured(); + $this->assertContains('file', $result); + + $this->assertTrue(Log::drop('file'), 'Should be dropped'); + $this->assertFalse(Log::drop('file'), 'Already gone'); + + $result = Log::configured(); + $this->assertNotContains('file', $result); + } + + /** + * test invalid level + */ + public function testInvalidLevel(): void + { + $this->expectException(InvalidArgumentException::class); + Log::setConfig('myengine', ['engine' => 'File']); + Log::write('invalid', 'This will not be logged'); + } + + /** + * Provider for config() tests. + * + * @return array + */ + public static function configProvider(): array + { + return [ + 'Array of data using engine key.' => [[ + 'engine' => 'File', + 'path' => TMP . 'tests', + ]], + 'Array of data using classname key.' => [[ + 'className' => 'File', + 'path' => TMP . 'tests', + ]], + 'Direct instance' => [new FileLog(['path' => LOGS])], + ]; + } + + /** + * Test the various config call signatures. + * + * @param mixed $settings + */ + #[DataProvider('configProvider')] + public function testConfigVariants($settings): void + { + Log::setConfig('test', $settings); + $this->assertContains('test', Log::configured()); + $this->assertInstanceOf(FileLog::class, Log::engine('test')); + Log::drop('test'); + } + + /** + * Test the various setConfig call signatures. + * + * @param mixed $settings + */ + #[DataProvider('configProvider')] + public function testSetConfigVariants($settings): void + { + Log::setConfig('test', $settings); + $this->assertContains('test', Log::configured()); + $this->assertInstanceOf(FileLog::class, Log::engine('test')); + Log::drop('test'); + } + + /** + * Test that config() can read data back + */ + public function testConfigRead(): void + { + $config = [ + 'engine' => 'File', + 'path' => LOGS, + ]; + Log::setConfig('tests', $config); + + $expected = $config; + $expected['className'] = $config['engine']; + unset($expected['engine']); + $this->assertSame($expected, Log::getConfig('tests')); + } + + /** + * Ensure you cannot reconfigure a log adapter. + */ + public function testConfigErrorOnReconfigure(): void + { + $this->expectException(BadMethodCallException::class); + Log::setConfig('tests', ['engine' => 'File', 'path' => TMP]); + Log::setConfig('tests', ['engine' => 'Apc']); + } + + /** + * testLogFileWriting method + */ + public function testLogFileWriting(): void + { + $this->_resetLogConfig(); + if (file_exists(LOGS . 'error.log')) { + unlink(LOGS . 'error.log'); + } + $result = Log::write(LOG_WARNING, 'Test warning'); + $this->assertTrue($result); + $this->assertFileExists(LOGS . 'error.log'); + unlink(LOGS . 'error.log'); + + Log::write(LOG_WARNING, 'Test warning 1'); + Log::write(LOG_WARNING, 'Test warning 2'); + $result = file_get_contents(LOGS . 'error.log'); + $this->assertMatchesRegularExpression('/^2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ warning: Test warning 1/', $result); + $this->assertMatchesRegularExpression('/2[0-9]{3}-[0-9]+-[0-9]+ [0-9]+:[0-9]+:[0-9]+ warning: Test warning 2$/', $result); + unlink(LOGS . 'error.log'); + } + + /** + * test selective logging by level/type + */ + public function testSelectiveLoggingByLevel(): void + { + if (file_exists(LOGS . 'spam.log')) { + unlink(LOGS . 'spam.log'); + } + if (file_exists(LOGS . 'eggs.log')) { + unlink(LOGS . 'eggs.log'); + } + Log::setConfig('spam', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => 'debug', + 'file' => 'spam', + ]); + Log::setConfig('eggs', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['eggs', 'debug', 'error', 'warning'], + 'file' => 'eggs', + ]); + + $testMessage = 'selective logging'; + Log::write('warning', $testMessage); + + $this->assertFileExists(LOGS . 'eggs.log'); + $this->assertFileDoesNotExist(LOGS . 'spam.log'); + + Log::write('debug', $testMessage); + $this->assertFileExists(LOGS . 'spam.log'); + + $contents = file_get_contents(LOGS . 'spam.log'); + $this->assertStringContainsString('debug: ' . $testMessage, $contents); + $contents = file_get_contents(LOGS . 'eggs.log'); + $this->assertStringContainsString('debug: ' . $testMessage, $contents); + + if (file_exists(LOGS . 'spam.log')) { + unlink(LOGS . 'spam.log'); + } + if (file_exists(LOGS . 'eggs.log')) { + unlink(LOGS . 'eggs.log'); + } + } + + /** + * test selective logging by level using the `types` attribute + */ + public function testSelectiveLoggingByLevelUsingTypes(): void + { + if (file_exists(LOGS . 'spam.log')) { + unlink(LOGS . 'spam.log'); + } + if (file_exists(LOGS . 'eggs.log')) { + unlink(LOGS . 'eggs.log'); + } + Log::setConfig('spam', [ + 'engine' => 'File', + 'path' => LOGS, + 'types' => 'debug', + 'file' => 'spam', + ]); + Log::setConfig('eggs', [ + 'engine' => 'File', + 'path' => LOGS, + 'types' => ['eggs', 'debug', 'error', 'warning'], + 'file' => 'eggs', + ]); + + $testMessage = 'selective logging'; + Log::write('warning', $testMessage); + + $this->assertFileExists(LOGS . 'eggs.log'); + $this->assertFileDoesNotExist(LOGS . 'spam.log'); + + Log::write('debug', $testMessage); + $this->assertFileExists(LOGS . 'spam.log'); + + $contents = file_get_contents(LOGS . 'spam.log'); + $this->assertStringContainsString('debug: ' . $testMessage, $contents); + $contents = file_get_contents(LOGS . 'eggs.log'); + $this->assertStringContainsString('debug: ' . $testMessage, $contents); + + if (file_exists(LOGS . 'spam.log')) { + unlink(LOGS . 'spam.log'); + } + if (file_exists(LOGS . 'eggs.log')) { + unlink(LOGS . 'eggs.log'); + } + } + + protected function _resetLogConfig(): void + { + Log::setConfig('debug', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['notice', 'info', 'debug'], + 'file' => 'debug', + ]); + Log::setConfig('error', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + 'file' => 'error', + ]); + } + + protected function _deleteLogs(): void + { + if (file_exists(LOGS . 'shops.log')) { + unlink(LOGS . 'shops.log'); + } + if (file_exists(LOGS . 'error.log')) { + unlink(LOGS . 'error.log'); + } + if (file_exists(LOGS . 'debug.log')) { + unlink(LOGS . 'debug.log'); + } + if (file_exists(LOGS . 'bogus.log')) { + unlink(LOGS . 'bogus.log'); + } + if (file_exists(LOGS . 'spam.log')) { + unlink(LOGS . 'spam.log'); + } + if (file_exists(LOGS . 'eggs.log')) { + unlink(LOGS . 'eggs.log'); + } + } + + /** + * test scoped logging + */ + public function testScopedLogging(): void + { + $this->_deleteLogs(); + $this->_resetLogConfig(); + Log::setConfig('shops', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['info', 'debug', 'warning'], + 'scopes' => ['transactions', 'orders'], + 'file' => 'shops', + ]); + + Log::write('debug', 'debug message', 'transactions'); + $this->assertFileDoesNotExist(LOGS . 'error.log'); + $this->assertFileExists(LOGS . 'shops.log'); + $this->assertFileExists(LOGS . 'debug.log'); + + $this->_deleteLogs(); + + Log::write('warning', 'warning message', 'orders'); + $this->assertFileExists(LOGS . 'error.log'); + $this->assertFileExists(LOGS . 'shops.log'); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + + $this->_deleteLogs(); + + Log::write('error', 'error message', ['scope' => 'orders']); + $this->assertFileExists(LOGS . 'error.log'); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + $this->assertFileDoesNotExist(LOGS . 'shops.log'); + + $this->_deleteLogs(); + + Log::drop('shops'); + } + + /** + * Test scoped logging backwards compat + */ + public function testScopedLoggingBackwardsCompat(): void + { + $this->_deleteLogs(); + + Log::setConfig('debug', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['notice', 'info', 'debug'], + 'file' => 'debug', + 'scopes' => false, + ]); + + $this->deprecated(function (): void { + Log::write('debug', 'debug message', 'orders'); + }); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + } + + /** + * Test scoped logging without the default loggers catching everything + */ + public function testScopedLoggingStrict(): void + { + $this->_deleteLogs(); + + Log::setConfig('debug', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['notice', 'info', 'debug'], + 'file' => 'debug', + 'scopes' => null, + ]); + Log::setConfig('shops', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['info', 'debug', 'warning'], + 'file' => 'shops', + 'scopes' => ['transactions', 'orders'], + ]); + + Log::write('debug', 'debug message'); + $this->assertFileDoesNotExist(LOGS . 'shops.log'); + $this->assertFileExists(LOGS . 'debug.log'); + + $this->_deleteLogs(); + + Log::write('debug', 'debug message', 'orders'); + $this->assertFileExists(LOGS . 'shops.log'); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + + $this->_deleteLogs(); + + Log::drop('shops'); + } + + /** + * test scoped logging with convenience methods + */ + public function testConvenienceScopedLogging(): void + { + if (file_exists(LOGS . 'shops.log')) { + unlink(LOGS . 'shops.log'); + } + if (file_exists(LOGS . 'error.log')) { + unlink(LOGS . 'error.log'); + } + if (file_exists(LOGS . 'debug.log')) { + unlink(LOGS . 'debug.log'); + } + + $this->_resetLogConfig(); + Log::setConfig('shops', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['info', 'debug', 'notice', 'warning'], + 'scopes' => ['transactions', 'orders'], + 'file' => 'shops', + ]); + + Log::info('info message', 'transactions'); + $this->assertFileDoesNotExist(LOGS . 'error.log'); + $this->assertFileExists(LOGS . 'shops.log'); + $this->assertFileExists(LOGS . 'debug.log'); + + $this->_deleteLogs(); + + Log::error('error message', 'orders'); + $this->assertFileExists(LOGS . 'error.log'); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + $this->assertFileDoesNotExist(LOGS . 'shops.log'); + + $this->_deleteLogs(); + + Log::warning('warning message', 'orders'); + $this->assertFileExists(LOGS . 'error.log'); + $this->assertFileExists(LOGS . 'shops.log'); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + + $this->_deleteLogs(); + + Log::drop('shops'); + } + + /** + * Test that scopes are exclusive and don't bleed. + */ + public function testScopedLoggingExclusive(): void + { + $this->_deleteLogs(); + + Log::setConfig('shops', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['debug', 'notice', 'warning'], + 'scopes' => ['transactions', 'orders'], + 'file' => 'shops.log', + ]); + Log::setConfig('eggs', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['debug', 'notice', 'warning'], + 'scopes' => ['eggs'], + 'file' => 'eggs.log', + ]); + + Log::write('debug', 'transactions message', 'transactions'); + $this->assertFileDoesNotExist(LOGS . 'eggs.log'); + $this->assertFileExists(LOGS . 'shops.log'); + + $this->_deleteLogs(); + + Log::write('debug', 'eggs message', ['scope' => ['eggs']]); + $this->assertFileExists(LOGS . 'eggs.log'); + $this->assertFileDoesNotExist(LOGS . 'shops.log'); + } + + /** + * testPassingScopeToEngine method + */ + public function testPassingScopeToEngine(): void + { + static::setAppNamespace(); + + Log::reset(); + + Log::setConfig('scope_test', [ + 'engine' => 'TestApp', + 'path' => LOGS, + 'levels' => ['notice', 'info', 'debug'], + 'scopes' => ['foo', 'bar'], + ]); + + /** @var \TestApp\Log\Engine\TestAppLog $engine */ + $engine = Log::engine('scope_test'); + $this->assertNull($engine->passedScope); + + Log::write('debug', 'test message', 'foo'); + $this->assertEquals(['scope' => ['foo']], $engine->passedScope); + + Log::write('debug', 'test message', ['foo', 'bar']); + $this->assertEquals(['scope' => ['foo', 'bar']], $engine->passedScope); + + $result = Log::write('debug', 'test message'); + $this->assertFalse($result); + } + + /** + * test convenience methods + */ + public function testConvenienceMethods(): void + { + $this->_deleteLogs(); + + Log::setConfig('debug', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['notice', 'info', 'debug'], + 'file' => 'debug', + ]); + Log::setConfig('error', [ + 'engine' => 'File', + 'path' => LOGS, + 'levels' => ['emergency', 'alert', 'critical', 'error', 'warning'], + 'file' => 'error', + ]); + + $testMessage = 'emergency message'; + Log::emergency($testMessage); + $contents = file_get_contents(LOGS . 'error.log'); + $this->assertMatchesRegularExpression('/(emergency|critical): ' . $testMessage . '/', $contents); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + $this->_deleteLogs(); + + $testMessage = 'alert message'; + Log::alert($testMessage); + $contents = file_get_contents(LOGS . 'error.log'); + $this->assertMatchesRegularExpression('/(alert|critical): ' . $testMessage . '/', $contents); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + $this->_deleteLogs(); + + $testMessage = 'critical message'; + Log::critical($testMessage); + $contents = file_get_contents(LOGS . 'error.log'); + $this->assertStringContainsString('critical: ' . $testMessage, $contents); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + $this->_deleteLogs(); + + $testMessage = 'error message'; + Log::error($testMessage); + $contents = file_get_contents(LOGS . 'error.log'); + $this->assertStringContainsString('error: ' . $testMessage, $contents); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + $this->_deleteLogs(); + + $testMessage = 'warning message'; + Log::warning($testMessage); + $contents = file_get_contents(LOGS . 'error.log'); + $this->assertStringContainsString('warning: ' . $testMessage, $contents); + $this->assertFileDoesNotExist(LOGS . 'debug.log'); + $this->_deleteLogs(); + + $testMessage = 'notice message'; + Log::notice($testMessage); + $contents = file_get_contents(LOGS . 'debug.log'); + $this->assertMatchesRegularExpression('/(notice|debug): ' . $testMessage . '/', $contents); + $this->assertFileDoesNotExist(LOGS . 'error.log'); + $this->_deleteLogs(); + + $testMessage = 'info message'; + Log::info($testMessage); + $contents = file_get_contents(LOGS . 'debug.log'); + $this->assertMatchesRegularExpression('/(info|debug): ' . $testMessage . '/', $contents); + $this->assertFileDoesNotExist(LOGS . 'error.log'); + $this->_deleteLogs(); + + $testMessage = 'debug message'; + Log::debug($testMessage); + $contents = file_get_contents(LOGS . 'debug.log'); + $this->assertStringContainsString('debug: ' . $testMessage, $contents); + $this->assertFileDoesNotExist(LOGS . 'error.log'); + $this->_deleteLogs(); + } + + /** + * Test that write() returns false on an unhandled message. + */ + public function testWriteUnhandled(): void + { + Log::drop('error'); + Log::drop('debug'); + + $result = Log::write('error', 'Bad stuff', 'impossible'); + $this->assertFalse($result); + } + + /** + * Tests using a callable for creating a Log engine + */ + public function testCreateLoggerWithCallable(): void + { + $instance = new FileLog(); + Log::setConfig('default', function ($alias) use ($instance) { + $this->assertSame('default', $alias); + + return $instance; + }); + $this->assertSame($instance, Log::engine('default')); + } +} diff --git a/tests/TestCase/Log/LogTraitTest.php b/tests/TestCase/Log/LogTraitTest.php new file mode 100644 index 00000000000..67fe9f13c85 --- /dev/null +++ b/tests/TestCase/Log/LogTraitTest.php @@ -0,0 +1,59 @@ +shouldReceive('log') + ->withSomeOfArgs(LogLevel::ERROR, 'Testing') + ->once(); + + $mock->shouldReceive('log') + ->withSomeOfArgs(LogLevel::DEBUG, 'message') + ->once(); + + Log::setConfig('trait_test', ['engine' => $mock]); + $subject = new class { + use LogTrait; + }; + + $subject->log('Testing'); + $subject->log('message', 'debug'); + } +} diff --git a/tests/TestCase/Mailer/MailerAwareTraitTest.php b/tests/TestCase/Mailer/MailerAwareTraitTest.php new file mode 100644 index 00000000000..dc7bb32b460 --- /dev/null +++ b/tests/TestCase/Mailer/MailerAwareTraitTest.php @@ -0,0 +1,57 @@ +assertInstanceOf(TestMailer::class, $stub->getMailer('Test')); + + $stub = new Stub(); + $mailer = $stub->getMailer('Test', ['from' => 'admad@cakephp.org']); + $this->assertSame(['admad@cakephp.org' => 'admad@cakephp.org'], $mailer->getFrom()); + + static::setAppNamespace($originalAppNamespace); + } + + /** + * Test exception thrown by getMailer. + */ + public function testGetMailerThrowsException(): void + { + $this->expectException(MissingMailerException::class); + $this->expectExceptionMessage('Mailer class `Test` could not be found.'); + $stub = new Stub(); + $stub->getMailer('Test'); + } +} diff --git a/tests/TestCase/Mailer/MailerDefaultProfileRestorationTest.php b/tests/TestCase/Mailer/MailerDefaultProfileRestorationTest.php new file mode 100644 index 00000000000..55c871d2d09 --- /dev/null +++ b/tests/TestCase/Mailer/MailerDefaultProfileRestorationTest.php @@ -0,0 +1,42 @@ + 'cakephp']) extends Mailer { + public bool $testIsCalled = false; + + public function test($to, $subject) + { + $this->testIsCalled = true; + } + + public function deliver(string $content = ''): array + { + return []; + } + }; + + $mailer->send('test', ['foo', 'bar']); + $this->assertSame('cakephp', $mailer->viewBuilder()->getTemplate()); + $this->assertTrue($mailer->testIsCalled); + } +} diff --git a/tests/TestCase/Mailer/MailerSendActionTest.php b/tests/TestCase/Mailer/MailerSendActionTest.php new file mode 100644 index 00000000000..1f8f6a36db3 --- /dev/null +++ b/tests/TestCase/Mailer/MailerSendActionTest.php @@ -0,0 +1,43 @@ +testIsCalled = true; + } + + public function deliver(string $content = ''): array + { + return []; + } + }; + + $mailer->send('test', ['foo', 'bar']); + + $this->assertNull($mailer->viewBuilder()->getTemplate()); + $this->assertTrue($mailer->testIsCalled); + } +} diff --git a/tests/TestCase/Mailer/MailerSendFailsEmailIsReset.php b/tests/TestCase/Mailer/MailerSendFailsEmailIsReset.php new file mode 100644 index 00000000000..2430f6c018a --- /dev/null +++ b/tests/TestCase/Mailer/MailerSendFailsEmailIsReset.php @@ -0,0 +1,52 @@ +restoreIsCalled = true; + + return $this; + } + }; + + try { + $mailer->send('welcome', ['foo', 'bar']); + $this->fail('Exception should bubble up.'); + } catch (RuntimeException) { + $this->assertTrue($mailer->restoreIsCalled, 'Exception was raised'); + } + } +} diff --git a/tests/TestCase/Mailer/MailerSendWithUnsetTemplateDefaultsToActionNameTest.php b/tests/TestCase/Mailer/MailerSendWithUnsetTemplateDefaultsToActionNameTest.php new file mode 100644 index 00000000000..7011e24203f --- /dev/null +++ b/tests/TestCase/Mailer/MailerSendWithUnsetTemplateDefaultsToActionNameTest.php @@ -0,0 +1,47 @@ +testIsCalled = true; + } + + public function deliver(string $content = ''): array + { + return []; + } + + protected function restore() + { + return $this; + } + }; + + $mailer->send('test', ['foo', 'bar']); + $this->assertSame('test', $mailer->viewBuilder()->getTemplate()); + $this->assertTrue($mailer->testIsCalled); + } +} diff --git a/tests/TestCase/Mailer/MailerTest.php b/tests/TestCase/Mailer/MailerTest.php new file mode 100644 index 00000000000..8b27ef24e37 --- /dev/null +++ b/tests/TestCase/Mailer/MailerTest.php @@ -0,0 +1,1372 @@ +transports = [ + 'debug' => [ + 'className' => 'Debug', + ], + 'badClassName' => [ + 'className' => 'TestFalse', + ], + ]; + + TransportFactory::setConfig($this->transports); + + $this->mailer = new TestMailer(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + + TransportFactory::drop('debug'); + TransportFactory::drop('badClassName'); + Mailer::drop('test'); + Mailer::drop('default'); + Log::drop('email'); + } + + /** + * testTransport method + */ + public function testTransport(): void + { + $result = $this->mailer->setTransport('debug'); + $this->assertSame($this->mailer, $result); + + $result = $this->mailer->getTransport(); + $this->assertInstanceOf(DebugTransport::class, $result); + + $instance = new class extends DebugTransport { + }; + $this->mailer->setTransport($instance); + $this->assertSame($instance, $this->mailer->getTransport()); + } + + /** + * Test that using unknown transports fails. + */ + public function testTransportInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `Invalid` transport configuration does not exist'); + $this->mailer->setTransport('Invalid'); + } + + /** + * testMessage function + * + * @deprecated + */ + public function testSetMessage(): void + { + $message = $this->mailer->getMessage(); + $this->assertInstanceOf(Message::class, $message); + + $newMessage = new Message(); + $this->deprecated(function () use ($newMessage): void { + $this->mailer->setMessage($newMessage); + }); + $this->assertSame($newMessage, $this->mailer->getMessage()); + $this->assertNotSame($message, $newMessage); + } + + /** + * Test reading/writing configuration profiles. + */ + public function testConfig(): void + { + $settings = [ + 'to' => 'mark@example.com', + 'from' => 'noreply@example.com', + ]; + Mailer::setConfig('test', $settings); + $this->assertEquals($settings, Mailer::getConfig('test'), 'Should be the same.'); + + $mailer = new Mailer('test'); + $this->assertContains($settings['to'], $mailer->getTo()); + } + + /** + * Test that exceptions are raised on duplicate config set. + */ + public function testConfigErrorOnDuplicate(): void + { + $this->expectException(BadMethodCallException::class); + $settings = [ + 'to' => 'mark@example.com', + 'from' => 'noreply@example.com', + ]; + Mailer::setConfig('test', $settings); + Mailer::setConfig('test', $settings); + } + + /** + * testConstructWithConfigArray method + */ + public function testConstructWithConfigArray(): void + { + $configs = [ + 'from' => ['some@example.com' => 'My website'], + 'to' => 'test@example.com', + 'subject' => 'Test mail subject', + 'transport' => 'debug', + ]; + $this->mailer = new Mailer($configs); + + $result = $this->mailer->getTo(); + $this->assertEquals([$configs['to'] => $configs['to']], $result); + + $result = $this->mailer->getFrom(); + $this->assertEquals($configs['from'], $result); + + $result = $this->mailer->getSubject(); + $this->assertSame($configs['subject'], $result); + + $result = $this->mailer->getTransport(); + $this->assertInstanceOf(DebugTransport::class, $result); + + $result = $this->mailer->deliver('This is the message'); + + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } + + /** + * testConfigArrayWithLayoutWithoutTemplate method + */ + public function testConfigArrayWithLayoutWithoutTemplate(): void + { + $configs = [ + 'from' => ['some@example.com' => 'My website'], + 'to' => 'test@example.com', + 'subject' => 'Test mail subject', + 'transport' => 'debug', + 'layout' => 'custom', + ]; + $this->mailer = new Mailer($configs); + + $template = $this->mailer->viewBuilder()->getTemplate(); + $layout = $this->mailer->viewBuilder()->getLayout(); + $this->assertNull($template); + $this->assertSame($configs['layout'], $layout); + } + + /** + * testConstructWithConfigString method + */ + public function testConstructWithConfigString(): void + { + $configs = [ + 'from' => ['some@example.com' => 'My website'], + 'to' => 'test@example.com', + 'subject' => 'Test mail subject', + 'transport' => 'debug', + ]; + Mailer::setConfig('test', $configs); + + $this->mailer = new Mailer('test'); + + $result = $this->mailer->getTo(); + $this->assertEquals([$configs['to'] => $configs['to']], $result); + + $result = $this->mailer->getFrom(); + $this->assertEquals($configs['from'], $result); + + $result = $this->mailer->getSubject(); + $this->assertSame($configs['subject'], $result); + + $result = $this->mailer->getTransport(); + $this->assertInstanceOf(DebugTransport::class, $result); + + $result = $this->mailer->deliver('This is the message'); + + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } + + /** + * test profile method + */ + public function testSetProfile(): void + { + $config = ['to' => 'foo@bar.com']; + $this->mailer->setProfile($config); + $this->assertSame(['foo@bar.com' => 'foo@bar.com'], $this->mailer->getTo()); + } + + /** + * test that default profile is used by constructor if available. + */ + public function testDefaultProfile(): void + { + $config = ['to' => 'foo@bar.com', 'from' => 'from@bar.com']; + + Configure::write('Mailer.default', $config); + Mailer::setConfig(Configure::consume('Mailer')); + + $mailer = new Mailer(); + $this->assertSame(['foo@bar.com' => 'foo@bar.com'], $mailer->getTo()); + $this->assertSame(['from@bar.com' => 'from@bar.com'], $mailer->getFrom()); + + Configure::delete('Mailer'); + Mailer::drop('default'); + } + + /** + * Test that using an invalid profile fails. + */ + public function testProfileInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown email configuration `derp`.'); + $mailer = new Mailer(); + $mailer->setProfile('derp'); + } + + /** + * testConfigString method + */ + public function testUseConfigString(): void + { + $config = [ + 'from' => ['some@example.com' => 'My website'], + 'to' => ['test@example.com' => 'Testname'], + 'subject' => 'Test mail subject', + 'transport' => 'debug', + 'theme' => 'TestTheme', + 'helpers' => ['Html', 'Form'], + 'autoLayout' => false, + ]; + Mailer::setConfig('test', $config); + $this->mailer->setProfile('test'); + + $result = $this->mailer->getTo(); + $this->assertEquals($config['to'], $result); + + $result = $this->mailer->getFrom(); + $this->assertEquals($config['from'], $result); + + $result = $this->mailer->getSubject(); + $this->assertSame($config['subject'], $result); + + $result = $this->mailer->viewBuilder()->getTheme(); + $this->assertSame($config['theme'], $result); + + $result = $this->mailer->getTransport(); + $this->assertInstanceOf(DebugTransport::class, $result); + + $result = $this->mailer->viewBuilder()->getHelpers(); + $this->assertEquals(['Html' => [], 'Form' => []], $result); + + $this->assertFalse($this->mailer->viewBuilder()->isAutoLayoutEnabled()); + + Mailer::drop('test'); + } + + /** + * CakeEmailTest::testDefaultTransport() + */ + public function testDefaultTransport(): void + { + TransportFactory::drop('default'); + + $instance = new class extends AbstractTransport { + public function send(Message $message): array + { + return []; + } + }; + $config = ['from' => 'tester@example.org', 'transport' => 'default']; + + Mailer::setConfig('default', $config); + TransportFactory::setConfig('default', $instance); + + $em = new Mailer('default'); + + $this->assertSame($instance, $em->getTransport()); + + TransportFactory::drop('default'); + } + + public function testProxies(): void + { + $result = (new Mailer())->setHeaders(['X-Something' => 'nice']); + $this->assertInstanceOf(Mailer::class, $result); + $header = $result->getMessage()->getHeaders(); + $this->assertSame('nice', $header['X-Something']); + + $result = (new Mailer())->setAttachments([ + ['file' => APP . 'ApplicationWithDefaultRoutes.php', 'mimetype' => 'text/plain'], + ]); + $this->assertInstanceOf(Mailer::class, $result); + $this->assertSame( + ['ApplicationWithDefaultRoutes.php' => ['file' => APP . 'ApplicationWithDefaultRoutes.php', 'mimetype' => 'text/plain']], + $result->getMessage()->getAttachments(), + ); + } + + /** + * Test that get/set methods can be proxied. + */ + public function testGetSetProxies(): void + { + $mailer = new Mailer(); + $result = $mailer + ->setTo('test@example.com') + ->setCc('cc@example.com'); + $this->assertSame($result, $mailer); + + $this->assertSame(['test@example.com' => 'test@example.com'], $result->getTo()); + $this->assertSame(['cc@example.com' => 'cc@example.com'], $result->getCc()); + } + + public function testSet(): void + { + $result = (new Mailer())->setViewVars('key', 'value'); + $this->assertInstanceOf(Mailer::class, $result); + $this->assertSame(['key' => 'value'], $result->getRenderer()->viewBuilder()->getVars()); + } + + /** + * testRenderWithLayoutAndAttachment method + */ + public function testRenderWithLayoutAndAttachment(): void + { + $this->mailer->setEmailFormat('html'); + $this->mailer->viewBuilder()->setTemplate('html'); + $this->mailer->setAttachments([APP . 'ApplicationWithDefaultRoutes.php']); + $this->mailer->render(); + $result = $this->mailer->getBody(); + $this->assertNotEmpty($result); + + $result = $this->mailer->getBoundary(); + $this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $result); + } + + /** + * Calling send() with no parameters should not overwrite the view variables. + */ + public function testSendWithNoContentDoesNotOverwriteViewVar(): void + { + $this->mailer->reset(); + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('you@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('text'); + $this->mailer->viewBuilder() + ->setTemplate('default') + ->setVars([ + 'content' => 'A message to you', + ]); + + $result = $this->mailer->send(); + $this->assertStringContainsString('A message to you', $result['message']); + } + + /** + * testSendWithContent method + */ + public function testSendWithContent(): void + { + $this->mailer->reset(); + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + + $result = $this->mailer->deliver("Here is my body, with multi lines.\nThis is the second line.\r\n\r\nAnd the last."); + $expected = ['headers', 'message']; + $this->assertEquals($expected, array_keys($result)); + $expected = "Here is my body, with multi lines.\r\nThis is the second line.\r\n\r\nAnd the last.\r\n\r\n"; + + $this->assertSame($expected, $result['message']); + $this->assertStringContainsString('Date: ', $result['headers']); + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + + $result = $this->mailer->deliver('Other body'); + $expected = "Other body\r\n\r\n"; + $this->assertSame($expected, $result['message']); + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } + + /** + * test send without a transport method + */ + public function testSendWithoutTransport(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage( + 'Transport was not defined. You must set on using setTransport() or set `transport` option in your mailer profile.', + ); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->send(); + } + + /** + * Test send() with no template. + */ + public function testSendNoTemplateWithAttachments(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('text'); + $this->mailer->setAttachments([APP . 'ApplicationWithDefaultRoutes.php']); + $result = $this->mailer->deliver('Hello'); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); + $expected = "--{$boundary}\r\n" . + "Content-Type: text/plain; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--{$boundary}\r\n" . + "Content-Disposition: attachment; filename=\"ApplicationWithDefaultRoutes.php\"\r\n" . + "Content-Type: text/x-php\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "\r\n"; + $this->assertStringContainsString($expected, $result['message']); + } + + /** + * Test send() with no template and data string attachment + */ + public function testSendNoTemplateWithDataStringAttachment(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('text'); + $data = file_get_contents(TEST_APP . 'webroot/img/cake.power.gif'); + $this->mailer->setAttachments(['cake.icon.gif' => [ + 'data' => $data, + 'mimetype' => 'image/gif', + ]]); + $result = $this->mailer->deliver('Hello'); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); + $expected = "--{$boundary}\r\n" . + "Content-Type: text/plain; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--{$boundary}\r\n" . + "Content-Disposition: attachment; filename=\"cake.icon.gif\"\r\n" . + "Content-Type: image/gif\r\n" . + "Content-Transfer-Encoding: base64\r\n\r\n"; + $expected .= chunk_split(base64_encode($data), 76, "\r\n"); + $this->assertStringContainsString($expected, $result['message']); + } + + /** + * Test send() with no template as both + */ + public function testSendNoTemplateWithAttachmentsAsBoth(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('both'); + $this->mailer->setAttachments([CORE_PATH . 'VERSION.txt']); + $result = $this->mailer->deliver('Hello'); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); + $expected = "--{$boundary}\r\n" . + "Content-Type: multipart/alternative; boundary=\"alt-{$boundary}\"\r\n" . + "\r\n" . + "--alt-{$boundary}\r\n" . + "Content-Type: text/plain; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--alt-{$boundary}\r\n" . + "Content-Type: text/html; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--alt-{$boundary}--\r\n" . + "\r\n" . + "--{$boundary}\r\n" . + "Content-Disposition: attachment; filename=\"VERSION.txt\"\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "\r\n"; + $this->assertStringContainsString($expected, $result['message']); + } + + /** + * Test setting inline attachments and messages. + */ + public function testSendWithInlineAttachments(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('both'); + $this->mailer->setAttachments([ + 'cake.png' => [ + 'file' => CORE_PATH . 'VERSION.txt', + 'contentId' => 'abc123', + ], + ]); + $result = $this->mailer->deliver('Hello'); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); + $expected = "--{$boundary}\r\n" . + "Content-Type: multipart/related; boundary=\"rel-{$boundary}\"\r\n" . + "\r\n" . + "--rel-{$boundary}\r\n" . + "Content-Type: multipart/alternative; boundary=\"alt-{$boundary}\"\r\n" . + "\r\n" . + "--alt-{$boundary}\r\n" . + "Content-Type: text/plain; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--alt-{$boundary}\r\n" . + "Content-Type: text/html; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--alt-{$boundary}--\r\n" . + "\r\n" . + "--rel-{$boundary}\r\n" . + "Content-Disposition: inline; filename=\"cake.png\"\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "Content-ID: \r\n" . + "\r\n"; + $this->assertStringContainsString($expected, $result['message']); + $this->assertStringContainsString('--rel-' . $boundary . '--', $result['message']); + $this->assertStringContainsString('--' . $boundary . '--', $result['message']); + } + + /** + * Test setting inline attachments and HTML only messages. + */ + public function testSendWithInlineAttachmentsHtmlOnly(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('html'); + $this->mailer->setAttachments([ + 'cake.png' => [ + 'file' => CORE_PATH . 'VERSION.txt', + 'contentId' => 'abc123', + ], + ]); + $result = $this->mailer->deliver('Hello'); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); + $expected = "--{$boundary}\r\n" . + "Content-Type: multipart/related; boundary=\"rel-{$boundary}\"\r\n" . + "\r\n" . + "--rel-{$boundary}\r\n" . + "Content-Type: text/html; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--rel-{$boundary}\r\n" . + "Content-Disposition: inline; filename=\"cake.png\"\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "Content-ID: \r\n" . + "\r\n"; + $this->assertStringContainsString($expected, $result['message']); + $this->assertStringContainsString('--rel-' . $boundary . '--', $result['message']); + $this->assertStringContainsString('--' . $boundary . '--', $result['message']); + } + + /** + * Test setting inline attachments with multibyte names + */ + public function testSendWithInlineAttachmentsMultibyteName(): void + { + $name = '日本語の名前'; + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('html'); + $this->mailer->setAttachments([ + $name => [ + 'file' => CORE_PATH . 'VERSION.txt', + 'contentId' => 'abc123', + ], + ]); + $result = $this->mailer->deliver('Hello'); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); + $expected = "--{$boundary}\r\n" . + "Content-Type: multipart/related; boundary=\"rel-{$boundary}\"\r\n" . + "\r\n" . + "--rel-{$boundary}\r\n" . + "Content-Type: text/html; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--rel-{$boundary}\r\n" . + "Content-Disposition: inline; filename=\"ri ben yuno ming qian\"; filename*=utf-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E5%90%8D%E5%89%8D\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "Content-ID: \r\n" . + "\r\n"; + $this->assertStringContainsString($expected, $result['message']); + $this->assertStringContainsString('--rel-' . $boundary . '--', $result['message']); + $this->assertStringContainsString('--' . $boundary . '--', $result['message']); + + // Extract the encoded filename and decode it. + preg_match("/utf-8''(.*?)\s/", $result['message'], $matches); + $this->assertEquals(urldecode($matches[1]), $name); + } + + /** + * Test disabling content-disposition. + */ + public function testSendWithNoContentDispositionAttachments(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setEmailFormat('text'); + $this->mailer->setAttachments([ + 'cake.png' => [ + 'file' => CORE_PATH . 'VERSION.txt', + 'contentDisposition' => false, + ], + ]); + $result = $this->mailer->deliver('Hello'); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/mixed; boundary="' . $boundary . '"', $result['headers']); + $expected = "--{$boundary}\r\n" . + "Content-Type: text/plain; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'Hello' . + "\r\n" . + "\r\n" . + "\r\n" . + "--{$boundary}\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "\r\n"; + + $this->assertStringContainsString($expected, $result['message']); + $this->assertStringContainsString('--' . $boundary . '--', $result['message']); + } + + /** + * testSendRender method + */ + public function testSendRender(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTemplate('default'); + $result = $this->mailer->send(); + + $this->assertStringContainsString('This email was sent using the CakePHP Framework', $result['message']); + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } + + /** + * test sending and rendering with no layout + */ + public function testSendRenderNoLayout(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->viewBuilder() + ->setTemplate('default') + ->setVar('content', 'message body.') + ->disableAutoLayout(); + $result = $this->mailer->send(); + + $this->assertStringContainsString('message body.', $result['message']); + $this->assertStringNotContainsString('This email was sent using the CakePHP Framework', $result['message']); + } + + /** + * testSendRender both method + */ + public function testSendRenderBoth(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTemplate('default'); + $this->mailer->setEmailFormat('both'); + $result = $this->mailer->send(); + + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + + $boundary = $this->mailer->boundary; + $this->assertStringContainsString('Content-Type: multipart/alternative; boundary="' . $boundary . '"', $result['headers']); + + $expected = "--{$boundary}\r\n" . + "Content-Type: text/plain; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + "\r\n" . + "\r\n" . + 'This email was sent using the CakePHP Framework, https://cakephp.org.' . + "\r\n" . + "\r\n" . + "\r\n" . + "--{$boundary}\r\n" . + "Content-Type: text/html; charset=UTF-8\r\n" . + "Content-Transfer-Encoding: 8bit\r\n" . + "\r\n" . + 'assertStringStartsWith($expected, $result['message']); + + $expected = "\r\n" . + "\r\n" . + "\r\n" . + "\r\n" . + "--{$boundary}--\r\n"; + $this->assertStringEndsWith($expected, $result['message']); + } + + /** + * testSendRender method for ISO-2022-JP + */ + public function testSendRenderJapanese(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTemplate('default'); + $this->mailer->viewBuilder()->setLayout('japanese'); + $this->mailer->setCharset('ISO-2022-JP'); + $result = $this->mailer->send(); + + $expected = mb_convert_encoding('CakePHP Framework を使って送信したメールです。 https://cakephp.org.', 'ISO-2022-JP'); + $this->assertStringContainsString($expected, $result['message']); + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } + + /** + * testSendRenderThemed method + */ + public function testSendRenderThemed(): void + { + $this->loadPlugins(['TestTheme']); + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTheme('TestTheme'); + $this->mailer->viewBuilder()->setTemplate('themed'); + $result = $this->mailer->send(); + + $this->assertStringContainsString('In TestTheme', $result['message']); + $this->assertStringContainsString('/test_theme/img/test.jpg', $result['message']); + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + $this->assertStringContainsString('/test_theme/img/test.jpg', $result['message']); + $this->clearPlugins(); + } + + /** + * testSendRenderWithHTML method and assert line length is kept below the required limit + */ + public function testSendRenderWithHTML(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->setEmailFormat('html'); + $this->mailer->viewBuilder()->setTemplate('html'); + $result = $this->mailer->send(); + + $this->assertTextContains('

    HTML Ipsum Presents

    ', $result['message']); + $this->assertLineLengths($result['message']); + } + + /** + * testSendRenderWithVars method + */ + public function testSendRenderWithVars(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTemplate('custom'); + $this->mailer->setViewVars(['value' => 12345]); + $result = $this->mailer->send(); + + $this->assertStringContainsString('Here is your value: 12345', $result['message']); + } + + /** + * testSendRenderWithVars method for ISO-2022-JP + */ + public function testSendRenderWithVarsJapanese(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTemplate('japanese'); + $this->mailer->setViewVars(['value' => '日本語の差し込み123']); + $this->mailer->setCharset('ISO-2022-JP'); + $result = $this->mailer->send(); + + $expected = mb_convert_encoding('ここにあなたの設定した値が入ります: 日本語の差し込み123', 'ISO-2022-JP'); + $this->assertStringContainsString($expected, $result['message']); + } + + /** + * testSendRenderWithHelpers method + */ + public function testSendRenderWithHelpers(): void + { + $this->mailer->setTransport('debug'); + + $timestamp = time(); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder() + ->setTemplate('custom_helper') + ->setLayout('default') + ->setHelpers(['Time']); + $this->mailer->setViewVars(['time' => $timestamp]); + + $result = $this->mailer->send(); + $dateTime = new DateTime(); + $dateTime->setTimestamp($timestamp); + $this->assertStringContainsString('Right now: ' . $dateTime->format(DateTime::ATOM), $result['message']); + + $result = $this->mailer->viewBuilder()->getHelpers(); + $this->assertEquals(['Time' => []], $result); + } + + /** + * testSendRenderWithImage method + */ + public function testSendRenderWithImage(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTemplate('image'); + $this->mailer->setEmailFormat('html'); + $server = env('SERVER_NAME') ?: 'localhost'; + + if (env('SERVER_PORT') && env('SERVER_PORT') !== 80) { + $server .= ':' . env('SERVER_PORT'); + } + + $expected = 'cool imagemailer->send(); + $this->assertStringContainsString($expected, $result['message']); + } + + /** + * testSendRenderPlugin method + */ + public function testSendRenderPlugin(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->loadPlugins(['TestPlugin', 'TestPluginTwo', 'TestTheme']); + }); + + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + + $this->mailer->viewBuilder() + ->setTemplate('TestPlugin.test_plugin_tpl') + ->setLayout('default'); + $result = $this->mailer->send(); + $this->assertStringContainsString('Into TestPlugin.', $result['message']); + $this->assertStringContainsString('This email was sent using the CakePHP Framework', $result['message']); + + $this->mailer->viewBuilder() + ->setTemplate('TestPlugin.test_plugin_tpl') + ->setLayout('TestPlugin.plug_default'); + $result = $this->mailer->send(); + $this->assertStringContainsString('Into TestPlugin.', $result['message']); + $this->assertStringContainsString('This email was sent using the TestPlugin.', $result['message']); + + $this->mailer->viewBuilder() + ->setTemplate('TestPlugin.test_plugin_tpl') + ->setLayout('plug_default'); + $result = $this->mailer->send(); + $this->assertStringContainsString('Into TestPlugin.', $result['message']); + $this->assertStringContainsString('This email was sent using the TestPlugin.', $result['message']); + + $this->mailer->viewBuilder() + ->setTemplate('TestPlugin.test_plugin_tpl') + ->setLayout('TestPluginTwo.default'); + $result = $this->mailer->send(); + $this->assertStringContainsString('Into TestPlugin.', $result['message']); + $this->assertStringContainsString('This email was sent using TestPluginTwo.', $result['message']); + + // test plugin template overridden by theme + $this->mailer->viewBuilder()->setTheme('TestTheme'); + $result = $this->mailer->send(); + + $this->assertStringContainsString('Into TestPlugin. (themed)', $result['message']); + + $this->mailer->setViewVars(['value' => 12345]); + $this->mailer->viewBuilder() + ->setTemplate('custom') + ->setLayout('TestPlugin.plug_default'); + $result = $this->mailer->send(); + $this->assertStringContainsString('Here is your value: 12345', $result['message']); + $this->assertStringContainsString('This email was sent using the TestPlugin.', $result['message']); + $this->clearPlugins(); + } + + /** + * Test that a MissingTemplateException is thrown + */ + public function testMissingTemplateException(): void + { + $this->expectException(MissingTemplateException::class); + + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->viewBuilder()->setTemplate('fooo'); + $this->mailer->send(); + } + + /** + * testSendMultipleMIME method + */ + public function testSendMultipleMIME(): void + { + $this->mailer->setTransport('debug'); + + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->viewBuilder()->setTemplate('custom'); + $this->mailer->setProfile([]); + $this->mailer->setViewVars(['value' => 12345]); + $this->mailer->setEmailFormat('both'); + $this->mailer->send(); + + $message = $this->mailer->getBody(); + $boundary = $this->mailer->boundary; + $this->assertNotEmpty($boundary); + $this->assertContains('--' . $boundary, $message); + $this->assertContains('--' . $boundary . '--', $message); + + $this->mailer->setAttachments(['fake.php' => APP . 'ApplicationWithDefaultRoutes.php']); + $this->mailer->send(); + + $message = $this->mailer->getBody(); + $boundary = $this->mailer->boundary; + $this->assertNotEmpty($boundary); + $this->assertContains('--' . $boundary, $message); + $this->assertContains('--' . $boundary . '--', $message); + $this->assertContains('--alt-' . $boundary, $message); + $this->assertContains('--alt-' . $boundary . '--', $message); + } + + /** + * testSendAttachment method + */ + public function testSendAttachment(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile([]); + $this->mailer->setAttachments([APP . 'ApplicationWithDefaultRoutes.php']); + $this->mailer->setBodyText('body'); + $result = $this->mailer->send(); + $expected = "Content-Disposition: attachment; filename=\"ApplicationWithDefaultRoutes.php\"\r\n" . + "Content-Type: text/x-php\r\n" . + "Content-Transfer-Encoding: base64\r\n"; + $this->assertStringContainsString($expected, $result['message']); + + $this->mailer->setAttachments(['my.file.txt' => APP . 'ApplicationWithDefaultRoutes.php']); + $this->mailer->setBodyText('body'); + $result = $this->mailer->send(); + $expected = "Content-Disposition: attachment; filename=\"my.file.txt\"\r\n" . + "Content-Type: text/x-php\r\n" . + "Content-Transfer-Encoding: base64\r\n"; + $this->assertStringContainsString($expected, $result['message']); + + $this->mailer->setAttachments(['file.txt' => ['file' => APP . 'ApplicationWithDefaultRoutes.php', 'mimetype' => 'text/plain']]); + $this->mailer->setBodyText('body'); + $result = $this->mailer->send(); + $expected = "Content-Disposition: attachment; filename=\"file.txt\"\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Transfer-Encoding: base64\r\n"; + $this->assertStringContainsString($expected, $result['message']); + + $this->mailer->setAttachments(['file2.txt' => ['file' => APP . 'ApplicationWithDefaultRoutes.php', 'mimetype' => 'text/plain', 'contentId' => 'a1b1c1']]); + $this->mailer->setBodyText('body'); + $result = $this->mailer->send(); + $expected = "Content-Disposition: inline; filename=\"file2.txt\"\r\n" . + "Content-Type: text/plain\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "Content-ID: \r\n"; + $this->assertStringContainsString($expected, $result['message']); + } + + /** + * testGetBody method + */ + public function testGetBody(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['empty']); + $this->mailer->viewBuilder()->setTemplate('default'); + $this->mailer->setEmailFormat('both'); + $this->mailer->send(); + + $expected = '

    This email was sent using the CakePHP Framework

    '; + $this->assertStringContainsString($expected, $this->mailer->getBodyHtml()); + + $expected = 'This email was sent using the CakePHP Framework, https://cakephp.org.'; + $this->assertStringContainsString($expected, $this->mailer->getBodyText()); + + $message = $this->mailer->getBody(); + $this->assertContains('Content-Type: text/plain; charset=UTF-8', $message); + $this->assertContains('Content-Type: text/html; charset=UTF-8', $message); + + // UTF-8 is 8bit + $this->assertTrue($this->_checkContentTransferEncoding($message, '8bit')); + + $this->mailer->setCharset('ISO-2022-JP'); + $this->mailer->send(); + $message = $this->mailer->getBody(); + $this->assertContains('Content-Type: text/plain; charset=ISO-2022-JP', $message); + $this->assertContains('Content-Type: text/html; charset=ISO-2022-JP', $message); + + // ISO-2022-JP is 7bit + $this->assertTrue($this->_checkContentTransferEncoding($message, '7bit')); + } + + /** + * testZeroOnlyLinesNotBeingEmptied() + */ + public function testZeroOnlyLinesNotBeingEmptied(): void + { + $message = "Lorem\r\n0\r\n0\r\nipsum"; + + $this->mailer->reset(); + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->setSubject('Wordwrap Test'); + + $result = $this->mailer->deliver($message); + $expected = "{$message}\r\n\r\n"; + $this->assertSame($expected, $result['message']); + } + + /** + * testReset method + */ + public function testReset(): void + { + $this->mailer->setTo('cake@cakephp.org'); + $this->mailer->viewBuilder()->setTheme('TestTheme'); + $this->mailer->setEmailPattern('/.+@.+\..+/i'); + $this->assertSame(['cake@cakephp.org' => 'cake@cakephp.org'], $this->mailer->getTo()); + + $this->mailer->reset(); + $this->assertSame([], $this->mailer->getTo()); + $this->assertNull($this->mailer->viewBuilder()->getTheme()); + $this->assertSame(Message::EMAIL_PATTERN, $this->mailer->getEmailPattern()); + } + + public function testRestore(): void + { + $this->expectNotToPerformAssertions(); + $this->mailer->send('dummy'); + } + + /** + * testSendWithLog method + */ + public function testSendWithLog(): void + { + Log::setConfig('email', [ + 'className' => 'Array', + ]); + + $this->mailer->setTransport('debug'); + $this->mailer->setTo('me@cakephp.org'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['log' => 'debug']); + + $text = 'Logging This'; + $result = $this->mailer->deliver($text); + $this->assertNotEmpty($result); + + $messages = Log::engine('email')->read(); + $this->assertCount(1, $messages); + $this->assertStringContainsString($text, $messages[0]); + $this->assertStringContainsString('cake@cakephp.org', $messages[0]); + $this->assertStringContainsString('me@cakephp.org', $messages[0]); + } + + /** + * testSendWithLogAndScope method + */ + public function testSendWithLogAndScope(): void + { + Log::setConfig('email', [ + 'className' => 'Array', + 'scopes' => ['email'], + ]); + + $this->mailer->setTransport('debug'); + $this->mailer->setTo('me@cakephp.org'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['log' => ['scope' => 'email']]); + $text = 'Logging This'; + $this->mailer->deliver($text); + + $messages = Log::engine('email')->read(); + $this->assertCount(1, $messages); + $this->assertStringContainsString($text, $messages[0]); + $this->assertStringContainsString('cake@cakephp.org', $messages[0]); + $this->assertStringContainsString('me@cakephp.org', $messages[0]); + } + + /** + * test mail logging to cake.mailer scope + */ + public function testSendWithLogCakeScope(): void + { + Log::setConfig('email', [ + 'className' => 'Array', + 'scopes' => ['cake.mailer'], + ]); + + $this->mailer->setTransport('debug'); + $this->mailer->setTo('me@cakephp.org'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setSubject('My title'); + $this->mailer->setProfile(['log' => true]); + $text = 'Logging This'; + $this->mailer->deliver($text); + + $messages = Log::engine('email')->read(); + $this->assertCount(1, $messages); + $this->assertStringContainsString($text, $messages[0]); + $this->assertStringContainsString('cake@cakephp.org', $messages[0]); + $this->assertStringContainsString('me@cakephp.org', $messages[0]); + } + + public function testMissingActionThrowsException(): void + { + $this->expectException(MissingActionException::class); + $this->expectExceptionMessage('Mail Cake\Mailer\Mailer::test() could not be found, or is not accessible.'); + (new Mailer())->send('test'); + } + + public function testDeliver(): void + { + $this->mailer->setTransport('debug'); + $this->mailer->setFrom('cake@cakephp.org'); + $this->mailer->setTo(['you@cakephp.org' => 'You']); + $this->mailer->setSubject('My title'); + + $result = $this->mailer->deliver("Here is my body, with multi lines.\nThis is the second line.\r\n\r\nAnd the last."); + $expected = ['headers', 'message']; + $this->assertEquals($expected, array_keys($result)); + $expected = "Here is my body, with multi lines.\r\nThis is the second line.\r\n\r\nAnd the last.\r\n\r\n"; + + $this->assertSame($expected, $result['message']); + $this->assertStringContainsString('Date: ', $result['headers']); + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + + $result = $this->mailer->deliver('Other body'); + $expected = "Other body\r\n\r\n"; + $this->assertSame($expected, $result['message']); + $this->assertStringContainsString('Message-ID: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } + + protected function assertLineLengths(string $message): void + { + $lines = explode("\r\n", $message); + foreach ($lines as $line) { + $this->assertTrue( + strlen($line) <= Message::LINE_LENGTH_MUST, + 'Line length exceeds the max. limit of Message::LINE_LENGTH_MUST', + ); + } + } + + /** + * @param array|string $message + */ + protected function _checkContentTransferEncoding($message, string $charset): bool + { + $boundary = '--' . $this->mailer->getBoundary(); + $result['text'] = false; + $result['html'] = false; + $length = count($message); + for ($i = 0; $i < $length; ++$i) { + if ($message[$i] === $boundary) { + $flag = false; + $type = ''; + while (!preg_match('/^$/', $message[$i])) { + if (preg_match('/^Content-Type: text\/plain/', $message[$i])) { + $type = 'text'; + } + if (preg_match('/^Content-Type: text\/html/', $message[$i])) { + $type = 'html'; + } + if ($message[$i] === 'Content-Transfer-Encoding: ' . $charset) { + $flag = true; + } + ++$i; + } + $result[$type] = $flag; + } + } + + return $result['text'] && $result['html']; + } +} diff --git a/tests/TestCase/Mailer/MessageTest.php b/tests/TestCase/Mailer/MessageTest.php new file mode 100644 index 00000000000..0f194f8cc5f --- /dev/null +++ b/tests/TestCase/Mailer/MessageTest.php @@ -0,0 +1,1373 @@ +message = new TestMessage(); + } + + /** + * testWrap method + */ + public function testWrap(): void + { + $renderer = new TestMessage(); + + $text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac turpis orci, non commodo odio. Morbi nibh nisi, vehicula pellentesque accumsan amet.'; + $result = $renderer->doWrap($text, Message::LINE_LENGTH_SHOULD); + $expected = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac turpis orci,', + 'non commodo odio. Morbi nibh nisi, vehicula pellentesque accumsan amet.', + '', + ]; + $this->assertSame($expected, $result); + + $text = 'Lorem ipsum dolor sit amet, consectetur < adipiscing elit. Donec ac turpis orci, non commodo odio. Morbi nibh nisi, vehicula > pellentesque accumsan amet.'; + $result = $renderer->doWrap($text, Message::LINE_LENGTH_SHOULD); + $expected = [ + 'Lorem ipsum dolor sit amet, consectetur < adipiscing elit. Donec ac turpis', + 'orci, non commodo odio. Morbi nibh nisi, vehicula > pellentesque accumsan', + 'amet.', + '', + ]; + $this->assertSame($expected, $result); + + $text = '

    Lorem ipsum dolor sit amet,
    consectetur adipiscing elit.
    Donec ac turpis orci, non commodo odio.
    Morbi nibh nisi, vehicula pellentesque accumsan amet.


    '; + $result = $renderer->doWrap($text, Message::LINE_LENGTH_SHOULD); + $expected = [ + '

    Lorem ipsum dolor sit amet,
    consectetur adipiscing elit.
    Donec ac', + 'turpis orci, non commodo odio.
    Morbi nibh nisi, vehicula', + 'pellentesque accumsan amet.


    ', + '', + ]; + $this->assertSame($expected, $result); + + $text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac turpis orci, non commodo odio. Morbi nibh nisi, vehicula pellentesque accumsan amet.'; + $result = $renderer->doWrap($text, Message::LINE_LENGTH_SHOULD); + $expected = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac', + 'turpis orci, non commodo odio. Morbi nibh', + 'nisi, vehicula pellentesque accumsan amet.', + '', + ]; + $this->assertSame($expected, $result); + + $text = 'Lorem ipsum ok'; + $result = $renderer->doWrap($text, Message::LINE_LENGTH_SHOULD); + $expected = [ + 'Lorem ipsum', + '', + 'ok', + '', + ]; + $this->assertSame($expected, $result); + + $text = 'Lorem ipsum withonewordverybigMorethanthelineshouldsizeofrfcspecificationbyieeeavailableonieeesite ok.'; + $result = $renderer->doWrap($text, Message::LINE_LENGTH_SHOULD); + $expected = [ + 'Lorem ipsum', + 'withonewordverybigMorethanthelineshouldsizeofrfcspecificationbyieeeavailableonieeesite', + 'ok.', + '', + ]; + $this->assertSame($expected, $result); + + /** @see https://github.com/cakephp/cakephp/issues/14459 */ + $line = 'some text with html'; + $trailing = str_repeat('X', Message::LINE_LENGTH_MUST - strlen($line)); + $result = $renderer->doWrap($line . $trailing, Message::LINE_LENGTH_MUST); + $expected = [ + 'some text with', + 'html' . $trailing, + '', + ]; + $this->assertSame($expected, $result); + } + + /** + * testWrapLongLine() + */ + public function testWrapLongLine(): void + { + $transort = new DebugTransport(); + + $message = '' . str_repeat('x', Message::LINE_LENGTH_MUST) . ''; + + $this->message->setFrom('cake@cakephp.org'); + $this->message->setTo('cake@cakephp.org'); + $this->message->setSubject('Wordwrap Test'); + $this->message->setBodyText($message); + + $result = $transort->send($this->message); + + $expected = "' . str_repeat('x', Message::LINE_LENGTH_MUST - 26) . "\r\n" . + str_repeat('x', 26) . "\r\n\r\n\r\n"; + $this->assertSame($expected, $result['message']); + $this->assertLineLengths($result['message']); + + $str1 = 'a '; + $str2 = ' b'; + $length = strlen($str1) + strlen($str2); + $message = $str1 . str_repeat('x', Message::LINE_LENGTH_MUST - $length - 1) . $str2; + + $this->message->setBodyText($message); + + $result = $transort->send($this->message); + $expected = "{$message}\r\n\r\n"; + $this->assertSame($expected, $result['message']); + $this->assertLineLengths($result['message']); + + $message = $str1 . str_repeat('x', Message::LINE_LENGTH_MUST - $length) . $str2; + + $this->message->setBodyText($message); + + $result = $transort->send($this->message); + $expected = "{$message}\r\n\r\n"; + $this->assertSame($expected, $result['message']); + $this->assertLineLengths($result['message']); + + $message = $str1 . str_repeat('x', Message::LINE_LENGTH_MUST - $length + 1) . $str2; + + $this->message->setBodyText($message); + + $result = $transort->send($this->message); + $expected = $str1 . str_repeat('x', Message::LINE_LENGTH_MUST - $length + 1) . sprintf("\r\n%s\r\n\r\n", trim($str2)); + $this->assertSame($expected, $result['message']); + $this->assertLineLengths($result['message']); + } + + /** + * testWrapWithTagsAcrossLines() + */ + public function testWrapWithTagsAcrossLines(): void + { + $str = << +The tag is across multiple lines + +HTML; + $message = $str . str_repeat('x', Message::LINE_LENGTH_MUST + 1); + + $this->message->setFrom('cake@cakephp.org'); + $this->message->setTo('cake@cakephp.org'); + $this->message->setSubject('Wordwrap Test'); + $this->message->setBodyText($message); + + $result = (new DebugTransport())->send($this->message); + + $message = str_replace("\r\n", "\n", substr($message, 0, -9)); + $message = str_replace("\n", "\r\n", $message); + $expected = "{$message}\r\nxxxxxxxxx\r\n\r\n"; + $this->assertSame($expected, $result['message']); + $this->assertLineLengths($result['message']); + } + + /** + * CakeEmailTest::testWrapIncludeLessThanSign() + */ + public function testWrapIncludeLessThanSign(): void + { + $str = 'foomessage->setFrom('cake@cakephp.org'); + $this->message->setTo('cake@cakephp.org'); + $this->message->setSubject('Wordwrap Test'); + $this->message->setBodyText($message); + + $result = (new DebugTransport())->send($this->message); + $message = substr($message, 0, -1); + $expected = "{$message}\r\nx\r\n\r\n"; + $this->assertSame($expected, $result['message']); + $this->assertLineLengths($result['message']); + } + + /** + * CakeEmailTest::testWrapForJapaneseEncoding() + */ + public function testWrapForJapaneseEncoding(): void + { + $this->skipIf(!function_exists('mb_convert_encoding')); + + $message = mb_convert_encoding('受け付けました', 'iso-2022-jp', 'UTF-8'); + + $this->message->setFrom('cake@cakephp.org'); + $this->message->setTo('cake@cakephp.org'); + $this->message->setSubject('Wordwrap Test'); + $this->message->setCharset('iso-2022-jp'); + $this->message->setHeaderCharset('iso-2022-jp'); + $this->message->setBodyText($message); + + $result = (new DebugTransport())->send($this->message); + $expected = "{$message}\r\n\r\n"; + $this->assertSame($expected, $result['message']); + } + + /** + * testHeaders method + */ + public function testHeaders(): void + { + $this->message->setMessageId(false); + // Set a fixed date to avoid flaky tests due to timing + $date = date(DATE_RFC2822); + $this->message->setHeaders(['X-Something' => 'nice', 'Date' => $date]); + $expected = [ + 'X-Something' => 'nice', + 'Date' => $date, + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Content-Transfer-Encoding' => '8bit', + ]; + $this->assertSame($expected, $this->message->getHeaders()); + + $this->message->addHeaders(['X-Something' => 'very nice', 'X-Other' => 'cool']); + $expected = [ + 'X-Something' => 'very nice', + 'Date' => $date, + 'X-Other' => 'cool', + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Content-Transfer-Encoding' => '8bit', + ]; + $this->assertSame($expected, $this->message->getHeaders()); + + $this->message->setFrom('cake@cakephp.org'); + $this->assertSame($expected, $this->message->getHeaders()); + + $expected = [ + 'From' => 'cake@cakephp.org', + 'X-Something' => 'very nice', + 'Date' => $date, + 'X-Other' => 'cool', + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Content-Transfer-Encoding' => '8bit', + ]; + $this->assertSame($expected, $this->message->getHeaders(['from' => true])); + + $this->message->setFrom('cake@cakephp.org', 'CakePHP'); + $expected['From'] = 'CakePHP '; + $this->assertSame($expected, $this->message->getHeaders(['from' => true])); + + $this->message->setTo(['cake@cakephp.org', 'php@cakephp.org' => 'CakePHP']); + $expected = [ + 'From' => 'CakePHP ', + 'To' => 'cake@cakephp.org, CakePHP ', + 'X-Something' => 'very nice', + 'Date' => $date, + 'X-Other' => 'cool', + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Content-Transfer-Encoding' => '8bit', + ]; + $this->assertSame($expected, $this->message->getHeaders(['from' => true, 'to' => true])); + + $this->message->setCharset('ISO-2022-JP'); + $expected = [ + 'From' => 'CakePHP ', + 'To' => 'cake@cakephp.org, CakePHP ', + 'X-Something' => 'very nice', + 'Date' => $date, + 'X-Other' => 'cool', + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset=ISO-2022-JP', + 'Content-Transfer-Encoding' => '7bit', + ]; + $this->assertSame($expected, $this->message->getHeaders(['from' => true, 'to' => true])); + + $result = $this->message->setHeaders([]); + $this->assertInstanceOf(Message::class, $result); + + $this->message->setHeaders(['o:tag' => ['foo']]); + $this->message->addHeaders(['o:tag' => ['bar']]); + $result = $this->message->getHeaders(); + $this->assertEquals(['foo', 'bar'], $result['o:tag']); + } + + /** + * testHeadersString method + */ + public function testHeadersString(): void + { + $this->message->setMessageId(false); + // Set a fixed date to avoid flaky tests due to timing + $date = date(DATE_RFC2822); + $this->message->setHeaders(['X-Something' => 'nice', 'Date' => $date]); + $expected = [ + 'X-Something: nice', + 'Date: ' . $date, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + 'Content-Transfer-Encoding: 8bit', + ]; + $this->assertSame(implode("\r\n", $expected), $this->message->getHeadersString()); + } + + /** + * testFrom method + */ + public function testFrom(): void + { + $this->assertSame([], $this->message->getFrom()); + + $this->message->setFrom('cake@cakephp.org'); + $expected = ['cake@cakephp.org' => 'cake@cakephp.org']; + $this->assertSame($expected, $this->message->getFrom()); + + $this->message->setFrom(['cake@cakephp.org']); + $this->assertSame($expected, $this->message->getFrom()); + + $this->message->setFrom('cake@cakephp.org', 'CakePHP'); + $expected = ['cake@cakephp.org' => 'CakePHP']; + $this->assertSame($expected, $this->message->getFrom()); + + $result = $this->message->setFrom(['cake@cakephp.org' => 'CakePHP']); + $this->assertSame($expected, $this->message->getFrom()); + $this->assertSame($this->message, $result); + + $this->expectException(InvalidArgumentException::class); + $this->message->setFrom(['cake@cakephp.org' => 'CakePHP', 'fail@cakephp.org' => 'From can only be one address']); + } + + /** + * Test that from addresses using colons work. + */ + public function testFromWithColonsAndQuotes(): void + { + $address = [ + 'info@example.com' => '70:20:00 " Forum', + ]; + $this->message->setFrom($address); + $this->assertEquals($address, $this->message->getFrom()); + + $result = $this->message->getHeadersString(['from']); + $this->assertStringContainsString('From: "70:20:00 \" Forum" ', $result); + } + + /** + * testSender method + */ + public function testSender(): void + { + $this->message->reset(); + $this->assertSame([], $this->message->getSender()); + + $this->message->setSender('cake@cakephp.org', 'Name'); + $expected = ['cake@cakephp.org' => 'Name']; + $this->assertSame($expected, $this->message->getSender()); + + $headers = $this->message->getHeaders(['from' => true, 'sender' => true]); + $this->assertSame('', $headers['From']); + $this->assertSame('Name ', $headers['Sender']); + + $this->message->setFrom('cake@cakephp.org', 'CakePHP'); + $headers = $this->message->getHeaders(['from' => true, 'sender' => true]); + $this->assertSame('CakePHP ', $headers['From']); + $this->assertSame('', $headers['Sender']); + } + + /** + * testTo method + */ + public function testTo(): void + { + $this->assertSame([], $this->message->getTo()); + + $result = $this->message->setTo('cake@cakephp.org'); + $expected = ['cake@cakephp.org' => 'cake@cakephp.org']; + $this->assertSame($expected, $this->message->getTo()); + $this->assertSame($this->message, $result); + + $this->message->setTo('cake@cakephp.org', 'CakePHP'); + $expected = ['cake@cakephp.org' => 'CakePHP']; + $this->assertSame($expected, $this->message->getTo()); + + $list = [ + 'root@localhost' => 'root', + 'bjørn@hammeröath.com' => 'Bjorn', + 'cake.php@cakephp.org' => 'Cake PHP', + 'cake-php@googlegroups.com' => 'Cake Groups', + 'root@cakephp.org', + ]; + $this->message->setTo($list); + $expected = [ + 'root@localhost' => 'root', + 'bjørn@hammeröath.com' => 'Bjorn', + 'cake.php@cakephp.org' => 'Cake PHP', + 'cake-php@googlegroups.com' => 'Cake Groups', + 'root@cakephp.org' => 'root@cakephp.org', + ]; + $this->assertSame($expected, $this->message->getTo()); + + $this->message->addTo('jrbasso@cakephp.org'); + $this->message->addTo('mark_story@cakephp.org', 'Mark Story'); + $this->message->addTo('foobar@ætdcadsl.dk'); + $result = $this->message->addTo(['phpnut@cakephp.org' => 'PhpNut', 'jose_zap@cakephp.org']); + $expected = [ + 'root@localhost' => 'root', + 'bjørn@hammeröath.com' => 'Bjorn', + 'cake.php@cakephp.org' => 'Cake PHP', + 'cake-php@googlegroups.com' => 'Cake Groups', + 'root@cakephp.org' => 'root@cakephp.org', + 'jrbasso@cakephp.org' => 'jrbasso@cakephp.org', + 'mark_story@cakephp.org' => 'Mark Story', + 'foobar@ætdcadsl.dk' => 'foobar@ætdcadsl.dk', + 'phpnut@cakephp.org' => 'PhpNut', + 'jose_zap@cakephp.org' => 'jose_zap@cakephp.org', + ]; + $this->assertSame($expected, $this->message->getTo()); + $this->assertSame($this->message, $result); + } + + /** + * test to address with _ in domain name + */ + public function testToUnderscoreDomain(): void + { + $result = $this->message->setTo('cake@cake_php.org'); + $expected = ['cake@cake_php.org' => 'cake@cake_php.org']; + $this->assertSame($expected, $this->message->getTo()); + $this->assertSame($this->message, $result); + } + + /** + * Data provider function for testBuildInvalidData + * + * @return array + */ + public static function invalidEmails(): array + { + return [ + [''], + ['string'], + [''], + [['ok@cakephp.org', '1.0', '', 'string']], + ]; + } + + /** + * testBuildInvalidData + * + * @param array|string $value + */ + #[DataProvider('invalidEmails')] + public function testInvalidEmail($value): void + { + $this->expectException(InvalidArgumentException::class); + $this->message->setTo($value); + } + + /** + * testBuildInvalidData + * + * @param array|string $value + */ + #[DataProvider('invalidEmails')] + public function testInvalidEmailAdd($value): void + { + $this->expectException(InvalidArgumentException::class); + $this->message->addTo($value); + } + + /** + * test emailPattern method + */ + public function testEmailPattern(): void + { + $regex = '/.+@.+\..+/i'; + $this->assertSame($regex, $this->message->setEmailPattern($regex)->getEmailPattern()); + } + + /** + * Tests that it is possible to set email regex configuration to a CakeEmail object + */ + public function testConfigEmailPattern(): void + { + $regex = '/.+@.+\..+/i'; + $email = new Message(['emailPattern' => $regex]); + $this->assertSame($regex, $email->getEmailPattern()); + } + + /** + * Tests that it is possible set custom email validation + */ + public function testCustomEmailValidation(): void + { + $regex = '/^[\.a-z0-9!#$%&\'*+\/=?^_`{|}~-]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]{2,6}$/i'; + + $this->message->setEmailPattern($regex)->setTo('pass.@example.com'); + $this->assertSame([ + 'pass.@example.com' => 'pass.@example.com', + ], $this->message->getTo()); + + $this->message->addTo('pass..old.docomo@example.com'); + $this->assertSame([ + 'pass.@example.com' => 'pass.@example.com', + 'pass..old.docomo@example.com' => 'pass..old.docomo@example.com', + ], $this->message->getTo()); + + $this->message->reset(); + $emails = [ + 'pass.@example.com', + 'pass..old.docomo@example.com', + ]; + $additionalEmails = [ + '.extend.@example.com', + '.docomo@example.com', + ]; + $this->message->setEmailPattern($regex)->setTo($emails); + $this->assertSame([ + 'pass.@example.com' => 'pass.@example.com', + 'pass..old.docomo@example.com' => 'pass..old.docomo@example.com', + ], $this->message->getTo()); + + $this->message->addTo($additionalEmails); + $this->assertSame([ + 'pass.@example.com' => 'pass.@example.com', + 'pass..old.docomo@example.com' => 'pass..old.docomo@example.com', + '.extend.@example.com' => '.extend.@example.com', + '.docomo@example.com' => '.docomo@example.com', + ], $this->message->getTo()); + } + + /** + * Tests that it is possible to unset the email pattern and make use of filter_var() instead. + */ + public function testUnsetEmailPattern(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email set for `to`. You passed `fail.@example.com`.'); + $email = new Message(); + $this->assertSame(Message::EMAIL_PATTERN, $email->getEmailPattern()); + + $email->setEmailPattern(null); + $this->assertNull($email->getEmailPattern()); + + $email->setTo('pass@example.com'); + $email->setTo('fail.@example.com'); + } + + /** + * Tests that passing an empty string throws an InvalidArgumentException. + */ + public function testEmptyTo(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The email set for `to` is empty.'); + $email = new Message(); + $email->setTo(''); + } + + /** + * testFormatAddress method + */ + public function testFormatAddress(): void + { + $result = $this->message->formatAddress(['cake@cakephp.org' => 'cake@cakephp.org']); + $expected = ['cake@cakephp.org']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress([ + 'cake@cakephp.org' => 'cake@cakephp.org', + 'php@cakephp.org' => 'php@cakephp.org', + ]); + $expected = ['cake@cakephp.org', 'php@cakephp.org']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress([ + 'cake@cakephp.org' => 'CakePHP', + 'php@cakephp.org' => 'Cake', + ]); + $expected = ['CakePHP ', 'Cake ']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress(['me@example.com' => 'Last, First']); + $expected = ['"Last, First" ']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress(['me@example.com' => '"Last" First']); + $expected = ['"\"Last\" First" ']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress(['me@example.com' => 'Last First']); + $expected = ['Last First ']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress(['cake@cakephp.org' => 'ÄÖÜTest']); + $expected = ['=?UTF-8?B?w4TDlsOcVGVzdA==?= ']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress(['cake@cakephp.org' => '日本語Test']); + $expected = ['=?UTF-8?B?5pel5pys6KqeVGVzdA==?= ']; + $this->assertSame($expected, $result); + } + + /** + * testFormatAddressJapanese + */ + public function testFormatAddressJapanese(): void + { + $this->message->setHeaderCharset('ISO-2022-JP'); + $result = $this->message->formatAddress(['cake@cakephp.org' => '日本語Test']); + $expected = ['=?ISO-2022-JP?B?GyRCRnxLXDhsGyhCVGVzdA==?= ']; + $this->assertSame($expected, $result); + + $result = $this->message->formatAddress(['cake@cakephp.org' => '寿限無寿限無五劫の擦り切れ海砂利水魚の水行末雲来末風来末食う寝る処に住む処やぶら小路の藪柑子パイポパイポパイポのシューリンガンシューリンガンのグーリンダイグーリンダイのポンポコピーのポンポコナーの長久命の長助']); + $expected = ["\"=?ISO-2022-JP?B?GyRCPHc4Qkw1PHc4Qkw1OF45ZSROOyQkakBaJGwzJDo9TXg/ZTV7GyhC?=\r\n" . + " =?ISO-2022-JP?B?GyRCJE4/ZTlUS3YxQE1oS3ZJd01oS3Y/KSQmPzIkaz1oJEs9OyRgGyhC?=\r\n" . + " =?ISO-2022-JP?B?GyRCPWgkZCRWJGk+Lk8pJE5pLjQ7O1IlUSUkJV0lUSUkJV0lUSUkGyhC?=\r\n" . + " =?ISO-2022-JP?B?GyRCJV0kTiU3JWUhPCVqJXMlLCVzJTclZSE8JWolcyUsJXMkTiUwGyhC?=\r\n" . + " =?ISO-2022-JP?B?GyRCITwlaiVzJUAlJCUwITwlaiVzJUAlJCROJV0lcyVdJTMlVCE8GyhC?=\r\n" . + ' =?ISO-2022-JP?B?GyRCJE4lXSVzJV0lMyVKITwkTkQ5NVdMPyRORDk9dRsoQg==?=" ']; + $this->assertSame($expected, $result); + } + + /** + * testAddresses method + */ + public function testAddresses(): void + { + $this->message->reset(); + $this->message->setFrom('cake@cakephp.org', 'CakePHP'); + $this->message->setReplyTo('replyto@cakephp.org', 'ReplyTo CakePHP'); + $this->message->setReadReceipt('readreceipt@cakephp.org', 'ReadReceipt CakePHP'); + $this->message->setReturnPath('returnpath@cakephp.org', 'ReturnPath CakePHP'); + $this->message->setTo('to@cakephp.org', 'To, CakePHP'); + $this->message->setCc('cc@cakephp.org', 'Cc CakePHP'); + $this->message->setBcc('bcc@cakephp.org', 'Bcc CakePHP'); + $this->message->addTo('to2@cakephp.org', 'To2 CakePHP'); + $this->message->addCc('cc2@cakephp.org', 'Cc2 CakePHP'); + $this->message->addBcc('bcc2@cakephp.org', 'Bcc2 CakePHP'); + $this->message->addReplyTo('replyto2@cakephp.org', 'ReplyTo2 CakePHP'); + + $this->assertSame($this->message->getFrom(), ['cake@cakephp.org' => 'CakePHP']); + $this->assertSame($this->message->getReplyTo(), ['replyto@cakephp.org' => 'ReplyTo CakePHP', 'replyto2@cakephp.org' => 'ReplyTo2 CakePHP']); + $this->assertSame($this->message->getReadReceipt(), ['readreceipt@cakephp.org' => 'ReadReceipt CakePHP']); + $this->assertSame($this->message->getReturnPath(), ['returnpath@cakephp.org' => 'ReturnPath CakePHP']); + $this->assertSame($this->message->getTo(), ['to@cakephp.org' => 'To, CakePHP', 'to2@cakephp.org' => 'To2 CakePHP']); + $this->assertSame($this->message->getCc(), ['cc@cakephp.org' => 'Cc CakePHP', 'cc2@cakephp.org' => 'Cc2 CakePHP']); + $this->assertSame($this->message->getBcc(), ['bcc@cakephp.org' => 'Bcc CakePHP', 'bcc2@cakephp.org' => 'Bcc2 CakePHP']); + + $headers = $this->message->getHeaders(array_fill_keys(['from', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'bcc'], true)); + $this->assertSame($headers['From'], 'CakePHP '); + $this->assertSame($headers['Reply-To'], 'ReplyTo CakePHP , ReplyTo2 CakePHP '); + $this->assertSame($headers['Disposition-Notification-To'], 'ReadReceipt CakePHP '); + $this->assertSame($headers['Return-Path'], 'ReturnPath CakePHP '); + $this->assertSame($headers['To'], '"To, CakePHP" , To2 CakePHP '); + $this->assertSame($headers['Cc'], 'Cc CakePHP , Cc2 CakePHP '); + $this->assertSame($headers['Bcc'], 'Bcc CakePHP , Bcc2 CakePHP '); + + $this->message->setReplyTo(['replyto@cakephp.org' => 'ReplyTo CakePHP', 'replyto2@cakephp.org' => 'ReplyTo2 CakePHP']); + $this->assertSame($this->message->getReplyTo(), ['replyto@cakephp.org' => 'ReplyTo CakePHP', 'replyto2@cakephp.org' => 'ReplyTo2 CakePHP']); + $headers = $this->message->getHeaders(array_fill_keys(['replyTo'], true)); + $this->assertSame($headers['Reply-To'], 'ReplyTo CakePHP , ReplyTo2 CakePHP '); + } + + /** + * test reset addresses method + */ + public function testResetAddresses(): void + { + $this->message->reset(); + $this->message + ->setFrom('cake@cakephp.org', 'CakePHP') + ->setReplyTo('replyto@cakephp.org', 'ReplyTo CakePHP') + ->setReadReceipt('readreceipt@cakephp.org', 'ReadReceipt CakePHP') + ->setReturnPath('returnpath@cakephp.org', 'ReturnPath CakePHP') + ->setTo('to@cakephp.org', 'To, CakePHP') + ->setCc('cc@cakephp.org', 'Cc CakePHP') + ->setBcc('bcc@cakephp.org', 'Bcc CakePHP'); + + $this->assertNotEmpty($this->message->getFrom()); + $this->assertNotEmpty($this->message->getReplyTo()); + $this->assertNotEmpty($this->message->getReadReceipt()); + $this->assertNotEmpty($this->message->getReturnPath()); + $this->assertNotEmpty($this->message->getTo()); + $this->assertNotEmpty($this->message->getCc()); + $this->assertNotEmpty($this->message->getBcc()); + + $this->message + ->setFrom([]) + ->setReplyTo([]) + ->setReadReceipt([]) + ->setReturnPath([]) + ->setTo([]) + ->setCc([]) + ->setBcc([]); + + $this->assertEmpty($this->message->getFrom()); + $this->assertEmpty($this->message->getReplyTo()); + $this->assertEmpty($this->message->getReadReceipt()); + $this->assertEmpty($this->message->getReturnPath()); + $this->assertEmpty($this->message->getTo()); + $this->assertEmpty($this->message->getCc()); + $this->assertEmpty($this->message->getBcc()); + } + + /** + * testMessageId method + */ + public function testMessageId(): void + { + $this->message->setMessageId(true); + $result = $this->message->getHeaders(); + $this->assertArrayHasKey('Message-ID', $result); + + $this->message->setMessageId(false); + $result = $this->message->getHeaders(); + $this->assertArrayNotHasKey('Message-ID', $result); + + $result = $this->message->setMessageId(''); + $this->assertSame($this->message, $result); + $result = $this->message->getHeaders(); + $this->assertSame('', $result['Message-ID']); + + $result = $this->message->getMessageId(); + $this->assertSame('', $result); + } + + public function testAutoMessageIdIsIdempotent(): void + { + $headers = $this->message->getHeaders(); + $this->assertArrayHasKey('Message-ID', $headers); + + $regeneratedHeaders = $this->message->getHeaders(); + $this->assertSame($headers['Message-ID'], $regeneratedHeaders['Message-ID']); + } + + public function testPriority(): void + { + $this->message->setPriority(4); + + $this->assertSame(4, $this->message->getPriority()); + + $result = $this->message->getHeaders(); + $this->assertArrayHasKey('X-Priority', $result); + } + + /** + * testMessageIdInvalid method + */ + public function testMessageIdInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->message->setMessageId('my-email@localhost'); + } + + /** + * testDomain method + */ + public function testDomain(): void + { + $result = $this->message->getDomain(); + $expected = env('HTTP_HOST') ?: php_uname('n'); + $this->assertSame($expected, $result); + + $this->message->setDomain('example.org'); + $result = $this->message->getDomain(); + $expected = 'example.org'; + $this->assertSame($expected, $result); + } + + /** + * testMessageIdWithDomain method + */ + public function testMessageIdWithDomain(): void + { + $this->message->setDomain('example.org'); + $result = $this->message->getHeaders(); + $expected = '@example.org>'; + $this->assertTextContains($expected, $result['Message-ID']); + + $_SERVER['HTTP_HOST'] = 'example.org'; + $result = $this->message->getHeaders(); + $this->assertTextContains('example.org', $result['Message-ID']); + + $_SERVER['HTTP_HOST'] = 'example.org:81'; + $result = $this->message->getHeaders(); + $this->assertTextNotContains(':81', $result['Message-ID']); + } + + /** + * testSubject method + */ + public function testSubject(): void + { + $this->message->setSubject('You have a new message.'); + $this->assertSame('You have a new message.', $this->message->getSubject()); + + $this->message->setSubject('You have a new message, I think.'); + $this->assertSame($this->message->getSubject(), 'You have a new message, I think.'); + $this->message->setSubject('1'); + $this->assertSame('1', $this->message->getSubject()); + + $input = 'هذه رسالة بعنوان طويل مرسل للمستلم'; + $this->message->setSubject($input); + $expected = '=?UTF-8?B?2YfYsNmHINix2LPYp9mE2Kkg2KjYudmG2YjYp9mGINi32YjZitmEINmF2LE=?=' . "\r\n" . ' =?UTF-8?B?2LPZhCDZhNmE2YXYs9iq2YTZhQ==?='; + $this->assertSame($expected, $this->message->getSubject()); + $this->assertSame($input, $this->message->getOriginalSubject()); + } + + /** + * testSubjectJapanese + */ + public function testSubjectJapanese(): void + { + mb_internal_encoding('UTF-8'); + + $this->message->setHeaderCharset('ISO-2022-JP'); + $this->message->setSubject('日本語のSubjectにも対応するよ'); + $expected = '=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0GyRCJEskYkJQMX4kOSRrJGgbKEI=?='; + $this->assertSame($expected, $this->message->getSubject()); + + $this->message->setSubject('長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'); + $expected = "=?ISO-2022-JP?B?GyRCRDkkJEQ5JCREOSQkGyhCU3ViamVjdBskQiROPmw5ZyRPGyhCZm9s?=\r\n" . + " =?ISO-2022-JP?B?ZGluZxskQiQ5JGskTiQsQDUkNyQkJHMkQCQxJEkkJCRDJD8kJCRJGyhC?=\r\n" . + ' =?ISO-2022-JP?B?GyRCJCYkSiRrJHMkQCRtJCYhKRsoQg==?='; + $this->assertSame($expected, $this->message->getSubject()); + } + + /** + * testAttachments method + */ + public function testSetAttachments(): void + { + $uploadedFile = new UploadedFile( + __FILE__, + filesize(__FILE__), + UPLOAD_ERR_OK, + 'MessageTest.php', + 'text/x-php', + ); + + $this->message->setAttachments([ + CAKE . 'Mailer' . DS . 'Message.php', + $uploadedFile, + ]); + $expected = [ + 'Message.php' => [ + 'file' => CAKE . 'Mailer' . DS . 'Message.php', + 'mimetype' => 'text/x-php', + ], + 'MessageTest.php' => [ + 'file' => $uploadedFile, + 'mimetype' => 'text/x-php', + ], + ]; + $this->assertSame($expected, $this->message->getAttachments()); + + $this->message->setAttachments([]); + $this->assertSame([], $this->message->getAttachments()); + + $this->message->setAttachments([ + ['file' => __FILE__, 'mimetype' => 'text/plain'], + ]); + $this->message->addAttachments([CORE_PATH . 'config' . DS . 'bootstrap.php']); + $this->message->addAttachments([CORE_PATH . 'config' . DS . 'bootstrap.php']); + $this->message->addAttachments([ + 'other.txt' => CORE_PATH . 'config' . DS . 'bootstrap.php', + 'license' => CORE_PATH . 'LICENSE', + ]); + $expected = [ + 'MessageTest.php' => ['file' => __FILE__, 'mimetype' => 'text/plain'], + 'bootstrap.php' => ['file' => CORE_PATH . 'config' . DS . 'bootstrap.php', 'mimetype' => 'text/x-php'], + 'other.txt' => ['file' => CORE_PATH . 'config' . DS . 'bootstrap.php', 'mimetype' => 'text/x-php'], + 'license' => ['file' => CORE_PATH . 'LICENSE', 'mimetype' => 'text/plain'], + ]; + $this->assertSame($expected, $this->message->getAttachments()); + $this->expectException(InvalidArgumentException::class); + $this->message->setAttachments([['nofile' => __FILE__, 'mimetype' => 'text/plain']]); + } + + /** + * Test send() with no template and data string attachment and no mimetype + */ + public function testSetAttachmentDataNoMimetype(): void + { + $this->message->setAttachments(['cake.icon.gif' => [ + 'data' => 'test', + ]]); + $expected = [ + 'cake.icon.gif' => [ + 'data' => base64_encode('test') . "\r\n", + 'mimetype' => 'application/octet-stream', + ], + ]; + $this->assertSame($expected, $this->message->getAttachments()); + } + + public function testAddAttachment(): void + { + $uploadedFile = new UploadedFile( + __FILE__, + filesize(__FILE__), + UPLOAD_ERR_OK, + 'MessageTest.php', + 'text/x-php', + ); + + $this->message->addAttachment(CAKE . 'Mailer' . DS . 'Message.php', mimetype: 'text/plain', contentId: 'attached'); + $this->message->addAttachment($uploadedFile, contentDisposition: false); + + $expected = [ + 'Message.php' => [ + 'file' => CAKE . 'Mailer' . DS . 'Message.php', + 'mimetype' => 'text/plain', + 'contentId' => 'attached', + 'contentDisposition' => null, + ], + 'MessageTest.php' => [ + 'file' => $uploadedFile, + 'mimetype' => 'text/x-php', + 'contentId' => null, + 'contentDisposition' => false, + ], + ]; + $this->assertSame($expected, $this->message->getAttachments()); + } + + public function testSetAttachmentInvalidFile(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'File must be a filepath or UploadedFileInterface instance. Found `boolean` instead.', + ); + + $this->message->setAttachments(['cake.icon.gif' => [ + 'file' => true, + ]]); + } + + /** + * testReset method + */ + public function testReset(): void + { + $this->message->setTo('cake@cakephp.org'); + $this->message->setEmailPattern('/.+@.+\..+/i'); + $this->assertSame(['cake@cakephp.org' => 'cake@cakephp.org'], $this->message->getTo()); + + $this->message->reset(); + $this->assertSame([], $this->message->getTo()); + $this->assertSame(Message::EMAIL_PATTERN, $this->message->getEmailPattern()); + } + + /** + * testReset with charset + */ + public function testResetWithCharset(): void + { + $this->message->setCharset('ISO-2022-JP'); + $this->message->reset(); + + $this->assertSame('utf-8', $this->message->getCharset()); + $this->assertSame('utf-8', $this->message->getHeaderCharset()); + } + + /** + * testEmailFormat method + */ + public function testEmailFormat(): void + { + $result = $this->message->getEmailFormat(); + $this->assertSame('text', $result); + + $result = $this->message->setEmailFormat('html'); + $this->assertInstanceOf(Message::class, $result); + + $result = $this->message->getEmailFormat(); + $this->assertSame('html', $result); + + $this->expectException(InvalidArgumentException::class); + $this->message->setEmailFormat('invalid'); + } + + /** + * Tests that it is possible to add charset configuration to a CakeEmail object + */ + public function testConfigCharset(): void + { + $email = new Message(); + $this->assertEquals(Configure::read('App.encoding'), $email->getCharset()); + $this->assertEquals(Configure::read('App.encoding'), $email->getHeaderCharset()); + + $email = new Message(['charset' => 'iso-2022-jp', 'headerCharset' => 'iso-2022-jp-ms']); + $this->assertSame('iso-2022-jp', $email->getCharset()); + $this->assertSame('iso-2022-jp-ms', $email->getHeaderCharset()); + + $email = new Message(['charset' => 'iso-2022-jp']); + $this->assertSame('iso-2022-jp', $email->getCharset()); + $this->assertSame('iso-2022-jp', $email->getHeaderCharset()); + + $email = new Message(['headerCharset' => 'iso-2022-jp-ms']); + $this->assertEquals(Configure::read('App.encoding'), $email->getCharset()); + $this->assertSame('iso-2022-jp-ms', $email->getHeaderCharset()); + } + + public function testGetBody(): void + { + $message = new Message(); + + $uploadedFile = new UploadedFile( + __FILE__, + filesize(__FILE__), + UPLOAD_ERR_OK, + 'MessageTest.php', + 'text/x-php', + ); + $chunks = base64_encode(file_get_contents(__FILE__)); + + $result = $message->setAttachments([$uploadedFile]) + ->setBodyText('Attached an uploaded file') + ->getBody(); + $result = implode("\r\n", $result); + $this->assertStringContainsString($chunks[0], $result); + } + + /** + * Tests that the body is encoded using the configured charset (Japanese standard encoding) + */ + public function testBodyEncodingIso2022Jp(): void + { + $message = new Message([ + 'charset' => 'iso-2022-jp', + 'headerCharset' => 'iso-2022-jp', + 'transport' => 'debug', + ]); + $message->setSubject('あれ?もしかしての前と'); + + $headers = $message->getHeaders(['subject']); + $expected = '?ISO-2022-JP?B?GyRCJCIkbCEpJGIkNyQrJDckRiROQTAkSBsoQg==?='; + $this->assertStringContainsString($expected, $headers['Subject']); + + $message->setBodyHtml('①㈱ ってテーブルを作ってやってたらう'); + + $result = $message->getHeadersString(); + $this->assertTextContains('Content-Type: text/plain; charset=ISO-2022-JP', $result); + $this->assertTextNotContains('Content-Type: text/plain; charset=ISO-2022-JP-MS', $result); // not charset=iso-2022-jp-ms + + $result = implode('', $message->getBody()); + $this->assertTextNotContains(mb_convert_encoding('①㈱ ってテーブルを作ってやってたらう', 'ISO-2022-JP-MS'), $result); + } + + /** + * Tests that the body is encoded using the configured charset (Japanese irregular encoding, but sometime use this) + */ + public function testBodyEncodingIso2022JpMs(): void + { + $message = new Message([ + 'charset' => 'iso-2022-jp-ms', + 'headerCharset' => 'iso-2022-jp-ms', + 'transport' => 'debug', + ]); + $message->setSubject('あれ?もしかしての前と'); + $headers = $message->getHeaders(['subject']); + $expected = '?ISO-2022-JP?B?GyRCJCIkbCEpJGIkNyQrJDckRiROQTAkSBsoQg==?='; + $this->assertStringContainsString($expected, $headers['Subject']); + + $result = $message->setBodyText('①㈱ ってテーブルを作ってやってたらう'); + + $result = $message->getHeadersString(); + $this->assertTextContains('Content-Type: text/plain; charset=ISO-2022-JP', $result); + $this->assertTextNotContains('Content-Type: text/plain; charset=iso-2022-jp-ms', $result); // not charset=iso-2022-jp-ms + + $result = implode('', $message->getBody()); + $this->assertStringContainsString(mb_convert_encoding('①㈱ ってテーブルを作ってやってたらう', 'ISO-2022-JP-MS'), $result); + } + + /** + * Tests that the body is encoded using the configured charset + */ + public function testEncodingMixed(): void + { + $message = new Message([ + 'headerCharset' => 'iso-2022-jp-ms', + 'charset' => 'iso-2022-jp', + ]); + + $message->setBodyText('ってテーブルを作ってやってたらう'); + + $result = $message->getHeadersString(); + $this->assertStringContainsString('Content-Type: text/plain; charset=ISO-2022-JP', $result); + + $result = implode('', $message->getBody()); + $this->assertStringContainsString(mb_convert_encoding('ってテーブルを作ってやってたらう', 'ISO-2022-JP'), $result); + } + + /** + * Test CakeMessage::_encode function + */ + public function testEncode(): void + { + $this->message->setHeaderCharset('ISO-2022-JP'); + $result = $this->message->encode('日本語'); + $expected = '=?ISO-2022-JP?B?GyRCRnxLXDhsGyhC?='; + $this->assertSame($expected, $result); + + $this->message->setHeaderCharset('ISO-2022-JP'); + $result = $this->message->encode('長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'); + $expected = "=?ISO-2022-JP?B?GyRCRDkkJEQ5JCREOSQkGyhCU3ViamVjdBskQiROPmw5ZyRPGyhCZm9s?=\r\n" . + " =?ISO-2022-JP?B?ZGluZxskQiQ5JGskTiQsQDUkNyQkJHMkQCQxJEkkJCRDJD8kJCRJGyhC?=\r\n" . + ' =?ISO-2022-JP?B?GyRCJCYkSiRrJHMkQCRtJCYhKRsoQg==?='; + $this->assertSame($expected, $result); + } + + /** + * Test CakeMessage::_decode function + */ + public function testDecode(): void + { + $this->message->setHeaderCharset('ISO-2022-JP'); + $result = $this->message->decode('=?ISO-2022-JP?B?GyRCRnxLXDhsGyhC?='); + $expected = '日本語'; + $this->assertSame($expected, $result); + + $this->message->setHeaderCharset('ISO-2022-JP'); + $result = $this->message->decode("=?ISO-2022-JP?B?GyRCRDkkJEQ5JCREOSQkGyhCU3ViamVjdBskQiROPmw5ZyRPGyhCZm9s?=\r\n" . + " =?ISO-2022-JP?B?ZGluZxskQiQ5JGskTiQsQDUkNyQkJHMkQCQxJEkkJCRDJD8kJCRJGyhC?=\r\n" . + ' =?ISO-2022-JP?B?GyRCJCYkSiRrJHMkQCRtJCYhKRsoQg==?='); + $expected = '長い長い長いSubjectの場合はfoldingするのが正しいんだけどいったいどうなるんだろう?'; + $this->assertSame($expected, $result); + } + + /** + * Tests charset setter/getter + */ + public function testCharset(): void + { + $this->message->setCharset('UTF-8'); + $this->assertSame($this->message->getCharset(), 'UTF-8'); + + $this->message->setCharset('ISO-2022-JP'); + $this->assertSame($this->message->getCharset(), 'ISO-2022-JP'); + + $charset = $this->message->setCharset('Shift_JIS'); + $this->assertSame('Shift_JIS', $charset->getCharset()); + } + + /** + * Tests headerCharset setter/getter + */ + public function testHeaderCharset(): void + { + $this->message->setHeaderCharset('UTF-8'); + $this->assertSame($this->message->getHeaderCharset(), 'UTF-8'); + + $this->message->setHeaderCharset('ISO-2022-JP'); + $this->assertSame($this->message->getHeaderCharset(), 'ISO-2022-JP'); + + $charset = $this->message->setHeaderCharset('Shift_JIS'); + $this->assertSame('Shift_JIS', $charset->getHeaderCharset()); + } + + /** + * Test transferEncoding + */ + public function testTransferEncoding(): void + { + // Test new transfer encoding + $expected = 'quoted-printable'; + $this->message->setTransferEncoding($expected); + $this->assertSame($expected, $this->message->getTransferEncoding()); + $this->assertSame($expected, $this->message->getContentTransferEncoding()); + + // Test default charset/encoding : utf8/8bit + $expected = '8bit'; + $this->message->reset(); + $this->assertNull($this->message->getTransferEncoding()); + $this->assertSame($expected, $this->message->getContentTransferEncoding()); + + //Test wrong encoding + $this->expectException(InvalidArgumentException::class); + $this->message->setTransferEncoding('invalid'); + } + + /** + * Tests for compatible check. + * charset property and charset() method. + * headerCharset property and headerCharset() method. + */ + public function testCharsetsCompatible(): void + { + $checkHeaders = [ + 'from' => true, + 'to' => true, + 'cc' => true, + 'subject' => true, + ]; + + // Header Charset : null (used by default UTF-8) + // Body Charset : ISO-2022-JP + $oldStyleEmail = $this->_getEmailByOldStyleCharset('iso-2022-jp', null); + $oldStyleHeaders = $oldStyleEmail->getHeaders($checkHeaders); + + $newStyleEmail = $this->_getEmailByNewStyleCharset('iso-2022-jp', null); + $newStyleHeaders = $newStyleEmail->getHeaders($checkHeaders); + + $this->assertSame($oldStyleHeaders['From'], $newStyleHeaders['From']); + $this->assertSame($oldStyleHeaders['To'], $newStyleHeaders['To']); + $this->assertSame($oldStyleHeaders['Cc'], $newStyleHeaders['Cc']); + $this->assertSame($oldStyleHeaders['Subject'], $newStyleHeaders['Subject']); + + // Header Charset : UTF-8 + // Boby Charset : ISO-2022-JP + $oldStyleEmail = $this->_getEmailByOldStyleCharset('iso-2022-jp', 'utf-8'); + $oldStyleHeaders = $oldStyleEmail->getHeaders($checkHeaders); + + $newStyleEmail = $this->_getEmailByNewStyleCharset('iso-2022-jp', 'utf-8'); + $newStyleHeaders = $newStyleEmail->getHeaders($checkHeaders); + + $this->assertSame($oldStyleHeaders['From'], $newStyleHeaders['From']); + $this->assertSame($oldStyleHeaders['To'], $newStyleHeaders['To']); + $this->assertSame($oldStyleHeaders['Cc'], $newStyleHeaders['Cc']); + $this->assertSame($oldStyleHeaders['Subject'], $newStyleHeaders['Subject']); + + // Header Charset : ISO-2022-JP + // Boby Charset : UTF-8 + $oldStyleEmail = $this->_getEmailByOldStyleCharset('utf-8', 'iso-2022-jp'); + $oldStyleHeaders = $oldStyleEmail->getHeaders($checkHeaders); + + $newStyleEmail = $this->_getEmailByNewStyleCharset('utf-8', 'iso-2022-jp'); + $newStyleHeaders = $newStyleEmail->getHeaders($checkHeaders); + + $this->assertSame($oldStyleHeaders['From'], $newStyleHeaders['From']); + $this->assertSame($oldStyleHeaders['To'], $newStyleHeaders['To']); + $this->assertSame($oldStyleHeaders['Cc'], $newStyleHeaders['Cc']); + $this->assertSame($oldStyleHeaders['Subject'], $newStyleHeaders['Subject']); + } + + /** + * @param mixed $charset + * @param mixed $headerCharset + */ + protected function _getEmailByOldStyleCharset($charset, $headerCharset): Message + { + $message = new Message(['transport' => 'debug']); + + if ($charset) { + $message->setCharset($charset); + } + if ($headerCharset) { + $message->setHeaderCharset($headerCharset); + } + + $message->setFrom('someone@example.com', 'どこかの誰か'); + $message->setTo('someperson@example.jp', 'どこかのどなたか'); + $message->setCc('miku@example.net', 'ミク'); + $message->setSubject('テストメール'); + $message->setBodyText('テストメールの本文'); + + return $message; + } + + /** + * @param mixed $charset + * @param mixed $headerCharset + */ + protected function _getEmailByNewStyleCharset($charset, $headerCharset): Message + { + $message = new Message(); + + if ($charset) { + $message->setCharset($charset); + } + if ($headerCharset) { + $message->setHeaderCharset($headerCharset); + } + + $message->setFrom('someone@example.com', 'どこかの誰か'); + $message->setTo('someperson@example.jp', 'どこかのどなたか'); + $message->setCc('miku@example.net', 'ミク'); + $message->setSubject('テストメール'); + $message->setBodyText('テストメールの本文'); + + return $message; + } + + /** + * @param string $message + */ + protected function assertLineLengths($message): void + { + $lines = explode("\r\n", $message); + foreach ($lines as $line) { + $this->assertTrue( + strlen($line) <= Message::LINE_LENGTH_MUST, + 'Line length exceeds the max. limit of Message::LINE_LENGTH_MUST', + ); + } + } + + public function testSerialization(): void + { + $message = new Message(); + $reflection = new ReflectionClass($message); + $property = $reflection->getProperty('serializableProperties'); + $serializableProperties = $property->getValue($message); + + $message + ->setSubject('I haz Cake') + ->setEmailFormat(Message::MESSAGE_BOTH) + ->setBody([ + Message::MESSAGE_TEXT => 'text message', + Message::MESSAGE_HTML => 'html message', + ]); + + $string = serialize($message); + $this->assertStringContainsString('text message', $string); + + $this->assertIsArray($serializableProperties); + $this->assertContains('subject', $serializableProperties); + $this->assertContains('emailFormat', $serializableProperties); + $this->assertContains('textMessage', $serializableProperties); + $this->assertContains('htmlMessage', $serializableProperties); + + /** @var \Cake\Mailer\Message $message */ + $message = unserialize($string); + $this->assertSame('I haz Cake', $message->getSubject()); + $body = $message->getBodyString(); + $this->assertStringContainsString('text message', $body); + $this->assertStringContainsString('html message', $body); + } +} diff --git a/tests/TestCase/Mailer/Transport/DebugTransportTest.php b/tests/TestCase/Mailer/Transport/DebugTransportTest.php new file mode 100644 index 00000000000..f6dda8a3e1e --- /dev/null +++ b/tests/TestCase/Mailer/Transport/DebugTransportTest.php @@ -0,0 +1,81 @@ +DebugTransport = new DebugTransport(); + } + + /** + * testSend method + */ + public function testSend(): void + { + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setCc(['mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso']); + $message->setBcc('phpnut@cakephp.org'); + $message->setMessageId('<4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>'); + $message->setSubject('Testing Message'); + $date = date(DATE_RFC2822); + $message->setHeaders(['Date' => $date, 'o:tag' => ['foo', 'bar']]); + $message->setBody(['text' => "First Line\nSecond Line\n.Third Line\n"]); + + $headers = "From: CakePHP Test \r\n"; + $headers .= "To: CakePHP \r\n"; + $headers .= "Cc: Mark Story , Juan Basso \r\n"; + $headers .= 'Date: ' . $date . "\r\n"; + $headers .= 'o:tag: foo' . "\r\n"; + $headers .= 'o:tag: bar' . "\r\n"; + $headers .= "Message-ID: <4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>\r\n"; + $headers .= "Subject: Testing Message\r\n"; + $headers .= "MIME-Version: 1.0\r\n"; + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $headers .= 'Content-Transfer-Encoding: 8bit'; + + $data = "First Line\r\n"; + $data .= "Second Line\r\n"; + $data .= ".Third Line\r\n\r\n"; // Not use 'RFC5321 4.5.2.Transparency' in DebugTransport. + + $result = $this->DebugTransport->send($message); + + $this->assertSame($headers, $result['headers']); + $this->assertSame($data, $result['message']); + } +} diff --git a/tests/TestCase/Mailer/Transport/MailTransportTest.php b/tests/TestCase/Mailer/Transport/MailTransportTest.php new file mode 100644 index 00000000000..0d1944e3f74 --- /dev/null +++ b/tests/TestCase/Mailer/Transport/MailTransportTest.php @@ -0,0 +1,117 @@ +MailTransport = $this->getMockBuilder(MailTransport::class) + ->onlyMethods(['_mail']) + ->getMock(); + $this->MailTransport->setConfig(['additionalParameters' => '-f']); + } + + /** + * testSendWithoutRecipient method + */ + public function testSendWithoutRecipient(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('You must specify at least one recipient. Use one of `setTo`, `setCc` or `setBcc` to define a recipient.'); + + $message = new Message(); + $this->MailTransport->send($message); + } + + /** + * testSend method + */ + public function testSendData(): void + { + $eol = "\r\n"; + + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setReturnPath('pleasereply@cakephp.org', 'CakePHP Return'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setReplyTo(['mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso']); + $message->setCc(['mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso']); + $message->setBcc('phpnut@cakephp.org'); + $message->setMessageId('<4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>'); + $longNonAscii = 'Foø Bår Béz Foø Bår Béz Foø Bår Béz Foø Bår Béz'; + $message->setSubject($longNonAscii); + $date = date(DATE_RFC2822); + $message->setHeaders([ + 'X-Mailer' => 'CakePHP Email', + 'Date' => $date, + 'X-add' => mb_encode_mimeheader($longNonAscii, 'utf8', 'B'), + ]); + $message->setBody(['text' => "First Line\nSecond Line\n.Third Line"]); + + $encoded = '=?UTF-8?B?Rm/DuCBCw6VyIELDqXogRm/DuCBCw6VyIELDqXogRm/DuCBCw6VyIELDqXog?='; + $encoded .= ' =?UTF-8?B?Rm/DuCBCw6VyIELDqXo=?='; + + $data = "From: CakePHP Test {$eol}"; + $data .= "Reply-To: Mark Story , Juan Basso {$eol}"; + $data .= "Return-Path: CakePHP Return {$eol}"; + $data .= "Cc: Mark Story , Juan Basso {$eol}"; + $data .= "Bcc: phpnut@cakephp.org{$eol}"; + $data .= "X-Mailer: CakePHP Email{$eol}"; + $data .= 'Date: ' . $date . $eol; + $data .= 'X-add: ' . $encoded . $eol; + $data .= "Message-ID: <4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>{$eol}"; + $data .= "MIME-Version: 1.0{$eol}"; + $data .= "Content-Type: text/plain; charset=UTF-8{$eol}"; + $data .= 'Content-Transfer-Encoding: 8bit'; + + $this->MailTransport->expects($this->once())->method('_mail') + ->with( + 'CakePHP ', + $encoded, + implode($eol, ['First Line', 'Second Line', '.Third Line', '', '']), + $data, + '-f', + ); + + $result = $this->MailTransport->send($message); + + $this->assertStringContainsString('Subject: ', $result['headers']); + $this->assertStringContainsString('To: ', $result['headers']); + } +} diff --git a/tests/TestCase/Mailer/Transport/SmtpTransportTest.php b/tests/TestCase/Mailer/Transport/SmtpTransportTest.php new file mode 100644 index 00000000000..2cf22ec7b1d --- /dev/null +++ b/tests/TestCase/Mailer/Transport/SmtpTransportTest.php @@ -0,0 +1,951 @@ + + */ + protected $credentials = [ + 'username' => 'mark', + 'password' => 'story', + ]; + + /** + * @var string + */ + protected $credentialsEncoded; + + /** + * Setup + */ + protected function setUp(): void + { + parent::setUp(); + + $this->socket = Mockery::mock(Socket::class)->shouldIgnoreMissing(); + + $this->SmtpTransport = new SmtpTestTransport(); + $this->SmtpTransport->setSocket($this->socket); + $this->SmtpTransport->setConfig(['client' => 'localhost']); + + $this->credentialsEncoded = base64_encode(chr(0) . 'mark' . chr(0) . 'story'); + } + + /** + * testConnectEhlo method + */ + public function testConnectEhlo(): void + { + $this->socket->shouldReceive('connect')->andReturnTrue(); + $this->socket->shouldReceive('read')->andReturn("220 Welcome message\r\n", "250 Accepted\r\n"); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + + $this->SmtpTransport->connect(); + } + + /** + * testConnectEhloTls method + */ + public function testConnectEhloTls(): void + { + $this->SmtpTransport->setConfig(['tls' => true]); + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n", + "220 Server ready\r\n", + "250 Accepted\r\n", + ) + ->times(4); + + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->socket->shouldReceive('write')->with("STARTTLS\r\n")->once(); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + + $this->socket->shouldReceive('enableCrypto')->with('tls')->once(); + + $this->SmtpTransport->connect(); + } + + /** + * testConnectEhloTlsOnNonTlsServer method + */ + public function testConnectEhloTlsOnNonTlsServer(): void + { + $this->SmtpTransport->setConfig(['tls' => true]); + $this->socket->shouldReceive('connect')->andReturnTrue(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n", + "500 5.3.3 Unrecognized command\r\n", + ) + ->times(3); + + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->socket->shouldReceive('write')->with("STARTTLS\r\n")->once(); + + $e = null; + try { + $this->SmtpTransport->connect(); + } catch (SocketException $e) { + } + + $this->assertNotNull($e); + $this->assertSame('SMTP server did not accept the connection or trying to connect to non TLS SMTP server using TLS.', $e->getMessage()); + $this->assertInstanceOf(SocketException::class, $e->getPrevious()); + $this->assertStringContainsString('500 5.3.3 Unrecognized command', $e->getPrevious()->getMessage()); + } + + /** + * testConnectEhloNoTlsOnRequiredTlsServer method + */ + public function testConnectEhloNoTlsOnRequiredTlsServer(): void + { + $this->expectException(SocketException::class); + $this->expectExceptionMessage('SMTP authentication method not allowed, check if SMTP server requires TLS.'); + $this->SmtpTransport->setConfig(['tls' => false] + $this->credentials); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n", + "504 5.7.4 Unrecognized Authentication Type\r\n", + "504 5.7.4 Unrecognized authentication type\r\n", + ) + ->times(4); + + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->SmtpTransport->connect(); + } + + public function testConnectEhloWithAuthPlain(): void + { + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n250 AUTH PLAIN LOGIN\r\n", + "235 OK\r\n", + ) + ->times(3); + + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->connect(); + $this->assertEquals($this->SmtpTransport->getAuthType(), 'PLAIN'); + } + + public function testConnectEhloWithAuthLogin(): void + { + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n250 AUTH LOGIN\r\n", + "334 Login\r\n", + "334 Pass\r\n", + "235 OK\r\n", + ); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + $this->socket->shouldReceive('write')->with("bWFyaw==\r\n")->once(); + $this->socket->shouldReceive('write')->with("c3Rvcnk=\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->connect(); + $this->assertEquals($this->SmtpTransport->getAuthType(), 'LOGIN'); + } + + /** + * testConnectHelo method + */ + public function testConnectHelo(): void + { + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "200 Not Accepted\r\n", + "250 Accepted\r\n", + ) + ->times(3); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->socket->shouldReceive('write')->with("HELO localhost\r\n")->once(); + + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + $this->SmtpTransport->connect(); + } + + /** + * testConnectFail method + */ + public function testConnectFail(): void + { + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "200 Not Accepted\r\n", + "200 Not Accepted\r\n", + ) + ->times(3); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->socket->shouldReceive('write')->with("HELO localhost\r\n")->once(); + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $e = null; + try { + $this->SmtpTransport->connect(); + } catch (SocketException $e) { + } + + $this->assertNotNull($e); + $this->assertSame('SMTP server did not accept the connection.', $e->getMessage()); + $this->assertInstanceOf(SocketException::class, $e->getPrevious()); + $this->assertStringContainsString('200 Not Accepted', $e->getPrevious()->getMessage()); + } + + /** + * Test that when "authType" is specified that's that one used instead of the + * 1st one supported by the server + * + * @return void + */ + public function testAuthTypeSet(): void + { + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n250 AUTH PLAIN LOGIN\r\n", + ) + ->twice(); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + + $this->SmtpTransport->setConfig(['authType' => SmtpTransport::AUTH_XOAUTH2]); + $this->SmtpTransport->connect(); + $this->assertEquals($this->SmtpTransport->getAuthType(), SmtpTransport::AUTH_XOAUTH2); + } + + public function testExceptionInvalidAuthType(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unsupported auth type. Available types are: PLAIN, LOGIN, XOAUTH2'); + + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n250 AUTH PLAIN LOGIN\r\n", + ) + ->twice(); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + + $this->SmtpTransport->setConfig(['authType' => 'invalid']); + $this->SmtpTransport->connect(); + } + + public function testAuthTypeUnsupported(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unsupported auth type: CRAM-MD5'); + + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n250 AUTH CRAM-MD5\r\n", + ) + ->twice(); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->connect(); + } + + public function testAuthTypeParsingIsSkippedIfNoCredentialsProvided(): void + { + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 Accepted\r\n250 AUTH CRAM-MD5\r\n", + ) + ->twice(); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + + $this->SmtpTransport->connect(); + $this->assertNull($this->SmtpTransport->getAuthType()); + } + + public function testAuthPlain(): void + { + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('read')->andReturn("235 OK\r\n")->once(); + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->auth(); + } + + /** + * testAuth method + */ + public function testAuthLogin(): void + { + $this->socket->shouldReceive('read') + ->andReturn( + "504 5.7.4 Unrecognized Authentication Type\r\n", + "334 Login\r\n", + "334 Pass\r\n", + "235 OK\r\n", + ) + ->times(4); + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + $this->socket->shouldReceive('write')->with("bWFyaw==\r\n")->once(); + $this->socket->shouldReceive('write')->with("c3Rvcnk=\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->auth(); + } + + /** + * testAuth method + */ + public function testAuthXoauth2(): void + { + $authString = base64_encode(sprintf( + "user=%s\1auth=Bearer %s\1\1", + $this->credentials['username'], + $this->credentials['password'], + )); + + $this->socket->shouldReceive('read')->andReturn("235 OK\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH XOAUTH2 {$authString}\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->setAuthType('XOAUTH2'); + $this->SmtpTransport->auth(); + } + + /** + * testAuthNotRecognized method + */ + public function testAuthNotRecognized(): void + { + $this->expectException(SocketException::class); + $this->expectExceptionMessage('AUTH command not recognized or not implemented, SMTP server may not require authentication.'); + + $this->socket->shouldReceive('read') + ->andReturn( + "504 5.7.4 Unrecognized Authentication Type\r\n", + "500 5.3.3 Unrecognized command\r\n", + ) + ->times(2); + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->auth(); + } + + /** + * testAuthNotImplemented method + */ + public function testAuthNotImplemented(): void + { + $this->expectException(SocketException::class); + $this->expectExceptionMessage('AUTH command not recognized or not implemented, SMTP server may not require authentication.'); + + $this->socket->shouldReceive('read') + ->andReturn( + "504 5.7.4 Unrecognized Authentication Type\r\n", + "502 5.3.3 Command not implemented\r\n", + ) + ->twice(); + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->auth(); + } + + /** + * testAuthBadSequence method + */ + public function testAuthBadSequence(): void + { + $this->expectException(SocketException::class); + $this->expectExceptionMessage('SMTP Error: 503 5.5.1 Already authenticated'); + + $this->socket + ->shouldReceive('read') + ->andReturn( + "504 5.7.4 Unrecognized Authentication Type\r\n", + "503 5.5.1 Already authenticated\r\n", + ) + ->twice(); + + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + $this->SmtpTransport->auth(); + } + + /** + * testAuthBadUsername method + */ + public function testAuthBadUsername(): void + { + $this->socket + ->shouldReceive('read') + ->andReturn( + "504 5.7.4 Unrecognized Authentication Type\r\n", + "334 Login\r\n", + "535 5.7.8 Authentication failed\r\n", + ) + ->times(3); + + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + $this->socket->shouldReceive('write')->with("bWFyaw==\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + + $e = null; + try { + $this->SmtpTransport->auth(); + } catch (SocketException $e) { + } + + $this->assertNotNull($e); + $this->assertSame('SMTP server did not accept the username.', $e->getMessage()); + $this->assertInstanceOf(SocketException::class, $e->getPrevious()); + $this->assertStringContainsString('535 5.7.8 Authentication failed', $e->getPrevious()->getMessage()); + } + + /** + * testAuthBadPassword method + */ + public function testAuthBadPassword(): void + { + $this->socket + ->shouldReceive('read') + ->andReturn( + "504 5.7.4 Unrecognized Authentication Type\r\n", + "334 Login\r\n", + "334 Pass\r\n", + "535 5.7.8 Authentication failed\r\n", + ) + ->times(4); + + $this->socket->shouldReceive('write')->with("AUTH PLAIN {$this->credentialsEncoded}\r\n")->once(); + $this->socket->shouldReceive('write')->with("AUTH LOGIN\r\n")->once(); + $this->socket->shouldReceive('write')->with("bWFyaw==\r\n")->once(); + $this->socket->shouldReceive('write')->with("c3Rvcnk=\r\n")->once(); + + $this->SmtpTransport->setConfig($this->credentials); + + $e = null; + try { + $this->SmtpTransport->auth(); + } catch (SocketException $e) { + } + + $this->assertNotNull($e); + $this->assertSame('SMTP server did not accept the password.', $e->getMessage()); + $this->assertInstanceOf(SocketException::class, $e->getPrevious()); + $this->assertStringContainsString('535 5.7.8 Authentication failed', $e->getPrevious()->getMessage()); + } + + /** + * testRcpt method + */ + public function testRcpt(): void + { + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setBcc('phpnut@cakephp.org'); + $message->setCc(['mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso']); + + $this->socket->shouldReceive('read')->andReturn("250 OK\r\n"); + + $this->socket->shouldReceive('write')->with("MAIL FROM:\r\n")->once(); + $this->socket->shouldReceive('write')->with("RCPT TO:\r\n")->once(); + $this->socket->shouldReceive('write')->with("RCPT TO:\r\n")->once(); + $this->socket->shouldReceive('write')->with("RCPT TO:\r\n")->once(); + $this->socket->shouldReceive('write')->with("RCPT TO:\r\n")->once(); + + $this->SmtpTransport->sendRcpt($message); + } + + /** + * testRcptWithReturnPath method + */ + public function testRcptWithReturnPath(): void + { + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setReturnPath('pleasereply@cakephp.org', 'CakePHP Return'); + + $this->socket->shouldReceive('read')->andReturn("250 OK\r\n")->twice(); + + $this->socket->shouldReceive('write')->with("MAIL FROM:\r\n")->once(); + $this->socket->shouldReceive('write')->with("RCPT TO:\r\n")->once(); + + $this->SmtpTransport->sendRcpt($message); + } + + /** + * testSendData method + */ + public function testSendData(): void + { + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setReturnPath('pleasereply@cakephp.org', 'CakePHP Return'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->setReplyTo(['mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso']); + $message->setCc(['mark@cakephp.org' => 'Mark Story', 'juan@cakephp.org' => 'Juan Basso']); + $message->setBcc('phpnut@cakephp.org'); + $message->setMessageId('<4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>'); + $message->setSubject('Testing SMTP'); + $date = date(DATE_RFC2822); + $message->setHeaders(['Date' => $date]); + $message->setBody(['text' => "First Line\nSecond Line\n.Third Line"]); + + $data = "From: CakePHP Test \r\n"; + $data .= "Reply-To: Mark Story , Juan Basso \r\n"; + $data .= "Return-Path: CakePHP Return \r\n"; + $data .= "To: CakePHP \r\n"; + $data .= "Cc: Mark Story , Juan Basso \r\n"; + $data .= 'Date: ' . $date . "\r\n"; + $data .= "Message-ID: <4d9946cf-0a44-4907-88fe-1d0ccbdd56cb@localhost>\r\n"; + $data .= "Subject: Testing SMTP\r\n"; + $data .= "MIME-Version: 1.0\r\n"; + $data .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $data .= "Content-Transfer-Encoding: 8bit\r\n"; + $data .= "\r\n"; + $data .= "First Line\r\n"; + $data .= "Second Line\r\n"; + $data .= "..Third Line\r\n\r\n"; // RFC5321 4.5.2.Transparency + $data .= "\r\n"; + $data .= "\r\n\r\n.\r\n"; + + $this->socket->shouldReceive('read') + ->andReturn( + "354 OK\r\n", + "250 OK\r\n", + ) + ->twice(); + + $this->socket->shouldReceive('write')->with("DATA\r\n")->once(); + $this->socket->shouldReceive('write')->with($data)->once(); + + $this->SmtpTransport->sendData($message); + } + + /** + * testQuit method + */ + public function testQuit(): void + { + $this->socket->shouldReceive('write')->with("QUIT\r\n")->once(); + $this->socket->shouldReceive('isConnected')->andReturnTrue()->once(); + + $this->SmtpTransport->disconnect(); + } + + /** + * Tests using empty client name + */ + public function testEmptyClientName(): void + { + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + $this->socket->shouldReceive('read')->andReturn("220 Welcome message\r\n", "250 Accepted\r\n"); + + $this->SmtpTransport->setConfig(['client' => '']); + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('Cannot use an empty client name'); + $this->SmtpTransport->connect(); + } + + /** + * testGetLastResponse method + */ + public function testGetLastResponse(): void + { + $this->assertEmpty($this->SmtpTransport->getLastResponse()); + + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250-PIPELINING\r\n", + "250-SIZE 102400000\r\n", + "250-VRFY\r\n", + "250-ETRN\r\n", + "250-STARTTLS\r\n", + "250-AUTH PLAIN LOGIN\r\n", + "250-AUTH=PLAIN LOGIN\r\n", + "250-ENHANCEDSTATUSCODES\r\n", + "250-8BITMIME\r\n", + "250 DSN\r\n", + ); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + $this->SmtpTransport->connect(); + + $expected = [ + ['code' => '250', 'message' => 'PIPELINING'], + ['code' => '250', 'message' => 'SIZE 102400000'], + ['code' => '250', 'message' => 'VRFY'], + ['code' => '250', 'message' => 'ETRN'], + ['code' => '250', 'message' => 'STARTTLS'], + ['code' => '250', 'message' => 'AUTH PLAIN LOGIN'], + ['code' => '250', 'message' => 'AUTH=PLAIN LOGIN'], + ['code' => '250', 'message' => 'ENHANCEDSTATUSCODES'], + ['code' => '250', 'message' => '8BITMIME'], + ['code' => '250', 'message' => 'DSN'], + ]; + $result = $this->SmtpTransport->getLastResponse(); + $this->assertEquals($expected, $result); + } + + /** + * Test getLastResponse() with multiple operations + */ + public function testGetLastResponseMultipleOperations(): void + { + $message = new Message(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + + $this->socket->shouldReceive('write')->with("MAIL FROM:\r\n")->once(); + $this->socket->shouldReceive('write')->with("RCPT TO:\r\n")->once(); + $this->socket->shouldReceive('read')->andReturn("250 OK\r\n")->twice(); + + $this->SmtpTransport->sendRcpt($message); + + $expected = [ + ['code' => '250', 'message' => 'OK'], + ]; + $result = $this->SmtpTransport->getLastResponse(); + $this->assertEquals($expected, $result); + } + + /** + * testBufferResponseLines method + */ + public function testBufferResponseLines(): void + { + $responseLines = [ + '123', + "456\tFOO", + 'FOOBAR', + '250-PIPELINING', + '250-ENHANCEDSTATUSCODES', + '250-8BITMIME', + '250 DSN', + ]; + $this->SmtpTransport->bufferResponseLines($responseLines); + + $expected = [ + ['code' => '123', 'message' => null], + ['code' => '250', 'message' => 'PIPELINING'], + ['code' => '250', 'message' => 'ENHANCEDSTATUSCODES'], + ['code' => '250', 'message' => '8BITMIME'], + ['code' => '250', 'message' => 'DSN'], + ]; + $result = $this->SmtpTransport->getLastResponse(); + $this->assertEquals($expected, $result); + } + + /** + * testExplicitConnectAlreadyConnected method + */ + public function testExplicitConnectAlreadyConnected(): void + { + $this->socket->shouldNotReceive('connect'); + $this->socket->shouldReceive('isConnected')->andReturnTrue()->once(); + + $this->SmtpTransport->connect(); + } + + /** + * testConnected method + */ + public function testConnected(): void + { + $this->socket->shouldReceive('isConnected') + ->andReturn(true, false) + ->twice(); + + $this->assertTrue($this->SmtpTransport->connected()); + $this->assertFalse($this->SmtpTransport->connected()); + } + + /** + * testAutoDisconnect method + */ + public function testAutoDisconnect(): void + { + $this->socket->shouldReceive('write')->with("QUIT\r\n")->once(); + $this->socket->shouldReceive('disconnect')->once(); + $this->socket->shouldReceive('isConnected')->andReturnTrue()->once(); + unset($this->SmtpTransport); + } + + /** + * testExplicitDisconnect method + */ + public function testExplicitDisconnect(): void + { + $this->socket->shouldReceive('write')->with("QUIT\r\n")->once(); + $this->socket->shouldReceive('disconnect')->once(); + $this->socket->shouldReceive('isConnected')->andReturnTrue()->once(); + $this->SmtpTransport->disconnect(); + } + + /** + * testExplicitDisconnectNotConnected method + */ + public function testExplicitDisconnectNotConnected(): void + { + $callback = function ($arg): void { + $this->assertNotEquals("QUIT\r\n", $arg); + }; + $this->socket->shouldReceive('write')->andReturnUsing($callback); + $this->socket->shouldNotReceive('disconnect'); + $this->SmtpTransport->disconnect(); + } + + /** + * testKeepAlive method + */ + public function testKeepAlive(): void + { + $this->SmtpTransport->setConfig(['keepAlive' => true]); + + $message = Mockery::mock(Message::class)->makePartial(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->shouldReceive('getBody')->twice()->andReturn(['First Line']); + + $this->socket->shouldNotReceive('disconnect'); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 OK\r\n", + "250 OK\r\n", + "250 OK\r\n", + "354 OK\r\n", + "250 OK\r\n", + "250 OK\r\n", + // Second email + "250 OK\r\n", + "250 OK\r\n", + "354 OK\r\n", + "250 OK\r\n", + ) + ->times(11); + + $andReturnCallback = function ($arg) { + $this->assertNotEquals("QUIT\r\n", $arg); + + return 1; + }; + $expected = [ + ["EHLO localhost\r\n"], + ["MAIL FROM:\r\n"], + ["RCPT TO:\r\n"], + ["DATA\r\n"], + [Mockery::pattern('/First Line/')], + ["RSET\r\n"], + // Second email + ["MAIL FROM:\r\n"], + ["RCPT TO:\r\n"], + ["DATA\r\n"], + [Mockery::pattern('/First Line/')], + ]; + foreach ($expected as $data) { + $this->socket->shouldReceive('write') + ->withArgs($data) + ->andReturnUsing($andReturnCallback) + ->once(); + } + + $this->socket->shouldReceive('connect')->once()->andReturnTrue(); + + $this->SmtpTransport->send($message); + + $this->socket->shouldReceive('isConnected')->once()->andReturnTrue(); + $this->SmtpTransport->send($message); + } + + /** + * testSendDefaults method + */ + public function testSendDefaults(): void + { + $message = Mockery::mock(Message::class)->makePartial(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->shouldReceive('getBody')->once()->andReturn(['First Line']); + + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 OK\r\n", + "250 OK\r\n", + "250 OK\r\n", + "354 OK\r\n", + "250 OK\r\n", + ) + ->atLeast() + ->times(6); + + $expected = [ + ["EHLO localhost\r\n"], + ["MAIL FROM:\r\n"], + ["RCPT TO:\r\n"], + ["DATA\r\n"], + [Mockery::pattern('/First Line/')], + ["QUIT\r\n"], + ]; + foreach ($expected as $data) { + $this->socket->shouldReceive('write') + ->withArgs($data) + ->once(); + } + + $this->socket->shouldReceive('disconnect')->once(); + + $this->SmtpTransport->send($message); + } + + /** + * testSendDefaults method + */ + public function testSendMessageTooBigOnWindows(): void + { + $message = Mockery::mock(Message::class)->makePartial(); + $message->setFrom('noreply@cakephp.org', 'CakePHP Test'); + $message->setTo('cake@cakephp.org', 'CakePHP'); + $message->shouldReceive('getBody')->once()->andReturn(['First Line']); + + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 OK\r\n", + "250 OK\r\n", + "250 OK\r\n", + "354 OK\r\n", + 'Message size too large', + null, + ) + ->atLeast() + ->times(6); + + $expected = [ + ["EHLO localhost\r\n"], + ["MAIL FROM:\r\n"], + ["RCPT TO:\r\n"], + ["DATA\r\n"], + [Mockery::pattern('/First Line/')], + ]; + foreach ($expected as $data) { + $this->socket->shouldReceive('write') + ->withArgs($data) + ->once(); + } + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('Message size too large'); + + $this->SmtpTransport->send($message); + } + + /** + * Ensure that unserialized transports have no connection. + */ + public function testSerializeCleanupSocket(): void + { + $this->socket->shouldReceive('connect')->andReturnTrue()->once(); + $this->socket->shouldReceive('read') + ->andReturn( + "220 Welcome message\r\n", + "250 OK\r\n", + ) + ->twice(); + $this->socket->shouldReceive('write')->with("EHLO localhost\r\n")->once(); + + $smtpTransport = new SmtpTestTransport(); + $smtpTransport->setSocket($this->socket); + $smtpTransport->connect(); + + $result = unserialize(serialize($smtpTransport)); + $this->assertStringContainsString('[protected] _socket => [uninitialized]', Debugger::exportVar($result)); + $this->assertFalse($result->connected()); + } +} diff --git a/tests/TestCase/Mailer/TransportFactoryTest.php b/tests/TestCase/Mailer/TransportFactoryTest.php new file mode 100644 index 00000000000..17710307653 --- /dev/null +++ b/tests/TestCase/Mailer/TransportFactoryTest.php @@ -0,0 +1,161 @@ +transports = [ + 'debug' => [ + 'className' => 'Debug', + ], + 'badClassName' => [ + 'className' => 'TestFalse', + ], + ]; + TransportFactory::setConfig($this->transports); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + TransportFactory::drop('debug'); + TransportFactory::drop('badClassName'); + TransportFactory::drop('test_smtp'); + } + + /** + * Test that using misconfigured transports fails. + */ + public function testGetMissingClassName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Transport config `debug` is invalid, the required `className` option is missing'); + + TransportFactory::drop('debug'); + TransportFactory::setConfig('debug', []); + + TransportFactory::get('debug'); + } + + /** + * Test configuring a transport. + */ + public function testSetConfig(): void + { + $settings = [ + 'className' => 'Debug', + 'log' => true, + ]; + TransportFactory::drop('debug'); + TransportFactory::setConfig('debug', $settings); + + $result = TransportFactory::getConfig('debug'); + $this->assertEquals($settings, $result); + } + + /** + * Test configuring multiple transports. + */ + public function testSetConfigMultiple(): void + { + $settings = [ + 'debug' => [ + 'className' => 'Debug', + 'log' => true, + ], + 'test_smtp' => [ + 'className' => 'Smtp', + 'username' => 'mark', + 'password' => 'password', + 'host' => 'example.com', + ], + ]; + TransportFactory::drop('debug'); + TransportFactory::setConfig($settings); + $this->assertEquals($settings['debug'], TransportFactory::getConfig('debug')); + $this->assertEquals($settings['test_smtp'], TransportFactory::getConfig('test_smtp')); + } + + /** + * Test that exceptions are raised when duplicate transports are configured. + */ + public function testSetConfigErrorOnDuplicate(): void + { + $this->expectException(BadMethodCallException::class); + $settings = [ + 'className' => 'Debug', + 'log' => true, + ]; + TransportFactory::setConfig('debug', $settings); + TransportFactory::setConfig('debug', $settings); + TransportFactory::drop('debug'); + } + + /** + * Test configTransport with an instance. + */ + public function testSetConfigInstance(): void + { + TransportFactory::drop('debug'); + $instance = new DebugTransport(); + TransportFactory::setConfig('debug', $instance); + $this->assertEquals(['className' => $instance], TransportFactory::getConfig('debug')); + } + + /** + * Test enumerating all transport configurations + */ + public function testConfigured(): void + { + $result = TransportFactory::configured(); + $this->assertIsArray($result, 'Should have config keys'); + foreach (array_keys($this->transports) as $key) { + $this->assertContains($key, $result, 'Loaded transports should be present.'); + } + } + + /** + * Test dropping a transport configuration + */ + public function testDrop(): void + { + $result = TransportFactory::getConfig('debug'); + $this->assertIsArray($result, 'Should have config data'); + TransportFactory::drop('debug'); + $this->assertNull(TransportFactory::getConfig('debug'), 'Should not exist.'); + } +} diff --git a/tests/TestCase/Network/SocketTest.php b/tests/TestCase/Network/SocketTest.php new file mode 100644 index 00000000000..0c4d5f57fe1 --- /dev/null +++ b/tests/TestCase/Network/SocketTest.php @@ -0,0 +1,494 @@ +Socket = new Socket(['timeout' => 1]); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->Socket); + } + + /** + * testConstruct method + */ + public function testConstruct(): void + { + $this->Socket = new Socket(); + $config = $this->Socket->getConfig(); + $this->assertSame($config, [ + 'persistent' => false, + 'host' => 'localhost', + 'protocol' => 'tcp', + 'port' => 80, + 'timeout' => 30, + ]); + + $this->Socket->reset(); + $this->Socket->__construct(['host' => 'foo-bar']); + $config['host'] = 'foo-bar'; + $this->assertSame($this->Socket->getConfig(), $config); + + $this->Socket = new Socket(['host' => 'www.cakephp.org', 'port' => 23, 'protocol' => 'udp']); + $config = $this->Socket->getConfig(); + + $config['host'] = 'www.cakephp.org'; + $config['port'] = 23; + $config['protocol'] = 'udp'; + + $this->assertSame($this->Socket->getConfig(), $config); + } + + /** + * test host() method + */ + public function testHost(): void + { + $this->Socket = new Socket(['host' => '1.1.1.1']); + $this->assertSame('one.one.one.one', $this->Socket->host()); + } + + /** + * test addresses() method + */ + public function testAddresses(): void + { + $this->Socket = new Socket(); + $this->assertContainsEquals('127.0.0.1', $this->Socket->addresses()); + + $this->Socket = new Socket(['host' => '8.8.8.8']); + $this->assertSame(['8.8.8.8'], $this->Socket->addresses()); + } + + /** + * testSocketConnection method + */ + public function testSocketConnection(): void + { + $this->assertFalse($this->Socket->isConnected()); + $this->Socket->disconnect(); + $this->assertFalse($this->Socket->isConnected()); + try { + $this->Socket->connect(); + $this->assertTrue($this->Socket->isConnected()); + $this->Socket->connect(); + $this->assertTrue($this->Socket->isConnected()); + + $this->Socket->disconnect(); + $config = ['persistent' => true]; + $this->Socket = new Socket($config); + $this->Socket->connect(); + $this->assertTrue($this->Socket->isConnected()); + } catch (SocketException) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + } + + /** + * data provider function for testInvalidConnection + * + * @return array + */ + public static function invalidConnections(): array + { + return [ + [['host' => 'invalid.host', 'port' => 9999, 'timeout' => 1]], + [['host' => '127.0.0.1', 'port' => '70000', 'timeout' => 1]], + ]; + } + + /** + * testInvalidConnection method + */ + #[DataProvider('invalidConnections')] + public function testInvalidConnection(array $data): void + { + $this->expectException(SocketException::class); + $this->Socket->setConfig($data); + $this->Socket->connect(); + } + + /** + * testSocketHost method + */ + public function testSocketHost(): void + { + try { + $this->Socket = new Socket(); + $this->Socket->connect(); + $this->assertSame('127.0.0.1', $this->Socket->address()); + $this->assertEquals(gethostbyaddr('127.0.0.1'), $this->Socket->host()); + $this->assertNull($this->Socket->lastError()); + $this->assertContains('127.0.0.1', $this->Socket->addresses()); + + $this->Socket = new Socket(['host' => '127.0.0.1']); + $this->Socket->connect(); + $this->assertSame('127.0.0.1', $this->Socket->address()); + $this->assertEquals(gethostbyaddr('127.0.0.1'), $this->Socket->host()); + $this->assertNull($this->Socket->lastError()); + $this->assertContains('127.0.0.1', $this->Socket->addresses()); + } catch (SocketException) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + } + + /** + * testSocketWriting method + */ + public function testSocketWriting(): void + { + try { + $request = "GET / HTTP/1.1\r\nConnection: close\r\n\r\n"; + $this->assertTrue((bool)$this->Socket->write($request)); + } catch (SocketException) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + } + + /** + * testSocketReading method + */ + public function testSocketReading(): void + { + $this->Socket = new Socket(['timeout' => 5]); + try { + $this->Socket->connect(); + $this->assertNull($this->Socket->read(26)); + + $config = ['host' => 'google.com', 'port' => 80, 'timeout' => 1]; + $this->Socket = new Socket($config); + $this->assertTrue($this->Socket->connect()); + $this->assertNull($this->Socket->read(26)); + $this->assertSame('2: ' . 'Connection timed out', $this->Socket->lastError()); + } catch (SocketException) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + } + + /** + * testTimeOutConnection method + */ + public function testTimeOutConnection(): void + { + $config = ['host' => '127.0.0.1', 'timeout' => 1]; + $this->Socket = new Socket($config); + try { + $this->assertTrue($this->Socket->connect()); + + $this->Socket = new Socket($config); + $this->assertNull($this->Socket->read(1024 * 1024)); + $this->assertSame('2: ' . 'Connection timed out', $this->Socket->lastError()); + } catch (SocketException) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + } + + /** + * testLastError method + */ + public function testLastError(): void + { + $this->Socket = new Socket(); + $this->Socket->setLastError(4, 'some error here'); + $this->assertSame('4: some error here', $this->Socket->lastError()); + } + + /** + * testReset method + */ + public function testReset(): void + { + $config = [ + 'persistent' => true, + 'host' => '127.0.0.1', + 'protocol' => 'udp', + 'port' => 80, + 'timeout' => 20, + ]; + $anotherSocket = new Socket($config); + $anotherSocket->reset(); + + $expected = [ + 'persistent' => false, + 'host' => 'localhost', + 'protocol' => 'tcp', + 'port' => 80, + 'timeout' => 30, + ]; + $this->assertEquals( + $expected, + $anotherSocket->getConfig(), + 'Reset should cause config to return the defaults defined in _defaultConfig', + ); + } + + /** + * testEncrypt + */ + public function testEnableCryptoSocketExceptionNoSsl(): void + { + $this->skipIf(!extension_loaded('openssl'), 'OpenSSL is not enabled cannot test SSL.'); + $configNoSslOrTls = ['host' => 'localhost', 'port' => 80, 'timeout' => 1]; + + // testing exception on no ssl socket server for ssl and tls methods + $this->Socket = new Socket($configNoSslOrTls); + + try { + $this->Socket->connect(); + } catch (SocketException $e) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + + $e = null; + try { + $this->Socket->enableCrypto('tlsv10', 'client'); + } catch (SocketException $e) { + } + + $this->assertNotNull($e); + } + + /** + * testEnableCryptoSocketExceptionNoTls + */ + public function testEnableCryptoSocketExceptionNoTls(): void + { + $configNoSslOrTls = ['host' => 'localhost', 'port' => 80, 'timeout' => 1]; + + // testing exception on no ssl socket server for ssl and tls methods + $this->Socket = new Socket($configNoSslOrTls); + + try { + $this->Socket->connect(); + } catch (SocketException $e) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + + $e = null; + try { + $this->Socket->enableCrypto('tls', 'client'); + } catch (SocketException $e) { + } + + $this->assertNotNull($e); + } + + /** + * Test that protocol in the host doesn't cause cert errors. + */ + public function testConnectProtocolInHost(): void + { + $this->skipIf(!extension_loaded('openssl'), 'OpenSSL is not enabled cannot test SSL.'); + $configSslTls = ['host' => 'ssl://smtp.gmail.com', 'port' => 465, 'timeout' => 5]; + $socket = new Socket($configSslTls); + try { + $socket->connect(); + $this->assertSame('smtp.gmail.com', $socket->getConfig('host')); + $this->assertSame('ssl', $socket->getConfig('protocol')); + } catch (SocketException) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + } + + /** + * _connectSocketToSslTls + */ + protected function _connectSocketToSslTls(): void + { + $this->skipIf(!extension_loaded('openssl'), 'OpenSSL is not enabled cannot test SSL.'); + $configSslTls = ['host' => 'smtp.gmail.com', 'port' => 465, 'timeout' => 5]; + $this->Socket = new Socket($configSslTls); + try { + $this->Socket->connect(); + } catch (SocketException) { + $this->markTestSkipped('Cannot test network, skipping.'); + } + } + + /** + * testEnableCryptoBadMode + */ + public function testEnableCryptoBadMode(): void + { + $this->expectException(InvalidArgumentException::class); + // testing wrong encryption mode + $this->_connectSocketToSslTls(); + $this->Socket->enableCrypto('doesntExistMode', 'server'); + $this->Socket->disconnect(); + } + + /** + * testEnableCrypto + */ + public function testEnableCrypto(): void + { + $this->_connectSocketToSslTls(); + $this->Socket->enableCrypto('tls', 'client'); + $result = $this->Socket->disconnect(); + $this->assertTrue($result); + } + + /** + * testEnableCryptoExceptionEnableTwice + */ + public function testEnableCryptoExceptionEnableTwice(): void + { + $this->expectException(SocketException::class); + // testing on tls server + $this->_connectSocketToSslTls(); + $this->Socket->enableCrypto('tls', 'client'); + $this->expectWarningMessageMatches('/^Unable to perform enableCrypto operation on the current socket$/', function (): void { + $this->Socket->enableCrypto('tls', 'client'); + }); + } + + /** + * testEnableCryptoExceptionDisableTwice + */ + public function testEnableCryptoExceptionDisableTwice(): void + { + $this->expectException(SocketException::class); + $this->_connectSocketToSslTls(); + $this->Socket->enableCrypto('tls', 'client', false); + } + + /** + * testEnableCryptoEnableStatus + */ + public function testEnableCryptoEnableTls12(): void + { + $this->_connectSocketToSslTls(); + $this->assertFalse($this->Socket->isEncrypted()); + $this->Socket->enableCrypto('tlsv12', 'client', true); + $this->assertTrue($this->Socket->isEncrypted()); + } + + /** + * testEnableCryptoEnableStatus + */ + public function testEnableCryptoEnableStatus(): void + { + $this->_connectSocketToSslTls(); + $this->assertFalse($this->Socket->isEncrypted()); + $this->Socket->enableCrypto('tls', 'client', true); + $this->assertTrue($this->Socket->isEncrypted()); + } + + /** + * test getting the context for a socket. + */ + public function testGetContext(): void + { + $this->skipIf(!extension_loaded('openssl'), 'OpenSSL is not enabled cannot test SSL.'); + $config = [ + 'host' => 'smtp.gmail.com', + 'port' => 465, + 'timeout' => 5, + 'context' => [ + 'ssl' => ['capture_peer' => true], + ], + ]; + try { + $this->Socket = new Socket($config); + $this->Socket->connect(); + } catch (SocketException) { + $this->markTestSkipped('No network, skipping test.'); + } + $result = $this->Socket->context(); + $this->assertTrue($result['ssl']['capture_peer']); + } + + /** + * test configuring the context from the flat keys. + */ + public function testConfigContext(): void + { + $this->skipIf(!extension_loaded('openssl'), 'OpenSSL is not enabled cannot test SSL.'); + $this->skipIf(!empty(getenv('http_proxy')) || !empty(getenv('https_proxy')), 'Proxy detected and cannot test SSL.'); + $config = [ + 'host' => 'smtp.gmail.com', + 'port' => 465, + 'timeout' => 5, + 'ssl_verify_peer' => true, + 'ssl_allow_self_signed' => false, + 'ssl_verify_depth' => 5, + 'ssl_verify_host' => true, + ]; + $socket = new Socket($config); + + $socket->connect(); + $result = $socket->context(); + + $this->assertTrue($result['ssl']['verify_peer']); + $this->assertFalse($result['ssl']['allow_self_signed']); + $this->assertSame(5, $result['ssl']['verify_depth']); + $this->assertSame('smtp.gmail.com', $result['ssl']['CN_match']); + $this->assertArrayNotHasKey('ssl_verify_peer', $socket->getConfig()); + $this->assertArrayNotHasKey('ssl_allow_self_signed', $socket->getConfig()); + $this->assertArrayNotHasKey('ssl_verify_host', $socket->getConfig()); + $this->assertArrayNotHasKey('ssl_verify_depth', $socket->getConfig()); + } + + /** + * test connect to a unix file socket + */ + public function testConnectToUnixFileSocket(): void + { + $socketName = 'unix:///tmp/test.socket'; + $socket = $this->getMockBuilder(Socket::class) + ->onlyMethods(['_getStreamSocketClient']) + ->getMock(); + $socket->expects($this->once()) + ->method('_getStreamSocketClient') + ->with('unix:///tmp/test.socket', null, null, 1) + ->willReturn(false); + $socket->setConfig([ + 'host' => $socketName, + 'port' => null, + 'timeout' => 1, + 'persistent' => true, + ]); + $socket->connect(); + } +} diff --git a/tests/TestCase/ORM/Association/BelongsToManySaveAssociatedOnlyEntitiesAppendTest.php b/tests/TestCase/ORM/Association/BelongsToManySaveAssociatedOnlyEntitiesAppendTest.php new file mode 100644 index 00000000000..6c1364c7ef4 --- /dev/null +++ b/tests/TestCase/ORM/Association/BelongsToManySaveAssociatedOnlyEntitiesAppendTest.php @@ -0,0 +1,104 @@ +tag = new Table(['alias' => 'Tags', 'table' => 'tags']); + $this->tag->setSchema([ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]); + $this->article = new Table(['alias' => 'Articles', 'table' => 'articles']); + $this->article->setSchema([ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]); + } + + /** + * Test that saveAssociated() ignores non entity values. + */ + public function testSaveAssociatedOnlyEntitiesAppend(): void + { + $connection = ConnectionManager::get('test'); + /** @var \Cake\Test\TestCase\ORM\Association\MockedTable&\Mockery\MockInterface $table */ + $table = Mockery::mock(new MockedTable(['table' => 'tags', 'connection' => $connection])) + ->makePartial(); + $table->setPrimaryKey('id'); + + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $table, + 'saveStrategy' => BelongsToMany::SAVE_APPEND, + ]; + + $entity = new Entity([ + 'id' => 1, + 'title' => 'First Post', + 'tags' => [ + ['tag' => 'nope'], + new Entity(['tag' => 'cakephp']), + ], + ]); + + $table->shouldReceive('saveAssociated')->never(); + + $association = new BelongsToMany('Tags', $config); + $association->saveAssociated($entity); + } +} + +// phpcs:disable +class MockedTable extends Table +{ + public function saveAssociated() {} + public function schema() {} +} +// phpcs:enable diff --git a/tests/TestCase/ORM/Association/BelongsToManyTest.php b/tests/TestCase/ORM/Association/BelongsToManyTest.php new file mode 100644 index 00000000000..8db777d62df --- /dev/null +++ b/tests/TestCase/ORM/Association/BelongsToManyTest.php @@ -0,0 +1,1781 @@ + + */ + protected array $fixtures = [ + 'core.Articles', + 'core.Tags', + 'core.SpecialTags', + 'core.ArticlesTags', + 'core.ArticlesTagsBindingKeys', + 'core.BinaryUuidItems', + 'core.BinaryUuidTags', + 'core.BinaryUuidItemsBinaryUuidTags', + 'core.CompositeKeyArticles', + 'core.CompositeKeyArticlesTags', + ]; + + /** + * @var \Cake\ORM\Table + */ + protected $tag; + + /** + * @var \Cake\ORM\Table + */ + protected $article; + + /** + * Set up + */ + protected function setUp(): void + { + parent::setUp(); + $this->tag = new Table(['alias' => 'Tags', 'table' => 'tags']); + $this->tag->setSchema([ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]); + $this->article = new Table(['alias' => 'Articles', 'table' => 'articles']); + $this->article->setSchema([ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]); + } + + protected function tearDown(): void + { + parent::tearDown(); + ConnectionManager::drop('test_read_write'); + Log::drop('queries'); + } + + /** + * Tests setForeignKey() + */ + public function testSetForeignKey(): void + { + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + ]); + $this->assertSame('article_id', $assoc->getForeignKey()); + $this->assertSame($assoc, $assoc->setForeignKey('another_key')); + $this->assertSame('another_key', $assoc->getForeignKey()); + } + + /** + * Tests that the association reports it can be joined + */ + public function testCanBeJoined(): void + { + $assoc = new BelongsToMany('Test'); + $this->assertFalse($assoc->canBeJoined()); + } + + /** + * Tests setSort() method + */ + public function testSetSort(): void + { + $assoc = new BelongsToMany('Test'); + $this->assertNull($assoc->getSort()); + + $assoc->setSort('id ASC'); + $this->assertSame('id ASC', $assoc->getSort()); + + $assoc->setSort(['id' => 'ASC']); + $this->assertSame(['id' => 'ASC'], $assoc->getSort()); + + $closure = function () { + return ['id' => 'ASC']; + }; + $assoc->setSort($closure); + $this->assertSame($closure, $assoc->getSort()); + + $expression = new OrderClauseExpression('id', 'ASC'); + $assoc->setSort($expression); + $this->assertSame($expression, $assoc->getSort()); + } + + /** + * Tests that sorting works using the accepted types for `setSort()`. + */ + public function testSorting(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $assoc = $articles->belongsToMany('Tags'); + + $field = 'Tags.id'; + $driver = $articles->getConnection()->getDriver(); + if ($driver->isAutoQuotingEnabled()) { + $field = $driver->quoteIdentifier($field); + } + + $assoc->setSort("{$field} DESC"); + $result = $articles->get(1, ...['contain' => 'Tags']); + $this->assertSame([2, 1], array_column($result['tags'], 'id')); + + $assoc->setSort(['Tags.id' => 'DESC']); + $result = $articles->get(1, ...['contain' => 'Tags']); + $this->assertSame([2, 1], array_column($result['tags'], 'id')); + + $assoc->setSort(function () { + return ['Tags.id' => 'DESC']; + }); + $result = $articles->get(1, ...['contain' => 'Tags']); + $this->assertSame([2, 1], array_column($result['tags'], 'id')); + + $assoc->setSort(new OrderClauseExpression('Tags.id', 'DESC')); + $result = $articles->get(1, ...['contain' => 'Tags']); + $this->assertSame([2, 1], array_column($result['tags'], 'id')); + } + + /** + * Tests requiresKeys() method + */ + public function testRequiresKeys(): void + { + $assoc = new BelongsToMany('Test'); + $this->assertTrue($assoc->requiresKeys()); + + $assoc->setStrategy(BelongsToMany::STRATEGY_SUBQUERY); + $this->assertFalse($assoc->requiresKeys()); + + $assoc->setStrategy(BelongsToMany::STRATEGY_SELECT); + $this->assertTrue($assoc->requiresKeys()); + } + + /** + * Tests that BelongsToMany can't use the join strategy + */ + public function testStrategyFailure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid strategy `join` was provided'); + $assoc = new BelongsToMany('Test'); + $assoc->setStrategy(BelongsToMany::STRATEGY_JOIN); + } + + public function testJunctionProperty(): void + { + $assoc = new BelongsToMany('Test'); + $this->assertSame('_joinData', $assoc->getJunctionProperty()); + + $assoc = new BelongsToMany('Test', ['junctionProperty' => 'junction']); + $this->assertSame('junction', $assoc->getJunctionProperty()); + + $assoc->setJunctionProperty('_pivot'); + $this->assertSame('_pivot', $assoc->getJunctionProperty()); + } + + /** + * Tests the junction method + */ + public function testJunction(): void + { + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'strategy' => 'subquery', + ]); + $junction = $assoc->junction(); + $this->assertInstanceOf(Table::class, $junction); + $this->assertSame('ArticlesTags', $junction->getAlias()); + $this->assertSame('articles_tags', $junction->getTable()); + $this->assertSame($this->article, $junction->getAssociation('Articles')->getTarget()); + $this->assertSame($this->tag, $junction->getAssociation('Tags')->getTarget()); + + $this->assertInstanceOf(BelongsTo::class, $junction->getAssociation('Articles')); + $this->assertInstanceOf(BelongsTo::class, $junction->getAssociation('Tags')); + + $this->assertSame($junction, $this->tag->getAssociation('ArticlesTags')->getTarget()); + $this->assertSame($this->article, $this->tag->getAssociation('Articles')->getTarget()); + + $this->assertInstanceOf(BelongsToMany::class, $this->tag->getAssociation('Articles')); + $this->assertInstanceOf(HasMany::class, $this->tag->getAssociation('ArticlesTags')); + + $this->assertSame($junction, $assoc->junction()); + $junction2 = $this->getTableLocator()->get('Foos'); + $assoc->junction($junction2); + $this->assertSame($junction2, $assoc->junction()); + + $assoc->junction('ArticlesTags'); + $this->assertSame($junction, $assoc->junction()); + + $this->assertSame($assoc->getStrategy(), $this->tag->getAssociation('Articles')->getStrategy()); + $this->assertSame($assoc->getStrategy(), $this->tag->getAssociation('ArticlesTags')->getStrategy()); + $this->assertSame($assoc->getStrategy(), $this->article->getAssociation('ArticlesTags')->getStrategy()); + + $this->assertSame($this->article->getPrimaryKey(), $junction->getAssociation('Articles')->getBindingKey()); + $this->assertSame($this->tag->getPrimaryKey(), $junction->getAssociation('Tags')->getBindingKey()); + } + + /** + * Tests the junction passes the source connection name on. + */ + public function testJunctionConnection(): void + { + $config = ConnectionManager::getConfig('test'); + ConnectionManager::setConfig('other_source', $config); + $otherConnection = ConnectionManager::get('other_source'); + $this->article->setConnection($otherConnection); + + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + ]); + $junction = $assoc->junction(); + $this->assertSame($otherConnection, $junction->getConnection()); + ConnectionManager::drop('other_source'); + } + + /** + * Tests the junction method custom keys + */ + public function testJunctionCustomKeys(): void + { + $this->article->belongsToMany('Tags', [ + 'targetTable' => $this->tag, + 'joinTable' => 'articles_tags', + 'foreignKey' => 'article', + 'targetForeignKey' => 'tag', + ]); + $this->tag->belongsToMany('Articles', [ + 'targetTable' => $this->article, + 'joinTable' => 'articles_tags', + 'foreignKey' => 'tag', + 'targetForeignKey' => 'article', + ]); + $junction = $this->article->getAssociation('Tags')->junction(); + $this->assertSame('article', $junction->getAssociation('Articles')->getForeignKey()); + $this->assertSame('article', $this->article->getAssociation('ArticlesTags')->getForeignKey()); + + $junction = $this->tag->getAssociation('Articles')->junction(); + $this->assertSame('tag', $junction->getAssociation('Tags')->getForeignKey()); + $this->assertSame('tag', $this->tag->getAssociation('ArticlesTags')->getForeignKey()); + } + + /** + * Tests it is possible to set the table name for the join table + */ + public function testJunctionWithDefaultTableName(): void + { + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'joinTable' => 'tags_articles', + ]); + $junction = $assoc->junction(); + $this->assertSame('TagsArticles', $junction->getAlias()); + $this->assertSame('tags_articles', $junction->getTable()); + } + + /** + * Test multiple associations with differerent keys fails + */ + public function testMultipleAssociationsSameJunction(): void + { + $assoc = new BelongsToMany('This', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'targetForeignKey' => 'this_id', + ]); + $assoc->junction(); + + $assoc = new BelongsToMany('That', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'targetForeignKey' => 'that_id', + ]); + + $this->expectException(InvalidArgumentException::class); + $assoc->junction(); + } + + /** + * Tests same source and target table failure. + */ + public function testSameSourceTargetJunction(): void + { + $assoc = new BelongsToMany('This', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->article, + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `This` association on `Articles` cannot target the same table.'); + $assoc->junction(); + } + + /** + * Tests saveStrategy + */ + public function testSetSaveStrategy(): void + { + $assoc = new BelongsToMany('Test'); + $this->assertSame(BelongsToMany::SAVE_REPLACE, $assoc->getSaveStrategy()); + + $assoc->setSaveStrategy(BelongsToMany::SAVE_APPEND); + $this->assertSame(BelongsToMany::SAVE_APPEND, $assoc->getSaveStrategy()); + + $assoc->setSaveStrategy(BelongsToMany::SAVE_REPLACE); + $this->assertSame(BelongsToMany::SAVE_REPLACE, $assoc->getSaveStrategy()); + } + + /** + * Tests that it is possible to pass the saveAssociated strategy in the constructor + */ + public function testSaveStrategyInOptions(): void + { + $assoc = new BelongsToMany('Test', ['saveStrategy' => BelongsToMany::SAVE_APPEND]); + $this->assertSame(BelongsToMany::SAVE_APPEND, $assoc->getSaveStrategy()); + } + + /** + * Tests that passing an invalid strategy will throw an exception + */ + public function testSaveStrategyInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid save strategy `depsert`'); + new BelongsToMany('Test', ['saveStrategy' => 'depsert']); + } + + /** + * Ensure that the `finder` option is applied to the target + * table. + */ + public function testFinderOption(): void + { + $this->setAppNamespace('TestApp'); + + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $tags->associations()->get('Articles')->setFinder('published'); + $articles->updateAll(['published' => 'N'], ['id' => 1]); + $entity = $tags->get(1, ...['contain' => 'Articles']); + $this->assertCount(1, $entity->articles, 'only one article should load'); + $this->assertSame('Y', $entity->articles[0]->published); + } + + /** + * Test cascading deletes. + */ + public function testCascadeDelete(): void + { + /** @var \Cake\ORM\Table&\Mockery\MockInterface $articleTag */ + $articleTag = Mockery::mock(new Table(['alias' => 'ArticlesTags', 'table' => 'articles_tags'])) + ->makePartial(); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'sort' => ['id' => 'ASC'], + ]; + $association = new BelongsToMany('Tags', $config); + $association->junction($articleTag); + $this->article + ->getAssociation($articleTag->getAlias()) + ->setConditions(['click_count' => 3]); + + $articleTag->shouldReceive('deleteAll') + ->once() + ->with([ + 'click_count' => 3, + 'article_id' => 1, + ]); + + $entity = new Entity(['id' => 1, 'name' => 'PHP']); + $association->cascadeDelete($entity); + } + + /** + * Test cascading deletes with dependent=false + */ + public function testCascadeDeleteDependent(): void + { + /** @var \Cake\ORM\Table&\Mockery\MockInterface $articleTag */ + $articleTag = Mockery::mock(new Table(['alias' => 'ArticlesTags', 'table' => 'articles_tags'])) + ->makePartial(); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'dependent' => false, + 'sort' => ['id' => 'ASC'], + ]; + $association = new BelongsToMany('Tags', $config); + $association->junction($articleTag); + $this->article + ->getAssociation($articleTag->getAlias()) + ->setConditions(['click_count' => 3]); + + $articleTag->shouldReceive('deleteAll')->never(); + $articleTag->shouldReceive('delete')->never(); + + $entity = new Entity(['id' => 1, 'name' => 'PHP']); + $association->cascadeDelete($entity); + } + + /** + * Test cascading deletes with callbacks. + */ + public function testCascadeDeleteWithCallbacks(): void + { + $articleTag = $this->getTableLocator()->get('ArticlesTags'); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'cascadeCallbacks' => true, + ]; + $association = new BelongsToMany('Tag', $config); + $association->junction($articleTag); + $this->article->getAssociation($articleTag->getAlias()); + + $counter = 0; + $articleTag->getEventManager()->on('Model.beforeDelete', function () use (&$counter): void { + $counter++; + }); + + $this->assertSame(2, $articleTag->find()->where(['article_id' => 1])->count()); + $entity = new Entity(['id' => 1, 'name' => 'PHP']); + $association->cascadeDelete($entity); + + $this->assertSame(0, $articleTag->find()->where(['article_id' => 1])->count()); + $this->assertSame(2, $counter); + } + + /** + * Test cascading delete with a rule preventing deletion + */ + public function testCascadeDeleteCallbacksRuleFailure(): void + { + $articleTag = $this->getTableLocator()->get('ArticlesTags'); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'cascadeCallbacks' => true, + ]; + $association = new BelongsToMany('Tag', $config); + $association->junction($articleTag); + $this->article->getAssociation($articleTag->getAlias()); + + $articleTag->getEventManager()->on('Model.buildRules', function ($event, $rules): void { + $rules->addDelete(function () { + return false; + }); + }); + $entity = new Entity(['id' => 1, 'name' => 'PHP']); + $this->assertFalse($association->cascadeDelete($entity)); + + $matching = $articleTag->find() + ->where(['ArticlesTags.tag_id' => $entity->id]) + ->all(); + $this->assertGreaterThan(0, count($matching)); + } + + /** + * Test linking entities having a non persisted source entity + */ + public function testLinkWithNotPersistedSource(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Source entity needs to be persisted before links can be created or removed'); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'joinTable' => 'tags_articles', + ]; + $assoc = new BelongsToMany('Test', $config); + $entity = new Entity(['id' => 1]); + $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])]; + $assoc->link($entity, $tags); + } + + /** + * Test liking entities having a non persisted target entity + */ + public function testLinkWithNotPersistedTarget(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot link entities that have not been persisted yet'); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'joinTable' => 'tags_articles', + ]; + $assoc = new BelongsToMany('Test', $config); + $entity = new Entity(['id' => 1], ['markNew' => false]); + $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])]; + $assoc->link($entity, $tags); + } + + /** + * Tests that linking entities will persist correctly with append strategy + */ + public function testLinkSuccessSaveAppend(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $config = [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'saveStrategy' => BelongsToMany::SAVE_APPEND, + ]; + $assoc = $articles->belongsToMany('Tags', $config); + + // Load without tags as that is a main use case for append strategies + $article = $articles->get(1); + $opts = ['markNew' => false]; + $tags = [ + new Entity(['id' => 2, 'name' => 'add'], $opts), + new Entity(['id' => 3, 'name' => 'adder'], $opts), + ]; + + $this->assertTrue($assoc->link($article, $tags)); + $this->assertCount(2, $article->tags, 'In-memory tags are incorrect'); + $this->assertSame([2, 3], collection($article->tags)->extract('id')->toList()); + + $article = $articles->get(1, ...['contain' => ['Tags']]); + $this->assertCount(3, $article->tags, 'Persisted tags are wrong'); + $this->assertSame([1, 2, 3], collection($article->tags)->extract('id')->toList()); + } + + /** + * Tests that linking the same tag to multiple articles works + */ + public function testLinkSaveAppendSharedTarget(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $articlesTags->deleteAll('1=1'); + + $config = [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'saveStrategy' => BelongsToMany::SAVE_APPEND, + ]; + $assoc = $articles->belongsToMany('Tags', $config); + + $articleOne = $articles->get(1); + $articleTwo = $articles->get(2); + + $tagTwo = $tags->get(2); + $tagThree = $tags->get(3); + + $this->assertTrue($assoc->link($articleOne, [$tagThree, $tagTwo])); + $this->assertTrue($assoc->link($articleTwo, [$tagThree])); + + $this->assertCount(2, $articleOne->tags, 'In-memory tags are incorrect'); + $this->assertSame([3, 2], collection($articleOne->tags)->extract('id')->toList()); + + $this->assertCount(1, $articleTwo->tags, 'In-memory tags are incorrect'); + $this->assertSame([3], collection($articleTwo->tags)->extract('id')->toList()); + $rows = $articlesTags->find()->all(); + $this->assertCount(3, $rows, '3 link rows should be created.'); + } + + /** + * Tests that liking entities will validate data and pass on to _saveLinks + */ + public function testLinkSuccessWithMocks(): void + { + $connection = ConnectionManager::get('test'); + /** @var \Cake\ORM\Table&\Mockery\MockInterface $joint */ + $joint = Mockery::mock(new Table(['alias' => 'ArticlesTags', 'connection' => $connection])) + ->makePartial(); + + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'through' => $joint, + 'joinTable' => 'tags_articles', + ]; + + $assoc = new BelongsToMany('Test', $config); + $opts = ['markNew' => false]; + $entity = new Entity(['id' => 1], $opts); + $tags = [new Entity(['id' => 2], $opts), new Entity(['id' => 3], $opts)]; + $saveOptions = ['foo' => 'bar']; + + $joint->shouldReceive('getPrimaryKey') + ->andReturn(['article_id', 'tag_id']); + + $joint->shouldReceive('save') + ->twice() + ->andReturn($entity, $entity); + + $this->assertTrue($assoc->link($entity, $tags, $saveOptions)); + $this->assertSame($entity->test, $tags); + } + + /** + * Tests that linking entities will set the junction table registry alias + */ + public function testLinkSetSourceToJunctionEntities(): void + { + $connection = ConnectionManager::get('test'); + /** @var \Cake\ORM\Table&\Mockery\MockInterface $joint */ + $joint = Mockery::mock(new Table(['alias' => 'ArticlesTags', 'connection' => $connection])) + ->makePartial(); + $joint->setRegistryAlias('Plugin.ArticlesTags'); + + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'through' => $joint, + ]; + + $assoc = new BelongsToMany('Tags', $config); + $opts = ['markNew' => false]; + $entity = new Entity(['id' => 1], $opts); + $tags = [new Entity(['id' => 2], $opts)]; + + $joint->shouldReceive('getPrimaryKey') + ->andReturn(['article_id', 'tag_id']); + + $joint->shouldReceive('save') + ->once() + ->andReturnUsing(function (EntityInterface $e) { + $this->assertSame('Plugin.ArticlesTags', $e->getSource()); + + return $e; + }); + + $this->assertTrue($assoc->link($entity, $tags)); + $this->assertSame($entity->tags, $tags); + $this->assertSame('Plugin.ArticlesTags', $entity->tags[0]->get('_joinData')->getSource()); + } + + /** + * Test liking entities having a non persisted source entity + */ + public function testUnlinkWithNotPersistedSource(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Source entity needs to be persisted before links can be created or removed'); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'joinTable' => 'tags_articles', + ]; + $assoc = new BelongsToMany('Test', $config); + $entity = new Entity(['id' => 1]); + $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])]; + $assoc->unlink($entity, $tags); + } + + /** + * Test liking entities having a non persisted target entity + */ + public function testUnlinkWithNotPersistedTarget(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot link entities that have not been persisted'); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'joinTable' => 'tags_articles', + ]; + $assoc = new BelongsToMany('Test', $config); + $entity = new Entity(['id' => 1], ['markNew' => false]); + $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])]; + $assoc->unlink($entity, $tags); + } + + /** + * Tests that unlinking calls the right methods + */ + public function testUnlinkSuccess(): void + { + $joint = $this->getTableLocator()->get('SpecialTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'through' => $joint, + 'joinTable' => 'special_tags', + ]); + $entity = $articles->get(2, ...['contain' => 'Tags']); + $initial = $entity->tags; + $this->assertCount(1, $initial); + + $this->assertTrue($assoc->unlink($entity, $entity->tags)); + $this->assertEmpty($entity->get('tags'), 'Property should be empty'); + + $new = $articles->get(2, ...['contain' => 'Tags']); + $this->assertCount(0, $new->tags, 'DB should be clean'); + $this->assertSame(3, $tags->find()->count(), 'Tags should still exist'); + } + + /** + * Tests that unlinking with last parameter set to false + * will not remove entities from the association property + */ + public function testUnlinkWithoutPropertyClean(): void + { + $joint = $this->getTableLocator()->get('SpecialTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'through' => $joint, + 'joinTable' => 'special_tags', + 'conditions' => ['SpecialTags.highlighted' => true], + ]); + $entity = $articles->get(2, ...['contain' => 'Tags']); + $initial = $entity->tags; + $this->assertCount(1, $initial); + + $this->assertTrue($assoc->unlink($entity, $initial, ['cleanProperty' => false])); + $this->assertNotEmpty($entity->get('tags'), 'Property should not be empty'); + $this->assertEquals($initial, $entity->get('tags'), 'Property should be untouched'); + + $new = $articles->get(2, ...['contain' => 'Tags']); + $this->assertCount(0, $new->tags, 'DB should be clean'); + } + + /** + * Tests that unlink returns false when junction table deleteMany fails + */ + public function testUnlinkFailure(): void + { + $connection = ConnectionManager::get('test'); + /** @var \Cake\ORM\Table&\Mockery\MockInterface $joint */ + $joint = Mockery::mock(new Table(['alias' => 'SpecialTags', 'table' => 'special_tags', 'connection' => $connection])) + ->makePartial(); + + $articles = $this->getTableLocator()->get('Articles'); + + $assoc = $articles->belongsToMany('Tags', [ + 'through' => $joint, + ]); + + $entity = $articles->get(2, contain: ['Tags']); + $this->assertCount(1, $entity->tags); + + $joint->shouldReceive('deleteMany') + ->once() + ->andReturn(false); + + $this->assertFalse($assoc->unlink($entity, $entity->tags)); + $this->assertCount(1, $entity->tags); + } + + /** + * Tests that replaceLink requires the sourceEntity to have primaryKey values + * for the source entity + */ + public function testReplaceWithMissingPrimaryKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Could not find primary key value for source entity'); + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'joinTable' => 'tags_articles', + ]; + $assoc = new BelongsToMany('Test', $config); + $entity = new Entity(['foo' => 1], ['markNew' => false]); + $tags = [new Entity(['id' => 2]), new Entity(['id' => 3])]; + $assoc->replaceLinks($entity, $tags); + } + + /** + * Test that replaceLinks() can saveAssociated an empty set, removing all rows. + */ + public function testReplaceLinksUpdateToEmptySet(): void + { + $joint = $this->getTableLocator()->get('ArticlesTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'through' => $joint, + 'joinTable' => 'articles_tags', + ]); + + $entity = $articles->get(1, ...['contain' => 'Tags']); + $this->assertCount(2, $entity->tags); + + $assoc->replaceLinks($entity, []); + $this->assertSame([], $entity->tags, 'Property should be empty'); + $this->assertFalse($entity->isDirty('tags'), 'Property should be cleaned'); + + $new = $articles->get(1, ...['contain' => 'Tags']); + $this->assertSame([], $new->tags, 'Should not be data in db'); + } + + /** + * Tests that replaceLinks will delete entities not present in the passed, + * array, maintain those are already persisted and were passed and also + * insert the rest. + */ + public function testReplaceLinkSuccess(): void + { + $joint = $this->getTableLocator()->get('ArticlesTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'through' => $joint, + ]); + $entity = $articles->get(1, ...['contain' => 'Tags']); + + // 1=existing, 2=removed, 3=new link, & new tag + $tagData = [ + new Entity(['id' => 1], ['markNew' => false]), + new Entity(['id' => 3]), + new Entity(['name' => 'net new']), + ]; + + $result = $assoc->replaceLinks($entity, $tagData, ['associated' => false]); + $this->assertTrue($result); + $this->assertSame($tagData, $entity->tags, 'Tags should match replaced objects'); + $this->assertFalse($entity->isDirty('tags'), 'Should be clean'); + + $fresh = $articles->get(1, ...['contain' => 'Tags']); + $this->assertCount(3, $fresh->tags, 'Records should be in db'); + + $this->assertNotEmpty($tags->get(2), 'Unlinked tag should still exist'); + } + + /** + * Tests that replaceLinks() will contain() the target table when + * there are conditions present on the association. + * + * In this case the replacement will fail because the association conditions + * hide the fixture data. + */ + public function testReplaceLinkWithConditions(): void + { + $joint = $this->getTableLocator()->get('SpecialTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'through' => $joint, + 'joinTable' => 'special_tags', + 'conditions' => ['SpecialTags.highlighted' => true], + ]); + $entity = $articles->get(1, ...['contain' => 'Tags']); + + $result = $assoc->replaceLinks($entity, [], ['associated' => false]); + $this->assertTrue($result); + $this->assertSame([], $entity->tags, 'Tags should match replaced objects'); + $this->assertFalse($entity->isDirty('tags'), 'Should be clean'); + + $fresh = $articles->get(1, ...['contain' => 'Tags']); + $this->assertCount(0, $fresh->tags, 'Association should be empty'); + + $jointCount = $joint->find()->where(['article_id' => 1])->count(); + $this->assertSame(1, $jointCount, 'Non matching joint record should remain.'); + } + + /** + * Test that replaceLinks() will apply finder conditions + * defined in the junction table associations if they exist. + */ + public function testReplaceLinkWithFinderInJunctionAssociations(): void + { + $this->setAppNamespace('TestApp'); + + $joint = $this->getTableLocator()->get('ArticlesTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + // Update an article to not match the association finder. + $articles->updateAll(['published' => 'N'], ['id' => 1]); + $assoc = $tags->associations()->get('Articles') + ->setFinder('published') + ->setThrough($joint); + $entity = $tags->get(1, ...['contain' => 'Articles']); + $this->assertCount(1, $entity->articles); + + $result = $assoc->replaceLinks($entity, [], ['associated' => false]); + $this->assertTrue($result); + $this->assertSame([], $entity->articles, 'Articles should match replaced objects'); + $this->assertFalse($entity->isDirty('articles'), 'Should be clean'); + + $fresh = $tags->get(1, ...['contain' => 'Articles']); + $this->assertCount(0, $fresh->articles, 'Association should be empty'); + + $other = $joint->find()->where(['tag_id' => 1])->toArray(); + $this->assertCount(1, $other, 'Non matching joint record should remain.'); + $this->assertSame(1, $other[0]->article_id); + } + + /** + * Test that replaceLinks() loads junction records with the correct entity class + */ + public function testReplaceLinksFetchCorrectJunctionEntity(): void + { + $joint = $this->getTableLocator()->get('ArticlesTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'through' => $joint, + ]); + $joint->setEntityClass(ArticlesTag::class); + + $joint->getEventManager()->on('Model.afterDelete', function ($event, $entity): void { + $this->assertInstanceOf(ArticlesTag::class, $entity); + $this->assertNotEmpty($entity->tag_id); + $this->assertNotEmpty($entity->article_id); + }); + + $entity = $articles->get(1, ...['contain' => 'Tags']); + $this->assertCount(2, $entity->tags); + + $assoc->replaceLinks($entity, []); + } + + /** + * Test that replaceLinks() loads junction records with the correct entity class + */ + public function testReplaceLinksFinderCondition(): void + { + $this->setAppNamespace('TestApp'); + + $joint = $this->getTableLocator()->get('ArticlesTags'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $tags->associations()->get('Articles') + ->setFinder(['published' => ['title' => 'First Article']]) + ->setThrough($joint); + $entity = $tags->get(1, ...['contain' => 'Articles']); + $this->assertCount(1, $entity->articles); + + $assoc->replaceLinks($entity, []); + + $fresh = $tags->get(1, ...['contain' => 'Articles']); + $this->assertCount(0, $fresh->articles, 'Association should be empty'); + + $other = $joint->find()->where(['tag_id' => 1])->toArray(); + $this->assertCount(1, $other, 'Non matching joint record should remain.'); + $this->assertSame(2, $other[0]->article_id); + } + + /** + * Test that replace links use loads junction records with a concrete + * target table so that finders with contain will work. + */ + public function testReplaceLinksFinderContain(): void + { + $this->setAppNamespace('TestApp'); + + $joint = $this->getTableLocator()->get('ArticlesTags'); + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $assoc = $tags->associations()->get('Articles') + ->setFinder('withAuthors') + ->setThrough($joint); + $tag = $tags->get(1); + $article = $articles->get(1); + + $assoc->replaceLinks($tag, [$article]); + + $fresh = $tags->get(1, ...['contain' => 'Articles']); + $this->assertCount(1, $fresh->articles); + } + + /** + * Tests replaceLinks with failing domain rules and new link targets. + */ + public function testReplaceLinkFailingDomainRules(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + $tags->getEventManager()->on('Model.buildRules', function (EventInterface $event, RulesChecker $rules): void { + $rules->add(function () { + return false; + }, 'rule', ['errorField' => 'name', 'message' => 'Bad data']); + }); + + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'through' => $this->getTableLocator()->get('ArticlesTags'), + ]); + $entity = $articles->get(1, ...['contain' => 'Tags']); + $originalCount = count($entity->tags); + + $tags = [ + new Entity(['name' => 'tag99', 'description' => 'Best tag']), + ]; + $result = $assoc->replaceLinks($entity, $tags); + $this->assertFalse($result, 'replace should have failed.'); + $this->assertNotEmpty($tags[0]->getErrors(), 'Bad entity should have errors.'); + + $entity = $articles->get(1, ...['contain' => 'Tags']); + $this->assertCount($originalCount, $entity->tags, 'Should not have changed.'); + $this->assertSame('tag1', $entity->tags[0]->name); + } + + /** + * Tests that replaceLinks will delete entities not present in the passed, + * array, maintain those are already persisted and were passed and also + * insert the rest. + */ + public function testReplaceLinkBinaryUuid(): void + { + $items = $this->getTableLocator()->get('BinaryUuidItems'); + $tags = $this->getTableLocator()->get('BinaryUuidTags'); + + $items->belongsToMany('BinaryUuidTags', [ + 'sourceTable' => $items, + 'targetTable' => $tags, + ]); + $itemName = 'Item 1'; + $item = $items->find()->where(['BinaryUuidItems.name' => $itemName])->firstOrFail(); + $existingTag = $tags->find()->where(['BinaryUuidTags.name' => 'Defect'])->firstOrFail(); + + // 1=existing, 2=new tag + $item->binary_uuid_tags = [ + $existingTag, + new Entity(['name' => 'net new']), + ]; + $item->name = 'Updated'; + $items->saveOrFail($item); + + $refresh = $items->find()->where(['id' => $item->id])->contain('BinaryUuidTags')->firstOrFail(); + $this->assertCount(2, $refresh->binary_uuid_tags, 'Two tags should exist'); + + $refresh->binary_uuid_tags = [$refresh->binary_uuid_tags[0]]; + $items->save($refresh); + + $refresh = $items->get($item->id, ...['contain' => 'BinaryUuidTags']); + $this->assertCount(1, $refresh->binary_uuid_tags, 'One tag should remain'); + } + + public function testReplaceLinksComplexTypeForeignKey(): void + { + $articles = $this->fetchTable('CompositeKeyArticles'); + $tags = $this->fetchTable('Tags'); + + $articles->belongsToMany('Tags', [ + 'foreignKey' => ['author_id', 'created'], + ]); + + $article = $articles->newEntity([ + 'author_id' => 1, + 'body' => 'First post', + 'created' => new DateTime(), + ]); + $articles->saveOrFail($article); + $tag1 = $tags->find()->where(['Tags.name' => 'tag1'])->firstOrFail(); + $tag2 = $tags->find()->where(['Tags.name' => 'tag2'])->firstOrFail(); + + $findArticle = function ($article) use ($articles) { + return $articles->find() + ->where(['CompositeKeyArticles.author_id' => $article->author_id]) + ->contain('Tags') + ->firstOrFail(); + }; + + $article = $findArticle($article); + $this->assertEmpty($article->tags); + + // Create the first link + $article = $articles->patchEntity($article, ['tags' => ['_ids' => [$tag1->id]]]); + $result = $articles->save($article, ['associated' => 'Tags']); + $this->assertNotEmpty($result); + $this->assertCount(1, $result->tags); + $this->assertEquals($tag1->id, $result->tags[0]->id); + + // Add second tag. Reload tag objects so created fields have different + // instances. + $article = $findArticle($article); + $article = $articles->patchEntity($article, ['tags' => ['_ids' => [$tag1->id, $tag2->id]]]); + $result = $articles->save($article, ['associated' => 'Tags']); + + // Check in memory entity. + $this->assertNotEmpty($result); + $this->assertCount(2, $result->tags); + $this->assertEquals('tag1', $result->tags[0]->name); + $this->assertEquals('tag2', $result->tags[1]->name); + + // Reload to check persisted state. + $result = $findArticle($article); + $this->assertNotEmpty($result); + $this->assertCount(2, $result->tags); + $this->assertEquals('tag1', $result->tags[0]->name); + $this->assertEquals('tag2', $result->tags[1]->name); + } + + public function testReplaceLinksMissingKeyData(): void + { + $articles = $this->fetchTable('Articles'); + $tags = $this->fetchTable('Tags'); + + $articles->belongsToMany('Tags'); + $article = $articles->find()->firstOrFail(); + + $tag1 = $tags->find()->where(['Tags.name' => 'tag1'])->firstOrFail(); + $tag1->_joinData = new ArticlesTag(['tag_id' => 99]); + + $article->tags = [$tag1]; + $articles->saveOrFail($article, ['associated' => ['Tags']]); + + $this->assertCount(1, $article->tags); + } + + /** + * Provider for empty values + * + * @return array + */ + public static function emptyProvider(): array + { + return [ + [''], + [false], + [null], + [[]], + ]; + } + + /** + * Test that saving an empty set on create works. + * + * @param mixed $value + */ + #[DataProvider('emptyProvider')] + public function testSaveAssociatedEmptySetSuccess($value): void + { + $table = new Table(['alias' => 'Articles', 'table' => 'articles']); + $table->setSchema([]); + $assoc = Mockery::mock(BelongsToMany::class, ['tags', ['sourceTable' => $table]]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $entity = new Entity([ + 'id' => 1, + 'tags' => $value, + ], ['markNew' => true]); + + $assoc->setSaveStrategy(BelongsToMany::SAVE_REPLACE); + $assoc->shouldReceive('replaceLinks')->never(); + $assoc->shouldReceive('_saveTarget')->never(); + $this->assertSame($entity, $assoc->saveAssociated($entity)); + } + + /** + * Test that saving an empty set on update works. + * + * @param mixed $value + */ + #[DataProvider('emptyProvider')] + public function testSaveAssociatedEmptySetUpdateSuccess($value): void + { + $table = new Table(['alias' => 'Articles', 'table' => 'articles']); + $table->setSchema([]); + $assoc = Mockery::mock(BelongsToMany::class, ['tags', ['sourceTable' => $table]]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $entity = new Entity([ + 'id' => 1, + 'tags' => $value, + ], ['markNew' => false]); + + $assoc->setSaveStrategy(BelongsToMany::SAVE_REPLACE); + $assoc->shouldReceive('replaceLinks') + ->once() + ->andReturn(true); + + $assoc->shouldReceive('_saveTarget')->never(); + + $this->assertSame($entity, $assoc->saveAssociated($entity)); + } + + /** + * Tests saving with replace strategy returning true + */ + public function testSaveAssociatedWithReplace(): void + { + $table = new Table(['alias' => 'Articles', 'table' => 'articles']); + $table->setSchema([]); + $assoc = Mockery::mock(BelongsToMany::class, ['tags', ['sourceTable' => $table]]) + ->makePartial(); + $entity = new Entity([ + 'id' => 1, + 'tags' => [ + new Entity(['name' => 'foo']), + ], + ]); + + $options = ['foo' => 'bar']; + $assoc->setSaveStrategy(BelongsToMany::SAVE_REPLACE); + $assoc->shouldReceive('replaceLinks') + ->once() + ->with($entity, $entity->tags, $options) + ->andReturn(true); + $this->assertSame($entity, $assoc->saveAssociated($entity, $options)); + } + + /** + * Tests saving with replace strategy returning true + */ + public function testSaveAssociatedWithReplaceReturnFalse(): void + { + $table = new Table(['alias' => 'Articles', 'table' => 'articles']); + $table->setSchema([]); + $assoc = Mockery::mock(BelongsToMany::class, ['tags', ['sourceTable' => $table]]) + ->makePartial(); + $entity = new Entity([ + 'id' => 1, + 'tags' => [ + new Entity(['name' => 'foo']), + ], + ]); + + $options = ['foo' => 'bar']; + $assoc->setSaveStrategy(BelongsToMany::SAVE_REPLACE); + $assoc->shouldReceive('replaceLinks') + ->once() + ->with($entity, $entity->tags, $options) + ->andReturn(false); + $this->assertFalse($assoc->saveAssociated($entity, $options)); + } + + /** + * Tests that setTargetForeignKey() returns the correct configured value + */ + public function testSetTargetForeignKey(): void + { + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + ]); + $this->assertSame('tag_id', $assoc->getTargetForeignKey()); + $assoc->setTargetForeignKey('another_key'); + $this->assertSame('another_key', $assoc->getTargetForeignKey()); + + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'targetForeignKey' => 'foo', + ]); + $this->assertSame('foo', $assoc->getTargetForeignKey()); + } + + /** + * Tests that custom foreignKeys are properly transmitted to involved associations + * when they are customized + */ + public function testJunctionWithCustomForeignKeys(): void + { + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'foreignKey' => 'Art', + 'targetForeignKey' => 'Tag', + ]); + $junction = $assoc->junction(); + $this->assertSame('Art', $junction->getAssociation('Articles')->getForeignKey()); + $this->assertSame('Tag', $junction->getAssociation('Tags')->getForeignKey()); + + $inverseRelation = $this->tag->getAssociation('Articles'); + $this->assertSame('Tag', $inverseRelation->getForeignKey()); + $this->assertSame('Art', $inverseRelation->getTargetForeignKey()); + } + + /** + * Test that fallback class is used for join table even when fallback + * class usage is turned off for table locator. + */ + public function testFallbackClassForJunction(): void + { + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + ]); + $assoc->setTableLocator((new TableLocator())->allowFallbackClass(false)); + $junction = $assoc->junction(); + $this->assertInstanceOf(Table::class, $junction); + } + + /** + * Test that fallback class is used for join table even when fallback + * class usage is turned off for table locator. + */ + public function testNoFallbackClassForThrough(): void + { + $this->expectException(MissingTableClassException::class); + $this->expectExceptionMessage('Table class for alias `ArticlesTags` could not be found.'); + + $assoc = new BelongsToMany('Test', [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + 'through' => 'ArticlesTags', + ]); + $tableLocator = new TableLocator(); + $tableLocator->allowFallbackClass(false); + $assoc->setTableLocator($tableLocator); + $assoc->junction(); + } + + /** + * Tests that property is being set using the constructor options. + */ + public function testPropertyOption(): void + { + $config = ['propertyName' => 'thing_placeholder', 'sourceTable' => $this->article]; + $association = new BelongsToMany('Thing', $config); + $this->assertSame('thing_placeholder', $association->getProperty()); + } + + /** + * Test that plugin names are omitted from property() + */ + public function testPropertyNoPlugin(): void + { + $config = [ + 'sourceTable' => $this->article, + 'targetTable' => $this->tag, + ]; + $association = new BelongsToMany('Contacts.Tags', $config); + $this->assertSame('tags', $association->getProperty()); + } + + /** + * Test that the generated associations are correct. + */ + public function testGeneratedAssociations(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + $conditions = ['SpecialTags.highlighted' => true]; + $assoc = $articles->belongsToMany('Tags', [ + 'sourceTable' => $articles, + 'targetTable' => $tags, + 'foreignKey' => 'foreign_key', + 'targetForeignKey' => 'target_foreign_key', + 'through' => 'SpecialTags', + 'conditions' => $conditions, + ]); + // Generate associations + $assoc->junction(); + + $tagAssoc = $articles->getAssociation('Tags'); + $this->assertNotEmpty($tagAssoc, 'btm should exist'); + $this->assertEquals($conditions, $tagAssoc->getConditions()); + $this->assertSame('target_foreign_key', $tagAssoc->getTargetForeignKey()); + $this->assertSame('foreign_key', $tagAssoc->getForeignKey()); + + $jointAssoc = $articles->getAssociation('SpecialTags'); + $this->assertNotEmpty($jointAssoc, 'has many to junction should exist'); + $this->assertInstanceOf(HasMany::class, $jointAssoc); + $this->assertSame('foreign_key', $jointAssoc->getForeignKey()); + + $articleAssoc = $tags->getAssociation('Articles'); + $this->assertNotEmpty($articleAssoc, 'reverse btm should exist'); + $this->assertInstanceOf(BelongsToMany::class, $articleAssoc); + $this->assertEquals($conditions, $articleAssoc->getConditions()); + $this->assertSame('foreign_key', $articleAssoc->getTargetForeignKey(), 'keys should swap'); + $this->assertSame('target_foreign_key', $articleAssoc->getForeignKey(), 'keys should swap'); + + $jointAssoc = $tags->getAssociation('SpecialTags'); + $this->assertNotEmpty($jointAssoc, 'has many to junction should exist'); + $this->assertInstanceOf(HasMany::class, $jointAssoc); + $this->assertSame('target_foreign_key', $jointAssoc->getForeignKey()); + } + + /** + * Tests that eager loading requires association keys + */ + public function testEagerLoadingRequiresPrimaryKey(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('The `tags` table does not define a primary key'); + $table = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + $tags->getSchema()->dropConstraint('primary'); + + $table->belongsToMany('Tags'); + $table->find()->contain('Tags')->first(); + } + + /** + * Tests that fetching belongsToMany association will not force + * all fields being returned, but instead will honor the select() clause + * + * @see https://github.com/cakephp/cakephp/issues/7916 + */ + public function testEagerLoadingBelongsToManyLimitedFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags'); + $result = $table + ->find() + ->contain(['Tags' => function (SelectQuery $q) { + return $q->select(['id']); + }]) + ->first(); + + $this->assertNotEmpty($result->tags[0]->id); + $this->assertEmpty($result->tags[0]->name); + + $result = $table + ->find() + ->contain([ + 'Tags' => [ + 'fields' => [ + 'Tags.name', + ], + ], + ]) + ->first(); + $this->assertNotEmpty($result->tags[0]->name); + $this->assertEmpty($result->tags[0]->id); + } + + /** + * Tests that fetching belongsToMany association will retain autoFields(true) if it was used. + * + * @see https://github.com/cakephp/cakephp/issues/8052 + */ + public function testEagerLoadingBelongsToManyLimitedFieldsWithAutoFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags'); + $result = $table + ->find() + ->contain(['Tags' => function (SelectQuery $q) { + return $q->select(['two' => $q->expr('1 + 1')])->enableAutoFields(); + }]) + ->first(); + + $this->assertNotEmpty($result->tags[0]->two, 'Should have computed field'); + $this->assertNotEmpty($result->tags[0]->name, 'Should have standard field'); + } + + /** + * Test that association proxy find() works with no join records + */ + public function testAssociationProxyFindNoJoinRecords(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags'); + $table->Tags->junction()->deleteAll('1=1'); + + $query = $table->Tags->find(); + $result = $query->toArray(); + $this->assertCount(3, $result); + } + + /** + * Test that association proxy find() applies joins when conditions are involved. + */ + public function testAssociationProxyFindWithConditions(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags', [ + 'conditions' => ['SpecialTags.highlighted' => true], + 'through' => 'SpecialTags', + ]); + $query = $table->Tags->find(); + $result = $query->toArray(); + $this->assertCount(1, $result); + $this->assertSame(1, $result[0]->id); + } + + /** + * Test that association proxy find() applies complex conditions + */ + public function testAssociationProxyFindWithComplexConditions(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags', [ + 'conditions' => [ + 'OR' => [ + 'SpecialTags.highlighted' => true, + ], + ], + 'through' => 'SpecialTags', + ]); + $query = $table->Tags->find(); + $result = $query->toArray(); + $this->assertCount(1, $result); + $this->assertSame(1, $result[0]->id); + } + + /** + * Test that matching() works on belongsToMany associations. + */ + public function testBelongsToManyAssociationWithArrayConditions(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags', [ + 'conditions' => ['SpecialTags.highlighted' => true], + 'through' => 'SpecialTags', + ]); + $query = $table->find()->matching('Tags', function (SelectQuery $q) { + return $q->where(['Tags.name' => 'tag1']); + }); + $results = $query->toArray(); + $this->assertCount(1, $results); + $this->assertNotEmpty($results[0]->_matchingData); + } + + /** + * Test that matching() works on belongsToMany associations. + */ + public function testBelongsToManyAssociationWithExpressionConditions(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags', [ + 'conditions' => [new QueryExpression("name LIKE 'tag%'")], + 'through' => 'SpecialTags', + ]); + $query = $table->find()->matching('Tags', function (SelectQuery $q) { + return $q->where(['Tags.name' => 'tag1']); + }); + $results = $query->toArray(); + $this->assertCount(1, $results); + $this->assertNotEmpty($results[0]->_matchingData); + } + + /** + * Test that association proxy find() with matching resolves joins correctly + */ + public function testAssociationProxyFindWithConditionsMatching(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags', [ + 'conditions' => ['SpecialTags.highlighted' => true], + 'through' => 'SpecialTags', + ]); + $query = $table->Tags->find()->matching('Articles', function (SelectQuery $query) { + return $query->where(['Articles.id' => 1]); + }); + // The inner join on special_tags excludes the results. + $this->assertSame(0, $query->count()); + } + + /** + * Test custom binding key for target table association + */ + public function testCustomTargetBindingKeyContain(): void + { + $this->getTableLocator()->get('ArticlesTags') + ->belongsTo('SpecialTags', [ + 'bindingKey' => 'tag_id', + 'foreignKey' => 'tag_id', + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('SpecialTags', [ + 'through' => 'ArticlesTags', + 'targetForeignKey' => 'tag_id', + ]); + + $results = $table->find() + ->contain('SpecialTags', function ($query) { + return $query->orderBy(['SpecialTags.tag_id']); + }) + ->where(['id' => 2]) + ->toArray(); + + $this->assertCount(1, $results); + $this->assertCount(2, $results[0]->special_tags); + + $this->assertSame(2, $results[0]->special_tags[0]->id); + $this->assertSame(1, $results[0]->special_tags[0]->tag_id); + + $this->assertSame(1, $results[0]->special_tags[1]->id); + $this->assertSame(3, $results[0]->special_tags[1]->tag_id); + } + + /** + * Test custom binding key for target table association + */ + public function testCustomTargetBindingKeyLink(): void + { + $this->getTableLocator()->get('ArticlesTags') + ->belongsTo('SpecialTags', [ + 'bindingKey' => 'tag_id', + 'foreignKey' => 'tag_id', + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('SpecialTags', [ + 'through' => 'ArticlesTags', + 'targetForeignKey' => 'tag_id', + ]); + + $specialTag = $table->SpecialTags->newEntity([ + 'article_id' => 2, + 'tag_id' => 2, + ]); + $table->SpecialTags->save($specialTag); + + $article = $table->get(2); + $this->assertTrue($table->SpecialTags->link($article, [$specialTag])); + + $results = $table->find() + ->contain('SpecialTags') + ->where(['id' => 2]) + ->toArray(); + + $this->assertCount(1, $results); + $this->assertCount(3, $results[0]->special_tags); + } + + /** + * Test custom binding key for target table association + */ + public function testBindingKeyMatching(): void + { + $table = $this->getTableLocator()->get('Tags'); + $table->belongsToMany('Articles', [ + 'through' => 'ArticlesTagsBindingKeys', + 'foreignKey' => 'tagname', + 'bindingKey' => 'name', + ]); + $query = $table->find() + ->matching('Articles', function ($q) { + return $q->where(['Articles.id >' => 0]); + }); + $results = $query->all(); + + // 4 records in the junction table. + $this->assertCount(4, $results); + } + + public function testEagerLoaderConnectionRole(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + + Log::setConfig('queries', [ + 'className' => 'Array', + 'scopes' => ['queriesLog'], + ]); + + ConnectionManager::setConfig('test_read_write', [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'write' => [ + 'database' => ':memory:', + 'cached' => 'shared', // used so role configs are unique + 'log' => true, + ], + 'read' => [ + 'database' => ':memory:', + 'log' => true, + ], + ]); + + $connection = ConnectionManager::get('test_read_write'); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + + // Create belongs to many relationships with unique table names + $driver = $connection->getDriver(Connection::ROLE_WRITE); + $driver->execute('CREATE TABLE unique_items (id int PRIMARY KEY);'); + $driver->execute('CREATE TABLE articles (id int PRIMARY KEY);'); + $driver->execute('CREATE TABLE articles_unique_items (unique_item_id int, article_id int);'); + + $driver = $connection->getDriver(Connection::ROLE_READ); + $driver->execute('CREATE TABLE unique_items (id int PRIMARY KEY);'); + $driver->execute('CREATE TABLE articles (id int PRIMARY KEY);'); + $driver->execute('CREATE TABLE articles_unique_items (unique_item_id int, article_id int);'); + $driver->execute('INSERT INTO unique_items (id) VALUES (1)'); + $driver->execute('INSERT INTO articles (id) VALUES (1)'); + $driver->execute('INSERT INTO articles_unique_items (unique_item_id, article_id) VALUES (1, 1)'); + + $articles = $this->getTableLocator()->get('Articles')->setConnection($connection); + $articles->belongsToMany('UniqueItems')->getTarget()->setConnection($connection); + + $query = $articles->find(); + $this->assertSame(Connection::ROLE_WRITE, $query->getConnectionRole(), 'This test assumes select queries still default to write role'); + + $results = $query->contain('UniqueItems')->useReadRole()->toArray(); + $this->assertCount(1, $results); + $this->assertCount(1, $results[0]->unique_items); + $this->assertSame(1, $results[0]->unique_items[0]->id); + + $logs = Log::engine('queries')->read(); + $this->assertNotEmpty($logs); + + foreach ($logs as $log) { + if ( + str_contains($log, 'FROM articles') || + str_contains($log, 'FROM articles_unique_items') || + str_contains($log, 'FROM unique_items') + ) { + $this->assertStringContainsString('role=read', $log); + } + } + } +} diff --git a/tests/TestCase/ORM/Association/BelongsToTest.php b/tests/TestCase/ORM/Association/BelongsToTest.php new file mode 100644 index 00000000000..f4e3788760c --- /dev/null +++ b/tests/TestCase/ORM/Association/BelongsToTest.php @@ -0,0 +1,470 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Authors', 'core.Comments']; + + /** + * @var \Cake\ORM\Table + */ + protected $company; + + /** + * @var \Cake\ORM\Table + */ + protected $client; + + /** + * @var \Cake\Database\TypeMap + */ + protected $companiesTypeMap; + + /** + * Set up + */ + protected function setUp(): void + { + parent::setUp(); + $this->company = $this->getTableLocator()->get('Companies', [ + 'schema' => [ + 'id' => ['type' => 'integer'], + 'company_name' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ], + ]); + $this->client = $this->getTableLocator()->get('Clients', [ + 'schema' => [ + 'id' => ['type' => 'integer'], + 'client_name' => ['type' => 'string'], + 'company_id' => ['type' => 'integer'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ], + ]); + $this->companiesTypeMap = new TypeMap([ + 'Companies.id' => 'integer', + 'id' => 'integer', + 'Companies.company_name' => 'string', + 'company_name' => 'string', + 'Companies__id' => 'integer', + 'Companies__company_name' => 'string', + ]); + } + + /** + * Test that foreignKey generation + */ + public function testSetForeignKey(): void + { + $assoc = new BelongsTo('Companies', [ + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + ]); + $this->assertSame('company_id', $assoc->getForeignKey()); + $this->assertSame($assoc, $assoc->setForeignKey('another_key')); + $this->assertSame('another_key', $assoc->getForeignKey()); + } + + /** + * Tests that the default foreign key condition generation can be disabled. + */ + public function testDisableForeignKey(): void + { + $table = $this->getTableLocator()->get('Articles'); + $assoc = $table + ->belongsTo('Authors') + ->setForeignKey('author_id'); + + $article = $table->find()->contain(['Authors'])->orderByAsc('Articles.id')->first(); + $this->assertSame('mariano', $article->author->name); + + $assoc + ->setForeignKey(false) + ->setConditions([ + 'Authors.name' => 'larry', + ]); + + $article = $table->find()->contain(['Authors'])->orderByAsc('Articles.id')->first(); + $this->assertSame('larry', $article->author->name); + } + + /** + * Test that foreignKey generation ignores database names in target table. + */ + public function testForeignKeyIgnoreDatabaseName(): void + { + $this->company->setTable('schema.companies'); + $this->client->setTable('schema.clients'); + $assoc = new BelongsTo('Companies', [ + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + ]); + $this->assertSame('company_id', $assoc->getForeignKey()); + } + + /** + * Tests that the association reports it can be joined + */ + public function testCanBeJoined(): void + { + $assoc = new BelongsTo('Test'); + $this->assertTrue($assoc->canBeJoined()); + } + + /** + * Tests that the alias set on associations is actually on the Entity + */ + public function testCustomAlias(): void + { + $table = $this->getTableLocator()->get('Articles', [ + 'className' => 'TestPlugin.Articles', + ]); + $table->addAssociations([ + 'belongsTo' => [ + 'FooAuthors' => ['className' => 'TestPlugin.Authors', 'foreignKey' => 'author_id'], + ], + ]); + $article = $table->find()->contain(['FooAuthors'])->first(); + + $this->assertTrue(isset($article->foo_author)); + $this->assertEquals($article->foo_author->name, 'mariano'); + $this->assertNull($article->Authors); + } + + /** + * Tests that the correct join and fields are attached to a query depending on + * the association config + */ + public function testAttachTo(): void + { + $config = [ + 'foreignKey' => 'company_id', + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + 'conditions' => ['Companies.is_active' => true], + ]; + $association = new BelongsTo('Companies', $config); + $query = $this->client->selectQuery(); + $association->attachTo($query); + + $expected = [ + 'Companies__id' => 'Companies.id', + 'Companies__company_name' => 'Companies.company_name', + ]; + $this->assertEquals($expected, $query->clause('select')); + $expected = [ + 'Companies' => [ + 'alias' => 'Companies', + 'table' => 'companies', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + 'Companies.is_active' => true, + ['Companies.id' => new IdentifierExpression('Clients.company_id')], + ], $this->companiesTypeMap), + ], + ]; + $this->assertEquals($expected, $query->clause('join')); + + $this->assertSame( + 'integer', + $query->getTypeMap()->type('Companies__id'), + 'Associations should map types.', + ); + } + + /** + * Tests that it is possible to avoid fields inclusion for the associated table + */ + public function testAttachToNoFields(): void + { + $config = [ + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + 'conditions' => ['Companies.is_active' => true], + ]; + $query = $this->client->selectQuery(); + $association = new BelongsTo('Companies', $config); + + $association->attachTo($query, ['includeFields' => false]); + $this->assertEmpty($query->clause('select'), 'no fields should be added.'); + } + + /** + * Tests that using belongsto with a table having a multi column primary + * key will work if the foreign key is passed + */ + public function testAttachToMultiPrimaryKey(): void + { + $this->company->setPrimaryKey(['id', 'tenant_id']); + $config = [ + 'foreignKey' => ['company_id', 'company_tenant_id'], + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + 'conditions' => ['Companies.is_active' => true], + ]; + $association = new BelongsTo('Companies', $config); + $query = $this->client->selectQuery(); + $association->attachTo($query); + + $expected = [ + 'Companies__id' => 'Companies.id', + 'Companies__company_name' => 'Companies.company_name', + ]; + $this->assertEquals($expected, $query->clause('select')); + + $field1 = new IdentifierExpression('Clients.company_id'); + $field2 = new IdentifierExpression('Clients.company_tenant_id'); + $expected = [ + 'Companies' => [ + 'conditions' => new QueryExpression([ + 'Companies.is_active' => true, + ['Companies.id' => $field1, 'Companies.tenant_id' => $field2], + ], $this->companiesTypeMap), + 'table' => 'companies', + 'type' => 'LEFT', + 'alias' => 'Companies', + ], + ]; + $this->assertEquals($expected, $query->clause('join')); + } + + /** + * Tests that using belongsto with a table having a multi column primary + * key will work if the foreign key is passed + */ + public function testAttachToMultiPrimaryKeyMismatch(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot match provided foreignKey for `Companies`, got `(company_id)` but expected foreign key for `(id, tenant_id)`'); + $this->company->setPrimaryKey(['id', 'tenant_id']); + $query = $this->client->selectQuery(); + $config = [ + 'foreignKey' => 'company_id', + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + 'conditions' => ['Companies.is_active' => true], + ]; + $association = new BelongsTo('Companies', $config); + $association->attachTo($query); + } + + /** + * Test the cascading delete of BelongsTo. + */ + public function testCascadeDelete(): void + { + /** @var \Cake\ORM\Table&\Mockery\MockInterface $mock */ + $mock = Mockery::mock(Table::class); + $config = [ + 'sourceTable' => $this->client, + 'targetTable' => $mock, + ]; + $mock->shouldReceive('find')->never(); + $mock->shouldReceive('delete')->never(); + + $association = new BelongsTo('Companies', $config); + $entity = new Entity(['company_name' => 'CakePHP', 'id' => 1]); + $this->assertTrue($association->cascadeDelete($entity)); + } + + /** + * Test that saveAssociated() ignores non entity values. + */ + public function testSaveAssociatedOnlyEntities(): void + { + $spy = Mockery::spy(Table::class); + $config = [ + 'sourceTable' => $this->client, + 'targetTable' => $spy, + ]; + + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + 'author' => ['name' => 'Jose'], + ]); + + $association = new BelongsTo('Authors', $config); + $result = $association->saveAssociated($entity); + $this->assertSame($result, $entity); + $this->assertNull($entity->author_id); + + $spy->shouldNotHaveReceived('saveAssociated'); + } + + /** + * Tests that property is being set using the constructor options. + */ + public function testPropertyOption(): void + { + $config = ['propertyName' => 'thing_placeholder', 'sourceTable' => $this->client]; + $association = new BelongsTo('Thing', $config); + $this->assertSame('thing_placeholder', $association->getProperty()); + } + + /** + * Test that plugin names are omitted from property() + */ + public function testPropertyNoPlugin(): void + { + $config = [ + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + ]; + $association = new BelongsTo('Contacts.Companies', $config); + $this->assertSame('company', $association->getProperty()); + } + + /** + * Tests that attaching an association to a query will trigger beforeFind + * for the target table + */ + public function testAttachToBeforeFind(): void + { + $config = [ + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + ]; + $called = false; + $this->company->getEventManager()->on('Model.beforeFind', function ($event, $query, $options) use (&$called): void { + $this->assertInstanceOf(Event::class, $event); + $this->assertInstanceOf(SelectQuery::class, $query); + $this->assertInstanceOf(ArrayObject::class, $options); + $called = true; + }); + $association = new BelongsTo('Companies', $config); + $association->attachTo($this->client->selectQuery()); + $this->assertTrue($called, 'Listener should be called.'); + } + + /** + * Tests that attaching an association to a query will trigger beforeFind + * for the target table + */ + public function testAttachToBeforeFindExtraOptions(): void + { + $config = [ + 'sourceTable' => $this->client, + 'targetTable' => $this->company, + ]; + $called = false; + $this->company->getEventManager()->on('Model.beforeFind', function ($event, $query, $options) use (&$called): void { + $this->assertSame('more', $options['something']); + $called = true; + }); + $association = new BelongsTo('Companies', $config); + $query = $this->client->selectQuery(); + $association->attachTo($query, ['queryBuilder' => function ($q) { + return $q->applyOptions(['something' => 'more']); + }]); + $this->assertTrue($called, 'Listener should be called.'); + } + + /** + * Test that failing to add the foreignKey to the list of fields will + * still attach associated data. + */ + public function testAttachToNoFieldsSelected(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + $query = $articles->find() + ->select(['Authors.name']) + ->where(['Articles.id' => 1]) + ->contain('Authors'); + $result = $query->firstOrFail(); + + $this->assertNotEmpty($result->author); + $this->assertSame('mariano', $result->author->name); + $this->assertSame(['author'], array_keys($result->toArray()), 'No other properties included.'); + } + + /** + * Test that not selecting join keys with strategy=select fails + */ + public function testAttachToNoForeignKeySelect(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors')->setStrategy('select'); + + $query = $articles->find() + ->select(['Articles.title', 'Articles.author_id']) + ->where(['Articles.id' => 1]) + ->contain('Authors'); + $result = $query->firstOrFail(); + $this->assertNotEmpty($result->author); + $this->assertSame(1, $result->author->id); + + $query = $articles->find() + ->select(['Articles.title']) + ->where(['Articles.id' => 1]) + ->contain('Authors'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to load `Authors` association. Ensure foreign key in `Articles`'); + $query->first(); + } + + /** + * Test that formatResults in a joined association finder doesn't dirty + * the root entity. + */ + public function testAttachToFormatResultsNoDirtyResults(): void + { + $this->setAppNamespace('TestApp'); + $articles = $this->getTableLocator()->get('Articles'); + $articles->associations()->get('Authors') + ->setFinder('formatted'); + + $query = $articles->find() + ->where(['Articles.id' => 1]) + ->contain('Authors'); + $result = $query->firstOrFail(); + + $this->assertNotEmpty($result->author); + $this->assertNotEmpty($result->author->formatted); + $this->assertFalse($result->isDirty(), 'Record should be clean as it was pulled from the db.'); + } +} diff --git a/tests/TestCase/ORM/Association/HasManyTest.php b/tests/TestCase/ORM/Association/HasManyTest.php new file mode 100644 index 00000000000..72fb5c89234 --- /dev/null +++ b/tests/TestCase/ORM/Association/HasManyTest.php @@ -0,0 +1,1698 @@ + + */ + protected array $fixtures = [ + 'core.Comments', + 'core.Articles', + 'core.Tags', + 'core.Authors', + 'core.Users', + 'core.ArticlesTags', + ]; + + /** + * @var \Cake\ORM\Table + */ + protected $author; + + /** + * @var \Cake\ORM\Table&\Mockery\MockInterface + */ + protected $article; + + /** + * @var \Cake\Database\TypeMap + */ + protected $articlesTypeMap; + + /** + * @var bool + */ + protected $autoQuote; + + /** + * Set up + */ + protected function setUp(): void + { + parent::setUp(); + $this->setAppNamespace('TestApp'); + + $this->author = $this->getTableLocator()->get('Authors', [ + 'schema' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ], + ]); + $connection = ConnectionManager::get('test'); + $this->article = Mockery::mock(new Table([ + 'alias' => 'Articles', + 'table' => 'articles', + 'connection' => $connection, + ]))->makePartial(); + $this->article->setSchema([ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'author_id' => ['type' => 'integer'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]); + + $this->articlesTypeMap = new TypeMap([ + 'Articles.id' => 'integer', + 'id' => 'integer', + 'Articles.title' => 'string', + 'title' => 'string', + 'Articles.author_id' => 'integer', + 'author_id' => 'integer', + 'Articles__id' => 'integer', + 'Articles__title' => 'string', + 'Articles__author_id' => 'integer', + ]); + $this->autoQuote = $connection->getDriver()->isAutoQuotingEnabled(); + } + + protected function tearDown(): void + { + parent::tearDown(); + ConnectionManager::drop('test_read_write'); + Log::drop('queries'); + // Clear the table locator to avoid state leaking to next tests + $this->getTableLocator()->clear(); + } + + /** + * Tests that foreignKey() returns the correct configured value + */ + public function testSetForeignKey(): void + { + $assoc = new HasMany('Articles', [ + 'sourceTable' => $this->author, + ]); + $this->assertSame('author_id', $assoc->getForeignKey()); + $this->assertSame($assoc, $assoc->setForeignKey('another_key')); + $this->assertSame('another_key', $assoc->getForeignKey()); + } + + /** + * Test that foreignKey generation ignores database names in target table. + */ + public function testForeignKeyIgnoreDatabaseName(): void + { + $this->author->setTable('schema.authors'); + $assoc = new HasMany('Articles', [ + 'sourceTable' => $this->author, + ]); + $this->assertSame('author_id', $assoc->getForeignKey()); + } + + /** + * Tests that the association reports it can be joined + */ + public function testCanBeJoined(): void + { + $assoc = new HasMany('Test'); + $this->assertFalse($assoc->canBeJoined()); + } + + /** + * Tests setSort() method + */ + public function testSetSort(): void + { + $assoc = new HasMany('Test'); + $this->assertNull($assoc->getSort()); + + $assoc->setSort('id ASC'); + $this->assertSame('id ASC', $assoc->getSort()); + + $assoc->setSort(['id' => 'ASC']); + $this->assertSame(['id' => 'ASC'], $assoc->getSort()); + + $closure = function () { + return ['id' => 'ASC']; + }; + $assoc->setSort($closure); + $this->assertSame($closure, $assoc->getSort()); + + $expression = new OrderClauseExpression('id', 'ASC'); + $assoc->setSort($expression); + $this->assertSame($expression, $assoc->getSort()); + } + + /** + * Tests that sorting works using the accepted types for `setSort()`. + */ + public function testSorting(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $assoc = $authors->Articles; + + $field = 'Articles.id'; + $driver = $authors->getConnection()->getDriver(); + if ($driver->isAutoQuotingEnabled()) { + $field = $driver->quoteIdentifier($field); + } + + $assoc->setSort("{$field} DESC"); + $result = $authors->get(1, ...['contain' => 'Articles']); + $this->assertSame([3, 1], array_column($result['articles'], 'id')); + + $assoc->setSort(['Articles.id' => 'DESC']); + $result = $authors->get(1, ...['contain' => 'Articles']); + $this->assertSame([3, 1], array_column($result['articles'], 'id')); + + $assoc->setSort(function () { + return ['Articles.id' => 'DESC']; + }); + $result = $authors->get(1, ...['contain' => 'Articles']); + $this->assertSame([3, 1], array_column($result['articles'], 'id')); + + $assoc->setSort(new OrderClauseExpression('Articles.id', 'DESC')); + $result = $authors->get(1, ...['contain' => 'Articles']); + $this->assertSame([3, 1], array_column($result['articles'], 'id')); + } + + /** + * Tests requiresKeys() method + */ + public function testRequiresKeys(): void + { + $assoc = new HasMany('Test'); + $this->assertTrue($assoc->requiresKeys()); + + $assoc->setStrategy(HasMany::STRATEGY_SUBQUERY); + $this->assertFalse($assoc->requiresKeys()); + + $assoc->setStrategy(HasMany::STRATEGY_SELECT); + $this->assertTrue($assoc->requiresKeys()); + } + + /** + * Tests that HasMany can't use the join strategy + */ + public function testStrategyFailure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid strategy `join` was provided'); + $assoc = new HasMany('Test'); + $assoc->setStrategy(HasMany::STRATEGY_JOIN); + } + + /** + * Test the eager loader method with no extra options + */ + public function testEagerLoader(): void + { + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $this->article, + 'strategy' => 'select', + ]; + $association = new HasMany('Articles', $config); + $query = $this->article->selectQuery(); + $this->article->shouldReceive('find') + ->andReturn($query); + $keys = [1, 2, 3, 4]; + + $callable = $association->eagerLoader(compact('keys', 'query')); + $row = ['Authors__id' => 1]; + + $result = $callable($row); + $this->assertArrayHasKey('Articles', $result); + $this->assertSame($row['Authors__id'], $result['Articles'][0]->author_id); + $this->assertSame($row['Authors__id'], $result['Articles'][1]->author_id); + + $row = ['Authors__id' => 2]; + $result = $callable($row); + $this->assertArrayNotHasKey('Articles', $result); + + $row = ['Authors__id' => 3]; + $result = $callable($row); + $this->assertArrayHasKey('Articles', $result); + $this->assertSame($row['Authors__id'], $result['Articles'][0]->author_id); + + $row = ['Authors__id' => 4]; + $result = $callable($row); + $this->assertArrayNotHasKey('Articles', $result); + } + + /** + * Test the eager loader method with default query clauses + */ + public function testEagerLoaderWithDefaults(): void + { + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $this->article, + 'conditions' => ['Articles.published' => 'Y'], + 'sort' => ['id' => 'ASC'], + 'strategy' => 'select', + ]; + $association = new HasMany('Articles', $config); + $keys = [1, 2, 3, 4]; + + $query = $this->article->selectQuery(); + $this->article->shouldReceive('find') + ->andReturn($query); + + $association->eagerLoader(compact('keys', 'query')); + + $expected = new QueryExpression( + ['Articles.published' => 'Y', 'Articles.author_id IN' => $keys], + $this->articlesTypeMap, + ); + $this->assertWhereClause($expected, $query); + + $expected = new OrderByExpression(['id' => 'ASC']); + $this->assertOrderClause($expected, $query); + } + + /** + * Test the eager loader method with overridden query clauses + */ + public function testEagerLoaderWithOverrides(): void + { + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $this->article, + 'conditions' => ['Articles.published' => 'Y'], + 'sort' => ['id' => 'ASC'], + 'strategy' => 'select', + ]; + $this->article->hasMany('Comments'); + + $association = new HasMany('Articles', $config); + $keys = [1, 2, 3, 4]; + + /** @var \Cake\ORM\Query\SelectQuery $query */ + $query = $this->article->query(); + $query->addDefaultTypes($this->article); + + $this->article->shouldReceive('find') + ->andReturn($query); + + $association->eagerLoader([ + 'conditions' => ['Articles.id !=' => 3], + 'sort' => ['title' => 'DESC'], + 'fields' => ['id', 'title', 'author_id'], + 'contain' => ['Comments' => ['fields' => ['comment', 'article_id']]], + 'keys' => $keys, + 'query' => $query, + ]); + $expected = [ + 'Articles__id' => 'Articles.id', + 'Articles__title' => 'Articles.title', + 'Articles__author_id' => 'Articles.author_id', + ]; + $this->assertSelectClause($expected, $query); + + $expected = new QueryExpression( + [ + 'Articles.published' => 'Y', + 'Articles.id !=' => 3, + 'Articles.author_id IN' => $keys, + ], + $query->getTypeMap(), + ); + $this->assertWhereClause($expected, $query); + + $expected = new OrderByExpression(['title' => 'DESC']); + $this->assertOrderClause($expected, $query); + $this->assertArrayHasKey('Comments', $query->getContain()); + } + + /** + * Test that failing to add the foreignKey to the list of fields will throw an + * exception + */ + public function testEagerLoaderFieldsException(): void + { + // This test now verifies that missing foreign keys are automatically added + // instead of throwing an exception + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $this->article, + 'strategy' => 'select', + ]; + $association = new HasMany('Articles', $config); + $keys = [1, 2, 3, 4]; + + // Create a query to be used as sourceQuery + $sourceQuery = $this->article->selectQuery(); + + // Setup the mock to track what happens + $queriesReturned = []; + $this->article->shouldReceive('find') + ->once() + ->with('all') + ->andReturnUsing(function () use (&$queriesReturned) { + // Preserve the current auto-quoting state (might be affected by other tests) + $query = $this->article->selectQuery(); + $query->enableAutoFields(false); + $queriesReturned[] = $query; + + return $query; + }); + + $loader = $association->eagerLoader([ + 'fields' => ['id', 'title'], + 'keys' => $keys, + 'query' => $sourceQuery, + ]); + + // Verify that the loader was created successfully + $this->assertIsCallable($loader); + + // Verify that find was called and a query was returned + $this->assertCount(1, $queriesReturned, 'Find should have been called once'); + + // Check the query that was actually modified by the loader + $fetchQuery = $queriesReturned[0]; + $select = $fetchQuery->clause('select'); + + // The foreign key should be in the select clause + // Handle both quoted and non-quoted identifiers + $hasAuthorId = false; + foreach ($select as $key => $field) { + // Check if the field contains author_id (handles both quoted and non-quoted) + if ( + str_contains((string)$field, 'author_id') || + str_contains((string)$key, 'author_id') + ) { + $hasAuthorId = true; + break; + } + } + + $this->assertTrue( + $hasAuthorId, + 'Foreign key author_id should be added. Select clause: ' . json_encode($select), + ); + } + + /** + * Tests that eager loader accepts a queryBuilder option + */ + public function testEagerLoaderWithQueryBuilder(): void + { + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $this->article, + 'strategy' => 'select', + ]; + $association = new HasMany('Articles', $config); + $keys = [1, 2, 3, 4]; + + /** @var \Cake\ORM\Query\SelectQuery $query */ + $query = $this->article->query(); + $this->article->shouldReceive('find') + ->with('all') + ->andReturn($query); + + $queryBuilder = function ($query) { + return $query->select(['author_id'])->join('comments')->where(['comments.id' => 1]); + }; + $association->eagerLoader(compact('keys', 'query', 'queryBuilder')); + + $expected = [ + 'Articles__author_id' => 'Articles.author_id', + ]; + $this->assertSelectClause($expected, $query); + + $expected = [ + [ + 'type' => 'INNER', + 'alias' => null, + 'table' => 'comments', + 'conditions' => new QueryExpression([], $query->getTypeMap()), + ], + ]; + $this->assertJoin($expected, $query); + + $expected = new QueryExpression( + [ + 'Articles.author_id IN' => $keys, + 'comments.id' => 1, + ], + $query->getTypeMap(), + ); + $this->assertWhereClause($expected, $query); + } + + /** + * Test the eager loader method with no extra options + */ + public function testEagerLoaderMultipleKeys(): void + { + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $this->article, + 'strategy' => 'select', + 'foreignKey' => ['author_id', 'site_id'], + ]; + + $this->author->setPrimaryKey(['id', 'site_id']); + $association = new HasMany('Articles', $config); + $keys = [[1, 10], [2, 20], [3, 30], [4, 40]]; + $results = new ResultSet([ + ['id' => 1, 'title' => 'article 1', 'author_id' => 2, 'site_id' => 10], + ['id' => 2, 'title' => 'article 2', 'author_id' => 1, 'site_id' => 20], + ]); + $tuple = new TupleComparison( + ['Articles.author_id', 'Articles.site_id'], + $keys, + ['integer'], + 'IN', + ); + $query = new class ($this->article, $results, $tuple) extends SelectQuery { + public bool $andWhereCalled = false; + + public function __construct( + Table $table, + protected ResultSet $resultSet, + protected TupleComparison $expectedTuple, + ) { + parent::__construct($table); + } + + public function andWhere( + ExpressionInterface|Closure|array|string|null $conditions = null, + array $types = [], + bool $overwrite = false, + ) { + if ($conditions == $this->expectedTuple) { + $this->andWhereCalled = true; + } + + return $this; + } + + public function all(): ResultSetInterface + { + return $this->resultSet; + } + }; + $this->article->shouldReceive('find') + ->with('all') + ->andReturn($query); + + $callable = $association->eagerLoader(compact('keys', 'query')); + $this->assertTrue($query->andWhereCalled); + $row = ['Authors__id' => 2, 'Authors__site_id' => 10, 'username' => 'author 1']; + $result = $callable($row); + $row['Articles'] = [ + ['id' => 1, 'title' => 'article 1', 'author_id' => 2, 'site_id' => 10], + ]; + $this->assertEquals($row, $result); + + $row = ['Authors__id' => 1, 'username' => 'author 2', 'Authors__site_id' => 20]; + $result = $callable($row); + $row['Articles'] = [ + ['id' => 2, 'title' => 'article 2', 'author_id' => 1, 'site_id' => 20], + ]; + $this->assertEquals($row, $result); + } + + /** + * Test that not selecting join keys fails with an error + */ + public function testEagerloaderNoForeignKeys(): void + { + $authors = $this->getTableLocator()->get('Authors'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to load `Articles` association. Ensure foreign key in `Authors`'); + $query = $authors->find() + ->select(['Authors.name']) + ->where(['Authors.id' => 1]) + ->contain('Articles'); + $query->first(); + } + + /** + * Test cascading deletes. + */ + public function testCascadeDelete(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $config = [ + 'dependent' => true, + 'sourceTable' => $this->author, + 'targetTable' => $articles, + 'conditions' => ['Articles.published' => 'Y'], + ]; + $association = new HasMany('Articles', $config); + + $entity = new Entity(['id' => 1, 'name' => 'PHP']); + $this->assertTrue($association->cascadeDelete($entity)); + + $published = $articles + ->find('published') + ->where([ + 'published' => 'Y', + 'author_id' => 1, + ]); + $this->assertCount(0, $published->all()); + } + + /** + * Test cascading deletes with a finder + */ + public function testCascadeDeleteFinder(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $config = [ + 'dependent' => true, + 'sourceTable' => $this->author, + 'targetTable' => $articles, + 'finder' => 'published', + ]; + // Exclude one record from the association finder + $articles->updateAll( + ['published' => 'N'], + ['author_id' => 1, 'title' => 'First Article'], + ); + $association = new HasMany('Articles', $config); + + $entity = new Entity(['id' => 1, 'name' => 'PHP']); + $this->assertTrue($association->cascadeDelete($entity)); + + $published = $articles->find('published')->where(['author_id' => 1]); + $this->assertCount(0, $published->all(), 'Associated records should be removed'); + + $all = $articles->find()->where(['author_id' => 1]); + $this->assertCount(1, $all->all(), 'Record not in association finder should remain'); + } + + /** + * Test cascading delete with has many. + */ + public function testCascadeDeleteCallbacks(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $config = [ + 'dependent' => true, + 'sourceTable' => $this->author, + 'targetTable' => $articles, + 'conditions' => ['Articles.published' => 'Y'], + 'cascadeCallbacks' => true, + ]; + $association = new HasMany('Articles', $config); + + $author = new Entity(['id' => 1, 'name' => 'mark']); + $this->assertTrue($association->cascadeDelete($author)); + + $query = $articles->find()->where(['author_id' => 1]); + $this->assertSame(0, $query->count(), 'Cleared related rows'); + + $query = $articles->find()->where(['author_id' => 3]); + $this->assertSame(1, $query->count(), 'other records left behind'); + } + + /** + * Test cascading delete with a rule preventing deletion + */ + public function testCascadeDeleteCallbacksRuleFailure(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $config = [ + 'dependent' => true, + 'sourceTable' => $this->author, + 'targetTable' => $articles, + 'cascadeCallbacks' => true, + ]; + $association = new HasMany('Articles', $config); + $articles = $association->getTarget(); + $articles->getEventManager()->on('Model.buildRules', function ($event, $rules): void { + $rules->addDelete(function () { + return false; + }); + }); + + $author = new Entity(['id' => 1, 'name' => 'mark']); + $this->assertFalse($association->cascadeDelete($author)); + $matching = $articles->find() + ->where(['Articles.author_id' => $author->id]) + ->all(); + $this->assertGreaterThan(0, count($matching)); + } + + /** + * Test that saveAssociated() ignores non entity values. + */ + public function testSaveAssociatedOnlyEntities(): void + { + $spy = Mockery::spy(Table::class); + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $spy, + ]; + + $entity = new Entity([ + 'username' => 'Mark', + 'email' => 'mark@example.com', + 'articles' => [ + ['title' => 'First Post'], + new Entity(['title' => 'Second Post']), + ], + ]); + + $association = new HasMany('Articles', $config); + $result = $association->saveAssociated($entity); + $this->assertSame($result, $entity); + + $spy->shouldNotHaveReceived('saveAssociated'); + } + + /** + * Tests that property is being set using the constructor options. + */ + public function testPropertyOption(): void + { + $config = ['propertyName' => 'thing_placeholder', 'sourceTable' => $this->author]; + $association = new HasMany('Thing', $config); + $this->assertSame('thing_placeholder', $association->getProperty()); + } + + /** + * Tests propertyName is used during marshalling and validation + */ + public function testPropertyOptionMarshalAndValidation(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setProperty('blogs'); + $authors->getValidator() + ->requirePresence('blogs', true, 'blogs must be set'); + + $data = [ + 'name' => 'corey', + ]; + $author = $authors->newEntity($data); + $this->assertEmpty($author->blogs, 'No blogs set'); + $this->assertTrue($author->hasErrors(), 'Should have validation errors'); + $this->assertArrayHasKey('blogs', $author->getErrors()); + } + + /** + * Test that plugin names are omitted from property() + */ + public function testPropertyNoPlugin(): void + { + $config = [ + 'sourceTable' => $this->author, + 'targetTable' => $this->article, + ]; + $association = new HasMany('Contacts.Addresses', $config); + $this->assertSame('addresses', $association->getProperty()); + } + + /** + * Test that the ValueBinder is reset when using strategy = Association::STRATEGY_SUBQUERY + */ + public function testValueBinderUpdateOnSubQueryStrategy(): void + { + $Authors = $this->getTableLocator()->get('Authors'); + $Authors->Articles->setStrategy(Association::STRATEGY_SUBQUERY); + + $query = $Authors->find(); + $authorsAndArticles = $query + ->select([ + 'id', + 'slug' => $query->func()->concat([ + '---', + 'name' => 'identifier', + ]), + ]) + ->contain('Articles') + ->where(['name' => 'mariano']) + ->first(); + + $this->assertCount(2, $authorsAndArticles->get('articles')); + } + + /** + * Tests using subquery strategy when parent query + * that contains limit without order. + */ + public function testSubqueryWithLimit(): void + { + $Authors = $this->getTableLocator()->get('Authors'); + $Authors->Articles->setStrategy(Association::STRATEGY_SUBQUERY); + + $query = $Authors->find(); + $result = $query + ->contain('Articles') + ->first(); + + if (in_array($result->name, ['mariano', 'larry'])) { + $this->assertNotEmpty($result->articles); + } else { + $this->assertEmpty($result->articles); + } + } + + /** + * Tests using subquery strategy when parent query + * that contains limit with order. + */ + public function testSubqueryWithLimitAndOrder(): void + { + $this->skipIf(ConnectionManager::get('test')->getDriver() instanceof Sqlserver, 'Sql Server does not support ORDER BY on field not in GROUP BY'); + + $Authors = $this->getTableLocator()->get('Authors'); + $Authors->Articles->setStrategy(Association::STRATEGY_SUBQUERY); + + $query = $Authors->find(); + $result = $query + ->contain('Articles') + ->orderBy(['name' => 'ASC']) + ->limit(2) + ->toArray(); + + $this->assertCount(0, $result[0]->articles); + $this->assertCount(1, $result[1]->articles); + } + + /** + * Assertion method for order by clause contents. + * + * @param array $expected The expected join clause. + * @param \Cake\ORM\Query\SelectQuery $query The query to check. + */ + protected function assertJoin($expected, $query): void + { + if ($this->autoQuote) { + $quoter = $query->getConnection()->getDriver()->quoter(); + foreach ($expected as &$join) { + $join['table'] = $quoter->quoteIdentifier($join['table']); + if ($join['conditions']) { + $quoter->quoteExpression($join['conditions']); + } + } + } + $this->assertEquals($expected, array_values($query->clause('join'))); + } + + /** + * Assertion method for where clause contents. + * + * @param \Cake\Database\QueryExpression $expected The expected where clause. + * @param \Cake\ORM\Query\SelectQuery $query The query to check. + */ + protected function assertWhereClause($expected, $query): void + { + if ($this->autoQuote) { + $expected->traverse($query->getConnection()->getDriver()->quoter()->quoteExpression(...)); + } + $this->assertEquals($expected, $query->clause('where')); + } + + /** + * Assertion method for order by clause contents. + * + * @param \Cake\Database\QueryExpression $expected The expected where clause. + * @param \Cake\ORM\Query\SelectQuery $query The query to check. + */ + protected function assertOrderClause($expected, $query): void + { + if ($this->autoQuote) { + $query->getConnection()->getDriver()->quoter()->quoteExpression($expected); + } + $this->assertEquals($expected, $query->clause('order')); + } + + /** + * Assertion method for select clause contents. + * + * @param array $expected Array of expected fields. + * @param \Cake\ORM\Query\SelectQuery $query The query to check. + */ + protected function assertSelectClause($expected, $query): void + { + if ($this->autoQuote) { + $driver = $query->getConnection()->getDriver(); + foreach ($expected as $key => $value) { + $expected[$driver->quoteIdentifier($key)] = $driver->quoteIdentifier($value); + unset($expected[$key]); + } + } + $this->assertEquals($expected, $query->clause('select')); + } + + /** + * Tests that unlinking calls the right methods + */ + public function testUnlinkSuccess(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $assoc = $this->author->Articles; + + $entity = $this->author->get(1, ...['contain' => 'Articles']); + $initial = $entity->articles; + $this->assertCount(2, $initial); + + $assoc->unlink($entity, $entity->articles); + $this->assertEmpty($entity->get('articles'), 'Property should be empty'); + + $new = $this->author->get(2, ...['contain' => 'Articles']); + $this->assertCount(0, $new->articles, 'DB should be clean'); + $this->assertSame(4, $this->author->find()->count(), 'Authors should still exist'); + $this->assertSame(3, $articles->find()->count(), 'Articles should still exist'); + } + + /** + * Tests that unlink with an empty array does nothing + */ + public function testUnlinkWithEmptyArray(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $assoc = $this->author->Articles; + + $entity = $this->author->get(1, ...['contain' => 'Articles']); + $initial = $entity->articles; + $this->assertCount(2, $initial); + + $assoc->unlink($entity, []); + + $new = $this->author->get(1, ...['contain' => 'Articles']); + $this->assertCount(2, $new->articles, 'Articles should remain linked'); + $this->assertSame(4, $this->author->find()->count(), 'Authors should still exist'); + $this->assertSame(3, $articles->find()->count(), 'Articles should still exist'); + } + + /** + * Tests that link only uses a single database transaction + */ + public function testLinkUsesSingleTransaction(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $assoc = $this->author->Articles; + + // Ensure author in fixture has zero associated articles + $entity = $this->author->get(2, ...['contain' => 'Articles']); + $initial = $entity->articles; + $this->assertCount(0, $initial); + + // Ensure that after each model is saved, we are still within a transaction. + $listenerAfterSave = function ($e, $entity, $options) use ($articles): void { + $this->assertTrue( + $articles->getConnection()->inTransaction(), + 'Multiple transactions used to save associated models.', + ); + }; + $articles->getEventManager()->on('Model.afterSave', $listenerAfterSave); + + $options = ['atomic' => false]; + $assoc->link($entity, $articles->find('all')->toArray(), $options); + + // Ensure that link was successful. + $new = $this->author->get(2, ...['contain' => 'Articles']); + $this->assertCount(3, $new->articles); + } + + /** + * Test that saveAssociated() fails on non-empty, non-iterable value + */ + public function testSaveAssociatedNotEmptyNotIterable(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Could not save comments, it cannot be traversed'); + $articles = $this->getTableLocator()->get('Articles'); + $association = $articles->hasMany('Comments', [ + 'saveStrategy' => HasMany::SAVE_APPEND, + ]); + + $entity = $articles->newEmptyEntity(); + $entity->set('comments', 'oh noes'); + + $association->saveAssociated($entity); + } + + /** + * Data provider for empty values. + * + * @return array + */ + public static function emptySetDataProvider(): array + { + return [ + [''], + [false], + [null], + [[]], + ]; + } + + /** + * Test that saving empty sets with the `append` strategy does not + * affect the associated records for not yet persisted parent entities. + * + * @param mixed $value Empty value. + */ + #[DataProvider('emptySetDataProvider')] + public function testSaveAssociatedEmptySetWithAppendStrategyDoesNotAffectAssociatedRecordsOnCreate($value): void + { + $articles = $this->getTableLocator()->get('Articles'); + $association = $articles->hasMany('Comments', [ + 'saveStrategy' => HasMany::SAVE_APPEND, + ]); + + $comments = $association->find(); + $this->assertNotEmpty($comments); + + $entity = $articles->newEmptyEntity(); + $entity->set('comments', $value); + + $this->assertSame($entity, $association->saveAssociated($entity)); + $this->assertEquals($value, $entity->get('comments')); + $this->assertEquals($comments, $association->find()); + } + + /** + * Test that saving empty sets with the `append` strategy does not + * affect the associated records for already persisted parent entities. + * + * @param mixed $value Empty value. + */ + #[DataProvider('emptySetDataProvider')] + public function testSaveAssociatedEmptySetWithAppendStrategyDoesNotAffectAssociatedRecordsOnUpdate($value): void + { + $articles = $this->getTableLocator()->get('Articles'); + $association = $articles->hasMany('Comments', [ + 'saveStrategy' => HasMany::SAVE_APPEND, + ]); + + $entity = $articles->get(1, ...[ + 'contain' => ['Comments'], + ]); + $comments = $entity->get('comments'); + $this->assertNotEmpty($comments); + + $entity->set('comments', $value); + $this->assertSame($entity, $association->saveAssociated($entity)); + $this->assertEquals($value, $entity->get('comments')); + + $entity = $articles->get(1, ...[ + 'contain' => ['Comments'], + ]); + $this->assertEquals($comments, $entity->get('comments')); + } + + /** + * Test that saving empty sets with the `replace` strategy does not + * affect the associated records for not yet persisted parent entities. + * + * @param mixed $value Empty value. + */ + #[DataProvider('emptySetDataProvider')] + public function testSaveAssociatedEmptySetWithReplaceStrategyDoesNotAffectAssociatedRecordsOnCreate($value): void + { + $articles = $this->getTableLocator()->get('Articles'); + $association = $articles->hasMany('Comments', [ + 'saveStrategy' => HasMany::SAVE_REPLACE, + ]); + + $comments = $association->find(); + $this->assertNotEmpty($comments); + + $entity = $articles->newEmptyEntity(); + $entity->set('comments', $value); + + $this->assertSame($entity, $association->saveAssociated($entity)); + $this->assertEquals($value, $entity->get('comments')); + $this->assertEquals($comments, $association->find()); + } + + /** + * Test that saving empty sets with the `replace` strategy does remove + * the associated records for already persisted parent entities. + * + * @param mixed $value Empty value. + */ + #[DataProvider('emptySetDataProvider')] + public function testSaveAssociatedEmptySetWithReplaceStrategyRemovesAssociatedRecordsOnUpdate($value): void + { + $articles = $this->getTableLocator()->get('Articles'); + $association = $articles->hasMany('Comments', [ + 'saveStrategy' => HasMany::SAVE_REPLACE, + ]); + + $entity = $articles->get(1, ...[ + 'contain' => ['Comments'], + ]); + $comments = $entity->get('comments'); + $this->assertNotEmpty($comments); + + $entity->set('comments', $value); + $this->assertSame($entity, $association->saveAssociated($entity)); + $this->assertEquals([], $entity->get('comments')); + + $entity = $articles->get(1, ...[ + 'contain' => ['Comments'], + ]); + $this->assertEmpty($entity->get('comments')); + } + + /** + * Test that the associated entities are not saved when there's any rule + * that fail on them and the errors are correctly set on the original entity. + */ + public function testSaveAssociatedWithFailedRuleOnAssociated(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->hasMany('Comments'); + $comments = $this->getTableLocator()->get('Comments'); + $comments->belongsTo('Users'); + $rules = $comments->rulesChecker(); + $rules->add($rules->existsIn('user_id', 'Users')); + $article = $articles->newEntity([ + 'title' => 'Bakeries are sky rocketing', + 'body' => 'All because of cake', + 'comments' => [ + [ + 'user_id' => 1, + 'comment' => 'That is true!', + ], + [ + 'user_id' => 999, // This rule will fail because the user doesn't exist + 'comment' => 'Of course', + ], + ], + ], ['associated' => ['Comments']]); + $this->assertFalse($article->hasErrors()); + $this->assertFalse($articles->save($article, ['associated' => ['Comments']])); + $this->assertTrue($article->hasErrors()); + $this->assertFalse($article->comments[0]->hasErrors()); + $this->assertTrue($article->comments[1]->hasErrors()); + $this->assertNotEmpty($article->comments[1]->getErrors()); + $expected = [ + 'user_id' => [ + '_existsIn' => __('This value does not exist'), + ], + ]; + $this->assertEquals($expected, $article->comments[1]->getErrors()); + } + + /** + * Tests that providing an invalid strategy throws an exception + */ + public function testInvalidSaveStrategy(): void + { + $this->expectException(InvalidArgumentException::class); + $articles = $this->getTableLocator()->get('Articles'); + + $association = $articles->hasMany('Comments'); + $association->setSaveStrategy('anotherThing'); + } + + /** + * Tests saveStrategy + */ + public function testSetSaveStrategy(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $association = $articles->hasMany('Comments'); + $this->assertSame($association, $association->setSaveStrategy(HasMany::SAVE_REPLACE)); + $this->assertSame(HasMany::SAVE_REPLACE, $association->getSaveStrategy()); + } + + /** + * Test that save works with replace saveStrategy and are not deleted once they are not null + */ + public function testSaveReplaceSaveStrategy(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setSaveStrategy(HasMany::SAVE_REPLACE); + + $entity = $authors->newEntity([ + 'name' => 'mylux', + 'articles' => [ + ['title' => 'One Random Post', 'body' => 'The cake is not a lie'], + ['title' => 'Another Random Post', 'body' => 'The cake is nice'], + ['title' => 'One more random post', 'body' => 'The cake is forever'], + ], + ], ['associated' => ['Articles']]); + + $entity = $authors->save($entity, ['associated' => ['Articles']]); + $sizeArticles = count($entity->articles); + $this->assertSame($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + + $articleId = $entity->articles[0]->id; + unset($entity->articles[0]); + $entity->setDirty('articles', true); + + $authors->save($entity, ['associated' => ['Articles']]); + + $this->assertSame($sizeArticles - 1, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + $this->assertTrue($authors->Articles->exists(['id' => $articleId])); + } + + /** + * Test that save works with replace saveStrategy conditions + */ + public function testSaveReplaceSaveStrategyClosureConditions(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles + ->setDependent(true) + ->setSaveStrategy('replace') + ->setConditions(function () { + return ['published' => 'Y']; + }); + + $entity = $authors->newEntity([ + 'name' => 'mylux', + 'articles' => [ + ['title' => 'Not matching conditions', 'body' => '', 'published' => 'N'], + ['title' => 'Random Post', 'body' => 'The cake is nice', 'published' => 'Y'], + ['title' => 'Another Random Post', 'body' => 'The cake is yummy', 'published' => 'Y'], + ['title' => 'One more random post', 'body' => 'The cake is forever', 'published' => 'Y'], + ], + ], ['associated' => ['Articles']]); + + $entity = $authors->save($entity, ['associated' => ['Articles']]); + $sizeArticles = count($entity->articles); + // Should be one fewer because of conditions. + $this->assertSame($sizeArticles - 1, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + + $articleId = $entity->articles[0]->id; + unset($entity->articles[0], $entity->articles[1]); + $entity->setDirty('articles', true); + + $authors->save($entity, ['associated' => ['Articles']]); + + $this->assertSame($sizeArticles - 2, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + + // Should still exist because it doesn't match the association conditions. + $articles = $this->getTableLocator()->get('Articles'); + $this->assertTrue($articles->exists(['id' => $articleId])); + } + + /** + * Test that save works with replace saveStrategy, replacing the already persisted entities even if no new entities are passed + */ + public function testSaveReplaceSaveStrategyNotAdding(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setSaveStrategy('replace'); + + $entity = $authors->newEntity([ + 'name' => 'mylux', + 'articles' => [ + ['title' => 'One Random Post', 'body' => 'The cake is not a lie'], + ['title' => 'Another Random Post', 'body' => 'The cake is nice'], + ['title' => 'One more random post', 'body' => 'The cake is forever'], + ], + ], ['associated' => ['Articles']]); + + $entity = $authors->save($entity, ['associated' => ['Articles']]); + $sizeArticles = count($entity->articles); + $this->assertCount($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])); + + $entity->set('articles', []); + + $entity = $authors->save($entity, ['associated' => ['Articles']]); + + $this->assertCount(0, $authors->Articles->find('all')->where(['author_id' => $entity['id']])); + } + + /** + * Test that save works with append saveStrategy not deleting or setting null anything + */ + public function testSaveAppendSaveStrategy(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setSaveStrategy('append'); + + $entity = $authors->newEntity([ + 'name' => 'mylux', + 'articles' => [ + ['title' => 'One Random Post', 'body' => 'The cake is not a lie'], + ['title' => 'Another Random Post', 'body' => 'The cake is nice'], + ['title' => 'One more random post', 'body' => 'The cake is forever'], + ], + ], ['associated' => ['Articles']]); + + $entity = $authors->save($entity, ['associated' => ['Articles']]); + $sizeArticles = count($entity->articles); + + $this->assertSame($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + + $articleId = $entity->articles[0]->id; + unset($entity->articles[0]); + $entity->setDirty('articles', true); + + $authors->save($entity, ['associated' => ['Articles']]); + + $this->assertSame($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + $this->assertTrue($authors->Articles->exists(['id' => $articleId])); + } + + /** + * Test that save has append as the default save strategy + */ + public function testSaveDefaultSaveStrategy(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setSaveStrategy(HasMany::SAVE_APPEND); + $this->assertSame(HasMany::SAVE_APPEND, $authors->getAssociation('Articles')->getSaveStrategy()); + } + + /** + * Test that the associated entities are unlinked and deleted when they are dependent + */ + public function testSaveReplaceSaveStrategyDependent(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setSaveStrategy(HasMany::SAVE_REPLACE) + ->setDependent(true); + + $entity = $authors->newEntity([ + 'name' => 'mylux', + 'articles' => [ + ['title' => 'One Random Post', 'body' => 'The cake is not a lie'], + ['title' => 'Another Random Post', 'body' => 'The cake is nice'], + ['title' => 'One more random post', 'body' => 'The cake is forever'], + ], + ], ['associated' => ['Articles']]); + + $entity = $authors->save($entity, ['associated' => ['Articles']]); + $sizeArticles = count($entity->articles); + $this->assertSame($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + + $articleId = $entity->articles[0]->id; + unset($entity->articles[0]); + $entity->setDirty('articles', true); + + $authors->save($entity, ['associated' => ['Articles']]); + + $this->assertSame($sizeArticles - 1, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + $this->assertFalse($authors->Articles->exists(['id' => $articleId])); + } + + /** + * Test that the associated entities are unlinked and deleted when they are dependent + * when associated entities array is indexed by string keys + */ + public function testSaveReplaceSaveStrategyDependentWithStringKeys(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setSaveStrategy(HasMany::SAVE_REPLACE) + ->setDependent(true); + + $entity = $authors->newEntity([ + 'name' => 'mylux', + 'articles' => [ + ['title' => 'One Random Post', 'body' => 'The cake is not a lie'], + ['title' => 'Another Random Post', 'body' => 'The cake is nice'], + ['title' => 'One more random post', 'body' => 'The cake is forever'], + ], + ], ['associated' => ['Articles']]); + + $entity = $authors->saveOrFail($entity, ['associated' => ['Articles']]); + $sizeArticles = count($entity->articles); + $this->assertSame($sizeArticles, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + + $articleId = $entity->articles[0]->id; + $entity->articles = [ + 'one' => $entity->articles[1], + 'two' => $entity->articles[2], + ]; + + $authors->saveOrFail($entity, ['associated' => ['Articles']]); + + $this->assertSame($sizeArticles - 1, $authors->Articles->find('all')->where(['author_id' => $entity['id']])->count()); + $this->assertFalse($authors->Articles->exists(['id' => $articleId])); + } + + /** + * Test that the associated entities are unlinked and deleted when they are dependent + * + * In the future this should change and apply the finder. + */ + public function testSaveReplaceSaveStrategyDependentWithConditions(): void + { + $this->getTableLocator()->clear(); + $this->setAppNamespace('TestApp'); + + $authors = $this->getTableLocator()->get('Authors'); + $authors->Articles->setSaveStrategy(HasMany::SAVE_REPLACE) + ->setDependent(true) + ->setFinder('published'); + $articles = $authors->Articles->getTarget(); + + // Remove an article from the association finder scope + $articles->updateAll(['published' => 'N'], ['author_id' => 1, 'title' => 'Third Article']); + + $entity = $authors->get(1, ...['contain' => ['Articles']]); + $data = [ + 'name' => 'updated', + 'articles' => [ + ['title' => 'New First', 'body' => 'New First', 'published' => 'Y'], + ], + ]; + $entity = $authors->patchEntity($entity, $data, ['associated' => ['Articles']]); + $entity = $authors->save($entity, ['associated' => ['Articles']]); + + // Should only have one article left as we 'replaced' the others. + $this->assertCount(1, $entity->articles); + + // No additional records in db. + $this->assertCount( + 1, + $authors->Articles->find()->where(['author_id' => 1])->toArray(), + ); + + $others = $articles->find('all') + ->where(['Articles.author_id' => 1, 'published' => 'N']) + ->orderByAsc('title') + ->toArray(); + $this->assertCount( + 1, + $others, + 'Record not matching association condition should stay', + ); + $this->assertSame('Third Article', $others[0]->title); + } + + /** + * Test that the associated entities are unlinked and deleted when they have a not nullable foreign key + */ + public function testSaveReplaceSaveStrategyNotNullable(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->hasMany('Comments', ['saveStrategy' => HasMany::SAVE_REPLACE]); + + $article = $articles->newEntity([ + 'title' => 'Bakeries are sky rocketing', + 'body' => 'All because of cake', + 'comments' => [ + [ + 'user_id' => 1, + 'comment' => 'That is true!', + ], + [ + 'user_id' => 2, + 'comment' => 'Of course', + ], + ], + ], ['associated' => ['Comments']]); + + $article = $articles->save($article, ['associated' => ['Comments']]); + $commentId = $article->comments[0]->id; + $sizeComments = count($article->comments); + + $this->assertSame($sizeComments, $articles->Comments->find('all')->where(['article_id' => $article->id])->count()); + $this->assertTrue($articles->Comments->exists(['id' => $commentId])); + + unset($article->comments[0]); + $article->setDirty('comments', true); + $article = $articles->save($article, ['associated' => ['Comments']]); + + $this->assertSame($sizeComments - 1, $articles->Comments->find('all')->where(['article_id' => $article->id])->count()); + $this->assertFalse($articles->Comments->exists(['id' => $commentId])); + } + + /** + * Test that the associated entities are unlinked and deleted when they have a not nullable foreign key + */ + public function testSaveReplaceSaveStrategyAdding(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->hasMany('Comments', ['saveStrategy' => HasMany::SAVE_REPLACE]); + + $article = $articles->newEntity([ + 'title' => 'Bakeries are sky rocketing', + 'body' => 'All because of cake', + 'comments' => [ + [ + 'user_id' => 1, + 'comment' => 'That is true!', + ], + [ + 'user_id' => 2, + 'comment' => 'Of course', + ], + ], + ], ['associated' => ['Comments']]); + + $article = $articles->save($article, ['associated' => ['Comments']]); + $commentId = $article->comments[0]->id; + $sizeComments = count($article->comments); + $articleId = $article->id; + + $this->assertSame($sizeComments, $articles->Comments->find('all')->where(['article_id' => $article->id])->count()); + $this->assertTrue($articles->Comments->exists(['id' => $commentId])); + + unset($article->comments[0]); + $article->comments[] = $articles->Comments->newEntity([ + 'user_id' => 1, + 'comment' => 'new comment', + ]); + + $article->setDirty('comments', true); + $article = $articles->save($article, ['associated' => ['Comments']]); + + $this->assertSame($sizeComments, $articles->Comments->find('all')->where(['article_id' => $article->id])->count()); + $this->assertFalse($articles->Comments->exists(['id' => $commentId])); + $this->assertTrue($articles->Comments->exists(['comment' => 'new comment', 'article_id' => $articleId])); + } + + /** + * Tests that dependent, non-cascading deletes are using the association + * conditions for deleting associated records. + */ + public function testHasManyNonCascadingUnlinkDeleteUsesAssociationConditions(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Comments = $Articles->hasMany('Comments', [ + 'dependent' => true, + 'cascadeCallbacks' => false, + 'saveStrategy' => HasMany::SAVE_REPLACE, + 'conditions' => [ + 'Comments.published' => 'Y', + ], + ]); + + $article = $Articles->newEntity([ + 'title' => 'Title', + 'body' => 'Body', + 'comments' => [ + [ + 'user_id' => 1, + 'comment' => 'First comment', + 'published' => 'Y', + ], + [ + 'user_id' => 1, + 'comment' => 'Second comment', + 'published' => 'Y', + ], + ], + ]); + $article = $Articles->save($article); + $this->assertNotEmpty($article); + + $comment3 = $Comments->getTarget()->newEntity([ + 'article_id' => $article->get('id'), + 'user_id' => 1, + 'comment' => 'Third comment', + 'published' => 'N', + ]); + $comment3 = $Comments->getTarget()->save($comment3); + $this->assertNotEmpty($comment3); + + $this->assertSame(3, $Comments->getTarget()->find()->where(['Comments.article_id' => $article->get('id')])->count()); + + unset($article->comments[1]); + $article->setDirty('comments', true); + + $article = $Articles->save($article); + $this->assertNotEmpty($article); + + // Given the association condition of `'Comments.published' => 'Y'`, + // it is expected that only one of the three linked comments are + // actually being deleted, as only one of them matches the + // association condition. + $this->assertSame(2, $Comments->getTarget()->find()->where(['Comments.article_id' => $article->get('id')])->count()); + } + + /** + * Tests that non-dependent, non-cascading deletes are using the association + * conditions for updating associated records. + */ + public function testHasManyNonDependentNonCascadingUnlinkUpdateUsesAssociationConditions(): void + { + $Authors = $this->getTableLocator()->get('Authors'); + $Authors->associations()->removeAll(); + $Articles = $Authors->hasMany('Articles', [ + 'dependent' => false, + 'cascadeCallbacks' => false, + 'saveStrategy' => HasMany::SAVE_REPLACE, + 'conditions' => [ + 'Articles.published' => 'Y', + ], + ]); + + $author = $Authors->newEntity([ + 'name' => 'Name', + 'articles' => [ + [ + 'title' => 'First article', + 'body' => 'First article', + 'published' => 'Y', + ], + [ + 'title' => 'Second article', + 'body' => 'Second article', + 'published' => 'Y', + ], + ], + ]); + $author = $Authors->save($author); + $this->assertNotEmpty($author); + + $article3 = $Articles->getTarget()->newEntity([ + 'author_id' => $author->get('id'), + 'title' => 'Third article', + 'body' => 'Third article', + 'published' => 'N', + ]); + $article3 = $Articles->getTarget()->save($article3); + $this->assertNotEmpty($article3); + + $this->assertSame(3, $Articles->getTarget()->find()->where(['Articles.author_id' => $author->get('id')])->count()); + + $article2 = $author->articles[1]; + unset($author->articles[1]); + $author->setDirty('articles', true); + + $author = $Authors->save($author); + $this->assertNotEmpty($author); + + // Given the association condition of `'Articles.published' => 'Y'`, + // it is expected that only one of the three linked articles are + // actually being unlinked (nulled), as only one of them matches the + // association condition. + $this->assertSame(2, $Articles->getTarget()->find()->where(['Articles.author_id' => $author->get('id')])->count()); + $this->assertNull($Articles->get($article2->get('id'))->get('author_id')); + $this->assertEquals($author->get('id'), $Articles->get($article3->get('id'))->get('author_id')); + } + + public function testEagerLoaderConnectionRole(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + + Log::setConfig('queries', [ + 'className' => 'Array', + 'scopes' => ['queriesLog'], + ]); + + ConnectionManager::setConfig('test_read_write', [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'write' => [ + 'database' => ':memory:', + 'cached' => 'shared', // used so role configs are unique + 'log' => true, + ], + 'read' => [ + 'database' => ':memory:', + 'log' => true, + ], + ]); + + $connection = ConnectionManager::get('test_read_write'); + $this->assertNotSame($connection->getDriver(Connection::ROLE_READ), $connection->getDriver(Connection::ROLE_WRITE)); + + // Create belongs to many relationships with unique table names + $driver = $connection->getDriver(Connection::ROLE_WRITE); + $driver->execute('CREATE TABLE unique_items (id int PRIMARY KEY, article_id int);'); + $driver->execute('CREATE TABLE articles (id int PRIMARY KEY);'); + + $driver = $connection->getDriver(Connection::ROLE_READ); + $driver->execute('CREATE TABLE unique_items (id int PRIMARY KEY, article_id int);'); + $driver->execute('CREATE TABLE articles (id int PRIMARY KEY);'); + $driver->execute('INSERT INTO unique_items (id, article_id) VALUES (1, 1)'); + $driver->execute('INSERT INTO articles (id) VALUES (1)'); + + $articles = $this->getTableLocator()->get('Articles')->setConnection($connection); + $articles->hasMany('UniqueItems')->setStrategy('select')->getTarget()->setConnection($connection); + + $query = $articles->find(); + $this->assertSame(Connection::ROLE_WRITE, $query->getConnectionRole(), 'This test assumes select queries still default to write role'); + + $results = $query->contain('UniqueItems')->useReadRole()->toArray(); + $this->assertCount(1, $results); + $this->assertCount(1, $results[0]->unique_items); + $this->assertSame(1, $results[0]->unique_items[0]->id); + + $logs = Log::engine('queries')->read(); + $this->assertNotEmpty($logs); + + foreach ($logs as $log) { + if ( + str_contains($log, 'FROM articles') || + str_contains($log, 'FROM unique_items') + ) { + $this->assertStringContainsString('role=read', $log); + } + } + } +} diff --git a/tests/TestCase/ORM/Association/HasOneTest.php b/tests/TestCase/ORM/Association/HasOneTest.php new file mode 100644 index 00000000000..5d334d2616a --- /dev/null +++ b/tests/TestCase/ORM/Association/HasOneTest.php @@ -0,0 +1,444 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Authors', 'core.NullableAuthors', 'core.Users', 'core.Profiles']; + + /** + * @var \Cake\ORM\Table + */ + protected $user; + + /** + * @var \Cake\ORM\Table + */ + protected $profile; + + /** + * @var bool + */ + protected $listenerCalled = false; + + /** + * Set up + */ + protected function setUp(): void + { + parent::setUp(); + $this->user = $this->getTableLocator()->get('Users'); + $this->profile = $this->getTableLocator()->get('Profiles'); + $this->listenerCalled = false; + } + + /** + * Tests that setForeignKey() returns the correct configured value + */ + public function testSetForeignKey(): void + { + $assoc = new HasOne('Profiles', [ + 'sourceTable' => $this->user, + ]); + $this->assertSame('user_id', $assoc->getForeignKey()); + $this->assertEquals($assoc, $assoc->setForeignKey('another_key')); + $this->assertSame('another_key', $assoc->getForeignKey()); + } + + /** + * Tests that the default foreign key condition generation can be disabled. + */ + public function testDisableForeignKey(): void + { + $table = $this->getTableLocator()->get('Users'); + $assoc = $table + ->hasOne('Profiles') + ->setForeignKey('user_id'); + + $user = $table->find()->contain(['Profiles'])->orderByAsc('Users.id')->first(); + $this->assertSame('mariano', $user->profile->first_name); + + $assoc + ->setForeignKey(false) + ->setConditions([ + 'Profiles.first_name' => 'larry', + ]); + + $user = $table->find()->contain(['Profiles'])->orderByAsc('Users.id')->first(); + $this->assertSame('larry', $user->profile->first_name); + } + + /** + * Tests that the association reports it can be joined + */ + public function testCanBeJoined(): void + { + $assoc = new HasOne('Test'); + $this->assertTrue($assoc->canBeJoined()); + } + + /** + * Tests that the correct join and fields are attached to a query depending on + * the association config + */ + public function testAttachTo(): void + { + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + 'property' => 'profile', + 'joinType' => 'INNER', + 'conditions' => ['Profiles.is_active' => true], + ]; + $association = new HasOne('Profiles', $config); + $query = $this->user->find(); + $association->attachTo($query); + + $results = $query->orderBy('Users.id')->toArray(); + $this->assertCount(1, $results, 'Only one record because of conditions & join type'); + $this->assertSame('masters', $results[0]->Profiles['last_name']); + } + + /** + * Tests that it is possible to avoid fields inclusion for the associated table + */ + public function testAttachToNoFields(): void + { + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + 'conditions' => ['Profiles.is_active' => true], + ]; + $association = new HasOne('Profiles', $config); + $query = $this->user->find(); + $association->attachTo($query, ['includeFields' => false]); + $this->assertEmpty($query->clause('select')); + } + + /** + * Tests that using hasOne with a table having a multi column primary + * key will work if the foreign key is passed + */ + public function testAttachToMultiPrimaryKey(): void + { + $selectTypeMap = new TypeMap([ + 'Profiles.id' => 'integer', + 'id' => 'integer', + 'Profiles.first_name' => 'string', + 'first_name' => 'string', + 'Profiles.user_id' => 'integer', + 'user_id' => 'integer', + 'Profiles__first_name' => 'string', + 'Profiles__user_id' => 'integer', + 'Profiles__id' => 'integer', + 'Profiles__last_name' => 'string', + 'Profiles.last_name' => 'string', + 'last_name' => 'string', + 'Profiles__is_active' => 'boolean', + 'Profiles.is_active' => 'boolean', + 'is_active' => 'boolean', + ]); + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + 'conditions' => ['Profiles.is_active' => true], + 'foreignKey' => ['user_id', 'user_site_id'], + ]; + + $this->user->setPrimaryKey(['id', 'site_id']); + $association = new HasOne('Profiles', $config); + + $query = new Query($this->user); + $field1 = new IdentifierExpression('Profiles.user_id'); + $field2 = new IdentifierExpression('Profiles.user_site_id'); + $expected = [ + 'Profiles' => [ + 'conditions' => new QueryExpression([ + 'Profiles.is_active' => true, + ['Users.id' => $field1, 'Users.site_id' => $field2], + ], $selectTypeMap), + 'type' => 'LEFT', + 'table' => 'profiles', + 'alias' => 'Profiles', + ], + ]; + $association->attachTo($query); + $this->assertEquals($expected, $query->clause('join')); + } + + /** + * Tests that using hasOne with a table having a multi column primary + * key will work if the foreign key is passed + */ + public function testAttachToMultiPrimaryKeyMismatch(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot match provided foreignKey for `Profiles`, got `(user_id)` but expected foreign key for `(id, site_id)`'); + $query = new Query($this->user); + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + 'conditions' => ['Profiles.is_active' => true], + ]; + $this->user->setPrimaryKey(['id', 'site_id']); + $association = new HasOne('Profiles', $config); + $association->attachTo($query, ['includeFields' => false]); + } + + /** + * Test that saveAssociated() ignores non entity values. + */ + public function testSaveAssociatedOnlyEntities(): void + { + $spy = Mockery::spy(Table::class); + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $spy, + ]; + + $entity = new Entity([ + 'username' => 'Mark', + 'email' => 'mark@example.com', + 'profile' => ['twitter' => '@cakephp'], + ]); + + $association = new HasOne('Profiles', $config); + $result = $association->saveAssociated($entity); + + $this->assertSame($result, $entity); + $spy->shouldNotHaveReceived('saveAssociated'); + } + + /** + * Tests that property is being set using the constructor options. + */ + public function testPropertyOption(): void + { + $config = ['propertyName' => 'thing_placeholder', 'sourceTable' => $this->user]; + $association = new HasOne('Thing', $config); + $this->assertSame('thing_placeholder', $association->getProperty()); + } + + /** + * Test that plugin names are omitted from property() + */ + public function testPropertyNoPlugin(): void + { + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + ]; + $association = new HasOne('Contacts.Profiles', $config); + $this->assertSame('profile', $association->getProperty()); + } + + /** + * Tests that attaching an association to a query will trigger beforeFind + * for the target table + */ + public function testAttachToBeforeFind(): void + { + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + ]; + $query = $this->user->find(); + + $this->listenerCalled = false; + $this->profile->getEventManager()->on('Model.beforeFind', function ($event, $query, $options, bool $primary): void { + $this->listenerCalled = true; + $this->assertInstanceOf(Event::class, $event); + $this->assertInstanceOf(Query::class, $query); + $this->assertInstanceOf('ArrayObject', $options); + $this->assertFalse($primary); + }); + $association = new HasOne('Profiles', $config); + $association->attachTo($query); + $this->assertTrue($this->listenerCalled, 'beforeFind event not fired.'); + } + + /** + * Tests that attaching an association to a query will trigger beforeFind + * for the target table + */ + public function testAttachToBeforeFindExtraOptions(): void + { + $config = [ + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + ]; + $this->listenerCalled = false; + $opts = new ArrayObject(['something' => 'more']); + $this->profile->getEventManager()->on( + 'Model.beforeFind', + function ($event, $query, $options, bool $primary) use ($opts): void { + $this->listenerCalled = true; + $this->assertInstanceOf(Event::class, $event); + $this->assertInstanceOf(Query::class, $query); + $this->assertEquals($options, $opts); + $this->assertFalse($primary); + }, + ); + $association = new HasOne('Profiles', $config); + $query = $this->user->find(); + $association->attachTo($query, ['queryBuilder' => function ($q) { + return $q->applyOptions(['something' => 'more']); + }]); + $this->assertTrue($this->listenerCalled, 'Event not fired'); + } + + /** + * Test cascading deletes. + */ + public function testCascadeDelete(): void + { + $config = [ + 'dependent' => true, + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + 'conditions' => ['Profiles.is_active' => true], + 'cascadeCallbacks' => false, + ]; + $association = new HasOne('Profiles', $config); + + $this->profile->getEventManager()->on('Model.beforeDelete', function (): void { + $this->fail('Callbacks should not be triggered when callbacks do not cascade.'); + }); + + $entity = new Entity(['id' => 1]); + $association->cascadeDelete($entity); + + $query = $this->profile->find()->where(['user_id' => 1]); + $this->assertSame(1, $query->count(), 'Left non-matching row behind'); + + $query = $this->profile->find()->where(['user_id' => 3]); + $this->assertSame(1, $query->count(), 'other records left behind'); + + $user = new Entity(['id' => 3]); + $this->assertTrue($association->cascadeDelete($user)); + $query = $this->profile->find()->where(['user_id' => 3]); + $this->assertSame(0, $query->count(), 'Matching record was deleted.'); + } + + /** + * Tests cascading deletes on entities with null binding and foreign key. + */ + public function testCascadeDeleteNullBindingNullForeign(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Authors = $this->getTableLocator()->get('NullableAuthors'); + + $config = [ + 'dependent' => true, + 'sourceTable' => $Authors, + 'targetTable' => $Articles, + 'bindingKey' => 'author_id', + 'foreignKey' => 'author_id', + 'cascadeCallbacks' => false, + ]; + $association = $Authors->hasOne('Articles', $config); + + // create article with null foreign key + $entity = new Entity(['author_id' => null, 'title' => 'this has no author', 'body' => 'I am abandoned', 'published' => 'N']); + $Articles->save($entity); + + // get author with null binding key + $entity = $Authors->get(2, ...['contain' => 'Articles']); + $this->assertNull($entity->article); + $this->assertTrue($association->cascadeDelete($entity)); + + $query = $Articles->find(); + $this->assertSame(4, $query->count(), 'No articles should be deleted'); + } + + /** + * Test cascading delete with has one. + */ + public function testCascadeDeleteCallbacks(): void + { + $config = [ + 'dependent' => true, + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + 'conditions' => ['Profiles.is_active' => true], + 'cascadeCallbacks' => true, + ]; + $association = new HasOne('Profiles', $config); + + $user = new Entity(['id' => 1]); + $this->assertTrue($association->cascadeDelete($user)); + + $query = $this->profile->find()->where(['user_id' => 1]); + $this->assertSame(1, $query->count(), 'Left non-matching row behind'); + + $query = $this->profile->find()->where(['user_id' => 3]); + $this->assertSame(1, $query->count(), 'other records left behind'); + + $user = new Entity(['id' => 3]); + $this->assertTrue($association->cascadeDelete($user)); + $query = $this->profile->find()->where(['user_id' => 3]); + $this->assertSame(0, $query->count(), 'Matching record was deleted.'); + } + + /** + * Test cascading delete with a rule preventing deletion + */ + public function testCascadeDeleteCallbacksRuleFailure(): void + { + $config = [ + 'dependent' => true, + 'sourceTable' => $this->user, + 'targetTable' => $this->profile, + 'cascadeCallbacks' => true, + ]; + $association = new HasOne('Profiles', $config); + $profiles = $association->getTarget(); + $profiles->getEventManager()->on('Model.buildRules', function ($event, $rules): void { + $rules->addDelete(function () { + return false; + }); + }); + + $user = new Entity(['id' => 1]); + $this->assertFalse($association->cascadeDelete($user)); + $matching = $profiles->find() + ->where(['Profiles.user_id' => $user->id]) + ->all(); + $this->assertGreaterThan(0, count($matching)); + } +} diff --git a/tests/TestCase/ORM/AssociationCollectionTest.php b/tests/TestCase/ORM/AssociationCollectionTest.php new file mode 100644 index 00000000000..ddcdcd0db97 --- /dev/null +++ b/tests/TestCase/ORM/AssociationCollectionTest.php @@ -0,0 +1,423 @@ +associations = new AssociationCollection(); + } + + /** + * Test the constructor. + */ + public function testConstructor(): void + { + $this->assertSame($this->getTableLocator(), $this->associations->getTableLocator()); + + $tableLocator = Mockery::mock(LocatorInterface::class); + $associations = new AssociationCollection($tableLocator); + $this->assertSame($tableLocator, $associations->getTableLocator()); + } + + /** + * Test the simple add/has and get methods. + */ + public function testAddHasRemoveAndGet(): void + { + $this->assertFalse($this->associations->has('users')); + $this->assertFalse($this->associations->has('Users')); + + $this->assertNull($this->associations->get('users')); + $this->assertNull($this->associations->get('Users')); + + $belongsTo = new BelongsTo(''); + $this->assertSame($belongsTo, $this->associations->add('Users', $belongsTo)); + $this->assertFalse($this->associations->has('users')); + $this->assertTrue($this->associations->has('Users')); + + $this->assertSame($belongsTo, $this->associations->get('Users')); + + $this->associations->remove('Users'); + + $this->assertFalse($this->associations->has('Users')); + $this->assertNull($this->associations->get('Users')); + } + + /** + * Test the load method. + */ + public function testLoad(): void + { + $this->associations->load(BelongsTo::class, 'Users'); + $this->assertTrue($this->associations->has('Users')); + $this->assertInstanceOf(BelongsTo::class, $this->associations->get('Users')); + $this->assertSame($this->associations->getTableLocator(), $this->associations->get('Users')->getTableLocator()); + } + + /** + * Test the load method with custom locator. + */ + public function testLoadCustomLocator(): void + { + $locator = Mockery::mock(LocatorInterface::class); + $this->associations->load(BelongsTo::class, 'Users', [ + 'tableLocator' => $locator, + ]); + $this->assertTrue($this->associations->has('Users')); + $this->assertInstanceOf(BelongsTo::class, $this->associations->get('Users')); + $this->assertSame($locator, $this->associations->get('Users')->getTableLocator()); + } + + /** + * Test removeAll method + */ + public function testRemoveAll(): void + { + $this->assertEmpty($this->associations->keys()); + + $belongsTo = new BelongsTo(''); + $this->assertSame($belongsTo, $this->associations->add('Users', $belongsTo)); + $belongsToMany = new BelongsToMany(''); + $this->assertSame($belongsToMany, $this->associations->add('Cart', $belongsToMany)); + + $this->associations->removeAll(); + $this->assertEmpty($this->associations->keys()); + } + + /** + * Test getting associations by property. + */ + public function testGetByProperty(): void + { + $table = new Table(['alias' => 'Clients', 'table' => 'clients']); + $table->setSchema([]); + $belongsTo = new BelongsTo('Users', [ + 'sourceTable' => $table, + ]); + $this->assertSame('user', $belongsTo->getProperty()); + $this->associations->add('Users', $belongsTo); + $this->assertNull($this->associations->get('user')); + + $this->assertSame($belongsTo, $this->associations->getByProperty('user')); + } + + /** + * Test associations with plugin names. + */ + public function testAddHasRemoveGetWithPlugin(): void + { + $this->assertFalse($this->associations->has('Photos.Photos')); + $this->assertFalse($this->associations->has('Photos')); + + $belongsTo = new BelongsTo(''); + $this->assertSame($belongsTo, $this->associations->add('Photos.Photos', $belongsTo)); + $this->assertTrue($this->associations->has('Photos')); + $this->assertFalse($this->associations->has('Photos.Photos')); + } + + /** + * Test keys() + */ + public function testKeys(): void + { + $belongsTo = new BelongsTo(''); + $this->associations->add('Users', $belongsTo); + $this->associations->add('Categories', $belongsTo); + $this->assertEquals(['Users', 'Categories'], $this->associations->keys()); + + $this->associations->remove('Categories'); + $this->assertEquals(['Users'], $this->associations->keys()); + } + + /** + * Data provider for AssociationCollection::getByType + */ + public static function associationCollectionType(): array + { + return [ + ['BelongsTo', 'BelongsToMany'], + ['belongsTo', 'belongsToMany'], + ['belongsto', 'belongstomany'], + ]; + } + + /** + * Test getting association names by getByType. + * + * @param string $belongsToStr + * @param string $belongsToManyStr + */ + #[DataProvider('associationCollectionType')] + public function testGetByType($belongsToStr, $belongsToManyStr): void + { + $belongsTo = new BelongsTo(''); + $this->associations->add('Users', $belongsTo); + + $belongsToMany = new BelongsToMany(''); + $this->associations->add('Tags', $belongsToMany); + + $this->assertSame([$belongsTo], $this->associations->getByType($belongsToStr)); + $this->assertSame([$belongsToMany], $this->associations->getByType($belongsToManyStr)); + $this->assertSame([$belongsTo, $belongsToMany], $this->associations->getByType([$belongsToStr, $belongsToManyStr])); + } + + /** + * Type should return empty array. + */ + public function hasTypeReturnsEmptyArray(): void + { + foreach (['HasMany', 'hasMany', 'FooBar', 'DoesNotExist'] as $value) { + $this->assertSame([], $this->associations->getByType($value)); + } + } + + /** + * test cascading deletes. + */ + public function testCascadeDelete(): void + { + $mockOne = Mockery::mock(new BelongsTo(''))->makePartial(); + $mockTwo = Mockery::mock(new HasMany(''))->makePartial(); + + $entity = new Entity(); + $options = ['option' => 'value']; + $this->associations->add('One', $mockOne); + $this->associations->add('Two', $mockTwo); + + $mockOne->shouldReceive('cascadeDelete') + ->once() + ->with($entity, $options) + ->andReturn(true); + + $mockTwo->shouldReceive('cascadeDelete') + ->once() + ->with($entity, $options) + ->andReturn(true); + + $result = $this->associations->cascadeDelete($entity, $options); + $this->assertTrue($result); + } + + /** + * Test saving parent associations + */ + public function testSaveParents(): void + { + $table = new Table(['alias' => 'Users', 'table' => 'users']); + $table->setSchema([]); + $mockOne = Mockery::mock(new BelongsTo('Parent', [ + 'sourceTable' => $table, + ]))->makePartial(); + $mockTwo = Mockery::mock(new HasMany('Child', [ + 'sourceTable' => $table, + ]))->makePartial(); + + $this->associations->add('Parent', $mockOne); + $this->associations->add('Child', $mockTwo); + + $entity = new Entity(); + $entity->set('parent', ['key' => 'value']); + $entity->set('child', ['key' => 'value']); + + $options = ['option' => 'value']; + + $mockOne->shouldReceive('saveAssociated') + ->once() + ->with($entity, $options) + ->andReturn($entity); + + $mockTwo->shouldReceive('saveAssociated')->never(); + + $result = $this->associations->saveParents( + $table, + $entity, + ['Parent', 'Child'], + $options, + ); + $this->assertTrue($result, 'Save should work.'); + } + + /** + * Test saving filtered parent associations. + */ + public function testSaveParentsFiltered(): void + { + $table = new Table(['alias' => 'Users', 'table' => 'users']); + $table->setSchema([]); + $mockOne = Mockery::mock(new BelongsTo('Parents', [ + 'sourceTable' => $table, + ]))->makePartial(); + $mockTwo = Mockery::mock(new BelongsTo('Categories', [ + 'sourceTable' => $table, + ]))->makePartial(); + + $this->associations->add('Parents', $mockOne); + $this->associations->add('Categories', $mockTwo); + + $entity = new Entity(); + $entity->set('parent', ['key' => 'value']); + $entity->set('category', ['key' => 'value']); + + $options = ['atomic' => true]; + + $mockOne->shouldReceive('saveAssociated') + ->once() + ->with($entity, ['atomic' => true, 'associated' => ['Others']]) + ->andReturn($entity); + + $mockTwo->shouldReceive('saveAssociated')->never(); + + $result = $this->associations->saveParents( + $table, + $entity, + ['Parents' => ['associated' => ['Others']]], + $options, + ); + $this->assertTrue($result, 'Save should work.'); + } + + /** + * Test saving filtered child associations. + */ + public function testSaveChildrenFiltered(): void + { + $table = new Table(['alias' => 'Users', 'table' => 'users']); + $table->setSchema([]); + $mockOne = Mockery::mock(new HasMany('Comments', [ + 'sourceTable' => $table, + ]))->makePartial(); + $mockTwo = Mockery::mock(new HasOne('Profiles', [ + 'sourceTable' => $table, + ]))->makePartial(); + + $this->associations->add('Comments', $mockOne); + $this->associations->add('Profiles', $mockTwo); + + $entity = new Entity(); + $entity->set('comments', ['key' => 'value']); + $entity->set('profile', ['key' => 'value']); + + $options = ['atomic' => true]; + + $mockOne->shouldReceive('saveAssociated') + ->once() + ->with($entity, $options + ['associated' => ['Other']]) + ->andReturn($entity); + + $mockTwo->shouldReceive('saveAssociated')->never(); + + $result = $this->associations->saveChildren( + $table, + $entity, + ['Comments' => ['associated' => ['Other']]], + $options, + ); + $this->assertTrue($result, 'Should succeed.'); + } + + /** + * Test exceptional case. + */ + public function testErrorOnUnknownAlias(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot save `Profiles`, it is not associated to `Users`'); + $table = new Table(['alias' => 'Users', 'table' => 'users']); + + $entity = new Entity(); + $entity->set('profile', ['key' => 'value']); + + $this->associations->saveChildren( + $table, + $entity, + ['Profiles'], + ['atomic' => true], + ); + } + + /** + * Tests the normalizeKeys method + */ + public function testNormalizeKeys(): void + { + $this->assertSame([], $this->associations->normalizeKeys([])); + $this->assertSame([], $this->associations->normalizeKeys(false)); + + $assocs = ['a', 'b', 'd' => ['something']]; + $expected = ['a' => [], 'b' => [], 'd' => ['something']]; + $this->assertSame($expected, $this->associations->normalizeKeys($assocs)); + + $belongsTo = new BelongsTo(''); + $this->associations->add('users', $belongsTo); + $this->associations->add('categories', $belongsTo); + $expected = ['users' => [], 'categories' => []]; + $this->assertSame($expected, $this->associations->normalizeKeys(true)); + } + + /** + * Ensure that the association collection can be iterated. + */ + public function testAssociationsCanBeIterated(): void + { + $belongsTo = new BelongsTo(''); + $this->associations->add('Users', $belongsTo); + $belongsToMany = new BelongsToMany(''); + $this->associations->add('Cart', $belongsToMany); + + $expected = ['Users' => $belongsTo, 'Cart' => $belongsToMany]; + $result = iterator_to_array($this->associations, true); + $this->assertSame($expected, $result); + } + + public function testExceptionOnDuplicateAlias(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Association alias `Users` is already set.'); + + $belongsTo = new BelongsTo(''); + $this->associations->add('Users', $belongsTo); + $this->associations->add('Users', $belongsTo); + } +} diff --git a/tests/TestCase/ORM/AssociationProxyTest.php b/tests/TestCase/ORM/AssociationProxyTest.php new file mode 100644 index 00000000000..0a164ea0b86 --- /dev/null +++ b/tests/TestCase/ORM/AssociationProxyTest.php @@ -0,0 +1,197 @@ + + */ + protected array $fixtures = [ + 'core.Articles', 'core.Authors', 'core.Comments', + ]; + + /** + * Tests that it is possible to get associations as a property + */ + public function testAssociationAsProperty(): void + { + $articles = $this->getTableLocator()->get('articles'); + $articles->hasMany('comments'); + $articles->belongsTo('authors'); + $this->assertTrue(isset($articles->authors)); + $this->assertTrue(isset($articles->comments)); + $this->assertFalse(isset($articles->posts)); + $this->assertSame($articles->getAssociation('authors'), $articles->authors); + $this->assertSame($articles->getAssociation('comments'), $articles->comments); + } + + /** + * Tests that getting a bad property throws exception + */ + public function testGetBadAssociation(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('You have not defined'); + $articles = $this->getTableLocator()->get('articles'); + $articles->posts; + } + + /** + * Test that find() with empty conditions generates valid SQL + */ + public function testFindEmptyConditions(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasMany('Articles', [ + 'foreignKey' => 'author_id', + 'conditions' => [], + ]); + $query = $table->Articles->find('list', limit: 2); + $this->assertCount(2, $query->all()); + } + + /** + * Tests that the proxied updateAll will preserve conditions set for the association + */ + public function testUpdateAllFromAssociation(): void + { + $articles = $this->getTableLocator()->get('articles'); + $comments = $this->getTableLocator()->get('comments'); + $articles->hasMany('comments', ['conditions' => ['published' => 'Y']]); + $articles->comments->updateAll(['comment' => 'changed'], ['article_id' => 1]); + $changed = $comments->find()->where(['comment' => 'changed'])->count(); + $this->assertSame(3, $changed); + } + + /** + * Tests that the proxied updateAll uses the association finder + */ + public function testUpdateAllFromAssociationFinder(): void + { + $this->setAppNamespace('TestApp'); + + $articles = $this->getTableLocator()->get('articles'); + $authors = $this->getTableLocator()->get('authors'); + // Exclude a record from the published finder. + $articles->updateAll(['published' => 'N'], ['id' => 1]); + + $authors->Articles->setFinder('published'); + $authors->Articles->updateAll(['published' => '?'], '1=1'); + $missed = $articles->find()->where(['published' => 'Y'])->count(); + $this->assertSame(0, $missed); + + $remaining = $articles->find()->where(['published' => 'N'])->count(); + $this->assertSame(1, $remaining); + } + + /** + * Tests that the proxied deleteAll preserves conditions set for the association + */ + public function testDeleteAllFromAssociationConditions(): void + { + $articles = $this->getTableLocator()->get('articles'); + $comments = $this->getTableLocator()->get('comments'); + $articles->hasMany('comments', ['conditions' => ['published' => 'Y']]); + $articles->comments->deleteAll(['article_id' => 1]); + $remaining = $comments->find()->where(['article_id' => 1])->count(); + $this->assertSame(1, $remaining); + } + + /** + * Tests that the proxied deleteAll uses the association finder + */ + public function testDeleteAllFromAssociationFinder(): void + { + $this->setAppNamespace('TestApp'); + + $articles = $this->getTableLocator()->get('articles'); + $authors = $this->getTableLocator()->get('authors'); + // Exclude a record from the published finder. + $articles->updateAll(['published' => 'N'], ['id' => 1]); + + $authors->Articles->setFinder('published'); + $authors->Articles->deleteAll('1=1'); + $remaining = $articles->find()->all(); + $this->assertCount(1, $remaining); + $this->assertSame(['N'], $remaining->extract('published')->toList()); + } + + /** + * Tests that it is possible to get associations as a property + */ + public function testAssociationAsPropertyProxy(): void + { + $articles = $this->getTableLocator()->get('articles'); + $authors = $this->getTableLocator()->get('authors'); + $articles->belongsTo('authors'); + $authors->hasMany('comments'); + $this->assertTrue(isset($articles->authors->comments)); + $this->assertSame($authors->getAssociation('comments'), $articles->authors->comments); + } + + /** + * Tests that isset on association only returns true for associations + */ + public function testAssociationIssetOnlyChecksAssociations(): void + { + $articles = $this->getTableLocator()->get('articles'); + $authors = $this->getTableLocator()->get('authors'); + $articles->belongsTo('authors'); + $authors->hasMany('comments'); + + // Existing association returns true + $this->assertTrue(isset($articles->authors->comments)); + + // Non-existing association returns false + $this->assertFalse(isset($articles->authors->posts)); + + // Non-association properties return false (table has these but they're not associations) + $this->assertFalse(isset($articles->authors->_table)); + $this->assertFalse(isset($articles->authors->entityClass)); + } + + /** + * Tests that methods are proxied from the Association to the target table + */ + public function testAssociationMethodProxy(): void + { + $articles = $this->getTableLocator()->get('articles'); + $spy = Mockery::spy(Table::class); + $articles->belongsTo('authors', [ + 'targetTable' => $spy, + ]); + + $articles->authors->crazy('a', 'b'); + + $spy + ->shouldHaveReceived('crazy') + ->with('a', 'b') + ->once(); + } +} diff --git a/tests/TestCase/ORM/AssociationTest.php b/tests/TestCase/ORM/AssociationTest.php new file mode 100644 index 00000000000..b15c2a28aa0 --- /dev/null +++ b/tests/TestCase/ORM/AssociationTest.php @@ -0,0 +1,528 @@ +source = new TestTable(); + $config = [ + 'className' => TestTable::class, + 'foreignKey' => 'a_key', + 'conditions' => ['field' => 'value'], + 'dependent' => true, + 'sourceTable' => $this->source, + 'joinType' => 'INNER', + ]; + $this->association = Mockery::mock( + Association::class . '[_options,attachTo,_joinCondition,cascadeDelete,isOwningSide,saveAssociated,eagerLoader,type]', + ['Foo', $config], + ) + ->makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + } + + /** + * Tests that _options acts as a callback where subclasses can add their own + * initialization code based on the passed configuration array + */ + public function testOptionsIsCalled(): void + { + $options = ['foo' => 'bar']; + $this->association->shouldReceive('_options')->once()->with($options); + $this->association->__construct('Name', $options); + } + + /** + * Test that _className property is set to alias when "className" config + * if not explicitly set. + */ + public function testSetttingClassNameFromAlias(): void + { + /** @var \Cake\ORM\Association&\Mockery\MockInterface $association */ + $association = Mockery::mock( + Association::class . '[type,eagerLoader,cascadeDelete,isOwningSide,saveAssociated]', + ['Foo'], + ) + ->makePartial() + ->shouldIgnoreMissing(); + $association->shouldReceive('type') + ->byDefault() + ->andReturn(Association::MANY_TO_ONE); + + $this->assertSame('Foo', $association->getClassName()); + } + + /** + * Tests that setClassName() succeeds before the target table is resolved. + */ + public function testSetClassNameBeforeTarget(): void + { + $this->assertSame(TestTable::class, $this->association->getClassName()); + $this->assertSame($this->association, $this->association->setClassName(AuthorsTable::class)); + $this->assertSame(AuthorsTable::class, $this->association->getClassName()); + } + + /** + * Tests that setClassName() fails after the target table is resolved. + */ + public function testSetClassNameAfterTarget(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The class name `' . AuthorsTable::class . "` doesn't match the target table class name of"); + $this->association->getTarget(); + $this->association->setClassName(AuthorsTable::class); + } + + /** + * Tests that setClassName() fails after the target table is resolved. + */ + public function testSetClassNameWithShortSyntaxAfterTarget(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The class name `Authors` doesn't match the target table class name of"); + $this->association->getTarget(); + $this->association->setClassName('Authors'); + } + + /** + * Tests that setClassName() succeeds if name equals target table's class name. + */ + public function testSetClassNameToTargetClassName(): void + { + $className = $this->association->getTarget()::class; + $this->association->setClassName($className); + $this->assertSame($className, $this->association->getClassName()); + } + + /** + * Tests that setClassName() succeeds if the short name resolves to the target table's class name. + */ + public function testSetClassNameWithShortSyntaxToTargetClassName(): void + { + Configure::write('App.namespace', 'TestApp'); + + $this->association->setClassName(AuthorsTable::class); + $className = $this->association->getTarget()::class; + $this->assertSame(AuthorsTable::class, $className); + $this->association->setClassName('Authors'); + $this->assertSame('Authors', $this->association->getClassName()); + } + + /** + * Tests that className() returns the correct (unnormalized) className + */ + public function testClassNameUnnormalized(): void + { + $config = [ + 'className' => 'Test', + ]; + $this->association = Mockery::mock( + Association::class . '[_options,attachTo,_joinCondition,cascadeDelete,isOwningSide,saveAssociated,eagerLoader,type]', + ['Foo', $config], + ) + ->makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + $this->association->shouldReceive('type') + ->byDefault() + ->andReturn(Association::MANY_TO_ONE); + + $this->assertSame('Test', $this->association->getClassName()); + } + + /** + * Tests that an exception is thrown when invalid target table is fetched + * from a registry. + */ + public function testInvalidTableFetchedFromRegistry(): void + { + $this->expectException(DatabaseException::class); + + $config = [ + 'className' => TestTable::class, + ]; + $this->association = Mockery::mock( + Association::class . '[_options,attachTo,_joinCondition,cascadeDelete,isOwningSide,saveAssociated,eagerLoader,type]', + ['Test', $config], + ) + ->makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + $this->association->shouldReceive('type') + ->byDefault() + ->andReturn(Association::MANY_TO_ONE); + $this->association->setSource($this->getTableLocator()->get('Test')); + + $this->association->getTarget(); + } + + /** + * Tests that a descendant table could be fetched from a registry. + */ + public function testTargetTableDescendant(): void + { + $this->getTableLocator()->get('Test', [ + 'className' => TestTable::class, + ]); + $className = Table::class; + + $config = [ + 'className' => $className, + ]; + $this->association = Mockery::mock( + Association::class . '[_options,attachTo,_joinCondition,cascadeDelete,isOwningSide,saveAssociated,eagerLoader,type]', + ['Test', $config], + ) + ->makePartial() + ->shouldAllowMockingProtectedMethods() + ->shouldIgnoreMissing(); + $this->association->shouldReceive('type') + ->byDefault() + ->andReturn(Association::MANY_TO_ONE); + + $target = $this->association->getTarget(); + $this->assertInstanceOf($className, $target); + } + + /** + * Tests that cascadeCallbacks() returns the correct configured value + */ + public function testSetCascadeCallbacks(): void + { + $this->assertFalse($this->association->getCascadeCallbacks()); + $this->assertSame($this->association, $this->association->setCascadeCallbacks(true)); + $this->assertTrue($this->association->getCascadeCallbacks()); + } + + /** + * Tests the bindingKey method as a setter/getter + */ + public function testSetBindingKey(): void + { + $this->assertSame($this->association, $this->association->setBindingKey('foo_id')); + $this->assertSame('foo_id', $this->association->getBindingKey()); + } + + /** + * Tests the bindingKey() method when called with its defaults + */ + public function testBindingKeyDefault(): void + { + $this->source->setPrimaryKey(['id', 'site_id']); + $this->association + ->shouldReceive('isOwningSide') + ->once() + ->andReturn(true); + $result = $this->association->getBindingKey(); + $this->assertEquals(['id', 'site_id'], $result); + } + + /** + * Tests the bindingKey() method when the association source is not the + * owning side + */ + public function testBindingDefaultNoOwningSide(): void + { + $target = new Table(); + $target->setPrimaryKey(['foo', 'site_id']); + $this->association->setTarget($target); + + $this->association + ->shouldReceive('isOwningSide') + ->once() + ->andReturn(false); + $result = $this->association->getBindingKey(); + $this->assertEquals(['foo', 'site_id'], $result); + } + + /** + * Tests setForeignKey() + */ + public function testSetForeignKey(): void + { + $this->assertSame('a_key', $this->association->getForeignKey()); + $this->assertSame($this->association, $this->association->setForeignKey('another_key')); + $this->assertSame('another_key', $this->association->getForeignKey()); + } + + /** + * Tests setConditions() + */ + public function testSetConditions(): void + { + $this->assertEquals(['field' => 'value'], $this->association->getConditions()); + $conds = ['another_key' => 'another value']; + $this->assertSame($this->association, $this->association->setConditions($conds)); + $this->assertEquals($conds, $this->association->getConditions()); + } + + /** + * Tests that canBeJoined() returns the correct configured value + */ + public function testCanBeJoined(): void + { + $this->assertTrue($this->association->canBeJoined()); + } + + /** + * Tests that setTarget() + */ + public function testSetTarget(): void + { + $table = $this->association->getTarget(); + $this->assertInstanceOf(TestTable::class, $table); + + $other = new Table(); + $this->assertSame($this->association, $this->association->setTarget($other)); + $this->assertSame($other, $this->association->getTarget()); + } + + /** + * Tests that target() returns the correct Table object for plugins + */ + public function testTargetPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + $config = [ + 'className' => 'TestPlugin.Comments', + 'foreignKey' => 'a_key', + 'conditions' => ['field' => 'value'], + 'dependent' => true, + 'sourceTable' => $this->source, + 'joinType' => 'INNER', + ]; + + $this->association = Mockery::mock( + Association::class . '[type,eagerLoader,cascadeDelete,isOwningSide,saveAssociated]', + ['ThisAssociationName', $config], + ) + ->makePartial() + ->shouldIgnoreMissing(); + $this->association->shouldReceive('type') + ->byDefault() + ->andReturn(Association::MANY_TO_ONE); + + $table = $this->association->getTarget(); + $this->assertInstanceOf(CommentsTable::class, $table); + + $this->assertTrue( + $this->getTableLocator()->exists('TestPlugin.ThisAssociationName'), + 'The association class will use this registry key', + ); + $this->assertFalse($this->getTableLocator()->exists('TestPlugin.Comments'), 'The association class will NOT use this key'); + $this->assertFalse($this->getTableLocator()->exists('Comments'), 'Should also not be set'); + $this->assertFalse($this->getTableLocator()->exists('ThisAssociationName'), 'Should also not be set'); + + $plugin = $this->getTableLocator()->get('TestPlugin.ThisAssociationName'); + $this->assertSame($table, $plugin, 'Should be an instance of TestPlugin.Comments'); + $this->assertSame('TestPlugin.ThisAssociationName', $table->getRegistryAlias()); + $this->assertSame('comments', $table->getTable()); + $this->assertSame('ThisAssociationName', $table->getAlias()); + $this->clearPlugins(); + } + + /** + * Tests that source() returns the correct Table object + */ + public function testSetSource(): void + { + $table = $this->association->getSource(); + $this->assertSame($this->source, $table); + + $other = new Table(); + $this->assertSame($this->association, $this->association->setSource($other)); + $this->assertSame($other, $this->association->getSource()); + } + + /** + * Tests setJoinType method + */ + public function testSetJoinType(): void + { + $this->assertSame('INNER', $this->association->getJoinType()); + $this->assertSame($this->association, $this->association->setJoinType('LEFT')); + $this->assertSame('LEFT', $this->association->getJoinType()); + } + + /** + * Tests property method + */ + public function testSetProperty(): void + { + $this->assertSame('foo', $this->association->getProperty()); + $this->assertSame($this->association, $this->association->setProperty('thing')); + $this->assertSame('thing', $this->association->getProperty()); + } + + /** + * Test that warning is shown if property name clashes with table field. + */ + public function testPropertyNameClash(): void + { + $this->expectWarningMessageMatches('/^Association property name `foo` clashes with field of same name of table `test`/', function (): void { + $this->source->setSchema(['foo' => ['type' => 'string']]); + $this->assertSame('foo', $this->association->getProperty()); + }); + } + + /** + * Tests strategy method + */ + public function testSetStrategy(): void + { + $this->assertSame('join', $this->association->getStrategy()); + + $this->association->setStrategy('select'); + $this->assertSame('select', $this->association->getStrategy()); + + $this->association->setStrategy('subquery'); + $this->assertSame('subquery', $this->association->getStrategy()); + } + + /** + * Tests that providing an invalid strategy throws an exception + */ + public function testInvalidStrategy(): void + { + $this->expectException(InvalidArgumentException::class); + $this->association->setStrategy('anotherThing'); + } + + /** + * Tests test setFinder() method + */ + public function testSetFinderMethod(): void + { + $this->assertSame('all', $this->association->getFinder()); + $this->assertSame($this->association, $this->association->setFinder('published')); + $this->assertSame('published', $this->association->getFinder()); + } + + /** + * Tests that `finder` is a valid option for the association constructor + */ + public function testFinderInConstructor(): void + { + $config = [ + 'className' => TestTable::class, + 'foreignKey' => 'a_key', + 'conditions' => ['field' => 'value'], + 'dependent' => true, + 'sourceTable' => $this->source, + 'joinType' => 'INNER', + 'finder' => 'published', + ]; + $assoc = Mockery::mock( + Association::class . '[type,eagerLoader,cascadeDelete,isOwningSide,saveAssociated]', + ['Foo', $config], + ) + ->makePartial() + ->shouldIgnoreMissing(); + $assoc->shouldReceive('type') + ->byDefault() + ->andReturn(Association::MANY_TO_ONE); + $this->assertSame('published', $assoc->getFinder()); + } + + public function testCustomFinderWithTypedArgs(): void + { + $this->association->setFinder('publishedWithArgOnly'); + $this->assertEquals( + ['this' => 'custom'], + $this->association->find(null, 'custom')->getOptions(), + ); + $this->assertEquals( + ['what' => 'custom', 'this' => 'custom'], + $this->association->find(null, what: 'custom')->getOptions(), + ); + $this->assertEquals( + ['what' => 'custom', 'this' => 'custom'], + $this->association->find(what: 'custom')->getOptions(), + ); + } + + public function testCustomFinderWithOptions(): void + { + $this->association->setFinder('withOptions'); + + $this->deprecated(function (): void { + $this->assertEquals( + ['this' => 'worked'], + $this->association->find(null)->getOptions(), + ); + + $this->assertEquals( + ['that' => 'custom', 'this' => 'worked'], + $this->association->find(null, ['that' => 'custom'])->getOptions(), + ); + }); + } + + /** + * Tests that `locator` is a valid option for the association constructor + */ + public function testLocatorInConstructor(): void + { + $locator = Mockery::mock(LocatorInterface::class); + $config = [ + 'className' => TestTable::class, + 'tableLocator' => $locator, + ]; + $assoc = Mockery::mock( + Association::class . '[type,eagerLoader,cascadeDelete,isOwningSide,saveAssociated]', + ['Foo', $config], + ) + ->makePartial() + ->shouldIgnoreMissing(); + $assoc->shouldReceive('type') + ->byDefault() + ->andReturn(Association::MANY_TO_ONE); + $this->assertEquals($locator, $assoc->getTableLocator()); + } +} diff --git a/tests/TestCase/ORM/Behavior/BehaviorRegressionTest.php b/tests/TestCase/ORM/Behavior/BehaviorRegressionTest.php new file mode 100644 index 00000000000..2adf07298db --- /dev/null +++ b/tests/TestCase/ORM/Behavior/BehaviorRegressionTest.php @@ -0,0 +1,70 @@ + + */ + protected array $fixtures = [ + 'core.NumberTrees', + 'core.Translates', + ]; + + /** + * Tests that the tree behavior and the translations behavior play together + * + * @see https://github.com/cakephp/cakephp/issues/5982 + */ + public function testTreeAndTranslateIntegration(): void + { + $connection = ConnectionManager::get('test'); + $this->skipIf( + $connection->getDriver() instanceof Sqlserver, + 'This test fails sporadically in SQLServer', + ); + + $table = $this->getTableLocator()->get('NumberTrees'); + $table->setPrimaryKey(['id']); + $table->addBehavior('Tree'); + $table->addBehavior('Translate', [ + 'fields' => ['name'], + 'strategyClass' => EavStrategy::class, + ]); + $table->setEntityClass(NumberTree::class); + + /** @var \TestApp\Model\Entity\NumberTree[] $all */ + $all = $table->find('threaded')->find('translations'); + $results = []; + foreach ($all as $node) { + $results[] = $node->translation('dan')->name; + } + $this->assertEquals(['Elektroniker', 'Alien Tingerne'], $results); + } +} diff --git a/tests/TestCase/ORM/Behavior/CounterCacheBehaviorTest.php b/tests/TestCase/ORM/Behavior/CounterCacheBehaviorTest.php new file mode 100644 index 00000000000..985ad6fa695 --- /dev/null +++ b/tests/TestCase/ORM/Behavior/CounterCacheBehaviorTest.php @@ -0,0 +1,705 @@ + + */ + protected array $fixtures = [ + 'core.CounterCacheCategories', + 'core.CounterCachePosts', + 'core.CounterCacheComments', + 'core.CounterCacheUsers', + 'core.CounterCacheUserCategoryPosts', + ]; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + + $this->user = $this->getTableLocator()->get('Users', [ + 'table' => 'counter_cache_users', + 'connection' => $this->connection, + ]); + + $this->category = $this->getTableLocator()->get('Categories', [ + 'table' => 'counter_cache_categories', + 'connection' => $this->connection, + ]); + + $this->comment = $this->getTableLocator()->get('Comments', [ + 'alias' => 'Comment', + 'table' => 'counter_cache_comments', + 'connection' => $this->connection, + ]); + + $this->post = new PublishedPostsTable([ + 'alias' => 'Post', + 'table' => 'counter_cache_posts', + 'connection' => $this->connection, + ]); + + $this->userCategoryPosts = new Table([ + 'alias' => 'UserCategoryPosts', + 'table' => 'counter_cache_user_category_posts', + 'connection' => $this->connection, + ]); + } + + /** + * teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + + unset($this->user, $this->post); + } + + /** + * Testing simple counter caching when adding a record + */ + public function testAdd(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Sqlserver, + 'This test fails sporadically in SQLServer', + ); + + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count', + ], + ]); + + $before = $this->_getUser(); + $entity = $this->_getEntity(); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(2, $before->get('post_count')); + $this->assertSame(3, $after->get('post_count')); + } + + /** + * Testing simple counter caching when adding a record + */ + public function testAddIgnore(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count', + ], + ]); + + $before = $this->_getUser(); + $entity = $this->_getEntity(); + $this->post->save($entity, ['ignoreCounterCache' => true]); + $after = $this->_getUser(); + + $this->assertSame(2, $before->get('post_count')); + $this->assertSame(2, $after->get('post_count')); + } + + /** + * Testing simple counter caching when adding a record + */ + public function testAddScope(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => [ + 'conditions' => [ + 'published' => true, + ], + ], + ], + ]); + + $before = $this->_getUser(); + $entity = $this->_getEntity()->set('published', true); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(1, $before->get('posts_published')); + $this->assertSame(2, $after->get('posts_published')); + } + + public function testSaveWithNullForeignKey(): void + { + $this->comment->belongsTo('Users'); + + $this->comment->addBehavior('CounterCache', [ + 'Users' => [ + 'comment_count', + ], + ]); + + $entity = new Entity([ + 'title' => 'Orphan comment', + 'user_id' => null, + ]); + $this->comment->saveOrFail($entity); + $this->assertTrue(true); + } + + /** + * Testing simple counter caching when deleting a record + */ + public function testDelete(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count', + ], + ]); + + $before = $this->_getUser(); + $post = $this->post->find('all')->first(); + $this->post->delete($post); + $after = $this->_getUser(); + + $this->assertSame(2, $before->get('post_count')); + $this->assertSame(1, $after->get('post_count')); + } + + /** + * Testing simple counter caching when deleting a record + */ + public function testDeleteIgnore(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count', + ], + ]); + + $before = $this->_getUser(); + $post = $this->post->find('all') + ->first(); + $this->post->delete($post, ['ignoreCounterCache' => true]); + $after = $this->_getUser(); + + $this->assertSame(2, $before->get('post_count')); + $this->assertSame(2, $after->get('post_count')); + } + + /** + * Testing update simple counter caching when updating a record association + */ + public function testUpdate(): void + { + $this->post->belongsTo('Users'); + $this->post->belongsTo('Categories'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count', + ], + 'Categories' => [ + 'post_count', + ], + ]); + + $user1 = $this->_getUser(1); + $user2 = $this->_getUser(2); + $category1 = $this->_getCategory(1); + $category2 = $this->_getCategory(2); + $post = $this->post->find('all')->first(); + $this->assertSame(2, $user1->get('post_count')); + $this->assertSame(1, $user2->get('post_count')); + $this->assertSame(1, $category1->get('post_count')); + $this->assertSame(2, $category2->get('post_count')); + + $entity = $this->post->patchEntity($post, ['user_id' => 2, 'category_id' => 2]); + $this->post->save($entity); + + $user1 = $this->_getUser(1); + $user2 = $this->_getUser(2); + $category1 = $this->_getCategory(1); + $category2 = $this->_getCategory(2); + $this->assertSame(1, $user1->get('post_count')); + $this->assertSame(2, $user2->get('post_count')); + $this->assertSame(0, $category1->get('post_count')); + $this->assertSame(3, $category2->get('post_count')); + + $entity = $this->post->patchEntity($post, ['user_id' => null, 'category_id' => null]); + $this->post->save($entity); + + $user2 = $this->_getUser(2); + $category2 = $this->_getCategory(2); + $this->assertSame(1, $user2->get('post_count')); + $this->assertSame(2, $category2->get('post_count')); + + $entity = $this->post->patchEntity($post, ['user_id' => 2, 'category_id' => 2]); + $this->post->save($entity); + + $user2 = $this->_getUser(2); + $category2 = $this->_getCategory(2); + $this->assertSame(2, $user2->get('post_count')); + $this->assertSame(3, $category2->get('post_count')); + } + + /** + * Testing counter cache with custom find + */ + public function testCustomFind(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => [ + 'finder' => 'published', + ], + ], + ]); + + $before = $this->_getUser(); + $entity = $this->_getEntity()->set('published', true); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(1, $before->get('posts_published')); + $this->assertSame(2, $after->get('posts_published')); + } + + public function testCustomFindWithoutSubquery(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => [ + 'finder' => 'published', + 'useSubQuery' => false, + ], + ], + ]); + + $before = $this->_getUser(); + $entity = $this->_getEntity()->set('published', true); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(1, $before->get('posts_published')); + $this->assertSame(2, $after->get('posts_published')); + } + + /** + * Testing counter cache with lambda returning number + */ + public function testLambdaNumber(): void + { + $this->post->belongsTo('Users'); + + $table = $this->post; + $entity = $this->_getEntity(); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => function (EventInterface $orgEvent, EntityInterface $orgEntity, Table $orgTable) use ($entity, $table) { + $this->assertSame($orgTable, $table); + $this->assertSame($orgEntity, $entity); + + return 2; + }, + ], + ]); + + $before = $this->_getUser(); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(1, $before->get('posts_published')); + $this->assertSame(2, $after->get('posts_published')); + } + + /** + * Testing counter cache with lambda returning false + */ + public function testLambdaFalse(): void + { + $this->post->belongsTo('Users'); + + $table = $this->post; + $entity = $this->_getEntity(); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => function (EventInterface $orgEvent, EntityInterface $orgEntity, Table $orgTable) use ($entity, $table) { + $this->assertSame($orgTable, $table); + $this->assertSame($orgEntity, $entity); + + return false; + }, + ], + ]); + + $before = $this->_getUser(); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(1, $before->get('posts_published')); + $this->assertSame(1, $after->get('posts_published')); + } + + /** + * Testing counter cache with lambda returning number and changing of related ID + */ + public function testLambdaNumberUpdate(): void + { + $this->post->belongsTo('Users'); + + $table = $this->post; + $entity = $this->_getEntity(); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => function (EventInterface $orgEvent, EntityInterface $orgEntity, Table $orgTable, $original) use ($entity, $table) { + $this->assertSame($orgTable, $table); + $this->assertSame($orgEntity, $entity); + + if (!$original) { + return 2; + } + + return 1; + }, + ], + ]); + + $this->post->save($entity); + $between = $this->_getUser(); + $entity->user_id = 2; + $this->post->save($entity); + $afterUser1 = $this->_getUser(1); + $afterUser2 = $this->_getUser(2); + + $this->assertSame(2, $between->get('posts_published')); + $this->assertSame(1, $afterUser1->get('posts_published')); + $this->assertSame(2, $afterUser2->get('posts_published')); + } + + /** + * Testing counter cache with lambda returning a subquery + */ + public function testLambdaSubquery(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => function (EventInterface $event, EntityInterface $entity, Table $table) { + return $table->getConnection()->selectQuery(4); + }, + ], + ]); + + $before = $this->_getUser(); + $entity = $this->_getEntity(); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(1, $before->get('posts_published')); + $this->assertSame(4, $after->get('posts_published')); + } + + /** + * Testing multiple counter cache when adding a record + */ + public function testMultiple(): void + { + $this->post->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count', + 'posts_published' => [ + 'conditions' => [ + 'published' => true, + ], + ], + ], + ]); + + $before = $this->_getUser(); + $entity = $this->_getEntity()->set('published', true); + $this->post->save($entity); + $after = $this->_getUser(); + + $this->assertSame(1, $before->get('posts_published')); + $this->assertSame(2, $after->get('posts_published')); + + $this->assertSame(2, $before->get('post_count')); + $this->assertSame(3, $after->get('post_count')); + } + + /** + * Tests to see that the binding key configuration is respected. + */ + public function testBindingKey(): void + { + $this->post->hasMany('UserCategoryPosts', [ + 'bindingKey' => ['category_id', 'user_id'], + 'foreignKey' => ['category_id', 'user_id'], + ]); + $this->post->getAssociation('UserCategoryPosts')->setTarget($this->userCategoryPosts); + $this->post->addBehavior('CounterCache', [ + 'UserCategoryPosts' => ['post_count'], + ]); + + $before = $this->userCategoryPosts->find() + ->where(['user_id' => 1, 'category_id' => 2]) + ->first(); + $entity = $this->_getEntity()->set('category_id', 2); + $this->post->save($entity); + $after = $this->userCategoryPosts->find() + ->where(['user_id' => 1, 'category_id' => 2]) + ->first(); + + $this->assertSame(1, $before->get('post_count')); + $this->assertSame(2, $after->get('post_count')); + } + + /** + * Testing the ignore if dirty option + */ + public function testIgnoreDirty(): void + { + $this->post->belongsTo('Users'); + $this->comment->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count' => [ + 'ignoreDirty' => true, + ], + 'comment_count' => [ + 'ignoreDirty' => true, + ], + ], + ]); + + $user = $this->_getUser(1); + $this->assertSame(2, $user->get('post_count')); + $this->assertSame(2, $user->get('comment_count')); + $this->assertSame(1, $user->get('posts_published')); + + $post = $this->post->find('all') + ->contain('Users') + ->where(['title' => 'Rock and Roll']) + ->first(); + $post = $this->post->patchEntity($post, [ + 'posts_published' => true, + 'user' => [ + 'id' => 1, + 'post_count' => 10, + 'comment_count' => 10, + ], + ]); + $this->post->save($post); + + $user = $this->_getUser(1); + $this->assertSame(10, $user->get('post_count')); + $this->assertSame(10, $user->get('comment_count')); + $this->assertSame(1, $user->get('posts_published')); + } + + /** + * Testing the ignore if dirty option with just one field set to ignoreDirty + */ + public function testIgnoreDirtyMixed(): void + { + $this->post->belongsTo('Users'); + $this->comment->belongsTo('Users'); + + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count' => [ + 'ignoreDirty' => true, + ], + ], + ]); + + $user = $this->_getUser(1); + $this->assertSame(2, $user->get('post_count')); + $this->assertSame(2, $user->get('comment_count')); + $this->assertSame(1, $user->get('posts_published')); + + $post = $this->post->find('all') + ->contain('Users') + ->where(['title' => 'Rock and Roll']) + ->first(); + $post = $this->post->patchEntity($post, [ + 'posts_published' => true, + 'user' => [ + 'id' => 1, + 'post_count' => 10, + ], + ]); + $this->post->save($post); + + $user = $this->_getUser(1); + $this->assertSame(10, $user->get('post_count')); + $this->assertSame(2, $user->get('comment_count')); + $this->assertSame(1, $user->get('posts_published')); + } + + public function testUpdateCounterCache(): void + { + $this->post->belongsTo('Users'); + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'post_count', + 'dummy' => function (): void { + throw new Exception('Closures are never called by "updateCounterCache()"'); + }, + ], + ]); + + $this->user->updateAll(['post_count' => 0], []); + + $user = $this->_getUser(1); + $this->assertSame(0, $user->get('post_count')); + + $this->post->getBehavior('CounterCache')->updateCounterCache('Users'); + + $user = $this->_getUser(1); + $this->assertSame(2, $user->get('post_count')); + $user = $this->_getUser(2); + $this->assertSame(1, $user->get('post_count')); + + $this->user->updateAll(['post_count' => 0], []); + + $this->post->getBehavior('CounterCache')->updateCounterCache(limit: 1, page: 2); + + $user = $this->_getUser(1); + $this->assertSame(0, $user->get('post_count')); + $user = $this->_getUser(2); + $this->assertSame(1, $user->get('post_count')); + } + + public function testUpdateCounterCacheSkipsClosureButContinues(): void + { + $this->post->belongsTo('Users'); + $this->post->addBehavior('CounterCache', [ + 'Users' => [ + 'posts_published' => function (): void { + // Should be skipped + }, + 'post_count', + ], + ]); + + $this->user->updateAll(['post_count' => 0], []); + $this->post->getBehavior('CounterCache')->updateCounterCache('Users'); + + $user = $this->_getUser(1); + // With "return": post_count stays 0 (buggy behavior) + // With "continue": post_count becomes 2 (correct behavior) + $this->assertSame(2, $user->get('post_count')); + } + + /** + * Get a new Entity + */ + protected function _getEntity(): Entity + { + return new Entity([ + 'title' => 'Test 123', + 'user_id' => 1, + ]); + } + + /** + * Returns entity for user + */ + protected function _getUser(int $id = 1): Entity + { + return $this->user->get($id); + } + + /** + * Returns entity for category + */ + protected function _getCategory(int $id = 1): Entity + { + return $this->category->find('all')->where(['id' => $id])->first(); + } +} diff --git a/tests/TestCase/ORM/Behavior/TimestampBehaviorTest.php b/tests/TestCase/ORM/Behavior/TimestampBehaviorTest.php new file mode 100644 index 00000000000..16e8938acb6 --- /dev/null +++ b/tests/TestCase/ORM/Behavior/TimestampBehaviorTest.php @@ -0,0 +1,452 @@ + + */ + protected array $fixtures = [ + 'core.Users', + ]; + + /** + * @var \Cake\ORM\Behavior\TimestampBehavior + */ + protected $Behavior; + + /** + * Sanity check Implemented events + */ + public function testImplementedEventsDefault(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + + $expected = [ + 'Model.beforeSave' => 'handleEvent', + ]; + $this->assertEquals($expected, $this->Behavior->implementedEvents()); + } + + /** + * testImplementedEventsCustom + * + * The behavior allows for handling any event - test an example + */ + public function testImplementedEventsCustom(): void + { + $table = $this->getTableInstance(); + $settings = ['events' => ['Something.special' => ['date_specialed' => 'always']]]; + $this->Behavior = new TimestampBehavior($table, $settings); + + $expected = [ + 'Something.special' => 'handleEvent', + ]; + $this->assertEquals($expected, $this->Behavior->implementedEvents()); + } + + /** + * testCreatedAbsent + * + * @triggers Model.beforeSave + */ + public function testCreatedAbsent(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $event = new Event('Model.beforeSave'); + $entity = new Entity(['name' => 'Foo']); + + $this->Behavior->handleEvent($event, $entity); + $this->assertInstanceOf(DateTime::class, $entity->created); + $this->assertSame($ts->format('c'), $entity->created->format('c'), 'Created timestamp is not the same'); + } + + /** + * testCreatedPresent + * + * @triggers Model.beforeSave + */ + public function testCreatedPresent(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $event = new Event('Model.beforeSave'); + $existingValue = new NativeDateTime('2011-11-11'); + $entity = new Entity(['name' => 'Foo', 'created' => $existingValue]); + + $this->Behavior->handleEvent($event, $entity); + $this->assertSame($existingValue, $entity->created, 'Created timestamp is expected to be unchanged'); + } + + /** + * testCreatedNotNew + * + * @triggers Model.beforeSave + */ + public function testCreatedNotNew(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $event = new Event('Model.beforeSave'); + $entity = new Entity(['name' => 'Foo']); + $entity->setNew(false); + + $this->Behavior->handleEvent($event, $entity); + $this->assertNull($entity->created, 'Created timestamp is expected to be untouched if the entity is not new'); + } + + /** + * testModifiedAbsent + * + * @triggers Model.beforeSave + */ + public function testModifiedAbsent(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $event = new Event('Model.beforeSave'); + $entity = new Entity(['name' => 'Foo']); + $entity->setNew(false); + + $this->Behavior->handleEvent($event, $entity); + $this->assertInstanceOf(DateTime::class, $entity->modified); + $this->assertSame($ts->format('c'), $entity->modified->format('c'), 'Modified timestamp is not the same'); + } + + /** + * testModifiedPresent + * + * @triggers Model.beforeSave + */ + public function testModifiedPresent(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $event = new Event('Model.beforeSave'); + $existingValue = new NativeDateTime('2011-11-11'); + $entity = new Entity(['name' => 'Foo', 'modified' => $existingValue]); + $entity->clean(); + $entity->setNew(false); + + $this->Behavior->handleEvent($event, $entity); + $this->assertInstanceOf(DateTime::class, $entity->modified); + $this->assertSame($ts->format('c'), $entity->modified->format('c'), 'Modified timestamp is expected to be updated'); + } + + /** + * test that timestamp creation doesn't fail on missing columns + */ + public function testModifiedMissingColumn(): void + { + $table = $this->getTableInstance(); + $table->getSchema()->removeColumn('created')->removeColumn('modified'); + $this->Behavior = new TimestampBehavior($table); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $event = new Event('Model.beforeSave'); + $entity = new Entity(['name' => 'Foo']); + + $this->Behavior->handleEvent($event, $entity); + $this->assertNull($entity->created); + $this->assertNull($entity->modified); + } + + /** + * testTimeInstanceCreation + * + * @triggers Model.beforeSave + */ + public function testTimeInstanceCreation(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + $entity = new Entity(); + $event = new Event('Model.beforeSave'); + + $entity->clean(); + $this->Behavior->handleEvent($event, $entity); + $this->assertInstanceOf(DateTime::class, $entity->modified); + } + + /** + * tests using non-DateTimeType throws runtime exception + */ + public function testNonDateTimeTypeException(): void + { + $this->expectException(AssertionError::class); + $this->expectExceptionMessage('TimestampBehavior only supports columns of type `Cake\Database\Type\DateTimeType`.'); + + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table, [ + 'events' => [ + 'Model.beforeSave' => [ + 'timestamp_str' => 'always', + ], + ], + ]); + + $entity = new Entity(); + $event = new Event('Model.beforeSave'); + $this->Behavior->handleEvent($event, $entity); + $this->assertIsString($entity->timestamp_str); + } + + /** + * testInvalidEventConfig + * + * @triggers Model.beforeSave + */ + public function testInvalidEventConfig(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('When should be one of "always", "new" or "existing". The passed value `fat fingers` is invalid.'); + $table = $this->getTableInstance(); + $settings = ['events' => ['Model.beforeSave' => ['created' => 'fat fingers']]]; + $this->Behavior = new TimestampBehavior($table, $settings); + + $event = new Event('Model.beforeSave'); + $entity = new Entity(['name' => 'Foo']); + $this->Behavior->handleEvent($event, $entity); + } + + /** + * testGetTimestamp + */ + public function testGetTimestamp(): void + { + $table = $this->getTableInstance(); + $behavior = new TimestampBehavior($table); + + $return = $behavior->timestamp(); + $this->assertInstanceOf( + DateTime::class, + $return, + 'Should return a timestamp object', + ); + + // Compare timestamps within tolerance to avoid flaky tests during slow CI runs + $this->assertEqualsWithDelta(time(), $return->getTimestamp(), 120); + } + + /** + * testGetTimestampPersists + */ + public function testGetTimestampPersists(): void + { + $table = $this->getTableInstance(); + $behavior = new TimestampBehavior($table); + + $initialValue = $behavior->timestamp(); + $postValue = $behavior->timestamp(); + + $this->assertSame( + $initialValue, + $postValue, + 'The timestamp should be exactly the same object', + ); + } + + /** + * testGetTimestampRefreshes + */ + public function testGetTimestampRefreshes(): void + { + $table = $this->getTableInstance(); + $behavior = new TimestampBehavior($table); + + $initialValue = $behavior->timestamp(); + $postValue = $behavior->timestamp(null, true); + + $this->assertNotSame( + $initialValue, + $postValue, + 'The timestamp should be a different object if refreshTimestamp is truthy', + ); + } + + /** + * testSetTimestampExplicit + */ + public function testSetTimestampExplicit(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + + $ts = new NativeDateTime(); + $this->Behavior->timestamp($ts); + $return = $this->Behavior->timestamp(); + + $this->assertSame( + $ts->format('c'), + $return->format('c'), + 'Should return the same value as initially set', + ); + } + + /** + * testTouch + */ + public function testTouch(): void + { + $table = $this->getTableInstance(); + $this->Behavior = new TimestampBehavior($table); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $entity = new Entity(['username' => 'timestamp test']); + $return = $this->Behavior->touch($entity); + $this->assertTrue($return, 'touch is expected to return true if it sets a field value'); + $this->assertSame( + $ts->format('Y-m-d H:i:s'), + $entity->modified->format('Y-m-d H:i:s'), + 'Modified field is expected to be updated', + ); + $this->assertNull($entity->created, 'Created field is NOT expected to change'); + } + + /** + * testTouchNoop + */ + public function testTouchNoop(): void + { + $table = $this->getTableInstance(); + $config = [ + 'events' => [ + 'Model.beforeSave' => [ + 'created' => 'new', + ], + ], + ]; + + $this->Behavior = new TimestampBehavior($table, $config); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $entity = new Entity(['username' => 'timestamp test']); + $return = $this->Behavior->touch($entity); + $this->assertFalse($return, 'touch is expected to do nothing and return false'); + $this->assertNull($entity->modified, 'Modified field is NOT expected to change'); + $this->assertNull($entity->created, 'Created field is NOT expected to change'); + } + + /** + * testTouchCustomEvent + */ + public function testTouchCustomEvent(): void + { + $table = $this->getTableInstance(); + $settings = ['events' => ['Something.special' => ['date_specialed' => 'always']]]; + $this->Behavior = new TimestampBehavior($table, $settings); + $ts = new NativeDateTime('2000-01-01'); + $this->Behavior->timestamp($ts); + + $entity = new Entity(['username' => 'timestamp test']); + $return = $this->Behavior->touch($entity, 'Something.special'); + $this->assertTrue($return, 'touch is expected to return true if it sets a field value'); + $this->assertSame( + $ts->format('Y-m-d H:i:s'), + $entity->date_specialed->format('Y-m-d H:i:s'), + 'Modified field is expected to be updated', + ); + $this->assertNull($entity->created, 'Created field is NOT expected to change'); + } + + /** + * Test that calling save, triggers an insert including the created and updated field values + */ + public function testSaveTriggersInsert(): void + { + $previousTestNow = DateTime::getTestNow(); + $now = new DateTime('2026-01-01 12:00:00'); + DateTime::setTestNow($now); + + try { + $table = $this->getTableLocator()->get('users'); + $table->addBehavior('Timestamp', [ + 'events' => [ + 'Model.beforeSave' => [ + 'created' => 'new', + 'updated' => 'always', + ], + ], + ]); + + $entity = new Entity(['username' => 'timestamp test']); + $return = $table->save($entity); + $this->assertSame($entity, $return, 'The returned object is expected to be the same entity object'); + + $row = $table->find('all')->where(['id' => $entity->id])->first(); + + $this->assertSame($now->toDateTimeString(), $row->created->toDateTimeString()); + $this->assertSame($now->toDateTimeString(), $row->updated->toDateTimeString()); + } finally { + DateTime::setTestNow($previousTestNow); + } + } + + /** + * Helper method to get Table instance with created/modified column + */ + protected function getTableInstance(): Table + { + $schema = [ + 'created' => ['type' => 'datetime'], + 'modified' => ['type' => 'timestamp'], + 'date_specialed' => ['type' => 'datetime'], + 'timestamp_str' => ['type' => 'string'], + ]; + + return new Table([ + 'alias' => 'Articles', + 'schema' => $schema, + ]); + } +} diff --git a/tests/TestCase/ORM/Behavior/Translate/TranslateTraitTest.php b/tests/TestCase/ORM/Behavior/Translate/TranslateTraitTest.php new file mode 100644 index 00000000000..7c9400d078e --- /dev/null +++ b/tests/TestCase/ORM/Behavior/Translate/TranslateTraitTest.php @@ -0,0 +1,88 @@ +translation('eng')->set('title', 'My Title'); + $this->assertSame('My Title', $entity->translation('eng')->get('title')); + + $this->assertTrue($entity->isDirty('_translations')); + + $entity->translation('spa')->set('body', 'Contenido'); + $this->assertSame('My Title', $entity->translation('eng')->get('title')); + $this->assertSame('Contenido', $entity->translation('spa')->get('body')); + } + + /** + * Tests that modifying existing translation entries work + */ + public function testTranslationModify(): void + { + $entity = new TranslateTestEntity(); + $entity->set('_translations', [ + 'eng' => new Entity(['title' => 'My Title']), + 'spa' => new Entity(['title' => 'Titulo']), + ]); + $this->assertSame('My Title', $entity->translation('eng')->get('title')); + $this->assertSame('Titulo', $entity->translation('spa')->get('title')); + } + + /** + * Tests empty translations. + */ + public function testTranslationEmpty(): void + { + $entity = new TranslateTestEntity(); + $entity->set('_translations', [ + 'eng' => new Entity(['title' => 'My Title']), + 'spa' => new Entity(['title' => 'Titulo']), + ]); + $this->assertTrue($entity->translation('pol')->isNew()); + $this->assertInstanceOf(TranslateTestEntity::class, $entity->translation('pol')); + } + + /** + * Tests that just accessing the translation will mark the property as dirty, this + * is to facilitate the saving process by not having to remember to mark the property + * manually + */ + public function testTranslationDirty(): void + { + $entity = new TranslateTestEntity(); + $entity->set('_translations', [ + 'eng' => new Entity(['title' => 'My Title']), + 'spa' => new Entity(['title' => 'Titulo']), + ]); + $entity->clean(); + $this->assertSame('My Title', $entity->translation('eng')->get('title')); + $this->assertTrue($entity->isDirty('_translations')); + } +} diff --git a/tests/TestCase/ORM/Behavior/TranslateBehaviorEavTest.php b/tests/TestCase/ORM/Behavior/TranslateBehaviorEavTest.php new file mode 100644 index 00000000000..9294f48943e --- /dev/null +++ b/tests/TestCase/ORM/Behavior/TranslateBehaviorEavTest.php @@ -0,0 +1,2093 @@ + + */ + protected array $fixtures = [ + 'core.Articles', + 'core.Tags', + 'core.ArticlesTags', + 'core.Authors', + 'core.Sections', + 'core.SpecialTags', + 'core.Comments', + 'core.Translates', + ]; + + /** + * setUpBeforeClass + */ + public static function setUpBeforeClass(): void + { + TranslateBehavior::setDefaultStrategyClass(EavStrategy::class); + + parent::setUpBeforeClass(); + } + + /** + * tearDownAfterClass + */ + public static function tearDownAfterClass(): void + { + TranslateBehavior::setDefaultStrategyClass(ShadowTableStrategy::class); + + parent::tearDownAfterClass(); + } + + protected function tearDown(): void + { + parent::tearDown(); + I18n::setLocale(I18n::getDefaultLocale()); + } + + /** + * Returns an array with all the translations found for a set of records + * + * @param \Traversable|array $data + */ + protected function _extractTranslations($data): CollectionInterface + { + return (new Collection($data))->map(function (EntityInterface $row) { + $translations = $row->get('_translations'); + if (!$translations) { + return []; + } + + return array_map(function (EntityInterface $entity) { + return $entity->toArray(); + }, $translations); + }); + } + + /** + * Tests that custom translation tables are respected + */ + public function testCustomTranslationTable(): void + { + ConnectionManager::setConfig('custom_i18n_datasource', ['url' => getenv('DB_URL')]); + + $table = $this->getTableLocator()->get('Articles'); + + $table->addBehavior('Translate', [ + 'translationTable' => CustomI18nTable::class, + 'fields' => ['title', 'body'], + ]); + + $items = $table->associations(); + $i18n = $items->getByProperty('_i18n'); + + $this->assertSame('CustomI18n', $i18n->getName()); + $this->assertInstanceOf(CustomI18nTable::class, $i18n->getTarget()); + $this->assertSame('custom_i18n_datasource', $i18n->getTarget()->getConnection()->configName()); + $this->assertSame('custom_i18n_table', $i18n->getTarget()->getTable()); + + ConnectionManager::drop('custom_i18n_datasource'); + } + + /** + * Tests that the strategy can be changed for i18n + */ + public function testStrategy(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $table->addBehavior('Translate', [ + 'strategy' => 'select', + 'fields' => ['title', 'body'], + ]); + + $items = $table->associations(); + $i18n = $items->getByProperty('_i18n'); + + $this->assertSame('select', $i18n->getStrategy()); + } + + public function testComplexLocales(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + I18n::setLocale('fr@currency=EUR'); + $this->assertSame('fr', $table->getBehavior('Translate')->getLocale()); + + $table->getBehavior('Translate')->setLocale('en_US'); + $this->assertSame('en_US', $table->getBehavior('Translate')->getLocale()); + } + + /** + * Tests that fields from a translated model are overridden + */ + public function testFindSingleLocale(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + $table->getBehavior('Translate')->setLocale('eng'); + $results = $table->find()->all()->combine('title', 'body', 'id')->toArray(); + $expected = [ + 1 => ['Title #1' => 'Content #1'], + 2 => ['Title #2' => 'Content #2'], + 3 => ['Title #3' => 'Content #3'], + ]; + $this->assertSame($expected, $results); + + $entity = $table->newEntity(['author_id' => 2, 'title' => 'Title 4', 'body' => 'Body 4']); + $table->save($entity); + + $results = $table->find('all', locale: 'cze') + ->select(['id', 'title', 'body']) + ->disableHydration() + ->orderByAsc('Articles.id') + ->toArray(); + $expected = [ + ['id' => 1, 'title' => 'Titulek #1', 'body' => 'Obsah #1', '_locale' => 'cze'], + ['id' => 2, 'title' => 'Titulek #2', 'body' => 'Obsah #2', '_locale' => 'cze'], + ['id' => 3, 'title' => 'Titulek #3', 'body' => 'Obsah #3', '_locale' => 'cze'], + ['id' => 4, 'title' => null, 'body' => null, '_locale' => 'cze'], + ]; + $this->assertSame($expected, $results); + } + + /** + * Test that iterating in a formatResults() does not drop data. + */ + public function testFindTranslationsFormatResultsIteration(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('eng'); + $results = $table->find('translations') + ->limit(1) + ->formatResults(function ($results) { + foreach ($results as $res) { + $res->first = 'val'; + } + foreach ($results as $res) { + $res->second = 'loop'; + } + + return $results; + }) + ->toArray(); + $this->assertCount(1, $results); + $this->assertSame('Title #1', $results[0]->title); + $this->assertSame('val', $results[0]->first); + $this->assertSame('loop', $results[0]->second); + $this->assertNotEmpty($results[0]->_translations); + } + + /** + * Tests that fields from a translated model use the I18n class locale + * and that it propagates to associated models + */ + public function testFindSingleLocaleAssociatedEnv(): void + { + I18n::setLocale('eng'); + + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + $table->hasMany('Comments'); + $table->Comments->addBehavior('Translate', ['fields' => ['comment']]); + + $results = $table->find() + ->select(['id', 'title', 'body']) + ->contain(['Comments' => ['fields' => ['article_id', 'comment']]]) + ->enableHydration(false) + ->toArray(); + + $expected = [ + [ + 'id' => 1, + 'title' => 'Title #1', + 'body' => 'Content #1', + 'comments' => [ + ['article_id' => 1, 'comment' => 'Comment #1', '_locale' => 'eng'], + ['article_id' => 1, 'comment' => 'Comment #2', '_locale' => 'eng'], + ['article_id' => 1, 'comment' => 'Comment #3', '_locale' => 'eng'], + ['article_id' => 1, 'comment' => 'Comment #4', '_locale' => 'eng'], + ], + '_locale' => 'eng', + ], + [ + 'id' => 2, + 'title' => 'Title #2', + 'body' => 'Content #2', + 'comments' => [ + ['article_id' => 2, 'comment' => 'First Comment for Second Article', '_locale' => 'eng'], + ['article_id' => 2, 'comment' => 'Second Comment for Second Article', '_locale' => 'eng'], + ], + '_locale' => 'eng', + ], + [ + 'id' => 3, + 'title' => 'Title #3', + 'body' => 'Content #3', + 'comments' => [], + '_locale' => 'eng', + ], + ]; + $this->assertSame($expected, $results); + + I18n::setLocale('spa'); + + $results = $table->find() + ->select(['id', 'title', 'body']) + ->contain([ + 'Comments' => [ + 'fields' => ['article_id', 'comment'], + 'sort' => ['Comments.id' => 'ASC'], + ], + ]) + ->enableHydration(false) + ->orderByAsc('Articles.id') + ->toArray(); + + $expected = [ + [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'Contenido #1', + 'comments' => [ + ['article_id' => 1, 'comment' => 'First Comment for First Article', '_locale' => 'spa'], + ['article_id' => 1, 'comment' => 'Second Comment for First Article', '_locale' => 'spa'], + ['article_id' => 1, 'comment' => 'Third Comment for First Article', '_locale' => 'spa'], + ['article_id' => 1, 'comment' => 'Comentario #4', '_locale' => 'spa'], + ], + '_locale' => 'spa', + ], + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'comments' => [ + ['article_id' => 2, 'comment' => 'First Comment for Second Article', '_locale' => 'spa'], + ['article_id' => 2, 'comment' => 'Second Comment for Second Article', '_locale' => 'spa'], + ], + '_locale' => 'spa', + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'comments' => [], + '_locale' => 'spa', + ], + ]; + $this->assertSame($expected, $results); + } + + /** + * Tests that fields from a translated model are not overridden if translation + * is null + */ + public function testFindSingleLocaleWithNullTranslation(): void + { + $table = $this->getTableLocator()->get('Comments'); + $table->addBehavior('Translate', ['fields' => ['comment']]); + $table->getBehavior('Translate')->setLocale('spa'); + $results = $table->find() + ->where(['Comments.id' => 6]) + ->all() + ->combine('id', 'comment') + ->toArray(); + $expected = [6 => 'Second Comment for Second Article']; + $this->assertSame($expected, $results); + } + + /** + * Tests that overriding fields with the translate behavior works when + * using conditions and that all other columns are preserved + */ + public function testFindSingleLocaleWithgetConditions(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('eng'); + $results = $table->find() + ->where(['Articles.id' => 2]) + ->all(); + + $this->assertCount(1, $results); + $row = $results->first(); + + $expected = [ + 'id' => 2, + 'title' => 'Title #2', + 'body' => 'Content #2', + 'author_id' => 3, + 'published' => 'Y', + '_locale' => 'eng', + ]; + $this->assertEquals($expected, $row->toArray()); + } + + /** + * Tests the locale setter/getter. + */ + public function testSetGetLocale(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + + $this->assertSame('en_US', $table->getBehavior('Translate')->getLocale()); + + $table->getBehavior('Translate')->setLocale('fr_FR'); + $this->assertSame('fr_FR', $table->getBehavior('Translate')->getLocale()); + + $table->getBehavior('Translate')->setLocale(null); + $this->assertSame('en_US', $table->getBehavior('Translate')->getLocale()); + + I18n::setLocale('fr_FR'); + $this->assertSame('fr_FR', $table->getBehavior('Translate')->getLocale()); + } + + /** + * Tests translationField method for translated fields. + */ + public function testTranslationFieldForTranslatedFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'defaultLocale' => 'en_US', + ]); + + $expectedSameLocale = 'Articles.title'; + $expectedOtherLocale = 'Articles_title_translation.content'; + + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedSameLocale, $field); + + I18n::setLocale('es_ES'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + + I18n::setLocale('en'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + + $table->removeBehavior('Translate'); + + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'defaultLocale' => 'de_DE', + ]); + + I18n::setLocale('de_DE'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedSameLocale, $field); + + I18n::setLocale('en_US'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + + $table->getBehavior('Translate')->setLocale('de_DE'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedSameLocale, $field); + + $table->getBehavior('Translate')->setLocale('es'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + } + + /** + * Tests translationField method for other fields. + */ + public function testTranslationFieldForOtherFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + $expected = 'Articles.foo'; + $field = $table->getBehavior('Translate')->translationField('foo'); + $this->assertSame($expected, $field); + } + + /** + * Tests that translating fields work when other formatters are used + */ + public function testFindList(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('eng'); + + $results = $table->find('list')->toArray(); + $expected = [1 => 'Title #1', 2 => 'Title #2', 3 => 'Title #3']; + $this->assertSame($expected, $results); + } + + /** + * Tests that the query count return the correct results + */ + public function testFindCount(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('eng'); + + $this->assertSame(3, $table->find()->count()); + } + + /** + * Tests that it is possible to get all translated fields at once + */ + public function testFindTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $results = $table->find('translations'); + $expected = [ + [ + 'eng' => ['title' => 'Title #1', 'body' => 'Content #1', 'description' => 'Description #1', 'locale' => 'eng'], + 'deu' => ['title' => 'Titel #1', 'body' => 'Inhalt #1', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #1', 'body' => 'Obsah #1', 'locale' => 'cze'], + 'spa' => ['body' => 'Contenido #1', 'locale' => 'spa', 'description' => ''], + ], + [ + 'eng' => ['title' => 'Title #2', 'body' => 'Content #2', 'locale' => 'eng'], + 'deu' => ['title' => 'Titel #2', 'body' => 'Inhalt #2', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #2', 'body' => 'Obsah #2', 'locale' => 'cze'], + ], + [ + 'eng' => ['title' => 'Title #3', 'body' => 'Content #3', 'locale' => 'eng'], + 'deu' => ['title' => 'Titel #3', 'body' => 'Inhalt #3', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #3', 'body' => 'Obsah #3', 'locale' => 'cze'], + ], + ]; + + $translations = $this->_extractTranslations($results); + $this->assertEquals($expected, $translations->toArray()); + $expected = [ + 1 => ['First Article' => 'First Article Body'], + 2 => ['Second Article' => 'Second Article Body'], + 3 => ['Third Article' => 'Third Article Body'], + ]; + + $grouped = $results->all()->combine('title', 'body', 'id'); + $this->assertEquals($expected, $grouped->toArray()); + + $entity = $table->newEntity(['title' => 'Fourth Title']); + $table->save($entity); + + $expected = [[]]; + $result = $table->find('translations')->where(['Articles.id' => $entity->id])->all(); + $this->assertEquals($expected, $this->_extractTranslations($result)->toArray()); + + $entity = $result->first(); + $this->assertSame('Fourth Title', $entity->title); + } + + /** + * Tests that it is possible to request just a few translations + */ + public function testFindFilteredTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $results = $table->find('translations', locales: ['deu', 'cze']); + $expected = [ + [ + 'deu' => ['title' => 'Titel #1', 'body' => 'Inhalt #1', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #1', 'body' => 'Obsah #1', 'locale' => 'cze'], + ], + [ + 'deu' => ['title' => 'Titel #2', 'body' => 'Inhalt #2', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #2', 'body' => 'Obsah #2', 'locale' => 'cze'], + ], + [ + 'deu' => ['title' => 'Titel #3', 'body' => 'Inhalt #3', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #3', 'body' => 'Obsah #3', 'locale' => 'cze'], + ], + ]; + + $translations = $this->_extractTranslations($results); + $this->assertEquals($expected, $translations->toArray()); + + $expected = [ + 1 => ['First Article' => 'First Article Body'], + 2 => ['Second Article' => 'Second Article Body'], + 3 => ['Third Article' => 'Third Article Body'], + ]; + + $grouped = $results->all()->combine('title', 'body', 'id'); + $this->assertEquals($expected, $grouped->toArray()); + } + + /** + * Tests that it is possible to combine find('list') and find('translations') + */ + public function testFindTranslationsList(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $results = $table + ->find( + 'list', + keyField: 'title', + valueField: '_translations.deu.title', + groupField: 'id', + ) + ->find('translations', locales: ['deu']); + + $expected = [ + 1 => ['First Article' => 'Titel #1'], + 2 => ['Second Article' => 'Titel #2'], + 3 => ['Third Article' => 'Titel #3'], + ]; + $this->assertEquals($expected, $results->toArray()); + } + + /** + * Tests that you can both override fields and find all translations + */ + public function testFindTranslationsWithFieldOverriding(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('cze'); + $results = $table->find('translations', locales: ['deu', 'cze']); + $expected = [ + [ + 'deu' => ['title' => 'Titel #1', 'body' => 'Inhalt #1', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #1', 'body' => 'Obsah #1', 'locale' => 'cze'], + ], + [ + 'deu' => ['title' => 'Titel #2', 'body' => 'Inhalt #2', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #2', 'body' => 'Obsah #2', 'locale' => 'cze'], + ], + [ + 'deu' => ['title' => 'Titel #3', 'body' => 'Inhalt #3', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #3', 'body' => 'Obsah #3', 'locale' => 'cze'], + ], + ]; + + $translations = $this->_extractTranslations($results); + $this->assertEquals($expected, $translations->toArray()); + + $expected = [ + 1 => ['Titulek #1' => 'Obsah #1'], + 2 => ['Titulek #2' => 'Obsah #2'], + 3 => ['Titulek #3' => 'Obsah #3'], + ]; + + $grouped = $results->all()->combine('title', 'body', 'id'); + $this->assertEquals($expected, $grouped->toArray()); + } + + /** + * Tests that fields can be overridden in a hasMany association + */ + public function testFindSingleLocaleHasMany(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->hasMany('Comments'); + $comments = $table->associations()->get('Comments')->getTarget(); + $comments->addBehavior('Translate', ['fields' => ['comment']]); + + $table->getBehavior('Translate')->setLocale('eng'); + $comments->getBehavior('Translate')->setLocale('eng'); + + $results = $table->find()->contain(['Comments' => function ($q) { + return $q->select(['id', 'comment', 'article_id']); + }]); + + $list = new Collection($results->first()->comments); + $expected = [ + 1 => 'Comment #1', + 2 => 'Comment #2', + 3 => 'Comment #3', + 4 => 'Comment #4', + ]; + $this->assertEquals($expected, $list->combine('id', 'comment')->toArray()); + } + + /** + * Test that it is possible to bring translations from hasMany relations + */ + public function testTranslationsHasMany(): void + { + // This test fails on mysql8 + php8 due to no data in the tables + // We have been unable to explain the behavior so disabling for now + $driver = ConnectionManager::get('test')->getDriver(); + $this->skipIf( + $driver instanceof Mysql && + version_compare($driver->version(), '8.0.0', '>='), + ); + + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->hasMany('Comments'); + $comments = $table->associations()->get('Comments')->getTarget(); + $comments->addBehavior('Translate', ['fields' => ['comment']]); + + $results = $table->find('translations')->contain([ + 'Comments' => function ($q) { + return $q->find('translations')->select(['id', 'comment', 'article_id']); + }, + ]); + + $comments = $results->first()->comments; + $expected = [ + [ + 'eng' => ['comment' => 'Comment #1', 'locale' => 'eng'], + ], + [ + 'eng' => ['comment' => 'Comment #2', 'locale' => 'eng'], + ], + [ + 'eng' => ['comment' => 'Comment #3', 'locale' => 'eng'], + ], + [ + 'eng' => ['comment' => 'Comment #4', 'locale' => 'eng'], + 'spa' => ['comment' => 'Comentario #4', 'locale' => 'spa'], + ], + ]; + + $translations = $this->_extractTranslations($comments); + $this->assertEquals($expected, $translations->toArray()); + } + + /** + * Tests that it is possible to both override fields with a translation and + * also find separately other translations + */ + public function testTranslationsHasManyWithOverride(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->hasMany('Comments'); + $comments = $table->associations()->get('Comments')->getTarget(); + $comments->addBehavior('Translate', ['fields' => ['comment']]); + + $table->getBehavior('Translate')->setLocale('cze'); + $comments->getBehavior('Translate')->setLocale('eng'); + $results = $table->find('translations')->contain([ + 'Comments' => function ($q) { + return $q->find('translations')->select(['id', 'comment', 'article_id']); + }, + ]); + + $comments = $results->first()->comments; + $expected = [ + 1 => 'Comment #1', + 2 => 'Comment #2', + 3 => 'Comment #3', + 4 => 'Comment #4', + ]; + $list = new Collection($comments); + $this->assertEquals($expected, $list->combine('id', 'comment')->toArray()); + + $expected = [ + [ + 'eng' => ['comment' => 'Comment #1', 'locale' => 'eng'], + ], + [ + 'eng' => ['comment' => 'Comment #2', 'locale' => 'eng'], + ], + [ + 'eng' => ['comment' => 'Comment #3', 'locale' => 'eng'], + ], + [ + 'eng' => ['comment' => 'Comment #4', 'locale' => 'eng'], + 'spa' => ['comment' => 'Comentario #4', 'locale' => 'spa'], + ], + ]; + $translations = $this->_extractTranslations($comments); + $this->assertEquals($expected, $translations->toArray()); + + $this->assertSame('Titulek #1', $results->first()->title); + $this->assertSame('Obsah #1', $results->first()->body); + } + + /** + * Tests that it is possible to translate belongsTo associations + */ + public function testFindSingleLocaleBelongsto(): void + { + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $table */ + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $authors */ + $authors = $table->belongsTo('Authors')->getTarget(); + $authors->addBehavior('Translate', ['fields' => ['name']]); + + $table->getBehavior('Translate')->setLocale('eng'); + $authors->getBehavior('Translate')->setLocale('eng'); + + $results = $table->find() + ->select(['title', 'body']) + ->orderBy(['title' => 'asc']) + ->contain(['Authors' => function (SelectQuery $q) { + return $q->select(['id', 'name']); + }]); + + $expected = [ + [ + 'title' => 'Title #1', + 'body' => 'Content #1', + 'author' => ['id' => 1, 'name' => 'May-rianoh', '_locale' => 'eng'], + '_locale' => 'eng', + ], + [ + 'title' => 'Title #2', + 'body' => 'Content #2', + 'author' => ['id' => 3, 'name' => 'larry', '_locale' => 'eng'], + '_locale' => 'eng', + ], + [ + 'title' => 'Title #3', + 'body' => 'Content #3', + 'author' => ['id' => 1, 'name' => 'May-rianoh', '_locale' => 'eng'], + '_locale' => 'eng', + ], + ]; + $results = array_map(function (EntityInterface $r) { + return $r->toArray(); + }, $results->toArray()); + $this->assertEquals($expected, $results); + } + + /** + * Tests that it is possible to translate belongsTo associations using loadInto + */ + public function testFindSingleLocaleBelongstoLoadInto(): void + { + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $table */ + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $authors */ + $authors = $table->belongsTo('Authors')->getTarget(); + $authors->addBehavior('Translate', ['fields' => ['name']]); + + $table->getBehavior('Translate')->setLocale('eng'); + $authors->getBehavior('Translate')->setLocale('eng'); + + $entity = $table->get(1); + $result = $table->loadInto($entity, ['Authors']); + $this->assertSame($entity, $result); + $this->assertNotEmpty($entity->author); + $this->assertNotEmpty($entity->author->name); + + $expected = $table->get(1, ...['contain' => ['Authors']]); + $this->assertEqualsCanonicalizing($expected->toArray(), $result->toArray()); + $this->assertNotEmpty($entity->author); + $this->assertNotEmpty($entity->author->name); + } + + /** + * Tests that it is possible to translate belongsToMany associations + */ + public function testFindSingleLocaleBelongsToMany(): void + { + $table = $this->getTableLocator()->get('Articles'); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $specialTags */ + $specialTags = $this->getTableLocator()->get('SpecialTags'); + $specialTags->addBehavior('Translate', ['fields' => ['extra_info']]); + + $table->belongsToMany('Tags', [ + 'through' => $specialTags, + ]); + $specialTags->getBehavior('Translate')->setLocale('eng'); + + $result = $table->get(2, ...['contain' => 'Tags']); + $this->assertNotEmpty($result); + $this->assertNotEmpty($result->tags); + $this->assertSame('Translated Info', $result->tags[0]->special_tags[0]->extra_info); + } + + /** + * Tests that parent entity isn't dirty when containing a translated association + */ + public function testGetAssociationNotDirtyBelongsTo(): void + { + $table = $this->getTableLocator()->get('Articles'); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $authors */ + $authors = $table->belongsTo('Authors')->getTarget(); + $authors->addBehavior('Translate', ['fields' => ['name']]); + + $authors->getBehavior('Translate')->setLocale('eng'); + + $entity = $table->get(1); + $this->assertNotEmpty($entity); + $entity = $table->loadInto($entity, ['Authors']); + $this->assertFalse($entity->isDirty()); + $this->assertNotEmpty($entity->author); + $this->assertFalse($entity->author->isDirty()); + + $entity = $table->get(1, ...['contain' => ['Authors']]); + $this->assertNotEmpty($entity); + $this->assertFalse($entity->isDirty()); + $this->assertNotEmpty($entity->author); + $this->assertFalse($entity->author->isDirty()); + } + + /** + * Tests that parent entity isn't dirty when containing a translated association + */ + public function testGetAssociationNotDirtyHasOne(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasOne('Articles'); + $table->Articles->addBehavior('Translate', ['fields' => ['title']]); + + $entity = $table->get(1); + $this->assertNotEmpty($entity); + $entity = $table->loadInto($entity, ['Articles']); + $this->assertFalse($entity->isDirty()); + $this->assertNotEmpty($entity->article); + $this->assertFalse($entity->article->isDirty()); + + $entity = $table->get(1, ...['contain' => 'Articles']); + $this->assertNotEmpty($entity); + $this->assertFalse($entity->isDirty()); + $this->assertNotEmpty($entity->article); + $this->assertFalse($entity->article->isDirty()); + } + + /** + * Tests that updating an existing record translations work + */ + public function testUpdateSingleLocale(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('eng'); + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $article->set('title', 'New translated article'); + $table->save($article); + $this->assertNull($article->get('_i18n')); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $this->assertSame('New translated article', $article->get('title')); + $this->assertSame('Content #1', $article->get('body')); + + $table->getBehavior('Translate')->setLocale(null); + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $this->assertSame('First Article', $article->get('title')); + + $table->getBehavior('Translate')->setLocale('eng'); + $article->set('title', 'Wow, such translated article'); + $article->set('body', 'A translated body'); + $table->save($article); + $this->assertNull($article->get('_i18n')); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $this->assertSame('Wow, such translated article', $article->get('title')); + $this->assertSame('A translated body', $article->get('body')); + } + + /** + * Tests adding new translation to a record + */ + public function testInsertNewTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('fra'); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $article->set('title', 'Le titre'); + $table->save($article); + $this->assertSame('fra', $article->get('_locale')); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $this->assertSame('Le titre', $article->get('title')); + $this->assertSame('First Article Body', $article->get('body')); + + $article->set('title', 'Un autre titre'); + $article->set('body', 'Le contenu'); + $table->save($article); + $this->assertNull($article->get('_i18n')); + + $article = $table->find()->first(); + $this->assertSame('Un autre titre', $article->get('title')); + $this->assertSame('Le contenu', $article->get('body')); + } + + /** + * Tests adding new translation to a record + */ + public function testAllowEmptyFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => '', + ], + ], + ]); + + $table->save($article); + + $noFra = $table->I18n->find()->where(['locale' => 'fra'])->first(); + $this->assertEmpty($noFra); + + $article = $table->find()->where(['id' => 2])->first(); + + $this->assertSame('Second Article', $article->get('title')); + $table->patchEntity($article, ['title' => 'Second Article updated']); + + $this->assertNotFalse($table->save($article)); + } + + /** + * Tests adding new translation to a record with a missing translation + */ + public function testAllowEmptyFalseWithNull(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'description'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => 'Title', + ], + ], + ]); + + $table->save($article); + + // Remove the Behavior to unset the content != '' condition + $table->removeBehavior('Translate'); + + $fra = $table->I18n->find()->where(['locale' => 'fra'])->first(); + $this->assertNotEmpty($fra); + } + + /** + * Tests adding new translation to a record + */ + public function testMixedAllowEmptyFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => '', + 'body' => 'Bonjour', + ], + ], + ]); + + $table->save($article); + + $fra = $table->I18n->find() + ->where([ + 'locale' => 'fra', + 'field' => 'body', + ]) + ->first(); + $this->assertSame('Bonjour', $fra->content); + + // Remove the Behavior to unset the content != '' condition + $table->removeBehavior('Translate'); + + $noTitle = $table->I18n->find() + ->where([ + 'locale' => 'fra', + 'field' => 'title', + ]) + ->first(); + $this->assertEmpty($noTitle); + } + + /** + * Tests adding new translation to a record + */ + public function testMultipleAllowEmptyFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => '', + 'body' => 'Bonjour', + ], + 'de' => [ + 'title' => 'Titel', + 'body' => 'Hallo', + ], + ], + ]); + + $table->save($article); + + $fra = $table->I18n->find() + ->where([ + 'locale' => 'fra', + 'field' => 'body', + ]) + ->first(); + $this->assertSame('Bonjour', $fra->content); + + $deTitle = $table->I18n->find() + ->where([ + 'locale' => 'de', + 'field' => 'title', + ]) + ->first(); + $this->assertSame('Titel', $deTitle->content); + + $deBody = $table->I18n->find() + ->where([ + 'locale' => 'de', + 'field' => 'body', + ]) + ->first(); + $this->assertSame('Hallo', $deBody->content); + + // Remove the Behavior to unset the content != '' condition + $table->removeBehavior('Translate'); + + $noTitle = $table->I18n->find() + ->where([ + 'locale' => 'fra', + 'field' => 'title', + ]) + ->first(); + $this->assertEmpty($noTitle); + } + + /** + * Tests that it is possible to use the _locale property to specify the language + * to use for saving an entity + */ + public function testUpdateTranslationWithLocaleInEntity(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $article->set('_locale', 'fra'); + $article->set('title', 'Le titre'); + $table->save($article); + $this->assertNull($article->get('_i18n')); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $this->assertSame('First Article', $article->get('title')); + $this->assertSame('First Article Body', $article->get('body')); + + $table->getBehavior('Translate')->setLocale('fra'); + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $this->assertSame('Le titre', $article->get('title')); + $this->assertSame('First Article Body', $article->get('body')); + } + + /** + * Tests that translations are added to the whitelist of associations to be + * saved + */ + public function testSaveTranslationWithAssociationWhitelist(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('fra'); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + $article->set('title', 'Le titre'); + $table->save($article, ['associated' => ['Comments']]); + $this->assertNull($article->get('_i18n')); + + $article = $table->find()->first(); + $this->assertSame('Le titre', $article->get('title')); + } + + /** + * Tests that after deleting a translated entity, all translations are also removed + */ + public function testDelete(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $article = $table->find()->first(); + $this->assertTrue($table->delete($article)); + + $translations = $this->getTableLocator()->get('I18n')->find() + ->where(['model' => 'Articles', 'foreign_key' => $article->id]) + ->count(); + $this->assertSame(0, $translations); + } + + /** + * Tests saving multiple translations at once when the translations already + * exist in the database + */ + public function testSaveMultipleTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $article = $table->find('translations')->first(); + + $translations = $article->get('_translations'); + $translations['deu']->set('title', 'Another title'); + $translations['eng']->set('body', 'Another body'); + $article->set('_translations', $translations); + $table->save($article); + $this->assertNull($article->get('_i18n')); + $article = $table->find('translations')->first(); + $translations = $article->get('_translations'); + $this->assertSame('Another title', $translations['deu']->get('title')); + $this->assertSame('Another body', $translations['eng']->get('body')); + } + + /** + * Tests saving multiple existing translations and adding new ones + */ + public function testSaveMultipleNewTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $article = $table->find('translations')->first(); + + $translations = $article->get('_translations'); + $translations['deu']->set('title', 'Another title'); + $translations['eng']->set('body', 'Another body'); + $translations['spa'] = new Entity(['title' => 'Titulo']); + $translations['fre'] = new Entity(['title' => 'Titre']); + $article->set('_translations', $translations); + $table->save($article); + $this->assertNull($article->get('_i18n')); + $article = $table->find('translations')->first(); + $translations = $article->get('_translations'); + $this->assertSame('Another title', $translations['deu']->get('title')); + $this->assertSame('Another body', $translations['eng']->get('body')); + $this->assertSame('Titulo', $translations['spa']->get('title')); + $this->assertSame('Titre', $translations['fre']->get('title')); + } + + /** + * Tests that iterating a result set twice when using the translations finder + * will not cause any errors nor information loss + */ + public function testUseCountInFindTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $articles = $table->find('translations'); + $all = $articles->all(); + $this->assertCount(3, $all); + $article = $all->first(); + $this->assertNotEmpty($article->get('_translations')); + } + + /** + * Tests that multiple translations saved when having a default locale + * are correctly saved + */ + public function testSavingWithNonDefaultLocale(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->setEntityClass(TranslateArticle::class); + I18n::setLocale('fra'); + $translations = [ + 'fra' => ['title' => 'Un article'], + 'spa' => ['title' => 'Un artículo'], + ]; + + $article = $table->get(1); + foreach ($translations as $lang => $data) { + $article->translation($lang)->patch($data, ['guard' => false]); + } + + $table->save($article); + $article = $table->find('translations')->where(['Articles.id' => 1])->first(); + $this->assertSame('Un article', $article->translation('fra')->title); + $this->assertSame('Un artículo', $article->translation('spa')->title); + } + + /** + * Tests that translation queries are added to union queries as well. + */ + public function testTranslationWithUnionQuery(): void + { + $table = $this->getTableLocator()->get('Comments'); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $table */ + $table->addBehavior('Translate', ['fields' => ['comment']]); + $table->getBehavior('Translate')->setLocale('spa'); + $query = $table->find()->where(['Comments.id' => 6]); + $query2 = $table->find()->where(['Comments.id' => 5]); + $query->union($query2); + $results = $query->all()->sortBy('id', SORT_ASC)->toList(); + $this->assertCount(2, $results); + + $this->assertSame('First Comment for Second Article', $results[0]->comment); + $this->assertSame('Second Comment for Second Article', $results[1]->comment); + } + + /** + * Tests the use of `referenceName` config option. + */ + public function testAutoReferenceName(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $table->hasMany('OtherComments', ['className' => 'Comments']); + $table->OtherComments->addBehavior( + 'Translate', + ['fields' => ['comment']], + ); + + $items = $table->OtherComments->associations(); + $association = $items->getByProperty('comment_translation'); + $this->assertNotEmpty($association, 'Translation association not found'); + + $found = false; + foreach ($association->getConditions() as $key => $value) { + if (str_contains($key, 'comment_translation.model')) { + $found = true; + $this->assertSame('Comments', $value); + break; + } + } + + $this->assertTrue($found, '`referenceName` field condition on a Translation association was not found'); + } + + /** + * Tests the use of unconventional `referenceName` config option. + */ + public function testChangingReferenceName(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->setAlias('FavoritePost'); + $table->addBehavior( + 'Translate', + ['fields' => ['body'], 'referenceName' => 'Posts'], + ); + + $items = $table->associations(); + $association = $items->getByProperty('body_translation'); + $this->assertNotEmpty($association, 'Translation association not found'); + + $found = false; + foreach ($association->getConditions() as $key => $value) { + if (str_contains($key, 'body_translation.model')) { + $found = true; + $this->assertSame('Posts', $value); + break; + } + } + + $this->assertTrue($found, '`referenceName` field condition on a Translation association was not found'); + } + + /** + * Tests that onlyTranslated will remove records from the result set + * if they are not fully translated + */ + public function testFilterUntranslated(): void + { + $table = $this->getTableLocator()->get('Articles'); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $table */ + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'onlyTranslated' => true, + ]); + $table->getBehavior('Translate')->setLocale('eng'); + $results = $table->find()->where(['Articles.id' => 1])->all(); + $this->assertCount(1, $results); + + $table->getBehavior('Translate')->setLocale('fr'); + $results = $table->find()->where(['Articles.id' => 1])->all(); + $this->assertCount(0, $results); + } + + /** + * Tests that records not translated in the current locale will not be + * present in the results for the translations finder, and also proves + * that this can be overridden. + */ + public function testFilterUntranslatedWithFinder(): void + { + $table = $this->getTableLocator()->get('Comments'); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $table */ + $table->addBehavior('Translate', [ + 'fields' => ['comment'], + 'onlyTranslated' => true, + ]); + $table->getBehavior('Translate')->setLocale('eng'); + $results = $table->find('translations')->all(); + $this->assertCount(4, $results); + + $table->getBehavior('Translate')->setLocale('spa'); + $results = $table->find('translations')->all(); + $this->assertCount(1, $results); + + $table->getBehavior('Translate')->setLocale('spa'); + $results = $table->find('translations', filterByCurrentLocale: false)->all(); + $this->assertCount(6, $results); + + $table->getBehavior('Translate')->setLocale('spa'); + $results = $table->find('translations')->all(); + $this->assertCount(1, $results); + } + + /** + * Tests that allowEmptyTranslations takes effect + */ + public function testEmptyTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + /** @var \Cake\ORM\Table|\Cake\ORM\Behavior\TranslateBehavior $table */ + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body', 'description'], + 'allowEmptyTranslations' => false, + ]); + $table->getBehavior('Translate')->setLocale('spa'); + $result = $table->find()->first(); + $this->assertNull($result->description); + } + + /** + * Test save with clean translate fields + */ + public function testSaveWithCleanFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title']]); + $table->setEntityClass(TranslateArticle::class); + I18n::setLocale('fra'); + $article = $table->get(1); + $article->set('body', 'New Body'); + $table->save($article); + $result = $table->get(1); + $this->assertSame('New Body', $result->body); + $this->assertSame($article->title, $result->title); + } + + /** + * Test save new entity with _translations field + */ + public function testSaveNewRecordWithTranslatesField(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->getValidator()->add('title', 'notBlank', ['rule' => 'notBlank']); + $table->addBehavior('Translate', [ + 'defaultLocale' => 'en', + 'fields' => ['title'], + ]); + $table->setEntityClass(TranslateArticle::class); + + $article = $table->patchEntity( + $table->newEmptyEntity(), + [ + '_translations' => ['en' => ['title' => '']], + ], + ); + $this->assertSame( + ['notBlank' => 'The provided value is invalid'], + $article->getError('title'), + ); + + $data = [ + 'author_id' => 1, + 'published' => 'N', + '_translations' => [ + 'en' => [ + 'title' => 'Title EN', + 'body' => 'Body EN', + ], + 'es' => [ + 'title' => 'Title ES', + ], + 'fr' => [ + 'title' => 'Title FR', + ], + ], + ]; + + $article = $table->patchEntity($table->newEmptyEntity(), $data); + $result = $table->save($article); + + $this->assertNotFalse($result); + + $expected = [ + [ + 'fr' => [ + 'title' => 'Title FR', + 'locale' => 'fr', + ], + 'es' => [ + 'title' => 'Title ES', + 'locale' => 'es', + ], + ], + ]; + $result = $table->find('translations')->where(['Articles.id' => $result->id])->all(); + $this->assertEquals($expected, $this->_extractTranslations($result)->toArray()); + + $entity = $result->first(); + $this->assertSame('Title EN', $entity->title); + $this->assertSame('Body EN', $entity->body); + + $data = [ + 'title' => 'New title', + 'author_id' => 1, + 'published' => 'N', + '_translations' => null, + ]; + + $article = $table->patchEntity($table->newEmptyEntity(), $data); + $result = $table->save($article); + + $this->assertNotFalse($result); + } + + /** + * Tests adding new translation to a record where the only field is the translated one and it's not the default locale + */ + public function testSaveNewRecordWithOnlyTranslationsNotDefaultLocale(): void + { + $table = $this->getTableLocator()->get('Sections'); + $table->getValidator()->add('title', 'notBlank', ['rule' => 'notBlank']); + $table->addBehavior('Translate', [ + 'fields' => ['title'], + ]); + + $data = [ + '_translations' => [ + 'es' => [ + 'title' => 'Title ES', + ], + ], + ]; + + $group = $table->newEntity($data); + $result = $table->save($group); + $this->assertNotFalse($result, 'Record should save.'); + + $expected = [ + [ + 'es' => [ + 'title' => 'Title ES', + 'locale' => 'es', + ], + ], + ]; + $result = $table->find('translations')->where(['id' => $result->id]); + $this->assertEquals($expected, $this->_extractTranslations($result)->toArray()); + } + + /** + * Test that existing records can be updated when only translations + * are modified/dirty. + */ + public function testSaveExistingRecordOnlyTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->setEntityClass(TranslateArticle::class); + + $data = [ + '_translations' => [ + 'es' => [ + 'title' => 'Spanish Translation', + ], + ], + ]; + + $article = $table->find()->first(); + $article = $table->patchEntity($article, $data); + + $this->assertNotFalse($table->save($article)); + + $results = $this->_extractTranslations( + $table->find('translations')->where(['id' => 1]), + )->first(); + + $this->assertArrayHasKey('es', $results, 'New translation added'); + $this->assertArrayHasKey('eng', $results, 'Old translations present'); + $this->assertSame('Spanish Translation', $results['es']['title']); + } + + /** + * Test update entity with _translations field. + */ + public function testSaveExistingRecordWithTranslatesField(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->setEntityClass(TranslateArticle::class); + + $data = [ + 'author_id' => 1, + 'published' => 'Y', + '_translations' => [ + 'eng' => [ + 'title' => 'First Article1', + 'body' => 'First Article content has been updated', + ], + 'spa' => [ + 'title' => 'Mi nuevo titulo', + 'body' => 'Contenido Actualizado', + ], + ], + ]; + + $article = $table->find()->first(); + $article = $table->patchEntity($article, $data); + + $this->assertNotFalse($table->save($article)); + + $results = $this->_extractTranslations( + $table->find('translations')->where(['id' => 1]), + )->first(); + + $this->assertSame('Mi nuevo titulo', $results['spa']['title']); + $this->assertSame('Contenido Actualizado', $results['spa']['body']); + + $this->assertSame('First Article1', $results['eng']['title']); + $this->assertSame('Description #1', $results['eng']['description']); + } + + /** + * Tests that default locale saves ok. + */ + public function testSaveDefaultLocale(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + $article = $table->get(1); + $data = [ + 'title' => 'New title', + 'body' => 'New body', + ]; + $article = $table->patchEntity($article, $data); + $table->save($article); + $this->assertNull($article->get('_i18n')); + + $article = $table->get(1); + $this->assertSame('New title', $article->get('title')); + $this->assertSame('New body', $article->get('body')); + } + + /** + * Test that when `defaultLocale` feature is disabled translations table + * is always used. + */ + public function testSaveDefaultLocaleFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'defaultLocale' => '', + 'fields' => ['title', 'body'], + ]); + + $data = [ + 'title' => 'New title', + 'body' => 'New body', + 'published' => 'Y', + ]; + $article = $table->newEntity($data); + $result = $table->save($article); + $this->assertNotEmpty($result); + + $record = $table->get($article->id); + $this->assertSame($data['title'], $record->title); + $this->assertSame($data['body'], $record->body); + + $table->removeBehavior('Translate'); + $record = $table->get($article->id); + $this->assertEmpty($record->title); + $this->assertEmpty($record->body); + + $article->title = 'updated title'; + $table->addBehavior('Translate', [ + 'defaultLocale' => '', + 'fields' => ['title', 'body'], + ]); + $result = $table->save($article); + $this->assertNotEmpty($result); + + $record = $table->get($article->id); + $this->assertSame('updated title', $record->title); + + $table->removeBehavior('Translate'); + $record = $table->get($article->id); + $this->assertEmpty($record->title); + } + + /** + * Tests that translations are added to the whitelist of associations to be + * saved + */ + public function testSaveTranslationDefaultLocale(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + $article = $table->get(1); + $data = [ + 'title' => 'New title', + 'body' => 'New body', + '_translations' => [ + 'es' => [ + 'title' => 'ES title', + 'body' => 'ES body', + ], + ], + ]; + $article = $table->patchEntity($article, $data); + $table->save($article); + $this->assertNull($article->get('_i18n')); + + $article = $table->find('translations')->where(['id' => 1])->first(); + $this->assertSame('New title', $article->get('title')); + $this->assertSame('New body', $article->get('body')); + + $this->assertSame('ES title', $article->_translations['es']->title); + $this->assertSame('ES body', $article->_translations['es']->body); + } + + /** + * Test that no properties are enabled when the translations + * option is off. + */ + public function testBuildMarshalMapTranslationsOff(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + $marshaller = $table->marshaller(); + $translate = $table->behaviors()->get('Translate'); + $result = $translate->buildMarshalMap($marshaller, [], ['translations' => false]); + $this->assertSame([], $result); + } + + /** + * Test building a marshal map with translations on. + */ + public function testBuildMarshalMapTranslationsOn(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $marshaller = $table->marshaller(); + $translate = $table->behaviors()->get('Translate'); + + $result = $translate->buildMarshalMap($marshaller, [], ['translations' => true]); + $this->assertArrayHasKey('_translations', $result); + $this->assertInstanceOf('Closure', $result['_translations']); + + $result = $translate->buildMarshalMap($marshaller, [], []); + $this->assertArrayHasKey('_translations', $result); + $this->assertInstanceOf('Closure', $result['_translations']); + } + + /** + * Test marshalling non-array data + */ + public function testBuildMarshalMapNonArrayData(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $translate = $table->behaviors()->get('Translate'); + + $map = $translate->buildMarshalMap($table->marshaller(), [], []); + $entity = $table->newEmptyEntity(); + $result = $map['_translations']('garbage', $entity); + $this->assertNull($result, 'Non-array should not error out.'); + $this->assertEmpty($entity->getErrors()); + $this->assertEmpty($entity->get('_translations')); + } + + /** + * Test buildMarshalMap() builds new entities. + */ + public function testBuildMarshalMapBuildEntities(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $translate = $table->behaviors()->get('Translate'); + + $map = $translate->buildMarshalMap($table->marshaller(), [], []); + $entity = $table->newEmptyEntity(); + $data = [ + 'en' => [ + 'title' => 'English Title', + 'body' => 'English Content', + ], + 'es' => [ + 'title' => 'Titulo Español', + 'body' => 'Contenido Español', + ], + ]; + $result = $map['_translations']($data, $entity); + $this->assertEmpty($entity->getErrors(), 'No validation errors.'); + $this->assertCount(2, $result); + $this->assertArrayHasKey('en', $result); + $this->assertArrayHasKey('es', $result); + $this->assertSame('English Title', $result['en']->title); + $this->assertSame('Titulo Español', $result['es']->title); + } + + /** + * Test that validation errors are added to the original entity. + */ + public function testBuildMarshalMapBuildEntitiesValidationErrors(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'validator' => 'custom', + ]); + $validator = (new Validator())->notEmptyString('title'); + $table->setValidator('custom', $validator); + $translate = $table->behaviors()->get('Translate'); + + $entity = $table->newEmptyEntity(); + $map = $translate->buildMarshalMap($table->marshaller(), [], []); + $data = [ + 'en' => [ + 'title' => 'English Title', + 'body' => 'English Content', + ], + 'es' => [ + 'title' => '', + 'body' => 'Contenido Español', + ], + ]; + $result = $map['_translations']($data, $entity); + $this->assertNotEmpty($entity->getErrors(), 'Needs validation errors.'); + $expected = [ + 'title' => [ + '_empty' => 'This field cannot be left empty', + ], + ]; + $this->assertEquals($expected, $entity->getError('_translations.es')); + + $this->assertSame('English Title', $result['en']->title); + $this->assertNull($result['es']->title); + } + + /** + * Test that marshalling updates existing translation entities. + */ + public function testBuildMarshalMapUpdateExistingEntities(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + ]); + $translate = $table->behaviors()->get('Translate'); + + $entity = $table->newEmptyEntity(); + $es = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']); + $en = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']); + $entity->set('_translations', [ + 'es' => $es, + 'en' => $en, + ]); + $map = $translate->buildMarshalMap($table->marshaller(), [], []); + $data = [ + 'en' => [ + 'title' => 'English Title', + ], + 'es' => [ + 'title' => 'Spanish Title', + ], + ]; + $result = $map['_translations']($data, $entity); + $this->assertEmpty($entity->getErrors(), 'No validation errors.'); + $this->assertSame($en, $result['en']); + $this->assertSame($es, $result['es']); + $this->assertSame($en, $entity->get('_translations')['en']); + $this->assertSame($es, $entity->get('_translations')['es']); + + $this->assertSame('English Title', $result['en']->title); + $this->assertSame('Spanish Title', $result['es']->title); + $this->assertSame('Old body', $result['en']->body); + $this->assertSame('Old body', $result['es']->body); + } + + /** + * Test that updating translation records works with validations. + */ + public function testBuildMarshalMapUpdateEntitiesValidationErrors(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'validator' => 'custom', + ]); + $validator = (new Validator())->notEmptyString('title'); + $table->setValidator('custom', $validator); + $translate = $table->behaviors()->get('Translate'); + + $entity = $table->newEmptyEntity(); + $es = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']); + $en = $table->newEntity(['title' => 'Old title', 'body' => 'Old body']); + $entity->set('_translations', [ + 'es' => $es, + 'en' => $en, + ]); + $map = $translate->buildMarshalMap($table->marshaller(), [], []); + $data = [ + 'en' => [ + 'title' => 'English Title', + 'body' => 'English Content', + ], + 'es' => [ + 'title' => '', + 'body' => 'Contenido Español', + ], + ]; + $map['_translations']($data, $entity); + $this->assertNotEmpty($entity->getErrors(), 'Needs validation errors.'); + $expected = [ + 'title' => [ + '_empty' => 'This field cannot be left empty', + ], + ]; + $this->assertEquals($expected, $entity->getError('_translations.es')); + } + + /** + * Test that the behavior uses associations' locator. + */ + public function testDefaultTableLocator(): void + { + $locator = new TableLocator(); + + $table = $locator->get('Articles'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'validator' => 'custom', + ]); + + $behaviorLocator = $table->behaviors()->get('Translate')->getTableLocator(); + + $this->assertSame($locator, $behaviorLocator); + $this->assertSame($table->associations()->getTableLocator(), $behaviorLocator); + $this->assertNotSame($this->getTableLocator(), $behaviorLocator); + } + + /** + * Test that the behavior uses a custom locator. + */ + public function testCustomTableLocator(): void + { + $locator = new TableLocator(); + + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'validator' => 'custom', + 'tableLocator' => $locator, + ]); + + $behaviorLocator = $table->behaviors()->get('Translate')->getTableLocator(); + + $this->assertSame($locator, $behaviorLocator); + $this->assertNotSame($table->associations()->getTableLocator(), $behaviorLocator); + $this->assertNotSame($this->getTableLocator(), $behaviorLocator); + } + + /** + * Tests that using deep matching doesn't cause an association property to be created. + */ + public function testDeepMatchingDoesNotCreateAssociationProperty(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->Comments->belongsTo('Authors')->setForeignKey('user_id'); + + $table->Comments->addBehavior('Translate', ['fields' => ['comment']]); + $table->Comments->getBehavior('Translate')->setLocale('abc'); + + $table->Comments->Authors->addBehavior('Translate', ['fields' => ['name']]); + $table->Comments->Authors->getBehavior('Translate')->setLocale('xyz'); + + $this->assertNotEquals($table->Comments->getBehavior('Translate')->getLocale(), I18n::getLocale()); + $this->assertNotEquals($table->Comments->Authors->getBehavior('Translate')->getLocale(), I18n::getLocale()); + + $result = $table + ->find() + ->contain('Comments') + ->matching('Comments.Authors') + ->first(); + + $this->assertArrayNotHasKey('author', $result->comments); + } + + /** + * Tests that the _locale property is set on the entity in the _matchingData property. + */ + public function testLocalePropertyIsSetInMatchingData(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + + $table->Comments->addBehavior('Translate', ['fields' => ['comment']]); + $table->Comments->getBehavior('Translate')->setLocale('abc'); + + $this->assertNotEquals($table->Comments->getBehavior('Translate')->getLocale(), I18n::getLocale()); + + $result = $table + ->find() + ->contain('Comments') + ->matching('Comments') + ->first(); + + $this->assertArrayNotHasKey('_locale', $result->comments); + $this->assertSame('abc', $result->_matchingData['Comments']->_locale); + } + + /** + * Tests that the _locale property is set on the entity in the _matchingData property + * when using deep matching. + */ + public function testLocalePropertyIsSetInMatchingDataWhenUsingDeepMatching(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->Comments->belongsTo('Authors')->setForeignKey('user_id'); + + $table->Comments->addBehavior('Translate', ['fields' => ['comment']]); + $table->Comments->getBehavior('Translate')->setLocale('abc'); + + $table->Comments->Authors->addBehavior('Translate', ['fields' => ['name']]); + $table->Comments->Authors->getBehavior('Translate')->setLocale('xyz'); + + $this->assertNotEquals($table->Comments->getBehavior('Translate')->getLocale(), I18n::getLocale()); + $this->assertNotEquals($table->Comments->Authors->getBehavior('Translate')->getLocale(), I18n::getLocale()); + + $result = $table + ->find() + ->contain('Comments.Authors') + ->matching('Comments.Authors') + ->first(); + + $this->assertArrayNotHasKey('_locale', $result->comments); + $this->assertSame('abc', $result->_matchingData['Comments']->_locale); + $this->assertSame('xyz', $result->_matchingData['Authors']->_locale); + } + + /** + * Tests that the _locale property is set on the entity in the _matchingData property + * when using contained matching. + */ + public function testLocalePropertyIsSetInMatchingDataWhenUsingContainedMatching(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany('Articles'); + $table->Articles->belongsToMany('Tags'); + + $table->Articles->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->Articles->getBehavior('Translate')->setLocale('abc'); + + $table->Articles->Tags->addBehavior('Translate', ['fields' => ['name']]); + $table->Articles->Tags->getBehavior('Translate')->setLocale('xyz'); + + $this->assertNotEquals($table->Articles->getBehavior('Translate')->getLocale(), I18n::getLocale()); + $this->assertNotEquals($table->Articles->Tags->getBehavior('Translate')->getLocale(), I18n::getLocale()); + + $result = $table + ->find() + ->contain([ + 'Articles' => function ($query) { + return $query->matching('Tags'); + }, + 'Articles.Tags', + ]) + ->first(); + + $this->assertArrayNotHasKey('_locale', $result->articles); + $this->assertArrayNotHasKey('_locale', $result->articles[0]->tags); + $this->assertSame('abc', $result->articles[0]->_locale); + $this->assertSame('xyz', $result->articles[0]->_matchingData['Tags']->_locale); + } + + /** + * Tests that modified entities aren't marked as clean after EavStrategy::rowMapper + */ + public function testModifiedEntityNotCleanAfterTranslationMapping(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('fra'); + + $articles = $table->find()->all(); + $articles->each(function ($article): void { + $article->published = 'N'; + }); + + $this->assertTrue($articles->first()->isDirty('published')); + } +} diff --git a/tests/TestCase/ORM/Behavior/TranslateBehaviorShadowTableTest.php b/tests/TestCase/ORM/Behavior/TranslateBehaviorShadowTableTest.php new file mode 100644 index 00000000000..fbd976ea1b0 --- /dev/null +++ b/tests/TestCase/ORM/Behavior/TranslateBehaviorShadowTableTest.php @@ -0,0 +1,1233 @@ +getTableLocator()->get('Articles'); + $table->getTable(); + $table->addBehavior('Translate'); + + $config = $table->behaviors()->get('Translate')->getStrategy()->getConfig(); + $wantedKeys = [ + 'translationTable', + 'mainTableAlias', + 'hasOneAlias', + ]; + $config = array_intersect_key($config, array_flip($wantedKeys)); + $expected = [ + 'translationTable' => 'ArticlesTranslations', + 'mainTableAlias' => 'Articles', + 'hasOneAlias' => 'ArticlesTranslation', + ]; + $this->assertEquals($expected, $config, 'Used aliases should match the main table object'); + + $this->_testFind(); + } + + /** + * Check things are setup correctly by default for plugin models + */ + public function testDefaultPluginAliases(): void + { + $table = $this->getTableLocator()->get('SomeRandomPlugin.Articles'); + + $table->getTable(); + $table->addBehavior('Translate'); + + $config = $table->behaviors()->get('Translate')->getStrategy()->getConfig(); + $wantedKeys = [ + 'translationTable', + 'mainTableAlias', + 'hasOneAlias', + ]; + $config = array_intersect_key($config, array_flip($wantedKeys)); + $expected = [ + 'translationTable' => 'SomeRandomPlugin.ArticlesTranslations', + 'mainTableAlias' => 'Articles', + 'hasOneAlias' => 'ArticlesTranslation', + ]; + $this->assertEquals($expected, $config, 'Used aliases should match the main table object'); + + $exists = $this->getTableLocator()->exists('SomeRandomPlugin.ArticlesTranslations'); + $this->assertTrue($exists, 'The behavior should have populated this key with a table object'); + + $translationTable = $this->getTableLocator()->get('SomeRandomPlugin.ArticlesTranslations'); + $this->assertSame( + 'SomeRandomPlugin.ArticlesTranslations', + $translationTable->getRegistryAlias(), + 'It should be a different object to the one in the no-plugin prefix', + ); + + $this->_testFind('SomeRandomPlugin.Articles'); + } + + /** + * testAutoReferenceName + * + * The parent test is EAV specific. Test that the config reflects the referenceName - + * which is used to determine the the translation table/association name only in the + * shadow translate behavior + */ + public function testAutoReferenceName(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->getTable(); + $table->addBehavior('Translate'); + + $config = $table->behaviors()->get('Translate')->getStrategy()->getConfig(); + $wantedKeys = [ + 'translationTable', + 'mainTableAlias', + 'hasOneAlias', + ]; + + $config = array_intersect_key($config, array_flip($wantedKeys)); + $expected = [ + 'translationTable' => 'ArticlesTranslations', + 'mainTableAlias' => 'Articles', + 'hasOneAlias' => 'ArticlesTranslation', + ]; + $this->assertEquals($expected, $config, 'The translationTable key should be derived from referenceName'); + } + + /** + * testChangingReferenceName + * + * The parent test is EAV specific. Test that the config reflects the referenceName - + * which is used to determine the the translation table/association name only in the + * shadow translate behavior + */ + public function testChangingReferenceName(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->getTable(); + $table->addBehavior( + 'Translate', + ['referenceName' => 'Posts'], + ); + + $config = $table->behaviors()->get('Translate')->getStrategy()->getConfig(); + $wantedKeys = [ + 'translationTable', + 'mainTableAlias', + 'hasOneAlias', + ]; + + $config = array_intersect_key($config, array_flip($wantedKeys)); + $expected = [ + 'translationTable' => 'PostsTranslations', + 'mainTableAlias' => 'Articles', + 'hasOneAlias' => 'ArticlesTranslation', + ]; + $this->assertEquals($expected, $config, 'The translationTable key should be derived from referenceName'); + } + + /** + * Allow usage without specifying fields explicitly + * + * Fields are only detected when necessary, one of those times is a fine with fields. + */ + public function testAutoFieldDetection(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + + $table->getBehavior('Translate')->setLocale('eng'); + $table->find()->select(['title'])->first(); + + $expected = ['title', 'body']; + $result = $table->behaviors()->get('Translate')->getStrategy()->getConfig('fields'); + $this->assertSame( + $expected, + $result, + 'If no fields are specified, they should be derived from the schema', + ); + } + + /** + * testTranslationTableConfig + */ + public function testTranslationTableConfig(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + + $exists = $this->getTableLocator()->exists('ArticlesTranslations'); + $this->assertTrue($exists, 'The table registry should have an object in this key now'); + + $translationTable = $this->getTableLocator()->get('ArticlesTranslations'); + $this->assertSame('articles_translations', $translationTable->getTable()); + $this->assertSame('ArticlesTranslations', $translationTable->getAlias()); + } + + /** + * Only join translations when necessary + * + * By inspecting the sql generated, verify that if there is a need for the translation + * table to be included in the query it is present, and when there is no clear need - + * that it is not. + */ + public function testNoUnnecessaryJoins(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + + $query = $table->find(); + $this->assertStringNotContainsString( + 'articles_translations', + $query->sql(), + "The default locale doesn't need a join", + ); + + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find()->select(['id']); + $this->assertStringNotContainsString( + 'articles_translations', + $query->sql(), + 'No translated fields, nothing to do', + ); + + $query = $table->find()->select(['Other.title']); + $this->assertStringNotContainsString( + 'articles_translations', + $query->sql(), + "Other isn't the table class with the translate behavior, nothing to do", + ); + } + + /** + * Join when translations are necessary + */ + public function testNecessaryJoinsSelect(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find(); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'No fields specified, means select all fields - translated included', + ); + + $query = $table->find()->select(['title']); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'Selecting a translated field should join the translations table', + ); + + $query = $table->find()->select(['Articles.title']); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'Selecting an aliased translated field should join the translations table', + ); + } + + /** + * Join when translations are necessary + */ + public function testNecessaryJoinsWhere(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find()->select(['id'])->where(['title' => 'First Article']); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'If the where clause includes a translated field - a join is required', + ); + } + + /** + * Join when translations are necessary + */ + public function testNecessaryJoinsConfig(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $table->addBehavior('Translate', [ + 'onlyTranslated' => true, + ]); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find()->select(['id'])->disableAutoFields(); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'Enabling `onlyTranslated` should join the translations table', + ); + + $table + ->removeBehavior('Translate') + ->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find('all', filterByCurrentLocale: true)->select(['id'])->disableAutoFields(); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'Enabling `filterByCurrentLocale` should join the translations table', + ); + } + + /** + * testTraversingWhereClauseWithNonStringField + */ + public function testTraversingWhereClauseWithNonStringField(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find()->select()->where(function (ExpressionInterface $exp) { + return $exp->lt(new QueryExpression('1'), 50); + }); + + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'Do not try to use non string fields when traversing "where" clause', + ); + } + + /** + * Join when translations are necessary + */ + public function testNecessaryJoinsOrder(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find()->select(['id'])->orderBy(['title' => 'desc']); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'If the order clause includes a translated field - a join is required', + ); + + $query = $table->find(); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'No fields means auto-fields - a join is required', + ); + } + + /** + * Setup a contrived self join and make sure both records are translated + * + * Different locales are used on each table object just to make any resulting + * confusion easier to identify as neither the original or translated values + * overlap between the two records. + */ + public function testSelfJoin(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $table->belongsTo('Copy', ['className' => 'Articles', 'foreignKey' => 'author_id']); + $table->Copy->addBehavior('Translate'); + $table->Copy->getBehavior('Translate')->setLocale('deu'); + + $query = $table->find() + ->where(['Articles.id' => 3]) + ->contain('Copy'); + + $result = $query->first()->toArray(); + $expected = [ + 'id' => 3, + 'author_id' => 1, + 'title' => 'Title #3', + 'body' => 'Content #3', + 'published' => 'Y', + 'copy' => [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'Titel #1', + 'body' => 'Inhalt #1', + 'published' => 'Y', + '_locale' => 'deu', + ], + '_locale' => 'eng', + ]; + $this->assertEquals( + $expected, + $result, + 'The copy record should also be translated', + ); + } + + /** + * Verify it is not necessary for a translated field to exist in the master table + */ + public function testVirtualTranslationField(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'translationTableAlias' => 'ArticlesMoreTranslations', + 'translationTable' => 'articles_more_translations', + ]); + + $table->getBehavior('Translate')->setLocale('eng'); + $results = $table->find()->all()->combine('title', 'subtitle', 'id')->toArray(); + $expected = [ + 1 => ['Title #1' => 'SubTitle #1'], + 2 => ['Title #2' => 'SubTitle #2'], + 3 => ['Title #3' => 'SubTitle #3'], + ]; + $this->assertSame($expected, $results); + } + + /** + * Tests that after deleting a translated entity, all translations are also removed + */ + public function testDelete(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $article = $table->find()->first(); + $this->assertTrue($table->delete($article)); + + $translations = $this->getTableLocator()->get('ArticlesTranslations')->find() + ->where(['id' => $article->id]) + ->count(); + $this->assertSame(0, $translations); + } + + /** + * testNoAmbiguousFields + */ + public function testNoAmbiguousFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $article = $table->find('all') + ->select(['id']) + ->toArray(); + + $this->assertNotNull($article, "There will be an exception if there's ambiguous sql"); + + $article = $table->find('all') + ->select(['title']) + ->toArray(); + + $this->assertNotNull($article, "There will be an exception if there's ambiguous sql"); + } + + /** + * testNoAmbiguousConditions + */ + public function testNoAmbiguousConditions(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $article = $table->find('all') + ->where(['id' => 1]) + ->toArray(); + + $this->assertNotNull($article, "There will be an exception if there's ambiguous sql"); + + $article = $table->find('all') + ->where(['title' => 1]) + ->toArray(); + + $this->assertNotNull($article, "There will be an exception if there's ambiguous sql"); + } + + /** + * testNoAmbiguousOrder + */ + public function testNoAmbiguousOrderBy(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $article = $table->find('all') + ->orderBy(['id' => 'desc']) + ->enableHydration(false) + ->toArray(); + + $this->assertSame([3, 2, 1], Hash::extract($article, '{n}.id')); + + $article = $table->find('all') + ->orderBy(['title' => 'asc']) + ->enableHydration(false) + ->toArray(); + + $expected = ['Title #1', 'Title #2', 'Title #3']; + $this->assertSame($expected, Hash::extract($article, '{n}.title')); + } + + /** + * If results are unhydrated, it should still work + */ + public function testUnhydratedResults(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + + $result = $table + ->find('translations') + ->enableHydration(false) + ->first(); + $this->assertArrayHasKey('title', $result); + } + + /** + * A find containing another association should act the same whether translated or not + */ + public function testFindWithAssociations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table + ->find('translations') + ->where(['Articles.id' => 1]) + ->contain(['Authors']); + $this->assertStringContainsString( + 'articles_translations', + $query->sql(), + 'There should be a join to the translations table', + ); + + $result = $query->firstOrFail(); + + $this->assertNotNull($result->author, 'There should be an author for article 1.'); + $expected = [ + 'id' => 1, + 'name' => 'mariano', + ]; + $this->assertSame($expected, $result->author->toArray()); + + $this->assertNotEmpty($result->_translations, "Translations can't be empty."); + } + + /** + * Test that when finding BTM associations, the contained BTM data is also translated. + */ + public function testFindWithBTMAssociations(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Tags = $this->getTableLocator()->get('Tags'); + + // This required because there's already a fixture for tags_translations which isn't a shadow table + $this->getTableLocator()->get('TagsTranslations', [ + 'className' => 'TagsShadowTranslations', + ]); + $this->getTableLocator()->get('TagsTranslation', [ + 'className' => 'TagsShadowTranslations', + ]); + + $Articles->addBehavior('Translate'); + $Tags->addBehavior('Translate'); + + $Articles->getBehavior('Translate')->setLocale('deu'); + $Tags->getBehavior('Translate')->setLocale('deu'); + + $Articles->belongsToMany('Tags'); + + $query = $Articles + ->find() + ->where(['Articles.id' => 1]) + ->contain(['Tags']); + + $result = $query->firstOrFail(); + + $this->assertCount(2, $result->tags, 'There should be two translated tags.'); + + $expected = [ + 'id' => 1, + 'name' => 'tag1 in deu', + '_locale' => 'deu', + '_joinData' => [ + 'tag_id' => 1, + 'article_id' => 1, + ], + ]; + $record = $result->tags[0]->toArray(); + unset($record['description'], $record['created']); + $this->assertEquals($expected, $record); + + $expected = [ + 'id' => 2, + 'name' => 'tag2 in deu', + '_locale' => 'deu', + '_joinData' => [ + 'tag_id' => 2, + 'article_id' => 1, + ], + ]; + $record = $result->tags[1]->toArray(); + unset($record['description'], $record['created']); + $this->assertEquals($expected, $record); + } + + /** + * A find containing a translated association doesn't error on incomplete data + */ + public function testFindTranslationsAssociatedContain(): void + { + $comments = $this->fetchTable('Comments'); + $comments->belongsTo('Articles'); + + $articles = $this->fetchTable('Articles'); + + // Remove all articles so we have a missing record. + $articles->deleteAll('1=1'); + + $articles->addBehavior('Translate'); + $articles->getBehavior('Translate')->setLocale('eng'); + + $query = $comments + ->find() + ->where(['Comments.id' => 1]) + ->contain([ + 'Articles' => function ($q) { + return $q->find('translations'); + }]); + $record = $query->firstOrFail(); + $this->assertNull($record->article); + } + + /** + * Tests that it is possible to get all translated fields at once + */ + public function testFindTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $results = $table->find('translations', locales: ['eng', 'deu', 'cze', 'spa']); + $expected = [ + [ + 'eng' => ['title' => 'Title #1', 'body' => 'Content #1', 'locale' => 'eng'], + 'deu' => ['title' => 'Titel #1', 'body' => 'Inhalt #1', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #1', 'body' => 'Obsah #1', 'locale' => 'cze'], + 'spa' => ['title' => 'First Article', 'body' => 'Contenido #1', 'locale' => 'spa'], + ], + [ + 'eng' => ['title' => 'Title #2', 'body' => 'Content #2', 'locale' => 'eng'], + 'deu' => ['title' => 'Titel #2', 'body' => 'Inhalt #2', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #2', 'body' => 'Obsah #2', 'locale' => 'cze'], + ], + [ + 'eng' => ['title' => 'Title #3', 'body' => 'Content #3', 'locale' => 'eng'], + 'deu' => ['title' => 'Titel #3', 'body' => 'Inhalt #3', 'locale' => 'deu'], + 'cze' => ['title' => 'Titulek #3', 'body' => 'Obsah #3', 'locale' => 'cze'], + ], + ]; + + $translations = $this->_extractTranslations($results); + $this->assertEquals($expected, $translations->toArray()); + $expected = [ + 1 => ['First Article' => 'First Article Body'], + 2 => ['Second Article' => 'Second Article Body'], + 3 => ['Third Article' => 'Third Article Body'], + ]; + + $grouped = $results->all()->combine('title', 'body', 'id'); + $this->assertEquals($expected, $grouped->toArray()); + + $entity = $table->newEntity(['title' => 'Fourth Title']); + $table->save($entity); + + $expected = [[]]; + $result = $table->find('translations')->where(['Articles.id' => $entity->id])->all(); + $this->assertEquals($expected, $this->_extractTranslations($result)->toArray()); + + $entity = $result->first(); + $this->assertSame('Fourth Title', $entity->title); + } + + /** + * By default empty translations should be honored + */ + public function testEmptyTranslationsDefaultBehavior(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + $table->getBehavior('Translate')->setLocale('zzz'); + $result = $table->get(1); + + $this->assertSame('', $result->title, 'The empty translation should be used'); + $this->assertSame('', $result->body, 'The empty translation should be used'); + $this->assertNull($result->description); + } + + /** + * Tests that allowEmptyTranslations takes effect + */ + public function testEmptyTranslations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'allowEmptyTranslations' => false, + ]); + $table->getBehavior('Translate')->setLocale('zzz'); + $result = $table->get(1); + + $this->assertSame('First Article', $result->title, 'The empty translation should be ignored'); + $this->assertSame('First Article Body', $result->body, 'The empty translation should be ignored'); + $this->assertNull($result->description); + } + + /** + * Tests using FunctionExpression + */ + public function testUsingFunctionExpression(): void + { + $this->skipIf( + ConnectionManager::get('test')->getDriver() instanceof Postgres, + 'Test needs to be adjusted to not fail on Postgres', + ); + + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate'); + + $table->getBehavior('Translate')->setLocale('eng'); + $query = $table->find()->select(); + $query->select([ + 'title', + 'function_expression' => $query->func()->concat(['ArticlesTranslation.title' => 'literal', ' with a suffix']), + 'body', + ]); + $result = array_intersect_key( + $query->first()->toArray(), + array_flip(['title', 'function_expression', 'body', '_locale']), + ); + + $expected = [ + 'title' => 'Title #1', + 'function_expression' => 'Title #1 with a suffix', + 'body' => 'Content #1', + '_locale' => 'eng', + ]; + $this->assertSame( + $expected, + $result, + 'Including a function expression should work but requires referencing the used table aliases', + ); + } + + /** + * Ensure saving with accessible defined works + * + * With a standard baked model the accessible property is defined, that'll mean that + * Setting fields such as id and locale will fail by default due to mass-assignment + * protection. An exception is thrown if that happens + */ + public function testSaveWithAccessibleFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->setEntityClass(TranslateBakedArticle::class); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + + $article = $table->get(1); + $article->translation('xyz')->title = 'XYZ title'; + + $this->assertNotFalse($table->save($article), 'The save should succeed'); + } + + /** + * Tests translationField method for translated fields. + */ + public function testTranslationFieldForTranslatedFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'defaultLocale' => 'en_US', + ]); + + $expectedSameLocale = 'Articles.title'; + $expectedOtherLocale = 'ArticlesTranslation.title'; + + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedSameLocale, $field); + + I18n::setLocale('es_ES'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + + I18n::setLocale('en'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + + $table->removeBehavior('Translate'); + $table->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + 'defaultLocale' => 'de_DE', + ]); + + I18n::setLocale('de_DE'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedSameLocale, $field); + + I18n::setLocale('en_US'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + + $table->getBehavior('Translate')->setLocale('de_DE'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedSameLocale, $field); + + $table->getBehavior('Translate')->setLocale('es'); + $field = $table->getBehavior('Translate')->translationField('title'); + $this->assertSame($expectedOtherLocale, $field); + } + + /** + * Test update entity with _translations field. + * + * Had to override this method because the core method has a wacky check + * for "description" field which doesn't even exist in ArticleFixture. + */ + public function testSaveExistingRecordWithTranslatesField(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->setEntityClass(TranslateArticle::class); + + $data = [ + 'author_id' => 1, + 'published' => 'Y', + '_translations' => [ + 'eng' => [ + 'title' => 'First Article1', + 'body' => 'First Article content has been updated', + ], + 'spa' => [ + 'title' => 'Mi nuevo titulo', + 'body' => 'Contenido Actualizado', + ], + ], + ]; + + $article = $table->find()->first(); + $article = $table->patchEntity($article, $data); + + $this->assertNotFalse($table->save($article)); + + $results = $this->_extractTranslations( + $table->find('translations')->where(['id' => 1]), + )->first(); + + $this->assertSame('Mi nuevo titulo', $results['spa']['title']); + $this->assertSame('Contenido Actualizado', $results['spa']['body']); + + $this->assertSame('First Article1', $results['eng']['title']); + } + + /** + * Test save new entity with _translations field + */ + public function testSaveNewRecordWithTranslatesField(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->getValidator()->add('title', 'notBlank', ['rule' => 'notBlank']); + $table->addBehavior('Translate', [ + 'defaultLocale' => 'en', + 'fields' => ['title'], + ]); + $table->setEntityClass(TranslateArticle::class); + + $article = $table->patchEntity( + $table->newEmptyEntity(), + [ + '_translations' => ['en' => ['title' => '']], + ], + ); + $this->assertSame( + ['notBlank' => 'The provided value is invalid'], + $article->getError('title'), + ); + + $data = [ + 'author_id' => 1, + 'published' => 'N', + '_translations' => [ + 'en' => [ + 'title' => 'Title EN', + 'body' => 'Body EN', + ], + 'es' => [ + 'title' => 'Title ES', + ], + 'fr' => [ + 'title' => 'Title FR', + ], + ], + ]; + + $article = $table->patchEntity($table->newEmptyEntity(), $data); + $result = $table->save($article); + + $this->assertNotFalse($result); + + $expected = [ + [ + 'fr' => [ + 'title' => 'Title FR', + 'locale' => 'fr', + 'body' => null, + ], + 'es' => [ + 'title' => 'Title ES', + 'locale' => 'es', + 'body' => null, + ], + ], + ]; + $result = $table->find('translations')->where(['Articles.id' => $result->id])->all(); + $this->assertEquals($expected, $this->_extractTranslations($result)->toArray()); + + $entity = $result->first(); + $this->assertSame('Title EN', $entity->title); + $this->assertSame('Body EN', $entity->body); + + $data = [ + 'title' => 'New title', + 'author_id' => 1, + 'published' => 'N', + '_translations' => null, + ]; + + $article = $table->patchEntity($table->newEmptyEntity(), $data); + $result = $table->save($article); + + $this->assertNotFalse($result); + } + + /** + * Tests adding new translation to a record + */ + public function testInsertNewTranslations(): void + { + parent::testInsertNewTranslations(); + + $shadowEntity = new class extends Entity { + protected function _setComment($value): string + { + return $value . ' modified'; + } + }; + + $table = $this->getTableLocator()->get('Comments'); + $table->addBehavior('Translate', ['fields' => ['comment']]); + $table->getBehavior('Translate')->setLocale('spa'); + $table->getBehavior('Translate')->getStrategy()->getTranslationTable()->setEntityClass($shadowEntity::class); + + $entity = $table->get(1); + $entity->comment = 'New Comment'; + $table->save($entity); + + $entity = $table->get(1); + $this->assertSame( + 'New Comment', + $entity->get('comment'), + 'New translation should not be modified', + ); + } + + /** + * Tests adding new translation to a record + */ + public function testAllowEmptyFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => '', + ], + ], + ]); + + $table->save($article); + + $noFra = $table->ArticlesTranslations->find()->where(['locale' => 'fra'])->first(); + $this->assertEmpty($noFra); + + $article = $table->find()->where(['id' => 2])->first(); + + $this->assertSame('Second Article', $article->get('title')); + $table->patchEntity($article, ['title' => 'Second Article updated']); + + $this->assertNotFalse($table->save($article)); + } + + /** + * Tests adding new translation to a record with a missing translation + */ + public function testAllowEmptyFalseWithNull(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'description'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => 'Title', + ], + ], + ]); + + $table->save($article); + + // Remove the Behavior to unset the content != '' condition + $table->removeBehavior('Translate'); + + $fra = $table->ArticlesTranslations->find()->where(['locale' => 'fra'])->first(); + $this->assertNotEmpty($fra); + } + + /** + * Tests adding new translation to a record + */ + public function testMixedAllowEmptyFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => '', + 'body' => 'Bonjour', + ], + ], + ]); + + $table->save($article); + + $fra = $table->ArticlesTranslations->find() + ->where([ + 'locale' => 'fra', + ]) + ->first(); + $this->assertSame('Bonjour', $fra->body); + $this->assertNull($fra->title); + } + + /** + * Tests adding new translation to a record + */ + public function testMultipleAllowEmptyFalse(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body'], 'allowEmptyTranslations' => false]); + + $article = $table->find()->first(); + $this->assertSame(1, $article->get('id')); + + $article = $table->patchEntity($article, [ + '_translations' => [ + 'fra' => [ + 'title' => '', + 'body' => 'Bonjour', + ], + 'de' => [ + 'title' => 'Titel', + 'body' => 'Hallo', + ], + ], + ]); + + $table->save($article); + + $fra = $table->ArticlesTranslations->find() + ->where([ + 'locale' => 'fra', + ]) + ->first(); + $this->assertSame('Bonjour', $fra->body); + $this->assertNull($fra->title); + + $de = $table->ArticlesTranslations->find() + ->where([ + 'locale' => 'de', + ]) + ->first(); + $this->assertSame('Titel', $de->title); + $this->assertSame('Hallo', $de->body); + } + + /** + * Test buildMarshalMap() builds new entities. + */ + public function testBuildMarshalMapBuildEntities(): void + { + $table = $this->getTableLocator()->get('Articles'); + // Unlike test case of core Translate behavior "fields" is not set to + // test marshalling with lazily fetched fields list. + $table->addBehavior('Translate'); + $translate = $table->behaviors()->get('Translate'); + + $map = $translate->buildMarshalMap($table->marshaller(), [], []); + $entity = $table->newEmptyEntity(); + $data = [ + 'en' => [ + 'title' => 'English Title', + 'body' => 'English Content', + ], + 'es' => [ + 'title' => 'Titulo Español', + 'body' => 'Contenido Español', + ], + ]; + $result = $map['_translations']($data, $entity); + $this->assertEmpty($entity->getErrors(), 'No validation errors.'); + $this->assertCount(2, $result); + $this->assertArrayHasKey('en', $result); + $this->assertArrayHasKey('es', $result); + $this->assertSame('English Title', $result['en']->title); + $this->assertSame('Titulo Español', $result['es']->title); + } + + /** + * Used in the config tests to verify that a simple find still works + * + * @param string $tableAlias + */ + protected function _testFind($tableAlias = 'Articles'): void + { + $table = $this->getTableLocator()->get($tableAlias); + $table->getBehavior('Translate')->setLocale('eng'); + + $query = $table->find()->select(); + $result = array_intersect_key( + $query->first()->toArray(), + array_flip(['title', 'body', '_locale']), + ); + $expected = [ + 'title' => 'Title #1', + 'body' => 'Content #1', + '_locale' => 'eng', + ]; + $this->assertSame( + $expected, + $result, + "Title and body are translated values, but don't match", + ); + } + + /** + * Tests that modified entities aren't marked as clean after ShadowTableStrategy::rowMapper + */ + public function testModifiedEntityNotCleanAfterTranslationMapping(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('fra'); + + $articles = $table->find()->all(); + $articles->each(function ($article): void { + $article->published = 'N'; + }); + + $this->assertTrue($articles->first()->isDirty('published')); + } +} diff --git a/tests/TestCase/ORM/Behavior/TreeBehaviorTest.php b/tests/TestCase/ORM/Behavior/TreeBehaviorTest.php new file mode 100644 index 00000000000..032990cdfcf --- /dev/null +++ b/tests/TestCase/ORM/Behavior/TreeBehaviorTest.php @@ -0,0 +1,1474 @@ + + */ + protected array $fixtures = [ + 'core.MenuLinkTrees', + 'core.NumberTrees', + 'core.NumberTreesArticles', + ]; + + /** + * @var \Cake\ORM\Table|\Cake\ORM\Behavior\TreeBehavior + */ + protected $table; + + protected function setUp(): void + { + parent::setUp(); + $this->table = $this->getTableLocator()->get('NumberTrees'); + $this->table->setPrimaryKey(['id']); + $this->table->addBehavior('Tree'); + } + + /** + * Sanity test + * + * Make sure the assert method acts as you'd expect, this is the expected + * initial db state + */ + public function testAssertMpttValues(): void + { + $expected = [ + ' 1:20 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '_10:19 - 6:portable', + '__11:14 - 7:mp3', + '___12:13 - 8:flash', + '__15:16 - 9:cd', + '__17:18 - 10:radios', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + $table->removeBehavior('Tree'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'categories']]); + $expected = [ + ' 1:10 - 9:electronics', + '_ 2: 9 - 10:televisions', + '__ 3: 4 - 11:tube', + '__ 5: 8 - 12:lcd', + '___ 6: 7 - 13:plasma', + '11:20 - 14:portable', + '_12:15 - 15:mp3', + '__13:14 - 16:flash', + '_16:17 - 17:cd', + '_18:19 - 18:radios', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests the find('path') method + */ + public function testFindPath(): void + { + $nodes = $this->table->find('path', for: 9); + $this->assertEquals([1, 6, 9], $nodes->all()->extract('id')->toArray()); + + $nodes = $this->table->find('path', for: 10); + $this->assertSame([1, 6, 10], $nodes->all()->extract('id')->toArray()); + + $nodes = $this->table->find('path', for: 5); + $this->assertSame([1, 2, 5], $nodes->all()->extract('id')->toArray()); + + $nodes = $this->table->find('path', for: 1); + $this->assertSame([1], $nodes->all()->extract('id')->toArray()); + + $entity = $this->table->newEntity(['name' => 'odd one', 'parent_id' => 1]); + $entity = $this->table->save($entity); + $newId = $entity->id; + + $entity = $this->table->get(2); + $entity->parent_id = $newId; + $this->table->save($entity); + + $nodes = $this->table->find('path', for: 4); + $this->assertSame([1, $newId, 2, 4], $nodes->all()->extract('id')->toArray()); + + // find path with scope + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $nodes = $table->find('path', for: 5); + $this->assertSame([1, 3, 4, 5], $nodes->all()->extract('id')->toArray()); + } + + /** + * Tests the childCount() method + */ + public function testChildCount(): void + { + // direct children for the root node + $table = $this->table; + $countDirect = $this->table->getBehavior('Tree')->childCount($table->get(1), true); + $this->assertSame(2, $countDirect); + + // counts all the children of root + $count = $this->table->getBehavior('Tree')->childCount($table->get(1), false); + $this->assertSame(9, $count); + + // counts direct children + $count = $this->table->getBehavior('Tree')->childCount($table->get(2), false); + $this->assertSame(3, $count); + + // count children for a middle-node + $count = $this->table->getBehavior('Tree')->childCount($table->get(6), false); + $this->assertSame(4, $count); + + // count leaf children + $count = $this->table->getBehavior('Tree')->childCount($table->get(10), false); + $this->assertSame(0, $count); + + // test scoping + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $count = $table->getBehavior('Tree')->childCount($table->get(3), false); + $this->assertSame(2, $count); + } + + /** + * Tests that childCount will provide the correct lft and rght values + */ + public function testChildCountNoTreeColumns(): void + { + $table = $this->table; + $node = $table->get(6); + $node->unset('lft'); + $node->unset('rght'); + $count = $this->table->getBehavior('Tree')->childCount($node, false); + $this->assertSame(4, $count); + } + + /** + * Tests the childCount() plus callable scoping + */ + public function testScopeCallable(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', [ + 'scope' => function ($query) { + return $query->where(['menu' => 'main-menu']); + }, + ]); + $count = $table->getBehavior('Tree')->childCount($table->get(1), false); + $this->assertSame(4, $count); + } + + /** + * Tests the find('children') method + */ + public function testFindChildren(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + + // root + $nodes = $table->find('children', for: 1)->all(); + $this->assertEquals([2, 3, 4, 5], $nodes->extract('id')->toArray()); + + // leaf + $nodes = $table->find('children', for: 5)->all(); + $this->assertCount(0, $nodes->extract('id')->toArray()); + + // direct children + $nodes = $table->find('children', for: 1, direct: true)->all(); + $this->assertEquals([2, 3], $nodes->extract('id')->toArray()); + } + + /** + * Tests the find('children') plus scope=null + */ + public function testScopeNull(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree'); + $table->behaviors()->get('Tree')->deleteConfig('scope'); + + $nodes = $table->find('children', for: 1, direct: true)->all(); + $this->assertEquals([2, 3], $nodes->extract('id')->toArray()); + } + + /** + * Tests that find('children') will throw an exception if the node was not found + */ + public function testFindChildrenException(): void + { + $this->expectException(RecordNotFoundException::class); + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $table->find('children', for: 500); + } + + /** + * Tests the find('treeList') method + */ + public function testFindTreeList(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $query = $table->find('treeList'); + + $result = null; + $query->clause('order')->iterateParts(function ($dir, $field) use (&$result): void { + $result = $field; + }); + $this->assertSame('MenuLinkTrees.lft', $result); + + $result = $query->toArray(); + $expected = [ + 1 => 'Link 1', + 2 => '_Link 2', + 3 => '_Link 3', + 4 => '__Link 4', + 5 => '___Link 5', + 6 => 'Link 6', + 7 => '_Link 7', + 8 => 'Link 8', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the find('treeList') method after moveUp, moveDown + */ + public function testFindTreeListAfterMove(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + + // moveUp + $table->getBehavior('Tree')->moveUp($table->get(3), 1); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 7 - 3:Link 3', + '__ 3: 6 - 4:Link 4', + '___ 4: 5 - 5:Link 5', + '_ 8: 9 - 2:Link 2', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + // moveDown + $table->getBehavior('Tree')->moveDown($table->get(6), 1); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 7 - 3:Link 3', + '__ 3: 6 - 4:Link 4', + '___ 4: 5 - 5:Link 5', + '_ 8: 9 - 2:Link 2', + '11:12 - 8:Link 8', + '13:16 - 6:Link 6', + '_14:15 - 7:Link 7', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests the find('treeList') method with custom options + */ + public function testFindTreeListCustom(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $result = $table + ->find('treeList', keyPath: 'url', valuePath: 'id', spacer: ' ') + ->toArray(); + $expected = [ + '/link1.html' => '1', + 'http://example.com' => ' 2', + '/what/even-more-links.html' => ' 3', + '/lorem/ipsum.html' => ' 4', + '/what/the.html' => ' 5', + '/yeah/another-link.html' => '6', + 'https://cakephp.org' => ' 7', + '/page/who-we-are.html' => '8', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the testFormatTreeListCustom() method. + */ + public function testFormatTreeListCustom(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree'); + + $query = $table + ->find('threaded') + ->where(['menu' => 'main-menu']); + + $options = ['keyPath' => 'url', 'valuePath' => 'id', 'spacer' => ' ']; + $result = $table->getBehavior('Tree')->formatTreeList($query, ...$options)->toArray(); + + $expected = [ + '/link1.html' => '1', + 'http://example.com' => ' 2', + '/what/even-more-links.html' => ' 3', + '/lorem/ipsum.html' => ' 4', + '/what/the.html' => ' 5', + '/yeah/another-link.html' => '6', + 'https://cakephp.org' => ' 7', + '/page/who-we-are.html' => '8', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the moveUp() method + */ + public function testMoveUp(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + + // top level, won't move + $node = $this->table->getBehavior('Tree')->moveUp($table->get(1), 10); + $this->assertEquals(['lft' => 1, 'rght' => 10], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + // edge cases + $this->assertFalse($this->table->getBehavior('Tree')->moveUp($table->get(1), 0)); + $this->assertFalse($this->table->getBehavior('Tree')->moveUp($table->get(1), -10)); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + // move inner node + $node = $table->getBehavior('Tree')->moveUp($table->get(3), 1); + $this->assertEquals(['lft' => 2, 'rght' => 7], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 7 - 3:Link 3', + '__ 3: 6 - 4:Link 4', + '___ 4: 5 - 5:Link 5', + '_ 8: 9 - 2:Link 2', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a node with no siblings + */ + public function testMoveLeaf(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $node = $table->getBehavior('Tree')->moveUp($table->get(5), 1); + $this->assertEquals(['lft' => 6, 'rght' => 7], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a node to the top + */ + public function testMoveTop(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $table->getBehavior('Tree')->moveUp($table->get(8), true); + $expected = [ + ' 1: 2 - 8:Link 8', + ' 3:12 - 1:Link 1', + '_ 4: 5 - 2:Link 2', + '_ 6:11 - 3:Link 3', + '__ 7:10 - 4:Link 4', + '___ 8: 9 - 5:Link 5', + '13:16 - 6:Link 6', + '_14:15 - 7:Link 7', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a node with no lft and rght + */ + public function testMoveNoTreeColumns(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $node = $table->get(8); + $node->unset('lft'); + $node->unset('rght'); + $node = $table->getBehavior('Tree')->moveUp($node, true); + $this->assertEquals(['lft' => 1, 'rght' => 2], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1: 2 - 8:Link 8', + ' 3:12 - 1:Link 1', + '_ 4: 5 - 2:Link 2', + '_ 6:11 - 3:Link 3', + '__ 7:10 - 4:Link 4', + '___ 8: 9 - 5:Link 5', + '13:16 - 6:Link 6', + '_14:15 - 7:Link 7', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests the moveDown() method + */ + public function testMoveDown(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + // latest node, won't move + $node = $table->getBehavior('Tree')->moveDown($table->get(8), 10); + $this->assertEquals(['lft' => 15, 'rght' => 16], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + // edge cases + $this->assertFalse($this->table->getBehavior('Tree')->moveDown($table->get(8), 0)); + $this->assertFalse($this->table->getBehavior('Tree')->moveDown($table->get(8), -10)); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + // move inner node + $node = $table->getBehavior('Tree')->moveDown($table->get(2), 1); + $this->assertEquals(['lft' => 8, 'rght' => 9], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 7 - 3:Link 3', + '__ 3: 6 - 4:Link 4', + '___ 4: 5 - 5:Link 5', + '_ 8: 9 - 2:Link 2', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a node that has no siblings + */ + public function testMoveLeafDown(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $node = $table->getBehavior('Tree')->moveDown($table->get(5), 1); + $this->assertEquals(['lft' => 6, 'rght' => 7], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a node to the bottom + */ + public function testMoveToBottom(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $node = $table->getBehavior('Tree')->moveDown($table->get(1), true); + $this->assertEquals(['lft' => 7, 'rght' => 16], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1: 4 - 6:Link 6', + '_ 2: 3 - 7:Link 7', + ' 5: 6 - 8:Link 8', + ' 7:16 - 1:Link 1', + '_ 8: 9 - 2:Link 2', + '_10:15 - 3:Link 3', + '__11:14 - 4:Link 4', + '___12:13 - 5:Link 5', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a node with no lft and rght columns + */ + public function testMoveDownNoTreeColumns(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $node = $table->get(1); + $node->unset('lft'); + $node->unset('rght'); + $node = $table->getBehavior('Tree')->moveDown($node, true); + $this->assertEquals(['lft' => 7, 'rght' => 16], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1: 4 - 6:Link 6', + '_ 2: 3 - 7:Link 7', + ' 5: 6 - 8:Link 8', + ' 7:16 - 1:Link 1', + '_ 8: 9 - 2:Link 2', + '_10:15 - 3:Link 3', + '__11:14 - 4:Link 4', + '___12:13 - 5:Link 5', + ]; + $this->assertMpttValues($expected, $table); + } + + public function testMoveDownMultiplePositions(): void + { + $node = $this->table->getBehavior('Tree')->moveDown($this->table->get(3), 2); + $this->assertEquals(['lft' => 7, 'rght' => 8], $node->extract(['lft', 'rght'])); + $expected = [ + ' 1:20 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 4:lcd', + '__ 5: 6 - 5:plasma', + '__ 7: 8 - 3:tube', + '_10:19 - 6:portable', + '__11:14 - 7:mp3', + '___12:13 - 8:flash', + '__15:16 - 9:cd', + '__17:18 - 10:radios', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests the recover function + */ + public function testRecover(): void + { + $table = $this->table; + + $expectedLevels = $table + ->find('list', valueField: 'depth') + ->orderBy('lft') + ->toArray(); + $table->updateAll(['lft' => null, 'rght' => null, 'depth' => null], []); + $table->behaviors()->Tree->setConfig('level', 'depth'); + $table->getBehavior('Tree')->recover(); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '_10:19 - 6:portable', + '__11:14 - 7:mp3', + '___12:13 - 8:flash', + '__15:16 - 9:cd', + '__17:18 - 10:radios', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $table); + + $result = $table + ->find('list', valueField: 'depth') + ->orderBy('lft') + ->toArray(); + $this->assertSame($expectedLevels, $result); + } + + /** + * Tests the recover function with a custom scope + */ + public function testRecoverScoped(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $table->updateAll(['lft' => null, 'rght' => null], ['menu' => 'main-menu']); + $table->getBehavior('Tree')->recover(); + + $expected = [ + ' 1:10 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + '_ 4: 9 - 3:Link 3', + '__ 5: 8 - 4:Link 4', + '___ 6: 7 - 5:Link 5', + '11:14 - 6:Link 6', + '_12:13 - 7:Link 7', + '15:16 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + $table->removeBehavior('Tree'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'categories']]); + $expected = [ + ' 1:10 - 9:electronics', + '_ 2: 9 - 10:televisions', + '__ 3: 4 - 11:tube', + '__ 5: 8 - 12:lcd', + '___ 6: 7 - 13:plasma', + '11:20 - 14:portable', + '_12:15 - 15:mp3', + '__13:14 - 16:flash', + '_16:17 - 17:cd', + '_18:19 - 18:radios', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Test recover function with a custom order clause + */ + public function testRecoverWithCustomOrder(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu'], 'recoverOrder' => ['MenuLinkTrees.title' => 'desc']]); + $table->updateAll(['lft' => null, 'rght' => null], ['menu' => 'main-menu']); + $table->getBehavior('Tree')->recover(); + + $expected = [ + ' 1: 2 - 8:Link 8', + ' 3: 6 - 6:Link 6', + '_ 4: 5 - 7:Link 7', + ' 7:16 - 1:Link 1', + '_ 8:13 - 3:Link 3', + '__ 9:12 - 4:Link 4', + '___10:11 - 5:Link 5', + '_14:15 - 2:Link 2', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests adding a new orphan node + */ + public function testAddOrphan(): void + { + $table = $this->table; + $entity = new Entity( + ['name' => 'New Orphan', 'parent_id' => null, 'level' => null], + ['markNew' => true], + ); + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(23, $entity->lft); + $this->assertSame(24, $entity->rght); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '_10:19 - 6:portable', + '__11:14 - 7:mp3', + '___12:13 - 8:flash', + '__15:16 - 9:cd', + '__17:18 - 10:radios', + '21:22 - 11:alien hardware', + '23:24 - 12:New Orphan', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests that adding a child node as a descendant of one of the roots works + */ + public function testAddMiddle(): void + { + $table = $this->table; + $entity = new Entity( + ['name' => 'laptops', 'parent_id' => 1], + ['markNew' => true], + ); + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(20, $entity->lft); + $this->assertSame(21, $entity->rght); + + $expected = [ + ' 1:22 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '_10:19 - 6:portable', + '__11:14 - 7:mp3', + '___12:13 - 8:flash', + '__15:16 - 9:cd', + '__17:18 - 10:radios', + '_20:21 - 12:laptops', + '23:24 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests adding a leaf to the tree + */ + public function testAddLeaf(): void + { + $table = $this->table; + $entity = new Entity( + ['name' => 'laptops', 'parent_id' => 2], + ['markNew' => true], + ); + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(9, $entity->lft); + $this->assertSame(10, $entity->rght); + + $expected = [ + ' 1:22 - 1:electronics', + '_ 2:11 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '__ 9:10 - 12:laptops', + '_12:21 - 6:portable', + '__13:16 - 7:mp3', + '___14:15 - 8:flash', + '__17:18 - 9:cd', + '__19:20 - 10:radios', + '23:24 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests adding a root element to the tree when all other root elements have children + */ + public function testAddRoot(): void + { + $table = $this->table; + + //First add a child to the empty root element + $alien = $table->find()->where(['name' => 'alien hardware'])->first(); + $entity = new Entity(['name' => 'plasma rifle', 'parent_id' => $alien->id], ['markNew' => true]); + $table->save($entity); + + $entity = new Entity(['name' => 'carpentry', 'parent_id' => null], ['markNew' => true]); + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(25, $entity->lft); + $this->assertSame(26, $entity->rght); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '_10:19 - 6:portable', + '__11:14 - 7:mp3', + '___12:13 - 8:flash', + '__15:16 - 9:cd', + '__17:18 - 10:radios', + '21:24 - 11:alien hardware', + '_22:23 - 12:plasma rifle', + '25:26 - 13:carpentry', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests making a node its own parent as an existing entity + */ + public function testReParentSelf(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage("Cannot set a node's parent as itself"); + $entity = $this->table->get(1); + $entity->parent_id = $entity->id; + $this->table->save($entity); + } + + /** + * Tests making a node its own parent as a new entity. + */ + public function testReParentSelfNewEntity(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage("Cannot set a node's parent as itself"); + $entity = $this->table->newEntity(['name' => 'root']); + $entity->id = 1; + $entity->parent_id = $entity->id; + $this->table->save($entity); + } + + /** + * Tests moving a subtree to the right + */ + public function testReParentSubTreeRight(): void + { + $table = $this->table; + $entity = $table->get(2); + $entity->parent_id = 6; + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(11, $entity->lft); + $this->assertSame(18, $entity->rght); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2:19 - 6:portable', + '__ 3: 6 - 7:mp3', + '___ 4: 5 - 8:flash', + '__ 7: 8 - 9:cd', + '__ 9:10 - 10:radios', + '__11:18 - 2:televisions', + '___12:13 - 3:tube', + '___14:15 - 4:lcd', + '___16:17 - 5:plasma', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a subtree to the left + */ + public function testReParentSubTreeLeft(): void + { + $table = $this->table; + $entity = $table->get(6); + $entity->parent_id = 2; + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(9, $entity->lft); + $this->assertSame(18, $entity->rght); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2:19 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '__ 9:18 - 6:portable', + '___10:13 - 7:mp3', + '____11:12 - 8:flash', + '___14:15 - 9:cd', + '___16:17 - 10:radios', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Test moving a leaft to the left + */ + public function testReParentLeafLeft(): void + { + $table = $this->table; + $entity = $table->get(10); + $entity->parent_id = 2; + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(9, $entity->lft); + $this->assertSame(10, $entity->rght); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2:11 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '__ 9:10 - 10:radios', + '_12:19 - 6:portable', + '__13:16 - 7:mp3', + '___14:15 - 8:flash', + '__17:18 - 9:cd', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Test moving a leaf to the left + */ + public function testReParentLeafRight(): void + { + $table = $this->table; + $entity = $table->get(5); + $entity->parent_id = 6; + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(17, $entity->lft); + $this->assertSame(18, $entity->rght); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2: 7 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '_ 8:19 - 6:portable', + '__ 9:12 - 7:mp3', + '___10:11 - 8:flash', + '__13:14 - 9:cd', + '__15:16 - 10:radios', + '__17:18 - 5:plasma', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a subtree with a node having no lft and rght columns + */ + public function testReParentNoTreeColumns(): void + { + $table = $this->table; + $entity = $table->get(6); + $entity->unset('lft'); + $entity->unset('rght'); + $entity->parent_id = 2; + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(9, $entity->lft); + $this->assertSame(18, $entity->rght); + + $expected = [ + ' 1:20 - 1:electronics', + '_ 2:19 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '__ 9:18 - 6:portable', + '___10:13 - 7:mp3', + '____11:12 - 8:flash', + '___14:15 - 9:cd', + '___16:17 - 10:radios', + '21:22 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests moving a subtree as a new root + */ + public function testRootingSubTree(): void + { + $table = $this->table; + $entity = $table->get(2); + $entity->parent_id = null; + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(15, $entity->lft); + $this->assertSame(22, $entity->rght); + + $expected = [ + ' 1:12 - 1:electronics', + '_ 2:11 - 6:portable', + '__ 3: 6 - 7:mp3', + '___ 4: 5 - 8:flash', + '__ 7: 8 - 9:cd', + '__ 9:10 - 10:radios', + '13:14 - 11:alien hardware', + '15:22 - 2:televisions', + '_16:17 - 3:tube', + '_18:19 - 4:lcd', + '_20:21 - 5:plasma', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests moving a subtree with no tree columns + */ + public function testRootingNoTreeColumns(): void + { + $table = $this->table; + $entity = $table->get(2); + $entity->unset('lft'); + $entity->unset('rght'); + $entity->parent_id = null; + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(15, $entity->lft); + $this->assertSame(22, $entity->rght); + + $expected = [ + ' 1:12 - 1:electronics', + '_ 2:11 - 6:portable', + '__ 3: 6 - 7:mp3', + '___ 4: 5 - 8:flash', + '__ 7: 8 - 9:cd', + '__ 9:10 - 10:radios', + '13:14 - 11:alien hardware', + '15:22 - 2:televisions', + '_16:17 - 3:tube', + '_18:19 - 4:lcd', + '_20:21 - 5:plasma', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests that trying to create a cycle throws an exception + */ + public function testReparentCycle(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot use node `5` as parent for entity `2`.'); + $table = $this->table; + $entity = $table->get(2); + $entity->parent_id = 5; + $table->save($entity); + } + + /** + * Tests deleting a leaf in the tree + */ + public function testDeleteLeaf(): void + { + $table = $this->table; + $entity = $table->get(4); + $this->assertTrue($table->delete($entity)); + + $expected = [ + ' 1:18 - 1:electronics', + '_ 2: 7 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 5:plasma', + '_ 8:17 - 6:portable', + '__ 9:12 - 7:mp3', + '___10:11 - 8:flash', + '__13:14 - 9:cd', + '__15:16 - 10:radios', + '19:20 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests deleting a subtree + */ + public function testDeleteSubTree(): void + { + $table = $this->table; + $entity = $table->get(6); + $this->assertTrue($table->delete($entity)); + + $expected = [ + ' 1:10 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '11:12 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests deleting a subtree in a scoped tree + */ + public function testDeleteSubTreeScopedTree(): void + { + $table = $this->getTableLocator()->get('MenuLinkTrees'); + $table->addBehavior('Tree', ['scope' => ['menu' => 'main-menu']]); + $entity = $table->get(3); + $this->assertTrue($table->delete($entity)); + + $expected = [ + ' 1: 4 - 1:Link 1', + '_ 2: 3 - 2:Link 2', + ' 5: 8 - 6:Link 6', + '_ 6: 7 - 7:Link 7', + ' 9:10 - 8:Link 8', + ]; + $this->assertMpttValues($expected, $table); + + $table->behaviors()->get('Tree')->setConfig('scope', ['menu' => 'categories']); + $expected = [ + ' 1:10 - 9:electronics', + '_ 2: 9 - 10:televisions', + '__ 3: 4 - 11:tube', + '__ 5: 8 - 12:lcd', + '___ 6: 7 - 13:plasma', + '11:20 - 14:portable', + '_12:15 - 15:mp3', + '__13:14 - 16:flash', + '_16:17 - 17:cd', + '_18:19 - 18:radios', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests deleting a subtree with ORM delete callbacks + */ + public function testDeleteSubTreeWithCallbacks(): void + { + $NumberTreesArticles = $this->getTableLocator()->get('NumberTreesArticles'); + $newArticle = $NumberTreesArticles->newEntity([ + 'number_tree_id' => 7, // Link to sub-tree item + 'title' => 'New Article', + 'body' => 'New Article Body', + 'published' => 'Y', + ]); + $NumberTreesArticles->save($newArticle); + + $table = $this->table; + $table->addAssociations([ + 'hasMany' => [ + 'NumberTreesArticles' => [ + 'cascadeCallbacks' => true, + 'dependent' => true, + ], + ], + ]); + $table->getBehavior('Tree')->setConfig(['cascadeCallbacks' => true]); + + // Delete parent category + $entity = $table->get(6); + $this->assertTrue($table->delete($entity)); + + $expected = [ + ' 1:12 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '13:14 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + + // Check if new article which was linked to sub-category was deleted + $count = $NumberTreesArticles->find() + ->where(['number_tree_id' => 7]) + ->count(); + $this->assertSame(0, $count); + } + + /** + * Test deleting a root node + */ + public function testDeleteRoot(): void + { + $table = $this->table; + $entity = $table->get(1); + $this->assertTrue($table->delete($entity)); + + $expected = [ + ' 1: 2 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Test deleting a node with no tree columns + */ + public function testDeleteRootNoTreeColumns(): void + { + $table = $this->table; + $entity = $table->get(1); + $entity->unset('lft'); + $entity->unset('rght'); + $this->assertTrue($table->delete($entity)); + + $expected = [ + ' 1: 2 - 11:alien hardware', + ]; + $this->assertMpttValues($expected, $this->table); + } + + /** + * Tests that a leaf can be taken out of the tree and put in as a root + */ + public function testRemoveFromLeafFromTree(): void + { + $table = $this->table; + $entity = $table->get(10); + $this->assertSame($entity, $table->getBehavior('Tree')->removeFromTree($entity)); + $this->assertSame(21, $entity->lft); + $this->assertSame(22, $entity->rght); + $this->assertNull($entity->parent_id); + $expected = [ + ' 1:18 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '_10:17 - 6:portable', + '__11:14 - 7:mp3', + '___12:13 - 8:flash', + '__15:16 - 9:cd', + '19:20 - 11:alien hardware', + '21:22 - 10:radios', + + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Test removing a middle node from a tree + */ + public function testRemoveMiddleNodeFromTree(): void + { + $table = $this->table; + $entity = $table->get(6); + $this->assertSame($entity, $table->getBehavior('Tree')->removeFromTree($entity)); + $this->assertSame(21, $entity->lft); + $this->assertSame(22, $entity->rght); + $this->assertNull($entity->parent_id); + $expected = [ + ' 1:18 - 1:electronics', + '_ 2: 9 - 2:televisions', + '__ 3: 4 - 3:tube', + '__ 5: 6 - 4:lcd', + '__ 7: 8 - 5:plasma', + '_10:13 - 7:mp3', + '__11:12 - 8:flash', + '_14:15 - 9:cd', + '_16:17 - 10:radios', + '19:20 - 11:alien hardware', + '21:22 - 6:portable', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests removing the root of a tree + */ + public function testRemoveRootFromTree(): void + { + $table = $this->table; + $entity = $table->get(1); + $this->assertSame($entity, $table->getBehavior('Tree')->removeFromTree($entity)); + $this->assertSame(21, $entity->lft); + $this->assertSame(22, $entity->rght); + $this->assertNull($entity->parent_id); + + $expected = [ + ' 1: 8 - 2:televisions', + '_ 2: 3 - 3:tube', + '_ 4: 5 - 4:lcd', + '_ 6: 7 - 5:plasma', + ' 9:18 - 6:portable', + '_10:13 - 7:mp3', + '__11:12 - 8:flash', + '_14:15 - 9:cd', + '_16:17 - 10:radios', + '19:20 - 11:alien hardware', + '21:22 - 1:electronics', + ]; + $this->assertMpttValues($expected, $table); + } + + /** + * Tests that using associations having tree fields in the schema + * does not generate SQL errors + */ + public function testFindPathWithAssociation(): void + { + $table = $this->table; + $this->getTableLocator()->get('FriendlyTrees', [ + 'table' => $table->getTable(), + ]); + $table->hasOne('FriendlyTrees', [ + 'foreignKey' => 'id', + ]); + $result = $table + ->find('children', for: 1) + ->contain('FriendlyTrees') + ->toArray(); + $this->assertCount(9, $result); + } + + /** + * Tests getting the depth level of a node in the tree. + */ + public function testGetLevel(): void + { + $entity = $this->table->get(8); + $result = $this->table->getBehavior('Tree')->getLevel($entity); + $this->assertSame(3, $result); + + $result = $this->table->getBehavior('Tree')->getLevel($entity->id); + $this->assertSame(3, $result); + + $result = $this->table->getBehavior('Tree')->getLevel(5); + $this->assertSame(2, $result); + + $result = $this->table->getBehavior('Tree')->getLevel(99999); + $this->assertFalse($result); + } + + /** + * Test setting level for new nodes + */ + public function testSetLevelNewNode(): void + { + $this->table->behaviors()->Tree->setConfig('level', 'depth'); + + $entity = new Entity(['parent_id' => null, 'name' => 'Depth 0']); + $this->table->save($entity); + $entity = $this->table->get(12); + $this->assertSame(0, $entity->depth); + + $entity = new Entity(['parent_id' => 1, 'name' => 'Depth 1']); + $this->table->save($entity); + $entity = $this->table->get(13); + $this->assertSame(1, $entity->depth); + + $entity = new Entity(['parent_id' => 8, 'name' => 'Depth 4']); + $this->table->save($entity); + $entity = $this->table->get(14); + $this->assertSame(4, $entity->depth); + } + + /** + * Test setting level for existing nodes + */ + public function testSetLevelExistingNode(): void + { + $this->table->behaviors()->Tree->setConfig('level', 'depth'); + + // Leaf node + $entity = $this->table->get(4); + $this->assertSame(2, $entity->depth); + $this->table->save($entity); + $entity = $this->table->get(4); + $this->assertSame(2, $entity->depth); + + // Non leaf node so depth of descendants will also change + $entity = $this->table->get(6); + $this->assertSame(1, $entity->depth); + + $entity->parent_id = null; + $this->table->save($entity); + $entity = $this->table->get(6); + $this->assertSame(0, $entity->depth); + + $entity = $this->table->get(7); + $this->assertSame(1, $entity->depth); + + $entity = $this->table->get(8); + $this->assertSame(2, $entity->depth); + + $entity->parent_id = 6; + $this->table->save($entity); + $entity = $this->table->get(8); + $this->assertSame(1, $entity->depth); + } + + /** + * Assert MPTT values + * + * Custom assert method to make identifying the differences between expected + * and actual db state easier to identify. + * + * @param array $expected tree state to be expected + * @param \Cake\ORM\Table $table Table instance + * @param \Cake\ORM\Query\SelectQuery $query Optional query object + */ + public function assertMpttValues($expected, $table, $query = null): void + { + $query = $query ?: $table->find(); + $primaryKey = $table->getPrimaryKey(); + if (is_array($primaryKey)) { + $primaryKey = $primaryKey[0]; + } + $displayField = $table->getDisplayField(); + + $options = [ + 'valuePath' => function ($item, $key, $iterator) use ($primaryKey, $displayField) { + return sprintf( + '%s:%s - %s:%s', + str_pad((string)$item->lft, 2, ' ', STR_PAD_LEFT), + str_pad((string)$item->rght, 2, ' ', STR_PAD_LEFT), + str_pad((string)$item->$primaryKey, 2, ' ', STR_PAD_LEFT), + $item->{$displayField}, + ); + }, + ]; + $result = array_values($query->find('treeList', ...$options)->toArray()); + + if (count($result) === count($expected)) { + $subExpected = array_diff($expected, $result); + if ($subExpected) { + $subResult = array_intersect_key($result, $subExpected); + $this->assertSame($subExpected, $subResult, 'Differences in the tree were found (lft:rght id:display-name)'); + } + } + + $this->assertSame($expected, $result, 'The tree is not the same (lft:rght id:display-name)'); + } +} diff --git a/tests/TestCase/ORM/BehaviorRegistryTest.php b/tests/TestCase/ORM/BehaviorRegistryTest.php new file mode 100644 index 00000000000..be784d374c4 --- /dev/null +++ b/tests/TestCase/ORM/BehaviorRegistryTest.php @@ -0,0 +1,424 @@ +Table = new Table(['table' => 'articles']); + $this->EventManager = $this->Table->getEventManager(); + $this->Behaviors = new BehaviorRegistry($this->Table); + static::setAppNamespace(); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + $this->clearPlugins(); + unset($this->Table, $this->EventManager, $this->Behaviors); + parent::tearDown(); + } + + /** + * Test classname resolution. + */ + public function testClassName(): void + { + $this->loadPlugins(['TestPlugin']); + + $expected = TranslateBehavior::class; + $result = BehaviorRegistry::className('Translate'); + $this->assertSame($expected, $result); + + $expected = PersisterOneBehavior::class; + $result = BehaviorRegistry::className('TestPlugin.PersisterOne'); + $this->assertSame($expected, $result); + + $this->assertNull(BehaviorRegistry::className('NonExistent')); + } + + /** + * Test loading behaviors. + */ + public function testLoad(): void + { + $this->loadPlugins(['TestPlugin']); + $config = ['alias' => 'Sluggable', 'replacement' => '-']; + $result = $this->Behaviors->load('Sluggable', $config); + $this->assertInstanceOf(SluggableBehavior::class, $result); + $this->assertEquals($config, $result->getConfig()); + + $result = $this->Behaviors->load('TestPlugin.PersisterOne'); + $this->assertInstanceOf(PersisterOneBehavior::class, $result); + + $config = ['className' => 'TestPlugin.PersisterOne']; + $this->assertSame($config, $result->getConfig()); + + $this->Behaviors->unload('PersisterOne'); + $this->Behaviors->load('TestPlugin.PersisterOne', $config); + $this->assertInstanceOf(PersisterOneBehavior::class, $this->Behaviors->PersisterOne); + } + + /** + * Test load() binding listeners. + */ + public function testLoadBindEvents(): void + { + $result = $this->EventManager->listeners('Model.beforeFind'); + $this->assertCount(0, $result); + + $sluggable = $this->Behaviors->load('Sluggable'); + $result = $this->EventManager->listeners('Model.beforeFind'); + $this->assertEquals([['callable' => $sluggable->beforeFind(...)]], $result); + } + + /** + * Test load() with enabled = false + */ + public function testLoadEnabledFalse(): void + { + $result = $this->EventManager->listeners('Model.beforeFind'); + $this->assertCount(0, $result); + + $this->Behaviors->load('Sluggable', ['enabled' => false]); + $result = $this->EventManager->listeners('Model.beforeFind'); + $this->assertCount(0, $result); + } + + /** + * Test loading plugin behaviors + */ + public function testLoadPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + $result = $this->Behaviors->load('TestPlugin.PersisterOne'); + + $expected = PersisterOneBehavior::class; + $this->assertInstanceOf($expected, $result); + $this->assertInstanceOf($expected, $this->Behaviors->PersisterOne); + + $this->Behaviors->unload('PersisterOne'); + + $result = $this->Behaviors->load('TestPlugin.PersisterOne', ['foo' => 'bar']); + $this->assertInstanceOf($expected, $result); + $this->assertInstanceOf($expected, $this->Behaviors->PersisterOne); + } + + /** + * Test load() on undefined class + */ + public function testLoadMissingClass(): void + { + $this->expectException(MissingBehaviorException::class); + $this->Behaviors->load('DoesNotExist'); + } + + /** + * Test load() duplicate method error + */ + public function testLoadDuplicateMethodError(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('`TestApp\Model\Behavior\DuplicateBehavior` contains duplicate method `slugify`' + . ' which is already provided by `Sluggable`.'); + $this->Behaviors->load('Sluggable'); + $this->Behaviors->load('Duplicate'); + } + + /** + * Test load() duplicate method aliasing + */ + public function testLoadDuplicateMethodAliasing(): void + { + $this->Behaviors->load('Tree'); + $this->Behaviors->load('Duplicate', [ + 'implementedFinders' => [ + 'renamed' => 'findChildren', + ], + 'implementedMethods' => [ + 'renamed' => 'slugify', + ], + ]); + $this->assertTrue($this->Behaviors->hasMethod('renamed')); + } + + /** + * Test load() duplicate finder error + */ + public function testLoadDuplicateFinderError(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('`TestApp\Model\Behavior\DuplicateBehavior` contains duplicate finder `children`' + . ' which is already provided by `Tree`.'); + $this->Behaviors->load('Tree'); + $this->Behaviors->load('Duplicate'); + } + + /** + * Test load() duplicate finder aliasing + */ + public function testLoadDuplicateFinderAliasing(): void + { + $this->Behaviors->load('Tree'); + $this->Behaviors->load('Duplicate', [ + 'implementedFinders' => [ + 'renamed' => 'findChildren', + ], + ]); + $this->assertTrue($this->Behaviors->hasFinder('renamed')); + } + + public function testSet(): void + { + $this->Behaviors->set('Sluggable', new SluggableBehavior($this->Table, ['replacement' => '_'])); + + $this->assertEquals(['replacement' => '_'], $this->Behaviors->get('Sluggable')->getConfig()); + $this->assertTrue($this->Behaviors->hasMethod('slugify')); + } + + /** + * test hasMethod() + */ + public function testHasMethod(): void + { + $this->loadPlugins(['TestPlugin']); + $this->Behaviors->load('TestPlugin.PersisterOne'); + $this->Behaviors->load('Sluggable'); + + $this->assertTrue($this->Behaviors->hasMethod('slugify')); + $this->assertTrue($this->Behaviors->hasMethod('SLUGIFY')); + + $this->assertTrue($this->Behaviors->hasMethod('persist')); + $this->assertTrue($this->Behaviors->hasMethod('PERSIST')); + + $this->assertFalse($this->Behaviors->hasMethod('__construct')); + $this->assertFalse($this->Behaviors->hasMethod('config')); + $this->assertFalse($this->Behaviors->hasMethod('implementedEvents')); + + $this->assertFalse($this->Behaviors->hasMethod('nope')); + $this->assertFalse($this->Behaviors->hasMethod('beforeFind')); + $this->assertFalse($this->Behaviors->hasMethod('noSlug')); + } + + /** + * Test hasFinder() method. + */ + public function testHasFinder(): void + { + $this->Behaviors->load('Sluggable'); + + $this->assertTrue($this->Behaviors->hasFinder('noSlug')); + $this->assertTrue($this->Behaviors->hasFinder('noslug')); + $this->assertTrue($this->Behaviors->hasFinder('NOSLUG')); + + $this->assertFalse($this->Behaviors->hasFinder('slugify')); + $this->assertFalse($this->Behaviors->hasFinder('beforeFind')); + $this->assertFalse($this->Behaviors->hasFinder('nope')); + } + + /** + * test call + * + * Setup a behavior, then replace it with a mock to verify methods are called. + * use dummy return values to verify the return value makes it back + */ + public function testCall(): void + { + $this->deprecated(function (): void { + $this->Behaviors->load('Sluggable'); + $return = $this->Behaviors->call('slugify', ['some value']); + $this->assertSame('some-value', $return); + }); + } + + /** + * Test errors on unknown methods. + */ + public function testCallError(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot call `nope`, it does not belong to any attached behavior.'); + $this->Behaviors->load('Sluggable'); + + $this->deprecated(function (): void { + $this->Behaviors->call('nope'); + }); + } + + /** + * test call finder + * + * Setup a behavior, then replace it with a mock to verify methods are called. + * use dummy return values to verify the return value makes it back + */ + public function testCallFinder(): void + { + $this->Behaviors->load('Sluggable'); + $mockedBehavior = Mockery::mock(Behavior::class)->makePartial(); + $mockedBehavior->shouldReceive(['implementedFinders' => ['noslug' => 'findNoSlug']]); + $this->Behaviors->set('Sluggable', $mockedBehavior); + + $query = new SelectQuery($this->Table); + $mockedBehavior->shouldReceive('findNoSlug') + ->once() + ->with($query) + ->andReturn($query); + $return = $this->Behaviors->callFinder('noSlug', $query); + $this->assertSame($query, $return); + } + + /** + * Test errors on unknown methods. + */ + public function testCallFinderError(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot call finder `nope`'); + $this->Behaviors->load('Sluggable'); + $this->Behaviors->callFinder('nope', new SelectQuery($this->Table)); + } + + /** + * Test errors on unloaded behavior methods. + */ + public function testUnloadBehaviorThenCall(): void + { + $this->deprecated(function (): void { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot call `slugify`, it does not belong to any attached behavior.'); + $this->Behaviors->load('Sluggable'); + + $this->assertTrue($this->Behaviors->hasMethod('slugify')); + $this->Behaviors->unload('Sluggable'); + + $this->assertFalse($this->Behaviors->hasMethod('slugify'), 'should not have method anymore'); + $this->Behaviors->call('slugify'); + }); + } + + /** + * Test errors on unloaded behavior finders. + */ + public function testUnloadBehaviorThenCallFinder(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot call finder `noslug`, it does not belong to any attached behavior.'); + $this->Behaviors->load('Sluggable'); + $this->assertTrue($this->Behaviors->hasFinder('noSlug')); + $this->Behaviors->unload('Sluggable'); + + $this->Behaviors->callFinder('noSlug', new SelectQuery($this->Table)); + $this->assertFalse($this->Behaviors->hasFinder('noSlug')); + } + + /** + * Test that unloading then reloading a behavior does not throw any errors. + */ + public function testUnloadBehaviorThenReload(): void + { + $this->Behaviors->load('Sluggable'); + $this->Behaviors->unload('Sluggable'); + + $this->assertEmpty($this->Behaviors->loaded()); + + $this->Behaviors->load('Sluggable'); + + $this->assertEquals(['Sluggable'], $this->Behaviors->loaded()); + } + + /** + * Test that unloading a none existing behavior triggers an error. + */ + public function testUnload(): void + { + $this->Behaviors->load('Sluggable'); + $this->assertTrue($this->Behaviors->hasFinder('noSlug')); + + $this->Behaviors->load('Validation'); + $this->assertTrue($this->Behaviors->hasMethod('customValidationRule')); + + $this->Behaviors->unload('Validation'); + $this->Behaviors->unload('Sluggable'); + + $this->assertEmpty($this->Behaviors->loaded()); + $this->assertCount(0, $this->EventManager->listeners('Model.beforeFind')); + $this->assertFalse($this->Behaviors->hasFinder('noSlug')); + $this->assertFalse($this->Behaviors->hasFinder('noslug')); + $this->assertFalse($this->Behaviors->hasMethod('customValidationRule')); + $this->assertFalse($this->Behaviors->hasMethod('customvalidationrule')); + } + + /** + * Test that unloading a none existing behavior triggers an error. + */ + public function testUnloadUnknown(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unknown object `Foo`'); + $this->Behaviors->unload('Foo'); + } + + /** + * Test setTable() method. + */ + public function testSetTable(): void + { + $table = new Table(['table' => 'users']); + + $this->Behaviors->setTable($table); + $this->assertSame($table->getEventManager(), $this->Behaviors->getEventManager()); + } +} diff --git a/tests/TestCase/ORM/BehaviorTest.php b/tests/TestCase/ORM/BehaviorTest.php new file mode 100644 index 00000000000..59c1743c8d1 --- /dev/null +++ b/tests/TestCase/ORM/BehaviorTest.php @@ -0,0 +1,288 @@ + 'value']; + $behavior = new TestBehavior($table, $config); + $this->assertEquals($config, $behavior->getConfig()); + } + + /** + * Test getting table instance. + */ + public function testGetTable(): void + { + $table = Mockery::mock(Table::class); + + $behavior = new TestBehavior($table); + $this->assertSame($table, $behavior->table()); + } + + public function testReflectionCache(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test3Behavior($table); + $expected = [ + 'finders' => [ + 'foo' => 'findFoo', + ], + 'methods' => [ + 'doSomething' => 'doSomething', + 'testReflectionCache' => 'testReflectionCache', + ], + ]; + $this->assertEquals($expected, $behavior->testReflectionCache()); + } + + /** + * Test the default behavior of implementedEvents + */ + public function testImplementedEvents(): void + { + $table = Mockery::mock(Table::class); + $behavior = new TestBehavior($table); + $expected = [ + 'Model.beforeFind' => 'beforeFind', + 'Model.afterSaveCommit' => 'afterSaveCommit', + 'Model.buildRules' => 'buildRules', + 'Model.beforeRules' => 'beforeRules', + 'Model.afterRules' => 'afterRules', + 'Model.afterDeleteCommit' => 'afterDeleteCommit', + ]; + $this->assertEquals($expected, $behavior->implementedEvents()); + } + + /** + * Test that implementedEvents uses the priority setting. + */ + public function testImplementedEventsWithPriority(): void + { + $table = Mockery::mock(Table::class); + $behavior = new TestBehavior($table, ['priority' => 10]); + $expected = [ + 'Model.beforeFind' => [ + 'priority' => 10, + 'callable' => 'beforeFind', + ], + 'Model.afterSaveCommit' => [ + 'priority' => 10, + 'callable' => 'afterSaveCommit', + ], + 'Model.beforeRules' => [ + 'priority' => 10, + 'callable' => 'beforeRules', + ], + 'Model.afterRules' => [ + 'priority' => 10, + 'callable' => 'afterRules', + ], + 'Model.buildRules' => [ + 'priority' => 10, + 'callable' => 'buildRules', + ], + 'Model.afterDeleteCommit' => [ + 'priority' => 10, + 'callable' => 'afterDeleteCommit', + ], + ]; + $this->assertEquals($expected, $behavior->implementedEvents()); + } + + /** + * testImplementedMethods + */ + public function testImplementedMethods(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table); + $expected = [ + 'doSomething' => 'doSomething', + ]; + $this->assertEquals($expected, $behavior->implementedMethods()); + } + + /** + * testImplementedMethodsAliased + */ + public function testImplementedMethodsAliased(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table, [ + 'implementedMethods' => [ + 'aliased' => 'doSomething', + ], + ]); + $expected = [ + 'aliased' => 'doSomething', + ]; + $this->assertEquals($expected, $behavior->implementedMethods()); + } + + /** + * testImplementedMethodsDisabled + */ + public function testImplementedMethodsDisabled(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table, [ + 'implementedMethods' => [], + ]); + $expected = []; + $this->assertEquals($expected, $behavior->implementedMethods()); + } + + /** + * testImplementedFinders + */ + public function testImplementedFinders(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table); + $expected = [ + 'foo' => 'findFoo', + ]; + $this->assertEquals($expected, $behavior->implementedFinders()); + } + + /** + * testImplementedFindersAliased + */ + public function testImplementedFindersAliased(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table, [ + 'implementedFinders' => [ + 'aliased' => 'findFoo', + ], + ]); + $expected = [ + 'aliased' => 'findFoo', + ]; + $this->assertEquals($expected, $behavior->implementedFinders()); + } + + /** + * testImplementedFindersDisabled + */ + public function testImplementedFindersDisabled(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table, [ + 'implementedFinders' => [], + ]); + $this->assertEquals([], $behavior->implementedFinders()); + } + + /** + * testVerifyConfig + * + * Don't expect an exception to be thrown + */ + public function testVerifyConfig(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table); + $behavior->verifyConfig(); + $this->assertTrue(true, 'No exception thrown'); + } + + /** + * testVerifyConfigImplementedFindersOverridden + * + * Simply don't expect an exception to be thrown + */ + public function testVerifyConfigImplementedFindersOverridden(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table, [ + 'implementedFinders' => [ + 'aliased' => 'findFoo', + ], + ]); + $behavior->verifyConfig(); + $this->assertTrue(true, 'No exception thrown'); + } + + /** + * testVerifyImplementedFindersInvalid + */ + public function testVerifyImplementedFindersInvalid(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('The method `findNotDefined` is not callable on class `' . Test2Behavior::class . '`'); + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table, [ + 'implementedFinders' => [ + 'aliased' => 'findNotDefined', + ], + ]); + $behavior->verifyConfig(); + } + + /** + * testVerifyConfigImplementedMethodsOverridden + * + * Don't expect an exception to be thrown + */ + public function testVerifyConfigImplementedMethodsOverridden(): void + { + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table); + $behavior = new Test2Behavior($table, [ + 'implementedMethods' => [ + 'aliased' => 'doSomething', + ], + ]); + $behavior->verifyConfig(); + $this->assertTrue(true, 'No exception thrown'); + } + + /** + * testVerifyImplementedMethodsInvalid + */ + public function testVerifyImplementedMethodsInvalid(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('The method `iDoNotExist` is not callable on class `' . Test2Behavior::class . '`'); + $table = Mockery::mock(Table::class); + $behavior = new Test2Behavior($table, [ + 'implementedMethods' => [ + 'aliased' => 'iDoNotExist', + ], + ]); + $behavior->verifyConfig(); + } +} diff --git a/tests/TestCase/ORM/BindingKeyTest.php b/tests/TestCase/ORM/BindingKeyTest.php new file mode 100644 index 00000000000..65484e56315 --- /dev/null +++ b/tests/TestCase/ORM/BindingKeyTest.php @@ -0,0 +1,132 @@ + + */ + protected array $fixtures = [ + 'core.AuthUsers', + 'core.SiteAuthors', + 'core.Users', + ]; + + /** + * Data provider for the two types of strategies BelongsTo and HasOne implements + * + * @return array + */ + public static function strategiesProviderJoinable(): array + { + return [['join'], ['select']]; + } + + /** + * Data provider for the two types of strategies HasMany and BelongsToMany implements + * + * @return array + */ + public static function strategiesProviderExternal(): array + { + return [['subquery'], ['select']]; + } + + /** + * Tests that bindingKey can be used in belongsTo associations + */ + #[DataProvider('strategiesProviderJoinable')] + public function testBelongsto(string $strategy): void + { + $users = $this->getTableLocator()->get('Users'); + $users->belongsTo('AuthUsers', [ + 'bindingKey' => 'username', + 'foreignKey' => 'username', + 'strategy' => $strategy, + ]); + + $result = $users->find() + ->contain(['AuthUsers']); + + $expected = ['mariano', 'nate', 'larry', 'garrett']; + $expected = array_combine($expected, $expected); + $this->assertEquals( + $expected, + $result->all()->combine('username', 'auth_user.username')->toArray(), + ); + + $expected = [1 => 1, 2 => 5, 3 => 2, 4 => 4]; + $this->assertEquals( + $expected, + $result->all()->combine('id', 'auth_user.id')->toArray(), + ); + } + + /** + * Tests that bindingKey can be used in hasOne associations + */ + #[DataProvider('strategiesProviderJoinable')] + public function testHasOne(string $strategy): void + { + $users = $this->getTableLocator()->get('Users'); + $users->hasOne('SiteAuthors', [ + 'bindingKey' => 'username', + 'foreignKey' => 'name', + 'strategy' => $strategy, + ]); + + $users->updateAll(['username' => 'jose'], ['username' => 'garrett']); + $result = $users->find() + ->contain(['SiteAuthors']) + ->where(['username' => 'jose']) + ->first(); + + $this->assertSame(3, $result->site_author->id); + } + + /** + * Tests that bindingKey can be used in hasOne associations + */ + #[DataProvider('strategiesProviderExternal')] + public function testHasMany(string $strategy): void + { + $users = $this->getTableLocator()->get('Users'); + $authors = $users->hasMany('SiteAuthors', [ + 'bindingKey' => 'username', + 'foreignKey' => 'name', + 'strategy' => $strategy, + ]); + + $authors->updateAll(['name' => 'garrett'], ['id >' => 2]); + $result = $users->find() + ->contain(['SiteAuthors']) + ->where(['username' => 'garrett']); + + $expected = [3, 4]; + $result = $result->all()->extract('site_authors.{*}.id')->toArray(); + $this->assertEquals($expected, $result); + } +} diff --git a/tests/TestCase/ORM/ColumnSchemaAwareTypeIntegrationTest.php b/tests/TestCase/ORM/ColumnSchemaAwareTypeIntegrationTest.php new file mode 100644 index 00000000000..00bc8c6cf3e --- /dev/null +++ b/tests/TestCase/ORM/ColumnSchemaAwareTypeIntegrationTest.php @@ -0,0 +1,84 @@ +typeMap = TypeFactory::getMap(); + + TypeFactory::map('text', ColumnSchemaAwareType::class); + // For SQLServer. + TypeFactory::map('nvarchar', ColumnSchemaAwareType::class); + + parent::setUp(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + TypeFactory::setMap($this->typeMap); + } + + public function testCustomTypesCanBeUsedInFixtures(): void + { + $table = $this->getTableLocator()->get('ColumnSchemaAwareTypeValues'); + + $expected = [ + 'this text has been processed via a custom type', + 'this text also has been processed via a custom type', + ]; + $result = $table->find()->orderByAsc('id')->all()->extract('val')->toArray(); + $this->assertSame($expected, $result); + } + + public function testCustomTypeCanProcessColumnInfo(): void + { + $column = $this->getTableLocator()->get('ColumnSchemaAwareTypeValues')->getSchema()->getColumn('val'); + + $this->assertSame('text', $column['type']); + $this->assertSame(255, $column['length']); + $this->assertSame('Custom schema aware type comment', $column['comment']); + } + + public function testCustomTypeReceivesAllColumnDefinitionKeys(): void + { + $table = $this->getTableLocator()->get('ColumnSchemaAwareTypeValues'); + + $type = Mockery::mock(new ColumnSchemaAwareType('char'))->makePartial(); + $type->shouldReceive('convertColumnDefinition') + ->once() + ->andReturnUsing(function (array $definition, Driver $driver) { + $this->assertEquals( + [ + 'length', + 'precision', + 'scale', + ], + array_keys($definition), + ); + + return null; + }); + + TypeFactory::set('text', $type); + TypeFactory::set('nvarchar', $type); + + $table->getSchema()->getColumn('val'); + } +} diff --git a/tests/TestCase/ORM/DtoMapperTest.php b/tests/TestCase/ORM/DtoMapperTest.php new file mode 100644 index 00000000000..69b707303fe --- /dev/null +++ b/tests/TestCase/ORM/DtoMapperTest.php @@ -0,0 +1,275 @@ +mapper = new DtoMapper(); + DtoMapper::clearCache(); + } + + protected function tearDown(): void + { + parent::tearDown(); + DtoMapper::clearCache(); + } + + public function testMapSimpleDto(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'body' => 'Test Body', + ]; + + $dto = $this->mapper->map($data, SimpleArticleDto::class); + + $this->assertInstanceOf(SimpleArticleDto::class, $dto); + $this->assertSame(1, $dto->id); + $this->assertSame('Test Article', $dto->title); + $this->assertSame('Test Body', $dto->body); + } + + public function testMapWithNullableField(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + ]; + + $dto = $this->mapper->map($data, SimpleArticleDto::class); + + $this->assertInstanceOf(SimpleArticleDto::class, $dto); + $this->assertSame(1, $dto->id); + $this->assertSame('Test Article', $dto->title); + $this->assertNull($dto->body); + } + + public function testMapWithDefaultValue(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + ]; + + $dto = $this->mapper->map($data, ArticleDto::class); + + $this->assertInstanceOf(ArticleDto::class, $dto); + $this->assertSame([], $dto->comments); + } + + public function testMapNestedDto(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'author' => [ + 'id' => 10, + 'name' => 'John Doe', + ], + ]; + + $dto = $this->mapper->map($data, ArticleDto::class); + + $this->assertInstanceOf(ArticleDto::class, $dto); + $this->assertInstanceOf(AuthorDto::class, $dto->author); + $this->assertSame(10, $dto->author->id); + $this->assertSame('John Doe', $dto->author->name); + } + + public function testMapNestedDtoNull(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'author' => null, + ]; + + $dto = $this->mapper->map($data, ArticleDto::class); + + $this->assertInstanceOf(ArticleDto::class, $dto); + $this->assertNull($dto->author); + } + + public function testMapCollectionOfDtos(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'comments' => [ + ['id' => 1, 'comment' => 'First comment', 'article_id' => 1, 'user_id' => 1], + ['id' => 2, 'comment' => 'Second comment', 'article_id' => 1, 'user_id' => 2], + ], + ]; + + $dto = $this->mapper->map($data, ArticleDto::class); + + $this->assertInstanceOf(ArticleDto::class, $dto); + $this->assertCount(2, $dto->comments); + $this->assertInstanceOf(CommentDto::class, $dto->comments[0]); + $this->assertInstanceOf(CommentDto::class, $dto->comments[1]); + $this->assertSame('First comment', $dto->comments[0]->comment); + $this->assertSame('Second comment', $dto->comments[1]->comment); + } + + public function testMapCollectionEmpty(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'comments' => [], + ]; + + $dto = $this->mapper->map($data, ArticleDto::class); + + $this->assertInstanceOf(ArticleDto::class, $dto); + $this->assertSame([], $dto->comments); + } + + public function testMapWithExtraFields(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'body' => 'Test Body', + 'extra_field' => 'ignored', + 'another_field' => 123, + ]; + + $dto = $this->mapper->map($data, SimpleArticleDto::class); + + $this->assertInstanceOf(SimpleArticleDto::class, $dto); + $this->assertSame(1, $dto->id); + $this->assertSame('Test Article', $dto->title); + $this->assertSame('Test Body', $dto->body); + } + + public function testMapComplexNestedStructure(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'body' => 'Test Body', + 'author' => [ + 'id' => 10, + 'name' => 'Jane Doe', + ], + 'comments' => [ + ['id' => 1, 'comment' => 'Great article!', 'article_id' => 1, 'user_id' => 5], + ['id' => 2, 'comment' => 'Thanks for sharing', 'article_id' => 1, 'user_id' => 6], + ['id' => 3, 'comment' => 'Very helpful', 'article_id' => 1, 'user_id' => 7], + ], + ]; + + $dto = $this->mapper->map($data, ArticleDto::class); + + $this->assertInstanceOf(ArticleDto::class, $dto); + $this->assertSame(1, $dto->id); + $this->assertSame('Test Article', $dto->title); + $this->assertSame('Test Body', $dto->body); + + $this->assertInstanceOf(AuthorDto::class, $dto->author); + $this->assertSame(10, $dto->author->id); + $this->assertSame('Jane Doe', $dto->author->name); + + $this->assertCount(3, $dto->comments); + $this->assertSame('Great article!', $dto->comments[0]->comment); + $this->assertSame(5, $dto->comments[0]->user_id); + } + + public function testCacheIsUsed(): void + { + $data = ['id' => 1, 'title' => 'Test', 'body' => 'Body']; + + // First call populates cache + $this->mapper->map($data, SimpleArticleDto::class); + + // Second call should use cache + $dto = $this->mapper->map($data, SimpleArticleDto::class); + + $this->assertInstanceOf(SimpleArticleDto::class, $dto); + } + + public function testClearCache(): void + { + $data = ['id' => 1, 'title' => 'Test', 'body' => 'Body']; + + $this->mapper->map($data, SimpleArticleDto::class); + + DtoMapper::clearCache(); + + // Should still work after clearing cache + $dto = $this->mapper->map($data, SimpleArticleDto::class); + + $this->assertInstanceOf(SimpleArticleDto::class, $dto); + } + + public function testMapWithDateTimeObjects(): void + { + $created = new DateTime('2024-01-15 10:30:00'); + $modified = new DateTime('2024-06-20 14:45:00'); + + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'created' => $created, + 'modified' => $modified, + ]; + + $dto = $this->mapper->map($data, ArticleWithDatesDto::class); + + $this->assertInstanceOf(ArticleWithDatesDto::class, $dto); + $this->assertSame(1, $dto->id); + $this->assertSame('Test Article', $dto->title); + // DateTime objects should be passed through, not mapped + $this->assertSame($created, $dto->created); + $this->assertSame($modified, $dto->modified); + } + + public function testMapWithNullDateTime(): void + { + $data = [ + 'id' => 1, + 'title' => 'Test Article', + 'created' => null, + 'modified' => null, + ]; + + $dto = $this->mapper->map($data, ArticleWithDatesDto::class); + + $this->assertInstanceOf(ArticleWithDatesDto::class, $dto); + $this->assertNull($dto->created); + $this->assertNull($dto->modified); + } +} diff --git a/tests/TestCase/ORM/EagerLoaderTest.php b/tests/TestCase/ORM/EagerLoaderTest.php new file mode 100644 index 00000000000..722f4f2c385 --- /dev/null +++ b/tests/TestCase/ORM/EagerLoaderTest.php @@ -0,0 +1,663 @@ +connection = ConnectionManager::get('test'); + $schema = [ + 'id' => ['type' => 'integer'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]; + $schema1 = [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'phone' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]; + $schema2 = [ + 'id' => ['type' => 'integer'], + 'total' => ['type' => 'string'], + 'placed' => ['type' => 'datetime'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]; + $this->table = $this->getTableLocator()->get('foo', ['schema' => $schema]); + $table = $this->table; + $clients = $this->getTableLocator()->get('clients', ['schema' => $schema1]); + $orders = $this->getTableLocator()->get('orders', ['schema' => $schema2]); + $companies = $this->getTableLocator()->get('companies', ['schema' => $schema, 'table' => 'organizations']); + $this->getTableLocator()->get('orderTypes', ['schema' => $schema]); + $stuff = $this->getTableLocator()->get('stuff', ['schema' => $schema, 'table' => 'things']); + $this->getTableLocator()->get('stuffTypes', ['schema' => $schema]); + $this->getTableLocator()->get('categories', ['schema' => $schema]); + + $table->belongsTo('clients'); + $clients->hasOne('orders'); + $clients->belongsTo('companies'); + $orders->belongsTo('orderTypes'); + $orders->hasOne('stuff'); + $stuff->belongsTo('stuffTypes'); + $companies->belongsTo('categories'); + + $this->clientsTypeMap = new TypeMap([ + 'clients.id' => 'integer', + 'id' => 'integer', + 'clients.name' => 'string', + 'name' => 'string', + 'clients.phone' => 'string', + 'phone' => 'string', + 'clients__id' => 'integer', + 'clients__name' => 'string', + 'clients__phone' => 'string', + ]); + $this->ordersTypeMap = new TypeMap([ + 'orders.id' => 'integer', + 'id' => 'integer', + 'orders.total' => 'string', + 'total' => 'string', + 'orders.placed' => 'datetime', + 'placed' => 'datetime', + 'orders__id' => 'integer', + 'orders__total' => 'string', + 'orders__placed' => 'datetime', + ]); + $this->orderTypesTypeMap = new TypeMap([ + 'orderTypes.id' => 'integer', + 'id' => 'integer', + 'orderTypes__id' => 'integer', + ]); + $this->stuffTypeMap = new TypeMap([ + 'stuff.id' => 'integer', + 'id' => 'integer', + 'stuff__id' => 'integer', + ]); + $this->stuffTypesTypeMap = new TypeMap([ + 'stuffTypes.id' => 'integer', + 'id' => 'integer', + 'stuffTypes__id' => 'integer', + ]); + $this->companiesTypeMap = new TypeMap([ + 'companies.id' => 'integer', + 'id' => 'integer', + 'companies__id' => 'integer', + ]); + $this->categoriesTypeMap = new TypeMap([ + 'categories.id' => 'integer', + 'id' => 'integer', + 'categories__id' => 'integer', + ]); + } + + /** + * Tests that fully defined belongsTo and hasOne relationships are joined correctly + */ + public function testContainToJoinsOneLevel(): void + { + $contains = [ + 'clients' => [ + 'orders' => [ + 'orderTypes', + 'stuff' => ['stuffTypes'], + ], + 'companies' => [ + 'foreignKey' => 'organization_id', + 'categories', + ], + ], + ]; + + $query = Mockery::mock(SelectQuery::class . '[join]', [$this->table]); + $query->setTypeMap($this->clientsTypeMap); + + $expectedTables = [ + ['clients' => [ + 'table' => 'clients', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + ['clients.id' => new IdentifierExpression('foo.client_id')], + ], new TypeMap($this->clientsTypeMap->getDefaults())), + ]], + ['orders' => [ + 'table' => 'orders', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + ['clients.id' => new IdentifierExpression('orders.client_id')], + ], $this->ordersTypeMap), + ]], + ['orderTypes' => [ + 'table' => 'order_types', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + ['orderTypes.id' => new IdentifierExpression('orders.order_type_id')], + ], $this->orderTypesTypeMap), + ]], + ['stuff' => [ + 'table' => 'things', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + ['orders.id' => new IdentifierExpression('stuff.order_id')], + ], $this->stuffTypeMap), + ]], + ['stuffTypes' => [ + 'table' => 'stuff_types', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + ['stuffTypes.id' => new IdentifierExpression('stuff.stuff_type_id')], + ], $this->stuffTypesTypeMap), + ]], + ['companies' => [ + 'table' => 'organizations', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + ['companies.id' => new IdentifierExpression('clients.organization_id')], + ], $this->companiesTypeMap), + ]], + ['categories' => [ + 'table' => 'categories', + 'type' => 'LEFT', + 'conditions' => new QueryExpression([ + ['categories.id' => new IdentifierExpression('companies.category_id')], + ], $this->categoriesTypeMap), + ]], + ]; + + foreach ($expectedTables as $table) { + $query->shouldReceive('join') + ->with($table) + ->andReturn($query) + ->once(); + } + + $loader = new EagerLoader(); + $loader->contain($contains); + $query->select('foo.id')->setEagerLoader($loader)->sql(); + } + + /** + * Tests setting containments using dot notation, additionally proves that options + * are not overwritten when combining dot notation and array notation + */ + public function testContainDotNotation(): void + { + $loader = new EagerLoader(); + $loader->contain([ + 'clients.orders.stuff', + 'clients.companies.categories' => ['conditions' => ['a >' => 1]], + ]); + $expected = [ + 'clients' => [ + 'orders' => [ + 'stuff' => [], + ], + 'companies' => [ + 'categories' => [ + 'conditions' => ['a >' => 1], + ], + ], + ], + ]; + $this->assertEquals($expected, $loader->getContain()); + $loader->contain([ + 'clients.orders' => ['fields' => ['a', 'b']], + 'clients' => ['sort' => ['a' => 'desc']], + ]); + + $expected['clients']['orders'] += ['fields' => ['a', 'b']]; + $expected['clients'] += ['sort' => ['a' => 'desc']]; + $this->assertEquals($expected, $loader->getContain()); + } + + /** + * Tests setting containments using direct key value pairs works just as with key array. + */ + public function testContainKeyValueNotation(): void + { + $loader = new EagerLoader(); + $loader->contain([ + 'clients', + 'companies' => 'categories', + ]); + $expected = [ + 'clients' => [], + 'companies' => [ + 'categories' => [], + ], + ]; + $this->assertEquals($expected, $loader->getContain()); + } + + /** + * Tests that it is possible to pass a function as the array value for contain + */ + public function testContainClosure(): void + { + $builder = function ($query): void { + }; + $loader = new EagerLoader(); + $loader->contain([ + 'clients.orders.stuff' => ['fields' => ['a']], + 'clients' => $builder, + ]); + + $expected = [ + 'clients' => [ + 'orders' => [ + 'stuff' => ['fields' => ['a']], + ], + 'queryBuilder' => $builder, + ], + ]; + $this->assertEquals($expected, $loader->getContain()); + + $loader = new EagerLoader(); + $loader->contain([ + 'clients.orders.stuff' => ['fields' => ['a']], + 'clients' => ['queryBuilder' => $builder], + ]); + $this->assertEquals($expected, $loader->getContain()); + } + + /** + * Tests using the same signature as matching with contain + */ + public function testContainSecondSignature(): void + { + $builder = function ($query): void { + }; + $loader = new EagerLoader(); + $loader->contain('clients', $builder); + + $expected = [ + 'clients' => [ + 'queryBuilder' => $builder, + ], + ]; + $this->assertEquals($expected, $loader->getContain()); + } + + /** + * Tests passing an array of associations with a query builder + */ + public function testContainSecondSignatureInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = function ($query): void { + }; + $loader = new EagerLoader(); + $loader->contain(['clients'], $builder); + + $expected = [ + 'clients' => [ + 'queryBuilder' => $builder, + ], + ]; + $this->assertEquals($expected, $loader->getContain()); + } + + /** + * Tests that query builders are stacked + */ + public function testContainMergeBuilders(): void + { + $loader = new EagerLoader(); + $loader->contain([ + 'clients' => function ($query) { + return $query->select(['a']); + }, + ]); + $loader->contain([ + 'clients' => function ($query) { + return $query->select(['b']); + }, + ]); + $builder = $loader->getContain()['clients']['queryBuilder']; + $table = $this->getTableLocator()->get('foo'); + $query = new SelectQuery($table); + $query = $builder($query); + $this->assertEquals(['a', 'b'], $query->clause('select')); + } + + /** + * Test that fields for contained models are aliased and added to the select clause + */ + public function testContainToFieldsPredefined(): void + { + $contains = [ + 'clients' => [ + 'fields' => ['name', 'company_id', 'clients.telephone'], + 'orders' => [ + 'fields' => ['total', 'placed'], + ], + ], + ]; + + $table = $this->getTableLocator()->get('foo'); + $query = new SelectQuery($table); + $loader = new EagerLoader(); + $loader->contain($contains); + $query->select('foo.id'); + $loader->attachAssociations($query, $table, true); + + $select = $query->clause('select'); + $expected = [ + 'foo.id', 'clients__name' => 'clients.name', + 'clients__company_id' => 'clients.company_id', + 'clients__telephone' => 'clients.telephone', + 'orders__total' => 'orders.total', 'orders__placed' => 'orders.placed', + // Primary keys are added to ensure proper entity hydration + 'clients__id' => 'clients.id', + 'orders__id' => 'orders.id', + ]; + $this->assertEquals($expected, $select); + } + + /** + * Tests that default fields for associations are added to the select clause when + * none is specified + */ + public function testContainToFieldsDefault(): void + { + $contains = ['clients' => ['orders']]; + + $query = new SelectQuery($this->table); + $query->select()->contain($contains)->sql(); + $select = $query->clause('select'); + $expected = [ + 'foo__id' => 'foo.id', 'clients__name' => 'clients.name', + 'clients__id' => 'clients.id', 'clients__phone' => 'clients.phone', + 'orders__id' => 'orders.id', 'orders__total' => 'orders.total', + 'orders__placed' => 'orders.placed', + ]; + $expected = $this->_quoteArray($expected); + $this->assertEquals($expected, $select); + + $contains['clients']['fields'] = ['name']; + $query = new SelectQuery($this->table); + $query->select('foo.id')->contain($contains)->sql(); + $select = $query->clause('select'); + $expected = [ + 'foo__id' => 'foo.id', + 'clients__name' => 'clients.name', + // Primary key is now auto-added to ensure proper entity hydration + 'clients__id' => 'clients.id', + ]; + $expected = $this->_quoteArray($expected); + $this->assertEquals($expected, $select); + + $contains['clients']['fields'] = []; + $contains['clients']['orders']['fields'] = false; + $query = new SelectQuery($this->table); + $query->select()->contain($contains)->sql(); + $select = $query->clause('select'); + $expected = [ + 'foo__id' => 'foo.id', + 'clients__id' => 'clients.id', + 'clients__name' => 'clients.name', + 'clients__phone' => 'clients.phone', + ]; + $expected = $this->_quoteArray($expected); + $this->assertEquals($expected, $select); + } + + /** + * Tests that the path for getting to a deep association is materialized in an + * array key + */ + public function testNormalizedPath(): void + { + $contains = [ + 'clients' => [ + 'orders' => [ + 'orderTypes', + 'stuff' => ['stuffTypes'], + ], + 'companies' => [ + 'categories', + ], + ], + ]; + + $loader = new EagerLoader(); + $loader->contain($contains); + $normalized = $loader->normalized($this->table); + $this->assertSame('clients', $normalized['clients']->aliasPath()); + $this->assertSame('client', $normalized['clients']->propertyPath()); + + $assocs = $normalized['clients']->associations(); + $this->assertSame('clients.orders', $assocs['orders']->aliasPath()); + $this->assertSame('client.order', $assocs['orders']->propertyPath()); + + $assocs = $assocs['orders']->associations(); + $this->assertSame('clients.orders.orderTypes', $assocs['orderTypes']->aliasPath()); + $this->assertSame('client.order.order_type', $assocs['orderTypes']->propertyPath()); + $this->assertSame('clients.orders.stuff', $assocs['stuff']->aliasPath()); + $this->assertSame('client.order.stuff', $assocs['stuff']->propertyPath()); + + $assocs = $assocs['stuff']->associations(); + $this->assertSame( + 'clients.orders.stuff.stuffTypes', + $assocs['stuffTypes']->aliasPath(), + ); + $this->assertSame( + 'client.order.stuff.stuff_type', + $assocs['stuffTypes']->propertyPath(), + ); + } + + /** + * Tests that the paths for matching containments point to _matchingData. + */ + public function testNormalizedMatchingPath(): void + { + $loader = new EagerLoader(); + $loader->setMatching('clients'); + $assocs = $loader->attachableAssociations($this->table); + + $this->assertSame('clients', $assocs['clients']->aliasPath()); + $this->assertSame('_matchingData.clients', $assocs['clients']->propertyPath()); + } + + /** + * Tests that the paths for deep matching containments point to _matchingData. + */ + public function testNormalizedDeepMatchingPath(): void + { + $loader = new EagerLoader(); + $loader->setMatching('clients.orders'); + $assocs = $loader->attachableAssociations($this->table); + + $this->assertSame('clients', $assocs['clients']->aliasPath()); + $this->assertSame('_matchingData.clients', $assocs['clients']->propertyPath()); + + $assocs = $assocs['clients']->associations(); + $this->assertSame('clients.orders', $assocs['orders']->aliasPath()); + $this->assertSame('_matchingData.orders', $assocs['orders']->propertyPath()); + } + + /** + * Test clearing containments but not matching joins. + */ + public function testClearContain(): void + { + $contains = [ + 'clients' => [ + 'orders' => [ + 'orderTypes', + 'stuff' => ['stuffTypes'], + ], + 'companies' => [ + 'categories', + ], + ], + ]; + + $loader = new EagerLoader(); + $loader->contain($contains); + $loader->setMatching('clients.addresses'); + + $loader->clearContain(); + $result = $loader->normalized($this->table); + $this->assertEquals([], $result); + $this->assertArrayHasKey('clients', $loader->getMatching()); + } + + /** + * Test for enableAutoFields() + */ + public function testEnableAutoFields(): void + { + $loader = new EagerLoader(); + $this->assertTrue($loader->isAutoFieldsEnabled()); + $this->assertSame($loader, $loader->disableAutoFields()); + $this->assertFalse($loader->isAutoFieldsEnabled()); + } + + /** + * Helper function sued to quoted both keys and values in an array in case + * the test suite is running with auto quoting enabled + * + * @param array $elements + * @return array + */ + protected function _quoteArray($elements): array + { + if ($this->connection->getDriver()->isAutoQuotingEnabled()) { + $quoter = function ($e) { + return $this->connection->getDriver()->quoteIdentifier($e); + }; + + return array_combine( + array_map($quoter, array_keys($elements)), + array_map($quoter, array_values($elements)), + ); + } + + return $elements; + } + + /** + * Asserts that matching('something') and setMatching('something') return consistent type. + */ + public function testSetMatchingReturnType(): void + { + $loader = new EagerLoader(); + $result = $loader->setMatching('clients'); + $this->assertInstanceOf(EagerLoader::class, $result); + $this->assertArrayHasKey('clients', $loader->getMatching()); + } + + /** + * Test that calling setMatching without a builder preserves the existing queryBuilder. + * + * @see https://github.com/cakephp/cakephp/issues/19285 + */ + public function testSetMatchingPreservesExistingQueryBuilder(): void + { + $loader = new EagerLoader(); + $builderCalled = false; + $loader->setMatching('clients', function ($q) use (&$builderCalled) { + $builderCalled = true; + + return $q; + }); + + // Second call without builder should not override the first + $loader->setMatching('clients'); + + $matching = $loader->getMatching(); + $this->assertArrayHasKey('clients', $matching); + $this->assertArrayHasKey('queryBuilder', $matching['clients']); + $this->assertNotNull($matching['clients']['queryBuilder']); + + // Actually call the builder to verify it's preserved + $matching['clients']['queryBuilder']( + Mockery::mock(SelectQuery::class), + ); + $this->assertTrue($builderCalled, 'Original queryBuilder should be preserved and called'); + } +} diff --git a/tests/TestCase/ORM/EntityTest.php b/tests/TestCase/ORM/EntityTest.php new file mode 100644 index 00000000000..b276cf8ebad --- /dev/null +++ b/tests/TestCase/ORM/EntityTest.php @@ -0,0 +1,1888 @@ +assertNull($entity->getOriginal('foo')); + $entity->set('foo', 'bar', ['asOriginal' => true]); + $this->assertSame('bar', $entity->foo); + $this->assertSame('bar', $entity->getOriginal('foo')); + + $entity->set('foo', 'baz'); + $this->assertSame('baz', $entity->foo); + $this->assertSame('bar', $entity->getOriginal('foo')); + + $entity->set('id', 1, ['asOriginal' => true]); + $this->assertSame(1, $entity->id); + $this->assertSame(1, $entity->getOriginal('id')); + $this->assertSame('bar', $entity->getOriginal('foo')); + } + + public function testEntitySetDeprecated(): void + { + $this->deprecated(function (): void { + $entity = new Entity(); + $entity->set(['foo' => 'bar']); + }); + } + + /** + * Tests setting multiple properties without custom setters + */ + public function testPatchPropertiesNoSetters(): void + { + $entity = new Entity(); + $entity->setAccess('*', true); + + $entity->patch(['foo' => 'bar', 'id' => 1], ['asOriginal' => true]); + $this->assertSame('bar', $entity->foo); + $this->assertSame(1, $entity->id); + + $entity->patch(['foo' => 'baz', 'id' => 2, 'thing' => 3]); + $this->assertSame('baz', $entity->foo); + $this->assertSame(2, $entity->id); + $this->assertSame(3, $entity->thing); + $this->assertSame('bar', $entity->getOriginal('foo')); + $this->assertSame(1, $entity->getOriginal('id')); + + $entity->patch(['foo', 'bar']); + $this->assertSame('foo', $entity->get('0')); + $this->assertSame('bar', $entity->get('1')); + + $entity->patch(['sample']); + $this->assertSame('sample', $entity->get('0')); + } + + public function testEntitySetException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot set an empty field'); + + $entity = new Entity(); + $entity->set('', 'value'); + } + + /** + * Test that getOriginal() retains falsey values. + */ + public function testGetOriginal(): void + { + $entity = new Entity( + ['false' => false, 'null' => null, 'zero' => 0, 'empty' => ''], + ['markNew' => true], + ); + $this->assertNull($entity->getOriginal('null')); + $this->assertFalse($entity->getOriginal('false')); + $this->assertSame(0, $entity->getOriginal('zero')); + $this->assertSame('', $entity->getOriginal('empty')); + + $entity->patch(['false' => 'y', 'null' => 'y', 'zero' => 'y', 'empty' => '']); + $this->assertNull($entity->getOriginal('null')); + $this->assertFalse($entity->getOriginal('false')); + $this->assertSame(0, $entity->getOriginal('zero')); + $this->assertSame('', $entity->getOriginal('empty')); + } + + /** + * Test that getOriginal throws an exception for fields without original value + * when called with second parameter "false" + */ + public function testGetOriginalFallback(): void + { + $entity = new Entity( + ['foo' => 'foo', 'bar' => 'bar'], + ['markNew' => true], + ); + $this->assertNull($entity->getOriginal('baz', true)); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot retrieve original value for field `baz`'); + $entity->getOriginal('baz', false); + } + + /** + * Test extractOriginal() + */ + public function testExtractOriginal(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'original', + 'body' => 'no', + 'null' => null, + ], ['markNew' => true]); + $entity->set('body', 'updated body'); + $result = $entity->extractOriginal(['id', 'title', 'body', 'null', 'undefined']); + $expected = [ + 'id' => 1, + 'title' => 'original', + 'body' => 'no', + 'null' => null, + ]; + $this->assertEquals($expected, $result); + + $result = $entity->extractOriginalChanged(['id', 'title', 'body', 'null', 'undefined']); + $expected = [ + 'body' => 'no', + ]; + $this->assertEquals($expected, $result); + + $entity->set('null', 'not null'); + $result = $entity->extractOriginalChanged(['id', 'title', 'body', 'null', 'undefined']); + $expected = [ + 'null' => null, + 'body' => 'no', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that all original values are returned properly + */ + public function testExtractOriginalValues(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'original', + 'body' => 'no', + 'null' => null, + ], ['markNew' => true]); + $entity->set('body', 'updated body'); + $result = $entity->getOriginalValues(); + $expected = [ + 'id' => 1, + 'title' => 'original', + 'body' => 'no', + 'null' => null, + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests setting a single property using a setter function + */ + public function testSetOneParamWithSetter(): void + { + $entity = new class extends Entity { + protected function _setName(?string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->set('name', 'Jones'); + $this->assertSame('Dr. Jones', $entity->name); + } + + /** + * Tests setting multiple properties using a setter function + */ + public function testMultipleWithSetter(): void + { + $entity = new class extends Entity { + protected function _setName(?string $name): string + { + return 'Dr. ' . $name; + } + + protected function _setStuff(?array $stuff): array + { + return ['c', 'd']; + } + }; + $entity->setAccess('*', true); + $entity->patch(['name' => 'Jones', 'stuff' => ['a', 'b']]); + $this->assertSame('Dr. Jones', $entity->name); + $this->assertEquals(['c', 'd'], $entity->stuff); + } + + /** + * Tests that it is possible to bypass the setters + */ + public function testBypassSetters(): void + { + $entity = new class extends Entity { + protected function _setName(?string $name): string + { + throw new Exception('_setName should not have been called'); + } + + protected function _setStuff(?array $stuff): array + { + throw new Exception('_setStuff should not have been called'); + } + }; + $entity->setAccess('*', true); + + $entity->set('name', 'Jones', ['setter' => false]); + $this->assertSame('Jones', $entity->name); + + $entity->set('stuff', 'Thing', ['setter' => false]); + $this->assertSame('Thing', $entity->stuff); + + $entity->patch(['name' => 'foo', 'stuff' => 'bar'], ['setter' => false]); + $this->assertSame('bar', $entity->stuff); + } + + /** + * Tests that the constructor will set initial properties + */ + public function testConstructor(): void + { + $entity = Mockery::mock(Entity::class)->makePartial(); + + $entity + ->shouldReceive('patch') + ->with(['a' => 'b', 'c' => 'd'], ['setter' => true, 'guard' => false, 'asOriginal' => true]) + ->once(); + + $entity->shouldReceive('patch') + ->with(['foo' => 'bar'], ['setter' => false, 'guard' => false, 'asOriginal' => true]) + ->once(); + + $entity->__construct(['a' => 'b', 'c' => 'd']); + $entity->__construct(['foo' => 'bar'], ['useSetters' => false]); + } + + /** + * Tests that the constructor will set initial properties and pass the guard + * option along + */ + public function testConstructorWithGuard(): void + { + $entity = Mockery::mock(Entity::class)->makePartial(); + + $entity + ->shouldReceive('patch') + ->with(['foo' => 'bar'], ['setter' => true, 'guard' => true, 'asOriginal' => true]) + ->once(); + + $entity->__construct(['foo' => 'bar'], ['guard' => true]); + } + + /** + * Tests getting properties with no custom getters + */ + public function testGetNoGetters(): void + { + $entity = new Entity(['id' => 1, 'foo' => 'bar']); + $this->assertSame(1, $entity->get('id')); + $this->assertSame('bar', $entity->get('foo')); + } + + public function testRequirePresenceException(): void + { + $this->expectException(MissingPropertyException::class); + $this->expectExceptionMessage('Property `not_present` does not exist for the entity `Cake\ORM\Entity`'); + + $entity = new Entity(); + $entity->requireFieldPresence(); + $entity->{'not_present'}; + } + + public function testGetOrFailException(): void + { + $this->expectException(MissingPropertyException::class); + $this->expectExceptionMessage('Property `not_present` does not exist for the entity `Cake\ORM\Entity`'); + + $entity = new Entity(); + $entity->getRequiredOrFail('not_present'); + } + + /** + * Test to ensure that requireFieldPresence does not affect get + */ + public function testGetNoException(): void + { + $entity = new Entity(); + $entity->requireFieldPresence(); + $this->assertNull($entity->get('not_present')); + } + + public function testRequirePresenceNoException(): void + { + $entity = new Entity(['is_present' => null]); + $entity->requireFieldPresence(); + $this->assertNull($entity->get('is_present')); + + $entity = new VirtualUser(); + $entity->requireFieldPresence(); + $this->assertSame('bonus', $entity->get('bonus')); + } + + /** + * Tests get with custom getter + */ + public function testGetCustomGetters(): void + { + $entity = new class extends Entity { + protected function _getName(string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->set('name', 'Jones'); + $this->assertSame('Dr. Jones', $entity->get('name')); + $this->assertSame('Dr. Jones', $entity->get('name')); + } + + /** + * Tests get with custom getter + */ + public function testGetCustomGettersAfterSet(): void + { + $entity = new class extends Entity { + protected function _getName(string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->set('name', 'Jones'); + $this->assertSame('Dr. Jones', $entity->get('name')); + $this->assertSame('Dr. Jones', $entity->get('name')); + + $entity->set('name', 'Mark'); + $this->assertSame('Dr. Mark', $entity->get('name')); + $this->assertSame('Dr. Mark', $entity->get('name')); + } + + /** + * Tests that the get cache is cleared by unset. + */ + public function testGetCacheClearedByUnset(): void + { + $entity = new class extends Entity { + protected function _getName(?string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->set('name', 'Jones'); + $this->assertSame('Dr. Jones', $entity->get('name')); + + $entity->unset('name'); + $this->assertSame('Dr. ', $entity->get('name')); + } + + /** + * Test getting camelcased virtual fields. + */ + public function testGetCamelCasedProperties(): void + { + $entity = new class extends Entity { + protected function _getListIdName(): string + { + return 'A name'; + } + }; + $entity->setVirtual(['ListIdName']); + $this->assertSame('A name', $entity->list_id_name, 'underscored virtual field should be accessible'); + $this->assertSame('A name', $entity->listIdName, 'Camelbacked virtual field should be accessible'); + } + + /** + * Test magic property setting with no custom setter + */ + public function testMagicSet(): void + { + $entity = new Entity(); + $entity->name = 'Jones'; + $this->assertSame('Jones', $entity->name); + $entity->name = 'George'; + $this->assertSame('George', $entity->name); + } + + /** + * Tests magic set with custom setter function + */ + public function testMagicSetWithSetter(): void + { + $entity = new class extends Entity { + protected function _setName(?string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->name = 'Jones'; + $this->assertSame('Dr. Jones', $entity->name); + } + + /** + * Tests magic set with custom setter function using a Title cased property + */ + public function testMagicSetWithSetterTitleCase(): void + { + $entity = new class extends Entity { + protected function _setName(?string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->Name = 'Jones'; + $this->assertSame('Dr. Jones', $entity->Name); + } + + /** + * Tests the magic getter with a custom getter function + */ + public function testMagicGetWithGetter(): void + { + $entity = new class extends Entity { + protected function _getName(string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->set('name', 'Jones'); + $this->assertSame('Dr. Jones', $entity->name); + } + + /** + * Tests magic get with custom getter function using a Title cased property + */ + public function testMagicGetWithGetterTitleCase(): void + { + $entity = new class extends Entity { + protected function _getName(string $name): string + { + return 'Dr. ' . $name; + } + }; + $entity->set('Name', 'Jones'); + $this->assertSame('Dr. Jones', $entity->Name); + } + + /** + * Test indirectly modifying internal properties + */ + public function testIndirectModification(): void + { + $entity = new Entity(); + $entity->things = ['a', 'b']; + $entity->things[] = 'c'; + $this->assertEquals(['a', 'b', 'c'], $entity->things); + } + + /** + * Tests has() method + */ + public function testHas(): void + { + $entity = new Entity(['id' => 1]); + $entity->name = 'Juan'; + $entity->foo = null; + $this->assertTrue($entity->has('id')); + $this->assertTrue($entity->has('name')); + $this->assertTrue($entity->has('foo')); + $this->assertFalse($entity->has('last_name')); + + $this->assertTrue($entity->has(['id'])); + $this->assertTrue($entity->has(['id', 'name'])); + $this->assertTrue($entity->has(['id', 'foo'])); + $this->assertFalse($entity->has(['id', 'nope'])); + + $entity = new class extends Entity { + protected function _getThings(): never + { + throw new Exception('_getThings() should not have been called'); + } + }; + $this->assertTrue($entity->has('things')); + } + + /** + * Tests unset one property at a time + */ + public function testUnset(): void + { + $entity = new Entity(['id' => 1, 'name' => 'bar']); + $entity->unset('id'); + $this->assertFalse($entity->has('id')); + $this->assertTrue($entity->has('name')); + $entity->unset('name'); + $this->assertFalse($entity->has('id')); + } + + /** + * Unsetting a property should not mark it as dirty. + */ + public function testUnsetMakesClean(): void + { + $entity = new Entity(['id' => 1, 'name' => 'bar']); + $this->assertTrue($entity->isDirty('name')); + $entity->unset('name'); + $this->assertFalse($entity->isDirty('name'), 'Removed properties are not dirty.'); + } + + /** + * Tests unset with multiple properties + */ + public function testUnsetMultiple(): void + { + $entity = new Entity(['id' => 1, 'name' => 'bar', 'thing' => 2]); + $entity->unset(['id', 'thing']); + $this->assertFalse($entity->has('id')); + $this->assertTrue($entity->has('name')); + $this->assertFalse($entity->has('thing')); + } + + /** + * Tests the magic __isset() method + */ + public function testMagicIsset(): void + { + $entity = new Entity(['id' => 1, 'name' => 'Juan', 'foo' => null]); + $this->assertTrue(isset($entity->id)); + $this->assertTrue(isset($entity->name)); + $this->assertFalse(isset($entity->foo)); + $this->assertFalse(isset($entity->thing)); + } + + /** + * Tests the magic __unset() method + */ + public function testMagicUnset(): void + { + $entity = new Entity(['foo' => 'bar']); + + unset($entity->foo); + + $this->assertFalse($entity->has('foo')); + } + + /** + * Tests isset with array access + */ + public function testIssetArrayAccess(): void + { + $entity = new Entity(['id' => 1, 'name' => 'Juan', 'foo' => null]); + $this->assertArrayHasKey('id', $entity); + $this->assertArrayHasKey('name', $entity); + $this->assertArrayNotHasKey('foo', $entity); + $this->assertArrayNotHasKey('thing', $entity); + } + + /** + * Tests get property with array access + */ + public function testGetArrayAccess(): void + { + $entity = Mockery::spy(Entity::class)->makePartial(); + + $entity['foo']; + $entity['bar']; + + $entity->shouldHaveReceived('get') + ->with('foo') + ->once(); + + $entity->shouldHaveReceived('get') + ->with('bar') + ->once(); + } + + /** + * Tests set with array access + */ + public function testSetArrayAccess(): void + { + $entity = Mockery::spy(Entity::class)->makePartial(); + $entity->setAccess('*', true); + + $entity['foo'] = 1; + $entity['bar'] = 2; + + $entity->shouldHaveReceived('set') + ->with('foo', 1) + ->once(); + + $entity->shouldHaveReceived('set') + ->with('bar', 2) + ->once(); + } + + /** + * Tests unset with array access + */ + public function testUnsetArrayAccess(): void + { + $entity = new Entity(['foo' => 'bar']); + + unset($entity['foo']); + + $this->assertFalse($entity->has('foo')); + } + + /** + * Tests that the method cache will only report the methods for the called class, + * this is, calling methods defined in another entity will not cause a fatal error + * when trying to call directly an inexistent method in another class + */ + public function testMethodCache(): void + { + $entity = new class extends Entity { + protected function _setFoo(?string $name): string + { + return 'Dr. ' . $name; + } + + protected function _getBar(string $bar): string + { + return 'Dir. ' . $bar; + } + }; + $entity2 = new class extends Entity { + protected function _setBar(?string $name): string + { + return 'DrDr. ' . $name; + } + }; + + $entity = $entity->set('foo', 'Someone'); + $this->assertEquals('Dr. Someone', $entity->get('foo')); + $entity2 = $entity2->set('bar', 'Someone'); + $this->assertEquals('DrDr. Someone', $entity2->get('bar')); + } + + /** + * Tests that long properties in the entity are inflected correctly + */ + public function testSetGetLongPropertyNames(): void + { + $entity = new class extends Entity { + protected function _setVeryLongProperty(?string $name): string + { + return 'Dr. ' . $name; + } + + protected function _getVeryLongProperty(?string $veryLongProperty): string + { + return 'Dir. ' . $veryLongProperty; + } + }; + $this->assertEquals('Dir. ', $entity->get('very_long_property')); + $entity->set('very_long_property', 'Someone'); + $this->assertEquals('Dir. Dr. Someone', $entity->get('very_long_property')); + } + + /** + * Tests serializing an entity as JSON + */ + public function testJsonSerialize(): void + { + $data = ['name' => 'James', 'age' => 20, 'phones' => ['123', '457']]; + $entity = new Entity($data); + $this->assertEquals(json_encode($data), json_encode($entity)); + } + + /** + * Tests serializing an entity as PHP + */ + public function testPhpSerialize(): void + { + $data = ['name' => 'James', 'age' => 20, 'phones' => ['123', '457']]; + $entity = new Entity($data); + $copy = unserialize(serialize($entity)); + $this->assertInstanceOf(Entity::class, $copy); + $this->assertEquals($data, $copy->toArray()); + } + + /** + * Tests that jsonSerialize is called recursively for contained entities + */ + public function testJsonSerializeRecursive(): void + { + $phone = new Entity(['something' => true]); + $data = ['name' => 'James', 'age' => 20, 'phone' => $phone]; + $entity = new Entity($data); + $expected = ['name' => 'James', 'age' => 20, 'phone' => ['something' => true]]; + $this->assertEquals(json_encode($expected), json_encode($entity)); + } + + /** + * Tests the extract method + */ + public function testExtract(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'Foo', + 'author_id' => 3, + ]); + $expected = ['author_id' => 3, 'title' => 'Foo',]; + $this->assertEquals($expected, $entity->extract(['author_id', 'title'])); + + $expected = ['id' => 1]; + $this->assertEquals($expected, $entity->extract(['id'])); + + $expected = []; + $this->assertEquals($expected, $entity->extract([])); + + $expected = ['id' => 1, 'craziness' => null]; + $this->assertEquals($expected, $entity->extract(['id', 'craziness'])); + } + + /** + * Tests isDirty() method on a newly created object + */ + public function testIsDirty(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'Foo', + 'author_id' => 3, + ]); + $this->assertTrue($entity->isDirty('id')); + $this->assertTrue($entity->isDirty('title')); + $this->assertTrue($entity->isDirty('author_id')); + + $this->assertTrue($entity->isDirty()); + + $entity->setDirty('id', false); + $this->assertFalse($entity->isDirty('id')); + $this->assertTrue($entity->isDirty('title')); + + $entity->setDirty('title', false); + $this->assertFalse($entity->isDirty('title')); + $this->assertTrue($entity->isDirty(), 'should be dirty, one field left'); + + $entity->setDirty('author_id', false); + $this->assertFalse($entity->isDirty(), 'all fields are clean.'); + } + + /** + * Test setDirty(). + */ + public function testSetDirty(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'Foo', + 'author_id' => 3, + ], ['markClean' => true]); + + $this->assertFalse($entity->isDirty()); + $this->assertSame($entity, $entity->setDirty('title')); + $this->assertSame($entity, $entity->setDirty('id', false)); + + $entity->setErrors(['title' => ['badness']]); + $entity->setDirty('title', true); + $this->assertEmpty($entity->getErrors(), 'Making a field dirty clears errors.'); + } + + /** + * Tests dirty() when altering properties values and adding new ones + */ + public function testDirtyChangingProperties(): void + { + $entity = new Entity([ + 'title' => 'Foo', + ]); + + $entity->setDirty('title', false); + $this->assertFalse($entity->isDirty('title')); + + $entity->set('title', 'Foo'); + // Not dirty as the value set is the same as the existing value + $this->assertFalse($entity->isDirty('title')); + + $entity->set('title', 'Bar'); + $this->assertTrue($entity->isDirty('title')); + + $entity->set('something', 'else'); + $this->assertTrue($entity->isDirty('something')); + } + + /** + * Tests that setting an object value when existing field is scalar + * correctly marks field as dirty without raising PHP notices. + * + * This tests the fix for a bug where isModified() compared an object + * to a scalar using loose equality, causing PHP 8+ to raise notices + * like "Object of class X could not be converted to float". + */ + public function testDirtyObjectReplacingScalar(): void + { + $entity = new Entity([ + 'amount' => 10.50, + ], ['markClean' => true]); + + $this->assertFalse($entity->isDirty('amount')); + + // Create an object that represents the same value but as object type. + // In real usage this would be something like BigDecimal. + $objectValue = new class (10.50) { + public function __construct(private float $value) + { + } + + public function getValue(): float + { + return $this->value; + } + }; + + // Setting object value when existing is scalar should: + // 1. Not raise a PHP notice (object-to-scalar comparison) + // 2. Mark the field as dirty (types differ) + $entity->set('amount', $objectValue); + + // Field should be dirty because we're changing from scalar to object + $this->assertTrue($entity->isDirty('amount')); + } + + /** + * Tests that setting an equivalent object when existing field is also an object + * correctly detects no change (field remains clean). + */ + public function testDirtyObjectReplacingEquivalentObject(): void + { + $objectValue = new stdClass(); + $objectValue->value = 10.50; + + $entity = new Entity([ + 'amount' => $objectValue, + ], ['markClean' => true]); + + $this->assertFalse($entity->isDirty('amount')); + + // Create an equivalent object (same properties, same values) + $equivalentObject = new stdClass(); + $equivalentObject->value = 10.50; + + // Setting equivalent object should NOT mark field dirty + $entity->set('amount', $equivalentObject); + $this->assertFalse($entity->isDirty('amount')); + + // Setting different object SHOULD mark field dirty + $differentObject = new stdClass(); + $differentObject->value = 20.00; + $entity->set('amount', $differentObject); + $this->assertTrue($entity->isDirty('amount')); + } + + /** + * Tests extract only dirty properties + */ + public function testExtractDirty(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'Foo', + 'author_id' => 3, + ]); + $entity->setDirty('id', false); + $entity->setDirty('title', false); + $expected = ['author_id' => 3]; + $result = $entity->extract(['id', 'title', 'author_id'], true); + $this->assertEquals($expected, $result); + } + + /** + * Tests the getDirty method + */ + public function testGetDirty(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'Foo', + 'author_id' => 3, + ]); + + $expected = [ + 'id', + 'title', + 'author_id', + ]; + $this->assertSame($expected, $entity->getDirty()); + } + + /** + * Tests the clean method + */ + public function testClean(): void + { + $entity = new Entity([ + 'id' => 1, + 'title' => 'Foo', + 'author_id' => 3, + ]); + $this->assertTrue($entity->isDirty('id')); + $this->assertTrue($entity->isDirty('title')); + $this->assertTrue($entity->isDirty('author_id')); + + $entity->clean(); + $this->assertFalse($entity->isDirty('id')); + $this->assertFalse($entity->isDirty('title')); + $this->assertFalse($entity->isDirty('author_id')); + } + + /** + * Tests the isNew method + */ + public function testIsNew(): void + { + $data = [ + 'id' => 1, + 'title' => 'Foo', + 'author_id' => 3, + ]; + $entity = new Entity($data); + $this->assertTrue($entity->isNew()); + + $entity->setNew(true); + $this->assertTrue($entity->isNew()); + + $entity->setNew(false); + $this->assertFalse($entity->isNew()); + } + + /** + * Tests the constructor when passing the markClean option + */ + public function testConstructorWithClean(): void + { + $entity = new Entity(['a' => 'b', 'c' => 'd']); + $this->assertTrue($entity->isDirty('a')); + $this->assertTrue($entity->isDirty('c')); + + $entity = new Entity(['a' => 'b', 'c' => 'd'], ['markClean' => true]); + $this->assertFalse($entity->isDirty('a')); + $this->assertFalse($entity->isDirty('c')); + } + + /** + * Tests the constructor when passing the markClean option + */ + public function testConstructorWithMarkNew(): void + { + $entity = new Entity(['a' => 'b', 'c' => 'd']); + $this->assertTrue($entity->isNew()); + + $entity = new Entity(['a' => 'b', 'c' => 'd'], ['markNew' => false]); + $this->assertFalse($entity->isNew()); + + $entity = new Entity(['a' => 'b', 'c' => 'd'], ['markNew' => true]); + $this->assertTrue($entity->isNew()); + } + + /** + * Test toArray method. + */ + public function testToArray(): void + { + $data = ['name' => 'James', 'age' => 20, 'phones' => ['123', '457']]; + $entity = new Entity($data); + + $this->assertEquals($data, $entity->toArray()); + } + + /** + * Test toArray recursive. + */ + public function testToArrayRecursive(): void + { + $data = ['id' => 1, 'name' => 'James', 'age' => 20, 'phones' => ['123', '457']]; + $user = new Extending($data); + $comments = [ + new NonExtending(['user_id' => 1, 'body' => 'Comment 1']), + new NonExtending(['user_id' => 1, 'body' => 'Comment 2']), + ]; + $user->comments = $comments; + $user->profile = new Entity(['email' => 'mark@example.com']); + + $expected = [ + 'id' => 1, + 'name' => 'James', + 'age' => 20, + 'phones' => ['123', '457'], + 'profile' => ['email' => 'mark@example.com'], + 'comments' => [ + ['user_id' => 1, 'body' => 'Comment 1'], + ['user_id' => 1, 'body' => 'Comment 2'], + ], + ]; + $this->assertEquals($expected, $user->toArray()); + } + + /** + * Tests that an entity with entities and other misc types can be properly toArray'd + */ + public function testToArrayMixed(): void + { + $test = new Entity([ + 'id' => 1, + 'foo' => [ + new Entity(['hi' => 'test']), + 'notentity' => 1, + ], + ]); + $expected = [ + 'id' => 1, + 'foo' => [ + ['hi' => 'test'], + 'notentity' => 1, + ], + ]; + $this->assertEquals($expected, $test->toArray()); + } + + /** + * Test that get accessors are called when converting to arrays. + */ + public function testToArrayWithAccessor(): void + { + $entity = new class extends Entity { + protected function _getName(?string $name): string + { + return 'Jose'; + } + }; + $entity->setAccess('*', true); + $entity->patch(['name' => 'Mark', 'email' => 'mark@example.com']); + $expected = ['name' => 'Jose', 'email' => 'mark@example.com']; + $this->assertEquals($expected, $entity->toArray()); + } + + /** + * Test that toArray respects hidden properties. + */ + public function testToArrayHiddenProperties(): void + { + $data = ['secret' => 'sauce', 'name' => 'mark', 'id' => 1]; + $entity = new Entity($data); + $entity->setHidden(['secret']); + $this->assertEquals(['name' => 'mark', 'id' => 1], $entity->toArray()); + } + + /** + * Tests setting hidden properties. + */ + public function testSetHidden(): void + { + $data = ['secret' => 'sauce', 'name' => 'mark', 'id' => 1]; + $entity = new Entity($data); + $entity->setHidden(['secret']); + + $result = $entity->getHidden(); + $this->assertSame(['secret'], $result); + + $entity->setHidden(['name']); + + $result = $entity->getHidden(); + $this->assertSame(['name'], $result); + } + + /** + * Tests setting hidden properties with merging. + */ + public function testSetHiddenWithMerge(): void + { + $data = ['secret' => 'sauce', 'name' => 'mark', 'id' => 1]; + $entity = new Entity($data); + $entity->setHidden(['secret'], true); + + $result = $entity->getHidden(); + $this->assertSame(['secret'], $result); + + $entity->setHidden(['name'], true); + + $result = $entity->getHidden(); + $this->assertSame(['secret', 'name'], $result); + + $entity->setHidden(['name'], true); + $result = $entity->getHidden(); + $this->assertSame(['secret', 'name'], $result); + } + + /** + * Test toArray includes 'virtual' properties. + */ + public function testToArrayVirtualProperties(): void + { + $entity = new class extends Entity { + protected function _getName(?string $name): string + { + return 'Jose'; + } + }; + $entity->setAccess('*', true); + $entity->patch(['email' => 'mark@example.com']); + + $entity->setVirtual(['name']); + $expected = ['name' => 'Jose', 'email' => 'mark@example.com']; + $this->assertEquals($expected, $entity->toArray()); + + $this->assertEquals(['name'], $entity->getVirtual()); + + $entity->setHidden(['name']); + $expected = ['email' => 'mark@example.com']; + $this->assertEquals($expected, $entity->toArray()); + $this->assertEquals(['name'], $entity->getHidden()); + } + + /** + * Tests the getVisible() method + */ + public function testGetVisible(): void + { + $entity = new Entity(); + $entity->foo = 'foo'; + $entity->bar = 'bar'; + + $expected = $entity->getVisible(); + $this->assertSame(['foo', 'bar'], $expected); + } + + /** + * Tests setting virtual properties with merging. + */ + public function testSetVirtualWithMerge(): void + { + $data = ['virtual' => 'sauce', 'name' => 'mark', 'id' => 1]; + $entity = new Entity($data); + $entity->setVirtual(['virtual']); + + $result = $entity->getVirtual(); + $this->assertSame(['virtual'], $result); + + $entity->setVirtual(['name'], true); + + $result = $entity->getVirtual(); + $this->assertSame(['virtual', 'name'], $result); + + $entity->setVirtual(['name'], true); + $result = $entity->getVirtual(); + $this->assertSame(['virtual', 'name'], $result); + } + + /** + * Tests error getters and setters + */ + public function testGetErrorAndSetError(): void + { + $entity = new Entity(); + $this->assertEmpty($entity->getErrors()); + + $entity->setError('foo', 'bar'); + $this->assertEquals(['bar'], $entity->getError('foo')); + + $entity->requireFieldPresence(true); + $this->assertEquals([], $entity->getError('non_existent')); + + $expected = [ + 'foo' => ['bar'], + ]; + $result = $entity->getErrors(); + $this->assertEquals($expected, $result); + + $indexedErrors = [2 => ['foo' => 'bar']]; + $entity = new Entity(); + $entity->setError('indexes', $indexedErrors); + + $expectedIndexed = [ + 'indexes' => ['2' => ['foo' => 'bar']], + ]; + $result = $entity->getErrors(); + $this->assertEquals($expectedIndexed, $result); + } + + /** + * Tests that setError with dotted paths creates nested structure + */ + public function testSetErrorDottedPath(): void + { + $entity = new Entity(); + $entity->setError('patients._ids', ['dummyRule' => 'Error message']); + + // Should create nested structure that can be retrieved with dotted path + $expected = ['dummyRule' => 'Error message']; + $this->assertEquals($expected, $entity->getError('patients._ids')); + + // Should also work with getErrors() + $expected = [ + 'patients' => [ + '_ids' => ['dummyRule' => 'Error message'], + ], + ]; + $this->assertEquals($expected, $entity->getErrors()); + + // Test deeper nesting + $entity = new Entity(); + $entity->setError('foo.bar.baz', 'deep error'); + + $this->assertEquals(['deep error'], $entity->getError('foo.bar.baz')); + $expected = [ + 'foo' => [ + 'bar' => [ + 'baz' => ['deep error'], + ], + ], + ]; + $this->assertEquals($expected, $entity->getErrors()); + + // Test with string error message + $entity = new Entity(); + $entity->setError('field.subfield', 'simple message'); + + $this->assertEquals(['simple message'], $entity->getError('field.subfield')); + } + + /** + * Tests reading errors from nested validator + */ + public function testGetErrorNested(): void + { + $entity = new Entity(); + $entity->setError('options', ['subpages' => ['_empty' => 'required']]); + + $expected = [ + 'subpages' => ['_empty' => 'required'], + ]; + $this->assertEquals($expected, $entity->getError('options')); + + $expected = ['_empty' => 'required']; + $this->assertEquals($expected, $entity->getError('options.subpages')); + } + + /** + * Tests that it is possible to get errors for nested entities + */ + public function testErrorsDeep(): void + { + $user = new Entity(); + $owner = new NonExtending(); + $author = new Extending([ + 'foo' => 'bar', + 'thing' => 'baz', + 'user' => $user, + 'owner' => $owner, + ]); + $author->setError('thing', ['this is a mistake']); + $user->setErrors(['a' => ['error1'], 'b' => ['error2']]); + $owner->setErrors(['c' => ['error3'], 'd' => ['error4']]); + + $expected = ['a' => ['error1'], 'b' => ['error2']]; + $this->assertEquals($expected, $author->getError('user')); + + $expected = ['c' => ['error3'], 'd' => ['error4']]; + $this->assertEquals($expected, $author->getError('owner')); + + $author->set('multiple', [$user, $owner]); + $expected = [ + ['a' => ['error1'], 'b' => ['error2']], + ['c' => ['error3'], 'd' => ['error4']], + ]; + $this->assertEquals($expected, $author->getError('multiple')); + + $expected = [ + 'thing' => $author->getError('thing'), + 'user' => $author->getError('user'), + 'owner' => $author->getError('owner'), + 'multiple' => $author->getError('multiple'), + ]; + $this->assertEquals($expected, $author->getErrors()); + } + + /** + * Tests that check if hasErrors() works + */ + public function testHasErrors(): void + { + $entity = new Entity(); + $hasErrors = $entity->hasErrors(); + $this->assertFalse($hasErrors); + + $nestedEntity = new Entity(); + $entity->patch([ + 'nested' => $nestedEntity, + ]); + $hasErrors = $entity->hasErrors(); + $this->assertFalse($hasErrors); + + $nestedEntity->setError('description', 'oops'); + $hasErrors = $entity->hasErrors(); + $this->assertTrue($hasErrors); + + $hasErrors = $entity->hasErrors(false); + $this->assertFalse($hasErrors); + + $entity->clean(); + $hasErrors = $entity->hasErrors(); + $this->assertTrue($hasErrors); + $hasErrors = $entity->hasErrors(false); + $this->assertFalse($hasErrors); + + $nestedEntity->clean(); + $hasErrors = $entity->hasErrors(); + $this->assertFalse($hasErrors); + + $entity->setError('foo', []); + $this->assertFalse($entity->hasErrors()); + } + + /** + * Test that errors can be read with a path. + */ + public function testErrorPathReading(): void + { + $assoc = new Entity(); + $assoc2 = new NonExtending(); + $entity = new Extending([ + 'field' => 'value', + 'one' => $assoc, + 'many' => [$assoc2], + ]); + $entity->setError('wrong', 'Bad stuff'); + $assoc->setError('nope', 'Terrible things'); + $assoc2->setError('nope', 'Terrible things'); + + $this->assertEquals(['Bad stuff'], $entity->getError('wrong')); + $this->assertEquals(['Terrible things'], $entity->getError('many.0.nope')); + $this->assertEquals(['Terrible things'], $entity->getError('one.nope')); + $this->assertEquals(['nope' => ['Terrible things']], $entity->getError('one')); + $this->assertEquals([0 => ['nope' => ['Terrible things']]], $entity->getError('many')); + $this->assertEquals(['nope' => ['Terrible things']], $entity->getError('many.0')); + + $this->assertEquals([], $entity->getError('many.0.mistake')); + $this->assertEquals([], $entity->getError('one.mistake')); + $this->assertEquals([], $entity->getError('one.1.mistake')); + $this->assertEquals([], $entity->getError('many.1.nope')); + } + + /** + * Tests that changing the value of a property will remove errors + * stored for it + */ + public function testDirtyRemovesError(): void + { + $entity = new Entity(['a' => 'b']); + $entity->setError('a', 'is not good'); + $entity->set('a', 'c'); + $this->assertEmpty($entity->getError('a')); + + $entity->setError('a', 'is not good'); + $entity->setDirty('a', true); + $this->assertEmpty($entity->getError('a')); + } + + /** + * Tests that marking an entity as clean will remove errors too + */ + public function testCleanRemovesErrors(): void + { + $entity = new Entity(['a' => 'b']); + $entity->setError('a', 'is not good'); + $entity->clean(); + $this->assertEmpty($entity->getErrors()); + } + + /** + * Tests getAccessible() method + */ + public function testGetAccessible(): void + { + $entity = new Entity(); + $entity->setAccess('*', false); + $entity->setAccess('bar', true); + + $accessible = $entity->getAccessible(); + $expected = [ + '*' => false, + 'bar' => true, + ]; + $this->assertSame($expected, $accessible); + } + + /** + * Tests isAccessible() and setAccess() methods + */ + public function testIsAccessible(): void + { + $entity = new Entity(); + $entity->setAccess('*', false); + $this->assertFalse($entity->isAccessible('foo')); + $this->assertFalse($entity->isAccessible('bar')); + + $this->assertSame($entity, $entity->setAccess('foo', true)); + $this->assertTrue($entity->isAccessible('foo')); + $this->assertFalse($entity->isAccessible('bar')); + + $this->assertSame($entity, $entity->setAccess('bar', true)); + $this->assertTrue($entity->isAccessible('foo')); + $this->assertTrue($entity->isAccessible('bar')); + + $this->assertSame($entity, $entity->setAccess('foo', false)); + $this->assertFalse($entity->isAccessible('foo')); + $this->assertTrue($entity->isAccessible('bar')); + + $this->assertSame($entity, $entity->setAccess('bar', false)); + $this->assertFalse($entity->isAccessible('foo')); + $this->assertFalse($entity->isAccessible('bar')); + } + + /** + * Tests that an array can be used to set + */ + public function testAccessibleAsArray(): void + { + $entity = new Entity(); + $entity->setAccess(['foo', 'bar', 'baz'], true); + $this->assertTrue($entity->isAccessible('foo')); + $this->assertTrue($entity->isAccessible('bar')); + $this->assertTrue($entity->isAccessible('baz')); + + $entity->setAccess('foo', false); + $this->assertFalse($entity->isAccessible('foo')); + $this->assertTrue($entity->isAccessible('bar')); + $this->assertTrue($entity->isAccessible('baz')); + + $entity->setAccess(['foo', 'bar', 'baz'], false); + $this->assertFalse($entity->isAccessible('foo')); + $this->assertFalse($entity->isAccessible('bar')); + $this->assertFalse($entity->isAccessible('baz')); + } + + /** + * Tests that a wildcard can be used for setting accessible properties + */ + public function testAccessibleWildcard(): void + { + $entity = new Entity(); + $entity->setAccess(['foo', 'bar', 'baz'], true); + $this->assertTrue($entity->isAccessible('foo')); + $this->assertTrue($entity->isAccessible('bar')); + $this->assertTrue($entity->isAccessible('baz')); + + $entity->setAccess('*', false); + $this->assertFalse($entity->isAccessible('foo')); + $this->assertFalse($entity->isAccessible('bar')); + $this->assertFalse($entity->isAccessible('baz')); + $this->assertFalse($entity->isAccessible('newOne')); + + $entity->setAccess('*', true); + $this->assertTrue($entity->isAccessible('foo')); + $this->assertTrue($entity->isAccessible('bar')); + $this->assertTrue($entity->isAccessible('baz')); + $this->assertTrue($entity->isAccessible('newOne2')); + } + + /** + * Tests that only accessible properties can be set + */ + public function testSetWithAccessible(): void + { + $entity = new Entity(['foo' => 1, 'bar' => 2]); + $options = ['guard' => true]; + $entity->setAccess('*', false); + $entity->setAccess('foo', true); + $entity->set('bar', 3, $options); + $entity->set('foo', 4, $options); + $this->assertSame(2, $entity->get('bar')); + $this->assertSame(4, $entity->get('foo')); + + $entity->setAccess('bar', true); + $entity->set('bar', 3, $options); + $this->assertSame(3, $entity->get('bar')); + } + + /** + * Tests that only accessible properties can be set + */ + public function testSetWithAccessibleWithArray(): void + { + $entity = new Entity(['foo' => 1, 'bar' => 2]); + $options = ['guard' => true]; + $entity->setAccess('*', false); + $entity->setAccess('foo', true); + $entity->patch(['bar' => 3, 'foo' => 4], $options); + $this->assertSame(2, $entity->get('bar')); + $this->assertSame(4, $entity->get('foo')); + + $entity->setAccess('bar', true); + $entity->patch(['bar' => 3, 'foo' => 5], $options); + $this->assertSame(3, $entity->get('bar')); + $this->assertSame(5, $entity->get('foo')); + } + + /** + * Test that accessible() and single property setting works. + */ + public function testSetWithAccessibleSingleProperty(): void + { + $entity = new Entity(['foo' => 1, 'bar' => 2]); + $entity->setAccess('*', false); + $entity->setAccess('title', true); + + $entity->patch(['title' => 'test', 'body' => 'Nope']); + $this->assertSame('test', $entity->title); + $this->assertNull($entity->body); + + $entity->body = 'Yep'; + $this->assertSame('Yep', $entity->body, 'Single set should bypass guards.'); + + $entity->set('body', 'Yes'); + $this->assertSame('Yes', $entity->body, 'Single set should bypass guards.'); + } + + /** + * Tests the entity's __toString method + * + * @deprecated + */ + public function testToString(): void + { + $this->deprecated(function (): void { + $entity = new Entity(['foo' => 1, 'bar' => 2]); + $this->assertEquals(json_encode($entity, JSON_PRETTY_PRINT), (string)$entity); + }); + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $entity = new Entity(['foo' => 'bar'], ['markClean' => true]); + $entity->somethingElse = 'value'; + $entity->setAccess('id', false); + $entity->setAccess('name', true); + $entity->setVirtual(['baz']); + $entity->setDirty('foo', true); + $entity->setError('foo', ['An error']); + $entity->setInvalidField('foo', 'a value'); + $entity->setSource('foos'); + $result = $entity->__debugInfo(); + $expected = [ + 'foo' => 'bar', + 'somethingElse' => 'value', + 'baz' => null, + '[new]' => true, + '[accessible]' => ['*' => true, 'id' => false, 'name' => true], + '[dirty]' => ['somethingElse' => true, 'foo' => true], + '[original]' => [], + '[originalFields]' => ['foo'], + '[virtual]' => ['baz'], + '[hasErrors]' => true, + '[errors]' => ['foo' => ['An error']], + '[invalid]' => ['foo' => 'a value'], + '[repository]' => 'foos', + ]; + $this->assertSame($expected, $result); + } + + /** + * Test the source getter + */ + public function testGetAndSetSource(): void + { + $entity = new Entity(); + $this->assertSame('', $entity->getSource()); + $entity->setSource('foos'); + $this->assertSame('foos', $entity->getSource()); + } + + /** + * Provides empty values + * + * @return array + */ + public function emptyNamesProvider(): array + { + return [[''], [null]]; + } + + /** + * Tests that trying to get an empty property name throws exception + */ + public function testEmptyProperties(): void + { + $this->expectException(InvalidArgumentException::class); + $entity = new Entity(); + $entity->get(''); + } + + /** + * Provides empty values + */ + public function testIsDirtyFromClone(): void + { + $entity = new Entity( + ['a' => 1, 'b' => 2], + ['markNew' => false, 'markClean' => true], + ); + + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->isDirty()); + + $cloned = clone $entity; + $cloned->setNew(true); + + $this->assertTrue($cloned->isDirty()); + $this->assertTrue($cloned->isDirty('a')); + $this->assertTrue($cloned->isDirty('b')); + } + + /** + * Tests getInvalid and setInvalid + */ + public function testGetSetInvalid(): void + { + $entity = new Entity(); + $return = $entity->setInvalid([ + 'title' => 'albert', + 'body' => 'einstein', + ]); + $this->assertSame($entity, $return); + $this->assertSame([ + 'title' => 'albert', + 'body' => 'einstein', + ], $entity->getInvalid()); + + $set = $entity->setInvalid([ + 'title' => 'nikola', + 'body' => 'tesla', + ]); + $this->assertSame([ + 'title' => 'albert', + 'body' => 'einstein', + ], $set->getInvalid()); + + $overwrite = $entity->setInvalid([ + 'title' => 'nikola', + 'body' => 'tesla', + ], true); + $this->assertSame($entity, $overwrite); + $this->assertSame([ + 'title' => 'nikola', + 'body' => 'tesla', + ], $entity->getInvalid()); + } + + /** + * Tests getInvalidField + */ + public function testGetSetInvalidField(): void + { + $entity = new Entity(); + $return = $entity->setInvalidField('title', 'albert'); + $this->assertSame($entity, $return); + $this->assertSame('albert', $entity->getInvalidField('title')); + + $overwrite = $entity->setInvalidField('title', 'nikola'); + $this->assertSame($entity, $overwrite); + $this->assertSame('nikola', $entity->getInvalidField('title')); + } + + /** + * Tests getInvalidFieldNull + */ + public function testGetInvalidFieldNull(): void + { + $entity = new Entity(); + $this->assertNull($entity->getInvalidField('foo')); + } + + /** + * Test the isEmpty() check + */ + public function testIsEmpty(): void + { + $this->deprecated(function (): void { + $entity = new Entity([ + 'array' => ['foo' => 'bar'], + 'emptyArray' => [], + 'object' => new stdClass(), + 'string' => 'string', + 'stringZero' => '0', + 'emptyString' => '', + 'intZero' => 0, + 'intNotZero' => 1, + 'floatZero' => 0.0, + 'floatNonZero' => 1.5, + 'null' => null, + ]); + + $this->assertFalse($entity->isEmpty('array')); + $this->assertTrue($entity->isEmpty('emptyArray')); + $this->assertFalse($entity->isEmpty('object')); + $this->assertFalse($entity->isEmpty('string')); + $this->assertFalse($entity->isEmpty('stringZero')); + $this->assertTrue($entity->isEmpty('emptyString')); + $this->assertFalse($entity->isEmpty('intZero')); + $this->assertFalse($entity->isEmpty('intNotZero')); + $this->assertFalse($entity->isEmpty('floatZero')); + $this->assertFalse($entity->isEmpty('floatNonZero')); + $this->assertTrue($entity->isEmpty('null')); + }); + } + + /** + * Test hasValue() + */ + public function testHasValue(): void + { + $entity = new Entity([ + 'array' => ['foo' => 'bar'], + 'emptyArray' => [], + 'object' => new stdClass(), + 'string' => 'string', + 'stringZero' => '0', + 'emptyString' => '', + 'intZero' => 0, + 'intNotZero' => 1, + 'floatZero' => 0.0, + 'floatNonZero' => 1.5, + 'null' => null, + ]); + + $this->assertTrue($entity->hasValue('array')); + $this->assertFalse($entity->hasValue('emptyArray')); + $this->assertTrue($entity->hasValue('object')); + $this->assertTrue($entity->hasValue('string')); + $this->assertTrue($entity->hasValue('stringZero')); + $this->assertFalse($entity->hasValue('emptyString')); + $this->assertTrue($entity->hasValue('intZero')); + $this->assertTrue($entity->hasValue('intNotZero')); + $this->assertTrue($entity->hasValue('floatZero')); + $this->assertTrue($entity->hasValue('floatNonZero')); + $this->assertFalse($entity->hasValue('null')); + } + + /** + * Test isOriginalField() + */ + public function testIsOriginalField(): void + { + $entity = new Entity(['foo' => null]); + $return = $entity->isOriginalField('foo'); + $this->assertSame(true, $return); + + $entity = new Entity([]); + $entity->set('foo'); + $return = $entity->isOriginalField('foo'); + $this->assertSame(false, $return); + + $return = $entity->isOriginalField('bar'); + $this->assertSame(false, $return); + } + + /** + * Test getOriginalFields() + */ + public function testGetOriginalFields(): void + { + $entity = new Entity(['foo' => 'foo', 'bar' => 'bar']); + $entity->set('baz', 'baz'); + $return = $entity->getOriginalFields(); + $this->assertEquals(['foo', 'bar'], $return); + + $entity = new Entity([]); + $entity->set('foo', 'foo'); + $entity->set('bar', 'bar'); + $entity->set('baz', 'baz'); + $return = $entity->getOriginalFields(); + $this->assertEquals([], $return); + } + + /** + * Test setOriginalField() inside EntityInterface::setDirty() + */ + public function testSetOriginalFieldInSetDirty(): void + { + $entity = new Entity([]); + $entity->set('foo', 'bar'); + + $return = $entity->isOriginalField('foo'); + $this->assertSame(false, $return); + + $entity->setDirty('foo', false); + + $return = $entity->isOriginalField('foo'); + $this->assertSame(true, $return); + } + + /** + * Test setOriginalField() inside EntityInterface::clean() + */ + public function testSetOriginalFieldInClean(): void + { + $entity = new Entity([]); + $entity->set('foo', 'bar'); + + $return = $entity->isOriginalField('foo'); + $this->assertSame(false, $return); + + $entity->clean(); + + $return = $entity->isOriginalField('foo'); + $this->assertSame(true, $return); + } + + /** + * Test infinite recursion in getErrors and hasErrors + * See https://github.com/cakephp/cakephp/issues/17318 + */ + public function testGetErrorsRecursionError(): void + { + $entity = new Entity(); + $secondEntity = new Entity(); + + $entity->set('child', $secondEntity); + $secondEntity->set('parent', $entity); + + $expectedErrors = ['name' => ['_required' => 'Must be present.']]; + $secondEntity->setErrors($expectedErrors); + + $this->assertEquals(['child' => $expectedErrors], $entity->getErrors()); + } + + /** + * Test infinite recursion in getErrors and hasErrors + * See https://github.com/cakephp/cakephp/issues/17318 + */ + public function testHasErrorsRecursionError(): void + { + $entity = new Entity(); + $secondEntity = new Entity(); + + $entity->set('child', $secondEntity); + $secondEntity->set('parent', $entity); + + $this->assertFalse($entity->hasErrors()); + } +} diff --git a/tests/TestCase/ORM/Locator/LocatorAwareTraitTest.php b/tests/TestCase/ORM/Locator/LocatorAwareTraitTest.php new file mode 100644 index 00000000000..e310f6823df --- /dev/null +++ b/tests/TestCase/ORM/Locator/LocatorAwareTraitTest.php @@ -0,0 +1,106 @@ +subject = new class { + use LocatorAwareTrait; + }; + } + + /** + * Tests testGetTableLocator method + */ + public function testGetTableLocator(): void + { + $tableLocator = $this->subject->getTableLocator(); + $this->assertSame($this->getTableLocator(), $tableLocator); + } + + /** + * Tests testSetTableLocator method + */ + public function testSetTableLocator(): void + { + $newLocator = Mockery::mock(LocatorInterface::class); + $this->subject->setTableLocator($newLocator); + $subjectLocator = $this->subject->getTableLocator(); + $this->assertSame($newLocator, $subjectLocator); + } + + public function testFetchTable(): void + { + $stub = new LocatorAwareStub('Articles'); + + $result = $stub->fetchTable(); + $this->assertInstanceOf(Table::class, $result); + + $result = $stub->fetchTable('Comments'); + $this->assertInstanceOf(Table::class, $result); + + $result = $stub->fetchTable(PaginatorPostsTable::class); + $this->assertInstanceOf(PaginatorPostsTable::class, $result); + $this->assertSame('PaginatorPosts', $result->getAlias()); + } + + public function testFetchTableException(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + 'You must provide an `$alias` or set the `$defaultTable` property to a non empty string.', + ); + + $stub = new LocatorAwareStub(); + $stub->fetchTable(); + } + + public function testFetchTableExceptionForEmptyString(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + 'You must provide an `$alias` or set the `$defaultTable` property to a non empty string.', + ); + + $stub = new LocatorAwareStub(''); + $stub->fetchTable(); + } +} diff --git a/tests/TestCase/ORM/Locator/TableContainerTest.php b/tests/TestCase/ORM/Locator/TableContainerTest.php new file mode 100644 index 00000000000..539a6170dd2 --- /dev/null +++ b/tests/TestCase/ORM/Locator/TableContainerTest.php @@ -0,0 +1,52 @@ +delegate(new TableContainer()); + + $table = $container->get(ArticlesTable::class); + $this->assertInstanceOf(ArticlesTable::class, $table); + $this->assertSame($table, $container->get(ArticlesTable::class)); + } + + public function testTableContainerMissingTable(): void + { + $container = new Container(); + $container->delegate(new TableContainer()); + + $this->expectException(NotFoundException::class); + $container->get(FakeTable::class); + } +} diff --git a/tests/TestCase/ORM/Locator/TableLocatorTest.php b/tests/TestCase/ORM/Locator/TableLocatorTest.php new file mode 100644 index 00000000000..251801c32e3 --- /dev/null +++ b/tests/TestCase/ORM/Locator/TableLocatorTest.php @@ -0,0 +1,713 @@ +_locator = new TableLocator(); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + $this->clearPlugins(); + parent::tearDown(); + } + + /** + * Test getConfig() method. + */ + public function testGetConfig(): void + { + $this->assertEquals([], $this->_locator->getConfig('Tests')); + + $data = [ + 'connection' => 'testing', + 'entityClass' => Article::class, + ]; + $result = $this->_locator->setConfig('Tests', $data); + $this->assertSame($this->_locator, $result, 'Returns locator'); + + $result = $this->_locator->getConfig(); + $expected = ['Tests' => $data]; + $this->assertEquals($expected, $result); + } + + /** + * Test getConfig() method with plugin syntax aliases + */ + public function testConfigPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + + $data = [ + 'connection' => 'testing', + 'entityClass' => Comment::class, + ]; + + $result = $this->_locator->setConfig('TestPlugin.TestPluginComments', $data); + $this->assertSame($this->_locator, $result, 'Returns locator'); + } + + /** + * Test calling getConfig() on existing instances throws an error. + */ + public function testConfigOnDefinedInstance(): void + { + $users = $this->_locator->get('Users'); + $this->assertNotEmpty($users); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('You cannot configure `Users`, it has already been constructed.'); + + $this->_locator->setConfig('Users', ['table' => 'my_users']); + } + + /** + * Test the exists() method. + */ + public function testExists(): void + { + $this->assertFalse($this->_locator->exists('Articles')); + + $this->_locator->setConfig('Articles', ['table' => 'articles']); + $this->assertFalse($this->_locator->exists('Articles')); + + $this->_locator->get('Articles', ['table' => 'articles']); + $this->assertTrue($this->_locator->exists('Articles')); + } + + /** + * Tests the casing and locator. Using table name directly is not + * the same as using conventional aliases anymore. + */ + public function testCasing(): void + { + $this->assertFalse($this->_locator->exists('Articles')); + + $Article = $this->_locator->get('Articles', ['table' => 'articles']); + $this->assertTrue($this->_locator->exists('Articles')); + + $this->assertFalse($this->_locator->exists('articles')); + + $article = $this->_locator->get('articles'); + $this->assertTrue($this->_locator->exists('articles')); + + $this->assertNotSame($Article, $article); + } + + /** + * Test the exists() method with plugin-prefixed models. + */ + public function testExistsPlugin(): void + { + $this->assertFalse($this->_locator->exists('Comments')); + $this->assertFalse($this->_locator->exists('TestPlugin.Comments')); + + $this->_locator->setConfig('TestPlugin.Comments', ['table' => 'comments']); + $this->assertFalse($this->_locator->exists('Comments'), 'The Comments key should not be populated'); + $this->assertFalse($this->_locator->exists('TestPlugin.Comments'), 'The plugin.alias key should not be populated'); + + $this->_locator->get('TestPlugin.Comments', ['table' => 'comments']); + $this->assertFalse($this->_locator->exists('Comments'), 'The Comments key should not be populated'); + $this->assertTrue($this->_locator->exists('TestPlugin.Comments'), 'The plugin.alias key should now be populated'); + } + + /** + * Test getting instances from the registry. + */ + public function testGet(): void + { + $result = $this->_locator->get('Articles', [ + 'table' => 'my_articles', + ]); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('my_articles', $result->getTable()); + + $result2 = $this->_locator->get('Articles'); + $this->assertSame($result, $result2); + $this->assertSame('my_articles', $result->getTable()); + + $this->assertSame($this->_locator, $result->associations()->getTableLocator()); + + $result = $this->_locator->get(ArticlesTable::class); + $this->assertSame('Articles', $result->getAlias()); + $this->assertSame(ArticlesTable::class, $result->getRegistryAlias()); + + $result2 = $this->_locator->get($result->getRegistryAlias()); + $this->assertSame($result, $result2); + } + + /** + * Are auto-models instantiated correctly? How about when they have an alias? + */ + public function testGetFallbacks(): void + { + $result = $this->_locator->get('Droids'); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('droids', $result->getTable()); + $this->assertSame('Droids', $result->getAlias()); + + $result = $this->_locator->get('R2D2', ['className' => 'Droids']); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('droids', $result->getTable(), 'The table should be derived from the className'); + $this->assertSame('R2D2', $result->getAlias()); + + $result = $this->_locator->get('C3P0', ['className' => 'Droids', 'table' => 'rebels']); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('rebels', $result->getTable(), 'The table should be taken from options'); + $this->assertSame('C3P0', $result->getAlias()); + + $result = $this->_locator->get('Funky.Chipmunks'); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('chipmunks', $result->getTable(), 'The table should be derived from the alias'); + $this->assertSame('Chipmunks', $result->getAlias()); + + $result = $this->_locator->get('Awesome', ['className' => 'Funky.Monkies']); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('monkies', $result->getTable(), 'The table should be derived from the classname'); + $this->assertSame('Awesome', $result->getAlias()); + + $result = $this->_locator->get('Stuff', ['className' => Table::class]); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('stuff', $result->getTable(), 'The table should be derived from the alias'); + $this->assertSame('Stuff', $result->getAlias()); + } + + public function testExceptionForAliasWhenFallbackTurnedOff(): void + { + $this->expectException(MissingTableClassException::class); + $this->expectExceptionMessage('Table class for alias `Droids` could not be found.'); + + $this->_locator->get('Droids', ['allowFallbackClass' => false]); + } + + public function testExceptionForFQCNWhenFallbackTurnedOff(): void + { + $this->expectException(MissingTableClassException::class); + $this->expectExceptionMessage('Table class `App\Model\DroidsTable` could not be found.'); + + $this->_locator->get('App\Model\DroidsTable', ['allowFallbackClass' => false]); + } + + /** + * Test that get() uses config data set with getConfig() + */ + public function testGetWithGetConfig(): void + { + $this->_locator->setConfig('Articles', [ + 'table' => 'my_articles', + ]); + $result = $this->_locator->get('Articles'); + $this->assertSame('my_articles', $result->getTable(), 'Should use getConfig() data.'); + } + + /** + * Test that get() uses config data set with getConfig() + */ + public function testGetWithConnectionName(): void + { + ConnectionManager::alias('test', 'testing'); + $result = $this->_locator->get('Articles', [ + 'connectionName' => 'testing', + ]); + $this->assertSame('articles', $result->getTable()); + $this->assertSame('test', $result->getConnection()->configName()); + } + + /** + * Test that get() uses config data `className` set with getConfig() + */ + public function testGetWithConfigClassName(): void + { + $this->_locator->setConfig('MyUsersTableAlias', [ + 'className' => MyUsersTable::class, + ]); + $result = $this->_locator->get('MyUsersTableAlias'); + $this->assertInstanceOf(MyUsersTable::class, $result, 'Should use getConfig() data className option.'); + } + + /** + * Test get with config throws an exception if the alias exists already. + */ + public function testGetExistingWithConfigData(): void + { + $users = $this->_locator->get('Users'); + $this->assertNotEmpty($users); + + $this->expectException(CakeException::class); + $this->expectExceptionMessage('You cannot configure `Users`, it already exists in the registry.'); + + $this->_locator->get('Users', ['table' => 'my_users']); + } + + /** + * Test get() can be called several times with the same option without + * throwing an exception. + */ + public function testGetWithSameOption(): void + { + $result = $this->_locator->get('Users', ['className' => MyUsersTable::class]); + $result2 = $this->_locator->get('Users', ['className' => MyUsersTable::class]); + $this->assertEquals($result, $result2); + } + + /** + * Tests that tables can be instantiated based on conventions + * and using plugin notation + */ + public function testGetWithConventions(): void + { + $table = $this->_locator->get('Articles'); + $this->assertInstanceOf(ArticlesTable::class, $table); + + $table = $this->_locator->get('Authors'); + $this->assertInstanceOf(AuthorsTable::class, $table); + } + + /** + * Test get() with plugin syntax aliases + */ + public function testGetPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + $table = $this->_locator->get('TestPlugin.TestPluginComments'); + + $this->assertInstanceOf(TestPluginCommentsTable::class, $table); + $this->assertFalse( + $this->_locator->exists('TestPluginComments'), + 'Short form should NOT exist', + ); + $this->assertTrue( + $this->_locator->exists('TestPlugin.TestPluginComments'), + 'Long form should exist', + ); + + $second = $this->_locator->get('TestPlugin.TestPluginComments'); + $this->assertSame($table, $second, 'Can fetch long form'); + } + + /** + * Test get() with same-alias models in different plugins + * + * There should be no internal cache-confusion + */ + public function testGetMultiplePlugins(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPlugin + $this->deprecated(function (): void { + $this->loadPlugins(['TestPlugin', 'TestPluginTwo']); + }); + + $app = $this->_locator->get('Comments'); + $plugin1 = $this->_locator->get('TestPlugin.Comments'); + $plugin2 = $this->_locator->get('TestPluginTwo.Comments'); + + $this->assertInstanceOf(Table::class, $app, 'Should be an app table instance'); + $this->assertInstanceOf(CommentsTable::class, $plugin1, 'Should be a plugin 1 table instance'); + $this->assertInstanceOf(PluginTwoCommentsTable::class, $plugin2, 'Should be a plugin 2 table instance'); + + $plugin2 = $this->_locator->get('TestPluginTwo.Comments'); + $plugin1 = $this->_locator->get('TestPlugin.Comments'); + $app = $this->_locator->get('Comments'); + + $this->assertInstanceOf(Table::class, $app, 'Should still be an app table instance'); + $this->assertInstanceOf(CommentsTable::class, $plugin1, 'Should still be a plugin 1 table instance'); + $this->assertInstanceOf(PluginTwoCommentsTable::class, $plugin2, 'Should still be a plugin 2 table instance'); + } + + /** + * Test get() with plugin aliases + className option. + */ + public function testGetPluginWithClassNameOption(): void + { + $this->loadPlugins(['TestPlugin']); + $table = $this->_locator->get('Comments', [ + 'className' => 'TestPlugin.TestPluginComments', + ]); + $class = TestPluginCommentsTable::class; + $this->assertInstanceOf($class, $table); + $this->assertFalse($this->_locator->exists('TestPluginComments'), 'Class name should not exist'); + $this->assertFalse($this->_locator->exists('TestPlugin.TestPluginComments'), 'Full class alias should not exist'); + $this->assertTrue($this->_locator->exists('Comments'), 'Class name should exist'); + + $second = $this->_locator->get('Comments'); + $this->assertSame($table, $second); + } + + /** + * Test get() with full namespaced classname + */ + public function testGetPluginWithFullNamespaceName(): void + { + $this->loadPlugins(['TestPlugin']); + $class = TestPluginCommentsTable::class; + $table = $this->_locator->get('Comments', [ + 'className' => $class, + ]); + $this->assertInstanceOf($class, $table); + $this->assertFalse($this->_locator->exists('TestPluginComments'), 'Class name should not exist'); + $this->assertFalse($this->_locator->exists('TestPlugin.TestPluginComments'), 'Full class alias should not exist'); + $this->assertTrue($this->_locator->exists('Comments'), 'Class name should exist'); + } + + /** + * Tests that table options can be pre-configured for the factory method + */ + public function testConfigAndBuild(): void + { + $this->_locator->clear(); + $map = $this->_locator->getConfig(); + $this->assertEquals([], $map); + + $connection = ConnectionManager::get('test', false); + $options = ['connection' => $connection]; + $this->_locator->setConfig('users', $options); + $map = $this->_locator->getConfig(); + $this->assertEquals(['users' => $options], $map); + $this->assertEquals($options, $this->_locator->getConfig('users')); + + $schema = ['id' => ['type' => 'rubbish']]; + $options += ['schema' => $schema]; + $this->_locator->setConfig('users', $options); + + $table = $this->_locator->get('users', ['table' => 'users']); + $this->assertInstanceOf(Table::class, $table); + $this->assertSame('users', $table->getTable()); + $this->assertSame('users', $table->getAlias()); + $this->assertSame($connection, $table->getConnection()); + $this->assertEquals(array_keys($schema), $table->getSchema()->columns()); + $this->assertSame($schema['id']['type'], $table->getSchema()->getColumnType('id')); + + $this->_locator->clear(); + $this->assertEmpty($this->_locator->getConfig()); + + $this->_locator->setConfig('users', $options); + $table = $this->_locator->get('users', ['className' => MyUsersTable::class]); + $this->assertInstanceOf(MyUsersTable::class, $table); + $this->assertSame('users', $table->getTable()); + $this->assertSame('users', $table->getAlias()); + $this->assertSame($connection, $table->getConnection()); + $this->assertEquals(array_keys($schema), $table->getSchema()->columns()); + $this->assertSame($schema['id']['type'], $table->getSchema()->getColumnType('id')); + } + + /** + * Tests that table options can be pre-configured with a single validator + */ + public function testConfigWithSingleValidator(): void + { + $validator = new Validator(); + + $this->_locator->setConfig('users', ['validator' => $validator]); + $table = $this->_locator->get('users'); + + $this->assertSame($table->getValidator('default'), $validator); + } + + /** + * Tests that table options can be pre-configured with multiple validators + */ + public function testConfigWithMultipleValidators(): void + { + $validator1 = new Validator(); + $validator2 = new Validator(); + $validator3 = new Validator(); + + $this->_locator->setConfig('users', [ + 'validator' => [ + 'default' => $validator1, + 'secondary' => $validator2, + 'tertiary' => $validator3, + ], + ]); + $table = $this->_locator->get('users'); + + $this->assertSame($table->getValidator('default'), $validator1); + $this->assertSame($table->getValidator('secondary'), $validator2); + $this->assertSame($table->getValidator('tertiary'), $validator3); + } + + /** + * Test setting an instance. + */ + public function testSet(): void + { + $mock = Mockery::mock(Table::class); + $this->assertSame($mock, $this->_locator->set('Articles', $mock)); + $this->assertSame($mock, $this->_locator->get('Articles')); + } + + /** + * Test setting an instance with plugin syntax aliases + */ + public function testSetPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + + $mock = Mockery::mock(CommentsTable::class); + + $this->assertSame($mock, $this->_locator->set('TestPlugin.Comments', $mock)); + $this->assertSame($mock, $this->_locator->get('TestPlugin.Comments')); + } + + /** + * Tests genericInstances + */ + public function testGenericInstances(): void + { + $foos = $this->_locator->get('Foos'); + $bars = $this->_locator->get('Bars'); + $this->_locator->get('Articles'); + $expected = ['Foos' => $foos, 'Bars' => $bars]; + $this->assertEquals($expected, $this->_locator->genericInstances()); + } + + /** + * Tests remove an instance + */ + public function testRemove(): void + { + $first = $this->_locator->get('Comments'); + + $this->assertTrue($this->_locator->exists('Comments')); + + $this->_locator->remove('Comments'); + $this->assertFalse($this->_locator->exists('Comments')); + + $second = $this->_locator->get('Comments'); + + $this->assertNotSame($first, $second, 'Should be different objects, as the reference to the first was destroyed'); + $this->assertTrue($this->_locator->exists('Comments')); + } + + /** + * testRemovePlugin + * + * Removing a plugin-prefixed model should not affect any other + * plugin-prefixed model, or app model. + * Removing an app model should not affect any other + * plugin-prefixed model. + */ + public function testRemovePlugin(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->loadPlugins(['TestPlugin', 'TestPluginTwo']); + }); + + $app = $this->_locator->get('Comments'); + $this->_locator->get('TestPlugin.Comments'); + $plugin = $this->_locator->get('TestPluginTwo.Comments'); + + $this->assertTrue($this->_locator->exists('Comments')); + $this->assertTrue($this->_locator->exists('TestPlugin.Comments')); + $this->assertTrue($this->_locator->exists('TestPluginTwo.Comments')); + + $this->_locator->remove('TestPlugin.Comments'); + + $this->assertTrue($this->_locator->exists('Comments')); + $this->assertFalse($this->_locator->exists('TestPlugin.Comments')); + $this->assertTrue($this->_locator->exists('TestPluginTwo.Comments')); + + $app2 = $this->_locator->get('Comments'); + $plugin2 = $this->_locator->get('TestPluginTwo.Comments'); + + $this->assertSame($app, $app2, 'Should be the same Comments object'); + $this->assertSame($plugin, $plugin2, 'Should be the same TestPluginTwo.Comments object'); + + $this->_locator->remove('Comments'); + + $this->assertFalse($this->_locator->exists('Comments')); + $this->assertFalse($this->_locator->exists('TestPlugin.Comments')); + $this->assertTrue($this->_locator->exists('TestPluginTwo.Comments')); + + $plugin3 = $this->_locator->get('TestPluginTwo.Comments'); + + $this->assertSame($plugin, $plugin3, 'Should be the same TestPluginTwo.Comments object'); + } + + /** + * testCustomLocation + * + * Tests that the correct table is returned when non-standard namespace is defined. + */ + public function testCustomLocation(): void + { + $locator = new TableLocator(['Infrastructure/Table']); + + $table = $locator->get('Addresses'); + $this->assertInstanceOf(AddressesTable::class, $table); + } + + /** + * testCustomLocationPlugin + * + * Tests that the correct plugin table is returned when non-standard namespace is defined. + */ + public function testCustomLocationPlugin(): void + { + $locator = new TableLocator(['Infrastructure/Table']); + + $table = $locator->get('TestPlugin.Addresses'); + $this->assertInstanceOf(PluginAddressesTable::class, $table); + } + + /** + * testCustomLocationDefaultWhenNone + * + * Tests that the default table is returned when no namespace is defined. + */ + public function testCustomLocationDefaultWhenNone(): void + { + $locator = new TableLocator([]); + + $table = $locator->get('Addresses'); + $this->assertInstanceOf(Table::class, $table); + } + + /** + * testCustomLocationDefaultWhenMissing + * + * Tests that the default table is returned when the class cannot be found in a non-standard namespace. + */ + public function testCustomLocationDefaultWhenMissing(): void + { + $locator = new TableLocator(['Infrastructure/Table']); + + $table = $locator->get('Articles'); + $this->assertInstanceOf(Table::class, $table); + } + + /** + * testCustomLocationMultiple + * + * Tests that the correct table is returned when multiple namespaces are defined. + */ + public function testCustomLocationMultiple(): void + { + $locator = new TableLocator([ + 'Infrastructure/Table', + 'Model/Table', + ]); + + $table = $locator->get('Articles'); + $this->assertInstanceOf(Table::class, $table); + } + + /** + * testAddLocation + * + * Tests that adding a namespace takes effect. + */ + public function testAddLocation(): void + { + $locator = new TableLocator([]); + + $table = $locator->get('Addresses'); + $this->assertInstanceOf(Table::class, $table); + + $locator->clear(); + $locator->addLocation('Infrastructure/Table'); + + $table = $locator->get('Addresses'); + $this->assertInstanceOf(AddressesTable::class, $table); + } + + public function testSetFallbackClassName(): void + { + $this->_locator->setFallbackClassName(ArticlesTable::class); + + $table = $this->_locator->get('FooBar'); + $this->assertInstanceOf(ArticlesTable::class, $table); + } + + /** + * testInstanceSetButNotOptions + * + * Tests that mock model will not throw an exception if model fetched with options. + */ + public function testInstanceSetButNotOptions(): void + { + $this->setTableLocator($this->_locator); + $mock = $this->getMockForModel('Articles', ['setAlias']); + $table = $this->_locator->get('Articles', ['className' => ArticlesTable::class]); + + // This is just to avoid phpunit's deprecation notice. + $mock->expects($this->never())->method('setAlias'); + + $this->assertSame($table, $mock); + } + + public function testQueryFactoryInstance(): void + { + $articles = $this->_locator->get(ArticlesTable::class); + $prop1 = new ReflectionProperty($articles, 'queryFactory'); + + $users = $this->_locator->get(MyUsersTable::class); + $prop2 = new ReflectionProperty($users, 'queryFactory'); + + $this->assertInstanceOf(QueryFactory::class, $prop1->getValue($articles)); + $this->assertSame($prop1->getValue($articles), $prop2->getValue($users)); + + $addresses = $this->_locator->get(AddressesTable::class, ['queryFactory' => new QueryFactory()]); + $prop3 = new ReflectionProperty($addresses, 'queryFactory'); + $this->assertNotSame($prop1->getValue($articles), $prop3->getValue($addresses)); + } +} diff --git a/tests/TestCase/ORM/MarshallerTest.php b/tests/TestCase/ORM/MarshallerTest.php new file mode 100644 index 00000000000..ceb68260e42 --- /dev/null +++ b/tests/TestCase/ORM/MarshallerTest.php @@ -0,0 +1,3665 @@ +articles = $this->getTableLocator()->get('Articles'); + $this->articles->belongsTo('Users', [ + 'foreignKey' => 'author_id', + ]); + $this->articles->hasMany('Comments'); + $this->articles->belongsToMany('Tags'); + + $this->comments = $this->getTableLocator()->get('Comments'); + $this->users = $this->getTableLocator()->get('Users'); + $this->tags = $this->getTableLocator()->get('Tags'); + $this->articleTags = $this->getTableLocator()->get('ArticlesTags'); + + $this->comments->belongsTo('Articles'); + $this->comments->belongsTo('Users'); + + $this->articles->setEntityClass(OpenArticleEntity::class); + $this->comments->setEntityClass(OpenArticleEntity::class); + $this->users->setEntityClass(OpenArticleEntity::class); + $this->tags->setEntityClass(OpenArticleEntity::class); + $this->articleTags->setEntityClass(OpenArticleEntity::class); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->articles, $this->comments, $this->users, $this->tags); + } + + /** + * Test one() in a simple use. + */ + public function testOneSimple(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true, + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertInstanceOf(Entity::class, $result); + $this->assertEquals($data, $result->toArray()); + $this->assertTrue($result->isDirty(), 'Should be a dirty entity.'); + $this->assertTrue($result->isNew(), 'Should be new'); + $this->assertSame('Articles', $result->getSource()); + } + + /** + * Test that marshalling an entity with numeric key in data array + */ + public function testOneWithNumericField(): void + { + $data = [ + 'sample', + 'username' => 'test', + 'password' => 'secret', + 1, + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + $this->assertSame($data[0], $result->get('0')); + $this->assertSame($data[1], $result->get('1')); + } + + /** + * Test that marshalling an entity with '' for pk values results + * in no pk value being set. + */ + public function testOneEmptyStringPrimaryKey(): void + { + $data = [ + 'id' => '', + 'username' => 'superuser', + 'password' => 'root', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertFalse($result->isDirty('id')); + $this->assertNull($result->id); + } + + /** + * Test marshalling datetime/date field. + */ + public function testOneWithDatetimeField(): void + { + $data = [ + 'comment' => 'My Comment text', + 'created' => [ + 'year' => '2014', + 'month' => '2', + 'day' => 14, + ], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->one($data, []); + + $this->assertEquals(new DateTime('2014-02-14 00:00:00'), $result->created); + + $data['created'] = [ + 'year' => '2014', + 'month' => '2', + 'day' => 14, + 'hour' => 9, + 'minute' => 25, + 'meridian' => 'pm', + ]; + $result = $marshall->one($data, []); + $this->assertEquals(new DateTime('2014-02-14 21:25:00'), $result->created); + + $data['created'] = [ + 'year' => '2014', + 'month' => '2', + 'day' => 14, + 'hour' => 9, + 'minute' => 25, + ]; + $result = $marshall->one($data, []); + $this->assertEquals(new DateTime('2014-02-14 09:25:00'), $result->created); + + $data['created'] = '2014-02-14 09:25:00'; + $result = $marshall->one($data, []); + $this->assertEquals(new DateTime('2014-02-14 09:25:00'), $result->created); + + $data['created'] = 1392387900; + $result = $marshall->one($data, []); + $this->assertSame($data['created'], $result->created->getTimestamp()); + } + + public function testOneWithFieldMatchingTableAlias(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->getSchema()->addColumn('Articles', ['type' => 'string']); + + $data = ['Articles' => 'a title', 'title' => 'First post', 'body' => 'Content here', 'author_id' => 1]; + $marshall = new Marshaller($articles); + $result = $marshall->one($data); + + $this->assertEquals($data['Articles'], $result->Articles); + } + + public function testOneWithGeospatialFields(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->getSchema() + ->addColumn('geo_line', ['type' => 'linestring']) + ->addColumn('geo_geometry', ['type' => 'geometry']) + ->addColumn('geo_point', ['type' => 'point']) + ->addColumn('geo_polygon', ['type' => 'polygon']); + + $data = [ + 'geo_line' => 'LINESTRING(0 0,1 1)', + 'geo_geometry' => 'GEOMETRY(15, 25)', + 'geo_point' => 'POINT(10, 15)', + 'geo_polygon' => 'POLYGON(0 0,6 6,6 12,12 0)', + ]; + $marshall = new Marshaller($articles); + $result = $marshall->one($data); + + $this->assertEquals($data['geo_line'], $result->geo_line); + $this->assertEquals($data['geo_geometry'], $result->geo_geometry); + $this->assertEquals($data['geo_point'], $result->geo_point); + $this->assertEquals($data['geo_polygon'], $result->geo_polygon); + } + + /** + * Ensure that marshalling casts reasonably. + */ + public function testOneOnlyCastMatchingData(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 'derp', + 'created' => 'fale', + ]; + $this->articles->setEntityClass(OpenArticleEntity::class); + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertSame($data['title'], $result->title); + $this->assertNull($result->author_id, 'No cast on bad data.'); + $this->assertSame($data['created'], $result->created, 'No cast on bad data.'); + } + + /** + * Test one() follows mass-assignment rules. + */ + public function testOneAccessibleProperties(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true, + ]; + $this->articles->setEntityClass(ProtectedArticle::class); + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, []); + + $this->assertInstanceOf(ProtectedArticle::class, $result); + $this->assertNull($result->author_id); + $this->assertNull($result->not_in_schema); + } + + /** + * Test one() supports accessibleFields option + */ + public function testOneAccessibleFieldsOption(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true, + ]; + $this->articles->setEntityClass(ProtectedArticle::class); + + $marshall = new Marshaller($this->articles); + + $result = $marshall->one($data, ['accessibleFields' => ['body' => false]]); + $this->assertNull($result->body); + + $result = $marshall->one($data, ['accessibleFields' => ['author_id' => true]]); + $this->assertSame($data['author_id'], $result->author_id); + $this->assertNull($result->not_in_schema); + + $result = $marshall->one($data, ['accessibleFields' => ['*' => true]]); + $this->assertSame($data['author_id'], $result->author_id); + $this->assertTrue($result->not_in_schema); + } + + /** + * Test one() with an invalid association + */ + public function testOneInvalidAssociation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot marshal data for `Derp` association. It is not associated with `Articles`.'); + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'derp' => [ + 'id' => 1, + 'username' => 'mark', + ], + ]; + $marshall = new Marshaller($this->articles); + $marshall->one($data, [ + 'associated' => ['Derp'], + ]); + } + + /** + * Test that one() correctly handles an association beforeMarshal + * making the association empty. + */ + public function testOneAssociationBeforeMarshalMutation(): void + { + $users = $this->getTableLocator()->get('Users'); + $articles = $this->getTableLocator()->get('Articles'); + + $users->hasOne('Articles', [ + 'foreignKey' => 'author_id', + ]); + $articles->getEventManager()->on('Model.beforeMarshal', function ($event, $data, $options): void { + // Blank the association, so it doesn't become dirty. + unset($data['not_a_real_field']); + }); + + $data = [ + 'username' => 'Jen', + 'article' => [ + 'not_a_real_field' => 'whatever', + ], + ]; + $marshall = new Marshaller($users); + $entity = $marshall->one($data, ['associated' => ['Articles']]); + $this->assertTrue($entity->isDirty('username')); + $this->assertFalse($entity->isDirty('article')); + + // Ensure consistency with merge() + $entity = new Entity([ + 'username' => 'Jenny', + ]); + // Make the entity think it is new. + $entity->setAccess('*', true); + $entity->clean(); + $entity = $marshall->merge($entity, $data, ['associated' => ['Articles']]); + $this->assertTrue($entity->isDirty('username')); + $this->assertFalse($entity->isDirty('article')); + } + + /** + * Test one() supports accessibleFields option for associations + */ + public function testOneAccessibleFieldsOptionForAssociations(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'user' => [ + 'id' => 1, + 'username' => 'mark', + ], + ]; + $this->articles->setEntityClass(ProtectedArticle::class); + $this->users->setEntityClass(ProtectedArticle::class); + + $marshall = new Marshaller($this->articles); + + $result = $marshall->one($data, [ + 'associated' => [ + 'Users' => ['accessibleFields' => ['id' => true]], + ], + 'accessibleFields' => ['body' => false, 'user' => true], + ]); + $this->assertNull($result->body); + $this->assertNull($result->user->username); + $this->assertSame(1, $result->user->id); + } + + /** + * test one() with a wrapping model name. + */ + public function testOneWithAdditionalName(): void + { + $data = [ + 'title' => 'Original Title', + 'Articles' => [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'not_in_schema' => true, + 'user' => [ + 'username' => 'mark', + ], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Users']]); + + $this->assertInstanceOf(Entity::class, $result); + $this->assertTrue($result->isDirty(), 'Should be a dirty entity.'); + $this->assertTrue($result->isNew(), 'Should be new'); + $this->assertFalse($result->has('Articles'), 'No prefixed field.'); + $this->assertSame($data['title'], $result->title, 'Data from prefix should be merged.'); + $this->assertSame($data['Articles']['user']['username'], $result->user->username); + } + + /** + * test one() with association data. + */ + public function testOneAssociationsSingle(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'comments' => [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ], + 'user' => [ + 'username' => 'mark', + 'password' => 'secret', + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Users']]); + + $this->assertSame($data['title'], $result->title); + $this->assertSame($data['body'], $result->body); + $this->assertSame($data['author_id'], $result->author_id); + + $this->assertIsArray($result->comments); + $this->assertEquals($data['comments'], $result->comments); + $this->assertTrue($result->isDirty('comments')); + + $this->assertInstanceOf(Entity::class, $result->user); + $this->assertTrue($result->isDirty('user')); + $this->assertSame($data['user']['username'], $result->user->username); + $this->assertSame($data['user']['password'], $result->user->password); + } + + /** + * test one() with association data. + */ + public function testOneAssociationsMany(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'comments' => [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ], + 'user' => [ + 'username' => 'mark', + 'password' => 'secret', + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Comments']]); + + $this->assertSame($data['title'], $result->title); + $this->assertSame($data['body'], $result->body); + $this->assertSame($data['author_id'], $result->author_id); + + $this->assertIsArray($result->comments); + $this->assertCount(2, $result->comments); + $this->assertInstanceOf(Entity::class, $result->comments[0]); + $this->assertInstanceOf(Entity::class, $result->comments[1]); + $this->assertSame($data['comments'][0]['comment'], $result->comments[0]->comment); + + $this->assertIsArray($result->user); + $this->assertEquals($data['user'], $result->user); + } + + /** + * Test building the _joinData entity for belongstomany associations. + */ + public function testOneBelongsToManyJoinData(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + ['tag' => 'news', '_joinData' => ['active' => 1]], + ['tag' => 'cakephp', '_joinData' => ['active' => 0]], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => ['Tags'], + ]); + + $this->assertSame($data['title'], $result->title); + $this->assertSame($data['body'], $result->body); + + $this->assertIsArray($result->tags); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertSame($data['tags'][0]['tag'], $result->tags[0]->tag); + + $this->assertInstanceOf( + Entity::class, + $result->tags[0]->_joinData, + '_joinData should be an entity.', + ); + $this->assertSame( + $data['tags'][0]['_joinData']['active'], + $result->tags[0]->_joinData->active, + '_joinData should be an entity.', + ); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for belongs to many associations. + */ + public function testOneBelongsToManyOnlyIdsRejectArray(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + ['tag' => 'news'], + ['tag' => 'cakephp'], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => ['Tags' => ['onlyIds' => true]], + ]); + $this->assertEmpty($result->tags, 'Only ids should be marshalled.'); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for belongs to many associations. + */ + public function testOneBelongsToManyOnlyIdsWithIds(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + '_ids' => [1, 2], + ['tag' => 'news'], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => ['Tags' => ['onlyIds' => true]], + ]); + $this->assertCount(2, $result->tags, 'Ids should be marshalled.'); + } + + /** + * Test marshalling nested associations on the _joinData structure. + */ + public function testOneBelongsToManyJoinDataAssociated(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'tag' => 'news', + '_joinData' => [ + 'active' => 1, + 'user' => ['username' => 'Bill'], + ], + ], + [ + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'Mark'], + ], + ], + ], + ]; + + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData.Users']]); + $this->assertInstanceOf( + Entity::class, + $result->tags[0]->_joinData->user, + 'joinData should contain a user entity.', + ); + $this->assertSame('Bill', $result->tags[0]->_joinData->user->username); + $this->assertInstanceOf( + Entity::class, + $result->tags[1]->_joinData->user, + 'joinData should contain a user entity.', + ); + $this->assertSame('Mark', $result->tags[1]->_joinData->user->username); + + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'tag' => 'news', + 'junction' => [ + 'active' => 1, + 'user' => ['username' => 'Bill'], + ], + ], + [ + 'tag' => 'cakephp', + 'junction' => [ + 'active' => 0, + 'user' => ['username' => 'Mark'], + ], + ], + ], + ]; + + $this->articles->Tags->setJunctionProperty('junction'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags.junction.Users']]); + $this->assertInstanceOf( + Entity::class, + $result->tags[0]->junction->user, + 'junction should contain a user entity.', + ); + } + + /** + * Test one() with with id and _joinData. + */ + public function testOneBelongsToManyJoinDataAssociatedWithIds(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + 3 => [ + 'id' => 1, + '_joinData' => [ + 'active' => 1, + 'user' => ['username' => 'MyLux'], + ], + ], + 5 => [ + 'id' => 2, + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'IronFall'], + ], + ], + ], + ]; + + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $tags = $this->getTableLocator()->get('Tags'); + $t1 = $tags->find('all')->where(['id' => 1])->first(); + $t2 = $tags->find('all')->where(['id' => 2])->first(); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData.Users']]); + $this->assertInstanceOf( + Entity::class, + $result->tags[0], + ); + $this->assertInstanceOf( + Entity::class, + $result->tags[1], + ); + + $this->assertInstanceOf( + Entity::class, + $result->tags[0]->_joinData->user, + ); + + $this->assertInstanceOf( + Entity::class, + $result->tags[1]->_joinData->user, + ); + $this->assertFalse($result->tags[0]->isNew(), 'Should not be new, as id is in db.'); + $this->assertFalse($result->tags[1]->isNew(), 'Should not be new, as id is in db.'); + $this->assertEquals($t1->tag, $result->tags[0]->tag); + $this->assertEquals($t2->tag, $result->tags[1]->tag); + $this->assertSame($data['tags'][3]['_joinData']['user']['username'], $result->tags[0]->_joinData->user->username); + $this->assertSame($data['tags'][5]['_joinData']['user']['username'], $result->tags[1]->_joinData->user->username); + } + + /** + * Test belongsToMany association with mixed data and _joinData + */ + public function testOneBelongsToManyWithMixedJoinData(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ], + ], + [ + 'name' => 'tag5', + '_joinData' => [ + 'active' => 1, + ], + ], + ], + ]; + $marshall = new Marshaller($this->articles); + + $result = $marshall->one($data, ['associated' => ['Tags._joinData']]); + + $this->assertSame($data['tags'][0]['id'], $result->tags[0]->id); + $this->assertSame($data['tags'][1]['name'], $result->tags[1]->name); + $this->assertSame(0, $result->tags[0]->_joinData->active); + $this->assertSame(1, $result->tags[1]->_joinData->active); + } + + public function testOneBelongsToManyWithNestedAssociations(): void + { + $this->tags->belongsToMany('Articles'); + $data = [ + 'name' => 'new tag', + 'articles' => [ + // This nested article exists, and we want to update it. + [ + 'id' => 1, + 'title' => 'New tagged article', + 'body' => 'New tagged article', + 'user' => [ + 'id' => 1, + 'username' => 'newuser', + ], + 'comments' => [ + ['comment' => 'New comment', 'user_id' => 1], + ['comment' => 'Second comment', 'user_id' => 1], + ], + ], + ], + ]; + $marshaller = new Marshaller($this->tags); + $tag = $marshaller->one($data, ['associated' => ['Articles.Users', 'Articles.Comments']]); + + $this->assertNotEmpty($tag->articles); + $this->assertCount(1, $tag->articles); + $this->assertTrue($tag->isDirty('articles'), 'Updated prop should be dirty'); + $this->assertInstanceOf(Entity::class, $tag->articles[0]); + $this->assertSame('New tagged article', $tag->articles[0]->title); + $this->assertFalse($tag->articles[0]->isNew()); + + $this->assertNotEmpty($tag->articles[0]->user); + $this->assertInstanceOf(Entity::class, $tag->articles[0]->user); + $this->assertTrue($tag->articles[0]->isDirty('user'), 'Updated prop should be dirty'); + $this->assertSame('newuser', $tag->articles[0]->user->username); + $this->assertTrue($tag->articles[0]->user->isNew()); + + $this->assertNotEmpty($tag->articles[0]->comments); + $this->assertCount(2, $tag->articles[0]->comments); + $this->assertTrue($tag->articles[0]->isDirty('comments'), 'Updated prop should be dirty'); + $this->assertInstanceOf(Entity::class, $tag->articles[0]->comments[0]); + $this->assertTrue($tag->articles[0]->comments[0]->isNew()); + $this->assertTrue($tag->articles[0]->comments[1]->isNew()); + } + + /** + * Same test as @see testOneBelongsToManyWithNestedAssociations + * just without using dot notation in the marshalling process + * + * @return void + */ + public function testOneBelongsToManyWithNestedAssociationsWithoutDotNotation(): void + { + $this->tags->belongsToMany('Articles'); + $data = [ + 'name' => 'new tag', + 'articles' => [ + // This nested article exists, and we want to update it. + [ + 'id' => 1, + 'title' => 'New tagged article', + 'body' => 'New tagged article', + 'user' => [ + 'id' => 1, + 'username' => 'newuser', + ], + 'comments' => [ + ['comment' => 'New comment', 'user_id' => 1], + ['comment' => 'Second comment', 'user_id' => 1], + ], + ], + ], + ]; + $marshaller = new Marshaller($this->tags); + $tag = $marshaller->one($data, ['associated' => ['Articles' => [ 'associated' => ['Users', 'Comments']]]]); + + $this->assertNotEmpty($tag->articles); + $this->assertCount(1, $tag->articles); + $this->assertTrue($tag->isDirty('articles'), 'Updated prop should be dirty'); + $this->assertInstanceOf(Entity::class, $tag->articles[0]); + $this->assertSame('New tagged article', $tag->articles[0]->title); + $this->assertFalse($tag->articles[0]->isNew()); + + $this->assertNotEmpty($tag->articles[0]->user); + $this->assertInstanceOf(Entity::class, $tag->articles[0]->user); + $this->assertTrue($tag->articles[0]->isDirty('user'), 'Updated prop should be dirty'); + $this->assertSame('newuser', $tag->articles[0]->user->username); + $this->assertTrue($tag->articles[0]->user->isNew()); + + $this->assertNotEmpty($tag->articles[0]->comments); + $this->assertCount(2, $tag->articles[0]->comments); + $this->assertTrue($tag->articles[0]->isDirty('comments'), 'Updated prop should be dirty'); + $this->assertInstanceOf(Entity::class, $tag->articles[0]->comments[0]); + $this->assertTrue($tag->articles[0]->comments[0]->isNew()); + $this->assertTrue($tag->articles[0]->comments[1]->isNew()); + } + + /** + * Test belongsToMany association with mixed data and _joinData + */ + public function testBelongsToManyAddingNewExisting(): void + { + $this->tags->setEntityClass(OpenTag::class); + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ], + ], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData']]); + $data = [ + 'title' => 'New Title', + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ], + ], + [ + 'id' => 2, + '_joinData' => [ + 'active' => 1, + ], + ], + ], + ]; + $result = $marshall->merge($result, $data, ['associated' => ['Tags._joinData']]); + + $this->assertSame($data['title'], $result->title); + $this->assertSame($data['tags'][0]['id'], $result->tags[0]->id); + $this->assertSame($data['tags'][1]['id'], $result->tags[1]->id); + $this->assertNotEmpty($result->tags[0]->_joinData); + $this->assertNotEmpty($result->tags[1]->_joinData); + $this->assertTrue($result->isDirty('tags'), 'Modified prop should be dirty'); + $this->assertSame(0, $result->tags[0]->_joinData->active); + $this->assertSame(1, $result->tags[1]->_joinData->active); + + $entity = new Entity(); + $entity->requireFieldPresence(true); + // MissingPropertyException should not be thrown for `tags` field + $marshall->merge($entity, $data, ['associated' => ['Tags._joinData']]); + + $inner = new Entity(['id' => 1]); + $inner->requireFieldPresence(true); + $entity = new Entity([ + 'tags' => [ + $inner, + ], + ]); + $entity->requireFieldPresence(true); + // MissingPropertyException should not be thrown for `_joinData` field + $marshall->merge($entity, $data, ['associated' => ['Tags._joinData']]); + } + + /** + * Test belongsToMany association with mixed data and _joinData + */ + public function testBelongsToManyWithMixedJoinDataOutOfOrder(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'name' => 'tag5', + '_joinData' => [ + 'active' => 1, + ], + ], + [ + 'id' => 1, + '_joinData' => [ + 'active' => 0, + ], + ], + [ + 'name' => 'tag3', + '_joinData' => [ + 'active' => 1, + ], + ], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags._joinData']]); + + $this->assertSame($data['tags'][0]['name'], $result->tags[0]->name); + $this->assertSame($data['tags'][1]['id'], $result->tags[1]->id); + $this->assertSame($data['tags'][2]['name'], $result->tags[2]->name); + + $this->assertSame(1, $result->tags[0]->_joinData->active); + $this->assertSame(0, $result->tags[1]->_joinData->active); + $this->assertSame(1, $result->tags[2]->_joinData->active); + } + + /** + * Test belongsToMany association with scalars + */ + public function testBelongsToManyInvalidData(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + 'id' => 1, + ], + ]; + + $article = $this->articles->newEntity($data, [ + 'associated' => ['Tags'], + ]); + $this->assertEmpty($article->tags, 'No entity should be created'); + + $data['tags'] = 1; + $article = $this->articles->newEntity($data, [ + 'associated' => ['Tags'], + ]); + $this->assertEmpty($article->tags, 'No entity should be created'); + } + + /** + * Test belongsToMany association with mixed data array + */ + public function testBelongsToManyWithMixedData(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'name' => 'tag4', + ], + [ + 'name' => 'tag5', + ], + [ + 'id' => 1, + ], + ], + ]; + + $tags = $this->getTableLocator()->get('Tags'); + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, ['associated' => ['Tags']]); + + $this->assertSame($data['tags'][0]['name'], $article->tags[0]->name); + $this->assertSame($data['tags'][1]['name'], $article->tags[1]->name); + $this->assertEquals($article->tags[2], $tags->get(1)); + + $this->assertTrue($article->tags[0]->isNew()); + $this->assertTrue($article->tags[1]->isNew()); + $this->assertFalse($article->tags[2]->isNew()); + + $tagCount = $tags->find()->count(); + $this->articles->save($article); + + $this->assertSame($tagCount + 2, $tags->find()->count()); + } + + /** + * Test belongsToMany association with the ForceNewTarget to force saving + * new records on the target tables with BTM relationships when the primaryKey(s) + * of the target table is specified. + */ + public function testBelongsToManyWithForceNew(): void + { + $data = [ + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 3, + ], + [ + 'id' => 4, + 'name' => 'tag4', + ], + ], + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, [ + 'associated' => ['Tags'], + 'forceNew' => true, + ]); + + $this->assertFalse($article->tags[0]->isNew(), 'The tag should not be new'); + $this->assertTrue($article->tags[1]->isNew(), 'The tag should be new'); + $this->assertSame('tag4', $article->tags[1]->name, 'Property should match request data.'); + } + + /** + * Test HasMany association with _ids attribute + */ + public function testOneHasManyWithIds(): void + { + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + '_ids' => [1, 2], + ], + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, ['associated' => ['Comments']]); + + $this->assertEquals($article->comments[0], $this->comments->get(1)); + $this->assertEquals($article->comments[1], $this->comments->get(2)); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for hasmany associations. + */ + public function testOneHasManyOnlyIdsRejectArray(): void + { + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + ['comment' => 'first comment'], + ['comment' => 'second comment'], + ], + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, [ + 'associated' => ['Comments' => ['onlyIds' => true]], + ]); + $this->assertEmpty($article->comments); + } + + /** + * Test that the onlyIds option restricts to only accepting ids for hasmany associations. + */ + public function testOneHasManyOnlyIdsWithIds(): void + { + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + '_ids' => [1, 2], + ['comment' => 'first comment'], + ], + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, [ + 'associated' => ['Comments' => ['onlyIds' => true]], + ]); + $this->assertCount(2, $article->comments); + } + + /** + * Test HasMany association with invalid data + */ + public function testOneHasManyInvalidData(): void + { + $data = [ + 'title' => 'new title', + 'body' => 'some content', + 'comments' => [ + 'id' => 1, + ], + ]; + + $marshaller = new Marshaller($this->articles); + $article = $marshaller->one($data, ['associated' => ['Comments']]); + $this->assertEmpty($article->comments); + + $data['comments'] = 1; + $article = $marshaller->one($data, ['associated' => ['Comments']]); + $this->assertEmpty($article->comments); + } + + /** + * Test one() with deeper associations. + */ + public function testOneDeepAssociations(): void + { + $data = [ + 'comment' => 'First post', + 'user_id' => 2, + 'article' => [ + 'title' => 'Article title', + 'body' => 'Article body', + 'user' => [ + 'username' => 'mark', + 'password' => 'secret', + ], + ], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->one($data, ['associated' => ['Articles.Users']]); + + $this->assertSame( + $data['article']['title'], + $result->article->title, + ); + $this->assertSame( + $data['article']['user']['username'], + $result->article->user->username, + ); + } + + /** + * Test many() with a simple set of data. + */ + public function testManySimple(): void + { + $data = [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data); + + $this->assertCount(2, $result); + $this->assertInstanceOf(Entity::class, $result[0]); + $this->assertInstanceOf(Entity::class, $result[1]); + $this->assertSame($data[0]['comment'], $result[0]->comment); + $this->assertSame($data[1]['comment'], $result[1]->comment); + } + + /** + * Test many() with some invalid data + */ + public function testManyInvalidData(): void + { + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + '_csrfToken' => 'abc123', + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data); + + $this->assertCount(2, $result); + } + + /** + * test many() with nested associations. + */ + public function testManyAssociations(): void + { + $data = [ + [ + 'comment' => 'First post', + 'user_id' => 2, + 'user' => [ + 'username' => 'mark', + ], + ], + [ + 'comment' => 'Second post', + 'user_id' => 2, + 'user' => [ + 'username' => 'jose', + ], + ], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data, ['associated' => ['Users']]); + + $this->assertCount(2, $result); + $this->assertInstanceOf(Entity::class, $result[0]); + $this->assertInstanceOf(Entity::class, $result[1]); + $this->assertSame( + $data[0]['user']['username'], + $result[0]->user->username, + ); + $this->assertSame( + $data[1]['user']['username'], + $result[1]->user->username, + ); + } + + /** + * Test if exception is raised when called with [associated => NonExistentAssociation] + * Previously such association were simply ignored + */ + public function testManyInvalidAssociation(): void + { + $this->expectException(InvalidArgumentException::class); + $data = [ + [ + 'comment' => 'First post', + 'user_id' => 2, + 'user' => [ + 'username' => 'mark', + ], + ], + [ + 'comment' => 'Second post', + 'user_id' => 2, + 'user' => [ + 'username' => 'jose', + ], + ], + ]; + $marshall = new Marshaller($this->comments); + $marshall->many($data, ['associated' => ['Users', 'People']]); + } + + /** + * Test generating a list of entities from a list of ids. + */ + public function testOneGenerateBelongsToManyEntitiesFromIds(): void + { + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => ''], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => false], + ]; + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => null], + ]; + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => []], + ]; + $result = $marshall->one($data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => [1, 2, 3]], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags']]); + + $this->assertCount(3, $result->tags); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[1]); + $this->assertInstanceOf(Entity::class, $result->tags[2]); + } + + /** + * Test merge() in a simple use. + */ + public function testMergeSimple(): void + { + $data = [ + 'title' => 'My title', + 'author_id' => 1, + 'not_in_schema' => true, + ]; + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'title' => 'Foo', + 'body' => 'My Content', + ]); + $entity->setAccess('*', true); + $entity->setNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, []); + + $this->assertSame($entity, $result); + $this->assertEquals($data + ['body' => 'My Content'], $result->toArray()); + $this->assertTrue($result->isDirty(), 'Should be a dirty entity.'); + $this->assertFalse($result->isNew(), 'Should not change the entity state'); + + $entity = new Entity(); + $entity->requireFieldPresence(true); + // MissingPropertyException should not be thrown + $marshall->merge($entity, $data, []); + } + + /** + * Test merge() with accessibleFields options + */ + public function testMergeAccessibleFields(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'New content', + 'author_id' => 1, + 'not_in_schema' => true, + ]; + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'title' => 'Foo', + 'body' => 'My Content', + ]); + $entity->setAccess('*', false); + $entity->setNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, ['accessibleFields' => ['body' => true]]); + + $this->assertSame($entity, $result); + $this->assertEquals(['title' => 'Foo', 'body' => 'New content'], $result->toArray()); + $this->assertTrue($entity->isAccessible('body')); + } + + /** + * Provides empty values. + * + * @return array + */ + public static function emptyProvider(): array + { + return [ + [0], + ['0'], + ]; + } + + /** + * Test merging empty values into an entity. + * + * @param mixed $value + */ + #[DataProvider('emptyProvider')] + public function testMergeFalseyValues($value): void + { + $marshall = new Marshaller($this->articles); + $entity = new Entity(); + $entity->setAccess('*', true); + $entity->clean(); + + $entity = $marshall->merge($entity, ['author_id' => $value]); + $this->assertTrue($entity->isDirty('author_id'), 'Field should be dirty'); + $this->assertSame(0, $entity->get('author_id'), 'Value should be zero'); + } + + /** + * Test merge() doesn't dirty values that were null and are null again. + */ + public function testMergeUnchangedNullValue(): void + { + $data = [ + 'title' => 'My title', + 'author_id' => 1, + 'body' => null, + ]; + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'title' => 'Foo', + 'body' => null, + ]); + $entity->setAccess('*', true); + $entity->setNew(false); + $entity->clean(); + $marshall->merge($entity, $data, []); + + $this->assertFalse($entity->isDirty('body'), 'unchanged null should not be dirty'); + } + + /** + * Test merge() doesn't dirty objects which are equal. + */ + public function testMergeWithSameObjectValue(): void + { + $created = new DateTime('2020-10-29'); + $entity = new Entity([ + 'comment' => 'foo', + 'created' => $created, + ]); + $entity->setAccess('*', true); + $entity->setNew(false); + $entity->clean(); + + $data = [ + 'comment' => 'bar', + 'created' => clone $created, + ]; + $marshall = new Marshaller($this->comments); + $marshall->merge($entity, $data); + + $this->assertFalse($entity->isDirty('created')); + } + + /** + * Tests that merge respects the entity accessible methods + */ + public function testMergeWhitelist(): void + { + $data = [ + 'title' => 'My title', + 'author_id' => 1, + 'not_in_schema' => true, + ]; + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'title' => 'Foo', + 'body' => 'My Content', + ]); + $entity->setAccess('*', false); + $entity->setAccess('author_id', true); + $entity->setNew(false); + $entity->clean(); + + $result = $marshall->merge($entity, $data, []); + + $expected = [ + 'title' => 'Foo', + 'body' => 'My Content', + 'author_id' => 1, + ]; + $this->assertEquals($expected, $result->toArray()); + } + + /** + * Test merge() with an invalid association + */ + public function testMergeInvalidAssociation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot marshal data for `Derp` association. It is not associated with `Articles`.'); + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'derp' => [ + 'id' => 1, + 'username' => 'mark', + ], + ]; + $article = new Entity([ + 'title' => 'title for post', + 'body' => 'body', + ]); + $marshall = new Marshaller($this->articles); + $marshall->merge($article, $data, [ + 'associated' => ['Derp'], + ]); + } + + /** + * Test merge when fields contains an association. + */ + public function testMergeWithSingleAssociationAndFields(): void + { + $user = new Entity([ + 'username' => 'user', + ]); + $article = new Entity([ + 'title' => 'title for post', + 'body' => 'body', + 'user' => $user, + ]); + + $user->setAccess('*', true); + $article->setAccess('*', true); + + $data = [ + 'title' => 'Chelsea', + 'user' => [ + 'username' => 'dee', + ], + ]; + + $marshall = new Marshaller($this->articles); + $marshall->merge($article, $data, [ + 'fields' => ['title', 'user'], + 'associated' => ['Users' => []], + ]); + $this->assertSame($user, $article->user); + $this->assertTrue($article->isDirty('user')); + } + + /** + * Tests that fields with the same value are not marked as dirty + */ + public function testMergeDirty(): void + { + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'title' => 'Foo', + 'author_id' => 1, + ]); + $data = [ + 'title' => 'Foo', + 'author_id' => 1, + 'crazy' => true, + ]; + $entity->setAccess('*', true); + $entity->clean(); + $result = $marshall->merge($entity, $data, []); + + $expected = [ + 'title' => 'Foo', + 'author_id' => 1, + 'crazy' => true, + ]; + $this->assertEquals($expected, $result->toArray()); + $this->assertFalse($entity->isDirty('title')); + $this->assertFalse($entity->isDirty('author_id')); + $this->assertTrue($entity->isDirty('crazy')); + + // https://github.com/cakephp/cakephp/issues/18346 + $entity = new class ([ + 'title' => 'Foo', + 'author_id' => 1, + ], ['useSetters' => false]) extends Entity { + protected function _setTitle(string $name): string + { + return 'The ' . $name; + } + }; + $entity->clean(); + + $this->assertSame('Foo', $entity->title); + $marshall->merge($entity, ['title' => 'Foo', 'author_id' => 2]); + $this->assertSame('Foo', $entity->title, 'Setter should not be called as the value is unchanged'); + $this->assertFalse($entity->isDirty('title')); + $this->assertTrue($entity->isDirty('author_id')); + $this->assertSame(2, $entity->author_id); + } + + /** + * Tests merging data into an associated entity + */ + public function testMergeWithSingleAssociation(): void + { + $user = new Entity([ + 'username' => 'mark', + 'password' => 'secret', + ]); + $entity = new Entity([ + 'title' => 'My Title', + 'user' => $user, + ]); + $user->setAccess('*', true); + $entity->setAccess('*', true); + $entity->clean(); + + $data = [ + 'body' => 'My Content', + 'user' => [ + 'password' => 'not a secret', + ], + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, ['associated' => ['Users']]); + + $this->assertTrue($entity->isDirty('user'), 'association should be dirty'); + $this->assertTrue($entity->isDirty('body'), 'body should be dirty'); + $this->assertSame('My Content', $entity->body); + $this->assertSame($user, $entity->user); + $this->assertSame('mark', $entity->user->username); + $this->assertSame('not a secret', $entity->user->password); + } + + /** + * Tests that new associated entities can be created when merging data into + * a parent entity + */ + public function testMergeCreateAssociation(): void + { + $entity = new Entity([ + 'title' => 'My Title', + ]); + $entity->setAccess('*', true); + $entity->clean(); + + $data = [ + 'body' => 'My Content', + 'user' => [ + 'username' => 'mark', + 'password' => 'not a secret', + ], + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, ['associated' => ['Users']]); + + $this->assertSame('My Content', $entity->body); + $this->assertInstanceOf(Entity::class, $entity->user); + $this->assertSame('mark', $entity->user->username); + $this->assertSame('not a secret', $entity->user->password); + $this->assertTrue($entity->isDirty('user')); + $this->assertTrue($entity->isDirty('body')); + $this->assertTrue($entity->user->isNew()); + } + + /** + * Test merge when an association has been replaced with null + */ + public function testMergeAssociationNullOut(): void + { + $user = new Entity([ + 'id' => 1, + 'username' => 'user', + ]); + $article = new Entity([ + 'title' => 'title for post', + 'user_id' => 1, + 'user' => $user, + ]); + + $user->setAccess('*', true); + $article->setAccess('*', true); + + $data = [ + 'title' => 'Chelsea', + 'user_id' => '', + 'user' => '', + ]; + + $marshall = new Marshaller($this->articles); + $marshall->merge($article, $data, [ + 'associated' => ['Users'], + ]); + $this->assertNull($article->user); + $this->assertSame('', $article->user_id); + $this->assertTrue($article->isDirty('user')); + } + + /** + * Tests merging one to many associations + */ + public function testMergeMultipleAssociations(): void + { + $user = new Entity(['username' => 'mark', 'password' => 'secret']); + $comment1 = new Entity(['id' => 1, 'comment' => 'A comment']); + $comment2 = new Entity(['id' => 2, 'comment' => 'Another comment']); + $entity = new Entity([ + 'title' => 'My Title', + 'user' => $user, + 'comments' => [$comment1, $comment2], + ]); + + $user->setAccess('*', true); + $comment1->setAccess('*', true); + $comment2->setAccess('*', true); + $entity->setAccess('*', true); + $entity->clean(); + + $data = [ + 'title' => 'Another title', + 'user' => ['password' => 'not so secret'], + 'comments' => [ + ['comment' => 'Extra comment 1'], + ['id' => 2, 'comment' => 'Altered comment 2'], + ['id' => 1, 'comment' => 'Altered comment 1'], + ['id' => 3, 'comment' => 'Extra comment 3'], + ['id' => 4, 'comment' => 'Extra comment 4'], + ['comment' => 'Extra comment 2'], + ], + ]; + $marshall = new Marshaller($this->articles); + + $result = $marshall->merge($entity, $data, ['associated' => ['Users', 'Comments']]); + $this->assertSame($entity, $result); + $this->assertSame($user, $result->user); + $this->assertTrue($result->isDirty('user'), 'association should be dirty'); + $this->assertSame('not so secret', $entity->user->password); + + $this->assertTrue($result->isDirty('comments')); + $this->assertSame($comment1, $entity->comments[0]); + $this->assertSame($comment2, $entity->comments[1]); + $this->assertSame('Altered comment 1', $entity->comments[0]->comment); + $this->assertSame('Altered comment 2', $entity->comments[1]->comment); + + $thirdComment = $this->articles->Comments + ->find() + ->where(['id' => 3]) + ->enableHydration(false) + ->first(); + + $this->assertEquals( + ['comment' => 'Extra comment 3'] + $thirdComment, + $entity->comments[2]->toArray(), + ); + + $forthComment = $this->articles->Comments + ->find() + ->where(['id' => 4]) + ->enableHydration(false) + ->first(); + + $this->assertEquals( + ['comment' => 'Extra comment 4'] + $forthComment, + $entity->comments[3]->toArray(), + ); + + $this->assertEquals( + ['comment' => 'Extra comment 1'], + $entity->comments[4]->toArray(), + ); + $this->assertEquals( + ['comment' => 'Extra comment 2'], + $entity->comments[5]->toArray(), + ); + } + + /** + * Tests that merging data to a hasMany association with _ids works. + */ + public function testMergeHasManyEntitiesFromIds(): void + { + $entity = $this->articles->get(1, ...['contain' => ['Comments']]); + $this->assertNotEmpty($entity->comments); + + $marshall = new Marshaller($this->articles); + $data = ['comments' => ['_ids' => [1, 2, 3]]]; + $result = $marshall->merge($entity, $data, ['associated' => ['Comments']]); + + $this->assertCount(3, $result->comments); + $this->assertTrue($result->isDirty('comments'), 'Updated prop should be dirty'); + $this->assertInstanceOf(Entity::class, $result->comments[0]); + $this->assertSame(1, $result->comments[0]->id); + $this->assertInstanceOf(Entity::class, $result->comments[1]); + $this->assertSame(2, $result->comments[1]->id); + $this->assertInstanceOf(Entity::class, $result->comments[2]); + $this->assertSame(3, $result->comments[2]->id); + } + + /** + * Tests that merging data to a hasMany association using onlyIds restricts operations. + */ + public function testMergeHasManyEntitiesFromIdsOnlyIds(): void + { + $entity = $this->articles->get(1, ...['contain' => ['Comments']]); + $this->assertNotEmpty($entity->comments); + + $marshall = new Marshaller($this->articles); + $data = [ + 'comments' => [ + '_ids' => [1], + [ + 'comment' => 'Nope', + ], + ], + ]; + $result = $marshall->merge($entity, $data, ['associated' => ['Comments' => ['onlyIds' => true]]]); + + $this->assertCount(1, $result->comments); + $this->assertTrue($result->isDirty('comments'), 'Updated prop should be dirty'); + $this->assertInstanceOf(Entity::class, $result->comments[0]); + $this->assertNotEquals('Nope', $result->comments[0]->comment); + } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * will just overwrite the data + */ + public function testMergeBelongsToManyEntitiesFromIds(): void + { + $entity = new Entity([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Entity(['id' => 1, 'name' => 'Cake']), + new Entity(['id' => 2, 'name' => 'PHP']), + ], + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => [1, 2, 3]], + ]; + $entity->setAccess('*', true); + $entity->clean(); + + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(3, $result->tags); + $this->assertTrue($result->isDirty('tags'), 'Updated prop should be dirty'); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[1]); + $this->assertInstanceOf(Entity::class, $result->tags[2]); + } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * will not generate conflicting queries when associations are automatically selected + */ + public function testMergeFromIdsWithAutoAssociation(): void + { + $entity = new Entity([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Entity(['id' => 1, 'name' => 'Cake']), + new Entity(['id' => 2, 'name' => 'PHP']), + ], + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => [1, 2, 3]], + ]; + $entity->setAccess('*', true); + $entity->clean(); + + // Adding a forced join to have another table with the same column names + $this->articles->Tags->getEventManager()->on('Model.beforeFind', function ($e, $query): void { + $left = new IdentifierExpression('Tags.id'); + $right = new IdentifierExpression('a.id'); + $query->leftJoin(['a' => 'tags'], $query->expr()->eq($left, $right)); + }); + + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(3, $result->tags); + $this->assertTrue($result->isDirty('tags')); + } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * with additional association conditions works. + */ + public function testMergeBelongsToManyFromIdsWithConditions(): void + { + $this->articles->associations()->get('Tags')->setConditions([ + 'conditions' => ['ArticleTags.article_id' => 1], + ]); + + $entity = new Entity([ + 'title' => 'No tags', + 'body' => 'Some content here', + 'tags' => [], + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => [1, 2, 3]], + ]; + $entity->setAccess('*', true); + $entity->clean(); + + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(3, $result->tags); + $this->assertTrue($result->isDirty('tags')); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[1]); + $this->assertInstanceOf(Entity::class, $result->tags[2]); + } + + /** + * Tests that merging data to an entity containing belongsToMany as an array + * with additional association conditions works. + */ + public function testMergeBelongsToManyFromArrayWithConditions(): void + { + $this->articles->associations()->get('Tags')->setConditions([ + 'conditions' => ['ArticleTags.article_id' => 1], + ]); + + $this->articles->Tags->getEventManager() + ->on('Model.beforeFind', function (EventInterface $event, $query) use (&$called): void { + $called = true; + + $query->where(['Tags.id >=' => 1]); + }); + + $entity = new Entity([ + 'title' => 'No tags', + 'body' => 'Some content here', + 'tags' => [], + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + ['id' => 1], + ['id' => 2], + ], + ]; + $entity->setAccess('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(2, $result->tags); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[1]); + $this->assertTrue($called); + } + + /** + * Tests that merging data to an entity containing belongsToMany and _ids + * will ignore empty values. + */ + public function testMergeBelongsToManyEntitiesFromIdsEmptyValue(): void + { + $entity = new Entity([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Entity(['id' => 1, 'name' => 'Cake']), + new Entity(['id' => 2, 'name' => 'PHP']), + ], + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => ''], + ]; + $entity->setAccess('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => false], + ]; + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => ['_ids' => null], + ]; + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + $this->assertCount(0, $result->tags); + $this->assertTrue($result->isDirty('tags')); + } + + /** + * Test that the ids option restricts to only accepting ids for belongs to many associations. + */ + public function testMergeBelongsToManyOnlyIdsRejectArray(): void + { + $entity = new Entity([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Entity(['id' => 1, 'name' => 'Cake']), + new Entity(['id' => 2, 'name' => 'PHP']), + ], + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + ['name' => 'new'], + ['name' => 'awesome'], + ], + ]; + $entity->setAccess('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, [ + 'associated' => ['Tags' => ['onlyIds' => true]], + ]); + $this->assertCount(0, $result->tags); + $this->assertTrue($result->isDirty('tags')); + } + + /** + * Test that the ids option restricts to only accepting ids for belongs to many associations. + */ + public function testMergeBelongsToManyOnlyIdsWithIds(): void + { + $entity = new Entity([ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => [ + new Entity(['id' => 1, 'name' => 'Cake']), + new Entity(['id' => 2, 'name' => 'PHP']), + ], + ]); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + '_ids' => [3], + ], + ]; + $entity->setAccess('*', true); + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, [ + 'associated' => ['Tags' => ['ids' => true]], + ]); + $this->assertCount(1, $result->tags); + $this->assertSame('tag3', $result->tags[0]->name); + $this->assertTrue($result->isDirty('tags')); + } + + /** + * Test that invalid _joinData (scalar data) is not marshalled. + */ + public function testMergeBelongsToManyJoinDataScalar(): void + { + $this->getTableLocator()->clear(); + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Tags', [ + 'through' => 'SpecialTags', + ]); + + $entity = $articles->get(1, ...['contain' => 'Tags']); + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => 'Invalid'], + ], + ]; + $marshall = new Marshaller($articles); + $marshall->merge($entity, $data, ['associated' => 'Tags._joinData']); + + $articles->save($entity, ['associated' => ['Tags._joinData']]); + $this->assertFalse($entity->tags[0]->isDirty('_joinData')); + $this->assertEmpty($entity->tags[0]->_joinData); + } + + /** + * Test merging the _joinData entity for belongstomany associations when * is not + * accessible. + */ + public function testMergeBelongsToManyJoinDataNotAccessible(): void + { + $this->getTableLocator()->clear(); + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Tags', [ + 'through' => 'SpecialTags', + ]); + + $entity = $articles->get(1, ...['contain' => 'Tags']); + // Make only specific fields accessible, but not _joinData. + $entity->tags[0]->setAccess('*', false); + $entity->tags[0]->setAccess(['article_id', 'tag_id'], true); + + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => ['highlighted' => '1', 'author_id' => '99']], + ], + ]; + $marshall = new Marshaller($articles); + $result = $marshall->merge($entity, $data, ['associated' => 'Tags._joinData']); + + $this->assertTrue($entity->isDirty('tags'), 'Association data changed'); + $this->assertTrue($entity->tags[0]->isDirty('_joinData')); + $this->assertTrue($result->tags[0]->_joinData->isDirty('author_id'), 'Field not modified'); + $this->assertTrue($result->tags[0]->_joinData->isDirty('highlighted'), 'Field not modified'); + $this->assertSame(99, $result->tags[0]->_joinData->author_id); + $this->assertTrue($result->tags[0]->_joinData->highlighted); + } + + /** + * Test that _joinData is marshalled consistently with both + * new and existing records + */ + public function testMergeBelongsToManyHandleJoinDataConsistently(): void + { + $this->getTableLocator()->clear(); + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Tags', [ + 'through' => 'SpecialTags', + ]); + + $entity = $articles->get(1); + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => ['highlighted' => true]], + ], + ]; + $marshall = new Marshaller($articles); + $result = $marshall->merge($entity, $data, ['associated' => 'Tags']); + + $this->assertTrue($entity->isDirty('tags')); + $this->assertInstanceOf(Entity::class, $result->tags[0]->_joinData); + $this->assertTrue($result->tags[0]->_joinData->highlighted); + + // Also ensure merge() overwrites existing data. + $entity = $articles->get(1, ...['contain' => 'Tags']); + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 3, 'tag' => 'Cake', '_joinData' => ['highlighted' => true]], + ], + ]; + $marshall = new Marshaller($articles); + $result = $marshall->merge($entity, $data, ['associated' => 'Tags']); + + $this->assertTrue($entity->isDirty('tags'), 'association data changed'); + $this->assertInstanceOf(Entity::class, $result->tags[0]->_joinData); + $this->assertTrue($result->tags[0]->_joinData->highlighted); + } + + /** + * Test merging belongsToMany data doesn't create 'new' entities. + */ + public function testMergeBelongsToManyJoinDataAssociatedWithIds(): void + { + $data = [ + 'title' => 'My title', + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 1, + 'user' => ['username' => 'MyLux'], + ], + ], + [ + 'id' => 2, + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'IronFall'], + ], + ], + ], + ]; + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $article = $this->articles->get(1, ...['associated' => 'Tags']); + $result = $marshall->merge($article, $data, ['associated' => ['Tags._joinData.Users']]); + + $this->assertTrue($result->isDirty('tags')); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[1]); + $this->assertInstanceOf(Entity::class, $result->tags[0]->_joinData->user); + + $this->assertInstanceOf(Entity::class, $result->tags[1]->_joinData->user); + $this->assertFalse($result->tags[0]->isNew(), 'Should not be new, as id is in db.'); + $this->assertFalse($result->tags[1]->isNew(), 'Should not be new, as id is in db.'); + $this->assertSame(1, $result->tags[0]->id); + $this->assertSame(2, $result->tags[1]->id); + + $this->assertSame(1, $result->tags[0]->_joinData->active); + $this->assertSame(0, $result->tags[1]->_joinData->active); + + $this->assertSame( + $data['tags'][0]['_joinData']['user']['username'], + $result->tags[0]->_joinData->user->username, + ); + $this->assertSame( + $data['tags'][1]['_joinData']['user']['username'], + $result->tags[1]->_joinData->user->username, + ); + } + + /** + * Same test as @see testMergeBelongsToManyJoinDataAssociatedWithIds + * just without using dot notation in the marshalling process + */ + public function testMergeBelongsToManyJoinDataAssociatedWithIdsWithoutDotNotation(): void + { + $data = [ + 'title' => 'My title', + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'active' => 1, + 'user' => ['username' => 'MyLux'], + ], + ], + [ + 'id' => 2, + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'IronFall'], + ], + ], + ], + ]; + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $article = $this->articles->get(1, ...['associated' => 'Tags']); + $result = $marshall->merge($article, $data, ['associated' => [ + 'Tags' => [ + 'associated' => [ + '_joinData' => [ + 'associated' => ['Users'], + ], + ], + ], + ]]); + + $this->assertTrue($result->isDirty('tags')); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[1]); + $this->assertInstanceOf(Entity::class, $result->tags[0]->_joinData->user); + + $this->assertInstanceOf(Entity::class, $result->tags[1]->_joinData->user); + $this->assertFalse($result->tags[0]->isNew(), 'Should not be new, as id is in db.'); + $this->assertFalse($result->tags[1]->isNew(), 'Should not be new, as id is in db.'); + $this->assertSame(1, $result->tags[0]->id); + $this->assertSame(2, $result->tags[1]->id); + + $this->assertSame(1, $result->tags[0]->_joinData->active); + $this->assertSame(0, $result->tags[1]->_joinData->active); + + $this->assertSame( + $data['tags'][0]['_joinData']['user']['username'], + $result->tags[0]->_joinData->user->username, + ); + $this->assertSame( + $data['tags'][1]['_joinData']['user']['username'], + $result->tags[1]->_joinData->user->username, + ); + } + + /** + * Test merging the _joinData entity for belongstomany associations. + */ + public function testMergeBelongsToManyJoinData(): void + { + $initData = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'active' => 0, + ], + ], + [ + 'id' => 2, + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0, + ], + ], + ], + ]; + + $options = ['associated' => ['Tags._joinData']]; + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($initData, $options); + $entity->setAccess('*', true); + + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 1, 'tag' => 'Cake', '_joinData' => ['foo' => 'bar']], + ['tag' => 'new tag', '_joinData' => ['active' => 1, 'foo' => 'baz']], + ], + ]; + $tag1 = $entity->tags[0]; + + $result = $marshall->merge($entity, $data, $options); + + $this->assertSame($data['title'], $result->title); + $this->assertSame('My content', $result->body); + $this->assertTrue($result->isDirty('tags')); + $this->assertSame($tag1, $entity->tags[0]); + $this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData); + $this->assertSame( + ['active' => 0, 'foo' => 'bar'], + $entity->tags[0]->_joinData->toArray(), + ); + $this->assertSame( + ['active' => 1, 'foo' => 'baz'], + $entity->tags[1]->_joinData->toArray(), + ); + $this->assertSame('new tag', $entity->tags[1]->tag); + $this->assertTrue($entity->tags[0]->isDirty('_joinData')); + $this->assertTrue($entity->tags[1]->isDirty('_joinData')); + + // With custom junction property + $this->articles->Tags->setJunctionProperty('_junction'); + $initData['tags'][0]['_junction'] = $initData['tags'][0]['_joinData']; + $initData['tags'][1]['_junction'] = $initData['tags'][1]['_joinData']; + unset($initData['tags'][0]['_joinData'], $initData['tags'][1]['_joinData']); + + $options = ['associated' => ['Tags._junction']]; + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($initData, $options); + $entity->setAccess('*', true); + + $data = [ + 'title' => 'Haz data 2', + 'tags' => [ + ['id' => 1, 'tag' => 'Cake 2', '_junction' => ['foo' => 'bar 2']], + ['tag' => 'new tag 2', '_junction' => ['active' => 1, 'foo' => 'baz 2']], + ], + ]; + $tag1 = $entity->tags[0]; + + $result = $marshall->merge($entity, $data, $options); + + $this->assertSame($data['title'], $result->title); + $this->assertSame('My content', $result->body); + $this->assertTrue($result->isDirty('tags')); + $this->assertSame($tag1, $entity->tags[0]); + $this->assertSame($tag1->_junction, $entity->tags[0]->_junction); + $this->assertSame( + ['active' => 0, 'foo' => 'bar 2'], + $entity->tags[0]->_junction->toArray(), + ); + $this->assertSame( + ['active' => 1, 'foo' => 'baz 2'], + $entity->tags[1]->_junction->toArray(), + ); + $this->assertSame('new tag 2', $entity->tags[1]->tag); + $this->assertTrue($entity->tags[0]->isDirty('_junction')); + $this->assertTrue($entity->tags[1]->isDirty('_junction')); + } + + /** + * Test merging associations inside _joinData + */ + public function testMergeJoinDataAssociations(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'active' => 0, + 'user' => ['username' => 'Bill'], + ], + ], + [ + 'id' => 2, + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0, + ], + ], + ], + ]; + + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $options = ['associated' => ['Tags._joinData.Users']]; + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, $options); + $entity->setAccess('*', true); + + $data = [ + 'title' => 'Haz data', + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'foo' => 'bar', + 'user' => ['password' => 'secret'], + ], + ], + [ + 'id' => 2, + '_joinData' => [ + 'active' => 1, + 'foo' => 'baz', + 'user' => ['username' => 'ber'], + ], + ], + ], + ]; + $tag1 = $entity->tags[0]; + $result = $marshall->merge($entity, $data, $options); + + $this->assertSame($data['title'], $result->title); + $this->assertSame('My content', $result->body); + $this->assertTrue($entity->isDirty('tags')); + $this->assertSame($tag1, $entity->tags[0]); + + $this->assertTrue($tag1->isDirty('_joinData')); + $this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData); + $this->assertSame('Bill', $entity->tags[0]->_joinData->user->username); + $this->assertSame('secret', $entity->tags[0]->_joinData->user->password); + $this->assertSame('ber', $entity->tags[1]->_joinData->user->username); + } + + /** + * Tests that merging belongsToMany association doesn't erase _joinData + * on existing objects. + */ + public function testMergeBelongsToManyIdsRetainJoinData(): void + { + $entity = $this->articles->get(1, ...['contain' => ['Tags']]); + $entity->setAccess('*', true); + $original = $entity->tags[0]->_joinData; + + $this->assertInstanceOf(Entity::class, $entity->tags[0]->_joinData); + + $data = [ + 'title' => 'Haz moar tags', + 'tags' => [ + ['id' => 1], + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->merge($entity, $data, ['associated' => ['Tags']]); + + $this->assertCount(1, $result->tags); + $this->assertTrue($result->isDirty('tags')); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[0]->_joinData); + $this->assertSame($original, $result->tags[0]->_joinData, 'Should be same object'); + } + + /** + * Test mergeMany() with a simple set of data. + */ + public function testMergeManySimple(): void + { + $entities = [ + new OpenArticleEntity(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new OpenArticleEntity(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]), + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[1]); + $this->assertSame('Changed 1', $result[0]->comment); + $this->assertSame(1, $result[0]->user_id); + $this->assertSame('Changed 2', $result[1]->comment); + $this->assertTrue($result[0]->isDirty('user_id')); + $this->assertFalse($result[1]->isDirty('user_id')); + } + + /** + * Test mergeMany() with some invalid data + */ + public function testMergeManyInvalidData(): void + { + $entities = [ + new OpenArticleEntity(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new OpenArticleEntity(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]), + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + '_csrfToken' => 'abc123', + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[1]); + } + + /** + * Tests that only records found in the data array are returned, those that cannot + * be matched are discarded + */ + public function testMergeManyWithAppend(): void + { + $entities = [ + new OpenArticleEntity(['comment' => 'First post', 'user_id' => 2]), + new OpenArticleEntity(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]), + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Comment 1', 'user_id' => 1], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertCount(2, $result); + $this->assertNotSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[0]); + $this->assertSame('Changed 2', $result[0]->comment); + + $this->assertSame('Comment 1', $result[1]->comment); + } + + /** + * Test that mergeMany() handles composite key associations properly. + * + * The articles_tags table has a composite primary key, and should be + * handled correctly. + */ + public function testMergeManyCompositeKey(): void + { + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + + $entities = [ + new OpenArticleEntity(['article_id' => 1, 'tag_id' => 2]), + new OpenArticleEntity(['article_id' => 1, 'tag_id' => 1]), + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['article_id' => 1, 'tag_id' => 1], + ['article_id' => 1, 'tag_id' => 2], + ]; + $marshall = new Marshaller($articlesTags); + $result = $marshall->mergeMany($entities, $data); + + $this->assertCount(2, $result, 'Should have two records'); + $this->assertSame($entities[0], $result[0], 'Should retain object'); + $this->assertSame($entities[1], $result[1], 'Should retain object'); + } + + /** + * Test mergeMany() with forced contain to ensure aliases are used in queries. + */ + public function testMergeManyExistingQueryAliases(): void + { + $entities = [ + new OpenArticleEntity(['id' => 1, 'comment' => 'First post', 'user_id' => 2], ['markClean' => true]), + ]; + + $data = [ + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ]; + $this->comments->getEventManager()->on('Model.beforeFind', function (EventInterface $event, $query): void { + $query->contain(['Articles']); + }); + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertSame($entities[0], $result[0]); + } + + /** + * Test mergeMany() when the exist check returns nothing. + */ + public function testMergeManyExistQueryFails(): void + { + $entities = [ + new Entity(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new Entity(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]), + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 1], + ['id' => 3, 'comment' => 'New 1'], + ]; + $comments = $this->getTableLocator()->get('GreedyComments', [ + 'className' => GreedyCommentsTable::class, + ]); + $marshall = new Marshaller($comments); + $result = $marshall->mergeMany($entities, $data); + + $this->assertCount(3, $result); + $this->assertSame('Changed 1', $result[0]->comment); + $this->assertSame(1, $result[0]->user_id); + $this->assertSame('Changed 2', $result[1]->comment); + $this->assertSame('New 1', $result[2]->comment); + } + + /** + * Tests merge with data types that need to be marshalled + */ + public function testMergeComplexType(): void + { + $entity = new Entity( + ['comment' => 'My Comment text'], + ['markNew' => false, 'markClean' => true], + ); + $data = [ + 'created' => [ + 'year' => '2014', + 'month' => '2', + 'day' => 14, + ], + ]; + $marshall = new Marshaller($this->comments); + $marshall->merge($entity, $data); + $this->assertInstanceOf(DateTime::class, $entity->created); + $this->assertSame('2014-02-14', $entity->created->format('Y-m-d')); + } + + /** + * Tests that it is possible to pass a fields option to the marshaller + */ + public function testOneWithFields(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => null, + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['fields' => ['title', 'author_id']]); + + $this->assertInstanceOf(Entity::class, $result); + unset($data['body']); + $this->assertEquals($data, $result->toArray()); + } + + /** + * Test one() with strictFields option + */ + public function testOneWithStrictFields(): void + { + // Add validation rules + $this->articles->getValidator() + ->requirePresence('title') + ->notEmptyString('title'); + + $data = [ + 'title' => '', + 'body' => 'My content', + 'author_id' => 'invalid', + ]; + $marshall = new Marshaller($this->articles); + + // Without strictFields, all fields are validated + $result = $marshall->one($data, ['fields' => ['body']]); + $this->assertInstanceOf(Entity::class, $result); + $this->assertEquals(['body' => 'My content'], $result->toArray()); + // We have validation errors for title even though it wasn't in fields + $this->assertNotEmpty($result->getErrors()); + + // With strictFields, only the specified fields are validated + $result = $marshall->one($data, ['fields' => ['body'], 'strictFields' => true]); + $this->assertInstanceOf(Entity::class, $result); + $this->assertEquals(['body' => 'My content'], $result->toArray()); + // No validation errors as we only validate the fields list + $this->assertEmpty($result->getErrors()); + } + + /** + * Test one() with translations + */ + public function testOneWithTranslations(): void + { + $this->articles->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + ]); + + $data = [ + 'author_id' => 1, + '_translations' => [ + 'en' => [ + 'title' => 'English Title', + 'body' => 'English Content', + ], + 'es' => [ + 'title' => 'Titulo Español', + 'body' => 'Contenido Español', + ], + ], + 'user' => [ + 'id' => 1, + 'username' => 'mark', + ], + ]; + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Users']]); + $this->assertEmpty($result->getErrors()); + $this->assertSame(1, $result->author_id); + $this->assertInstanceOf(OpenArticleEntity::class, $result->user); + $this->assertSame('mark', $result->user->username); + + $translations = $result->get('_translations'); + $this->assertCount(2, $translations); + $this->assertInstanceOf(OpenArticleEntity::class, $translations['en']); + $this->assertInstanceOf(OpenArticleEntity::class, $translations['es']); + $this->assertEquals($data['_translations']['en'], $translations['en']->toArray()); + } + + /** + * Tests that it is possible to pass a fields option to the merge method + */ + public function testMergeWithFields(): void + { + $data = [ + 'title' => 'My title', + 'body' => null, + 'author_id' => 1, + ]; + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'title' => 'Foo', + 'body' => 'My content', + 'author_id' => 2, + ]); + $entity->setAccess('*', false); + $entity->setNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, ['fields' => ['title', 'body']]); + + $expected = [ + 'title' => 'My title', + 'body' => null, + 'author_id' => 2, + ]; + + $this->assertSame($entity, $result); + $this->assertEquals($expected, $result->toArray()); + $this->assertFalse($entity->isAccessible('*')); + } + + /** + * Tests that it is possible to pass a strict fields option to the merge method + */ + public function testMergeWithFieldsStrict(): void + { + $this->articles->getValidator() + ->requirePresence('title') + ->notEmptyString('title'); + + $data = [ + 'title' => null, + 'body' => 'My body', + 'author_id' => 1, + ]; + $marshall = new Marshaller($this->articles); + + $entity = new Entity([ + 'title' => 'Foo', + 'body' => 'My content', + 'author_id' => 2, + ]); + + $entity->setAccess('*', false); + $entity->setNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, ['fields' => ['body']]); + + $expected = [ + 'title' => 'Foo', + 'body' => 'My body', + 'author_id' => 2, + ]; + + $this->assertSame($entity, $result); + $this->assertEquals($expected, $result->toArray()); + $this->assertFalse($entity->isAccessible('*')); + // We have validation errors though + $this->assertNotEmpty($entity->getErrors()); + + $entity = new Entity([ + 'title' => 'Foo', + 'body' => 'My content', + 'author_id' => 2, + ]); + + $entity->setAccess('*', false); + $entity->setNew(false); + $entity->clean(); + $result = $marshall->merge($entity, $data, ['fields' => ['body'], 'strictFields' => true]); + + $this->assertSame($entity, $result); + $this->assertEquals($expected, $result->toArray()); + $this->assertFalse($entity->isAccessible('*')); + // We only validate fields list now + $this->assertEmpty($entity->getErrors()); + } + + /** + * Test that many() also receives a fields option + */ + public function testManyFields(): void + { + $data = [ + ['comment' => 'First post', 'user_id' => 2, 'foo' => 'bar'], + ['comment' => 'Second post', 'user_id' => 2, 'foo' => 'bar'], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->many($data, ['fields' => ['comment', 'user_id']]); + + $this->assertCount(2, $result); + unset($data[0]['foo'], $data[1]['foo']); + $this->assertEquals($data[0], $result[0]->toArray()); + $this->assertEquals($data[1], $result[1]->toArray()); + } + + /** + * Test that many() also receives a fields option + */ + public function testMergeManyFields(): void + { + $entities = [ + new OpenArticleEntity(['id' => 1, 'comment' => 'First post', 'user_id' => 2]), + new OpenArticleEntity(['id' => 2, 'comment' => 'Second post', 'user_id' => 2]), + ]; + $entities[0]->clean(); + $entities[1]->clean(); + + $data = [ + ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 10], + ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 20], + ]; + $marshall = new Marshaller($this->comments); + $result = $marshall->mergeMany($entities, $data, ['fields' => ['id', 'comment']]); + + $this->assertSame($entities[0], $result[0]); + $this->assertSame($entities[1], $result[1]); + + $expected = ['id' => 2, 'comment' => 'Changed 2', 'user_id' => 2]; + $this->assertEquals($expected, $entities[1]->toArray()); + + $expected = ['id' => 1, 'comment' => 'Changed 1', 'user_id' => 2]; + $this->assertEquals($expected, $entities[0]->toArray()); + } + + /** + * test marshalling association data while passing a fields + */ + public function testAssociationsFields(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'user' => [ + 'username' => 'mark', + 'password' => 'secret', + 'foo' => 'bar', + ], + ]; + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'fields' => ['title', 'body', 'user'], + 'associated' => [ + 'Users' => ['fields' => ['username', 'foo']], + ], + ]); + + $this->assertSame($data['title'], $result->title); + $this->assertSame($data['body'], $result->body); + $this->assertNull($result->author_id); + + $this->assertInstanceOf(Entity::class, $result->user); + $this->assertSame($data['user']['username'], $result->user->username); + $this->assertNull($result->user->password); + } + + /** + * Tests merging associated data with a fields + */ + public function testMergeAssociationWithfields(): void + { + $user = new Entity([ + 'username' => 'mark', + 'password' => 'secret', + ]); + $entity = new Entity([ + 'tile' => 'My Title', + 'user' => $user, + ]); + $user->setAccess('*', true); + $entity->setAccess('*', true); + + $data = [ + 'body' => 'My Content', + 'something' => 'else', + 'user' => [ + 'password' => 'not a secret', + 'extra' => 'data', + ], + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, [ + 'fields' => ['something'], + 'associated' => ['Users' => ['fields' => ['extra']]], + ]); + $this->assertNull($entity->body); + $this->assertSame('else', $entity->something); + $this->assertSame($user, $entity->user); + $this->assertSame('mark', $entity->user->username); + $this->assertSame('secret', $entity->user->password); + $this->assertSame('data', $entity->user->extra); + $this->assertTrue($entity->isDirty('user')); + } + + /** + * Test marshalling nested associations on the _joinData structure + * while having a fields + */ + public function testJoinDataWhiteList(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'tag' => 'news', + '_joinData' => [ + 'active' => 1, + 'crazy' => 'data', + 'user' => ['username' => 'Bill'], + ], + ], + [ + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0, + 'crazy' => 'stuff', + 'user' => ['username' => 'Mark'], + ], + ], + ], + ]; + + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $articlesTags->belongsTo('Users'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, [ + 'associated' => [ + 'Tags._joinData' => ['fields' => ['active', 'user']], + 'Tags._joinData.Users', + ], + ]); + $this->assertInstanceOf( + Entity::class, + $result->tags[0]->_joinData->user, + 'joinData should contain a user entity.', + ); + $this->assertSame('Bill', $result->tags[0]->_joinData->user->username); + $this->assertInstanceOf( + Entity::class, + $result->tags[1]->_joinData->user, + 'joinData should contain a user entity.', + ); + $this->assertSame('Mark', $result->tags[1]->_joinData->user->username); + + $this->assertNull($result->tags[0]->_joinData->crazy); + $this->assertNull($result->tags[1]->_joinData->crazy); + } + + /** + * Test merging the _joinData entity for belongstomany associations + * while passing a whitelist + */ + public function testMergeJoinDataWithFields(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'tag' => 'news', + '_joinData' => [ + 'active' => 0, + ], + ], + [ + 'id' => 2, + 'tag' => 'cakephp', + '_joinData' => [ + 'active' => 0, + ], + ], + ], + ]; + + $options = ['associated' => ['Tags' => ['associated' => ['_joinData']]]]; + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, $options); + $entity->setAccess('*', true); + + $data = [ + 'title' => 'Haz data', + 'tags' => [ + ['id' => 1, 'tag' => 'Cake', '_joinData' => ['foo' => 'bar', 'crazy' => 'something']], + ['tag' => 'new tag', '_joinData' => ['active' => 1, 'foo' => 'baz']], + ], + ]; + + $tag1 = $entity->tags[0]; + $result = $marshall->merge($entity, $data, [ + 'associated' => ['Tags._joinData' => ['fields' => ['foo']]], + ]); + $this->assertSame($data['title'], $result->title); + $this->assertSame('My content', $result->body); + $this->assertSame($tag1, $entity->tags[0]); + $this->assertSame($tag1->_joinData, $entity->tags[0]->_joinData); + $this->assertSame( + ['active' => 0, 'foo' => 'bar'], + $entity->tags[0]->_joinData->toArray(), + ); + $this->assertSame( + ['foo' => 'baz'], + $entity->tags[1]->_joinData->toArray(), + ); + $this->assertSame('new tag', $entity->tags[1]->tag); + $this->assertTrue($entity->tags[0]->isDirty('_joinData')); + $this->assertTrue($entity->tags[1]->isDirty('_joinData')); + } + + /** + * Tests marshalling with validation errors + */ + public function testValidationFail(): void + { + $data = [ + 'title' => 'Thing', + 'body' => 'hey', + ]; + + $this->articles->getValidator()->requirePresence('thing'); + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data); + $this->assertNotEmpty($entity->getError('thing')); + } + + /** + * Tests that associations are validated and custom validators can be used + */ + public function testValidateWithAssociationsAndCustomValidator(): void + { + $data = [ + 'title' => 'foo', + 'body' => 'bar', + 'user' => [ + 'name' => 'Susan', + ], + 'comments' => [ + [ + 'comment' => 'foo', + ], + ], + ]; + $validator = (new Validator())->add('body', 'numeric', ['rule' => 'numeric']); + $this->articles->setValidator('custom', $validator); + + $validator2 = (new Validator())->requirePresence('thing'); + $this->articles->Users->setValidator('customThing', $validator2); + + $this->articles->Comments->setValidator('default', $validator2); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'validate' => 'custom', + 'associated' => ['Users', 'Comments'], + ]); + $this->assertNotEmpty($entity->getError('body'), 'custom was not used'); + $this->assertNull($entity->body); + $this->assertEmpty($entity->user->getError('thing')); + $this->assertNotEmpty($entity->comments[0]->getError('thing')); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'validate' => 'custom', + 'associated' => ['Users' => ['validate' => 'customThing'], 'Comments'], + ]); + $this->assertNotEmpty($entity->getError('body')); + $this->assertNull($entity->body); + $this->assertNotEmpty($entity->user->getError('thing'), 'customThing was not used'); + $this->assertNotEmpty($entity->comments[0]->getError('thing')); + } + + /** + * Tests that validation can be bypassed + */ + public function testSkipValidation(): void + { + $data = [ + 'title' => 'foo', + 'body' => 'bar', + 'user' => [ + 'name' => 'Susan', + ], + ]; + $validator = (new Validator())->requirePresence('thing'); + $this->articles->setValidator('default', $validator); + $this->articles->Users->setValidator('default', $validator); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'validate' => false, + 'associated' => ['Users'], + ]); + $this->assertEmpty($entity->getError('thing')); + $this->assertNotEmpty($entity->user->getError('thing')); + + $entity = (new Marshaller($this->articles))->one($data, [ + 'associated' => ['Users' => ['validate' => false]], + ]); + $this->assertNotEmpty($entity->getError('thing')); + $this->assertEmpty($entity->user->getError('thing')); + } + + /** + * Tests that invalid property is being filled when data cannot be patched into an entity. + */ + public function testValidationWithInvalidFilled(): void + { + $data = [ + 'title' => 'foo', + 'number' => 'bar', + ]; + $this->articles->setValidator( + 'custom', + (new Validator())->add('number', 'numeric', ['rule' => 'numeric']), + ); + $marshall = new Marshaller($this->articles); + $entity = $marshall->one($data, ['validate' => 'custom']); + $this->assertNotEmpty($entity->getError('number')); + $this->assertNull($entity->number); + $this->assertSame(['number' => 'bar'], $entity->getInvalid()); + } + + /** + * Test merge with validation error + */ + public function testMergeWithValidation(): void + { + $data = [ + 'title' => 'My title', + 'author_id' => 'foo', + ]; + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'id' => 1, + 'title' => 'Foo', + 'body' => 'My Content', + 'author_id' => 1, + ]); + $this->assertEmpty($entity->getInvalid()); + + $entity->setAccess('*', true); + $entity->setNew(false); + $entity->clean(); + + $this->articles->getValidator() + ->requirePresence('thing', 'update') + ->requirePresence('id', 'update') + ->add('author_id', 'numeric', ['rule' => 'numeric']) + ->add('id', 'numeric', ['rule' => 'numeric', 'on' => 'update']); + + $expected = clone $entity; + $result = $marshall->merge($expected, $data, []); + + $this->assertSame($expected, $result); + $this->assertSame(1, $result->author_id); + $this->assertNotEmpty($result->getError('thing')); + $this->assertEmpty($result->getError('id')); + + $this->articles->getValidator()->requirePresence('thing', 'create'); + $result = $marshall->merge($entity, $data, []); + + $this->assertEmpty($result->getError('thing')); + $this->assertSame(['author_id' => 'foo'], $result->getInvalid()); + } + + /** + * Test merge with validation and create or update validation rules + */ + public function testMergeWithCreate(): void + { + $data = [ + 'title' => 'My title', + 'author_id' => 'foo', + ]; + $marshall = new Marshaller($this->articles); + $entity = new Entity([ + 'title' => 'Foo', + 'body' => 'My Content', + 'author_id' => 1, + ]); + $entity->setAccess('*', true); + $entity->setNew(true); + $entity->clean(); + + $this->articles->getValidator() + ->requirePresence('thing', 'update') + ->add('author_id', 'numeric', ['rule' => 'numeric', 'on' => 'update']); + + $expected = clone $entity; + $result = $marshall->merge($expected, $data, []); + + $this->assertEmpty($result->getError('author_id')); + $this->assertEmpty($result->getError('thing')); + + $entity->clean(); + $entity->setNew(false); + $result = $marshall->merge($entity, $data, []); + $this->assertNotEmpty($result->getError('author_id')); + $this->assertNotEmpty($result->getError('thing')); + } + + /** + * Test merge() with translate behavior integration + */ + public function testMergeWithTranslations(): void + { + $this->articles->addBehavior('Translate', [ + 'fields' => ['title', 'body'], + ]); + + $data = [ + 'author_id' => 1, + '_translations' => [ + 'en' => [ + 'title' => 'English Title', + 'body' => 'English Content', + ], + 'es' => [ + 'title' => 'Titulo Español', + 'body' => 'Contenido Español', + ], + ], + ]; + + $marshall = new Marshaller($this->articles); + $entity = $this->articles->newEmptyEntity(); + $result = $marshall->merge($entity, $data, []); + + $this->assertSame($entity, $result); + $this->assertEmpty($result->getErrors()); + $this->assertTrue($result->isDirty('_translations')); + + $translations = $result->get('_translations'); + $this->assertCount(2, $translations); + $this->assertInstanceOf(OpenArticleEntity::class, $translations['en']); + $this->assertInstanceOf(OpenArticleEntity::class, $translations['es']); + + /** @var \Cake\Datasource\EntityInterface $translation */ + $translation = $translations['en']; + $this->assertEquals($data['_translations']['en'], $translation->toArray()); + } + + /** + * Test Model.beforeMarshal event. + */ + public function testBeforeMarshalEvent(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'user' => [ + 'name' => 'Robert', + 'username' => 'rob', + ], + ]; + + $marshall = new Marshaller($this->articles); + + $this->articles->getEventManager()->on( + 'Model.beforeMarshal', + function ($e, $data, $options): void { + $this->assertArrayHasKey('validate', $options); + $data['title'] = 'Modified title'; + $data['user']['username'] = 'robert'; + + $options['associated'] = ['Users']; + }, + ); + + $entity = $marshall->one($data); + + $this->assertSame('Modified title', $entity->title); + $this->assertSame('My content', $entity->body); + $this->assertSame('Robert', $entity->user->name); + $this->assertSame('robert', $entity->user->username); + } + + /** + * Test Model.beforeMarshal event on associated tables. + */ + public function testBeforeMarshalEventOnAssociations(): void + { + $data = [ + 'title' => 'My title', + 'body' => 'My content', + 'author_id' => 1, + 'user' => [ + 'username' => 'mark', + 'password' => 'secret', + ], + 'comments' => [ + ['comment' => 'First post', 'user_id' => 2], + ['comment' => 'Second post', 'user_id' => 2], + ], + 'tags' => [ + ['tag' => 'news', '_joinData' => ['active' => 1]], + ['tag' => 'cakephp', '_joinData' => ['active' => 0]], + ], + ]; + + $marshall = new Marshaller($this->articles); + + // Assert event options are correct + $this->articles->Users->getEventManager()->on( + 'Model.beforeMarshal', + function ($e, $data, $options): void { + $this->assertArrayHasKey('validate', $options); + $this->assertTrue($options['validate']); + + $this->assertArrayHasKey('associated', $options); + $this->assertSame([], $options['associated']); + + $this->assertArrayHasKey('association', $options); + $this->assertInstanceOf(Association::class, $options['association']); + }, + ); + + $this->articles->Users->getEventManager()->on( + 'Model.beforeMarshal', + function ($e, $data, $options): void { + $data['secret'] = 'h45h3d'; + }, + ); + + $this->articles->Comments->getEventManager()->on( + 'Model.beforeMarshal', + function ($e, $data): void { + $data['comment'] .= ' (modified)'; + }, + ); + + $this->articles->Tags->getEventManager()->on( + 'Model.beforeMarshal', + function ($e, $data): void { + $data['tag'] .= ' (modified)'; + }, + ); + + $this->articles->Tags->junction()->getEventManager()->on( + 'Model.beforeMarshal', + function ($e, $data): void { + $data['modified_by'] = 1; + }, + ); + + $entity = $marshall->one($data, [ + 'associated' => ['Users', 'Comments', 'Tags'], + ]); + + $this->assertSame('h45h3d', $entity->user->secret); + $this->assertSame('First post (modified)', $entity->comments[0]->comment); + $this->assertSame('Second post (modified)', $entity->comments[1]->comment); + $this->assertSame('news (modified)', $entity->tags[0]->tag); + $this->assertSame('cakephp (modified)', $entity->tags[1]->tag); + $this->assertSame(1, $entity->tags[0]->_joinData->modified_by); + $this->assertSame(1, $entity->tags[1]->_joinData->modified_by); + } + + /** + * Test Model.afterMarshal event. + */ + public function testAfterMarshalEvent(): void + { + $data = [ + 'title' => 'original title', + 'body' => 'original content', + 'user' => [ + 'name' => 'Robert', + 'username' => 'rob', + ], + ]; + + $marshall = new Marshaller($this->articles); + + $this->articles->getEventManager()->on( + 'Model.afterMarshal', + function ($e, $entity, $data, $options): void { + $this->assertInstanceOf(Entity::class, $entity); + $this->assertArrayHasKey('validate', $options); + $this->assertFalse($options['isMerge']); + + $data['title'] = 'Modified title'; + $data['user']['username'] = 'robert'; + $options['associated'] = ['Users']; + + $entity->body = 'Modified body'; + }, + ); + + $entity = $marshall->one($data); + + $this->assertSame('original title', $entity->title, '$data is immutable'); + $this->assertSame('Modified body', $entity->body); + // both $options and $data are unchangeable + $this->assertIsArray($entity->user, '$options[\'associated\'] is ignored'); + $this->assertSame('Robert', $entity->user['name']); + $this->assertSame('rob', $entity->user['username']); + } + + /** + * Test Model.afterMarshal event on patchEntity. + * when $options['fields'] is set and is empty + */ + public function testAfterMarshalEventOnPatchEntity(): void + { + $data = [ + 'title' => 'original title', + 'body' => 'original content', + 'user' => [ + 'name' => 'Robert', + 'username' => 'rob', + ], + ]; + + $marshall = new Marshaller($this->articles); + + $this->articles->getEventManager()->on( + 'Model.afterMarshal', + function ($e, $entity, $data, $options): void { + $this->assertInstanceOf(Entity::class, $entity); + $this->assertArrayHasKey('validate', $options); + $this->assertTrue($options['isMerge']); + + $data['title'] = 'Modified title'; + $data['user']['username'] = 'robert'; + $options['associated'] = ['Users']; + + $entity->body = 'options[fields] is empty'; + if (isset($options['fields'])) { + $entity->body = 'options[fields] is set'; + } + }, + ); + + //test when $options['fields'] is empty + $entity = $this->articles->newEmptyEntity(); + $marshall->merge($entity, $data, []); + + $this->assertSame('original title', $entity->title, '$data is immutable'); + $this->assertSame('options[fields] is empty', $entity->body); + // both $options and $data are unchangeable + $this->assertIsArray($entity->user, '$options[\'associated\'] is ignored'); + $this->assertSame('Robert', $entity->user['name']); + $this->assertSame('rob', $entity->user['username']); + + //test when $options['fields'] is set + $entity = $this->articles->newEmptyEntity(); + $marshall->merge($entity, $data, ['fields' => ['title', 'body']]); + + $this->assertSame('original title', $entity->title, '$data is immutable'); + $this->assertSame('options[fields] is set', $entity->body); + } + + /** + * Tests that patching an association resulting in no changes, will + * not mark the parent entity as dirty + */ + public function testAssociationNoChanges(): void + { + $options = ['markClean' => true, 'isNew' => false]; + $entity = new Entity([ + 'title' => 'My Title', + 'user' => new Entity([ + 'username' => 'mark', + 'password' => 'not a secret', + ], $options), + ], $options); + + $data = [ + 'body' => 'My Content', + 'user' => [ + 'username' => 'mark', + 'password' => 'not a secret', + ], + ]; + $marshall = new Marshaller($this->articles); + $marshall->merge($entity, $data, ['associated' => ['Users']]); + $this->assertSame('My Content', $entity->body); + $this->assertInstanceOf(Entity::class, $entity->user); + $this->assertSame('mark', $entity->user->username); + $this->assertSame('not a secret', $entity->user->password); + $this->assertFalse($entity->isDirty('user')); + $this->assertTrue($entity->user->isNew()); + } + + /** + * Test that primary key meta data is being read from the table + * and not the schema reflection when handling belongsToMany associations. + */ + public function testEnsurePrimaryKeyBeingReadFromTableForHandlingEmptyStringPrimaryKey(): void + { + $data = [ + 'id' => '', + ]; + + $articles = $this->getTableLocator()->get('Articles'); + $articles->getSchema()->dropConstraint('primary'); + $articles->setPrimaryKey('id'); + + $marshall = new Marshaller($articles); + $result = $marshall->one($data); + + $this->assertFalse($result->isDirty('id')); + $this->assertNull($result->id); + } + + /** + * Test that primary key meta data is being read from the table + * and not the schema reflection when handling belongsToMany associations. + */ + public function testEnsurePrimaryKeyBeingReadFromTableWhenLoadingBelongsToManyRecordsByPrimaryKey(): void + { + $data = [ + 'tags' => [ + [ + 'id' => 1, + ], + [ + 'id' => 2, + ], + ], + ]; + + $tags = $this->getTableLocator()->get('Tags'); + $tags->getSchema()->dropConstraint('primary'); + $tags->setPrimaryKey('id'); + + $marshall = new Marshaller($this->articles); + $result = $marshall->one($data, ['associated' => ['Tags']]); + + $expected = [ + 'tags' => [ + [ + 'id' => 1, + 'name' => 'tag1', + 'description' => 'A big description', + 'created' => new DateTime('2016-01-01 00:00'), + ], + [ + 'id' => 2, + 'name' => 'tag2', + 'description' => 'Another big description', + 'created' => new DateTime('2016-01-01 00:00'), + ], + ], + ]; + $this->assertEquals($expected, $result->toArray()); + } + + /** + * Tests that ID values are being bound with the correct type when loading associated records. + */ + public function testInvalidTypesWhenLoadingAssociatedByIds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot convert value `foobar` of type `string` to int'); + + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + '_ids' => ['foobar'], + ], + ]; + + $marshaller = new Marshaller($this->articles); + $marshaller->one($data, ['associated' => ['Comments']]); + } + + /** + * Tests that composite ID values are being bound with the correct type when loading associated records. + */ + public function testInvalidTypesWhenLoadingAssociatedByCompositeIds(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot convert value `foo` of type `string` to int'); + + $data = [ + 'title' => 'article', + 'body' => 'some content', + 'comments' => [ + '_ids' => [['foo', 'bar']], + ], + ]; + + $this->articles->Comments->setPrimaryKey(['id', 'article_id']); + + $marshaller = new Marshaller($this->articles); + $marshaller->one($data, ['associated' => ['Comments']]); + } +} diff --git a/tests/TestCase/ORM/Query/CaseExpressionQueryTest.php b/tests/TestCase/ORM/Query/CaseExpressionQueryTest.php new file mode 100644 index 00000000000..5ca6de521b1 --- /dev/null +++ b/tests/TestCase/ORM/Query/CaseExpressionQueryTest.php @@ -0,0 +1,112 @@ +getTableLocator()->get('Products') + ->find() + ->select(function (SelectQuery $query) { + return [ + 'name', + 'price', + 'is_cheap' => $query->expr() + ->case() + ->when(['price <' => 20]) + ->then(1) + ->else(0) + ->setReturnType('boolean'), + ]; + }) + ->orderByAsc('price') + ->orderByAsc('name') + ->disableHydration(); + + $expected = [ + [ + 'name' => 'First product', + 'price' => 10, + 'is_cheap' => true, + ], + [ + 'name' => 'Second product', + 'price' => 20, + 'is_cheap' => false, + ], + [ + 'name' => 'Third product', + 'price' => 30, + 'is_cheap' => false, + ], + ]; + $this->assertSame($expected, $query->toArray()); + } + + public function testInferredReturnType(): void + { + $query = $this->getTableLocator()->get('Products') + ->find() + ->select(function (SelectQuery $query) { + $expression = $query->expr() + ->case() + ->when(['Products.price <' => 20]) + ->then(true) + ->else(false); + + if ($query->getConnection()->getDriver() instanceof Postgres) { + $expression = $query->func()->cast($expression, 'boolean'); + } + + return [ + 'Products.name', + 'Products.price', + 'is_cheap' => $expression, + ]; + }) + ->disableHydration(); + + $expected = [ + [ + 'name' => 'First product', + 'price' => 10, + 'is_cheap' => true, + ], + [ + 'name' => 'Second product', + 'price' => 20, + 'is_cheap' => false, + ], + [ + 'name' => 'Third product', + 'price' => 30, + 'is_cheap' => false, + ], + ]; + + $this->assertSame($expected, $query->toArray()); + } +} diff --git a/tests/TestCase/ORM/Query/CompositeKeysTest.php b/tests/TestCase/ORM/Query/CompositeKeysTest.php new file mode 100644 index 00000000000..23ec4a5bb92 --- /dev/null +++ b/tests/TestCase/ORM/Query/CompositeKeysTest.php @@ -0,0 +1,751 @@ + + */ + protected array $fixtures = [ + 'core.CompositeIncrements', + 'core.SiteArticles', + 'core.SiteArticlesTags', + 'core.SiteAuthors', + 'core.SiteTags', + ]; + + /** + * @var \Cake\Datasource\ConnectionInterface + */ + protected $connection; + + /** + * setUp method + */ + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + } + + /** + * Data provider for the two types of strategies HasOne implements + * + * @return array + */ + public static function strategiesProviderHasOne(): array + { + return [['join'], ['select']]; + } + + /** + * Data provider for the two types of strategies HasMany implements + * + * @return array + */ + public static function strategiesProviderHasMany(): array + { + return [['subquery'], ['select']]; + } + + /** + * Data provider for the two types of strategies BelongsTo implements + * + * @return array + */ + public static function strategiesProviderBelongsTo(): array + { + return [['join'], ['select']]; + } + + /** + * Data provider for the two types of strategies BelongsToMany implements + * + * @return array + */ + public static function strategiesProviderBelongsToMany(): array + { + return [['subquery'], ['select']]; + } + + /** + * Test that you cannot save rows with composite keys if some columns are missing. + */ + public function testSaveNewErrorCompositeKeyNoIncrement(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot insert row, some of the primary key values are missing'); + $articles = $this->getTableLocator()->get('SiteArticles'); + $article = $articles->newEntity(['site_id' => 1, 'author_id' => 1, 'title' => 'testing']); + $articles->save($article); + } + + /** + * Test that saving into composite primary keys where one column is missing & autoIncrement works. + * + * SQLite is skipped because it doesn't support autoincrement composite keys. + */ + public function testSaveNewCompositeKeyIncrement(): void + { + $this->skipIfSqlite(); + $table = $this->getTableLocator()->get('CompositeIncrements'); + $thing = $table->newEntity(['account_id' => 3, 'name' => 'new guy']); + $this->assertSame($thing, $table->save($thing)); + $this->assertNotEmpty($thing->id, 'Primary key should have been populated'); + $this->assertSame(3, $thing->account_id); + } + + /** + * Tests that HasMany associations are correctly eager loaded and results + * correctly nested when multiple foreignKeys are used + */ + #[DataProvider('strategiesProviderHasMany')] + public function testHasManyEager(string $strategy): void + { + $table = $this->getTableLocator()->get('SiteAuthors'); + $table->hasMany('SiteArticles', [ + 'propertyName' => 'articles', + 'strategy' => $strategy, + 'sort' => ['SiteArticles.id' => 'asc'], + 'foreignKey' => ['author_id', 'site_id'], + ]); + $query = new SelectQuery($table); + + $results = $query->select() + ->contain('SiteArticles') + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'name' => 'mark', + 'site_id' => 1, + 'articles' => [ + [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'site_id' => 1, + ], + ], + ], + [ + 'id' => 2, + 'name' => 'juan', + 'site_id' => 2, + 'articles' => [], + ], + [ + 'id' => 3, + 'name' => 'jose', + 'site_id' => 2, + 'articles' => [ + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author_id' => 3, + 'site_id' => 2, + ], + ], + ], + [ + 'id' => 4, + 'name' => 'andy', + 'site_id' => 1, + 'articles' => [], + ], + ]; + $this->assertEquals($expected, $results); + + $results = $query->setRepository($table) + ->select() + ->contain(['SiteArticles' => ['conditions' => ['SiteArticles.id' => 2]]]) + ->enableHydration(false) + ->toArray(); + $expected[0]['articles'] = []; + $this->assertEquals($expected, $results); + $this->assertSame($table->getAssociation('SiteArticles')->getStrategy(), $strategy); + } + + /** + * Tests that BelongsToMany associations are correctly eager loaded when multiple + * foreignKeys are used + */ + #[DataProvider('strategiesProviderBelongsToMany')] + public function testBelongsToManyEager(string $strategy): void + { + $articles = $this->getTableLocator()->get('SiteArticles'); + $tags = $this->getTableLocator()->get('SiteTags'); + $articles->belongsToMany('SiteTags', [ + 'strategy' => $strategy, + 'targetTable' => $tags, + 'propertyName' => 'tags', + 'through' => 'SiteArticlesTags', + 'sort' => ['SiteTags.id' => 'asc'], + 'foreignKey' => ['article_id', 'site_id'], + 'targetForeignKey' => ['tag_id', 'site_id'], + ]); + $query = new SelectQuery($articles); + + $results = $query->select()->contain('SiteTags')->enableHydration(false)->toArray(); + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'site_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'name' => 'tag1', + '_joinData' => ['article_id' => 1, 'tag_id' => 1, 'site_id' => 1], + 'site_id' => 1, + ], + [ + 'id' => 3, + 'name' => 'tag3', + '_joinData' => ['article_id' => 1, 'tag_id' => 3, 'site_id' => 1], + 'site_id' => 1, + ], + ], + ], + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author_id' => 3, + 'site_id' => 2, + 'tags' => [ + [ + 'id' => 4, + 'name' => 'tag4', + '_joinData' => ['article_id' => 2, 'tag_id' => 4, 'site_id' => 2], + 'site_id' => 2, + ], + ], + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'author_id' => 1, + 'site_id' => 2, + 'tags' => [], + ], + [ + 'id' => 4, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'author_id' => 3, + 'site_id' => 1, + 'tags' => [ + [ + 'id' => 1, + 'name' => 'tag1', + '_joinData' => ['article_id' => 4, 'tag_id' => 1, 'site_id' => 1], + 'site_id' => 1, + ], + ], + ], + ]; + $this->assertEquals($expected, $results); + $this->assertSame($articles->getAssociation('SiteTags')->getStrategy(), $strategy); + } + + /** + * Tests loading belongsTo with composite keys + */ + #[DataProvider('strategiesProviderBelongsTo')] + public function testBelongsToEager(string $strategy): void + { + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors', [ + 'propertyName' => 'author', + 'strategy' => $strategy, + 'foreignKey' => ['author_id', 'site_id'], + ]); + $query = new SelectQuery($table); + $results = $query->select() + ->where(['SiteArticles.id IN' => [1, 2]]) + ->contain('SiteAuthors') + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + 'site_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author' => [ + 'id' => 1, + 'name' => 'mark', + 'site_id' => 1, + ], + ], + [ + 'id' => 2, + 'author_id' => 3, + 'site_id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author' => [ + 'id' => 3, + 'name' => 'jose', + 'site_id' => 2, + ], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests loading hasOne with composite keys + */ + #[DataProvider('strategiesProviderHasOne')] + public function testHasOneEager(string $strategy): void + { + $table = $this->getTableLocator()->get('SiteAuthors'); + $table->hasOne('SiteArticles', [ + 'propertyName' => 'first_article', + 'strategy' => $strategy, + 'foreignKey' => ['author_id', 'site_id'], + ]); + $query = new SelectQuery($table); + $results = $query->select() + ->where(['SiteAuthors.id IN' => [1, 3]]) + ->contain('SiteArticles') + ->enableHydration(false) + ->toArray(); + + $expected = [ + [ + 'id' => 1, + 'name' => 'mark', + 'site_id' => 1, + 'first_article' => [ + 'id' => 1, + 'author_id' => 1, + 'site_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + ], + ], + [ + 'id' => 3, + 'name' => 'jose', + 'site_id' => 2, + 'first_article' => [ + 'id' => 2, + 'author_id' => 3, + 'site_id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + ], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that it is possible to insert a new row using the save method + * if the entity has composite primary key + */ + public function testSaveNewEntity(): void + { + $entity = new Entity([ + 'id' => 5, + 'site_id' => 1, + 'title' => 'Fifth Article', + 'body' => 'Fifth Article Body', + 'author_id' => 3, + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $this->assertSame($entity, $table->save($entity)); + $this->assertEquals($entity->id, 5); + + $row = $table->find('all')->where(['id' => 5, 'site_id' => 1])->first(); + $this->assertEquals($entity->toArray(), $row->toArray()); + } + + /** + * Tests that it is possible to insert a new row using the save method + * if the entity has composite primary key + */ + public function testSaveNewEntityMissingKey(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot insert row, some of the primary key values are missing. Got (5, ), expecting (id, site_id)'); + $entity = new Entity([ + 'id' => 5, + 'title' => 'Fifth Article', + 'body' => 'Fifth Article Body', + 'author_id' => 3, + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->save($entity); + } + + /** + * Test simple delete with composite primary key + */ + public function testDelete(): void + { + $table = $this->getTableLocator()->get('SiteAuthors'); + $table->save(new Entity(['id' => 1, 'site_id' => 2])); + $entity = $table->get([1, 1]); + $result = $table->delete($entity); + $this->assertTrue($result); + + $this->assertSame(4, $table->find('all')->count()); + $this->assertEmpty($table->find()->where(['id' => 1, 'site_id' => 1])->first()); + } + + /** + * Test delete with dependent records having composite keys + */ + public function testDeleteDependent(): void + { + $table = $this->getTableLocator()->get('SiteAuthors'); + $table->hasMany('SiteArticles', [ + 'foreignKey' => ['author_id', 'site_id'], + 'dependent' => true, + ]); + + $entity = $table->get([3, 2]); + $table->delete($entity); + + $query = $table->getAssociation('SiteArticles')->find('all', ...[ + 'conditions' => [ + 'author_id' => $entity->id, + 'site_id' => $entity->site_id, + ], + ]); + $this->assertNull($query->all()->first(), 'Should not find any rows.'); + } + + /** + * Test generating a list of entities from a list of composite ids + */ + public function testOneGenerateBelongsToManyEntitiesFromIds(): void + { + $articles = $this->getTableLocator()->get('SiteArticles'); + $articles->setEntityClass(OpenArticleEntity::class); + $tags = $this->getTableLocator()->get('SiteTags'); + $articles->belongsToMany('SiteTags', [ + 'targetTable' => $tags, + 'propertyName' => 'tags', + 'through' => 'SiteArticlesTags', + 'foreignKey' => ['article_id', 'site_id'], + 'targetForeignKey' => ['tag_id', 'site_id'], + ]); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => [[1, 1], [2, 2], [3, 1]]], + ]; + $marshall = new Marshaller($articles); + $result = $marshall->one($data, ['associated' => ['SiteTags']]); + + $this->assertCount(3, $result->tags); + $this->assertInstanceOf(Entity::class, $result->tags[0]); + $this->assertInstanceOf(Entity::class, $result->tags[1]); + $this->assertInstanceOf(Entity::class, $result->tags[2]); + + $data = [ + 'title' => 'Haz tags', + 'body' => 'Some content here', + 'tags' => ['_ids' => [1, 2, 3]], + ]; + $marshall = new Marshaller($articles); + $result = $marshall->one($data, ['associated' => ['SiteTags']]); + $this->assertEmpty($result->tags); + } + + /** + * Tests find('list') with composite keys + */ + public function testFindListCompositeKeys(): void + { + $table = new Table([ + 'table' => 'site_authors', + 'connection' => $this->connection, + ]); + $table->setDisplayField('name'); + $query = $table->find('list') + ->enableHydration(false) + ->orderBy('id'); + $expected = [ + '1;1' => 'mark', + '2;2' => 'juan', + '3;2' => 'jose', + '4;1' => 'andy', + ]; + $this->assertEquals($expected, $query->toArray()); + + $table->setDisplayField(['name', 'site_id']); + $query = $table->find('list') + ->enableHydration(false) + ->orderBy('id'); + $expected = [ + '1;1' => 'mark 1', + '2;2' => 'juan 2', + '3;2' => 'jose 2', + '4;1' => 'andy 1', + ]; + $this->assertEquals($expected, $query->toArray()); + + $query = $table->find('list', groupField: ['site_id', 'site_id']) + ->enableHydration(false) + ->orderBy('id'); + $expected = [ + '1 1' => [ + '1;1' => 'mark 1', + '4;1' => 'andy 1', + ], + '2 2' => [ + '2;2' => 'juan 2', + '3;2' => 'jose 2', + ], + ]; + $this->assertEquals($expected, $query->toArray()); + } + + /** + * Tests find('threaded') with composite keys + */ + public function testFindThreadedCompositeKeys(): void + { + $table = $this->getTableLocator()->get('SiteAuthors'); + $query = new SelectQuery($table); + + $items = new ResultSetDecorator([ + ['id' => 1, 'name' => 'a', 'site_id' => 1, 'parent_id' => null], + ['id' => 2, 'name' => 'a', 'site_id' => 2, 'parent_id' => null], + ['id' => 3, 'name' => 'a', 'site_id' => 1, 'parent_id' => 1], + ['id' => 4, 'name' => 'a', 'site_id' => 2, 'parent_id' => 2], + ['id' => 5, 'name' => 'a', 'site_id' => 2, 'parent_id' => 4], + ['id' => 6, 'name' => 'a', 'site_id' => 1, 'parent_id' => 2], + ['id' => 7, 'name' => 'a', 'site_id' => 1, 'parent_id' => 3], + ['id' => 8, 'name' => 'a', 'site_id' => 2, 'parent_id' => 4], + ]); + $query->find('threaded', parentField: ['parent_id', 'site_id']); + $formatter = $query->getResultFormatters()[0]; + + $expected = [ + [ + 'id' => 1, + 'name' => 'a', + 'site_id' => 1, + 'parent_id' => null, + 'children' => [ + [ + 'id' => 3, + 'name' => 'a', + 'site_id' => 1, + 'parent_id' => 1, + 'children' => [ + [ + 'id' => 7, + 'name' => 'a', + 'site_id' => 1, + 'parent_id' => 3, + 'children' => [], + ], + ], + ], + ], + ], + [ + 'id' => 2, + 'name' => 'a', + 'site_id' => 2, + 'parent_id' => null, + 'children' => [ + [ + 'id' => 4, + 'name' => 'a', + 'site_id' => 2, + 'parent_id' => 2, + 'children' => [ + [ + 'id' => 5, + 'name' => 'a', + 'site_id' => 2, + 'parent_id' => 4, + 'children' => [], + ], + [ + 'id' => 8, + 'name' => 'a', + 'site_id' => 2, + 'parent_id' => 4, + 'children' => [], + ], + ], + ], + ], + ], + [ + 'id' => 6, + 'name' => 'a', + 'site_id' => 1, + 'parent_id' => 2, + 'children' => [], + ], + ]; + $this->assertEquals($expected, $formatter($items)->toArray()); + } + + /** + * Tests that loadInto() is capable of handling composite primary keys + */ + public function testLoadInto(): void + { + $table = $this->getTableLocator()->get('SiteAuthors'); + $table->hasMany('SiteArticles', [ + 'foreignKey' => ['author_id', 'site_id'], + ]); + + $author = $table->get([1, 1]); + $result = $table->loadInto($author, ['SiteArticles']); + $this->assertSame($author, $result); + $this->assertNotEmpty($result->site_articles); + + $expected = $table->get([1, 1], ...['contain' => ['SiteArticles']]); + $this->assertEquals($expected, $result); + } + + /** + * Tests that loadInto() is capable of handling composite primary keys + * when loading belongsTo associations + */ + public function testLoadIntoWithBelongsTo(): void + { + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors', [ + 'foreignKey' => ['author_id', 'site_id'], + ]); + + $author = $table->get([2, 2]); + $result = $table->loadInto($author, ['SiteAuthors']); + $this->assertSame($author, $result); + $this->assertNotEmpty($result->site_author); + + $expected = $table->get([2, 2], ...['contain' => ['SiteAuthors']]); + $this->assertEquals($expected, $result); + } + + /** + * Tests that loadInto() is capable of handling composite primary keys + * when loading into multiple entities + */ + public function testLoadIntoMany(): void + { + $table = $this->getTableLocator()->get('SiteAuthors'); + $table->hasMany('SiteArticles', [ + 'foreignKey' => ['author_id', 'site_id'], + ]); + + /** @var \Cake\Datasource\EntityInterface[] $authors */ + $authors = $table->find()->toArray(); + $result = $table->loadInto($authors, ['SiteArticles']); + + foreach ($authors as $k => $v) { + $this->assertSame($result[$k], $v); + } + + /** @var \Cake\Datasource\EntityInterface[] $expected */ + $expected = $table->find('all', contain: ['SiteArticles'])->toArray(); + $this->assertEquals($expected, $result); + } + + /** + * Tests notMatching() with a belongsToMany association + */ + public function testNotMatchingBelongsToMany(): void + { + $driver = $this->connection->getDriver(); + + if ($driver instanceof Sqlserver) { + $this->markTestSkipped('Sqlserver does not support the requirements of this test.'); + } elseif ($driver instanceof Sqlite) { + $serverVersion = $driver->version(); + if (version_compare($serverVersion, '3.15.0', '<')) { + $this->markTestSkipped("Sqlite ({$serverVersion}) does not support the requirements of this test."); + } + } + + $articles = $this->getTableLocator()->get('SiteArticles'); + $articles->belongsToMany('SiteTags', [ + 'through' => 'SiteArticlesTags', + 'foreignKey' => ['article_id', 'site_id'], + 'targetForeignKey' => ['tag_id', 'site_id'], + ]); + + $results = $articles->find() + ->enableHydration(false) + ->notMatching('SiteTags') + ->toArray(); + + $expected = [ + [ + 'id' => 3, + 'author_id' => 1, + 'site_id' => 2, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + ], + ]; + + $this->assertEquals($expected, $results); + } + + /** + * Helper method to skip tests when connection is SQLite. + */ + public function skipIfSqlite(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Sqlite, + 'SQLite does not support the requirements of this test.', + ); + } +} diff --git a/tests/TestCase/ORM/Query/QueryRegressionTest.php b/tests/TestCase/ORM/Query/QueryRegressionTest.php new file mode 100644 index 00000000000..32381ceab8a --- /dev/null +++ b/tests/TestCase/ORM/Query/QueryRegressionTest.php @@ -0,0 +1,1852 @@ + + */ + protected array $fixtures = [ + 'core.Articles', + 'core.Tags', + 'core.ArticlesTags', + 'core.Authors', + 'core.AuthorsTags', + 'core.Comments', + 'core.FeaturedTags', + 'core.SpecialTags', + 'core.TagsTranslations', + 'core.Translates', + 'core.Users', + ]; + + /** + * Test for https://github.com/cakephp/cakephp/issues/3087 + */ + public function testSelectTimestampColumn(): void + { + $table = $this->getTableLocator()->get('users'); + $user = $table->find()->where(['id' => 1])->first(); + $this->assertEquals(new DateTime('2007-03-17 01:16:23'), $user->created); + $this->assertEquals(new DateTime('2007-03-17 01:18:31'), $user->updated); + } + + /** + * Tests that EagerLoader does not try to create queries for associations having no + * keys to compare against + */ + public function testEagerLoadingFromEmptyResults(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('ArticlesTags'); + $results = $table->find()->where(['id >' => 100])->contain('ArticlesTags')->toArray(); + $this->assertEmpty($results); + } + + /** + * Tests that eagerloading associations with aliased fields works. + */ + public function testEagerLoadingAliasedAssociationFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $result = $table->find() + ->contain(['Authors' => [ + 'fields' => [ + 'id', + 'Authors__aliased_name' => 'name', + ], + ]]) + ->where(['Articles.id' => 1]) + ->first(); + $this->assertInstanceOf(EntityInterface::class, $result); + $this->assertInstanceOf(EntityInterface::class, $result->author); + $this->assertSame('mariano', $result->author->aliased_name); + } + + /** + * Tests that eagerloading and hydration works for associations that have + * different aliases in the association and targetTable + */ + public function testEagerLoadingMismatchingAliasInBelongsTo(): void + { + $table = $this->getTableLocator()->get('Articles'); + $users = $this->getTableLocator()->get('Users'); + $table->belongsTo('Authors', [ + 'targetTable' => $users, + 'foreignKey' => 'author_id', + ]); + $result = $table->find()->where(['Articles.id' => 1])->contain('Authors')->first(); + $this->assertInstanceOf(EntityInterface::class, $result); + $this->assertInstanceOf(EntityInterface::class, $result->author); + $this->assertSame('mariano', $result->author->username); + } + + /** + * Tests that eagerloading and hydration works for associations that have + * different aliases in the association and targetTable + */ + public function testEagerLoadingMismatchingAliasInHasOne(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $users = $this->getTableLocator()->get('Users'); + $users->hasOne('Posts', [ + 'targetTable' => $articles, + 'foreignKey' => 'author_id', + ]); + $result = $users->find()->where(['Users.id' => 1])->contain('Posts')->first(); + $this->assertInstanceOf(EntityInterface::class, $result); + $this->assertInstanceOf(EntityInterface::class, $result->post); + $this->assertSame('First Article', $result->post->title); + } + + /** + * Tests that eagerloading belongsToMany with find list fails with a helpful message. + */ + public function testEagerLoadingBelongsToManyList(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags', [ + 'finder' => 'list', + ]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('`_joinData` is missing from the belongsToMany results.'); + $table->find()->contain('Tags')->toArray(); + } + + /** + * Tests that eagerloading and hydration works for associations that have + * different aliases in the association and targetTable + */ + public function testEagerLoadingNestedMatchingCalls(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Tags'); + $tags = $this->getTableLocator()->get('Tags'); + $tags->belongsToMany('Authors'); + + $query = $articles->find() + ->matching('Tags', function ($q) { + return $q->matching('Authors', function ($q) { + return $q->where(['Authors.name' => 'larry']); + }); + }); + $this->assertSame(3, $query->count()); + + $result = $query->first(); + $this->assertInstanceOf(EntityInterface::class, $result); + $this->assertInstanceOf(EntityInterface::class, $result->_matchingData['Tags']); + $this->assertInstanceOf(EntityInterface::class, $result->_matchingData['Authors']); + } + + /** + * Tests that duplicate aliases in contain() can be used, even when they would + * naturally be attached to the query instead of eagerly loaded. What should + * happen here is that One of the duplicates will be changed to be loaded using + * an extra query, but yielding the same results + */ + public function testDuplicateAttachableAliases(): void + { + $this->getTableLocator()->get('Stuff', ['table' => 'tags']); + $this->getTableLocator()->get('Things', ['table' => 'articles_tags']); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $table->hasOne('Things', ['propertyName' => 'articles_tag']); + $table->Authors->getTarget()->hasOne('Stuff', [ + 'foreignKey' => 'id', + 'propertyName' => 'favorite_tag', + ]); + $table->Things->getTarget()->belongsTo('Stuff', [ + 'foreignKey' => 'tag_id', + 'propertyName' => 'foo', + ]); + + $results = $table->find() + ->contain(['Authors.Stuff', 'Things.Stuff']) + ->orderBy(['Articles.id' => 'ASC']) + ->toArray(); + + $this->assertCount(5, $results); + $this->assertSame(1, $results[0]->articles_tag->foo->id); + $this->assertSame(1, $results[0]->author->favorite_tag->id); + $this->assertSame(2, $results[1]->articles_tag->foo->id); + $this->assertSame(1, $results[2]->articles_tag->foo->id); + $this->assertSame(3, $results[2]->author->favorite_tag->id); + $this->assertSame(3, $results[3]->articles_tag->foo->id); + $this->assertSame(3, $results[3]->author->favorite_tag->id); + } + + /** + * Test for https://github.com/cakephp/cakephp/issues/3410 + */ + public function testNullableTimeColumn(): void + { + $table = $this->getTableLocator()->get('users'); + $entity = $table->newEntity(['username' => 'derp', 'created' => null]); + $this->assertSame($entity, $table->save($entity)); + $this->assertNull($entity->created); + } + + /** + * Test for https://github.com/cakephp/cakephp/issues/3626 + * + * Checks that join data is actually created and not tried to be updated every time + */ + public function testCreateJointData(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Highlights', [ + 'className' => TagsTable::class, + 'targetForeignKey' => 'tag_id', + 'through' => 'SpecialTags', + ]); + $entity = $articles->get(2); + $data = [ + 'id' => 2, + 'highlights' => [ + [ + 'name' => 'New Special Tag', + '_joinData' => ['highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00'], + ], + ], + ]; + $entity = $articles->patchEntity($entity, $data, ['Highlights._joinData']); + $articles->save($entity); + $entity = $articles->get(2, ...['contain' => ['Highlights']]); + $this->assertSame(4, $entity->highlights[0]->_joinData->tag_id); + $this->assertSame('2014-06-01', $entity->highlights[0]->_joinData->highlighted_time->format('Y-m-d')); + } + + /** + * Tests that the junction table instance taken from both sides of a belongsToMany + * relationship is actually the same object. + */ + public function testReciprocalBelongsToMany(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $articles->belongsToMany('Tags'); + $tags->belongsToMany('Articles'); + + $left = $articles->Tags->junction(); + $right = $tags->Articles->junction(); + $this->assertSame($left, $right); + } + + /** + * Test for https://github.com/cakephp/cakephp/issues/4253 + * + * Makes sure that the belongsToMany association is not overwritten with conflicting information + * by any of the sides when the junction() function is invoked + */ + public function testReciprocalBelongsToManyNoOverwrite(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $this->getTableLocator()->get('Tags'); + + $articles->belongsToMany('Tags'); + $tags->belongsToMany('Articles'); + + $sub = $articles->Tags->find()->select(['Tags.id'])->matching('Articles', function ($q) { + return $q->where(['Articles.id' => 1]); + }); + + $query = $articles->Tags->find()->where(['Tags.id NOT IN' => $sub]); + $this->assertSame(1, $query->count()); + } + + /** + * Returns an array with the saving strategies for a belongsTo association + * + * @return array + */ + public static function strategyProvider(): array + { + return [ + ['append'], + ['replace'], + ]; + } + + /** + * Test for https://github.com/cakephp/cakephp/issues/3677 and + * https://github.com/cakephp/cakephp/issues/3714 + * + * Checks that only relevant associations are passed when saving _joinData + * Tests that _joinData can also save deeper associations + * + * @param string $strategy + */ + #[DataProvider('strategyProvider')] + public function testBelongsToManyDeepSave($strategy): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Highlights', [ + 'className' => TagsTable::class, + 'targetForeignKey' => 'tag_id', + 'through' => 'SpecialTags', + 'saveStrategy' => $strategy, + ]); + $articles->Highlights->junction()->belongsTo('Authors'); + $articles->Highlights->associations()->remove('Authors'); + $articles->Highlights->hasOne('Authors', [ + 'foreignKey' => 'id', + ]); + $entity = $articles->get(2, ...['contain' => ['Highlights']]); + + $data = [ + 'highlights' => [ + [ + 'name' => 'New Special Tag', + '_joinData' => [ + 'highlighted' => true, + 'highlighted_time' => '2014-06-01 10:10:00', + 'author' => [ + 'name' => 'mariano', + ], + ], + 'author' => ['name' => 'mark'], + ], + ], + ]; + $options = [ + 'associated' => [ + 'Highlights._joinData.Authors', 'Highlights.Authors', + ], + ]; + $entity = $articles->patchEntity($entity, $data, $options); + $articles->save($entity, $options); + $entity = $articles->get(2, ...[ + 'contain' => [ + 'SpecialTags' => ['sort' => ['SpecialTags.id' => 'ASC']], + 'SpecialTags.Authors', + 'Highlights.Authors', + ], + ]); + $this->assertSame('mark', end($entity->highlights)->author->name); + + $lastTag = end($entity->special_tags); + $this->assertTrue($lastTag->highlighted); + $this->assertSame('2014-06-01 10:10:00', $lastTag->highlighted_time->format('Y-m-d H:i:s')); + $this->assertSame('mariano', $lastTag->author->name); + } + + /** + * Tests that no exceptions are generated because of ambiguous column names in queries + * during a save operation + * + * @see https://github.com/cakephp/cakephp/issues/3803 + */ + public function testSaveWithCallbacks(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + $articles->getEventManager()->on('Model.beforeFind', function (EventInterface $event, $query) { + return $query->contain('Authors'); + }); + + $article = $articles->newEmptyEntity(); + $article->title = 'Foo'; + $article->body = 'Bar'; + $this->assertSame($article, $articles->save($article)); + } + + /** + * Test that save() works with entities containing expressions + * as properties. + */ + public function testSaveWithExpressionProperty(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $article = $articles->newEmptyEntity(); + $article->title = new QueryExpression("SELECT 'jose'"); + $this->assertSame($article, $articles->save($article)); + } + + /** + * Tests that whe saving deep associations for a belongsToMany property, + * data is not removed because of excessive associations filtering. + * + * @see https://github.com/cakephp/cakephp/issues/4009 + */ + public function testBelongsToManyDeepSave2(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Highlights', [ + 'className' => TagsTable::class, + 'targetForeignKey' => 'tag_id', + 'through' => 'SpecialTags', + ]); + $articles->Highlights->hasMany('TopArticles', [ + 'className' => ArticlesTable::class, + 'foreignKey' => 'author_id', + ]); + $entity = $articles->get(2, ...['contain' => ['Highlights']]); + + $data = [ + 'highlights' => [ + [ + 'name' => 'New Special Tag', + '_joinData' => [ + 'highlighted' => true, + 'highlighted_time' => '2014-06-01 10:10:00', + ], + 'top_articles' => [ + ['title' => 'First top article'], + ['title' => 'Second top article'], + ], + ], + ], + ]; + $options = [ + 'associated' => [ + 'Highlights._joinData', 'Highlights.TopArticles', + ], + ]; + $entity = $articles->patchEntity($entity, $data, $options); + $articles->save($entity, $options); + $entity = $articles->get(2, ...[ + 'contain' => [ + 'Highlights.TopArticles', + ], + ]); + $highlights = $entity->highlights[0]; + $this->assertSame('First top article', $highlights->top_articles[0]->title); + $this->assertSame('Second top article', $highlights->top_articles[1]->title); + $this->assertEquals( + new DateTime('2014-06-01 10:10:00'), + $highlights->_joinData->highlighted_time, + ); + } + + /** + * An integration test that spot checks that associations use the + * correct alias names to generate queries. + */ + public function testPluginAssociationQueryGeneration(): void + { + $this->loadPlugins(['TestPlugin']); + $articles = $this->getTableLocator()->get('Articles'); + $articles->hasMany('TestPlugin.Comments'); + $articles->belongsTo('TestPlugin.Authors'); + + $result = $articles->find() + ->where(['Articles.id' => 2]) + ->contain(['Comments', 'Authors']) + ->first(); + + $this->assertNotEmpty( + $result->comments[0]->id, + 'No SQL error and comment exists.', + ); + $this->assertNotEmpty( + $result->author->id, + 'No SQL error and author exists.', + ); + $this->clearPlugins(); + } + + /** + * Tests that loading associations having the same alias in the + * joinable associations chain is not sensitive to the order in which + * the associations are selected. + * + * @see https://github.com/cakephp/cakephp/issues/4454 + */ + public function testAssociationChainOrder(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + $articles->hasOne('ArticlesTags'); + + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $articlesTags->belongsTo('Authors', [ + 'foreignKey' => 'tag_id', + ]); + + $resultA = $articles->find() + ->contain(['ArticlesTags.Authors', 'Authors']) + ->first(); + + $resultB = $articles->find() + ->contain(['Authors', 'ArticlesTags.Authors']) + ->first(); + + $this->assertEquals($resultA->author, $resultB->author); + $this->assertEquals($resultA->articles_tag, $resultB->articles_tag); + $this->assertNotEmpty($resultA->author); + $this->assertNotEmpty($resultA->articles_tag->author); + } + + /** + * Test that offset/limit are elided from subquery loads. + */ + public function testAssociationSubQueryNoOffset(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->addBehavior('Translate', ['fields' => ['title', 'body']]); + $table->getBehavior('Translate')->setLocale('eng'); + $query = $table->find('translations') + ->orderBy(['Articles.id' => 'ASC']) + ->limit(10) + ->offset(1); + $result = $query->toArray(); + $this->assertCount(2, $result); + } + + /** + * Tests that using the subquery strategy in a deep association returns the right results + * + * @see https://github.com/cakephp/cakephp/issues/4484 + */ + public function testDeepBelongsToManySubqueryStrategy(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany('Articles'); + $table->Articles->belongsToMany('Tags', [ + 'strategy' => 'subquery', + ]); + + $result = $table->find()->contain(['Articles.Tags'])->toArray(); + + $this->assertEquals( + ['tag1', 'tag3'], + collection($result[2]->articles[0]->tags)->sortBy('name')->extract('name')->toArray(), + ); + } + + /** + * Tests that using the subquery strategy in a deep association returns the right results + * + * @see https://github.com/cakephp/cakephp/issues/5769 + */ + public function testDeepBelongsToManySubqueryStrategy2(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany('Articles'); + $table->Articles->belongsToMany('Tags', [ + 'strategy' => 'subquery', + ]); + $table->belongsToMany('Tags', [ + 'strategy' => 'subquery', + ]); + $table->Articles->belongsTo('Authors'); + + $result = $table->Articles->find() + ->where(['Authors.id >' => 1]) + ->contain(['Authors.Tags']) + ->toArray(); + $this->assertEquals( + ['tag1', 'tag2'], + collection($result[0]->author->tags)->extract('name')->toArray(), + ); + $this->assertSame(3, $result[0]->author->id); + } + + /** + * Tests that finding on a table with a primary key other than `id` will work + * seamlessly with either select or subquery. + * + * @see https://github.com/cakephp/cakephp/issues/6781 + */ + public function testDeepHasManyEitherStrategy(): void + { + $tags = $this->getTableLocator()->get('Tags'); + + $this->skipIf( + $tags->getConnection()->getDriver() instanceof Sqlserver, + 'SQL server is temporarily weird in this test, will investigate later', + ); + $tags = $this->getTableLocator()->get('Tags'); + $featuredTags = $this->getTableLocator()->get('FeaturedTags'); + $featuredTags->belongsTo('Tags'); + + $tags->hasMany('TagsTranslations', [ + 'foreignKey' => 'id', + 'strategy' => 'select', + ]); + $findViaSelect = $featuredTags + ->find() + ->where(['FeaturedTags.tag_id' => 2]) + ->contain('Tags.TagsTranslations') + ->all(); + + $tags->TagsTranslations->setStrategy('subquery'); + $findViaSubquery = $featuredTags + ->find() + ->where(['FeaturedTags.tag_id' => 2]) + ->contain('Tags.TagsTranslations') + ->all(); + + $expected = [2 => 'tag 2 translated into en_us']; + + $this->assertEquals($expected, $findViaSelect->combine('tag_id', 'tag.tags_translations.0.name')->toArray()); + $this->assertEquals($expected, $findViaSubquery->combine('tag_id', 'tag.tags_translations.0.name')->toArray()); + } + + /** + * Tests that getting the count of a query having containments return + * the correct results + * + * @see https://github.com/cakephp/cakephp/issues/4511 + */ + public function testCountWithContain(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors', ['joinType' => 'inner']); + $count = $table + ->find() + ->contain(['Authors' => function ($q) { + return $q->where(['Authors.id' => 1]); + }]) + ->count(); + $this->assertSame(2, $count); + } + + /** + * Tests that getting the count of a query with bind is correct + * + * @see https://github.com/cakephp/cakephp/issues/8466 + */ + public function testCountWithBind(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = $table + ->find() + ->select(['title', 'id']) + ->where('title LIKE :val') + ->groupBy(['id', 'title']) + ->bind(':val', '%Second%'); + $count = $query->count(); + $this->assertSame(1, $count); + } + + /** + * Test count() with inner join containments. + */ + public function testCountWithInnerJoinContain(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors')->setJoinType('INNER'); + + $result = $table->save($table->newEntity([ + 'author_id' => null, + 'title' => 'title', + 'body' => 'body', + 'published' => 'Y', + ])); + $this->assertNotFalse($result); + + $table->getEventManager() + ->on('Model.beforeFind', function (EventInterface $event, $query): void { + $query->contain(['Authors']); + }); + + $count = $table->find()->count(); + $this->assertSame(3, $count); + } + + /** + * Tests that bind in subqueries works. + */ + public function testSubqueryBind(): void + { + $table = $this->getTableLocator()->get('Articles'); + $sub = $table->find() + ->select(['id']) + ->where('title LIKE :val') + ->bind(':val', 'Second %'); + + $query = $table + ->find() + ->select(['title']) + ->where(['id NOT IN' => $sub]); + $result = $query->toArray(); + $this->assertCount(2, $result); + $this->assertSame('First Article', $result[0]->title); + $this->assertSame('Third Article', $result[1]->title); + } + + /** + * Test that deep containments don't generate empty entities for + * intermediary relations. + */ + public function testContainNoEmptyAssociatedObjects(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $comments->belongsTo('Users'); + $users = $this->getTableLocator()->get('Users'); + $users->hasMany('Articles', [ + 'foreignKey' => 'author_id', + ]); + + $comments->updateAll(['user_id' => 99], ['id' => 1]); + + $result = $comments->find() + ->contain(['Users']) + ->where(['Comments.id' => 1]) + ->first(); + $this->assertNull($result->user, 'No record should be null.'); + + $result = $comments->find() + ->contain(['Users', 'Users.Articles']) + ->where(['Comments.id' => 1]) + ->first(); + $this->assertNull($result->user, 'No record should be null.'); + } + + /** + * Tests that using a comparison expression inside an OR condition works + * + * @see https://github.com/cakephp/cakephp/issues/5081 + */ + public function testOrConditionsWithExpression(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = $table->find(); + $query->where([ + 'OR' => [ + new ComparisonExpression('id', 1, 'integer', '>'), + new ComparisonExpression('id', 3, 'integer', '<'), + ], + ]); + + $results = $query->toArray(); + $this->assertCount(3, $results); + } + + /** + * Tests that calling count on a query having a union works correctly + * + * @see https://github.com/cakephp/cakephp/issues/5107 + */ + public function testCountWithUnionQuery(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = $table->find()->where(['id' => 1]); + $query2 = $table->find()->where(['id' => 2]); + $query->union($query2); + $this->assertSame(2, $query->count()); + } + + /** + * Integration test when selecting no fields on the primary table. + */ + public function testSelectNoFieldsOnPrimaryAlias(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Users'); + $query = $table->find() + ->select(['Users__id' => 'id']); + $results = $query->toArray(); + $this->assertCount(3, $results); + } + + /** + * Test selecting with aliased aggregates and identifier quoting + * does not emit notice errors. + * + * @see https://github.com/cakephp/cakephp/issues/12766 + */ + public function testAliasedAggregateFieldTypeConversionSafe(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $driver = $articles->getConnection()->getDriver(); + $restore = $driver->isAutoQuotingEnabled(); + + $driver->enableAutoQuoting(true); + $query = $articles->find(); + $query->select([ + 'sumUsers' => $articles->find()->func()->sum('author_id'), + ]); + $driver->enableAutoQuoting($restore); + + $result = $query->execute()->fetchAll('assoc'); + $this->assertArrayHasKey('sumUsers', $result[0]); + } + + /** + * Tests that calling first on the query results will not remove all other results + * from the set. + */ + public function testFirstOnResultSet(): void + { + $results = $this->getTableLocator()->get('Articles')->find()->all(); + $this->assertSame(3, $results->count()); + $this->assertNotNull($results->first()); + $this->assertCount(3, $results->toArray()); + } + + /** + * Checks that matching and contain can be called for the same belongsTo association + * + * @see https://github.com/cakephp/cakephp/issues/5463 + */ + public function testFindMatchingAndContain(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $article = $table->find() + ->contain('Authors') + ->matching('Authors', function ($q) { + return $q->where(['Authors.id' => 1]); + }) + ->first(); + $this->assertNotNull($article->author); + $this->assertEquals($article->author, $article->_matchingData['Authors']); + } + + /** + * Checks that matching and contain can be called for the same belongsTo association + * + * @see https://github.com/cakephp/cakephp/issues/5463 + */ + public function testFindMatchingAndContainWithSubquery(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles', ['strategy' => 'subquery']); + $table->articles->belongsToMany('tags'); + + $result = $table->find() + ->matching('articles.tags', function ($q) { + return $q->where(['tags.id' => 2]); + }) + ->contain('articles'); + + $this->assertCount(2, $result->first()->articles); + } + + /** + * Tests that matching does not overwrite associations in contain + * + * @see https://github.com/cakephp/cakephp/issues/5584 + */ + public function testFindMatchingOverwrite(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $comments->belongsTo('Articles'); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Tags'); + + $result = $comments + ->find() + ->matching('Articles.Tags', function ($q) { + return $q->where(['Tags.id' => 2]); + }) + ->contain('Articles') + ->first(); + + $this->assertSame(1, $result->id); + $this->assertSame(1, $result->_matchingData['Articles']->id); + $this->assertSame(2, $result->_matchingData['Tags']->id); + $this->assertNotNull($result->article); + $this->assertEquals($result->article, $result->_matchingData['Articles']); + } + + /** + * Tests that matching does not overwrite associations in contain + * + * @see https://github.com/cakephp/cakephp/issues/5584 + */ + public function testFindMatchingOverwrite2(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $comments->belongsTo('Articles'); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + $articles->belongsToMany('Tags'); + + $result = $comments + ->find() + ->matching('Articles.Tags', function ($q) { + return $q->where(['Tags.id' => 2]); + }) + ->contain('Articles.Authors') + ->first(); + + $this->assertNotNull($result->article->author); + } + + /** + * Tests that trying to contain an inexistent association + * throws an exception and not a fatal error. + */ + public function testQueryNotFatalError(): void + { + $this->expectException(InvalidArgumentException::class); + $comments = $this->getTableLocator()->get('Comments'); + $comments->find()->contain('Deprs')->all(); + } + + /** + * Tests that using matching and contain on belongsTo associations + * works correctly. + * + * @see https://github.com/cakephp/cakephp/issues/5721 + */ + public function testFindMatchingWithContain(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $comments->belongsTo('Articles'); + $comments->belongsTo('Users'); + + $result = $comments->find() + ->contain(['Articles', 'Users']) + ->matching('Articles', function ($q) { + return $q->where(['Articles.id >=' => 1]); + }) + ->matching('Users', function ($q) { + return $q->where(['Users.id >=' => 1]); + }) + ->orderBy(['Comments.id' => 'ASC']) + ->first(); + $this->assertInstanceOf(Entity::class, $result->article); + $this->assertInstanceOf(Entity::class, $result->user); + $this->assertSame(2, $result->user->id); + $this->assertSame(1, $result->article->id); + } + + /** + * Tests that HasMany associations don't use duplicate PK values. + */ + public function testHasManyEagerLoadingUniqueKey(): void + { + $table = $this->getTableLocator()->get('ArticlesTags'); + $table->belongsTo('Articles', [ + 'strategy' => 'select', + ]); + + $result = $table->find() + ->contain(['Articles' => function ($q) { + $result = $q->sql(); + $this->assertStringNotContainsString(':c2', $result, 'Only 2 bindings as there are only 2 rows.'); + $this->assertStringNotContainsString(':c3', $result, 'Only 2 bindings as there are only 2 rows.'); + + return $q; + }]) + ->toArray(); + $this->assertNotEmpty($result[0]->article); + } + + /** + * Tests that using contain but selecting no fields from the association + * does not trigger any errors and fetches the right results. + * + * @see https://github.com/cakephp/cakephp/issues/6214 + */ + public function testContainWithNoFields(): void + { + $table = $this->getTableLocator()->get('Comments'); + $table->belongsTo('Users'); + $results = $table->find() + ->select(['Comments.id', 'Comments.user_id']) + ->contain(['Users']) + ->where(['Users.id' => 1]) + ->all() + ->combine('id', 'user_id'); + + $this->assertEquals([3 => 1, 4 => 1, 5 => 1], $results->toArray()); + } + + /** + * Tests that find() and contained associations using computed fields doesn't error out. + * + * @see https://github.com/cakephp/cakephp/issues/9326 + */ + public function testContainWithComputedField(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasMany('Comments'); + + $query = $table->find()->contain([ + 'Comments' => function ($q) { + return $q->select([ + 'concat' => $q->func()->concat(['red', 'blue']), + 'user_id', + ]); + }]) + ->where(['Users.id' => 2]); + + $results = $query->toArray(); + $this->assertCount(1, $results); + $this->assertSame('redblue', $results[0]->comments[0]->concat); + } + + /** + * Tests that using matching and selecting no fields for that association + * will no trigger any errors and fetch the right results + * + * @see https://github.com/cakephp/cakephp/issues/6223 + */ + public function testMatchingWithNoFields(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasMany('Comments'); + $results = $table->find() + ->select(['Users.id']) + ->matching('Comments', function ($q) { + return $q->where(['Comments.id' => 1]); + }) + ->all() + ->extract('id') + ->toList(); + $this->assertEquals([2], $results); + } + + /** + * Test that empty conditions in a matching clause don't cause errors. + */ + public function testMatchingEmptyQuery(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags'); + + $rows = $table->find() + ->matching('Tags', function ($q) { + return $q->where([]); + }) + ->all(); + $this->assertNotEmpty($rows); + + $rows = $table->find() + ->matching('Tags', function ($q) { + return $q->where(null); + }) + ->all(); + $this->assertNotEmpty($rows); + } + + /** + * Tests that using a subquery as part of an expression will not make invalid SQL + */ + public function testSubqueryInSelectExpression(): void + { + $table = $this->getTableLocator()->get('Comments'); + $ratio = $table->find() + ->select(function ($query) use ($table) { + $allCommentsCount = $table->find()->select($query->func()->count('*')); + $countToFloat = $query->expr([$query->func()->count('*'), '1.0'])->setConjunction('*'); + + return [ + 'ratio' => $query + ->expr($countToFloat) + ->add($allCommentsCount) + ->setConjunction('/'), + ]; + }) + ->where(['user_id' => 1]) + ->first() + ->ratio; + $this->assertSame(0.5, (float)$ratio); + } + + /** + * Tests calling contain in a nested closure + * + * @see https://github.com/cakephp/cakephp/issues/7591 + */ + public function testContainInNestedClosure(): void + { + $table = $this->getTableLocator()->get('Comments'); + $table->belongsTo('Articles'); + $table->Articles->belongsTo('Authors'); + $table->Articles->Authors->belongsToMany('Tags'); + + $query = $table->find()->where(['Comments.id' => 5])->contain(['Articles' => function ($q) { + return $q->contain(['Authors' => function ($q) { + return $q->contain('Tags'); + }]); + }]); + $this->assertCount(2, $query->first()->article->author->tags); + } + + /** + * Test that the typemaps used in function expressions + * create the correct results. + */ + public function testTypemapInFunctions(): void + { + $table = $this->getTableLocator()->get('Comments'); + $table->updateAll(['published' => null], ['1 = 1']); + $query = $table->find(); + $query->select([ + 'id', + 'coalesced' => $query->func()->coalesce( + ['published' => 'identifier', -1], + ['integer'], + ), + ]); + $result = $query->all()->first(); + $this->assertSame( + -1, + $result['coalesced'], + 'Output values for functions should be casted', + ); + } + + /** + * Test that the typemaps used in function expressions + * create the correct results. + */ + public function testTypemapInFunctions2(): void + { + $table = $this->getTableLocator()->get('Comments'); + $query = $table->find(); + $query->select([ + 'max' => $query->func()->max('created', ['datetime']), + ]); + $result = $query->all()->first(); + $this->assertEquals(new DateTime('2007-03-18 10:55:23'), $result['max']); + } + + /** + * Test that the type specified in function expressions takes priority over + * default types set for columns. + * + * @see https://github.com/cakephp/cakephp/issues/13049 + * @return void + */ + public function testTypemapInFunctions3(): void + { + $table = $this->getTableLocator()->get('Comments'); + $query = $table->find(); + + $result = $query->select(['id' => $query->func()->min('id')]) + ->first(); + $this->assertSame(1.0, $result['id']); + + $query = $table->find(); + $result = $query->select(['id' => $query->func()->min('id', ['boolean'])]) + ->first(); + $this->assertTrue($result['id']); + } + + /** + * Test that contain queries map types correctly. + */ + public function testBooleanConditionsInContain(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsToMany('Tags', [ + 'through' => 'SpecialTags', + ]); + $query = $table->find() + ->contain(['Tags' => function ($q) { + return $q->where(['SpecialTags.highlighted_time >' => new DateTime('2014-06-01 00:00:00')]); + }]) + ->where(['Articles.id' => 2]); + + $result = $query->first(); + $this->assertSame(2, $result->id); + $this->assertNotEmpty($result->tags, 'Missing tags'); + $this->assertNotEmpty($result->tags[0]->_joinData, 'Missing join data'); + } + + /** + * Test that contain queries map types correctly. + */ + public function testComplexTypesInJoinedWhere(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasOne('Comments'); + $query = $table->find() + ->contain('Comments') + ->where([ + 'Comments.updated >' => new NativeDateTime('2007-03-18 10:55:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->comment->updated); + } + + /** + * Test that nested contain queries map types correctly. + */ + public function testComplexNestedTypesInJoinedWhere(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasOne('Comments'); + $table->Comments->belongsTo('Articles'); + $table->Comments->Articles->belongsTo('Authors', [ + 'className' => 'Users', + ]); + + $query = $table->find() + ->contain('Comments.Articles.Authors') + ->where([ + 'Authors.created >' => new NativeDateTime('2007-03-17 01:16:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->comment->article->author->updated); + } + + /** + * Test that matching queries map types correctly. + */ + public function testComplexTypesInJoinedWhereWithMatching(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasOne('Comments'); + $table->Comments->belongsTo('Articles'); + $table->Comments->Articles->belongsTo('Authors', [ + 'className' => 'Users', + ]); + + $query = $table->find() + ->matching('Comments') + ->where([ + 'Comments.updated >' => new NativeDateTime('2007-03-18 10:55:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->_matchingData['Comments']->updated); + + $query = $table->find() + ->matching('Comments.Articles.Authors') + ->where([ + 'Authors.created >' => new NativeDateTime('2007-03-17 01:16:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->_matchingData['Authors']->updated); + } + + /** + * Test that notMatching queries map types correctly. + */ + public function testComplexTypesInJoinedWhereWithNotMatching(): void + { + $Tags = $this->getTableLocator()->get('Tags'); + $Tags->belongsToMany('Articles'); + + $query = $Tags->find() + ->notMatching('Articles', function ($q) { + return $q ->where(['ArticlesTags.tag_id !=' => 3 ]); + }) + ->where([ + 'Tags.created <' => new NativeDateTime('2016-01-02 00:00:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertSame(3, $result->id); + $this->assertInstanceOf(DateTime::class, $result->created); + } + + /** + * Test that innerJoinWith queries map types correctly. + */ + public function testComplexTypesInJoinedWhereWithInnerJoinWith(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasOne('Comments'); + $table->Comments->belongsTo('Articles'); + $table->Comments->Articles->belongsTo('Authors', [ + 'className' => 'Users', + ]); + + $query = $table->find() + ->innerJoinWith('Comments') + ->where([ + 'Comments.updated >' => new NativeDateTime('2007-03-18 10:55:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->updated); + + $query = $table->find() + ->innerJoinWith('Comments.Articles.Authors') + ->where([ + 'Authors.created >' => new NativeDateTime('2007-03-17 01:16:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->updated); + } + + /** + * Test that leftJoinWith queries map types correctly. + */ + public function testComplexTypesInJoinedWhereWithLeftJoinWith(): void + { + $table = $this->getTableLocator()->get('Users'); + $table->hasOne('Comments'); + $table->Comments->belongsTo('Articles'); + $table->Comments->Articles->belongsTo('Authors', [ + 'className' => 'Users', + ]); + + $query = $table->find() + ->leftJoinWith('Comments') + ->where([ + 'Comments.updated >' => new NativeDateTime('2007-03-18 10:55:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->updated); + + $query = $table->find() + ->leftJoinWith('Comments.Articles.Authors') + ->where([ + 'Authors.created >' => new NativeDateTime('2007-03-17 01:16:00'), + ]); + + $result = $query->first(); + $this->assertNotEmpty($result); + $this->assertInstanceOf(DateTime::class, $result->updated); + } + + /** + * Tests that it is possible to contain to fetch + * associations off of a junction table. + */ + public function testBelongsToManyJoinDataAssociation(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $tags = $this->getTableLocator()->get('Tags'); + $tags->hasMany('SpecialTags'); + + $specialTags = $this->getTableLocator()->get('SpecialTags'); + $specialTags->belongsTo('Authors'); + $specialTags->belongsTo('Articles'); + $specialTags->belongsTo('Tags'); + + $articles->belongsToMany('Tags', [ + 'through' => $specialTags, + ]); + $query = $articles->find() + ->contain(['Tags', 'Tags.SpecialTags.Authors']) + ->where(['Articles.id' => 1]); + $result = $query->first(); + $this->assertNotEmpty($result->tags, 'Missing tags'); + $this->assertNotEmpty($result->tags[0], 'Missing first tag'); + $this->assertNotEmpty($result->tags[0]->_joinData, 'Missing _joinData'); + $this->assertNotEmpty($result->tags[0]->special_tags[0]->author, 'Missing author on _joinData'); + } + + /** + * Tests that it is possible to use matching with dot notation + * even when part of the part of the path in the dot notation is + * shared for two different calls + */ + public function testDotNotationNotOverride(): void + { + $table = $this->getTableLocator()->get('Comments'); + $articles = $table->belongsTo('Articles'); + $specialTags = $articles->hasMany('SpecialTags'); + $specialTags->belongsTo('Authors'); + $specialTags->belongsTo('Tags'); + + $results = $table + ->find() + ->select(['name' => 'Authors.name', 'tag' => 'Tags.name']) + ->matching('Articles.SpecialTags.Tags') + ->matching('Articles.SpecialTags.Authors', function ($q) { + return $q->where(['Authors.id' => 2]); + }) + ->distinct() + ->enableHydration(false) + ->toArray(); + + $this->assertEquals([['name' => 'nate', 'tag' => 'tag1']], $results); + } + + /** + * Test expression based ordering with unions. + */ + public function testComplexOrderWithUnion(): void + { + $table = $this->getTableLocator()->get('Comments'); + $query = $table->find(); + $inner = $table->find() + ->select(['content' => 'comment']) + ->where(['id >' => 3]); + $inner2 = $table->find() + ->select(['content' => 'comment']) + ->where(['id <' => 3]); + + $order = $query->func()->concat(['content' => 'literal', 'test']); + + $query->select(['inside.content']) + ->from(['inside' => $inner->unionAll($inner2)]) + ->orderByAsc($order); + + $results = $query->toArray(); + $this->assertCount(5, $results); + } + + /** + * Test that associations that are loaded with subqueries + * do not cause errors when the subquery has a limit & order clause. + */ + public function testEagerLoadOrderAndSubquery(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments', [ + 'strategy' => 'subquery', + ]); + $query = $table->find() + ->select(['score' => 100]) + ->enableAutoFields() + ->contain(['Comments']) + ->limit(5) + ->orderBy(['score' => 'desc']); + $result = $query->all(); + $this->assertCount(3, $result); + } + + /** + * Tests that having bound placeholders in the order clause does not result + * in an error when trying to count a query. + */ + public function testCountWithComplexOrderBy(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = $table->find(); + $query->orderByDesc( + $query->expr()->case()->when(['id' => 3])->then(1)->else(0), + ); + $query->orderBy(['title' => 'desc']); + // Executing the normal query before getting the count + $query->all(); + $this->assertSame(3, $query->count()); + + $table = $this->getTableLocator()->get('Articles'); + $query = $table->find(); + $query->orderByDesc( + $query->expr()->case()->when(['id' => 3])->then(1)->else(0), + ); + $query->orderByDesc($query->expr()->add(['id' => 3])); + // Not executing the query first, just getting the count + $this->assertSame(3, $query->count()); + } + + /** + * Tests that the now() function expression can be used in the + * where clause of a query + * + * @see https://github.com/cakephp/cakephp/issues/7943 + */ + public function testFunctionInWhereClause(): void + { + $table = $this->getTableLocator()->get('Comments'); + $table->updateAll(['updated' => DateTime::now()->addDays(2)], ['id' => 6]); + $query = $table->find(); + $result = $query->where(['updated >' => $query->func()->now('datetime')])->first(); + $this->assertSame(6, $result->id); + } + + /** + * Tests that `notMatching()` can be used on `belongsToMany` + * associations without passing a query builder callback. + */ + public function testNotMatchingForBelongsToManyWithoutQueryBuilder(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->belongsToMany('Tags'); + + $result = $Articles->find('list')->notMatching('Tags')->toArray(); + $expected = [ + 3 => 'Third Article', + ]; + + $this->assertEquals($expected, $result); + } + + /** + * Tests deep formatters get the right object type when applied in a beforeFind + * + * @see https://github.com/cakephp/cakephp/issues/9787 + */ + public function testFormatDeepDistantAssociationRecords2(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $articles = $table->getAssociation('articles')->getTarget(); + $articles->hasMany('articlesTags'); + $tags = $articles->getAssociation('articlesTags')->getTarget()->belongsTo('tags'); + + $tags->getTarget()->getEventManager()->on('Model.beforeFind', function ($e, $query): void { + $query->formatResults(function ($results) { + return $results->map(function (Entity $tag) { + $tag->name .= ' - visited'; + + return $tag; + }); + }); + }); + + $query = $table->find()->contain(['articles.articlesTags.tags']); + + $query->mapReduce(function ($row, $key, $mr): void { + foreach ((array)$row->articles as $article) { + foreach ((array)$article->articles_tags as $articleTag) { + $mr->emit($articleTag->tag->name); + } + } + }); + + $expected = ['tag1 - visited', 'tag2 - visited', 'tag1 - visited', 'tag3 - visited']; + $this->assertEquals($expected, $query->toArray()); + } + + /** + * Tests that subqueries can be used with function expressions. + */ + public function testFunctionExpressionWithSubquery(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $query = $table + ->find() + ->select(function (SelectQuery $q) use ($table) { + return [ + 'value' => $q + ->func() + ->ABS([ + $table + ->getConnection() + ->selectQuery(-1), + ]) + ->setReturnType('integer'), + ]; + }); + + $result = $query->first()->get('value'); + $this->assertSame(1, $result); + } + + /** + * Tests that correlated subqueries can be used with function expressions. + */ + public function testFunctionExpressionWithCorrelatedSubquery(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $query = $table + ->find() + ->select(function (SelectQuery $q) use ($table) { + return [ + 'value' => $q->func()->UPPER([ + $table + ->getAssociation('Authors') + ->find() + ->select(['Authors.name']) + ->where(function (QueryExpression $exp) { + return $exp->equalFields('Authors.id', 'Articles.author_id'); + }), + ]), + ]; + }); + + $result = $query->first()->get('value'); + $this->assertSame('MARIANO', $result); + } + + /** + * Tests that subqueries can be used with multi argument function expressions. + */ + public function testMultiArgumentFunctionExpressionWithSubquery(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $query = $table + ->find() + ->select(function (SelectQuery $q) use ($table) { + return [ + 'value' => $q + ->func() + ->ROUND( + [ + $table + ->getConnection() + ->selectQuery(1.23456), + 2, + ], + [null, 'integer'], + ) + ->setReturnType('float'), + ]; + }); + + $result = $query->first()->get('value'); + $this->assertSame(1.23, $result); + } + + /** + * Tests that correlated subqueries can be used with multi argument function expressions. + */ + public function testMultiArgumentFunctionExpressionWithCorrelatedSubquery(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $this->assertSame( + 1, + $table->getAssociation('Authors')->updateAll(['name' => null], ['id' => 3]), + ); + + $query = $table + ->find() + ->select(function (SelectQuery $q) use ($table) { + return [ + 'value' => $q->func()->coalesce([ + $table + ->getAssociation('Authors') + ->find() + ->select(['Authors.name']) + ->where(function (QueryExpression $exp) { + return $exp->equalFields('Authors.id', 'Articles.author_id'); + }), + '1', + ]), + ]; + }); + + $results = $query->all()->extract('value')->toArray(); + $this->assertEquals(['mariano', '1', 'mariano'], $results); + } + + /** + * Tests that subqueries can be used with function expressions that are being transpiled. + */ + public function testTranspiledFunctionExpressionWithSubquery(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $query = $table + ->find() + ->select(function (SelectQuery $q) use ($table) { + return [ + 'value' => $q->func()->concat([ + $table + ->getAssociation('Authors') + ->find() + ->select(['Authors.name']) + ->where(['Authors.id' => 1]), + ' appended', + ]), + ]; + }); + + $result = $query->first()->get('value'); + $this->assertSame('mariano appended', $result); + } + + /** + * Tests that correlated subqueries can be used with function expressions that are being transpiled. + */ + public function testTranspiledFunctionExpressionWithCorrelatedSubquery(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $query = $table + ->find() + ->select(function (SelectQuery $q) use ($table) { + return [ + 'value' => $q->func()->concat([ + $table + ->getAssociation('Authors') + ->find() + ->select(['Authors.name']) + ->where(function (QueryExpression $exp) { + return $exp->equalFields('Authors.id', 'Articles.author_id'); + }), + ' appended', + ]), + ]; + }); + + $result = $query->first()->get('value'); + $this->assertSame('mariano appended', $result); + } + + /** + * Test that eager loading with subquery strategy properly preserves limit and order clauses. + * This is a regression test for issue #11395 where limit and order clauses were not + * properly propagated in eager loaded associations. + * + * @see https://github.com/cakephp/cakephp/issues/11395 + * @return void + */ + public function testEagerLoadingWithOrderAndLimitPreservation(): void + { + // Set up Authors table with Articles association + $authors = $this->getTableLocator()->get('Authors'); + $articles = $this->getTableLocator()->get('Articles'); + $authors->hasMany('Articles', [ + 'foreignKey' => 'author_id', + 'strategy' => 'subquery', + ]); + + // First, ensure we have an author with more than 2 articles to properly test the limit + // Create additional test articles for author_id 1 (mariano) + $testArticles = [ + ['author_id' => 1, 'title' => 'Test Article X', 'body' => 'Body X', 'published' => 'Y'], + ['author_id' => 1, 'title' => 'Test Article Y', 'body' => 'Body Y', 'published' => 'Y'], + ['author_id' => 1, 'title' => 'Test Article Z', 'body' => 'Body Z', 'published' => 'Y'], + ]; + + foreach ($testArticles as $article) { + $entity = $articles->newEntity($article); + $articles->save($entity); + } + + // Verify author 1 now has more than 2 articles + $totalArticles = $articles->find() + ->where(['author_id' => 1]) + ->count(); + $this->assertGreaterThan(2, $totalArticles, 'Test requires author to have more than 2 articles'); + + // Test with both order and limit - both should be preserved + $query = $authors->find() + ->contain(['Articles' => function ($q) { + return $q->orderBy(['Articles.id' => 'DESC']) + ->limit(2); + }]) + ->where(['Authors.id' => 1]); + + $result = $query->first(); + $this->assertNotNull($result); + $this->assertNotEmpty($result->articles, 'Author should have articles'); + + // Check that we got exactly 2 articles due to the limit (not less) + $this->assertCount(2, $result->articles, 'Should return exactly 2 articles when limit is applied'); + + // Verify that articles are ordered by id DESC + $ids = collection($result->articles)->extract('id')->toArray(); + $sortedIds = $ids; + rsort($sortedIds); + $this->assertEquals($sortedIds, $ids, 'Articles should be ordered by id DESC'); + + // Verify we got the 2 articles with highest IDs + $allIds = $articles->find() + ->select(['id']) + ->where(['author_id' => 1]) + ->orderBy(['id' => 'DESC']) + ->limit(2) + ->all() + ->extract('id') + ->toArray(); + $this->assertEquals($allIds, $ids, 'Should return the 2 articles with highest IDs'); + + // Test with only order (no limit) - order should be preserved + $query = $authors->find() + ->contain(['Articles' => function ($q) { + return $q->orderBy(['Articles.title' => 'ASC']); + }]) + ->where(['Authors.id' => 1]); + + $result = $query->first(); + $this->assertNotNull($result); + $this->assertGreaterThan(2, count($result->articles), 'Should have all articles when no limit'); + + $titles = collection($result->articles)->extract('title')->toArray(); + $sortedTitles = $titles; + sort($sortedTitles); + $this->assertEquals($sortedTitles, $titles, 'Articles should be ordered by title ASC'); + + // Test with only limit (no order) - limit should be respected + $query = $authors->find() + ->contain(['Articles' => function ($q) { + return $q->limit(1); + }]) + ->where(['Authors.id' => 1]); + + $result = $query->first(); + $this->assertNotNull($result); + $this->assertCount(1, $result->articles, 'Should return exactly 1 article when limit is 1'); + } + + /** + * Test that executed queries, can still be used as subqueries + */ + public function testExecutedSubqueryCanBeReused(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $users = $this->getTableLocator()->get('Users'); + $articles->belongsTo('Users', [ + 'foreignKey' => 'author_id', + ]); + + $subquery1 = $articles->find() + ->select(['id']) + ->where(['title LIKE' => '%First%', 'id >=' => 1]); + + $subquery2 = $users->find() + ->select(['id']) + ->where(['username' => 'mariano']); + + // Execute the query to force it to have a different value binder + $subquery1->all(); + + $query = $articles->find() + ->contain('Users') + ->where([ + 'Users.id IN' => $subquery2, + 'Articles.id IN' => $subquery1, + ]); + $result = $query->all()->toArray(); + + // If these assertions fail, the query is likely malformed + $this->assertCount(1, $result); + $this->assertEquals('First Article', $result[0]->title); + $this->assertNotEmpty($result[0]->user); + } +} diff --git a/tests/TestCase/ORM/Query/SelectQueryTest.php b/tests/TestCase/ORM/Query/SelectQueryTest.php new file mode 100644 index 00000000000..2fde8b83fa4 --- /dev/null +++ b/tests/TestCase/ORM/Query/SelectQueryTest.php @@ -0,0 +1,4175 @@ + + */ + protected array $fixtures = [ + 'core.Articles', + 'core.Tags', + 'core.ArticlesTags', + 'core.ArticlesTranslations', + 'core.Authors', + 'core.Comments', + 'core.Datatypes', + 'core.Posts', + ]; + + /** + * @var \Cake\Database\Connection + */ + protected $connection; + + /** + * @var \Cake\ORM\Table + */ + protected $table; + + /** + * setUp method + */ + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $schema = [ + 'id' => ['type' => 'integer'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]; + $schema1 = [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'phone' => ['type' => 'string'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]; + $schema2 = [ + 'id' => ['type' => 'integer'], + 'total' => ['type' => 'string'], + 'placed' => ['type' => 'datetime'], + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ]; + + $this->table = $this->getTableLocator()->get('foo', ['schema' => $schema]); + $clients = $this->getTableLocator()->get('clients', ['schema' => $schema1]); + $orders = $this->getTableLocator()->get('orders', ['schema' => $schema2]); + $companies = $this->getTableLocator()->get('companies', ['schema' => $schema, 'table' => 'organizations']); + $this->getTableLocator()->get('orderTypes', ['schema' => $schema]); + $stuff = $this->getTableLocator()->get('stuff', ['schema' => $schema, 'table' => 'things']); + $this->getTableLocator()->get('stuffTypes', ['schema' => $schema]); + $this->getTableLocator()->get('categories', ['schema' => $schema]); + + $this->table->belongsTo('clients'); + $clients->hasOne('orders'); + $clients->belongsTo('companies'); + $orders->belongsTo('orderTypes'); + $orders->hasOne('stuff'); + $stuff->belongsTo('stuffTypes'); + $companies->belongsTo('categories'); + } + + /** + * Data provider for the two types of strategies HasMany implements + * + * @return array + */ + public static function strategiesProviderHasMany(): array + { + return [['subquery'], ['select']]; + } + + /** + * Data provider for the two types of strategies BelongsTo implements + * + * @return array + */ + public static function strategiesProviderBelongsTo(): array + { + return [['join'], ['select']]; + } + + /** + * Data provider for the two types of strategies BelongsToMany implements + * + * @return array + */ + public static function strategiesProviderBelongsToMany(): array + { + return [['subquery'], ['select']]; + } + + /** + * Test getRepository() method. + */ + public function testGetRepository(): void + { + $query = new SelectQuery($this->table); + + $result = $query->getRepository(); + $this->assertSame($this->table, $result); + } + + public function testSelectAlso(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = new SelectQuery($table); + $results = $query + ->selectAlso(['extra' => 'id']) + ->where(['author_id' => 3]) + ->disableHydration() + ->first(); + + $this->assertSame( + ['extra' => 2, 'id' => 2, 'author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + $results, + ); + + $query = new SelectQuery($table); + $results = $query + ->select('id') + ->selectAlso(['extra' => 'id']) + ->where(['author_id' => 3]) + ->disableHydration() + ->first(); + + $this->assertSame( + ['id' => 2, 'extra' => 2, 'author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + $results, + ); + + $query = new SelectQuery($table); + $results = $query + ->selectAlso(['extra' => 'id']) + ->select('id') + ->where(['author_id' => 3]) + ->disableHydration() + ->first(); + + $this->assertSame( + ['extra' => 2, 'id' => 2, 'author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + $results, + ); + } + + /** + * Tests that results are grouped correctly when using contain() + * and results are not hydrated + */ + #[DataProvider('strategiesProviderBelongsTo')] + public function testContainResultFetchingOneLevel(string $strategy): void + { + $table = $this->getTableLocator()->get('articles', ['table' => 'articles']); + $table->belongsTo('authors', ['strategy' => $strategy]); + + $query = new SelectQuery($table); + $results = $query->select() + ->contain('authors') + ->enableHydration(false) + ->orderBy(['articles.id' => 'asc']) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'published' => 'Y', + 'author' => [ + 'id' => 1, + 'name' => 'mariano', + ], + ], + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author_id' => 3, + 'published' => 'Y', + 'author' => [ + 'id' => 3, + 'name' => 'larry', + ], + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'author_id' => 1, + 'published' => 'Y', + 'author' => [ + 'id' => 1, + 'name' => 'mariano', + ], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that HasMany associations are correctly eager loaded and results + * correctly nested when no hydration is used + * Also that the query object passes the correct parent model keys to the + * association objects in order to perform eager loading with select strategy + */ + #[DataProvider('strategiesProviderHasMany')] + public function testHasManyEagerLoadingNoHydration(string $strategy): void + { + $table = $this->getTableLocator()->get('authors'); + $this->getTableLocator()->get('articles'); + $table->hasMany('articles', [ + 'propertyName' => 'articles', + 'strategy' => $strategy, + 'sort' => ['articles.id' => 'asc'], + ]); + $query = new SelectQuery($table); + + $results = $query->select() + ->contain('articles') + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'name' => 'mariano', + 'articles' => [ + [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'published' => 'Y', + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'author_id' => 1, + 'published' => 'Y', + ], + ], + ], + [ + 'id' => 2, + 'name' => 'nate', + 'articles' => [], + ], + [ + 'id' => 3, + 'name' => 'larry', + 'articles' => [ + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author_id' => 3, + 'published' => 'Y', + ], + ], + ], + [ + 'id' => 4, + 'name' => 'garrett', + 'articles' => [], + ], + ]; + $this->assertEquals($expected, $results); + + $results = $query->setRepository($table) + ->select() + ->contain(['articles' => ['conditions' => ['articles.id' => 2]]]) + ->enableHydration(false) + ->toArray(); + $expected[0]['articles'] = []; + $this->assertEquals($expected, $results); + $this->assertEquals($table->getAssociation('articles')->getStrategy(), $strategy); + } + + /** + * Tests that it is possible to count results containing hasMany associations + * both hydrating and not hydrating the results. + */ + #[DataProvider('strategiesProviderHasMany')] + public function testHasManyEagerLoadingCount(string $strategy): void + { + $table = $this->getTableLocator()->get('authors'); + $this->getTableLocator()->get('articles'); + $table->hasMany('articles', [ + 'property' => 'articles', + 'strategy' => $strategy, + 'sort' => ['articles.id' => 'asc'], + ]); + $query = new SelectQuery($table); + + $query = $query->select() + ->contain('articles'); + + $expected = 4; + + $results = $query->enableHydration(false) + ->count(); + $this->assertEquals($expected, $results); + + $results = $query->enableHydration(true) + ->count(); + $this->assertEquals($expected, $results); + } + + /** + * Tests that it is possible to set fields & order in a hasMany result set + */ + #[DataProvider('strategiesProviderHasMany')] + public function testHasManyEagerLoadingFieldsAndOrderNoHydration(string $strategy): void + { + $table = $this->getTableLocator()->get('authors'); + $this->getTableLocator()->get('articles'); + $table->hasMany('articles', ['propertyName' => 'articles'] + compact('strategy')); + + $query = new SelectQuery($table); + $results = $query->select() + ->contain([ + 'articles' => [ + 'fields' => ['title', 'author_id'], + 'sort' => ['articles.id' => 'DESC'], + ], + ]) + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'name' => 'mariano', + 'articles' => [ + ['title' => 'Third Article', 'author_id' => 1], + ['title' => 'First Article', 'author_id' => 1], + ], + ], + [ + 'id' => 2, + 'name' => 'nate', + 'articles' => [], + ], + [ + 'id' => 3, + 'name' => 'larry', + 'articles' => [ + ['title' => 'Second Article', 'author_id' => 3], + ], + ], + [ + 'id' => 4, + 'name' => 'garrett', + 'articles' => [], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that deep associations can be eagerly loaded + */ + #[DataProvider('strategiesProviderHasMany')] + public function testHasManyEagerLoadingDeep(string $strategy): void + { + $table = $this->getTableLocator()->get('authors'); + $article = $this->getTableLocator()->get('articles'); + $table->hasMany('articles', [ + 'propertyName' => 'articles', + 'strategy' => $strategy, + 'sort' => ['articles.id' => 'asc'], + ]); + $article->belongsTo('authors'); + $query = new SelectQuery($table); + + $results = $query->select() + ->contain(['articles' => ['authors']]) + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'name' => 'mariano', + 'articles' => [ + [ + 'id' => 1, + 'title' => 'First Article', + 'author_id' => 1, + 'body' => 'First Article Body', + 'published' => 'Y', + 'author' => ['id' => 1, 'name' => 'mariano'], + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'author_id' => 1, + 'body' => 'Third Article Body', + 'published' => 'Y', + 'author' => ['id' => 1, 'name' => 'mariano'], + ], + ], + ], + [ + 'id' => 2, + 'name' => 'nate', + 'articles' => [], + ], + [ + 'id' => 3, + 'name' => 'larry', + 'articles' => [ + [ + 'id' => 2, + 'title' => 'Second Article', + 'author_id' => 3, + 'body' => 'Second Article Body', + 'published' => 'Y', + 'author' => ['id' => 3, 'name' => 'larry'], + ], + ], + ], + [ + 'id' => 4, + 'name' => 'garrett', + 'articles' => [], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that hasMany associations can be loaded even when related to a secondary + * model in the query + */ + #[DataProvider('strategiesProviderHasMany')] + public function testHasManyEagerLoadingFromSecondaryTable(string $strategy): void + { + $author = $this->getTableLocator()->get('authors'); + $article = $this->getTableLocator()->get('articles'); + $this->getTableLocator()->get('posts'); + + $author->hasMany('posts', [ + 'sort' => ['posts.id' => 'ASC'], + 'strategy' => $strategy, + ]); + $article->belongsTo('authors'); + + $query = new SelectQuery($article); + + $results = $query->select() + ->contain(['authors' => ['posts']]) + ->orderBy(['articles.id' => 'ASC']) + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'published' => 'Y', + 'author' => [ + 'id' => 1, + 'name' => 'mariano', + 'posts' => [ + [ + 'id' => '1', + 'title' => 'First Post', + 'body' => 'First Post Body', + 'author_id' => 1, + 'published' => 'Y', + ], + [ + 'id' => '3', + 'title' => 'Third Post', + 'body' => 'Third Post Body', + 'author_id' => 1, + 'published' => 'Y', + ], + ], + ], + ], + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author_id' => 3, + 'published' => 'Y', + 'author' => [ + 'id' => 3, + 'name' => 'larry', + 'posts' => [ + [ + 'id' => 2, + 'title' => 'Second Post', + 'body' => 'Second Post Body', + 'author_id' => 3, + 'published' => 'Y', + ], + ], + ], + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'author_id' => 1, + 'published' => 'Y', + 'author' => [ + 'id' => 1, + 'name' => 'mariano', + 'posts' => [ + [ + 'id' => '1', + 'title' => 'First Post', + 'body' => 'First Post Body', + 'author_id' => 1, + 'published' => 'Y', + ], + [ + 'id' => '3', + 'title' => 'Third Post', + 'body' => 'Third Post Body', + 'author_id' => 1, + 'published' => 'Y', + ], + ], + ], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that BelongsToMany associations are correctly eager loaded. + * Also that the query object passes the correct parent model keys to the + * association objects in order to perform eager loading with select strategy + */ + #[DataProvider('strategiesProviderBelongsToMany')] + public function testBelongsToManyEagerLoadingNoHydration(string $strategy): void + { + $table = $this->getTableLocator()->get('Articles'); + $this->getTableLocator()->get('Tags'); + $this->getTableLocator()->get('ArticlesTags', [ + 'table' => 'articles_tags', + ]); + $table->belongsToMany('Tags', [ + 'strategy' => $strategy, + 'sort' => 'tag_id', + ]); + $query = new SelectQuery($table); + + $results = $query->select()->contain('Tags')->disableHydration()->toArray(); + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + 'tags' => [ + [ + 'id' => 1, + 'name' => 'tag1', + '_joinData' => ['article_id' => 1, 'tag_id' => 1], + 'description' => 'A big description', + 'created' => new DateTime('2016-01-01 00:00'), + ], + [ + 'id' => 2, + 'name' => 'tag2', + '_joinData' => ['article_id' => 1, 'tag_id' => 2], + 'description' => 'Another big description', + 'created' => new DateTime('2016-01-01 00:00'), + ], + ], + ], + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author_id' => 3, + 'published' => 'Y', + 'tags' => [ + [ + 'id' => 1, + 'name' => 'tag1', + '_joinData' => ['article_id' => 2, 'tag_id' => 1], + 'description' => 'A big description', + 'created' => new DateTime('2016-01-01 00:00'), + ], + [ + 'id' => 3, + 'name' => 'tag3', + '_joinData' => ['article_id' => 2, 'tag_id' => 3], + 'description' => 'Yet another one', + 'created' => new DateTime('2016-01-01 00:00'), + ], + ], + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'author_id' => 1, + 'published' => 'Y', + 'tags' => [], + ], + ]; + $this->assertEquals($expected, $results); + + $results = $query->select() + ->contain(['Tags' => ['conditions' => ['Tags.id' => 3]]]) + ->disableHydration() + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + 'tags' => [], + ], + [ + 'id' => 2, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'author_id' => 3, + 'published' => 'Y', + 'tags' => [ + [ + 'id' => 3, + 'name' => 'tag3', + '_joinData' => ['article_id' => 2, 'tag_id' => 3], + 'description' => 'Yet another one', + 'created' => new DateTime('2016-01-01 00:00'), + ], + ], + ], + [ + 'id' => 3, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'author_id' => 1, + 'published' => 'Y', + 'tags' => [], + ], + ]; + $this->assertEquals($expected, $results); + $this->assertEquals($table->getAssociation('Tags')->getStrategy(), $strategy); + } + + /** + * Tests that tables results can be filtered by the result of a HasMany + */ + public function testFilteringByHasManyNoHydration(): void + { + $query = new SelectQuery($this->table); + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + + $results = $query->setRepository($table) + ->select() + ->disableHydration() + ->matching('Comments', function ($q) { + return $q->where(['Comments.user_id' => 4]); + }) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'published' => 'Y', + '_matchingData' => [ + 'Comments' => [ + 'id' => 2, + 'article_id' => 1, + 'user_id' => 4, + 'comment' => 'Second Comment for First Article', + 'published' => 'Y', + 'created' => new DateTime('2007-03-18 10:47:23'), + 'updated' => new DateTime('2007-03-18 10:49:31'), + ], + ], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that tables results can be filtered by the result of a HasMany + */ + public function testFilteringByHasManyHydration(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = new SelectQuery($table); + $table->hasMany('Comments'); + + $result = $query->setRepository($table) + ->matching('Comments', function ($q) { + return $q->where(['Comments.user_id' => 4]); + }) + ->first(); + $this->assertInstanceOf(Entity::class, $result); + $this->assertInstanceOf(Entity::class, $result->_matchingData['Comments']); + $this->assertIsInt($result->_matchingData['Comments']->id); + $this->assertInstanceOf(DateTime::class, $result->_matchingData['Comments']->created); + } + + /** + * Tests that BelongsToMany associations are correctly eager loaded. + * Also that the query object passes the correct parent model keys to the + * association objects in order to perform eager loading with select strategy + */ + public function testFilteringByBelongsToManyNoHydration(): void + { + $query = new SelectQuery($this->table); + $table = $this->getTableLocator()->get('Articles'); + $this->getTableLocator()->get('Tags'); + $this->getTableLocator()->get('ArticlesTags', [ + 'table' => 'articles_tags', + ]); + $table->belongsToMany('Tags'); + + $results = $query->setRepository($table)->select() + ->matching('Tags', function ($q) { + return $q->where(['Tags.id' => 3]); + }) + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 2, + 'author_id' => 3, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'published' => 'Y', + '_matchingData' => [ + 'Tags' => [ + 'id' => 3, + 'name' => 'tag3', + 'description' => 'Yet another one', + 'created' => new DateTime('2016-01-01 00:00'), + ], + 'ArticlesTags' => ['article_id' => 2, 'tag_id' => 3], + ], + ], + ]; + $this->assertEquals($expected, $results); + + $query = new SelectQuery($table); + $results = $query->select() + ->matching('Tags', function ($q) { + return $q->where(['Tags.name' => 'tag2']); + }) + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'published' => 'Y', + '_matchingData' => [ + 'Tags' => [ + 'id' => 2, + 'name' => 'tag2', + 'description' => 'Another big description', + 'created' => new DateTime('2016-01-01 00:00'), + ], + 'ArticlesTags' => ['article_id' => 1, 'tag_id' => 2], + ], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that it is possible to filter by deep associations + */ + public function testMatchingDotNotation(): void + { + $query = new SelectQuery($this->table); + $table = $this->getTableLocator()->get('authors'); + $this->getTableLocator()->get('articles'); + $table->hasMany('articles'); + $this->getTableLocator()->get('articles')->belongsToMany('tags'); + + $results = $query->setRepository($table) + ->select() + ->enableHydration(false) + ->matching('articles.tags', function ($q) { + return $q->where(['tags.id' => 2]); + }) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'name' => 'mariano', + '_matchingData' => [ + 'tags' => [ + 'id' => 2, + 'name' => 'tag2', + 'description' => 'Another big description', + 'created' => new DateTime('2016-01-01 00:00'), + ], + 'articles' => [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + ], + 'ArticlesTags' => [ + 'article_id' => 1, + 'tag_id' => 2, + ], + ], + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Test setResult() + */ + public function testSetResult(): void + { + $query = new SelectQuery($this->table); + + $results = new ResultSet([]); + $query->setResult($results); + $this->assertSame($results, $query->all()); + + $query->setResult([]); + $this->assertInstanceOf(ResultSet::class, $query->all()); + } + + /** + * Test clearResult() + */ + public function testClearResult(): void + { + $article = $this->getTableLocator()->get('articles'); + $query = new SelectQuery($article); + + $firstCount = $query->count(); + $firstResults = $query->toArray(); + + $this->assertEquals(3, $firstCount); + $this->assertCount(3, $firstResults); + + $article->delete(reset($firstResults)); + $return = $query->clearResult(); + + $this->assertSame($return, $query); + + $secondCount = $query->count(); + $secondResults = $query->toArray(); + + $this->assertEquals(2, $secondCount); + $this->assertCount(2, $secondResults); + } + + /** + * Tests that applying array options to a query will convert them + * to equivalent function calls with the correspondent array values + */ + public function testApplyOptions(): void + { + $this->table->belongsTo('articles'); + $typeMap = new TypeMap([ + 'foo.id' => 'integer', + 'id' => 'integer', + 'foo__id' => 'integer', + 'articles.id' => 'integer', + 'articles__id' => 'integer', + 'articles.author_id' => 'integer', + 'articles__author_id' => 'integer', + 'author_id' => 'integer', + 'articles.title' => 'string', + 'articles__title' => 'string', + 'title' => 'string', + 'articles.body' => 'text', + 'articles__body' => 'text', + 'body' => 'text', + 'articles.published' => 'string', + 'articles__published' => 'string', + 'published' => 'string', + ]); + + $options = [ + 'fields' => ['field_a', 'field_b'], + 'conditions' => ['field_a' => 1, 'field_b' => 'something'], + 'limit' => 1, + 'order' => ['a' => 'ASC'], + 'offset' => 5, + 'group' => ['field_a'], + 'having' => ['field_a >' => 100], + 'contain' => ['articles'], + 'join' => ['table_a' => ['conditions' => ['a > b']]], + ]; + $query = new SelectQuery($this->table); + $query->applyOptions($options); + + $this->assertEquals(['field_a', 'field_b'], $query->clause('select')); + + $expected = new QueryExpression($options['conditions'], $typeMap); + $result = $query->clause('where'); + $this->assertEquals($expected, $result); + + $this->assertEquals(1, $query->clause('limit')); + + $expected = new QueryExpression(['a > b'], $typeMap); + $result = $query->clause('join'); + $this->assertEquals([ + 'table_a' => ['alias' => 'table_a', 'type' => 'INNER', 'conditions' => $expected], + ], $result); + + $expected = new OrderByExpression(['a' => 'ASC']); + $this->assertEquals($expected, $query->clause('order')); + + $this->assertEquals(5, $query->clause('offset')); + $this->assertEquals(['field_a'], $query->clause('group')); + + $expected = new QueryExpression($options['having'], $typeMap); + $this->assertEquals($expected, $query->clause('having')); + + $expected = ['articles' => []]; + $this->assertEquals($expected, $query->getContain()); + } + + public function testApplyOptionsSelectWhere(): void + { + $options = [ + 'select' => ['field_a', 'field_b'], + 'where' => ['field_a' => 1, 'field_b' => 'something'], + 'orderBy' => ['field_a'], + 'groupBy' => ['field_b'], + ]; + $query = new SelectQuery($this->table); + $query->applyOptions($options); + + $this->assertEquals(['field_a', 'field_b'], $query->clause('select')); + + $typeMap = new TypeMap([ + 'foo.id' => 'integer', + 'id' => 'integer', + 'foo__id' => 'integer', + ]); + + $expected = new QueryExpression($options['where'], $typeMap); + $result = $query->clause('where'); + $this->assertEquals($expected, $result); + + $expected = new OrderByExpression($options['orderBy']); + $result = $query->clause('order'); + $this->assertEquals($expected, $result); + + $this->assertSame($options['groupBy'], $query->clause('group')); + } + + /** + * Test that page is applied after limit. + */ + public function testApplyOptionsPageIsLast(): void + { + $query = new SelectQuery($this->table); + $opts = [ + 'page' => 3, + 'limit' => 5, + ]; + $query->applyOptions($opts); + $this->assertEquals(5, $query->clause('limit')); + $this->assertEquals(10, $query->clause('offset')); + } + + /** + * ApplyOptions should ignore null values. + */ + public function testApplyOptionsIgnoreNull(): void + { + $options = [ + 'fields' => null, + ]; + $query = new SelectQuery($this->table); + $query->applyOptions($options); + $this->assertEquals([], $query->clause('select')); + } + + /** + * Tests getOptions() method + */ + public function testGetOptions(): void + { + $options = ['doABarrelRoll' => true, 'fields' => ['id', 'name']]; + $query = new SelectQuery($this->table); + $query->applyOptions($options); + $expected = ['doABarrelRoll' => true]; + $this->assertEquals($expected, $query->getOptions()); + + $expected = ['doABarrelRoll' => false, 'doAwesome' => true]; + $query->applyOptions($expected); + $this->assertEquals($expected, $query->getOptions()); + } + + /** + * Tests registering mappers with mapReduce() + */ + public function testMapReduceOnlyMapper(): void + { + $mapper1 = function (): void { + }; + $mapper2 = function (): void { + }; + $query = new SelectQuery($this->table); + $this->assertSame($query, $query->mapReduce($mapper1)); + $this->assertEquals( + [['mapper' => $mapper1, 'reducer' => null]], + $query->getMapReducers(), + ); + + $this->assertEquals($query, $query->mapReduce($mapper2)); + $result = $query->getMapReducers(); + $this->assertSame( + [ + ['mapper' => $mapper1, 'reducer' => null], + ['mapper' => $mapper2, 'reducer' => null], + ], + $result, + ); + } + + /** + * Tests registering mappers and reducers with mapReduce() + */ + public function testMapReduceBothMethods(): void + { + $mapper1 = function (): void { + }; + $mapper2 = function (): void { + }; + $reducer1 = function (): void { + }; + $reducer2 = function (): void { + }; + $query = new SelectQuery($this->table); + $this->assertSame($query, $query->mapReduce($mapper1, $reducer1)); + $this->assertEquals( + [['mapper' => $mapper1, 'reducer' => $reducer1]], + $query->getMapReducers(), + ); + + $this->assertSame($query, $query->mapReduce($mapper2, $reducer2)); + $this->assertEquals( + [ + ['mapper' => $mapper1, 'reducer' => $reducer1], + ['mapper' => $mapper2, 'reducer' => $reducer2], + ], + $query->getMapReducers(), + ); + } + + /** + * Tests that it is possible to overwrite previous map reducers + */ + public function testOverwriteMapReduce(): void + { + $mapper1 = function (): void { + }; + $mapper2 = function (): void { + }; + $reducer1 = function (): void { + }; + $reducer2 = function (): void { + }; + $query = new SelectQuery($this->table); + $this->assertEquals($query, $query->mapReduce($mapper1, $reducer1)); + $this->assertEquals( + [['mapper' => $mapper1, 'reducer' => $reducer1]], + $query->getMapReducers(), + ); + + $this->assertEquals($query, $query->mapReduce($mapper2, $reducer2, true)); + $this->assertEquals( + [['mapper' => $mapper2, 'reducer' => $reducer2]], + $query->getMapReducers(), + ); + } + + /** + * Tests that multiple map reducers can be stacked + */ + public function testResultsAreWrappedInMapReduce(): void + { + $table = $this->getTableLocator()->get('articles', ['table' => 'articles']); + $query = new SelectQuery($table); + $query->select(['a' => 'id'])->limit(2)->orderBy(['id' => 'ASC']); + $query->mapReduce(function ($v, $k, $mr): void { + $mr->emit($v['a']); + }); + $query->mapReduce( + function ($v, $k, $mr): void { + $mr->emitIntermediate($v, $k); + }, + function ($v, $k, $mr): void { + $mr->emit($v[0] + 1); + }, + ); + + $this->assertEquals([2, 3], iterator_to_array($query->all())); + } + + /** + * Tests first() method when the query has not been executed before + */ + public function testFirstDirtyQuery(): void + { + $table = $this->getTableLocator()->get('articles', ['table' => 'articles']); + $query = new SelectQuery($table); + $result = $query->select(['id'])->enableHydration(false)->first(); + $this->assertEquals(['id' => 1], $result); + $this->assertEquals(1, $query->clause('limit')); + $result = $query->select(['id'])->first(); + $this->assertEquals(['id' => 1], $result); + } + + /** + * Tests that first can be called again on an already executed query + */ + public function testFirstCleanQuery(): void + { + $table = $this->getTableLocator()->get('articles', ['table' => 'articles']); + $query = new SelectQuery($table); + $query->select(['id'])->toArray(); + + $first = $query->enableHydration(false)->first(); + $this->assertEquals(['id' => 1], $first); + $this->assertEquals(1, $query->clause('limit')); + } + + /** + * Tests that first() will not execute the same query twice + */ + public function testFirstSameResult(): void + { + $table = $this->getTableLocator()->get('articles', ['table' => 'articles']); + $query = new SelectQuery($table); + $query->select(['id'])->toArray(); + + $first = $query->enableHydration(false)->first(); + $resultSet = $query->all(); + $this->assertEquals(['id' => 1], $first); + $this->assertSame($resultSet, $query->all()); + } + + /** + * Tests that first can be called against a query with a mapReduce + */ + public function testFirstMapReduce(): void + { + $map = function ($row, $key, $mapReduce): void { + $mapReduce->emitIntermediate($row['id'], 'id'); + }; + $reduce = function ($values, $key, $mapReduce): void { + $mapReduce->emit(array_sum($values)); + }; + + $table = $this->getTableLocator()->get('articles', ['table' => 'articles']); + $query = new SelectQuery($table); + $query->select(['id']) + ->enableHydration(false) + ->mapReduce($map, $reduce); + + $first = $query->first(); + $this->assertEquals(1, $first); + } + + /** + * Tests that first can be called on an unbuffered query + */ + public function testFirstUnbuffered(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = new SelectQuery($table); + $query->select(['id']); + + $first = $query->enableHydration(false)->first(); + + $this->assertEquals(['id' => 1], $first); + } + + /** + * Testing hydrating a result set into Entity objects + */ + public function testHydrateSimple(): void + { + $table = $this->getTableLocator()->get('articles', ['table' => 'articles']); + $query = new SelectQuery($table); + $results = $query->select()->toArray(); + + $this->assertCount(3, $results); + foreach ($results as $r) { + $this->assertInstanceOf(Entity::class, $r); + } + + $first = $results[0]; + $this->assertEquals(1, $first->id); + $this->assertEquals(1, $first->author_id); + $this->assertSame('First Article', $first->title); + $this->assertSame('First Article Body', $first->body); + $this->assertSame('Y', $first->published); + } + + /** + * Tests that has many results are also hydrated correctly + */ + public function testHydrateHasMany(): void + { + $table = $this->getTableLocator()->get('authors'); + $this->getTableLocator()->get('articles'); + $table->hasMany('articles', [ + 'sort' => ['articles.id' => 'asc'], + ]); + $query = new SelectQuery($table); + $results = $query->select() + ->contain('articles') + ->toArray(); + + $first = $results[0]; + foreach ($first->articles as $r) { + $this->assertInstanceOf(Entity::class, $r); + } + + $this->assertCount(2, $first->articles); + $expected = [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'published' => 'Y', + ]; + $this->assertEquals($expected, $first->articles[0]->toArray()); + $expected = [ + 'id' => 3, + 'title' => 'Third Article', + 'author_id' => 1, + 'body' => 'Third Article Body', + 'published' => 'Y', + ]; + $this->assertEquals($expected, $first->articles[1]->toArray()); + } + + /** + * Tests that belongsToMany associations are also correctly hydrated + */ + public function testHydrateBelongsToMany(): void + { + $table = $this->getTableLocator()->get('Articles'); + $this->getTableLocator()->get('Tags'); + $this->getTableLocator()->get('ArticlesTags', [ + 'table' => 'articles_tags', + ]); + $table->belongsToMany('Tags'); + $query = new SelectQuery($table); + + $results = $query + ->select() + ->contain('Tags') + ->toArray(); + + $first = $results[0]; + foreach ($first->tags as $r) { + $this->assertInstanceOf(Entity::class, $r); + } + + $this->assertCount(2, $first->tags); + $expected = [ + 'id' => 1, + 'name' => 'tag1', + '_joinData' => ['article_id' => 1, 'tag_id' => 1], + 'description' => 'A big description', + 'created' => new DateTime('2016-01-01 00:00'), + ]; + $this->assertEquals($expected, $first->tags[0]->toArray()); + $this->assertInstanceOf(DateTime::class, $first->tags[0]->created); + + $expected = [ + 'id' => 2, + 'name' => 'tag2', + '_joinData' => ['article_id' => 1, 'tag_id' => 2], + 'description' => 'Another big description', + 'created' => new DateTime('2016-01-01 00:00'), + ]; + $this->assertEquals($expected, $first->tags[1]->toArray()); + $this->assertInstanceOf(DateTime::class, $first->tags[1]->created); + } + + /** + * Tests that belongsToMany associations are also correctly hydrated + */ + public function testFormatResultsBelongsToMany(): void + { + $table = $this->getTableLocator()->get('Articles'); + $this->getTableLocator()->get('Tags'); + $articlesTags = $this->getTableLocator()->get('ArticlesTags', [ + 'table' => 'articles_tags', + ]); + $table->belongsToMany('Tags'); + + $articlesTags + ->getEventManager() + ->on('Model.beforeFind', function (EventInterface $event, $query): void { + $query->formatResults(function ($results) { + foreach ($results as $result) { + $result->beforeFind = true; + } + + return $results; + }); + }); + + $query = new SelectQuery($table); + + $results = $query + ->select() + ->contain('Tags') + ->toArray(); + + $first = $results[0]; + foreach ($first->tags as $r) { + $this->assertInstanceOf(Entity::class, $r); + } + + $this->assertCount(2, $first->tags); + $expected = [ + 'id' => 1, + 'name' => 'tag1', + '_joinData' => [ + 'article_id' => 1, + 'tag_id' => 1, + 'beforeFind' => true, + ], + 'description' => 'A big description', + 'created' => new DateTime('2016-01-01 00:00'), + ]; + $this->assertEquals($expected, $first->tags[0]->toArray()); + $this->assertInstanceOf(DateTime::class, $first->tags[0]->created); + + $expected = [ + 'id' => 2, + 'name' => 'tag2', + '_joinData' => [ + 'article_id' => 1, + 'tag_id' => 2, + 'beforeFind' => true, + ], + 'description' => 'Another big description', + 'created' => new DateTime('2016-01-01 00:00'), + ]; + $this->assertEquals($expected, $first->tags[1]->toArray()); + $this->assertInstanceOf(DateTime::class, $first->tags[0]->created); + } + + public function testBelongsToManyWithPreservedKeys(): void + { + $table = $this->getTableLocator()->get('Articles'); + $this->getTableLocator()->get('Tags', ['className' => TagsTable::class]); + $table->belongsToMany('Tags'); + + $first = $table->find() + ->where(['Articles.id' => 1]) + ->contain([ + 'Tags' => ['finder' => 'slugged'], + ]) + ->first(); + + $this->assertArrayHasKey('tag1', $first->tags); + $this->assertArrayHasKey('tag2', $first->tags); + $this->assertSame('tag1', $first->tags['tag1']->name); + } + + /** + * Tests that belongsTo relations are correctly hydrated + */ + #[DataProvider('strategiesProviderBelongsTo')] + public function testHydrateBelongsTo(string $strategy): void + { + $table = $this->getTableLocator()->get('articles'); + $this->getTableLocator()->get('authors'); + $table->belongsTo('authors', ['strategy' => $strategy]); + + $query = new SelectQuery($table); + $results = $query->select() + ->contain('authors') + ->orderBy(['articles.id' => 'asc']) + ->toArray(); + + $this->assertCount(3, $results); + $first = $results[0]; + $this->assertInstanceOf(Entity::class, $first->author); + $expected = ['id' => 1, 'name' => 'mariano']; + $this->assertEquals($expected, $first->author->toArray()); + } + + /** + * Tests that deeply nested associations are also hydrated correctly + */ + #[DataProvider('strategiesProviderBelongsTo')] + public function testHydrateDeep(string $strategy): void + { + $table = $this->getTableLocator()->get('authors'); + $article = $this->getTableLocator()->get('articles'); + $table->hasMany('articles', [ + 'sort' => ['articles.id' => 'asc'], + ]); + $article->belongsTo('authors', ['strategy' => $strategy]); + $query = new SelectQuery($table); + + $results = $query->select() + ->contain(['articles' => ['authors']]) + ->toArray(); + + $this->assertCount(4, $results); + $first = $results[0]; + $this->assertInstanceOf(Entity::class, $first->articles[0]->author); + $expected = ['id' => 1, 'name' => 'mariano']; + $this->assertEquals($expected, $first->articles[0]->author->toArray()); + $this->assertTrue(isset($results[3]->articles)); + } + + /** + * Tests that it is possible to use a custom entity class + */ + public function testHydrateCustomObject(): void + { + // phpcs:ignore + $class = (new class extends Entity {})::class; + $table = $this->getTableLocator()->get('articles', [ + 'table' => 'articles', + 'entityClass' => '\\' . $class, + ]); + $query = new SelectQuery($table); + $results = $query->select()->toArray(); + + $this->assertCount(3, $results); + foreach ($results as $r) { + $this->assertInstanceOf($class, $r); + } + + $first = $results[0]; + $this->assertEquals(1, $first->id); + $this->assertEquals(1, $first->author_id); + $this->assertSame('First Article', $first->title); + $this->assertSame('First Article Body', $first->body); + $this->assertSame('Y', $first->published); + } + + /** + * Tests that has many results are also hydrated correctly + * when specified a custom entity class + */ + public function testHydrateHasManyCustomEntity(): void + { + // phpcs:disable + $authorEntity = (new class extends Entity {})::class; + $articleEntity = (new class extends Entity {})::class; + // phpcs:enable + $table = $this->getTableLocator()->get('authors', [ + 'entityClass' => '\\' . $authorEntity, + ]); + $this->getTableLocator()->get('articles', [ + 'entityClass' => '\\' . $articleEntity, + ]); + $table->hasMany('articles', [ + 'sort' => ['articles.id' => 'asc'], + ]); + $query = new SelectQuery($table); + $results = $query->select() + ->contain('articles') + ->toArray(); + + $first = $results[0]; + $this->assertInstanceOf($authorEntity, $first); + foreach ($first->articles as $r) { + $this->assertInstanceOf($articleEntity, $r); + } + + $this->assertCount(2, $first->articles); + $expected = [ + 'id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'author_id' => 1, + 'published' => 'Y', + ]; + $this->assertEquals($expected, $first->articles[0]->toArray()); + } + + /** + * Tests that belongsTo relations are correctly hydrated into a custom entity class + */ + public function testHydrateBelongsToCustomEntity(): void + { + // phpcs:ignore + $authorEntity = (new class extends Entity {})::class; + $table = $this->getTableLocator()->get('articles'); + $this->getTableLocator()->get('authors', [ + 'entityClass' => '\\' . $authorEntity, + ]); + $table->belongsTo('authors'); + + $query = new SelectQuery($table); + $results = $query->select() + ->contain('authors') + ->orderBy(['articles.id' => 'asc']) + ->toArray(); + + $first = $results[0]; + $this->assertInstanceOf($authorEntity, $first->author); + } + + /** + * Test getting counts from queries. + */ + public function testCount(): void + { + $table = $this->getTableLocator()->get('articles'); + $result = $table->find('all')->count(); + $this->assertSame(3, $result); + + $query = $table->find('all') + ->where(['id >' => 1]) + ->limit(1); + $result = $query->count(); + $this->assertSame(2, $result); + + $result = $query->all(); + $this->assertCount(1, $result); + $this->assertEquals(2, $result->first()->id); + } + + /** + * Test that rebinding parameters clears the count cache + */ + public function testCountWithRebinding(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $query = $table->find() + ->where('id >= :start') + ->where('id <= :end') + ->bind(':start', 1, 'integer') + ->bind(':end', 3, 'integer'); + + $firstCount = $query->count(); + $this->assertSame(3, $firstCount); + + $query->bind(':start', 2, 'integer') + ->bind(':end', 2, 'integer'); + + $secondCount = $query->count(); + $this->assertSame(1, $secondCount, 'Count should reflect the new binding value'); + } + + /** + * Test getting counts from queries with contain. + */ + public function testCountWithContain(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $result = $table->find('all') + ->contain([ + 'Authors' => [ + 'fields' => ['name'], + ], + ]) + ->count(); + $this->assertSame(3, $result); + } + + /** + * Test getting counts from queries with contain. + */ + public function testCountWithSubselect(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $table->hasMany('ArticlesTags'); + + $counter = $table->ArticlesTags->find(); + $counter->select([ + 'total' => $counter->func()->count('*'), + ]) + ->where([ + 'ArticlesTags.tag_id' => 1, + 'ArticlesTags.article_id' => new IdentifierExpression('Articles.id'), + ]); + + $result = $table->find('all') + ->select([ + 'Articles.title', + 'tag_count' => $counter, + ]) + ->matching('Authors', function ($q) { + return $q->where(['Authors.id' => 1]); + }) + ->count(); + $this->assertSame(2, $result); + } + + /** + * Test getting counts with complex fields. + */ + public function testCountWithExpressions(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = $table->find(); + $query->select([ + 'title' => $query->func()->concat( + ['title' => 'identifier', 'test'], + ['string'], + ), + ]); + $query->where(['id' => 1]); + $this->assertCount(1, $query->all()); + $this->assertEquals(1, $query->count()); + } + + /** + * test count with a beforeFind. + */ + public function testCountBeforeFind(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->getEventManager() + ->on('Model.beforeFind', function (EventInterface $event, $query): void { + $query + ->limit(1) + ->orderBy(['Articles.title' => 'DESC']); + }); + + $query = $table->find(); + $result = $query->count(); + $this->assertSame(3, $result); + } + + /** + * Tests that beforeFind is only ever called once, even if you trigger it again in the beforeFind + */ + public function testBeforeFindCalledOnce(): void + { + $callCount = 0; + $table = $this->getTableLocator()->get('Articles'); + $table->getEventManager() + ->on('Model.beforeFind', function (EventInterface $event, $query) use (&$callCount): void { + $valueBinder = new ValueBinder(); + $query->sql($valueBinder); + $callCount++; + }); + + $query = $table->find(); + $valueBinder = new ValueBinder(); + $query->sql($valueBinder); + $this->assertSame(1, $callCount); + } + + /** + * Test that count() returns correct results with group by. + */ + public function testCountWithGroup(): void + { + $table = $this->getTableLocator()->get('articles'); + $query = $table->find('all'); + $query->select(['author_id', 's' => $query->func()->sum('id')]) + ->groupBy(['author_id']); + $result = $query->count(); + $this->assertEquals(2, $result); + } + + /** + * Tests that it is possible to provide a callback for calculating the count + * of a query + */ + public function testCountWithCustomCounter(): void + { + $table = $this->getTableLocator()->get('articles'); + $query = $table->find('all'); + $query + ->select(['author_id', 's' => $query->func()->sum('id')]) + ->where(['id >' => 2]) + ->groupBy(['author_id']) + ->counter(function ($q) use ($query) { + $this->assertNotSame($q, $query); + + return $q->select([], true)->groupBy([], true)->count(); + }); + + $result = $query->count(); + $this->assertEquals(1, $result); + } + + /** + * Test that RAND() returns correct results. + */ + public function testSelectRandom(): void + { + $table = $this->getTableLocator()->get('articles'); + $query = $table->selectQuery(); + + $query->select(['s' => $query->func()->rand()]); + $result = $query + ->all() + ->extract('s') + ->first(); + + $this->assertGreaterThanOrEqual(0, $result); + $this->assertLessThan(1, $result); + } + + /** + * Test update method. + */ + public function testUpdate(): void + { + $table = $this->getTableLocator()->get('articles'); + + $result = $table->updateQuery() + ->set(['title' => 'First']) + ->execute(); + + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertGreaterThan(0, $result->rowCount()); + } + + /** + * Test update method. + */ + public function testUpdateWithTableExpression(): void + { + $this->skipIf(!$this->connection->getDriver() instanceof Mysql); + $table = $this->getTableLocator()->get('articles'); + + $query = $table->updateQuery(); + $result = $query->update($query->expr('articles, authors')) + ->set(['title' => 'First']) + ->where(['articles.author_id = authors.id']) + ->andWhere(['authors.name' => 'mariano']) + ->execute(); + + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertGreaterThan(0, $result->rowCount()); + } + + /** + * Test insert method. + */ + public function testInsert(): void + { + $table = $this->getTableLocator()->get('articles'); + + $result = $table->insertQuery() + ->insert(['title']) + ->values(['title' => 'First']) + ->values(['title' => 'Second']) + ->execute(); + + $result->closeCursor(); + + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertEquals(2, $result->rowCount()); + } + + /** + * Test delete method. + */ + public function testDelete(): void + { + $table = $this->getTableLocator()->get('articles'); + + $result = $table->deleteQuery() + ->where(['id >=' => 1]) + ->execute(); + + $this->assertInstanceOf(StatementInterface::class, $result); + $this->assertGreaterThan(0, $result->rowCount()); + } + + /** + * testClearContain + */ + public function testClearContain(): void + { + $query = new SelectQuery($this->table); + + $query->contain([ + 'Articles', + ]); + + $result = $query->getContain(); + $this->assertIsArray($result); + $this->assertNotEmpty($result); + + $result = $query->clearContain(); + $this->assertInstanceOf(SelectQuery::class, $result); + + $result = $query->getContain(); + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Integration test for query caching. + */ + public function testCacheReadIntegration(): void + { + $query = Mockery::mock(new Query($this->table))->makePartial(); + $resultSet = new ResultSet([]); + + $query->shouldReceive('execute')->never(); + + $cacher = Mockery::mock(CacheEngine::class); + $cacher->shouldReceive('get') + ->with('my_key') + ->once() + ->andReturn($resultSet); + $cacher->shouldReceive('set')->never(); + + $query->cache('my_key', $cacher) + ->where(['id' => 1]); + + $results = $query->all(); + $this->assertSame($resultSet, $results); + } + + /** + * Integration test for query caching. + */ + public function testCacheWriteIntegration(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = new SelectQuery($table); + + $query->select(['id', 'title']); + + $cacher = Mockery::mock(CacheEngine::class); + $cacher->shouldReceive('get') + ->with('my_key') + ->once() + ->andReturn(null); + $cacher->shouldReceive('set') + ->withArgs(function (string $key, mixed $value): bool { + return $key === 'my_key' && $value instanceof ResultSetInterface; + }) + ->once() + ->andReturn(true); + + $query->cache('my_key', $cacher) + ->where(['id' => 1]); + + $query->all(); + } + + /** + * Integration test for query caching using a real cache engine and + * a formatResults callback + */ + public function testCacheIntegrationWithFormatResults(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = new SelectQuery($table); + $cacher = new FileEngine(); + $cacher->init(); + + $query + ->select(['id', 'title']) + ->formatResults(function ($results) { + return $results->combine('id', 'title'); + }) + ->cache('my_key', $cacher); + + $expected = $query->toArray(); + $query = new SelectQuery($table); + $results = $query->cache('my_key', $cacher)->toArray(); + $this->assertSame($expected, $results); + } + + /** + * Test overwriting the contained associations. + */ + public function testContainOverwrite(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->belongsTo('Authors'); + + $query = $table->find(); + $query->contain(['Comments']); + $this->assertEquals(['Comments'], array_keys($query->getContain())); + + $query->contain(['Authors'], true); + $this->assertEquals(['Authors'], array_keys($query->getContain())); + + $query->contain(['Comments', 'Authors'], true); + $this->assertEquals(['Comments', 'Authors'], array_keys($query->getContain())); + } + + /** + * Integration test to show filtering associations using contain and a closure + */ + public function testContainWithClosure(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $query = new SelectQuery($table); + $query + ->select() + ->disableBufferedResults() + ->contain([ + 'articles' => function ($q) { + return $q->where(['articles.id' => 1]); + }, + ]); + + $ids = []; + foreach ($query as $entity) { + foreach ((array)$entity->articles as $article) { + $ids[] = $article->id; + } + } + $this->assertEquals([1], array_unique($ids)); + } + + /** + * Integration test that uses the contain signature that is the same as the + * matching signature + */ + public function testContainClosureSignature(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $query = new SelectQuery($table); + $query + ->select() + ->contain('articles', function ($q) { + return $q->where(['articles.id' => 1]); + }); + + $ids = []; + foreach ($query as $entity) { + foreach ((array)$entity->articles as $article) { + $ids[] = $article->id; + } + } + $this->assertEquals([1], array_unique($ids)); + } + + public function testContainAutoFields(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $query = new SelectQuery($table); + $query + ->select() + ->contain('articles', function ($q) { + return $q->select(['test' => '(SELECT 20)']) + ->enableAutoFields(true); + }); + $results = $query->toArray(); + $this->assertNotEmpty($results); + } + + /** + * Integration test to ensure that filtering associations with the queryBuilder + * option works. + */ + public function testContainWithQueryBuilderHasManyError(): void + { + $this->expectException(DatabaseException::class); + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany('Articles'); + $query = new SelectQuery($table); + $query->select() + ->contain([ + 'Articles' => [ + 'foreignKey' => false, + 'queryBuilder' => function ($q) { + return $q->where(['articles.id' => 1]); + }, + ], + ]); + $query->toArray(); + } + + /** + * Integration test to ensure that filtering associations with the queryBuilder + * option works. + */ + public function testContainWithQueryBuilderJoinableAssociation(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasOne('Articles'); + $query = new SelectQuery($table); + $query->select() + ->contain([ + 'Articles' => [ + 'foreignKey' => false, + 'queryBuilder' => function ($q) { + return $q->where(['Articles.id' => 1]); + }, + ], + ]); + $result = $query->toArray(); + $this->assertEquals(1, $result[0]->article->id); + $this->assertEquals(1, $result[1]->article->id); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + $query = new SelectQuery($articles); + $query->select() + ->contain([ + 'Authors' => [ + 'foreignKey' => false, + 'queryBuilder' => function ($q) { + return $q->where(['Authors.id' => 1]); + }, + ], + ]); + $result = $query->toArray(); + $this->assertEquals(1, $result[0]->author->id); + } + + /** + * Test containing associations that have empty conditions. + */ + public function testContainAssociationWithEmptyConditions(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors', [ + 'conditions' => function ($exp, $query) { + return $exp; + }, + ]); + $query = $articles->find('all')->contain(['Authors']); + $result = $query->toArray(); + $this->assertCount(3, $result); + } + + /** + * Tests the formatResults method + */ + public function testFormatResults(): void + { + $callback1 = function (): void { + }; + $callback2 = function (): void { + }; + $table = $this->getTableLocator()->get('authors'); + $query = new SelectQuery($table); + $this->assertSame($query, $query->formatResults($callback1)); + $this->assertSame([$callback1], $query->getResultFormatters()); + $this->assertSame($query, $query->formatResults($callback2)); + $this->assertSame([$callback1, $callback2], $query->getResultFormatters()); + $query->formatResults($callback2, true); + $this->assertSame([$callback2], $query->getResultFormatters()); + $query->formatResults(null, true); + $this->assertSame([], $query->getResultFormatters()); + + $query->formatResults($callback1); + $query->formatResults($callback2, SelectQuery::PREPEND); + $this->assertSame([$callback2, $callback1], $query->getResultFormatters()); + } + + /** + * Tests that results formatters do receive the query object. + */ + public function testResultFormatterReceivesTheQueryObject(): void + { + $resultFormatterQuery = null; + + $query = $this->getTableLocator()->get('Authors') + ->find() + ->formatResults(function ($results, $query) use (&$resultFormatterQuery) { + $resultFormatterQuery = $query; + + return $results; + }); + $query->firstOrFail(); + + $this->assertSame($query, $resultFormatterQuery); + } + + /** + * Tests that when using `beforeFind` events, results formatters for + * queries of joined associations do receive the source query, not the + * association target query. + */ + public function testResultFormatterReceivesTheSourceQueryForJoinedAssociationsWhenUsingBeforeFind(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $authors = $articles->belongsTo('Authors'); + + $resultFormatterTargetQuery = null; + $resultFormatterSourceQuery = null; + + $authors->getEventManager()->on( + 'Model.beforeFind', + function ($event, SelectQuery $targetQuery) use (&$resultFormatterTargetQuery, &$resultFormatterSourceQuery): void { + $resultFormatterTargetQuery = $targetQuery; + + $targetQuery->formatResults(function ($results, $query) use (&$resultFormatterSourceQuery) { + $resultFormatterSourceQuery = $query; + + return $results; + }); + }, + ); + + $sourceQuery = $articles + ->find() + ->contain('Authors'); + + $sourceQuery->firstOrFail(); + + $this->assertNotSame($resultFormatterTargetQuery, $resultFormatterSourceQuery); + $this->assertNotSame($sourceQuery, $resultFormatterTargetQuery); + $this->assertSame($sourceQuery, $resultFormatterSourceQuery); + } + + /** + * Tests that when using `contain()` callables, results formatters for + * queries of joined associations do receive the source query, not the + * association target query. + */ + public function testResultFormatterReceivesTheSourceQueryForJoinedAssociationWhenUsingContainCallables(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + $resultFormatterTargetQuery = null; + $resultFormatterSourceQuery = null; + + $sourceQuery = $articles + ->find() + ->contain('Authors', function (SelectQuery $targetQuery) use ( + &$resultFormatterTargetQuery, + &$resultFormatterSourceQuery, + ) { + $resultFormatterTargetQuery = $targetQuery; + + return $targetQuery->formatResults(function ($results, $query) use (&$resultFormatterSourceQuery) { + $resultFormatterSourceQuery = $query; + + return $results; + }); + }); + + $sourceQuery->firstOrFail(); + + $this->assertNotSame($resultFormatterTargetQuery, $resultFormatterSourceQuery); + $this->assertNotSame($sourceQuery, $resultFormatterTargetQuery); + $this->assertSame($sourceQuery, $resultFormatterSourceQuery); + } + + /** + * Tests that when using `beforeFind` events, results formatters for + * queries of non-joined associations do receive the association target + * query, not the source query. + */ + public function testResultFormatterReceivesTheTargetQueryForNonJoinedAssociationsWhenUsingBeforeFind(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $articles->belongsToMany('Tags'); + + $resultFormatterTargetQuery = null; + $resultFormatterSourceQuery = null; + + $tags->getEventManager()->on( + 'Model.beforeFind', + function ($event, SelectQuery $targetQuery) use (&$resultFormatterTargetQuery, &$resultFormatterSourceQuery): void { + $resultFormatterTargetQuery = $targetQuery; + + $targetQuery->formatResults(function ($results, $query) use (&$resultFormatterSourceQuery) { + $resultFormatterSourceQuery = $query; + + return $results; + }); + }, + ); + + $sourceQuery = $articles + ->find('all') + ->contain('Tags'); + + $sourceQuery->firstOrFail(); + + $this->assertNotSame($sourceQuery, $resultFormatterTargetQuery); + $this->assertNotSame($sourceQuery, $resultFormatterSourceQuery); + $this->assertSame($resultFormatterTargetQuery, $resultFormatterSourceQuery); + } + + /** + * Tests that when using `contain()` callables, results formatters for + * queries of non-joined associations do receive the association target + * query, not the source query. + */ + public function testResultFormatterReceivesTheTargetQueryForNonJoinedAssociationsWhenUsingContainCallables(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsToMany('Tags'); + + $resultFormatterTargetQuery = null; + $resultFormatterSourceQuery = null; + + $sourceQuery = $articles + ->find() + ->contain('Tags', function (SelectQuery $targetQuery) use ( + &$resultFormatterTargetQuery, + &$resultFormatterSourceQuery, + ) { + $resultFormatterTargetQuery = $targetQuery; + + return $targetQuery->formatResults(function ($results, $query) use (&$resultFormatterSourceQuery) { + $resultFormatterSourceQuery = $query; + + return $results; + }); + }); + + $sourceQuery->firstOrFail(); + + $this->assertNotSame($sourceQuery, $resultFormatterTargetQuery); + $this->assertNotSame($sourceQuery, $resultFormatterSourceQuery); + $this->assertSame($resultFormatterTargetQuery, $resultFormatterSourceQuery); + } + + /** + * Test fetching results from a qurey with a custom formatter + */ + public function testQueryWithFormatter(): void + { + $table = $this->getTableLocator()->get('authors'); + $query = new SelectQuery($table); + $query->select()->formatResults(function ($results) { + $this->assertInstanceOf(ResultSet::class, $results); + + return $results->indexBy('id'); + }); + $this->assertEquals([1, 2, 3, 4], array_keys($query->toArray())); + } + + /** + * Test fetching results from a qurey with a two custom formatters + */ + public function testQueryWithStackedFormatters(): void + { + $table = $this->getTableLocator()->get('authors'); + $query = new SelectQuery($table); + $query->select()->formatResults(function ($results) { + $this->assertInstanceOf(ResultSet::class, $results); + + return $results->indexBy('id'); + }); + + $query->formatResults(function ($results) { + return $results->extract('name'); + }); + + $expected = [ + 1 => 'mariano', + 2 => 'nate', + 3 => 'larry', + 4 => 'garrett', + ]; + $this->assertEquals($expected, $query->toArray()); + } + + /** + * Tests that getting results from a query having a contained association + * will not attach joins twice if count() is called on it afterwards + */ + public function testCountWithContainCallingAll(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + $query = $table->find() + ->select(['id', 'title']) + ->contain('authors') + ->limit(2); + + $results = $query->all(); + $this->assertCount(2, $results); + $this->assertEquals(3, $query->count()); + } + + /** + * Verify that only one count query is issued + * A subsequent request for the count will take the previously + * returned value + */ + public function testCountCache(): void + { + $query = Mockery::mock(Query::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $query->shouldReceive('_performCount') + ->once() + ->andReturn(1); + + $result = $query->count(); + $this->assertSame(1, $result, 'The result of the sql query should be returned'); + + $resultAgain = $query->count(); + $this->assertSame(1, $resultAgain, 'No query should be issued and the cached value returned'); + } + + /** + * If the query is dirty the cached value should be ignored + * and a new count query issued + */ + public function testCountCacheDirty(): void + { + $query = Mockery::mock(Query::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $query->shouldReceive('_performCount') + ->twice() + ->andReturn(1, 2); + + $result = $query->count(); + $this->assertSame(1, $result, 'The result of the sql query should be returned'); + + $query->where(['dirty' => 'cache']); + + $secondResult = $query->count(); + $this->assertSame(2, $secondResult, 'The query cache should be dropped with any modification'); + + $thirdResult = $query->count(); + $this->assertSame(2, $thirdResult, 'The query has not been modified, the cached value is valid'); + } + + /** + * Test that bind() marks the query as dirty and clears cached count + */ + public function testCountCacheClearedOnBind(): void + { + $query = Mockery::mock(SelectQuery::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $query->shouldReceive('_performCount') + ->twice() + ->andReturn(1, 2); + + $query->bind(':start', 'value1'); + $query->bind(':end', 'value2'); + + $result = $query->count(); + $this->assertSame(1, $result, 'The result of the first count should be returned'); + + $query->bind(':start', 'new_value1'); + $query->bind(':end', 'new_value2'); + + $secondResult = $query->count(); + $this->assertSame(2, $secondResult, 'The query cache should be dropped after bind()'); + + $thirdResult = $query->count(); + $this->assertSame(2, $thirdResult, 'The query has not been modified, the cached value is valid'); + } + + /** + * Tests that it is possible to apply formatters inside the query builder + * for belongsTo associations + */ + public function testFormatBelongsToRecords(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + + $query = $table->find() + ->contain([ + 'authors' => function ($q) { + return $q + ->formatResults(function ($authors) { + return $authors->map(function ($author) { + $author->idCopy = $author->id; + + return $author; + }); + }) + ->formatResults(function ($authors) { + return $authors->map(function ($author) { + $author->idCopy += 2; + + return $author; + }); + }); + }, + ]); + + $query->formatResults(function ($results) { + return $results->combine('id', 'author.idCopy'); + }); + $results = $query->toArray(); + $expected = [1 => 3, 2 => 5, 3 => 3]; + $this->assertEquals($expected, $results); + } + + /** + * Tests it is possible to apply formatters to deep relations. + */ + public function testFormatDeepAssociationRecords(): void + { + $table = $this->getTableLocator()->get('ArticlesTags'); + $table->belongsTo('Articles'); + $table->getAssociation('Articles')->getTarget()->belongsTo('Authors'); + + $builder = function ($q) { + return $q + ->formatResults(function ($results) { + return $results->map(function ($result) { + $result->idCopy = $result->id; + + return $result; + }); + }) + ->formatResults(function ($results) { + return $results->map(function ($result) { + $result->idCopy += 2; + + return $result; + }); + }); + }; + $query = $table->find() + ->contain(['Articles' => $builder, 'Articles.Authors' => $builder]) + ->orderBy(['ArticlesTags.article_id' => 'ASC']); + + $query->formatResults(function ($results) { + return $results->map(function ($row) { + return sprintf( + '%s - %s - %s', + $row->tag_id, + $row->article->idCopy, + $row->article->author->idCopy, + ); + }); + }); + + $expected = ['1 - 3 - 3', '2 - 3 - 3', '1 - 4 - 5', '3 - 4 - 5']; + $this->assertEquals($expected, $query->toArray()); + } + + /** + * Tests that formatters cna be applied to deep associations that are fetched using + * additional queries + */ + public function testFormatDeepDistantAssociationRecords(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $articles = $table->getAssociation('articles')->getTarget(); + $articles->hasMany('articlesTags'); + $articles->getAssociation('articlesTags')->getTarget()->belongsTo('tags'); + + $query = $table->find()->contain([ + 'articles.articlesTags.tags' => function ($q) { + return $q->formatResults(function ($results) { + return $results->map(function ($tag) { + $tag->name .= ' - visited'; + + return $tag; + }); + }); + }, + ]); + + $query->mapReduce(function ($row, $key, $mr): void { + foreach ((array)$row->articles as $article) { + foreach ((array)$article->articles_tags as $articleTag) { + $mr->emit($articleTag->tag->name); + } + } + }); + + $expected = ['tag1 - visited', 'tag2 - visited', 'tag1 - visited', 'tag3 - visited']; + $this->assertEquals($expected, $query->toArray()); + } + + /** + * Tests that custom finders are applied to associations when using the proxies + */ + public function testCustomFinderInBelongsTo(): void + { + $table = $this->getTableLocator()->get('ArticlesTags'); + $table->belongsTo('Articles', [ + 'className' => ArticlesTable::class, + 'finder' => 'published', + ]); + $result = $table->find()->contain('Articles'); + $this->assertCount(4, $result->all()->extract('article')->filter()->toArray()); + $table->Articles->updateAll(['published' => 'N'], ['1 = 1']); + + $result = $table->find()->contain('Articles'); + $this->assertCount(0, $result->all()->extract('article')->filter()->toArray()); + } + + /** + * Test finding fields on the non-default table that + * have the same name as the primary table. + */ + public function testContainSelectedFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $query = $table->find() + ->contain(['Authors']) + ->orderBy(['Authors.id' => 'asc']) + ->select(['Authors.id']); + $results = $query->all()->extract('author.id')->toList(); + $expected = [1, 1, 3]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that when selecting only specific fields from a contained association, + * the primary key is automatically added to ensure proper entity loading. + */ + public function testContainWithOnlyNullableFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + // First, let's test with a regular field to ensure our fix works + $query = $table->find() + ->contain(['Authors' => function ($q) { + // Select only the name field (not the primary key) + return $q->select(['Authors.name']); + }]) + ->where(['Articles.id' => 1]); + + $result = $query->first(); + + // The author entity should be loaded + $this->assertNotNull($result->author); + $this->assertInstanceOf('Cake\ORM\Entity', $result->author); + + // The primary key should have been automatically added even though we didn't select it + $this->assertTrue($result->author->has('id')); + $this->assertEquals(1, $result->author->id); + + // The name field we selected should also be present + $this->assertTrue($result->author->has('name')); + $this->assertEquals('mariano', $result->author->name); + } + + /** + * Tests that it is possible to attach more association when using a query + * builder for other associations + */ + public function testContainInAssociationQuery(): void + { + $table = $this->getTableLocator()->get('ArticlesTags'); + $table->belongsTo('Articles'); + $table->getAssociation('Articles')->getTarget()->belongsTo('Authors'); + + $query = $table->find() + ->orderBy(['Articles.id' => 'ASC']) + ->contain([ + 'Articles' => function ($q) { + return $q->contain('Authors'); + }, + ]); + $results = $query->all()->extract('article.author.name')->toArray(); + $expected = ['mariano', 'mariano', 'larry', 'larry']; + $this->assertEquals($expected, $results); + } + + /** + * Tests that it is possible to apply more `matching` conditions inside query + * builders for associations + */ + public function testContainInAssociationMatching(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $articles = $table->getAssociation('articles')->getTarget(); + $articles->hasMany('articlesTags'); + $articles->getAssociation('articlesTags')->getTarget()->belongsTo('tags'); + + $query = $table->find()->matching('articles.articlesTags', function ($q) { + return $q->matching('tags', function ($q) { + return $q->where(['tags.name' => 'tag3']); + }); + }); + + $results = $query->toArray(); + $this->assertCount(1, $results); + $this->assertSame('tag3', $results[0]->_matchingData['tags']->name); + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $query = $table->find() + ->where(['id > ' => 1]) + ->enableHydration(false) + ->matching('articles') + ->applyOptions(['foo' => 'bar']) + ->formatResults(function ($results) { + return $results; + }) + ->mapReduce(function ($item, $key, $mr): void { + $mr->emit($item); + }); + + $expected = [ + '(help)' => 'This is a Query object, to get the results execute or iterate it.', + 'sql' => $query->sql(), + 'params' => $query->getValueBinder()->bindings(), + 'role' => Connection::ROLE_WRITE, + 'defaultTypes' => [ + 'authors__id' => 'integer', + 'authors.id' => 'integer', + 'id' => 'integer', + 'authors__name' => 'string', + 'authors.name' => 'string', + 'name' => 'string', + 'articles__id' => 'integer', + 'articles.id' => 'integer', + 'articles__author_id' => 'integer', + 'articles.author_id' => 'integer', + 'author_id' => 'integer', + 'articles__title' => 'string', + 'articles.title' => 'string', + 'title' => 'string', + 'articles__body' => 'text', + 'articles.body' => 'text', + 'body' => 'text', + 'articles__published' => 'string', + 'articles.published' => 'string', + 'published' => 'string', + ], + 'executed' => false, + 'decorators' => 0, + 'hydrate' => false, + 'formatters' => 1, + 'mapReducers' => 1, + 'contain' => [], + 'extraOptions' => ['foo' => 'bar'], + 'repository' => $table, + ]; + $result = $query->__debugInfo(); + + // Check matching separately since queryBuilder is a Closure + $this->assertArrayHasKey('matching', $result); + $this->assertArrayHasKey('articles', $result['matching']); + $this->assertTrue($result['matching']['articles']['matching']); + $this->assertInstanceOf(Closure::class, $result['matching']['articles']['queryBuilder']); + $this->assertSame('INNER', $result['matching']['articles']['joinType']); + unset($result['matching']); + + $this->assertSame($expected, $result); + } + + /** + * Tests that the eagerLoaded function works and is transmitted correctly to eagerly + * loaded associations + */ + public function testEagerLoaded(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $query = $table->find()->contain([ + 'articles' => function ($q) { + $this->assertTrue($q->isEagerLoaded()); + + return $q; + }, + ]); + $this->assertFalse($query->isEagerLoaded()); + + $table->getEventManager()->on('Model.beforeFind', function ($e, $q, $o, bool $primary): void { + $this->assertTrue($primary); + }); + + $this->getTableLocator()->get('articles') + ->getEventManager()->on('Model.beforeFind', function ($e, $q, $o, bool $primary): void { + $this->assertFalse($primary); + }); + $query->all(); + } + + /** + * Tests that the isEagerLoaded function works and is transmitted correctly to eagerly + * loaded associations + */ + public function testIsEagerLoaded(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $query = $table->find()->contain([ + 'articles' => function ($q) { + $this->assertTrue($q->isEagerLoaded()); + + return $q; + }, + ]); + $this->assertFalse($query->isEagerLoaded()); + + $table->getEventManager()->on('Model.beforeFind', function ($e, $q, $o, bool $primary): void { + $this->assertTrue($primary); + }); + + $this->getTableLocator()->get('articles') + ->getEventManager()->on('Model.beforeFind', function ($e, $q, $o, bool $primary): void { + $this->assertFalse($primary); + }); + $query->all(); + } + + /** + * Tests that columns from manual joins are also contained in the result set + */ + public function testColumnsFromJoin(): void + { + $table = $this->getTableLocator()->get('articles'); + $query = $table->find(); + $results = $query + ->select(['title', 'person.name']) + ->join([ + 'person' => [ + 'table' => 'authors', + 'conditions' => [$query->expr()->equalFields('person.id', 'articles.author_id')], + ], + ]) + ->orderBy(['articles.id' => 'ASC']) + ->enableHydration(false) + ->toArray(); + $expected = [ + ['title' => 'First Article', 'person' => ['name' => 'mariano']], + ['title' => 'Second Article', 'person' => ['name' => 'larry']], + ['title' => 'Third Article', 'person' => ['name' => 'mariano']], + ]; + $this->assertSame($expected, $results); + } + + /** + * Tests that it is possible to use the same association aliases in the association + * chain for contain + */ + #[DataProvider('strategiesProviderBelongsTo')] + public function testRepeatedAssociationAliases(string $strategy): void + { + $table = $this->getTableLocator()->get('ArticlesTags'); + $table->belongsTo('Articles', ['strategy' => $strategy]); + $table->belongsTo('Tags', ['strategy' => $strategy]); + $this->getTableLocator()->get('Tags')->belongsToMany('Articles'); + $results = $table + ->find() + ->contain(['Articles', 'Tags.Articles']) + ->enableHydration(false) + ->toArray(); + $this->assertNotEmpty($results[0]['tag']['articles']); + $this->assertNotEmpty($results[0]['article']); + $this->assertNotEmpty($results[1]['tag']['articles']); + $this->assertNotEmpty($results[1]['article']); + $this->assertNotEmpty($results[2]['tag']['articles']); + $this->assertNotEmpty($results[2]['article']); + } + + /** + * Tests that a hasOne association using the select strategy will still have the + * key present in the results when no match is found + */ + public function testAssociationKeyPresent(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasOne('ArticlesTags', ['strategy' => 'select']); + $article = $table->find()->where(['id' => 3]) + ->enableHydration(false) + ->contain('ArticlesTags') + ->first(); + + $this->assertNull($article['articles_tag']); + } + + /** + * Tests that queries can be serialized to JSON to get the results + */ + public function testJsonSerialize(): void + { + $table = $this->getTableLocator()->get('Articles'); + $this->assertEquals( + json_encode($table->find()), + json_encode($table->find()->toArray()), + ); + } + + /** + * Test that addFields() works in the basic case. + */ + public function testAutoFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $result = $table->find('all') + ->select(['myField' => '(SELECT 20)']) + ->enableAutoFields() + ->enableHydration(false) + ->first(); + + $this->assertArrayHasKey('myField', $result); + $this->assertArrayHasKey('id', $result); + $this->assertArrayHasKey('title', $result); + } + + /** + * Test autoFields with auto fields. + */ + public function testAutoFieldsWithAssociations(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $result = $table->find() + ->select(['myField' => '(SELECT 2 + 2)']) + ->enableAutoFields() + ->enableHydration(false) + ->contain('Authors') + ->first(); + + $this->assertArrayHasKey('myField', $result); + $this->assertArrayHasKey('title', $result); + $this->assertArrayHasKey('author', $result); + $this->assertNotNull($result['author']); + $this->assertArrayHasKey('name', $result['author']); + } + + /** + * Test autoFields in contain query builder + */ + public function testAutoFieldsWithContainQueryBuilder(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $result = $table->find() + ->select(['myField' => '(SELECT 2 + 2)']) + ->enableAutoFields() + ->enableHydration(false) + ->contain([ + 'Authors' => function ($q) { + return $q->select(['computed' => '(SELECT 2 + 20)']) + ->enableAutoFields(); + }, + ]) + ->first(); + + $this->assertArrayHasKey('myField', $result); + $this->assertArrayHasKey('title', $result); + $this->assertArrayHasKey('author', $result); + $this->assertNotNull($result['author']); + $this->assertArrayHasKey('name', $result['author']); + $this->assertArrayHasKey('computed', $result); + } + + /** + * Test that autofields works with count() + */ + public function testAutoFieldsCount(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $result = $table->find() + ->select(['myField' => '(SELECT (2 + 2))']) + ->enableAutoFields() + ->count(); + + $this->assertEquals(3, $result); + } + + /** + * test that cleanCopy makes a cleaned up clone. + */ + public function testCleanCopy(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + + $query = $table->find(); + $query->offset(10) + ->limit(1) + ->orderBy(['Articles.id' => 'DESC']) + ->contain(['Comments']) + ->matching('Comments'); + $copy = $query->cleanCopy(); + + $this->assertNotSame($copy, $query); + $copyLoader = $copy->getEagerLoader(); + $loader = $query->getEagerLoader(); + $this->assertEquals($copyLoader, $loader, 'should be equal'); + $this->assertNotSame($copyLoader, $loader, 'should be clones'); + + $reflect = new ReflectionProperty($loader, '_matching'); + $this->assertNotSame( + $reflect->getValue($copyLoader), + $reflect->getValue($loader), + 'should be clones', + ); + $this->assertNull($copy->clause('offset')); + $this->assertNull($copy->clause('limit')); + $this->assertNull($copy->clause('order')); + } + + /** + * test that cleanCopy retains bindings + */ + public function testCleanCopyRetainsBindings(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = $table->find(); + $query->offset(10) + ->limit(1) + ->where(['Articles.id BETWEEN :start AND :end']) + ->orderBy(['Articles.id' => 'DESC']) + ->bind(':start', 1) + ->bind(':end', 2); + $copy = $query->cleanCopy(); + + $this->assertNotEmpty($copy->getValueBinder()->bindings()); + } + + /** + * test that cleanCopy makes a cleaned up clone with a beforeFind. + */ + public function testCleanCopyBeforeFind(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->getEventManager() + ->on('Model.beforeFind', function (EventInterface $event, $query): void { + $query + ->limit(5) + ->orderBy(['Articles.title' => 'DESC']); + }); + + $query = $table->find(); + $query->offset(10) + ->limit(1) + ->orderBy(['Articles.id' => 'DESC']) + ->contain(['Comments']); + $copy = $query->cleanCopy(); + + $this->assertNotSame($copy, $query); + $this->assertNull($copy->clause('offset')); + $this->assertNull($copy->clause('limit')); + $this->assertNull($copy->clause('order')); + } + + /** + * Test that finder options sent through via contain are sent to custom finder for belongsTo associations. + */ + public function testContainFinderBelongsTo(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo( + 'Authors', + ['className' => AuthorsTable::class], + ); + $authorId = 1; + + $resultWithoutAuthor = $table->find('all') + ->where(['Articles.author_id' => $authorId]) + ->contain([ + 'Authors' => [ + 'finder' => ['byAuthor' => ['authorId' => 2]], + ], + ]); + + $resultWithAuthor = $table->find('all') + ->where(['Articles.author_id' => $authorId]) + ->contain([ + 'Authors' => [ + 'finder' => ['byAuthor' => ['authorId' => $authorId]], + ], + ]); + + $this->assertEmpty($resultWithoutAuthor->first()['author']); + $this->assertEquals($authorId, $resultWithAuthor->first()['author']['id']); + } + + /** + * Test that finder options sent through via contain are sent to custom finder for hasMany associations. + */ + public function testContainFinderHasMany(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany( + 'Articles', + ['className' => ArticlesTable::class], + ); + + $newArticle = $table->newEntity([ + 'author_id' => 1, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'published' => 'N', + ]); + $table->save($newArticle); + + $resultWithArticles = $table->find('all') + ->where(['id' => 1]) + ->contain([ + 'Articles' => [ + 'finder' => 'published', + ], + ]); + + $resultWithArticlesArray = $table->find('all') + ->where(['id' => 1]) + ->contain([ + 'Articles' => [ + 'finder' => ['published' => []], + ], + ]); + + $resultWithArticlesArrayOptions = $table->find('all') + ->where(['id' => 1]) + ->contain([ + 'Articles' => [ + 'finder' => [ + 'published' => [ + 'title' => 'First Article', + ], + ], + ], + ]); + + $resultWithoutArticles = $table->find('all') + ->where(['id' => 1]) + ->contain([ + 'Articles' => [ + 'finder' => [ + 'published' => [ + 'title' => 'Foo', + ], + ], + ], + ]); + + $resultWithSlugIndexedArticles = $table->find('all') + ->where(['id' => 1]) + ->contain([ + 'Articles' => [ + 'finder' => [ + 'slugged' => [ + 'preserveKeys' => true, + ], + ], + ], + ]); + + $this->assertCount(2, $resultWithArticles->first()->articles); + $this->assertCount(2, $resultWithArticlesArray->first()->articles); + + $this->assertCount(1, $resultWithArticlesArrayOptions->first()->articles); + $this->assertSame( + 'First Article', + $resultWithArticlesArrayOptions->first()->articles[0]->title, + ); + + $this->assertCount(0, $resultWithoutArticles->first()->articles); + + $this->assertSame('First-Article', key($resultWithSlugIndexedArticles->first()->articles)); + } + + /** + * Test that using a closure for a custom finder for contain works. + */ + public function testContainFinderHasManyClosure(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany( + 'Articles', + ['className' => ArticlesTable::class], + ); + + $newArticle = $table->newEntity([ + 'author_id' => 1, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'published' => 'N', + ]); + $table->save($newArticle); + + $resultWithArticles = $table->find('all') + ->where(['id' => 1]) + ->contain([ + 'Articles' => function ($q) { + return $q->find('published'); + }, + ]); + + $this->assertCount(2, $resultWithArticles->first()->articles); + } + + /** + * Tests that it is possible to bind arguments to a query and it will return the right + * results + */ + public function testCustomBindings(): void + { + $table = $this->getTableLocator()->get('Articles'); + $query = $table->find()->where(['id >' => 1]); + $query->where(function (QueryExpression $exp) { + return $exp->add('author_id = :author'); + }); + $query->bind(':author', 1, 'integer'); + $this->assertEquals(1, $query->count()); + $this->assertEquals(3, $query->first()->id); + } + + /** + * Tests that it is possible to pass a custom join type for an association when + * using contain + */ + public function testContainWithCustomJoinType(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + + $articles = $table->find() + ->contain([ + 'Authors' => [ + 'joinType' => 'inner', + 'conditions' => ['Authors.id' => 3], + ], + ]) + ->toArray(); + $this->assertCount(1, $articles); + $this->assertEquals(3, $articles[0]->author->id); + } + + /** + * Tests that it is possible to override the contain strategy using the + * containments array. In this case, no inner join will be made and for that + * reason, the parent association will not be filtered as the strategy changed + * from join to select. + */ + public function testContainWithStrategyOverride(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors', [ + 'joinType' => 'INNER', + ]); + $articles = $table->find() + ->contain([ + 'Authors' => [ + 'strategy' => 'select', + 'conditions' => ['Authors.id' => 3], + ], + ]) + ->toArray(); + $this->assertCount(3, $articles); + $this->assertEquals(3, $articles[1]->author->id); + + $this->assertNull($articles[0]->author); + $this->assertNull($articles[2]->author); + } + + /** + * Tests that it is possible to call matching and contain on the same + * association. + */ + public function testMatchingWithContain(): void + { + $query = new SelectQuery($this->table); + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $this->getTableLocator()->get('articles')->belongsToMany('tags'); + + $result = $query->setRepository($table) + ->select() + ->matching('articles.tags', function ($q) { + return $q->where(['tags.id' => 2]); + }) + ->contain('articles') + ->first(); + + $this->assertEquals(1, $result->id); + $this->assertCount(2, $result->articles); + $this->assertEquals(2, $result->_matchingData['tags']->id); + } + + /** + * Tests that it is possible to call matching and contain on the same + * association with only one level of depth. + */ + public function testNotSoFarMatchingWithContainOnTheSameAssociation(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsToMany('tags'); + + $result = $table->find() + ->matching('tags', function ($q) { + return $q->where(['tags.id' => 2]); + }) + ->contain('tags') + ->first(); + + $this->assertEquals(1, $result->id); + $this->assertCount(2, $result->tags); + $this->assertEquals(2, $result->_matchingData['tags']->id); + } + + /** + * Tests that it is possible to find large numeric values. + */ + public function testSelectLargeNumbers(): void + { + // Sqlite only supports maximum 16 digits for decimals. + $this->skipIf($this->connection->getDriver() instanceof Sqlite); + + $big = '1234567890123456789.2'; + $table = $this->getTableLocator()->get('Datatypes'); + $entity = $table->newEntity([]); + $entity->cost = $big; + $entity->tiny = 1; + $entity->small = 10; + + $table->save($entity); + $out = $table->find() + ->where([ + 'cost' => $big, + ]) + ->first(); + $this->assertNotEmpty($out, 'Should get a record'); + $this->assertSame($big, $out->cost); + + $small = '0.1234567890123456789'; + $entity = $table->newEntity(['fraction' => $small]); + + $table->save($entity); + $out = $table->find() + ->where([ + 'fraction' => $small, + ]) + ->first(); + $this->assertNotEmpty($out, 'Should get a record'); + $this->assertMatchesRegularExpression('/^0?\.1234567890123456789$/', $out->fraction); + + $small = 0.1234567890123456789; + $entity = $table->newEntity(['fraction' => $small]); + + $table->save($entity); + $out = $table->find() + ->where([ + 'fraction' => $small, + ]) + ->first(); + $this->assertNotEmpty($out, 'Should get a record'); + // There will be loss of precision if too large/small value is set as float instead of string. + $this->assertMatchesRegularExpression('/^0?\.123456789012350+$/', $out->fraction); + } + + /** + * Tests that select() can be called with Table and Association + * instance + */ + public function testSelectWithTableAndAssociationInstance(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + $result = $table + ->find() + ->select(function ($q) { + return ['foo' => $q->expr('1 + 1')]; + }) + ->select($table) + ->select($table->authors) + ->contain(['authors']) + ->first(); + + $expected = $table + ->find() + ->select(function ($q) { + return ['foo' => $q->expr('1 + 1')]; + }) + ->enableAutoFields() + ->contain(['authors']) + ->first(); + + $this->assertNotEmpty($result); + $this->assertEquals($expected, $result); + } + + /** + * Test that simple aliased field have results typecast. + */ + public function testSelectTypeInferSimpleAliases(): void + { + $table = $this->getTableLocator()->get('comments'); + $result = $table + ->find() + ->select(['created', 'updated_time' => 'updated']) + ->first(); + $this->assertInstanceOf(DateTime::class, $result->created); + $this->assertInstanceOf(DateTime::class, $result->updated_time); + } + + /** + * Tests that leftJoinWith() creates a left join with a given association and + * that no fields from such association are loaded. + */ + public function testLeftJoinWith(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $table->articles->deleteAll(['author_id' => 4]); + $results = $table + ->find() + ->select(['total_articles' => 'count(articles.id)']) + ->enableAutoFields() + ->leftJoinWith('articles') + ->groupBy(['authors.id', 'authors.name']); + + $expected = [ + 1 => 2, + 2 => 0, + 3 => 1, + 4 => 0, + ]; + $this->assertEquals($expected, $results->all()->combine('id', 'total_articles')->toArray()); + $fields = ['total_articles', 'id', 'name']; + $this->assertEquals($fields, array_keys($results->first()->toArray())); + + $results = $table + ->find() + ->leftJoinWith('articles') + ->where(['articles.id IS' => null]); + + $this->assertEquals([2, 4], $results->all()->extract('id')->toList()); + $this->assertEquals(['id', 'name'], array_keys($results->first()->toArray())); + + $results = $table + ->find() + ->leftJoinWith('articles') + ->where(['articles.id IS NOT' => null]) + ->orderBy(['authors.id']); + + $this->assertEquals([1, 1, 3], $results->all()->extract('id')->toList()); + $this->assertEquals(['id', 'name'], array_keys($results->first()->toArray())); + } + + /** + * Tests that leftJoinWith() creates a left join with a given association and + * that no fields from such association are loaded. + */ + public function testLeftJoinWithNested(): void + { + $table = $this->getTableLocator()->get('authors'); + $articles = $table->hasMany('articles'); + $articles->belongsToMany('tags'); + + $results = $table + ->find() + ->select([ + 'authors.id', + 'tagged_articles' => 'count(tags.id)', + ]) + ->leftJoinWith('articles.tags', function ($q) { + return $q->where(['tags.name' => 'tag3']); + }) + ->groupBy(['authors.id']); + + $expected = [ + 1 => 0, + 2 => 0, + 3 => 1, + 4 => 0, + ]; + $this->assertEquals($expected, $results->all()->combine('id', 'tagged_articles')->toArray()); + } + + /** + * Tests that leftJoinWith() can be used with select() + */ + public function testLeftJoinWithSelect(): void + { + $table = $this->getTableLocator()->get('authors'); + $articles = $table->hasMany('articles'); + $articles->belongsToMany('tags'); + $results = $table + ->find() + ->leftJoinWith('articles.tags', function ($q) { + return $q + ->select(['articles.id', 'articles.title', 'tags.name']) + ->where(['tags.name' => 'tag3']); + }) + ->enableAutoFields() + ->where(['ArticlesTags.tag_id' => 3]) + ->all(); + + $expected = ['id' => 2, 'title' => 'Second Article']; + $this->assertEquals( + $expected, + $results->first()->_matchingData['articles']->toArray(), + ); + $this->assertEquals( + ['name' => 'tag3'], + $results->first()->_matchingData['tags']->toArray(), + ); + } + + /** + * Tests that leftJoinWith() can be used with autofields() + */ + public function testLeftJoinWithAutoFields(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + + $results = $table + ->find() + ->leftJoinWith('authors', function ($q) { + return $q->enableAutoFields(); + }) + ->all(); + $this->assertCount(3, $results); + } + + /** + * Test leftJoinWith and contain on optional association + */ + public function testLeftJoinWithAndContainOnOptionalAssociation(): void + { + $table = $this->getTableLocator()->get('Articles', ['table' => 'articles']); + $table->belongsTo('Authors'); + $newArticle = $table->newEntity([ + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'published' => 'N', + ]); + $table->save($newArticle); + $results = $table + ->find() + ->disableHydration() + ->contain('Authors') + ->leftJoinWith('Authors') + ->all(); + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + 'author' => [ + 'id' => 1, + 'name' => 'mariano', + ], + ], + [ + 'id' => 2, + 'author_id' => 3, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'published' => 'Y', + 'author' => [ + 'id' => 3, + 'name' => 'larry', + ], + ], + [ + 'id' => 3, + 'author_id' => 1, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'published' => 'Y', + 'author' => [ + 'id' => 1, + 'name' => 'mariano', + ], + ], + [ + 'id' => 4, + 'author_id' => null, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'published' => 'N', + 'author' => null, + ], + ]; + $this->assertEquals($expected, $results->toList()); + $results = $table + ->find() + ->disableHydration() + ->contain('Authors') + ->leftJoinWith('Authors') + ->where(['Articles.author_id is' => null]) + ->all(); + $expected = [ + [ + 'id' => 4, + 'author_id' => null, + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + 'published' => 'N', + 'author' => null, + ], + ]; + $this->assertEquals($expected, $results->toList()); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `Tags` association is not defined on `Articles`.'); + $table + ->find() + ->disableHydration() + ->contain('Tags') + ->leftJoinWith('Tags') + ->all(); + } + + /** + * Tests innerJoinWith() + */ + public function testInnerJoinWith(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $results = $table + ->find() + ->innerJoinWith('articles', function ($q) { + return $q->where(['articles.title' => 'Third Article']); + }); + $expected = [ + [ + 'id' => 1, + 'name' => 'mariano', + ], + ]; + $this->assertEquals($expected, $results->enableHydration(false)->toArray()); + } + + /** + * Tests innerJoinWith() with nested associations + */ + public function testInnerJoinWithNested(): void + { + $table = $this->getTableLocator()->get('authors'); + $articles = $table->hasMany('articles'); + $articles->belongsToMany('tags'); + $results = $table + ->find() + ->innerJoinWith('articles.tags', function ($q) { + return $q->where(['tags.name' => 'tag3']); + }); + $expected = [ + [ + 'id' => 3, + 'name' => 'larry', + ], + ]; + $this->assertEquals($expected, $results->enableHydration(false)->toArray()); + } + + /** + * Tests innerJoinWith() with select + */ + public function testInnerJoinWithSelect(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $results = $table + ->find() + ->enableAutoFields() + ->innerJoinWith('articles', function ($q) { + return $q->select(['id', 'author_id', 'title', 'body', 'published']); + }) + ->toArray(); + + $expected = $table + ->find() + ->matching('articles') + ->toArray(); + $this->assertEquals($expected, $results); + } + + /** + * Tests contain() in query returned by innerJoinWith throws exception. + */ + public function testInnerJoinWithContain(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $articles = $comments->belongsTo('Articles'); + $articles->hasOne('ArticlesTranslations'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('`Articles` association cannot contain() associations when using JOIN strategy'); + $comments->find() + ->innerJoinWith('Articles', function (SelectQuery $q) { + return $q + ->contain('ArticlesTranslations') + ->where(['ArticlesTranslations.title' => 'Titel #1']); + }) + ->sql(); + } + + /** + * Tests notMatching() with and without conditions + */ + public function testNotMatching(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + + $results = $table->find() + ->enableHydration(false) + ->notMatching('articles') + ->orderBy(['authors.id']) + ->toArray(); + + $expected = [ + ['id' => 2, 'name' => 'nate'], + ['id' => 4, 'name' => 'garrett'], + ]; + $this->assertEquals($expected, $results); + + $results = $table->find() + ->enableHydration(false) + ->notMatching('articles', function ($q) { + return $q->where(['articles.author_id' => 1]); + }) + ->orderBy(['authors.id']) + ->toArray(); + $expected = [ + ['id' => 2, 'name' => 'nate'], + ['id' => 3, 'name' => 'larry'], + ['id' => 4, 'name' => 'garrett'], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests notMatching() with a belongsToMany association + */ + public function testNotMatchingBelongsToMany(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsToMany('tags'); + + $results = $table->find() + ->enableHydration(false) + ->notMatching('tags', function ($q) { + return $q->where(['tags.name' => 'tag2']); + }); + + $results = $results->toArray(); + + $expected = [ + [ + 'id' => 2, + 'author_id' => 3, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'published' => 'Y', + ], + [ + 'id' => 3, + 'author_id' => 1, + 'title' => 'Third Article', + 'body' => 'Third Article Body', + 'published' => 'Y', + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests notMatching() with a deeply nested belongsToMany association. + */ + public function testNotMatchingDeep(): void + { + $table = $this->getTableLocator()->get('authors'); + $articles = $table->hasMany('articles'); + $articles->belongsToMany('tags'); + + $results = $table->find() + ->enableHydration(false) + ->select('authors.id') + ->notMatching('articles.tags', function ($q) { + return $q->where(['tags.name' => 'tag3']); + }) + ->distinct(['authors.id']); + + $this->assertEquals([1, 2, 4], $results->all()->extract('id')->toList()); + + $results = $table->find() + ->enableHydration(false) + ->notMatching('articles.tags', function ($q) { + return $q->where(['tags.name' => 'tag3']); + }) + ->matching('articles') + ->distinct(['authors.id']); + + $this->assertEquals([1], $results->all()->extract('id')->toList()); + } + + /** + * Tests that it is possible to nest a notMatching call inside another + * eagerloader function. + */ + public function testNotMatchingNested(): void + { + $table = $this->getTableLocator()->get('authors'); + $articles = $table->hasMany('articles'); + $articles->belongsToMany('tags'); + + $results = $table->find() + ->enableHydration(false) + ->matching('articles', function (SelectQuery $q) { + return $q->notMatching('tags', function (SelectQuery $q) { + return $q->where(['tags.name' => 'tag3']); + }); + }) + ->orderBy(['authors.id' => 'ASC', 'articles.id' => 'ASC']); + + $expected = [ + 'id' => 1, + 'name' => 'mariano', + '_matchingData' => [ + 'articles' => [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + ], + ], + ]; + $this->assertSame($expected, $results->first()); + } + + /** + * Test to see that the excluded fields are not in the select clause + */ + public function testSelectAllExcept(): void + { + $table = $this->getTableLocator()->get('Articles'); + $result = $table + ->find() + ->selectAllExcept($table, ['body']); + $selectedFields = $result->clause('select'); + $expected = [ + 'Articles__id' => 'Articles.id', + 'Articles__author_id' => 'Articles.author_id', + 'Articles__title' => 'Articles.title', + 'Articles__published' => 'Articles.published', + ]; + $this->assertEquals($expected, $selectedFields); + } + + /** + * Test that the excluded fields are not included + * in the final query result. + */ + public function testSelectAllExceptWithContains(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->belongsTo('Authors'); + + $result = $table + ->find() + ->contain([ + 'Comments' => function (SelectQuery $query) use ($table) { + return $query->selectAllExcept($table->Comments, ['published']); + }, + ]) + ->selectAllExcept($table, ['body']) + ->first(); + $this->assertNull($result->comments[0]->published); + $this->assertNull($result->body); + $this->assertNotEmpty($result->id); + $this->assertNotEmpty($result->comments[0]->id); + } + + /** + * Test what happens if you call selectAllExcept() more + * than once. + */ + public function testSelectAllExceptWithMulitpleCalls(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $result = $table + ->find() + ->selectAllExcept($table, ['body']) + ->selectAllExcept($table, ['published']); + $selectedFields = $result->clause('select'); + $expected = [ + 'Articles__id' => 'Articles.id', + 'Articles__author_id' => 'Articles.author_id', + 'Articles__title' => 'Articles.title', + 'Articles__published' => 'Articles.published', + 'Articles__body' => 'Articles.body', + ]; + $this->assertEquals($expected, $selectedFields); + + $result = $table + ->find() + ->selectAllExcept($table, ['body']) + ->selectAllExcept($table, ['published', 'body']); + $selectedFields = $result->clause('select'); + $expected = [ + 'Articles__id' => 'Articles.id', + 'Articles__author_id' => 'Articles.author_id', + 'Articles__title' => 'Articles.title', + 'Articles__published' => 'Articles.published', + ]; + $this->assertEquals($expected, $selectedFields); + + $result = $table + ->find() + ->selectAllExcept($table, ['body']) + ->selectAllExcept($table, ['published', 'body'], true); + $selectedFields = $result->clause('select'); + $expected = [ + 'Articles__id' => 'Articles.id', + 'Articles__author_id' => 'Articles.author_id', + 'Articles__title' => 'Articles.title', + ]; + $this->assertEquals($expected, $selectedFields); + } + + /** + * Tests that using Having on an aggregated field returns the correct result + * model in the query + */ + public function testHavingOnAnAggregatedField(): void + { + $post = $this->getTableLocator()->get('posts'); + + $query = new SelectQuery($post); + + $results = $query + ->select([ + 'posts.author_id', + 'post_count' => $query->func()->count('posts.id'), + ]) + ->groupBy(['posts.author_id']) + ->having([$query->expr()->gte('post_count', 2, 'integer')]) + ->enableHydration(false) + ->toArray(); + + $expected = [ + [ + 'author_id' => 1, + 'post_count' => 2, + ], + ]; + + $this->assertEquals($expected, $results); + } + + /** + * Tests ORM query using with CTE. + */ + public function testWith(): void + { + $this->skipIf( + !$this->connection->getDriver()->supports(DriverFeatureEnum::CTE), + 'The current driver does not support common table expressions.', + ); + $this->skipIf( + ( + $this->connection->getDriver() instanceof Mysql || + $this->connection->getDriver() instanceof Sqlite + ) && + !$this->connection->getDriver()->supports(DriverFeatureEnum::WINDOW), + 'The current driver does not support window functions.', + ); + + $table = $this->getTableLocator()->get('Articles'); + + $cteQuery = $table + ->find() + ->select(function (SelectQuery $query) use ($table) { + $columns = $table->getSchema()->columns(); + + return array_combine($columns, $columns) + [ + 'row_num' => $query + ->func() + ->rowNumber() + ->over() + ->partition('author_id') + ->orderBy(['id' => 'ASC']), + ]; + }); + + $query = $table + ->find() + ->with(function (CommonTableExpression $cte) use ($cteQuery) { + return $cte + ->name('cte') + ->query($cteQuery); + }) + ->select(['row_num']) + ->enableAutoFields() + ->from([$table->getAlias() => 'cte']) + ->where(['row_num' => 1], ['row_num' => 'integer']) + ->orderBy(['id' => 'ASC']) + ->disableHydration(); + + $expected = [ + [ + 'id' => 1, + 'author_id' => 1, + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + 'row_num' => '1', + ], + [ + 'id' => 2, + 'author_id' => 3, + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'published' => 'Y', + 'row_num' => '1', + ], + ]; + + $this->assertEquals($expected, $query->toArray()); + } + + /** + * Tests that queries that fetch associated data in separate queries do properly + * inherit the hydration and results casting mode of the parent query. + */ + public function testSelectLoaderAssociationsInheritHydrationAndResultsCastingMode(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $tags = $articles->belongsToMany('Tags'); + $tags->belongsToMany('Articles'); + + $comments = $articles->hasMany('Comments'); + $comments + ->belongsTo('Articles') + ->setStrategy(BelongsTo::STRATEGY_SELECT); + + $articles + ->find() + ->contain('Comments', function (SelectQuery $query) { + $this->assertFalse($query->isHydrationEnabled()); + $this->assertFalse($query->isResultsCastingEnabled()); + + return $query; + }) + ->contain('Comments.Articles', function (SelectQuery $query) { + $this->assertFalse($query->isHydrationEnabled()); + $this->assertFalse($query->isResultsCastingEnabled()); + + return $query; + }) + ->contain('Comments.Articles.Tags', function (SelectQuery $query) { + $this->assertFalse($query->isHydrationEnabled()); + $this->assertFalse($query->isResultsCastingEnabled()); + + return $query + ->enableHydration() + ->enableResultsCasting(); + }) + ->contain('Comments.Articles.Tags.Articles', function (SelectQuery $query) { + $this->assertTrue($query->isHydrationEnabled()); + $this->assertTrue($query->isResultsCastingEnabled()); + + return $query; + }) + ->disableHydration() + ->disableResultsCasting() + ->firstOrFail(); + } + + /** + * Tests that passing a ORM query as an argument wraps the query SQL into parentheses. + */ + public function testFunctionWithOrmQuery(): void + { + $query = $this->getTableLocator()->get('Articles') + ->setSchema(['column' => 'integer']) + ->find() + ->select(['column']); + + $binder = new ValueBinder(); + $function = new FunctionExpression('MyFunction', [$query]); + $this->assertSame( + 'MyFunction((SELECT Articles.column AS Articles__column FROM articles Articles))', + preg_replace('/[`"\[\]]/', '', $function->sql($binder)), + ); + } + + public function testContainConflictingAliases(): void + { + $comments = $this->getTableLocator()->get('Comments'); + + $comments->belongsTo('Authors', [ + 'className' => 'Authors', + 'foreignKey' => 'user_id', + ]); + + $comments + ->belongsTo('Articles', [ + 'className' => 'Articles', + 'foreignKey' => 'article_id', + ]) + ->getTarget() + ->belongsTo('Authors', [ + 'className' => 'Authors', + 'foreignKey' => 'author_id', + ]); + + $result = $comments->find() + ->contain('Authors') + ->contain('Articles', function (SelectQuery $q) { + return $q->contain('Authors'); + }) + ->where(['Comments.id' => 1]) + ->disableHydration() + ->toArray(); + + $this->assertEquals(2, $result[0]['author']['id']); + $this->assertEquals(1, $result[0]['article']['author']['id']); + } + + public function testJoinWithConflictingAliases(): void + { + $comments = $this->getTableLocator()->get('Comments'); + + $comments->belongsTo('Authors', [ + 'className' => 'Authors', + 'foreignKey' => 'user_id', + ]); + + $comments + ->belongsTo('Articles', [ + 'className' => 'Articles', + 'foreignKey' => 'article_id', + ]) + ->getTarget() + ->belongsTo('Authors', [ + 'className' => 'Authors', + 'foreignKey' => 'author_id', + ]); + + $this->expectException(AssertionError::class); + $this->expectExceptionMessage('You cannot join with `Articles.Authors` because it conflicts with the existing `Authors` join.'); + $comments->find() + ->leftJoinWith('Authors') + ->leftJoinWith('Articles', fn(SelectQuery $q) => $q->leftJoinWith('Authors')) + ->where(['Comments.id' => 1]) + ->disableHydration() + ->all(); + } + + public function testMatchingConflictingAliases(): void + { + $comments = $this->getTableLocator()->get('Comments'); + + $comments->belongsTo('Authors', [ + 'className' => 'Authors', + 'foreignKey' => 'user_id', + ]); + + $comments + ->belongsTo('Articles', [ + 'className' => 'Articles', + 'foreignKey' => 'article_id', + ]) + ->getTarget() + ->belongsTo('Authors', [ + 'className' => 'Authors', + 'foreignKey' => 'author_id', + ]); + + $this->expectException(AssertionError::class); + $this->expectExceptionMessage('You cannot join with `Articles.Authors` because it conflicts with the existing `Authors` join.'); + $comments->find() + ->leftJoinWith('Authors') + ->matching('Articles', fn(SelectQuery $q) => $q->leftJoinWith('Authors')) + ->where(['Comments.id' => 1]) + ->disableHydration() + ->all(); + } +} diff --git a/tests/TestCase/ORM/ResultSetFactoryTest.php b/tests/TestCase/ORM/ResultSetFactoryTest.php new file mode 100644 index 00000000000..35ec54c6b1c --- /dev/null +++ b/tests/TestCase/ORM/ResultSetFactoryTest.php @@ -0,0 +1,558 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Authors', 'core.Comments']; + + /** + * @var \Cake\ORM\Table + */ + protected $table; + + /** + * @var array + */ + protected $fixtureData; + + /** + * @var \Cake\Datasource\ConnectionInterface + */ + protected $connection; + + /** + * @var \Cake\ORM\ResultSetFactory + */ + protected $factory; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->table = new Table([ + 'table' => 'articles', + 'connection' => $this->connection, + ]); + $this->factory = new ResultSetFactory(); + + $this->fixtureData = [ + ['id' => 1, 'author_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y'], + ['id' => 2, 'author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + ['id' => 3, 'author_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'], + ]; + } + + public function testSetResultSetClass(): void + { + $mock = Mockery::mock(ResultSetInterface::class); + + $this->factory->setResultSetClass($mock::class); + $this->assertSame($mock::class, $this->factory->getResultSetClass()); + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $query = $this->table->find('all'); + $results = $query->all(); + $expected = [ + 'count' => 3, + 'items' => $results->toArray(), + ]; + $this->assertSame($expected, $results->__debugInfo()); + } + + /** + * Test that eagerLoader leaves empty associations unpopulated. + */ + public function testBelongsToEagerLoaderLeavesEmptyAssociation(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $comments->belongsTo('Articles'); + + // Clear the articles table so we can trigger an empty belongsTo + $this->table->deleteAll([]); + + $comment = $comments->find()->where(['Comments.id' => 1]) + ->contain(['Articles']) + ->enableHydration(false) + ->first(); + $this->assertSame(1, $comment['id']); + $this->assertNotEmpty($comment['comment']); + $this->assertNull($comment['article']); + + $comment = $comments->get(1, ...['contain' => ['Articles']]); + $this->assertNull($comment->article); + $this->assertSame(1, $comment->id); + $this->assertNotEmpty($comment->comment); + } + + /** + * Test showing associated record is preserved when selecting only field with + * null value if auto fields is disabled. + */ + public function testBelongsToEagerLoaderWithAutoFieldsFalse(): void + { + $authors = $this->getTableLocator()->get('Authors'); + + $author = $authors->newEntity(['name' => null]); + $authors->save($author); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + $article = $articles->newEntity([ + 'author_id' => $author->id, + 'title' => 'article with author with null name', + ]); + $articles->save($article); + + $result = $articles->find() + ->select(['Articles.id', 'Articles.title', 'Authors.name']) + ->contain(['Authors']) + ->where(['Articles.id' => $article->id]) + ->disableAutoFields() + ->enableHydration(false) + ->first(); + + $this->assertNotNull($result['author']); + } + + /** + * Test that eagerLoader leaves empty associations unpopulated. + */ + public function testHasOneEagerLoaderLeavesEmptyAssociation(): void + { + $this->table->hasOne('Comments'); + + // Clear the comments table so we can trigger an empty hasOne. + $comments = $this->getTableLocator()->get('Comments'); + $comments->deleteAll([]); + + $article = $this->table->get(1, ...['contain' => ['Comments']]); + $this->assertNull($article->comment); + $this->assertSame(1, $article->id); + $this->assertNotEmpty($article->title); + + $article = $this->table->find()->where(['articles.id' => 1]) + ->contain(['Comments']) + ->enableHydration(false) + ->first(); + $this->assertNull($article['comment']); + $this->assertSame(1, $article['id']); + $this->assertNotEmpty($article['title']); + } + + /** + * Test that fetching rows does not fail when no fields were selected + * on the default alias. + */ + public function testFetchMissingDefaultAlias(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $query = $comments->find()->select(['Other__field' => 'test']); + $query->disableAutoFields(); + + $row = ['Other__field' => 'test']; + $statement = Mockery::mock(StatementInterface::class); + $statement->shouldReceive('fetchAll') + ->andReturn([$row]); + + $results = $this->factory->createResultSet($statement->fetchAll(), $query); + $this->assertNotEmpty($results); + } + + /** + * Test that associations have source() correctly set. + */ + public function testSourceOnContainAssociations(): void + { + $this->loadPlugins(['TestPlugin']); + $comments = $this->getTableLocator()->get('TestPlugin.Comments'); + $comments->belongsTo('Authors', [ + 'className' => 'TestPlugin.Authors', + 'foreignKey' => 'user_id', + ]); + $result = $comments->find()->contain(['Authors'])->first(); + $this->assertSame('TestPlugin.Comments', $result->getSource()); + $this->assertSame('TestPlugin.Authors', $result->author->getSource()); + + $result = $comments->find()->matching('Authors', function ($q) { + return $q->where(['Authors.id' => 1]); + })->first(); + $this->assertSame('TestPlugin.Comments', $result->getSource()); + $this->assertSame('TestPlugin.Authors', $result->_matchingData['Authors']->getSource()); + $this->clearPlugins(); + } + + /** + * @see https://github.com/cakephp/cakephp/issues/14676 + */ + public function testQueryLoggingForSelectsWithZeroRows(): void + { + Log::setConfig('queries', ['className' => 'Array']); + + $logger = new QueryLogger(); + $this->connection->getDriver()->setLogger($logger); + + $messages = Log::engine('queries')->read(); + $this->assertCount(0, $messages); + + $results = $this->table->find('all') + ->where(['id' => 0]) + ->all(); + + $this->assertCount(0, $results); + + $messages = Log::engine('queries')->read(); + $message = array_pop($messages); + $this->assertStringContainsString('SELECT', $message); + + Log::reset(); + } + + /** + * Test projectAs() returns DTOs instead of entities. + */ + public function testProjectAsSimpleDto(): void + { + DtoMapper::clearCache(); + + $result = $this->table->find() + ->where(['id' => 1]) + ->projectAs(SimpleArticleDto::class) + ->first(); + + $this->assertInstanceOf(SimpleArticleDto::class, $result); + $this->assertSame(1, $result->id); + $this->assertSame('First Article', $result->title); + $this->assertSame('First Article Body', $result->body); + } + + /** + * Test projectAs() with multiple results. + */ + public function testProjectAsMultipleResults(): void + { + DtoMapper::clearCache(); + + $results = $this->table->find() + ->projectAs(SimpleArticleDto::class) + ->toArray(); + + $this->assertCount(3, $results); + foreach ($results as $result) { + $this->assertInstanceOf(SimpleArticleDto::class, $result); + } + } + + /** + * Test projectAs() with BelongsTo association. + */ + public function testProjectAsWithBelongsTo(): void + { + DtoMapper::clearCache(); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + $result = $articles->find() + ->contain(['Authors']) + ->where(['Articles.id' => 1]) + ->projectAs(ArticleDto::class) + ->first(); + + $this->assertInstanceOf(ArticleDto::class, $result); + $this->assertSame(1, $result->id); + $this->assertSame('First Article', $result->title); + $this->assertInstanceOf(AuthorDto::class, $result->author); + $this->assertSame('mariano', $result->author->name); + } + + /** + * Test projectAs() with HasMany association. + */ + public function testProjectAsWithHasMany(): void + { + DtoMapper::clearCache(); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->hasMany('Comments'); + + $result = $articles->find() + ->contain(['Comments']) + ->where(['Articles.id' => 1]) + ->projectAs(ArticleDto::class) + ->first(); + + $this->assertInstanceOf(ArticleDto::class, $result); + $this->assertSame(1, $result->id); + $this->assertIsArray($result->comments); + $this->assertGreaterThan(0, count($result->comments)); + foreach ($result->comments as $comment) { + $this->assertInstanceOf(CommentDto::class, $comment); + } + } + + /** + * Test projectAs() with null BelongsTo association. + */ + public function testProjectAsWithNullBelongsTo(): void + { + DtoMapper::clearCache(); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + // Clear authors to trigger null association + $authors = $this->getTableLocator()->get('Authors'); + $authors->deleteAll([]); + + $result = $articles->find() + ->contain(['Authors']) + ->where(['Articles.id' => 1]) + ->projectAs(ArticleDto::class) + ->first(); + + $this->assertInstanceOf(ArticleDto::class, $result); + $this->assertNull($result->author); + } + + /** + * Test projectAs() with empty HasMany association. + */ + public function testProjectAsWithEmptyHasMany(): void + { + DtoMapper::clearCache(); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->hasMany('Comments'); + + // Clear comments to trigger empty collection + $comments = $this->getTableLocator()->get('Comments'); + $comments->deleteAll([]); + + $result = $articles->find() + ->contain(['Comments']) + ->where(['Articles.id' => 1]) + ->projectAs(ArticleDto::class) + ->first(); + + $this->assertInstanceOf(ArticleDto::class, $result); + $this->assertSame([], $result->comments); + } + + /** + * Test getDtoClass() returns the DTO class. + */ + public function testGetDtoClass(): void + { + $query = $this->table->find(); + $this->assertNull($query->getDtoClass()); + + $query->projectAs(SimpleArticleDto::class); + $this->assertSame(SimpleArticleDto::class, $query->getDtoClass()); + } + + /** + * Test isDtoProjectionEnabled(). + */ + public function testIsDtoProjectionEnabled(): void + { + $query = $this->table->find(); + $this->assertFalse($query->isDtoProjectionEnabled()); + + $query->projectAs(SimpleArticleDto::class); + $this->assertTrue($query->isDtoProjectionEnabled()); + } + + /** + * Test projectAs() with createFromArray factory method. + */ + public function testProjectAsWithCreateFromArray(): void + { + DtoMapper::clearCache(); + + $result = $this->table->find() + ->where(['id' => 1]) + ->projectAs(ArticleArrayDto::class) + ->first(); + + $this->assertInstanceOf(ArticleArrayDto::class, $result); + $this->assertSame(1, $result->id); + $this->assertSame('First Article', $result->title); + $this->assertSame('First Article Body', $result->body); + } + + /** + * Test projectAs() with createFromArray and BelongsTo association. + */ + public function testProjectAsCreateFromArrayWithBelongsTo(): void + { + DtoMapper::clearCache(); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + $result = $articles->find() + ->contain(['Authors']) + ->where(['Articles.id' => 1]) + ->projectAs(ArticleArrayDto::class) + ->first(); + + $this->assertInstanceOf(ArticleArrayDto::class, $result); + $this->assertSame(1, $result->id); + $this->assertSame('First Article', $result->title); + $this->assertInstanceOf(AuthorArrayDto::class, $result->author); + $this->assertSame('mariano', $result->author->name); + } + + /** + * Test projectAs() with createFromArray and null association. + */ + public function testProjectAsCreateFromArrayWithNullBelongsTo(): void + { + DtoMapper::clearCache(); + + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Authors'); + + // Clear authors to trigger null association + $authors = $this->getTableLocator()->get('Authors'); + $authors->deleteAll([]); + + $result = $articles->find() + ->contain(['Authors']) + ->where(['Articles.id' => 1]) + ->projectAs(ArticleArrayDto::class) + ->first(); + + $this->assertInstanceOf(ArticleArrayDto::class, $result); + $this->assertNull($result->author); + } + + /** + * Test getDtoHydrator() returns cached callable for plain DTOs. + */ + public function testGetDtoHydratorPlainDto(): void + { + DtoMapper::clearCache(); + ResultSetFactory::clearDtoHydratorCache(); + + $hydrator = $this->factory->getDtoHydrator(SimpleArticleDto::class); + $this->assertIsCallable($hydrator); + + // Calling again should return the same cached callable + $hydrator2 = $this->factory->getDtoHydrator(SimpleArticleDto::class); + $this->assertSame($hydrator, $hydrator2); + + // Test the hydrator works + $result = $hydrator(['id' => 1, 'title' => 'Test', 'body' => 'Body']); + $this->assertInstanceOf(SimpleArticleDto::class, $result); + $this->assertSame(1, $result->id); + $this->assertSame('Test', $result->title); + } + + /** + * Test getDtoHydrator() returns cached callable for DTOs with createFromArray. + */ + public function testGetDtoHydratorCreateFromArray(): void + { + DtoMapper::clearCache(); + ResultSetFactory::clearDtoHydratorCache(); + + $hydrator = $this->factory->getDtoHydrator(ArticleArrayDto::class); + $this->assertIsCallable($hydrator); + + // Calling again should return the same cached callable + $hydrator2 = $this->factory->getDtoHydrator(ArticleArrayDto::class); + $this->assertSame($hydrator, $hydrator2); + + // Test the hydrator works + $result = $hydrator(['id' => 2, 'title' => 'Test 2', 'body' => 'Body 2']); + $this->assertInstanceOf(ArticleArrayDto::class, $result); + $this->assertSame(2, $result->id); + $this->assertSame('Test 2', $result->title); + } + + /** + * Test clearDtoHydratorCache() clears the cache. + */ + public function testClearDtoHydratorCache(): void + { + DtoMapper::clearCache(); + ResultSetFactory::clearDtoHydratorCache(); + + // Get a hydrator to populate the cache + $this->factory->getDtoHydrator(SimpleArticleDto::class); + + // Clear the cache + ResultSetFactory::clearDtoHydratorCache(); + + // Get the hydrator again - should be a new callable + $hydrator2 = $this->factory->getDtoHydrator(SimpleArticleDto::class); + + // The callables should be equivalent but not the same instance + // since the cache was cleared + $this->assertIsCallable($hydrator2); + } + + /** + * Test hydrateDto() method. + */ + public function testHydrateDto(): void + { + DtoMapper::clearCache(); + ResultSetFactory::clearDtoHydratorCache(); + + $row = ['id' => 3, 'title' => 'Hydrate Test', 'body' => 'Body']; + $result = $this->factory->hydrateDto($row, SimpleArticleDto::class); + + $this->assertInstanceOf(SimpleArticleDto::class, $result); + $this->assertSame(3, $result->id); + $this->assertSame('Hydrate Test', $result->title); + } +} diff --git a/tests/TestCase/ORM/ResultSetTest.php b/tests/TestCase/ORM/ResultSetTest.php new file mode 100644 index 00000000000..2b1d1b16c2a --- /dev/null +++ b/tests/TestCase/ORM/ResultSetTest.php @@ -0,0 +1,289 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Authors', 'core.Comments']; + + /** + * @var \Cake\ORM\Table + */ + protected $table; + + /** + * @var array + */ + protected $fixtureData; + + /** + * @var \Cake\Datasource\ConnectionInterface + */ + protected $connection; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + $this->connection = ConnectionManager::get('test'); + $this->table = new Table([ + 'table' => 'articles', + 'connection' => $this->connection, + ]); + + $this->fixtureData = [ + ['id' => 1, 'author_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y'], + ['id' => 2, 'author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + ['id' => 3, 'author_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'], + ]; + } + + /** + * Test that result sets can be rewound and re-used. + */ + public function testRewind(): void + { + $query = $this->table->find('all'); + $results = $query->all(); + $first = []; + $second = []; + foreach ($results as $result) { + $first[] = $result; + } + foreach ($results as $result) { + $second[] = $result; + } + $this->assertEquals($first, $second); + } + + /** + * An integration test for testing serialize and unserialize features. + * + * Compare the results of a query with the results iterated, with + * those of a different query that have been serialized/unserialized. + */ + public function testSerialization(): void + { + $query = $this->table->find('all'); + $results = $query->all(); + $expected = $results->toArray(); + + $query2 = $this->table->find('all'); + $results2 = $query2->all(); + $serialized = serialize($results2); + $outcome = unserialize($serialized); + $this->assertEquals($expected, $outcome->toArray()); + } + + /** + * Test iteration after serialization + */ + public function testIteratorAfterSerializationNoHydration(): void + { + $query = $this->table->find('all')->enableHydration(false); + $results = unserialize(serialize($query->all())); + + // Use a loop to test Iterator implementation + foreach ($results as $i => $row) { + $this->assertEquals($this->fixtureData[$i], $row, "Row {$i} does not match"); + } + } + + /** + * Test iteration after serialization + */ + public function testIteratorAfterSerializationHydrated(): void + { + $query = $this->table->find('all'); + $results = unserialize(serialize($query->all())); + + // Use a loop to test Iterator implementation + foreach ($results as $i => $row) { + $expected = new Entity($this->fixtureData[$i]); + $expected->setNew(false); + $expected->setSource($this->table->getAlias()); + $expected->clean(); + $this->assertEquals($expected, $row, "Row {$i} does not match"); + } + } + + /** + * Test converting result sets into JSON + */ + public function testJsonSerialize(): void + { + $query = $this->table->find('all'); + $results = $query->all(); + + $expected = json_encode($this->fixtureData); + $this->assertEquals($expected, json_encode($results)); + } + + /** + * Test first() method with a statement backed result set. + */ + public function testFirst(): void + { + $query = $this->table->find('all'); + $results = $query->enableHydration(false)->all(); + + $row = $results->first(); + $this->assertEquals($this->fixtureData[0], $row); + + $row = $results->first(); + $this->assertEquals($this->fixtureData[0], $row); + } + + /** + * Test first() method with a result set that has been unserialized + */ + public function testFirstAfterSerialize(): void + { + $query = $this->table->find('all'); + $results = $query->enableHydration(false)->all(); + $results = unserialize(serialize($results)); + + $row = $results->first(); + $this->assertEquals($this->fixtureData[0], $row); + + $this->assertSame($row, $results->first()); + $this->assertSame($row, $results->first()); + } + + /** + * Test the countable interface. + */ + public function testCount(): void + { + $query = $this->table->find('all'); + $results = $query->all(); + + $this->assertCount(3, $results, 'Should be countable and 3'); + } + + /** + * Test the countable interface after unserialize + */ + public function testCountAfterSerialize(): void + { + $query = $this->table->find('all'); + $results = $query->all(); + $results = unserialize(serialize($results)); + + $this->assertCount(3, $results, 'Should be countable and 3'); + } + + /** + * Integration test to show methods from CollectionTrait work + */ + public function testGroupBy(): void + { + $query = $this->table->find('all'); + $results = $query->all()->groupBy('author_id')->toArray(); + $options = [ + 'markNew' => false, + 'markClean' => true, + 'source' => $this->table->getAlias(), + ]; + $expected = [ + 1 => [ + new Entity($this->fixtureData[0], $options), + new Entity($this->fixtureData[2], $options), + ], + 3 => [ + new Entity($this->fixtureData[1], $options), + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $query = $this->table->find('all'); + $results = $query->all(); + $expected = [ + 'count' => 3, + 'items' => $results->toArray(), + ]; + $this->assertSame($expected, $results->__debugInfo()); + } + + /** + * Ensure that isEmpty() on a ResultSet doesn't result in loss + * of records. This behavior is provided by CollectionTrait. + */ + public function testIsEmptyDoesNotConsumeData(): void + { + $table = $this->getTableLocator()->get('Comments'); + $query = $table->find() + ->formatResults(function ($results) { + return $results; + }); + $res = $query->all(); + $res->isEmpty(); + $this->assertCount(6, $res->toArray()); + } + + /** + * Test that ResultSet + */ + public function testCollectionMinAndMax(): void + { + $query = $this->table->find('all'); + + $min = $query->all()->min('id'); + $minExpected = $this->table->get(1); + + $max = $query->all()->max('id'); + $maxExpected = $this->table->get(3); + + $this->assertEquals($minExpected, $min); + $this->assertEquals($maxExpected, $max); + } + + /** + * Test that ResultSet + */ + public function testCollectionMinAndMaxWithAggregateField(): void + { + $query = $this->table->find(); + $query->select([ + 'counter' => 'COUNT(*)', + ])->groupBy('author_id'); + + $min = $query->all()->min('counter'); + $max = $query->all()->max('counter'); + + $this->assertTrue($max > $min); + } +} diff --git a/tests/TestCase/ORM/Rule/ExistsInNullableTest.php b/tests/TestCase/ORM/Rule/ExistsInNullableTest.php new file mode 100644 index 00000000000..48f62924465 --- /dev/null +++ b/tests/TestCase/ORM/Rule/ExistsInNullableTest.php @@ -0,0 +1,278 @@ + + */ + protected array $fixtures = [ + 'core.SiteArticles', + 'core.SiteAuthors', + ]; + + /** + * Test that allowNullableNulls is true by default + */ + public function testAllowNullableNullsDefaultValue(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add(new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors')); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Test that allowNullableNulls can be explicitly overridden to false + */ + public function testAllowNullableNullsCanBeOverridden(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add(new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => false, + ])); + $this->assertFalse($table->save($entity)); + } + + /** + * Test with all foreign keys set + */ + public function testAllKeysSet(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 1, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add(new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors')); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Test with invalid foreign key + */ + public function testInvalidKey(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add( + new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors'), + '_existsIn', + ['errorField' => 'author_id', 'message' => 'will error'], + ); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'will error']], $entity->getErrors()); + } + + /** + * Test with all invalid foreign keys + */ + public function testInvalidKeys(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 99999999, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add( + new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors'), + '_existsIn', + ['errorField' => 'author_id', 'message' => 'will error'], + ); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'will error']], $entity->getErrors()); + } + + /** + * Test with saveMany + */ + public function testSaveMany(): void + { + $entities = [ + new Entity([ + 'id' => 1, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]), + new Entity([ + 'id' => 2, + 'author_id' => 1, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]), + ]; + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add(new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors', [ + 'message' => 'will error with array_combine warning', + ])); + /** @var iterable<\Cake\ORM\Entity> $result */ + $result = $table->saveMany($entities); + $this->assertCount(2, $result); + /** @var array<\Cake\ORM\Entity> $result */ + $result = iterator_to_array($result); + + $this->assertInstanceOf(Entity::class, $result[0]); + $this->assertEmpty($result[0]->getErrors()); + + $this->assertInstanceOf(Entity::class, $result[1]); + $this->assertEmpty($result[1]->getErrors()); + } + + /** + * Test using ExistsInNullable directly with table object + */ + public function testWithTableObject(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $authorsTable = $this->getTableLocator()->get('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add(new ExistsInNullable(['author_id', 'site_id'], $authorsTable)); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Test with custom message + */ + public function testCustomMessage(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add( + new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors'), + '_existsIn', + ['errorField' => 'author_id', 'message' => 'Custom error message'], + ); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'Custom error message']], $entity->getErrors()); + } + + /** + * Test using rulesChecker existsInNullable method + */ + public function testUsingRulesCheckerMethod(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + /** @var \Cake\ORM\RulesChecker $rules */ + $rules = $table->rulesChecker(); + + $rules->add($rules->existsInNullable(['author_id', 'site_id'], 'SiteAuthors')); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Test using rulesChecker existsInNullable method with custom message + */ + public function testUsingRulesCheckerMethodWithCustomMessage(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 1, + 'name' => 'New Site Article with invalid author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + /** @var \Cake\ORM\RulesChecker $rules */ + $rules = $table->rulesChecker(); + + $rules->add($rules->existsInNullable(['author_id', 'site_id'], 'SiteAuthors', 'Custom message via method')); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'Custom message via method']], $entity->getErrors()); + } + + /** + * Test that ExistsInNullable extends ExistsIn + */ + public function testExtendsExistsIn(): void + { + $rule = new ExistsInNullable(['author_id', 'site_id'], 'SiteAuthors'); + + $this->assertInstanceOf(ExistsIn::class, $rule); + } +} diff --git a/tests/TestCase/ORM/Rule/LinkConstraintTest.php b/tests/TestCase/ORM/Rule/LinkConstraintTest.php new file mode 100644 index 00000000000..416889b8861 --- /dev/null +++ b/tests/TestCase/ORM/Rule/LinkConstraintTest.php @@ -0,0 +1,1004 @@ +getTableLocator()->clear(); + } + + /** + * Data provider for invalid constructor argument. + * + * @return array + */ + public static function invalidConstructorArgumentOneDataProvider(): array + { + return [[null, 'null'], [1, 'int'], [[], 'array'], [new stdClass(), 'stdClass']]; + } + + /** + * Tests that an exception is thrown when passing an invalid value for the `$requiredLinkStatus` argument. + */ + public function testInvalidConstructorArgumentTwo(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument 2 is expected to match one of the `\Cake\ORM\Rule\LinkConstraint::STATUS_*` constants.'); + + new LinkConstraint('Association', 'invalid'); + } + + /** + * Tests that an exception is thrown when an association with the given name doesn't exist. + */ + public function testNonExistentAssociation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `NonExistent` association is not defined on `Articles`.'); + + $Articles = $this->getTableLocator()->get('Articles'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('NonExistent', LinkConstraint::STATUS_NOT_LINKED), + ); + + $article = $Articles->get(1); + $Articles->delete($article); + } + + /** + * Tests that an exception is thrown when the checked entity doesn't contain all primary key values. + */ + public function testMissingPrimaryKeyValues(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage( + 'LinkConstraint rule on `Articles` requires all primary key values for building the counting ' . + 'conditions, expected values for `(id, nonexistent)`, got `(1, )`.', + ); + + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + $Articles->getEventManager()->on('Model.beforeRules', function (Event $event): void { + $event->getSubject()->setPrimaryKey(['id', 'nonexistent']); + }); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + ); + + $article = $Articles->get(1); + $Articles->delete($article); + } + + /** + * Tests that an exception is thrown when the number of the extracted primary keys in the check entity doesn't + * match the required number of primary key parts. + */ + public function testNonMatchingKeyFields(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The number of fields is expected to match the number of values, got 0 field(s) and 1 value(s).', + ); + + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments')->setForeignKey(['id', 'article_id']); + + /** @var \Cake\ORM\Rule\LinkConstraint&\Mockery\MockInterface $ruleMock */ + $ruleMock = Mockery::mock(LinkConstraint::class, ['Comments', LinkConstraint::STATUS_NOT_LINKED]) + ->makePartial(); + $ruleMock + ->shouldAllowMockingProtectedMethods() + ->shouldReceive('_aliasFields') + ->once() + ->andReturn([]); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete($ruleMock); + + $article = $Articles->get(1); + $Articles->delete($article); + } + + /** + * Data provider for invalid `repository` option. + * + * @return array + */ + public static function invalidRepositoryOptionsDataProvider(): array + { + return [ + [['repository' => null]], + [['repository' => new stdClass()]], + [[]], + ]; + } + + /** + * Tests that an exception is thrown when the `repository` option holds an invalid value. + * + * @param mixed $options + */ + #[DataProvider('invalidRepositoryOptionsDataProvider')] + public function testInvalidRepository($options): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Argument 2 is expected to have a `repository` key that holds an instance of `\Cake\ORM\Table`'); + + $rulesChecker = new RulesChecker($options); + + $Articles = Mockery::mock( + ArticlesTable::class . '[buildRules]', + [[ + 'alias' => 'Articles', + 'table' => 'articles', + ]], + ); + $Articles->shouldReceive('buildRules') + ->between(1, 2) + ->andReturn($rulesChecker); + + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + ); + $Articles->buildRules($rulesChecker); + + $article = $Articles->get(1); + $Articles->delete($article); + } + + /** + * Tests that the rule succeeds when a required `belongsTo` link exists. + */ + public function testMustBeLinkedViaBelongsToIsLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Articles', LinkConstraint::STATUS_LINKED), + ); + + $comment = $Comments->get(1); + $comment->setDirty('comment', true); + $this->assertNotFalse($Comments->save($comment)); + $this->assertEmpty($comment->getErrors()); + } + + /** + * Tests that the rule fails when a required `belongsTo` link does not exist. + */ + public function testMustBeLinkedViaBelongsToIsNotLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Articles', LinkConstraint::STATUS_LINKED), + '_isLinkedTo', + [ + 'errorField' => 'article', + ], + ); + + $comment = $Comments->get(7); + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that the rule succeeds when a required `belongsToMany` link exists. + */ + public function testMustBeLinkedViaBelongsToManyToIsLinked(): void + { + $Tags = $this->getTableLocator()->get('Tags'); + + $rulesChecker = $Tags->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Articles', LinkConstraint::STATUS_LINKED), + ); + + $tag = $Tags->get(1); + $tag->setDirty('name', true); + $this->assertNotFalse($Tags->save($tag)); + $this->assertEmpty($tag->getErrors()); + } + + /** + * Tests that the rule fails when a required `belongsToMany` link does not exist. + */ + public function testMustBeLinkedViaBelongsToManyIsNotLinked(): void + { + $Tags = $this->getTableLocator()->get('Tags'); + + $Tags->save($Tags->newEntity([ + 'name' => 'Orphaned Tag', + ])); + + $rulesChecker = $Tags->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Articles', LinkConstraint::STATUS_LINKED), + '_isLinkedTo', + [ + 'errorField' => 'articles', + ], + ); + + $tag = $Tags->get(4); + $tag->setDirty('name', true); + $this->assertFalse($Tags->save($tag)); + + $expected = [ + 'articles' => [ + '_isLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $tag->getErrors()); + } + + /** + * Tests that the rule succeeds when a required `hasMany` link exists. + */ + public function testMustBeLinkedViaHasManyIsLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Comments', LinkConstraint::STATUS_LINKED), + ); + + $article = $Articles->get(1); + $article->setDirty('comment', true); + $this->assertNotFalse($Articles->save($article)); + $this->assertEmpty($article->getErrors()); + } + + /** + * Tests that the rule fails when a required `hasMany` link does not exist. + */ + public function testMustBeLinkedViaHasManyIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Comments', LinkConstraint::STATUS_LINKED), + '_isLinkedTo', + [ + 'errorField' => 'comments', + ], + ); + + $article = $Articles->get(3); + $article->setDirty('comment', true); + $this->assertFalse($Articles->save($article)); + + $expected = [ + 'comments' => [ + '_isLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the rule succeeds when a required `hasOne` link exists. + */ + public function testMustBeLinkedViaHasOneIsLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Comments', LinkConstraint::STATUS_LINKED), + ); + + $article = $Articles->get(1); + $article->setDirty('title', true); + $this->assertNotFalse($Articles->save($article)); + $this->assertEmpty($article->getErrors()); + } + + /** + * Tests that the rule fails when a required `hasOne` link does not exist. + */ + public function testMustBeLinkedViaHasOneIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint('Comments', LinkConstraint::STATUS_LINKED), + '_isLinkedTo', + [ + 'errorField' => 'comment', + ], + ); + + $article = $Articles->get(3); + $article->setDirty('title', true); + $this->assertFalse($Articles->save($article)); + + $expected = [ + 'comment' => [ + '_isLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the rule succeeds when a prohibited `belongsTo` link does not exist. + */ + public function testMustNotBeLinkedViaBelongsToIsNotLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Articles', LinkConstraint::STATUS_NOT_LINKED), + ); + + $comment = $Comments->get(7); + $this->assertTrue($Comments->delete($comment)); + $this->assertEmpty($comment->getErrors()); + } + + /** + * Tests that the rule fails when a prohibited `belongsTo` link exists. + */ + public function testMustNotBeLinkedViaBelongsToIsLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Articles', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'article', + ], + ); + + $comment = $Comments->get(1); + $this->assertFalse($Comments->delete($comment)); + + $expected = [ + 'article' => [ + '_isNotLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that the rule succeeds when a prohibited `belongsToMany` link does not exist. + */ + public function testMustNotBeLinkedViaBelongsToManyIsNotLinked(): void + { + $Tags = $this->getTableLocator()->get('Tags'); + + $Tags->save($Tags->newEntity([ + 'name' => 'Orphaned Tag', + ])); + + $rulesChecker = $Tags->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Articles', LinkConstraint::STATUS_NOT_LINKED), + ); + + $tag = $Tags->get(4); + $this->assertTrue($Tags->delete($tag)); + $this->assertEmpty($tag->getErrors()); + } + + /** + * Tests that the rule fails when a prohibited `belongsToMany` link exists. + */ + public function testMustNotBeLinkedViaBelongsToManyIsLinked(): void + { + $Tags = $this->getTableLocator()->get('Tags'); + + $rulesChecker = $Tags->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Articles', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'articles', + ], + ); + + $tag = $Tags->get(1); + $this->assertFalse($Tags->delete($tag)); + + $expected = [ + 'articles' => [ + '_isNotLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $tag->getErrors()); + } + + /** + * Tests that the rule succeeds when a prohibited `hasMany` link does not exist. + */ + public function testMustNotBeLinkedViaHasManyIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + ); + + $article = $Articles->get(3); + $this->assertTrue($Articles->delete($article)); + $this->assertEmpty($article->getErrors()); + } + + /** + * Tests that the rule fails when a prohibited `hasMany` link exists. + */ + public function testMustNotBeLinkedViaHasManyIsLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'comments', + ], + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comments' => [ + '_isNotLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the rule succeeds when a prohibited `hasOne` link does not exist. + */ + public function testMustNotBeLinkedViaHasOneIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + ); + + $article = $Articles->get(3); + $this->assertTrue($Articles->delete($article)); + $this->assertEmpty($article->getErrors()); + } + + /** + * Tests that the rule fails when a prohibited `hasOne` link exists. + */ + public function testMustNotBeLinkedViaHasOneIsLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments'); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'comment', + ], + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comment' => [ + '_isNotLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that using associations with disabled foreign keys and expression conditions works. + */ + public function testDisabledForeignKeyAndSubQueryConditionsWithMustNotBeLinkedIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments', [ + 'foreignKey' => false, + 'conditions' => function (QueryExpression $exp, SelectQuery $query) { + $connection = $query->getConnection(); + $subQuery = $connection + ->selectQuery(['RecentComments.id']) + ->from(['RecentComments' => 'comments']) + ->where(function (QueryExpression $exp) { + return $exp->eq( + new IdentifierExpression('Articles.id'), + new IdentifierExpression('RecentComments.article_id'), + ); + }) + ->orderBy(['RecentComments.created' => 'DESC']) + ->limit(1); + + return $exp->add(['Comments.id' => $subQuery]); + }, + ]); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + ); + + $article = $Articles->get(3); + $this->assertTrue($Articles->delete($article)); + $this->assertEmpty($article->getErrors()); + } + + /** + * Tests that using associations with disabled foreign keys and expression conditions works. + */ + public function testDisabledForeignKeyAndSubQueryConditionsWithMustNotBeLinkedIsLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments', [ + 'foreignKey' => false, + 'conditions' => function (QueryExpression $exp, SelectQuery $query) { + $connection = $query->getConnection(); + $subQuery = $connection + ->selectQuery(['RecentComments.id']) + ->from(['RecentComments' => 'comments']) + ->where(function (QueryExpression $exp) { + return $exp->eq( + new IdentifierExpression('Articles.id'), + new IdentifierExpression('RecentComments.article_id'), + ); + }) + ->orderBy(['RecentComments.created' => 'DESC']) + ->limit(1); + + return $exp->add(['Comments.id' => $subQuery]); + }, + ]); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'comment', + ], + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comment' => [ + '_isNotLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that using associations with array conditions works. + */ + public function testConditionsWithMustNotBeLinkedIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments', [ + 'conditions' => [ + 'Comments.published' => 'N', + ], + ]); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + ); + + $article = $Articles->get(2); + $this->assertTrue($Articles->delete($article)); + $this->assertEmpty($article->getErrors()); + } + + /** + * Tests that using associations with array conditions works. + */ + public function testConditionsWithMustNotBeLinkedIsLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments', [ + 'conditions' => [ + 'Comments.published' => 'Y', + ], + ]); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'comments', + ], + ); + + $article = $Articles->get(2); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comments' => [ + '_isNotLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that using associations with conditions that are referencing the main table works. + */ + public function testConditionsReferencingParentColumnWithMustNotBeLinkedIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments', [ + 'conditions' => function (QueryExpression $exp) { + return $exp->notEq( + new IdentifierExpression('Comments.published'), + new IdentifierExpression('Articles.published'), + ); + }, + ]); + + $article = $Articles->save($Articles->newEntity([ + 'user_id' => 1, + 'body' => 'Some Text', + 'published' => 'N', + 'comment' => [ + 'user_id' => 1, + 'comment' => 'Some Comment', + 'published' => 'N', + ], + ])); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + ); + + $article = $Articles->get($article->id); + $this->assertTrue($Articles->delete($article)); + $this->assertEmpty($article->getErrors()); + } + + /** + * Tests that using associations with conditions that are referencing the main table works. + */ + public function testConditionsReferencingParentColumnWithMustNotBeLinkedIsLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasOne('Comments', [ + 'conditions' => function (QueryExpression $exp) { + return $exp->eq( + new IdentifierExpression('Comments.published'), + new IdentifierExpression('Articles.published'), + ); + }, + ]); + + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Comments', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'comment', + ], + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comment' => [ + '_isNotLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that using associations with custom finders works. + */ + public function testFinderWithMustNotBeLinkedIsNotLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles', [ + 'finder' => 'published', + ]); + + $comment = $Comments->save($Comments->newEntity([ + 'user_id' => 1, + 'comment' => 'Some Comment', + 'published' => 'Y', + 'article' => [ + 'user_id' => 1, + 'body' => 'Some Text', + 'published' => 'N', + ], + ])); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Articles', LinkConstraint::STATUS_NOT_LINKED), + ); + + $comment = $Comments->get($comment->id); + $this->assertTrue($Comments->delete($comment)); + $this->assertEmpty($comment->getErrors()); + } + + /** + * Tests that using associations with custom finders works. + */ + public function testFinderWithMustNotBeLinkedIsLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles', [ + 'finder' => 'published', + ]); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Articles', LinkConstraint::STATUS_NOT_LINKED), + '_isLinkedTo', + [ + 'errorField' => 'article', + ], + ); + + $comment = $Comments->get(1); + $this->assertFalse($Comments->delete($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that using association instances works. + */ + public function testAssociationInstanceWithMustBeLinkedIsLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint($Comments->getAssociation('Articles'), LinkConstraint::STATUS_LINKED), + ); + + $comment = $Comments->get(1); + $comment->setDirty('comment', true); + $this->assertNotFalse($Comments->save($comment)); + $this->assertEmpty($comment->getErrors()); + } + + /** + * Tests that using association instances works. + */ + public function testAssociationInstanceWithMustBeLinkedIsNotLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + new LinkConstraint($Comments->getAssociation('Articles'), LinkConstraint::STATUS_LINKED), + '_isLinkedTo', + [ + 'errorField' => 'article', + ], + ); + + $comment = $Comments->get(7); + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'invalid', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests implicit delete operations on `hasMany` associations. + */ + public function testImplicitHasManyDeleteErrors(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles + ->hasMany('Comments') + ->setDependent(true) + ->setCascadeCallbacks(true) + ->setSaveStrategy(HasMany::SAVE_REPLACE); + $Articles + ->getAssociation('Comments') + ->hasMany('Attachments'); + + $rulesChecker = $Articles->getAssociation('Comments')->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Attachments', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'attachments', + ], + ); + + $article = $Articles->get(2); + $article->set('comments', [ + $Articles->getAssociation('Comments')->newEntity([ + 'user_id' => 1, + 'comment' => 'New Comment', + ]), + ]); + $article->setDirty('comments', true); + $this->assertFalse($Articles->save($article)); + $this->assertEmpty( + $article->getErrors(), + 'This should not be empty, but currently is because unlink errors are not being returned.', + ); + + $this->markTestIncomplete('This test is incomplete because currently unlink errors are not being returned.'); + } + + /** + * Tests implicit delete operations on `belongsToMany` junction associations. + */ + public function testImplicitBelongsToManyJunctionDeleteErrors(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + + $rulesChecker = $Articles->getAssociation('Tags')->junction()->rulesChecker(); + $rulesChecker->addDelete( + new LinkConstraint('Articles', LinkConstraint::STATUS_NOT_LINKED), + '_isNotLinkedTo', + [ + 'errorField' => 'articles', + ], + ); + + $article = $Articles->get(1); + $article->set('tags', [ + $Articles->getAssociation('Tags')->newEntity([ + 'name' => 'New Tag', + 'description' => 'New Tag', + ]), + ]); + $article->setDirty('tags', true); + $this->assertFalse($Articles->save($article)); + $this->assertEmpty( + $article->getErrors(), + 'This should not be empty, but currently is because junction delete errors are not being returned.', + ); + + $this->markTestIncomplete( + 'This test is incomplete because currently junction delete errors are not returned.', + ); + } +} diff --git a/tests/TestCase/ORM/RulesCheckerIntegrationTest.php b/tests/TestCase/ORM/RulesCheckerIntegrationTest.php new file mode 100644 index 00000000000..901dd4cfeb5 --- /dev/null +++ b/tests/TestCase/ORM/RulesCheckerIntegrationTest.php @@ -0,0 +1,1878 @@ + + */ + protected array $fixtures = [ + 'core.Articles', 'core.Tags', 'core.ArticlesTags', 'core.Authors', 'core.Comments', + 'core.SpecialTags', 'core.Categories', 'core.SiteArticles', 'core.SiteAuthors', + 'core.UniqueAuthors', + ]; + + /** + * Tests saving belongsTo association and get a validation error + */ + public function testSaveBelongsToWithValidationError(): void + { + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + $entity->author = new Entity([ + 'name' => 'Jose', + ]); + + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + $table->getAssociation('authors') + ->getTarget() + ->rulesChecker() + ->add( + function (Entity $author, array $options) use ($table) { + $this->assertSame($options['repository'], $table->getAssociation('authors')->getTarget()); + + return false; + }, + ['errorField' => 'name', 'message' => 'This is an error'], + ); + + $this->assertFalse($table->save($entity)); + $this->assertTrue($entity->isNew()); + $this->assertTrue($entity->author->isNew()); + $this->assertNull($entity->get('author_id')); + $this->assertNotEmpty($entity->author->getError('name')); + $this->assertEquals(['This is an error'], $entity->author->getError('name')); + } + + /** + * Tests saving hasOne association and returning a validation error will + * abort the saving process + */ + public function testSaveHasOneWithValidationError(): void + { + $entity = new Entity([ + 'name' => 'Jose', + ]); + $entity->article = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + + $table = $this->getTableLocator()->get('authors'); + $table->hasOne('articles'); + $table->getAssociation('articles') + ->getTarget() + ->rulesChecker() + ->add( + function (EntityInterface $entity) { + return false; + }, + ['errorField' => 'title', 'message' => 'This is an error'], + ); + + $this->assertFalse($table->save($entity)); + $this->assertTrue($entity->isNew()); + $this->assertTrue($entity->article->isNew()); + $this->assertNull($entity->article->id); + $this->assertNull($entity->article->get('author_id')); + $this->assertFalse($entity->article->isDirty('author_id')); + $this->assertNotEmpty($entity->article->getError('title')); + $this->assertSame('A Title', $entity->article->getInvalidField('title')); + } + + /** + * Tests saving multiple entities in a hasMany association and getting and + * error while saving one of them. It should abort all the save operation + * when options are set to defaults + */ + public function testSaveHasManyWithErrorsAtomic(): void + { + $entity = new Entity([ + 'name' => 'Jose', + ]); + $entity->articles = [ + new Entity([ + 'title' => '1', + 'body' => 'A body', + ]), + new Entity([ + 'title' => 'Another Title', + 'body' => 'Another body', + ]), + ]; + + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $table->getAssociation('articles') + ->getTarget() + ->rulesChecker() + ->add( + function (Entity $entity, $options) use ($table) { + $this->assertSame($table, $options['_sourceTable']); + + return $entity->title === '1'; + }, + ['errorField' => 'title', 'message' => 'This is an error'], + ); + + $this->assertFalse($table->save($entity)); + $this->assertTrue($entity->isNew()); + $this->assertTrue($entity->articles[0]->isNew()); + $this->assertTrue($entity->articles[1]->isNew()); + $this->assertNull($entity->articles[0]->id); + $this->assertNull($entity->articles[1]->id); + $this->assertNull($entity->articles[0]->author_id); + $this->assertNull($entity->articles[1]->author_id); + $this->assertEmpty($entity->articles[0]->getErrors()); + $this->assertNotEmpty($entity->articles[1]->getErrors()); + } + + /** + * Tests that it is possible to continue saving hasMany associations + * even if any of the records fail validation when atomic is set + * to false + */ + public function testSaveHasManyWithErrorsNonAtomic(): void + { + $entity = new Entity([ + 'name' => 'Jose', + ]); + $entity->articles = [ + new Entity([ + 'title' => 'A title', + 'body' => 'A body', + ]), + new Entity([ + 'title' => '1', + 'body' => 'Another body', + ]), + ]; + + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $table->getAssociation('articles') + ->getTarget() + ->rulesChecker() + ->add( + function (Entity $article) { + return is_numeric($article->title); + }, + ['errorField' => 'title', 'message' => 'This is an error'], + ); + + $result = $table->save($entity, ['atomic' => false]); + $this->assertSame($entity, $result); + $this->assertFalse($entity->isNew()); + $this->assertTrue($entity->articles[0]->isNew()); + $this->assertFalse($entity->articles[1]->isNew()); + $this->assertSame(4, $entity->articles[1]->id); + $this->assertNull($entity->articles[0]->id); + $this->assertNotEmpty($entity->articles[0]->getError('title')); + } + + /** + * Tests saving belongsToMany records with a validation error in a joint entity + */ + public function testSaveBelongsToManyWithValidationErrorInJointEntity(): void + { + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + $entity->tags = [ + new Entity([ + 'name' => 'Something New', + ]), + new Entity([ + 'name' => '100', + ]), + ]; + $table = $this->getTableLocator()->get('articles'); + $table->belongsToMany('tags'); + $table->getAssociation('tags') + ->junction() + ->rulesChecker() + ->add(function (Entity $entity) { + return $entity->article_id > 4; + }); + + $this->assertFalse($table->save($entity)); + $this->assertTrue($entity->isNew()); + $this->assertTrue($entity->tags[0]->isNew()); + $this->assertTrue($entity->tags[1]->isNew()); + $this->assertNull($entity->tags[0]->id); + $this->assertNull($entity->tags[1]->id); + $this->assertNull($entity->tags[0]->_joinData); + $this->assertNull($entity->tags[1]->_joinData); + } + + /** + * Tests saving belongsToMany records with a validation error in a joint entity + * and atomic set to false + */ + public function testSaveBelongsToManyWithValidationErrorInJointEntityNonAtomic(): void + { + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + $entity->tags = [ + new Entity([ + 'name' => 'Something New', + ]), + new Entity([ + 'name' => 'New one', + ]), + ]; + $table = $this->getTableLocator()->get('articles'); + $table->belongsToMany('tags'); + $table->getAssociation('tags') + ->junction() + ->rulesChecker() + ->add(function (Entity $entity) { + return $entity->tag_id > 4; + }); + + $this->assertSame($entity, $table->save($entity, ['atomic' => false])); + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->tags[0]->isNew()); + $this->assertFalse($entity->tags[1]->isNew()); + $this->assertSame(4, $entity->tags[0]->id); + $this->assertSame(5, $entity->tags[1]->id); + $this->assertTrue($entity->tags[0]->_joinData->isNew()); + $this->assertSame(4, $entity->tags[1]->_joinData->article_id); + $this->assertSame(5, $entity->tags[1]->_joinData->tag_id); + } + + /** + * Test adding rule with name + */ + public function testAddingRuleWithName(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $table = $this->getTableLocator()->get('Authors'); + $rules = $table->rulesChecker(); + $rules->add( + function () { + return false; + }, + 'ruleName', + ['errorField' => 'name'], + ); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['ruleName' => 'invalid'], $entity->getError('name')); + } + + /** + * Ensure that add(isUnique()) only invokes a rule once. + */ + public function testIsUniqueRuleSingleInvocation(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $table = $this->getTableLocator()->get('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->isUnique(['name']), '_isUnique', ['errorField' => 'title']); + $this->assertFalse($table->save($entity)); + + $this->assertEquals( + ['_isUnique' => 'This value is already in use'], + $entity->getError('title'), + 'Provided field should have errors', + ); + $this->assertEmpty($entity->getError('name'), 'Errors should not apply to original field.'); + } + + /** + * Tests the isUnique domain rule + */ + public function testIsUniqueDomainRule(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $table = $this->getTableLocator()->get('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->isUnique(['name'])); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['_isUnique' => 'This value is already in use'], $entity->getError('name')); + + $entity->name = 'jose'; + $this->assertSame($entity, $table->save($entity)); + + $entity = $table->get(1); + $entity->setDirty('name', true); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests isUnique with multiple fields + */ + public function testIsUniqueMultipleFields(): void + { + $entity = new Entity([ + 'author_id' => 1, + 'title' => 'First Article', + ]); + + $table = $this->getTableLocator()->get('Articles'); + $rules = $table->rulesChecker(); + $rules->add($rules->isUnique(['title', 'author_id'], 'Nope')); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['title' => ['_isUnique' => 'Nope']], $entity->getErrors()); + + $entity->clean(); + $entity->author_id = 2; + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests isUnique with non-unique null values + */ + public function testIsUniqueNonUniqueNulls(): void + { + $table = $this->getTableLocator()->get('UniqueAuthors'); + $rules = $table->rulesChecker(); + $rules->add($rules->isUnique( + ['first_author_id', 'second_author_id'], + ['allowMultipleNulls' => false], + )); + + $entity = new Entity([ + 'first_author_id' => null, + 'second_author_id' => 1, + ]); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['first_author_id' => ['_isUnique' => 'This value is already in use']], $entity->getErrors()); + } + + /** + * Tests isUnique with allowMultipleNulls + */ + public function testIsUniqueAllowMultipleNulls(): void + { + $this->skipIf(ConnectionManager::get('test')->getDriver() instanceof Sqlserver); + + $table = $this->getTableLocator()->get('UniqueAuthors'); + $rules = $table->rulesChecker(); + $rules->add($rules->isUnique( + ['first_author_id', 'second_author_id'], + )); + + $entity = new Entity([ + 'first_author_id' => null, + 'second_author_id' => 1, + ]); + $this->assertNotEmpty($table->save($entity)); + + $entity->first_author_id = 2; + $this->assertSame($entity, $table->save($entity)); + + $entity = new Entity([ + 'first_author_id' => 2, + 'second_author_id' => 1, + ]); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['first_author_id' => ['_isUnique' => 'This value is already in use']], $entity->getErrors()); + } + + /** + * Tests the existsIn domain rule + */ + public function testExistsInDomainRule(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', 'Authors')); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['_existsIn' => 'This value does not exist'], $entity->getError('author_id')); + } + + /** + * Ensure that add(existsIn()) only invokes a rule once. + */ + public function testExistsInRuleSingleInvocation(): void + { + $entity = new Entity([ + 'title' => 'larry', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', 'Authors'), '_existsIn', ['errorField' => 'other']); + $this->assertFalse($table->save($entity)); + + $this->assertEquals( + ['_existsIn' => 'This value does not exist'], + $entity->getError('other'), + 'Provided field should have errors', + ); + $this->assertEmpty($entity->getError('author_id'), 'Errors should not apply to original field.'); + } + + /** + * Tests the existsIn domain rule when passing an object + */ + public function testExistsInDomainRuleWithObject(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', $this->getTableLocator()->get('Authors'), 'Nope')); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['_existsIn' => 'Nope'], $entity->getError('author_id')); + } + + /** + * ExistsIn uses the schema to verify that nullable fields are ok. + */ + public function testExistsInNullValue(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => null, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', 'Authors')); + + $this->assertEquals($entity, $table->save($entity)); + $this->assertEquals([], $entity->getError('author_id')); + } + + /** + * Test ExistsIn on a new entity that doesn't have the field populated. + * + * This use case is important for saving records and their + * associated belongsTo records in one pass. + */ + public function testExistsInNotNullValueNewEntity(): void + { + $entity = new Entity([ + 'name' => 'A Category', + ]); + $table = $this->getTableLocator()->get('Categories'); + $table->belongsTo('Categories', [ + 'foreignKey' => 'parent_id', + 'bindingKey' => 'id', + ]); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('parent_id', 'Categories')); + $this->assertTrue($table->checkRules($entity, RulesChecker::CREATE)); + $this->assertEmpty($entity->getError('parent_id')); + } + + /** + * Tests exists in uses the bindingKey of the association + */ + public function testExistsInWithBindingKey(): void + { + $entity = new Entity([ + 'title' => 'An Article', + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors', [ + 'bindingKey' => 'name', + 'foreignKey' => 'title', + ]); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('title', 'Authors')); + + $this->assertFalse($table->save($entity)); + $this->assertNotEmpty($entity->getError('title')); + + $entity->clean(); + $entity->title = 'larry'; + $this->assertEquals($entity, $table->save($entity)); + } + + /** + * Tests existsIn with invalid associations + */ + public function testExistsInInvalidAssociation(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('ExistsIn rule for `author_id` is invalid. `NotValid` is not associated with `Cake\ORM\Table`.'); + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', 'NotValid')); + + $table->save($entity); + } + + /** + * Tests existsIn does not prevent new entities from saving if parent entity is new + */ + public function testExistsInHasManyNewEntities(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->Comments->belongsTo('Articles'); + + $rules = $table->Comments->rulesChecker(); + $rules->add($rules->existsIn(['article_id'], $table)); + + $article = $table->newEntity([ + 'title' => 'new article', + 'comments' => [ + $table->Comments->newEntity([ + 'user_id' => 1, + 'comment' => 'comment 1', + ]), + $table->Comments->newEntity([ + 'user_id' => 1, + 'comment' => 'comment 2', + ]), + ], + ]); + + $this->assertNotFalse($table->save($article)); + } + + /** + * Tests existsIn does not prevent new entities from saving if parent entity is new, + * getting the parent entity from the association + */ + public function testExistsInHasManyNewEntitiesViaAssociation(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments'); + $table->Comments->belongsTo('Articles'); + + $rules = $table->Comments->rulesChecker(); + $rules->add($rules->existsIn(['article_id'], 'Articles')); + + $article = $table->newEntity([ + 'title' => 'test', + ]); + + $article->comments = [ + $table->Comments->newEntity([ + 'user_id' => 1, + 'comment' => 'test', + ]), + ]; + + $this->assertNotFalse($table->save($article)); + } + + /** + * Tests the checkRules save option + */ + public function testSkipRulesChecking(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', $this->getTableLocator()->get('Authors'), 'Nope')); + + $this->assertSame($entity, $table->save($entity, ['checkRules' => false])); + } + + /** + * Tests the beforeRules event + */ + public function testUseBeforeRules(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', $this->getTableLocator()->get('Authors'), 'Nope')); + + $table->getEventManager()->on( + 'Model.beforeRules', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options, $operation): void { + $this->assertEquals( + [ + 'atomic' => true, + 'associated' => true, + 'checkRules' => true, + 'checkExisting' => true, + '_primary' => true, + '_cleanOnSuccess' => true, + ], + $options->getArrayCopy(), + ); + $this->assertSame('create', $operation); + $event->stopPropagation(); + + $event->setResult(true); + }, + ); + + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests the afterRules event + */ + public function testUseAfterRules(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', $this->getTableLocator()->get('Authors'), 'Nope')); + + $table->getEventManager()->on( + 'Model.afterRules', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options, $result, $operation): void { + $this->assertEquals( + [ + 'atomic' => true, + 'associated' => true, + 'checkRules' => true, + 'checkExisting' => true, + '_primary' => true, + '_cleanOnSuccess' => true, + ], + $options->getArrayCopy(), + ); + $this->assertSame('create', $operation); + $this->assertFalse($result); + $event->stopPropagation(); + + $event->setResult(true); + }, + ); + + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests that rules can be changed using the buildRules event + */ + public function testUseBuildRulesEvent(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->getEventManager()->on('Model.buildRules', function (EventInterface $event, RulesChecker $rules): void { + $rules->add($rules->existsIn('author_id', $this->getTableLocator()->get('Authors'), 'Nope')); + }); + + $this->assertFalse($table->save($entity)); + } + + /** + * Tests isUnique with untouched fields + */ + public function testIsUniqueWithCleanFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $entity = $table->get(1); + $rules = $table->rulesChecker(); + $rules->add($rules->isUnique(['title', 'author_id'], 'Nope')); + + $entity->body = 'Foo'; + $this->assertSame($entity, $table->save($entity)); + + $entity->title = 'Third Article'; + $this->assertFalse($table->save($entity)); + } + + /** + * Tests isUnique rule with conflicting columns + */ + public function testIsUniqueAliasPrefix(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 1, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->isUnique(['author_id'])); + + $table->Authors->getEventManager()->on('Model.beforeFind', function (EventInterface $event, $query): void { + $query->leftJoin(['a2' => 'authors']); + }); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['_isUnique' => 'This value is already in use'], $entity->getError('author_id')); + } + + /** + * Tests the existsIn rule when passing non dirty fields + */ + public function testExistsInWithCleanFields(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', 'Authors')); + + $entity = $table->get(1); + $entity->title = 'Foo'; + $entity->author_id = 1000; + $entity->setDirty('author_id', false); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests the existsIn with conflicting columns + */ + public function testExistsInAliasPrefix(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', 'Authors')); + + $table->Authors->getEventManager()->on('Model.beforeFind', function (EventInterface $event, $query): void { + $query->leftJoin(['a2' => 'authors']); + }); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['_existsIn' => 'This value does not exist'], $entity->getError('author_id')); + } + + /** + * Tests that using an array in existsIn() sets the error message correctly + */ + public function testExistsInErrorWithArrayField(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 500, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors'); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn(['author_id'], 'Authors')); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['_existsIn' => 'This value does not exist'], $entity->getError('author_id')); + } + + /** + * Tests new allowNullableNulls flag with author id set to null + */ + public function testExistsInAllowNullableNullsOn(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => true, + ])); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Tests new allowNullableNulls flag with author id set to null + */ + public function testExistsInAllowNullableNullsOff(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => false, + ])); + $this->assertFalse($table->save($entity)); + } + + /** + * Tests new allowNullableNulls flag with author id set to null + */ + public function testExistsInAllowNullableNullsDefaultValue(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors')); + $this->assertFalse($table->save($entity)); + } + + /** + * Tests new allowNullableNulls flag with author id set to null + */ + public function testExistsInAllowNullableNullsCustomMessage(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => false, + 'message' => 'Niente', + ])); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'Niente']], $entity->getErrors()); + } + + /** + * Tests new allowNullableNulls flag with author id set to 1 + */ + public function testExistsInAllowNullableNullsOnAllKeysSet(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 1, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', ['allowNullableNulls' => true])); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Tests new allowNullableNulls flag with author id set to 1 + */ + public function testExistsInAllowNullableNullsOffAllKeysSet(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 1, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', ['allowNullableNulls' => false])); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Tests new allowNullableNulls flag with author id set to 1 + */ + public function testExistsInAllowNullableNullsOnAllKeysCustomMessage(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 1, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => true, + 'message' => 'will not error'])); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Tests new allowNullableNulls flag with author id set to 99999999 (does not exist) + */ + public function testExistsInAllowNullableNullsOnInvalidKey(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => true, + 'message' => 'will error'])); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'will error']], $entity->getErrors()); + } + + /** + * Tests new allowNullableNulls flag with author id set to 99999999 (does not exist) + * and site_id set to 99999999 (does not exist) + */ + public function testExistsInAllowNullableNullsOnInvalidKeys(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 99999999, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => true, + 'message' => 'will error'])); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'will error']], $entity->getErrors()); + } + + /** + * Tests new allowNullableNulls flag with author id set to 1 (does exist) + * and site_id set to 99999999 (does not exist) + */ + public function testExistsInAllowNullableNullsOnInvalidKeySecond(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 1, + 'site_id' => 99999999, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => true, + 'message' => 'will error'])); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'will error']], $entity->getErrors()); + } + + /** + * Tests new allowNullableNulls with saveMany + */ + public function testExistsInAllowNullableNullsSaveMany(): void + { + $entities = [ + new Entity([ + 'id' => 1, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]), + new Entity([ + 'id' => 2, + 'author_id' => 1, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]), + ]; + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', [ + 'allowNullableNulls' => true, + 'message' => 'will error with array_combine warning'])); + $result = $table->saveMany($entities); + $this->assertCount(2, $result); + + $this->assertInstanceOf(Entity::class, $result[0]); + $this->assertEmpty($result[0]->getErrors()); + + $this->assertInstanceOf(Entity::class, $result[1]); + $this->assertEmpty($result[1]->getErrors()); + } + + /** + * Tests existsInNullable helper method with null value + */ + public function testExistsInNullableMethod(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => null, + 'site_id' => 1, + 'name' => 'New Site Article without Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsInNullable(['author_id', 'site_id'], 'SiteAuthors')); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Tests existsInNullable helper method with valid values + */ + public function testExistsInNullableMethodWithValidValues(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 1, + 'site_id' => 1, + 'name' => 'New Site Article with Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsInNullable(['author_id', 'site_id'], 'SiteAuthors')); + $this->assertInstanceOf(Entity::class, $table->save($entity)); + } + + /** + * Tests existsInNullable helper method with invalid values + */ + public function testExistsInNullableMethodWithInvalidValues(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 1, + 'name' => 'New Site Article with Invalid Author', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsInNullable(['author_id', 'site_id'], 'SiteAuthors')); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'This value does not exist']], $entity->getErrors()); + } + + /** + * Tests existsInNullable helper method with custom message + */ + public function testExistsInNullableMethodWithCustomMessage(): void + { + $entity = new Entity([ + 'id' => 10, + 'author_id' => 99999999, + 'site_id' => 1, + 'name' => 'New Site Article', + ]); + $table = $this->getTableLocator()->get('SiteArticles'); + $table->belongsTo('SiteAuthors'); + $rules = $table->rulesChecker(); + + $rules->add($rules->existsInNullable(['author_id', 'site_id'], 'SiteAuthors', 'Custom nullable message')); + $this->assertFalse($table->save($entity)); + $this->assertEquals(['author_id' => ['_existsIn' => 'Custom nullable message']], $entity->getErrors()); + } + + /** + * Tests using rules to prevent delete operations + */ + public function testDeleteRules(): void + { + $table = $this->getTableLocator()->get('Articles'); + $rules = $table->rulesChecker(); + $rules->addDelete(function ($entity) { + return false; + }); + + $entity = $table->get(1); + $this->assertFalse($table->delete($entity)); + } + + /** + * Checks that it is possible to pass custom options to rules when saving + */ + public function testCustomOptionsPassingSave(): void + { + $entity = new Entity([ + 'name' => 'jose', + ]); + + $table = $this->getTableLocator()->get('Authors'); + $rules = $table->rulesChecker(); + $rules->add(function ($entity, $options) { + $this->assertSame('bar', $options['foo']); + $this->assertSame('option', $options['another']); + + return false; + }, ['another' => 'option']); + + $this->assertFalse($table->save($entity, ['foo' => 'bar'])); + } + + /** + * Tests passing custom options to rules from delete + */ + public function testCustomOptionsPassingDelete(): void + { + $table = $this->getTableLocator()->get('Articles'); + $rules = $table->rulesChecker(); + $rules->addDelete(function ($entity, $options) { + $this->assertSame('bar', $options['foo']); + $this->assertSame('option', $options['another']); + + return false; + }, ['another' => 'option']); + + $entity = $table->get(1); + $this->assertFalse($table->delete($entity, ['foo' => 'bar'])); + } + + /** + * Test adding rules that return error string + */ + public function testCustomErrorMessageFromRule(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $table = $this->getTableLocator()->get('Authors'); + $rules = $table->rulesChecker(); + $rules->add(function () { + return 'So much nope'; + }, ['errorField' => 'name']); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['So much nope'], $entity->getError('name')); + } + + /** + * Test adding rules with no errorField now sets errors under _rule key + */ + public function testCustomErrorMessageFromRuleNoErrorField(): void + { + $entity = new Entity([ + 'name' => 'larry', + ]); + + $table = $this->getTableLocator()->get('Authors'); + $rules = $table->rulesChecker(); + $rules->add(function () { + return 'So much nope'; + }); + + $this->assertFalse($table->save($entity)); + $this->assertNotEmpty($entity->getErrors()); + $this->assertEquals(['So much nope'], $entity->getError('_rule')); + } + + /** + * Tests that using existsIn for a hasMany association will not be called + * as the foreign key for the association was automatically validated already. + */ + public function testAvoidExistsInOnAutomaticSaving(): void + { + $entity = new Entity([ + 'name' => 'Jose', + ]); + $entity->articles = [ + new Entity([ + 'title' => '1', + 'body' => 'A body', + ]), + new Entity([ + 'title' => 'Another Title', + 'body' => 'Another body', + ]), + ]; + + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('articles'); + $table->getAssociation('articles')->belongsTo('authors'); + $checker = $table->getAssociation('articles')->getTarget()->rulesChecker(); + $checker->add(function ($entity, $options) use ($checker) { + $rule = $checker->existsIn('author_id', 'authors'); + $id = $entity->author_id; + $entity->author_id = 5000; + $result = $rule($entity, $options); + $this->assertTrue($result); + $entity->author_id = $id; + + return true; + }); + + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests the existsIn domain rule respects the conditions set for the associations + */ + public function testExistsInDomainRuleWithAssociationConditions(): void + { + $entity = new Entity([ + 'title' => 'An Article', + 'author_id' => 1, + ]); + + $table = $this->getTableLocator()->get('Articles'); + $table->belongsTo('Authors', [ + 'conditions' => ['Authors.name !=' => 'mariano'], + ]); + $rules = $table->rulesChecker(); + $rules->add($rules->existsIn('author_id', 'Authors')); + + $this->assertFalse($table->save($entity)); + $this->assertEquals(['_existsIn' => 'This value does not exist'], $entity->getError('author_id')); + } + + /** + * Tests that associated items have a count of X. + */ + public function testCountOfAssociatedItems(): void + { + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + $entity->tags = [ + new Entity([ + 'name' => 'Something New', + ]), + new Entity([ + 'name' => '100', + ]), + ]; + + $this->getTableLocator()->get('ArticlesTags'); + + $table = $this->getTableLocator()->get('articles'); + $table->belongsToMany('tags'); + + $rules = $table->rulesChecker(); + $rules->add($rules->validCount('tags', 3)); + + $this->assertFalse($table->save($entity)); + $this->assertEquals($entity->getErrors(), [ + 'tags' => [ + '_validCount' => 'The count does not match >3', + ], + ]); + + // Testing that undesired types fail + $entity->tags = null; + $this->assertFalse($table->save($entity)); + + $entity->tags = new stdClass(); + $this->assertFalse($table->save($entity)); + + $entity->tags = 'string'; + $this->assertFalse($table->save($entity)); + + $entity->tags = 123456; + $this->assertFalse($table->save($entity)); + + $entity->tags = 0.512; + $this->assertFalse($table->save($entity)); + } + + /** + * Tests that the error field name is inferred from the association name in case no name is provided. + */ + public function testIsLinkedToInferFieldFromAssociationName(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $comment = $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + $rulesChecker->isLinkedTo('Articles'), + ); + + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'Cannot modify row: a constraint for the `Articles` association fails.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that the error field name is inferred from the association name in case no name is provided. + */ + public function testIsNotLinkedToInferFieldFromAssociationName(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + $rulesChecker->isNotLinkedTo('Comments'), + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comments' => [ + '_isNotLinkedTo' => 'Cannot modify row: a constraint for the `Comments` association fails.', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the error field name is inferred from the association name in case no name is provided, + * and no repository is available at the time of creating the rule. + */ + public function testIsLinkedToInferFieldFromAssociationNameWithNoRepositoryAvailable(): void + { + $Comments = new class extends Table { + public function initialize(array $config): void + { + $this->setAlias('Comments'); + $this->setTable('comments'); + $this->belongsTo('Articles'); + } + + public function buildRules(RulesChecker $rules): RulesChecker + { + return $rules->addUpdate( + $rules->isLinkedTo('Articles'), + ['repository' => $this], + ); + } + }; + + $comment = $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'Cannot modify row: a constraint for the `Articles` association fails.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that the error field name is inferred from the association name in case no name is provided, + * and no repository is available at the time of creating the rule. + */ + public function testIsNotLinkedToInferFieldFromAssociationNameWithNoRepositoryAvailable(): void + { + $Articles = new class extends Table { + public function initialize(array $config): void + { + $this->setAlias('Articles'); + $this->setTable('articles'); + $this->hasMany('Comments'); + } + + public function buildRules(RulesChecker $rules): RulesChecker + { + return $rules->addDelete( + $rules->isNotLinkedTo('Comments'), + ['repository' => $this], + ); + } + }; + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comments' => [ + '_isNotLinkedTo' => 'Cannot modify row: a constraint for the `Comments` association fails.', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the error field name is inferred from the association object in case no name is provided. + */ + public function testIsLinkedToInferFieldFromAssociationObject(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $comment = $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + $rulesChecker->isLinkedTo($Comments->getAssociation('Articles')), + ); + + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'Cannot modify row: a constraint for the `Articles` association fails.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that the error field name is inferred from the association object in case no name is provided. + */ + public function testIsNotLinkedToInferFieldFromAssociationObject(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + $rulesChecker->isNotLinkedTo($Articles->getAssociation('Comments')), + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comments' => [ + '_isNotLinkedTo' => 'Cannot modify row: a constraint for the `Comments` association fails.', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the custom error field name is being used. + */ + public function testIsLinkedToWithCustomField(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $comment = $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + $rulesChecker->isLinkedTo('Articles', 'custom'), + ); + + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'custom' => [ + '_isLinkedTo' => 'Cannot modify row: a constraint for the `Articles` association fails.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that the custom error field name is being used. + */ + public function testIsNotLinkedToWithCustomField(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + $rulesChecker->isNotLinkedTo('Comments', 'custom'), + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'custom' => [ + '_isNotLinkedTo' => 'Cannot modify row: a constraint for the `Comments` association fails.', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the custom error message is being used. + */ + public function testIsLinkedToWithCustomMessage(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $comment = $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + $rulesChecker->isLinkedTo('Articles', 'article', 'custom'), + ); + + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'custom', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + } + + /** + * Tests that the custom error message is being used. + */ + public function testIsNotLinkedToWithCustomMessage(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + $rulesChecker->isNotLinkedTo('Comments', 'comments', 'custom'), + ); + + $article = $Articles->get(1); + $this->assertFalse($Articles->delete($article)); + + $expected = [ + 'comments' => [ + '_isNotLinkedTo' => 'custom', + ], + ]; + $this->assertEquals($expected, $article->getErrors()); + } + + /** + * Tests that the default error message can be translated. + */ + public function testIsLinkedToMessageWithI18n(): void + { + /** @var \Cake\I18n\Translator $translator */ + $translator = I18n::getTranslator('cake'); + + $messageId = 'Cannot modify row: a constraint for the `{0}` association fails.'; + $translator->getPackage()->addMessage( + $messageId, + 'Zeile kann nicht geändert werden: Eine Einschränkung für die "{0}" Beziehung schlägt fehl.', + ); + + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $comment = $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + + $rulesChecker->addUpdate( + $rulesChecker->isLinkedTo('Articles', 'article'), + ); + + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'Zeile kann nicht geändert werden: Eine Einschränkung für die "Articles" Beziehung schlägt fehl.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + + $translator->getPackage()->addMessage($messageId, ''); + } + + /** + * Tests that the default error message can be translated. + */ + public function testIsNotLinkedToMessageWithI18n(): void + { + /** @var \Cake\I18n\Translator $translator */ + $translator = I18n::getTranslator('cake'); + + $messageId = 'Cannot modify row: a constraint for the `{0}` association fails.'; + $translator->getPackage()->addMessage( + $messageId, + 'Zeile kann nicht geändert werden: Eine Einschränkung für die "{0}" Beziehung schlägt fehl.', + ); + + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + + $rulesChecker->addUpdate( + $rulesChecker->isNotLinkedTo('Articles', 'articles'), + ); + + $comment = $Comments->get(1); + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'articles' => [ + '_isNotLinkedTo' => 'Zeile kann nicht geändert werden: Eine Einschränkung für die "Articles" Beziehung schlägt fehl.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + + $translator->getPackage()->addMessage($messageId, ''); + } + + /** + * Tests that the default error message works without I18n. + */ + public function testIsLinkedToMessageWithoutI18n(): void + { + /** @var \Cake\I18n\Translator $translator */ + $translator = I18n::getTranslator('cake'); + + $messageId = 'Cannot modify row: a constraint for the `{0}` association fails.'; + $translator->getPackage()->addMessage( + $messageId, + 'translated', + ); + + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + $comment = $Comments->save($Comments->newEntity([ + 'article_id' => 9999, + 'user_id' => 1, + 'comment' => 'Orphaned Comment', + ])); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + + Closure::bind( + function () use ($rulesChecker): void { + $rulesChecker->{'_useI18n'} = false; + }, + null, + RulesChecker::class, + )(); + + $rulesChecker->addUpdate( + $rulesChecker->isLinkedTo('Articles', 'article'), + ); + + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'article' => [ + '_isLinkedTo' => 'Cannot modify row: a constraint for the `Articles` association fails.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + + $translator->getPackage()->addMessage($messageId, ''); + } + + /** + * Tests that the default error message works without I18n. + */ + public function testIsNotLinkedToMessageWithoutI18n(): void + { + /** @var \Cake\I18n\Translator $translator */ + $translator = I18n::getTranslator('cake'); + + $messageId = 'Cannot modify row: a constraint for the `{0}` association fails.'; + $translator->getPackage()->addMessage( + $messageId, + 'translated', + ); + + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + + Closure::bind( + function () use ($rulesChecker): void { + $rulesChecker->{'_useI18n'} = false; + }, + null, + RulesChecker::class, + )(); + + $rulesChecker->addUpdate( + $rulesChecker->isNotLinkedTo('Articles', 'articles'), + ); + + $comment = $Comments->get(1); + $comment->setDirty('comment', true); + $this->assertFalse($Comments->save($comment)); + + $expected = [ + 'articles' => [ + '_isNotLinkedTo' => 'Cannot modify row: a constraint for the `Articles` association fails.', + ], + ]; + $this->assertEquals($expected, $comment->getErrors()); + + $translator->getPackage()->addMessage($messageId, ''); + } + + /** + * Tests that the rule can pass. + */ + public function testIsLinkedToIsLinked(): void + { + $Comments = $this->getTableLocator()->get('Comments'); + $Comments->belongsTo('Articles'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Comments->rulesChecker(); + $rulesChecker->addUpdate( + $rulesChecker->isLinkedTo('Articles', 'articles'), + ); + + $comment = $Comments->get(1); + $comment->setDirty('comment', true); + $this->assertNotFalse($Comments->save($comment)); + } + + /** + * Tests that the rule can pass. + */ + public function testIsNotLinkedToIsNotLinked(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->hasMany('Comments'); + + /** @var \Cake\ORM\RulesChecker $rulesChecker */ + $rulesChecker = $Articles->rulesChecker(); + $rulesChecker->addDelete( + $rulesChecker->isNotLinkedTo('Comments', 'comments'), + ); + + $article = $Articles->get(3); + $this->assertTrue($Articles->delete($article)); + } +} diff --git a/tests/TestCase/ORM/TableComplexIdTest.php b/tests/TestCase/ORM/TableComplexIdTest.php new file mode 100644 index 00000000000..8eb237ce3ff --- /dev/null +++ b/tests/TestCase/ORM/TableComplexIdTest.php @@ -0,0 +1,109 @@ + + */ + protected array $fixtures = [ + 'core.DateKeys', + ]; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + static::setAppNamespace(); + } + + /** + * teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->getTableLocator()->clear(); + } + + /** + * Test saving new records sets uuids + */ + public function testSaveNew(): void + { + $now = new DateTime('now'); + $entity = new Entity([ + 'id' => $now, + 'title' => 'shiny new', + ]); + $table = $this->getTableLocator()->get('DateKeys'); + $this->assertSame($entity, $table->save($entity)); + $this->assertEquals($now, $entity->id); + + $row = $table->find('all')->where(['id' => $entity->id])->first(); + $this->assertEquals($row->id->format('Y-m-d'), $entity->id->format('Y-m-d')); + } + + /** + * Test saving existing records works + */ + public function testSaveUpdate(): void + { + $id = new DateTime('now'); + $entity = new Entity([ + 'id' => $id, + 'title' => 'shiny update', + ]); + + $table = $this->getTableLocator()->get('DateKeys'); + $this->assertSame($entity, $table->save($entity)); + $this->assertEquals($id, $entity->id, 'Should match'); + + $row = $table->find('all')->where(['id' => $entity->id])->first(); + $row->title = 'things'; + $this->assertSame($row, $table->save($row)); + } + + /** + * Test delete with string pk. + */ + public function testDelete(): void + { + $table = $this->getTableLocator()->get('DateKeys'); + $entity = new Entity([ + 'id' => new DateTime('now'), + 'title' => 'shiny update', + ]); + $table->save($entity); + $this->assertTrue($table->delete($entity)); + + $query = $table->find('all')->where(['id' => $entity->id]); + $this->assertEmpty($query->first(), 'No row left'); + } +} diff --git a/tests/TestCase/ORM/TableGetWithCustomFinderTest.php b/tests/TestCase/ORM/TableGetWithCustomFinderTest.php new file mode 100644 index 00000000000..56f29f6e728 --- /dev/null +++ b/tests/TestCase/ORM/TableGetWithCustomFinderTest.php @@ -0,0 +1,94 @@ +connection = ConnectionManager::get('test'); + static::setAppNamespace(); + } + + public static function providerForTestGetWithCustomFinder(): array + { + return [ + [['fields' => ['id'], 'finder' => 'custom']], + ]; + } + + /** + * Test that get() will call a custom finder. + * + * @param array $options + */ + #[DataProvider('providerForTestGetWithCustomFinder')] + public function testGetWithCustomFinder($options): void + { + $queryFactory = Mockery::mock(QueryFactory::class); + $table = new GetWithCustomFinderTable([ + 'connection' => $this->connection, + 'schema' => [ + 'id' => ['type' => 'integer'], + 'bar' => ['type' => 'integer'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['bar']]], + ], + 'queryFactory' => $queryFactory, + ]); + + $query = Mockery::mock(new SelectQuery($table))->makePartial(); + $queryFactory->shouldReceive('select')->once()->with($table)->andReturn($query); + + $entity = new Entity(); + $query->shouldReceive('applyOptions') + ->once() + ->with(['fields' => ['id']]) + ->andReturnSelf(); + $query->shouldReceive('where') + ->once() + ->with([$table->getAlias() . '.bar' => 10]) + ->andReturnSelf(); + $query->shouldReceive('cache')->never(); + $query->shouldReceive('firstOrFail') + ->once() + ->andReturn($entity); + + $result = $table->get(10, ...$options); + $this->assertSame($entity, $result); + } +} + +// phpcs:disable +class GetWithCustomFinderTable extends Table +{ + public function findCustom($query) + { + return $query; + } +} +// phpcs:enable diff --git a/tests/TestCase/ORM/TableImplementedEventsTest.php b/tests/TestCase/ORM/TableImplementedEventsTest.php new file mode 100644 index 00000000000..f6424b4acda --- /dev/null +++ b/tests/TestCase/ORM/TableImplementedEventsTest.php @@ -0,0 +1,82 @@ +implementedEvents(); + $expected = [ + 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.buildValidator' => 'buildValidator', + 'Model.beforeFind' => 'beforeFind', + 'Model.beforeSave' => 'beforeSave', + 'Model.afterSave' => 'afterSave', + 'Model.beforeDelete' => 'beforeDelete', + 'Model.afterDelete' => 'afterDelete', + 'Model.afterRules' => 'afterRules', + ]; + $this->assertEquals($expected, $result, 'Events do not match.'); + } + + public function testImplementedEventsWithTableEventsTrait(): void + { + $table = new ImplementedAllEventsTable(); + $result = $table->implementedEvents(); + $expected = [ + 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.afterMarshal' => 'afterMarshal', + 'Model.buildValidator' => 'buildValidator', + 'Model.beforeFind' => 'beforeFind', + 'Model.beforeSave' => 'beforeSave', + 'Model.afterSave' => 'afterSave', + 'Model.afterSaveCommit' => 'afterSaveCommit', + 'Model.beforeDelete' => 'beforeDelete', + 'Model.afterDelete' => 'afterDelete', + 'Model.afterDeleteCommit' => 'afterDeleteCommit', + 'Model.beforeRules' => 'beforeRules', + 'Model.afterRules' => 'afterRules', + ]; + $this->assertEquals($expected, $result, 'Events do not match.'); + } +} + +// phpcs:disable +class ImplementedEventsTable extends Table +{ + public function buildValidator(): void {} + public function beforeMarshal(): void {} + public function beforeFind(): void {} + public function beforeSave(): void {} + public function afterSave(): void {} + public function beforeDelete(): void {} + public function afterDelete(): void {} + public function afterRules(): void {} +} + +class ImplementedAllEventsTable extends Table +{ + use TableEventsTrait; +} +// phpcs:enable diff --git a/tests/TestCase/ORM/TableRegistryTest.php b/tests/TestCase/ORM/TableRegistryTest.php new file mode 100644 index 00000000000..522be3e5d76 --- /dev/null +++ b/tests/TestCase/ORM/TableRegistryTest.php @@ -0,0 +1,95 @@ +_originalLocator = TableRegistry::getTableLocator(); + } + + /** + * tear down + */ + protected function tearDown(): void + { + parent::tearDown(); + TableRegistry::setTableLocator($this->_originalLocator); + } + + /** + * Sets and returns mock LocatorInterface instance. + * + * @return \Cake\ORM\Locator\LocatorInterface + */ + protected function _setMockLocator() + { + $locator = Mockery::mock(LocatorInterface::class)->shouldIgnoreMissing(); + TableRegistry::setTableLocator($locator); + + return $locator; + } + + /** + * Test testSetLocator() method. + */ + public function testSetLocator(): void + { + $locator = $this->_setMockLocator(); + + $this->assertSame($locator, TableRegistry::getTableLocator()); + } + + /** + * Test testSetLocator() method. + */ + public function testGetLocator(): void + { + $this->assertInstanceOf(LocatorInterface::class, TableRegistry::getTableLocator()); + } + + /** + * Test that locator() method is returning TableLocator by default. + */ + public function testLocatorDefault(): void + { + $locator = TableRegistry::getTableLocator(); + $this->assertInstanceOf(TableLocator::class, $locator); + } +} diff --git a/tests/TestCase/ORM/TableRegressionTest.php b/tests/TestCase/ORM/TableRegressionTest.php new file mode 100644 index 00000000000..a3819abaddb --- /dev/null +++ b/tests/TestCase/ORM/TableRegressionTest.php @@ -0,0 +1,71 @@ + + */ + protected array $fixtures = [ + 'core.Authors', + ]; + + /** + * Tests that an exception is thrown if the transaction is aborted + * in the afterSave callback + * + * @see https://github.com/cakephp/cakephp/issues/9079 + */ + public function testAfterSaveRollbackTransaction(): void + { + $this->expectException(RolledbackTransactionException::class); + $table = $this->getTableLocator()->get('Authors'); + $table->getEventManager()->on( + 'Model.afterSave', + function () use ($table): void { + $table->getConnection()->rollback(); + }, + ); + $entity = $table->newEntity(['name' => 'Jon']); + $table->save($entity); + } + + /** + * Ensure that saving to a table with no primary key fails. + */ + public function testSaveNoPrimaryKeyException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('primary key'); + $table = $this->getTableLocator()->get('Authors'); + $table->getSchema()->dropConstraint('primary'); + + $entity = $table->find()->first(); + $entity->name = 'new name'; + $table->save($entity); + } +} diff --git a/tests/TestCase/ORM/TableTest.php b/tests/TestCase/ORM/TableTest.php new file mode 100644 index 00000000000..712067ea3a8 --- /dev/null +++ b/tests/TestCase/ORM/TableTest.php @@ -0,0 +1,6660 @@ +connection = ConnectionManager::get('test'); + static::setAppNamespace(); + + $this->usersTypeMap = new TypeMap([ + 'Users.id' => 'integer', + 'id' => 'integer', + 'Users__id' => 'integer', + 'Users.username' => 'string', + 'Users__username' => 'string', + 'username' => 'string', + 'Users.password' => 'string', + 'Users__password' => 'string', + 'password' => 'string', + 'Users.created' => 'timestamp', + 'Users__created' => 'timestamp', + 'created' => 'timestamp', + 'Users.updated' => 'timestamp', + 'Users__updated' => 'timestamp', + 'updated' => 'timestamp', + ]); + + $config = $this->connection->config(); + if (str_contains($config['driver'], 'Postgres')) { + $this->usersTypeMap = new TypeMap([ + 'Users.id' => 'integer', + 'id' => 'integer', + 'Users__id' => 'integer', + 'Users.username' => 'string', + 'Users__username' => 'string', + 'username' => 'string', + 'Users.password' => 'string', + 'Users__password' => 'string', + 'password' => 'string', + 'Users.created' => 'timestampfractional', + 'Users__created' => 'timestampfractional', + 'created' => 'timestampfractional', + 'Users.updated' => 'timestampfractional', + 'Users__updated' => 'timestampfractional', + 'updated' => 'timestampfractional', + ]); + } elseif (str_contains($config['driver'], 'Sqlserver')) { + $this->usersTypeMap = new TypeMap([ + 'Users.id' => 'integer', + 'id' => 'integer', + 'Users__id' => 'integer', + 'Users.username' => 'string', + 'Users__username' => 'string', + 'username' => 'string', + 'Users.password' => 'string', + 'Users__password' => 'string', + 'password' => 'string', + 'Users.created' => 'datetimefractional', + 'Users__created' => 'datetimefractional', + 'created' => 'datetimefractional', + 'Users.updated' => 'datetimefractional', + 'Users__updated' => 'datetimefractional', + 'updated' => 'datetimefractional', + ]); + } + + $this->articlesTypeMap = new TypeMap([ + 'Articles.id' => 'integer', + 'Articles__id' => 'integer', + 'id' => 'integer', + 'Articles.title' => 'string', + 'Articles__title' => 'string', + 'title' => 'string', + 'Articles.author_id' => 'integer', + 'Articles__author_id' => 'integer', + 'author_id' => 'integer', + 'Articles.body' => 'text', + 'Articles__body' => 'text', + 'body' => 'text', + 'Articles.published' => 'string', + 'Articles__published' => 'string', + 'published' => 'string', + ]); + } + + /** + * teardown method + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + } + + /** + * Tests query creation wrappers. + */ + public function testTableQuery(): void + { + $table = new Table(['table' => 'users']); + + $query = $table->query(); + $this->assertEquals('users', $query->getRepository()->getTable()); + + $query = $table->selectQuery(); + $this->assertEquals('users', $query->getRepository()->getTable()); + + $query = $table->subquery(); + $this->assertEquals('users', $query->getRepository()->getTable()); + + $sql = $query->select(['username'])->sql(); + $this->assertRegExpSql( + 'SELECT FROM ', + $sql, + !$this->connection->getDriver()->isAutoQuotingEnabled(), + ); + } + + /** + * Tests subquery() disables aliasing. + */ + public function testSubqueryAliasing(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $subquery = $articles->subquery(); + + $subquery->select('Articles.field1'); + $this->assertRegExpSql( + 'SELECT . FROM ', + $subquery->sql(), + !$this->connection->getDriver()->isAutoQuotingEnabled(), + ); + + $subquery->select($articles, true); + $this->assertEqualsSql('SELECT id, author_id, title, body, published FROM articles Articles', $subquery->sql()); + + $subquery->selectAllExcept($articles, ['author_id'], true); + $this->assertEqualsSql('SELECT id, title, body, published FROM articles Articles', $subquery->sql()); + } + + /** + * Tests subquery() in where clause. + */ + public function testSubqueryWhereClause(): void + { + $subquery = $this->getTableLocator()->get('Authors')->subquery() + ->select(['Authors.id']) + ->where(['Authors.name' => 'mariano']); + + $query = $this->getTableLocator()->get('Articles')->find() + ->where(['Articles.author_id IN' => $subquery]) + ->orderBy(['Articles.id' => 'ASC']); + + $results = $query->all()->toList(); + $this->assertCount(2, $results); + $this->assertEquals([1, 3], array_column($results, 'id')); + } + + /** + * Tests subquery() in join clause. + */ + public function testSubqueryJoinClause(): void + { + $subquery = $this->getTableLocator()->get('Articles')->subquery() + ->select(['author_id']); + + $query = $this->getTableLocator()->get('Authors')->find(); + $query + ->select(['Authors.id', 'total_articles' => $query->func()->count('articles.author_id')]) + ->leftJoin(['articles' => $subquery], ['articles.author_id' => new IdentifierExpression('Authors.id')]) + ->groupBy(['Authors.id']) + ->orderBy(['Authors.id' => 'ASC']); + + $results = $query->all()->toList(); + $this->assertEquals(1, $results[0]->id); + $this->assertEquals(2, $results[0]->total_articles); + } + + /** + * Tests the table method + */ + public function testTableMethod(): void + { + $table = new Table(['table' => 'users']); + $this->assertSame('users', $table->getTable()); + + $table = new UsersTable(); + $this->assertSame('users', $table->getTable()); + + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['find']) + ->setMockClassName('SpecialThingsTable') + ->getMock(); + $this->assertSame('special_things', $table->getTable()); + + $table = new Table(['alias' => 'LoveBoats']); + $this->assertSame('love_boats', $table->getTable()); + + $table->setTable('other'); + $this->assertSame('other', $table->getTable()); + + $table->setTable('database.other'); + $this->assertSame('database.other', $table->getTable()); + } + + /** + * Tests the setAlias method + */ + public function testSetAlias(): void + { + $table = new Table(['alias' => 'users']); + $this->assertSame('users', $table->getAlias()); + + $table = new Table(['table' => 'stuffs']); + $this->assertSame('stuffs', $table->getAlias()); + + $table = new UsersTable(); + $this->assertSame('Users', $table->getAlias()); + + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['find']) + ->setMockClassName('SpecialThingTable') + ->getMock(); + $this->assertSame('SpecialThing', $table->getAlias()); + + $table->setAlias('AnotherOne'); + $this->assertSame('AnotherOne', $table->getAlias()); + } + + public function testGetAliasException(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('You must specify either the `alias` or the `table` option for the constructor.'); + + $table = new Table(); + $table->getAlias(); + } + + public function testGetTableException(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('You must specify either the `alias` or the `table` option for the constructor.'); + + $table = new Table(); + $table->getTable(); + } + + /** + * Test that aliasField() works. + */ + public function testAliasField(): void + { + $table = new Table(['alias' => 'Users']); + $this->assertSame('Users.id', $table->aliasField('id')); + + $this->assertSame('Users.id', $table->aliasField('Users.id')); + } + + /** + * Tests setConnection method + */ + public function testSetConnection(): void + { + $table = new Table(['table' => 'users']); + $this->assertSame($this->connection, $table->getConnection()); + $this->assertSame($table, $table->setConnection($this->connection)); + $this->assertSame($this->connection, $table->getConnection()); + } + + /** + * Tests primaryKey method + */ + public function testSetPrimaryKey(): void + { + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'integer'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('id', $table->getPrimaryKey()); + $this->assertSame($table, $table->setPrimaryKey('thingID')); + $this->assertSame('thingID', $table->getPrimaryKey()); + + $table->setPrimaryKey(['thingID', 'user_id']); + $this->assertEquals(['thingID', 'user_id'], $table->getPrimaryKey()); + } + + /** + * Tests that name will be selected as a displayField + */ + public function testDisplayFieldName(): void + { + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'foo' => ['type' => 'string'], + 'name' => ['type' => 'string'], + ], + ]); + $this->assertSame('name', $table->getDisplayField()); + } + + /** + * Tests that title will be selected as a displayField + */ + public function testDisplayFieldTitle(): void + { + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'foo' => ['type' => 'string'], + 'title' => ['type' => 'string'], + ], + ]); + $this->assertSame('title', $table->getDisplayField()); + } + + /** + * Tests that label will be selected as a displayField + */ + public function testDisplayFieldLabel(): void + { + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'foo' => ['type' => 'string'], + 'label' => ['type' => 'string'], + ], + ]); + $this->assertSame('label', $table->getDisplayField()); + } + + /** + * Tests that displayField will fallback to first *_name field + */ + public function testDisplayNameFallback(): void + { + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'integer'], + 'custom_title' => ['type' => 'string'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('custom_title', $table->getDisplayField()); + + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'custom_title' => ['type' => 'string'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('name', $table->getDisplayField()); + + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'integer'], + 'title_id' => ['type' => 'integer'], + 'custom_name' => ['type' => 'string'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('custom_name', $table->getDisplayField()); + + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'integer'], + 'nullable_title' => ['type' => 'string', 'null' => true], + 'custom_name' => ['type' => 'string'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('custom_name', $table->getDisplayField()); + + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'integer'], + 'nullable_title' => ['type' => 'string', 'null' => true], + 'password' => ['type' => 'string'], + 'user_secret' => ['type' => 'string'], + 'api_token' => ['type' => 'string'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('id', $table->getDisplayField()); + } + + /** + * Tests that no displayField will fallback to primary key + */ + public function testDisplayIdFallback(): void + { + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'string'], + 'foo' => ['type' => 'string'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('id', $table->getDisplayField()); + + $table = $this->getTableLocator()->get('ArticlesTags'); + $this->assertSame(['article_id', 'tag_id'], $table->getDisplayField()); + } + + /** + * Tests that displayField can be changed + */ + public function testDisplaySet(): void + { + $table = new Table([ + 'table' => 'users', + 'schema' => [ + 'id' => ['type' => 'string'], + 'foo' => ['type' => 'string'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ], + ]); + $this->assertSame('id', $table->getDisplayField()); + $table->setDisplayField('foo'); + $this->assertSame('foo', $table->getDisplayField()); + } + + /** + * Tests schema method + */ + public function testSetSchema(): void + { + $schema = $this->connection->getSchemaCollection()->describe('users'); + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $this->assertEquals($schema, $table->getSchema()); + + $table = new Table(['table' => 'stuff']); + $table->setSchema($schema); + $this->assertSame($schema, $table->getSchema()); + + $table = new Table(['table' => 'another']); + $schema = ['id' => ['type' => 'integer']]; + $table->setSchema($schema); + $this->assertEquals( + new TableSchema('another', $schema), + $table->getSchema(), + ); + } + + /** + * Tests schema method with long identifiers + */ + public function testSetSchemaLongIdentifiers(): void + { + $schema = new TableSchema('long_identifiers', [ + 'this_is_invalid_because_it_is_very_very_very_long' => [ + 'type' => 'string', + ], + ]); + $table = new Table([ + 'table' => 'very_long_alias_name', + 'connection' => $this->connection, + ]); + + $maxAlias = $this->connection->getDriver()->getMaxAliasLength(); + if ($maxAlias && $maxAlias < 72) { + $nameLength = $maxAlias - 2; + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage( + 'ORM queries generate field aliases using the table name/alias and column name. ' . + "The table alias `very_long_alias_name` and column `this_is_invalid_because_it_is_very_very_very_long` 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.', + ); + } + $this->assertNotNull($table->setSchema($schema)); + } + + public function testSchemaTypeOverrideInInitialize(): void + { + $table = new class (['alias' => 'Users', 'table' => 'users', 'connection' => $this->connection]) extends Table { + public function initialize(array $config): void + { + $this->getSchema()->setColumnType('username', 'foobar'); + } + }; + + $result = $table->getSchema(); + $this->assertSame('foobar', $result->getColumnType('username')); + } + + /** + * Undocumented function + * + * @return void + * @deprecated + */ + public function testFindAllOldStyleOptionsArray(): void + { + $this->deprecated(function (): void { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + + $query = $table->find('all', ['fields' => ['id']]); + $this->assertSame(['id'], $query->clause('select')); + }); + } + + /** + * Tests that all fields for a table are added by default in a find when no + * other fields are specified + */ + public function testFindAllNoFieldsAndNoHydration(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $results = $table + ->find('all') + ->where(['id IN' => [1, 2]]) + ->orderBy('id') + ->enableHydration(false) + ->toArray(); + $expected = [ + [ + 'id' => 1, + 'username' => 'mariano', + 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', + 'created' => new DateTime('2007-03-17 01:16:23'), + 'updated' => new DateTime('2007-03-17 01:18:31'), + ], + [ + 'id' => 2, + 'username' => 'nate', + 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', + 'created' => new DateTime('2008-03-17 01:18:23'), + 'updated' => new DateTime('2008-03-17 01:20:31'), + ], + ]; + $this->assertEquals($expected, $results); + } + + /** + * Tests that it is possible to select only a few fields when finding over a table + */ + public function testFindAllSomeFieldsNoHydration(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $results = $table->find('all') + ->select(['username', 'password']) + ->enableHydration(false) + ->orderBy('username')->toArray(); + $expected = [ + ['username' => 'garrett', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ['username' => 'larry', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ['username' => 'mariano', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ['username' => 'nate', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ]; + $this->assertSame($expected, $results); + + $results = $table->find('all') + ->select(['foo' => 'username', 'password']) + ->orderBy('username') + ->enableHydration(false) + ->toArray(); + $expected = [ + ['foo' => 'garrett', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ['foo' => 'larry', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ['foo' => 'mariano', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ['foo' => 'nate', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO'], + ]; + $this->assertSame($expected, $results); + } + + /** + * Tests that the query will automatically casts complex conditions to the correct + * types when the columns belong to the default table + */ + public function testFindAllConditionAutoTypes(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $query = $table->find('all') + ->select(['id', 'username']) + ->where(['created >=' => new DateTime('2010-01-22 00:00')]) + ->enableHydration(false) + ->orderBy('id'); + $expected = [ + ['id' => 3, 'username' => 'larry'], + ['id' => 4, 'username' => 'garrett'], + ]; + $this->assertSame($expected, $query->toArray()); + + $query = $table->find() + ->enableHydration(false) + ->select(['id', 'username']) + ->where(['OR' => [ + 'created >=' => new DateTime('2010-01-22 00:00'), + 'users.created' => new DateTime('2008-03-17 01:18:23'), + ]]) + ->orderBy('id'); + $expected = [ + ['id' => 2, 'username' => 'nate'], + ['id' => 3, 'username' => 'larry'], + ['id' => 4, 'username' => 'garrett'], + ]; + $this->assertSame($expected, $query->toArray()); + } + + /** + * Test that beforeFind events can mutate the query. + */ + public function testFindBeforeFindEventMutateQuery(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $table->getEventManager()->on( + 'Model.beforeFind', + function (EventInterface $event, $query, $options): void { + $query->limit(1); + }, + ); + + $result = $table->find('all')->all(); + $this->assertCount(1, $result, 'Should only have 1 record, limit 1 applied.'); + } + + /** + * Test that beforeFind events are fired and can stop the find and + * return custom results. + */ + public function testFindBeforeFindEventOverrideReturn(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $expected = ['One', 'Two', 'Three']; + $table->getEventManager()->on( + 'Model.beforeFind', + function (EventInterface $event, $query, $options) use ($expected): void { + $query->setResult($expected); + $event->stopPropagation(); + }, + ); + + $query = $table->find('all') + ->formatResults(function (ResultSet $results) { + return $results; + }); + $query->limit(1); + $this->assertEquals($expected, $query->all()->toArray()); + } + + /** + * Test that the getAssociation() method supports the dot syntax. + */ + public function testAssociationDotSyntax(): void + { + $sections = $this->getTableLocator()->get('Sections'); + $members = $this->getTableLocator()->get('Members'); + $sectionsMembers = $this->getTableLocator()->get('SectionsMembers'); + + $sections->belongsToMany('Members'); + $sections->hasMany('SectionsMembers'); + $sectionsMembers->belongsTo('Members'); + $members->belongsToMany('Sections'); + + $association = $sections->getAssociation('SectionsMembers.Members.Sections'); + $this->assertInstanceOf(BelongsToMany::class, $association); + $this->assertSame( + $sections->getAssociation('SectionsMembers')->getAssociation('Members')->getAssociation('Sections'), + $association, + ); + } + + public function testGetAssociationWithIncorrectCasing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The `authors` association is not defined on `Articles`.\n" + . 'Valid associations are: Authors, Tags, ArticlesTags', + ); + + $articles = $this->getTableLocator()->get('Articles', ['className' => ArticlesTable::class]); + + $articles->getAssociation('authors'); + } + + /** + * Tests that the getAssociation() method throws an exception on nonexistent ones. + */ + public function testGetAssociationNonExistent(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `FooBar` association is not defined on `Sections`.'); + + $this->getTableLocator()->get('Sections')->getAssociation('FooBar'); + } + + /** + * Tests that belongsTo() creates and configures correctly the association + */ + public function testBelongsTo(): void + { + $options = ['foreignKey' => 'fake_id', 'conditions' => ['a' => 'b']]; + $table = new Table(['table' => 'dates']); + $belongsTo = $table->belongsTo('user', $options); + $this->assertInstanceOf(BelongsTo::class, $belongsTo); + $this->assertSame($belongsTo, $table->getAssociation('user')); + $this->assertSame('user', $belongsTo->getName()); + $this->assertSame('fake_id', $belongsTo->getForeignKey()); + $this->assertEquals(['a' => 'b'], $belongsTo->getConditions()); + $this->assertSame($table, $belongsTo->getSource()); + } + + /** + * Tests that hasOne() creates and configures correctly the association + */ + public function testHasOne(): void + { + $table = new Table(['table' => 'users']); + $hasOne = $table->hasOne('profile', ['conditions' => ['b' => 'c']]); + $this->assertInstanceOf(HasOne::class, $hasOne); + $this->assertSame($hasOne, $table->getAssociation('profile')); + $this->assertSame('profile', $hasOne->getName()); + $this->assertSame('user_id', $hasOne->getForeignKey()); + $this->assertEquals(['b' => 'c'], $hasOne->getConditions()); + $this->assertSame($table, $hasOne->getSource()); + } + + /** + * Test has one with a plugin model + */ + public function testHasOnePlugin(): void + { + $table = new Table(['table' => 'users']); + + $hasOne = $table->hasOne('Comments', ['className' => 'TestPlugin.Comments']); + $this->assertInstanceOf(HasOne::class, $hasOne); + $this->assertSame('Comments', $hasOne->getName()); + + $this->assertSame('Comments', $hasOne->getAlias()); + $this->assertSame('TestPlugin.Comments', $hasOne->getRegistryAlias()); + + $table = new Table(['table' => 'users']); + + $hasOne = $table->hasOne('TestPlugin.Comments', ['className' => 'TestPlugin.Comments']); + $this->assertInstanceOf(HasOne::class, $hasOne); + $this->assertSame('Comments', $hasOne->getName()); + + $this->assertSame('Comments', $hasOne->getAlias()); + $this->assertSame('TestPlugin.Comments', $hasOne->getRegistryAlias()); + } + + /** + * testNoneUniqueAssociationsSameClass + */ + public function testNoneUniqueAssociationsSameClass(): void + { + $Users = new Table(['table' => 'users']); + $Users->hasMany('Comments'); + + $Articles = new Table(['table' => 'articles']); + $Articles->hasMany('Comments'); + + $Categories = new Table(['table' => 'categories']); + $options = ['className' => 'TestPlugin.Comments']; + $Categories->hasMany('Comments', $options); + + $this->assertInstanceOf(Table::class, $Users->Comments->getTarget()); + $this->assertInstanceOf(Table::class, $Articles->Comments->getTarget()); + $this->assertInstanceOf(CommentsTable::class, $Categories->Comments->getTarget()); + } + + /** + * Test associations which refer to the same table multiple times + */ + public function testSelfJoinAssociations(): void + { + $Categories = $this->getTableLocator()->get('Categories'); + $options = ['className' => 'Categories']; + $Categories->hasMany('Children', ['foreignKey' => 'parent_id'] + $options); + $Categories->belongsTo('Parent', $options); + + $this->assertSame('categories', $Categories->Children->getTarget()->getTable()); + $this->assertSame('categories', $Categories->Parent->getTarget()->getTable()); + + $this->assertSame('Children', $Categories->Children->getAlias()); + $this->assertSame('Children', $Categories->Children->getTarget()->getAlias()); + + $this->assertSame('Parent', $Categories->Parent->getAlias()); + $this->assertSame('Parent', $Categories->Parent->getTarget()->getAlias()); + + $expected = [ + 'id' => 2, + 'parent_id' => 1, + 'name' => 'Category 1.1', + 'parent' => [ + 'id' => 1, + 'parent_id' => 0, + 'name' => 'Category 1', + ], + 'children' => [ + [ + 'id' => 7, + 'parent_id' => 2, + 'name' => 'Category 1.1.1', + ], + [ + 'id' => 8, + 'parent_id' => 2, + 'name' => 'Category 1.1.2', + ], + ], + ]; + + $fields = ['id', 'parent_id', 'name']; + $result = $Categories->find('all') + ->select(['Categories.id', 'Categories.parent_id', 'Categories.name']) + ->contain(['Children' => ['fields' => $fields], 'Parent' => ['fields' => $fields]]) + ->where(['Categories.id' => 2]) + ->first() + ->toArray(); + + $this->assertSame($expected, $result); + } + + /** + * Tests that hasMany() creates and configures correctly the association + */ + public function testHasMany(): void + { + $options = [ + 'conditions' => ['b' => 'c'], + 'sort' => ['foo' => 'asc'], + ]; + $table = new Table(['table' => 'authors']); + $hasMany = $table->hasMany('article', $options); + $this->assertInstanceOf(HasMany::class, $hasMany); + $this->assertSame($hasMany, $table->getAssociation('article')); + $this->assertSame('article', $hasMany->getName()); + $this->assertSame('author_id', $hasMany->getForeignKey()); + $this->assertEquals(['b' => 'c'], $hasMany->getConditions()); + $this->assertEquals(['foo' => 'asc'], $hasMany->getSort()); + $this->assertSame($table, $hasMany->getSource()); + } + + /** + * testHasManyWithClassName + */ + public function testHasManyWithClassName(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->hasMany('Comments', [ + 'conditions' => ['published' => 'Y'], + ]); + + $table->hasMany('UnapprovedComments', [ + 'className' => 'Comments', + 'conditions' => ['published' => 'N'], + 'propertyName' => 'unaproved_comments', + ]); + + $expected = [ + 'id' => 1, + 'title' => 'First Article', + 'unaproved_comments' => [ + [ + 'id' => 4, + 'article_id' => 1, + 'comment' => 'Fourth Comment for First Article', + ], + ], + 'comments' => [ + [ + 'id' => 1, + 'article_id' => 1, + 'comment' => 'First Comment for First Article', + ], + [ + 'id' => 2, + 'article_id' => 1, + 'comment' => 'Second Comment for First Article', + ], + [ + 'id' => 3, + 'article_id' => 1, + 'comment' => 'Third Comment for First Article', + ], + ], + ]; + $result = $table->find() + ->select(['id', 'title']) + ->contain([ + 'Comments' => ['fields' => ['id', 'article_id', 'comment']], + 'UnapprovedComments' => ['fields' => ['id', 'article_id', 'comment']], + ]) + ->where(['id' => 1]) + ->first(); + + $this->assertSame($expected, $result->toArray()); + } + + /** + * Ensure associations use the plugin-prefixed model + */ + public function testHasManyPluginOverlap(): void + { + $this->getTableLocator()->get('Comments'); + $this->loadPlugins(['TestPlugin']); + + $table = new Table(['table' => 'authors']); + + $table->hasMany('TestPlugin.Comments'); + $comments = $table->Comments->getTarget(); + $this->assertInstanceOf(CommentsTable::class, $comments); + } + + /** + * Ensure associations use the plugin-prefixed model + * even if specified with config + */ + public function testHasManyPluginOverlapConfig(): void + { + $this->getTableLocator()->get('Comments'); + $this->loadPlugins(['TestPlugin']); + + $table = new Table(['table' => 'authors']); + + $table->hasMany('Comments', ['className' => 'TestPlugin.Comments']); + $comments = $table->Comments->getTarget(); + $this->assertInstanceOf(CommentsTable::class, $comments); + } + + /** + * Tests that BelongsToMany() creates and configures correctly the association + */ + public function testBelongsToMany(): void + { + $options = [ + 'foreignKey' => 'thing_id', + 'joinTable' => 'things_tags', + 'conditions' => ['b' => 'c'], + 'sort' => ['foo' => 'asc'], + ]; + $table = new Table(['table' => 'authors', 'connection' => $this->connection]); + $belongsToMany = $table->belongsToMany('tag', $options); + $this->assertInstanceOf(BelongsToMany::class, $belongsToMany); + $this->assertSame($belongsToMany, $table->getAssociation('tag')); + $this->assertSame('tag', $belongsToMany->getName()); + $this->assertSame('thing_id', $belongsToMany->getForeignKey()); + $this->assertEquals(['b' => 'c'], $belongsToMany->getConditions()); + $this->assertEquals(['foo' => 'asc'], $belongsToMany->getSort()); + $this->assertSame($table, $belongsToMany->getSource()); + $this->assertSame('things_tags', $belongsToMany->junction()->getTable()); + } + + /** + * Test addAssociations() + */ + public function testAddAssociations(): void + { + $params = [ + 'belongsTo' => [ + 'users' => ['foreignKey' => 'fake_id', 'conditions' => ['a' => 'b']], + ], + 'hasOne' => ['profiles'], + 'hasMany' => ['authors'], + 'belongsToMany' => [ + 'tags' => [ + 'joinTable' => 'things_tags', + 'conditions' => [ + 'Tags.starred' => true, + ], + ], + ], + ]; + + $table = new Table(['table' => 'members']); + $result = $table->addAssociations($params); + $this->assertSame($table, $result); + + $associations = $table->associations(); + + $belongsTo = $associations->get('users'); + $this->assertInstanceOf(BelongsTo::class, $belongsTo); + $this->assertSame('users', $belongsTo->getName()); + $this->assertSame('fake_id', $belongsTo->getForeignKey()); + $this->assertEquals(['a' => 'b'], $belongsTo->getConditions()); + $this->assertSame($table, $belongsTo->getSource()); + + $hasOne = $associations->get('profiles'); + $this->assertInstanceOf(HasOne::class, $hasOne); + $this->assertSame('profiles', $hasOne->getName()); + + $hasMany = $associations->get('authors'); + $this->assertInstanceOf(HasMany::class, $hasMany); + $this->assertSame('authors', $hasMany->getName()); + + $belongsToMany = $associations->get('tags'); + $this->assertInstanceOf(BelongsToMany::class, $belongsToMany); + $this->assertSame('tags', $belongsToMany->getName()); + $this->assertSame('things_tags', $belongsToMany->junction()->getTable()); + $this->assertSame(['Tags.starred' => true], $belongsToMany->getConditions()); + } + + /** + * Test basic multi row updates. + */ + public function testUpdateAll(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $fields = ['username' => 'mark']; + $result = $table->updateAll($fields, ['id <' => 4]); + $this->assertSame(3, $result); + + $result = $table->find('all') + ->select(['username']) + ->orderBy(['id' => 'asc']) + ->enableHydration(false) + ->toArray(); + $expected = array_fill(0, 3, $fields); + $expected[] = ['username' => 'garrett']; + $this->assertEquals($expected, $result); + } + + public function testUpdateExpression(): void + { + $table = new Table([ + 'table' => 'counter_cache_users', + 'connection' => $this->connection, + ]); + $entity = new Entity([ + 'name' => 'test', + 'post_count' => 0, + 'comment_count' => 0, + 'posts_published' => 0, + ]); + $table->save($entity); + $expression = new QueryExpression(['post_count = post_count + 1']); + $result = $table->updateAll([$expression], ['id' => 1]); + $this->assertNotEmpty($result); + } + + /** + * Test that exceptions from the Query bubble up. + */ + public function testUpdateAllFailure(): void + { + $this->expectException(DatabaseException::class); + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['updateQuery']) + ->setConstructorArgs([['table' => 'users']]) + ->getMock(); + $query = $this->getMockBuilder(UpdateQuery::class) + ->onlyMethods(['execute']) + ->setConstructorArgs([$table]) + ->getMock(); + $table->expects($this->once()) + ->method('updateQuery') + ->willReturn($query); + + $query->expects($this->once()) + ->method('execute') + ->will($this->throwException(new DatabaseException('Not good'))); + + $table->updateAll(['username' => 'mark'], []); + } + + /** + * Test deleting many records. + */ + public function testDeleteAll(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $result = $table->deleteAll(['id <' => 4]); + $this->assertSame(3, $result); + + $result = $table->find('all')->toArray(); + $this->assertCount(1, $result, 'Only one record should remain'); + $this->assertSame(4, $result[0]['id']); + } + + /** + * Test deleting many records with conditions using the alias + */ + public function testDeleteAllAliasedConditions(): void + { + $table = new Table([ + 'table' => 'users', + 'alias' => 'Managers', + 'connection' => $this->connection, + ]); + $result = $table->deleteAll(['Managers.id <' => 4]); + $this->assertSame(3, $result); + + $result = $table->find('all')->toArray(); + $this->assertCount(1, $result, 'Only one record should remain'); + $this->assertSame(4, $result[0]['id']); + } + + /** + * Test that exceptions from the Query bubble up. + */ + public function testDeleteAllFailure(): void + { + $this->expectException(DatabaseException::class); + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['deleteQuery']) + ->setConstructorArgs([['table' => 'users']]) + ->getMock(); + $query = $this->getMockBuilder(DeleteQuery::class) + ->onlyMethods(['execute']) + ->setConstructorArgs([$table]) + ->getMock(); + $table->expects($this->once()) + ->method('deleteQuery') + ->willReturn($query); + + $query->expects($this->once()) + ->method('execute') + ->will($this->throwException(new DatabaseException('Not good'))); + + $table->deleteAll(['id >' => 4]); + } + + /** + * Tests that array options are passed to the query object using applyOptions + */ + public function testFindApplyOptions(): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['selectQuery', 'findAll']) + ->setConstructorArgs([['table' => 'users', 'connection' => $this->connection]]) + ->getMock(); + $query = $this->getMockBuilder(SelectQuery::class) + ->setConstructorArgs([$table]) + ->getMock(); + $table->expects($this->once()) + ->method('selectQuery') + ->willReturn($query); + + $options = ['fields' => ['a', 'b']]; + $query->method('select') + ->willReturnSelf(); + + $query->expects($this->once())->method('getOptions') + ->willReturn([]); + $query->expects($this->once()) + ->method('applyOptions') + ->with($options); + + $table->expects($this->once())->method('findAll'); + $table->find('all', ...$options); + } + + /** + * Tests that extra arguments are passed to finders. + */ + public function testFindTypedParameters(): void + { + $author = $this->getTableLocator()->get('Authors')->find('WithIdArgument', 2)->first(); + $this->assertSame(2, $author->id); + + $author = $this->getTableLocator()->get('Authors')->find('WithIdArgument', id: 2)->first(); + $this->assertSame(2, $author->id); + } + + /** + * https://github.com/cakephp/cakephp/issues/18716 + */ + public function testChangedFindWithOverlappingArgs(): void + { + $query = $this->getTableLocator()->get('Authors') + ->find('withIdArgument', 2) + ->find('custom', id: [1, 2], second: false); + + $this->assertSame(['id' => [1, 2], 'second' => false], $query->getOptions()); + + $query = $this->getTableLocator()->get('Authors') + ->find('withIdArgument', id: 2) + ->find('custom', second: true); + + $this->assertSame(['id' => 2, 'second' => true], $query->getOptions()); + + $query = $this->getTableLocator()->get('Authors') + ->find('withIdArgument', id: 2) + ->find('custom2', id: [2, 3], second: true); + + $this->assertSame(['id' => [2, 3], 'second' => true], $query->getOptions()); + } + + public function testFindTypedParameterCompatibility(): void + { + $articles = $this->fetchTable('Articles'); + $article = $articles->find('titled')->first(); + $this->assertNotEmpty($article); + + // Options arrays are deprecated but should work + $this->deprecated(function () use ($articles): void { + $article = $articles->find('titled', ['title' => 'Second Article'])->first(); + $this->assertNotEmpty($article); + $this->assertEquals('Second Article', $article->title); + }); + + // Named parameters should be compatible with options finders + $article = $articles->find('titled', title: 'Second Article')->first(); + $this->assertNotEmpty($article); + $this->assertEquals('Second Article', $article->title); + } + + public function testFindForFinderVariadic(): void + { + $testTable = $this->fetchTable('Test'); + + $testTable->find('variadic', foo: 'bar'); + $this->assertNull($testTable->first); + $this->assertSame(['foo' => 'bar'], $testTable->variadic); + + $testTable->find('variadic', first: 'one', foo: 'bar'); + $this->assertSame('one', $testTable->first); + $this->assertSame(['foo' => 'bar'], $testTable->variadic); + + $testTable->find('variadicOptions'); + $this->assertSame([], $testTable->variadicOptions); + + $testTable->find('variadicOptions', foo: 'bar'); + $this->assertSame(['foo' => 'bar'], $testTable->variadicOptions); + } + + /** + * Tests find('list') + */ + public function testFindListNoHydration(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $table->setDisplayField('username'); + $query = $table->find('list') + ->enableHydration(false) + ->orderBy('id'); + $expected = [ + 1 => 'mariano', + 2 => 'nate', + 3 => 'larry', + 4 => 'garrett', + ]; + $this->assertSame($expected, $query->toArray()); + + $query = $table->find('list', fields: ['id', 'username']) + ->enableHydration(false) + ->orderBy('id'); + $expected = [ + 1 => 'mariano', + 2 => 'nate', + 3 => 'larry', + 4 => 'garrett', + ]; + $this->assertSame($expected, $query->toArray()); + + $query = $table->find('list', groupField: 'odd') + ->select(['id', 'username', 'odd' => new QueryExpression('id % 2')]) + ->enableHydration(false) + ->orderBy('id'); + $expected = [ + 1 => [ + 1 => 'mariano', + 3 => 'larry', + ], + 0 => [ + 2 => 'nate', + 4 => 'garrett', + ], + ]; + $this->assertSame($expected, $query->toArray()); + } + + /** + * Tests find('threaded') + */ + public function testFindThreadedNoHydration(): void + { + $table = new Table([ + 'table' => 'categories', + 'connection' => $this->connection, + ]); + $expected = [ + [ + 'id' => 1, + 'parent_id' => 0, + 'name' => 'Category 1', + 'children' => [ + [ + 'id' => 2, + 'parent_id' => 1, + 'name' => 'Category 1.1', + 'children' => [ + [ + 'id' => 7, + 'parent_id' => 2, + 'name' => 'Category 1.1.1', + 'children' => [], + ], + [ + 'id' => 8, + 'parent_id' => '2', + 'name' => 'Category 1.1.2', + 'children' => [], + ], + ], + ], + [ + 'id' => 3, + 'parent_id' => '1', + 'name' => 'Category 1.2', + 'children' => [], + ], + ], + ], + [ + 'id' => 4, + 'parent_id' => 0, + 'name' => 'Category 2', + 'children' => [], + ], + [ + 'id' => 5, + 'parent_id' => 0, + 'name' => 'Category 3', + 'children' => [ + [ + 'id' => '6', + 'parent_id' => '5', + 'name' => 'Category 3.1', + 'children' => [], + ], + ], + ], + ]; + $results = $table->find('all') + ->select(['id', 'parent_id', 'name']) + ->enableHydration(false) + ->find('threaded') + ->toArray(); + + $this->assertEquals($expected, $results); + } + + /** + * Tests that finders can be stacked + */ + public function testStackingFinders(): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['find', 'findList']) + ->disableOriginalConstructor() + ->getMock(); + $query = $this->getMockBuilder(Query::class) + ->onlyMethods(['addDefaultTypes']) + ->setConstructorArgs([$table]) + ->getMock(); + + $table->expects($this->once()) + ->method('find') + ->with('threaded', ['order' => ['name' => 'ASC']]) + ->willReturn($query); + + $table->expects($this->once()) + ->method('findList') + ->with($query, 'id') + ->willReturn($query); + + $result = $table + ->find('threaded', ['order' => ['name' => 'ASC']]) + ->find('list', keyField: 'id'); + $this->assertSame($query, $result); + } + + /** + * Tests find('threaded') with hydrated results + */ + public function testFindThreadedHydrated(): void + { + $table = new Table([ + 'table' => 'categories', + 'connection' => $this->connection, + ]); + $results = $table->find('all') + ->find('threaded') + ->select(['id', 'parent_id', 'name']) + ->toArray(); + + $this->assertSame(1, $results[0]->id); + $expected = [ + 'id' => 8, + 'parent_id' => 2, + 'name' => 'Category 1.1.2', + 'children' => [], + ]; + $this->assertEquals($expected, $results[0]->children[0]->children[1]->toArray()); + } + + /** + * Tests find('list') with hydrated records + */ + public function testFindListHydrated(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $table->setDisplayField('username'); + $query = $table + ->find('list', fields: ['id', 'username']) + ->orderBy('id'); + $expected = [ + 1 => 'mariano', + 2 => 'nate', + 3 => 'larry', + 4 => 'garrett', + ]; + $this->assertSame($expected, $query->toArray()); + + $query = $table->find('list', groupField: 'odd') + ->select(['id', 'username', 'odd' => new QueryExpression('id % 2')]) + ->enableHydration(true) + ->orderBy('id'); + $expected = [ + 1 => [ + 1 => 'mariano', + 3 => 'larry', + ], + 0 => [ + 2 => 'nate', + 4 => 'garrett', + ], + ]; + $this->assertSame($expected, $query->toArray()); + } + + /** + * Test that find('list') only selects required fields. + */ + public function testFindListSelectedFields(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $table->setDisplayField('username'); + + $query = $table->find('list'); + $expected = ['id', 'username']; + $this->assertSame($expected, $query->clause('select')); + + $query = $table->find('list', valueField: function ($row) { + return $row->username; + }); + $this->assertEmpty($query->clause('select')); + + $expected = ['odd' => new QueryExpression('id % 2'), 'id', 'username']; + $query = $table->find('list', fields: $expected, groupField: 'odd'); + $this->assertSame($expected, $query->clause('select')); + + $articles = new Table([ + 'table' => 'articles', + 'connection' => $this->connection, + ]); + + $query = $articles->find('list', groupField: 'author_id'); + $expected = ['id', 'title', 'author_id']; + $this->assertSame($expected, $query->clause('select')); + + $query = $articles->find('list', valueField: ['author_id', 'title']) + ->orderBy('id'); + $expected = ['id', 'author_id', 'title']; + $this->assertSame($expected, $query->clause('select')); + + $expected = [ + 1 => '1 First Article', + 2 => '3 Second Article', + 3 => '1 Third Article', + ]; + $this->assertSame($expected, $query->toArray()); + + $query = $articles->find('list', valueField: ['id', 'title'], valueSeparator: ' : ') + ->orderBy('id'); + + $expected = [ + 1 => '1 : First Article', + 2 => '2 : Second Article', + 3 => '3 : Third Article', + ]; + $this->assertSame($expected, $query->toArray()); + } + + /** + * Tests find(list) with backwards compatibile options + */ + public function testFindListArrayOptions(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + ]); + $table->setDisplayField('username'); + $this->deprecated(function () use ($table): void { + $query = $table + ->find('list', ['fields' => ['id', 'username']]) + ->orderBy('id'); + $expected = [ + 1 => 'mariano', + 2 => 'nate', + 3 => 'larry', + 4 => 'garrett', + ]; + $this->assertSame($expected, $query->toArray()); + }); + } + + /** + * test that find('list') does not auto add fields to select if using virtual properties + */ + public function testFindListWithVirtualField(): void + { + $table = new Table([ + 'table' => 'users', + 'connection' => $this->connection, + 'entityClass' => VirtualUser::class, + ]); + $table->setDisplayField('bonus'); + + $query = $table + ->find('list') + ->orderBy('id'); + $this->assertEmpty($query->clause('select')); + + $expected = [ + 1 => 'bonus', + 2 => 'bonus', + 3 => 'bonus', + 4 => 'bonus', + ]; + $this->assertSame($expected, $query->toArray()); + + $query = $table->find('list', groupField: 'odd'); + $this->assertEmpty($query->clause('select')); + } + + /** + * Test find('list') with value field from associated table + */ + public function testFindListWithAssociatedTable(): void + { + $articles = new Table([ + 'table' => 'articles', + 'connection' => $this->connection, + ]); + + $articles->belongsTo('Authors'); + $query = $articles->find('list', valueField: 'author.name') + ->contain(['Authors']) + ->orderBy('articles.id'); + $this->assertEmpty($query->clause('select')); + + $expected = [ + 1 => 'mariano', + 2 => 'larry', + 3 => 'mariano', + ]; + $this->assertSame($expected, $query->toArray()); + } + + /** + * Test find('list') called with option array instead of named args for backwards compatility + * + * @return void + * @deprecated + */ + public function testFindListWithArray(): void + { + $this->deprecated(function (): void { + $articles = new Table([ + 'table' => 'articles', + 'connection' => $this->connection, + ]); + + $articles->belongsTo('Authors'); + $query = $articles->find('list', ['valueField' => 'author.name']) + ->contain(['Authors']) + ->orderBy('articles.id'); + $this->assertEmpty($query->clause('select')); + + $expected = [ + 1 => 'mariano', + 2 => 'larry', + 3 => 'mariano', + ]; + $this->assertSame($expected, $query->toArray()); + }); + } + + /** + * Test the default entityClass. + */ + public function testEntityClassDefault(): void + { + $table = new Table(); + $this->assertSame(Entity::class, $table->getEntityClass()); + } + + /** + * Tests that using a simple string for entityClass will try to + * load the class from the App namespace + */ + public function testTableClassInApp(): void + { + $class = Mockery::mock(Entity::class)::class; + + if (!class_exists('TestApp\Model\Entity\TestUser')) { + class_alias($class, 'TestApp\Model\Entity\TestUser'); + } + + $table = new Table(); + $this->assertSame($table, $table->setEntityClass('TestUser')); + $this->assertSame('TestApp\Model\Entity\TestUser', $table->getEntityClass()); + } + + /** + * Test that entity class inflection works for compound nouns + */ + public function testEntityClassInflection(): void + { + $class = Mockery::mock(Entity::class)::class; + + if (!class_exists('TestApp\Model\Entity\CustomCookie')) { + class_alias($class, 'TestApp\Model\Entity\CustomCookie'); + } + + $table = $this->getTableLocator()->get('CustomCookies'); + $this->assertSame('TestApp\Model\Entity\CustomCookie', $table->getEntityClass()); + + if (!class_exists('TestApp\Model\Entity\Address')) { + class_alias($class, 'TestApp\Model\Entity\Address'); + } + + $table = $this->getTableLocator()->get('Addresses'); + $this->assertSame('TestApp\Model\Entity\Address', $table->getEntityClass()); + } + + /** + * Tests that using a simple string for entityClass will try to + * load the class from the Plugin namespace when using plugin notation + */ + public function testTableClassInPlugin(): void + { + $class = Mockery::mock(Entity::class)::class; + + if (!class_exists('MyPlugin\Model\Entity\SuperUser')) { + class_alias($class, 'MyPlugin\Model\Entity\SuperUser'); + } + + $table = new Table(); + $this->assertSame($table, $table->setEntityClass('MyPlugin.SuperUser')); + $this->assertSame( + 'MyPlugin\Model\Entity\SuperUser', + $table->getEntityClass(), + ); + } + + /** + * Tests that using a simple string for entityClass will throw an exception + * when the class does not exist in the namespace + */ + public function testTableClassNonExistent(): void + { + $this->expectException(MissingEntityException::class); + $this->expectExceptionMessage('Entity class `FooUser` could not be found.'); + $table = new Table(); + $table->setEntityClass('FooUser'); + } + + /** + * Tests getting the entityClass based on conventions for the entity + * namespace + */ + public function testTableClassConventionForAPP(): void + { + $table = new ArticlesTable(); + $this->assertSame(Article::class, $table->getEntityClass()); + } + + /** + * Tests setting a entity class object using the setter method + */ + public function testSetEntityClass(): void + { + $table = new Table(); + $class = '\\' . Mockery::mock(Entity::class)::class; + $this->assertSame($table, $table->setEntityClass($class)); + $this->assertSame($class, $table->getEntityClass()); + } + + /** + * Proves that associations, even though they are lazy loaded, will fetch + * records using the correct table class and hydrate with the correct entity + */ + public function testReciprocalBelongsToLoading(): void + { + $table = new ArticlesTable([ + 'connection' => $this->connection, + ]); + $result = $table->find('all')->contain(['Authors'])->first(); + $this->assertInstanceOf(Author::class, $result->author); + } + + /** + * Proves that associations, even though they are lazy loaded, will fetch + * records using the correct table class and hydrate with the correct entity + */ + public function testReciprocalHasManyLoading(): void + { + $table = new ArticlesTable([ + 'connection' => $this->connection, + ]); + $result = $table->find('all')->contain(['Authors' => ['Articles']])->first(); + $this->assertCount(2, $result->author->articles); + foreach ($result->author->articles as $article) { + $this->assertInstanceOf(Article::class, $article); + } + } + + /** + * Tests that the correct table and entity are loaded for the join association in + * a belongsToMany setup + */ + public function testReciprocalBelongsToMany(): void + { + $table = new ArticlesTable([ + 'connection' => $this->connection, + ]); + $result = $table->find('all')->contain(['Tags'])->first(); + $this->assertInstanceOf(Tag::class, $result->tags[0]); + $this->assertInstanceOf( + ArticlesTag::class, + $result->tags[0]->_joinData, + ); + } + + /** + * Tests that recently fetched entities are always clean + */ + public function testFindCleanEntities(): void + { + $table = new ArticlesTable([ + 'connection' => $this->connection, + ]); + $results = $table->find('all')->contain(['Tags', 'Authors'])->toArray(); + $this->assertCount(3, $results); + foreach ($results as $article) { + $this->assertFalse($article->isDirty('id')); + $this->assertFalse($article->isDirty('title')); + $this->assertFalse($article->isDirty('author_id')); + $this->assertFalse($article->isDirty('body')); + $this->assertFalse($article->isDirty('published')); + $this->assertFalse($article->isDirty('author')); + $this->assertFalse($article->author->isDirty('id')); + $this->assertFalse($article->author->isDirty('name')); + $this->assertFalse($article->isDirty('tag')); + if ($article->tag) { + $this->assertFalse($article->tag[0]->_joinData->isDirty('tag_id')); + } + } + } + + /** + * Tests that recently fetched entities are marked as not new + */ + public function testFindPersistedEntities(): void + { + $table = new ArticlesTable([ + 'connection' => $this->connection, + ]); + $results = $table->find('all')->contain(['Tags', 'Authors'])->toArray(); + $this->assertCount(3, $results); + foreach ($results as $article) { + $this->assertFalse($article->isNew()); + foreach ((array)$article->tag as $tag) { + $this->assertFalse($tag->isNew()); + $this->assertFalse($tag->_joinData->isNew()); + } + } + } + + /** + * Tests the exists function + */ + public function testExists(): void + { + $table = $this->getTableLocator()->get('users'); + $this->assertTrue($table->exists(['id' => 1])); + $this->assertFalse($table->exists(['id' => 501])); + $this->assertTrue($table->exists(['id' => 3, 'username' => 'larry'])); + } + + /** + * Test adding a behavior to a table. + */ + public function testAddBehavior(): void + { + $mock = $this->getMockBuilder(BehaviorRegistry::class) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects($this->once()) + ->method('load') + ->with('Sluggable'); + + $table = new Table([ + 'table' => 'articles', + 'behaviors' => $mock, + ]); + $result = $table->addBehavior('Sluggable'); + $this->assertSame($table, $result); + } + + /** + * Test adding a plugin behavior to a table. + */ + public function testAddBehaviorPlugin(): void + { + $table = new Table([ + 'table' => 'articles', + ]); + $result = $table->addBehavior('TestPlugin.PersisterOne', ['some' => 'key']); + + $this->assertSame(['PersisterOne'], $result->behaviors()->loaded()); + $className = $result->behaviors()->get('PersisterOne')->getConfig('className'); + $this->assertSame('TestPlugin.PersisterOne', $className); + } + + /** + * Test adding a behavior that is a duplicate. + */ + public function testAddBehaviorDuplicate(): void + { + $table = new Table(['table' => 'articles']); + $this->assertSame($table, $table->addBehavior('Sluggable', ['test' => 'value'])); + $this->assertSame($table, $table->addBehavior('Sluggable', ['test' => 'value'])); + try { + $table->addBehavior('Sluggable', ['thing' => 'thing']); + $this->fail('No exception raised'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('The `Sluggable` alias has already been loaded', $e->getMessage()); + } + } + + /** + * Test removing a behavior from a table. + */ + public function testRemoveBehavior(): void + { + $mock = $this->getMockBuilder(BehaviorRegistry::class) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects($this->once()) + ->method('unload') + ->with('Sluggable'); + + $table = new Table([ + 'table' => 'articles', + 'behaviors' => $mock, + ]); + $result = $table->removeBehavior('Sluggable'); + $this->assertSame($table, $result); + } + + /** + * Test removing a behavior from a table clears the method map for the behavior + */ + public function testRemoveBehaviorMethodMapCleared(): void + { + $this->deprecated(function (): void { + $table = new Table(['table' => 'articles']); + $table->addBehavior('Sluggable'); + $this->assertTrue($table->behaviors()->hasMethod('slugify'), 'slugify should be mapped'); + $this->assertSame('foo-bar', $table->slugify('foo bar')); + + $table->removeBehavior('Sluggable'); + $this->assertFalse($table->behaviors()->hasMethod('slugify'), 'slugify should not be callable'); + }); + } + + /** + * Test adding multiple behaviors to a table. + */ + public function testAddBehaviors(): void + { + $table = new Table(['table' => 'comments']); + $behaviors = [ + 'Sluggable', + 'Timestamp' => [ + 'events' => [ + 'Model.beforeSave' => [ + 'created' => 'new', + 'updated' => 'always', + ], + ], + ], + ]; + + $this->assertSame($table, $table->addBehaviors($behaviors)); + $this->assertTrue($table->behaviors()->has('Sluggable')); + $this->assertTrue($table->behaviors()->has('Timestamp')); + $this->assertSame( + $behaviors['Timestamp']['events'], + $table->behaviors()->get('Timestamp')->getConfig('events'), + ); + } + + /** + * Test getting a behavior instance from a table. + */ + public function testBehaviors(): void + { + $table = $this->getTableLocator()->get('article'); + $result = $table->behaviors(); + $this->assertInstanceOf(BehaviorRegistry::class, $result); + } + + /** + * Test that the getBehavior() method retrieves a behavior from the table registry. + */ + public function testGetBehavior(): void + { + $table = new Table(['table' => 'comments']); + $table->addBehavior('Sluggable'); + $this->assertSame($table->behaviors()->get('Sluggable'), $table->getBehavior('Sluggable')); + } + + /** + * Test that the getBehavior() method will throw an exception when you try to + * get a behavior that does not exist. + */ + public function testGetBehaviorThrowsExceptionForMissingBehavior(): void + { + $table = new Table(['table' => 'comments']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `Sluggable` behavior is not defined on `' . $table::class . '`.'); + + $this->assertFalse($table->hasBehavior('Sluggable')); + $table->getBehavior('Sluggable'); + } + + /** + * Ensure exceptions are raised on missing behaviors. + */ + public function testAddBehaviorMissing(): void + { + $this->expectException(MissingBehaviorException::class); + $table = $this->getTableLocator()->get('article'); + $this->assertNull($table->addBehavior('NopeNotThere')); + } + + /** + * Test mixin methods from behaviors. + */ + public function testCallBehaviorMethod(): void + { + $this->deprecated(function (): void { + $table = $this->getTableLocator()->get('article'); + $table->addBehavior('Sluggable'); + $this->assertSame('some-value', $table->slugify('some value')); + }); + } + + /** + * Test you can alias a behavior method + */ + public function testCallBehaviorAliasedMethod(): void + { + $this->deprecated(function (): void { + $table = $this->getTableLocator()->get('article'); + $table->addBehavior('Sluggable', ['implementedMethods' => ['wednesday' => 'slugify']]); + $this->assertSame('some-value', $table->wednesday('some value')); + }); + } + + /** + * Test finder methods from behaviors. + */ + public function testCallBehaviorFinder(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->addBehavior('Sluggable'); + + $query = $table->find('noSlug'); + $this->assertInstanceOf(Query::class, $query); + $this->assertNotEmpty($query->clause('where')); + } + + /** + * testCallBehaviorAliasedFinder + */ + public function testCallBehaviorAliasedFinder(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->addBehavior('Sluggable', ['implementedFinders' => ['special' => 'findNoSlug']]); + + $query = $table->find('special'); + $this->assertInstanceOf(Query::class, $query); + $this->assertNotEmpty($query->clause('where')); + } + + /** + * Tests that it is possible to insert a new row using the save method + */ + public function testSaveNewEntity(): void + { + $entity = new Entity([ + 'username' => 'superuser', + 'password' => 'root', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $table = $this->getTableLocator()->get('users'); + $this->assertSame($entity, $table->save($entity)); + $this->assertEquals($entity->id, self::$nextUserId); + + $row = $table->find()->where(['id' => self::$nextUserId])->first(); + $this->assertEquals($entity->toArray(), $row->toArray()); + } + + /** + * Test that saving a new empty entity does nothing. + */ + public function testSaveNewEmptyEntity(): void + { + $entity = new Entity(); + $table = $this->getTableLocator()->get('users'); + $this->assertFalse($table->save($entity)); + } + + /** + * Test that saving a new empty entity does not call exists. + */ + public function testSaveNewEntityNoExists(): void + { + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['exists']) + ->setConstructorArgs([[ + 'connection' => $this->connection, + 'alias' => 'Users', + 'table' => 'users', + ]]) + ->getMock(); + $entity = $table->newEntity(['username' => 'mark']); + $this->assertTrue($entity->isNew()); + + $table->expects($this->never()) + ->method('exists'); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Test that saving a new entity with a Primary Key set does call exists. + */ + public function testSavePrimaryKeyEntityExists(): void + { + $this->skipIfSqlServer(); + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['exists']) + ->setConstructorArgs([[ + 'connection' => $this->connection, + 'alias' => 'Users', + 'table' => 'users', + ]]) + ->getMock(); + $entity = $table->newEntity(['id' => 20, 'username' => 'mark']); + $this->assertTrue($entity->isNew()); + + $table->expects($this->once())->method('exists'); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Test that saving a new entity with a Primary Key set does not call exists when checkExisting is false. + */ + public function testSavePrimaryKeyEntityNoExists(): void + { + $this->skipIfSqlServer(); + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['exists']) + ->setConstructorArgs([[ + 'connection' => $this->connection, + 'alias' => 'Users', + 'table' => 'users', + ]]) + ->getMock(); + $entity = $table->newEntity(['id' => 20, 'username' => 'mark']); + $this->assertTrue($entity->isNew()); + + $table->expects($this->never())->method('exists'); + $this->assertSame($entity, $table->save($entity, ['checkExisting' => false])); + } + + /** + * Tests that saving an entity will filter out properties that + * are not present in the table schema when saving + */ + public function testSaveEntityOnlySchemaFields(): void + { + $entity = new Entity([ + 'username' => 'superuser', + 'password' => 'root', + 'crazyness' => 'super crazy value', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $table = $this->getTableLocator()->get('users'); + $this->assertSame($entity, $table->save($entity)); + $this->assertEquals($entity->id, self::$nextUserId); + + $row = $table->find('all')->where(['id' => self::$nextUserId])->first(); + $entity->unset('crazyness'); + $this->assertEquals($entity->toArray(), $row->toArray()); + } + + /** + * Tests that it is possible to modify data from the beforeSave callback + */ + public function testBeforeSaveModifyData(): void + { + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $listener = function ($event, EntityInterface $entity, $options) use ($data): void { + $this->assertSame($data, $entity); + $entity->set('password', 'foo'); + }; + $table->getEventManager()->on('Model.beforeSave', $listener); + $this->assertSame($data, $table->save($data)); + $this->assertEquals($data->id, self::$nextUserId); + $row = $table->find('all')->where(['id' => self::$nextUserId])->first(); + $this->assertSame('foo', $row->get('password')); + } + + /** + * Tests that it is possible to modify the options array in beforeSave + */ + public function testBeforeSaveModifyOptions(): void + { + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'password' => 'foo', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $listener1 = function ($event, $entity, $options): void { + $options['crazy'] = true; + }; + $listener2 = function ($event, $entity, $options): void { + $this->assertTrue($options['crazy']); + }; + $table->getEventManager()->on('Model.beforeSave', $listener1); + $table->getEventManager()->on('Model.beforeSave', $listener2); + $this->assertSame($data, $table->save($data)); + $this->assertEquals($data->id, self::$nextUserId); + + $row = $table->find('all')->where(['id' => self::$nextUserId])->first(); + $this->assertEquals($data->toArray(), $row->toArray()); + } + + /** + * Tests that it is possible to stop the saving altogether, without implying + * the save operation failed + */ + public function testBeforeSaveStopEvent(): void + { + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $listener = function (EventInterface $event, $entity): void { + $event->stopPropagation(); + $event->setResult($entity); + }; + $table->getEventManager()->on('Model.beforeSave', $listener); + $this->assertSame($data, $table->save($data)); + $this->assertNull($data->id); + $row = $table->find('all')->where(['id' => self::$nextUserId])->first(); + $this->assertNull($row); + } + + /** + * Tests that if beforeSave event is stopped and callback doesn't return any + * value then save() returns false. + */ + public function testBeforeSaveStopEventWithNoResult(): void + { + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $listener = function (EventInterface $event, $entity): void { + $event->stopPropagation(); + }; + $table->getEventManager()->on('Model.beforeSave', $listener); + $this->assertFalse($table->save($data)); + } + + public function testBeforeSaveException(): void + { + $this->expectException(AssertionError::class); + $this->expectExceptionMessage('The result for the `Model.beforeSave` event must be `false` or `EntityInterface` instance. Got `int` instead.'); + + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $listener = function (EventInterface $event, $entity): void { + $event->stopPropagation(); + $event->setResult(1); + }; + $table->getEventManager()->on('Model.beforeSave', $listener); + $table->save($data); + } + + /** + * Asserts that afterSave callback is called on successful save + */ + public function testAfterSave(): void + { + $table = $this->getTableLocator()->get('users'); + $data = $table->get(1); + + $data->username = 'newusername'; + + $called = false; + $listener = function ($e, EntityInterface $entity, $options) use ($data, &$called): void { + $this->assertSame($data, $entity); + $this->assertTrue($entity->isDirty()); + $called = true; + }; + $table->getEventManager()->on('Model.afterSave', $listener); + + $calledAfterCommit = false; + $listenerAfterCommit = function ($e, EntityInterface $entity, $options) use ($data, &$calledAfterCommit): void { + $this->assertSame($data, $entity); + $this->assertTrue($entity->isDirty()); + $this->assertNotSame($data->get('username'), $data->getOriginal('username')); + $calledAfterCommit = true; + }; + $table->getEventManager()->on('Model.afterSaveCommit', $listenerAfterCommit); + + $this->assertSame($data, $table->save($data)); + $this->assertTrue($called); + $this->assertTrue($calledAfterCommit); + } + + /** + * Asserts that afterSaveCommit is also triggered for non-atomic saves + */ + public function testAfterSaveCommitForNonAtomic(): void + { + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + + $called = false; + $listener = function ($e, $entity, $options) use ($data, &$called): void { + $this->assertSame($data, $entity); + $called = true; + }; + $table->getEventManager()->on('Model.afterSave', $listener); + + $calledAfterCommit = false; + $listenerAfterCommit = function ($e, $entity, $options) use (&$calledAfterCommit): void { + $calledAfterCommit = true; + }; + $table->getEventManager()->on('Model.afterSaveCommit', $listenerAfterCommit); + + $this->assertSame($data, $table->save($data, ['atomic' => false])); + $this->assertEquals($data->id, self::$nextUserId); + $this->assertTrue($called); + $this->assertTrue($calledAfterCommit); + } + + /** + * Asserts the afterSaveCommit is not triggered if transaction is running. + */ + public function testAfterSaveCommitWithTransactionRunning(): void + { + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + + $called = false; + $listener = function ($e, $entity, $options) use (&$called): void { + $called = true; + }; + $table->getEventManager()->on('Model.afterSaveCommit', $listener); + + $this->connection->begin(); + $this->assertSame($data, $table->save($data)); + $this->assertFalse($called); + $this->connection->commit(); + } + + /** + * Asserts the afterSaveCommit is not triggered if transaction is running. + */ + public function testAfterSaveCommitWithNonAtomicAndTransactionRunning(): void + { + $table = $this->getTableLocator()->get('users'); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + + $called = false; + $listener = function ($e, $entity, $options) use (&$called): void { + $called = true; + }; + $table->getEventManager()->on('Model.afterSaveCommit', $listener); + + $this->connection->begin(); + $this->assertSame($data, $table->save($data, ['atomic' => false])); + $this->assertFalse($called); + $this->connection->commit(); + } + + /** + * Asserts that afterSave callback not is called on unsuccessful save + */ + public function testAfterSaveNotCalled(): void + { + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['insertQuery']) + ->setConstructorArgs([['table' => 'users']]) + ->getMock(); + $query = $this->getMockBuilder(InsertQuery::class) + ->onlyMethods(['execute', 'addDefaultTypes']) + ->setConstructorArgs([$table]) + ->getMock(); + $statement = Mockery::mock(StatementInterface::class); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + + $table->expects($this->once())->method('insertQuery') + ->willReturn($query); + + $query->expects($this->once())->method('execute') + ->willReturn($statement); + + $statement->shouldReceive('rowCount') + ->once() + ->andReturn(0); + + $called = false; + $listener = function ($e, $entity, $options) use (&$called): void { + $called = true; + }; + $table->getEventManager()->on('Model.afterSave', $listener); + + $calledAfterCommit = false; + $listenerAfterCommit = function ($e, $entity, $options) use (&$calledAfterCommit): void { + $calledAfterCommit = true; + }; + $table->getEventManager()->on('Model.afterSaveCommit', $listenerAfterCommit); + + $this->assertFalse($table->save($data)); + $this->assertFalse($called); + $this->assertFalse($calledAfterCommit); + } + + /** + * Asserts that afterSaveCommit callback is triggered only for primary table + */ + public function testAfterSaveCommitTriggeredOnlyForPrimaryTable(): void + { + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + $entity->author = new Entity([ + 'name' => 'Jose', + ]); + + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + + $calledForArticle = false; + $listenerForArticle = function ($e, $entity, $options) use (&$calledForArticle): void { + $calledForArticle = true; + }; + $table->getEventManager()->on('Model.afterSaveCommit', $listenerForArticle); + + $calledForAuthor = false; + $listenerForAuthor = function ($e, $entity, $options) use (&$calledForAuthor): void { + $calledForAuthor = true; + }; + $table->authors->getEventManager()->on('Model.afterSaveCommit', $listenerForAuthor); + + $this->assertSame($entity, $table->save($entity)); + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->author->isNew()); + $this->assertTrue($calledForArticle); + $this->assertFalse($calledForAuthor); + } + + /** + * Test that you cannot save rows without a primary key. + */ + public function testSaveNewErrorOnNoPrimaryKey(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Cannot insert row in `users` table, it has no primary key'); + $entity = new Entity(['username' => 'superuser']); + $table = $this->getTableLocator()->get('users', [ + 'schema' => [ + 'id' => ['type' => 'integer'], + 'username' => ['type' => 'string'], + ], + ]); + $table->save($entity); + } + + /** + * Tests that save is wrapped around a transaction + */ + public function testAtomicSave(): void + { + $config = ConnectionManager::getConfig('test'); + + $connection = $this->getMockBuilder(Connection::class) + ->onlyMethods(['begin', 'commit', 'inTransaction']) + ->setConstructorArgs([['driver' => $this->connection->getDriver()] + $config]) + ->getMock(); + + $table = new Table(['table' => 'users', 'connection' => $connection]); + + $connection->expects($this->once())->method('begin'); + $connection->expects($this->once())->method('commit'); + $connection->method('inTransaction')->willReturn(true); + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $this->assertSame($data, $table->save($data)); + } + + /** + * Tests that save will rollback the transaction in the case of an exception + */ + public function testAtomicSaveRollback(): void + { + $this->expectException(PDOException::class); + /** @var \Cake\Database\Connection|\PHPUnit\Framework\MockObject\MockObject $connection */ + $connection = $this->getMockBuilder(Connection::class) + ->onlyMethods(['begin', 'rollback']) + ->setConstructorArgs([['driver' => $this->connection->getDriver()] + ConnectionManager::getConfig('test')]) + ->getMock(); + + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['insertQuery', 'getConnection']) + ->setConstructorArgs([['table' => 'users']]) + ->getMock(); + $query = $this->getMockBuilder(InsertQuery::class) + ->onlyMethods(['execute', 'addDefaultTypes']) + ->setConstructorArgs([$table]) + ->getMock(); + $table->method('getConnection') + ->willReturn($connection); + + $table->expects($this->once())->method('insertQuery') + ->willReturn($query); + + $connection->expects($this->once())->method('begin'); + $connection->expects($this->once())->method('rollback'); + $query->expects($this->once())->method('execute') + ->will($this->throwException(new PDOException())); + + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $table->save($data); + } + + /** + * Tests that save will rollback the transaction in the case of an exception + */ + public function testAtomicSaveRollbackOnFailure(): void + { + /** @var \Cake\Database\Connection|\PHPUnit\Framework\MockObject\MockObject $connection */ + $connection = $this->getMockBuilder(Connection::class) + ->onlyMethods(['begin', 'rollback']) + ->setConstructorArgs([['driver' => $this->connection->getDriver()] + ConnectionManager::getConfig('test')]) + ->getMock(); + + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['insertQuery', 'getConnection', 'exists']) + ->setConstructorArgs([['table' => 'users']]) + ->getMock(); + $query = $this->getMockBuilder(InsertQuery::class) + ->onlyMethods(['execute', 'addDefaultTypes']) + ->setConstructorArgs([$table]) + ->getMock(); + + $table->method('getConnection') + ->willReturn($connection); + + $table->expects($this->once())->method('insertQuery') + ->willReturn($query); + + $statement = Mockery::mock(StatementInterface::class); + $statement->shouldReceive('rowCount') + ->once() + ->andReturn(0); + $connection->expects($this->once())->method('begin'); + $connection->expects($this->once())->method('rollback'); + $query->expects($this->once()) + ->method('execute') + ->willReturn($statement); + + $data = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $table->save($data); + } + + /** + * Tests that only the properties marked as dirty are actually saved + * to the database + */ + public function testSaveOnlyDirtyProperties(): void + { + $entity = new Entity([ + 'username' => 'superuser', + 'password' => 'root', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $entity->clean(); + $entity->setDirty('username', true); + $entity->setDirty('created', true); + $entity->setDirty('updated', true); + + $table = $this->getTableLocator()->get('users'); + $this->assertSame($entity, $table->save($entity)); + $this->assertEquals($entity->id, self::$nextUserId); + + $row = $table->find('all')->where(['id' => self::$nextUserId])->first(); + $entity->set('password'); + $this->assertEquals($entity->toArray(), $row->toArray()); + } + + /** + * Tests that a recently saved entity is marked as clean + */ + public function testASavedEntityIsClean(): void + { + $entity = new Entity([ + 'username' => 'superuser', + 'password' => 'root', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $table = $this->getTableLocator()->get('users'); + $this->assertSame($entity, $table->save($entity)); + $this->assertFalse($entity->isDirty('usermane')); + $this->assertFalse($entity->isDirty('password')); + $this->assertFalse($entity->isDirty('created')); + $this->assertFalse($entity->isDirty('updated')); + } + + /** + * Tests that a recently saved entity is marked as not new + */ + public function testASavedEntityIsNotNew(): void + { + $entity = new Entity([ + 'username' => 'superuser', + 'password' => 'root', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ]); + $table = $this->getTableLocator()->get('users'); + $this->assertSame($entity, $table->save($entity)); + $this->assertFalse($entity->isNew()); + } + + /** + * Tests that save can detect automatically if it needs to insert + * or update a row + */ + public function testSaveUpdateAuto(): void + { + $entity = new Entity([ + 'id' => 2, + 'username' => 'baggins', + ]); + $table = $this->getTableLocator()->get('users'); + $original = $table->find('all')->where(['id' => 2])->first(); + $this->assertSame($entity, $table->save($entity)); + + $row = $table->find('all')->where(['id' => 2])->first(); + $this->assertSame('baggins', $row->username); + $this->assertEquals($original->password, $row->password); + $this->assertEquals($original->created, $row->created); + $this->assertEquals($original->updated, $row->updated); + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->isDirty('id')); + $this->assertFalse($entity->isDirty('username')); + } + + /** + * Tests that beforeFind gets the correct isNew() state for the entity + */ + public function testBeforeSaveGetsCorrectPersistance(): void + { + $entity = new Entity([ + 'id' => 2, + 'username' => 'baggins', + ]); + $table = $this->getTableLocator()->get('users'); + $called = false; + $listener = function (EventInterface $event, $entity) use (&$called): void { + $this->assertFalse($entity->isNew()); + $called = true; + }; + $table->getEventManager()->on('Model.beforeSave', $listener); + $this->assertSame($entity, $table->save($entity)); + $this->assertTrue($called); + } + + /** + * Tests that marking an entity as already persisted will prevent the save + * method from trying to infer the entity's actual status. + */ + public function testSaveUpdateWithHint(): void + { + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['exists']) + ->setConstructorArgs([['table' => 'users', 'connection' => ConnectionManager::get('test')]]) + ->getMock(); + $entity = new Entity([ + 'id' => 2, + 'username' => 'baggins', + ], ['markNew' => false]); + $this->assertFalse($entity->isNew()); + $table->expects($this->never())->method('exists'); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests that when updating the primary key is not passed to the list of + * attributes to change + */ + public function testSaveUpdatePrimaryKeyNotModified(): void + { + /** @var \Cake\Database\Connection|\PHPUnit\Framework\MockObject\MockObject $connection */ + $connection = $this->getMockBuilder(Connection::class) + ->onlyMethods(['run']) + ->setConstructorArgs([['driver' => $this->connection->getDriver()] + ConnectionManager::getConfig('test')]) + ->getMock(); + $table = $this->fetchTable('Users'); + $table->setConnection($connection); + + $statement = $this->getMockBuilder(StatementInterface::class)->getMock(); + $statement->expects($this->once()) + ->method('errorCode') + ->willReturn('00000'); + + $connection->expects($this->once())->method('run') + ->willReturn($statement); + + $entity = new Entity([ + 'id' => 2, + 'username' => 'baggins', + ], ['markNew' => false]); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests that passing only the primary key to save will not execute any queries + * but still return success + */ + public function testUpdateNoChange(): void + { + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['query']) + ->setConstructorArgs([['table' => 'users', 'connection' => $this->connection]]) + ->getMock(); + $table->expects($this->never())->method('query'); + $entity = new Entity([ + 'id' => 2, + ], ['markNew' => false]); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests that passing only the primary key to save will not execute any queries + * but still return success + */ + public function testUpdateDirtyNoActualChanges(): void + { + $table = $this->getTableLocator()->get('Articles'); + $entity = $table->get(1); + + $entity->setAccess('*', true); + $entity->patch($entity->toArray()); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Tests that failing to pass a primary key to save will result in exception + */ + public function testUpdateNoPrimaryButOtherKeys(): void + { + $this->expectException(InvalidArgumentException::class); + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['query']) + ->setConstructorArgs([['table' => 'users', 'connection' => $this->connection]]) + ->getMock(); + $table->expects($this->never())->method('query'); + $entity = new Entity([ + 'username' => 'mariano', + ], ['markNew' => false]); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Test saveMany() with entities array + */ + public function testSaveManyArray(): void + { + $entities = [ + new Entity(['name' => 'admad']), + new Entity(['name' => 'dakota']), + ]; + + $timesCalled = 0; + $listener = function ($e, $entity, $options) use (&$timesCalled): void { + $timesCalled++; + }; + $table = $this->getTableLocator() + ->get('authors'); + + $table->getEventManager() + ->on('Model.afterSaveCommit', $listener); + + $result = $table->saveMany($entities); + + $this->assertSame($entities, $result); + $this->assertTrue(isset($result[0]->id)); + foreach ($entities as $entity) { + $this->assertFalse($entity->isNew()); + } + $this->assertSame(2, $timesCalled); + } + + /** + * Test saveMany() with ResultSet instance + */ + public function testSaveManyResultSet(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->Articles->setSort('Articles.id'); + + $entities = $table->find() + ->orderBy(['id' => 'ASC']) + ->contain(['Articles']) + ->all(); + $entities->first()->name = 'admad'; + $entities->first()->articles[0]->title = 'First Article Edited'; + + $listener = function (EventInterface $event, EntityInterface $entity, $options): void { + if ($entity->id === 1) { + $this->assertTrue($entity->isDirty()); + + $this->assertSame('admad', $entity->name); + $this->assertSame('mariano', $entity->getOriginal('name')); + + $this->assertSame('First Article Edited', $entity->articles[0]->title); + $this->assertSame('First Article', $entity->articles[0]->getOriginal('title')); + } else { + $this->assertFalse($entity->isDirty()); + } + }; + $table = $this->getTableLocator() + ->get('authors'); + + $table->getEventManager() + ->on('Model.afterSaveCommit', $listener); + + $result = $table->saveMany($entities); + $this->assertSame($entities, $result); + $this->assertFalse($result->first()->isDirty()); + $this->assertFalse($result->first()->articles[0]->isDirty()); + + $first = $table->find() + ->orderBy(['id' => 'ASC']) + ->first(); + $this->assertSame('admad', $first->name); + } + + /** + * Test saveMany() with failed save + */ + public function testSaveManyFailed(): void + { + $table = $this->getTableLocator()->get('authors'); + $expectedCount = $table->find()->count(); + $entities = [ + new Entity(['name' => 'mark']), + new Entity(['name' => 'jose']), + ]; + $entities[1]->setErrors(['name' => ['message']]); + $result = $table->saveMany($entities); + + $this->assertFalse($result); + $this->assertSame($expectedCount, $table->find()->count()); + foreach ($entities as $entity) { + $this->assertTrue($entity->isNew()); + } + } + + /** + * Test saveMany() with failed save due to an exception + */ + public function testSaveManyFailedWithException(): void + { + $table = $this->getTableLocator() + ->get('authors'); + $entities = [ + new Entity(['name' => 'mark']), + new Entity(['name' => 'jose']), + ]; + + $table->getEventManager()->on('Model.beforeSave', function (EventInterface $event, EntityInterface $entity): void { + if ($entity->name === 'jose') { + throw new Exception('Oh noes'); + } + }); + + $this->expectException(Exception::class); + + try { + $table->saveMany($entities); + } finally { + foreach ($entities as $entity) { + $this->assertTrue($entity->isNew()); + } + } + } + + /** + * Test saveManyOrFail() with entities array + */ + public function testSaveManyOrFailArray(): void + { + $entities = [ + new Entity(['name' => 'admad']), + new Entity(['name' => 'dakota']), + ]; + + $table = $this->getTableLocator()->get('authors'); + $result = $table->saveManyOrFail($entities); + + $this->assertSame($entities, $result); + $this->assertTrue(isset($result[0]->id)); + foreach ($entities as $entity) { + $this->assertFalse($entity->isNew()); + } + } + + /** + * Test saveManyOrFail() with ResultSet instance + */ + public function testSaveManyOrFailResultSet(): void + { + $table = $this->getTableLocator()->get('authors'); + + $entities = $table->find() + ->orderBy(['id' => 'ASC']) + ->all(); + $entities->first()->name = 'admad'; + + $result = $table->saveManyOrFail($entities); + $this->assertSame($entities, $result); + + $first = $table->find() + ->orderBy(['id' => 'ASC']) + ->first(); + $this->assertSame('admad', $first->name); + } + + /** + * Test saveManyOrFail() with failed save + */ + public function testSaveManyOrFailFailed(): void + { + $table = $this->getTableLocator()->get('authors'); + $entities = [ + new Entity(['name' => 'mark']), + new Entity(['name' => 'jose']), + ]; + $entities[1]->setErrors(['name' => ['message']]); + + $this->expectException(PersistenceFailedException::class); + + $table->saveManyOrFail($entities); + } + + public function testSaveWithBuildRulesFailWithErrorMessage(): void + { + $Articles = new class extends Table { + public function initialize(array $config): void + { + $this->setAlias('Articles'); + $this->setTable('articles'); + $this->hasMany('Comments'); + } + }; + $Comments = new class extends Table { + public function initialize(array $config): void + { + $this->setAlias('Comments'); + $this->setTable('comments'); + } + + public function buildRules(RulesChecker $rules): RulesChecker + { + return $rules->add(function () { + return 'Xyz'; + }); + } + }; + TableRegistry::getTableLocator()->set('Comments', $Comments); + + $article = $Articles->newEntity([ + 'title' => 'First Article', + 'body' => 'First Article Body', + 'published' => 'Y', + 'comments' => [ + '_ids' => [1], + ], + ]); + + $result = $Articles->save($article, ['associated' => ['Comments']]); + $this->assertFalse($result); + + // There should be errors here, due to comment not being savable. + $errors = $article->getErrors(); + $this->assertNotEmpty($errors); + $this->assertArrayHasKey('comments', $errors); + $this->assertArrayHasKey(0, $errors['comments']); + $this->assertArrayHasKey('_rule', $errors['comments'][0]); + $this->assertSame(['Xyz'], $errors['comments'][0]['_rule']); + } + + /** + * Test simple delete. + */ + public function testDelete(): void + { + $table = $this->getTableLocator()->get('users'); + $options = [ + 'limit' => 1, + 'conditions' => [ + 'username' => 'nate', + ], + ]; + $query = $table->find('all', ...$options); + $entity = $query->first(); + $result = $table->delete($entity); + $this->assertTrue($result); + + $query = $table->find('all', ...$options); + $this->assertCount(0, $query->all(), 'Find should fail.'); + } + + /** + * Test delete with dependent records + */ + public function testDeleteDependent(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->Articles->setDependent(true); + + $entity = $table->get(1); + $table->delete($entity); + + $articles = $table->getAssociation('Articles')->getTarget(); + $query = $articles->find('all', conditions: ['author_id' => $entity->id]); + $this->assertNull($query->all()->first(), 'Should not find any rows.'); + } + + /** + * Test delete with dependent records + */ + public function testDeleteDependentHasMany(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->Articles + ->setDependent(true) + ->setCascadeCallbacks(true); + + $articles = $table->getAssociation('Articles')->getTarget(); + $articles->getEventManager()->on('Model.buildRules', function ($event, $rules): void { + $rules->addDelete(function ($entity) { + if ($entity->author_id === 3) { + return false; + } + + return true; + }); + }); + + $entity = $table->get(1); + $result = $table->delete($entity); + $this->assertTrue($result); + + $query = $articles->find('all', conditions: ['author_id' => $entity->id]); + $this->assertNull($query->all()->first(), 'Should not find any rows.'); + + $entity = $table->get(3); + $result = $table->delete($entity); + $this->assertFalse($result); + + $query = $articles->find('all', conditions: ['author_id' => $entity->id]); + $this->assertFalse($query->all()->isEmpty(), 'Should find some rows.'); + + $table->associations()->get('Articles')->setCascadeCallbacks(false); + $entity = $table->get(2); + $result = $table->delete($entity); + $this->assertTrue($result); + } + + /** + * Test delete with dependent = false does not cascade. + */ + public function testDeleteNoDependentNoCascade(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->hasMany('article', [ + 'dependent' => false, + ]); + + $query = $table->find('all')->where(['id' => 1]); + $entity = $query->first(); + $table->delete($entity); + + $articles = $table->getAssociation('Articles')->getTarget(); + $query = $articles->find('all')->where(['author_id' => $entity->id]); + $this->assertCount(2, $query->all(), 'Should find rows.'); + } + + /** + * Test delete with BelongsToMany + */ + public function testDeleteBelongsToMany(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsToMany('tag', [ + 'foreignKey' => 'article_id', + 'joinTable' => 'articles_tags', + ]); + $query = $table->find('all')->where(['id' => 1]); + $entity = $query->first(); + $table->delete($entity); + + $junction = $table->getAssociation('tag')->junction(); + $query = $junction->find('all')->where(['article_id' => 1]); + $this->assertNull($query->all()->first(), 'Should not find any rows.'); + } + + /** + * Test delete with dependent records belonging to an aliased + * belongsToMany association. + */ + public function testDeleteDependentAliased(): void + { + $Authors = $this->getTableLocator()->get('authors'); + $Authors->associations()->removeAll(); + $Articles = $this->getTableLocator()->get('articles'); + $Articles->associations()->removeAll(); + + $Authors->hasMany('AliasedArticles', [ + 'className' => 'Articles', + 'dependent' => true, + 'cascadeCallbacks' => true, + ]); + $Articles->belongsToMany('Tags'); + + $author = $Authors->get(1); + $result = $Authors->delete($author); + + $this->assertTrue($result); + } + + /** + * Test that cascading associations are deleted first. + */ + public function testDeleteAssociationsCascadingCallbacksOrder(): void + { + $sections = $this->getTableLocator()->get('Sections'); + $members = $this->getTableLocator()->get('Members'); + $sectionsMembers = $this->getTableLocator()->get('SectionsMembers'); + + $sections->belongsToMany('Members', [ + 'joinTable' => 'sections_members', + ]); + $sections->hasMany('SectionsMembers', [ + 'dependent' => true, + 'cascadeCallbacks' => true, + ]); + $sectionsMembers->belongsTo('Members'); + $sectionsMembers->addBehavior('CounterCache', [ + 'Members' => ['section_count'], + ]); + + $member = $members->get(1); + $this->assertSame(2, $member->section_count); + + $section = $sections->get(1); + $sections->delete($section); + + $member = $members->get(1); + $this->assertSame(1, $member->section_count); + } + + /** + * Test that primary record is not deleted if junction record deletion fails + * when cascadeCallbacks is enabled. + */ + public function testDeleteBelongsToManyDependentFailure(): void + { + $sections = $this->getTableLocator()->get('Sections'); + $sectionsMembers = $this->getTableLocator()->get('SectionsMembers'); + $sectionsMembers->getEventManager()->on('Model.buildRules', function ($event, $rules): void { + $rules->addDelete(function () { + return false; + }); + }); + + $sections->belongsToMany('Members', [ + 'joinTable' => 'sections_members', + 'dependent' => true, + 'cascadeCallbacks' => true, + ]); + + $section = $sections->get(1, contain: 'Members'); + $this->assertSame(1, count($section->members)); + + $this->assertFalse($sections->delete($section)); + + $section = $sections->get(1, contain: 'Members'); + $this->assertSame(1, count($section->members)); + } + + /** + * Test delete callbacks + */ + public function testDeleteCallbacks(): void + { + $entity = new Entity(['id' => 1, 'name' => 'mark']); + $options = new ArrayObject(['atomic' => true, 'checkRules' => false, '_primary' => true]); + + $mock = Mockery::mock(EventManager::class); + + $mock->shouldReceive('on'); + + $mock->shouldReceive('dispatch') + ->withAnyArgs() + ->once(); + + $mock->shouldReceive('dispatch') + ->withArgs(function (EventInterface $event) use ($entity, $options) { + $this->assertSame('Model.beforeDelete', $event->getName()); + $this->assertEquals(['entity' => $entity, 'options' => $options], $event->getData()); + + return true; + }) + ->once(); + + $mock->shouldReceive('dispatch') + ->withArgs(function (EventInterface $event) use ($entity, $options) { + $this->assertSame('Model.afterDelete', $event->getName()); + $this->assertEquals(['entity' => $entity, 'options' => $options], $event->getData()); + + return true; + }) + ->once(); + + $mock->shouldReceive('dispatch') + ->withArgs(function (EventInterface $event) use ($entity, $options) { + $this->assertSame('Model.afterDeleteCommit', $event->getName()); + $this->assertEquals(['entity' => $entity, 'options' => $options], $event->getData()); + + return true; + }) + ->once(); + + $table = $this->getTableLocator()->get('users', ['eventManager' => $mock]); + $entity->setNew(false); + $table->delete($entity, ['checkRules' => false]); + } + + /** + * Test afterDeleteCommit is also called for non-atomic delete + */ + public function testDeleteCallbacksNonAtomic(): void + { + $table = $this->getTableLocator()->get('users'); + + $data = $table->get(1); + + $called = false; + $listener = function ($e, $entity, $options) use ($data, &$called): void { + $this->assertSame($data, $entity); + $called = true; + }; + $table->getEventManager()->on('Model.afterDelete', $listener); + + $calledAfterCommit = false; + $listenerAfterCommit = function ($e, $entity, $options) use (&$calledAfterCommit): void { + $calledAfterCommit = true; + }; + $table->getEventManager()->on('Model.afterDeleteCommit', $listenerAfterCommit); + + $table->delete($data, ['atomic' => false]); + $this->assertTrue($called); + $this->assertTrue($calledAfterCommit); + } + + /** + * Test that afterDeleteCommit is only triggered for primary table + */ + public function testAfterDeleteCommitTriggeredOnlyForPrimaryTable(): void + { + $table = $this->getTableLocator()->get('authors'); + $table->Articles->setDependent(true); + + $called = false; + $listener = function ($e, $entity, $options) use (&$called): void { + $called = true; + }; + $table->getEventManager()->on('Model.afterDeleteCommit', $listener); + + $called2 = false; + $listener = function ($e, $entity, $options) use (&$called2): void { + $called2 = true; + }; + $table->Articles->getEventManager()->on('Model.afterDeleteCommit', $listener); + + $entity = $table->get(1); + $this->assertTrue($table->delete($entity)); + + $this->assertTrue($called); + $this->assertFalse($called2); + } + + /** + * Test delete beforeDelete can abort the delete. + */ + public function testDeleteBeforeDeleteAbort(): void + { + $entity = new Entity(['id' => 1, 'name' => 'mark']); + + $mock = $this->getMockBuilder(EventManager::class)->getMock(); + $mock->method('dispatch') + ->willReturnCallback(function (EventInterface $event) { + $event->stopPropagation(); + + return $event; + }); + + $table = $this->getTableLocator()->get('users', ['eventManager' => $mock]); + $entity->setNew(false); + $result = $table->delete($entity, ['checkRules' => false]); + $this->assertFalse($result); + } + + /** + * Test delete beforeDelete return result + */ + public function testDeleteBeforeDeleteReturnResult(): void + { + $entity = new Entity(['id' => 1, 'name' => 'mark']); + + $mock = $this->getMockBuilder(EventManager::class)->getMock(); + $mock->method('dispatch') + ->willReturnCallback(function (EventInterface $event) { + $event->stopPropagation(); + $event->setResult('got stopped'); + + return $event; + }); + + $table = $this->getTableLocator()->get('users', ['eventManager' => $mock]); + $entity->setNew(false); + $result = $table->delete($entity, ['checkRules' => false]); + $this->assertTrue($result); + } + + /** + * Test deleting new entities does nothing. + */ + public function testDeleteIsNew(): void + { + $entity = new Entity(['id' => 1, 'name' => 'mark']); + + /** @var \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject $table */ + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['query']) + ->setConstructorArgs([['connection' => $this->connection]]) + ->getMock(); + $table->expects($this->never()) + ->method('query'); + + $entity->setNew(true); + $result = $table->delete($entity); + $this->assertFalse($result); + } + + /** + * Test simple delete. + */ + public function testDeleteMany(): void + { + $table = $this->getTableLocator()->get('users'); + $entities = $table->find()->limit(2)->all()->toArray(); + $this->assertCount(2, $entities); + + $result = $table->deleteMany($entities); + $this->assertSame($entities, $result); + + $count = $table->find()->where(['id IN' => Hash::extract($entities, '{n}.id')])->count(); + $this->assertSame(0, $count, 'Find should not return > 0.'); + } + + /** + * Test simple delete. + */ + public function testDeleteManyOrFail(): void + { + $table = $this->getTableLocator()->get('users'); + $entities = $table->find()->limit(2)->all()->toArray(); + $this->assertCount(2, $entities); + + $result = $table->deleteManyOrFail($entities); + $this->assertSame($entities, $result); + + $count = $table->find()->where(['id IN' => Hash::extract($entities, '{n}.id')])->count(); + $this->assertSame(0, $count, 'Find should not return > 0.'); + } + + /** + * test hasField() + */ + public function testHasField(): void + { + $table = $this->getTableLocator()->get('articles'); + $this->assertFalse($table->hasField('nope'), 'Should not be there.'); + $this->assertTrue($table->hasField('title'), 'Should be there.'); + $this->assertTrue($table->hasField('body'), 'Should be there.'); + } + + /** + * Tests that there exists a default validator + */ + public function testValidatorDefault(): void + { + $table = new Table(); + $validator = $table->getValidator(); + $this->assertSame($table, $validator->getProvider('table')); + $this->assertInstanceOf(Validator::class, $validator); + $default = $table->getValidator('default'); + $this->assertSame($validator, $default); + } + + /** + * Tests that there exists a validator defined in a behavior. + */ + public function testValidatorBehavior(): void + { + $this->deprecated(function (): void { + $table = new Table(); + $table->addBehavior('Validation'); + + $validator = $table->getValidator('Behavior'); + $set = $validator->field('name'); + $this->assertArrayHasKey('behaviorRule', $set); + }); + } + + /** + * https://github.com/cakephp/cakephp/issues/18273 + */ + public function testValidatorWithMethodInBehavior(): void + { + $table = new Table(); + $table->addBehavior('Validation'); + + $table->getValidator('default')->add( + 'name', + 'customValidationRule', + ['rule' => 'customValidationRule', 'provider' => 'table'], + ); + + $result = $table->getValidator('default')->validate([ + 'name' => 'test', + ], true); + + $this->assertSame( + [ + 'name' => [ + 'customValidationRule' => 'The provided value is invalid', + ], + ], + $result, + ); + } + + /** + * Checks that entity is passed into context. + * + * @return void + */ + public function testValidatorWithEntityInContext(): void + { + $table = new class (['alias' => 'Users', 'table' => 'users', 'connection' => $this->connection]) extends Table { + public function validateMe(string $text, array $context): bool + { + if (!isset($context['entity'])) { + throw new InvalidArgumentException('Entity not found in context'); + } + + return true; + } + }; + + $table->getValidator('default')->add( + 'name', + 'validateMe', + ['rule' => 'validateMe', 'provider' => 'table'], + ); + + $entity = $table->newEmptyEntity(); + $result = $table->patchEntity($entity, [ + 'name' => 'test', + ]); + $this->assertSame('test', $result->get('name')); + } + + /** + * Tests that a InvalidArgumentException is thrown if the custom validator method does not exist. + */ + public function testValidatorWithMissingMethod(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `Cake\ORM\Table::validationMissing()` validation method does not exist.'); + $table = new Table(); + $table->getValidator('missing'); + } + + /** + * Tests that it is possible to set a custom validator under a name + */ + public function testValidatorSetter(): void + { + $table = new Table(); + $validator = new Validator(); + $table->setValidator('other', $validator); + $this->assertSame($validator, $table->getValidator('other')); + $this->assertSame($table, $validator->getProvider('table')); + } + + /** + * Tests hasValidator method. + */ + public function testHasValidator(): void + { + $table = new Table(); + $this->assertTrue($table->hasValidator('default')); + $this->assertFalse($table->hasValidator('other')); + + $validator = new Validator(); + $table->setValidator('other', $validator); + $this->assertTrue($table->hasValidator('other')); + } + + /** + * Tests that the source of an existing Entity is the same as a new one + */ + public function testEntitySourceExistingAndNew(): void + { + $this->loadPlugins(['TestPlugin']); + $table = $this->getTableLocator()->get('TestPlugin.Authors'); + + $existingAuthor = $table->find()->first(); + $newAuthor = $table->newEmptyEntity(); + + $this->assertSame('TestPlugin.Authors', $existingAuthor->getSource()); + $this->assertSame('TestPlugin.Authors', $newAuthor->getSource()); + } + + /** + * Tests that calling an entity with an empty array will run validation. + */ + public function testNewEntityAndValidation(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->getValidator()->requirePresence('title'); + + $entity = $table->newEntity([]); + $errors = $entity->getErrors(); + $this->assertNotEmpty($errors['title']); + } + + /** + * Tests that creating an entity will not run any validation. + */ + public function testCreateEntityAndValidation(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->getValidator()->requirePresence('title'); + + $entity = $table->newEmptyEntity(); + $this->assertEmpty($entity->getErrors()); + } + + /** + * Test magic findByXX method. + */ + public function testMagicFindDefaultToAll(): void + { + $table = $this->getTableLocator()->get('Users'); + + $result = $table->findByUsername('garrett'); + $this->assertInstanceOf(Query::class, $result); + + $expected = new QueryExpression(['Users.username' => 'garrett'], $this->usersTypeMap); + $this->assertEquals($expected, $result->clause('where')); + } + + /** + * Test magic findByXX errors on missing arguments. + */ + public function testMagicFindError(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Not enough arguments for magic finder. Got 0 required 1'); + $table = $this->getTableLocator()->get('Users'); + + $table->findByUsername(); + } + + /** + * Test magic findByXX errors on missing arguments. + */ + public function testMagicFindErrorMissingField(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Not enough arguments for magic finder. Got 1 required 2'); + $table = $this->getTableLocator()->get('Users'); + + $table->findByUsernameAndId('garrett'); + } + + /** + * Test magic findByXX errors when there is a mix of or & and. + */ + public function testMagicFindErrorMixOfOperators(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot mix "and" & "or" in a magic finder. Use find() instead.'); + $table = $this->getTableLocator()->get('Users'); + + $table->findByUsernameAndIdOrPassword('garrett', 1, 'sekret'); + } + + /** + * Test magic findByXX method. + */ + public function testMagicFindFirstAnd(): void + { + $table = $this->getTableLocator()->get('Users'); + + $result = $table->findByUsernameAndId('garrett', 4); + $this->assertInstanceOf(Query::class, $result); + + $expected = new QueryExpression(['Users.username' => 'garrett', 'Users.id' => 4], $this->usersTypeMap); + $this->assertEquals($expected, $result->clause('where')); + } + + /** + * Test magic findByXX method. + */ + public function testMagicFindFirstOr(): void + { + $table = $this->getTableLocator()->get('Users'); + + $result = $table->findByUsernameOrId('garrett', 4); + $this->assertInstanceOf(Query::class, $result); + + $expected = new QueryExpression([], $this->usersTypeMap); + $expected->add( + [ + 'OR' => [ + 'Users.username' => 'garrett', + 'Users.id' => 4, + ], + ], + ); + $this->assertEquals($expected, $result->clause('where')); + } + + /** + * Test magic findAllByXX method. + */ + public function testMagicFindAll(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $result = $table->findAllByAuthorId(1); + $this->assertInstanceOf(Query::class, $result); + $this->assertNull($result->clause('limit')); + + $expected = new QueryExpression(['Articles.author_id' => 1], $this->articlesTypeMap); + $this->assertEquals($expected, $result->clause('where')); + } + + /** + * Test magic findAllByXX method. + */ + public function testMagicFindAllAnd(): void + { + $table = $this->getTableLocator()->get('Users'); + + $result = $table->findAllByAuthorIdAndPublished(1, 'Y'); + $this->assertInstanceOf(Query::class, $result); + $this->assertNull($result->clause('limit')); + $expected = new QueryExpression( + ['Users.author_id' => 1, 'Users.published' => 'Y'], + $this->usersTypeMap, + ); + $this->assertEquals($expected, $result->clause('where')); + } + + /** + * Test magic findAllByXX method. + */ + public function testMagicFindAllOr(): void + { + $table = $this->getTableLocator()->get('Users'); + + $result = $table->findAllByAuthorIdOrPublished(1, 'Y'); + $this->assertInstanceOf(Query::class, $result); + $this->assertNull($result->clause('limit')); + $expected = new QueryExpression(); + $expected->getTypeMap()->setDefaults($this->usersTypeMap->toArray()); + $expected->add( + ['or' => ['Users.author_id' => 1, 'Users.published' => 'Y']], + ); + $this->assertEquals($expected, $result->clause('where')); + $this->assertNull($result->clause('order')); + } + + /** + * Test the behavior method. + */ + public function testBehaviorIntrospection(): void + { + $table = $this->getTableLocator()->get('users'); + + $table->addBehavior('Timestamp'); + $this->assertTrue($table->hasBehavior('Timestamp'), 'should be true on loaded behavior'); + $this->assertFalse($table->hasBehavior('Tree'), 'should be false on unloaded behavior'); + } + + /** + * Tests saving belongsTo association + */ + public function testSaveBelongsTo(): void + { + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + $entity->author = new Entity([ + 'name' => 'Jose', + ]); + + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + $this->assertSame($entity, $table->save($entity)); + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->author->isNew()); + $this->assertSame(5, $entity->author->id); + $this->assertSame(5, $entity->get('author_id')); + } + + /** + * Tests saving hasOne association + */ + public function testSaveHasOne(): void + { + $entity = new Entity([ + 'name' => 'Jose', + ]); + $entity->article = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + + $table = $this->getTableLocator()->get('authors'); + $table->associations()->remove('Articles'); + $table->hasOne('Articles'); + $this->assertSame($entity, $table->save($entity)); + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->article->isNew()); + $this->assertSame(4, $entity->article->id); + $this->assertSame(5, $entity->article->get('author_id')); + $this->assertFalse($entity->article->isDirty('author_id')); + } + + /** + * Tests saving associations only saves associations + * if they are entities. + */ + public function testSaveOnlySaveAssociatedEntities(): void + { + $entity = new Entity([ + 'name' => 'Jose', + ]); + + // Not an entity. + $entity->article = [ + 'title' => 'A Title', + 'body' => 'A body', + ]; + + $table = $this->getTableLocator()->get('authors'); + // $table->hasOne('articles'); + + $table->save($entity); + $this->assertFalse($entity->isNew()); + $this->assertIsArray($entity->article); + } + + /** + * Tests saving multiple entities in a hasMany association + */ + public function testSaveHasMany(): void + { + $entity = new Entity([ + 'name' => 'Jose', + ]); + $entity->articles = [ + new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]), + new Entity([ + 'title' => 'Another Title', + 'body' => 'Another body', + ]), + ]; + + $table = $this->getTableLocator()->get('authors'); + $this->assertSame($entity, $table->save($entity)); + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->articles[0]->isNew()); + $this->assertFalse($entity->articles[1]->isNew()); + $this->assertSame(4, $entity->articles[0]->id); + $this->assertSame(5, $entity->articles[1]->id); + $this->assertSame(5, $entity->articles[0]->author_id); + $this->assertSame(5, $entity->articles[1]->author_id); + } + + /** + * Tests overwriting hasMany associations in an integration scenario. + */ + public function testSaveHasManyOverwrite(): void + { + $table = $this->getTableLocator()->get('authors'); + + $entity = $table->get(3, contain: ['Articles']); + $data = [ + 'name' => 'big jose', + 'articles' => [ + [ + 'id' => 2, + 'title' => 'New title', + ], + ], + ]; + $entity = $table->patchEntity($entity, $data, ['associated' => 'Articles']); + $this->assertSame($entity, $table->save($entity)); + + $entity = $table->get(3, contain: ['Articles']); + $this->assertSame('big jose', $entity->name, 'Author did not persist'); + $this->assertSame('New title', $entity->articles[0]->title, 'Article did not persist'); + } + + /** + * Tests saving belongsToMany records + */ + public function testSaveBelongsToMany(): void + { + $entity = new Entity([ + 'title' => 'A Title', + 'body' => 'A body', + ]); + $entity->tags = [ + new Entity([ + 'name' => 'Something New', + ]), + new Entity([ + 'name' => 'Another Something', + ]), + ]; + $table = $this->getTableLocator()->get('Articles'); + $this->assertSame($entity, $table->save($entity)); + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->tags[0]->isNew()); + $this->assertFalse($entity->tags[1]->isNew()); + $this->assertSame(4, $entity->tags[0]->id); + $this->assertSame(5, $entity->tags[1]->id); + $this->assertSame(4, $entity->tags[0]->_joinData->article_id); + $this->assertSame(4, $entity->tags[1]->_joinData->article_id); + $this->assertSame(4, $entity->tags[0]->_joinData->tag_id); + $this->assertSame(5, $entity->tags[1]->_joinData->tag_id); + } + + /** + * Tests saving belongsToMany records when record exists. + */ + public function testSaveBelongsToManyJoinDataOnExistingRecord(): void + { + $tags = $this->getTableLocator()->get('Tags'); + $table = $this->getTableLocator()->get('Articles'); + + $entity = $table->find()->contain('Tags')->first(); + // not associated to the article already. + $entity->tags[] = $tags->get(3); + $entity->setDirty('tags', true); + + $this->assertSame($entity, $table->save($entity)); + + $this->assertFalse($entity->isNew()); + $this->assertFalse($entity->tags[0]->isNew()); + $this->assertFalse($entity->tags[1]->isNew()); + $this->assertFalse($entity->tags[2]->isNew()); + + $this->assertNotEmpty($entity->tags[0]->_joinData); + $this->assertNotEmpty($entity->tags[1]->_joinData); + $this->assertNotEmpty($entity->tags[2]->_joinData); + } + + /** + * Test that belongsToMany can be saved with _joinData data. + */ + public function testSaveBelongsToManyJoinData(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $article = $articles->get(1, contain: ['Tags']); + $data = [ + 'tags' => [ + ['id' => 1, '_joinData' => ['highlighted' => 1]], + ['id' => 3], + ], + ]; + $article = $articles->patchEntity($article, $data); + $result = $articles->save($article); + $this->assertSame($result, $article); + } + + /** + * Test to check that association condition are used when fetching existing + * records to decide which records to unlink. + */ + public function testPolymorphicBelongsToManySave(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->Tags->setThrough('PolymorphicTagged') + ->setForeignKey('foreign_key') + ->setConditions(['PolymorphicTagged.foreign_model' => 'Articles']) + ->setSort(['PolymorphicTagged.position' => 'ASC']); + + $entity = $articles->get(1, contain: ['Tags']); + $data = [ + 'id' => 1, + 'tags' => [ + [ + 'id' => 1, + '_joinData' => [ + 'id' => 2, + 'foreign_model' => 'Articles', + 'position' => 2, + ], + ], + [ + 'id' => 2, + '_joinData' => [ + 'foreign_model' => 'Articles', + 'position' => 1, + ], + ], + ], + ]; + $entity = $articles->patchEntity($entity, $data, ['associated' => ['Tags._joinData']]); + $entity = $articles->save($entity); + + $expected = [ + [ + 'id' => 1, + 'tag_id' => 1, + 'foreign_key' => 1, + 'foreign_model' => 'Posts', + 'position' => 1, + ], + [ + 'id' => 2, + 'tag_id' => 1, + 'foreign_key' => 1, + 'foreign_model' => 'Articles', + 'position' => 2, + ], + [ + 'id' => 3, + 'tag_id' => 2, + 'foreign_key' => 1, + 'foreign_model' => 'Articles', + 'position' => 1, + ], + ]; + $result = $this->getTableLocator()->get('PolymorphicTagged') + ->find('all', sort: ['id' => 'DESC']) + ->enableHydration(false) + ->toArray(); + $this->assertEquals($expected, $result); + } + + /** + * Tests saving belongsToMany records can delete all links. + */ + public function testSaveBelongsToManyDeleteAllLinks(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->Tags->setSaveStrategy('replace'); + + $entity = $table->get(1, contain: 'Tags'); + $this->assertCount(2, $entity->tags, 'Fixture data did not change.'); + + $entity->tags = []; + $result = $table->save($entity); + $this->assertSame($result, $entity); + $this->assertSame([], $entity->tags, 'No tags on the entity.'); + + $entity = $table->get(1, contain: 'Tags'); + $this->assertSame([], $entity->tags, 'No tags in the db either.'); + } + + /** + * Tests saving belongsToMany records can delete some links. + */ + public function testSaveBelongsToManyDeleteSomeLinks(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->Tags->setSaveStrategy('replace'); + + $entity = $table->get(1, contain: 'Tags'); + $this->assertCount(2, $entity->tags, 'Fixture data did not change.'); + + $tag = new Entity([ + 'id' => 2, + ]); + $entity->tags = [$tag]; + $result = $table->save($entity); + $this->assertSame($result, $entity); + $this->assertCount(1, $entity->tags, 'Only one tag left.'); + $this->assertEquals($tag, $entity->tags[0]); + + $entity = $table->get(1, contain: 'Tags'); + $this->assertCount(1, $entity->tags, 'Only one tag in the db.'); + $this->assertEquals($tag->id, $entity->tags[0]->id); + } + + /** + * Test that belongsToMany ignores non-entity data. + */ + public function testSaveBelongsToManyIgnoreNonEntityData(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $article = $articles->get(1, contain: ['Tags']); + $article->tags = [ + '_ids' => [2, 1], + ]; + $result = $articles->save($article); + $this->assertSame($result, $article); + } + + /** + * Tests that saving a persisted and clean entity will is a no-op + */ + public function testSaveCleanEntity(): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['_processSave']) + ->getMock(); + $entity = new Entity( + ['id' => 'foo'], + ['markNew' => false, 'markClean' => true], + ); + $table->expects($this->never())->method('_processSave'); + $this->assertSame($entity, $table->save($entity)); + } + + /** + * Integration test to show how to append a new tag to an article + */ + public function testBelongsToManyIntegration(): void + { + $table = $this->getTableLocator()->get('Articles'); + $article = $table->find('all')->where(['id' => 1])->contain(['Tags'])->first(); + $tags = $article->tags; + $this->assertNotEmpty($tags); + $tags[] = new Tag(['name' => 'Something New']); + $article->tags = $tags; + $this->assertSame($article, $table->save($article)); + $tags = $article->tags; + $this->assertCount(3, $tags); + $this->assertFalse($tags[2]->isNew()); + $this->assertSame(4, $tags[2]->id); + $this->assertSame(1, $tags[2]->_joinData->article_id); + $this->assertSame(4, $tags[2]->_joinData->tag_id); + } + + /** + * Tests that it is possible to do a deep save and control what associations get saved, + * while having control of the options passed to each level of the save + */ + public function testSaveDeepAssociationOptions(): void + { + $articles = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'articles', 'connection' => $this->connection]]) + ->getMock(); + $authors = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'authors', 'connection' => $this->connection]]) + ->getMock(); + $supervisors = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([[ + 'table' => 'authors', + 'alias' => 'supervisors', + 'connection' => $this->connection, + ]]) + ->getMock(); + $tags = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'tags', 'connection' => $this->connection]]) + ->getMock(); + + $articles->belongsTo('authors', ['targetTable' => $authors]); + $authors->hasOne('supervisors', ['targetTable' => $supervisors]); + $supervisors->belongsToMany('tags', ['targetTable' => $tags]); + + $entity = new Entity([ + 'title' => 'bar', + 'author' => new Entity([ + 'name' => 'Juan', + 'supervisor' => new Entity(['name' => 'Marc']), + 'tags' => [ + new Entity(['name' => 'foo']), + ], + ]), + ]); + $entity->setNew(true); + $entity->author->setNew(true); + $entity->author->supervisor->setNew(true); + $entity->author->tags[0]->setNew(true); + + $articles->expects($this->once()) + ->method('_insert') + ->with($entity, ['title' => 'bar']) + ->willReturn($entity); + + $authors->expects($this->once()) + ->method('_insert') + ->with($entity->author, ['name' => 'Juan']) + ->willReturn($entity->author); + + $supervisors->expects($this->once()) + ->method('_insert') + ->with($entity->author->supervisor, ['name' => 'Marc']) + ->willReturn($entity->author->supervisor); + + $tags->expects($this->never())->method('_insert'); + + $this->assertSame($entity, $articles->save($entity, [ + 'associated' => [ + 'authors' => [], + 'authors.supervisors' => [ + 'atomic' => false, + 'associated' => false, + ], + ], + ])); + } + + public function testBelongsToFluentInterface(): void + { + /** @var \TestApp\Model\Table\ArticlesTable $articles */ + $articles = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'articles', 'connection' => $this->connection]]) + ->getMock(); + $authors = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'authors', 'connection' => $this->connection]]) + ->getMock(); + + try { + $articles->belongsTo('Articles') + ->setForeignKey('author_id') + ->setTarget($authors) + ->setBindingKey('id') + ->setConditions([]) + ->setFinder('list') + ->setProperty('authors') + ->setJoinType('inner'); + } catch (BadMethodCallException) { + $this->fail('Method chaining should be ok'); + } + $this->assertSame('articles', $articles->getTable()); + } + + public function testHasOneFluentInterface(): void + { + /** @var \TestApp\Model\Table\AuthorsTable $authors */ + $authors = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'authors', 'connection' => $this->connection]]) + ->getMock(); + + try { + $authors->hasOne('Articles') + ->setForeignKey('author_id') + ->setDependent(true) + ->setBindingKey('id') + ->setConditions([]) + ->setCascadeCallbacks(true) + ->setFinder('list') + ->setStrategy('select') + ->setProperty('authors') + ->setJoinType('inner'); + } catch (BadMethodCallException) { + $this->fail('Method chaining should be ok'); + } + $this->assertSame('authors', $authors->getTable()); + } + + public function testHasManyFluentInterface(): void + { + /** @var \TestApp\Model\Table\AuthorsTable $authors */ + $authors = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'authors', 'connection' => $this->connection]]) + ->getMock(); + + try { + $authors->hasMany('Articles') + ->setForeignKey('author_id') + ->setDependent(true) + ->setSort(['created' => 'DESC']) + ->setBindingKey('id') + ->setConditions([]) + ->setCascadeCallbacks(true) + ->setFinder('list') + ->setStrategy('select') + ->setSaveStrategy('replace') + ->setProperty('authors') + ->setJoinType('inner'); + } catch (BadMethodCallException) { + $this->fail('Method chaining should be ok'); + } + $this->assertSame('authors', $authors->getTable()); + } + + public function testBelongsToManyFluentInterface(): void + { + /** @var \TestApp\Model\Table\AuthorsTable $authors */ + $authors = $this->getMockBuilder(Table::class) + ->onlyMethods(['_insert']) + ->setConstructorArgs([['table' => 'authors', 'connection' => $this->connection]]) + ->getMock(); + try { + $authors->belongsToMany('Articles') + ->setForeignKey('author_id') + ->setDependent(true) + ->setTargetForeignKey('article_id') + ->setBindingKey('id') + ->setConditions([]) + ->setFinder('list') + ->setProperty('authors') + ->setSource($authors) + ->setStrategy('select') + ->setSaveStrategy('append') + ->setThrough('author_articles') + ->setJoinType('inner'); + } catch (BadMethodCallException) { + $this->fail('Method chaining should be ok'); + } + $this->assertSame('authors', $authors->getTable()); + } + + /** + * Integration test for linking entities with belongsToMany + */ + public function testLinkBelongsToMany(): void + { + $table = $this->getTableLocator()->get('Articles'); + $tagsTable = $this->getTableLocator()->get('Tags'); + $source = ['source' => 'Tags']; + $options = ['markNew' => false]; + + $article = new Entity([ + 'id' => 1, + ], $options); + + $newTag = new Tag([ + 'name' => 'Foo', + 'description' => 'Foo desc', + 'created' => null, + ], $source); + $tags[] = new Tag([ + 'id' => 3, + ], $options + $source); + $tags[] = $newTag; + + $tagsTable->save($newTag); + $table->getAssociation('Tags')->link($article, $tags); + + $this->assertEquals($article->tags, $tags); + foreach ($tags as $tag) { + $this->assertFalse($tag->isNew()); + } + + $article = $table->find('all')->where(['id' => 1])->contain(['Tags'])->first(); + $this->assertEquals($article->tags[2]->id, $tags[0]->id); + $this->assertEqualsCanonicalizing($article->tags[3]->toArray(), $tags[1]->toArray()); + } + + /** + * Integration test for linking entities with HasMany + */ + public function testLinkHasMany(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $this->getTableLocator()->get('Articles'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + ], + ); + + $sizeArticles = count($newArticles); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $this->assertCount($sizeArticles, $authors->Articles->findAllByAuthorId($author->id)); + $this->assertCount($sizeArticles, $author->articles); + $this->assertFalse($author->isDirty('articles')); + } + + /** + * Integration test for linking entities with HasMany combined with ReplaceSaveStrategy. It must append, not unlinking anything + */ + public function testLinkHasManyReplaceSaveStrategy(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $this->getTableLocator()->get('Articles'); + + $authors->Articles->setSaveStrategy('replace'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + ], + ); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $sizeArticles = count($newArticles); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'Nothing but the cake', + 'body' => 'It is all that we need', + ], + ], + ); + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $sizeArticles++; + + $this->assertCount($sizeArticles, $authors->Articles->findAllByAuthorId($author->id)); + $this->assertCount($sizeArticles, $author->articles); + $this->assertFalse($author->isDirty('articles')); + } + + /** + * Integration test for linking entities with HasMany. The input contains already linked entities and they should not appeat duplicated + */ + public function testLinkHasManyExisting(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $this->getTableLocator()->get('Articles'); + + $authors->Articles->setSaveStrategy('replace'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + ], + ); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $sizeArticles = count($newArticles); + + $newArticles = array_merge( + $author->articles, + $articles->newEntities( + [ + [ + 'title' => 'Nothing but the cake', + 'body' => 'It is all that we need', + ], + ], + ), + ); + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $sizeArticles++; + + $this->assertCount($sizeArticles, $authors->Articles->findAllByAuthorId($author->id)); + $this->assertCount($sizeArticles, $author->articles); + $this->assertFalse($author->isDirty('articles')); + } + + /** + * Integration test for unlinking entities with HasMany. The association property must be cleaned + */ + public function testUnlinkHasManyCleanProperty(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $this->getTableLocator()->get('Articles'); + + $authors->Articles->setSaveStrategy('replace'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + [ + 'title' => 'Creamy cake recipe', + 'body' => 'chocolate and cream', + ], + ], + ); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $sizeArticles = count($newArticles); + + $articlesToUnlink = [$author->articles[0], $author->articles[1]]; + + $authors->Articles->unlink($author, $articlesToUnlink); + + $this->assertCount($sizeArticles - count($articlesToUnlink), $authors->Articles->findAllByAuthorId($author->id)); + $this->assertCount($sizeArticles - count($articlesToUnlink), $author->articles); + $this->assertFalse($author->isDirty('articles')); + } + + /** + * Integration test for unlinking entities with HasMany. The association property must stay unchanged + */ + public function testUnlinkHasManyNotCleanProperty(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $this->getTableLocator()->get('Articles'); + + $authors->Articles->setSaveStrategy('replace'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + [ + 'title' => 'Creamy cake recipe', + 'body' => 'chocolate and cream', + ], + ], + ); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $sizeArticles = count($newArticles); + + $articlesToUnlink = [$author->articles[0], $author->articles[1]]; + + $authors->Articles->unlink($author, $articlesToUnlink, ['cleanProperty' => false]); + + $this->assertCount($sizeArticles - count($articlesToUnlink), $authors->Articles->findAllByAuthorId($author->id)); + $this->assertCount($sizeArticles, $author->articles); + $this->assertFalse($author->isDirty('articles')); + } + + /** + * Integration test for unlinking entities with HasMany. + * Checking that no error happens when the hasMany property is originally + * null + */ + public function testUnlinkHasManyEmpty(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $author = $authors->get(1); + $article = $authors->Articles->get(1); + + $authors->Articles->unlink($author, [$article]); + $this->assertNotEmpty($authors); + } + + /** + * Integration test for replacing entities which depend on their source entity with HasMany and failing transaction. False should be returned when + * unlinking fails while replacing even when cascadeCallbacks is enabled + */ + public function testReplaceHasManyOnErrorDependentCascadeCallbacks(): void + { + $articles = $this->getMockBuilder(Table::class) + ->onlyMethods(['deleteMany']) + ->setConstructorArgs([[ + 'connection' => $this->connection, + 'alias' => 'Articles', + 'table' => 'articles', + ]]) + ->getMock(); + + $articles->method('deleteMany')->willReturn(false); + + $associations = new AssociationCollection(); + + $hasManyArticles = $this->getMockBuilder(HasMany::class) + ->onlyMethods(['getTarget']) + ->setConstructorArgs([ + 'articles', + [ + 'target' => $articles, + 'foreignKey' => 'author_id', + 'dependent' => true, + 'cascadeCallbacks' => true, + ], + ]) + ->getMock(); + $hasManyArticles->method('getTarget')->willReturn($articles); + + $associations->add('Articles', $hasManyArticles); + + $authors = new Table([ + 'connection' => $this->connection, + 'alias' => 'Authors', + 'table' => 'authors', + 'associations' => $associations, + ]); + $authors->Articles->setSource($authors); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + ], + ); + + $sizeArticles = count($newArticles); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + $this->assertEquals($authors->Articles->findAllByAuthorId($author->id)->count(), $sizeArticles); + $this->assertCount($sizeArticles, $author->articles); + + $newArticles = array_merge( + $author->articles, + $articles->newEntities( + [ + [ + 'title' => 'Cheese cake recipe', + 'body' => 'The secrets of mixing salt and sugar', + ], + [ + 'title' => 'Not another piece of cake', + 'body' => 'This is the best', + ], + ], + ), + ); + unset($newArticles[0]); + + $this->assertFalse($authors->Articles->replace($author, $newArticles)); + $this->assertCount($sizeArticles, $authors->Articles->findAllByAuthorId($author->id)); + } + + /** + * Integration test for replacing entities with HasMany and an empty target list. The transaction must be successful + */ + public function testReplaceHasManyEmptyList(): void + { + $authors = new Table([ + 'connection' => $this->connection, + 'alias' => 'Authors', + 'table' => 'authors', + ]); + $authors->hasMany('Articles'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $authors->Articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + ], + ); + + $sizeArticles = count($newArticles); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + $this->assertEquals($authors->Articles->findAllByAuthorId($author->id)->count(), $sizeArticles); + $this->assertCount($sizeArticles, $author->articles); + + $newArticles = []; + + $this->assertTrue($authors->Articles->replace($author, $newArticles)); + $this->assertCount(0, $authors->Articles->findAllByAuthorId($author->id)); + } + + /** + * Integration test for replacing entities with HasMany and no already persisted entities. The transaction must be successful. + * Replace operation should prevent considering 0 changed records an error when they are not found in the table + */ + public function testReplaceHasManyNoPersistedEntities(): void + { + $authors = new Table([ + 'connection' => $this->connection, + 'alias' => 'Authors', + 'table' => 'authors', + ]); + $authors->hasMany('Articles'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $authors->Articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + ], + ); + + $authors->Articles->deleteAll(['1=1']); + + $sizeArticles = count($newArticles); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + $this->assertEquals($authors->Articles->findAllByAuthorId($author->id)->count(), $sizeArticles); + $this->assertCount($sizeArticles, $author->articles); + $this->assertTrue($authors->Articles->replace($author, $newArticles)); + $this->assertCount($sizeArticles, $authors->Articles->findAllByAuthorId($author->id)); + } + + /** + * Integration test for replacing entities with HasMany. + */ + public function testReplaceHasMany(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $this->getTableLocator()->get('Articles'); + + $author = $authors->newEntity(['name' => 'mylux']); + $author = $authors->save($author); + + $newArticles = $articles->newEntities( + [ + [ + 'title' => 'New bakery next corner', + 'body' => 'They sell tastefull cakes', + ], + [ + 'title' => 'Spicy cake recipe', + 'body' => 'chocolate and peppers', + ], + ], + ); + + $sizeArticles = count($newArticles); + + $this->assertTrue($authors->Articles->link($author, $newArticles)); + + $this->assertEquals($authors->Articles->findAllByAuthorId($author->id)->count(), $sizeArticles); + $this->assertCount($sizeArticles, $author->articles); + + $newArticles = array_merge( + $author->articles, + $articles->newEntities( + [ + [ + 'title' => 'Cheese cake recipe', + 'body' => 'The secrets of mixing salt and sugar', + ], + [ + 'title' => 'Not another piece of cake', + 'body' => 'This is the best', + ], + ], + ), + ); + unset($newArticles[0]); + + $this->assertTrue($authors->Articles->replace($author, $newArticles)); + $this->assertCount(count($newArticles), $author->articles); + $this->assertEquals((new Collection($newArticles))->extract('title')->toArray(), (new Collection($author->articles))->extract('title')->toArray()); + } + + /** + * Integration test to show how to unlink a single record from a belongsToMany + */ + public function testUnlinkBelongsToMany(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $article = $table->find('all') + ->where(['id' => 1]) + ->contain(['Tags'])->first(); + + $table->getAssociation('Tags')->unlink($article, [$article->tags[0]]); + $this->assertCount(1, $article->tags); + $this->assertSame(2, $article->tags[0]->get('id')); + $this->assertFalse($article->isDirty('tags')); + } + + /** + * Integration test to show how to unlink multiple records from a belongsToMany + */ + public function testUnlinkBelongsToManyMultiple(): void + { + $table = $this->getTableLocator()->get('Articles'); + $options = ['markNew' => false]; + + $article = new Entity(['id' => 1], $options); + $tags[] = new Tag(['id' => 1], $options); + $tags[] = new Tag(['id' => 2], $options); + + $table->getAssociation('Tags')->unlink($article, $tags); + $left = $table->find('all')->where(['id' => 1])->contain(['Tags'])->first(); + $this->assertEmpty($left->tags); + } + + /** + * Integration test to show how to unlink multiple records from a belongsToMany + * providing some of the joint + */ + public function testUnlinkBelongsToManyPassingJoint(): void + { + $table = $this->getTableLocator()->get('Articles'); + $options = ['markNew' => false]; + + $article = new Entity(['id' => 1], $options); + $tags[] = new Tag(['id' => 1], $options); + $tags[] = new Tag(['id' => 2], $options); + + $tags[1]->_joinData = new Entity([ + 'article_id' => 1, + 'tag_id' => 2, + ], $options); + + $table->getAssociation('Tags')->unlink($article, $tags); + $left = $table->find('all')->where(['id' => 1])->contain(['Tags'])->first(); + $this->assertEmpty($left->tags); + } + + /** + * Integration test to show how to replace records from a belongsToMany + */ + public function testReplacelinksBelongsToMany(): void + { + $table = $this->getTableLocator()->get('Articles'); + $options = ['markNew' => false]; + + $article = new Entity(['id' => 1], $options); + $tags[] = new Tag(['id' => 2], $options); + $tags[] = new Tag(['id' => 3], $options); + $tags[] = new Tag(['name' => 'foo']); + + $table->getAssociation('Tags')->replaceLinks($article, $tags); + $this->assertSame(2, $article->tags[0]->id); + $this->assertSame(3, $article->tags[1]->id); + $this->assertSame(4, $article->tags[2]->id); + + $article = $table->find('all')->where(['id' => 1])->contain(['Tags'])->first(); + $this->assertCount(3, $article->tags); + $this->assertSame(2, $article->tags[0]->id); + $this->assertSame(3, $article->tags[1]->id); + $this->assertSame(4, $article->tags[2]->id); + $this->assertSame('foo', $article->tags[2]->name); + } + + /** + * Integration test to show how remove all links from a belongsToMany + */ + public function testReplacelinksBelongsToManyWithEmpty(): void + { + $table = $this->getTableLocator()->get('Articles'); + $options = ['markNew' => false]; + + $article = new Entity(['id' => 1], $options); + $tags = []; + + $table->getAssociation('Tags')->replaceLinks($article, $tags); + $this->assertSame($tags, $article->tags); + $article = $table->find('all')->where(['id' => 1])->contain(['Tags'])->first(); + $this->assertEmpty($article->tags); + } + + /** + * Integration test to show how to replace records from a belongsToMany + * passing the joint property along in the target entity + */ + public function testReplacelinksBelongsToManyWithJoint(): void + { + $table = $this->getTableLocator()->get('Articles'); + $options = ['markNew' => false]; + + $article = new Entity(['id' => 1], $options); + $tags[] = new Tag([ + 'id' => 2, + '_joinData' => new Entity([ + 'article_id' => 1, + 'tag_id' => 2, + ]), + ], $options); + $tags[] = new Tag(['id' => 3], $options); + + $table->getAssociation('Tags')->replaceLinks($article, $tags); + $this->assertSame($tags, $article->tags); + $article = $table->find('all')->where(['id' => 1])->contain(['Tags'])->first(); + $this->assertCount(2, $article->tags); + $this->assertSame(2, $article->tags[0]->id); + $this->assertSame(3, $article->tags[1]->id); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToImplicitBelongsToManyDeletesUsingSaveReplace(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $tags = $articles->Tags; + $tags->setSaveStrategy(BelongsToMany::SAVE_REPLACE) + ->setDependent(true) + ->setCascadeCallbacks(true); + + $actualOptions = null; + $tags->junction()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $article = $articles->get(1); + $article->tags = []; + $article->setDirty('tags', true); + + $result = $articles->save($article, ['foo' => 'bar']); + $this->assertNotEmpty($result); + + $expected = [ + '_primary' => false, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'checkExisting' => true, + '_cleanOnSuccess' => true, + ]; + $this->assertEquals($expected, $actualOptions); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToInternalSaveCallsUsingBelongsToManyLink(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $articles->Tags; + + $actualOptions = null; + $tags->junction()->getEventManager()->on( + 'Model.beforeSave', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $article = $articles->get(1); + + $result = $tags->link($article, [$tags->getTarget()->get(2)], ['foo' => 'bar']); + $this->assertTrue($result); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'checkExisting' => true, + 'associated' => [ + 'Articles' => [], + 'Tags' => [], + ], + '_cleanOnSuccess' => true, + ]; + $this->assertEquals($expected, $actualOptions); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToInternalSaveCallsUsingBelongsToManyUnlink(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $articles->Tags; + + $actualOptions = null; + $tags->junction()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $article = $articles->get(1); + + $tags->unlink($article, [$tags->getTarget()->get(2)], ['foo' => 'bar']); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'cleanProperty' => true, + ]; + $this->assertEquals($expected, $actualOptions); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToInternalSaveAndDeleteCallsUsingBelongsToManyReplaceLinks(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $articles->Tags; + + $actualSaveOptions = null; + $actualDeleteOptions = null; + $tags->junction()->getEventManager()->on( + 'Model.beforeSave', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualSaveOptions): void { + $actualSaveOptions = $options->getArrayCopy(); + }, + ); + $tags->junction()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualDeleteOptions): void { + $actualDeleteOptions = $options->getArrayCopy(); + }, + ); + + $article = $articles->get(1); + + $result = $tags->replaceLinks( + $article, + [ + $tags->getTarget()->newEntity(['name' => 'new']), + $tags->getTarget()->get(2), + ], + ['foo' => 'bar'], + ); + $this->assertTrue($result); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'checkExisting' => true, + 'associated' => [], + '_cleanOnSuccess' => true, + ]; + $this->assertEquals($expected, $actualSaveOptions); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + ]; + $this->assertEquals($expected, $actualDeleteOptions); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToImplicitHasManyDeletesUsingSaveReplace(): void + { + $authors = $this->getTableLocator()->get('Authors'); + + $articles = $authors->Articles; + $articles->setSaveStrategy(HasMany::SAVE_REPLACE) + ->setDependent(true) + ->setCascadeCallbacks(true); + + $actualOptions = null; + $articles->getTarget()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $author = $authors->get(1); + $author->articles = []; + $author->setDirty('articles', true); + + $result = $authors->save($author, ['foo' => 'bar']); + $this->assertNotEmpty($result); + + $expected = [ + '_primary' => false, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'checkExisting' => true, + '_sourceTable' => $authors, + '_cleanOnSuccess' => true, + ]; + $this->assertEquals($expected, $actualOptions); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToInternalSaveCallsUsingHasManyLink(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $authors->Articles; + + $actualOptions = null; + $articles->getTarget()->getEventManager()->on( + 'Model.beforeSave', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $author = $authors->get(1); + $author->articles = []; + $author->setDirty('articles', true); + + $result = $articles->link($author, [$articles->getTarget()->get(2)], ['foo' => 'bar']); + $this->assertTrue($result); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'checkExisting' => true, + '_sourceTable' => $authors, + 'associated' => [ + 'Authors' => [], + 'Tags' => [], + 'ArticlesTags' => [], + ], + '_cleanOnSuccess' => true, + ]; + $this->assertEquals($expected, $actualOptions); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToInternalSaveCallsUsingHasManyUnlink(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $authors->Articles; + $articles->setDependent(true); + $articles->setCascadeCallbacks(true); + + $actualOptions = null; + $articles->getTarget()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $author = $authors->get(1); + $author->articles = []; + $author->setDirty('articles', true); + + $articles->unlink($author, [$articles->getTarget()->get(1)], ['foo' => 'bar']); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'cleanProperty' => true, + ]; + $this->assertEquals($expected, $actualOptions); + } + + /** + * Tests that options are being passed through to the internal table method calls. + */ + public function testOptionsBeingPassedToInternalSaveAndDeleteCallsUsingHasManyReplace(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $authors->Articles; + $articles->setDependent(true); + $articles->setCascadeCallbacks(true); + + $actualSaveOptions = null; + $actualDeleteOptions = null; + $articles->getTarget()->getEventManager()->on( + 'Model.beforeSave', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualSaveOptions): void { + $actualSaveOptions = $options->getArrayCopy(); + }, + ); + $articles->getTarget()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualDeleteOptions): void { + $actualDeleteOptions = $options->getArrayCopy(); + }, + ); + + $author = $authors->get(1); + + $result = $articles->replace( + $author, + [ + $articles->getTarget()->newEntity(['title' => 'new', 'body' => 'new']), + $articles->getTarget()->get(1), + ], + ['foo' => 'bar'], + ); + $this->assertTrue($result); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + 'checkExisting' => true, + '_sourceTable' => $authors, + 'associated' => [ + 'Authors' => [], + 'Tags' => [], + 'ArticlesTags' => [], + ], + '_cleanOnSuccess' => true, + ]; + $this->assertEquals($expected, $actualSaveOptions); + + $expected = [ + '_primary' => true, + 'foo' => 'bar', + 'atomic' => true, + 'checkRules' => true, + '_sourceTable' => $authors, + ]; + $this->assertEquals($expected, $actualDeleteOptions); + } + + /** + * Tests backwards compatibility of the the `$options` argument, formerly `$cleanProperty`. + */ + public function testBackwardsCompatibilityForBelongsToManyUnlinkCleanPropertyOption(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $tags = $articles->Tags; + + $actualOptions = null; + $tags->junction()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $article = $articles->get(1); + + $tags->unlink($article, [$tags->getTarget()->get(1)], false); + $this->assertArrayHasKey('cleanProperty', $actualOptions); + $this->assertFalse($actualOptions['cleanProperty']); + + $actualOptions = null; + $tags->unlink($article, [$tags->getTarget()->get(2)]); + $this->assertArrayHasKey('cleanProperty', $actualOptions); + $this->assertTrue($actualOptions['cleanProperty']); + } + + /** + * Tests backwards compatibility of the the `$options` argument, formerly `$cleanProperty`. + */ + public function testBackwardsCompatibilityForHasManyUnlinkCleanPropertyOption(): void + { + $authors = $this->getTableLocator()->get('Authors'); + $articles = $authors->Articles; + $articles->setDependent(true); + $articles->setCascadeCallbacks(true); + + $actualOptions = null; + $articles->getTarget()->getEventManager()->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$actualOptions): void { + $actualOptions = $options->getArrayCopy(); + }, + ); + + $author = $authors->get(1); + $author->articles = []; + $author->setDirty('articles', true); + + $articles->unlink($author, [$articles->getTarget()->get(1)], false); + $this->assertArrayHasKey('cleanProperty', $actualOptions); + $this->assertFalse($actualOptions['cleanProperty']); + + $actualOptions = null; + $articles->unlink($author, [$articles->getTarget()->get(3)]); + $this->assertArrayHasKey('cleanProperty', $actualOptions); + $this->assertTrue($actualOptions['cleanProperty']); + } + + /** + * Tests that it is possible to call find with no arguments + */ + public function testSimplifiedFind(): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['findAll']) + ->setConstructorArgs([[ + 'connection' => $this->connection, + 'schema' => ['id' => ['type' => 'integer']], + ]]) + ->getMock(); + + $table->expects($this->once())->method('findAll'); + $table->find(); + } + + public static function providerForTestGet(): array + { + return [ + [['fields' => ['id']]], + [['fields' => ['id'], 'cache' => null]], + ]; + } + + /** + * Test that get() will use the primary key for searching and return the first + * entity found + * + * @param array $options + */ + #[DataProvider('providerForTestGet')] + public function testGet($options): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['selectQuery']) + ->setConstructorArgs([[ + 'connection' => $this->connection, + 'schema' => [ + 'id' => ['type' => 'integer'], + 'bar' => ['type' => 'integer'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['bar']]], + ], + ]]) + ->getMock(); + + $query = $this->getMockBuilder(SelectQuery::class) + ->onlyMethods(['addDefaultTypes', 'firstOrFail', 'where', 'cache', 'applyOptions']) + ->setConstructorArgs([$table]) + ->getMock(); + + $table->expects($this->once())->method('selectQuery') + ->willReturn($query); + + $entity = new Entity(); + $query->expects($this->once())->method('applyOptions') + ->with(['fields' => ['id']]); + $query->expects($this->once())->method('where') + ->with([$table->getAlias() . '.bar' => 10]) + ->willReturnSelf(); + $query->expects($this->never())->method('cache'); + $query->expects($this->once())->method('firstOrFail') + ->willReturn($entity); + + $result = $table->get(10, ...$options); + $this->assertSame($entity, $result); + } + + public static function providerForTestGetWithCache(): array + { + return [ + [ + ['fields' => ['id'], 'cache' => 'default'], + 'get-test-table_name-[10]', 'default', 10, + ], + [ + ['fields' => ['id'], 'cache' => 'default'], + 'get-test-table_name-["uuid"]', 'default', 'uuid', + ], + [ + ['fields' => ['id'], 'cache' => 'default'], + 'get-test-table_name-["2020-07-08T00:00:00+00:00"]', 'default', new DateTime('2020-07-08'), + ], + [ + ['fields' => ['id'], 'cache' => 'default', 'cacheKey' => 'custom_key'], + 'custom_key', 'default', 10, + ], + ]; + } + + /** + * Test that get() will use the cache. + * + * @param array $options + * @param string $cacheKey + * @param string $cacheConfig + * @param mixed $primaryKey + */ + #[DataProvider('providerForTestGetWithCache')] + public function testGetWithCache($options, $cacheKey, $cacheConfig, $primaryKey): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['selectQuery']) + ->setConstructorArgs([[ + 'connection' => $this->connection, + 'schema' => [ + 'id' => ['type' => 'integer'], + 'bar' => ['type' => 'integer'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['bar']]], + ], + ]]) + ->getMock(); + $table->setTable('table_name'); + + $query = $this->getMockBuilder(SelectQuery::class) + ->onlyMethods(['addDefaultTypes', 'firstOrFail', 'where', 'cache', 'applyOptions']) + ->setConstructorArgs([$table]) + ->getMock(); + + $table->expects($this->once())->method('selectQuery') + ->willReturn($query); + + $entity = new Entity(); + $query->expects($this->once())->method('applyOptions') + ->with(['fields' => ['id']]); + $query->expects($this->once())->method('where') + ->with([$table->getAlias() . '.bar' => $primaryKey]) + ->willReturnSelf(); + $query->expects($this->once())->method('cache') + ->with($cacheKey, $cacheConfig) + ->willReturnSelf(); + $query->expects($this->once())->method('firstOrFail') + ->willReturn($entity); + + $result = $table->get($primaryKey, ...$options); + $this->assertSame($entity, $result); + } + + /** + * Test get() with options array. + * + * @return void + */ + public function testGetBackwardsCompatibility(): void + { + $this->deprecated(function (): void { + $table = $this->getTableLocator()->get('Articles'); + $article = $table->get(1, ['contain' => 'Authors']); + $this->assertNotEmpty($article->author); + }); + } + + /** + * Tests that get() will throw an exception if the record was not found + */ + public function testGetNotFoundException(): void + { + $this->expectException(RecordNotFoundException::class); + $this->expectExceptionMessage('Record not found in table `articles`.'); + $table = new Table([ + 'name' => 'Articles', + 'connection' => $this->connection, + 'table' => 'articles', + ]); + $table->get(10); + } + + /** + * Test that an exception is raised when there are not enough keys. + */ + public function testGetExceptionOnNoData(): void + { + $this->expectException(InvalidPrimaryKeyException::class); + $this->expectExceptionMessage('Record not found in table `articles` with primary key `[NULL]`.'); + $table = new Table([ + 'name' => 'Articles', + 'connection' => $this->connection, + 'table' => 'articles', + ]); + $table->get(null); + } + + /** + * Test that an exception is raised when there are too many keys. + */ + public function testGetExceptionOnTooMuchData(): void + { + $this->expectException(InvalidPrimaryKeyException::class); + $this->expectExceptionMessage("Record not found in table `articles` with primary key `[1, 'two']`."); + $table = new Table([ + 'name' => 'Articles', + 'connection' => $this->connection, + 'table' => 'articles', + ]); + $table->get([1, 'two']); + } + + /** + * Tests that patchEntity delegates the task to the marshaller and passed + * all associations + */ + public function testPatchEntityMarshallerUsage(): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['marshaller']) + ->getMock(); + $marshaller = $this->getMockBuilder(Marshaller::class) + ->setConstructorArgs([$table]) + ->getMock(); + $table->belongsTo('users'); + $table->hasMany('articles'); + $table->expects($this->once())->method('marshaller') + ->willReturn($marshaller); + + $entity = new Entity(); + $data = ['foo' => 'bar']; + $marshaller->expects($this->once()) + ->method('merge') + ->with($entity, $data, ['associated' => ['users', 'articles']]) + ->willReturn($entity); + $table->patchEntity($entity, $data); + } + + /** + * Tests patchEntity in a simple scenario. The tests for Marshaller cover + * patch scenarios in more depth. + */ + public function testPatchEntity(): void + { + $table = $this->getTableLocator()->get('Articles'); + $entity = new Entity(['title' => 'old title'], ['markNew' => false]); + $data = ['title' => 'new title']; + $entity = $table->patchEntity($entity, $data); + + $this->assertSame($data['title'], $entity->title); + $this->assertFalse($entity->isNew(), 'entity should not be new.'); + } + + /** + * Tests that patchEntities delegates the task to the marshaller and passed + * all associations + */ + public function testPatchEntitiesMarshallerUsage(): void + { + $table = $this->getMockBuilder(Table::class) + ->onlyMethods(['marshaller']) + ->getMock(); + $marshaller = $this->getMockBuilder(Marshaller::class) + ->setConstructorArgs([$table]) + ->getMock(); + $table->belongsTo('users'); + $table->hasMany('articles'); + $table->expects($this->once())->method('marshaller') + ->willReturn($marshaller); + + $entities = [new Entity()]; + $data = [['foo' => 'bar']]; + $marshaller->expects($this->once()) + ->method('mergeMany') + ->with($entities, $data, ['associated' => ['users', 'articles']]) + ->willReturn($entities); + $table->patchEntities($entities, $data); + } + + /** + * Tests patchEntities in a simple scenario. The tests for Marshaller cover + * patch scenarios in more depth. + */ + public function testPatchEntities(): void + { + $table = $this->getTableLocator()->get('Articles'); + $entities = $table->find()->limit(2)->toArray(); + + $data = [ + ['id' => $entities[0]->id, 'title' => 'new title'], + ['id' => $entities[1]->id, 'title' => 'new title2'], + ]; + $entities = $table->patchEntities($entities, $data); + foreach ($entities as $i => $entity) { + $this->assertFalse($entity->isNew(), 'entities should not be new.'); + $this->assertSame($data[$i]['title'], $entity->title); + } + } + + /** + * Tests __debugInfo + */ + public function testDebugInfo(): void + { + $articles = $this->getTableLocator()->get('articles'); + $articles->addBehavior('Timestamp'); + $result = $articles->__debugInfo(); + $expected = [ + 'registryAlias' => 'articles', + 'table' => 'articles', + 'alias' => 'articles', + 'entityClass' => Article::class, + 'associations' => ['Authors', 'Tags', 'ArticlesTags'], + 'behaviors' => ['Timestamp'], + 'defaultConnection' => 'default', + 'connectionName' => 'test', + ]; + $this->assertEquals($expected, $result); + + $articles = $this->getTableLocator()->get('Foo.Articles'); + $result = $articles->__debugInfo(); + $expected = [ + 'registryAlias' => 'Foo.Articles', + 'table' => 'articles', + 'alias' => 'Articles', + 'entityClass' => Entity::class, + 'associations' => [], + 'behaviors' => [], + 'defaultConnection' => 'default', + 'connectionName' => 'test', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that findOrCreate creates a new entity, and then finds that entity. + */ + public function testFindOrCreateNewEntity(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $callbackExecuted = false; + $firstArticle = $articles->findOrCreate(['title' => 'Not there'], function ($article) use (&$callbackExecuted): void { + $this->assertInstanceOf(EntityInterface::class, $article); + $article->body = 'New body'; + $callbackExecuted = true; + }); + $this->assertTrue($callbackExecuted); + $this->assertFalse($firstArticle->isNew()); + $this->assertNotNull($firstArticle->id); + $this->assertSame('Not there', $firstArticle->title); + $this->assertSame('New body', $firstArticle->body); + + $secondArticle = $articles->findOrCreate(['title' => 'Not there'], function ($article): void { + $this->fail('Should not be called for existing entities.'); + }); + $this->assertFalse($secondArticle->isNew()); + $this->assertNotNull($secondArticle->id); + $this->assertSame('Not there', $secondArticle->title); + $this->assertEquals($firstArticle->id, $secondArticle->id); + } + + /** + * Test that findOrCreate finds fixture data. + */ + public function testFindOrCreateExistingEntity(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $article = $articles->findOrCreate(['title' => 'First Article'], function ($article): void { + $this->fail('Should not be called for existing entities.'); + }); + $this->assertFalse($article->isNew()); + $this->assertNotNull($article->id); + $this->assertSame('First Article', $article->title); + } + + /** + * Test that findOrCreate uses the search conditions as defaults for new entity. + */ + public function testFindOrCreateDefaults(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $callbackExecuted = false; + $article = $articles->findOrCreate( + ['author_id' => 2, 'title' => 'First Article'], + function ($article) use (&$callbackExecuted): void { + $this->assertInstanceOf(EntityInterface::class, $article); + $article->patch(['published' => 'N', 'body' => 'New body']); + $callbackExecuted = true; + }, + ); + $this->assertTrue($callbackExecuted); + $this->assertFalse($article->isNew()); + $this->assertNotNull($article->id); + $this->assertSame('First Article', $article->title); + $this->assertSame('New body', $article->body); + $this->assertSame('N', $article->published); + $this->assertSame(2, $article->author_id); + + $query = $articles->find()->where(['author_id' => 2, 'title' => 'First Article']); + $article = $articles->findOrCreate($query); + $this->assertSame('First Article', $article->title); + $this->assertSame(2, $article->author_id); + $this->assertFalse($article->isNew()); + } + + /** + * Test that findOrCreate adds new entity without using a callback. + */ + public function testFindOrCreateNoCallable(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $article = $articles->findOrCreate(['title' => 'Just Something New']); + $this->assertFalse($article->isNew()); + $this->assertNotNull($article->id); + $this->assertSame('Just Something New', $article->title); + } + + /** + * Test that findOrCreate executes search conditions as a callable. + */ + public function testFindOrCreateSearchCallable(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $calledOne = false; + $calledTwo = false; + $article = $articles->findOrCreate(function ($query) use (&$calledOne): void { + $this->assertInstanceOf(Query::class, $query); + $query->where(['title' => 'Something Else']); + $calledOne = true; + }, function ($article) use (&$calledTwo): void { + $this->assertInstanceOf(EntityInterface::class, $article); + $article->title = 'Set Defaults Here'; + $calledTwo = true; + }); + $this->assertTrue($calledOne); + $this->assertTrue($calledTwo); + $this->assertFalse($article->isNew()); + $this->assertNotNull($article->id); + $this->assertSame('Set Defaults Here', $article->title); + } + + /** + * Test that findOrCreate options disable defaults. + */ + public function testFindOrCreateNoDefaults(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $article = $articles->findOrCreate(['title' => 'A New Article', 'published' => 'Y'], function ($article): void { + $this->assertInstanceOf(EntityInterface::class, $article); + $article->title = 'A Different Title'; + }, ['defaults' => false]); + $this->assertFalse($article->isNew()); + $this->assertNotNull($article->id); + $this->assertSame('A Different Title', $article->title); + $this->assertNull($article->published, 'Expected Null since defaults are disabled.'); + } + + /** + * Test that findOrCreate executes callable inside transaction. + */ + public function testFindOrCreateTransactions(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->getEventManager()->on('Model.afterSaveCommit', function (EventInterface $event, EntityInterface $entity, ArrayObject $options): void { + $entity->afterSaveCommit = true; + }); + + $article = $articles->findOrCreate(function ($query): void { + $this->assertInstanceOf(Query::class, $query); + $query->where(['title' => 'Find Something New']); + $this->assertTrue($this->connection->inTransaction()); + }, function ($article): void { + $this->assertInstanceOf(EntityInterface::class, $article); + $article->title = 'Success'; + $this->assertTrue($this->connection->inTransaction()); + }); + $this->assertFalse($article->isNew()); + $this->assertNotNull($article->id); + $this->assertSame('Success', $article->title); + $this->assertTrue($article->afterSaveCommit); + } + + /** + * Test that findOrCreate executes callable without transaction. + */ + public function testFindOrCreateNoTransaction(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $article = $articles->findOrCreate(function (SelectQuery $query): void { + $this->assertInstanceOf(SelectQuery::class, $query); + $query->where(['title' => 'Find Something New']); + $this->assertFalse($this->connection->inTransaction()); + }, function ($article): void { + $this->assertInstanceOf(EntityInterface::class, $article); + $this->assertFalse($this->connection->inTransaction()); + $article->title = 'Success'; + }, ['atomic' => false]); + $this->assertFalse($article->isNew()); + $this->assertNotNull($article->id); + $this->assertSame('Success', $article->title); + } + + /** + * Test that findOrCreate throws a PersistenceFailedException when it cannot save + * an entity created from $search + */ + public function testFindOrCreateWithInvalidEntity(): void + { + $this->expectException(PersistenceFailedException::class); + $this->expectExceptionMessage( + 'Entity findOrCreate failure. ' . + 'Found the following errors (title._empty: "This field cannot be left empty").', + ); + + $articles = $this->getTableLocator()->get('Articles'); + $validator = new Validator(); + $validator->notEmptyString('title'); + $articles->setValidator('default', $validator); + + $articles->findOrCreate(['title' => '']); + } + + /** + * Test that findOrCreate allows patching of all $search keys + */ + public function testFindOrCreateAccessibleFields(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->setEntityClass(ProtectedEntity::class); + $validator = new Validator(); + $validator->notBlank('title'); + $articles->setValidator('default', $validator); + + $article = $articles->findOrCreate(['title' => 'test']); + $this->assertInstanceOf(ProtectedEntity::class, $article); + $this->assertSame('test', $article->title); + } + + /** + * Test that findOrCreate cannot accidentally bypass required validation. + */ + public function testFindOrCreatePartialValidation(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->setEntityClass(ProtectedEntity::class); + $validator = new Validator(); + $validator->notBlank('title')->requirePresence('title', 'create'); + $validator->notBlank('body')->requirePresence('body', 'create'); + $articles->setValidator('default', $validator); + + $this->expectException(PersistenceFailedException::class); + $this->expectExceptionMessage( + 'Entity findOrCreate failure. ' . + 'Found the following errors (title._required: "This field is required").', + ); + + $articles->findOrCreate(['body' => 'test']); + } + + /** + * Test that findOrCreate with array data. + */ + public function testFindOrCreateArrayData(): void + { + $articles = $this->getTableLocator()->get('Articles'); + + $firstArticle = $articles->findOrCreate(['title' => 'Some title'], ['body' => 'Some body']); + $this->assertFalse($firstArticle->isNew()); + $this->assertNotNull($firstArticle->id); + $this->assertSame('Some title', $firstArticle->title); + $this->assertSame('Some body', $firstArticle->body); + + $secondArticle = $articles->findOrCreate(['title' => 'Some title'], ['body' => 'Different body']); + $this->assertFalse($secondArticle->isNew()); + $this->assertNotNull($secondArticle->id); + $this->assertSame('Some title', $secondArticle->title); + $this->assertEquals($firstArticle->id, $secondArticle->id); + $this->assertSame('Some body', $secondArticle->body); + } + + /** + * Test that creating a table fires the initialize event. + */ + public function testInitializeEvent(): void + { + $count = 0; + $cb = function (EventInterface $event) use (&$count): void { + $count++; + }; + EventManager::instance()->on('Model.initialize', $cb); + $this->getTableLocator()->get('Articles'); + + $this->assertSame(1, $count, 'Callback should be called'); + EventManager::instance()->off('Model.initialize', $cb); + } + + /** + * Tests the hasFinder method + */ + public function testHasFinder(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->addBehavior('Sluggable'); + + $this->assertTrue($table->hasFinder('list')); + $this->assertTrue($table->hasFinder('noSlug')); + $this->assertFalse($table->hasFinder('noFind')); + } + + /** + * Tests that calling validator() trigger the buildValidator event + */ + public function testBuildValidatorEvent(): void + { + $count = 0; + $cb = function (EventInterface $event) use (&$count): void { + $count++; + }; + EventManager::instance()->on('Model.buildValidator', $cb); + $articles = $this->getTableLocator()->get('Articles'); + $articles->getValidator(); + $this->assertSame(1, $count, 'Callback should be called'); + + $articles->getValidator(); + $this->assertSame(1, $count, 'Callback should be called only once'); + } + + /** + * Tests the validateUnique method with different combinations + */ + public function testValidateUnique(): void + { + $table = $this->getTableLocator()->get('Users'); + $validator = new Validator(); + $validator->add('username', 'unique', ['rule' => 'validateUnique', 'provider' => 'table']); + $validator->setProvider('table', $table); + + $data = ['username' => ['larry', 'notthere']]; + $this->assertNotEmpty($validator->validate($data)); + + $data = ['username' => 'larry']; + $this->assertNotEmpty($validator->validate($data)); + + $data = ['username' => 'jose']; + $this->assertEmpty($validator->validate($data)); + + $data = ['username' => 'larry', 'id' => 3]; + $this->assertEmpty($validator->validate($data, false)); + + $data = ['username' => 'larry', 'id' => 3]; + $this->assertNotEmpty($validator->validate($data)); + + $data = ['username' => 'larry']; + $this->assertNotEmpty($validator->validate($data, false)); + } + + /** + * Tests the validateUnique method with scope + */ + public function testValidateUniqueScope(): void + { + $table = $this->getTableLocator()->get('Users'); + $validator = new Validator(); + $validator->add('username', 'unique', [ + 'rule' => ['validateUnique', ['derp' => 'erp', 'scope' => 'id']], + 'provider' => 'table', + ]); + $validator->setProvider('table', $table); + $data = ['username' => 'larry', 'id' => 3]; + $this->assertNotEmpty($validator->validate($data)); + + $data = ['username' => 'larry', 'id' => 1]; + $this->assertEmpty($validator->validate($data)); + + $data = ['username' => 'jose']; + $this->assertEmpty($validator->validate($data)); + } + + /** + * Tests the validateUnique method with options + */ + public function testValidateUniqueMultipleNulls(): void + { + $entity = new Entity([ + 'id' => 9, + 'site_id' => 1, + 'author_id' => null, + 'title' => 'Null title', + ]); + + $table = $this->getTableLocator()->get('SiteArticles'); + $table->save($entity); + + $validator = new Validator(); + $validator->add('site_id', 'unique', [ + 'rule' => [ + 'validateUnique', + [ + 'allowMultipleNulls' => false, + 'scope' => ['author_id'], + ], + ], + 'provider' => 'table', + 'message' => 'Must be unique.', + ]); + $validator->setProvider('table', $table); + + $data = ['site_id' => 1, 'author_id' => null, 'title' => 'Null dupe']; + $expected = ['site_id' => ['unique' => 'Must be unique.']]; + $this->assertEquals($expected, $validator->validate($data)); + } + + /** + * Tests that the callbacks receive the expected types of arguments. + */ + public function testCallbackArgumentTypes(): void + { + $table = $this->getTableLocator()->get('articles'); + $table->belongsTo('authors'); + + $eventManager = $table->getEventManager(); + + $associationBeforeFindCount = 0; + $table->getAssociation('authors')->getTarget()->getEventManager()->on( + 'Model.beforeFind', + function (EventInterface $event, SelectQuery $query, ArrayObject $options, bool $primary) use (&$associationBeforeFindCount): void { + $this->assertIsBool($primary); + $associationBeforeFindCount++; + }, + ); + + $beforeFindCount = 0; + $eventManager->on( + 'Model.beforeFind', + function (EventInterface $event, SelectQuery $query, ArrayObject $options, bool $primary) use (&$beforeFindCount): void { + $this->assertIsBool($primary); + $beforeFindCount++; + }, + ); + $table->find()->contain('authors')->first(); + $this->assertSame(1, $associationBeforeFindCount); + $this->assertSame(1, $beforeFindCount); + + $buildValidatorCount = 0; + $eventManager->on( + 'Model.buildValidator', + $callback = function (EventInterface $event, Validator $validator, $name) use (&$buildValidatorCount): void { + $this->assertIsString($name); + $buildValidatorCount++; + }, + ); + $table->getValidator(); + $this->assertSame(1, $buildValidatorCount); + $buildRulesCount = 0; + $beforeRulesCount = 0; + $afterRulesCount = 0; + $beforeSaveCount = 0; + $afterSaveCount = 0; + $eventManager->on( + 'Model.buildRules', + function (EventInterface $event, RulesChecker $rules) use (&$buildRulesCount): void { + $buildRulesCount++; + }, + ); + $eventManager->on( + 'Model.beforeRules', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options, $operation) use (&$beforeRulesCount): void { + $this->assertIsString($operation); + $beforeRulesCount++; + }, + ); + $eventManager->on( + 'Model.afterRules', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options, $result, $operation) use (&$afterRulesCount): void { + $this->assertIsBool($result); + $this->assertIsString($operation); + $afterRulesCount++; + }, + ); + $eventManager->on( + 'Model.beforeSave', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$beforeSaveCount): void { + $beforeSaveCount++; + }, + ); + $eventManager->on( + 'Model.afterSave', + $afterSaveCallback = function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$afterSaveCount): void { + $afterSaveCount++; + }, + ); + $entity = new Entity(['title' => 'Title']); + $this->assertNotFalse($table->save($entity)); + $this->assertSame(1, $buildRulesCount); + $this->assertSame(1, $beforeRulesCount); + $this->assertSame(1, $afterRulesCount); + $this->assertSame(1, $beforeSaveCount); + $this->assertSame(1, $afterSaveCount); + $beforeDeleteCount = 0; + $afterDeleteCount = 0; + $eventManager->on( + 'Model.beforeDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$beforeDeleteCount): void { + $beforeDeleteCount++; + }, + ); + $eventManager->on( + 'Model.afterDelete', + function (EventInterface $event, EntityInterface $entity, ArrayObject $options) use (&$afterDeleteCount): void { + $afterDeleteCount++; + }, + ); + $this->assertTrue($table->delete($entity, ['checkRules' => false])); + $this->assertSame(1, $beforeDeleteCount); + $this->assertSame(1, $afterDeleteCount); + } + + /** + * Tests that calling newEmptyEntity() on a table sets the right source alias. + */ + public function testSetEntitySource(): void + { + $table = $this->getTableLocator()->get('Articles'); + $this->assertSame('Articles', $table->newEmptyEntity()->getSource()); + + $this->loadPlugins(['TestPlugin']); + $table = $this->getTableLocator()->get('TestPlugin.Comments'); + $this->assertSame('TestPlugin.Comments', $table->newEmptyEntity()->getSource()); + } + + /** + * Tests that passing a coned entity that was marked as new to save() will + * actually save it as a new entity + */ + public function testSaveWithClonedEntity(): void + { + $table = $this->getTableLocator()->get('Articles'); + $article = $table->get(1); + + $cloned = clone $article; + $cloned->unset('id'); + $cloned->setNew(true); + $this->assertSame($cloned, $table->save($cloned)); + $this->assertEquals( + $article->extract(['title', 'author_id']), + $cloned->extract(['title', 'author_id']), + ); + $this->assertSame(4, $cloned->id); + } + + /** + * Tests that the _ids notation can be used for HasMany + */ + public function testSaveHasManyWithIds(): void + { + $data = [ + 'username' => 'lux', + 'password' => 'passphrase', + 'comments' => [ + '_ids' => [1, 2], + ], + ]; + + $userTable = $this->getTableLocator()->get('Users'); + $userTable->hasMany('Comments'); + $savedUser = $userTable->save($userTable->newEntity($data, ['associated' => ['Comments']])); + $retrievedUser = $userTable->find('all')->where(['id' => $savedUser->id])->contain(['Comments'])->first(); + $this->assertEquals($savedUser->comments[0]->user_id, $retrievedUser->comments[0]->user_id); + $this->assertEquals($savedUser->comments[1]->user_id, $retrievedUser->comments[1]->user_id); + } + + /** + * Tests that on second save, entities for the has many relation are not marked + * as dirty unnecessarily. This helps avoid wasteful database statements and makes + * for a cleaner transaction log + */ + public function testSaveHasManyNoWasteSave(): void + { + $data = [ + 'username' => 'lux', + 'password' => 'passphrase', + 'comments' => [ + '_ids' => [1, 2], + ], + ]; + + $userTable = $this->getTableLocator()->get('Users'); + $userTable->hasMany('Comments'); + $savedUser = $userTable->save($userTable->newEntity($data, ['associated' => ['Comments']])); + + $counter = 0; + $userTable->Comments + ->getEventManager() + ->on('Model.afterSave', function (EventInterface $event, $entity) use (&$counter): void { + if ($entity->isDirty()) { + $counter++; + } + }); + + $savedUser->comments[] = $userTable->Comments->get(5); + $this->assertCount(3, $savedUser->comments); + $savedUser->setDirty('comments', true); + $userTable->save($savedUser); + $this->assertSame(1, $counter); + } + + /** + * Tests that on second save, entities for the belongsToMany relation are not marked + * as dirty unnecessarily. This helps avoid wasteful database statements and makes + * for a cleaner transaction log + */ + public function testSaveBelongsToManyNoWasteSave(): void + { + $data = [ + 'title' => 'foo', + 'body' => 'bar', + 'tags' => [ + '_ids' => [1, 2], + ], + ]; + + $table = $this->getTableLocator()->get('Articles'); + $article = $table->save($table->newEntity($data, ['associated' => ['Tags']])); + + $counter = 0; + $table->Tags->junction() + ->getEventManager() + ->on('Model.afterSave', function (EventInterface $event, $entity) use (&$counter): void { + if ($entity->isDirty()) { + $counter++; + } + }); + + $article->tags[] = $table->Tags->get(3); + $this->assertCount(3, $article->tags); + $article->setDirty('tags', true); + $table->save($article); + $this->assertSame(1, $counter); + } + + /** + * Tests that after saving then entity contains the right primary + * key casted to the right type + */ + public function testSaveCorrectPrimaryKeyType(): void + { + $entity = new Entity([ + 'username' => 'superuser', + 'created' => new DateTime('2013-10-10 00:00'), + 'updated' => new DateTime('2013-10-10 00:00'), + ], ['markNew' => true]); + + $table = $this->getTableLocator()->get('Users'); + $this->assertSame($entity, $table->save($entity)); + $this->assertSame(self::$nextUserId, $entity->id); + } + + /** + * Tests entity clean() + */ + public function testEntityClean(): void + { + $table = $this->getTableLocator()->get('Articles'); + $table->getValidator()->requirePresence('body'); + $entity = $table->newEntity(['title' => 'mark']); + + $entity->setDirty('title', true); + $entity->setInvalidField('title', 'albert'); + + $this->assertNotEmpty($entity->getErrors()); + $this->assertTrue($entity->isDirty()); + $this->assertEquals(['title' => 'albert'], $entity->getInvalid()); + + $entity->title = 'alex'; + $this->assertSame($entity->getOriginal('title'), 'mark'); + + $entity->clean(); + + $this->assertEmpty($entity->getErrors()); + $this->assertFalse($entity->isDirty()); + $this->assertEquals([], $entity->getInvalid()); + $this->assertSame($entity->getOriginal('title'), 'alex'); + } + + /** + * Tests the loadInto() method + */ + public function testLoadIntoEntity(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany('SiteArticles'); + + $entity = $table->get(1); + $result = $table->loadInto($entity, ['SiteArticles', 'Articles.Tags']); + $this->assertSame($entity, $result); + + $expected = $table->get(1, contain: ['SiteArticles', 'Articles.Tags']); + $this->assertEquals($expected->site_articles, $result->site_articles); + $this->assertEquals($expected->articles, $result->articles); + } + + /** + * Tests that it is possible to pass conditions and fields to loadInto() + */ + public function testLoadIntoWithConditions(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany('SiteArticles'); + + $entity = $table->get(1); + $options = [ + 'SiteArticles' => ['fields' => ['title', 'author_id']], + 'Articles.Tags' => function ($q) { + return $q->where(['Tags.name' => 'tag2']); + }, + ]; + $result = $table->loadInto($entity, $options); + $this->assertSame($entity, $result); + $expected = $table->get(1, contain: $options); + $this->assertEquals($expected->site_articles, $result->site_articles); + $this->assertEquals(['title', 'author_id'], $expected->site_articles[0]->getOriginalFields()); + $this->assertEquals($expected->articles, $result->articles); + $this->assertSame('tag2', $expected->articles[0]->tags[0]->name); + } + + /** + * Tests loadInto() with a belongsTo association + */ + public function testLoadBelongsTo(): void + { + $table = $this->getTableLocator()->get('Articles'); + + $entity = $table->get(2); + $result = $table->loadInto($entity, ['Authors']); + $this->assertSame($entity, $result); + + $expected = $table->get(2, contain: ['Authors']); + $this->assertEquals($expected, $entity); + } + + /** + * Tests loadInto() with a belongsTo association with a join and contain on the same table + */ + public function testLoadBelongsToDoubleJoin(): void + { + $table = $this->getTableLocator()->get('Comments'); + $table->belongsTo('Articles'); + + $entity = $table->get(2); + $result = $table->loadInto($entity, [ + 'Articles' => function (SelectQuery $q) { + return $q->innerJoinWith('Authors', function ($q) { + return $q->where(['Authors.name' => 'mariano']); + }); + }, + 'Articles.Authors', + ]); + + $this->assertSame($entity, $result); + + $expected = $table->get(2, contain: ['Articles.Authors']); + $this->assertEquals($expected, $entity); + $this->assertEquals($expected->article, $entity->article); + $this->assertEquals($expected->article->author, $entity->article->author); + } + + /** + * Tests that it is possible to post-load associations for many entities at + * the same time + */ + public function testLoadIntoMany(): void + { + $table = $this->getTableLocator()->get('Authors'); + $table->hasMany('SiteArticles'); + + $entities = $table->find()->toArray(); + $contain = ['SiteArticles', 'Articles.Tags']; + $result = $table->loadInto($entities, $contain); + + foreach ($entities as $k => $v) { + $this->assertSame($v, $result[$k]); + } + + $entities = $table->find()->contain($contain)->toArray(); + foreach ($entities as $k => $v) { + $this->assertEquals($v->site_articles, $result[$k]->site_articles); + $this->assertEquals($v->articles, $result[$k]->articles); + } + } + + /** + * Tests loadInto() with deeply nested associations + */ + public function testLoadIntoNestedAssociations(): void + { + $table = $this->getTableLocator()->get('Authors'); + + $entity = $table->get(1); + // This should work without throwing an error about 'includeFields' not being an association + $result = $table->loadInto($entity, ['Articles.Tags']); + $this->assertSame($entity, $result); + $this->assertNotEmpty($result->articles); + $this->assertNotEmpty($result->articles[0]->tags); + + $expected = $table->get(1, contain: ['Articles.Tags']); + $this->assertEquals($expected->articles, $result->articles); + $this->assertEquals($expected->articles[0]->tags, $result->articles[0]->tags); + } + + /** + * Tests loadInto() multiple times with nested associations - reproduces GitHub issue #16362 + */ + public function testLoadIntoMultipleTimesWithNestedAssociations(): void + { + $table = $this->getTableLocator()->get('Authors'); + + // First load some associations + $entity = $table->get(1); + $entity = $table->loadInto($entity, ['Articles']); + $this->assertNotEmpty($entity->articles); + $this->assertEmpty($entity->articles[0]->tags); + + // Now load nested associations - this should not throw an error about 'includeFields' + $result = $table->loadInto($entity, ['Articles.Tags']); + $this->assertSame($entity, $result); + + // Verify the nested associations were loaded correctly + $this->assertNotEmpty($result->articles); + $firstArticle = $result->articles[0]; + $this->assertNotNull($firstArticle); + + // Tags should be loaded now + $this->assertIsArray($firstArticle->tags); + } + + /** + * Tests that saveOrFail triggers an exception on not successful save + */ + public function testSaveOrFail(): void + { + $this->expectException(PersistenceFailedException::class); + $this->expectExceptionMessage('Entity save failure.'); + + $entity = new Entity([ + 'foo' => 'bar', + ]); + $table = $this->getTableLocator()->get('users'); + + $table->saveOrFail($entity); + } + + /** + * Tests that saveOrFail displays useful messages on output, especially in tests for CLI. + */ + public function testSaveOrFailErrorDisplay(): void + { + $this->expectException(PersistenceFailedException::class); + $this->expectExceptionMessage('Entity save failure. Found the following errors (field.0: "Some message", multiple.one: "One", multiple.two: "Two")'); + + $entity = new Entity([ + 'foo' => 'bar', + ]); + $entity->setError('field', 'Some message'); + $entity->setError('multiple', ['one' => 'One', 'two' => 'Two']); + $table = $this->getTableLocator()->get('users'); + + $table->saveOrFail($entity); + } + + /** + * Tests that saveOrFail with nested errors + */ + public function testSaveOrFailNestedError(): void + { + $this->expectException(PersistenceFailedException::class); + $this->expectExceptionMessage('Entity save failure. Found the following errors (articles.0.title.0: "Bad value")'); + + $entity = new Entity([ + 'username' => 'bad', + 'articles' => [ + new Entity(['title' => 'not an entity']), + ], + ]); + $entity->articles[0]->setError('title', 'Bad value'); + + $table = $this->getTableLocator()->get('Users'); + $table->hasMany('Articles'); + + $table->saveOrFail($entity); + } + + /** + * Tests that saveOrFail returns the right entity + */ + public function testSaveOrFailGetEntity(): void + { + $entity = new Entity([ + 'foo' => 'bar', + ]); + $table = $this->getTableLocator()->get('users'); + + try { + $table->saveOrFail($entity); + } catch (PersistenceFailedException $e) { + $this->assertSame($entity, $e->getEntity()); + } + } + + /** + * Tests that deleteOrFail triggers an exception on not successful delete + */ + public function testDeleteOrFail(): void + { + $this->expectException(PersistenceFailedException::class); + $this->expectExceptionMessage('Entity delete failure.'); + $entity = new Entity([ + 'id' => 999, + ]); + $table = $this->getTableLocator()->get('users'); + + $table->deleteOrFail($entity); + } + + /** + * Tests that deleteOrFail returns the right entity + */ + public function testDeleteOrFailGetEntity(): void + { + $entity = new Entity([ + 'id' => 999, + ]); + $table = $this->getTableLocator()->get('users'); + + try { + $table->deleteOrFail($entity); + } catch (PersistenceFailedException $e) { + $this->assertSame($entity, $e->getEntity()); + } + } + + /** + * Helper method to skip tests when connection is SQLServer. + */ + public function skipIfSqlServer(): void + { + $this->skipIf( + $this->connection->getDriver() instanceof Sqlserver, + 'SQLServer does not support the requirements of this test.', + ); + } +} diff --git a/tests/TestCase/ORM/TableUuidTest.php b/tests/TestCase/ORM/TableUuidTest.php new file mode 100644 index 00000000000..ff160e779c6 --- /dev/null +++ b/tests/TestCase/ORM/TableUuidTest.php @@ -0,0 +1,162 @@ + + */ + protected array $fixtures = [ + 'core.BinaryUuidItems', + 'core.UuidItems', + ]; + + /** + * setup + */ + protected function setUp(): void + { + parent::setUp(); + static::setAppNamespace(); + } + + /** + * Provider for testing that string and binary uuids work the same + * + * @return array + */ + public static function uuidTableProvider(): array + { + return [['uuid_items'], ['binary_uuid_items']]; + } + + /** + * Test saving new records sets uuids + */ + #[DataProvider('uuidTableProvider')] + public function testSaveNew(string $tableName): void + { + $entity = new Entity([ + 'name' => 'shiny new', + 'published' => true, + ]); + $table = $this->getTableLocator()->get($tableName); + $this->assertSame($entity, $table->save($entity)); + $this->assertMatchesRegularExpression('/^[a-f0-9-]{36}$/', $entity->id, 'Should be 36 characters'); + + $row = $table->find('all')->where(['id' => $entity->id])->first(); + $row->id = strtolower($row->id); + $this->assertEquals($entity->toArray(), $row->toArray()); + } + + /** + * Test saving new records allows manual uuids + */ + #[DataProvider('uuidTableProvider')] + public function testSaveNewSpecificId(string $tableName): void + { + $id = Text::uuid(); + $entity = new Entity([ + 'id' => $id, + 'name' => 'shiny and new', + 'published' => true, + ]); + $table = $this->getTableLocator()->get($tableName); + $this->assertSame($entity, $table->save($entity)); + $this->assertSame($id, $entity->id); + + $row = $table->find('all')->where(['id' => $id])->first(); + $this->assertNotEmpty($row); + $this->assertSame($id, strtolower($row->id)); + $this->assertSame($entity->name, $row->name); + } + + /** + * Test saving existing records works + */ + #[DataProvider('uuidTableProvider')] + public function testSaveUpdate(string $tableName): void + { + $id = '481fc6d0-b920-43e0-a40d-6d1740cf8569'; + $entity = new Entity([ + 'id' => $id, + 'name' => 'shiny update', + 'published' => true, + ]); + + $table = $this->getTableLocator()->get($tableName); + $this->assertSame($entity, $table->save($entity)); + $this->assertSame($id, $entity->id, 'Should be 36 characters'); + + $row = $table->find('all')->where(['id' => $entity->id])->first(); + $row->id = strtolower($row->id); + $this->assertEquals($entity->toArray(), $row->toArray()); + } + + /** + * Test delete with string pk. + */ + #[DataProvider('uuidTableProvider')] + public function testGetById(string $tableName): void + { + $table = $this->getTableLocator()->get($tableName); + $entity = $table->find('all')->firstOrFail(); + + $result = $table->get($entity->id); + $this->assertSame($result->id, $entity->id); + } + + /** + * Test delete with string pk. + */ + #[DataProvider('uuidTableProvider')] + public function testDelete(string $tableName): void + { + $table = $this->getTableLocator()->get($tableName); + $entity = $table->find('all')->firstOrFail(); + + $this->assertTrue($table->delete($entity)); + $query = $table->find('all')->where(['id' => $entity->id]); + $this->assertEmpty($query->first(), 'No row left'); + } + + /** + * Tests that sql server does not error when an empty uuid is bound + */ + #[DataProvider('uuidTableProvider')] + public function testEmptyUuid(string $tableName): void + { + $id = ''; + $table = $this->getTableLocator()->get($tableName); + $entity = $table->find('all') + ->where(['id' => $id]) + ->first(); + + $this->assertNull($entity); + } +} diff --git a/tests/TestCase/ORM/TableValidationWithBadDefinerTest.php b/tests/TestCase/ORM/TableValidationWithBadDefinerTest.php new file mode 100644 index 00000000000..e3abccc30c6 --- /dev/null +++ b/tests/TestCase/ORM/TableValidationWithBadDefinerTest.php @@ -0,0 +1,47 @@ +expectException(AssertionError::class); + $this->expectExceptionMessage(sprintf( + 'The `%s::validationBad()` validation method must return an instance of `Cake\Validation\Validator`.', + $table::class, + )); + + $table->getValidator('bad'); + } +} + +// phpcs:disable +class ValidationWithBadDefinerTable extends Table +{ + public function validationBad($validator): string + { + return ''; + } +} +// phpcs:enable diff --git a/tests/TestCase/ORM/TableValidationWithDefinerTest.php b/tests/TestCase/ORM/TableValidationWithDefinerTest.php new file mode 100644 index 00000000000..848943800a4 --- /dev/null +++ b/tests/TestCase/ORM/TableValidationWithDefinerTest.php @@ -0,0 +1,42 @@ +getValidator('forOtherStuff'); + $this->assertNotSame($other, $table->getValidator()); + $this->assertSame($table, $other->getProvider('table')); + } +} + +// phpcs:disable +class ValidationWithDefinerTable extends Table +{ + public function validationForOtherStuff($validator) + { + return $validator; + } +} +// phpcs:enable diff --git a/tests/TestCase/Routing/AssetTest.php b/tests/TestCase/Routing/AssetTest.php new file mode 100644 index 00000000000..b6285ca6c19 --- /dev/null +++ b/tests/TestCase/Routing/AssetTest.php @@ -0,0 +1,428 @@ + '/', + ]); + Router::setRequest($request); + + static::setAppNamespace(); + $this->loadPlugins(['TestTheme']); + $this->builder = Router::createRouteBuilder('/'); + $this->builder->fallbacks(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + + $this->clearPlugins(); + } + + /** + * test assetTimestamp application + */ + public function testAssetTimestamp(): void + { + Configure::write('Foo.bar', 'test'); + Configure::write('Asset.timestamp', false); + $result = Asset::assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertSame(Configure::read('App.cssBaseUrl') . 'cake.generic.css', $result); + + Configure::write('Asset.timestamp', true); + Configure::write('debug', false); + + $result = Asset::assetTimestamp('/%3Cb%3E/cake.generic.css'); + $this->assertSame('/%3Cb%3E/cake.generic.css', $result); + + $result = Asset::assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertSame(Configure::read('App.cssBaseUrl') . 'cake.generic.css', $result); + + Configure::write('Asset.timestamp', true); + Configure::write('debug', true); + $result = Asset::assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertMatchesRegularExpression('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + + Configure::write('Asset.timestamp', 'force'); + Configure::write('debug', false); + $result = Asset::assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertMatchesRegularExpression('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + + $result = Asset::assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css?someparam'); + $this->assertSame(Configure::read('App.cssBaseUrl') . 'cake.generic.css?someparam', $result); + + $request = Router::getRequest()->withAttribute('webroot', '/some/dir/'); + Router::setRequest($request); + $result = Asset::assetTimestamp('/some/dir/' . Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertMatchesRegularExpression('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + } + + /** + * test assetUrl application + */ + public function testAssetUrl(): void + { + $this->builder->connect('/{controller}/{action}/*'); + + $result = Asset::url('js/post.js', ['fullBase' => true]); + $this->assertSame(Router::fullBaseUrl() . '/js/post.js', $result); + + $result = Asset::url('foo.jpg', ['pathPrefix' => 'img/']); + $this->assertSame('/img/foo.jpg', $result); + + $result = Asset::url('foo.jpg', ['fullBase' => true]); + $this->assertSame(Router::fullBaseUrl() . '/foo.jpg', $result); + + $result = Asset::url('style', ['ext' => '.css']); + $this->assertSame('/style.css', $result); + + $result = Asset::url('dir/sub dir/my image', ['ext' => '.jpg']); + $this->assertSame('/dir/sub%20dir/my%20image.jpg', $result); + + $result = Asset::url('foo.jpg?one=two&three=four'); + $this->assertSame('/foo.jpg?one=two&three=four', $result); + + // No HTML entities encoding is done + $result = Asset::url('x:">'); + $this->assertSame('x:">', $result); + + // URL encoding is done + $result = Asset::url('dir/big+tall/image', ['ext' => '.jpg']); + $this->assertSame('/dir/big%2Btall/image.jpg', $result); + + $result = Asset::url('/posts/index/adbirawwy/page:6/sort:type/'); + $this->assertSame('/posts/index/adbirawwy/page%3A6/sort%3Atype/', $result); + } + + /** + * Test assetUrl and data uris + */ + public function testAssetUrlDataUri(): void + { + $request = Router::getRequest() + ->withAttribute('base', 'subdir') + ->withAttribute('webroot', 'subdir/'); + Router::setRequest($request); + + $data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4' . + '/8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='; + $result = Asset::url($data); + $this->assertSame($data, $result); + + $data = 'data:image/png;base64,'; + $result = Asset::url($data); + $this->assertSame($data, $result); + } + + /** + * Test assetUrl with no rewriting. + */ + public function testAssetUrlNoRewrite(): void + { + $request = Router::getRequest() + ->withAttribute('base', '/cake_dev/index.php') + ->withAttribute('webroot', '/cake_dev/app/webroot/') + ->withRequestTarget('/cake_dev/index.php/tasks'); + Router::setRequest($request); + + $result = Asset::url('img/cake.icon.png', ['fullBase' => true]); + $expected = Configure::read('App.fullBaseUrl') . '/cake_dev/app/webroot/img/cake.icon.png'; + $this->assertSame($expected, $result); + } + + /** + * Test assetUrl with plugins. + */ + public function testAssetUrlPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + + $result = Asset::url('TestPlugin.style', ['ext' => '.css']); + $this->assertSame('/test_plugin/style.css', $result); + + $result = Asset::url('TestPlugin.style', ['ext' => '.css', 'plugin' => false]); + $this->assertSame('/TestPlugin.style.css', $result); + + $this->removePlugins(['TestPlugin']); + } + + /** + * Tests assetUrl() with full base URL. + */ + public function testAssetUrlFullBase(): void + { + $result = Asset::url('img/foo.jpg', ['fullBase' => true]); + $this->assertSame(Router::fullBaseUrl() . '/img/foo.jpg', $result); + + $result = Asset::url('img/foo.jpg', ['fullBase' => 'https://xyz/']); + $this->assertSame('https://xyz/img/foo.jpg', $result); + } + + /** + * test assetUrl and Asset.timestamp = force + */ + public function testAssetUrlTimestampForce(): void + { + Configure::write('Asset.timestamp', 'force'); + + $result = Asset::url('cake.generic.css', ['pathPrefix' => Configure::read('App.cssBaseUrl')]); + $this->assertMatchesRegularExpression('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + } + + /** + * Test assetTimestamp with timestamp option overriding `Asset.timestamp` in Configure. + */ + public function testAssetTimestampConfigureOverride(): void + { + Configure::write('Asset.timestamp', 'force'); + $timestamp = false; + + $result = Asset::assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css', $timestamp); + $this->assertSame(Configure::read('App.cssBaseUrl') . 'cake.generic.css', $result); + } + + /** + * test assetTimestamp with plugins and themes + */ + public function testAssetTimestampPluginsAndThemes(): void + { + Configure::write('Asset.timestamp', 'force'); + $this->loadPlugins(['TestTheme', 'TestPlugin', 'Company/TestPluginThree']); + + $result = Asset::assetTimestamp('/test_plugin/css/test_plugin_asset.css'); + $this->assertMatchesRegularExpression('#/test_plugin/css/test_plugin_asset.css\?[0-9]+$#', $result, 'Missing timestamp plugin'); + + $result = Asset::assetTimestamp('/company/test_plugin_three/css/company.css'); + $this->assertMatchesRegularExpression('#/company/test_plugin_three/css/company.css\?[0-9]+$#', $result, 'Missing timestamp plugin'); + + $result = Asset::assetTimestamp('/test_plugin/css/i_dont_exist.css'); + $this->assertMatchesRegularExpression('#/test_plugin/css/i_dont_exist.css$#', $result, 'No error on missing file'); + + $result = Asset::assetTimestamp('/test_theme/js/theme.js'); + $this->assertMatchesRegularExpression('#/test_theme/js/theme.js\?[0-9]+$#', $result, 'Missing timestamp theme'); + + $result = Asset::assetTimestamp('/test_theme/js/nonexistent.js'); + $this->assertMatchesRegularExpression('#/test_theme/js/nonexistent.js$#', $result, 'No error on missing file'); + } + + /** + * test script() + */ + public function testScript(): void + { + $this->builder->connect('/{controller}/{action}/*'); + + $result = Asset::scriptUrl('post.js', ['fullBase' => true]); + $this->assertSame(Router::fullBaseUrl() . '/js/post.js', $result); + } + + /** + * Test script and Asset.timestamp = force + */ + public function testScriptTimestampForce(): void + { + Configure::write('Asset.timestamp', 'force'); + + $result = Asset::scriptUrl('script.js'); + $this->assertMatchesRegularExpression('/' . preg_quote(Configure::read('App.jsBaseUrl') . 'script.js?', '/') . '[0-9]+/', $result); + } + + /** + * Test script with timestamp option overriding `Asset.timestamp` in Configure + */ + public function testScriptTimestampConfigureOverride(): void + { + Configure::write('Asset.timestamp', 'force'); + $timestamp = false; + + $result = Asset::scriptUrl('script.js', ['timestamp' => $timestamp]); + $this->assertSame('/' . Configure::read('App.jsBaseUrl') . 'script.js', $result); + } + + /** + * test image() + */ + public function testImage(): void + { + $result = Asset::imageUrl('foo.jpg'); + $this->assertSame('/img/foo.jpg', $result); + + $result = Asset::imageUrl('foo.jpg', ['fullBase' => true]); + $this->assertSame(Router::fullBaseUrl() . '/img/foo.jpg', $result); + + $result = Asset::imageUrl('dir/sub dir/my image.jpg'); + $this->assertSame('/img/dir/sub%20dir/my%20image.jpg', $result); + + $result = Asset::imageUrl('foo.jpg?one=two&three=four'); + $this->assertSame('/img/foo.jpg?one=two&three=four', $result); + + $result = Asset::imageUrl('dir/big+tall/image.jpg'); + $this->assertSame('/img/dir/big%2Btall/image.jpg', $result); + + $result = Asset::imageUrl('cid:foo.jpg'); + $this->assertSame('cid:foo.jpg', $result); + + $result = Asset::imageUrl('CID:foo.jpg'); + $this->assertSame('CID:foo.jpg', $result); + } + + /** + * Test image with `Asset.timestamp` = force + */ + public function testImageTimestampForce(): void + { + Configure::write('Asset.timestamp', 'force'); + + $result = Asset::imageUrl('cake.icon.png'); + $this->assertMatchesRegularExpression('/' . preg_quote('img/cake.icon.png?', '/') . '[0-9]+/', $result); + } + + /** + * Test image with timestamp option overriding `Asset.timestamp` in Configure + */ + public function testImageTimestampConfigureOverride(): void + { + Configure::write('Asset.timestamp', 'force'); + $timestamp = false; + + $result = Asset::imageUrl('cake.icon.png', ['timestamp' => $timestamp]); + $this->assertSame('/img/cake.icon.png', $result); + } + + /** + * test css + */ + public function testCss(): void + { + $result = Asset::cssUrl('style'); + $this->assertSame('/css/style.css', $result); + } + + /** + * Test css with `Asset.timestamp` = force + */ + public function testCssTimestampForce(): void + { + Configure::write('Asset.timestamp', 'force'); + + $result = Asset::cssUrl('cake.generic'); + $this->assertMatchesRegularExpression('/' . preg_quote('css/cake.generic.css?', '/') . '[0-9]+/', $result); + } + + /** + * Test image with timestamp option overriding `Asset.timestamp` in Configure + */ + public function testCssTimestampConfigureOverride(): void + { + Configure::write('Asset.timestamp', 'force'); + $timestamp = false; + + $result = Asset::cssUrl('cake.generic', ['timestamp' => $timestamp]); + $this->assertSame('/css/cake.generic.css', $result); + } + + /** + * Test generating paths with webroot(). + */ + public function testWebrootPaths(): void + { + $result = Asset::webroot('/img/cake.power.gif'); + $expected = '/img/cake.power.gif'; + $this->assertSame($expected, $result); + + $result = Asset::webroot('/img/cake.power.gif', ['theme' => 'TestTheme']); + $expected = '/test_theme/img/cake.power.gif'; + $this->assertSame($expected, $result); + + Asset::setInflectionType('dasherize'); + $result = Asset::webroot('/img/test.jpg', ['theme' => 'TestTheme']); + $expected = '/test-theme/img/test.jpg'; + $this->assertSame($expected, $result); + Asset::setInflectionType('underscore'); + + $webRoot = Configure::read('App.wwwRoot'); + Configure::write('App.wwwRoot', TEST_APP . 'TestApp/webroot/'); + + $result = Asset::webroot('/img/cake.power.gif', ['theme' => 'TestTheme']); + $expected = '/test_theme/img/cake.power.gif'; + $this->assertSame($expected, $result); + + $result = Asset::webroot('/img/test.jpg', ['theme' => 'TestTheme']); + $expected = '/test_theme/img/test.jpg'; + $this->assertSame($expected, $result); + + $result = Asset::webroot('/img/cake.icon.gif', ['theme' => 'TestTheme']); + $expected = '/img/cake.icon.gif'; + $this->assertSame($expected, $result); + + $result = Asset::webroot('/img/cake.icon.gif?some=param', ['theme' => 'TestTheme']); + $expected = '/img/cake.icon.gif?some=param'; + $this->assertSame($expected, $result); + + Configure::write('App.wwwRoot', $webRoot); + } + + /** + * Test plugin based assets will NOT use the plugin name + */ + public function testPluginAssetsPrependImageBaseUrl(): void + { + $cdnPrefix = 'https://cdn.example.com/'; + Configure::write('App.imageBaseUrl', $cdnPrefix . '{plugin}img/'); + $result = Asset::imageUrl('TestTheme.text.jpg'); + $expected = $cdnPrefix . 'test_theme/img/text.jpg'; + $this->assertSame($expected, $result); + + Configure::write('App.jsBaseUrl', $cdnPrefix . '{plugin}js/'); + $result = Asset::scriptUrl('TestTheme.app.js'); + $expected = $cdnPrefix . 'test_theme/js/app.js'; + $this->assertSame($expected, $result); + + Configure::write('App.cssBaseUrl', $cdnPrefix); + $result = Asset::cssUrl('TestTheme.app.css'); + $expected = $cdnPrefix . 'app.css'; + $this->assertSame($expected, $result); + } +} diff --git a/tests/TestCase/Routing/FunctionsGlobalTest.php b/tests/TestCase/Routing/FunctionsGlobalTest.php new file mode 100644 index 00000000000..8813c0be67f --- /dev/null +++ b/tests/TestCase/Routing/FunctionsGlobalTest.php @@ -0,0 +1,51 @@ +fallbacks(); + + $routerResult = Router::url(['controller' => 'Articles']); + $globalResult = url(['controller' => 'Articles']); + $this->assertSame($routerResult, $globalResult); + } + + /** + * Tests that the urlArray() method is a shortcut Router::parseRoutePath() + */ + public function testUrlArray(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->fallbacks(); + + $routerResult = Router::parseRoutePath('Controller::articles'); + $globalResult = urlArray('Controller::articles'); + $this->assertSame($globalResult, $routerResult + ['plugin' => false, 'prefix' => false]); + } +} diff --git a/tests/TestCase/Routing/FunctionsTest.php b/tests/TestCase/Routing/FunctionsTest.php new file mode 100644 index 00000000000..71d1af9bbb4 --- /dev/null +++ b/tests/TestCase/Routing/FunctionsTest.php @@ -0,0 +1,54 @@ +fallbacks(); + + $routerResult = Router::url(['controller' => 'Articles']); + $globalResult = url(['controller' => 'Articles']); + $this->assertSame($routerResult, $globalResult); + } + + /** + * Tests that the urlArray() method is a shortcut Router::parseRoutePath() + */ + public function testUrlArray(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->fallbacks(); + + $routerResult = Router::parseRoutePath('Controller::articles'); + $globalResult = urlArray('Controller::articles'); + $this->assertSame($globalResult, $routerResult + ['plugin' => false, 'prefix' => false]); + } +} diff --git a/tests/TestCase/Routing/Middleware/AssetMiddlewareTest.php b/tests/TestCase/Routing/Middleware/AssetMiddlewareTest.php new file mode 100644 index 00000000000..13563220403 --- /dev/null +++ b/tests/TestCase/Routing/Middleware/AssetMiddlewareTest.php @@ -0,0 +1,219 @@ +loadPlugins(['TestPlugin', 'Company/TestPluginThree']); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + $this->clearPlugins(); + parent::tearDown(); + } + + /** + * test that the if modified since header generates 304 responses + */ + public function testCheckIfModifiedHeader(): void + { + $modified = filemtime(TEST_APP . 'Plugin/TestPlugin/webroot/root.js'); + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_URI' => '/test_plugin/root.js', + 'HTTP_IF_MODIFIED_SINCE' => DateTime::parse($modified)->toRfc7231String(), + ]); + $handler = new TestRequestHandler(); + $middleware = new AssetMiddleware(); + $res = $middleware->process($request, $handler); + + $body = $res->getBody()->getContents(); + $this->assertSame('', $body); + $this->assertSame(304, $res->getStatusCode()); + $this->assertNotEmpty($res->getHeaderLine('Last-Modified')); + } + + /** + * test missing plugin assets. + */ + public function testMissingPluginAsset(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/not_found.js']); + $handler = new TestRequestHandler(); + + $middleware = new AssetMiddleware(); + $res = $middleware->process($request, $handler); + + $body = $res->getBody()->getContents(); + $this->assertSame('', $body); + } + + /** + * Data provider for assets. + * + * @return array + */ + public static function assetProvider(): array + { + return [ + // In plugin root. + [ + '/test_plugin/root.js', + TEST_APP . 'Plugin/TestPlugin/webroot/root.js', + ], + // Subdirectory + [ + '/test_plugin/js/alert.js', + TEST_APP . 'Plugin/TestPlugin/webroot/js/alert.js', + ], + // In path that matches the plugin name + [ + '/test_plugin/js/test_plugin/test.js', + TEST_APP . 'Plugin/TestPlugin/webroot/js/test_plugin/test.js', + ], + // In vendored plugin + [ + '/company/test_plugin_three/css/company.css', + TEST_APP . 'Plugin/Company/TestPluginThree/webroot/css/company.css', + ], + ]; + } + + /** + * Test assets in a plugin. + */ + #[DataProvider('assetProvider')] + public function testPluginAsset(string $url, string $expectedFile): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => $url]); + $handler = new TestRequestHandler(); + + $middleware = new AssetMiddleware(); + $res = $middleware->process($request, $handler); + + $body = $res->getBody()->getContents(); + $this->assertStringEqualsFile($expectedFile, $body); + } + + /** + * Test headers with plugin assets + */ + public function testPluginAssetHeaders(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/root.js']); + $handler = new TestRequestHandler(); + + $modified = filemtime(TEST_APP . 'Plugin/TestPlugin/webroot/root.js'); + $expires = strtotime('+4 hours'); + $time = time(); + + $middleware = new AssetMiddleware(['cacheTime' => '+4 hours']); + $res = $middleware->process($request, $handler); + + $this->assertSame( + 'application/javascript', + $res->getHeaderLine('Content-Type'), + ); + $this->assertSame( + DateTime::parse($time)->toRfc7231String(), + $res->getHeaderLine('Date'), + ); + $this->assertSame( + 'public,max-age=' . ($expires - $time), + $res->getHeaderLine('Cache-Control'), + ); + $this->assertSame( + DateTime::parse($modified)->toRfc7231String(), + $res->getHeaderLine('Last-Modified'), + ); + $this->assertSame( + DateTime::parse($expires)->toRfc7231String(), + $res->getHeaderLine('Expires'), + ); + } + + /** + * Test that // results in a 404 + */ + public function test404OnDoubleSlash(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '//index.php']); + $handler = new TestRequestHandler(); + + $middleware = new AssetMiddleware(); + $res = $middleware->process($request, $handler); + $this->assertEmpty($res->getBody()->getContents()); + } + + /** + * Test that .. results in a 404 + */ + public function test404OnDoubleDot(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/../webroot/root.js']); + $handler = new TestRequestHandler(); + + $middleware = new AssetMiddleware(); + $res = $middleware->process($request, $handler); + $this->assertEmpty($res->getBody()->getContents()); + } + + /** + * Test that hidden filenames result in a 404 + */ + public function test404OnHiddenFile(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/.hiddenfile']); + $handler = new TestRequestHandler(); + + $middleware = new AssetMiddleware(); + $res = $middleware->process($request, $handler); + $this->assertEmpty($res->getBody()->getContents()); + } + + /** + * Test that hidden filenames result in a 404 + */ + public function test404OnHiddenFolder(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/.hiddenfolder/some.js']); + $handler = new TestRequestHandler(); + + $middleware = new AssetMiddleware(); + $res = $middleware->process($request, $handler); + $this->assertEmpty($res->getBody()->getContents()); + } +} diff --git a/tests/TestCase/Routing/Middleware/RoutingMiddlewareTest.php b/tests/TestCase/Routing/Middleware/RoutingMiddlewareTest.php new file mode 100644 index 00000000000..579a2285ed0 --- /dev/null +++ b/tests/TestCase/Routing/Middleware/RoutingMiddlewareTest.php @@ -0,0 +1,527 @@ +builder = Router::createRouteBuilder('/'); + $this->builder->connect('/articles', ['controller' => 'Articles', 'action' => 'index']); + $this->log = []; + + Configure::write('App.base', ''); + } + + /** + * Test redirect responses from redirect routes + */ + public function testRedirectResponse(): void + { + $this->builder->redirect('/testpath', '/pages'); + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/testpath']); + $request = $request->withAttribute('base', '/subdir'); + + $handler = new TestRequestHandler(); + $middleware = new RoutingMiddleware($this->app()); + $response = $middleware->process($request, $handler); + + $this->assertSame(301, $response->getStatusCode()); + $this->assertSame('http://localhost/subdir/pages', $response->getHeaderLine('Location')); + } + + /** + * Test redirects with additional headers + */ + public function testRedirectResponseWithHeaders(): void + { + $this->builder->connect('/testpath', ['controller' => 'Articles', 'action' => 'index'], ['routeClass' => HeaderRedirectRoute::class]); + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/testpath']); + $handler = new TestRequestHandler(function ($request) { + return new Response(); + }); + $middleware = new RoutingMiddleware($this->app()); + $response = $middleware->process($request, $handler); + + $this->assertSame(301, $response->getStatusCode()); + $this->assertSame('http://localhost/pages', $response->getHeaderLine('Location')); + $this->assertSame('yes', $response->getHeaderLine('Redirect-Exception')); + } + + /** + * Test that Router sets parameters + */ + public function testRouterSetParams(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/articles']); + $handler = new TestRequestHandler(function ($req) { + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + '_ext' => null, + '_matchedRoute' => '/articles', + ]; + $this->assertEquals($expected, $req->getAttribute('params')); + + return new Response(); + }); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, $handler); + } + + /** + * Test that Router sets matched routes instance. + */ + public function testRouterSetRoute(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/articles']); + $handler = new TestRequestHandler(function ($req) { + $this->assertInstanceOf(Route::class, $req->getAttribute('route')); + $this->assertSame('/articles', $req->getAttribute('route')->staticPath()); + + return new Response(); + }); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, $handler); + } + + /** + * Test routing middleware does wipe off existing params keys. + */ + public function testPreservingExistingParams(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/articles']); + $request = $request->withAttribute('params', ['_csrfToken' => 'i-am-groot']); + $handler = new TestRequestHandler(function ($req) { + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + '_matchedRoute' => '/articles', + '_csrfToken' => 'i-am-groot', + ]; + $this->assertEquals($expected, $req->getAttribute('params')); + + return new Response(); + }); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, $handler); + } + + /** + * Test middleware invoking hook method + */ + public function testRoutesHookInvokedOnApp(): void + { + Router::reload(); + + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/app/articles']); + $handler = new TestRequestHandler(function ($req) { + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + '_ext' => null, + '_matchedRoute' => '/app/articles', + ]; + $this->assertEquals($expected, $req->getAttribute('params')); + $this->assertNotEmpty(Router::routes()); + $this->assertSame('/app/articles', Router::routes()[5]->template); + + return new Response(); + }); + $app = new Application(CONFIG); + $middleware = new RoutingMiddleware($app); + $middleware->process($request, $handler); + } + + /** + * Test that pluginRoutes hook is called + */ + public function testRoutesHookCallsPluginHook(): void + { + Router::reload(); + + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/app/articles']); + $app = new class (CONFIG) extends Application { + public function pluginRoutes(RouteBuilder $routes): RouteBuilder + { + $routes->connect('/app/articles', ['controller' => 'Articles', 'action' => 'index']); + + return $routes; + } + }; + $middleware = new RoutingMiddleware($app); + $middleware->process($request, new TestRequestHandler(function ($req) { + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'plugin' => null, + 'pass' => [], + '_ext' => null, + '_matchedRoute' => '/app/articles', + ]; + $this->assertEquals($expected, $req->getAttribute('params')); + + return new Response(); + })); + } + + /** + * Test that routing is not applied if a controller exists already + */ + public function testRouterNoopOnController(): void + { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/articles']); + $request = $request->withAttribute('params', ['controller' => 'Articles']); + $handler = new TestRequestHandler(function ($req) { + $this->assertEquals(['controller' => 'Articles'], $req->getAttribute('params')); + + return new Response(); + }); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, $handler); + } + + /** + * Test missing routes not being caught. + */ + public function testMissingRouteNotCaught(): void + { + $this->expectException(MissingRouteException::class); + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/missing']); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, new TestRequestHandler()); + } + + /** + * Test route with _method being parsed correctly. + */ + public function testFakedRequestMethodParsed(): void + { + $this->builder->connect('/articles-patch', [ + 'controller' => 'Articles', + 'action' => 'index', + '_method' => 'PATCH', + ]); + $request = ServerRequestFactory::fromGlobals( + [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/articles-patch', + ], + null, + ['_method' => 'PATCH'], + ); + $handler = new TestRequestHandler(function ($req) { + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + '_method' => 'PATCH', + 'plugin' => null, + 'pass' => [], + '_matchedRoute' => '/articles-patch', + '_ext' => null, + ]; + $this->assertEquals($expected, $req->getAttribute('params')); + $this->assertSame('PATCH', $req->getMethod()); + + return new Response(); + }); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, $handler); + } + + /** + * Test invoking simple scoped middleware + */ + public function testInvokeScopedMiddleware(): void + { + $this->builder->scope('/api', function (RouteBuilder $routes): void { + $routes->registerMiddleware('first', function ($request, $handler) { + $this->log[] = 'first'; + + return $handler->handle($request); + }); + $routes->registerMiddleware('second', function ($request, $handler) { + $this->log[] = 'second'; + + return $handler->handle($request); + }); + $routes->registerMiddleware('dumb', DumbMiddleware::class); + + // Connect middleware in reverse to test ordering. + $routes->applyMiddleware('second', 'first', 'dumb'); + + $routes->connect('/ping', ['controller' => 'Pings']); + }); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/api/ping', + ]); + $app = $this->app(function ($req) { + $this->log[] = 'last'; + + return new Response(); + }); + $middleware = new RoutingMiddleware($app); + $middleware->process($request, $app); + $this->assertSame(['second', 'first', 'last'], $this->log); + } + + /** + * Test control flow in scoped middleware. + * + * Scoped middleware should be able to generate a response + * and abort lower layers. + */ + public function testInvokeScopedMiddlewareReturnResponse(): void + { + $this->builder->registerMiddleware('first', function ($request, $handler) { + $this->log[] = 'first'; + + return $handler->handle($request); + }); + $this->builder->registerMiddleware('second', function ($request, $handler) { + $this->log[] = 'second'; + + return new Response(); + }); + + $this->builder->applyMiddleware('first'); + $this->builder->connect('/', ['controller' => 'Home']); + + $this->builder->scope('/api', function (RouteBuilder $routes): void { + $routes->applyMiddleware('second'); + $routes->connect('/articles', ['controller' => 'Articles']); + }); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/api/articles', + ]); + $handler = new TestRequestHandler(function ($req): void { + $this->fail('Should not be invoked as first should be ignored.'); + }); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, $handler); + + $this->assertSame(['first', 'second'], $this->log); + } + + /** + * Test control flow in scoped middleware. + */ + public function testInvokeScopedMiddlewareReturnResponseMainScope(): void + { + $this->builder->registerMiddleware('first', function ($request, $handler) { + $this->log[] = 'first'; + + return new Response(); + }); + $this->builder->registerMiddleware('second', function ($request, $handler) { + $this->log[] = 'second'; + + return $handler->handle($request); + }); + + $this->builder->applyMiddleware('first'); + $this->builder->connect('/', ['controller' => 'Home']); + + $this->builder->scope('/api', function (RouteBuilder $routes): void { + $routes->applyMiddleware('second'); + $routes->connect('/articles', ['controller' => 'Articles']); + }); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/', + ]); + $handler = new TestRequestHandler(function ($req): void { + $this->fail('Should not be invoked as first should be ignored.'); + }); + $middleware = new RoutingMiddleware($this->app()); + $middleware->process($request, $handler); + + $this->assertSame(['first'], $this->log); + } + + /** + * Test invoking middleware & scope separation + * + * Re-opening a scope should not inherit middleware declared + * in the first context. + */ + #[DataProvider('scopedMiddlewareUrlProvider')] + public function testInvokeScopedMiddlewareIsolatedScopes(string $url, array $expected): void + { + $this->builder->registerMiddleware('first', function ($request, $handler) { + $this->log[] = 'first'; + + return $handler->handle($request); + }); + $this->builder->registerMiddleware('second', function ($request, $handler) { + $this->log[] = 'second'; + + return $handler->handle($request); + }); + + $this->builder->scope('/api', function (RouteBuilder $routes): void { + $routes->applyMiddleware('first'); + $routes->connect('/ping', ['controller' => 'Pings']); + }); + + $this->builder->scope('/api', function (RouteBuilder $routes): void { + $routes->applyMiddleware('second'); + $routes->connect('/version', ['controller' => 'Version']); + }); + + $request = ServerRequestFactory::fromGlobals([ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => $url, + ]); + $app = $this->app(function ($req) { + $this->log[] = 'last'; + + return new Response(); + }); + $middleware = new RoutingMiddleware($app); + $middleware->process($request, $app); + $this->assertSame($expected, $this->log); + } + + /** + * Provider for scope isolation test. + * + * @return array + */ + public static function scopedMiddlewareUrlProvider(): array + { + return [ + ['/api/ping', ['first', 'last']], + ['/api/version', ['second', 'last']], + ]; + } + + /** + * Test middleware works without an application implementing ContainerApplicationInterface + */ + public function testAppWithoutContainerApplicationInterface(): void + { + $app = new class implements RoutingApplicationInterface { + public function routes(RouteBuilder $routes): void + { + } + }; + $this->builder->scope('/', function (RouteBuilder $routes): void { + $routes->connect('/testpath', ['controller' => 'Articles', 'action' => 'index']); + }); + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/testpath']); + $handler = new TestRequestHandler(function ($request) { + return new Response('php://memory', 200); + }); + $middleware = new RoutingMiddleware($app); + $response = $middleware->process($request, $handler); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Test middleware works with an application implementing ContainerApplicationInterface + */ + public function testAppWithContainerApplicationInterface(): void + { + $app = $this->app(); + $this->builder->scope('/', function (RouteBuilder $routes): void { + $routes->connect('/testpath', ['controller' => 'Articles', 'action' => 'index']); + }); + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/testpath']); + $handler = new TestRequestHandler(function ($request) { + return new Response('php://memory', 200); + }); + $middleware = new RoutingMiddleware($app); + $response = $middleware->process($request, $handler); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * Create a stub application for testing. + * + * @param callable|null $handleCallback Callback for "handle" method. + */ + protected function app(?callable $handleCallback = null): Application + { + $app = new class (CONFIG) extends Application { + public ?Closure $handleCallback = null; + + public function routes(RouteBuilder $routes): void + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + if ($this->handleCallback) { + return ($this->handleCallback)($request); + } + + return parent::handle($request); // TODO: Change the autogenerated stub + } + }; + + $app->handleCallback = $handleCallback; + + return $app; + } +} diff --git a/tests/TestCase/Routing/Route/DashedRouteTest.php b/tests/TestCase/Routing/Route/DashedRouteTest.php new file mode 100644 index 00000000000..09708691067 --- /dev/null +++ b/tests/TestCase/Routing/Route/DashedRouteTest.php @@ -0,0 +1,226 @@ + null]); + $result = $route->match(['controller' => 'Posts', 'action' => 'myView', 'plugin' => null]); + $this->assertNull($result); + + $result = $route->match([ + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'myView', + 0, + ]); + $this->assertNull($result); + + $result = $route->match([ + 'plugin' => null, + 'controller' => 'MyPosts', + 'action' => 'myView', + 'id' => 1, + ]); + $this->assertSame('/my-posts/my-view/1', $result); + + $route = new DashedRoute('/', ['controller' => 'Pages', 'action' => 'myDisplay', 'home']); + $result = $route->match(['controller' => 'Pages', 'action' => 'myDisplay', 'home']); + $this->assertSame('/', $result); + + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 'about']); + $this->assertNull($result); + + $route = new DashedRoute('/blog/{action}', ['controller' => 'Posts']); + $result = $route->match(['controller' => 'Posts', 'action' => 'myView']); + $this->assertSame('/blog/my-view', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'myView', '?' => ['id' => 2]]); + $this->assertSame('/blog/my-view?id=2', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'myView', 1]); + $this->assertNull($result); + + $route = new DashedRoute('/foo/{controller}/{action}', ['action' => 'index']); + $result = $route->match(['controller' => 'Posts', 'action' => 'myView']); + $this->assertSame('/foo/posts/my-view', $result); + + $route = new DashedRoute('/{plugin}/{id}/*', ['controller' => 'Posts', 'action' => 'myView']); + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Posts', + 'action' => 'myView', + 'id' => '1', + ]); + $this->assertSame('/test-plugin/1/', $result); + + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Posts', + 'action' => 'myView', + 'id' => '1', + '0', + ]); + $this->assertSame('/test-plugin/1/0', $result); + + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Nodes', + 'action' => 'myView', + 'id' => 1, + ]); + $this->assertNull($result); + + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Posts', + 'action' => 'edit', + 'id' => 1, + ]); + $this->assertNull($result); + + $route = new DashedRoute('/admin/subscriptions/{action}/*', [ + 'controller' => 'Subscribe', 'prefix' => 'Admin', + ]); + $result = $route->match([ + 'controller' => 'Subscribe', + 'prefix' => 'Admin', + 'action' => 'editAdminE', + 1, + ]); + $expected = '/admin/subscriptions/edit-admin-e/1'; + $this->assertSame($expected, $result); + + $route = new DashedRoute('/{controller}/{action}-{id}'); + $result = $route->match([ + 'controller' => 'MyPosts', + 'action' => 'myView', + 'id' => 1, + ]); + $this->assertSame('/my-posts/my-view-1', $result); + + $route = new DashedRoute('/{controller}/{action}/{slug}-{id}', [], ['id' => Router::ID]); + $result = $route->match([ + 'controller' => 'MyPosts', + 'action' => 'myView', + 'id' => '1', + 'slug' => 'the-slug', + ]); + $this->assertSame('/my-posts/my-view/the-slug-1', $result); + } + + /** + * test the parse method of DashedRoute. + */ + public function testParse(): void + { + $route = new DashedRoute('/{controller}/{action}/{id}', [], ['id' => Router::ID]); + $route->compile(); + $result = $route->parse('/my-posts/my-view/1', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('myView', $result['action']); + $this->assertSame('1', $result['id']); + + $route = new DashedRoute('/{controller}/{action}-{id}'); + $route->compile(); + $result = $route->parse('/my-posts/my-view-1', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('myView', $result['action']); + $this->assertSame('1', $result['id']); + + $route = new DashedRoute('/{controller}/{action}/{slug}-{id}', [], ['id' => Router::ID]); + $route->compile(); + $result = $route->parse('/my-posts/my-view/the-slug-1', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('myView', $result['action']); + $this->assertSame('1', $result['id']); + $this->assertSame('the-slug', $result['slug']); + + $route = new DashedRoute( + '/admin/{controller}', + ['prefix' => 'Admin', 'action' => 'index'], + ); + $route->compile(); + $result = $route->parse('/admin/', 'GET'); + $this->assertNull($result); + + $result = $route->parse('/admin/my-posts', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('index', $result['action']); + + $route = new DashedRoute( + '/media/search/*', + ['controller' => 'Media', 'action' => 'searchIt'], + ); + $result = $route->parse('/media/search', 'GET'); + $this->assertSame('Media', $result['controller']); + $this->assertSame('searchIt', $result['action']); + $this->assertEquals([], $result['pass']); + + $result = $route->parse('/media/search/tv_shows', 'GET'); + $this->assertSame('Media', $result['controller']); + $this->assertSame('searchIt', $result['action']); + $this->assertEquals(['tv_shows'], $result['pass']); + } + + public function testMatchThenParse(): void + { + $route = new DashedRoute('/plugin/{controller}/{action}', [ + 'plugin' => 'Vendor/PluginName', + ]); + $url = $route->match([ + 'plugin' => 'Vendor/PluginName', + 'controller' => 'ControllerName', + 'action' => 'actionName', + ]); + $expectedUrl = '/plugin/controller-name/action-name'; + $this->assertSame($expectedUrl, $url); + $result = $route->parse($expectedUrl, 'GET'); + $this->assertSame('ControllerName', $result['controller']); + $this->assertSame('actionName', $result['action']); + $this->assertSame('Vendor/PluginName', $result['plugin']); + } + + public function testMatchDoesNotCorruptDefaults(): void + { + $route = new DashedRoute('/user-permissions/edit', [ + 'controller' => 'UserPermissions', + 'action' => 'edit', + ]); + $route->match(['controller' => 'UserPermissions', 'action' => 'edit'], []); + + $this->assertSame('UserPermissions', $route->defaults['controller']); + $this->assertSame('edit', $route->defaults['action']); + + // Do the match again to ensure that state doesn't become incorrect. + $route->match(['controller' => 'UserPermissions', 'action' => 'edit'], []); + $this->assertSame('UserPermissions', $route->defaults['controller']); + $this->assertSame('edit', $route->defaults['action']); + } +} diff --git a/tests/TestCase/Routing/Route/EntityRouteTest.php b/tests/TestCase/Routing/Route/EntityRouteTest.php new file mode 100644 index 00000000000..45e94844ad0 --- /dev/null +++ b/tests/TestCase/Routing/Route/EntityRouteTest.php @@ -0,0 +1,225 @@ + 2, + 'slug' => 'article-slug', + ]); + + $route = new EntityRoute( + '/articles/{category_id}/{slug}', + [ + '_name' => 'articlesView', + ], + ); + + $result = $route->match([ + 'slug' => 'other-slug', + '_entity' => $entity, + '_name' => 'articlesView', + ]); + + $this->assertSame('/articles/2/other-slug', $result); + } + + /** + * test that routes match their pattern. + */ + public function testMatchEntityObject(): void + { + $entity = new Article([ + 'category_id' => 2, + 'slug' => 'article-slug', + ]); + + $route = new EntityRoute( + '/articles/{category_id}/{slug}', + [ + '_name' => 'articlesView', + ], + ); + + $result = $route->match([ + '_entity' => $entity, + '_name' => 'articlesView', + ]); + + $this->assertSame('/articles/2/article-slug', $result); + } + + /** + * test that routes match their pattern. + */ + public function testMatchBackedEnum(): void + { + $entity = new Article([ + 'category_id' => 2, + 'published' => ArticleStatus::Published, + ]); + + $route = new EntityRoute( + '/articles/{category_id}/{published}', + [ + '_name' => 'articlesView', + ], + ); + + $result = $route->match([ + '_entity' => $entity, + '_name' => 'articlesView', + ]); + + $this->assertSame('/articles/2/Y', $result); + } + + /** + * test that routes match their pattern. + */ + public function testMatchBackedIntEnum(): void + { + $entity = new Article([ + 'category_id' => 2, + 'prio' => Priority::High, + ]); + + $route = new EntityRoute( + '/articles/{category_id}/{prio}', + [ + '_name' => 'articlesView', + ], + ); + + $result = $route->match([ + '_entity' => $entity, + '_name' => 'articlesView', + ]); + + $this->assertSame('/articles/2/3', $result); + } + + /** + * test that routes match their pattern. + */ + public function testMatchNonBackedEnum(): void + { + $entity = new Article([ + 'category_id' => 2, + 'level' => NonBacked::Advanced, + ]); + + $route = new EntityRoute( + '/articles/{category_id}/{level}', + [ + '_name' => 'articlesView', + ], + ); + + $result = $route->match([ + '_entity' => $entity, + '_name' => 'articlesView', + ]); + + $this->assertSame('/articles/2/Advanced', $result); + } + + /** + * test that routes match their pattern. + */ + public function testMatchUnderscoreBetweenVar(): void + { + $entity = new Article([ + 'category_id' => 2, + 'slug' => 'article-slug', + ]); + + $route = new EntityRoute( + '/articles/{category_id}_{slug}', + [ + '_name' => 'articlesView', + ], + ); + + $result = $route->match([ + '_entity' => $entity, + '_name' => 'articlesView', + ]); + + $this->assertSame('/articles/2_article-slug', $result); + } + + /** + * test that routes match their pattern. + */ + public function testMatchingArray(): void + { + $entity = [ + 'category_id' => 2, + 'slug' => 'article-slug', + ]; + + $route = new EntityRoute( + '/articles/{category_id}/{slug}', + [ + '_name' => 'articlesView', + '_entity' => $entity, + ], + ); + + $result = $route->match([ + '_entity' => $entity, + '_name' => 'articlesView', + ]); + + $this->assertSame('/articles/2/article-slug', $result); + } + + /** + * Test invalid entity option value + */ + public function testInvalidEntityValueException(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Route `/` expects the URL option `_entity` to be an array or object implementing \ArrayAccess, but `string` passed.'); + + $route = new EntityRoute('/', [ + '_entity' => 'Something else', + ]); + + $route->match([ + '_entity' => 'something-else', + ]); + } +} diff --git a/tests/TestCase/Routing/Route/InflectedRouteTest.php b/tests/TestCase/Routing/Route/InflectedRouteTest.php new file mode 100644 index 00000000000..105d82bcde4 --- /dev/null +++ b/tests/TestCase/Routing/Route/InflectedRouteTest.php @@ -0,0 +1,236 @@ + null]); + $result = $route->match(['controller' => 'Posts', 'action' => 'my_view', 'plugin' => null]); + $this->assertNull($result); + + $result = $route->match([ + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'my_view', + 0, + ]); + $this->assertNull($result); + + $result = $route->match([ + 'plugin' => null, + 'controller' => 'MyPosts', + 'action' => 'my_view', + 'id' => 1, + ]); + $this->assertSame('/my_posts/my_view/1', $result); + + $route = new InflectedRoute('/', ['controller' => 'Pages', 'action' => 'my_view', 'home']); + $result = $route->match(['controller' => 'Pages', 'action' => 'my_view', 'home']); + $this->assertSame('/', $result); + + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 'about']); + $this->assertNull($result); + + $route = new InflectedRoute('/blog/{action}', ['controller' => 'Posts']); + $result = $route->match(['controller' => 'Posts', 'action' => 'my_view']); + $this->assertSame('/blog/my_view', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'my_view', '?' => ['id' => 2]]); + $this->assertSame('/blog/my_view?id=2', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'my_view', 1]); + $this->assertNull($result); + + $route = new InflectedRoute('/foo/{controller}/{action}', ['action' => 'index']); + $result = $route->match(['controller' => 'Posts', 'action' => 'my_view']); + $this->assertSame('/foo/posts/my_view', $result); + + $route = new InflectedRoute('/{plugin}/{id}/*', ['controller' => 'Posts', 'action' => 'my_view']); + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Posts', + 'action' => 'my_view', + 'id' => '1', + ]); + $this->assertSame('/test_plugin/1/', $result); + + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Posts', + 'action' => 'my_view', + 'id' => '1', + '0', + ]); + $this->assertSame('/test_plugin/1/0', $result); + + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Nodes', + 'action' => 'my_view', + 'id' => 1, + ]); + $this->assertNull($result); + + $result = $route->match([ + 'plugin' => 'TestPlugin', + 'controller' => 'Posts', + 'action' => 'edit', + 'id' => 1, + ]); + $this->assertNull($result); + + $route = new InflectedRoute('/admin/subscriptions/{action}/*', [ + 'controller' => 'Subscribe', 'prefix' => 'Admin', + ]); + $result = $route->match([ + 'controller' => 'Subscribe', + 'prefix' => 'Admin', + 'action' => 'edit_admin_e', + 1, + ]); + $expected = '/admin/subscriptions/edit_admin_e/1'; + $this->assertSame($expected, $result); + + $route = new InflectedRoute('/{controller}/{action}-{id}'); + $result = $route->match([ + 'controller' => 'MyPosts', + 'action' => 'my_view', + 'id' => 1, + ]); + $this->assertSame('/my_posts/my_view-1', $result); + + $route = new InflectedRoute('/{controller}/{action}/{slug}-{id}', [], ['id' => Router::ID]); + $result = $route->match([ + 'controller' => 'MyPosts', + 'action' => 'my_view', + 'id' => '1', + 'slug' => 'the-slug', + ]); + $this->assertSame('/my_posts/my_view/the-slug-1', $result); + } + + /** + * test the parse method of InflectedRoute. + */ + public function testParse(): void + { + $route = new InflectedRoute('/{controller}/{action}/{id}', [], ['id' => Router::ID]); + $route->compile(); + $result = $route->parse('/my_posts/my_view/1', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('my_view', $result['action']); + $this->assertSame('1', $result['id']); + + $route = new InflectedRoute('/{controller}/{action}-{id}'); + $route->compile(); + $result = $route->parse('/my_posts/my_view-1', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('my_view', $result['action']); + $this->assertSame('1', $result['id']); + + $route = new InflectedRoute('/{controller}/{action}/{slug}-{id}', [], ['id' => Router::ID]); + $route->compile(); + $result = $route->parse('/my_posts/my_view/the-slug-1', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('my_view', $result['action']); + $this->assertSame('1', $result['id']); + $this->assertSame('the-slug', $result['slug']); + + $route = new InflectedRoute( + '/admin/{controller}', + ['prefix' => 'Admin', 'action' => 'index'], + ); + $route->compile(); + $result = $route->parse('/admin/', 'GET'); + $this->assertNull($result); + + $result = $route->parse('/admin/my_posts', 'GET'); + $this->assertSame('MyPosts', $result['controller']); + $this->assertSame('index', $result['action']); + + $route = new InflectedRoute( + '/media/search/*', + ['controller' => 'Media', 'action' => 'search_it'], + ); + $result = $route->parse('/media/search', 'GET'); + $this->assertSame('Media', $result['controller']); + $this->assertSame('search_it', $result['action']); + $this->assertEquals([], $result['pass']); + + $result = $route->parse('/media/search/tv_shows', 'GET'); + $this->assertSame('Media', $result['controller']); + $this->assertSame('search_it', $result['action']); + $this->assertEquals(['tv_shows'], $result['pass']); + } + + /** + * Test that parse() checks methods. + */ + public function testParseMethodMatch(): void + { + $route = new InflectedRoute('/{controller}/{action}', ['_method' => 'POST']); + $this->assertNull($route->parse('/blog_posts/add_new', 'GET')); + + $result = $route->parse('/blog_posts/add_new', 'POST'); + $this->assertSame('BlogPosts', $result['controller']); + $this->assertSame('add_new', $result['action']); + } + + public function testMatchThenParse(): void + { + $route = new InflectedRoute('/plugin/{controller}/{action}', [ + 'plugin' => 'Vendor/PluginName', + ]); + $url = $route->match([ + 'plugin' => 'Vendor/PluginName', + 'controller' => 'ControllerName', + 'action' => 'action_name', + ]); + $expectedUrl = '/plugin/controller_name/action_name'; + $this->assertSame($expectedUrl, $url); + $result = $route->parse($expectedUrl, 'GET'); + $this->assertSame('ControllerName', $result['controller']); + $this->assertSame('action_name', $result['action']); + $this->assertSame('Vendor/PluginName', $result['plugin']); + } + + public function testMatchDoesNotCorruptDefaults(): void + { + $route = new InflectedRoute('/user_permissions/edit', ['controller' => 'UserPermissions', 'action' => 'edit']); + $route->match(['controller' => 'UserPermissions', 'action' => 'edit'], []); + + $this->assertSame('UserPermissions', $route->defaults['controller']); + $this->assertSame('edit', $route->defaults['action']); + + // Do the match again to ensure that state doesn't become incorrect. + $route->match(['controller' => 'UserPermissions', 'action' => 'edit'], []); + $this->assertSame('UserPermissions', $route->defaults['controller']); + $this->assertSame('edit', $route->defaults['action']); + } +} diff --git a/tests/TestCase/Routing/Route/PluginShortRouteTest.php b/tests/TestCase/Routing/Route/PluginShortRouteTest.php new file mode 100644 index 00000000000..791d05a3207 --- /dev/null +++ b/tests/TestCase/Routing/Route/PluginShortRouteTest.php @@ -0,0 +1,68 @@ + []]); + Router::reload(); + } + + /** + * test the parsing of routes. + */ + public function testParsing(): void + { + $route = new PluginShortRoute('/{plugin}', ['action' => 'index'], ['plugin' => 'foo|bar']); + + $result = $route->parse('/foo', 'GET'); + $this->assertSame('Foo', $result['plugin']); + $this->assertSame('Foo', $result['controller']); + $this->assertSame('index', $result['action']); + + $result = $route->parse('/wrong', 'GET'); + $this->assertNull($result, 'Wrong plugin name matched %s'); + } + + /** + * test the reverse routing of the plugin shortcut URLs. + */ + public function testMatch(): void + { + $route = new PluginShortRoute('/{plugin}', ['action' => 'index'], ['plugin' => 'foo|bar']); + + $result = $route->match(['plugin' => 'Foo', 'controller' => 'Posts', 'action' => 'index']); + $this->assertNull($result, 'plugin controller mismatch was converted. %s'); + + $result = $route->match(['plugin' => 'Foo', 'controller' => 'Foo', 'action' => 'index']); + $this->assertSame('/foo', $result); + } +} diff --git a/tests/TestCase/Routing/Route/RedirectRouteTest.php b/tests/TestCase/Routing/Route/RedirectRouteTest.php new file mode 100644 index 00000000000..9831361e349 --- /dev/null +++ b/tests/TestCase/Routing/Route/RedirectRouteTest.php @@ -0,0 +1,229 @@ +builder = Router::createRouteBuilder('/'); + $this->builder->connect('/{controller}', ['action' => 'index']); + $this->builder->connect('/{controller}/{action}/*'); + } + + /** + * test match + */ + public function testMatch(): void + { + $route = new RedirectRoute('/home', ['controller' => 'Posts']); + $this->assertNull($route->match(['controller' => 'Posts', 'action' => 'index'])); + } + + /** + * test parse failure + */ + public function testParseMiss(): void + { + $route = new RedirectRoute('/home', ['controller' => 'Posts']); + $this->assertNull($route->parse('/nope')); + $this->assertNull($route->parse('/homes')); + } + + /** + * test the parsing of routes. + */ + public function testParseSimple(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Posts'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/home', ['controller' => 'Posts']); + $route->parse('/home'); + } + + /** + * test the parsing of routes. + */ + public function testParseRedirectOption(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Posts'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/home', ['redirect' => ['controller' => 'Posts']]); + $route->parse('/home'); + } + + /** + * test the parsing of routes. + */ + public function testParseArray(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Posts'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/home', ['controller' => 'Posts', 'action' => 'index']); + $route->parse('/home'); + } + + /** + * test redirecting to an external url + */ + public function testParseAbsolute(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://google.com'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/google', ['redirect' => 'http://google.com']); + $route->parse('/google'); + } + + /** + * test redirecting with a status code + */ + public function testParseStatusCode(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Posts/view'); + $this->expectExceptionCode(302); + $route = new RedirectRoute('/posts/*', ['controller' => 'Posts', 'action' => 'view'], ['status' => 302]); + $route->parse('/posts/2'); + } + + /** + * test redirecting with the persist option + */ + public function testParsePersist(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Posts/view/2'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/posts/*', ['controller' => 'Posts', 'action' => 'view'], ['persist' => true]); + $route->parse('/posts/2'); + } + + /** + * test redirecting with persist and a base directory + */ + public function testParsePersistBaseDirectory(): void + { + $request = new ServerRequest([ + 'base' => '/basedir', + 'url' => '/posts/2', + ]); + Router::setRequest($request); + + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/basedir/Posts/view/2'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/posts/*', ['controller' => 'Posts', 'action' => 'view'], ['persist' => true]); + $route->parse('/posts/2'); + } + + /** + * test redirecting with persist and string target URLs + */ + public function testParsePersistStringUrl(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/test'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/posts/*', ['redirect' => '/test'], ['persist' => true]); + $route->parse('/posts/2'); + } + + /** + * test redirecting with persist and passed args + */ + public function testParsePersistPassedArgs(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Tags/add/passme'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/my_controllers/{action}/*', ['controller' => 'Tags', 'action' => 'add'], ['persist' => true]); + $route->parse('/my_controllers/do_something/passme'); + } + + /** + * test redirecting without persist and passed args + */ + public function testParseNoPersistPassedArgs(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Tags/add'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/my_controllers/{action}/*', ['controller' => 'Tags', 'action' => 'add']); + $route->parse('/my_controllers/do_something/passme'); + } + + /** + * test redirecting with patterns + */ + public function testParsePersistPatterns(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/Tags/add'); + $this->expectExceptionCode(301); + $route = new RedirectRoute('/{lang}/my_controllers', ['controller' => 'Tags', 'action' => 'add'], ['lang' => '(nl|en)', 'persist' => ['lang']]); + $route->parse('/nl/my_controllers/'); + } + + /** + * test redirecting with patterns and a routed target + */ + public function testParsePersistMatchesAnotherRoute(): void + { + $this->expectException(RedirectException::class); + $this->expectExceptionMessage('http://localhost/nl/preferred_controllers'); + $this->expectExceptionCode(301); + + $this->builder->connect('/{lang}/preferred_controllers', ['controller' => 'Tags', 'action' => 'add'], ['lang' => '(nl|en)', 'persist' => ['lang']]); + $route = new RedirectRoute('/{lang}/my_controllers', ['controller' => 'Tags', 'action' => 'add'], ['lang' => '(nl|en)', 'persist' => ['lang']]); + $route->parse('/nl/my_controllers/'); + } + + /** + * Test setting HTTP status + */ + public function testSetStatus(): void + { + $route = new RedirectRoute('/home', ['controller' => 'Posts']); + $result = $route->setStatus(302); + $this->assertSame($result, $route); + $this->assertSame(302, $route->options['status']); + } +} diff --git a/tests/TestCase/Routing/Route/RouteTest.php b/tests/TestCase/Routing/Route/RouteTest.php new file mode 100644 index 00000000000..f1fa7f91c64 --- /dev/null +++ b/tests/TestCase/Routing/Route/RouteTest.php @@ -0,0 +1,1824 @@ + []]); + } + + /** + * Test the construction of a Route + */ + public function testConstruction(): void + { + $route = new Route('/{controller}/{action}/{id}', [], ['id' => '[0-9]+']); + + $this->assertSame('/{controller}/{action}/{id}', $route->template); + $this->assertEquals([], $route->defaults); + $this->assertEquals(['id' => '[0-9]+', '_ext' => []], $route->options); + $this->assertFalse($route->compiled()); + } + + public function testConstructionWithInvalidMethod(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid HTTP method received. `NOPE` is invalid'); + new Route('/books/reviews', ['controller' => 'Reviews', 'action' => 'index', '_method' => 'nope']); + } + + /** + * Test set middleware in the constructor + */ + public function testConstructorSetMiddleware(): void + { + $route = new Route('/{controller}/{action}/*', [], ['_middleware' => ['auth', 'cookie']]); + $this->assertSame(['auth', 'cookie'], $route->getMiddleware()); + } + + /** + * Test Route compiling. + */ + public function testBasicRouteCompiling(): void + { + $route = new Route('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + $result = $route->compile(); + $expected = '#^/*$#'; + $this->assertSame($expected, $result); + $this->assertEquals([], $route->keys); + + $route = new Route('/{controller}/{action}', ['controller' => 'Posts']); + $result = $route->compile(); + + $this->assertMatchesRegularExpression($result, '/posts/edit'); + $this->assertMatchesRegularExpression($result, '/posts/super_delete'); + $this->assertDoesNotMatchRegularExpression($result, '/posts'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/super_delete/1'); + $this->assertSame($result, $route->compile()); + + $route = new Route('/posts/foo{id}', ['controller' => 'Posts', 'action' => 'view']); + $result = $route->compile(); + + $this->assertMatchesRegularExpression($result, '/posts/foo:1'); + $this->assertMatchesRegularExpression($result, '/posts/foo:param'); + $this->assertDoesNotMatchRegularExpression($result, '/posts'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/'); + + $this->assertEquals(['id'], $route->keys); + + $route = new Route('/{plugin}/{controller}/{action}/*', ['plugin' => 'test_plugin', 'action' => 'index']); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/test_plugin/posts/index'); + $this->assertMatchesRegularExpression($result, '/test_plugin/posts/edit/5'); + $this->assertMatchesRegularExpression($result, '/test_plugin/posts/edit/5/name:value/nick:name'); + } + + /** + * Test that single letter placeholders work. + */ + public function testRouteCompileSmallPlaceholders(): void + { + $route = new Route( + '/fighters/{id}/move/{x}/{y}', + ['controller' => 'Fighters', 'action' => 'move'], + ['id' => '\d+', 'x' => '\d+', 'y' => '\d+', 'pass' => ['id', 'x', 'y']], + ); + $pattern = $route->compile(); + $this->assertMatchesRegularExpression($pattern, '/fighters/123/move/8/42'); + + $result = $route->match([ + 'controller' => 'Fighters', + 'action' => 'move', + 'id' => '123', + 'x' => '8', + 'y' => '42', + ]); + $this->assertSame('/fighters/123/move/8/42', $result); + } + + /** + * Test route compile with brace format. + */ + public function testRouteCompileBraces(): void + { + $route = new Route( + '/fighters/{id}/move/{x}/{y}', + ['controller' => 'Fighters', 'action' => 'move'], + ['id' => '\d+', 'x' => '\d+', 'y' => '\d+', 'pass' => ['id', 'x', 'y']], + ); + $this->assertMatchesRegularExpression($route->compile(), '/fighters/123/move/8/42'); + + $result = $route->match([ + 'controller' => 'Fighters', + 'action' => 'move', + 'id' => '123', + 'x' => '8', + 'y' => '42', + ]); + $this->assertSame('/fighters/123/move/8/42', $result); + + $route = new Route( + '/images/{id}/{x}x{y}', + ['controller' => 'Images', 'action' => 'view'], + ); + $this->assertMatchesRegularExpression($route->compile(), '/images/123/640x480'); + + $result = $route->match([ + 'controller' => 'Images', + 'action' => 'view', + 'id' => '123', + 'x' => '8', + 'y' => '42', + ]); + $this->assertSame('/images/123/8x42', $result); + } + + /** + * Test route compile with brace format. + */ + public function testRouteCompileBracesVariableName(): void + { + $route = new Route( + '/fighters/{0id}', + ['controller' => 'Fighters', 'action' => 'move'], + ); + $this->assertDoesNotMatchRegularExpression($route->compile(), '/fighters/123', 'Placeholders must start with letter'); + + $route = new Route('/fighters/{Id}', ['controller' => 'Fighters', 'action' => 'move']); + $this->assertMatchesRegularExpression($route->compile(), '/fighters/123'); + + $route = new Route('/fighters/{i_d}', ['controller' => 'Fighters', 'action' => 'move']); + $this->assertMatchesRegularExpression($route->compile(), '/fighters/123'); + + $route = new Route('/fighters/{id99}', ['controller' => 'Fighters', 'action' => 'move']); + $this->assertMatchesRegularExpression($route->compile(), '/fighters/123'); + } + + /** + * Test route compile with brace format. + */ + public function testRouteCompileBracesInvalid(): void + { + $route = new Route( + '/fighters/{ id }', + ['controller' => 'Fighters', 'action' => 'move'], + ); + $this->assertDoesNotMatchRegularExpression($route->compile(), '/fighters/123', 'no spaces in placeholder'); + + $route = new Route( + '/fighters/{i d}', + ['controller' => 'Fighters', 'action' => 'move'], + ); + $this->assertDoesNotMatchRegularExpression($route->compile(), '/fighters/123', 'no spaces in placeholder'); + } + + /** + * Test route compile with mixed placeholder types brace format. + */ + public function testRouteCompileMixedPlaceholders(): void + { + $route = new Route( + '/images/{open/{id}', + ['controller' => 'Images', 'action' => 'open'], + ); + $pattern = $route->compile(); + $this->assertMatchesRegularExpression($pattern, '/images/{open/9', 'Need both {} to enable brace mode'); + $result = $route->match([ + 'controller' => 'Images', + 'action' => 'open', + 'id' => 123, + ]); + $this->assertSame('/images/{open/123', $result); + + $route = new Route( + '/fighters/{id}/move/{x}/:y', + ['controller' => 'Fighters', 'action' => 'move'], + ['id' => '\d+', 'x' => '\d+', 'pass' => ['id', 'x']], + ); + $pattern = $route->compile(); + $this->assertMatchesRegularExpression($pattern, '/fighters/123/move/8/:y'); + + $result = $route->match([ + 'controller' => 'Fighters', + 'action' => 'move', + 'id' => '123', + 'x' => '8', + '?' => ['y' => '9'], + ]); + $this->assertSame('/fighters/123/move/8/:y?y=9', $result); + } + + /** + * Test parsing routes with extensions. + */ + public function testRouteParsingWithExtensions(): void + { + $route = new Route( + '/{controller}/{action}/*', + [], + ['_ext' => ['json', 'xml']], + ); + + $result = $route->parse('/posts/index', 'GET'); + $this->assertArrayNotHasKey('_ext', $result); + + $result = $route->parse('/posts/index.pdf', 'GET'); + $this->assertArrayNotHasKey('_ext', $result); + + $result = $route->setExtensions(['pdf', 'json', 'xml', 'xml.gz'])->parse('/posts/index.pdf', 'GET'); + $this->assertSame('pdf', $result['_ext']); + + $result = $route->parse('/posts/index.json', 'GET'); + $this->assertSame('json', $result['_ext']); + + $result = $route->parse('/posts/index.xml', 'GET'); + $this->assertSame('xml', $result['_ext']); + + $result = $route->parse('/posts/index.xml.gz', 'GET'); + $this->assertSame('xml.gz', $result['_ext']); + } + + /** + * @return array + */ + public static function provideMatchParseExtension(): array + { + return [ + ['/foo/bar.xml', ['/foo/bar', 'xml'], ['xml', 'json', 'xml.gz']], + ['/foo/bar.json', ['/foo/bar', 'json'], ['xml', 'json', 'xml.gz']], + ['/foo/bar.xml.gz', ['/foo/bar', 'xml.gz'], ['xml', 'json', 'xml.gz']], + ['/foo/with.dots.json.xml.zip', ['/foo/with.dots.json.xml', 'zip'], ['zip']], + ['/foo/confusing.extensions.dots.json.xml.zip', ['/foo/confusing.extensions.dots.json.xml', 'zip'], ['json', 'xml', 'zip']], + ['/foo/confusing.extensions.dots.json.xml', ['/foo/confusing.extensions.dots.json', 'xml'], ['json', 'xml', 'zip']], + ['/foo/confusing.extensions.dots.json', ['/foo/confusing.extensions.dots', 'json'], ['json', 'xml', 'zip']], + ]; + } + + /** + * Expects _parseExtension to match extensions in URLs + * + * @param string $url + * @param array $expected + * @param array $ext + */ + #[DataProvider('provideMatchParseExtension')] + public function testMatchParseExtension($url, array $expected, array $ext): void + { + $route = new ProtectedRoute('/{controller}/{action}/*', [], ['_ext' => $ext]); + $result = $route->parseExtension($url); + $this->assertEquals($expected, $result); + } + + /** + * @return array + */ + public static function provideNoMatchParseExtension(): array + { + return [ + ['/foo/bar', ['xml']], + ['/foo/bar.zip', ['xml']], + ['/foo/bar.xml.zip', ['xml']], + ['/foo/bar.', ['xml']], + ['/foo/bar.xml', []], + ['/foo/bar...xml...zip...', ['xml']], + ]; + } + + /** + * Expects _parseExtension to not match extensions in URLs + * + * @param string $url + * @param array $ext + */ + #[DataProvider('provideNoMatchParseExtension')] + public function testNoMatchParseExtension($url, array $ext): void + { + $route = new ProtectedRoute('/{controller}/{action}/*', [], ['_ext' => $ext]); + [$outUrl, $outExt] = $route->parseExtension($url); + $this->assertSame($url, $outUrl); + $this->assertNull($outExt); + } + + /** + * Expects extensions to be set + */ + public function testSetExtensions(): void + { + $route = new ProtectedRoute('/{controller}/{action}/*', []); + $this->assertEquals([], $route->getExtensions()); + $route->setExtensions(['xml']); + $this->assertEquals(['xml'], $route->getExtensions()); + $route->setExtensions(['xml', 'json', 'zip']); + $this->assertEquals(['xml', 'json', 'zip'], $route->getExtensions()); + $route->setExtensions([]); + $this->assertEquals([], $route->getExtensions()); + + $route = new ProtectedRoute('/{controller}/{action}/*', [], ['_ext' => ['one', 'two']]); + $this->assertEquals(['one', 'two'], $route->getExtensions()); + } + + /** + * Expects extensions to be return. + */ + public function testGetExtensions(): void + { + $route = new ProtectedRoute('/{controller}/{action}/*', []); + $this->assertEquals([], $route->getExtensions()); + + $route = new ProtectedRoute('/{controller}/{action}/*', [], ['_ext' => ['one', 'two']]); + $this->assertEquals(['one', 'two'], $route->getExtensions()); + + $route = new ProtectedRoute('/{controller}/{action}/*', []); + $this->assertEquals([], $route->getExtensions()); + $route->setExtensions(['xml', 'json', 'zip']); + $this->assertEquals(['xml', 'json', 'zip'], $route->getExtensions()); + } + + /** + * Test that route parameters that overlap don't cause errors. + */ + public function testRouteParameterOverlap(): void + { + $route = new Route('/invoices/add/{idd}/{id}', ['controller' => 'Invoices', 'action' => 'add']); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/invoices/add/1/3'); + + $route = new Route('/invoices/add/{id}/{idd}', ['controller' => 'Invoices', 'action' => 'add']); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/invoices/add/1/3'); + } + + /** + * Test compiling routes with keys that have patterns + */ + public function testRouteCompilingWithParamPatterns(): void + { + $route = new Route( + '/{controller}/{action}/{id}', + [], + ['id' => Router::ID], + ); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/posts/edit/1'); + $this->assertMatchesRegularExpression($result, '/posts/view/518098'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/edit/name-of-post'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/edit/4/other:param'); + $this->assertEquals(['id', 'controller', 'action'], $route->keys); + + $route = new Route( + '/{lang}/{controller}/{action}/{id}', + ['controller' => 'Testing4'], + ['id' => Router::ID, 'lang' => '[a-z]{3}'], + ); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/eng/posts/edit/1'); + $this->assertMatchesRegularExpression($result, '/cze/articles/view/1'); + $this->assertDoesNotMatchRegularExpression($result, '/language/articles/view/2'); + $this->assertDoesNotMatchRegularExpression($result, '/eng/articles/view/name-of-article'); + $this->assertEquals(['lang', 'id', 'controller', 'action'], $route->keys); + + foreach ([':', '@', ';', '$', '-'] as $delim) { + $route = new Route('/posts/{id}' . $delim . '{title}'); + $result = $route->compile(); + + $this->assertMatchesRegularExpression($result, '/posts/1' . $delim . 'name-of-article'); + $this->assertMatchesRegularExpression($result, '/posts/13244' . $delim . 'name-of_Article[]'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/11!nameofarticle'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/11'); + + $this->assertEquals(['title', 'id'], $route->keys); + } + + $route = new Route( + '/posts/{id}:{title}/{year}', + ['controller' => 'Posts', 'action' => 'view'], + ['id' => Router::ID, 'year' => Router::YEAR, 'title' => '[a-z-_]+'], + ); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/posts/1:name-of-article/2009/'); + $this->assertMatchesRegularExpression($result, '/posts/13244:name-of-article/1999'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/hey_now:nameofarticle'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/:nameofarticle/2009'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/:nameofarticle/01'); + $this->assertEquals(['year', 'title', 'id'], $route->keys); + + $route = new Route( + '/posts/{url_title}-(uuid:{id})', + ['controller' => 'Posts', 'action' => 'view'], + ['pass' => ['id', 'url_title'], 'id' => Router::ID], + ); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/posts/some_title_for_article-(uuid:12534)/'); + $this->assertMatchesRegularExpression($result, '/posts/some_title_for_article-(uuid:12534)'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/nameofarticle'); + $this->assertDoesNotMatchRegularExpression($result, '/posts/nameofarticle-12347'); + $this->assertEquals(['url_title', 'id'], $route->keys); + } + + /** + * Test route with unicode + */ + public function testCompileWithUnicodePatterns(): void + { + $route = new Route( + '/test/{slug}', + ['controller' => 'Pages', 'action' => 'display'], + ['pass' => ['slug'], 'multibytePattern' => false, 'slug' => '[A-zА-я\-\ ]+'], + ); + $result = $route->compile(); + $this->assertDoesNotMatchRegularExpression($result, '/test/bla-blan-тест'); + + $route = new Route( + '/test/{slug}', + ['controller' => 'Pages', 'action' => 'display'], + ['pass' => ['slug'], 'multibytePattern' => true, 'slug' => '[A-zА-я\-\ ]+'], + ); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/test/bla-blan-тест'); + } + + /** + * Test more complex route compiling & parsing with mid route greedy stars + * and optional routing parameters + */ + public function testComplexRouteCompilingAndParsing(): void + { + $route = new Route( + '/posts/{month}/{day}/{year}/*', + ['controller' => 'Posts', 'action' => 'view'], + ['year' => Router::YEAR, 'month' => Router::MONTH, 'day' => Router::DAY], + ); + $result = $route->compile(); + $this->assertMatchesRegularExpression($result, '/posts/08/01/2007/title-of-post'); + $result = $route->parse('/posts/08/01/2007/title-of-post', 'GET'); + + $this->assertCount(8, $result); + $this->assertSame('Posts', $result['controller']); + $this->assertSame('view', $result['action']); + $this->assertSame('2007', $result['year']); + $this->assertSame('08', $result['month']); + $this->assertSame('01', $result['day']); + $this->assertSame('title-of-post', $result['pass'][0]); + $this->assertSame($route, $result['_route']); + $this->assertSame('/posts/{month}/{day}/{year}/*', $result['_matchedRoute']); + + $route = new Route( + '/{extra}/page/{slug}/*', + ['controller' => 'Pages', 'action' => 'view', 'extra' => null], + ['extra' => '[a-z1-9_]*', 'slug' => '[a-z1-9_]+', 'action' => 'view'], + ); + $result = $route->compile(); + + $this->assertMatchesRegularExpression($result, '/some_extra/page/this_is_the_slug'); + $this->assertMatchesRegularExpression($result, '/page/this_is_the_slug'); + $this->assertEquals(['slug', 'extra'], $route->keys); + $this->assertEquals(['extra' => '[a-z1-9_]*', 'slug' => '[a-z1-9_]+', 'action' => 'view', '_ext' => []], $route->options); + $expected = [ + 'controller' => 'Pages', + 'action' => 'view', + ]; + $this->assertEquals($expected, $route->defaults); + + $route = new Route( + '/{controller}/{action}/*', + ['project' => false], + [ + 'controller' => 'source|wiki|commits|tickets|comments|view', + 'action' => 'branches|history|branch|logs|view|start|add|edit|modify', + ], + ); + $this->assertNull($route->parse('/chaw_test/wiki', 'GET')); + + $result = $route->compile(); + $this->assertDoesNotMatchRegularExpression($result, '/some_project/source'); + $this->assertMatchesRegularExpression($result, '/source/view'); + $this->assertMatchesRegularExpression($result, '/source/view/other/params'); + $this->assertDoesNotMatchRegularExpression($result, '/chaw_test/wiki'); + $this->assertDoesNotMatchRegularExpression($result, '/source/weird_action'); + } + + /** + * Test that routes match their pattern. + */ + public function testMatchBasic(): void + { + $route = new Route('/{controller}/{action}/{id}', ['plugin' => null]); + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 'plugin' => null]); + $this->assertNull($result); + + $result = $route->match(['plugin' => null, 'controller' => 'Posts', 'action' => 'view', 0]); + $this->assertNull($result); + + $result = $route->match(['plugin' => null, 'controller' => 'Posts', 'action' => 'view', 'id' => 1]); + $this->assertSame('/Posts/view/1', $result); + + $route = new Route('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 'home']); + $this->assertSame('/', $result); + + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 'about']); + $this->assertNull($result); + + $route = new Route('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 'home']); + $this->assertSame('/pages/home', $result); + + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 'about']); + $this->assertSame('/pages/about', $result); + + $route = new Route('/blog/{action}', ['controller' => 'Posts']); + $result = $route->match(['controller' => 'Posts', 'action' => 'view']); + $this->assertSame('/blog/view', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'view', '?' => ['id' => 2]]); + $this->assertSame('/blog/view?id=2', $result); + + $result = $route->match(['controller' => 'Nodes', 'action' => 'view']); + $this->assertNull($result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 1]); + $this->assertNull($result); + + $route = new Route('/foo/{controller}/{action}', ['action' => 'index']); + $result = $route->match(['controller' => 'Posts', 'action' => 'view']); + $this->assertSame('/foo/Posts/view', $result); + + $route = new Route('/{plugin}/{id}/*', ['controller' => 'Posts', 'action' => 'view']); + $result = $route->match(['plugin' => 'test', 'controller' => 'Posts', 'action' => 'view', 'id' => '1']); + $this->assertSame('/test/1/', $result); + + $result = $route->match(['plugin' => 'fo', 'controller' => 'Posts', 'action' => 'view', 'id' => '1', '0']); + $this->assertSame('/fo/1/0', $result); + + $result = $route->match(['plugin' => 'fo', 'controller' => 'Nodes', 'action' => 'view', 'id' => 1]); + $this->assertNull($result); + + $result = $route->match(['plugin' => 'fo', 'controller' => 'Posts', 'action' => 'edit', 'id' => 1]); + $this->assertNull($result); + + $route = new Route('/admin/subscriptions/{action}/*', [ + 'controller' => 'Subscribe', 'prefix' => 'Admin', + ]); + + $url = ['controller' => 'Subscribe', 'prefix' => 'Admin', 'action' => 'edit', 1]; + $result = $route->match($url); + $expected = '/admin/subscriptions/edit/1'; + $this->assertSame($expected, $result); + + $url = [ + 'controller' => 'Subscribe', + 'prefix' => 'Admin', + 'action' => 'edit_admin_e', + 1, + ]; + $result = $route->match($url); + $expected = '/admin/subscriptions/edit_admin_e/1'; + $this->assertSame($expected, $result); + } + + /** + * Test match() with persist option + */ + public function testMatchWithPersistOption(): void + { + $context = [ + 'params' => ['lang' => 'en'], + ]; + $route = new Route('/{lang}/{controller}/{action}', [], ['persist' => ['lang']]); + $result = $route->match( + ['controller' => 'Tasks', 'action' => 'add'], + $context, + ); + $this->assertSame('/en/Tasks/add', $result); + } + + /** + * Test match() with _host and other keys. + */ + public function testMatchWithHostKeys(): void + { + $context = [ + '_host' => 'foo.com', + '_scheme' => 'http', + '_port' => 80, + '_base' => '', + ]; + $route = new Route('/{controller}/{action}'); + $result = $route->match( + ['controller' => 'Posts', 'action' => 'index', '_host' => 'example.com'], + $context, + ); + // Http has port 80 as default, do not include it in the url + $this->assertSame('http://example.com/Posts/index', $result); + + $result = $route->match( + ['controller' => 'Posts', 'action' => 'index', '_scheme' => 'webcal'], + $context, + ); + // Webcal is not on port 80 by default, include it in url + $this->assertSame('webcal://foo.com:80/Posts/index', $result); + + $result = $route->match( + ['controller' => 'Posts', 'action' => 'index', '_port' => '8080'], + $context, + ); + $this->assertSame('http://foo.com:8080/Posts/index', $result); + + $result = $route->match( + ['controller' => 'Posts', 'action' => 'index', '_base' => '/dir'], + $context, + ); + $this->assertSame('/dir/Posts/index', $result); + + $result = $route->match( + [ + 'controller' => 'Posts', + 'action' => 'index', + '_port' => '8080', + '_host' => 'example.com', + '_scheme' => 'https', + '_base' => '/dir', + ], + $context, + ); + $this->assertSame('https://example.com:8080/dir/Posts/index', $result); + + $context = [ + '_host' => 'foo.com', + '_scheme' => 'http', + '_port' => 8080, + '_base' => '', + ]; + $result = $route->match( + [ + 'controller' => 'Posts', + 'action' => 'index', + '_port' => '8080', + '_host' => 'example.com', + '_scheme' => 'https', + '_base' => '/dir', + ], + $context, + ); + // Https scheme is not on port 8080 by default, include the port + $this->assertSame('https://example.com:8080/dir/Posts/index', $result); + } + + /** + * Test that the _host option sets the default host. + */ + public function testMatchWithHostOption(): void + { + $route = new Route( + '/fallback', + ['controller' => 'Articles', 'action' => 'index'], + ['_host' => 'www.example.com'], + ); + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'index', + ]); + $this->assertSame('http://www.example.com/fallback', $result); + } + + /** + * Test wildcard host options + */ + public function testMatchWithHostWildcardOption(): void + { + $route = new Route( + '/fallback', + ['controller' => 'Articles', 'action' => 'index'], + ['_host' => '*.example.com'], + ); + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'index', + ]); + $this->assertNull($result, 'No request context means no match'); + + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'index', + ], ['_host' => 'wrong.com']); + $this->assertNull($result, 'Request context has bad host'); + + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'index', + '_host' => 'wrong.com', + ]); + $this->assertNull($result, 'Url param is wrong'); + + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'index', + '_host' => 'foo.example.com', + ]); + $this->assertSame('http://foo.example.com/fallback', $result); + + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'index', + ], [ + '_host' => 'foo.example.com', + ]); + $this->assertSame('http://foo.example.com/fallback', $result); + + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'index', + ], [ + '_scheme' => 'https', + '_host' => 'foo.example.com', + '_port' => 8080, + ]); + // When the port and scheme in the context are not present in the original url, they should be added + $this->assertSame('https://foo.example.com:8080/fallback', $result); + } + + /** + * Test that non-greedy routes fail with extra passed args + */ + public function testMatchGreedyRouteFailurePassedArg(): void + { + $route = new Route('/{controller}/{action}', ['plugin' => null]); + $result = $route->match(['controller' => 'Posts', 'action' => 'view', '0']); + $this->assertNull($result); + + $route = new Route('/{controller}/{action}', ['plugin' => null]); + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 'test']); + $this->assertNull($result); + } + + /** + * Test that falsey values do not interrupt a match. + */ + public function testMatchWithFalseyValues(): void + { + $route = new Route('/{controller}/{action}/*', ['plugin' => null]); + $result = $route->match([ + 'controller' => 'Posts', 'action' => 'index', 'plugin' => null, 'admin' => false, + ]); + $this->assertSame('/Posts/index/', $result); + } + + /** + * Test match() with greedy routes, and passed args. + */ + public function testMatchWithPassedArgs(): void + { + $route = new Route('/{controller}/{action}/*', ['plugin' => null]); + + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 'plugin' => null, 5]); + $this->assertSame('/Posts/view/5', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 'plugin' => null, 0]); + $this->assertSame('/Posts/view/0', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 'plugin' => null, '0']); + $this->assertSame('/Posts/view/0', $result); + + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 'plugin' => null, 'word space']); + $this->assertSame('/Posts/view/word%20space', $result); + + $route = new Route('/test2/*', ['controller' => 'Pages', 'action' => 'display', 2]); + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 1]); + $this->assertNull($result); + + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 2, 'something']); + $this->assertSame('/test2/something', $result); + + $result = $route->match(['controller' => 'Pages', 'action' => 'display', 5, 'something']); + $this->assertNull($result); + } + + /** + * Test that the pass option lets you use positional arguments for the + * route elements that were named. + */ + public function testMatchWithPassOption(): void + { + $route = new Route( + '/blog/{id}-{slug}', + ['controller' => 'Blog', 'action' => 'view'], + ['pass' => ['id', 'slug']], + ); + $result = $route->match([ + 'controller' => 'Blog', + 'action' => 'view', + 'id' => 1, + 'slug' => 'second', + ]); + $this->assertSame('/blog/1-second', $result); + + $result = $route->match([ + 'controller' => 'Blog', + 'action' => 'view', + 1, + 'second', + ]); + $this->assertSame('/blog/1-second', $result); + + $result = $route->match([ + 'controller' => 'Blog', + 'action' => 'view', + 1, + 'second', + '?' => ['query' => 'string'], + ]); + $this->assertSame('/blog/1-second?query=string', $result); + + $result = $route->match([ + 'controller' => 'Blog', + 'action' => 'view', + 1 => 2, + 2 => 'second', + ]); + $this->assertNull($result, 'Positional args must match exactly.'); + } + + /** + * Test that match() with pass and greedy routes. + */ + public function testMatchWithPassOptionGreedy(): void + { + $route = new Route( + '/blog/{id}-{slug}/*', + ['controller' => 'Blog', 'action' => 'view'], + ['pass' => ['id', 'slug']], + ); + $result = $route->match([ + 'controller' => 'Blog', + 'action' => 'view', + 'id' => 1, + 'slug' => 'second', + 'third', + 'fourth', + '?' => ['query' => 'string'], + ]); + $this->assertSame('/blog/1-second/third/fourth?query=string', $result); + + $result = $route->match([ + 'controller' => 'Blog', + 'action' => 'view', + 1, + 'second', + 'third', + 'fourth', + '?' => ['query' => 'string'], + ]); + $this->assertSame('/blog/1-second/third/fourth?query=string', $result); + } + + /** + * Test that extensions work. + */ + public function testMatchWithExtension(): void + { + $route = new Route('/{controller}/{action}'); + $result = $route->match([ + 'controller' => 'Posts', + 'action' => 'index', + '_ext' => 'json', + ]); + $this->assertSame('/Posts/index.json', $result); + + $route = new Route('/{controller}/{action}/*'); + $result = $route->match([ + 'controller' => 'Posts', + 'action' => 'index', + '_ext' => 'json', + ]); + $this->assertSame('/Posts/index.json', $result); + + $result = $route->match([ + 'controller' => 'Posts', + 'action' => 'view', + 1, + '_ext' => 'json', + ]); + $this->assertSame('/Posts/view/1.json', $result); + + $result = $route->match([ + 'controller' => 'Posts', + 'action' => 'view', + 1, + '_ext' => 'json', + '?' => ['id' => 'b', 'c' => 'd', ], + ]); + $this->assertSame('/Posts/view/1.json?id=b&c=d', $result); + + $result = $route->match([ + 'controller' => 'Posts', + 'action' => 'index', + '_ext' => 'json.gz', + ]); + $this->assertSame('/Posts/index.json.gz', $result); + } + + /** + * Test that match with patterns works. + */ + public function testMatchWithPatterns(): void + { + $route = new Route('/{controller}/{action}/{id}', ['plugin' => null], ['id' => '[0-9]+']); + $result = $route->match(['controller' => 'Posts', 'action' => 'view', 'id' => 'foo']); + $this->assertNull($result); + + $result = $route->match(['plugin' => null, 'controller' => 'Posts', 'action' => 'view', 'id' => 9]); + $this->assertSame('/Posts/view/9', $result); + + $result = $route->match(['plugin' => null, 'controller' => 'Posts', 'action' => 'view', 'id' => '9']); + $this->assertSame('/Posts/view/9', $result); + + $result = $route->match(['plugin' => null, 'controller' => 'Posts', 'action' => 'view', 'id' => '922']); + $this->assertSame('/Posts/view/922', $result); + + $result = $route->match(['plugin' => null, 'controller' => 'Posts', 'action' => 'view', 'id' => 'a99']); + $this->assertNull($result); + } + + /** + * Test that match() with multibyte pattern + */ + public function testMatchWithMultibytePattern(): void + { + $route = new Route( + '/articles/{action}/{id}', + ['controller' => 'Articles'], + ['multibytePattern' => true, 'id' => '\pL+'], + ); + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'view', + 'id' => "\xC4\x81", + ]); + $this->assertSame("/articles/view/\xC4\x81", $result); + } + + /** + * Test that match() matches explicit GET routes + */ + public function testMatchWithExplicitGet(): void + { + $route = new Route( + '/anything', + ['controller' => 'Articles', 'action' => 'foo', '_method' => 'GET'], + ); + $result = $route->match([ + 'controller' => 'Articles', + 'action' => 'foo', + ]); + $this->assertSame('/anything', $result); + } + + /** + * Test separartor. + */ + public function testQueryStringGeneration(): void + { + $route = new Route('/{controller}/{action}/*'); + + $restore = ini_get('arg_separator.output'); + ini_set('arg_separator.output', '&'); + + $result = $route->match([ + 'controller' => 'Posts', + 'action' => 'index', + 0, + '?' => [ + 'test' => 'var', + 'var2' => 'test2', + 'more' => 'test data', + ], + ]); + $expected = '/Posts/index/0?test=var&var2=test2&more=test+data'; + $this->assertSame($expected, $result); + ini_set('arg_separator.output', $restore); + } + + /** + * Ensure that parseRequest() calls parse() as that is required + * for backwards compat + */ + public function testParseRequestDelegates(): void + { + $route = Mockery::spy(Route::class)->makePartial(); + + $request = new ServerRequest([ + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => '/forward', + ], + ]); + $route->parseRequest($request); + + $route->shouldHaveReceived('parse') + ->with('/forward', 'GET') + ->once(); + } + + /** + * Test that parseRequest() applies host conditions + */ + public function testParseRequestHostConditions(): void + { + $route = new Route( + '/fallback', + ['controller' => 'Articles', 'action' => 'index'], + ['_host' => '*.example.com'], + ); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'a.example.com', + 'REQUEST_URI' => '/fallback', + ], + ]); + $result = $route->parseRequest($request); + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + '_route' => $route, + '_matchedRoute' => '/fallback', + ]; + $this->assertEquals($expected, $result, 'Should match, domain is correct'); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'foo.bar.example.com', + 'REQUEST_URI' => '/fallback', + ], + ]); + $result = $route->parseRequest($request); + $this->assertEquals($expected, $result, 'Should match, domain is a matching subdomain'); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'example.test.com', + 'PATH_INFO' => '/fallback', + ], + ]); + $this->assertNull($route->parseRequest($request)); + } + + /** + * test the parse method of Route. + */ + public function testParse(): void + { + $route = new Route( + '/{controller}/{action}/{id}', + ['controller' => 'Testing4', 'id' => null], + ['id' => Router::ID], + ); + $route->compile(); + $result = $route->parse('/Posts/view/1', 'GET'); + $this->assertSame('Posts', $result['controller']); + $this->assertSame('view', $result['action']); + $this->assertSame('1', $result['id']); + + $route = new Route( + '/admin/{controller}', + ['prefix' => 'Admin', 'action' => 'index'], + ); + $route->compile(); + $result = $route->parse('/admin/', 'GET'); + $this->assertNull($result); + + $result = $route->parse('/admin/Posts', 'GET'); + $this->assertSame('Posts', $result['controller']); + $this->assertSame('index', $result['action']); + + $route = new Route( + '/media/search/*', + ['controller' => 'Media', 'action' => 'search'], + ); + $result = $route->parse('/media/search', 'GET'); + $this->assertSame('Media', $result['controller']); + $this->assertSame('search', $result['action']); + $this->assertEquals([], $result['pass']); + + $result = $route->parse('/media/search/tv/shows', 'GET'); + $this->assertSame('Media', $result['controller']); + $this->assertSame('search', $result['action']); + $this->assertEquals(['tv', 'shows'], $result['pass']); + } + + /** + * Test that parse() throws a BadRequestException instead of InvalidArgumentException + * for an invalid method. + * + * @return void + */ + public function testParseException(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Invalid HTTP method received. `NOPE` is invalid'); + + $route = new Route( + '/{controller}', + ['prefix' => 'Admin', 'action' => 'index'], + ); + $route->parse('/posts', 'NOPE'); + } + + /** + * Test that :key elements are urldecoded + */ + public function testParseUrlDecodeElements(): void + { + $route = new Route( + '/{controller}/{slug}', + ['action' => 'view'], + ); + $route->compile(); + $result = $route->parse('/posts/%E2%88%82%E2%88%82', 'GET'); + $this->assertSame('posts', $result['controller']); + $this->assertSame('view', $result['action']); + $this->assertSame('∂∂', $result['slug']); + + $result = $route->parse('/posts/∂∂', 'GET'); + $this->assertSame('posts', $result['controller']); + $this->assertSame('view', $result['action']); + $this->assertSame('∂∂', $result['slug']); + } + + /** + * Test numerically indexed defaults, get appended to pass + */ + public function testParseWithPassDefaults(): void + { + $route = new Route('/{controller}', ['action' => 'display', 'home']); + $result = $route->parse('/Posts', 'GET'); + $expected = [ + 'controller' => 'Posts', + 'action' => 'display', + 'pass' => ['home'], + '_route' => $route, + '_matchedRoute' => '/{controller}', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that middleware is returned from parse() + */ + public function testParseWithMiddleware(): void + { + $route = new Route('/{controller}', ['action' => 'display', 'home']); + $route->setMiddleware(['auth', 'cookie']); + $result = $route->parse('/Posts', 'GET'); + $expected = [ + 'controller' => 'Posts', + 'action' => 'display', + 'pass' => ['home'], + '_route' => $route, + '_matchedRoute' => '/{controller}', + '_middleware' => ['auth', 'cookie'], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that http header conditions can cause route failures. + */ + public function testParseWithHttpHeaderConditions(): void + { + $route = new Route('/sample', ['controller' => 'Posts', 'action' => 'index', '_method' => 'POST']); + $this->assertNull($route->parse('/sample', 'GET')); + + $expected = [ + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => [], + '_method' => 'POST', + '_route' => $route, + '_matchedRoute' => '/sample', + ]; + $this->assertEquals($expected, $route->parse('/sample', 'post')); + } + + /** + * Test that http header conditions can cause route failures. + * And that http method names are normalized. + */ + public function testParseWithMultipleHttpMethodConditions(): void + { + $route = new Route('/sample', [ + 'controller' => 'Posts', + 'action' => 'index', + '_method' => ['put', 'post'], + ]); + $this->assertNull($route->parse('/sample', 'GET')); + + $expected = [ + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => [], + '_method' => ['PUT', 'POST'], + '_route' => $route, + '_matchedRoute' => '/sample', + ]; + $this->assertEquals($expected, $route->parse('/sample', 'POST')); + } + + /** + * Test that http header conditions can work with URL generation + */ + public function testMatchWithMultipleHttpMethodConditions(): void + { + $route = new Route('/sample', [ + 'controller' => 'Posts', + 'action' => 'index', + '_method' => ['PUT', 'POST'], + ]); + $url = [ + 'controller' => 'Posts', + 'action' => 'index', + ]; + $this->assertNull($route->match($url)); + + $url = [ + 'controller' => 'Posts', + 'action' => 'index', + '_method' => 'GET', + ]; + $this->assertNull($route->match($url)); + + $url = [ + 'controller' => 'Posts', + 'action' => 'index', + '_method' => 'PUT', + ]; + $this->assertSame('/sample', $route->match($url)); + + $url = [ + 'controller' => 'Posts', + 'action' => 'index', + '_method' => 'POST', + ]; + $this->assertSame('/sample', $route->match($url)); + + $url = [ + 'controller' => 'Posts', + 'action' => 'index', + '_method' => ['PUT', 'POST'], + ]; + $this->assertSame('/sample', $route->match($url)); + } + + /** + * Test that patterns work for {action} + */ + public function testPatternOnAction(): void + { + $route = new Route( + '/blog/{action}/*', + ['controller' => 'BlogPosts'], + ['action' => 'other|actions'], + ); + $result = $route->match(['controller' => 'BlogPosts', 'action' => 'foo']); + $this->assertNull($result); + + $result = $route->match(['controller' => 'BlogPosts', 'action' => 'actions']); + $this->assertNotEmpty($result); + + $result = $route->parse('/blog/other', 'GET'); + $expected = [ + 'controller' => 'BlogPosts', + 'action' => 'other', + 'pass' => [], + '_route' => $route, + '_matchedRoute' => '/blog/{action}/*', + ]; + $this->assertEquals($expected, $result); + + $result = $route->parse('/blog/foobar', 'GET'); + $this->assertNull($result); + } + + /** + * Test the parseArgs method + */ + public function testParsePassedArgument(): void + { + $route = new Route('/{controller}/{action}/*'); + $result = $route->parse('/Posts/edit/1/2/0', 'GET'); + $expected = [ + 'controller' => 'Posts', + 'action' => 'edit', + 'pass' => ['1', '2', '0'], + '_route' => $route, + '_matchedRoute' => '/{controller}/{action}/*', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test matching of parameters where one parameter name starts with another parameter name + */ + public function testMatchSimilarParameters(): void + { + $route = new Route('/{thisParam}/{thisParamIsLonger}'); + + $url = [ + 'thisParamIsLonger' => 'bar', + 'thisParam' => 'foo', + ]; + + $result = $route->match($url); + $expected = '/foo/bar'; + $this->assertSame($expected, $result); + } + + /** + * Test match() with trailing ** style routes. + */ + public function testMatchTrailing(): void + { + $route = new Route('/pages/**', ['controller' => 'Pages', 'action' => 'display']); + $id = 'test/ spaces/漢字/la†în'; + $result = $route->match([ + 'controller' => 'Pages', + 'action' => 'display', + $id, + ]); + $expected = '/pages/test/%20spaces/%E6%BC%A2%E5%AD%97/la%E2%80%A0%C3%AEn'; + $this->assertSame($expected, $result); + } + + /** + * Test match handles optional keys + */ + public function testMatchNullValueOptionalKey(): void + { + $route = new Route('/path/{optional}/fixed'); + $this->assertSame('/path/fixed', $route->match(['optional' => null])); + + $route = new Route('/path/{optional}/fixed'); + $this->assertSame('/path/fixed', $route->match(['optional' => null])); + } + + /** + * Test matching fails on required keys (controller/action) + */ + public function testMatchControllerRequiredKeys(): void + { + $route = new Route('/{controller}/', ['action' => 'index']); + $this->assertNull($route->match(['controller' => null, 'action' => 'index'])); + + $route = new Route('/test/{action}', ['controller' => 'Thing']); + $this->assertNull($route->match(['action' => null, 'controller' => 'Thing'])); + } + + /** + * Test matching with null prefix in defaults + */ + public function testMatchWithNullPrefix(): void + { + // Test that a route with null prefix can match URLs without prefix + $route = new Route('/login', ['controller' => 'Users', 'action' => 'login', 'prefix' => null]); + $result = $route->match(['controller' => 'Users', 'action' => 'login']); + $this->assertSame('/login', $result); + + // Test that null prefix in defaults should match when URL has a prefix key + $route = new Route('/logout', ['controller' => 'Users', 'action' => 'logout', 'prefix' => null]); + $result = $route->match(['controller' => 'Users', 'action' => 'logout', 'prefix' => null]); + $this->assertSame('/logout', $result); + + $route = new Route('/logout', ['controller' => 'Users', 'action' => 'logout', 'prefix' => null]); + $result = $route->match(['controller' => 'Users', 'action' => 'logout', 'prefix' => 'admin']); + $this->assertNull($result); + } + + /** + * Test restructuring args with pass key + */ + public function testPassArgRestructure(): void + { + $route = new Route('/{controller}/{action}/{slug}', [], [ + 'pass' => ['slug'], + ]); + $result = $route->parse('/Posts/view/my-title', 'GET'); + $expected = [ + 'controller' => 'Posts', + 'action' => 'view', + 'slug' => 'my-title', + 'pass' => ['my-title'], + '_route' => $route, + '_matchedRoute' => '/{controller}/{action}/{slug}', + ]; + $this->assertEquals($expected, $result, 'Slug should have moved'); + } + + /** + * Test the /** special type on parsing. + */ + public function testParseTrailing(): void + { + $route = new Route('/{controller}/{action}/**'); + $result = $route->parse('/Posts/index/1/2/3/foo:bar', 'GET'); + $expected = [ + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => ['1/2/3/foo:bar'], + '_route' => $route, + '_matchedRoute' => '/{controller}/{action}/**', + ]; + $this->assertEquals($expected, $result); + + $result = $route->parse('/Posts/index/http://example.com', 'GET'); + $expected = [ + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => ['http://example.com'], + '_route' => $route, + '_matchedRoute' => '/{controller}/{action}/**', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test the /** special type on parsing - UTF8. + */ + public function testParseTrailingUTF8(): void + { + $route = new Route('/category/**', ['controller' => 'Categories', 'action' => 'index']); + $result = $route->parse('/category/%D9%85%D9%88%D8%A8%D8%A7%DB%8C%D9%84', 'GET'); + $expected = [ + 'controller' => 'Categories', + 'action' => 'index', + 'pass' => ['موبایل'], + '_route' => $route, + '_matchedRoute' => '/category/**', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test getName(); + */ + public function testGetName(): void + { + $route = new Route('/foo/bar', [], ['_name' => 'testing']); + $this->assertSame('', $route->getName()); + + $route = new Route('/{controller}/{action}'); + $this->assertSame('_controller:_action', $route->getName()); + + $route = new Route('/{controller}/{action}'); + $this->assertSame('_controller:_action', $route->getName()); + + $route = new Route('/{controller}/{action}'); + $this->assertSame('_controller:_action', $route->getName()); + + $route = new Route('/{controller}/{action}'); + $this->assertSame('_controller:_action', $route->getName()); + + $route = new Route('/articles/{action}', ['controller' => 'Posts']); + $this->assertSame('posts:_action', $route->getName()); + + $route = new Route('/articles/list', ['controller' => 'Posts', 'action' => 'index']); + $this->assertSame('posts:index', $route->getName()); + + $route = new Route('/{controller}/{action}', ['action' => 'index']); + $this->assertSame('_controller:_action', $route->getName()); + } + + /** + * Test getName() with plugins. + */ + public function testGetNamePlugins(): void + { + $route = new Route( + '/a/{controller}/{action}', + ['plugin' => 'Asset'], + ); + $this->assertSame('asset._controller:_action', $route->getName()); + + $route = new Route( + '/a/assets/{action}', + ['plugin' => 'Asset', 'controller' => 'Assets'], + ); + $this->assertSame('asset.assets:_action', $route->getName()); + + $route = new Route( + '/assets/get', + ['plugin' => 'Asset', 'controller' => 'Assets', 'action' => 'get'], + ); + $this->assertSame('asset.assets:get', $route->getName()); + } + + /** + * Test getName() with prefixes. + */ + public function testGetNamePrefix(): void + { + $route = new Route( + '/admin/{controller}/{action}', + ['prefix' => 'Admin'], + ); + $this->assertSame('admin:_controller:_action', $route->getName()); + + $route = new Route( + '/{prefix}/assets/{action}', + ['controller' => 'Assets'], + ); + $this->assertSame('_prefix:assets:_action', $route->getName()); + + $route = new Route( + '/admin/assets/get', + ['prefix' => 'Admin', 'plugin' => 'Asset', 'controller' => 'Assets', 'action' => 'get'], + ); + $this->assertSame('admin:asset.assets:get', $route->getName()); + + $route = new Route( + '/{prefix}/{plugin}/{controller}/{action}/*', + [], + ); + $this->assertSame('_prefix:_plugin._controller:_action', $route->getName()); + } + + /** + * Test that utf-8 patterns work for {section} + */ + public function testUTF8PatternOnSection(): void + { + $route = new Route( + '/{section}', + ['plugin' => 'blogs', 'controller' => 'Posts', 'action' => 'index'], + [ + 'persist' => ['section'], + 'section' => 'آموزش|weblog', + ], + ); + + $result = $route->parse('/%D8%A2%D9%85%D9%88%D8%B2%D8%B4', 'GET'); + $expected = [ + 'section' => 'آموزش', + 'plugin' => 'blogs', + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => [], + '_route' => $route, + '_matchedRoute' => '/{section}', + ]; + $this->assertEquals($expected, $result); + + $result = $route->parse('/weblog', 'GET'); + $expected = [ + 'section' => 'weblog', + 'plugin' => 'blogs', + 'controller' => 'Posts', + 'action' => 'index', + 'pass' => [], + '_route' => $route, + '_matchedRoute' => '/{section}', + ]; + $this->assertEquals($expected, $result); + } + + public function testUrlWithEncodedSlash(): void + { + $route = new Route( + '/products/tests/*', + ['controller' => 'Products', 'action' => 'test'], + ['_urldecode' => false], + ); + + $result = $route->parse('/products/tests/xx%2Fyy', 'GET'); + $expected = [ + 'controller' => 'Products', + 'action' => 'test', + 'pass' => ['xx%2Fyy'], + '_route' => $route, + '_matchedRoute' => '/products/tests/*', + ]; + $this->assertEquals($expected, $result); + + $route = new Route( + '/products/view/{slug}', + ['controller' => 'Products', 'action' => 'view'], + ['_urldecode' => false], + ); + + $result = $route->parse('/products/view/xx%2Fyy', 'GET'); + $expected = [ + 'controller' => 'Products', + 'action' => 'view', + 'slug' => 'xx%2Fyy', + 'pass' => [], + '_route' => $route, + '_matchedRoute' => '/products/view/{slug}', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test getting the static path for a route. + */ + public function testStaticPathBrace(): void + { + $route = new Route('/*', ['controller' => 'Pages', 'action' => 'display']); + $this->assertSame('/', $route->staticPath()); + + $route = new Route('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + $this->assertSame('/pages', $route->staticPath()); + + $route = new Route('/pages/{id}/*', ['controller' => 'Pages', 'action' => 'display']); + $this->assertSame('/pages/', $route->staticPath()); + + $route = new Route('/{controller}/{action}/*'); + $this->assertSame('/', $route->staticPath()); + + $route = new Route('/api/{/{action}/*'); + $this->assertSame('/api/{/', $route->staticPath()); + + $route = new Route('/books/reviews', ['controller' => 'Reviews', 'action' => 'index']); + $this->assertSame('/books/reviews', $route->staticPath()); + } + + /** + * Test for __set_state magic method on CakeRoute + */ + public function testSetState(): void + { + $route = Route::__set_state([ + 'keys' => [], + 'options' => [], + 'defaults' => [ + 'controller' => 'Pages', + 'action' => 'display', + 'home', + ], + 'template' => '/', + '_greedy' => false, + '_compiledRoute' => null, + ]); + $this->assertInstanceOf(Route::class, $route); + $this->assertSame('/', $route->match(['controller' => 'Pages', 'action' => 'display', 'home'])); + $this->assertNull($route->match(['controller' => 'Pages', 'action' => 'display', 'about'])); + $expected = [ + 'controller' => 'Pages', + 'action' => 'display', + 'pass' => ['home'], + '_route' => $route, + '_matchedRoute' => '/', + ]; + $this->assertEquals($expected, $route->parse('/', 'GET')); + } + + /** + * Test setting the method on a route. + */ + public function testSetMethods(): void + { + $route = new Route('/books/reviews', ['controller' => 'Reviews', 'action' => 'index']); + $result = $route->setMethods(['put']); + + $this->assertSame($result, $route, 'Should return this'); + $this->assertSame(['PUT'], $route->defaults['_method'], 'method is wrong'); + + $route->setMethods(['post', 'get', 'patch']); + $this->assertSame(['POST', 'GET', 'PATCH'], $route->defaults['_method']); + } + + /** + * Test setting the method on a route to an invalid method + */ + public function testSetMethodsInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid HTTP method received. `NOPE` is invalid'); + $route = new Route('/books/reviews', ['controller' => 'Reviews', 'action' => 'index']); + $route->setMethods(['nope']); + } + + /** + * Test setting patterns through the method + */ + public function testSetPatterns(): void + { + $route = new Route('/reviews/{date}/{id}', ['controller' => 'Reviews', 'action' => 'view']); + $result = $route->setPatterns([ + 'date' => '\d+\-\d+\-\d+', + 'id' => '[a-z]+', + ]); + $this->assertSame($result, $route, 'Should return this'); + $this->assertArrayHasKey('id', $route->options); + $this->assertArrayHasKey('date', $route->options); + $this->assertSame('[a-z]+', $route->options['id']); + $this->assertArrayNotHasKey('multibytePattern', $route->options); + + $this->assertNull($route->parse('/reviews/a-b-c/xyz', 'GET')); + $this->assertNotEmpty($route->parse('/reviews/2016-05-12/xyz', 'GET')); + } + + /** + * Test setting patterns enables multibyte mode + */ + public function testSetPatternsMultibyte(): void + { + $route = new Route('/reviews/{accountid}/{slug}', ['controller' => 'Reviews', 'action' => 'view']); + $route->setPatterns([ + 'date' => '[A-zА-я\-\ ]+', + 'accountid' => '[a-z]+', + ]); + $this->assertArrayHasKey('multibytePattern', $route->options); + + $this->assertNotEmpty($route->parse('/reviews/abcs/bla-blan-тест', 'GET')); + } + + /** + * Test setting host requirements + */ + public function testSetHost(): void + { + $route = new Route('/reviews', ['controller' => 'Reviews', 'action' => 'index']); + $result = $route->setHost('blog.example.com'); + $this->assertSame($result, $route, 'Should return this'); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'a.example.com', + 'REQUEST_URI' => '/reviews', + ], + ]); + $this->assertNull($route->parseRequest($request)); + + $uri = $request->getUri(); + $request = $request->withUri($uri->withHost('blog.example.com')); + $this->assertNotEmpty($route->parseRequest($request)); + } + + /** + * Test setting pass parameters + */ + public function testSetPass(): void + { + $route = new Route('/reviews/{date}/{id}', ['controller' => 'Reviews', 'action' => 'view']); + $result = $route->setPass(['date', 'id']); + $this->assertSame($result, $route, 'Should return this'); + $this->assertEquals(['date', 'id'], $route->options['pass']); + } + + /** + * Test setting persisted parameters + */ + public function testSetPersist(): void + { + $route = new Route('/reviews/{date}/{id}', ['controller' => 'Reviews', 'action' => 'view']); + $result = $route->setPersist(['date']); + $this->assertSame($result, $route, 'Should return this'); + $this->assertEquals(['date'], $route->options['persist']); + } + + /** + * Test setting/getting middleware. + */ + public function testSetMiddleware(): void + { + $route = new Route('/reviews/{date}/{id}', ['controller' => 'Reviews', 'action' => 'view']); + $result = $route->setMiddleware(['auth', 'cookie']); + $this->assertSame($result, $route); + $this->assertSame(['auth', 'cookie'], $route->getMiddleware()); + } +} diff --git a/tests/TestCase/Routing/RouteBuilderTest.php b/tests/TestCase/Routing/RouteBuilderTest.php new file mode 100644 index 00000000000..ba0595854c4 --- /dev/null +++ b/tests/TestCase/Routing/RouteBuilderTest.php @@ -0,0 +1,1357 @@ +collection = new RouteCollection(); + } + + /** + * Teardown method + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + } + + /** + * Test path() + */ + public function testPath(): void + { + $routes = new RouteBuilder($this->collection, '/some/path'); + $this->assertSame('/some/path', $routes->path()); + + $routes = new RouteBuilder($this->collection, '/{book_id}'); + $this->assertSame('/', $routes->path()); + + $routes = new RouteBuilder($this->collection, '/path/{book_id}'); + $this->assertSame('/path/', $routes->path()); + + $routes = new RouteBuilder($this->collection, '/path/book{book_id}'); + $this->assertSame('/path/book', $routes->path()); + } + + /** + * Test params() + */ + public function testParams(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $this->assertEquals(['prefix' => 'Api'], $routes->params()); + } + + /** + * Test getting connected routes. + */ + public function testRoutes(): void + { + $routes = new RouteBuilder($this->collection, '/l'); + $routes->connect('/{controller}', ['action' => 'index']); + $routes->connect('/{controller}/{action}/*'); + + $all = $this->collection->routes(); + $this->assertCount(2, $all); + $this->assertInstanceOf(Route::class, $all[0]); + $this->assertInstanceOf(Route::class, $all[1]); + } + + /** + * Test setting default route class + */ + public function testRouteClass(): void + { + $routes = new RouteBuilder( + $this->collection, + '/l', + [], + ['routeClass' => 'InflectedRoute'], + ); + $routes->connect('/{controller}', ['action' => 'index']); + $routes->connect('/{controller}/{action}/*'); + + $all = $this->collection->routes(); + $this->assertInstanceOf(InflectedRoute::class, $all[0]); + $this->assertInstanceOf(InflectedRoute::class, $all[1]); + + $this->collection = new RouteCollection(); + $routes = new RouteBuilder($this->collection, '/l'); + $this->assertSame($routes, $routes->setRouteClass(DashedRoute::class)); + $this->assertSame(DashedRoute::class, $routes->getRouteClass()); + + $routes->connect('/{controller}', ['action' => 'index']); + $all = $this->collection->routes(); + $this->assertInstanceOf(DashedRoute::class, $all[0]); + } + + /** + * Test connecting an instance routes. + */ + public function testConnectInstance(): void + { + $routes = new RouteBuilder($this->collection, '/l', ['prefix' => 'Api']); + + $route = new Route('/{controller}'); + $this->assertSame($route, $routes->connect($route)); + + $result = $this->collection->routes()[0]; + $this->assertSame($route, $result); + } + + /** + * Test connecting basic routes. + */ + public function testConnectBasic(): void + { + $routes = new RouteBuilder($this->collection, '/l', ['prefix' => 'Api']); + + $route = $routes->connect('/{controller}'); + $this->assertInstanceOf(Route::class, $route); + + $this->assertSame($route, $this->collection->routes()[0]); + $this->assertSame('/l/{controller}', $route->template); + $expected = ['prefix' => 'Api', 'action' => 'index', 'plugin' => null]; + $this->assertEquals($expected, $route->defaults); + } + + /** + * Test that compiling a route results in an trailing / optional pattern. + */ + public function testConnectTrimTrailingSlash(): void + { + $routes = new RouteBuilder($this->collection, '/articles', ['controller' => 'Articles']); + $routes->connect('/', ['action' => 'index']); + + $expected = [ + 'plugin' => null, + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + '_matchedRoute' => '/articles', + ]; + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/articles'])); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/articles/'])); + unset($result['_route']); + $this->assertEquals($expected, $result); + } + + /** + * Test connect() with short string syntax + */ + public function testConnectShortStringInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/my-articles/view', 'Articles:no'); + } + + /** + * Test connect() with short string syntax + */ + public function testConnectShortString(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/my-articles/view', 'Articles::view'); + $expected = [ + 'pass' => [], + 'controller' => 'Articles', + 'action' => 'view', + 'plugin' => null, + '_matchedRoute' => '/my-articles/view', + ]; + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/my-articles/view'])); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $url = $expected['_matchedRoute']; + unset($expected['_matchedRoute']); + $this->assertSame($url, '/' . $this->collection->match($expected, [])); + } + + /** + * Test connect() with short string syntax + */ + public function testConnectShortStringPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/admin/bookmarks', 'Admin/Bookmarks::index'); + $expected = [ + 'pass' => [], + 'plugin' => null, + 'prefix' => 'Admin', + 'controller' => 'Bookmarks', + 'action' => 'index', + '_matchedRoute' => '/admin/bookmarks', + ]; + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/admin/bookmarks'])); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $url = $expected['_matchedRoute']; + unset($expected['_matchedRoute']); + $this->assertSame($url, '/' . $this->collection->match($expected, [])); + } + + /** + * Test connect() with short string syntax + */ + public function testConnectShortStringPlugin(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/blog/articles/view', 'Blog.Articles::view'); + $expected = [ + 'pass' => [], + 'plugin' => 'Blog', + 'controller' => 'Articles', + 'action' => 'view', + '_matchedRoute' => '/blog/articles/view', + ]; + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/blog/articles/view'])); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $url = $expected['_matchedRoute']; + unset($expected['_matchedRoute']); + $this->assertSame($url, '/' . $this->collection->match($expected, [])); + } + + /** + * Test connect() with short string syntax + */ + public function testConnectShortStringPluginPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/admin/blog/articles/view', 'Vendor/Blog.Management/Admin/Articles::view'); + $expected = [ + 'pass' => [], + 'plugin' => 'Vendor/Blog', + 'prefix' => 'Management/Admin', + 'controller' => 'Articles', + 'action' => 'view', + '_matchedRoute' => '/admin/blog/articles/view', + ]; + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/admin/blog/articles/view'])); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $url = $expected['_matchedRoute']; + unset($expected['_matchedRoute']); + $this->assertSame($url, '/' . $this->collection->match($expected, [])); + } + + /** + * Test if a route name already exist + */ + public function testNameExists(): void + { + $routes = new RouteBuilder($this->collection, '/l', ['prefix' => 'Api']); + $this->assertFalse($routes->nameExists('myRouteName')); + + $routes->connect('myRouteUrl', ['action' => 'index'], ['_name' => 'myRouteName']); + $this->assertTrue($routes->nameExists('myRouteName')); + } + + /** + * Test setExtensions() and getExtensions(). + */ + public function testExtensions(): void + { + $routes = new RouteBuilder($this->collection, '/l'); + $this->assertSame($routes, $routes->setExtensions(['html'])); + $this->assertSame(['html'], $routes->getExtensions()); + } + + /** + * Test extensions being connected to routes. + */ + public function testConnectExtensions(): void + { + $routes = new RouteBuilder( + $this->collection, + '/l', + [], + ['extensions' => ['json']], + ); + $this->assertEquals(['json'], $routes->getExtensions()); + + $routes->connect('/{controller}'); + $route = $this->collection->routes()[0]; + + $this->assertEquals(['json'], $route->options['_ext']); + $routes->setExtensions(['xml', 'json']); + + $routes->connect('/{controller}/{action}'); + $new = $this->collection->routes()[1]; + $this->assertEquals(['json'], $route->options['_ext']); + $this->assertEquals(['xml', 'json'], $new->options['_ext']); + } + + /** + * Test adding additional extensions will be merged with current. + */ + public function testConnectExtensionsAdd(): void + { + $routes = new RouteBuilder( + $this->collection, + '/l', + [], + ['extensions' => ['json']], + ); + $this->assertEquals(['json'], $routes->getExtensions()); + + $routes->addExtensions(['xml']); + $this->assertEquals(['json', 'xml'], $routes->getExtensions()); + + $routes->addExtensions('csv'); + $this->assertEquals(['json', 'xml', 'csv'], $routes->getExtensions()); + } + + /** + * test that setExtensions() accepts a string. + */ + public function testExtensionsString(): void + { + $routes = new RouteBuilder($this->collection, '/l'); + $routes->setExtensions('json'); + + $this->assertEquals(['json'], $routes->getExtensions()); + } + + /** + * Test conflicting parameters raises an exception. + */ + public function testConnectConflictingParameters(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('You cannot define routes that conflict with the scope.'); + $routes = new RouteBuilder($this->collection, '/admin', ['plugin' => 'TestPlugin']); + $routes->connect('/', ['plugin' => 'TestPlugin2', 'controller' => 'Dashboard', 'action' => 'view']); + } + + /** + * Test connecting redirect routes. + */ + public function testRedirect(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->redirect('/p/{id}', ['controller' => 'Posts', 'action' => 'view'], ['status' => 301]); + $route = $this->collection->routes()[0]; + + $this->assertInstanceOf(RedirectRoute::class, $route); + + $routes->redirect('/old', '/forums', ['status' => 301]); + $route = $this->collection->routes()[1]; + + $this->assertInstanceOf(RedirectRoute::class, $route); + $this->assertSame('/forums', $route->redirect[0]); + + $route = $routes->redirect('/old', '/forums'); + $this->assertInstanceOf(RedirectRoute::class, $route); + $this->assertSame($route, $this->collection->routes()[2]); + } + + /** + * Test using a custom route class for redirect routes. + */ + public function testRedirectWithCustomRouteClass(): void + { + $routes = new RouteBuilder($this->collection, '/'); + + $routes->redirect('/old', '/forums', ['status' => 301, 'routeClass' => 'InflectedRoute']); + $route = $this->collection->routes()[0]; + + $this->assertInstanceOf(InflectedRoute::class, $route); + } + + /** + * Test creating sub-scopes with prefix() + */ + public function testPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/path', ['key' => 'value']); + $res = $routes->prefix('admin', ['param' => 'value'], function (RouteBuilder $r): void { + $this->assertInstanceOf(RouteBuilder::class, $r); + $this->assertCount(0, $this->collection->routes()); + $this->assertSame('/path/admin', $r->path()); + $this->assertEquals(['prefix' => 'Admin', 'key' => 'value', 'param' => 'value'], $r->params()); + }); + $this->assertSame($routes, $res); + } + + /** + * Test creating sub-scopes with prefix() + */ + public function testPrefixWithNoParams(): void + { + $routes = new RouteBuilder($this->collection, '/path', ['key' => 'value']); + $res = $routes->prefix('admin', function (RouteBuilder $r): void { + $this->assertInstanceOf(RouteBuilder::class, $r); + $this->assertCount(0, $this->collection->routes()); + $this->assertSame('/path/admin', $r->path()); + $this->assertEquals(['prefix' => 'Admin', 'key' => 'value'], $r->params()); + }); + $this->assertSame($routes, $res); + } + + /** + * Test creating sub-scopes with prefix() + */ + public function testNestedPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/admin', ['prefix' => 'Admin']); + $res = $routes->prefix('api', ['_namePrefix' => 'api:'], function (RouteBuilder $r): void { + $this->assertSame('/admin/api', $r->path()); + $this->assertEquals(['prefix' => 'Admin/Api'], $r->params()); + $this->assertSame('api:', $r->namePrefix()); + }); + $this->assertSame($routes, $res); + } + + /** + * Test creating sub-scopes with prefix() + */ + public function testPathWithDotInPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/admin', ['prefix' => 'Admin']); + $res = $routes->prefix('Api', function (RouteBuilder $r): void { + $r->prefix('v10', ['path' => '/v1.0'], function (RouteBuilder $r2): void { + $this->assertSame('/admin/api/v1.0', $r2->path()); + $this->assertEquals(['prefix' => 'Admin/Api/V10'], $r2->params()); + $r2->prefix('b1', ['path' => '/beta.1'], function (RouteBuilder $r3): void { + $this->assertSame('/admin/api/v1.0/beta.1', $r3->path()); + $this->assertEquals(['prefix' => 'Admin/Api/V10/B1'], $r3->params()); + }); + }); + }); + $this->assertSame($routes, $res); + } + + /** + * Test creating sub-scopes with plugin() + */ + public function testPlugin(): void + { + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $res = $routes->plugin('Contacts', function (RouteBuilder $r): void { + $this->assertSame('/b/contacts', $r->path()); + $this->assertEquals(['plugin' => 'Contacts', 'key' => 'value'], $r->params()); + + $r->connect('/{controller}'); + $route = $this->collection->routes()[0]; + $this->assertEquals( + ['key' => 'value', 'plugin' => 'Contacts', 'action' => 'index'], + $route->defaults, + ); + }); + $this->assertSame($routes, $res); + } + + /** + * Test creating sub-scopes with plugin() + path option + */ + public function testPluginPathOption(): void + { + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->plugin('Contacts', ['path' => '/people'], function (RouteBuilder $r): void { + $this->assertSame('/b/people', $r->path()); + $this->assertEquals(['plugin' => 'Contacts', 'key' => 'value'], $r->params()); + }); + } + + /** + * Test creating sub-scopes with plugin() + namePrefix option + */ + public function testPluginNamePrefix(): void + { + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->plugin('Contacts', ['_namePrefix' => 'contacts.'], function (RouteBuilder $r): void { + $this->assertEquals('contacts.', $r->namePrefix()); + }); + + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->namePrefix('default.'); + $routes->plugin('Blog', ['_namePrefix' => 'blog.'], function (RouteBuilder $r): void { + $this->assertEquals('default.blog.', $r->namePrefix(), 'Should combine nameprefix'); + }); + } + + /** + * Test connecting resources. + */ + public function testResources(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->resources('Articles', ['_ext' => 'json']); + + $all = $this->collection->routes(); + $this->assertCount(5, $all); + + $this->assertSame('/api/articles', $all[4]->template); + $this->assertEquals( + ['controller', 'action', '_method', 'prefix', 'plugin'], + array_keys($all[0]->defaults), + ); + $this->assertSame('json', $all[0]->options['_ext']); + $this->assertSame('Articles', $all[0]->defaults['controller']); + } + + /** + * Test connecting resources with a path + */ + public function testResourcesPathOption(): void + { + $routes = new RouteBuilder($this->collection, '/api'); + $routes->resources('Articles', ['path' => 'posts'], function (RouteBuilder $routes): void { + $routes->resources('Comments'); + }); + $all = $this->collection->routes(); + $this->assertSame('Articles', $all[8]->defaults['controller']); + $this->assertSame('/api/posts', $all[8]->template); + $this->assertSame('/api/posts/{id}', $all[1]->template); + $this->assertSame( + '/api/posts/{article_id}/comments', + $all[4]->template, + 'parameter name should reflect resource name', + ); + } + + /** + * Test connecting resources with a prefix + */ + public function testResourcesPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/api'); + $routes->resources('Articles', ['prefix' => 'Rest']); + $all = $this->collection->routes(); + $this->assertSame('Rest', $all[0]->defaults['prefix']); + } + + /** + * Test that resource prefixes work within a prefixed scope. + */ + public function testResourcesNestedPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->resources('Articles', ['prefix' => 'Rest']); + + $all = $this->collection->routes(); + $this->assertCount(5, $all); + + $this->assertSame('/api/articles', $all[4]->template); + foreach ($all as $route) { + $this->assertSame('Api/Rest', $route->defaults['prefix']); + $this->assertSame('Articles', $route->defaults['controller']); + } + } + + /** + * Test connecting resources with the inflection option + */ + public function testResourcesInflection(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->resources('BlogPosts', ['_ext' => 'json', 'inflect' => 'dasherize']); + + $all = $this->collection->routes(); + $this->assertCount(5, $all); + + $this->assertSame('/api/blog-posts', $all[4]->template); + $this->assertEquals( + ['controller', 'action', '_method', 'prefix', 'plugin'], + array_keys($all[0]->defaults), + ); + $this->assertSame('BlogPosts', $all[0]->defaults['controller']); + } + + /** + * Test connecting nested resources with the inflection option + */ + public function testResourcesNestedInflection(): void + { + $routes = new RouteBuilder($this->collection, '/api'); + $routes->resources( + 'NetworkObjects', + ['inflect' => 'dasherize'], + function (RouteBuilder $routes): void { + $routes->resources('Attributes'); + }, + ); + + $all = $this->collection->routes(); + $this->assertCount(10, $all); + + $this->assertSame('/api/network-objects', $all[8]->template); + $this->assertSame('/api/network-objects/{id}', $all[2]->template); + $this->assertSame('/api/network-objects/{network_object_id}/attributes', $all[4]->template); + } + + /** + * Test connecting resources with additional mappings + */ + public function testResourcesMappings(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->resources('Articles', [ + '_ext' => 'json', + 'map' => [ + 'delete_all' => ['action' => 'deleteAll', 'method' => 'DELETE'], + 'update_many' => ['action' => 'updateAll', 'method' => 'DELETE', 'path' => '/updateAll'], + ], + ]); + + $all = $this->collection->routes(); + $this->assertCount(7, $all); + + $this->assertSame('/api/articles/delete_all', $all[1]->template, 'Path defaults to key name.'); + $this->assertEquals( + ['controller', 'action', '_method', 'prefix', 'plugin'], + array_keys($all[5]->defaults), + ); + $this->assertSame('Articles', $all[5]->defaults['controller']); + $this->assertSame('deleteAll', $all[1]->defaults['action']); + + $this->assertSame('/api/articles/updateAll', $all[0]->template, 'Explicit path option'); + $this->assertEquals( + ['controller', 'action', '_method', 'prefix', 'plugin'], + array_keys($all[6]->defaults), + ); + $this->assertSame('Articles', $all[6]->defaults['controller']); + $this->assertSame('updateAll', $all[0]->defaults['action']); + } + + /** + * Test connecting resources with restricted mappings. + */ + public function testResourcesWithMapOnly(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->resources('Articles', [ + 'map' => [ + 'conditions' => ['action' => 'conditions', 'method' => 'DeLeTe'], + ], + 'only' => ['conditions'], + ]); + + $all = $this->collection->routes(); + $this->assertCount(1, $all); + $this->assertSame('DELETE', $all[0]->defaults['_method'], 'method should be normalized.'); + $this->assertSame('Articles', $all[0]->defaults['controller']); + $this->assertSame('conditions', $all[0]->defaults['action']); + + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/api/articles/conditions', + 'environment' => [ + 'REQUEST_METHOD' => 'DELETE', + ], + ])); + $this->assertNotNull($result); + } + + /** + * Test connecting resources. + */ + public function testResourcesInScope(): void + { + $builder = Router::createRouteBuilder('/'); + $builder->scope('/api', ['prefix' => 'Api'], function (RouteBuilder $routes): void { + $routes->setExtensions(['json']); + $routes->resources('Articles'); + }); + $url = Router::url([ + 'prefix' => 'Api', + 'controller' => 'Articles', + 'action' => 'edit', + '_method' => 'PUT', + 'id' => '99', + ]); + $this->assertSame('/api/articles/99', $url); + + $url = Router::url([ + 'prefix' => 'Api', + 'controller' => 'Articles', + 'action' => 'edit', + '_method' => 'PUT', + '_ext' => 'json', + 'id' => '99', + ]); + $this->assertSame('/api/articles/99.json', $url); + } + + /** + * Test resource parsing. + */ + public function testResourcesParsing(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->resources('Articles'); + + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/articles', + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + ])); + $this->assertSame('Articles', $result['controller']); + $this->assertSame('index', $result['action']); + $this->assertEquals([], $result['pass']); + + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/articles/1', + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + ])); + $this->assertSame('Articles', $result['controller']); + $this->assertSame('view', $result['action']); + $this->assertEquals([1], $result['pass']); + + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/articles', + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ])); + $this->assertSame('Articles', $result['controller']); + $this->assertSame('add', $result['action']); + $this->assertEquals([], $result['pass']); + + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/articles/1', + 'environment' => [ + 'REQUEST_METHOD' => 'PUT', + ], + ])); + $this->assertSame('Articles', $result['controller']); + $this->assertSame('edit', $result['action']); + $this->assertEquals([1], $result['pass']); + + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/articles/1', + 'environment' => [ + 'REQUEST_METHOD' => 'DELETE', + ], + ])); + $this->assertSame('Articles', $result['controller']); + $this->assertSame('delete', $result['action']); + $this->assertEquals([1], $result['pass']); + } + + /** + * Test the only option of RouteBuilder. + */ + public function testResourcesOnlyString(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->resources('Articles', ['only' => 'index']); + + $result = $this->collection->routes(); + $this->assertCount(1, $result); + $this->assertSame('/articles', $result[0]->template); + } + + /** + * Test the only option of RouteBuilder. + */ + public function testResourcesOnlyArray(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->resources('Articles', ['only' => ['index', 'delete']]); + + $result = $this->collection->routes(); + $this->assertCount(2, $result); + $this->assertSame('/articles', $result[1]->template); + $this->assertSame('index', $result[1]->defaults['action']); + $this->assertSame('GET', $result[1]->defaults['_method']); + + $this->assertSame('/articles/{id}', $result[0]->template); + $this->assertSame('delete', $result[0]->defaults['action']); + $this->assertSame('DELETE', $result[0]->defaults['_method']); + } + + /** + * Test the actions option of RouteBuilder. + */ + public function testResourcesActions(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->resources('Articles', [ + 'only' => ['index', 'delete'], + 'actions' => ['index' => 'showList'], + ]); + + $result = $this->collection->routes(); + $this->assertCount(2, $result); + $this->assertSame('/articles', $result[1]->template); + $this->assertSame('showList', $result[1]->defaults['action']); + + $this->assertSame('/articles/{id}', $result[0]->template); + $this->assertSame('delete', $result[0]->defaults['action']); + } + + /** + * Test nesting resources + */ + public function testResourcesNested(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->resources('Articles', function (RouteBuilder $routes): void { + $this->assertSame('/api/articles/', $routes->path()); + $this->assertEquals(['prefix' => 'Api'], $routes->params()); + + $routes->resources('Comments'); + $route = $this->collection->routes()[3]; + $this->assertSame('/api/articles/{article_id}/comments', $route->template); + }); + } + + /** + * Test connecting fallback routes. + */ + public function testFallbacks(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->fallbacks(); + + $all = $this->collection->routes(); + $this->assertSame('/api/{controller}', $all[0]->template); + $this->assertSame('/api/{controller}/{action}/*', $all[1]->template); + $this->assertInstanceOf(Route::class, $all[0]); + } + + /** + * Test connecting fallback routes with specific route class + */ + public function testFallbacksWithClass(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->fallbacks('InflectedRoute'); + + $all = $this->collection->routes(); + $this->assertSame('/api/{controller}', $all[0]->template); + $this->assertSame('/api/{controller}/{action}/*', $all[1]->template); + $this->assertInstanceOf(InflectedRoute::class, $all[0]); + } + + /** + * Test connecting fallback routes after setting default route class. + */ + public function testDefaultRouteClassFallbacks(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->setRouteClass(DashedRoute::class); + $routes->fallbacks(); + + $all = $this->collection->routes(); + $this->assertInstanceOf(DashedRoute::class, $all[0]); + } + + /** + * Test adding a scope. + */ + public function testScope(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->scope('/v1', ['version' => 1], function (RouteBuilder $routes): void { + $this->assertSame('/api/v1', $routes->path()); + $this->assertEquals(['prefix' => 'Api', 'version' => 1], $routes->params()); + }); + } + + /** + * Test adding a scope with action in the scope + */ + public function testScopeWithAction(): void + { + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->scope('/prices', ['controller' => 'Prices', 'action' => 'view'], function (RouteBuilder $routes): void { + $routes->connect('/shared', ['shared' => true]); + $routes->get('/exclusive', ['exclusive' => true]); + }); + $all = $this->collection->routes(); + $this->assertCount(2, $all); + $this->assertSame('view', $all[0]->defaults['action']); + $this->assertArrayHasKey('shared', $all[0]->defaults); + + $this->assertSame('view', $all[1]->defaults['action']); + $this->assertArrayHasKey('exclusive', $all[1]->defaults); + } + + /** + * Test that exception is thrown if callback is not a valid callable. + */ + public function testScopeException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Need a valid Closure to connect routes.'); + + $routes = new RouteBuilder($this->collection, '/api', ['prefix' => 'Api']); + $routes->scope('/v1', ['fail']); + } + + /** + * Test that nested scopes inherit middleware. + */ + public function testScopeInheritMiddleware(): void + { + $routes = new RouteBuilder( + $this->collection, + '/api', + ['prefix' => 'Api'], + ['middleware' => ['auth']], + ); + $routes->scope('/v1', function (RouteBuilder $routes): void { + $this->assertSame(['auth'], $routes->getMiddleware(), 'Should inherit middleware'); + $this->assertSame('/api/v1', $routes->path()); + $this->assertEquals(['prefix' => 'Api'], $routes->params()); + }); + } + + /** + * Test using name prefixes. + */ + public function testNamePrefixes(): void + { + $routes = new RouteBuilder($this->collection, '/api', [], ['namePrefix' => 'api:']); + $routes->scope('/v1', ['version' => 1, '_namePrefix' => 'v1:'], function (RouteBuilder $routes): void { + $this->assertSame('api:v1:', $routes->namePrefix()); + $routes->connect('/ping', ['controller' => 'Pings'], ['_name' => 'ping']); + + $routes->namePrefix('web:'); + $routes->connect('/pong', ['controller' => 'Pongs'], ['_name' => 'pong']); + }); + + $all = $this->collection->named(); + $this->assertArrayHasKey('api:v1:ping', $all); + $this->assertArrayHasKey('web:pong', $all); + } + + /** + * Test adding middleware to the collection. + */ + public function testRegisterMiddleware(): void + { + $func = function (): void { + }; + $routes = new RouteBuilder($this->collection, '/api'); + $result = $routes->registerMiddleware('test', $func); + + $this->assertSame($result, $routes); + $this->assertTrue($this->collection->hasMiddleware('test')); + $this->assertTrue($this->collection->middlewareExists('test')); + } + + /** + * Test middleware group + */ + public function testMiddlewareGroup(): void + { + $func = function (): void { + }; + $routes = new RouteBuilder($this->collection, '/api'); + $routes->registerMiddleware('test', $func); + $routes->registerMiddleware('test_two', $func); + $result = $routes->middlewareGroup('group', ['test', 'test_two']); + + $this->assertSame($result, $routes); + $this->assertTrue($this->collection->hasMiddlewareGroup('group')); + $this->assertTrue($this->collection->middlewareExists('group')); + } + + /** + * Test overlap between middleware name and group name + */ + public function testMiddlewareGroupOverlap(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Cannot add middleware group 'test'. A middleware by this name has already been registered."); + $func = function (): void { + }; + $routes = new RouteBuilder($this->collection, '/api'); + $routes->registerMiddleware('test', $func); + $routes->middlewareGroup('test', ['test']); + } + + /** + * Test applying middleware to a scope when it doesn't exist + */ + public function testApplyMiddlewareInvalidName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot apply `bad` middleware or middleware group. Use `registerMiddleware()` to register middleware'); + $routes = new RouteBuilder($this->collection, '/api'); + $routes->applyMiddleware('bad'); + } + + /** + * Test applying middleware to a scope + */ + public function testApplyMiddleware(): void + { + $func = function (): void { + }; + $routes = new RouteBuilder($this->collection, '/api'); + $routes->registerMiddleware('test', $func) + ->registerMiddleware('test2', $func); + $result = $routes->applyMiddleware('test', 'test2'); + + $this->assertSame($result, $routes); + } + + /** + * Test that applyMiddleware() merges with previous data. + */ + public function testApplyMiddlewareMerges(): void + { + $func = function (): void { + }; + $routes = new RouteBuilder($this->collection, '/api'); + $routes->registerMiddleware('test', $func) + ->registerMiddleware('test2', $func); + $routes->applyMiddleware('test'); + $routes->applyMiddleware('test2'); + + $this->assertSame(['test', 'test2'], $routes->getMiddleware()); + } + + /** + * Test that applyMiddleware() uses unique middleware set + */ + public function testApplyMiddlewareUnique(): void + { + $func = function (): void { + }; + $routes = new RouteBuilder($this->collection, '/api'); + $routes->registerMiddleware('test', $func) + ->registerMiddleware('test2', $func); + + $routes->applyMiddleware('test', 'test2'); + $routes->applyMiddleware('test2', 'test'); + + $this->assertEquals(['test', 'test2'], $routes->getMiddleware()); + } + + /** + * Test applying middleware results in middleware attached to the route. + */ + public function testApplyMiddlewareAttachToRoutes(): void + { + $func = function (): void { + }; + $routes = new RouteBuilder($this->collection, '/api'); + $routes->registerMiddleware('test', $func) + ->registerMiddleware('test2', $func); + $routes->applyMiddleware('test', 'test2'); + $route = $routes->get('/docs', ['controller' => 'Docs']); + + $this->assertSame(['test', 'test2'], $route->getMiddleware()); + } + + /** + * @return array + */ + public static function httpMethodProvider(): array + { + return [ + ['GET'], + ['POST'], + ['PUT'], + ['PATCH'], + ['DELETE'], + ['OPTIONS'], + ['HEAD'], + ]; + } + + /** + * Test that the HTTP method helpers create the right kind of routes. + */ + #[DataProvider('httpMethodProvider')] + public function testHttpMethods(string $method): void + { + $routes = new RouteBuilder($this->collection, '/', [], ['namePrefix' => 'app:']); + $route = $routes->{strtolower($method)}( + '/bookmarks/{id}', + ['controller' => 'Bookmarks', 'action' => 'view'], + 'route-name' + ); + $this->assertInstanceOf(Route::class, $route, 'Should return a route'); + $this->assertSame($method, $route->defaults['_method']); + $this->assertSame('app:route-name', $route->options['_name']); + $this->assertSame('/bookmarks/{id}', $route->template); + $this->assertEquals( + ['plugin' => null, 'controller' => 'Bookmarks', 'action' => 'view', '_method' => $method], + $route->defaults, + ); + } + + /** + * Test that the HTTP method helpers create the right kind of routes. + */ + #[DataProvider('httpMethodProvider')] + public function testHttpMethodsStringTarget(string $method): void + { + $routes = new RouteBuilder($this->collection, '/', [], ['namePrefix' => 'app:']); + $route = $routes->{strtolower($method)}( + '/bookmarks/{id}', + 'Bookmarks::view', + 'route-name' + ); + $this->assertInstanceOf(Route::class, $route, 'Should return a route'); + $this->assertSame($method, $route->defaults['_method']); + $this->assertSame('app:route-name', $route->options['_name']); + $this->assertSame('/bookmarks/{id}', $route->template); + $this->assertEquals( + ['plugin' => null, 'controller' => 'Bookmarks', 'action' => 'view', '_method' => $method], + $route->defaults, + ); + } + + /** + * Integration test for http method helpers and route fluent method + */ + public function testHttpMethodIntegration(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->scope('/', function (RouteBuilder $routes): void { + $routes->get('/faq/{page}', ['controller' => 'Pages', 'action' => 'faq'], 'faq') + ->setPatterns(['page' => '[a-z0-9_]+']) + ->setHost('docs.example.com'); + + $routes->post('/articles/{id}', ['controller' => 'Articles', 'action' => 'update'], 'article:update') + ->setPatterns(['id' => '[0-9]+']) + ->setPass(['id']); + }); + $this->assertCount(2, $this->collection->routes()); + $this->assertEquals(['faq', 'article:update'], array_keys($this->collection->named())); + $this->assertNotEmpty($this->collection->parseRequest(new ServerRequest([ + 'url' => '/faq/things_you_know', + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + 'HTTP_HOST' => 'docs.example.com', + ], + ]))); + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/articles/123', + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ])); + $this->assertEquals(['123'], $result['pass']); + } + + /** + * Test loading routes from a missing plugin + */ + public function testLoadPluginBadPlugin(): void + { + $this->expectException(MissingPluginException::class); + $routes = new RouteBuilder($this->collection, '/'); + $routes->loadPlugin('Nope'); + } + + /** + * Test loading routes with success + */ + public function testLoadPlugin(): void + { + $this->loadPlugins(['TestPlugin']); + $routes = new RouteBuilder($this->collection, '/'); + $routes->loadPlugin('TestPlugin'); + $this->assertCount(1, $this->collection->routes()); + $this->assertNotEmpty($this->collection->parseRequest(new ServerRequest(['url' => '/test_plugin']))); + + $plugin = Plugin::getCollection()->get('TestPlugin'); + $this->assertFalse($plugin->isEnabled('routes'), 'Hook should be disabled preventing duplicate routes'); + } + + /** + * Test setOptions() sets default options for routes. + */ + public function testSetOptions(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->setOptions(['_host' => 'example.com']); + + $routes->connect('/{controller}', ['action' => 'index']); + $route = $this->collection->routes()[0]; + + $this->assertEquals('example.com', $route->options['_host']); + } + + /** + * Test setOptions() with multiple options. + */ + public function testSetOptionsMultiple(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->setOptions([ + '_host' => 'example.com', + '_https' => true, + '_port' => 8080, + ]); + + $routes->connect('/{controller}/{action}'); + $route = $this->collection->routes()[0]; + + $this->assertEquals('example.com', $route->options['_host']); + $this->assertTrue($route->options['_https']); + $this->assertEquals(8080, $route->options['_port']); + } + + /** + * Test setOptions() with method routes. + */ + public function testSetOptionsMethodRoutes(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->setOptions(['_host' => 'api.example.com']); + + $routes->get('/posts', ['controller' => 'Posts', 'action' => 'index']); + $routes->post('/posts', ['controller' => 'Posts', 'action' => 'add']); + + // Routes are stored in reverse order - most recent first + $postRoute = $this->collection->routes()[0]; + $getRoute = $this->collection->routes()[1]; + + $this->assertEquals('api.example.com', $getRoute->options['_host']); + $this->assertEquals('api.example.com', $postRoute->options['_host']); + } + + /** + * Test setOptions() can be overridden per route. + */ + public function testSetOptionsOverride(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->setOptions(['_host' => 'example.com']); + + $routes->connect('/admin', ['controller' => 'Admin', 'action' => 'index'], ['_host' => 'admin.example.com']); + $routes->connect('/user', ['controller' => 'Users', 'action' => 'index']); + + // Routes are stored in reverse order - most recent first + $userRoute = $this->collection->routes()[0]; + $adminRoute = $this->collection->routes()[1]; + + $this->assertEquals('admin.example.com', $adminRoute->options['_host']); + $this->assertEquals('example.com', $userRoute->options['_host']); + } + + /** + * Test setOptions() inheritance in nested scopes. + */ + public function testSetOptionsInheritance(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->setOptions(['_host' => 'example.com']); + + $routes->scope('/api', function ($routes): void { + $routes->connect('/users', ['controller' => 'Users', 'action' => 'index']); + }); + + $route = $this->collection->routes()[0]; + $this->assertEquals('example.com', $route->options['_host']); + } + + /** + * Test setOptions() can be overridden in nested scopes. + */ + public function testSetOptionsInheritanceOverride(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->setOptions(['_host' => 'example.com']); + + $routes->scope('/api', function ($routes): void { + $routes->setOptions(['_host' => 'api.example.com']); + $routes->connect('/users', ['controller' => 'Users', 'action' => 'index']); + }); + + $routes->connect('/pages', ['controller' => 'Pages', 'action' => 'index']); + + // Routes are stored in reverse order - most recent first + $pageRoute = $this->collection->routes()[0]; + $apiRoute = $this->collection->routes()[1]; + + $this->assertEquals('api.example.com', $apiRoute->options['_host']); + $this->assertEquals('example.com', $pageRoute->options['_host']); + } + + /** + * Test setOptions() with prefix scopes. + */ + public function testSetOptionsWithPrefix(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->prefix('Admin', function ($routes): void { + $routes->setOptions(['_host' => 'admin.example.com']); + $routes->connect('/dashboard', ['controller' => 'Dashboard', 'action' => 'index']); + }); + + $route = $this->collection->routes()[0]; + $this->assertEquals('admin.example.com', $route->options['_host']); + $this->assertEquals('Admin', $route->defaults['prefix']); + } + + /** + * Test setOptions() with plugin scopes. + */ + public function testSetOptionsWithPlugin(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->plugin('MyPlugin', function ($routes): void { + $routes->setOptions(['_host' => 'plugin.example.com']); + $routes->connect('/dashboard', ['controller' => 'Dashboard', 'action' => 'index']); + }); + + $route = $this->collection->routes()[0]; + $this->assertEquals('plugin.example.com', $route->options['_host']); + $this->assertEquals('MyPlugin', $route->defaults['plugin']); + } +} diff --git a/tests/TestCase/Routing/RouteCollectionTest.php b/tests/TestCase/Routing/RouteCollectionTest.php new file mode 100644 index 00000000000..253af476a70 --- /dev/null +++ b/tests/TestCase/Routing/RouteCollectionTest.php @@ -0,0 +1,871 @@ +collection = new RouteCollection(); + } + + /** + * Test parse() throws an error on unknown routes. + */ + public function testParseMissingRoute(): void + { + $this->expectException(MissingRouteException::class); + $this->expectExceptionMessage('A route matching `/` could not be found'); + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->connect('/', ['controller' => 'Articles']); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/'])); + $this->assertEquals([], $result, 'Should not match, missing /b'); + } + + /** + * Test parse() throws an error on known routes called with unknown methods. + */ + public function testParseMissingRouteMethod(): void + { + $this->expectException(MissingRouteException::class); + $this->expectExceptionMessage('A route matching `/b` could not be found'); + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->connect('/', ['controller' => 'Articles', '_method' => ['GET']]); + + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/b', + 'environment' => [ + 'REQUEST_METHOD' => 'GET', + ], + ])); + $this->assertNotEmpty($result, 'Route should be found'); + $result = $this->collection->parseRequest(new ServerRequest([ + 'url' => '/b', + 'environment' => [ + 'REQUEST_METHOD' => 'POST', + ], + ])); + $this->assertEquals([], $result, 'Should not match with missing method'); + } + + /** + * Test parsing routes. + */ + public function testParse(): void + { + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->connect('/', ['controller' => 'Articles']); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + $routes->connect('/media/search/*', ['controller' => 'Media', 'action' => 'search']); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/'])); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + 'plugin' => null, + 'key' => 'value', + '_matchedRoute' => '/b', + ]; + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/the-thing?one=two'])); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'id' => 'the-thing', + 'pass' => [], + 'plugin' => null, + 'key' => 'value', + '?' => ['one' => 'two'], + '_matchedRoute' => '/b/{id}', + ]; + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/media/search'])); + unset($result['_route']); + $expected = [ + 'key' => 'value', + 'pass' => [], + 'plugin' => null, + 'controller' => 'Media', + 'action' => 'search', + '_matchedRoute' => '/b/media/search/*', + ]; + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/media/search/thing'])); + unset($result['_route']); + $expected = [ + 'key' => 'value', + 'pass' => ['thing'], + 'plugin' => null, + 'controller' => 'Media', + 'action' => 'search', + '_matchedRoute' => '/b/media/search/*', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test parse() handling query strings. + */ + public function testParseQueryString(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + $routes->connect('/media/search/*', ['controller' => 'Media', 'action' => 'search']); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/media/search/php?one=two'])); + unset($result['_route']); + $expected = [ + 'controller' => 'Media', + 'action' => 'search', + 'pass' => ['php'], + 'plugin' => null, + '_matchedRoute' => '/media/search/*', + '?' => ['one' => 'two'], + ]; + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/thing?one=two'])); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => [], + 'id' => 'thing', + 'plugin' => null, + '_matchedRoute' => '/{id}', + '?' => ['one' => 'two'], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test parsing routes with and without _name. + */ + public function testParseWithNameParameter(): void + { + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->connect('/', ['controller' => 'Articles']); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + $routes->connect('/media/search/*', ['controller' => 'Media', 'action' => 'search'], ['_name' => 'media_search']); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/'])); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + 'plugin' => null, + 'key' => 'value', + '_matchedRoute' => '/b', + ]; + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/the-thing?one=two'])); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'id' => 'the-thing', + 'pass' => [], + 'plugin' => null, + 'key' => 'value', + '?' => ['one' => 'two'], + '_matchedRoute' => '/b/{id}', + ]; + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/media/search'])); + unset($result['_route']); + $expected = [ + 'key' => 'value', + 'pass' => [], + 'plugin' => null, + 'controller' => 'Media', + 'action' => 'search', + '_matchedRoute' => '/b/media/search/*', + '_name' => 'media_search', + ]; + $this->assertEquals($expected, $result); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/b/media/search/thing'])); + unset($result['_route']); + $expected = [ + 'key' => 'value', + 'pass' => ['thing'], + 'plugin' => null, + 'controller' => 'Media', + 'action' => 'search', + '_matchedRoute' => '/b/media/search/*', + '_name' => 'media_search', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that parse decodes URL data before matching. + * This is important for multibyte URLs that pass through URL rewriting. + */ + public function testParseEncodedBytesInFixedSegment(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/ден/{day}-{month}', ['controller' => 'Events', 'action' => 'index']); + + $url = '/%D0%B4%D0%B5%D0%BD/15-%D0%BE%D0%BA%D1%82%D0%BE%D0%BC%D0%B2%D1%80%D0%B8?test=foo'; + $result = $this->collection->parseRequest(new ServerRequest(['url' => $url])); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'plugin' => null, + 'controller' => 'Events', + 'action' => 'index', + 'day' => '15', + 'month' => 'октомври', + '?' => ['test' => 'foo'], + '_matchedRoute' => '/ден/{day}-{month}', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that parsing checks all the related path scopes. + */ + public function testParseFallback(): void + { + $routes = new RouteBuilder($this->collection, '/', []); + + $routes->resources('Articles'); + $routes->connect('/{controller}', ['action' => 'index'], ['routeClass' => 'InflectedRoute']); + $routes->connect('/{controller}/{action}', [], ['routeClass' => 'InflectedRoute']); + + $result = $this->collection->parseRequest(new ServerRequest(['url' => '/articles/add'])); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'add', + 'plugin' => null, + 'pass' => [], + '_matchedRoute' => '/{controller}/{action}', + + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test parseRequest() throws an error on unknown routes. + */ + public function testParseRequestMissingRoute(): void + { + $this->expectException(MissingRouteException::class); + $this->expectExceptionMessage('A route matching `/` could not be found'); + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->connect('/', ['controller' => 'Articles']); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + + $request = new ServerRequest(['url' => '/']); + $result = $this->collection->parseRequest($request); + $this->assertEquals([], $result, 'Should not match, missing /b'); + } + + /** + * Test parseRequest() handling query strings. + */ + public function testParseRequestQueryString(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + $routes->connect('/media/search/*', ['controller' => 'Media', 'action' => 'search']); + + $request = new ServerRequest(['url' => '/media/search/php?one=two']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Media', + 'action' => 'search', + 'pass' => ['php'], + 'plugin' => null, + '_matchedRoute' => '/media/search/*', + '?' => ['one' => 'two'], + ]; + $this->assertEquals($expected, $result); + + $request = new ServerRequest(['url' => '/thing?one=two']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => [], + 'id' => 'thing', + 'plugin' => null, + '_matchedRoute' => '/{id}', + '?' => ['one' => 'two'], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test parseRequest() handling query strings. + */ + public function testParseRequestQueryStringFromRoute(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect( + '/test', + ['controller' => 'Articles', 'action' => 'view'], + ['routeClass' => AddQueryParamRoute::class], + ); + $request = new ServerRequest(['url' => '/test?y=2']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => [], + 'plugin' => null, + '_matchedRoute' => '/test', + '?' => ['x' => '1', 'y' => '2'], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test parseRequest() checks host conditions + */ + public function testParseRequestCheckHostCondition(): void + { + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect( + '/fallback', + ['controller' => 'Articles', 'action' => 'index'], + ['_host' => '*.example.com'], + ); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'a.example.com', + 'REQUEST_URI' => '/fallback', + ], + ]); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + 'plugin' => null, + '_matchedRoute' => '/fallback', + ]; + $this->assertEquals($expected, $result, 'Should match, domain is correct'); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'foo.bar.example.com', + 'REQUEST_URI' => '/fallback', + ], + ]); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $this->assertEquals($expected, $result, 'Should match, domain is a matching subdomain'); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => 'example.test.com', + 'REQUEST_URI' => '/fallback', + ], + ]); + try { + $this->collection->parseRequest($request); + $this->fail('No exception raised'); + } catch (MissingRouteException $e) { + $this->assertStringContainsString('/fallback', $e->getMessage()); + } + } + + /** + * Get a list of hostnames + * + * @return array + */ + public static function hostProvider(): array + { + return [ + ['wrong.example'], + ['example.com'], + ['aexample.com'], + ]; + } + + /** + * Test parseRequest() checks host conditions + */ + #[DataProvider('hostProvider')] + public function testParseRequestCheckHostConditionFail(string $host): void + { + $this->expectException(MissingRouteException::class); + $this->expectExceptionMessage('A route matching `/fallback` could not be found'); + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect( + '/fallback', + ['controller' => 'Articles', 'action' => 'index'], + ['_host' => '*.example.com'], + ); + + $request = new ServerRequest([ + 'environment' => [ + 'HTTP_HOST' => $host, + 'REQUEST_URI' => '/fallback', + ], + ]); + $this->collection->parseRequest($request); + } + + /** + * Test parsing routes. + */ + public function testParseRequest(): void + { + $routes = new RouteBuilder($this->collection, '/b', ['key' => 'value']); + $routes->connect('/', ['controller' => 'Articles']); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + $routes->connect('/media/search/*', ['controller' => 'Media', 'action' => 'search']); + + $request = new ServerRequest(['url' => '/b/']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + 'plugin' => null, + 'key' => 'value', + '_matchedRoute' => '/b', + ]; + $this->assertEquals($expected, $result); + + $request = new ServerRequest(['url' => '/b/media/search']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'key' => 'value', + 'pass' => [], + 'plugin' => null, + 'controller' => 'Media', + 'action' => 'search', + '_matchedRoute' => '/b/media/search/*', + ]; + $this->assertEquals($expected, $result); + + $request = new ServerRequest(['url' => '/b/media/search/thing']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'key' => 'value', + 'pass' => ['thing'], + 'plugin' => null, + 'controller' => 'Media', + 'action' => 'search', + '_matchedRoute' => '/b/media/search/*', + ]; + $this->assertEquals($expected, $result); + + $request = new ServerRequest(['url' => '/b/the-thing?one=two']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'id' => 'the-thing', + 'pass' => [], + 'plugin' => null, + 'key' => 'value', + '?' => ['one' => 'two'], + '_matchedRoute' => '/b/{id}', + ]; + $this->assertEquals($expected, $result); + } + + public function testParseRequestExtension(): void + { + $builder = new RouteBuilder($this->collection, '/'); + $builder->connect('/foo', ['controller' => 'Articles'])->setExtensions(['json']); + + $request = new ServerRequest(['url' => '/foo']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + 'plugin' => null, + '_matchedRoute' => '/foo', + ]; + $this->assertEquals($expected, $result); + + $request = new ServerRequest(['url' => '/foo.json']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Articles', + 'action' => 'index', + 'pass' => [], + 'plugin' => null, + '_ext' => 'json', + '_matchedRoute' => '/foo', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test parsing routes that match non-ascii urls + */ + public function testParseRequestUnicode(): void + { + $routes = new RouteBuilder($this->collection, '/b', []); + $routes->connect('/alta/confirmación', ['controller' => 'Media', 'action' => 'confirm']); + + $request = new ServerRequest(['url' => '/b/alta/confirmaci%C3%B3n']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $expected = [ + 'controller' => 'Media', + 'action' => 'confirm', + 'pass' => [], + 'plugin' => null, + '_matchedRoute' => '/b/alta/confirmación', + ]; + $this->assertEquals($expected, $result); + + $request = new ServerRequest(['url' => '/b/alta/confirmación']); + $result = $this->collection->parseRequest($request); + unset($result['_route']); + $this->assertEquals($expected, $result); + } + + /** + * Test parsing routes that match non-ascii urls + */ + public function testParseRequestNoDecode2f(): void + { + $routes = new RouteBuilder($this->collection, '/b', []); + $routes->connect('/media/confirm', ['controller' => 'Media', 'action' => 'confirm']); + + $request = new ServerRequest(['url' => '/b/media%2fconfirm']); + + $this->expectException(MissingRouteException::class); + $this->collection->parseRequest($request); + } + + /** + * Test match() throws an error on unknown routes. + */ + public function testMatchError(): void + { + $this->expectException(MissingRouteException::class); + $this->expectExceptionMessage('A route matching `array ('); + $context = [ + '_base' => '/', + '_scheme' => 'http', + '_host' => 'example.org', + ]; + $routes = new RouteBuilder($this->collection, '/b'); + $routes->connect('/', ['controller' => 'Articles']); + + $this->collection->match(['plugin' => null, 'controller' => 'Articles', 'action' => 'add'], $context); + } + + /** + * Test matching routes. + */ + public function testMatch(): void + { + $context = [ + '_base' => '/', + '_scheme' => 'http', + '_host' => 'example.org', + ]; + $routes = new RouteBuilder($this->collection, '/b'); + $routes->connect('/', ['controller' => 'Articles']); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view']); + + $result = $this->collection->match(['plugin' => null, 'controller' => 'Articles', 'action' => 'index'], $context); + $this->assertSame('b', $result); + + $result = $this->collection->match( + ['id' => 'thing', 'plugin' => null, 'controller' => 'Articles', 'action' => 'view'], + $context, + ); + $this->assertSame('b/thing', $result); + } + + /** + * Test matching routes with names + */ + public function testMatchNamed(): void + { + $context = [ + '_base' => '/', + '_scheme' => 'http', + '_host' => 'example.org', + ]; + $routes = new RouteBuilder($this->collection, '/b'); + $routes->connect('/', ['controller' => 'Articles']); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view'], ['_name' => 'article:view']); + + $result = $this->collection->match(['_name' => 'article:view', 'id' => '2'], $context); + $this->assertSame('/b/2', $result); + + $result = $this->collection->match(['plugin' => null, 'controller' => 'Articles', 'action' => 'view', 'id' => '2'], $context); + $this->assertSame('b/2', $result); + } + + /** + * Test match() throws an error on named routes that fail to match + */ + public function testMatchNamedError(): void + { + $this->expectException(MissingRouteException::class); + $this->expectExceptionMessage('A named route was found for `fail`, but matching failed'); + $context = [ + '_base' => '/', + '_scheme' => 'http', + '_host' => 'example.org', + ]; + $routes = new RouteBuilder($this->collection, '/b'); + $routes->connect('/{lang}/articles', ['controller' => 'Articles'], ['_name' => 'fail']); + + $this->collection->match(['_name' => 'fail'], $context); + } + + /** + * Test matching routes with names and failing + */ + public function testMatchNamedMissingError(): void + { + $this->expectException(MissingRouteException::class); + $context = [ + '_base' => '/', + '_scheme' => 'http', + '_host' => 'example.org', + ]; + $routes = new RouteBuilder($this->collection, '/b'); + $routes->connect('/{id}', ['controller' => 'Articles', 'action' => 'view'], ['_name' => 'article:view']); + + $this->collection->match(['_name' => 'derp'], $context); + } + + /** + * Test matching plugin routes. + */ + public function testMatchPlugin(): void + { + $context = [ + '_base' => '/', + '_scheme' => 'http', + '_host' => 'example.org', + ]; + $routes = new RouteBuilder($this->collection, '/contacts', ['plugin' => 'Contacts']); + $routes->connect('/', ['controller' => 'Contacts']); + + $result = $this->collection->match(['plugin' => 'Contacts', 'controller' => 'Contacts', 'action' => 'index'], $context); + $this->assertSame('contacts', $result); + } + + /** + * Test that prefixes increase the specificity of a route match. + * + * Connect the admin route after the non prefixed version, this means + * the non-prefix route would have a more specific name (users:index) + */ + public function testMatchPrefixSpecificity(): void + { + $context = [ + '_base' => '/', + '_scheme' => 'http', + '_host' => 'example.org', + ]; + $routes = new RouteBuilder($this->collection, '/'); + $routes->connect('/{action}/*', ['controller' => 'Users']); + $routes->connect('/admin/{controller}', ['prefix' => 'Admin', 'action' => 'index']); + + $url = [ + 'plugin' => null, + 'prefix' => 'Admin', + 'controller' => 'Users', + 'action' => 'index', + ]; + $result = $this->collection->match($url, $context); + $this->assertSame('admin/Users', $result); + + $url = [ + 'plugin' => null, + 'controller' => 'Users', + 'action' => 'index', + ]; + $result = $this->collection->match($url, $context); + $this->assertSame('index', $result); + } + + /** + * Test getting named routes. + */ + public function testNamed(): void + { + $routes = new RouteBuilder($this->collection, '/l'); + $routes->connect('/{controller}', ['action' => 'index'], ['_name' => 'cntrl']); + $routes->connect('/{controller}/{action}/*'); + + $all = $this->collection->named(); + $this->assertCount(1, $all); + $this->assertInstanceOf(Route::class, $all['cntrl']); + $this->assertSame('/l/{controller}', $all['cntrl']->template); + } + + /** + * Test the add() and routes() method. + */ + public function testAddingRoutes(): void + { + $one = new Route('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + $two = new Route('/', ['controller' => 'Dashboards', 'action' => 'display']); + $this->collection->add($one); + $this->collection->add($two); + + $routes = $this->collection->routes(); + $this->assertCount(2, $routes); + $this->assertSame($one, $routes[0]); + $this->assertSame($two, $routes[1]); + } + + /** + * Test the add() with some _name. + */ + public function testAddingDuplicateNamedRoutes(): void + { + $this->expectException(DuplicateNamedRouteException::class); + $one = new Route('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + $two = new Route('/', ['controller' => 'Dashboards', 'action' => 'display']); + $this->collection->add($one, ['_name' => 'test']); + $this->collection->add($two, ['_name' => 'test']); + } + + /** + * Test basic setExtension and its getter. + */ + public function testSetExtensions(): void + { + $this->assertEquals([], $this->collection->getExtensions()); + + $this->collection->setExtensions(['json']); + $this->assertEquals(['json'], $this->collection->getExtensions()); + + $this->collection->setExtensions(['rss', 'xml']); + $this->assertEquals(['json', 'rss', 'xml'], $this->collection->getExtensions()); + + $this->collection->setExtensions(['csv'], false); + $this->assertEquals(['csv'], $this->collection->getExtensions()); + } + + /** + * Test adding middleware to the collection. + */ + public function testRegisterMiddleware(): void + { + $result = $this->collection->registerMiddleware('closure', function (): void { + }); + $this->assertSame($result, $this->collection); + + $callable = function (): void { + }; + $result = $this->collection->registerMiddleware('callable', $callable); + $this->assertSame($result, $this->collection); + + $this->assertTrue($this->collection->hasMiddleware('closure')); + $this->assertTrue($this->collection->hasMiddleware('callable')); + + $this->collection->registerMiddleware('class', 'Dumb'); + } + + /** + * Test adding a middleware group to the collection. + */ + public function testMiddlewareGroup(): void + { + $this->collection->registerMiddleware('closure', function (): void { + }); + + $callable = function (): void { + }; + $this->collection->registerMiddleware('callable', $callable); + + $this->collection->middlewareGroup('group', ['closure', 'callable']); + + $this->assertTrue($this->collection->hasMiddlewareGroup('group')); + } + + /** + * Test adding a middleware group with the same name overwrites the original list + */ + public function testMiddlewareGroupOverwrite(): void + { + $stub = function (): void { + }; + $this->collection->registerMiddleware('closure', $stub); + $this->collection->registerMiddleware('callable', $stub); + + $this->collection->middlewareGroup('group', ['callable']); + $this->collection->middlewareGroup('group', ['closure', 'callable']); + $this->assertSame([$stub, $stub], $this->collection->getMiddleware(['group'])); + } + + /** + * Test adding ab unregistered middleware to a middleware group fails. + */ + public function testMiddlewareGroupUnregisteredMiddleware(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Cannot add 'bad' middleware to group 'group'. It has not been registered."); + $this->collection->middlewareGroup('group', ['bad']); + } +} diff --git a/tests/TestCase/Routing/RouterTest.php b/tests/TestCase/Routing/RouterTest.php new file mode 100644 index 00000000000..050cb0ca1d4 --- /dev/null +++ b/tests/TestCase/Routing/RouterTest.php @@ -0,0 +1,3570 @@ + []]); + Router::reload(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + Router::defaultRouteClass(Route::class); + } + + /** + * testFullBaseUrl method + */ + public function testBaseUrl(): void + { + Router::createRouteBuilder('/')->fallbacks(); + $this->assertMatchesRegularExpression('/^http(s)?:\/\//', Router::url('/', true)); + $this->assertMatchesRegularExpression('/^http(s)?:\/\//', Router::url(null, true)); + $this->assertMatchesRegularExpression('/^http(s)?:\/\//', Router::url(['controller' => 'Test', '_full' => true])); + } + + /** + * Tests that the base URL can be changed at runtime. + */ + public function testFullBaseURL(): void + { + Router::createRouteBuilder('/')->fallbacks(); + Router::fullBaseUrl('http://example.com'); + $this->assertSame('http://example.com/', Router::url('/', true)); + $this->assertSame('http://example.com', Configure::read('App.fullBaseUrl')); + Router::fullBaseUrl('https://example.com'); + $this->assertSame('https://example.com/', Router::url('/', true)); + $this->assertSame('https://example.com', Configure::read('App.fullBaseUrl')); + } + + /** + * Test that Router uses App.base to build URL's when there are no stored + * request objects. + */ + public function testBaseUrlWithBasePath(): void + { + Configure::write('App.base', '/cakephp'); + Router::fullBaseUrl('http://example.com'); + $this->assertSame('http://example.com/cakephp/tasks', Router::url('/tasks', true)); + } + + /** + * Test that Router uses App.base to build URL's when there are no stored + * request objects. + */ + public function testBaseUrlWithBasePathArrayUrl(): void + { + Configure::write('App.base', '/cakephp'); + Router::fullBaseUrl('http://example.com'); + Router::createRouteBuilder('/') + ->scope('/', function (RouteBuilder $routes): void { + $routes->get('/{controller}', ['action' => 'index']); + }); + + $out = Router::url([ + 'controller' => 'Tasks', + 'action' => 'index', + '_method' => 'GET', + ], true); + $this->assertSame('http://example.com/cakephp/Tasks', $out); + + $out = Router::url([ + 'controller' => 'Tasks', + 'action' => 'index', + '_base' => false, + '_method' => 'GET', + ], true); + $this->assertSame('http://example.com/Tasks', $out); + } + + /** + * Test that Router uses the correct url including base path for requesting the current actions. + */ + public function testCurrentUrlWithBasePath(): void + { + Router::fullBaseUrl('http://example.com'); + $request = new ServerRequest([ + 'params' => [ + 'action' => 'view', + 'plugin' => null, + 'controller' => 'Pages', + 'pass' => ['1'], + ], + 'here' => '/cakephp', + 'url' => '/cakephp/pages/view/1', + ]); + Router::setRequest($request); + $this->assertSame('http://example.com/cakephp/pages/view/1', Router::url(null, true)); + $this->assertSame('/cakephp/pages/view/1', Router::url()); + } + + /** + * Test that full base URL can be generated from request context too if + * App.fullBaseUrl is not set. + */ + public function testFullBaseURLFromRequest(): void + { + Configure::write('App.fullBaseUrl', false); + $server = [ + 'HTTP_HOST' => 'cake.local', + ]; + + $request = ServerRequestFactory::fromGlobals($server); + Router::setRequest($request); + $this->assertSame('http://cake.local', Router::fullBaseUrl()); + } + + /** + * testRouteExists method + */ + public function testRouteExists(): void + { + Router::createRouteBuilder('/') + ->connect('/posts/{action}', ['controller' => 'Posts']); + $this->assertTrue(Router::routeExists(['controller' => 'Posts', 'action' => 'view'])); + + $this->assertFalse(Router::routeExists(['action' => 'view', 'controller' => 'Users', 'plugin' => 'test'])); + } + + /** + * testMultipleResourceRoute method + */ + public function testMultipleResourceRoute(): void + { + Router::createRouteBuilder('/') + ->connect('/{controller}', ['action' => 'index', '_method' => ['GET', 'POST']]); + + $result = Router::parseRequest($this->makeRequest('/Posts', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'index', + '_method' => ['GET', 'POST'], + '_matchedRoute' => '/{controller}', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/Posts', 'POST')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'index', + '_method' => ['GET', 'POST'], + '_matchedRoute' => '/{controller}', + ]; + $this->assertEquals($expected, $result); + } + + /** + * testGenerateUrlResourceRoute method + */ + public function testGenerateUrlResourceRoute(): void + { + Router::createRouteBuilder('/') + ->scope('/', function (RouteBuilder $routes): void { + $routes->resources('Posts'); + }); + + $result = Router::url([ + 'controller' => 'Posts', + 'action' => 'index', + '_method' => 'GET', + ]); + $expected = '/posts'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'controller' => 'Posts', + 'action' => 'view', + '_method' => 'GET', + 'id' => '10', + ]); + $expected = '/posts/10'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Posts', 'action' => 'add', '_method' => 'POST']); + $expected = '/posts'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Posts', 'action' => 'edit', '_method' => 'PUT', 'id' => '10']); + $expected = '/posts/10'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'controller' => 'Posts', + 'action' => 'delete', + '_method' => 'DELETE', + 'id' => '10', + ]); + $expected = '/posts/10'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'controller' => 'Posts', + 'action' => 'edit', + '_method' => 'PATCH', + 'id' => '10', + ]); + $expected = '/posts/10'; + $this->assertSame($expected, $result); + } + + /** + * testUrlNormalization method + */ + public function testUrlNormalization(): void + { + Router::createRouteBuilder('/') + ->connect('/{controller}/{action}'); + + $expected = '/users/logout'; + + $result = Router::normalize('/users/logout/'); + $this->assertSame($expected, $result); + + $result = Router::normalize('//users//logout//'); + $this->assertSame($expected, $result); + + $result = Router::normalize('users/logout'); + $this->assertSame($expected, $result); + + $expected = '/Users/logout'; + $result = Router::normalize(['controller' => 'Users', 'action' => 'logout']); + $this->assertSame($expected, $result); + + $result = Router::normalize('/'); + $this->assertSame('/', $result); + + $result = Router::normalize('http://google.com/'); + $this->assertSame('http://google.com/', $result); + + $result = Router::normalize('http://google.com//'); + $this->assertSame('http://google.com//', $result); + + $result = Router::normalize('/users/login/scope://foo'); + $this->assertSame('/users/login/scope:/foo', $result); + + $result = Router::normalize('/recipe/recipes/add'); + $this->assertSame('/recipe/recipes/add', $result); + + $request = new ServerRequest(['base' => '/us']); + Router::setRequest($request); + $result = Router::normalize('/us/users/logout/'); + $this->assertSame('/users/logout', $result); + + Router::reload(); + + $request = new ServerRequest(['base' => '/cake_12']); + Router::setRequest($request); + $result = Router::normalize('/cake_12/users/logout/'); + $this->assertSame('/users/logout', $result); + + Router::reload(); + $_back = Configure::read('App.fullBaseUrl'); + Configure::write('App.fullBaseUrl', '/'); + + $request = new ServerRequest(); + Router::setRequest($request); + $result = Router::normalize('users/login'); + $this->assertSame('/users/login', $result); + Configure::write('App.fullBaseUrl', $_back); + + Router::reload(); + $request = new ServerRequest(['base' => 'beer']); + Router::setRequest($request); + $result = Router::normalize('beer/admin/beers_tags/add'); + $this->assertSame('/admin/beers_tags/add', $result); + + $result = Router::normalize('/admin/beers_tags/add'); + $this->assertSame('/admin/beers_tags/add', $result); + } + + /** + * Test generating urls with base paths. + */ + public function testUrlGenerationWithBasePath(): void + { + Router::createRouteBuilder('/') + ->connect('/{controller}/{action}/*'); + $request = new ServerRequest([ + 'params' => [ + 'action' => 'index', + 'plugin' => null, + 'controller' => 'Subscribe', + ], + 'url' => '/subscribe', + 'base' => '/magazine', + 'webroot' => '/magazine/', + ]); + Router::setRequest($request); + + $result = Router::url(); + $this->assertSame('/magazine/subscribe', $result); + + $result = Router::url([]); + $this->assertSame('/magazine/subscribe', $result); + + $result = Router::url('/'); + $this->assertSame('/magazine/', $result); + + $result = Router::url('/articles/'); + $this->assertSame('/magazine/articles/', $result); + + $result = Router::url('/articles::index'); + $this->assertSame('/magazine/articles::index', $result); + + $result = Router::url('/articles/view'); + $this->assertSame('/magazine/articles/view', $result); + + $result = Router::url(['controller' => 'Articles', 'action' => 'view', 1]); + $this->assertSame('/magazine/Articles/view/1', $result); + } + + /** + * Test url() with _host option routes with request context + */ + public function testUrlGenerationHostOptionRequestContext(): void + { + $server = [ + 'HTTP_HOST' => 'foo.example.com', + 'DOCUMENT_ROOT' => '/Users/markstory/Sites', + 'SCRIPT_FILENAME' => '/Users/markstory/Sites/subdir/webroot/index.php', + 'PHP_SELF' => '/subdir/webroot/index.php/articles/view/1', + 'REQUEST_URI' => '/subdir/articles/view/1', + 'QUERY_STRING' => '', + 'SERVER_PORT' => 80, + ]; + + Router::createRouteBuilder('/') + ->connect('/fallback', ['controller' => 'Articles'], ['_host' => '*.example.com']); + $request = ServerRequestFactory::fromGlobals($server); + Router::setRequest($request); + + $result = Router::url(['controller' => 'Articles', 'action' => 'index']); + $this->assertSame('http://foo.example.com/subdir/fallback', $result); + + $result = Router::url(['controller' => 'Articles', 'action' => 'index'], true); + $this->assertSame('http://foo.example.com/subdir/fallback', $result); + } + + /** + * Test that catch all routes work with a variety of falsey inputs. + */ + public function testUrlCatchAllRoute(): void + { + Router::createRouteBuilder('/') + ->connect('/*', ['controller' => 'Categories', 'action' => 'index']); + $result = Router::url(['controller' => 'Categories', 'action' => 'index', '0']); + $this->assertSame('/0', $result); + + $expected = [ + 'plugin' => null, + 'controller' => 'Categories', + 'action' => 'index', + 'pass' => ['0'], + '_matchedRoute' => '/*', + ]; + $result = Router::parseRequest($this->makeRequest('/0', 'GET')); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('0', 'GET')); + unset($result['_route']); + $this->assertEquals($expected, $result); + } + + /** + * test generation of basic urls. + */ + public function testUrlGenerationBasic(): void + { + /** + * @var string $ID + * @var string $UUID + * @var string $Year + * @var string $Month + * @var string $Action + */ + extract(Router::getNamedExpressions()); + + $routes = Router::createRouteBuilder('/'); + $routes->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + $out = Router::url(['controller' => 'Pages', 'action' => 'display', 'home']); + $this->assertSame('/', $out); + + $routes->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + $result = Router::url(['controller' => 'Pages', 'action' => 'display', 'about']); + $expected = '/pages/about'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{plugin}/{id}/*', ['controller' => 'Posts', 'action' => 'view'], ['id' => $ID]); + + $result = Router::url([ + 'plugin' => 'CakePlugin', + 'controller' => 'Posts', + 'action' => 'view', + 'id' => '1', + ]); + $expected = '/CakePlugin/1'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'plugin' => 'CakePlugin', + 'controller' => 'Posts', + 'action' => 'view', + 'id' => '1', + '0', + ]); + $expected = '/CakePlugin/1/0'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'plugin' => 'CakePlugin', + 'controller' => 'Posts', + 'action' => 'view', + 'id' => '1', + null, + ]); + $expected = '/CakePlugin/1'; + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}/{id}', [], ['id' => $ID]); + + $result = Router::url(['controller' => 'Posts', 'action' => 'view', 'id' => '1']); + $expected = '/Posts/view/1'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{id}', ['action' => 'view']); + + $result = Router::url(['controller' => 'Posts', 'action' => 'view', 'id' => '1']); + $expected = '/Posts/1'; + $this->assertSame($expected, $result); + + $routes->connect('/view/*', ['controller' => 'Posts', 'action' => 'view']); + $result = Router::url(['controller' => 'Posts', 'action' => 'view', '1']); + $expected = '/view/1'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}'); + $request = new ServerRequest([ + 'params' => [ + 'action' => 'index', + 'plugin' => null, + 'controller' => 'Users', + ], + ]); + Router::setRequest($request); + + $result = Router::url(['action' => 'login']); + $expected = '/Users/login'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/contact/{action}', ['plugin' => 'Contact', 'controller' => 'Contact']); + + $result = Router::url([ + 'plugin' => 'Contact', + 'controller' => 'Contact', + 'action' => 'me', + ]); + + $expected = '/contact/me'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}', ['action' => 'index']); + $request = new ServerRequest([ + 'params' => [ + 'action' => 'index', + 'plugin' => 'Myplugin', + 'controller' => 'Mycontroller', + ], + ]); + Router::setRequest($request); + + $result = Router::url(['plugin' => null, 'controller' => 'Myothercontroller']); + $expected = '/Myothercontroller'; + $this->assertSame($expected, $result); + } + + /** + * Test that generated names for routes are case-insensitive. + */ + public function testRouteNameCasing(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/articles/{id}', ['controller' => 'Articles', 'action' => 'view']); + $routes->connect('/{controller}/{action}/*', [], ['routeClass' => 'InflectedRoute']); + $result = Router::url(['controller' => 'Articles', 'action' => 'view', 'id' => 10]); + $expected = '/articles/10'; + $this->assertSame($expected, $result); + } + + /** + * Test generation of routes with query string parameters. + */ + public function testUrlGenerationWithQueryStrings(): void + { + Router::createRouteBuilder('/')->connect('/{controller}/{action}/*'); + + $result = Router::url([ + 'controller' => 'Posts', + '0', + '?' => ['var' => 'test', 'var2' => 'test2'], + ]); + $expected = '/Posts/index/0?var=test&var2=test2'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Posts', '0', '?' => ['var' => null]]); + $this->assertSame('/Posts/index/0', $result); + + $result = Router::url([ + 'controller' => 'Posts', + '0', + '?' => [ + 'var' => 'test', + 'var2' => 'test2', + ], + '#' => 'unencoded string %', + ]); + $expected = '/Posts/index/0?var=test&var2=test2#unencoded string %'; + $this->assertSame($expected, $result); + } + + /** + * test that regex validation of keyed route params is working. + */ + public function testUrlGenerationWithRegexQualifiedParams(): void + { + $routes = Router::createRouteBuilder('/'); + + $routes->connect( + '{language}/galleries', + ['controller' => 'Galleries', 'action' => 'index'], + ['language' => '[a-z]{3}'], + ); + + $routes->connect( + '/{language}/{admin}/{controller}/{action}/*', + ['admin' => 'admin'], + ['language' => '[a-z]{3}', 'admin' => 'admin'], + ); + + $routes->connect( + '/{language}/{controller}/{action}/*', + [], + ['language' => '[a-z]{3}'], + ); + + $result = Router::url(['admin' => false, 'language' => 'dan', 'action' => 'index', 'controller' => 'Galleries']); + $expected = '/dan/galleries'; + $this->assertSame($expected, $result); + + $result = Router::url(['admin' => false, 'language' => 'eng', 'action' => 'index', 'controller' => 'Galleries']); + $expected = '/eng/galleries'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/{language}/pages', + ['controller' => 'Pages', 'action' => 'index'], + ['language' => '[a-z]{3}'], + ); + $routes->connect('/{language}/{controller}/{action}/*', [], ['language' => '[a-z]{3}']); + + $result = Router::url(['language' => 'eng', 'action' => 'index', 'controller' => 'Pages']); + $expected = '/eng/pages'; + $this->assertSame($expected, $result); + + $result = Router::url(['language' => 'eng', 'controller' => 'Pages']); + $this->assertSame($expected, $result); + + $result = Router::url(['language' => 'eng', 'controller' => 'Pages', 'action' => 'add']); + $expected = '/eng/Pages/add'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/forestillinger/{month}/{year}/*', + ['plugin' => 'Shows', 'controller' => 'Shows', 'action' => 'calendar'], + ['month' => '0[1-9]|1[012]', 'year' => '[12][0-9]{3}'], + ); + + $result = Router::url([ + 'plugin' => 'Shows', + 'controller' => 'Shows', + 'action' => 'calendar', + 'month' => '10', + 'year' => '2007', + 'min-forestilling', + ]); + $expected = '/forestillinger/10/2007/min-forestilling'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/kalender/{month}/{year}/*', + ['plugin' => 'Shows', 'controller' => 'Shows', 'action' => 'calendar'], + ['month' => '0[1-9]|1[012]', 'year' => '[12][0-9]{3}'], + ); + $routes->connect('/kalender/*', ['plugin' => 'Shows', 'controller' => 'Shows', 'action' => 'calendar']); + + $result = Router::url(['plugin' => 'Shows', 'controller' => 'Shows', 'action' => 'calendar', 'min-forestilling']); + $expected = '/kalender/min-forestilling'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'plugin' => 'Shows', + 'controller' => 'Shows', + 'action' => 'calendar', + 'year' => '2007', + 'month' => '10', + 'min-forestilling', + ]); + $expected = '/kalender/10/2007/min-forestilling'; + $this->assertSame($expected, $result); + } + + /** + * Test URL generation with an admin prefix + */ + public function testUrlGenerationWithPrefix(): void + { + $routes = Router::createRouteBuilder('/'); + + $routes->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + $routes->connect('/reset/*', ['admin' => true, 'controller' => 'Users', 'action' => 'reset']); + $routes->connect('/tests', ['controller' => 'Tests', 'action' => 'index']); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + Router::extensions('rss', false); + + $request = new ServerRequest([ + 'params' => [ + 'controller' => 'Registrations', + 'action' => 'index', + 'plugin' => null, + 'prefix' => 'Admin', + '_ext' => 'html', + ], + 'url' => '/admin/registrations/index', + ]); + Router::setRequest($request); + + $result = Router::url([]); + $expected = '/admin/registrations/index'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/subscriptions/{action}/*', ['controller' => 'Subscribe', 'prefix' => 'Admin']); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + + $request = new ServerRequest([ + 'params' => [ + 'action' => 'index', + 'plugin' => null, + 'controller' => 'Subscribe', + 'prefix' => 'Admin', + ], + 'webroot' => '/magazine/', + 'base' => '/magazine', + 'url' => '/admin/subscriptions/edit/1', + ]); + Router::setRequest($request); + + $result = Router::url(['action' => 'edit', 1]); + $expected = '/magazine/admin/subscriptions/edit/1'; + $this->assertSame($expected, $result); + + $result = Router::url(['prefix' => 'Admin', 'controller' => 'Users', 'action' => 'login']); + $expected = '/magazine/admin/Users/login'; + $this->assertSame($expected, $result); + + Router::reload(); + + $request = new ServerRequest([ + 'params' => [ + 'prefix' => 'Admin', + 'action' => 'index', + 'plugin' => null, + 'controller' => 'Users', + ], + 'webroot' => '/', + 'url' => '/admin/users/index', + ]); + Router::setRequest($request); + + $routes = Router::createRouteBuilder('/'); + $routes->connect('/page/*', ['controller' => 'Pages', 'action' => 'view', 'prefix' => 'Admin']); + + $result = Router::url(['prefix' => 'Admin', 'controller' => 'Pages', 'action' => 'view', 'my-page']); + $expected = '/page/my-page'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'add', + 'prefix' => 'Admin', + ], + 'webroot' => '/', + 'url' => '/admin/pages/add', + ]); + Router::setRequest($request); + + $result = Router::url(['plugin' => null, 'controller' => 'Pages', 'action' => 'add', 'id' => false]); + $expected = '/admin/Pages/add'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'add', + 'prefix' => 'Admin', + ], + 'webroot' => '/', + 'url' => '/admin/pages/add', + ]); + Router::setRequest($request); + + $result = Router::url(['plugin' => null, 'controller' => 'Pages', 'action' => 'add', 'id' => false]); + $expected = '/admin/Pages/add'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/{controller}/{action}/{id}', ['prefix' => 'Admin'], ['id' => '[0-9]+']); + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'edit', + 'pass' => ['284'], + 'prefix' => 'Admin', + ], + 'url' => '/admin/pages/edit/284', + ]); + Router::setRequest($request); + + $result = Router::url(['plugin' => null, 'controller' => 'Pages', 'action' => 'edit', 'id' => '284']); + $expected = '/admin/Pages/edit/284'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, 'controller' => 'Pages', 'action' => 'add', 'prefix' => 'Admin', + ], + 'url' => '/admin/pages/add', + ]); + Router::setRequest($request); + + $result = Router::url(['plugin' => null, 'controller' => 'Pages', 'action' => 'add', 'id' => false]); + $expected = '/admin/Pages/add'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, 'controller' => 'Pages', 'action' => 'edit', 'prefix' => 'Admin', + ], + 'url' => '/admin/pages/edit/284', + ]); + Router::setRequest($request); + + $result = Router::url(['plugin' => null, 'controller' => 'Pages', 'action' => 'edit', 284]); + $expected = '/admin/Pages/edit/284'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/posts/*', ['controller' => 'Posts', 'action' => 'index', 'prefix' => 'Admin']); + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, 'controller' => 'Posts', 'action' => 'index', 'prefix' => 'Admin', + 'pass' => ['284'], + ], + 'url' => '/admin/pages/edit/284', + ]); + Router::setRequest($request); + + $result = Router::url(['all']); + $expected = '/admin/posts/all'; + $this->assertSame($expected, $result); + } + + /** + * Test URL generation inside a prefixed plugin. + */ + public function testUrlGenerationPrefixedPlugin(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->prefix('admin', function (RouteBuilder $routes): void { + $routes->plugin('MyPlugin', function (RouteBuilder $routes): void { + $routes->fallbacks('InflectedRoute'); + }); + }); + $result = Router::url([ + 'prefix' => 'Admin', + 'plugin' => 'MyPlugin', + 'controller' => 'Forms', + 'action' => 'edit', + 2, + ]); + $expected = '/admin/my-plugin/forms/edit/2'; + $this->assertSame($expected, $result); + } + + /** + * Test URL generation with multiple prefixes. + */ + public function testUrlGenerationMultiplePrefixes(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->prefix('admin', function (RouteBuilder $routes): void { + $routes->prefix('backoffice', function (RouteBuilder $routes): void { + $routes->fallbacks('InflectedRoute'); + }); + }); + $result = Router::url([ + 'prefix' => 'Admin/Backoffice', + 'controller' => 'Dashboards', + 'action' => 'home', + ]); + $expected = '/admin/backoffice/dashboards/home'; + $this->assertSame($expected, $result); + } + + /** + * testUrlGenerationWithExtensions method + */ + public function testUrlGenerationWithExtensions(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}', ['action' => 'index']); + $routes->connect('/{controller}/{action}'); + + $result = Router::url([ + 'plugin' => null, + 'controller' => 'Articles', + 'action' => 'add', + 'id' => null, + '_ext' => 'json', + ]); + $expected = '/Articles/add.json'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'plugin' => null, + 'controller' => 'Articles', + 'action' => 'add', + '_ext' => 'json', + ]); + $expected = '/Articles/add.json'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'plugin' => null, + 'controller' => 'Articles', + 'action' => 'index', + 'id' => null, + '_ext' => 'json', + ]); + $expected = '/Articles.json'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'plugin' => null, + 'controller' => 'Articles', + 'action' => 'index', + '?' => ['id' => 'testing'], + '_ext' => 'json', + ]); + $expected = '/Articles.json?id=testing'; + $this->assertSame($expected, $result); + } + + /** + * test url() when the current request has an extension. + */ + public function testUrlGenerationWithExtensionInCurrentRequest(): void + { + Router::extensions('rss'); + $routes = Router::createRouteBuilder('/'); + $routes->fallbacks('InflectedRoute'); + $request = new ServerRequest([ + 'params' => ['plugin' => null, 'controller' => 'Tasks', 'action' => 'index', '_ext' => 'rss'], + ]); + Router::setRequest($request); + + $result = Router::url([ + 'controller' => 'Tasks', + 'action' => 'view', + 1, + ]); + $expected = '/tasks/view/1'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'controller' => 'Tasks', + 'action' => 'view', + 1, + '_ext' => 'json', + ]); + $expected = '/tasks/view/1.json'; + $this->assertSame($expected, $result); + } + + /** + * Test url generation with named routes. + */ + public function testUrlGenerationNamedRoute(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/users', + ['controller' => 'Users', 'action' => 'index'], + ['_name' => 'users-index'], + ); + $routes->connect( + '/users/{name}', + ['controller' => 'Users', 'action' => 'view'], + ['_name' => 'test'], + ); + $routes->connect( + '/view/*', + ['action' => 'view'], + ['_name' => 'Articles::view'], + ); + + $url = Router::url(['_name' => 'test', 'name' => 'mark']); + $this->assertSame('/users/mark', $url); + + $url = Router::url([ + '_name' => 'test', 'name' => 'mark', + '?' => ['page' => 1, 'sort' => 'title', 'dir' => 'desc', ], + ]); + $this->assertSame('/users/mark?page=1&sort=title&dir=desc', $url); + + $url = Router::url(['_name' => 'Articles::view']); + $this->assertSame('/view/', $url); + + $url = Router::url(['_name' => 'Articles::view', '1']); + $this->assertSame('/view/1', $url); + + $url = Router::url(['_name' => 'Articles::view', '_full' => true, '1']); + $this->assertSame('http://localhost/view/1', $url); + + $url = Router::url(['_name' => 'Articles::view', '1', '#' => 'frag']); + $this->assertSame('/view/1#frag', $url); + } + + /** + * Test that using invalid names causes exceptions. + */ + public function testNamedRouteException(): void + { + $this->expectException(MissingRouteException::class); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/users/{name}', + ['controller' => 'Users', 'action' => 'view'], + ['_name' => 'test'], + ); + Router::url(['_name' => 'junk', 'name' => 'mark']); + } + + /** + * Test that using duplicate names causes exceptions. + */ + public function testDuplicateNamedRouteException(): void + { + $this->expectException(DuplicateNamedRouteException::class); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/users/{name}', + ['controller' => 'Users', 'action' => 'view'], + ['_name' => 'test'], + ); + $routes->connect( + '/users/{name}', + ['controller' => 'Users', 'action' => 'view'], + ['_name' => 'otherName'], + ); + $routes->connect( + '/users/{name}', + ['controller' => 'Users', 'action' => 'view'], + ['_name' => 'test'], + ); + } + + /** + * Test that url filters are applied to url params. + */ + public function testUrlGenerationWithUrlFilter(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*'); + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, + 'lang' => 'en', + 'controller' => 'Posts', + 'action' => 'index', + ], + ]); + Router::setRequest($request); + + $calledCount = 0; + Router::addUrlFilter(function ($url, $request) use (&$calledCount) { + $calledCount++; + $url['lang'] = $request->getParam('lang'); + + return $url; + }); + Router::addUrlFilter(function ($url, $request) use (&$calledCount) { + $calledCount++; + $url[] = '1234'; + + return $url; + }); + $result = Router::url(['controller' => 'Tasks', 'action' => 'edit']); + $this->assertSame('/en/Tasks/edit/1234', $result); + $this->assertSame(2, $calledCount); + } + + /** + * Test that url filter failure gives better errors + */ + public function testUrlGenerationWithUrlFilterFailureClosure(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessageMatches( + '/URL filter defined in .*RouterTest\.php on line \d+ could not be applied\.' . + ' The filter failed with: nope/', + ); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*'); + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, + 'lang' => 'en', + 'controller' => 'Posts', + 'action' => 'index', + ], + ]); + Router::setRequest($request); + + Router::addUrlFilter(function ($url, $request): void { + throw new RuntimeException('nope'); + }); + Router::url(['controller' => 'Posts', 'action' => 'index', 'lang' => 'en']); + } + + /** + * Test that url filter failure gives better errors + */ + public function testUrlGenerationWithUrlFilterFailureMethod(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessageMatches( + '/URL filter defined in .*RouterTest\.php on line \d+ could not be applied\.' . + ' The filter failed with: /', + ); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*'); + $request = new ServerRequest([ + 'params' => [ + 'plugin' => null, + 'lang' => 'en', + 'controller' => 'Posts', + 'action' => 'index', + ], + ]); + Router::setRequest($request); + + Router::addUrlFilter(function (): void { + throw new Exception(); + }); + Router::url(['controller' => 'Posts', 'action' => 'index', 'lang' => 'en']); + } + + /** + * Testing stub for broken URL filters. + * + * @throws \RuntimeException + */ + public function badFilter(): void + { + throw new RuntimeException('nope'); + } + + /** + * Test url param persistence. + */ + public function testUrlParamPersistence(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*', [], ['persist' => ['lang']]); + $request = new ServerRequest([ + 'url' => '/en/posts/index', + 'params' => [ + 'plugin' => null, + 'lang' => 'en', + 'controller' => 'Posts', + 'action' => 'index', + ], + ]); + Router::setRequest($request); + + $result = Router::url(['controller' => 'Tasks', 'action' => 'edit', '1234']); + $this->assertSame('/en/Tasks/edit/1234', $result); + } + + /** + * Test that plain strings urls work + */ + public function testUrlGenerationPlainString(): void + { + $mailto = 'mailto:mark@example.com'; + $result = Router::url($mailto); + $this->assertSame($mailto, $result); + + $js = 'javascript:alert("hi")'; + $result = Router::url($js); + $this->assertSame($js, $result); + + $hash = '#first'; + $result = Router::url($hash); + $this->assertSame($hash, $result); + } + + /** + * test that you can leave active plugin routes with plugin = null + */ + public function testCanLeavePlugin(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/{controller}', ['action' => 'index', 'prefix' => 'Admin']); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + $request = new ServerRequest([ + 'url' => '/admin/this/interesting/index', + 'params' => [ + 'pass' => [], + 'prefix' => 'Admin', + 'plugin' => 'this', + 'action' => 'index', + 'controller' => 'Interesting', + ], + ]); + Router::setRequest($request); + $result = Router::url(['plugin' => null, 'controller' => 'Posts', 'action' => 'index']); + $this->assertSame('/admin/Posts', $result); + } + + /** + * testUrlParsing method + */ + public function testUrlParsing(): void + { + /** + * @var string $ID + * @var string $UUID + * @var string $Year + * @var string $Month + * @var string $Day + * @var string $Action + */ + extract(Router::getNamedExpressions()); + + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/posts/{value}/{somevalue}/{othervalue}/*', + ['controller' => 'Posts', 'action' => 'view'], + ['value', 'somevalue', 'othervalue'], + ); + $result = Router::parseRequest($this->makeRequest('/posts/2007/08/01/title-of-post-here', 'GET')); + unset($result['_route']); + $expected = [ + 'value' => '2007', + 'somevalue' => '08', + 'othervalue' => '01', + 'controller' => 'Posts', + 'action' => 'view', + 'plugin' => null, + 'pass' => ['0' => 'title-of-post-here'], + '_matchedRoute' => '/posts/{value}/{somevalue}/{othervalue}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/posts/{year}/{month}/{day}/*', + ['controller' => 'Posts', 'action' => 'view'], + ['year' => $Year, 'month' => $Month, 'day' => $Day], + ); + $result = Router::parseRequest($this->makeRequest('/posts/2007/08/01/title-of-post-here', 'GET')); + unset($result['_route']); + $expected = [ + 'year' => '2007', + 'month' => '08', + 'day' => '01', + 'controller' => 'Posts', + 'action' => 'view', + 'plugin' => null, + 'pass' => ['0' => 'title-of-post-here'], + '_matchedRoute' => '/posts/{year}/{month}/{day}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/posts/{day}/{year}/{month}/*', + ['controller' => 'Posts', 'action' => 'view'], + ['year' => $Year, 'month' => $Month, 'day' => $Day], + ); + $result = Router::parseRequest($this->makeRequest('/posts/01/2007/08/title-of-post-here', 'GET')); + unset($result['_route']); + $expected = [ + 'day' => '01', + 'year' => '2007', + 'month' => '08', + 'controller' => 'Posts', + 'action' => 'view', + 'plugin' => null, + 'pass' => ['0' => 'title-of-post-here'], + '_matchedRoute' => '/posts/{day}/{year}/{month}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/posts/{month}/{day}/{year}/*', + ['controller' => 'Posts', 'action' => 'view'], + ['year' => $Year, 'month' => $Month, 'day' => $Day], + ); + $result = Router::parseRequest($this->makeRequest('/posts/08/01/2007/title-of-post-here', 'GET')); + unset($result['_route']); + $expected = [ + 'month' => '08', + 'day' => '01', + 'year' => '2007', + 'controller' => 'Posts', + 'action' => 'view', + 'plugin' => null, + 'pass' => ['0' => 'title-of-post-here'], + '_matchedRoute' => '/posts/{month}/{day}/{year}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/posts/{year}/{month}/{day}/*', + ['controller' => 'Posts', 'action' => 'view'], + ); + $result = Router::parseRequest($this->makeRequest('/posts/2007/08/01/title-of-post-here', 'GET')); + unset($result['_route']); + $expected = [ + 'year' => '2007', + 'month' => '08', + 'day' => '01', + 'controller' => 'Posts', + 'action' => 'view', + 'plugin' => null, + 'pass' => ['0' => 'title-of-post-here'], + '_matchedRoute' => '/posts/{year}/{month}/{day}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/', ['routeClass' => 'InflectedRoute'])->fallbacks(); + $result = Router::parseRequest($this->makeRequest('/pages/display/home', 'GET')); + unset($result['_route']); + $expected = [ + 'plugin' => null, + 'pass' => ['home'], + 'controller' => 'Pages', + 'action' => 'display', + '_matchedRoute' => '/{controller}/{action}/*', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('pages/display/home/', 'GET')); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('pages/display/home', 'GET')); + unset($result['_route']); + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/page/*', ['controller' => 'Test']); + $result = Router::parseRequest($this->makeRequest('/page/my-page', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['my-page'], + 'plugin' => null, + 'controller' => 'Test', + 'action' => 'index', + '_matchedRoute' => '/page/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/{language}/contact', + ['language' => 'eng', 'plugin' => 'Contact', 'controller' => 'Contact', 'action' => 'index'], + ['language' => '[a-z]{3}'], + ); + $result = Router::parseRequest($this->makeRequest('/eng/contact', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'language' => 'eng', + 'plugin' => 'Contact', + 'controller' => 'Contact', + 'action' => 'index', + '_matchedRoute' => '/{language}/contact', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/forestillinger/{month}/{year}/*', + ['plugin' => 'Shows', 'controller' => 'Shows', 'action' => 'calendar'], + ['month' => '0[1-9]|1[012]', 'year' => '[12][0-9]{3}'], + ); + + $result = Router::parseRequest($this->makeRequest('/forestillinger/10/2007/min-forestilling', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['min-forestilling'], + 'plugin' => 'Shows', + 'controller' => 'Shows', + 'action' => 'calendar', + 'year' => 2007, + 'month' => 10, + '_matchedRoute' => '/forestillinger/{month}/{year}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}/*'); + $routes->connect('/', ['plugin' => 'pages', 'controller' => 'Pages', 'action' => 'display']); + $result = Router::parseRequest($this->makeRequest('/', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'controller' => 'Pages', + 'action' => 'display', + 'plugin' => 'pages', + '_matchedRoute' => '/', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/Posts/edit/0', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [0], + 'controller' => 'Posts', + 'action' => 'edit', + 'plugin' => null, + '_matchedRoute' => '/{controller}/{action}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/Posts/{id}:{url_title}', + ['controller' => 'Posts', 'action' => 'view'], + ['pass' => ['id', 'url_title'], 'id' => '[\d]+'], + ); + $result = Router::parseRequest($this->makeRequest('/Posts/5:sample-post-title', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['5', 'sample-post-title'], + 'id' => '5', + 'url_title' => 'sample-post-title', + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'view', + '_matchedRoute' => '/Posts/{id}:{url_title}', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/Posts/{id}:{url_title}/*', + ['controller' => 'Posts', 'action' => 'view'], + ['pass' => ['id', 'url_title'], 'id' => '[\d]+'], + ); + $result = Router::parseRequest($this->makeRequest('/Posts/5:sample-post-title/other/params/4', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['5', 'sample-post-title', 'other', 'params', '4'], + 'id' => 5, + 'url_title' => 'sample-post-title', + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'view', + '_matchedRoute' => '/Posts/{id}:{url_title}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/posts/view/*', ['controller' => 'Posts', 'action' => 'view']); + $result = Router::parseRequest($this->makeRequest('/posts/view/10?id=123&tab=abc', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [10], + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'view', + '?' => ['id' => '123', 'tab' => 'abc'], + '_matchedRoute' => '/posts/view/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/posts/{url_title}-(uuid:{id})', + ['controller' => 'Posts', 'action' => 'view'], + ['pass' => ['id', 'url_title'], 'id' => $UUID], + ); + $result = Router::parseRequest($this->makeRequest('/posts/sample-post-title-(uuid:47fc97a9-019c-41d1-a058-1fa3cbdd56cb)', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['47fc97a9-019c-41d1-a058-1fa3cbdd56cb', 'sample-post-title'], + 'id' => '47fc97a9-019c-41d1-a058-1fa3cbdd56cb', + 'url_title' => 'sample-post-title', + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'view', + '_matchedRoute' => '/posts/{url_title}-(uuid:{id})', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/posts/view/*', ['controller' => 'Posts', 'action' => 'view']); + $result = Router::parseRequest($this->makeRequest('/posts/view/foo:bar/routing:fun', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['foo:bar', 'routing:fun'], + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'view', + '_matchedRoute' => '/posts/view/*', + ]; + $this->assertEquals($expected, $result); + } + + /** + * test parseRequest + */ + public function testParseRequest(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/articles/{action}/*', ['controller' => 'Articles']); + $request = new ServerRequest(['url' => '/articles/view/1']); + $result = Router::parseRequest($request); + unset($result['_route']); + $expected = [ + 'pass' => ['1'], + 'plugin' => null, + 'controller' => 'Articles', + 'action' => 'view', + '_matchedRoute' => '/articles/{action}/*', + ]; + $this->assertEquals($expected, $result); + } + + /** + * testUuidRoutes method + */ + public function testUuidRoutes(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/subjects/add/{category_id}', + ['controller' => 'Subjects', 'action' => 'add'], + ['category_id' => '\w{8}-\w{4}-\w{4}-\w{4}-\w{12}'], + ); + $result = Router::parseRequest($this->makeRequest('/subjects/add/4795d601-19c8-49a6-930e-06a8b01d17b7', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'category_id' => '4795d601-19c8-49a6-930e-06a8b01d17b7', + 'plugin' => null, + 'controller' => 'Subjects', + 'action' => 'add', + '_matchedRoute' => '/subjects/add/{category_id}', + ]; + $this->assertEquals($expected, $result); + } + + /** + * testRouteSymmetry method + */ + public function testRouteSymmetry(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/{extra}/page/{slug}/*', + ['controller' => 'Pages', 'action' => 'view', 'extra' => null], + ['extra' => '[a-z1-9_]*', 'slug' => '[a-z1-9_]+', 'action' => 'view'], + ); + + $result = Router::parseRequest($this->makeRequest('/some_extra/page/this_is_the_slug', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'view', + 'slug' => 'this_is_the_slug', + 'extra' => 'some_extra', + '_matchedRoute' => '/{extra}/page/{slug}/*', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/page/this_is_the_slug', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'view', + 'slug' => 'this_is_the_slug', + 'extra' => null, + '_matchedRoute' => '/{extra}/page/{slug}/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/{extra}/page/{slug}/*', + ['controller' => 'Pages', 'action' => 'view', 'extra' => null], + ['extra' => '[a-z1-9_]*', 'slug' => '[a-z1-9_]+'], + ); + + $result = Router::url([ + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'view', + 'slug' => 'this_is_the_slug', + 'extra' => null, + ]); + $expected = '/page/this_is_the_slug'; + $this->assertSame($expected, $result); + + $result = Router::url([ + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'view', + 'slug' => 'this_is_the_slug', + 'extra' => 'some_extra', + ]); + $expected = '/some_extra/page/this_is_the_slug'; + $this->assertSame($expected, $result); + } + + /** + * Test exceptions when parsing fails. + */ + public function testParseError(): void + { + $this->expectException(MissingRouteException::class); + + $routes = Router::createRouteBuilder('/'); + $routes->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + Router::parseRequest($this->makeRequest('/nope', 'GET')); + } + + /** + * Test parse and reverse symmetry + */ + #[DataProvider('parseReverseSymmetryData')] + public function testParseReverseSymmetry(string $url): void + { + Router::createRouteBuilder('/')->fallbacks(); + $this->assertSame($url, Router::reverse(Router::parseRequest($this->makeRequest($url, 'GET')) + ['url' => []])); + } + + /** + * Data for parse and reverse test + * + * @return array + */ + public static function parseReverseSymmetryData(): array + { + return [ + ['/controller/action'], + ['/controller/action/param'], + ['/controller/action?param1=value1¶m2=value2'], + ['/controller/action/param?param1=value1'], + ]; + } + + /** + * testSetExtensions method + */ + public function testSetExtensions(): void + { + Router::extensions('rss', false); + $this->assertContains('rss', Router::extensions()); + + Router::createRouteBuilder('/')->fallbacks(); + + $result = Router::parseRequest($this->makeRequest('/posts.rss', 'GET')); + $this->assertSame('rss', $result['_ext']); + + $result = Router::parseRequest($this->makeRequest('/posts.xml', 'GET')); + $this->assertArrayNotHasKey('_ext', $result); + + Router::extensions(['xml']); + } + + /** + * Test that route builders propagate extensions to the top. + */ + public function testExtensionsWithScopedRoutes(): void + { + Router::extensions(['json']); + + $routes = Router::createRouteBuilder('/'); + $routes->scope('/', function (RouteBuilder $routes): void { + $routes->setExtensions('rss'); + $routes->connect('/', ['controller' => 'Pages', 'action' => 'index']); + + $routes->scope('/api', function (RouteBuilder $routes): void { + $routes->setExtensions('xml'); + $routes->connect('/docs', ['controller' => 'ApiDocs', 'action' => 'index']); + }); + }); + + $this->assertEquals(['json', 'rss', 'xml'], array_values(Router::extensions())); + } + + /** + * Test connecting resources. + */ + public function testResourcesInScope(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->scope('/api', ['prefix' => 'Api'], function (RouteBuilder $routes): void { + $routes->setExtensions(['json']); + $routes->resources('Articles'); + }); + $url = Router::url([ + 'prefix' => 'Api', + 'controller' => 'Articles', + 'action' => 'edit', + '_method' => 'PUT', + 'id' => '99', + ]); + $this->assertSame('/api/articles/99', $url); + + $url = Router::url([ + 'prefix' => 'Api', + 'controller' => 'Articles', + 'action' => 'edit', + '_method' => 'PUT', + '_ext' => 'json', + 'id' => '99', + ]); + $this->assertSame('/api/articles/99.json', $url); + } + + /** + * testExtensionParsing method + */ + public function testExtensionParsing(): void + { + Router::extensions('rss', false); + Router::createRouteBuilder('/', ['routeClass' => 'InflectedRoute'])->fallbacks(); + + $result = Router::parseRequest($this->makeRequest('/posts.rss', 'GET')); + unset($result['_route']); + $expected = [ + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'index', + '_ext' => 'rss', + 'pass' => [], + '_matchedRoute' => '/{controller}', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/posts/view/1.rss', 'GET')); + unset($result['_route']); + $expected = [ + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => ['1'], + '_ext' => 'rss', + '_matchedRoute' => '/{controller}/{action}/*', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/posts/view/1.rss?query=test', 'GET')); + unset($result['_route']); + $expected['?'] = ['query' => 'test']; + $this->assertEquals($expected, $result); + + Router::reload(); + Router::extensions(['rss', 'xml'], false); + Router::createRouteBuilder('/', ['routeClass' => 'InflectedRoute'])->fallbacks(); + + $result = Router::parseRequest($this->makeRequest('/posts.xml', 'GET')); + unset($result['_route']); + $expected = [ + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'index', + '_ext' => 'xml', + 'pass' => [], + '_matchedRoute' => '/{controller}', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/posts.atom?hello=goodbye', 'GET')); + unset($result['_route']); + $expected = [ + 'plugin' => null, + 'controller' => 'Posts.atom', + 'action' => 'index', + 'pass' => [], + '?' => ['hello' => 'goodbye'], + '_matchedRoute' => '/{controller}', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/controller/action', ['controller' => 'Controller', 'action' => 'action', '_ext' => 'rss']); + $result = Router::parseRequest($this->makeRequest('/controller/action', 'GET')); + unset($result['_route']); + $expected = [ + 'controller' => 'Controller', + 'action' => 'action', + 'plugin' => null, + '_ext' => 'rss', + 'pass' => [], + '_matchedRoute' => '/controller/action', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/controller/action', ['controller' => 'Controller', 'action' => 'action', '_ext' => 'rss']); + $result = Router::parseRequest($this->makeRequest('/controller/action', 'GET')); + unset($result['_route']); + $expected = [ + 'controller' => 'Controller', + 'action' => 'action', + 'plugin' => null, + '_ext' => 'rss', + 'pass' => [], + '_matchedRoute' => '/controller/action', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + Router::extensions('rss', false); + $routes->connect('/controller/action', ['controller' => 'Controller', 'action' => 'action', '_ext' => 'rss']); + $result = Router::parseRequest($this->makeRequest('/controller/action', 'GET')); + unset($result['_route']); + $expected = [ + 'controller' => 'Controller', + 'action' => 'action', + 'plugin' => null, + '_ext' => 'rss', + 'pass' => [], + '_matchedRoute' => '/controller/action', + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test newer style automatically generated prefix routes. + * + * @see testUrlGenerationWithAutoPrefixes + */ + public function testUrlGenerationWithAutoPrefixes(): void + { + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/protected/{controller}/{action}/*', ['prefix' => 'Protected']); + $routes->connect('/admin/{controller}/{action}/*', ['prefix' => 'Admin']); + $routes->connect('/{controller}/{action}/*'); + + $request = new ServerRequest([ + 'url' => '/images/index', + 'params' => [ + 'plugin' => null, 'controller' => 'Images', 'action' => 'index', + 'prefix' => null, 'protected' => false, 'url' => ['url' => 'images/index'], + ], + ]); + Router::setRequest($request); + + $result = Router::url(['controller' => 'Images', 'action' => 'add']); + $expected = '/Images/add'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Images', 'action' => 'add', 'prefix' => 'Protected']); + $expected = '/protected/Images/add'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Images', 'action' => 'add_protected_test', 'prefix' => 'Protected']); + $expected = '/protected/Images/add_protected_test'; + $this->assertSame($expected, $result); + + $result = Router::url(['action' => 'edit', 1]); + $expected = '/Images/edit/1'; + $this->assertSame($expected, $result); + + $result = Router::url(['action' => 'edit', 1, 'prefix' => 'Protected']); + $expected = '/protected/Images/edit/1'; + $this->assertSame($expected, $result); + + $result = Router::url(['action' => 'protectedEdit', 1, 'prefix' => 'Protected']); + $expected = '/protected/Images/protectedEdit/1'; + $this->assertSame($expected, $result); + + $result = Router::url(['action' => 'edit', 1, 'prefix' => 'Protected']); + $expected = '/protected/Images/edit/1'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Others', 'action' => 'edit', 1]); + $expected = '/Others/edit/1'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Others', 'action' => 'edit', 1, 'prefix' => 'Protected']); + $expected = '/protected/Others/edit/1'; + $this->assertSame($expected, $result); + } + + /** + * Test that the https option works. + */ + public function testGenerationWithHttpsOption(): void + { + Router::fullBaseUrl('http://app.test'); + Router::createRouteBuilder('/')->connect('/{controller}/{action}/*'); + $request = new ServerRequest([ + 'url' => '/images/index', + 'params' => [ + 'plugin' => null, 'controller' => 'Images', 'action' => 'index', + ], + 'environment' => ['HTTP_HOST' => 'localhost'], + ]); + Router::setRequest($request); + + $result = Router::url([ + '_https' => true, + ]); + $this->assertSame('https://app.test/Images/index', $result); + + $result = Router::url([ + '_https' => false, + ]); + $this->assertSame('http://app.test/Images/index', $result); + } + + /** + * Test https option when the current request is https. + */ + public function testGenerateWithHttpsInHttps(): void + { + Router::createRouteBuilder('/')->connect('/{controller}/{action}/*'); + $request = new ServerRequest([ + 'url' => '/images/index', + 'environment' => ['HTTP_HOST' => 'app.test', 'HTTPS' => 'on'], + 'params' => [ + 'plugin' => null, + 'controller' => 'Images', + 'action' => 'index', + ], + ]); + Router::setRequest($request); + + $result = Router::url([ + '_https' => false, + ]); + $this->assertSame('http://app.test/Images/index', $result); + + $result = Router::url([ + '_https' => true, + ]); + $this->assertSame('https://app.test/Images/index', $result); + } + + /** + * test that prefix routes persist when they are in the current request. + */ + public function testPrefixRoutePersistence(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/protected/{controller}/{action}', ['prefix' => 'Protected']); + $routes->connect('/{controller}/{action}'); + + $request = new ServerRequest([ + 'url' => '/protected/images/index', + 'params' => [ + 'plugin' => null, + 'controller' => 'Images', + 'action' => 'index', + 'prefix' => 'Protected', + ], + ]); + Router::setRequest($request); + + $result = Router::url(['prefix' => 'Protected', 'controller' => 'Images', 'action' => 'add']); + $expected = '/protected/Images/add'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Images', 'action' => 'add']); + $expected = '/protected/Images/add'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Images', 'action' => 'add', 'prefix' => false]); + $expected = '/Images/add'; + $this->assertSame($expected, $result); + } + + /** + * test that setting a prefix override the current one + */ + public function testPrefixOverride(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/{controller}/{action}', ['prefix' => 'Admin']); + $routes->connect('/protected/{controller}/{action}', ['prefix' => 'Protected']); + + $request = new ServerRequest([ + 'url' => '/protected/images/index', + 'params' => [ + 'plugin' => null, 'controller' => 'Images', 'action' => 'index', 'prefix' => 'Protected', + ], + ]); + Router::setRequest($request); + + $result = Router::url(['controller' => 'Images', 'action' => 'add', 'prefix' => 'Admin']); + $expected = '/admin/Images/add'; + $this->assertSame($expected, $result); + + $request = new ServerRequest([ + 'url' => '/admin/images/index', + 'params' => [ + 'plugin' => null, 'controller' => 'Images', 'action' => 'index', 'prefix' => 'Admin', + ], + ]); + Router::setRequest($request); + $result = Router::url(['controller' => 'Images', 'action' => 'add', 'prefix' => 'Protected']); + $expected = '/protected/Images/add'; + $this->assertSame($expected, $result); + } + + /** + * Test that well known route parameters are passed through. + */ + public function testRouteParamDefaults(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/cache/*', ['prefix' => null, 'plugin' => '1', 'controller' => '0', 'action' => '1']); + + $url = Router::url(['prefix' => null, 'plugin' => '1', 'controller' => '0', 'action' => '1', 'test']); + $expected = '/cache/test'; + $this->assertSame($expected, $url); + + try { + Router::url(['controller' => '0', 'action' => '1', 'test']); + $this->fail('No exception raised'); + } catch (Exception) { + $this->assertTrue(true, 'Exception was raised'); + } + + try { + Router::url(['prefix' => '1', 'controller' => '0', 'action' => '1', 'test']); + $this->fail('No exception raised'); + } catch (Exception) { + $this->assertTrue(true, 'Exception was raised'); + } + } + + /** + * Test that null prefix in route defaults works correctly + * This is a regression test for https://github.com/cakephp/cakephp/issues/18860 + */ + public function testNullPrefixInDefaults(): void + { + Router::reload(); + $routes = Router::createRouteBuilder('/'); + + // Test the exact issue from the GitHub report + $routes->connect('/login', ['controller' => 'Users', 'action' => 'login', 'prefix' => null]); + $routes->connect('/logout', ['controller' => 'Users', 'action' => 'logout', 'prefix' => null]); + + // Test that URL generation works with null prefix + $url = Router::url(['controller' => 'Users', 'action' => 'login']); + $this->assertSame('/login', $url, 'Should generate /login not /users/login'); + + $url = Router::url(['controller' => 'Users', 'action' => 'logout']); + $this->assertSame('/logout', $url, 'Should generate /logout not /users/logout'); + + // Test that parsing works correctly + $result = Router::parseRequest(new ServerRequest(['url' => '/login'])); + $this->assertSame('Users', $result['controller']); + $this->assertSame('login', $result['action']); + $this->assertNull($result['prefix']); + + $result = Router::parseRequest(new ServerRequest(['url' => '/logout'])); + $this->assertSame('Users', $result['controller']); + $this->assertSame('logout', $result['action']); + $this->assertNull($result['prefix']); + } + + public function testRouteInvalidDefaults(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage( + 'Value for `plugin` in $defaults when connecting routes must be of type `string` or `null`', + ); + + $routes = Router::createRouteBuilder('/'); + $routes->connect('/foo', ['plugin' => false, 'controller' => 'Foo', 'action' => 'index']); + } + + /** + * testRemoveBase method + */ + public function testRemoveBase(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}'); + $request = new ServerRequest([ + 'url' => '/', + 'base' => '/base', + 'params' => [ + 'plugin' => null, 'controller' => 'Controller', 'action' => 'index', + ], + ]); + Router::setRequest($request); + + $result = Router::url(['controller' => 'MyController', 'action' => 'myAction']); + $expected = '/base/MyController/myAction'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'MyController', 'action' => 'myAction', '_base' => false]); + $expected = '/MyController/myAction'; + $this->assertSame($expected, $result); + } + + /** + * testPagesUrlParsing method + */ + public function testPagesUrlParsing(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + $routes->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + + $result = Router::parseRequest($this->makeRequest('/', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['home'], + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'display', + '_matchedRoute' => '/', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/pages/home/', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['home'], + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'display', + '_matchedRoute' => '/pages/*', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']); + + $result = Router::parseRequest($this->makeRequest('/', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => ['home'], + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'display', + '_matchedRoute' => '/', + ]; + $this->assertEquals($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/', ['controller' => 'Posts', 'action' => 'index']); + $routes->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + $result = Router::parseRequest($this->makeRequest('/pages/contact/', 'GET')); + unset($result['_route']); + + $expected = [ + 'pass' => ['contact'], + 'plugin' => null, + 'controller' => 'Pages', + 'action' => 'display', + '_matchedRoute' => '/pages/*', + ]; + $this->assertEquals($expected, $result); + } + + /** + * test that requests with a trailing dot don't loose the do. + */ + public function testParsingWithTrailingPeriod(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}/*'); + $result = Router::parseRequest($this->makeRequest('/posts/view/something.', 'GET')); + $this->assertSame('something.', $result['pass'][0], 'Period was chopped off'); + + $result = Router::parseRequest($this->makeRequest('/posts/view/something. . .', 'GET')); + $this->assertSame('something. . .', $result['pass'][0], 'Period was chopped off'); + } + + /** + * test that requests with a trailing dot don't loose the do. + */ + public function testParsingWithTrailingPeriodAndParseExtensions(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}/*'); + + $result = Router::parseRequest($this->makeRequest('/posts/view/something.', 'GET')); + $this->assertSame('something.', $result['pass'][0], 'Period was chopped off'); + + $result = Router::parseRequest($this->makeRequest('/posts/view/something. . .', 'GET')); + $this->assertSame('something. . .', $result['pass'][0], 'Period was chopped off'); + } + + /** + * test that patterns work for {action} + */ + public function testParsingWithPatternOnAction(): void + { + $this->expectException(MissingRouteException::class); + + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/blog/{action}/*', + ['controller' => 'BlogPosts'], + ['action' => 'other|actions'], + ); + + $result = Router::parseRequest($this->makeRequest('/blog/other', 'GET')); + unset($result['_route']); + $expected = [ + 'plugin' => null, + 'controller' => 'BlogPosts', + 'action' => 'other', + 'pass' => [], + '_matchedRoute' => '/blog/{action}/*', + ]; + $this->assertEquals($expected, $result); + + Router::parseRequest($this->makeRequest('/blog/foobar', 'GET')); + } + + public static function routePathProvider(): array + { + // Input path, output route data. + return [ + // Controller + action + [ + 'Bookmarks::view', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + ], + ], + // Prefix controller + [ + 'Admin/Bookmarks::view', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'prefix' => 'Admin', + ], + ], + // Nested prefixes + [ + 'LongPrefix/BackEnd/Bookmarks::view', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'prefix' => 'LongPrefix/BackEnd', + ], + ], + // Plugins + [ + 'Cms.Articles::edit', + [ + 'controller' => 'Articles', + 'action' => 'edit', + 'plugin' => 'Cms', + ], + ], + // Vendor plugins & nested prefix + [ + 'Vendor/Cms.Management/Admin/Articles::view', + [ + 'controller' => 'Articles', + 'action' => 'view', + 'plugin' => 'Vendor/Cms', + 'prefix' => 'Management/Admin', + ], + ], + + // Passed parameters + [ + 'Bookmarks::view/1/', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + '1', + ], + ], + [ + 'Bookmarks::view/1', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + '1', + ], + ], + [ + 'Cms.Articles::edit/2023/03/5th', + [ + 'controller' => 'Articles', + 'action' => 'edit', + 'plugin' => 'Cms', + '2023', + '03', + '5th', + ], + ], + [ + 'Bookmarks::view/organization=cakephp/repository=debug_kit', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'organization' => 'cakephp', + 'repository' => 'debug_kit',], + ], + [ + 'Bookmarks::view/organization=cakephp/bake', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'organization' => 'cakephp', + 'bake', + ], + ], + [ + 'Bookmarks::view/organization="cakephp=test"', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'organization' => 'cakephp=test', + ], + ], + [ + "Bookmarks::view/test='repo=debug_kit'", + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'test' => 'repo=debug_kit', + ], + ], + [ + 'Bookmarks::view/organization="cakephp=test"/test=\'repo=debug_kit\'', + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'organization' => 'cakephp=test', + 'test' => 'repo=debug_kit', + ], + ], + [ + "Bookmarks::view/organization='cakephp='", + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'organization' => 'cakephp=', + ], + ], + [ + "Bookmarks::view/organization='cakephp'", + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'organization' => 'cakephp', + ], + ], + [ + "Bookmarks::view/organization='foo@bar.com~1!#$%^&*()'", + [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'organization' => 'foo@bar.com~1!#$%^&*()', + ], + ], + ]; + } + + /** + * Test parseRoutePath() with valid strings + */ + #[DataProvider('routePathProvider')] + public function testParseRoutePath($path, $expected): void + { + $this->assertSame($expected, Router::parseRoutePath($path)); + } + + public function testParseRoutePathInvalidNumeric(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Param key `1invalid` is not valid'); + Router::parseRoutePath('Bookmarks::view/1invalid=cakephp'); + } + + public function testParseRoutePathInvalidParameterKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Param key `-invalid` is not valid'); + Router::parseRoutePath('Bookmarks::view/-invalid=cakephp'); + } + + /** + * @return array + */ + public static function invalidRoutePathProvider(): array + { + return [ + ['view'], + ['Bookmarks:view'], + ['Bookmarks/view'], + ['Vendor\Cms.Articles::edit'], + ['Vendor//Cms.Articles::edit'], + ['Cms./Articles::edit'], + ['Cms./Admin/Articles::edit'], + ['Cms.Admin//Articles::edit'], + ['Vendor\Cms.Management\Admin\Articles::edit'], + ]; + } + + /** + * Test parseRoutePath() with invalid strings + */ + #[DataProvider('invalidRoutePathProvider')] + public function testParseInvalidRoutePath(string $value): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Could not parse a string route path'); + + Router::parseRoutePath($value); + } + + /** + * Tests that convenience wrapper urlArray() works as the internal + * Router::parseRoutePath() does. + */ + public function testUrlArray(): void + { + $expected = [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'plugin' => false, + 'prefix' => false, + ]; + $this->assertSame($expected, urlArray('Bookmarks::view')); + + $expected = [ + 'controller' => 'Bookmarks', + 'action' => 'view', + 'prefix' => 'Admin', + 'plugin' => false, + ]; + $this->assertSame($expected, urlArray('Admin/Bookmarks::view')); + + $expected = [ + 'controller' => 'Articles', + 'action' => 'view', + 'plugin' => 'Vendor/Cms', + 'prefix' => 'Management/Admin', + 3, + '?' => ['query' => 'string'], + ]; + $params = [3, '?' => ['query' => 'string']]; + $this->assertSame($expected, urlArray('Vendor/Cms.Management/Admin/Articles::view', $params)); + } + + /** + * Test url() works with patterns on {action} + */ + public function testUrlPatternOnAction(): void + { + $this->expectException(MissingRouteException::class); + + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/blog/{action}/*', + ['controller' => 'BlogPosts'], + ['action' => 'other|actions'], + ); + + $result = Router::url(['controller' => 'BlogPosts', 'action' => 'actions']); + $this->assertSame('/blog/actions', $result); + + $result = Router::url(['controller' => 'BlogPosts', 'action' => 'foo']); + $this->assertSame('/', $result); + } + + /** + * testParsingWithLiteralPrefixes method + */ + public function testParsingWithLiteralPrefixes(): void + { + $routes = Router::createRouteBuilder('/'); + $adminParams = ['prefix' => 'Admin']; + $routes->connect('/admin/{controller}', $adminParams); + $routes->connect('/admin/{controller}/{action}/*', $adminParams); + + $request = new ServerRequest([ + 'url' => '/', + 'base' => '/base', + 'params' => ['plugin' => null, 'controller' => 'Controller', 'action' => 'index'], + ]); + Router::setRequest($request); + + $result = Router::parseRequest($this->makeRequest('/admin/Posts/', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'prefix' => 'Admin', + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'index', + '_matchedRoute' => '/admin/{controller}', + ]; + $this->assertEquals($expected, $result); + + $result = Router::parseRequest($this->makeRequest('/admin/Posts', 'GET')); + unset($result['_route']); + $this->assertEquals($expected, $result); + + $result = Router::url(['prefix' => 'Admin', 'controller' => 'Posts']); + $expected = '/base/admin/Posts'; + $this->assertSame($expected, $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + + $prefixParams = ['prefix' => 'Members']; + $routes->connect('/members/{controller}', $prefixParams); + $routes->connect('/members/{controller}/{action}', $prefixParams); + $routes->connect('/members/{controller}/{action}/*', $prefixParams); + + $request = new ServerRequest([ + 'url' => '/', + 'base' => '/base', + 'params' => ['plugin' => null, 'controller' => 'Controller', 'action' => 'index'], + ]); + Router::setRequest($request); + + $result = Router::parseRequest($this->makeRequest('/members/Posts/index', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'prefix' => 'Members', + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'index', + '_matchedRoute' => '/members/{controller}/{action}', + ]; + $this->assertEquals($expected, $result); + + $result = Router::url(['prefix' => 'Members', 'controller' => 'Users', 'action' => 'add']); + $expected = '/base/members/Users/add'; + $this->assertSame($expected, $result); + } + + /** + * Tests URL generation with flags and prefixes in and out of context + */ + public function testUrlWritingWithPrefixes(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/company/{controller}/{action}/*', ['prefix' => 'Company']); + $routes->connect('/{action}', ['controller' => 'Users']); + + $result = Router::url(['controller' => 'Users', 'action' => 'login', 'prefix' => 'Company']); + $expected = '/company/Users/login'; + $this->assertSame($expected, $result); + + $request = new ServerRequest([ + 'url' => '/', + 'params' => [ + 'plugin' => null, + 'controller' => 'Users', + 'action' => 'login', + 'prefix' => 'Company', + ], + ]); + Router::setRequest($request); + + $result = Router::url(['controller' => 'Users', 'action' => 'login', 'prefix' => false]); + $expected = '/login'; + $this->assertSame($expected, $result); + } + + /** + * test url generation with prefixes and custom routes + */ + public function testUrlWritingWithPrefixesAndCustomRoutes(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect( + '/admin/login', + ['controller' => 'Users', 'action' => 'login', 'prefix' => 'Admin'], + ); + $request = new ServerRequest([ + 'url' => '/', + 'params' => [ + 'plugin' => null, + 'controller' => 'Posts', + 'action' => 'index', + 'prefix' => 'Admin', + ], + 'webroot' => '/', + ]); + Router::setRequest($request); + $result = Router::url(['controller' => 'Users', 'action' => 'login']); + $this->assertSame('/admin/login', $result); + + $result = Router::url(['controller' => 'Users', 'action' => 'login']); + $this->assertSame('/admin/login', $result); + } + + /** + * testPassedArgsOrder method + */ + public function testPassedArgsOrder(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/test-passed/*', ['controller' => 'Pages', 'action' => 'display', 'home']); + $routes->connect('/test2/*', ['controller' => 'Pages', 'action' => 'display', 2]); + $routes->connect('/test/*', ['controller' => 'Pages', 'action' => 'display', 1]); + + $result = Router::url(['controller' => 'Pages', 'action' => 'display', 1, 'whatever']); + $expected = '/test/whatever'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Pages', 'action' => 'display', 2, 'whatever']); + $expected = '/test2/whatever'; + $this->assertSame($expected, $result); + + $result = Router::url(['controller' => 'Pages', 'action' => 'display', 'home', 'whatever']); + $expected = '/test-passed/whatever'; + $this->assertSame($expected, $result); + } + + /** + * testRegexRouteMatching method + */ + public function testRegexRouteMatching(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{locale}/{controller}/{action}/*', [], ['locale' => 'dan|eng']); + + $result = Router::parseRequest($this->makeRequest('/eng/Test/testAction', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'locale' => 'eng', + 'controller' => 'Test', + 'action' => 'testAction', + 'plugin' => null, + '_matchedRoute' => '/{locale}/{controller}/{action}/*', + ]; + $this->assertEquals($expected, $result); + } + + /** + * testRegexRouteMatching method + */ + public function testRegexRouteMatchUrl(): void + { + $this->expectException(MissingRouteException::class); + + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{locale}/{controller}/{action}/*', [], ['locale' => 'dan|eng']); + + $request = new ServerRequest([ + 'url' => '/test/test_action', + 'params' => [ + 'plugin' => null, + 'controller' => 'Test', + 'action' => 'index', + ], + 'webroot' => '/', + ]); + Router::setRequest($request); + + $result = Router::url(['action' => 'testAnotherAction', 'locale' => 'eng']); + $expected = '/eng/Test/testAnotherAction'; + $this->assertSame($expected, $result); + + $result = Router::url(['action' => 'testAnotherAction']); + $expected = '/'; + $this->assertSame($expected, $result); + } + + /** + * test using a custom route class for route connection + */ + public function testUsingCustomRouteClass(): void + { + $routes = Router::createRouteBuilder('/'); + $this->loadPlugins(['TestPlugin']); + $routes->connect( + '/{slug}', + ['plugin' => 'TestPlugin', 'action' => 'index'], + ['routeClass' => 'PluginShortRoute', 'slug' => '[a-z_-]+'], + ); + $result = Router::parseRequest($this->makeRequest('/the-best', 'GET')); + unset($result['_route']); + $expected = [ + 'plugin' => 'TestPlugin', + 'controller' => 'TestPlugin', + 'action' => 'index', + 'slug' => 'the-best', + 'pass' => [], + '_matchedRoute' => '/{slug}', + ]; + $this->assertEquals($expected, $result); + } + + /** + * test using custom route class in PluginDot notation + */ + public function testUsingCustomRouteClassPluginDotSyntax(): void + { + $routes = Router::createRouteBuilder('/'); + $this->loadPlugins(['TestPlugin']); + $routes->connect( + '/{slug}', + ['controller' => 'Posts', 'action' => 'view'], + ['routeClass' => 'TestPlugin.TestRoute', 'slug' => '[a-z_-]+'], + ); + $this->assertTrue(true); // Just to make sure the connect do not throw exception + $this->removePlugins(['TestPlugin']); + } + + /** + * test that route classes must extend \Cake\Routing\Route\Route + */ + public function testCustomRouteException(): void + { + $this->expectException(InvalidArgumentException::class); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}', [], ['routeClass' => 'Object']); + } + + public function testReverseLocalized(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*', [], ['lang' => '[a-z]{3}']); + $params = [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => [1], + 'url' => ['url' => 'eng/posts/view/1'], + ]; + $result = Router::reverse($params); + $this->assertSame('/eng/Posts/view/1', $result); + } + + public function testReverseRouteKeyAndPass(): void + { + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/articles/{slug}', ['controller' => 'Articles', 'action' => 'view'])->setPass(['slug']); + + $request = new ServerRequest([ + 'url' => '/articles/first-post', + 'params' => [ + 'lang' => 'eng', + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => ['first-post'], + 'slug' => 'first-post', + '_matchedRoute' => '/articles/{slug}', + ], + ]); + $result = Router::reverse($request); + $this->assertSame('/articles/first-post', $result); + } + + public function testReverseRouteKeyAndPassDuplicateValues(): void + { + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/authors/{author_id}/articles/{id}', ['controller' => 'Articles', 'action' => 'view']) + ->setPass(['id']); + + $request = new ServerRequest([ + 'url' => '/authors/1/articles/1', + 'params' => [ + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => ['1'], + 'author_id' => '1', + 'id' => '1', + '_matchedRoute' => '/authors/{author_id}/articles/{id}', + ], + ]); + $result = Router::reverse($request); + $this->assertSame('/authors/1/articles/1', $result); + + Router::reload(); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/authors/{author_id}/articles/{id}/*', ['controller' => 'Articles', 'action' => 'view']) + ->setPass(['id', 'author_id']); + + $request = new ServerRequest([ + 'url' => '/authors/88/articles/11', + 'params' => [ + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => ['11', '88', '99'], + 'author_id' => '88', + 'id' => '11', + '_matchedRoute' => '/authors/{author_id}/articles/{id}/*', + ], + ]); + $result = Router::reverse($request); + $this->assertSame('/authors/88/articles/11/99', $result); + + $request = new ServerRequest([ + 'url' => '/authors/1/articles/1/1', + 'params' => [ + 'controller' => 'Articles', + 'action' => 'view', + 'pass' => ['1', '1', '1'], + 'author_id' => '1', + 'id' => '1', + '_matchedRoute' => '/authors/{author_id}/articles/{id}/*', + ], + ]); + $result = Router::reverse($request); + $this->assertSame('/authors/1/articles/1/1', $result); + } + + public function testReverseArrayQuery(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*', [], ['lang' => '[a-z]{3}']); + $params = [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 1, + '?' => ['foo' => 'bar'], + ]; + $result = Router::reverse($params); + $this->assertSame('/eng/Posts/view/1?foo=bar', $result); + + $params = [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => [1], + 'url' => ['url' => 'eng/posts/view/1'], + 'models' => [], + ]; + $result = Router::reverse($params); + $this->assertSame('/eng/Posts/view/1', $result); + } + + public function testReverseCakeRequestQuery(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*', [], ['lang' => '[a-z]{3}']); + $request = new ServerRequest([ + 'url' => '/eng/posts/view/1', + 'params' => [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => [1], + ], + 'query' => ['test' => 'value'], + ]); + $result = Router::reverse($request); + $expected = '/eng/Posts/view/1?test=value'; + $this->assertSame($expected, $result); + } + + public function testReverseFull(): void + { + Router::createRouteBuilder('/')->fallbacks(); + $params = [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => [1], + 'url' => ['url' => 'eng/posts/view/1'], + ]; + $result = Router::reverse($params, true); + $this->assertMatchesRegularExpression('/^http(s)?:\/\//', $result); + } + + /** + * Test that extensions work with Router::reverse() + */ + public function testReverseWithExtension(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}/*'); + Router::extensions('json', false); + + $request = new ServerRequest([ + 'url' => '/posts/view/1.json', + 'params' => [ + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => [1], + '_ext' => 'json', + ], + ]); + $result = Router::reverse($request); + $expected = '/Posts/view/1.json'; + $this->assertSame($expected, $result); + } + + public function testReverseToArrayQuery(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{lang}/{controller}/{action}/*', [], ['lang' => '[a-z]{3}']); + $params = [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => [123], + '?' => ['foo' => 'bar', 'baz' => 'quu'], + ]; + $actual = Router::reverseToArray($params); + $expected = [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 123, + '?' => ['foo' => 'bar', 'baz' => 'quu'], + ]; + $this->assertEquals($expected, $actual); + } + + public function testReverseToArrayRequestQuery(): void + { + $builder = Router::createRouteBuilder('/'); + $route = $builder->connect('/{lang}/{controller}/{action}/*', [], ['lang' => '[a-z]{3}']); + $request = new ServerRequest([ + 'url' => '/eng/posts/view/1', + 'params' => [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 'pass' => [123], + ], + 'query' => ['test' => 'value'], + ]); + $actual = Router::reverseToArray($request); + $expected = [ + 'lang' => 'eng', + 'controller' => 'Posts', + 'action' => 'view', + 123, + '?' => [ + 'test' => 'value', + ], + ]; + $this->assertEquals($expected, $actual); + + $request = $request->withAttribute('route', $route) + ->withQueryParams(['x' => 'y']); + $expected['?'] = ['x' => 'y']; + $actual = Router::reverseToArray($request); + $this->assertEquals($expected, $actual); + } + + /** + * test get request. + */ + public function testGetRequest(): void + { + $requestA = new ServerRequest(['url' => '/']); + Router::setRequest($requestA); + $this->assertSame($requestA, Router::getRequest()); + + $requestB = new ServerRequest(['url' => '/posts']); + Router::setRequest($requestB); + $this->assertSame($requestB, Router::getRequest()); + } + + /** + * test that a route object returning a full URL is not modified. + */ + public function testUrlFullUrlReturnFromRoute(): void + { + $route = new class ('/{controller}/{action}/*') extends Route { + public function match(array $url, array $context = []): string + { + return 'http://example.com/posts/view/1'; + } + }; + + Router::createRouteBuilder('/')->connect($route); + + $result = Router::url(['controller' => 'Posts', 'action' => 'view', 1]); + $this->assertSame('http://example.com/posts/view/1', $result); + } + + /** + * test protocol in url + */ + public function testUrlProtocol(): void + { + $url = 'http://example.com'; + $this->assertSame($url, Router::url($url)); + + $url = 'ed2k://example.com'; + $this->assertSame($url, Router::url($url)); + + $url = 'svn+ssh://example.com'; + $this->assertSame($url, Router::url($url)); + + $url = '://example.com'; + $this->assertSame($url, Router::url($url)); + + $url = '//example.com'; + $this->assertSame($url, Router::url($url)); + + $url = 'javascript:void(0)'; + $this->assertSame($url, Router::url($url)); + + $url = 'tel:012345-678'; + $this->assertSame($url, Router::url($url)); + + $url = 'sms:012345-678'; + $this->assertSame($url, Router::url($url)); + + $url = '#here'; + $this->assertSame($url, Router::url($url)); + + $url = '?param=0'; + $this->assertSame($url, Router::url($url)); + + $url = '/posts/index#here'; + $expected = Configure::read('App.fullBaseUrl') . '/posts/index#here'; + $this->assertSame($expected, Router::url($url, true)); + } + + /** + * Testing that patterns on the {action} param work properly. + */ + public function testPatternOnAction(): void + { + $route = new Route( + '/blog/{action}/*', + ['controller' => 'BlogPosts'], + ['action' => 'other|actions'], + ); + $result = $route->match(['controller' => 'BlogPosts', 'action' => 'foo']); + $this->assertNull($result); + + $result = $route->match(['controller' => 'BlogPosts', 'action' => 'actions']); + $this->assertSame('/blog/actions/', $result); + + $result = $route->parseRequest($this->makeRequest('/blog/other', 'GET')); + unset($result['_route']); + $expected = [ + 'controller' => 'BlogPosts', + 'action' => 'other', + 'pass' => [], + '_matchedRoute' => '/blog/{action}/*', + ]; + $this->assertEquals($expected, $result); + + $result = $route->parseRequest($this->makeRequest('/blog/foobar', 'GET')); + $this->assertNull($result); + } + + /** + * Test the scope() method + */ + public function testScope(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->scope('/path', ['param' => 'value'], function (RouteBuilder $routes): void { + $this->assertSame('/path', $routes->path()); + $this->assertEquals(['param' => 'value'], $routes->params()); + $this->assertSame('', $routes->namePrefix()); + + $routes->connect('/articles', ['controller' => 'Articles']); + }); + } + + /** + * Test to ensure that extensions defined in scopes don't leak. + * And that global extensions are propagated. + */ + public function testScopeExtensionsContained(): void + { + Router::extensions(['json']); + $routes = Router::createRouteBuilder('/'); + $routes->scope('/', function (RouteBuilder $routes): void { + $this->assertEquals(['json'], $routes->getExtensions(), 'Should default to global extensions.'); + $routes->setExtensions(['rss']); + + $this->assertEquals( + ['rss'], + $routes->getExtensions(), + 'Should include new extensions.', + ); + $routes->connect('/home', []); + }); + + $this->assertEquals(['json', 'rss'], array_values(Router::extensions())); + + $routes->scope('/api', function (RouteBuilder $routes): void { + $this->assertEquals(['json'], $routes->getExtensions(), 'Should default to global extensions.'); + + $routes->setExtensions(['json', 'csv']); + $routes->connect('/export', []); + + $routes->scope('/v1', function (RouteBuilder $routes): void { + $this->assertEquals(['json', 'csv'], $routes->getExtensions()); + }); + }); + + $this->assertEquals(['json', 'rss', 'csv'], array_values(Router::extensions())); + } + + /** + * Test the scope() options + */ + public function testScopeOptions(): void + { + $options = ['param' => 'value']; + $routes = Router::createRouteBuilder('/', ['routeClass' => 'InflectedRoute', 'extensions' => ['json']]); + $routes->scope('/path', $options, function (RouteBuilder $routes): void { + $this->assertSame('InflectedRoute', $routes->getRouteClass()); + $this->assertSame(['json'], $routes->getExtensions()); + $this->assertSame('/path', $routes->path()); + $this->assertEquals(['param' => 'value'], $routes->params()); + }); + } + + /** + * Test the scope() method + */ + public function testScopeNamePrefix(): void + { + $routes = Router::createRouteBuilder('/'); + + $routes->scope('/path', ['param' => 'value', '_namePrefix' => 'path:'], function (RouteBuilder $routes): void { + $this->assertSame('/path', $routes->path()); + $this->assertEquals(['param' => 'value'], $routes->params()); + $this->assertSame('path:', $routes->namePrefix()); + + $routes->connect('/articles', ['controller' => 'Articles']); + }); + } + + /** + * Test that prefix() creates a scope. + */ + public function testPrefix(): void + { + $routes = Router::createRouteBuilder('/'); + + $routes->prefix('admin', function (RouteBuilder $routes): void { + $this->assertSame('/admin', $routes->path()); + $this->assertEquals(['prefix' => 'Admin'], $routes->params()); + }); + + $routes->prefix('admin', ['_namePrefix' => 'admin:'], function (RouteBuilder $routes): void { + $this->assertSame('admin:', $routes->namePrefix()); + $this->assertEquals(['prefix' => 'Admin'], $routes->params()); + }); + } + + /** + * Test that prefix() accepts options + */ + public function testPrefixOptions(): void + { + $routes = Router::createRouteBuilder('/'); + + $routes->prefix('admin', ['param' => 'value'], function (RouteBuilder $routes): void { + $this->assertSame('/admin', $routes->path()); + $this->assertEquals(['prefix' => 'Admin', 'param' => 'value'], $routes->params()); + }); + + $routes->prefix('CustomPath', ['path' => '/custom-path'], function (RouteBuilder $routes): void { + $this->assertSame('/custom-path', $routes->path()); + $this->assertEquals(['prefix' => 'CustomPath'], $routes->params()); + }); + } + + /** + * Test that plugin() creates a scope. + */ + public function testPlugin(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->plugin('DebugKit', function (RouteBuilder $routes): void { + $this->assertSame('/debug-kit', $routes->path()); + $this->assertEquals(['plugin' => 'DebugKit'], $routes->params()); + }); + } + + /** + * Test that plugin() accepts options + */ + public function testPluginOptions(): void + { + $routes = Router::createRouteBuilder('/'); + + $routes->plugin('DebugKit', ['path' => '/debugger'], function (RouteBuilder $routes): void { + $this->assertSame('/debugger', $routes->path()); + $this->assertEquals(['plugin' => 'DebugKit'], $routes->params()); + }); + + $routes->plugin('Contacts', ['_namePrefix' => 'contacts:'], function (RouteBuilder $routes): void { + $this->assertSame('contacts:', $routes->namePrefix()); + }); + } + + /** + * Test setting default route class. + */ + public function testDefaultRouteClass(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}', ['action' => 'index']); + $result = Router::url(['controller' => 'FooBar', 'action' => 'index']); + $this->assertSame('/FooBar', $result); + + // This is needed because tests/bootstrap.php sets App.namespace to 'App' + static::setAppNamespace(); + + Router::defaultRouteClass('DashedRoute'); + $routes = Router::createRouteBuilder('/'); + $routes->connect('/cake/{controller}', ['action' => 'cake']); + $result = Router::url(['controller' => 'FooBar', 'action' => 'cake']); + $this->assertSame('/cake/foo-bar', $result); + + $result = Router::url(['controller' => 'FooBar', 'action' => 'index']); + $this->assertSame('/FooBar', $result); + + Router::reload(); + Router::defaultRouteClass('DashedRoute'); + $routes = Router::createRouteBuilder('/'); + $routes->fallbacks(); + + $result = Router::url(['controller' => 'FooBar', 'action' => 'index']); + $this->assertSame('/foo-bar', $result); + } + + /** + * Test setting the request context. + */ + public function testSetRequestContextCakePHP(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}/*'); + $request = new ServerRequest([ + 'base' => '/subdir', + 'url' => 'articles/view/1', + ]); + Router::setRequest($request); + $result = Router::url(['controller' => 'Things', 'action' => 'add']); + $this->assertSame('/subdir/Things/add', $result); + + $result = Router::url(['controller' => 'Things', 'action' => 'add'], true); + $this->assertSame('http://localhost/subdir/Things/add', $result); + + $result = Router::url('/pages/home'); + $this->assertSame('/subdir/pages/home', $result); + } + + /** + * Test setting the request context. + */ + public function testSetRequestContextPsr(): void + { + $server = [ + 'DOCUMENT_ROOT' => '/Users/markstory/Sites', + 'SCRIPT_FILENAME' => '/Users/markstory/Sites/subdir/webroot/index.php', + 'PHP_SELF' => '/subdir/webroot/index.php/articles/view/1', + 'REQUEST_URI' => '/subdir/articles/view/1', + 'QUERY_STRING' => '', + 'SERVER_PORT' => 80, + ]; + + $routes = Router::createRouteBuilder('/'); + $routes->connect('/{controller}/{action}/*'); + $request = ServerRequestFactory::fromGlobals($server); + Router::setRequest($request); + + $result = Router::url(['controller' => 'Things', 'action' => 'add']); + $this->assertSame('/subdir/Things/add', $result); + + $result = Router::url(['controller' => 'Things', 'action' => 'add'], true); + $this->assertSame('http://localhost/subdir/Things/add', $result); + + $result = Router::url('/pages/home'); + $this->assertSame('/subdir/pages/home', $result); + } + + /** + * Test getting the route collection + */ + public function testGetRouteCollection(): void + { + $collection = Router::getRouteCollection(); + $this->assertInstanceOf(RouteCollection::class, $collection); + $this->assertCount(0, $collection->routes()); + } + + /** + * Test getting a route builder instance. + */ + public function testCreateRouteBuilder(): void + { + $builder = Router::createRouteBuilder('/api'); + $this->assertInstanceOf(RouteBuilder::class, $builder); + $this->assertSame('/api', $builder->path()); + + $builder = Router::createRouteBuilder('/', [ + 'routeClass' => 'InflectedRoute', + 'extensions' => ['json'], + ]); + $this->assertInstanceOf(RouteBuilder::class, $builder); + $this->assertSame(['json'], $builder->getExtensions()); + } + + /** + * test connect() with short string syntax + */ + public function testConnectShortStringSyntax(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/admin/articles/view', 'Admin/Articles::view'); + $result = Router::parseRequest($this->makeRequest('/admin/articles/view', 'GET')); + unset($result['_route']); + $expected = [ + 'pass' => [], + 'prefix' => 'Admin', + 'controller' => 'Articles', + 'action' => 'view', + 'plugin' => null, + '_matchedRoute' => '/admin/articles/view', + + ]; + $this->assertEquals($result, $expected); + } + + /** + * test url() with a string route path + */ + public function testUrlGenerationWithPathUrl(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/articles', 'Articles::index'); + $routes->connect('/articles/view/*', 'Articles::view'); + $routes->connect('/article/{slug}', 'Articles::read'); + $routes->connect('/admin/articles', 'Admin/Articles::index'); + $routes->connect('/cms/articles', 'Cms.Articles::index'); + $routes->connect('/cms/admin/articles', 'Cms.Admin/Articles::index'); + + $result = Router::pathUrl('Articles::index'); + $expected = '/articles'; + $this->assertSame($result, $expected); + + $result = Router::pathUrl('Articles::view', [3]); + $expected = '/articles/view/3'; + $this->assertSame($result, $expected); + + $result = Router::pathUrl('Articles::read', ['slug' => 'title']); + $expected = '/article/title'; + $this->assertSame($result, $expected); + + $result = Router::pathUrl('Admin/Articles::index'); + $expected = '/admin/articles'; + $this->assertSame($result, $expected); + + $result = Router::pathUrl('Cms.Admin/Articles::index'); + $expected = '/cms/admin/articles'; + $this->assertSame($result, $expected); + + $result = Router::pathUrl('Cms.Articles::index'); + $expected = '/cms/articles'; + $this->assertSame($result, $expected); + } + + /** + * test url() with a string route path doesn't take parameters from current request + */ + public function testUrlGenerationWithRoutePathWithContext(): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/articles', 'Articles::index'); + $routes->connect('/articles/view/*', 'Articles::view'); + $routes->connect('/admin/articles', 'Admin/Articles::index'); + $routes->connect('/cms/articles', 'Cms.Articles::index'); + $routes->connect('/cms/admin/articles', 'Cms.Admin/Articles::index'); + + $request = new ServerRequest([ + 'params' => [ + 'plugin' => 'Cms', + 'prefix' => 'Admin', + 'controller' => 'Articles', + 'action' => 'edit', + 'pass' => ['3'], + ], + 'url' => '/admin/articles/edit/3', + ]); + Router::setRequest($request); + + $expected = '/articles'; + $result = Router::pathUrl('Articles::index'); + $this->assertSame($result, $expected); + $result = Router::url(['_path' => 'Articles::index']); + $this->assertSame($result, $expected); + + $expected = '/articles/view/3'; + $result = Router::pathUrl('Articles::view', [3]); + $this->assertSame($result, $expected); + $result = Router::url(['_path' => 'Articles::view', 3]); + $this->assertSame($result, $expected); + + $expected = '/admin/articles'; + $result = Router::pathUrl('Admin/Articles::index'); + $this->assertSame($result, $expected); + $result = Router::url(['_path' => 'Admin/Articles::index']); + $this->assertSame($result, $expected); + + $expected = '/cms/admin/articles'; + $result = Router::pathUrl('Cms.Admin/Articles::index'); + $this->assertSame($result, $expected); + $result = Router::url(['_path' => 'Cms.Admin/Articles::index']); + $this->assertSame($result, $expected); + + $expected = '/cms/articles'; + $result = Router::pathUrl('Cms.Articles::index'); + $this->assertSame($result, $expected); + $result = Router::url(['_path' => 'Cms.Articles::index']); + $this->assertSame($result, $expected); + } + + /** + * @return array + */ + public static function invalidRoutePathParametersArrayProvider(): array + { + return [ + [['plugin' => false]], + [['plugin' => 'Cms']], + [['prefix' => false]], + [['prefix' => 'Manager']], + [['controller' => 'Bookmarks']], + [['controller' => 'Articles']], + [['action' => 'edit']], + [['action' => 'index']], + ]; + } + + /** + * Test url() doesn't let override parts of string route path + * + * @param array $params + */ + #[DataProvider('invalidRoutePathParametersArrayProvider')] + public function testUrlGenerationOverridingShortString(array $params): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/articles', 'Articles::index'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be used when defining route targets with a string route path.'); + + Router::pathUrl('Articles::index', $params); + } + + /** + * Test url() doesn't let override parts of string route path from `_path` key + * + * @param array $params + */ + #[DataProvider('invalidRoutePathParametersArrayProvider')] + public function testUrlGenerationOverridingPathKey(array $params): void + { + $routes = Router::createRouteBuilder('/'); + $routes->connect('/articles', 'Articles::index'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be used when defining route targets with a string route path.'); + + Router::url(['_path' => 'Articles::index'] + $params); + } + + /** + * Test the url() function which wraps Router::url() + * + * @return void + */ + public function testUrlFunction(): void + { + $this->assertSame(Router::url('/'), url('/')); + } + + /** + * Connect some fallback routes for testing router behavior. + */ + protected function _connectDefaultRoutes(): void + { + Router::scope('/', function (RouteBuilder $routes): void { + $routes->fallbacks('InflectedRoute'); + }); + } + + /** + * Helper to create a request for a given URL and method. + * + * @param string $url The URL to create a request for + * @param string $method The HTTP method to use. + */ + protected function makeRequest($url, $method): ServerRequest + { + $request = new ServerRequest([ + 'url' => $url, + 'environment' => ['REQUEST_METHOD' => $method], + ]); + + return $request; + } +} diff --git a/tests/TestCase/TestSuite/AssertHtmlTest.php b/tests/TestCase/TestSuite/AssertHtmlTest.php new file mode 100644 index 00000000000..80f0a2c9f1e --- /dev/null +++ b/tests/TestCase/TestSuite/AssertHtmlTest.php @@ -0,0 +1,267 @@ + +

    Popular tags + +

    + +HTML; + $pattern = [ + 'div' => ['class' => 'wrapper'], + 'h4' => ['class' => 'widget-title'], 'Popular tags', + 'i' => ['class' => 'i-icon'], '/i', + '/h4', + '/div', + ]; + $this->assertHtml($pattern, $input); + } + + /** + * Test whitespace inside HTML tags + */ + public function testAssertHtmlInnerWhitespace(): void + { + $input = << +
    + A custom widget +
    + +HTML; + $expected = [ + ['div' => ['class' => 'widget']], + ['div' => ['class' => 'widget-content']], + 'A custom widget', + '/div', + '/div', + ]; + $this->assertHtml($expected, $input); + } + + /** + * test assertHtml works with single and double quotes + */ + public function testAssertHtmlQuoting(): void + { + $input = 'My link'; + $pattern = [ + 'a' => ['href' => '/test.html', 'class' => 'active'], + 'My link', + '/a', + ]; + $this->assertHtml($pattern, $input); + + $input = "My link"; + $pattern = [ + 'a' => ['href' => '/test.html', 'class' => 'active'], + 'My link', + '/a', + ]; + $this->assertHtml($pattern, $input); + + $input = "My link"; + $pattern = [ + 'a' => ['href' => 'preg:/.*\.html/', 'class' => 'active'], + 'My link', + '/a', + ]; + $this->assertHtml($pattern, $input); + + $input = 'Text'; + $pattern = [ + 'assertHtml($pattern, $input); + + $input = "Text"; + $pattern = [ + 'span' => ['class'], + 'assertHtml($pattern, $input); + } + + /** + * Test that assertHtml runs quickly. + */ + public function testAssertHtmlRuntimeComplexity(): void + { + $pattern = [ + 'div' => [ + 'attr1' => 'val1', + 'attr2' => 'val2', + 'attr3' => 'val3', + 'attr4' => 'val4', + 'attr5' => 'val5', + 'attr6' => 'val6', + 'attr7' => 'val7', + 'attr8' => 'val8', + ], + 'My div', + '/div', + ]; + $input = '
    ' . + 'My div' . + '
    '; + $this->assertHtml($pattern, $input); + } + + /** + * test that assertHtml knows how to handle correct quoting. + */ + public function testAssertHtmlQuotes(): void + { + $input = 'My link'; + $pattern = [ + 'a' => ['href' => '/test.html', 'class' => 'active'], + 'My link', + '/a', + ]; + $this->assertHtml($pattern, $input); + + $input = "My link"; + $pattern = [ + 'a' => ['href' => '/test.html', 'class' => 'active'], + 'My link', + '/a', + ]; + $this->assertHtml($pattern, $input); + + $input = "My link"; + $pattern = [ + 'a' => ['href' => 'preg:/.*\.html/', 'class' => 'active'], + 'My link', + '/a', + ]; + $this->assertHtml($pattern, $input); + } + + /** + * testNumericValuesInExpectationForAssertHtml + */ + public function testNumericValuesInExpectationForAssertHtml(): void + { + $value = 220985; + + $input = '

    ' . $value . '

    '; + $pattern = [ + 'assertHtml($pattern, $input); + + $input = '

    ' . $value . '

    ' . $value . '

    '; + $pattern = [ + 'assertHtml($pattern, $input); + + $input = '

    ' . $value . '

    ' . $value . '

    '; + $pattern = [ + ' ['id' => $value], + 'assertHtml($pattern, $input); + } + + /** + * test assertions fail when attributes are wrong. + */ + public function testBadAssertHtmlInvalidAttribute(): void + { + $input = 'My link'; + $pattern = [ + 'a' => ['hRef' => '/test.html', 'clAss' => 'active'], + 'My link2', + '/a', + ]; + try { + $this->assertHtml($pattern, $input); + $this->fail('Assertion should fail'); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString( + 'Attribute did not match. Was expecting Attribute `clAss` == `active`', + $e->getMessage(), + ); + } + } + + /** + * test assertion failure on incomplete HTML + */ + public function testBadAssertHtmlMissingTags(): void + { + $input = 'My link'; + $pattern = [ + ' ['href' => '/test.html', 'class' => 'active'], + 'My link', + '/a', + ]; + try { + $this->assertHtml($pattern, $input); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString( + 'Item #1 / regex #0 failed: Open getMessage(), + ); + } + } +} diff --git a/tests/TestCase/TestSuite/ConnectionHelperTest.php b/tests/TestCase/TestSuite/ConnectionHelperTest.php new file mode 100644 index 00000000000..2827389185d --- /dev/null +++ b/tests/TestCase/TestSuite/ConnectionHelperTest.php @@ -0,0 +1,101 @@ +assertSame( + ConnectionManager::get('test'), + ConnectionManager::get('default'), + ); + } + + public function testAliasNonDefaultConnections(): void + { + $connection = new Connection(['driver' => TestDriver::class]); + ConnectionManager::setConfig('test_something', $connection); + + ConnectionHelper::addTestAliases(); + + // Having a test_ alias defined will generate an alias for the unprefixed + // connection for simpler CI configuration + $this->assertSame( + ConnectionManager::get('test_something'), + ConnectionManager::get('something'), + ); + } + + public function testAliasNoTestClass(): void + { + $connection = new Connection(['driver' => TestDriver::class]); + ConnectionManager::setConfig('something', $connection); + + (new ConnectionHelper())->addTestAliases(); + + // Should raise as no test connection was defined. + $this->expectException(MissingDatasourceConfigException::class); + ConnectionManager::get('test_something'); + } + + public function testAliasNonDefaultConnectionWithTestConnection(): void + { + $testConnection = new Connection(['driver' => TestDriver::class]); + $connection = new Connection(['driver' => TestDriver::class]); + ConnectionManager::setConfig('something', $connection); + ConnectionManager::setConfig('test_something', $testConnection); + + (new ConnectionHelper())->addTestAliases(); + + // Development connections that have test_ prefix connections defined + // should have an alias defined for the test_ prefixed name. This allows + // access to the development connection to resolve to the test prefixed name + // in tests. + $this->assertSame($testConnection, ConnectionManager::get('test_something')); + $this->assertSame($testConnection, ConnectionManager::get('something')); + } + + public function testEnableQueryLogging(): void + { + $connection = new Connection(['driver' => TestDriver::class]); + ConnectionManager::setConfig('query_logging', $connection); + $this->assertFalse($connection->getDriver()->log('')); + + ConnectionHelper::enableQueryLogging(['query_logging']); + $this->assertTrue($connection->getDriver()->log('')); + } +} diff --git a/tests/TestCase/TestSuite/Constraint/EventFiredTest.php b/tests/TestCase/TestSuite/Constraint/EventFiredTest.php new file mode 100644 index 00000000000..c0258858b7c --- /dev/null +++ b/tests/TestCase/TestSuite/Constraint/EventFiredTest.php @@ -0,0 +1,38 @@ +setEventList(new EventList()); + $manager->trackEvents(true); + + $myEvent = new Event('my.event', $this, []); + $myOtherEvent = new Event('my.other.event', $this, []); + + $manager->getEventList()->add($myEvent); + $manager->getEventList()->add($myOtherEvent); + + $constraint = new EventFired($manager); + + $this->assertTrue($constraint->matches('my.event')); + $this->assertTrue($constraint->matches('my.other.event')); + $this->assertFalse($constraint->matches('event.not.fired')); + } +} diff --git a/tests/TestCase/TestSuite/Constraint/EventFiredWithTest.php b/tests/TestCase/TestSuite/Constraint/EventFiredWithTest.php new file mode 100644 index 00000000000..fc504417a7c --- /dev/null +++ b/tests/TestCase/TestSuite/Constraint/EventFiredWithTest.php @@ -0,0 +1,101 @@ +setEventList(new EventList()); + $manager->trackEvents(true); + + $myEvent = new Event('my.event', $this, [ + 'key' => 'value', + ]); + $myOtherEvent = new Event('my.other.event', $this, [ + 'key' => null, + ]); + + $obj = new stdClass(); + $myEventWithObject = new Event('my.obj.event', $this, [ + 'key' => $obj, + ]); + + $manager->getEventList()->add($myEvent); + $manager->getEventList()->add($myOtherEvent); + $manager->getEventList()->add($myEventWithObject); + + $constraint = new EventFiredWith($manager, 'key', 'value'); + + $this->assertTrue($constraint->matches('my.event')); + $this->assertFalse($constraint->matches('my.other.event')); + $this->assertFalse($constraint->matches('event.not.fired')); + + $constraint = new EventFiredWith($manager, 'key', null); + + $this->assertTrue($constraint->matches('my.other.event')); + $this->assertFalse($constraint->matches('my.event')); + + $constraint = new EventFiredWith($manager, 'key', $obj); + + $this->assertTrue($constraint->matches('my.obj.event')); + } + + /** + * tests trying to assert data key=>value when an event is fired multiple times + */ + public function testMatchesInvalid(): void + { + $this->expectException(AssertionFailedError::class); + $manager = EventManager::instance(); + $manager->setEventList(new EventList()); + $manager->trackEvents(true); + + $myEvent = new Event('my.event', $this, [ + 'key' => 'value', + ]); + + $manager->getEventList()->add($myEvent); + $manager->getEventList()->add($myEvent); + + $constraint = new EventFiredWith($manager, 'key', 'value'); + + $constraint->matches('my.event'); + } + + /** + * tests assertions on events with non-scalar data + */ + public function testMatchesArrayData(): void + { + $manager = EventManager::instance(); + $manager->setEventList(new EventList()); + $manager->trackEvents(true); + + $myEvent = new Event('my.event', $this, [ + 'data' => ['one' => 1], + ]); + + $manager->getEventList()->add($myEvent); + + $constraint = new EventFiredWith($manager, 'data', ['one' => 1]); + $constraint->matches('my.event'); + $this->assertEquals('was fired with `data` matching `{"one":1}`', $constraint->toString()); + } +} diff --git a/tests/TestCase/TestSuite/EmailTraitTest.php b/tests/TestCase/TestSuite/EmailTraitTest.php new file mode 100644 index 00000000000..72edd127a89 --- /dev/null +++ b/tests/TestCase/TestSuite/EmailTraitTest.php @@ -0,0 +1,287 @@ + 'test_tools', + 'from' => ['default@example.com' => 'Default Name'], + ]); + Mailer::setConfig('alternate', [ + 'transport' => 'test_tools', + 'from' => 'alternate@example.com', + ]); + TransportFactory::setConfig('test_tools', [ + 'className' => TestEmailTransport::class, + ]); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + + Mailer::drop('default'); + Mailer::drop('alternate'); + TransportFactory::drop('test_tools'); + } + + /** + * tests assertions against any emails that were sent + */ + public function testSingleAssertions(): void + { + $this->sendEmails(); + + $this->assertMailSentFrom(['default@example.com' => 'Default Name']); + $this->assertMailSentFrom('alternate@example.com'); + + $this->assertMailSentTo('to@example.com'); + $this->assertMailSentTo('alsoto@example.com'); + $this->assertMailSentTo('to2@example.com'); + + $this->assertMailContains('text'); + $this->assertMailContains('html'); + + $this->assertMailSubjectContains('world'); + + $this->assertMailContainsAttachment('custom_name.php'); + $this->assertMailContainsAttachment('custom_name.php', ['file' => __FILE__]); + + $this->assertMailSentWith('Hello world', 'subject'); + $this->assertMailSentWith('cc@example.com', 'cc'); + $this->assertMailSentWith('bcc@example.com', 'bcc'); + $this->assertMailSentWith('cc2@example.com', 'cc'); + $this->assertMailSentWith('replyto@example.com', 'replyTo'); + $this->assertMailSentWith('sender@example.com', 'sender'); + } + + /** + * tests multiple email assertions + */ + public function testMultipleAssertions(): void + { + $this->assertNoMailSent(); + + $this->sendEmails(); + + $this->assertMailCount(3); + + $this->assertMailSentFromAt(0, 'default@example.com'); + $this->assertMailSentFromAt(1, 'alternate@example.com'); + + // Confirm that "at 0" is really testing email 0, not all the emails + $this->assertThat('alternate@example.com', new LogicalNot(new MailSentFrom(0))); + + $this->assertMailSentToAt(0, 'to@example.com'); + $this->assertMailSentToAt(1, 'to2@example.com'); + $this->assertMailSentToAt(2, 'to3@example.com'); + + $this->assertMailContainsAt(0, 'text'); + $this->assertMailContainsAt(1, 'html'); + + $this->assertMailSubjectContainsAt(0, 'world'); + + $this->assertMailSentWithAt(0, 'Hello world', 'subject'); + $this->assertMailSentWithAt(0, 'replyto@example.com', 'replyTo'); + } + + /** + * tests assertNoMailSent fails when no mail is sent + */ + public function testAssertNoMailSentFailure(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Failed asserting that no emails were sent.'); + + $this->sendEmails(); + $this->assertNoMailSent(); + } + + /** + * tests assertMailContainsHtml fails appropriately + */ + public function testAssertContainsHtmlFailure(): void + { + $this->expectException(AssertionFailedError::class); + + $this->sendEmails(); + + $this->assertMailContainsHtmlAt(0, 'text'); + } + + /** + * tests assertMailContainsText fails appropriately + */ + public function testAssertContainsTextFailure(): void + { + $this->expectException(AssertionFailedError::class); + + $this->sendEmails(); + + $this->assertMailContainsTextAt(1, 'html'); + } + + /** + * tests multiple messages sent by same Mailer are captured correctly + */ + public function testAssertMultipleMessages(): void + { + $this->sendMultipleEmails(); + + $this->assertMailSentTo('to@example.com'); + $this->assertMailSentTo('to2@example.com'); + $this->assertMailSentFrom('reusable-mailer@example.com'); + } + + /** + * Tests asserting using RegExp characters doesn't break the assertion + */ + public function testAssertUsingRegExpCharacters(): void + { + (new Mailer()) + ->setTo('to3@example.com') + ->setCc('cc3@example.com') + ->deliver('email with regexp chars $/[]'); + + $this->assertMailContains('$/[]'); + } + + /** + * tests constraint failure messages + * + * @param string $assertion Assertion method + * @param string $expectedMessage Expected failure message + * @param array $params Assertion params + */ + #[DataProvider('failureMessageDataProvider')] + public function testFailureMessages($assertion, $expectedMessage, $params): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage($expectedMessage); + + $this->$assertion(...$params); + } + + /** + * data provider for checking failure messages + * + * @return array + */ + public static function failureMessageDataProvider(): array + { + return [ + 'assertMailCount' => ['assertMailCount', 'Failed asserting that 2 emails were sent.', [2]], + 'assertMailSentTo' => ['assertMailSentTo', "Failed asserting that 'missing@example.com' was sent an email.", ['missing@example.com']], + 'assertMailSentToAt' => ['assertMailSentToAt', "Failed asserting that 'missing@example.com' was sent email #1.", [1, 'missing@example.com']], + 'assertMailSentFrom' => ['assertMailSentFrom', "Failed asserting that 'missing@example.com' sent an email.", ['missing@example.com']], + 'assertMailSentFromAt' => ['assertMailSentFromAt', "Failed asserting that 'missing@example.com' sent email #1.", [1, 'missing@example.com']], + 'assertMailSentWith' => ['assertMailSentWith', "Failed asserting that 'Missing' is in an email `subject`.", ['Missing', 'subject']], + 'assertMailSentWithAt' => ['assertMailSentWithAt', "Failed asserting that 'Missing' is in email #1 `subject`.", [1, 'Missing', 'subject']], + 'assertMailContains' => ['assertMailContains', "Failed asserting that 'Missing' is in an email" . PHP_EOL . 'was: .', ['Missing']], + 'assertMailContainsAttachment' => ['assertMailContainsAttachment', "Failed asserting that 'no_existing_file.php' is an attachment of an email.", ['no_existing_file.php']], + 'assertMailContainsHtml' => ['assertMailContainsHtml', "Failed asserting that 'Missing' is in the html message of an email" . PHP_EOL . 'was: .', ['Missing']], + 'assertMailContainsText' => ['assertMailContainsText', "Failed asserting that 'Missing' is in the text message of an email" . PHP_EOL . 'was: .', ['Missing']], + 'assertMailContainsAt' => ['assertMailContainsAt', "Failed asserting that 'Missing' is in email #1" . PHP_EOL . 'was: .', [1, 'Missing']], + 'assertMailContainsHtmlAt' => ['assertMailContainsHtmlAt', "Failed asserting that 'Missing' is in the html message of email #1" . PHP_EOL . 'was: .', [1, 'Missing']], + 'assertMailContainsTextAt' => ['assertMailContainsTextAt', "Failed asserting that 'Missing' is in the text message of email #1" . PHP_EOL . 'was: .', [1, 'Missing']], + 'assertMailSubjectContains' => ['assertMailSubjectContains', "Failed asserting that 'Missing' is in an email subject" . PHP_EOL . 'was: .', ['Missing']], + 'assertMailSubjectContainsAt' => ['assertMailSubjectContainsAt', "Failed asserting that 'Missing' is in an email subject #1" . PHP_EOL . 'was: .', [1, 'Missing']], + ]; + } + + /** + * sends some emails + */ + private function sendEmails(): void + { + (new Mailer()) + ->setSender(['sender@example.com' => 'Sender']) + ->setTo(['to@example.com' => 'Foo Bar']) + ->addTo('alsoto@example.com') + ->setReplyTo(['replyto@example.com' => 'Reply to me']) + ->setCc('cc@example.com') + ->setBcc(['bcc@example.com' => 'Baz Qux']) + ->setSubject('Hello world') + ->setAttachments(['custom_name.php' => __FILE__]) + ->setEmailFormat(Message::MESSAGE_TEXT) + ->deliver('text'); + + (new Mailer('alternate')) + ->setTo('to2@example.com') + ->setCc('cc2@example.com') + ->setEmailFormat(Message::MESSAGE_HTML) + ->deliver('html'); + + (new Mailer('alternate')) + ->setTo(['to3@example.com' => null]) + ->deliver('html'); + } + + /** + * sends some emails + */ + private function sendMultipleEmails(): void + { + $reusableMailer = new Mailer(); + $reusableMailer + ->setEmailFormat(Message::MESSAGE_TEXT) + ->setFrom('reusable-mailer@example.com'); + + $emails = [ + 'to@example.com' => ['title' => 'Title1', 'content' => 'abc'], + 'to2@example.com' => ['title' => 'Title2', 'content' => 'xyz'], + ]; + + foreach ($emails as $email => $messageContents) { + $reusableMailer->setTo($email) + ->setSubject($messageContents['title']) + ->setViewVars($messageContents) + ->deliver(); + } + } +} diff --git a/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php b/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php new file mode 100644 index 00000000000..86f53df35a3 --- /dev/null +++ b/tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php @@ -0,0 +1,276 @@ +clearPlugins(); + ConnectionManager::dropAlias('test1'); + ConnectionManager::dropAlias('test2'); + } + + /** + * Tests loading fixtures. + */ + public function testLoadFixtures(): void + { + $this->setAppNamespace('TestApp'); + $this->loadPlugins(['TestPlugin']); + $fixtures = (new FixtureHelper())->loadFixtures([ + 'core.Articles', + 'plugin.TestPlugin.Articles', + 'plugin.TestPlugin.Blog/Comments', + 'plugin.Company/TestPluginThree.Articles', + 'app.Articles', + ]); + $this->assertNotEmpty($fixtures); + $this->assertInstanceOf(ArticlesFixture::class, $fixtures[ArticlesFixture::class]); + $this->assertInstanceOf(PluginArticlesFixture::class, $fixtures[PluginArticlesFixture::class]); + $this->assertInstanceOf(PluginCommentsFixture::class, $fixtures[PluginCommentsFixture::class]); + $this->assertInstanceOf(CompanyArticlesFixture::class, $fixtures[CompanyArticlesFixture::class]); + $this->assertInstanceOf(AppArticlesFixture::class, $fixtures[AppArticlesFixture::class]); + } + + /** + * Tests that possible table instances used in the fixture loading mechanism + * do not remain in the table locator. + */ + public function testLoadFixturesDoesNotPolluteTheTableLocator(): void + { + (new FixtureHelper())->loadFixtures([ + 'core.Articles', + 'plugin.TestPlugin.Blog/Comments', + ]); + + $this->assertFalse($this->getTableLocator()->exists('Articles')); + $this->assertFalse($this->getTableLocator()->exists('Comments')); + } + + /** + * Tests loading missing fixtures. + */ + public function testLoadMissingFixtures(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Could not find fixture `core.ThisIsMissing`'); + (new FixtureHelper())->loadFixtures(['core.ThisIsMissing']); + } + + /** + * Tests loading duplicate fixtures. + */ + public function testLoadDulicateFixtures(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Found duplicate fixture `core.Articles`'); + (new FixtureHelper())->loadFixtures(['core.Articles','core.Articles']); + } + + /** + * Tests running callback per connection + */ + public function testPerConnection(): void + { + $fixture1 = new class extends TestFixture { + public function connection(): string + { + return 'test1'; + } + + protected function _schemaFromReflection(): void + { + } + }; + $fixture2 = new class extends TestFixture { + public function connection(): string + { + return 'test2'; + } + + protected function _schemaFromReflection(): void + { + } + }; + + ConnectionManager::alias('test', 'test1'); + ConnectionManager::alias('test', 'test2'); + + $numCalls = 0; + (new FixtureHelper())->runPerConnection(function () use (&$numCalls): void { + ++$numCalls; + }, [$fixture1, $fixture2]); + $this->assertSame(2, $numCalls); + } + + /** + * Tests inserting fixtures. + */ + public function testInsertFixtures(): void + { + /** + * @var \Cake\Database\Connection $connection + */ + $connection = ConnectionManager::get('test'); + $connection->deleteQuery()->delete('articles')->execute()->closeCursor(); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertEmpty($rows->fetchAll()); + $rows->closeCursor(); + + $helper = new FixtureHelper(); + $helper->insert($helper->loadFixtures(['core.Articles'])); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertNotEmpty($rows->fetchAll()); + $rows->closeCursor(); + } + + /** + * Tests handling PDO errors when inserting rows. + */ + public function testInsertFixturesException(): void + { + $fixture = new class extends TestFixture { + public function connection(): string + { + return 'test'; + } + + protected function _schemaFromReflection(): void + { + } + + public function insert(ConnectionInterface $connection): bool + { + throw new PDOException('Missing key'); + } + }; + + $helper = new class extends FixtureHelper { + public function sortByConstraint(Connection $connection, array $fixtures): array + { + return [new class extends TestFixture { + public function connection(): string + { + return 'test'; + } + + protected function _schemaFromReflection(): void + { + } + + public function insert(ConnectionInterface $connection): bool + { + throw new PDOException('Missing key'); + } + }]; + } + }; + + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unable to insert rows for table `'); + $helper->insert([$fixture]); + } + + /** + * Tests truncating fixtures. + */ + public function testTruncateFixtures(): void + { + /** + * @var \Cake\Database\Connection $connection + */ + $connection = ConnectionManager::get('test'); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertNotEmpty($rows->fetchAll()); + $rows->closeCursor(); + + $helper = new FixtureHelper(); + $helper->truncate($helper->loadFixtures(['core.Articles'])); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertEmpty($rows->fetchAll()); + $rows->closeCursor(); + } + + /** + * Tests handling PDO errors when trucating rows. + */ + public function testTruncateFixturesException(): void + { + $fixture = new class extends TestFixture { + public function connection(): string + { + return 'test'; + } + + protected function _schemaFromReflection(): void + { + } + + public function truncate(ConnectionInterface $connection): bool + { + throw new PDOException('Missing key'); + } + }; + + $helper = new class extends FixtureHelper { + public function sortByConstraint(Connection $connection, array $fixtures): array + { + return [new class extends TestFixture { + public function connection(): string + { + return 'test'; + } + + protected function _schemaFromReflection(): void + { + } + + public function truncate(ConnectionInterface $connection): bool + { + throw new PDOException('Missing key'); + } + }]; + } + }; + + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unable to truncate table `'); + $helper->truncate([$fixture]); + } +} diff --git a/tests/TestCase/TestSuite/Fixture/SchemaLoaderTest.php b/tests/TestCase/TestSuite/Fixture/SchemaLoaderTest.php new file mode 100644 index 00000000000..09e45040256 --- /dev/null +++ b/tests/TestCase/TestSuite/Fixture/SchemaLoaderTest.php @@ -0,0 +1,175 @@ +restore = $GLOBALS['__PHPUNIT_BOOTSTRAP']; + unset($GLOBALS['__PHPUNIT_BOOTSTRAP']); + } + + $this->loader = new SchemaLoader(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + if ($this->restore !== null) { + $GLOBALS['__PHPUNIT_BOOTSTRAP'] = $this->restore; + } + + ConnectionHelper::dropTables('test', ['schema_loader_test_one', 'schema_loader_test_two']); + ConnectionManager::drop('test_schema_loader'); + + if (file_exists($this->truncateDbFile)) { + unlink($this->truncateDbFile); + } + } + + /** + * Tests loading schema files. + */ + public function testLoadSqlFiles(): void + { + $connection = ConnectionManager::get('test'); + + $schemaFiles[] = $this->createSchemaFile('schema_loader_test_one'); + $schemaFiles[] = $this->createSchemaFile('schema_loader_test_two'); + + $this->loader->loadSqlFiles($schemaFiles, 'test', false, false); + + $connection = ConnectionManager::get('test'); + $tables = $connection->getSchemaCollection()->listTables(); + $this->assertContains('schema_loader_test_one', $tables); + $this->assertContains('schema_loader_test_two', $tables); + } + + /** + * Tests loading missing files. + */ + public function testLoadMissingFile(): void + { + $this->expectException(InvalidArgumentException::class); + $this->loader->loadSqlFiles('missing_schema_file.sql', 'test', false, false); + } + + /** + * Tests dropping and truncating tables during schema load. + */ + public function testDropTruncateTables(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + ConnectionManager::setConfig('test_schema_loader', [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => $this->truncateDbFile, + ]); + + $schemaFile = $this->createSchemaFile('schema_loader_first'); + $this->loader->loadSqlFiles($schemaFile, 'test_schema_loader', true, true); + $connection = ConnectionManager::get('test_schema_loader'); + + $result = $connection->getSchemaCollection()->listTables(); + $this->assertEquals(['schema_loader_first'], $result); + + $schemaFile = $this->createSchemaFile('schema_loader_second'); + $this->loader->loadSqlFiles($schemaFile, 'test_schema_loader', true, true); + + $result = $connection->getSchemaCollection()->listTables(); + $this->assertEquals(['schema_loader_second'], $result); + + $statement = $connection->execute('SELECT * FROM schema_loader_second'); + $result = $statement->fetchAll(); + $this->assertCount(0, $result, 'Table should be empty.'); + } + + public function testLoadInternalFiles(): void + { + $this->skipIf(!extension_loaded('pdo_sqlite'), 'Skipping as SQLite extension is missing'); + ConnectionManager::setConfig('test_schema_loader', [ + 'className' => Connection::class, + 'driver' => Sqlite::class, + 'database' => $this->truncateDbFile, + ]); + + $this->loader->loadInternalFile(__DIR__ . '/test_schema.php', 'test_schema_loader'); + + $connection = ConnectionManager::get('test_schema_loader'); + /** @var \Cake\Database\Schema\Collection $schema */ + $schema = $connection->getSchemaCollection(); + $tables = $schema->listTables(); + $this->assertContains('schema_generator', $tables); + $this->assertContains('schema_generator_comment', $tables); + + $table = $schema->describe('schema_generator'); + + $constraint = $table->constraint('checked_relation_id'); + assert($constraint instanceof CheckConstraint); + $this->assertEquals('checked_relation_id', $constraint->getName()); + $this->assertEquals('relation_id > 1', $constraint->getExpression()); + + $key = $table->constraint('relation_fk'); + assert($key instanceof ForeignKey); + $this->assertEquals('relation_fk', $key->getName()); + $this->assertEquals(['relation_id'], $key->getColumns()); + } + + protected function createSchemaFile(string $tableName): string + { + $connection = ConnectionManager::get('test'); + + $schema = new TableSchema($tableName); + $schema + ->addColumn('id', 'integer') + ->addColumn('name', 'string'); + + $query = $schema->createSql($connection)[0] . ';'; + $query .= "\nINSERT INTO {$tableName} (id, name) VALUES (1, 'testing');"; + $tmpFile = tempnam(sys_get_temp_dir(), 'SchemaLoaderTest'); + file_put_contents($tmpFile, $query); + + return $tmpFile; + } +} diff --git a/tests/TestCase/TestSuite/Fixture/TestFixtureTest.php b/tests/TestCase/TestSuite/Fixture/TestFixtureTest.php new file mode 100644 index 00000000000..8b14ce4431e --- /dev/null +++ b/tests/TestCase/TestSuite/Fixture/TestFixtureTest.php @@ -0,0 +1,71 @@ +loadPlugins(['TestPlugin']); + + // Plugin fixture class should automatically get plugin prefix in alias + $pluginFixture = new TestableArticlesFixture(); + $this->assertSame('TestPlugin.TestableArticles', $pluginFixture->getAliasFromClass()); + $this->assertSame('TestPlugin.TestableArticles', $pluginFixture->tableAlias); + } + + public function testStrictFields(): void + { + $fixture = new class extends TestFixture { + public string $table = 'my_table'; + protected bool $strictFields = true; + + public function init(): void + { + parent::init(); + $this->records = [ + [ + 'non_existent_field' => 'value', + ], + ]; + } + + protected function _schemaFromReflection(): void + { + $this->_schema = new TableSchema( + 'my_table', + [], + ); + } + }; + + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Record #0 in fixture has additional fields that do not exist in the schema.' . + ' Remove the following fields: ["non_existent_field"]'); + $fixture->insert(ConnectionManager::get('test')); + } +} diff --git a/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php b/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php new file mode 100644 index 00000000000..6bf0bad00e2 --- /dev/null +++ b/tests/TestCase/TestSuite/Fixture/TransactionStrategyTest.php @@ -0,0 +1,52 @@ +deleteQuery()->delete('articles')->execute()->closeCursor(); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertEmpty($rows->fetchAll()); + $rows->closeCursor(); + + $strategy = new TransactionStrategy(); + $strategy->setupTest(['core.Articles']); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertNotEmpty($rows->fetchAll()); + $rows->closeCursor(); + + $strategy->teardownTest(); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertEmpty($rows->fetchAll()); + $rows->closeCursor(); + } +} diff --git a/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php b/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php new file mode 100644 index 00000000000..d56ddc122cc --- /dev/null +++ b/tests/TestCase/TestSuite/Fixture/TruncateStrategyTest.php @@ -0,0 +1,52 @@ +deleteQuery()->delete('articles')->execute()->closeCursor(); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertEmpty($rows->fetchAll()); + $rows->closeCursor(); + + $strategy = new TruncateStrategy(); + $strategy->setupTest(['core.Articles']); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertNotEmpty($rows->fetchAll()); + $rows->closeCursor(); + + $strategy->teardownTest(); + $rows = $connection->selectQuery()->select('*')->from('articles')->execute(); + $this->assertEmpty($rows->fetchAll()); + $rows->closeCursor(); + } +} diff --git a/tests/TestCase/TestSuite/Fixture/test_schema.php b/tests/TestCase/TestSuite/Fixture/test_schema.php new file mode 100644 index 00000000000..f451b85c0ed --- /dev/null +++ b/tests/TestCase/TestSuite/Fixture/test_schema.php @@ -0,0 +1,42 @@ + 'schema_generator_comment', + 'columns' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string', 'null' => true], + ], + 'constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ], + [ + 'table' => 'schema_generator', + 'columns' => [ + 'id' => ['type' => 'integer'], + 'relation_id' => ['type' => 'integer'], + 'title' => ['type' => 'string', 'null' => true], + 'body' => 'text', + ], + 'constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + 'relation_fk' => [ + 'type' => 'foreign', + 'columns' => ['relation_id'], + 'references' => ['schema_generator_comment', 'id'], + ], + 'checked_relation_id' => [ + 'type' => 'check', + 'expression' => 'relation_id > 1', + ], + ], + 'indexes' => [ + 'title_idx' => [ + 'type' => 'index', + 'columns' => ['title'], + ], + ], + ], +]; diff --git a/tests/TestCase/TestSuite/IntegrationTestTraitTest.php b/tests/TestCase/TestSuite/IntegrationTestTraitTest.php new file mode 100644 index 00000000000..32dde36e2fb --- /dev/null +++ b/tests/TestCase/TestSuite/IntegrationTestTraitTest.php @@ -0,0 +1,2091 @@ +setExtensions(['json']); + $routes->registerMiddleware('cookie', new EncryptedCookieMiddleware(['secrets'], $this->key)); + $routes->applyMiddleware('cookie'); + + $routes->setRouteClass(InflectedRoute::class); + $routes->get('/get/{controller}/{action}', []); + $routes->head('/head/{controller}/{action}', []); + $routes->options('/options/{controller}/{action}', []); + $routes->connect('/{controller}/{action}/*', []); + + $routes->scope('/cookie-csrf/', ['csrf' => 'cookie'], function (RouteBuilder $routes): void { + $routes->registerMiddleware('cookieCsrf', new CsrfProtectionMiddleware()); + $routes->applyMiddleware('cookieCsrf'); + $routes->connect('/posts/{action}', ['controller' => 'Posts']); + }); + $routes->scope('/session-csrf/', ['csrf' => 'session'], function (RouteBuilder $routes): void { + $routes->registerMiddleware('sessionCsrf', new SessionCsrfProtectionMiddleware()); + $routes->applyMiddleware('sessionCsrf'); + $routes->connect('/posts/{action}/', ['controller' => 'Posts']); + }); + }; + $routesClosure(Router::createRouteBuilder('/')); + Configure::write('TestApp.routes', $routesClosure); + + $this->configApplication(Configure::read('App.namespace') . '\Application', null); + } + + /** + * Tests that all data that used by the request is cast to strings + */ + public function testDataCastToString(): void + { + $data = [ + 'title' => 'Blog Post', + 'status' => 1, + 'published' => true, + 'not_published' => false, + 'comments' => [ + [ + 'body' => 'Comment', + 'status' => 1, + ], + ], + 'file' => [ + 'tmp_name' => __FILE__, + 'size' => 42, + 'error' => 0, + 'type' => 'text/plain', + 'name' => 'Uploaded file', + ], + 'pictures' => [ + 'name' => [ + ['file' => 'a-file.png'], + ['file' => 'a-moose.png'], + ], + 'type' => [ + ['file' => 'image/png'], + ['file' => 'image/jpg'], + ], + 'tmp_name' => [ + ['file' => __FILE__], + ['file' => __FILE__], + ], + 'error' => [ + ['file' => 0], + ['file' => 0], + ], + 'size' => [ + ['file' => 17188], + ['file' => 2010], + ], + ], + 'upload' => new UploadedFile(__FILE__, 42, 0), + ]; + $request = $this->_buildRequest('/posts/add', 'POST', $data); + $this->assertIsString($request['post']['status']); + $this->assertIsString($request['post']['published']); + $this->assertSame('0', $request['post']['not_published']); + $this->assertIsString($request['post']['comments'][0]['status']); + $this->assertIsInt($request['post']['file']['error']); + $this->assertIsInt($request['post']['file']['size']); + $this->assertIsInt($request['post']['pictures']['error'][0]['file']); + $this->assertIsInt($request['post']['pictures']['error'][1]['file']); + $this->assertIsInt($request['post']['pictures']['size'][0]['file']); + $this->assertIsInt($request['post']['pictures']['size'][1]['file']); + $this->assertInstanceOf(UploadedFile::class, $request['post']['upload']); + } + + /** + * Test building a request. + */ + public function testRequestBuilding(): void + { + $this->requestAsJson(); + $this->configRequest([ + 'headers' => [ + 'X-CSRF-Token' => 'abc123', + ], + 'base' => '', + 'webroot' => '/', + 'environment' => [ + 'PHP_AUTH_USER' => 'foo', + 'PHP_AUTH_PW' => 'bar', + ], + ]); + $this->cookie('split_token', 'def345'); + $this->session(['User' => ['id' => '1', 'username' => 'mark']]); + $request = $this->_buildRequest('/tasks/add', 'POST', ['title' => 'First post']); + + $this->assertSame('abc123', $request['environment']['HTTP_X_CSRF_TOKEN']); + $this->assertSame('application/json', $request['environment']['CONTENT_TYPE']); + $this->assertSame('/tasks/add', $request['url']); + $this->assertArrayHasKey('split_token', $request['cookies']); + $this->assertSame('def345', $request['cookies']['split_token']); + $this->assertSame(['id' => '1', 'username' => 'mark'], $request['session']->read('User')); + $this->assertSame('foo', $request['environment']['PHP_AUTH_USER']); + $this->assertSame('bar', $request['environment']['PHP_AUTH_PW']); + + $this->replaceRequest([ + 'headers' => [ + 'X-CSRF-Token' => 'test321', + ], + ]); + $this->assertSame('test321', $this->_request['headers']['X-CSRF-Token']); + $this->assertArrayNotHasKey('webroot', $this->_request); + } + + /** + * Test request building adds csrf tokens + */ + public function testRequestBuildingCsrfTokens(): void + { + $this->enableCsrfToken(); + $request = $this->_buildRequest('/tasks/add', 'POST', ['title' => 'First post']); + + $this->assertArrayHasKey('csrfToken', $request['cookies']); + $this->assertArrayHasKey('_csrfToken', $request['post']); + $this->assertSame($request['cookies']['csrfToken'], $request['post']['_csrfToken']); + $this->assertSame($request['session']->read('csrfToken'), $request['post']['_csrfToken']); + + $this->cookie('csrfToken', ''); + $request = $this->_buildRequest('/tasks/add', 'POST', [ + '_csrfToken' => 'fale', + 'title' => 'First post', + ]); + + $this->assertSame('', $request['cookies']['csrfToken']); + $this->assertSame('fale', $request['post']['_csrfToken']); + } + + /** + * Test multiple actions using CSRF tokens don't fail + */ + public function testEnableCsrfMultipleRequests(): void + { + $this->enableCsrfToken(); + $first = $this->_buildRequest('/tasks/add', 'POST', ['title' => 'First post']); + $second = $this->_buildRequest('/tasks/add', 'POST', ['title' => 'Second post']); + $this->assertSame( + $first['cookies']['csrfToken'], + $second['post']['_csrfToken'], + 'Csrf token should match cookie', + ); + $this->assertSame( + $first['session']->read('csrfToken'), + $second['post']['_csrfToken'], + 'Csrf token should match session', + ); + $this->assertSame( + $first['post']['_csrfToken'], + $second['post']['_csrfToken'], + 'Tokens should be consistent per test method', + ); + } + + /** + * Test for issue #17612 - skip adding tokens for GET without data. + */ + public function testAddTokenInGetRequest(): void + { + $this->enableCsrfToken(); + $this->enableSecurityToken(); + $requestWithoutTokens = $this->_buildRequest('tasks/view', 'GET'); + + $this->assertArrayNotHasKey('_Token', $requestWithoutTokens['post']); + $this->assertArrayNotHasKey('_csrfToken', $requestWithoutTokens['post']); + $this->assertArrayNotHasKey('csrfToken', $requestWithoutTokens['cookies']); + + $this->enableCsrfToken(); + $this->enableSecurityToken(); + $requestWithTokens = $this->_buildRequest('tasks/view', 'GET', ['lorem' => 'ipsum']); + + $this->assertArrayHasKey('_Token', $requestWithTokens['post']); + $this->assertArrayHasKey('csrfToken', $requestWithTokens['cookies']); + $this->assertArrayNotHasKey('_csrfToken', $requestWithTokens['post']); + } + + /** + * Test building a request, with query parameters + */ + public function testRequestBuildingQueryParameters(): void + { + $request = $this->_buildRequest('/tasks/view?archived=yes', 'GET', []); + + $this->assertSame('/tasks/view', $request['url']); + $this->assertSame('archived=yes', $request['environment']['QUERY_STRING']); + $this->assertSame('/tasks/view', $request['environment']['REQUEST_URI']); + } + + /** + * Test cookie encrypted + * + * @see CookieComponentControllerTest + */ + public function testCookieEncrypted(): void + { + Security::setSalt($this->key); + $this->cookieEncrypted('KeyOfCookie', 'Encrypted with aes by default'); + $request = $this->_buildRequest('/tasks/view', 'GET', []); + $this->assertStringStartsWith('Q2FrZQ==.', $request['cookies']['KeyOfCookie']); + } + + /** + * Test sending get request and using default `test_app/config/routes.php`. + */ + public function testGetUsingApplicationWithPluginRoutes(): void + { + // first clean routes to have Router::$initailized === false + Router::reload(); + $this->clearPlugins(); + + $this->configApplication(Configure::read('App.namespace') . '\ApplicationWithPluginRoutes', null); + + $this->get('/test_plugin'); + $this->assertResponseOk(); + } + + /** + * Test sending get request and using default `test_app/config/routes.php`. + */ + public function testGetUsingApplicationWithDefaultRoutes(): void + { + // first clean routes to have Router::$initialized === false + Router::reload(); + + $this->configApplication(Configure::read('App.namespace') . '\ApplicationWithDefaultRoutes', null); + + $this->get('/some_alias'); + $this->assertResponseOk(); + $this->assertSame('5', $this->_getBodyAsString()); + } + + public function testExceptionsInMiddlewareJsonView(): void + { + Router::reload(); + Configure::write('TestApp.routes', function (RouteBuilder $routes): void { + $routes->connect('/json_response/api_get_data', [ + 'controller' => 'JsonResponse', + 'action' => 'apiGetData', + ]); + }); + + $this->configApplication(Configure::read('App.namespace') . '\ApplicationWithExceptionsInMiddleware', null); + + $this->_request['headers'] = ['Accept' => 'application/json']; + $this->get('/json_response/api_get_data'); + $this->assertResponseCode(403); + $this->assertHeader('Content-Type', 'application/json'); + $this->assertResponseContains('"message": "Sample Message"'); + $this->assertResponseContains('"code": 403'); + } + + /** + * Test sending head requests. + */ + public function testHead(): void + { + $this->assertNull($this->_response); + + $this->head('/request_action/test_request_action'); + $this->assertNotEmpty($this->_response); + $this->assertInstanceOf(Response::class, $this->_response); + $this->assertResponseSuccess(); + } + + /** + * Test sending head requests. + */ + public function testHeadMethodRoute(): void + { + $this->head('/head/request_action/test_request_action'); + $this->assertResponseSuccess(); + } + + /** + * Test sending options requests. + */ + public function testOptions(): void + { + $this->assertNull($this->_response); + + $this->options('/request_action/test_request_action'); + $this->assertNotEmpty($this->_response); + $this->assertInstanceOf(Response::class, $this->_response); + $this->assertResponseSuccess(); + } + + /** + * Test sending options requests. + */ + public function testOptionsMethodRoute(): void + { + $this->options('/options/request_action/test_request_action'); + $this->assertResponseSuccess(); + } + + /** + * Test sending get requests sets the request method + */ + public function testGetSpecificRouteHttpServer(): void + { + $this->get('/get/request_action/test_request_action'); + $this->assertResponseOk(); + $this->assertSame('This is a test', (string)$this->_response->getBody()); + } + + /** + * Test customizing the app class. + */ + public function testConfigApplication(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot load `TestApp\MissingApp` for use in integration'); + $this->configApplication('TestApp\MissingApp', []); + $this->get('/request_action/test_request_action'); + } + + /** + * Test sending get requests with Http\Server + */ + public function testGetHttpServer(): void + { + $this->assertNull($this->_response); + + $this->get('/request_action/test_request_action'); + $this->assertNotEmpty($this->_response); + $this->assertInstanceOf(Response::class, $this->_response); + $this->assertSame('This is a test', (string)$this->_response->getBody()); + $this->assertHeader('X-Middleware', 'true'); + } + + /** + * Test that the PSR7 requests get query string data + */ + public function testGetQueryStringHttpServer(): void + { + $this->configRequest(['headers' => ['Content-Type' => 'text/plain']]); + $this->get('/request_action/params_pass?q=query'); + $this->assertResponseOk(); + $this->assertResponseContains('"q":"query"'); + $this->assertResponseContains('"contentType":"text\/plain"'); + $this->assertHeader('X-Middleware', 'true'); + + $request = $this->_controller->getRequest(); + $this->assertStringContainsString('/request_action/params_pass?q=query', $request->getRequestTarget()); + } + + /** + * Test that the PSR7 requests get query string data + */ + public function testGetQueryStringSetsHere(): void + { + $this->configRequest(['headers' => ['Content-Type' => 'text/plain']]); + $this->get('/request_action/params_pass?q=query'); + $this->assertResponseOk(); + $this->assertResponseContains('"q":"query"'); + $this->assertResponseContains('"contentType":"text\/plain"'); + $this->assertHeader('X-Middleware', 'true'); + + $request = $this->_controller->getRequest(); + $this->assertStringContainsString('/request_action/params_pass?q=query', $request->getRequestTarget()); + $this->assertStringContainsString('/request_action/params_pass', $request->getAttribute('here')); + } + + /** + * Test that the PSR7 requests get cookies + */ + public function testGetCookiesHttpServer(): void + { + $this->configRequest(['cookies' => ['split_test' => 'abc']]); + $this->get('/request_action/cookie_pass'); + $this->assertResponseOk(); + $this->assertResponseContains('"split_test":"abc"'); + $this->assertHeader('X-Middleware', 'true'); + } + + /** + * Test that the PSR7 requests receive post data + */ + public function testPostDataHttpServer(): void + { + $this->post('/request_action/post_pass', ['title' => 'value']); + $data = json_decode('' . $this->_response->getBody()); + $this->assertSame('value', $data->title); + $this->assertHeader('X-Middleware', 'true'); + } + + /** + * Test that the PSR7 requests receive put data + */ + public function testPutDataFormUrlEncoded(): void + { + $this->configRequest([ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + ]); + $this->put('/request_action/post_pass', ['title' => 'value']); + $this->assertResponseOk(); + $data = json_decode('' . $this->_response->getBody()); + $this->assertSame('value', $data->title); + } + + /** + * Test that the uploaded files are passed correctly to the request + */ + public function testUploadedFiles(): void + { + $this->configRequest([ + 'files' => [ + 'file' => [ + 'tmp_name' => __FILE__, + 'size' => 42, + 'error' => 0, + 'type' => 'text/plain', + 'name' => 'Uploaded file', + ], + 'pictures' => [ + 'name' => [ + ['file' => 'a-file.png'], + ['file' => 'a-moose.png'], + ], + 'type' => [ + ['file' => 'image/png'], + ['file' => 'image/jpg'], + ], + 'tmp_name' => [ + ['file' => __FILE__], + ['file' => __FILE__], + ], + 'error' => [ + ['file' => 0], + ['file' => 0], + ], + 'size' => [ + ['file' => 17188], + ['file' => 2010], + ], + ], + 'upload' => new UploadedFile(__FILE__, 42, 0), + ], + ]); + $this->post('/request_action/uploaded_files'); + $this->assertHeader('X-Middleware', 'true'); + $data = json_decode((string)$this->_response->getBody(), true); + + $this->assertSame([ + 'file' => 'Uploaded file', + 'pictures.0.file' => 'a-file.png', + 'pictures.1.file' => 'a-moose.png', + 'upload' => null, + ], $data); + } + + /** + * Test that the PSR7 requests receive encoded data. + */ + public function testInputDataHttpServer(): void + { + $this->post('/request_action/input_test', '{"hello":"world"}'); + if ($this->_response->getBody()->isSeekable()) { + $this->_response->getBody()->rewind(); + } + $this->assertSame('world', $this->_response->getBody()->getContents()); + $this->assertHeader('X-Middleware', 'true'); + } + + /** + * Test that the PSR7 requests receive encoded data. + */ + public function testInputDataSecurityToken(): void + { + $this->enableSecurityToken(); + + $this->post('/request_action/input_test', '{"hello":"world"}'); + $this->assertSame('world', '' . $this->_response->getBody()); + $this->assertHeader('X-Middleware', 'true'); + } + + /** + * Test that the PSR7 requests get cookies + */ + public function testSessionHttpServer(): void + { + $this->session(['foo' => 'session data']); + $this->get('/request_action/session_test'); + $this->assertResponseOk(); + $this->assertResponseContains('session data'); + $this->assertHeader('X-Middleware', 'true'); + } + + /** + * Test sending requests stores references to controller/view/layout. + */ + public function testRequestSetsProperties(): void + { + $this->post('/posts/index'); + $this->assertInstanceOf(Controller::class, $this->_controller); + $this->assertNotEmpty($this->_viewName, 'View name not set'); + $this->assertStringContainsString('templates' . DS . 'Posts' . DS . 'index.php', $this->_viewName); + $this->assertNotEmpty($this->_layoutName, 'Layout name not set'); + $this->assertStringContainsString('templates' . DS . 'layout' . DS . 'default.php', $this->_layoutName); + + $this->assertTemplate('index'); + $this->assertLayout('default'); + $this->assertSame('value', $this->viewVariable('test')); + } + + /** + * Test PSR7 requests store references to controller/view/layout + */ + public function testRequestSetsPropertiesHttpServer(): void + { + $this->post('/posts/index'); + $this->assertInstanceOf(Controller::class, $this->_controller); + $this->assertNotEmpty($this->_viewName, 'View name not set'); + $this->assertStringContainsString('templates' . DS . 'Posts' . DS . 'index.php', $this->_viewName); + $this->assertNotEmpty($this->_layoutName, 'Layout name not set'); + $this->assertStringContainsString('templates' . DS . 'layout' . DS . 'default.php', $this->_layoutName); + + $this->assertTemplate('index'); + $this->assertLayout('default'); + $this->assertSame('value', $this->viewVariable('test')); + } + + /** + * Tests URLs containing extensions. + */ + public function testRequestWithExt(): void + { + $this->get(['controller' => 'Posts', 'action' => 'ajax', '_ext' => 'json']); + + $this->assertResponseCode(200); + } + + /** + * Assert that the stored template doesn't change when cells are rendered. + */ + public function testAssertTemplateAfterCellRender(): void + { + $this->get('/posts/get'); + $this->assertStringContainsString('templates' . DS . 'Posts' . DS . 'get.php', $this->_viewName); + $this->assertTemplate('get'); + $this->assertResponseContains('cellcontent'); + } + + /** + * Test array URLs + */ + public function testArrayUrls(): void + { + $this->post(['controller' => 'Posts', 'action' => 'index', '_method' => 'POST']); + $this->assertResponseOk(); + $this->assertSame('value', $this->viewVariable('test')); + } + + /** + * Test array URL with host + */ + public function testArrayUrlWithHost(): void + { + $this->get([ + 'controller' => 'Posts', + 'action' => 'hostData', + '_host' => 'app.example.org', + '_https' => true, + ]); + $this->assertResponseOk(); + $this->assertResponseContains('"isSsl":true'); + $this->assertResponseContains('"host":"app.example.org"'); + } + + /** + * Test array URLs with an empty router. + */ + public function testArrayUrlsEmptyRouter(): void + { + Router::reload(); + $this->assertEmpty(Router::getRouteCollection()->routes()); + + $this->get(['controller' => 'Posts', 'action' => 'index']); + $this->assertResponseOk(); + $this->assertSame('value', $this->viewVariable('test')); + } + + /** + * Test flash and cookie assertions + */ + public function testFlashSessionAndCookieAsserts(): void + { + $this->post('/posts/index'); + + $this->assertSession('An error message', 'Flash.flash.0.message'); + $this->assertCookie('1', 'remember_me'); + $this->assertCookieNotSet('user_id'); + } + + /** + * Test flash and cookie assertions + */ + public function testFlashSessionAndCookieAssertsHttpServer(): void + { + $this->post('/posts/index'); + + $this->assertSession('An error message', 'Flash.flash.0.message'); + $this->assertCookieNotSet('user_id'); + $this->assertCookie('1', 'remember_me'); + } + + /** + * Test flash assertions stored with enableRememberFlashMessages() after a + * redirect. + */ + public function testFlashAssertionsAfterRedirect(): void + { + $this->get('/posts/someRedirect'); + + $this->assertResponseCode(302); + + $this->assertSession('A success message', 'Flash.flash.0.message'); + } + + /** + * Test flash assertions stored with enableRememberFlashMessages() after they + * are rendered + */ + public function testFlashAssertionsAfterRender(): void + { + $this->enableRetainFlashMessages(); + $this->get('/posts/index/with_flash'); + + $this->assertResponseCode(200); + + $this->assertSession('An error message', 'Flash.flash.0.message'); + } + + /** + * Test asserting session and flash messages. + */ + public function testFlashAssertionsWithSession(): void + { + $this->enableRetainFlashMessages(); + $this->get('/posts/flashWithSession'); + $this->assertRedirect(); + + $this->assertFlashElement('flash/error'); + $this->assertSession(true, 'test'); + + $result = $this->_requestSession->read('Flash.flash'); + $this->assertSame(['flash/error'], Hash::extract($result, '{n}.element')); + } + + /** + * Test flash assertions stored with enableRememberFlashMessages() even if + * no view is rendered + */ + public function testFlashAssertionsWithNoRender(): void + { + $this->enableRetainFlashMessages(); + $this->get('/posts/flashNoRender'); + $this->assertRedirect(); + + $this->assertFlashElement('flash/error'); + $this->assertFlashMessage('An error message'); + } + + /** + * If multiple requests occur in the same test method + * flash messages should be retained. + */ + public function testFlashAssertionMultipleRequests(): void + { + $this->enableRetainFlashMessages(); + $this->disableErrorHandlerMiddleware(); + + $this->get('/posts/index/with_flash'); + $this->assertResponseCode(200); + $this->assertFlashMessage('An error message'); + + $this->get('/posts/someRedirect'); + $this->assertResponseCode(302); + $this->assertFlashMessage('A success message'); + } + + /** + * Test flash assertions stored with enableRememberFlashMessages() even if + * the controller clears flash data in `beforeRender` + */ + public function testFlashAssertionsRemoveInBeforeRender(): void + { + $this->enableRetainFlashMessages(); + $this->get('/posts/index/with_flash/?clear=true'); + $this->assertResponseOk(); + + $this->assertFlashElement('flash/error'); + $this->assertFlashMessage('An error message'); + } + + /** + * Tests assertCookieNotSet assertion + */ + public function testAssertCookieNotSet(): void + { + $this->cookie('test', 'value'); + $this->get('/posts/index'); + $this->assertCookieNotSet('test'); + + $this->get('/posts/redirectWithCookie'); + $this->assertCookieNotSet('test'); + } + + /** + * Tests the failure message for assertCookieNotSet + */ + public function testCookieNotSetFailure(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage("Failed asserting that 'remember_me' cookie is not set"); + $this->post('/posts/index'); + $this->assertCookieNotSet('remember_me'); + } + + /** + * Tests the failure message for assertCookieNotSet when no + * response whas generated + */ + public function testCookieNotSetFailureNoResponse(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('No response set, cannot assert content.'); + $this->assertCookieNotSet('remember_me'); + } + + /** + * Tests assertCookieIsSet assertion + */ + public function testAssertCookieIsSet(): void + { + $this->get('/posts/secretCookie'); + $this->assertCookieIsSet('secrets'); + + $this->get('/posts/redirectWithCookie'); + $this->assertCookieIsSet('remember'); + } + + /** + * Tests the failure message for assertCookieIsSet + */ + public function testCookieIsSetFailure(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage("Failed asserting that 'not-secrets' cookie is set"); + $this->post('/posts/secretCookie'); + $this->assertCookieIsSet('not-secrets'); + } + + /** + * Tests the failure message for assertCookieIsSet when no + * response whas generated + */ + public function testCookieIsSetFailureNoResponse(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('No response set, cannot assert content.'); + $this->assertCookieIsSet('secrets'); + } + + /** + * Test error handling and error page rendering. + */ + public function testPostAndErrorHandling(): void + { + $this->post('/request_action/error_method'); + $this->assertResponseNotEmpty(); + $this->assertResponseContains('Not there or here'); + $this->assertResponseContains(''); + } + + /** + * Test posting to a secured form action. + */ + public function testPostSecuredForm(): void + { + $this->enableSecurityToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/posts/securePost', $data); + $this->assertResponseOk(); + $this->assertResponseContains('Request was accepted'); + } + + /** + * Test posting to a secured form action. + */ + public function testPostSecuredFormNumericField(): void + { + $this->enableSecurityToken(); + $data = [ + '123456789' => 'Some text', + ]; + $this->post('/posts/securePost', $data); + $this->assertResponseOk(); + $this->assertResponseContains('Request was accepted'); + } + + /** + * Test posting to a secured form action with nested data. + */ + public function testPostSecuredFormNestedData(): void + { + $this->enableSecurityToken(); + $data = [ + 'title' => 'New post', + 'comments' => [ + ['comment' => 'A new comment'], + ], + 'tags' => ['_ids' => [1, 2, 3, 4]], + ]; + $this->post('/posts/securePost', $data); + $this->assertResponseOk(); + $this->assertResponseContains('Request was accepted'); + } + + /** + * Test posting to a secured form action with unlocked fields + */ + public function testPostSecuredFormUnlockedFieldsFails(): void + { + $this->enableSecurityToken(); + $data = [ + 'title' => 'New post', + 'comments' => [ + ['comment' => 'A new comment'], + ], + 'tags' => ['_ids' => [1, 2, 3, 4]], + 'some_unlocked_field' => 'Unlocked data', + ]; + $this->post('/posts/securePost', $data); + $this->assertResponseCode(400); + $this->assertResponseContains('Invalid form protection debug token.'); + } + + /** + * Test posting to a secured form action with unlocked fields + */ + public function testPostSecuredFormUnlockedFieldsWithSet(): void + { + $this->enableSecurityToken(); + $data = [ + 'title' => 'New post', + 'comments' => [ + ['comment' => 'A new comment'], + ], + 'tags' => ['_ids' => [1, 2, 3, 4]], + 'some_unlocked_field' => 'Unlocked data', + ]; + $this->setUnlockedFields(['some_unlocked_field']); + $this->post('/posts/securePost', $data); + $this->assertResponseOk(); + $this->assertResponseContains('Request was accepted'); + } + + /** + * Test posting to a secured form action. + */ + public function testPostSecuredFormWithQuery(): void + { + $this->enableSecurityToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/posts/securePost?foo=bar', $data); + $this->assertResponseOk(); + $this->assertResponseContains('Request was accepted'); + } + + /** + * Test posting to a secured form action with a query that has a part that + * will be encoded by the security component + */ + public function testPostSecuredFormWithUnencodedQuery(): void + { + $this->enableSecurityToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/posts/securePost?foo=/', $data); + $this->assertResponseOk(); + $this->assertResponseContains('Request was accepted'); + } + + /** + * Test that security token does not include debug field when debug mode is disabled + */ + public function testPostSecuredFormWithDebugDisabled(): void + { + Configure::write('debug', false); + + $this->enableSecurityToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/posts/securePost', $data); + $this->assertResponseOk(); + $this->assertResponseContains('Request was accepted'); + } + + /** + * Test posting to a secured form action action. + */ + public function testPostSecuredFormFailure(): void + { + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/posts/securePost', $data); + $this->assertResponseError(); + } + + /** + * Integration test for cookie based CSRF token protection success + */ + public function testPostCookieCsrfSuccess(): void + { + $this->enableCsrfToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/cookie-csrf/posts/header', $data); + $this->assertResponseSuccess(); + } + + /** + * Integration test for cookie based CSRF token protection fail + */ + public function testPostCookieCsrfFailure(): void + { + $this->enableCsrfToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + '_csrfToken' => 'failure', + ]; + $this->post('/cookie-csrf/posts/header', $data); + $this->assertResponseCode(403); + } + + /** + * Integration test for session based CSRF token protection success + */ + public function testPostSessionCsrfSuccess(): void + { + $this->enableCsrfToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/session-csrf/posts/header', $data); + $this->assertResponseSuccess(); + } + + /** + * Integration test for session based CSRF token protection fail + */ + public function testPostSessionCsrfFailure(): void + { + $this->enableCsrfToken(); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + '_csrfToken' => 'failure', + ]; + $this->post('/session-csrf/posts/header', $data); + $this->assertResponseCode(403); + } + + /** + * Integration test for session based CSRF token protection success with specified cookie name + */ + public function testPostSessionCsrfSuccessWithSetCookieName(): void + { + Configure::write('TestApp.routes', function (RouteBuilder $routes): void { + $routes->scope('/custom-cookie-csrf/', ['csrf' => 'cookie'], function (RouteBuilder $routes): void { + $routes->registerMiddleware('cookieCsrf', new CsrfProtectionMiddleware( + [ + 'cookieName' => 'customCsrfToken', + ], + )); + $routes->applyMiddleware('cookieCsrf'); + $routes->connect('/posts/{action}', ['controller' => 'Posts']); + }); + }); + + $this->enableCsrfToken('customCsrfToken'); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + ]; + $this->post('/custom-cookie-csrf/posts/header', $data); + $this->assertResponseSuccess(); + } + + /** + * Integration test for session based CSRF token protection fail with specified cookie name + */ + public function testPostSessionCsrfFailureWithSetCookieName(): void + { + Configure::write('TestApp.routes', function (RouteBuilder $routes): void { + $routes->scope('/custom-cookie-csrf/', ['csrf' => 'cookie'], function (RouteBuilder $routes): void { + $routes->registerMiddleware('cookieCsrf', new CsrfProtectionMiddleware( + [ + 'cookieName' => 'customCsrfToken', + ], + )); + $routes->applyMiddleware('cookieCsrf'); + $routes->connect('/posts/{action}', ['controller' => 'Posts']); + }); + }); + + $this->enableCsrfToken('customCsrfToken'); + $data = [ + 'title' => 'Some title', + 'body' => 'Some text', + '_csrfToken' => 'failure', + ]; + $this->post('/custom-cookie-csrf/posts/header', $data); + $this->assertResponseCode(403); + } + + /** + * Test that exceptions being thrown are handled correctly. + */ + public function testWithExpectedException(): void + { + $this->get('/tests_apps/throw_exception'); + $this->assertResponseCode(500); + } + + /** + * Test that exceptions being thrown are handled correctly by the psr7 stack. + */ + public function testWithExpectedExceptionHttpServer(): void + { + $this->get('/tests_apps/throw_exception'); + $this->assertResponseCode(500); + } + + /** + * Test that exceptions being thrown are handled correctly. + */ + public function testWithUnexpectedException(): void + { + $this->expectException(AssertionFailedError::class); + $this->get('/tests_apps/throw_exception'); + $this->assertResponseCode(501); + } + + /** + * Test redirecting and integration tests. + */ + public function testRedirect(): void + { + $this->post('/tests_apps/redirect_to'); + $this->assertResponseSuccess(); + $this->assertResponseCode(302); + } + + /** + * Test redirecting and psr7 stack + */ + public function testRedirectHttpServer(): void + { + $this->post('/tests_apps/redirect_to'); + $this->assertResponseCode(302); + $this->assertHeader('X-Middleware', 'true'); + } + + /** + * Test redirecting and integration tests. + */ + public function testRedirectPermanent(): void + { + $this->post('/tests_apps/redirect_to_permanent'); + $this->assertResponseSuccess(); + $this->assertResponseCode(301); + } + + /** + * Test the responseOk status assertion + */ + public function testAssertResponseStatusCodes(): void + { + $this->_response = new Response(); + + $this->_response = $this->_response->withStatus(200); + $this->assertResponseOk(); + + $this->_response = $this->_response->withStatus(201); + $this->assertResponseOk(); + + $this->_response = $this->_response->withStatus(204); + $this->assertResponseOk(); + + $this->_response = $this->_response->withStatus(202); + $this->assertResponseSuccess(); + + $this->_response = $this->_response->withStatus(302); + $this->assertResponseSuccess(); + + $this->_response = $this->_response->withStatus(400); + $this->assertResponseError(); + + $this->_response = $this->_response->withStatus(417); + $this->assertResponseError(); + + $this->_response = $this->_response->withStatus(500); + $this->assertResponseFailure(); + + $this->_response = $this->_response->withStatus(505); + $this->assertResponseFailure(); + + $this->_response = $this->_response->withStatus(301); + $this->assertResponseCode(301); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirect(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withHeader('Location', 'http://localhost/get/tasks/index'); + + $this->assertRedirect(); + $this->assertRedirect('/get/tasks/index'); + $this->assertRedirect(['controller' => 'Tasks', 'action' => 'index']); + + $this->assertResponseEmpty(); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirectBack(): void + { + $this->_response = new Response(); + $this->_request = [ + 'url' => '/get/tasks/index', + ]; + $this->_response = $this->_response + ->withStatus(302) + ->withHeader('Location', 'http://localhost/get/tasks/index'); + + $this->assertRedirectBack(); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirectBackSpecificCode(): void + { + $this->_response = new Response(); + $this->_request = [ + 'url' => '/get/tasks/index', + ]; + $this->_response = $this->_response + ->withStatus(301) + ->withHeader('Location', 'http://localhost/get/tasks/index'); + + $this->assertRedirectBack(301); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirectBackInvalid(): void + { + $this->_response = new Response(); + $this->_request = [ + 'url' => '/get/tasks/edit', + ]; + $this->_response = $this->_response + ->withStatus(302) + ->withHeader('Location', 'http://localhost/get/tasks/index'); + + $this->expectException(AssertionFailedError::class); + + $this->assertRedirectBack(); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirectBackToReferer(): void + { + $this->_response = new Response(); + $this->_request = [ + 'environment' => [ + 'HTTP_REFERER' => 'http://localhost/get/tasks/index', + ], + ]; + $this->_response = $this->_response + ->withStatus(302) + ->withHeader('Location', 'http://localhost/get/tasks/index'); + + $this->assertRedirectBackToReferer(); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirectBackToRefererSpecificCode(): void + { + $this->_response = new Response(); + $this->_request = [ + 'environment' => [ + 'HTTP_REFERER' => 'http://localhost/get/tasks/index', + ], + ]; + $this->_response = $this->_response + ->withStatus(301) + ->withHeader('Location', 'http://localhost/get/tasks/index'); + + $this->assertRedirectBackToReferer(301); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirectBackToRefererInvalid(): void + { + $this->_response = new Response(); + $this->_request = [ + 'environment' => [ + 'HTTP_REFERER' => 'http://localhost/get/tasks/index', + ], + ]; + $this->_response = $this->_response + ->withStatus(302) + ->withHeader('Location', 'http://localhost/get/tasks/view/1'); + + $this->expectException(AssertionFailedError::class); + + $this->assertRedirectBackToReferer(); + } + + /** + * Test the location header assertion. + */ + public function testAssertRedirectEquals(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withHeader('Location', '/get/tasks/index'); + + $this->assertRedirect(); + $this->assertRedirectEquals('/get/tasks/index'); + $this->assertRedirectEquals(['controller' => 'Tasks', 'action' => 'index']); + + $this->assertResponseEmpty(); + } + + /** + * Test the location header assertion string not contains + */ + public function testAssertRedirectNotContains(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withHeader('Location', 'http://localhost/tasks/index'); + $this->assertRedirectNotContains('test'); + } + + /** + * Test the location header assertion. + */ + public function testAssertNoRedirect(): void + { + $this->_response = new Response(); + + $this->assertNoRedirect(); + } + + /** + * Test the location header assertion string contains + */ + public function testAssertRedirectContains(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withHeader('Location', 'http://localhost/tasks/index'); + + $this->assertRedirectContains('/tasks/index'); + } + + /** + * Test assertRedirect with relative URL in Location header + */ + public function testAssertRedirectWithRelativeUrl(): void + { + $this->_response = new Response(); + // Simulate authentication plugin returning relative URL + $this->_response = $this->_response->withHeader('Location', '/get/users/login'); + + // Should work with string URL + $this->assertRedirect('/get/users/login'); + + // Should work with array URL + $this->assertRedirect(['controller' => 'Users', 'action' => 'login']); + } + + /** + * Test assertRedirectEquals with relative URL in Location header + */ + public function testAssertRedirectEqualsWithRelativeUrl(): void + { + $this->_response = new Response(); + // Simulate authentication plugin returning relative URL + $this->_response = $this->_response->withHeader('Location', '/get/users/login'); + + // Should work with string URL + $this->assertRedirectEquals('/get/users/login'); + + // Should work with array URL + $this->assertRedirectEquals(['controller' => 'Users', 'action' => 'login']); + } + + /** + * Test assertRedirect with named routes and relative URLs + */ + public function testAssertRedirectWithNamedRouteAndRelativeUrl(): void + { + Router::createRouteBuilder('/')->connect( + '/user/signin', + ['controller' => 'Users', 'action' => 'login'], + ['_name' => 'login:form'], + ); + + $this->_response = new Response(); + // Simulate authentication plugin returning relative URL + $this->_response = $this->_response->withHeader('Location', '/user/signin'); + + // Should work with named route + $this->assertRedirect(['_name' => 'login:form']); + $this->assertRedirectEquals(['_name' => 'login:form']); + } + + /** + * Test assertRedirect handles mixed absolute/relative URLs correctly + */ + public function testAssertRedirectMixedUrlFormats(): void + { + $this->_response = new Response(); + + // Test with absolute URL in header, checking with relative path + $this->_response = $this->_response->withHeader('Location', 'http://localhost/get/tasks/view/1'); + $this->assertRedirect('/get/tasks/view/1'); + $this->assertRedirectEquals('/get/tasks/view/1'); + + // Test with relative URL in header, checking with absolute URL + $this->_response = $this->_response->withHeader('Location', '/get/tasks/edit/2'); + $this->assertRedirect('http://localhost/get/tasks/edit/2'); + $this->assertRedirectEquals('http://localhost/get/tasks/edit/2'); + } + + /** + * Test the header assertion. + */ + public function testAssertHeader(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withHeader('Etag', 'abc123'); + + $this->assertHeader('Etag', 'abc123'); + } + + /** + * Test the header contains assertion. + */ + public function testAssertHeaderContains(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withHeader('Etag', 'abc123'); + + $this->assertHeaderContains('Etag', 'abc'); + } + + /** + * Test the header not contains assertion. + */ + public function testAssertHeaderNotContains(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withHeader('Etag', 'abc123'); + + $this->assertHeaderNotContains('Etag', 'xyz'); + } + + /** + * Test the content type assertion. + */ + public function testAssertContentType(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withType('json'); + + $this->assertContentType('json'); + $this->assertContentType('application/json'); + } + + /** + * Test that type() in an action sets the content-type header. + */ + public function testContentTypeInAction(): void + { + $this->get('/tests_apps/set_type'); + $this->assertHeader('Content-Type', 'application/json'); + $this->assertContentType('json'); + $this->assertContentType('application/json'); + } + + /** + * Test the content assertion. + */ + public function testAssertResponseEquals(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withStringBody('Some content'); + + $this->assertResponseEquals('Some content'); + } + + /** + * Test the negated content assertion. + */ + public function testAssertResponseNotEquals(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withStringBody('Some content'); + + $this->assertResponseNotEquals('Some Content'); + } + + /** + * Test the content assertion. + */ + public function testAssertResponseContains(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withStringBody('Some content'); + + $this->assertResponseContains('content'); + } + + /** + * Test the content assertion with no case sensitivity. + */ + public function testAssertResponseContainsWithIgnoreCaseFlag(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withStringBody('Some content'); + + $this->assertResponseContains('some', 'Failed asserting that the body contains given content', true); + } + + /** + * Test the negated content assertion. + */ + public function testAssertResponseNotContains(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withStringBody('Some content'); + + $this->assertResponseNotContains('contents'); + } + + /** + * Test the content regexp assertion. + */ + public function testAssertResponseRegExp(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withStringBody('Some content'); + + $this->assertResponseRegExp('/cont/'); + } + + /** + * Test the content regexp assertion failing + */ + public function testAssertResponseRegExpNoResponse(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('No response set'); + $this->assertResponseRegExp('/cont/'); + } + + /** + * Test the negated content regexp assertion. + */ + public function testAssertResponseNotRegExp(): void + { + $this->_response = new Response(); + $this->_response = $this->_response->withStringBody('Some content'); + + $this->assertResponseNotRegExp('/cant/'); + } + + /** + * Test negated content regexp assertion failing + */ + public function testAssertResponseNotRegExpNoResponse(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('No response set'); + $this->assertResponseNotRegExp('/cont/'); + } + + /** + * Test that works in tandem with testEventManagerReset2 to + * test the EventManager reset. + * + * The return value is passed to testEventManagerReset2 as + * an arguments. + */ + public function testEventManagerReset1(): EventManager + { + $eventManager = EventManager::instance(); + $this->assertInstanceOf(EventManager::class, $eventManager); + + return $eventManager; + } + + /** + * Test if the EventManager is reset between tests. + */ + #[Depends('testEventManagerReset1')] + public function testEventManagerReset2(EventManager $prevEventManager): void + { + $this->assertInstanceOf(EventManager::class, $prevEventManager); + $this->assertNotSame($prevEventManager, EventManager::instance()); + } + + /** + * Test sending file in requests. + */ + public function testSendFile(): void + { + $this->get('/posts/file'); + $this->assertFileResponse(TEST_APP . 'TestApp' . DS . 'Controller' . DS . 'PostsController.php'); + } + + /** + * Test sending file in requests. + */ + public function testSendUnlinked(): void + { + $file = microtime(true) . 'txt'; + $path = TMP . $file; + file_put_contents($path, 'testing unlink'); + + $this->get("/posts/file?file={$file}"); + $this->assertResponseOk(); + $this->assertFileResponse($path); + $this->assertFileExists($path); + system("rm -rf {$path}"); + $this->assertFileDoesNotExist($path); + } + + /** + * Test sending file with psr7 stack + */ + public function testSendFileHttpServer(): void + { + $this->get('/posts/file'); + $this->assertFileResponse(TEST_APP . 'TestApp' . DS . 'Controller' . DS . 'PostsController.php'); + } + + /** + * Test that assertFile requires a response + */ + public function testAssertFileNoResponse(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('No response set, cannot assert content'); + $this->assertFileResponse('foo'); + } + + /** + * Test that assertFile requires a file + */ + public function testAssertFileNoFile(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Failed asserting that file was sent.'); + $this->get('/posts/get'); + $this->assertFileResponse('foo'); + } + + /** + * Test disabling the error handler middleware with exceptions + * in controllers. + */ + public function testDisableErrorHandlerMiddleware(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('oh no!'); + $this->disableErrorHandlerMiddleware(); + $this->get('/posts/throw_exception'); + } + + /** + * tests getting a secure action while passing a query string + */ + #[DataProvider('methodsProvider')] + public function testSecureWithQueryString(string $method): void + { + $this->enableSecurityToken(); + $this->{$method}('/posts/securePost/?ids[]=1&ids[]=2'); + $this->assertResponseOk(); + } + + /** + * Tests flash assertions + * + * @throws \PHPUnit\Exception + */ + public function testAssertFlashMessage(): void + { + $this->get('/posts/stacked_flash'); + + $this->assertFlashElement('flash/error'); + $this->assertFlashElement('flash/success', 'custom'); + + $this->assertFlashMessage('Error 1'); + $this->assertFlashMessageAt(0, 'Error 1'); + $this->assertFlashElementAt(0, 'flash/error'); + + $this->assertFlashMessage('Error 2'); + $this->assertFlashMessageAt(1, 'Error 2'); + $this->assertFlashElementAt(1, 'flash/error'); + + $this->assertFlashMessage('Success 1', 'custom'); + $this->assertFlashMessageAt(0, 'Success 1', 'custom'); + $this->assertFlashElementAt(0, 'flash/success', 'custom'); + + $this->assertFlashMessage('Success 2', 'custom'); + $this->assertFlashMessageAt(1, 'Success 2', 'custom'); + $this->assertFlashElementAt(1, 'flash/success', 'custom'); + } + + /** + * Tests assertFlashMessageContains assertions. + * + * @throws \PHPUnit\Exception + */ + public function testAssertFlashMessageContains(): void + { + $this->get('/posts/stacked_flash'); + + // Test contains assertions for exact messages + $this->assertFlashMessageContains('Error'); + $this->assertFlashMessageContains('Error 1'); + $this->assertFlashMessageContains('Error 2'); + $this->assertFlashMessageContains('rror 1'); // partial match + + // Test contains with specific key + $this->assertFlashMessageContains('Success', 'custom'); + $this->assertFlashMessageContains('Success 1', 'custom'); + $this->assertFlashMessageContains('Success 2', 'custom'); + $this->assertFlashMessageContains('uccess 1', 'custom'); // partial match + + // Test contains at specific index + $this->assertFlashMessageContainsAt(0, 'Error 1'); + $this->assertFlashMessageContainsAt(0, 'rror 1'); // partial match + $this->assertFlashMessageContainsAt(1, 'Error 2'); + $this->assertFlashMessageContainsAt(1, 'rror 2'); // partial match + + // Test contains at specific index with custom key + $this->assertFlashMessageContainsAt(0, 'Success 1', 'custom'); + $this->assertFlashMessageContainsAt(0, 'uccess 1', 'custom'); // partial match + $this->assertFlashMessageContainsAt(1, 'Success 2', 'custom'); + $this->assertFlashMessageContainsAt(1, 'uccess 2', 'custom'); // partial match + + // Test case-insensitive matching + $this->assertFlashMessageContains('error 1', 'flash', '', true); + $this->assertFlashMessageContains('ERROR 1', 'flash', '', true); + $this->assertFlashMessageContainsAt(0, 'ERROR 1', 'flash', '', true); + $this->assertFlashMessageContains('success 1', 'custom', '', true); + $this->assertFlashMessageContainsAt(0, 'SUCCESS 1', 'custom', '', true); + } + + /** + * Tests asserting flash messages without first sending a request + */ + public function testAssertFlashMessageWithoutSendingRequest(): void + { + $this->expectException(AssertionFailedError::class); + $message = 'There is no stored session data. Perhaps you need to run a request?'; + $message .= ' Additionally, ensure `$this->enableRetainFlashMessages()` has been enabled for the test.'; + $this->expectExceptionMessage($message); + + $this->assertFlashMessage('Will not work'); + } + + /** + * Tests asserting flash message contains without first sending a request + */ + public function testAssertFlashMessageContainsWithoutSendingRequest(): void + { + $this->expectException(AssertionFailedError::class); + $message = 'There is no stored session data. Perhaps you need to run a request?'; + $message .= ' Additionally, ensure `$this->enableRetainFlashMessages()` has been enabled for the test.'; + $this->expectExceptionMessage($message); + + $this->assertFlashMessageContains('Will not work'); + } + + /** + * tests failure messages for assertions + * + * @param string $assertion Assertion method + * @param string $message Expected failure message + * @param string $url URL to test + * @param mixed ...$rest + */ + #[DataProvider('assertionFailureMessagesProvider')] + public function testAssertionFailureMessages($assertion, $message, $url, ...$rest): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage($message); + + Security::setSalt($this->key); + + $this->get($url); + + $this->$assertion(...$rest); + } + + /** + * Test for assertion message generation for previous. + * + * @return void + */ + public function testAssertMessagePrevious(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Caused by `RuntimeException`'); + + $this->get('/posts/throw_chained'); + $this->assertContentType('test'); + } + + /** + * data provider for assertion failure messages + * + * @return array + */ + public static function assertionFailureMessagesProvider(): array + { + $templateDir = TEST_APP . 'templates' . DS; + + return [ + 'assertContentType' => ['assertContentType', "Failed asserting that 'test' is set as the Content-Type (`text/html`).", '/posts/index', 'test'], + 'assertContentTypeVerbose' => ['assertContentType', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'test'], + 'assertCookie' => ['assertCookie', "Failed asserting that 'test' is in cookie 'remember_me'.", '/posts/index', 'test', 'remember_me'], + 'assertCookieVerbose' => ['assertCookie', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'test', 'remember_me'], + 'assertCookieEncrypted' => ['assertCookieEncrypted', "Failed asserting that 'test' is encrypted in cookie 'secrets'.", '/posts/secretCookie', 'test', 'secrets'], + 'assertCookieEncryptedVerbose' => ['assertCookieEncrypted', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'test', 'NameOfCookie'], + 'assertCookieNotSet' => ['assertCookieNotSet', "Failed asserting that 'remember_me' cookie is not set.", '/posts/index', 'remember_me'], + 'assertFileResponse' => ['assertFileResponse', "Failed asserting that 'test' file was sent.", '/posts/file', 'test'], + 'assertFileResponseVerbose' => ['assertFileResponse', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'test'], + 'assertHeader' => ['assertHeader', "Failed asserting that 'test' equals content in header 'X-Cake' (`custom header`).", '/posts/header', 'X-Cake', 'test'], + 'assertHeaderContains' => ['assertHeaderContains', "Failed asserting that 'test' is in header 'X-Cake' (`custom header`)", '/posts/header', 'X-Cake', 'test'], + 'assertHeaderNotContains' => ['assertHeaderNotContains', "Failed asserting that 'custom header' is not in header 'X-Cake' (`custom header`)", '/posts/header', 'X-Cake', 'custom header'], + 'assertHeaderContainsVerbose' => ['assertHeaderContains', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'X-Cake', 'test'], + 'assertHeaderNotContainsVerbose' => ['assertHeaderNotContains', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'X-Cake', 'test'], + 'assertLayout' => ['assertLayout', "Failed asserting that 'custom_layout' equals layout file `" . $templateDir . 'layout' . DS . 'default.php`.', '/posts/index', 'custom_layout'], + 'assertLayoutVerbose' => ['assertLayout', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'custom_layout'], + 'assertRedirect' => ['assertRedirect', "Failed asserting that 'http://localhost/' equals content in header 'Location' (`http://localhost/posts`).", '/posts/flashNoRender', '/'], + 'assertRedirectVerbose' => ['assertRedirect', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', '/'], + 'assertRedirectContains' => ['assertRedirectContains', "Failed asserting that '/posts/somewhere-else' is in header 'Location' (`http://localhost/posts`).", '/posts/flashNoRender', '/posts/somewhere-else'], + 'assertRedirectContainsVerbose' => ['assertRedirectContains', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', '/posts/somewhere-else'], + 'assertRedirectNotContainsVerbose' => ['assertRedirectNotContains', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', '/posts/somewhere-else'], + 'assertResponseCode' => ['assertResponseCode', 'Failed asserting that `302` matches response status code `200`.', '/posts/index', 302], + 'assertResponseContains' => ['assertResponseContains', "Failed asserting that 'test' is in response body.", '/posts/index', 'test'], + 'assertResponseEmpty' => ['assertResponseEmpty', 'Failed asserting that response body is empty.', '/posts/index'], + 'assertResponseEquals' => ['assertResponseEquals', "Failed asserting that 'test' matches response body.", '/posts/index', 'test'], + 'assertResponseEqualsVerbose' => ['assertResponseEquals', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'test'], + 'assertResponseError' => ['assertResponseError', 'Failed asserting that 200 is between 400 and 429.', '/posts/index'], + 'assertResponseFailure' => ['assertResponseFailure', 'Failed asserting that 200 is between 500 and 505.', '/posts/index'], + 'assertResponseNotContains' => ['assertResponseNotContains', "Failed asserting that 'index' is not in response body.", '/posts/index', 'index'], + 'assertResponseNotEmpty' => ['assertResponseNotEmpty', 'Failed asserting that response body is not empty.', '/posts/empty_response'], + 'assertResponseNotEquals' => ['assertResponseNotEquals', "Failed asserting that 'posts index' does not match response body.", '/posts/index/error', 'posts index'], + 'assertResponseNotRegExp' => ['assertResponseNotRegExp', 'Failed asserting that `/index/` PCRE pattern not found in response body.', '/posts/index/error', '/index/'], + 'assertResponseNotRegExpVerbose' => ['assertResponseNotRegExp', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', '/index/'], + 'assertResponseOk' => ['assertResponseOk', 'Failed asserting that 404 is between 200 and 204.', '/posts/missing', '/index/'], + 'assertResponseRegExp' => ['assertResponseRegExp', 'Failed asserting that `/test/` PCRE pattern found in response body.', '/posts/index/error', '/test/'], + 'assertResponseSuccess' => ['assertResponseSuccess', 'Failed asserting that 404 is between 200 and 308.', '/posts/missing'], + 'assertResponseSuccessVerbose' => ['assertResponseSuccess', 'Possibly related to `Cake\Controller\Exception\MissingActionException`: "Action `PostsController::missing()` could not be found, or is not accessible."', '/posts/missing'], + + 'assertSession' => ['assertSession', "Failed asserting that 'test' is in session path 'Missing.path'.", '/posts/index', 'test', 'Missing.path'], + 'assertSessionHasKey' => ['assertSessionHasKey', "Failed asserting that 'Missing.path' is a path present in the session.", '/posts/index', 'Missing.path'], + 'assertSessionNotHasKey' => ['assertSessionNotHasKey', "Failed asserting that 'Flash.flash' is not a path present in the session.", '/posts/index', 'Flash.flash'], + + 'assertTemplate' => ['assertTemplate', "Failed asserting that 'custom_template' equals template file `" . $templateDir . 'Posts' . DS . 'index.php`.', '/posts/index', 'custom_template'], + 'assertTemplateVerbose' => ['assertTemplate', 'Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."', '/notfound', 'custom_template'], + 'assertFlashMessage' => ['assertFlashMessage', "Failed asserting that 'missing' is in 'flash' message.", '/posts/index', 'missing'], + 'assertFlashMessageWithKey' => ['assertFlashMessage', "Failed asserting that 'missing' is in 'auth' message.", '/posts/index', 'missing', 'auth'], + 'assertFlashMessageAt' => ['assertFlashMessageAt', "Failed asserting that 'missing' is in 'flash' message #0.", '/posts/index', 0, 'missing'], + 'assertFlashMessageAtWithKey' => ['assertFlashMessageAt', "Failed asserting that 'missing' is in 'auth' message #0.", '/posts/index', 0, 'missing', 'auth'], + 'assertFlashElement' => ['assertFlashElement', "Failed asserting that 'missing' is in 'flash' element.", '/posts/index', 'missing'], + 'assertFlashElementWithKey' => ['assertFlashElement', "Failed asserting that 'missing' is in 'auth' element.", '/posts/index', 'missing', 'auth'], + 'assertFlashElementAt' => ['assertFlashElementAt', "Failed asserting that 'missing' is in 'flash' element #0.", '/posts/index', 0, 'missing'], + 'assertFlashElementAtWithKey' => ['assertFlashElementAt', "Failed asserting that 'missing' is in 'auth' element #0.", '/posts/index', 0, 'missing', 'auth'], + ]; + } + + /** + * data provider for HTTP methods + * + * @return array + */ + public static function methodsProvider(): array + { + return [ + 'GET' => ['get'], + 'POST' => ['post'], + 'PATCH' => ['patch'], + 'PUT' => ['put'], + 'DELETE' => ['delete'], + ]; + } + + /** + * Test assertCookieNotSet is creating a verbose message + */ + public function testAssertCookieNotSetVerbose(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."'); + $this->get('/notfound'); + $this->_response = $this->_response->withCookie(new Cookie('cookie', 'value')); + $this->assertCookieNotSet('cookie'); + } + + /** + * Test assertNoRedirect is creating a verbose message + */ + public function testAssertNoRedirectVerbose(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."'); + $this->get('/notfound'); + $this->_response = $this->_response->withHeader('Location', '/redirect'); + $this->assertNoRedirect(); + } + + /** + * Test the header assertion generating a verbose message. + */ + public function testAssertHeaderVerbose(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."'); + $this->get('/notfound'); + $this->assertHeader('Etag', 'abc123'); + } + + /** + * Test the assertResponseNotEquals generates a verbose message. + */ + public function testAssertResponseNotEqualsVerbose(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."'); + $this->get('/notfound'); + $this->_response = $this->_response->withStringBody('body'); + $this->assertResponseNotEquals('body'); + } + + /** + * Test the assertResponseRegExp generates a verbose message. + */ + public function testAssertResponseRegExpVerbose(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Possibly related to `Cake\Routing\Exception\MissingRouteException`: "A route matching `/notfound` could not be found."'); + $this->get('/notfound'); + $this->_response = $this->_response->withStringBody('body'); + $this->assertResponseRegExp('/patternNotFound/'); + } + + /** + * Test the assertion generates a verbose message for session related checks. + * + * @param mixed ...$rest + */ + #[DataProvider('assertionFailureSessionVerboseProvider')] + public function testAssertSessionRelatedVerboseMessages(string $assertMethod, ...$rest): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Possibly related to `OutOfBoundsException`: "oh no!"'); + $this->get('/posts/throw_exception'); + $this->_requestSession = new Session(); + $this->$assertMethod(...$rest); + } + + /** + * data provider for assertion verbose session related tests + * + * @return array + */ + public static function assertionFailureSessionVerboseProvider(): array + { + return [ + 'assertFlashMessageVerbose' => ['assertFlashMessage', 'notfound'], + 'assertFlashMessageAtVerbose' => ['assertFlashMessageAt', 2, 'notfound'], + 'assertFlashElementVerbose' => ['assertFlashElement', 'notfound'], + 'assertSessionVerbose' => ['assertSession', 'notfound', 'notfound'], + ]; + } + + /** + * Test viewVariable not found + */ + public function testViewVariableNotFoundShouldReturnNull(): void + { + $this->_controller = new Controller(new ServerRequest()); + $this->assertNull($this->viewVariable('notFound')); + } + + /** + * Integration test for a controller with action dependencies. + */ + public function testHandleWithContainerDependencies(): void + { + $this->get('/dependencies/requiredDep'); + $this->assertResponseOk(); + $this->assertResponseContains('"key":"value"', 'Contains the data from the stdClass container object.'); + } + + /** + * Test that mockService() injects into controllers. + */ + public function testHandleWithMockServices(): void + { + $this->mockService(stdClass::class, function () { + return json_decode('{"mock":true}'); + }); + $this->get('/dependencies/requiredDep'); + $this->assertResponseOk(); + $this->assertResponseContains('"mock":true', 'Contains the data from the stdClass mock container.'); + } + + /** + * Test that mockService() injects into controllers. + */ + public function testHandleWithMockServicesFromReflectionContainer(): void + { + $this->mockService(ReflectionDependency::class, function () { + return new ReflectionDependency(); + }); + $this->get('/dependencies/reflectionDep'); + $this->assertResponseOk(); + $this->assertResponseContains('{"dep":{}}', 'Contains the data from the reflection container'); + } + + /** + * Test that mockService() injects into controllers. + */ + public function testHandleWithMockServicesOverwrite(): void + { + $this->mockService(stdClass::class, function () { + return json_decode('{"first":true}'); + }); + $this->mockService(stdClass::class, function () { + return json_decode('{"second":true}'); + }); + $this->get('/dependencies/requiredDep'); + $this->assertResponseOk(); + $this->assertResponseContains('"second":true', 'Contains the data from the stdClass mock container.'); + } + + /** + * Test that removeMock() unsets mocks + */ + public function testHandleWithMockServicesUnset(): void + { + $this->mockService(stdClass::class, function () { + return json_decode('{"first":true}'); + }); + $this->removeMockService(stdClass::class); + + $this->get('/dependencies/requiredDep'); + $this->assertResponseOk(); + $this->assertResponseNotContains('"first":true'); + } +} diff --git a/tests/TestCase/TestSuite/LogTestTraitTest.php b/tests/TestCase/TestSuite/LogTestTraitTest.php new file mode 100644 index 00000000000..dd97350530c --- /dev/null +++ b/tests/TestCase/TestSuite/LogTestTraitTest.php @@ -0,0 +1,222 @@ +setupLog('debug'); + Log::setConfig([ + 'error' => [ + 'className' => TestAppLog::class, + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + ]); + Log::debug('This usually needs to happen inside your app'); + $this->assertLogMessage('debug', 'This usually needs to happen inside your app'); + } + + /** + * Test expecting log messages from different engines + */ + public function testExpectMultipleLog(): void + { + $this->setupLog(['debug', 'error']); + Log::debug('This usually needs to happen inside your app'); + Log::error('Some error message'); + $this->assertLogMessage('debug', 'This usually needs to happen inside your app'); + $this->assertLogMessage('error', 'Some error message'); + } + + /** + * Test log messages from lower levels don't get mixed up with upper level ones + */ + public function testExpectMultipleLogsMixedUpWithHigherFails(): void + { + $this->setupLog(['debug', 'error']); + Log::debug('This usually needs to happen inside your app'); + Log::error('Some error message'); + + $this->expectException(AssertionFailedError::class); + $this->assertLogMessage('error', 'This usually needs to happen inside your app'); + } + + /** + * Test log messages from higher levels don't get mixed up with lower level ones + */ + public function testExpectMultipleLogsMixedUpWithLowerFails(): void + { + $this->setupLog(['debug', 'error']); + Log::debug('This usually needs to happen inside your app'); + Log::error('Some error message'); + + $this->expectException(AssertionFailedError::class); + $this->assertLogMessage('debug', 'Some error message'); + } + + /** + * Test expecting log messages contains + */ + public function testExpectLogContains(): void + { + $this->setupLog('debug'); + Log::debug('This usually needs to happen inside your app'); + $this->assertLogMessageContains('debug', 'This usually needs'); + } + + /** + * Test expecting log message without setup + */ + public function testExpectLogWithoutSetup(): void + { + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Could not find the message `debug: ` in logs.'); + $this->assertLogMessage('debug', ''); + } + + /** + * Test expecting log messages from different engines with custom configs + */ + public function testExpectMultipleLogWithLevels(): void + { + $this->setupLog([ + 'debug' => [ + 'levels' => ['notice', 'info', 'debug'], + ], + 'error' => [ + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + ]); + Log::notice('This is a notice message'); + Log::info('This is a info message'); + Log::debug('This is a debug message'); + Log::warning('This is a warning message'); + Log::error('This is a error message'); + Log::critical('This is a critical message'); + Log::alert('This is a alert message'); + Log::emergency('This is a emergency message'); + $this->assertLogMessage('notice', 'This is a notice message'); + $this->assertLogMessage('info', 'This is a info message'); + $this->assertLogMessage('debug', 'This is a debug message'); + $this->assertLogMessage('warning', 'This is a warning message'); + $this->assertLogMessage('error', 'This is a error message'); + $this->assertLogMessage('critical', 'This is a critical message'); + $this->assertLogMessage('alert', 'This is a alert message'); + $this->assertLogMessage('emergency', 'This is a emergency message'); + } + + /** + * Test expecting log messages from different engines with custom configs + */ + public function testExpectMultipleLogAbsent(): void + { + $this->setupLog([ + 'error' => [ + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + ]); + Log::notice('This is a notice message'); + Log::info('This is a info message'); + Log::debug('This is a debug message'); + Log::warning('This is a warning message'); + Log::error('This is a error message'); + Log::critical('This is a critical message'); + Log::alert('This is a alert message'); + Log::emergency('This is a emergency message'); + + $this->assertLogAbsent('notice', 'No notice messages should be captured'); + $this->assertLogAbsent('info', 'No info messages should be captured'); + $this->assertLogAbsent('debug', 'No debug messages should be captured'); + $this->assertLogMessage('warning', 'This is a warning message'); + $this->assertLogMessage('error', 'This is a error message'); + $this->assertLogMessage('critical', 'This is a critical message'); + $this->assertLogMessage('alert', 'This is a alert message'); + $this->assertLogMessage('emergency', 'This is a emergency message'); + } + + public function testAbsentLogWithSetup(): void + { + $this->setupLog([ + 'error' => [ + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + ]); + $this->assertLogAbsent('warning', 'This is a warning message'); + $this->assertLogAbsent('error', 'This is a error message'); + $this->assertLogAbsent('critical', 'This is a critical message'); + $this->assertLogAbsent('alert', 'This is a critical message'); + $this->assertLogAbsent('emergency', 'This is a emergency message'); + } + + public function testAbsentLogWithoutSetup(): void + { + Log::setConfig([ + 'debug' => [ + 'className' => TestAppLog::class, + 'levels' => ['notice', 'info', 'debug'], + ], + ]); + Log::debug('This is a debug message'); + $this->expectNotToPerformAssertions(); + $this->assertLogAbsent('debug', 'Some error message'); + } + + /** + * Test expecting log messages from different engines with custom configs + */ + public function testExpectMultipleLogWithLevelsAndScopes(): void + { + $this->setupLog([ + 'debug' => [ + 'levels' => ['notice', 'info', 'debug'], + ], + 'error' => [ + 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], + ], + 'scoped' => [ + 'scopes' => ['app.security'], + 'levels' => ['info'], + ], + ]); + Log::info('This is a info message'); + Log::info('security settings changed', 'app.security'); + Log::warning('This is a warning message'); + Log::error('This is a error message'); + + $this->assertLogMessage('info', 'This is a info message'); + $this->assertLogMessage('info', 'security settings changed'); + $this->assertLogMessage('info', 'security settings changed', 'app.security'); + $this->assertLogMessage('warning', 'This is a warning message'); + $this->assertLogMessage('error', 'This is a error message'); + } +} diff --git a/tests/TestCase/TestSuite/PluginBootstrapTest.php b/tests/TestCase/TestSuite/PluginBootstrapTest.php new file mode 100644 index 00000000000..344aeb84286 --- /dev/null +++ b/tests/TestCase/TestSuite/PluginBootstrapTest.php @@ -0,0 +1,158 @@ + ["bootstrap" => true]];', + ); + + // Clear any existing configuration + Configure::delete('Test.plugins'); + + // Enable plugin loading for tests + enablePluginLoadingForTests($testConfigDir); + + // Check that the configuration was stored + $stored = Configure::read('Test.plugins'); + $this->assertIsArray($stored); + $this->assertArrayHasKey('TestPlugin', $stored); + $this->assertEquals(['bootstrap' => true], $stored['TestPlugin']); + + // Clean up + unlink($testConfigDir . 'plugins.php'); + rmdir($testConfigDir); + } + + /** + * Test that loadAllPlugins method reads from configuration + * + * @return void + */ + public function testLoadAllPluginsWithConfiguredPlugins(): void + { + // Set up plugin configuration + Configure::write('Test.plugins', [ + 'TestPlugin' => ['bootstrap' => false], + 'TestPluginTwo' => ['bootstrap' => true], + ]); + + // Clear any existing state + Plugin::getCollection()->clear(); + $this->appPluginsToLoad = []; + + // Load all plugins using the TestCase method + $result = $this->loadAllPlugins(); + + // Check that the method returns $this for chaining + $this->assertSame($this, $result); + + // When using IntegrationTestTrait, loadAllPlugins sets appPluginsToLoad + $this->assertArrayHasKey('TestPlugin', $this->appPluginsToLoad); + $this->assertArrayHasKey('TestPluginTwo', $this->appPluginsToLoad); + $this->assertEquals(['bootstrap' => false], $this->appPluginsToLoad['TestPlugin']); + $this->assertEquals(['bootstrap' => true], $this->appPluginsToLoad['TestPluginTwo']); + } + + /** + * Test that enablePluginLoadingForTests throws exception for missing file + * + * @return void + */ + public function testEnablePluginLoadingForTestsWithMissingFile(): void + { + // Clear any existing configuration + Configure::delete('Test.plugins'); + + // Expect exception when plugins.php file doesn't exist + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to load plugins list'); + + // Try to enable with non-existent path + enablePluginLoadingForTests('/non/existent/path/'); + } + + /** + * Test that enablePluginLoadingForTests raises error for invalid plugins.php return value + * + * @return void + */ + public function testEnablePluginLoadingForTestsWithInvalidReturn(): void + { + // Create a test plugins.php file that returns non-array + $testConfigDir = TMP . 'test_invalid_config' . DS; + if (!is_dir($testConfigDir)) { + mkdir($testConfigDir, 0777, true); + } + + file_put_contents( + $testConfigDir . 'plugins.php', + 'expectException(RuntimeException::class); + $this->expectExceptionMessage('The plugins configuration file'); + $this->expectExceptionMessage('must return an array'); + + enablePluginLoadingForTests($testConfigDir); + + // Clean up (won't reach here due to exception) + unlink($testConfigDir . 'plugins.php'); + rmdir($testConfigDir); + } + + /** + * @inheritDoc + */ + public function tearDown(): void + { + parent::tearDown(); + Plugin::getCollection()->clear(); + Configure::delete('Test.plugins'); + } +} diff --git a/tests/TestCase/TestSuite/TestCaseTest.php b/tests/TestCase/TestSuite/TestCaseTest.php new file mode 100644 index 00000000000..9d8dd3a153d --- /dev/null +++ b/tests/TestCase/TestSuite/TestCaseTest.php @@ -0,0 +1,509 @@ +clearPlugins(); + } + + /** + * tests trying to assertEventFired without configuring an event list + */ + public function testEventFiredMisconfiguredEventList(): void + { + $this->expectException(AssertionFailedError::class); + $manager = EventManager::instance(); + $this->assertEventFired('my.event', $manager); + } + + /** + * tests trying to assertEventFired without configuring an event list + */ + public function testEventFiredWithMisconfiguredEventList(): void + { + $this->expectException(AssertionFailedError::class); + $manager = EventManager::instance(); + $this->assertEventFiredWith('my.event', 'some', 'data', $manager); + } + + /** + * tests assertEventFiredWith + */ + public function testEventFiredWith(): void + { + $manager = EventManager::instance(); + $manager->setEventList(new EventList()); + $manager->trackEvents(true); + + $event = new Event('my.event', $this, [ + 'some' => 'data', + ]); + $manager->dispatch($event); + $this->assertEventFiredWith('my.event', 'some', 'data'); + + $manager = new EventManager(); + $manager->setEventList(new EventList()); + $manager->trackEvents(true); + + $event = new Event('my.event', $this, [ + 'other' => 'data', + ]); + $manager->dispatch($event); + $this->assertEventFiredWith('my.event', 'other', 'data', $manager); + } + + /** + * tests assertEventFired + */ + public function testEventFired(): void + { + $manager = EventManager::instance(); + $manager->setEventList(new EventList()); + $manager->trackEvents(true); + + $event = new Event('my.event'); + $manager->dispatch($event); + $this->assertEventFired('my.event'); + + $manager = new EventManager(); + $manager->setEventList(new EventList()); + $manager->trackEvents(true); + + $event = new Event('my.event'); + $manager->dispatch($event); + $this->assertEventFired('my.event', $manager); + } + + /** + * testSkipIf + */ + #[WithoutErrorHandler] + public function testSkipIf(): void + { + $test = new FixturizedTestCase('testSkipIfTrue'); + $test->run(); + $result = $test->status(); + $this->assertInstanceOf(Skipped::class, $result); + + $test = new FixturizedTestCase('testSkipIfFalse'); + $test->run(); + $result = $test->status(); + $this->assertInstanceOf(Success::class, $result); + } + + /** + * test withErrorReporting + */ + public function testWithErrorReporting(): void + { + $errorLevel = error_reporting(); + $this->withErrorReporting(E_USER_WARNING, function (): void { + $this->assertSame(E_USER_WARNING, error_reporting()); + }); + $this->assertSame($errorLevel, error_reporting()); + } + + /** + * test withCaptureError + */ + public function testCaptureError(): void + { + $error = $this->captureError(E_USER_WARNING, function (): void { + trigger_error('Something bad', E_USER_WARNING); + }); + $this->assertEquals('Something bad', $error->getMessage()); + $this->assertEqualsWithDelta(__LINE__, $error->getLine(), 10); + $this->assertEquals(E_USER_WARNING, $error->getCode()); + $this->assertEquals(__FILE__, $error->getFile()); + } + + /** + * test withCaptureError + */ + public function testCaptureErrorNoError(): void + { + $this->expectException(AssertionFailedError::class); + $this->captureError(E_USER_WARNING, function (): void { + // nothing + }); + } + + /** + * test withErrorReporting with exceptions + */ + public function testWithErrorReportingWithException(): void + { + $this->expectException(AssertionFailedError::class); + + $errorLevel = error_reporting(); + try { + $this->withErrorReporting(E_USER_WARNING, function (): void { + $this->assertSame(1, 2); + }); + } finally { + $this->assertSame($errorLevel, error_reporting()); + } + } + + /** + * test deprecated + */ + public function testDeprecated(): void + { + $this->deprecated(function (): void { + trigger_error('deprecation message', E_USER_DEPRECATED); + }); + } + + /** + * test deprecated with assert after trigger warning + */ + public function testDeprecatedWithAssertAfterTriggerWarning(): void + { + try { + $this->deprecated(function (): void { + trigger_error('deprecation message', E_USER_DEPRECATED); + $this->fail('A random message'); + }); + + $this->fail(); + } catch (Exception $e) { + $this->assertStringContainsString('A random message', $e->getMessage()); + } + } + + /** + * test deprecated + */ + public function testDeprecatedWithNoDeprecation(): void + { + try { + $this->deprecated(function (): void { + }); + + $this->fail(); + } catch (Exception $e) { + $this->assertStringStartsWith('Should have at least one deprecation warning', $e->getMessage()); + } + } + + /** + * test deprecated() with duplicate deprecation with same messsage and line + */ + public function testDeprecatedWithDuplicatedDeprecation(): void + { + /** + * setting stackframe = 0 and having same method + * to have same deprecation message and same line for all cases + */ + $fun = function (): void { + deprecationWarning('5.0.0', 'Test same deprecation message', 0); + }; + $this->deprecated(function () use ($fun): void { + $fun(); + }); + $this->deprecated(function () use ($fun): void { + $fun(); + }); + } + + /** + * Test that TestCase::setUp() backs up values. + */ + public function testSetupBackUpValues(): void + { + $this->assertArrayHasKey('debug', $this->_configure); + } + + /** + * test assertTextNotEquals() + */ + public function testAssertTextNotEquals(): void + { + $one = "\r\nOne\rTwooo"; + $two = "\nOne\nTwo"; + $this->assertTextNotEquals($one, $two); + } + + /** + * test assertTextEquals() + */ + public function testAssertTextEquals(): void + { + $one = "\r\nOne\rTwo"; + $two = "\nOne\nTwo"; + $this->assertTextEquals($one, $two); + } + + /** + * test assertTextStartsWith() + */ + public function testAssertTextStartsWith(): void + { + $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; + + $this->assertStringStartsWith("some\nstring", $stringDirty); + $this->assertStringStartsNotWith("some\r\nstring\r\nwith", $stringDirty); + $this->assertStringStartsNotWith("some\nstring\nwith", $stringDirty); + + $this->assertTextStartsWith("some\nstring\nwith", $stringDirty); + $this->assertTextStartsWith("some\r\nstring\r\nwith", $stringDirty); + } + + /** + * test assertTextStartsNotWith() + */ + public function testAssertTextStartsNotWith(): void + { + $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; + + $this->assertTextStartsNotWith("some\nstring\nwithout", $stringDirty); + } + + /** + * test assertTextEndsWith() + */ + public function testAssertTextEndsWith(): void + { + $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; + + $this->assertTextEndsWith("string\nwith\r\ndifferent\rline endings!", $stringDirty); + $this->assertTextEndsWith("string\r\nwith\ndifferent\nline endings!", $stringDirty); + } + + /** + * test assertTextEndsNotWith() + */ + public function testAssertTextEndsNotWith(): void + { + $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; + + $this->assertStringEndsNotWith("different\nline endings", $stringDirty); + $this->assertTextEndsNotWith("different\rline endings", $stringDirty); + } + + /** + * test assertTextContains() + */ + public function testAssertTextContains(): void + { + $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; + + $this->assertStringContainsString('different', $stringDirty); + $this->assertStringNotContainsString("different\rline", $stringDirty); + + $this->assertTextContains("different\rline", $stringDirty); + } + + /** + * test assertTextNotContains() + */ + public function testAssertTextNotContains(): void + { + $stringDirty = "some\nstring\r\nwith\rdifferent\nline endings!"; + + $this->assertTextNotContains("different\rlines", $stringDirty); + } + + /** + * test testAssertWithinRange() + */ + public function testAssertWithinRange(): void + { + $this->assertWithinRange(21, 22, 1, 'Not within range'); + $this->assertWithinRange(21.3, 22.2, 1.0, 'Not within range'); + } + + /** + * test testAssertNotWithinRange() + */ + public function testAssertNotWithinRange(): void + { + $this->assertNotWithinRange(21, 23, 1, 'Within range'); + $this->assertNotWithinRange(21.3, 22.2, 0.7, 'Within range'); + } + + /** + * test getMockForModel() + */ + public function testGetMockForModel(): void + { + $this->skipIf(version_compare(Version::id(), '12.0.0', '>='), 'This test is not compatible with PHPUnit 12'); + + static::setAppNamespace(); + // No methods will be mocked if $methods argument of getMockForModel() is empty. + $Posts = $this->getMockForModel('Posts'); + $entity = new Entity([]); + + $this->assertInstanceOf(PostsTable::class, $Posts); + $this->assertSame('posts', $Posts->getTable()); + + $Posts = $this->getMockForModel('Posts', ['save']); + $Posts->expects($this->once()) + ->method('save') + ->willReturn(false); + $this->assertSame(false, $Posts->save($entity)); + $this->assertSame(Entity::class, $Posts->getEntityClass()); + $this->assertInstanceOf(Connection::class, $Posts->getConnection()); + $this->assertSame('test', $Posts->getConnection()->configName()); + + $Tags = $this->getMockForModel('Tags', ['save']); + $this->assertSame(Tag::class, $Tags->getEntityClass()); + } + + /** + * Test getMockForModel on secondary datasources. + */ + public function testGetMockForModelSecondaryDatasource(): void + { + ConnectionManager::alias('test', 'secondary'); + + $post = $this->getMockForModel(SecondaryPostsTable::class, ['save']); + $this->assertSame('test', $post->getConnection()->configName()); + } + + /** + * test getMockForModel() with plugin models + */ + public function testGetMockForModelWithPlugin(): void + { + static::setAppNamespace(); + $this->loadPlugins(['TestPlugin']); + $TestPluginComment = $this->getMockForModel('TestPlugin.TestPluginComments'); + + $result = $this->getTableLocator()->get('TestPlugin.TestPluginComments'); + $this->assertInstanceOf(TestPluginCommentsTable::class, $result); + $this->assertSame($TestPluginComment, $result); + + $TestPluginComment = $this->getMockForModel('TestPlugin.TestPluginComments', ['save']); + + $this->assertInstanceOf(TestPluginCommentsTable::class, $TestPluginComment); + $this->assertSame(Entity::class, $TestPluginComment->getEntityClass()); + $TestPluginComment->expects($this->exactly(1)) + ->method('save') + ->willReturn(false); + + $entity = new Entity([]); + $this->assertFalse($TestPluginComment->save($entity)); + + $TestPluginAuthors = $this->getMockForModel('TestPlugin.Authors', ['save']); + $this->assertInstanceOf(AuthorsTable::class, $TestPluginAuthors); + $this->assertSame(Author::class, $TestPluginAuthors->getEntityClass()); + $this->clearPlugins(); + } + + /** + * testGetMockForModelTable + */ + public function testGetMockForModelTable(): void + { + $Mock = $this->getMockForModel( + 'Table', + ['save'], + ['alias' => 'Comments', 'className' => Table::class], + ); + + $result = $this->getTableLocator()->get('Comments'); + $this->assertInstanceOf(Table::class, $result); + $this->assertSame('Comments', $Mock->getAlias()); + + $Mock->expects($this->exactly(1)) + ->method('save') + ->willReturn(false); + + $entity = new Entity([]); + $this->assertFalse($Mock->save($entity)); + + $allMethodsStubs = $this->getMockForModel( + 'Table', + [], + ['alias' => 'Comments', 'className' => Table::class], + ); + $result = $this->getTableLocator()->get('Comments'); + $this->assertInstanceOf(Table::class, $result); + $this->assertEmpty([], $allMethodsStubs->getAlias()); + } + + /** + * Test getting a table mock that doesn't have a preset table name sets the proper name + */ + public function testGetMockForModelSetTable(): void + { + static::setAppNamespace(); + ConnectionManager::alias('test', 'custom_i18n_datasource'); + + $I18n = $this->getMockForModel('CustomI18n', ['save']); + $this->assertSame('custom_i18n_table', $I18n->getTable()); + + $Tags = $this->getMockForModel('Tags', ['save']); + $this->assertSame('tags', $Tags->getTable()); + ConnectionManager::dropAlias('custom_i18n_datasource'); + } + + /** + * Test loadRoutes() helper + */ + public function testLoadRoutes(): void + { + $url = ['controller' => 'Articles', 'action' => 'index']; + try { + Router::url($url); + $this->fail('Missing URL should throw an exception'); + } catch (MissingRouteException) { + } + Configure::write('App.namespace', 'TestApp'); + $this->loadRoutes(); + + $result = Router::url($url); + $this->assertSame('/app/articles', $result); + } +} diff --git a/tests/TestCase/TestSuite/TestEmailTransportTest.php b/tests/TestCase/TestSuite/TestEmailTransportTest.php new file mode 100644 index 00000000000..9dd88489ae5 --- /dev/null +++ b/tests/TestCase/TestSuite/TestEmailTransportTest.php @@ -0,0 +1,98 @@ + DebugTransport::class, + ]); + TransportFactory::setConfig('transport_alternate', [ + 'className' => DebugTransport::class, + ]); + + Mailer::setConfig('default', [ + 'transport' => 'transport_default', + 'from' => 'default@example.com', + ]); + Mailer::setConfig('alternate', [ + 'transport' => 'transport_alternate', + 'from' => 'alternate@example.com', + ]); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + + Mailer::drop('default'); + Mailer::drop('alternate'); + TransportFactory::drop('transport_default'); + TransportFactory::drop('transport_alternate'); + } + + /** + * tests replaceAllTransports + */ + public function testReplaceAllTransports(): void + { + TestEmailTransport::replaceAllTransports(); + + $config = TransportFactory::getConfig('transport_default'); + $this->assertSame(TestEmailTransport::class, $config['className']); + + $config = TransportFactory::getConfig('transport_alternate'); + $this->assertSame(TestEmailTransport::class, $config['className']); + } + + /** + * tests sending an email through the transport, getting it, and clearing all emails + */ + public function testSendGetAndClear(): void + { + TestEmailTransport::replaceAllTransports(); + + (new Mailer()) + ->setTo('test@example.com') + ->deliver('test'); + $this->assertCount(1, TestEmailTransport::getMessages()); + + TestEmailTransport::clearMessages(); + $this->assertCount(0, TestEmailTransport::getMessages()); + } +} diff --git a/tests/TestCase/TestSuite/TestFixtureTest.php b/tests/TestCase/TestSuite/TestFixtureTest.php new file mode 100644 index 00000000000..92554b8ec1d --- /dev/null +++ b/tests/TestCase/TestSuite/TestFixtureTest.php @@ -0,0 +1,261 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Posts']; + + /** + * Set up + */ + protected function setUp(): void + { + parent::setUp(); + Log::reset(); + } + + /** + * Tear down + */ + protected function tearDown(): void + { + parent::tearDown(); + Log::reset(); + Inflector::reset(); + ConnectionManager::get('test')->execute('DROP TABLE IF EXISTS letters'); + ConnectionManager::get('test')->execute('DROP TABLE IF EXISTS special_pks'); + ConnectionManager::get('test')->execute('DROP TABLE IF EXISTS equipment'); + } + + /** + * test initializing a static fixture + */ + public function testInitStaticFixture(): void + { + $Fixture = new ArticlesFixture(); + $this->assertSame('articles', $Fixture->table); + + $Fixture = new ArticlesFixture(); + $Fixture->table = ''; + $Fixture->tableAlias = ''; + $Fixture->init(); + $this->assertSame('articles', $Fixture->table); + + $schema = $Fixture->getTableSchema(); + $this->assertInstanceOf(TableSchema::class, $schema); + } + + public function testCustomTableAlias(): void + { + $Fixture = new AliasedArticlesFixture(); + $this->assertSame('articles', $Fixture->table); + $this->assertSame('Articles', $Fixture->tableAlias); + } + + public function testAliasPlural(): void + { + $connection = ConnectionManager::get('test'); + $connection->execute('CREATE TABLE special_pks (id INT PRIMARY KEY, name VARCHAR(50))'); + $Fixture = new SpecialPkFixture(); + $this->assertSame('special_pks', $Fixture->table); + $this->assertSame('SpecialPks', $Fixture->tableAlias); + } + + /** + * Test that uninflected rules are respected when deriving table names. + * + * "equipment" is in the default uninflected list, so EquipmentFixture + * should use table "equipment" (not "equipments"). + * + * This ensures the fixture uses tableize() logic (underscore then pluralize) + * rather than pluralizing the CamelCase name directly. + */ + public function testAliasRespectsUninflectedRules(): void + { + $connection = ConnectionManager::get('test'); + $connection->execute('CREATE TABLE equipment (id INT PRIMARY KEY, name VARCHAR(50))'); + + $fixture = new EquipmentFixture(); + $this->assertSame('equipment', $fixture->table); + $this->assertSame('Equipment', $fixture->tableAlias); + } + + /** + * Tests that trying to reflect with a table that doesn't exist throws an exception. + */ + public function testReflectionMissingTable(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage( + sprintf( + 'Cannot describe schema for table `letters` for fixture `%s`. The table does not exist.', + LettersFixture::class, + ), + ); + + new LettersFixture(); + } + + /** + * Tests schema reflection. + */ + public function testReflection(): void + { + $db = ConnectionManager::get('test'); + $table = new TableSchema('letters', [ + 'id' => ['type' => 'integer'], + 'letter' => ['type' => 'string', 'length' => 1], + ]); + $table->addConstraint('primary', ['type' => 'primary', 'columns' => ['id']]); + $sql = $table->createSql($db); + + foreach ($sql as $stmt) { + $db->execute($stmt); + } + + $fixture = new LettersFixture(); + $this->assertSame(['id', 'letter'], $fixture->getTableSchema()->columns()); + } + + /** + * Tests that schema reflection picks up dynamically configured column types. + */ + public function testReflectionWithDynamicTypes(): void + { + $db = ConnectionManager::get('test'); + $table = new TableSchema('letters', [ + 'id' => ['type' => 'integer'], + 'letter' => ['type' => 'string', 'length' => 1], + 'complex_field' => ['type' => 'text'], + ]); + $table->addConstraint('primary', ['type' => 'primary', 'columns' => ['id']]); + $sql = $table->createSql($db); + + foreach ($sql as $stmt) { + $db->execute($stmt); + } + + $table = $this->fetchTable('Letters', ['connection' => $db]); + $table->getSchema()->setColumnType('complex_field', 'json'); + + $fixture = new LettersFixture(); + $fixtureSchema = $fixture->getTableSchema(); + $this->assertSame(['id', 'letter', 'complex_field'], $fixtureSchema->columns()); + $this->assertSame('json', $fixtureSchema->getColumnType('complex_field')); + } + + /** + * test init with other tables used in initialize() + * + * The FeaturedTagsTable uses PostsTable, then when PostsFixture + * reflects schema it should not raise an error. + */ + public function testInitInitializeUsesRegistry(): void + { + $this->setAppNamespace(); + + $fixture = new FeaturedTagsFixture(); + new PostsFixture(); + + $expected = ['tag_id', 'priority']; + $this->assertSame($expected, $fixture->getTableSchema()->columns()); + } + + /** + * test the insert method + */ + public function testInsert(): void + { + $fixture = new ArticlesFixture(); + + $db = Mockery::mock(Connection::class); + $query = Mockery::mock(InsertQuery::class . '[execute,insert,into,values]', [$db]); + + $db->shouldReceive('insertQuery') + ->andReturn($query) + ->once(); + + $query->shouldReceive('insert') + ->with(['author_id', 'title', 'body', 'published'], ['author_id' => 'integer', 'title' => 'string', 'body' => 'text', 'published' => 'string']) + ->andReturnSelf() + ->once(); + + $query->shouldReceive('into') + ->with('articles') + ->andReturnSelf() + ->once(); + + $expected = [ + ['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'], + ]; + foreach ($expected as $data) { + $query->shouldReceive('values') + ->with($data) + ->andReturnSelf() + ->once(); + } + + $statement = Mockery::mock(StatementInterface::class); + + $query->shouldReceive('execute') + ->andReturn($statement) + ->once(); + + $this->assertSame(true, $fixture->insert($db)); + } + + /** + * Test the truncate method. + */ + public function testTruncate(): void + { + $fixture = new ArticlesFixture(); + + $this->assertTrue($fixture->truncate(ConnectionManager::get('test'))); + $rows = ConnectionManager::get('test')->selectQuery()->select('*')->from('articles')->execute(); + $this->assertEmpty($rows->fetchAll()); + } +} diff --git a/tests/TestCase/TestSuite/TestSessionTest.php b/tests/TestCase/TestSuite/TestSessionTest.php new file mode 100644 index 00000000000..f550027a950 --- /dev/null +++ b/tests/TestCase/TestSuite/TestSessionTest.php @@ -0,0 +1,74 @@ +sessionData = [ + 'root' => [ + 'sub' => [ + 'subsub' => 'foo', + ], + ], + ]; + $this->session = new TestSession($this->sessionData); + } + + /** + * Tests read() + */ + public function testRead(): void + { + $result = $this->session->read(); + $this->assertSame($this->sessionData, $result); + + $result = $this->session->read('root.sub'); + $this->assertSame(['subsub' => 'foo'], $result); + } + + /** + * Tests check() + */ + public function testCheck(): void + { + $result = $this->session->check(); + $this->assertTrue($result); + + $result = $this->session->check('root.sub'); + $this->assertTrue($result); + + $result = $this->session->check('root.nonexistent'); + $this->assertFalse($result); + } +} diff --git a/tests/TestCase/Utility/Crypto/OpenSslTest.php b/tests/TestCase/Utility/Crypto/OpenSslTest.php new file mode 100644 index 00000000000..7dd1a3fd3d7 --- /dev/null +++ b/tests/TestCase/Utility/Crypto/OpenSslTest.php @@ -0,0 +1,67 @@ +skipIf(!function_exists('openssl_encrypt'), 'No openssl skipping tests'); + $this->crypt = new OpenSsl(); + } + + /** + * Test encrypt/decrypt. + */ + public function testEncryptDecrypt(): void + { + $txt = 'The quick brown fox'; + $key = 'This key is enough bytes'; + $result = $this->crypt->encrypt($txt, $key); + $this->assertNotEquals($txt, $result, 'Should be encrypted.'); + $this->assertNotEquals($result, $this->crypt->encrypt($txt, $key), 'Each result is unique.'); + $this->assertSame($txt, $this->crypt->decrypt($result, $key)); + } + + /** + * Test that changing the key causes decryption to fail. + */ + public function testDecryptKeyFailure(): void + { + $txt = 'The quick brown fox'; + $key = 'This key is enough bytes'; + $result = $this->crypt->encrypt($txt, $key); + + $key = 'Not the same key.'; + $decrypted = $this->crypt->decrypt($result, $key); + // With unauthenticated CBC mode, wrong key decryption may return null + // (padding failure) or garbage data (padding accidentally valid). + $this->assertNotSame($txt, $decrypted, 'Modified key should not decrypt to original text.'); + } +} diff --git a/tests/TestCase/Utility/FilesystemTest.php b/tests/TestCase/Utility/FilesystemTest.php new file mode 100644 index 00000000000..6becea12457 --- /dev/null +++ b/tests/TestCase/Utility/FilesystemTest.php @@ -0,0 +1,111 @@ +vfs = vfsStream::setup('root'); + $this->vfsPath = vfsStream::url('root'); + + $this->fs = new Filesystem(); + + clearstatcache(); + } + + public function testMkdir(): void + { + $path = $this->vfsPath . DS . 'tests' . DS . 'first' . DS . 'second' . DS . 'third'; + $this->fs->mkdir($path); + $this->assertTrue(is_dir($path)); + } + + public function testDumpFile(): void + { + $path = $this->vfsPath . DS . 'foo.txt'; + + $this->fs->dumpFile($path, 'bar'); + $this->assertEquals(file_get_contents($path), 'bar'); + + $path = $this->vfsPath . DS . 'empty.txt'; + $this->fs->dumpFile($path, ''); + $this->assertSame(file_get_contents($path), ''); + } + + public function testCopyDir(): void + { + $return = $this->fs->copyDir(WWW_ROOT, $this->vfsPath . DS . 'dest'); + + $this->assertTrue($return); + } + + public function testDeleteDir(): void + { + $structure = [ + 'Core' => [ + 'AbstractFactory' => [ + 'test.php' => 'some text content', + 'other.php' => 'Some more text content', + 'Invalid.csv' => 'Something else', + ], + 'AnEmptyFolder' => [], + 'badlocation.php' => 'some bad content', + ], + ]; + vfsStream::create($structure); + + $return = $this->fs->deleteDir($this->vfsPath . DS . 'Core'); + + $this->assertTrue($return); + } + + /** + * Tests deleteDir() on directory that contains symlinks + */ + public function testDeleteDirWithLinks(): void + { + $path = TMP . 'fs_links_test'; + // phpcs:ignore + @mkdir($path); + $target = $path . DS . 'target'; + // phpcs:ignore + @mkdir($target); + + $link = $path . DS . 'link'; + // phpcs:ignore + @symlink($target, $link); + + $this->assertTrue($this->fs->deleteDir($path)); + $this->assertFalse(file_exists($link)); + } +} diff --git a/tests/TestCase/Utility/HashTest.php b/tests/TestCase/Utility/HashTest.php new file mode 100644 index 00000000000..04f3ab2e6b3 --- /dev/null +++ b/tests/TestCase/Utility/HashTest.php @@ -0,0 +1,3441 @@ + [ + 'id' => '1', + 'user_id' => '1', + 'title' => 'First Article', + 'body' => 'First Article Body', + ], + 'User' => [ + 'id' => '1', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ], + 'Comment' => [ + [ + 'id' => '1', + 'article_id' => '1', + 'user_id' => '2', + 'comment' => 'First Comment for First Article', + ], + [ + 'id' => '2', + 'article_id' => '1', + 'user_id' => '4', + 'comment' => 'Second Comment for First Article', + ], + ], + 'Tag' => [ + [ + 'id' => '1', + 'tag' => 'tag1', + ], + [ + 'id' => '2', + 'tag' => 'tag2', + ], + ], + 'Deep' => [ + 'Nesting' => [ + 'test' => [ + 1 => 'foo', + 2 => [ + 'and' => ['more' => 'stuff'], + ], + ], + ], + ], + ], + [ + 'Article' => [ + 'id' => '2', + 'user_id' => '1', + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'published' => 'Y', + ], + 'User' => [ + 'id' => '2', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ], + 'Comment' => [], + 'Tag' => [], + ], + [ + 'Article' => [ + 'id' => '3', + 'user_id' => '1', + 'title' => 'Third Article', + 'body' => 'Third Article Body', + ], + 'User' => [ + 'id' => '3', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ], + 'Comment' => [], + 'Tag' => [], + ], + [ + 'Article' => [ + 'id' => '4', + 'user_id' => '1', + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + ], + 'User' => [ + 'id' => '4', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ], + 'Comment' => [], + 'Tag' => [], + ], + [ + 'Article' => [ + 'id' => '5', + 'user_id' => '1', + 'title' => 'Fifth Article', + 'body' => 'Fifth Article Body', + ], + 'User' => [ + 'id' => '5', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ], + 'Comment' => [], + 'Tag' => [], + ], + ]; + } + + /** + * Data provider + */ + public static function articleDataObject(): ArrayObject + { + return new ArrayObject([ + new Entity([ + 'Article' => new ArrayObject([ + 'id' => '1', + 'user_id' => '1', + 'title' => 'First Article', + 'body' => 'First Article Body', + ]), + 'User' => new ArrayObject([ + 'id' => '1', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ]), + 'Comment' => new ArrayObject([ + new ArrayObject([ + 'id' => '1', + 'article_id' => '1', + 'user_id' => '2', + 'comment' => 'First Comment for First Article', + ]), + new ArrayObject([ + 'id' => '2', + 'article_id' => '1', + 'user_id' => '4', + 'comment' => 'Second Comment for First Article', + ]), + ]), + 'Tag' => new ArrayObject([ + new ArrayObject([ + 'id' => '1', + 'tag' => 'tag1', + ]), + new ArrayObject([ + 'id' => '2', + 'tag' => 'tag2', + ]), + ]), + 'Deep' => new ArrayObject([ + 'Nesting' => new ArrayObject([ + 'test' => new ArrayObject([ + 1 => 'foo', + 2 => new ArrayObject([ + 'and' => new ArrayObject(['more' => 'stuff']), + ]), + ]), + ]), + ]), + ]), + new ArrayObject([ + 'Article' => new ArrayObject([ + 'id' => '2', + 'user_id' => '1', + 'title' => 'Second Article', + 'body' => 'Second Article Body', + 'published' => 'Y', + ]), + 'User' => new ArrayObject([ + 'id' => '2', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ]), + 'Comment' => new ArrayObject([]), + 'Tag' => new ArrayObject([]), + ]), + new ArrayObject([ + 'Article' => new ArrayObject([ + 'id' => '3', + 'user_id' => '1', + 'title' => 'Third Article', + 'body' => 'Third Article Body', + ]), + 'User' => new ArrayObject([ + 'id' => '3', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ]), + 'Comment' => new ArrayObject([]), + 'Tag' => new ArrayObject([]), + ]), + new ArrayObject([ + 'Article' => new ArrayObject([ + 'id' => '4', + 'user_id' => '1', + 'title' => 'Fourth Article', + 'body' => 'Fourth Article Body', + ]), + 'User' => new ArrayObject([ + 'id' => '4', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ]), + 'Comment' => new ArrayObject([]), + 'Tag' => new ArrayObject([]), + ]), + new ArrayObject([ + 'Article' => new ArrayObject([ + 'id' => '5', + 'user_id' => '1', + 'title' => 'Fifth Article', + 'body' => 'Fifth Article Body', + ]), + 'User' => new ArrayObject([ + 'id' => '5', + 'user' => 'mariano', + 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', + ]), + 'Comment' => new ArrayObject([]), + 'Tag' => new ArrayObject([]), + ]), + ]); + } + + /** + * Data provider + * + * @return array + */ + public static function articleDataSets(): array + { + return [ + [static::articleData()], + [static::articleDataObject()], + ]; + } + + /** + * Data provider + * + * @return array + */ + public static function userData(): array + { + return [ + [ + 'User' => [ + 'id' => 2, + 'group_id' => 1, + 'Data' => [ + 'user' => 'mariano.iglesias', + 'name' => 'Mariano Iglesias', + ], + ], + ], + [ + 'User' => [ + 'id' => 14, + 'group_id' => 2, + 'Data' => [ + 'user' => 'phpnut', + 'name' => 'Larry E. Masters', + ], + ], + ], + [ + 'User' => [ + 'id' => 25, + 'group_id' => 1, + 'Data' => [ + 'user' => 'gwoo', + 'name' => 'The Gwoo', + ], + ], + ], + ]; + } + + /** + * Test get() + */ + public function testGet(): void + { + $data = ['abc', 'def']; + + $result = Hash::get($data, '0'); + $this->assertSame('abc', $result); + + $result = Hash::get($data, 0); + $this->assertSame('abc', $result); + + $result = Hash::get($data, '1'); + $this->assertSame('def', $result); + + $data = self::articleData(); + + $result = Hash::get([], '1.Article.title'); + $this->assertNull($result); + + $result = Hash::get($data, ''); + $this->assertNull($result); + + $result = Hash::get($data, null, '-'); + $this->assertSame('-', $result); + + $result = Hash::get($data, '0.Article.title'); + $this->assertSame('First Article', $result); + + $result = Hash::get($data, '1.Article.title'); + $this->assertSame('Second Article', $result); + + $result = Hash::get($data, '5.Article.title'); + $this->assertNull($result); + + $default = ['empty']; + $this->assertSame($default, Hash::get($data, '5.Article.title', $default)); + $this->assertSame($default, Hash::get([], '5.Article.title', $default)); + + $result = Hash::get($data, '1.Article.title.not_there'); + $this->assertNull($result); + + $result = Hash::get($data, '1.Article'); + $this->assertSame($data[1]['Article'], $result); + + $result = Hash::get($data, ['1', 'Article']); + $this->assertSame($data[1]['Article'], $result); + + // Object which implements ArrayAccess + $nested = new ArrayObject([ + 'user' => 'bar', + ]); + $data = new ArrayObject([ + 'name' => 'foo', + 'associated' => $nested, + ]); + + $return = Hash::get($data, 'name'); + $this->assertSame('foo', $return); + + $return = Hash::get($data, 'associated'); + $this->assertSame($nested, $return); + + $return = Hash::get($data, 'associated.user'); + $this->assertSame('bar', $return); + + $return = Hash::get($data, 'nonexistent'); + $this->assertNull($return); + + $data = ['a' => ['b' => ['c' => ['d' => 1]]]]; + $this->assertSame(1, Hash::get(new ArrayObject($data), 'a.b.c.d')); + } + + /** + * Test that get() can extract '' key data. + */ + public function testGetEmptyKey(): void + { + $data = [ + '' => 'some value', + ]; + $result = Hash::get($data, ''); + $this->assertSame($data[''], $result); + } + + /** + * Test dimensions. + */ + public function testDimensions(): void + { + $result = Hash::dimensions([]); + $this->assertSame($result, 0); + + $data = ['one', '2', 'three']; + $result = Hash::dimensions($data); + $this->assertSame($result, 1); + + $data = ['1' => '1.1', '2', '3']; + $result = Hash::dimensions($data); + $this->assertSame($result, 1); + + $data = ['1' => ['1.1' => '1.1.1'], '2', '3' => ['3.1' => '3.1.1']]; + $result = Hash::dimensions($data); + $this->assertSame($result, 2); + + $data = ['1' => '1.1', '2', '3' => ['3.1' => '3.1.1']]; + $result = Hash::dimensions($data); + $this->assertSame($result, 1); + + $data = ['1' => ['1.1' => '1.1.1'], '2', '3' => ['3.1' => ['3.1.1' => '3.1.1.1']]]; + $result = Hash::dimensions($data); + $this->assertSame($result, 2); + } + + /** + * Test maxDimensions + */ + public function testMaxDimensions(): void + { + $data = []; + $result = Hash::maxDimensions($data); + $this->assertSame(0, $result); + + $data = ['a', 'b']; + $result = Hash::maxDimensions($data); + $this->assertSame(1, $result); + + $data = ['1' => '1.1', '2', '3' => ['3.1' => '3.1.1']]; + $result = Hash::maxDimensions($data); + $this->assertSame(2, $result); + + $data = ['1' => ['1.1' => '1.1.1'], '2', '3' => ['3.1' => ['3.1.1' => '3.1.1.1']]]; + $result = Hash::maxDimensions($data); + $this->assertSame(3, $result); + + $data = [ + '1' => ['1.1' => '1.1.1'], + ['2' => ['2.1' => ['2.1.1' => '2.1.1.1']]], + '3' => ['3.1' => ['3.1.1' => '3.1.1.1']], + ]; + $result = Hash::maxDimensions($data); + $this->assertSame(4, $result); + + $data = [ + '1' => [ + '1.1' => '1.1.1', + '1.2' => [ + '1.2.1' => [ + '1.2.1.1', + ['1.2.2.1'], + ], + ], + ], + '2' => ['2.1' => '2.1.1'], + ]; + $result = Hash::maxDimensions($data); + $this->assertSame(5, $result); + + $data = [ + '1' => false, + '2' => ['2.1' => '2.1.1'], + ]; + $result = Hash::maxDimensions($data); + $this->assertSame(2, $result); + } + + /** + * Tests Hash::flatten + */ + public function testFlatten(): void + { + $data = ['Larry', 'Curly', 'Moe']; + $result = Hash::flatten($data); + $this->assertSame($result, $data); + + $data[9] = 'Shemp'; + $result = Hash::flatten($data); + $this->assertSame($result, $data); + + $data = [ + [ + 'Post' => ['id' => '1', 'author_id' => '1', 'title' => 'First Post'], + 'Author' => ['id' => '1', 'user' => 'nate', 'password' => 'foo'], + ], + [ + 'Post' => ['id' => '2', 'author_id' => '3', 'title' => 'Second Post', 'body' => 'Second Post Body'], + 'Author' => ['id' => '3', 'user' => 'larry', 'password' => null], + ], + ]; + $result = Hash::flatten($data); + $expected = [ + '0.Post.id' => '1', + '0.Post.author_id' => '1', + '0.Post.title' => 'First Post', + '0.Author.id' => '1', + '0.Author.user' => 'nate', + '0.Author.password' => 'foo', + '1.Post.id' => '2', + '1.Post.author_id' => '3', + '1.Post.title' => 'Second Post', + '1.Post.body' => 'Second Post Body', + '1.Author.id' => '3', + '1.Author.user' => 'larry', + '1.Author.password' => null, + ]; + $this->assertSame($expected, $result); + + $data = [ + [ + 'Post' => ['id' => '1', 'author_id' => null, 'title' => 'First Post'], + 'Author' => [], + ], + ]; + $result = Hash::flatten($data); + $expected = [ + '0.Post.id' => '1', + '0.Post.author_id' => null, + '0.Post.title' => 'First Post', + '0.Author' => [], + ]; + $this->assertSame($expected, $result); + + $data = [ + ['Post' => ['id' => 1]], + ['Post' => ['id' => 2]], + ]; + $result = Hash::flatten($data, '/'); + $expected = [ + '0/Post/id' => 1, + '1/Post/id' => 2, + ]; + $this->assertSame($expected, $result); + } + + /** + * Test diff(); + */ + public function testDiff(): void + { + $a = [ + 0 => ['name' => 'main'], + 1 => ['name' => 'about'], + ]; + $b = [ + 0 => ['name' => 'main'], + 1 => ['name' => 'about'], + 2 => ['name' => 'contact'], + ]; + + $result = Hash::diff($a, []); + $expected = $a; + $this->assertSame($expected, $result); + + $result = Hash::diff([], $b); + $expected = $b; + $this->assertSame($expected, $result); + + $result = Hash::diff($a, $b); + $expected = [ + 2 => ['name' => 'contact'], + ]; + $this->assertSame($expected, $result); + + $b = [ + 0 => ['name' => 'me'], + 1 => ['name' => 'about'], + ]; + + $result = Hash::diff($a, $b); + $expected = [ + 0 => ['name' => 'main'], + ]; + $this->assertSame($expected, $result); + + $a = []; + $b = ['name' => 'bob', 'address' => 'home']; + $result = Hash::diff($a, $b); + $this->assertSame($result, $b); + + $a = ['name' => 'bob', 'address' => 'home']; + $b = []; + $result = Hash::diff($a, $b); + $this->assertSame($result, $a); + + $a = ['key' => true, 'another' => false, 'name' => 'me']; + $b = ['key' => 1, 'another' => 0]; + $expected = ['name' => 'me']; + $result = Hash::diff($a, $b); + $this->assertSame($expected, $result); + + $a = ['key' => 'value', 'another' => null, 'name' => 'me']; + $b = ['key' => 'differentValue', 'another' => null]; + $expected = ['key' => 'value', 'name' => 'me']; + $result = Hash::diff($a, $b); + $this->assertSame($expected, $result); + + $a = ['key' => 'value', 'another' => null, 'name' => 'me']; + $b = ['key' => 'differentValue', 'another' => 'value']; + $expected = ['key' => 'value', 'another' => null, 'name' => 'me']; + $result = Hash::diff($a, $b); + $this->assertSame($expected, $result); + + $a = ['key' => 'value', 'another' => null, 'name' => 'me']; + $b = ['key' => 'differentValue', 'another' => 'value']; + $expected = ['key' => 'differentValue', 'another' => 'value', 'name' => 'me']; + $result = Hash::diff($b, $a); + $this->assertSame($expected, $result); + + $a = ['key' => 'value', 'another' => null, 'name' => 'me']; + $b = [0 => 'differentValue', 1 => 'value']; + $expected = $a + $b; + $result = Hash::diff($a, $b); + $this->assertSame($expected, $result); + } + + /** + * Test merge() + */ + public function testMerge(): void + { + $result = Hash::merge(['foo'], ['bar']); + $this->assertSame($result, ['foo', 'bar']); + + $a = ['foo', 'foo2']; + $b = ['bar', 'bar2']; + $expected = ['foo', 'foo2', 'bar', 'bar2']; + $this->assertSame($expected, Hash::merge($a, $b)); + + $a = ['foo' => 'bar', 'bar' => 'foo']; + $b = ['foo' => 'no-bar', 'bar' => 'no-foo']; + $expected = ['foo' => 'no-bar', 'bar' => 'no-foo']; + $this->assertSame($expected, Hash::merge($a, $b)); + + $a = ['users' => ['bob', 'jim']]; + $b = ['users' => ['lisa', 'tina']]; + $expected = ['users' => ['bob', 'jim', 'lisa', 'tina']]; + $this->assertSame($expected, Hash::merge($a, $b)); + + $a = ['users' => ['jim', 'bob']]; + $b = ['users' => 'none']; + $expected = ['users' => 'none']; + $this->assertSame($expected, Hash::merge($a, $b)); + + $a = [ + 'Tree', + 'CounterCache', + 'Upload' => [ + 'folder' => 'products', + 'fields' => ['image_1_id', 'image_2_id', 'image_3_id', 'image_4_id', 'image_5_id'], + ], + ]; + $b = [ + 'Cacheable' => ['enabled' => false], + 'Limit', + 'Bindable', + 'Validator', + 'Transactional', + ]; + $expected = [ + 'Tree', + 'CounterCache', + 'Upload' => [ + 'folder' => 'products', + 'fields' => ['image_1_id', 'image_2_id', 'image_3_id', 'image_4_id', 'image_5_id'], + ], + 'Cacheable' => ['enabled' => false], + 'Limit', + 'Bindable', + 'Validator', + 'Transactional', + ]; + $this->assertSame($expected, Hash::merge($a, $b)); + } + + /** + * Test that merge() works with variadic arguments. + */ + public function testMergeVariadic(): void + { + $result = Hash::merge( + ['hkuc' => ['lion']], + ['hkuc' => 'lion'], + ); + $expected = ['hkuc' => 'lion']; + $this->assertSame($expected, $result); + + $result = Hash::merge( + ['hkuc' => ['lion']], + ['hkuc' => ['lion']], + ['hkuc' => 'lion'], + ); + $this->assertSame($expected, $result); + + $result = Hash::merge(['foo'], ['user' => 'bob', 'no-bar'], 'bar'); + $this->assertSame($result, ['foo', 'user' => 'bob', 'no-bar', 'bar']); + + $a = ['users' => ['lisa' => ['id' => 5, 'pw' => 'secret']], 'cakephp']; + $b = ['users' => ['lisa' => ['pw' => 'new-pass', 'age' => 23]], 'ice-cream']; + $expected = [ + 'users' => ['lisa' => ['id' => 5, 'pw' => 'new-pass', 'age' => 23]], + 'cakephp', + 'ice-cream', + ]; + $result = Hash::merge($a, $b); + $this->assertSame($expected, $result); + + $c = [ + 'users' => ['lisa' => ['pw' => 'you-will-never-guess', 'age' => 25, 'pet' => 'dog']], + 'chocolate', + ]; + $expected = [ + 'users' => ['lisa' => ['id' => 5, 'pw' => 'you-will-never-guess', 'age' => 25, 'pet' => 'dog']], + 'cakephp', + 'ice-cream', + 'chocolate', + ]; + $this->assertSame($expected, Hash::merge($a, $b, $c)); + $this->assertSame($expected, Hash::merge($a, $b, [], $c)); + } + + /** + * test normalizing arrays + */ + public function testNormalize(): void + { + $result = Hash::normalize(['one', 'two', 'three']); + $expected = ['one' => null, 'two' => null, 'three' => null]; + $this->assertSame($expected, $result); + + $result = Hash::normalize(['one', 'two', 'three'], false); + $expected = ['one', 'two', 'three']; + $this->assertSame($expected, $result); + + $result = Hash::normalize(['one', 'two', 'three'], false, []); + $expected = ['one', 'two', 'three']; + $this->assertSame($expected, $result); + + $result = Hash::normalize(['one' => 1, 'two' => 2, 'three' => 3, 'four'], false); + $expected = ['one' => 1, 'two' => 2, 'three' => 3, 'four' => null]; + $this->assertSame($expected, $result); + + $result = Hash::normalize(['one' => 1, 'two' => 2, 'three' => 3, 'four'], false, []); + $expected = ['one' => 1, 'two' => 2, 'three' => 3, 'four' => []]; + $this->assertSame($expected, $result); + + $result = Hash::normalize(['one' => 1, 'two' => 2, 'three' => 3, 'four']); + $expected = ['one' => 1, 'two' => 2, 'three' => 3, 'four' => null]; + $this->assertSame($expected, $result); + + $result = Hash::normalize(['one' => ['a', 'b', 'c' => 'cee'], 'two' => 2, 'three']); + $expected = ['one' => ['a', 'b', 'c' => 'cee'], 'two' => 2, 'three' => null]; + $this->assertSame($expected, $result); + + $result = Hash::normalize(['one' => ['a', 'b', 'c' => 'cee'], 'two' => 2, 'three'], true, 'x'); + $expected = ['one' => ['a', 'b', 'c' => 'cee'], 'two' => 2, 'three' => 'x']; + $this->assertSame($expected, $result); + } + + /** + * testContains method + */ + public function testContains(): void + { + $data = ['apple', 'bee', 'cyclops']; + $this->assertTrue(Hash::contains($data, ['apple'])); + $this->assertFalse(Hash::contains($data, ['data'])); + + $a = [ + 0 => ['name' => 'main'], + 1 => ['name' => 'about'], + ]; + $b = [ + 0 => ['name' => 'main'], + 1 => ['name' => 'about'], + 2 => ['name' => 'contact'], + 'a' => 'b', + ]; + + $this->assertTrue(Hash::contains($a, $a)); + $this->assertFalse(Hash::contains($a, $b)); + $this->assertTrue(Hash::contains($b, $a)); + + $a = [ + ['User' => ['id' => 1]], + ['User' => ['id' => 2]], + ]; + $b = [ + ['User' => ['id' => 1]], + ['User' => ['id' => 2]], + ['User' => ['id' => 3]], + ]; + $this->assertTrue(Hash::contains($b, $a)); + $this->assertFalse(Hash::contains($a, $b)); + + $a = [0 => 'test', 'string' => null]; + $this->assertTrue(Hash::contains($a, ['string' => null])); + + $a = [0 => 'test', 'string' => null]; + $this->assertTrue(Hash::contains($a, ['test'])); + } + + /** + * testFilter method + */ + public function testFilter(): void + { + $result = Hash::filter([ + '0', + false, + true, + 0, + 0.0, + ['one thing', 'I can tell you', 'is you got to be', false], + ]); + $expected = [ + 0 => '0', + 2 => true, + 3 => 0, + 4 => 0.0, + 5 => ['one thing', 'I can tell you', 'is you got to be'], + ]; + $this->assertSame($expected, $result); + + $result = Hash::filter([1, [false]]); + $expected = [1]; + $this->assertSame($expected, $result); + + $result = Hash::filter([1, [false, false]]); + $expected = [1]; + $this->assertSame($expected, $result); + + $result = Hash::filter([1, ['empty', false]]); + $expected = [1, ['empty']]; + $this->assertSame($expected, $result); + + $result = Hash::filter([1, ['2', false, [3, null]]]); + $expected = [1, ['2', 2 => [3]]]; + $this->assertSame($expected, $result); + + $this->assertSame([], Hash::filter([])); + } + + /** + * testNumericArrayCheck method + */ + public function testNumeric(): void + { + $data = ['one']; + $this->assertTrue(Hash::numeric(array_keys($data))); + + $data = [1 => 'one']; + $this->assertFalse(Hash::numeric($data)); + + $data = ['one']; + $this->assertFalse(Hash::numeric($data)); + + $data = ['one' => 'two']; + $this->assertFalse(Hash::numeric($data)); + + $data = ['one' => 1]; + $this->assertTrue(Hash::numeric($data)); + + $data = [0]; + $this->assertTrue(Hash::numeric($data)); + + $data = ['one', 'two', 'three', 'four', 'five']; + $this->assertTrue(Hash::numeric(array_keys($data))); + + $data = [1 => 'one', 2 => 'two', 3 => 'three', 4 => 'four', 5 => 'five']; + $this->assertTrue(Hash::numeric(array_keys($data))); + + $data = ['1' => 'one', 2 => 'two', 3 => 'three', 4 => 'four', 5 => 'five']; + $this->assertTrue(Hash::numeric(array_keys($data))); + + $data = ['one', 2 => 'two', 3 => 'three', 4 => 'four', 'a' => 'five']; + $this->assertFalse(Hash::numeric(array_keys($data))); + + $data = [2.4, 1, 0, -1, -2]; + $this->assertTrue(Hash::numeric($data)); + } + + /** + * Test the extraction of a single value filtered by another field. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractSingleValueWithFilteringByAnotherField($data): void + { + $result = Hash::extract($data, '{*}.Article[id=1].title'); + $this->assertSame([0 => 'First Article'], $result); + + $result = Hash::extract($data, '{*}.Article[id=2].title'); + $this->assertSame([0 => 'Second Article'], $result); + } + + /** + * Test simple paths. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractBasic($data): void + { + $result = Hash::extract($data, ''); + $this->assertSame($data, $result); + + $result = Hash::extract($data, '0.Article.title'); + $this->assertSame(['First Article'], $result); + + $result = Hash::extract($data, '1.Article.title'); + $this->assertSame(['Second Article'], $result); + + $result = Hash::extract([false], '{n}.Something.another_thing'); + $this->assertSame([], $result); + } + + /** + * Test the {n} selector + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractNumericKey($data): void + { + $result = Hash::extract($data, '{n}.Article.title'); + $expected = [ + 'First Article', 'Second Article', + 'Third Article', 'Fourth Article', + 'Fifth Article', + ]; + $this->assertSame($expected, $result); + + $result = Hash::extract($data, '0.Comment.{n}.user_id'); + $expected = [ + '2', '4', + ]; + $this->assertSame($expected, $result); + } + + /** + * Test the {n} selector with inconsistent arrays + */ + public function testExtractNumericMixedKeys(): void + { + $data = [ + 'User' => [ + 0 => [ + 'id' => 4, + 'name' => 'Neo', + ], + 1 => [ + 'id' => 5, + 'name' => 'Morpheus', + ], + 'stringKey' => [ + 'name' => 'Fail', + ], + ], + ]; + $result = Hash::extract($data, 'User.{n}.name'); + $expected = ['Neo', 'Morpheus']; + $this->assertSame($expected, $result); + + $data = new ArrayObject([ + 'User' => new ArrayObject([ + 0 => new Entity([ + 'id' => 4, + 'name' => 'Neo', + ]), + 1 => new ArrayObject([ + 'id' => 5, + 'name' => 'Morpheus', + ]), + 'stringKey' => new ArrayObject([ + 'name' => 'Fail', + ]), + ]), + ]); + $result = Hash::extract($data, 'User.{n}.name'); + $this->assertSame($expected, $result); + + $data = [ + 0 => new Entity([ + 'id' => 4, + 'name' => 'Neo', + ]), + 'stringKey' => new ArrayObject([ + 'name' => 'Fail', + ]), + ]; + $result = Hash::extract($data, '{n}.name'); + $expected = ['Neo']; + $this->assertSame($expected, $result); + } + + /** + * Test the {n} selector with non-zero based arrays + */ + public function testExtractNumericNonZero(): void + { + $data = [ + 1 => [ + 'User' => [ + 'id' => 1, + 'name' => 'John', + ], + ], + 2 => [ + 'User' => [ + 'id' => 2, + 'name' => 'Bob', + ], + ], + 3 => [ + 'User' => [ + 'id' => 3, + 'name' => 'Tony', + ], + ], + ]; + $result = Hash::extract($data, '{n}.User.name'); + $expected = ['John', 'Bob', 'Tony']; + $this->assertSame($expected, $result); + + $data = new ArrayObject([ + 1 => new ArrayObject([ + 'User' => new ArrayObject([ + 'id' => 1, + 'name' => 'John', + ]), + ]), + 2 => new ArrayObject([ + 'User' => new ArrayObject([ + 'id' => 2, + 'name' => 'Bob', + ]), + ]), + 3 => new ArrayObject([ + 'User' => new ArrayObject([ + 'id' => 3, + 'name' => 'Tony', + ]), + ]), + ]); + $result = Hash::extract($data, '{n}.User.name'); + $expected = ['John', 'Bob', 'Tony']; + $this->assertSame($expected, $result); + } + + /** + * Test the {s} selector. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractStringKey($data): void + { + $result = Hash::extract($data, '{n}.{s}.user'); + $expected = [ + 'mariano', + 'mariano', + 'mariano', + 'mariano', + 'mariano', + ]; + $this->assertSame($expected, $result); + + $result = Hash::extract($data, '{n}.{s}.Nesting.test.1'); + $this->assertSame(['foo'], $result); + } + + /** + * Test wildcard matcher + */ + public function testExtractWildcard(): void + { + $data = [ + '02000009C5560001' => ['name' => 'Mr. Alphanumeric'], + '2300000918020101' => ['name' => 'Mr. Numeric'], + '390000096AB30001' => ['name' => 'Mrs. Alphanumeric'], + 'stuff' => ['name' => 'Ms. Word'], + 123 => ['name' => 'Mr. Number'], + true => ['name' => 'Ms. Bool'], + ]; + $result = Hash::extract($data, '{*}.name'); + $expected = [ + 'Mr. Alphanumeric', + 'Mr. Numeric', + 'Mrs. Alphanumeric', + 'Ms. Word', + 'Mr. Number', + 'Ms. Bool', + ]; + $this->assertSame($expected, $result); + + $data = new ArrayObject([ + '02000009C5560001' => new ArrayObject(['name' => 'Mr. Alphanumeric']), + '2300000918020101' => new ArrayObject(['name' => 'Mr. Numeric']), + '390000096AB30001' => new ArrayObject(['name' => 'Mrs. Alphanumeric']), + 'stuff' => new ArrayObject(['name' => 'Ms. Word']), + 123 => new ArrayObject(['name' => 'Mr. Number']), + true => new ArrayObject(['name' => 'Ms. Bool']), + ]); + $result = Hash::extract($data, '{*}.name'); + $expected = [ + 'Mr. Alphanumeric', + 'Mr. Numeric', + 'Mrs. Alphanumeric', + 'Ms. Word', + 'Mr. Number', + 'Ms. Bool', + ]; + $this->assertSame($expected, $result); + } + + /** + * Test the attribute presence selector. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractAttributePresence($data): void + { + $result = Hash::extract($data, '{n}.Article[published]'); + $expected = [$data[1]['Article']]; + $this->assertSame($expected, $result); + + $result = Hash::extract($data, '{n}.Article[id][published]'); + $expected = [$data[1]['Article']]; + $this->assertSame($expected, $result); + } + + /** + * Test = and != operators. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractAttributeEquality($data): void + { + $result = Hash::extract($data, '{n}.Article[id=3]'); + $expected = [$data[2]['Article']]; + $this->assertSame($expected, $result); + + $result = Hash::extract($data, '{n}.Article[id = 3]'); + $expected = [$data[2]['Article']]; + $this->assertSame($expected, $result, 'Whitespace should not matter.'); + + $result = Hash::extract($data, '{n}.Article[id!=3]'); + $this->assertSame('1', $result[0]['id']); + $this->assertSame('2', $result[1]['id']); + $this->assertSame('4', $result[2]['id']); + $this->assertSame('5', $result[3]['id']); + } + + /** + * Test extracting based on attributes with boolean values. + */ + public function testExtractAttributeBoolean(): void + { + $usersArray = [ + [ + 'id' => 2, + 'username' => 'johndoe', + 'active' => true, + ], + [ + 'id' => 5, + 'username' => 'kevin', + 'active' => true, + ], + [ + 'id' => 9, + 'username' => 'samantha', + 'active' => false, + ], + ]; + + $usersObject = new ArrayObject([ + new ArrayObject([ + 'id' => 2, + 'username' => 'johndoe', + 'active' => true, + ]), + new ArrayObject([ + 'id' => 5, + 'username' => 'kevin', + 'active' => true, + ]), + new ArrayObject([ + 'id' => 9, + 'username' => 'samantha', + 'active' => false, + ]), + ]); + + foreach ([$usersArray, $usersObject] as $users) { + $result = Hash::extract($users, '{n}[active=0]'); + $this->assertCount(1, $result); + $this->assertSame($users[2], $result[0]); + + $result = Hash::extract($users, '{n}[active=false]'); + $this->assertCount(1, $result); + $this->assertSame($users[2], $result[0]); + + $result = Hash::extract($users, '{n}[active=1]'); + $this->assertCount(2, $result); + $this->assertSame($users[0], $result[0]); + $this->assertSame($users[1], $result[1]); + + $result = Hash::extract($users, '{n}[active=true]'); + $this->assertCount(2, $result); + $this->assertSame($users[0], $result[0]); + $this->assertSame($users[1], $result[1]); + } + } + + /** + * Test that attribute matchers don't cause errors on scalar data. + */ + public function testExtractAttributeEqualityOnScalarValue(): void + { + $dataArray = [ + 'Entity' => [ + 'id' => 1, + 'data1' => 'value', + ], + ]; + $dataObject = new ArrayObject([ + 'Entity' => new ArrayObject([ + 'id' => 1, + 'data1' => 'value', + ]), + ]); + + foreach ([$dataArray, $dataObject] as $data) { + $result = Hash::extract($data, 'Entity[id=1].data1'); + $this->assertSame(['value'], $result); + + $data = ['Entity' => false]; + $result = Hash::extract($data, 'Entity[id=1].data1'); + $this->assertSame([], $result); + } + } + + /** + * Test comparison operators. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractAttributeComparison($data): void + { + $result = Hash::extract($data, '{n}.Comment.{n}[user_id > 2]'); + $expected = [$data[0]['Comment'][1]]; + $this->assertSame($expected, $result); + $this->assertSame('4', $expected[0]['user_id']); + + $result = Hash::extract($data, '{n}.Comment.{n}[user_id >= 4]'); + $expected = [$data[0]['Comment'][1]]; + $this->assertSame($expected, $result); + $this->assertSame('4', $expected[0]['user_id']); + + $result = Hash::extract($data, '{n}.Comment.{n}[user_id < 3]'); + $expected = [$data[0]['Comment'][0]]; + $this->assertSame($expected, $result); + $this->assertSame('2', $expected[0]['user_id']); + + $result = Hash::extract($data, '{n}.Comment.{n}[user_id <= 2]'); + $expected = [$data[0]['Comment'][0]]; + $this->assertSame($expected, $result); + $this->assertSame('2', $expected[0]['user_id']); + } + + /** + * Test multiple attributes with conditions. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractAttributeMultiple($data): void + { + $result = Hash::extract($data, '{n}.Comment.{n}[user_id > 2][id=1]'); + $this->assertEmpty($result); + + $result = Hash::extract($data, '{n}.Comment.{n}[user_id > 2][id=2]'); + $expected = [$data[0]['Comment'][1]]; + $this->assertSame($expected, $result); + $this->assertSame('4', $expected[0]['user_id']); + } + + /** + * Test attribute pattern matching. + * + * @param \ArrayAccess|array $data + */ + #[DataProvider('articleDataSets')] + public function testExtractAttributePattern($data): void + { + $result = Hash::extract($data, '{n}.Article[title=/^First/]'); + $expected = [$data[0]['Article']]; + $this->assertSame($expected, $result); + + $result = Hash::extract($data, '{n}.Article[title=/^Fir[a-z]+/]'); + $expected = [$data[0]['Article']]; + $this->assertSame($expected, $result); + } + + /** + * Test that extract() + matching can hit null things. + */ + public function testExtractMatchesNull(): void + { + $data = [ + 'Country' => [ + ['name' => 'Canada'], + ['name' => 'Australia'], + ['name' => null], + ], + ]; + $result = Hash::extract($data, 'Country.{n}[name=/Canada|^$/]'); + $expected = [ + [ + 'name' => 'Canada', + ], + [ + 'name' => null, + ], + ]; + $this->assertSame($expected, $result); + + $data = new ArrayObject([ + 'Country' => new ArrayObject([ + ['name' => 'Canada'], + ['name' => 'Australia'], + ['name' => null], + ]), + ]); + $result = Hash::extract($data, 'Country.{n}[name=/Canada|^$/]'); + $this->assertSame($expected, $result); + } + + /** + * Test extracting attributes with string + */ + public function testExtractAttributeString(): void + { + $data = [ + ['value' => 0], + ['value' => 3], + ['value' => 'string-value'], + ['value' => new DateTime('2010-01-05 01:23:45')], + ]; + + // check _matches does not work as `0 == 'string-value'` + $expected = [$data[2]]; + $result = Hash::extract($data, '{n}[value=string-value]'); + $this->assertSame($expected, $result); + + // check _matches work with object implements __toString() + $expected = [$data[3]]; + $result = Hash::extract($data, sprintf('{n}[value=%s]', $data[3]['value'])); + $this->assertSame($expected, $result); + + // check _matches does not work as `3 == '3 people'` + $unexpected = $data[1]; + $result = Hash::extract($data, '{n}[value=3people]'); + $this->assertNotContains($unexpected, $result); + } + + /** + * Test that uneven keys are handled correctly. + */ + public function testExtractUnevenKeys(): void + { + $data = [ + 'Level1' => [ + 'Level2' => ['test1', 'test2'], + 'Level2bis' => ['test3', 'test4'], + ], + ]; + $this->assertSame( + ['test1', 'test2'], + Hash::extract($data, 'Level1.Level2'), + ); + $this->assertSame( + ['test3', 'test4'], + Hash::extract($data, 'Level1.Level2bis'), + ); + + $data = new ArrayObject([ + 'Level1' => new ArrayObject([ + 'Level2' => ['test1', 'test2'], + 'Level2bis' => ['test3', 'test4'], + ]), + ]); + $this->assertSame( + ['test1', 'test2'], + Hash::extract($data, 'Level1.Level2'), + ); + $this->assertSame( + ['test3', 'test4'], + Hash::extract($data, 'Level1.Level2bis'), + ); + + $data = [ + 'Level1' => [ + 'Level2bis' => [ + ['test3', 'test4'], + ['test5', 'test6'], + ], + ], + ]; + $expected = [ + ['test3', 'test4'], + ['test5', 'test6'], + ]; + $this->assertSame($expected, Hash::extract($data, 'Level1.Level2bis')); + + $data['Level1']['Level2'] = ['test1', 'test2']; + $this->assertSame($expected, Hash::extract($data, 'Level1.Level2bis')); + + $data = new ArrayObject([ + 'Level1' => new ArrayObject([ + 'Level2bis' => [ + ['test3', 'test4'], + ['test5', 'test6'], + ], + ]), + ]); + $this->assertSame($expected, Hash::extract($data, 'Level1.Level2bis')); + + $data['Level1']['Level2'] = ['test1', 'test2']; + $this->assertSame($expected, Hash::extract($data, 'Level1.Level2bis')); + } + + /** + * Tests that objects as values handled correctly. + */ + public function testExtractObjects(): void + { + $data = [ + 'root' => [ + 'array' => new ArrayObject([ + 'foo' => 'bar', + ]), + 'created' => new DateTime('2010-01-05'), + ], + ]; + + $result = Hash::extract($data, 'root.created'); + $this->assertSame([$data['root']['created']], $result); + + $result = Hash::extract($data, 'root.array'); + $this->assertSame(['foo' => 'bar'], $result); + + $result = Hash::extract($data, 'root.array.foo'); + $this->assertSame(['bar'], $result); + } + + /** + * testSort method + */ + public function testSort(): void + { + $result = Hash::sort([], '{n}.name'); + $this->assertSame([], $result); + + $a = [ + 0 => [ + 'Person' => ['name' => 'Jeff'], + 'Friend' => [['name' => 'Nate']], + ], + 1 => [ + 'Person' => ['name' => 'Tracy'], + 'Friend' => [['name' => 'Lindsay']], + ], + ]; + $b = [ + 0 => [ + 'Person' => ['name' => 'Tracy'], + 'Friend' => [['name' => 'Lindsay']], + ], + 1 => [ + 'Person' => ['name' => 'Jeff'], + 'Friend' => [['name' => 'Nate']], + ], + ]; + $a = Hash::sort($a, '{n}.Friend.{n}.name'); + $this->assertSame($a, $b); + + $b = [ + 0 => [ + 'Person' => ['name' => 'Jeff'], + 'Friend' => [['name' => 'Nate']], + ], + 1 => [ + 'Person' => ['name' => 'Tracy'], + 'Friend' => [['name' => 'Lindsay']], + ], + ]; + $a = [ + 0 => [ + 'Person' => ['name' => 'Tracy'], + 'Friend' => [['name' => 'Lindsay']], + ], + 1 => [ + 'Person' => ['name' => 'Jeff'], + 'Friend' => [['name' => 'Nate']], + ], + ]; + $a = Hash::sort($a, '{n}.Friend.{n}.name', 'desc'); + $this->assertSame($a, $b); + + $a = [ + 0 => [ + 'Person' => ['name' => 'Jeff'], + 'Friend' => [['name' => 'Nate']], + ], + 1 => [ + 'Person' => ['name' => 'Tracy'], + 'Friend' => [['name' => 'Lindsay']], + ], + 2 => [ + 'Person' => ['name' => 'Adam'], + 'Friend' => [['name' => 'Bob']], + ], + ]; + $b = [ + 0 => [ + 'Person' => ['name' => 'Adam'], + 'Friend' => [['name' => 'Bob']], + ], + 1 => [ + 'Person' => ['name' => 'Jeff'], + 'Friend' => [['name' => 'Nate']], + ], + 2 => [ + 'Person' => ['name' => 'Tracy'], + 'Friend' => [['name' => 'Lindsay']], + ], + ]; + $a = Hash::sort($a, '{n}.Person.name', 'asc'); + $this->assertSame($a, $b); + + $a = [ + 0 => ['Person' => ['name' => 'Jeff']], + 1 => ['Shirt' => ['color' => 'black']], + ]; + $b = [ + 0 => ['Shirt' => ['color' => 'black']], + 1 => ['Person' => ['name' => 'Jeff']], + ]; + $a = Hash::sort($a, '{n}.Person.name', 'ASC', 'STRING'); + $this->assertSame($a, $b); + + $names = [ + ['employees' => [ + ['name' => ['first' => 'John', 'last' => 'Doe']]], + ], + ['employees' => [ + ['name' => ['first' => 'Jane', 'last' => 'Doe']]], + ], + ['employees' => [['name' => []]]], + ['employees' => [['name' => []]]], + ]; + $result = Hash::sort($names, '{n}.employees.0.name', 'asc'); + $expected = [ + ['employees' => [ + ['name' => ['first' => 'John', 'last' => 'Doe']]], + ], + ['employees' => [ + ['name' => ['first' => 'Jane', 'last' => 'Doe']]], + ], + ['employees' => [['name' => []]]], + ['employees' => [['name' => []]]], + ]; + $this->assertSame($expected, $result); + + $a = [ + 'SU' => [ + 'total_fulfillable' => 2, + ], + 'AA' => [ + 'total_fulfillable' => 1, + ], + 'LX' => [ + 'total_fulfillable' => 0, + ], + 'BL' => [ + 'total_fulfillable' => 3, + ], + ]; + $expected = [ + 'LX' => [ + 'total_fulfillable' => 0, + ], + 'AA' => [ + 'total_fulfillable' => 1, + ], + 'SU' => [ + 'total_fulfillable' => 2, + ], + 'BL' => [ + 'total_fulfillable' => 3, + ], + ]; + $result = Hash::sort($a, '{s}.total_fulfillable', 'asc'); + $this->assertSame($expected, $result); + } + + /** + * Test sort() with numeric option. + */ + public function testSortNumeric(): void + { + $items = [ + ['Item' => ['price' => '155,000']], + ['Item' => ['price' => '139,000']], + ['Item' => ['price' => '275,622']], + ['Item' => ['price' => '230,888']], + ['Item' => ['price' => '66,000']], + ]; + $result = Hash::sort($items, '{n}.Item.price', 'asc', 'numeric'); + $expected = [ + ['Item' => ['price' => '66,000']], + ['Item' => ['price' => '139,000']], + ['Item' => ['price' => '155,000']], + ['Item' => ['price' => '230,888']], + ['Item' => ['price' => '275,622']], + ]; + $this->assertSame($expected, $result); + + $result = Hash::sort($items, '{n}.Item.price', 'desc', 'numeric'); + $expected = [ + ['Item' => ['price' => '275,622']], + ['Item' => ['price' => '230,888']], + ['Item' => ['price' => '155,000']], + ['Item' => ['price' => '139,000']], + ['Item' => ['price' => '66,000']], + ]; + $this->assertSame($expected, $result); + } + + /** + * Test natural sorting. + */ + public function testSortNatural(): void + { + $items = [ + ['Item' => ['image' => 'img1.jpg']], + ['Item' => ['image' => 'img99.jpg']], + ['Item' => ['image' => 'img12.jpg']], + ['Item' => ['image' => 'img10.jpg']], + ['Item' => ['image' => 'img2.jpg']], + ]; + $result = Hash::sort($items, '{n}.Item.image', 'desc', 'natural'); + $expected = [ + ['Item' => ['image' => 'img99.jpg']], + ['Item' => ['image' => 'img12.jpg']], + ['Item' => ['image' => 'img10.jpg']], + ['Item' => ['image' => 'img2.jpg']], + ['Item' => ['image' => 'img1.jpg']], + ]; + $this->assertSame($expected, $result); + + $result = Hash::sort($items, '{n}.Item.image', 'asc', 'natural'); + $expected = [ + ['Item' => ['image' => 'img1.jpg']], + ['Item' => ['image' => 'img2.jpg']], + ['Item' => ['image' => 'img10.jpg']], + ['Item' => ['image' => 'img12.jpg']], + ['Item' => ['image' => 'img99.jpg']], + ]; + $this->assertSame($expected, $result); + } + + /** + * Test sort() with locale option. + */ + public function testSortLocale(): void + { + // get the current locale + $original = setlocale(LC_COLLATE, '0'); + $updated = setlocale(LC_COLLATE, 'de_DE.utf8'); + $this->skipIf($updated === false, 'Could not set locale to de_DE.utf8, skipping test.'); + + $items = [ + ['Item' => ['entry' => 'Übergabe']], + ['Item' => ['entry' => 'Ostfriesland']], + ['Item' => ['entry' => 'Äpfel']], + ['Item' => ['entry' => 'Apfel']], + ]; + + $result = Hash::sort($items, '{n}.Item.entry', 'asc', 'locale'); + $expected = [ + ['Item' => ['entry' => 'Apfel']], + ['Item' => ['entry' => 'Äpfel']], + ['Item' => ['entry' => 'Ostfriesland']], + ['Item' => ['entry' => 'Übergabe']], + ]; + + setlocale(LC_COLLATE, $original); + $this->assertSame($expected, $result); + } + + /** + * Test that sort() with 'natural' type will fallback to 'regular' as SORT_NATURAL is introduced in PHP 5.4 + */ + public function testSortNaturalFallbackToRegular(): void + { + $a = [ + 0 => ['Person' => ['name' => 'Jeff']], + 1 => ['Shirt' => ['color' => 'black']], + ]; + $b = [ + 0 => ['Shirt' => ['color' => 'black']], + 1 => ['Person' => ['name' => 'Jeff']], + ]; + $sorted = Hash::sort($a, '{n}.Person.name', 'asc', 'natural'); + $this->assertSame($sorted, $b); + } + + /** + * test sorting with out of order keys. + */ + public function testSortWithOutOfOrderKeys(): void + { + $data = [ + 9 => ['class' => 510, 'test2' => 2], + 1 => ['class' => 500, 'test2' => 1], + 2 => ['class' => 600, 'test2' => 2], + 5 => ['class' => 625, 'test2' => 4], + 0 => ['class' => 605, 'test2' => 3], + ]; + $expected = [ + ['class' => 500, 'test2' => 1], + ['class' => 510, 'test2' => 2], + ['class' => 600, 'test2' => 2], + ['class' => 605, 'test2' => 3], + ['class' => 625, 'test2' => 4], + ]; + $result = Hash::sort($data, '{n}.class', 'asc'); + $this->assertSame($expected, $result); + + $result = Hash::sort($data, '{n}.test2', 'asc'); + $this->assertSame($expected, $result); + } + + /** + * test sorting with string keys. + */ + public function testSortString(): void + { + $toSort = [ + 'four' => ['number' => 4, 'some' => 'foursome'], + 'six' => ['number' => 6, 'some' => 'sixsome'], + 'five' => ['number' => 5, 'some' => 'fivesome'], + 'two' => ['number' => 2, 'some' => 'twosome'], + 'three' => ['number' => 3, 'some' => 'threesome'], + ]; + $sorted = Hash::sort($toSort, '{s}.number', 'asc'); + $expected = [ + 'two' => ['number' => 2, 'some' => 'twosome'], + 'three' => ['number' => 3, 'some' => 'threesome'], + 'four' => ['number' => 4, 'some' => 'foursome'], + 'five' => ['number' => 5, 'some' => 'fivesome'], + 'six' => ['number' => 6, 'some' => 'sixsome'], + ]; + $this->assertSame($expected, $sorted); + + $menus = [ + 'blogs' => ['title' => 'Blogs', 'weight' => 3], + 'comments' => ['title' => 'Comments', 'weight' => 2], + 'users' => ['title' => 'Users', 'weight' => 1], + ]; + $expected = [ + 'users' => ['title' => 'Users', 'weight' => 1], + 'comments' => ['title' => 'Comments', 'weight' => 2], + 'blogs' => ['title' => 'Blogs', 'weight' => 3], + ]; + $result = Hash::sort($menus, '{s}.weight', 'ASC'); + $this->assertSame($expected, $result); + } + + /** + * test sorting with string ignoring case. + */ + public function testSortStringIgnoreCase(): void + { + $toSort = [ + ['Item' => ['name' => 'bar']], + ['Item' => ['name' => 'Baby']], + ['Item' => ['name' => 'Baz']], + ['Item' => ['name' => 'bat']], + ]; + $sorted = Hash::sort($toSort, '{n}.Item.name', 'asc', ['type' => 'string', 'ignoreCase' => true]); + $expected = [ + ['Item' => ['name' => 'Baby']], + ['Item' => ['name' => 'bar']], + ['Item' => ['name' => 'bat']], + ['Item' => ['name' => 'Baz']], + ]; + $this->assertSame($expected, $sorted); + } + + /** + * test regular sorting ignoring case. + */ + public function testSortRegularIgnoreCase(): void + { + $toSort = [ + ['Item' => ['name' => 'bar']], + ['Item' => ['name' => 'Baby']], + ['Item' => ['name' => 'Baz']], + ['Item' => ['name' => 'bat']], + ]; + $sorted = Hash::sort($toSort, '{n}.Item.name', 'asc', ['type' => 'regular', 'ignoreCase' => true]); + $expected = [ + ['Item' => ['name' => 'Baby']], + ['Item' => ['name' => 'bar']], + ['Item' => ['name' => 'bat']], + ['Item' => ['name' => 'Baz']], + ]; + $this->assertSame($expected, $sorted); + } + + /** + * Test sorting on a nested key that is sometimes undefined. + */ + public function testSortSparse(): void + { + $data = [ + [ + 'id' => 1, + 'title' => 'element 1', + 'extra' => 1, + ], + [ + 'id' => 2, + 'title' => 'element 2', + 'extra' => 2, + ], + [ + 'id' => 3, + 'title' => 'element 3', + ], + [ + 'id' => 4, + 'title' => 'element 4', + 'extra' => 4, + ], + ]; + $result = Hash::sort($data, '{n}.extra', 'desc', 'natural'); + $expected = [ + [ + 'id' => 4, + 'title' => 'element 4', + 'extra' => 4, + ], + [ + 'id' => 2, + 'title' => 'element 2', + 'extra' => 2, + ], + [ + 'id' => 1, + 'title' => 'element 1', + 'extra' => 1, + ], + [ + 'id' => 3, + 'title' => 'element 3', + ], + ]; + $this->assertSame($expected, $result); + } + + /** + * Test insert() + */ + public function testInsertSimple(): void + { + $a = [ + 'pages' => ['name' => 'page'], + ]; + $result = Hash::insert($a, 'files', ['name' => 'files']); + $expected = [ + 'pages' => ['name' => 'page'], + 'files' => ['name' => 'files'], + ]; + $this->assertSame($expected, $result); + + $a = [ + 'pages' => ['name' => 'page'], + ]; + $result = Hash::insert($a, 'pages.name', []); + $expected = [ + 'pages' => ['name' => []], + ]; + $this->assertSame($expected, $result); + + $a = [ + 'foo' => ['bar' => 'baz'], + ]; + $result = Hash::insert($a, 'some.0123.path', ['foo' => ['bar' => 'baz']]); + $expected = ['foo' => ['bar' => 'baz']]; + $this->assertSame($expected, Hash::get($result, 'some.0123.path')); + } + + /** + * Test inserting with multiple values. + */ + public function testInsertMulti(): void + { + $data = static::articleData(); + + $result = Hash::insert($data, '{n}.Article.insert', 'value'); + $this->assertSame('value', $result[0]['Article']['insert']); + $this->assertSame('value', $result[1]['Article']['insert']); + + $result = Hash::insert($data, '{n}.Comment.{n}.insert', 'value'); + $this->assertSame('value', $result[0]['Comment'][0]['insert']); + $this->assertSame('value', $result[0]['Comment'][1]['insert']); + + $data = [ + 0 => ['Item' => ['id' => 1, 'title' => 'first']], + 1 => ['Item' => ['id' => 2, 'title' => 'second']], + 2 => ['Item' => ['id' => 3, 'title' => 'third']], + 3 => ['Item' => ['id' => 4, 'title' => 'fourth']], + 4 => ['Item' => ['id' => 5, 'title' => 'fifth']], + ]; + $result = Hash::insert($data, '{n}.Item[id=/\b2|\b4/]', ['test' => 2]); + $expected = [ + 0 => ['Item' => ['id' => 1, 'title' => 'first']], + 1 => ['Item' => ['id' => 2, 'title' => 'second', 'test' => 2]], + 2 => ['Item' => ['id' => 3, 'title' => 'third']], + 3 => ['Item' => ['id' => 4, 'title' => 'fourth', 'test' => 2]], + 4 => ['Item' => ['id' => 5, 'title' => 'fifth']], + ]; + $this->assertSame($expected, $result); + + $data[3]['testable'] = true; + $result = Hash::insert($data, '{n}[testable].Item[id=/\b2|\b4/].test', 2); + $expected = [ + 0 => ['Item' => ['id' => 1, 'title' => 'first']], + 1 => ['Item' => ['id' => 2, 'title' => 'second']], + 2 => ['Item' => ['id' => 3, 'title' => 'third']], + 3 => ['Item' => ['id' => 4, 'title' => 'fourth', 'test' => 2], 'testable' => true], + 4 => ['Item' => ['id' => 5, 'title' => 'fifth']], + ]; + $this->assertSame($expected, $result); + } + + /** + * test insert() with {s} placeholders and conditions. + */ + public function testInsertMultiWord(): void + { + $data = static::articleData(); + + $result = Hash::insert($data, '{n}.{s}.insert', 'value'); + $this->assertSame('value', $result[0]['Article']['insert']); + $this->assertSame('value', $result[1]['Article']['insert']); + + $data = [ + 0 => ['obj' => new stdClass(), 'Item' => ['id' => 1, 'title' => 'first']], + 1 => ['float' => 1.5, 'Item' => ['id' => 2, 'title' => 'second']], + 2 => ['int' => 1, 'Item' => ['id' => 3, 'title' => 'third']], + 3 => ['str' => 'yes', 'Item' => ['id' => 3, 'title' => 'third']], + 4 => ['bool' => true, 'Item' => ['id' => 4, 'title' => 'fourth']], + 5 => ['null' => null, 'Item' => ['id' => 5, 'title' => 'fifth']], + 6 => ['arrayish' => new ArrayObject(['val']), 'Item' => ['id' => 6, 'title' => 'sixth']], + ]; + $result = Hash::insert($data, '{n}.{s}[id=4].new', 'value'); + $this->assertEquals('value', $result[4]['Item']['new']); + } + + /** + * Test that insert() can insert data over a string value. + */ + public function testInsertOverwriteStringValue(): void + { + $data = [ + 'Some' => [ + 'string' => 'value', + ], + ]; + $result = Hash::insert($data, 'Some.string.value', ['values']); + $expected = [ + 'Some' => [ + 'string' => [ + 'value' => ['values'], + ], + ], + ]; + $this->assertSame($expected, $result); + } + + public function testInsertArrayAccess(): void + { + $testObject = new ArrayObject([ + 'name' => 'about', + 'vars' => ['title' => 'page title'], + ]); + + $a = [ + 'pages' => [ + 0 => ['name' => 'main'], + 1 => $testObject, + ], + ]; + + $result = Hash::insert($a, 'pages.1.vars.new', 1); + $expected = [ + 'pages' => [ + 0 => ['name' => 'main'], + 1 => $testObject, + ], + ]; + $this->assertSame($expected, $result); + $this->assertSame(['title' => 'page title', 'new' => 1], $testObject->getArrayCopy()['vars']); + + $result = new ArrayObject([ + 'name' => 'about', + 'vars' => ['title' => 'page title'], + ]); + $result = Hash::insert($result, 'vars', 1); + $expected = [ + 'name' => 'about', + 'vars' => 1, + ]; + $this->assertSame($expected, $result->getArrayCopy()); + + $a = new ArrayObject([ + 0 => [ + 'name' => 'pages', + ], + 1 => [ + 'name' => 'files', + ], + ]); + + $result = Hash::insert($a, '{n}[name=files]', 'new'); + $expected = [ + 0 => [ + 'name' => 'pages', + ], + 1 => [ + 'name' => 'files', + 0 => 'new', + ], + ]; + $this->assertSame($expected, $result->getArrayCopy()); + } + + /** + * Test remove() method. + */ + public function testRemove(): void + { + $a = [ + 'pages' => ['name' => 'page'], + 'files' => ['name' => 'files'], + ]; + + $result = Hash::remove($a, 'files'); + $expected = [ + 'pages' => ['name' => 'page'], + ]; + $this->assertSame($expected, $result); + + $a = [ + 'pages' => [ + 0 => ['name' => 'main'], + 1 => [ + 'name' => 'about', + 'vars' => ['title' => 'page title'], + ], + ], + ]; + + $result = Hash::remove($a, 'pages.1.vars'); + $expected = [ + 'pages' => [ + 0 => ['name' => 'main'], + 1 => ['name' => 'about'], + ], + ]; + $this->assertSame($expected, $result); + + $result = Hash::remove($a, 'pages.2.vars'); + $expected = $a; + $this->assertSame($expected, $result); + + $a = [ + 0 => [ + 'name' => 'pages', + ], + 1 => [ + 'name' => 'files', + ], + ]; + + $result = Hash::remove($a, '{n}[name=files]'); + $expected = [ + 0 => [ + 'name' => 'pages', + ], + ]; + $this->assertSame($expected, $result); + + $array = [ + 0 => 'foo', + 1 => [ + 0 => 'baz', + ], + ]; + $expected = $array; + $result = Hash::remove($array, '{n}.part'); + $this->assertSame($expected, $result); + $result = Hash::remove($array, '{n}.{n}.part'); + $this->assertSame($expected, $result); + + $array = [ + 'foo' => 'string', + ]; + $expected = $array; + $result = Hash::remove($array, 'foo.bar'); + $this->assertSame($expected, $result); + + $array = [ + 'foo' => 'string', + 'bar' => [ + 0 => 'a', + 1 => 'b', + ], + ]; + $expected = [ + 'foo' => 'string', + 'bar' => [ + 1 => 'b', + ], + ]; + $result = Hash::remove($array, '{s}.0'); + $this->assertSame($expected, $result); + + $array = [ + 'foo' => [ + 0 => 'a', + 1 => 'b', + ], + ]; + $expected = [ + 'foo' => [ + 1 => 'b', + ], + ]; + $result = Hash::remove($array, 'foo[1=b].0'); + $this->assertSame($expected, $result); + } + + /** + * Test removing multiple values. + */ + public function testRemoveMulti(): void + { + $data = static::articleData(); + + $result = Hash::remove($data, '{n}.Article.title'); + $this->assertFalse(isset($result[0]['Article']['title'])); + $this->assertFalse(isset($result[1]['Article']['title'])); + + $result = Hash::remove($data, '{n}.Article.{s}'); + $this->assertFalse(isset($result[0]['Article']['id'])); + $this->assertFalse(isset($result[0]['Article']['user_id'])); + $this->assertFalse(isset($result[0]['Article']['title'])); + $this->assertFalse(isset($result[0]['Article']['body'])); + + $data = [ + 0 => ['Item' => ['id' => 1, 'title' => 'first']], + 1 => ['Item' => ['id' => 2, 'title' => 'second']], + 2 => ['Item' => ['id' => 3, 'title' => 'third']], + 3 => ['Item' => ['id' => 4, 'title' => 'fourth']], + 4 => ['Item' => ['id' => 5, 'title' => 'fifth']], + ]; + + $result = Hash::remove($data, '{n}.Item[id=/\b2|\b4/]'); + $expected = [ + 0 => ['Item' => ['id' => 1, 'title' => 'first']], + 2 => ['Item' => ['id' => 3, 'title' => 'third']], + 4 => ['Item' => ['id' => 5, 'title' => 'fifth']], + ]; + $this->assertSame($expected, $result); + + $data[3]['testable'] = true; + $result = Hash::remove($data, '{n}[testable].Item[id=/\b2|\b4/].title'); + $expected = [ + 0 => ['Item' => ['id' => 1, 'title' => 'first']], + 1 => ['Item' => ['id' => 2, 'title' => 'second']], + 2 => ['Item' => ['id' => 3, 'title' => 'third']], + 3 => ['Item' => ['id' => 4], 'testable' => true], + 4 => ['Item' => ['id' => 5, 'title' => 'fifth']], + ]; + $this->assertSame($expected, $result); + } + + public function testRemoveArrayAccess(): void + { + $testObject = new ArrayObject([ + 'name' => 'about', + 'vars' => ['title' => 'page title'], + ]); + + $a = [ + 'pages' => [ + 0 => ['name' => 'main'], + 1 => $testObject, + ], + ]; + + $result = Hash::remove($a, 'pages.1.vars'); + $expected = [ + 'pages' => [ + 0 => ['name' => 'main'], + 1 => $testObject, + ], + ]; + $this->assertSame($expected, $result); + $this->assertSame(['name' => 'about'], $testObject->getArrayCopy()); + + $result = new ArrayObject([ + 'name' => 'about', + 'vars' => ['title' => 'page title'], + ]); + $result = Hash::remove($result, 'vars.title'); + $expected = [ + 'name' => 'about', + 'vars' => [], + ]; + $this->assertSame($expected, $result->getArrayCopy()); + + $a = new ArrayObject([ + 0 => [ + 'name' => 'pages', + ], + 1 => [ + 'name' => 'files', + ], + ]); + + $result = Hash::remove($a, '{n}[name=files]'); + $expected = [ + 0 => [ + 'name' => 'pages', + ], + ]; + $this->assertSame($expected, $result->getArrayCopy()); + } + + /** + * testCheck method + */ + public function testCheck(): void + { + $set = [ + 'My Index 1' => ['First' => 'The first item'], + ]; + $this->assertTrue(Hash::check($set, 'My Index 1.First')); + $this->assertTrue(Hash::check($set, 'My Index 1')); + + $set = [ + 'My Index 1' => [ + 'First' => [ + 'Second' => [ + 'Third' => [ + 'Fourth' => 'Heavy. Nesting.', + ], + ], + ], + ], + ]; + $this->assertTrue(Hash::check($set, 'My Index 1.First.Second')); + $this->assertTrue(Hash::check($set, 'My Index 1.First.Second.Third')); + $this->assertTrue(Hash::check($set, 'My Index 1.First.Second.Third.Fourth')); + $this->assertFalse(Hash::check($set, 'My Index 1.First.Seconds.Third.Fourth')); + } + + /** + * testCombine method + */ + public function testCombine(): void + { + $result = Hash::combine([], '{n}.User.id', '{n}.User.Data'); + $this->assertEmpty($result); + + $a = static::userData(); + + $result = Hash::combine($a, '{n}.User.id'); + $expected = [2 => null, 14 => null, 25 => null]; + $this->assertSame($expected, $result); + + $result = Hash::combine($a, '{n}.User.id', '{n}.User.nonexistent'); + $expected = [2 => null, 14 => null, 25 => null]; + $this->assertSame($expected, $result); + + $result = Hash::combine($a, '{n}.User.id', '{n}.User.Data'); + $expected = [ + 2 => ['user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'], + 14 => ['user' => 'phpnut', 'name' => 'Larry E. Masters'], + 25 => ['user' => 'gwoo', 'name' => 'The Gwoo']]; + $this->assertSame($expected, $result); + + $result = Hash::combine($a, '{n}.User.id', '{n}.User.Data.name'); + $expected = [ + 2 => 'Mariano Iglesias', + 14 => 'Larry E. Masters', + 25 => 'The Gwoo']; + $this->assertSame($expected, $result); + } + + /** + * test combine() with null key path. + */ + public function testCombineWithNullKeyPath(): void + { + $result = Hash::combine([], null, '{n}.User.Data'); + $this->assertEmpty($result); + + $a = static::userData(); + + $result = Hash::combine($a, null); + $expected = [0 => null, 1 => null, 2 => null]; + $this->assertEquals($expected, $result); + + $result = Hash::combine($a, null, '{n}.User.nonexistent'); + $expected = [0 => null, 1 => null, 2 => null]; + $this->assertEquals($expected, $result); + + $result = Hash::combine($a, null, '{n}.User.Data'); + $expected = [ + 0 => ['user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'], + 1 => ['user' => 'phpnut', 'name' => 'Larry E. Masters'], + 2 => ['user' => 'gwoo', 'name' => 'The Gwoo']]; + $this->assertEquals($expected, $result); + + $result = Hash::combine($a, null, '{n}.User.Data.name'); + $expected = [ + 0 => 'Mariano Iglesias', + 1 => 'Larry E. Masters', + 2 => 'The Gwoo']; + $this->assertEquals($expected, $result); + } + + /** + * test combine() giving errors on key/value length mismatches. + */ + public function testCombineErrorMissingValue(): void + { + $this->expectException(InvalidArgumentException::class); + $data = [ + ['User' => ['id' => 1, 'name' => 'mark']], + ['User' => ['name' => 'jose']], + ]; + Hash::combine($data, '{n}.User.id', '{n}.User.name'); + } + + /** + * test combine() giving errors on key/value length mismatches. + */ + public function testCombineErrorMissingKey(): void + { + $this->expectException(InvalidArgumentException::class); + $data = [ + ['User' => ['id' => 1, 'name' => 'mark']], + ['User' => ['id' => 2]], + ]; + Hash::combine($data, '{n}.User.id', '{n}.User.name'); + } + + /** + * test combine() with a group path. + */ + public function testCombineWithGroupPath(): void + { + $a = static::userData(); + + $result = Hash::combine($a, '{n}.User.id', '{n}.User.Data', '{n}.User.group_id'); + $expected = [ + 1 => [ + 2 => ['user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'], + 25 => ['user' => 'gwoo', 'name' => 'The Gwoo'], + ], + 2 => [ + 14 => ['user' => 'phpnut', 'name' => 'Larry E. Masters'], + ], + ]; + $this->assertSame($expected, $result); + + $result = Hash::combine($a, null, '{n}.User.Data', '{n}.User.group_id'); + $expected = [ + 1 => [ + 0 => ['user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'], + 1 => ['user' => 'gwoo', 'name' => 'The Gwoo'], + ], + 2 => [ + 0 => ['user' => 'phpnut', 'name' => 'Larry E. Masters'], + ], + ]; + $this->assertEquals($expected, $result); + + $result = Hash::combine($a, '{n}.User.id', '{n}.User.Data.name', '{n}.User.group_id'); + $expected = [ + 1 => [ + 2 => 'Mariano Iglesias', + 25 => 'The Gwoo', + ], + 2 => [ + 14 => 'Larry E. Masters', + ], + ]; + $this->assertSame($expected, $result); + + $result = Hash::combine($a, null, '{n}.User.Data.name', '{n}.User.group_id'); + $expected = [ + 1 => [ + 0 => 'Mariano Iglesias', + 1 => 'The Gwoo', + ], + 2 => [ + 0 => 'Larry E. Masters', + ], + ]; + $this->assertEquals($expected, $result); + + $result = Hash::combine($a, '{n}.User.id', '{n}.User.Data', '{n}.User.group_id'); + $expected = [ + 1 => [ + 2 => ['user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'], + 25 => ['user' => 'gwoo', 'name' => 'The Gwoo'], + ], + 2 => [ + 14 => ['user' => 'phpnut', 'name' => 'Larry E. Masters'], + ], + ]; + $this->assertSame($expected, $result); + + $result = Hash::combine($a, null, '{n}.User.Data', '{n}.User.group_id'); + $expected = [ + 1 => [ + 0 => ['user' => 'mariano.iglesias', 'name' => 'Mariano Iglesias'], + 1 => ['user' => 'gwoo', 'name' => 'The Gwoo'], + ], + 2 => [ + 0 => ['user' => 'phpnut', 'name' => 'Larry E. Masters'], + ], + ]; + $this->assertEquals($expected, $result); + + $result = Hash::combine($a, '{n}.User.id', '{n}.User.Data.name', '{n}.User.group_id'); + $expected = [ + 1 => [ + 2 => 'Mariano Iglesias', + 25 => 'The Gwoo', + ], + 2 => [ + 14 => 'Larry E. Masters', + ], + ]; + $this->assertSame($expected, $result); + + $result = Hash::combine($a, null, '{n}.User.Data.name', '{n}.User.group_id'); + $expected = [ + 1 => [ + 0 => 'Mariano Iglesias', + 1 => 'The Gwoo', + ], + 2 => [ + 0 => 'Larry E. Masters', + ], + ]; + $this->assertSame($expected, $result); + } + + /** + * Test combine with formatting rules. + */ + public function testCombineWithFormatting(): void + { + $a = static::userData(); + + $result = Hash::combine( + $a, + '{n}.User.id', + ['%1$s: %2$s', '{n}.User.Data.user', '{n}.User.Data.name'], + '{n}.User.group_id', + ); + $expected = [ + 1 => [ + 2 => 'mariano.iglesias: Mariano Iglesias', + 25 => 'gwoo: The Gwoo', + ], + 2 => [ + 14 => 'phpnut: Larry E. Masters', + ], + ]; + $this->assertSame($expected, $result); + + $result = Hash::combine( + $a, + null, + ['%1$s: %2$s', '{n}.User.Data.user', '{n}.User.Data.name'], + '{n}.User.group_id', + ); + $expected = [ + 1 => [ + 0 => 'mariano.iglesias: Mariano Iglesias', + 1 => 'gwoo: The Gwoo', + ], + 2 => [ + 0 => 'phpnut: Larry E. Masters', + ], + ]; + $this->assertEquals($expected, $result); + + $result = Hash::combine( + $a, + [ + '%s: %s', + '{n}.User.Data.user', + '{n}.User.Data.name', + ], + '{n}.User.id', + ); + $expected = [ + 'mariano.iglesias: Mariano Iglesias' => 2, + 'phpnut: Larry E. Masters' => 14, + 'gwoo: The Gwoo' => 25, + ]; + $this->assertSame($expected, $result); + + $result = Hash::combine( + $a, + ['%1$s: %2$d', '{n}.User.Data.user', '{n}.User.id'], + '{n}.User.Data.name', + ); + $expected = [ + 'mariano.iglesias: 2' => 'Mariano Iglesias', + 'phpnut: 14' => 'Larry E. Masters', + 'gwoo: 25' => 'The Gwoo', + ]; + $this->assertSame($expected, $result); + + $result = Hash::combine( + $a, + ['%2$d: %1$s', '{n}.User.Data.user', '{n}.User.id'], + '{n}.User.Data.name', + ); + $expected = [ + '2: mariano.iglesias' => 'Mariano Iglesias', + '14: phpnut' => 'Larry E. Masters', + '25: gwoo' => 'The Gwoo', + ]; + $this->assertSame($expected, $result); + } + + /** + * testFormat method + */ + public function testFormat(): void + { + $data = static::userData(); + + $result = Hash::format( + $data, + ['{n}.User.Data.user', '{n}.User.id'], + '%s, %s', + ); + $expected = [ + 'mariano.iglesias, 2', + 'phpnut, 14', + 'gwoo, 25', + ]; + $this->assertSame($expected, $result); + + $result = Hash::format( + $data, + ['{n}.User.Data.user', '{n}.User.id'], + '%2$s, %1$s', + ); + $expected = [ + '2, mariano.iglesias', + '14, phpnut', + '25, gwoo', + ]; + $this->assertSame($expected, $result); + } + + /** + * testFormattingNullValues method + */ + public function testFormatNullValues(): void + { + $data = [ + ['Person' => [ + 'first_name' => 'Nate', 'last_name' => 'Abele', 'city' => 'Boston', 'state' => 'MA', 'something' => '42', + ]], + ['Person' => [ + 'first_name' => 'Larry', 'last_name' => 'Masters', 'city' => 'Boondock', 'state' => 'TN', 'something' => null, + ]], + ['Person' => [ + 'first_name' => 'Garrett', 'last_name' => 'Woodworth', 'city' => 'Venice Beach', 'state' => 'CA', 'something' => null, + ]], + ]; + + $result = Hash::format($data, ['{n}.Person.something'], '%s'); + $expected = ['42', '', '']; + $this->assertSame($expected, $result); + + $result = Hash::format($data, ['{n}.Person.city', '{n}.Person.something'], '%s, %s'); + $expected = ['Boston, 42', 'Boondock, ', 'Venice Beach, ']; + $this->assertSame($expected, $result); + } + + /** + * Test map() + */ + public function testMap(): void + { + $data = static::articleData(); + + $result = Hash::map($data, '{n}.Article.id', $this->mapCallback(...)); + $expected = [2, 4, 6, 8, 10]; + $this->assertSame($expected, $result); + } + + /** + * testApply + */ + public function testApply(): void + { + $data = static::articleData(); + + $result = Hash::apply($data, '{n}.Article.id', 'array_sum'); + $this->assertSame(15, $result); + } + + /** + * Test reduce() + */ + public function testReduce(): void + { + $data = static::articleData(); + + $result = Hash::reduce($data, '{n}.Article.id', $this->reduceCallback(...)); + $this->assertSame(15, $result); + } + + /** + * testing method for map callbacks. + * + * @param mixed $value Value + * @return mixed + */ + public function mapCallback($value) + { + return $value * 2; + } + + /** + * testing method for reduce callbacks. + * + * @param mixed $one First param + * @param mixed $two Second param + * @return mixed + */ + public function reduceCallback($one, $two) + { + return $one + $two; + } + + /** + * test Hash nest with a normal model result set. For kicks rely on Hash nest detecting the key names + * automatically + */ + public function testNestModel(): void + { + $input = [ + [ + 'ModelName' => [ + 'id' => 1, + 'parent_id' => null, + ], + ], + [ + 'ModelName' => [ + 'id' => 2, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 3, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 4, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 5, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 6, + 'parent_id' => null, + ], + ], + [ + 'ModelName' => [ + 'id' => 7, + 'parent_id' => 6, + ], + ], + [ + 'ModelName' => [ + 'id' => 8, + 'parent_id' => 6, + ], + ], + [ + 'ModelName' => [ + 'id' => 9, + 'parent_id' => 6, + ], + ], + [ + 'ModelName' => [ + 'id' => 10, + 'parent_id' => 6, + ], + ], + ]; + $expected = [ + [ + 'ModelName' => [ + 'id' => 1, + 'parent_id' => null, + ], + 'children' => [ + [ + 'ModelName' => [ + 'id' => 2, + 'parent_id' => 1, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 3, + 'parent_id' => 1, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 4, + 'parent_id' => 1, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 5, + 'parent_id' => 1, + ], + 'children' => [], + ], + + ], + ], + [ + 'ModelName' => [ + 'id' => 6, + 'parent_id' => null, + ], + 'children' => [ + [ + 'ModelName' => [ + 'id' => 7, + 'parent_id' => 6, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 8, + 'parent_id' => 6, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 9, + 'parent_id' => 6, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 10, + 'parent_id' => 6, + ], + 'children' => [], + ], + ], + ], + ]; + $result = Hash::nest($input); + $this->assertSame($expected, $result); + } + + /** + * test Hash nest with a normal model result set, and a nominated root id + */ + public function testNestModelExplicitRoot(): void + { + $input = [ + [ + 'ModelName' => [ + 'id' => 1, + 'parent_id' => null, + ], + ], + [ + 'ModelName' => [ + 'id' => 2, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 3, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 4, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 5, + 'parent_id' => 1, + ], + ], + [ + 'ModelName' => [ + 'id' => 6, + 'parent_id' => null, + ], + ], + [ + 'ModelName' => [ + 'id' => 7, + 'parent_id' => 6, + ], + ], + [ + 'ModelName' => [ + 'id' => 8, + 'parent_id' => 6, + ], + ], + [ + 'ModelName' => [ + 'id' => 9, + 'parent_id' => 6, + ], + ], + [ + 'ModelName' => [ + 'id' => 10, + 'parent_id' => 6, + ], + ], + ]; + $expected = [ + [ + 'ModelName' => [ + 'id' => 6, + 'parent_id' => null, + ], + 'children' => [ + [ + 'ModelName' => [ + 'id' => 7, + 'parent_id' => 6, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 8, + 'parent_id' => 6, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 9, + 'parent_id' => 6, + ], + 'children' => [], + ], + [ + 'ModelName' => [ + 'id' => 10, + 'parent_id' => 6, + ], + 'children' => [], + ], + ], + ], + ]; + $result = Hash::nest($input, ['root' => 6]); + $this->assertSame($expected, $result); + } + + /** + * test Hash nest with a 1d array - this method should be able to handle any type of array input + */ + public function testNest1Dimensional(): void + { + $input = [ + [ + 'id' => 1, + 'parent_id' => null, + ], + [ + 'id' => 2, + 'parent_id' => 1, + ], + [ + 'id' => 3, + 'parent_id' => 1, + ], + [ + 'id' => 4, + 'parent_id' => 1, + ], + [ + 'id' => 5, + 'parent_id' => 1, + ], + [ + 'id' => 6, + 'parent_id' => null, + ], + [ + 'id' => 7, + 'parent_id' => 6, + ], + [ + 'id' => 8, + 'parent_id' => 6, + ], + [ + 'id' => 9, + 'parent_id' => 6, + ], + [ + 'id' => 10, + 'parent_id' => 6, + ], + ]; + $expected = [ + [ + 'id' => 1, + 'parent_id' => null, + 'children' => [ + [ + 'id' => 2, + 'parent_id' => 1, + 'children' => [], + ], + [ + 'id' => 3, + 'parent_id' => 1, + 'children' => [], + ], + [ + 'id' => 4, + 'parent_id' => 1, + 'children' => [], + ], + [ + 'id' => 5, + 'parent_id' => 1, + 'children' => [], + ], + + ], + ], + [ + 'id' => 6, + 'parent_id' => null, + 'children' => [ + [ + 'id' => 7, + 'parent_id' => 6, + 'children' => [], + ], + [ + 'id' => 8, + 'parent_id' => 6, + 'children' => [], + ], + [ + 'id' => 9, + 'parent_id' => 6, + 'children' => [], + ], + [ + 'id' => 10, + 'parent_id' => 6, + 'children' => [], + ], + ], + ], + ]; + $result = Hash::nest($input, ['idPath' => '{n}.id', 'parentPath' => '{n}.parent_id']); + $this->assertSame($expected, $result); + } + + /** + * test Hash nest with no specified parent data. + * + * The result should be the same as the input. + * For an easier comparison, unset all the empty children arrays from the result + */ + public function testMissingParent(): void + { + $input = [ + [ + 'id' => 1, + ], + [ + 'id' => 2, + ], + [ + 'id' => 3, + ], + [ + 'id' => 4, + ], + [ + 'id' => 5, + ], + [ + 'id' => 6, + ], + [ + 'id' => 7, + ], + [ + 'id' => 8, + ], + [ + 'id' => 9, + ], + [ + 'id' => 10, + ], + ]; + + $result = Hash::nest($input, ['idPath' => '{n}.id', 'parentPath' => '{n}.parent_id']); + foreach ($result as &$row) { + if (empty($row['children'])) { + unset($row['children']); + } + } + $this->assertSame($input, $result); + } + + /** + * Tests that nest() throws an InvalidArgumentException when providing an invalid input. + */ + public function testNestInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $input = [ + [ + 'ParentCategory' => [ + 'id' => '1', + 'name' => 'Lorem ipsum dolor sit amet', + 'parent_id' => '1', + ], + ], + ]; + Hash::nest($input); + } + + /** + * testMergeDiff method + */ + public function testMergeDiff(): void + { + $first = [ + 'ModelOne' => [ + 'id' => 1001, + 'field_one' => 'a1.m1.f1', + 'field_two' => 'a1.m1.f2', + ], + ]; + $second = [ + 'ModelTwo' => [ + 'id' => 1002, + 'field_one' => 'a2.m2.f1', + 'field_two' => 'a2.m2.f2', + ], + ]; + $result = Hash::mergeDiff($first, $second); + $this->assertSame($result, $first + $second); + + $result = Hash::mergeDiff($first, []); + $this->assertSame($result, $first); + + $result = Hash::mergeDiff([], $first); + $this->assertSame($result, $first); + + $third = [ + 'ModelOne' => [ + 'id' => 1003, + 'field_one' => 'a3.m1.f1', + 'field_two' => 'a3.m1.f2', + 'field_three' => 'a3.m1.f3', + ], + ]; + $result = Hash::mergeDiff($first, $third); + $expected = [ + 'ModelOne' => [ + 'id' => 1001, + 'field_one' => 'a1.m1.f1', + 'field_two' => 'a1.m1.f2', + 'field_three' => 'a3.m1.f3', + ], + ]; + $this->assertSame($expected, $result); + + $first = [ + 0 => ['ModelOne' => ['id' => 1001, 'field_one' => 's1.0.m1.f1', 'field_two' => 's1.0.m1.f2']], + 1 => ['ModelTwo' => ['id' => 1002, 'field_one' => 's1.1.m2.f2', 'field_two' => 's1.1.m2.f2']], + ]; + $second = [ + 0 => ['ModelOne' => ['id' => 1001, 'field_one' => 's2.0.m1.f1', 'field_two' => 's2.0.m1.f2']], + 1 => ['ModelTwo' => ['id' => 1002, 'field_one' => 's2.1.m2.f2', 'field_two' => 's2.1.m2.f2']], + ]; + + $result = Hash::mergeDiff($first, $second); + $this->assertSame($result, $first); + + $third = [ + 0 => [ + 'ModelThree' => [ + 'id' => 1003, + 'field_one' => 's3.0.m3.f1', + 'field_two' => 's3.0.m3.f2', + ], + ], + ]; + + $result = Hash::mergeDiff($first, $third); + $expected = [ + 0 => [ + 'ModelOne' => [ + 'id' => 1001, + 'field_one' => 's1.0.m1.f1', + 'field_two' => 's1.0.m1.f2', + ], + 'ModelThree' => [ + 'id' => 1003, + 'field_one' => 's3.0.m3.f1', + 'field_two' => 's3.0.m3.f2', + ], + ], + 1 => [ + 'ModelTwo' => [ + 'id' => 1002, + 'field_one' => 's1.1.m2.f2', + 'field_two' => 's1.1.m2.f2', + ], + ], + ]; + $this->assertSame($expected, $result); + + $result = Hash::mergeDiff($first, []); + $this->assertSame($result, $first); + + $result = Hash::mergeDiff($first, $second); + $this->assertSame($result, $first + $second); + } + + /** + * Test mergeDiff() with scalar elements. + */ + public function testMergeDiffWithScalarValue(): void + { + $result = Hash::mergeDiff(['a' => 'value'], ['a' => ['value']]); + $this->assertSame(['a' => 'value'], $result); + + $result = Hash::mergeDiff(['a' => ['value']], ['a' => 'value']); + $this->assertSame(['a' => ['value']], $result); + } + + /** + * Tests Hash::expand + */ + public function testExpand(): void + { + $data = ['My', 'Array', 'To', 'Flatten']; + $flat = Hash::flatten($data); + $result = Hash::expand($flat); + $this->assertSame($data, $result); + + $data = [ + '0.Post.id' => '1', '0.Post.author_id' => '1', '0.Post.title' => 'First Post', '0.Author.id' => '1', + '0.Author.user' => 'nate', '0.Author.password' => 'foo', '1.Post.id' => '2', '1.Post.author_id' => '3', + '1.Post.title' => 'Second Post', '1.Post.body' => 'Second Post Body', '1.Author.id' => '3', + '1.Author.user' => 'larry', '1.Author.password' => null, + ]; + $result = Hash::expand($data); + $expected = [ + [ + 'Post' => ['id' => '1', 'author_id' => '1', 'title' => 'First Post'], + 'Author' => ['id' => '1', 'user' => 'nate', 'password' => 'foo'], + ], + [ + 'Post' => ['id' => '2', 'author_id' => '3', 'title' => 'Second Post', 'body' => 'Second Post Body'], + 'Author' => ['id' => '3', 'user' => 'larry', 'password' => null], + ], + ]; + $this->assertSame($expected, $result); + + $data = [ + '0/Post/id' => 1, + '0/Post/name' => 'test post', + ]; + $result = Hash::expand($data, '/'); + $expected = [ + [ + 'Post' => [ + 'id' => 1, + 'name' => 'test post', + ], + ], + ]; + $this->assertSame($expected, $result); + + $data = ['a.b.100.a' => null, 'a.b.200.a' => null]; + $expected = [ + 'a' => [ + 'b' => [ + 100 => ['a' => null], + 200 => ['a' => null], + ], + ], + ]; + $result = Hash::expand($data); + $this->assertSame($expected, $result); + } + + /** + * Test that flattening a large complex set doesn't loop forever. + */ + public function testFlattenInfiniteLoop(): void + { + $data = [ + 'Order.ASI' => '0', + 'Order.Accounting' => '0', + 'Order.Admin' => '0', + 'Order.Art' => '0', + 'Order.ArtChecker' => '0', + 'Order.Canned' => '0', + 'Order.Customer_Tags' => '', + 'Order.Embroidery' => '0', + 'Order.Item.0.Product.style_number' => 'a11222', + 'Order.Item.0.Product.slug' => 'a11222', + 'Order.Item.0.Product._id' => '4ff8b8d3d7bbe8ad30000000', + 'Order.Item.0.Product.Color.slug' => 'kelly_green', + 'Order.Item.0.Product.ColorSizes.0.Color.color' => 'Sport Grey', + 'Order.Item.0.Product.ColorSizes.0.Color.slug' => 'sport_grey', + 'Order.Item.0.Product.ColorSizes.1.Color.color' => 'Kelly Green', + 'Order.Item.0.Product.ColorSizes.1.Color.slug' => 'kelly_green', + 'Order.Item.0.Product.ColorSizes.2.Color.color' => 'Orange', + 'Order.Item.0.Product.ColorSizes.2.Color.slug' => 'orange', + 'Order.Item.0.Product.ColorSizes.3.Color.color' => 'Yellow Haze', + 'Order.Item.0.Product.ColorSizes.3.Color.slug' => 'yellow_haze', + 'Order.Item.0.Product.brand' => 'OUTER BANKS', + 'Order.Item.0.Product.style' => 'T-shirt', + 'Order.Item.0.Product.description' => 'uhiuhuih oin ooi ioo ioio', + 'Order.Item.0.Product.sizes.0.Size.qty' => '', + 'Order.Item.0.Product.sizes.0.Size.size' => '0-3mo', + 'Order.Item.0.Product.sizes.0.Size.id' => '38', + 'Order.Item.0.Product.sizes.1.Size.qty' => '', + 'Order.Item.0.Product.sizes.1.Size.size' => '3-6mo', + 'Order.Item.0.Product.sizes.1.Size.id' => '39', + 'Order.Item.0.Product.sizes.2.Size.qty' => '78', + 'Order.Item.0.Product.sizes.2.Size.size' => '6-9mo', + 'Order.Item.0.Product.sizes.2.Size.id' => '40', + 'Order.Item.0.Product.sizes.3.Size.qty' => '', + 'Order.Item.0.Product.sizes.3.Size.size' => '6-12mo', + 'Order.Item.0.Product.sizes.3.Size.id' => '41', + 'Order.Item.0.Product.sizes.4.Size.qty' => '', + 'Order.Item.0.Product.sizes.4.Size.size' => '12-18mo', + 'Order.Item.0.Product.sizes.4.Size.id' => '42', + 'Order.Item.0.Art.imprint_locations.0.id' => 2, + 'Order.Item.0.Art.imprint_locations.0.name' => 'Left Chest', + 'Order.Item.0.Art.imprint_locations.0.imprint_type.id' => 7, + 'Order.Item.0.Art.imprint_locations.0.imprint_type.type' => 'Embroidery', + 'Order.Item.0.Art.imprint_locations.0.art' => '', + 'Order.Item.0.Art.imprint_locations.0.num_colors' => 3, + 'Order.Item.0.Art.imprint_locations.0.description' => 'Wooo! This is Embroidery!!', + 'Order.Item.0.Art.imprint_locations.0.lines.0' => 'Platen', + 'Order.Item.0.Art.imprint_locations.0.lines.1' => 'Logo', + 'Order.Item.0.Art.imprint_locations.0.height' => 4, + 'Order.Item.0.Art.imprint_locations.0.width' => 5, + 'Order.Item.0.Art.imprint_locations.0.stitch_density' => 'Light', + 'Order.Item.0.Art.imprint_locations.0.metallic_thread' => true, + 'Order.Item.0.Art.imprint_locations.1.id' => 4, + 'Order.Item.0.Art.imprint_locations.1.name' => 'Full Back', + 'Order.Item.0.Art.imprint_locations.1.imprint_type.id' => 6, + 'Order.Item.0.Art.imprint_locations.1.imprint_type.type' => 'Screenprinting', + 'Order.Item.0.Art.imprint_locations.1.art' => '', + 'Order.Item.0.Art.imprint_locations.1.num_colors' => 3, + 'Order.Item.0.Art.imprint_locations.1.description' => 'Wooo! This is Screenprinting!!', + 'Order.Item.0.Art.imprint_locations.1.lines.0' => 'Platen', + 'Order.Item.0.Art.imprint_locations.1.lines.1' => 'Logo', + 'Order.Item.0.Art.imprint_locations.2.id' => 26, + 'Order.Item.0.Art.imprint_locations.2.name' => 'HS - JSY Name Below', + 'Order.Item.0.Art.imprint_locations.2.imprint_type.id' => 9, + 'Order.Item.0.Art.imprint_locations.2.imprint_type.type' => 'Names', + 'Order.Item.0.Art.imprint_locations.2.description' => 'Wooo! This is Names!!', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.0.active' => 1, + 'Order.Item.0.Art.imprint_locations.2.sizes.S.0.name' => 'Benjamin Talavera', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.0.color' => 'Red', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.0.height' => '3', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.0.layout' => 'Arched', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.0.style' => 'Classic', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.1.active' => 0, + 'Order.Item.0.Art.imprint_locations.2.sizes.S.1.name' => 'Rishi Narayan', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.1.color' => 'Cardinal', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.1.height' => '4', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.1.layout' => 'Straight', + 'Order.Item.0.Art.imprint_locations.2.sizes.S.1.style' => 'Team US', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.0.active' => 1, + 'Order.Item.0.Art.imprint_locations.2.sizes.M.0.name' => 'Brandon Plasters', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.0.color' => 'Red', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.0.height' => '3', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.0.layout' => 'Arched', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.0.style' => 'Classic', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.1.active' => 0, + 'Order.Item.0.Art.imprint_locations.2.sizes.M.1.name' => 'Andrew Reed', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.1.color' => 'Cardinal', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.1.height' => '4', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.1.layout' => 'Straight', + 'Order.Item.0.Art.imprint_locations.2.sizes.M.1.style' => 'Team US', + 'Order.Job.0._id' => 'job-1', + 'Order.Job.0.type' => 'screenprinting', + 'Order.Job.0.postPress' => 'job-2', + 'Order.Job.1._id' => 'job-2', + 'Order.Job.1.type' => 'embroidery', + 'Order.Postpress' => '0', + 'Order.PriceAdjustment.0._id' => 'price-adjustment-1', + 'Order.PriceAdjustment.0.adjustment' => '-20', + 'Order.PriceAdjustment.0.adjustment_type' => 'percent', + 'Order.PriceAdjustment.0.type' => 'grand_total', + 'Order.PriceAdjustment.1.adjustment' => '20', + 'Order.PriceAdjustment.1.adjustment_type' => 'flat', + 'Order.PriceAdjustment.1.min-items' => '10', + 'Order.PriceAdjustment.1.type' => 'min-items', + 'Order.PriceAdjustment.1._id' => 'another-test-adjustment', + 'Order.Purchasing' => '0', + 'Order.QualityControl' => '0', + 'Order.Receiving' => '0', + 'Order.ScreenPrinting' => '0', + 'Order.Stage.art_approval' => 0, + 'Order.Stage.draft' => 1, + 'Order.Stage.quote' => 1, + 'Order.Stage.order' => 1, + 'Order.StoreLiason' => '0', + 'Order.Tag_UI_Email' => '', + 'Order.Tags' => '', + 'Order._id' => 'test-2', + 'Order.add_print_location' => '', + 'Order.created' => '2011-Dec-29 05:40:18', + 'Order.force_admin' => '0', + 'Order.modified' => '2012-Jul-25 01:24:49', + 'Order.name' => 'towering power', + 'Order.order_id' => '135961', + 'Order.slug' => 'test-2', + 'Order.title' => 'test job 2', + 'Order.type' => 'ttt', + ]; + $expanded = Hash::expand($data); + $flattened = Hash::flatten($expanded); + $this->assertSame($data, $flattened); + } +} diff --git a/tests/TestCase/Utility/InflectorTest.php b/tests/TestCase/Utility/InflectorTest.php new file mode 100644 index 00000000000..e75bc4670aa --- /dev/null +++ b/tests/TestCase/Utility/InflectorTest.php @@ -0,0 +1,555 @@ + + */ + public static $maps = [ + 'de' => [ /* German */ + 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', + 'ẞ' => 'SS', + ], + 'latin' => [ + 'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Å' => 'A', 'Ă' => 'A', 'Æ' => 'AE', 'Ç' => + 'C', 'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', + 'Ï' => 'I', 'Ð' => 'D', 'Ñ' => 'N', 'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ő' => 'O', 'Ø' => 'O', + 'Ș' => 'S', 'Ț' => 'T', 'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ű' => 'U', + 'Ý' => 'Y', 'Þ' => 'TH', 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', + 'å' => 'a', 'ă' => 'a', 'æ' => 'ae', 'ç' => 'c', 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', + 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ð' => 'd', 'ñ' => 'n', 'ò' => 'o', 'ó' => + 'o', 'ô' => 'o', 'õ' => 'o', 'ő' => 'o', 'ø' => 'o', 'ș' => 's', 'ț' => 't', 'ù' => 'u', 'ú' => 'u', + 'û' => 'u', 'ű' => 'u', 'ý' => 'y', 'þ' => 'th', 'ÿ' => 'y', + ], + 'tr' => [ /* Turkish */ + 'ş' => 's', 'Ş' => 'S', 'ı' => 'i', 'İ' => 'I', 'ç' => 'c', 'Ç' => 'C', 'ğ' => 'g', 'Ğ' => 'G', + ], + 'uk' => [ /* Ukrainian */ + 'Є' => 'Ye', 'І' => 'I', 'Ї' => 'Yi', 'Ґ' => 'G', 'є' => 'ye', 'і' => 'i', 'ї' => 'yi', 'ґ' => 'g', + ], + 'cs' => [ /* Czech */ + 'č' => 'c', 'ď' => 'd', 'ě' => 'e', 'ň' => 'n', 'ř' => 'r', 'š' => 's', 'ť' => 't', 'ů' => 'u', + 'ž' => 'z', 'Č' => 'C', 'Ď' => 'D', 'Ě' => 'E', 'Ň' => 'N', 'Ř' => 'R', 'Š' => 'S', 'Ť' => 'T', + 'Ů' => 'U', 'Ž' => 'Z', + ], + 'pl' => [ /* Polish */ + 'ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n', 'ó' => 'o', 'ś' => 's', 'ź' => 'z', + 'ż' => 'z', 'Ą' => 'A', 'Ć' => 'C', 'Ł' => 'L', 'Ń' => 'N', 'Ó' => 'O', 'Ś' => 'S', + 'Ź' => 'Z', 'Ż' => 'Z', + ], + 'ro' => [ /* Romanian */ + 'ă' => 'a', 'â' => 'a', 'î' => 'i', 'ș' => 's', 'ț' => 't', 'Ţ' => 'T', 'ţ' => 't', + ], + 'lv' => [ /* Latvian */ + 'ā' => 'a', 'č' => 'c', 'ē' => 'e', 'ģ' => 'g', 'ī' => 'i', 'ķ' => 'k', 'ļ' => 'l', 'ņ' => 'n', + 'š' => 's', 'ū' => 'u', 'ž' => 'z', 'Ā' => 'A', 'Č' => 'C', 'Ē' => 'E', 'Ģ' => 'G', 'Ī' => 'I', + 'Ķ' => 'K', 'Ļ' => 'L', 'Ņ' => 'N', 'Š' => 'S', 'Ū' => 'U', 'Ž' => 'Z', + ], + 'lt' => [ /* Lithuanian */ + 'ą' => 'a', 'č' => 'c', 'ę' => 'e', 'ė' => 'e', 'į' => 'i', 'š' => 's', 'ų' => 'u', 'ū' => 'u', 'ž' => 'z', + 'Ą' => 'A', 'Č' => 'C', 'Ę' => 'E', 'Ė' => 'E', 'Į' => 'I', 'Š' => 'S', 'Ų' => 'U', 'Ū' => 'U', 'Ž' => 'Z', + ], + ]; + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + Inflector::reset(); + } + + /** + * testInflectingSingulars method + */ + #[DataProvider('singularizeProvider')] + public function testInflectingSingulars(string $singular, string $plural): void + { + $this->assertSame($singular, Inflector::singularize($plural)); + + $singular = Inflector::camelize($singular); + $plural = Inflector::camelize($plural); + $this->assertSame($singular, Inflector::singularize($plural)); + } + + /** + * Data provider for testing singularize() + * + * @return array + */ + public static function singularizeProvider(): array + { + return [ + ['categoria', 'categorias'], + ['menu', 'menus'], + ['news', 'news'], + ['food_menu', 'food_menus'], + ['Menu', 'Menus'], + ['FoodMenu', 'FoodMenus'], + ['house', 'houses'], + ['powerhouse', 'powerhouses'], + ['quiz', 'quizzes'], + ['Bus', 'Buses'], + ['bus', 'buses'], + ['matrix_row', 'matrix_rows'], + ['matrix', 'matrices'], + ['vertex', 'vertices'], + ['index', 'indices'], + ['index', 'indexes'], + ['Alias', 'Aliases'], + ['Alias', 'Alias'], + ['Media', 'Media'], + ['NodeMedia', 'NodeMedia'], + ['alumnus', 'alumni'], + ['bacillus', 'bacilli'], + ['cactus', 'cacti'], + ['focus', 'foci'], + ['fungus', 'fungi'], + ['nucleus', 'nuclei'], + ['octopus', 'octopuses'], + ['radius', 'radii'], + ['stimulus', 'stimuli'], + ['syllabus', 'syllabi'], + ['terminus', 'termini'], + ['virus', 'viruses'], + ['person', 'people'], + ['glove', 'gloves'], + ['dove', 'doves'], + ['life', 'lives'], + ['knife', 'knives'], + ['wolf', 'wolves'], + ['slave', 'slaves'], + ['shelf', 'shelves'], + ['taxi', 'taxis'], + ['tax', 'taxes'], + ['Tax', 'Taxes'], + ['AwesomeTax', 'AwesomeTaxes'], + ['fax', 'faxes'], + ['wax', 'waxes'], + ['niche', 'niches'], + ['cave', 'caves'], + ['grave', 'graves'], + ['wave', 'waves'], + ['bureau', 'bureaus'], + ['genetic_analysis', 'genetic_analyses'], + ['doctor_diagnosis', 'doctor_diagnoses'], + ['paranthesis', 'parantheses'], + ['Cause', 'Causes'], + ['colossus', 'colossuses'], + ['diagnosis', 'diagnoses'], + ['basis', 'bases'], + ['analysis', 'analyses'], + ['curve', 'curves'], + ['cafe', 'cafes'], + ['roof', 'roofs'], + ['foe', 'foes'], + ['database', 'databases'], + ['cookie', 'cookies'], + ['thief', 'thieves'], + ['potato', 'potatoes'], + ['hero', 'heroes'], + ['buffalo', 'buffaloes'], + ['baby', 'babies'], + ['tooth', 'teeth'], + ['goose', 'geese'], + ['foot', 'feet'], + ['objective', 'objectives'], + ['archive', 'archives'], + ['brief', 'briefs'], + ['quota', 'quotas'], + ['curve', 'curves'], + ['body_curve', 'body_curves'], + ['metadata', 'metadata'], + ['files_metadata', 'files_metadata'], + ['address', 'addresses'], + ['sieve', 'sieves'], + ['blue_octopus', 'blue_octopuses'], + ['chef', 'chefs'], + ['', ''], + ['cache', 'caches'], + ['lens', 'lenses'], + ['species', 'species'], + ['animal_species', 'animal_species'], + ]; + } + + /** + * Test that overlapping irregulars don't collide. + */ + public function testSingularizeMultiWordIrregular(): void + { + Inflector::rules('irregular', [ + 'pregunta_frecuente' => 'preguntas_frecuentes', + 'categoria_pregunta_frecuente' => 'categorias_preguntas_frecuentes', + ]); + $this->assertSame('pregunta_frecuente', Inflector::singularize('preguntas_frecuentes')); + $this->assertSame( + 'categoria_pregunta_frecuente', + Inflector::singularize('categorias_preguntas_frecuentes'), + ); + $this->assertSame( + 'faq_categoria_pregunta_frecuente', + Inflector::singularize('faq_categorias_preguntas_frecuentes'), + ); + } + + /** + * testInflectingPlurals method + */ + #[DataProvider('pluralizeProvider')] + public function testInflectingPlurals(string $plural, string $singular): void + { + $this->assertSame($plural, Inflector::pluralize($singular)); + + $plural = Inflector::camelize($plural); + $singular = Inflector::camelize($singular); + $this->assertSame($plural, Inflector::pluralize($singular)); + } + + /** + * Data provider for testing pluralize() + * + * @return array + */ + public static function pluralizeProvider(): array + { + return [ + ['axmen', 'axman'], + ['men', 'man'], + ['women', 'woman'], + ['humans', 'human'], + ['axmen', 'axman'], + ['men', 'man'], + ['women', 'woman'], + ['humans', 'human'], + ['categorias', 'categoria'], + ['houses', 'house'], + ['powerhouses', 'powerhouse'], + ['Buses', 'Bus'], + ['buses', 'bus'], + ['menus', 'menu'], + ['news', 'news'], + ['food_menus', 'food_menu'], + ['Menus', 'Menu'], + ['FoodMenus', 'FoodMenu'], + ['quizzes', 'quiz'], + ['matrix_rows', 'matrix_row'], + ['matrices', 'matrix'], + ['vertices', 'vertex'], + ['indexes', 'index'], + ['Aliases', 'Alias'], + ['Aliases', 'Aliases'], + ['Media', 'Media'], + ['NodeMedia', 'NodeMedia'], + ['alumni', 'alumnus'], + ['bacilli', 'bacillus'], + ['cacti', 'cactus'], + ['foci', 'focus'], + ['fungi', 'fungus'], + ['nuclei', 'nucleus'], + ['octopuses', 'octopus'], + ['radii', 'radius'], + ['stimuli', 'stimulus'], + ['syllabi', 'syllabus'], + ['termini', 'terminus'], + ['viruses', 'virus'], + ['people', 'person'], + ['people', 'people'], + ['gloves', 'glove'], + ['crises', 'crisis'], + ['taxes', 'tax'], + ['waves', 'wave'], + ['bureaus', 'bureau'], + ['cafes', 'cafe'], + ['roofs', 'roof'], + ['foes', 'foe'], + ['cookies', 'cookie'], + ['wolves', 'wolf'], + ['thieves', 'thief'], + ['potatoes', 'potato'], + ['heroes', 'hero'], + ['buffaloes', 'buffalo'], + ['teeth', 'tooth'], + ['geese', 'goose'], + ['feet', 'foot'], + ['objectives', 'objective'], + ['briefs', 'brief'], + ['quotas', 'quota'], + ['curves', 'curve'], + ['body_curves', 'body_curve'], + ['metadata', 'metadata'], + ['files_metadata', 'files_metadata'], + ['stadia', 'stadia'], + ['Addresses', 'Address'], + ['sieves', 'sieve'], + ['blue_octopuses', 'blue_octopus'], + ['chefs', 'chef'], + ['', ''], + ['pokemon', 'pokemon'], + ]; + } + + /** + * Test that overlapping irregulars don't collide. + */ + public function testPluralizeMultiWordIrregular(): void + { + Inflector::rules('irregular', [ + 'pregunta_frecuente' => 'preguntas_frecuentes', + 'categoria_pregunta_frecuente' => 'categorias_preguntas_frecuentes', + ]); + $this->assertSame('preguntas_frecuentes', Inflector::pluralize('pregunta_frecuente')); + $this->assertSame( + 'categorias_preguntas_frecuentes', + Inflector::pluralize('categoria_pregunta_frecuente'), + ); + $this->assertSame( + 'faq_categorias_preguntas_frecuentes', + Inflector::pluralize('faq_categoria_pregunta_frecuente'), + ); + } + + /** + * testInflectingMultiWordIrregulars + */ + public function testInflectingMultiWordIrregulars(): void + { + // unset the default rules in order to avoid them possibly matching + // the words in case the irregular regex won't match, the tests + // should fail in that case + Inflector::rules('plural', [ + 'rules' => [], + ]); + Inflector::rules('singular', [ + 'rules' => [], + ]); + + $this->assertSame('wisdom tooth', Inflector::singularize('wisdom teeth')); + $this->assertSame('wisdom-tooth', Inflector::singularize('wisdom-teeth')); + $this->assertSame('wisdom_tooth', Inflector::singularize('wisdom_teeth')); + + $this->assertSame('sweet potatoes', Inflector::pluralize('sweet potato')); + $this->assertSame('sweet-potatoes', Inflector::pluralize('sweet-potato')); + $this->assertSame('sweet_potatoes', Inflector::pluralize('sweet_potato')); + } + + /** + * testUnderscore method + */ + public function testUnderscore(): void + { + $this->assertSame('test_thing', Inflector::underscore('TestThing')); + $this->assertSame('test_thing', Inflector::underscore('testThing')); + $this->assertSame('test_thing_extra', Inflector::underscore('TestThingExtra')); + $this->assertSame('test_thing_extra', Inflector::underscore('testThingExtra')); + $this->assertSame('test_this_thing', Inflector::underscore('test-this-thing')); + $this->assertSame('test_thing_extrå', Inflector::underscore('testThingExtrå')); + + // Identical checks test the cache code path. + $this->assertSame('test_thing', Inflector::underscore('TestThing')); + $this->assertSame('test_thing', Inflector::underscore('testThing')); + $this->assertSame('test_thing_extra', Inflector::underscore('TestThingExtra')); + $this->assertSame('test_thing_extra', Inflector::underscore('testThingExtra')); + $this->assertSame('test_thing_extrå', Inflector::underscore('testThingExtrå')); + + // Test other values + $this->assertSame('0', Inflector::underscore('0')); + } + + /** + * testDasherized method + */ + public function testDasherized(): void + { + $this->assertSame('test-thing', Inflector::dasherize('TestThing')); + $this->assertSame('test-thing', Inflector::dasherize('testThing')); + $this->assertSame('test-thing-extra', Inflector::dasherize('TestThingExtra')); + $this->assertSame('test-thing-extra', Inflector::dasherize('testThingExtra')); + $this->assertSame('test-this-thing', Inflector::dasherize('test_this_thing')); + + // Test stupid values + $this->assertSame('', Inflector::dasherize('')); + $this->assertSame('0', Inflector::dasherize('0')); + } + + /** + * Demonstrate the expected output for bad inputs + */ + public function testCamelize(): void + { + $this->assertSame('TestThing', Inflector::camelize('test_thing')); + $this->assertSame('Test-thing', Inflector::camelize('test-thing')); + $this->assertSame('TestThing', Inflector::camelize('test thing')); + + $this->assertSame('Test_thing', Inflector::camelize('test_thing', '-')); + $this->assertSame('TestThing', Inflector::camelize('test-thing', '-')); + $this->assertSame('TestThing', Inflector::camelize('test thing', '-')); + + $this->assertSame('Test_thing', Inflector::camelize('test_thing', ' ')); + $this->assertSame('Test-thing', Inflector::camelize('test-thing', ' ')); + $this->assertSame('TestThing', Inflector::camelize('test thing', ' ')); + + $this->assertSame('TestPlugin.TestPluginComments', Inflector::camelize('TestPlugin.TestPluginComments')); + } + + /** + * testVariableNaming method + */ + public function testVariableNaming(): void + { + $this->assertSame('testField', Inflector::variable('test_field')); + $this->assertSame('testFieLd', Inflector::variable('test_fieLd')); + $this->assertSame('testField', Inflector::variable('test field')); + $this->assertSame('testField', Inflector::variable('Test_field')); + } + + /** + * testClassNaming method + */ + public function testClassNaming(): void + { + $this->assertSame('ArtistsGenre', Inflector::classify('artists_genres')); + $this->assertSame('FileSystem', Inflector::classify('file_systems')); + $this->assertSame('News', Inflector::classify('news')); + $this->assertSame('Bureau', Inflector::classify('bureaus')); + } + + /** + * testTableNaming method + */ + public function testTableNaming(): void + { + $this->assertSame('artists_genres', Inflector::tableize('ArtistsGenre')); + $this->assertSame('file_systems', Inflector::tableize('FileSystem')); + $this->assertSame('news', Inflector::tableize('News')); + $this->assertSame('bureaus', Inflector::tableize('Bureau')); + } + + /** + * testHumanization method + */ + public function testHumanization(): void + { + $this->assertSame('Posts', Inflector::humanize('posts')); + $this->assertSame('Posts Tags', Inflector::humanize('posts_tags')); + $this->assertSame('File Systems', Inflector::humanize('file_systems')); + $this->assertSame('Hello Wörld', Inflector::humanize('hello_wörld')); + $this->assertSame('福岡 City', Inflector::humanize('福岡_city')); + } + + /** + * testCustomPluralRule method + */ + public function testCustomPluralRule(): void + { + Inflector::rules('plural', ['/^(custom)$/i' => '\1izables']); + Inflector::rules('uninflected', ['uninflectable']); + + $this->assertSame('customizables', Inflector::pluralize('custom')); + $this->assertSame('uninflectable', Inflector::pluralize('uninflectable')); + + Inflector::rules('plural', ['/^(alert)$/i' => '\1ables']); + Inflector::rules('irregular', ['amaze' => 'amazable', 'phone' => 'phonezes']); + Inflector::rules('uninflected', ['noflect', 'abtuse']); + $this->assertSame('noflect', Inflector::pluralize('noflect')); + $this->assertSame('abtuse', Inflector::pluralize('abtuse')); + $this->assertSame('alertables', Inflector::pluralize('alert')); + $this->assertSame('amazable', Inflector::pluralize('amaze')); + $this->assertSame('phonezes', Inflector::pluralize('phone')); + + $this->assertSame('criteria', Inflector::pluralize('criterion')); + $this->assertSame('test_criteria', Inflector::pluralize('test_criterion')); + $this->assertSame('Criteria', Inflector::pluralize('Criterion')); + $this->assertSame('TestCriteria', Inflector::pluralize('TestCriterion')); + $this->assertSame('Test Criteria', Inflector::pluralize('Test Criterion')); + } + + /** + * testCustomSingularRule method + */ + public function testCustomSingularRule(): void + { + Inflector::rules('uninflected', ['singulars']); + Inflector::rules('singular', ['/(eple)r$/i' => '\1', '/(jente)r$/i' => '\1']); + + $this->assertSame('eple', Inflector::singularize('epler')); + $this->assertSame('jente', Inflector::singularize('jenter')); + + Inflector::rules('singular', ['/^(bil)er$/i' => '\1', '/^(inflec|contribu)tors$/i' => '\1ta']); + Inflector::rules('irregular', ['spinor' => 'spins']); + + $this->assertSame('spinor', Inflector::singularize('spins')); + $this->assertSame('inflecta', Inflector::singularize('inflectors')); + $this->assertSame('contributa', Inflector::singularize('contributors')); + $this->assertSame('singulars', Inflector::singularize('singulars')); + + $this->assertSame('criterion', Inflector::singularize('criteria')); + $this->assertSame('test_criterion', Inflector::singularize('test_criteria')); + $this->assertSame('Criterion', Inflector::singularize('Criteria')); + $this->assertSame('TestCriterion', Inflector::singularize('TestCriteria')); + $this->assertSame('Test Criterion', Inflector::singularize('Test Criteria')); + } + + /** + * test that setting new rules clears the inflector caches. + */ + public function testRulesClearsCaches(): void + { + $this->assertSame('Banana', Inflector::singularize('Bananas')); + $this->assertSame('bananas', Inflector::tableize('Banana')); + $this->assertSame('Bananas', Inflector::pluralize('Banana')); + + Inflector::rules('singular', ['/(.*)nas$/i' => '\1zzz']); + $this->assertSame('Banazzz', Inflector::singularize('Bananas'), 'Was inflected with old rules.'); + + Inflector::rules('plural', ['/(.*)na$/i' => '\1zzz']); + Inflector::rules('irregular', ['corpus' => 'corpora']); + $this->assertSame('Banazzz', Inflector::pluralize('Banana'), 'Was inflected with old rules.'); + $this->assertSame('corpora', Inflector::pluralize('corpus'), 'Was inflected with old irregular form.'); + } + + /** + * Test resetting inflection rules. + */ + public function testCustomRuleWithReset(): void + { + $uninflected = ['atlas', 'lapis', 'onibus', 'pires', 'virus', '.*x']; + $pluralIrregular = ['as' => 'ases']; + + Inflector::rules('singular', ['/^(.*)(a|e|o|u)is$/i' => '\1\2l'], true); + Inflector::rules('plural', ['/^(.*)(a|e|o|u)l$/i' => '\1\2is'], true); + Inflector::rules('uninflected', $uninflected, true); + Inflector::rules('irregular', $pluralIrregular, true); + + $this->assertSame('Alcoois', Inflector::pluralize('Alcool')); + $this->assertSame('Atlas', Inflector::pluralize('Atlas')); + $this->assertSame('Alcool', Inflector::singularize('Alcoois')); + $this->assertSame('Atlas', Inflector::singularize('Atlas')); + } +} diff --git a/tests/TestCase/Utility/MergeVariablesTraitTest.php b/tests/TestCase/Utility/MergeVariablesTraitTest.php new file mode 100644 index 00000000000..32e8e81fdb8 --- /dev/null +++ b/tests/TestCase/Utility/MergeVariablesTraitTest.php @@ -0,0 +1,105 @@ +mergeVars(['listProperty']); + + $expected = ['One', 'Two', 'Three', 'Four', 'Five']; + $this->assertSame($expected, $object->listProperty); + } + + /** + * Test merging vars as an associative list. + */ + public function testMergeVarsAsAssoc(): void + { + $object = new Grandchild(); + $object->mergeVars(['assocProperty'], ['associative' => ['assocProperty']]); + $expected = [ + 'Red' => null, + 'Orange' => null, + 'Green' => ['apple'], + 'Yellow' => ['banana'], + ]; + $this->assertEquals($expected, $object->assocProperty); + } + + /** + * Test merging variable in associated properties that contain + * additional keys. + */ + public function testMergeVarsAsAssocWithKeyValues(): void + { + $object = new Grandchild(); + $object->mergeVars(['nestedProperty'], ['associative' => ['nestedProperty']]); + + $expected = [ + 'Red' => [ + 'citrus' => 'blood orange', + ], + 'Green' => [ + 'citrus' => 'key lime', + ], + ]; + $this->assertSame($expected, $object->nestedProperty); + } + + /** + * Test merging vars with mixed modes. + */ + public function testMergeVarsMixedModes(): void + { + $object = new Grandchild(); + $object->mergeVars(['assocProperty', 'listProperty'], ['associative' => ['assocProperty']]); + $expected = [ + 'Red' => null, + 'Orange' => null, + 'Green' => ['apple'], + 'Yellow' => ['banana'], + ]; + $this->assertEquals($expected, $object->assocProperty); + + $expected = ['One', 'Two', 'Three', 'Four', 'Five']; + $this->assertEquals($expected, $object->listProperty); + } + + /** + * Test that merging variables with booleans in the class hierarchy + * doesn't cause issues. + */ + public function testMergeVarsWithBoolean(): void + { + $object = new Child(); + $object->mergeVars(['hasBoolean']); + $this->assertSame(['test'], $object->hasBoolean); + } +} diff --git a/tests/TestCase/Utility/SecurityTest.php b/tests/TestCase/Utility/SecurityTest.php new file mode 100644 index 00000000000..d48c6465d56 --- /dev/null +++ b/tests/TestCase/Utility/SecurityTest.php @@ -0,0 +1,285 @@ +assertSame($newEngine, Security::engine()); + $this->assertNotSame($restore, Security::engine()); + } + + /** + * testHash method + */ + public function testHash(): void + { + $_hashType = Security::$hashType; + + $key = 'someKey'; + $hash = 'someHash'; + + $this->assertSame(40, strlen(Security::hash($key, null, false))); + $this->assertSame(40, strlen(Security::hash($key, 'sha1', false))); + $this->assertSame(40, strlen(Security::hash($key, null, true))); + $this->assertSame(40, strlen(Security::hash($key, 'sha1', true))); + + $result = Security::hash($key, null, $hash); + $this->assertSame($result, 'e38fcb877dccb6a94729a81523851c931a46efb1'); + + $result = Security::hash($key, 'sha1', $hash); + $this->assertSame($result, 'e38fcb877dccb6a94729a81523851c931a46efb1'); + + $hashType = 'sha1'; + Security::setHash($hashType); + $this->assertSame($hashType, Security::$hashType); + $this->assertSame(40, strlen(Security::hash($key, null, true))); + $this->assertSame(40, strlen(Security::hash($key, null, false))); + + $this->assertSame(32, strlen(Security::hash($key, 'md5', false))); + $this->assertSame(32, strlen(Security::hash($key, 'md5', true))); + + $hashType = 'md5'; + Security::setHash($hashType); + $this->assertSame($hashType, Security::$hashType); + $this->assertSame(32, strlen(Security::hash($key, null, false))); + $this->assertSame(32, strlen(Security::hash($key, null, true))); + + $this->assertSame(64, strlen(Security::hash($key, 'sha256', false))); + $this->assertSame(64, strlen(Security::hash($key, 'sha256', true))); + + Security::setHash($_hashType); + } + + /** + * testInvalidHashTypeException + */ + public function testInvalidHashTypeException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/The hash type `doesnotexist` was not found. Available algorithms are: `\w+/'); + + Security::hash('test', 'doesnotexist', false); + } + + /** + * Test encrypt/decrypt. + */ + public function testEncryptDecrypt(): void + { + $txt = 'The quick brown fox'; + $key = 'This key is longer than 32 bytes long.'; + $result = Security::encrypt($txt, $key); + $this->assertNotEquals($txt, $result, 'Should be encrypted.'); + $this->assertNotEquals($result, Security::encrypt($txt, $key), 'Each result is unique.'); + $this->assertSame($txt, Security::decrypt($result, $key)); + } + + /** + * Test that changing the key causes decryption to fail. + */ + public function testDecryptKeyFailure(): void + { + $txt = 'The quick brown fox'; + $key = 'This key is longer than 32 bytes long.'; + $result = Security::encrypt($txt, $key); + + $key = 'Not the same key. This one will fail'; + $this->assertNull(Security::decrypt($result, $key), 'Modified key will fail.'); + } + + /** + * Test that decrypt fails when there is an hmac error. + */ + public function testDecryptHmacFailure(): void + { + $txt = 'The quick brown fox'; + $key = 'This key is quite long and works well.'; + $salt = 'this is a delicious salt!'; + $result = Security::encrypt($txt, $key, $salt); + + // Change one of the bytes in the hmac. + $result[10] = 'x'; + $this->assertNull(Security::decrypt($result, $key, $salt), 'Modified hmac causes failure.'); + } + + /** + * Test that changing the hmac salt will cause failures. + */ + public function testDecryptHmacSaltFailure(): void + { + $txt = 'The quick brown fox'; + $key = 'This key is quite long and works well.'; + $salt = 'this is a delicious salt!'; + $result = Security::encrypt($txt, $key, $salt); + + $salt = 'humpty dumpty had a great fall.'; + $this->assertNull(Security::decrypt($result, $key, $salt), 'Modified salt causes failure.'); + } + + /** + * Test that short keys cause errors + */ + public function testEncryptInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid key for encrypt(), key must be at least 256 bits (32 bytes) long.'); + $txt = 'The quick brown fox jumped over the lazy dog.'; + $key = 'this is too short'; + Security::encrypt($txt, $key); + } + + /** + * Test encrypting falsey data + */ + public function testEncryptDecryptFalseyData(): void + { + $key = 'This is a key that is long enough to be ok.'; + + $result = Security::encrypt('', $key); + $this->assertSame('', Security::decrypt($result, $key)); + + $result = Security::encrypt('0', $key); + $this->assertSame('0', Security::decrypt($result, $key)); + } + + /** + * Test that short keys cause errors + */ + public function testDecryptInvalidKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid key for decrypt(), key must be at least 256 bits (32 bytes) long.'); + $txt = 'The quick brown fox jumped over the lazy dog.'; + $key = 'this is too short'; + Security::decrypt($txt, $key); + } + + /** + * Test that empty data cause errors + */ + public function testDecryptInvalidData(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The data to decrypt cannot be empty.'); + $txt = ''; + $key = 'This is a key that is long enough to be ok.'; + Security::decrypt($txt, $key); + } + + /** + * Tests that the salt can be set and retrieved + */ + public function testSalt(): void + { + Security::setSalt('foobarbaz'); + $this->assertSame('foobarbaz', Security::getSalt()); + } + + /** + * Tests that the salt can be set and retrieved + */ + public function testGetSetSalt(): void + { + Security::setSalt('foobarbaz'); + $this->assertSame('foobarbaz', Security::getSalt()); + } + + /** + * Test the randomBytes method. + */ + public function testRandomBytes(): void + { + $value = Security::randomBytes(16); + $this->assertSame(16, strlen($value)); + + $value = Security::randomBytes(64); + $this->assertSame(64, strlen($value)); + + $this->assertMatchesRegularExpression('/[^0-9a-f]/', $value, 'should return a binary string'); + } + + /** + * Test the randomString method. + */ + public function testRandomString(): void + { + $value = Security::randomString(7); + $this->assertSame(7, strlen($value)); + + $value = Security::randomString(); + $this->assertSame(64, strlen($value)); + + $this->assertMatchesRegularExpression('/^[0-9a-f]+$/', $value, 'should return a ASCII string'); + } + + /** + * Test the insecureRandomBytes method + */ + public function testInsecureRandomBytes(): void + { + $value = Security::insecureRandomBytes(16); + $this->assertSame(16, strlen($value)); + + $value = Security::insecureRandomBytes(64); + $this->assertSame(64, strlen($value)); + + $this->assertMatchesRegularExpression('/[^0-9a-f]/', $value, 'should return a binary string'); + } + + /** + * test constantEquals + */ + public function testConstantEquals(): void + { + $this->assertFalse(Security::constantEquals('abcde', null)); + $this->assertFalse(Security::constantEquals('abcde', false)); + $this->assertFalse(Security::constantEquals('abcde', true)); + $this->assertFalse(Security::constantEquals('abcde', 1)); + + $this->assertFalse(Security::constantEquals(null, 'abcde')); + $this->assertFalse(Security::constantEquals(false, 'abcde')); + $this->assertFalse(Security::constantEquals(1, 'abcde')); + $this->assertFalse(Security::constantEquals(true, 'abcde')); + + $this->assertTrue(Security::constantEquals('abcde', 'abcde')); + $this->assertFalse(Security::constantEquals('abcdef', 'abcde')); + $this->assertFalse(Security::constantEquals('abcde', 'abcdef')); + $this->assertFalse(Security::constantEquals('a', 'abcdef')); + + $snowman = "\xe2\x98\x83"; + $this->assertTrue(Security::constantEquals($snowman, $snowman)); + $this->assertFalse(Security::constantEquals(str_repeat($snowman, 3), $snowman)); + } +} diff --git a/tests/TestCase/Utility/TextTest.php b/tests/TestCase/Utility/TextTest.php new file mode 100644 index 00000000000..759dfa83949 --- /dev/null +++ b/tests/TestCase/Utility/TextTest.php @@ -0,0 +1,1926 @@ +encoding = mb_internal_encoding(); + $this->Text = new Text(); + } + + protected function tearDown(): void + { + parent::tearDown(); + mb_internal_encoding($this->encoding); + unset($this->Text); + } + + /** + * testUuidGeneration method + */ + public function testUuidGeneration(): void + { + $result = Text::uuid(); + $pattern = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/'; + $match = (bool)preg_match($pattern, $result); + $this->assertTrue($match); + } + + /** + * testMultipleUuidGeneration method + */ + public function testMultipleUuidGeneration(): void + { + $check = []; + $count = mt_rand(10, 1000); + $pattern = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/'; + + for ($i = 0; $i < $count; $i++) { + $result = Text::uuid(); + $match = (bool)preg_match($pattern, $result); + $this->assertTrue($match); + $this->assertNotContains($result, $check); + $check[] = $result; + } + } + + /** + * testUuidGenerationWithClosure method + */ + public function testUuidGenerationWithClosure(): void + { + $customUuid = 'custom-uuid-12345678-1234-1234-1234-123456789012'; + Configure::write('Text.uuidGenerator', function () use ($customUuid) { + return $customUuid; + }); + + $result = Text::uuid(); + $this->assertSame($customUuid, $result); + + // Clean up + Configure::delete('Text.uuidGenerator'); + + // Test that it falls back to default implementation + $result = Text::uuid(); + $pattern = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/'; + $match = (bool)preg_match($pattern, $result); + $this->assertTrue($match); + } + + /** + * testInsert method + */ + public function testInsert(): void + { + $string = 'some string'; + $expected = 'some string'; + $result = Text::insert($string, []); + $this->assertSame($expected, $result); + + $string = '2 + 2 = :sum. Cake is :adjective.'; + $expected = '2 + 2 = 4. Cake is yummy.'; + $result = Text::insert($string, ['sum' => '4', 'adjective' => 'yummy']); + $this->assertSame($expected, $result); + + $string = '2 + 2 = %sum. Cake is %adjective.'; + $result = Text::insert($string, ['sum' => '4', 'adjective' => 'yummy'], ['before' => '%']); + $this->assertSame($expected, $result); + + $string = '2 + 2 = 2sum2. Cake is 9adjective9.'; + $result = Text::insert($string, ['sum' => '4', 'adjective' => 'yummy'], ['format' => '/([\d])%s\\1/']); + $this->assertSame($expected, $result); + + $string = '2 + 2 = 12sum21. Cake is 23adjective45.'; + $expected = '2 + 2 = 4. Cake is 23adjective45.'; + $result = Text::insert($string, ['sum' => '4', 'adjective' => 'yummy'], ['format' => '/([\d])([\d])%s\\2\\1/']); + $this->assertSame($expected, $result); + + $string = ':web :web_site'; + $expected = 'www http'; + $result = Text::insert($string, ['web' => 'www', 'web_site' => 'http']); + $this->assertSame($expected, $result); + + $string = '2 + 2 = .'; + $expected = '2 + 2 = '4', 'adjective' => 'yummy'], ['before' => '<', 'after' => '>']); + $this->assertSame($expected, $result); + + $string = '2 + 2 = \:sum. Cake is :adjective.'; + $expected = '2 + 2 = :sum. Cake is yummy.'; + $result = Text::insert($string, ['sum' => '4', 'adjective' => 'yummy']); + $this->assertSame($expected, $result); + + $string = '2 + 2 = !:sum. Cake is :adjective.'; + $result = Text::insert($string, ['sum' => '4', 'adjective' => 'yummy'], ['escape' => '!']); + $this->assertSame($expected, $result); + + $string = '2 + 2 = \%sum. Cake is %adjective.'; + $expected = '2 + 2 = %sum. Cake is yummy.'; + $result = Text::insert($string, ['sum' => '4', 'adjective' => 'yummy'], ['before' => '%']); + $this->assertSame($expected, $result); + + $string = ':a :b \:a :a'; + $expected = '1 2 :a 1'; + $result = Text::insert($string, ['a' => 1, 'b' => 2]); + $this->assertSame($expected, $result); + + $string = ':a :b :c'; + $expected = '2 3'; + $result = Text::insert($string, ['b' => 2, 'c' => 3], ['clean' => true]); + $this->assertSame($expected, $result); + + $string = ':a :b :c'; + $expected = '1 3'; + $result = Text::insert($string, ['a' => 1, 'c' => 3], ['clean' => true]); + $this->assertSame($expected, $result); + + $string = ':a :b :c'; + $expected = '2 3'; + $result = Text::insert($string, ['b' => 2, 'c' => 3], ['clean' => true]); + $this->assertSame($expected, $result); + + $string = ':a, :b and :c'; + $expected = '2 and 3'; + $result = Text::insert($string, ['b' => 2, 'c' => 3], ['clean' => true]); + $this->assertSame($expected, $result); + + $string = '":a, :b and :c"'; + $expected = '"1, 2"'; + $result = Text::insert($string, ['a' => 1, 'b' => 2], ['clean' => true]); + $this->assertSame($expected, $result); + + $string = '"${a}, ${b} and ${c}"'; + $expected = '"1, 2"'; + $result = Text::insert($string, ['a' => 1, 'b' => 2], ['before' => '${', 'after' => '}', 'clean' => true]); + $this->assertSame($expected, $result); + + $string = ':alt'; + $expected = ''; + $result = Text::insert($string, ['src' => 'foo'], ['clean' => 'html']); + + $this->assertSame($expected, $result); + + $string = ''; + $expected = ''; + $result = Text::insert($string, ['src' => 'foo'], ['clean' => 'html']); + $this->assertSame($expected, $result); + + $string = ''; + $expected = ''; + $result = Text::insert($string, ['src' => 'foo', 'extra' => 'bar'], ['clean' => 'html']); + $this->assertSame($expected, $result); + + $result = Text::insert('update saved_urls set url = :url where id = :id', ['url' => 'http://www.testurl.com/param1:url/param2:id', 'id' => 1]); + $expected = 'update saved_urls set url = http://www.testurl.com/param1:url/param2:id where id = 1'; + $this->assertSame($expected, $result); + + $result = Text::insert('update saved_urls set url = :url where id = :id', ['id' => 1, 'url' => 'http://www.testurl.com/param1:url/param2:id']); + $expected = 'update saved_urls set url = http://www.testurl.com/param1:url/param2:id where id = 1'; + $this->assertSame($expected, $result); + + $result = Text::insert(':me cake. :subject :verb fantastic.', ['me' => 'I :verb', 'subject' => 'cake', 'verb' => 'is']); + $expected = 'I :verb cake. cake is fantastic.'; + $this->assertSame($expected, $result); + + $result = Text::insert(':I.am: :not.yet: passing.', ['I.am' => 'We are'], ['before' => ':', 'after' => ':', 'clean' => ['replacement' => ' of course', 'method' => 'text']]); + $expected = 'We are of course passing.'; + $this->assertSame($expected, $result); + + $result = Text::insert( + ':I.am: :not.yet: passing.', + ['I.am' => 'We are'], + ['before' => ':', 'after' => ':', 'clean' => true], + ); + $expected = 'We are passing.'; + $this->assertSame($expected, $result); + + $string = 'switching :timeout / :timeout_count'; + $expected = 'switching 5 / 10'; + $result = Text::insert($string, ['timeout' => 5, 'timeout_count' => 10]); + $this->assertSame($expected, $result); + + $string = 'switching :timeout / :timeout_count'; + $expected = 'switching 5 / 10'; + $result = Text::insert($string, ['timeout_count' => 10, 'timeout' => 5]); + $this->assertSame($expected, $result); + + $string = 'switching :timeout_count by :timeout'; + $expected = 'switching 10 by 5'; + $result = Text::insert($string, ['timeout' => 5, 'timeout_count' => 10]); + $this->assertSame($expected, $result); + + $string = 'switching :timeout_count by :timeout'; + $expected = 'switching 10 by 5'; + $result = Text::insert($string, ['timeout_count' => 10, 'timeout' => 5]); + $this->assertSame($expected, $result); + $string = 'inserting a :user.email'; + $expected = 'inserting a security@example.com'; + $result = Text::insert($string, [ + 'user.email' => 'security@example.com', + 'user.id' => 2, + 'user.created' => DateTime::parse('2016-01-01'), + ]); + $this->assertSame($expected, $result); + + $string = 'Sum is :sum (:ob).'; + $expected = 'Sum is 26083 (foo).'; + $result = Text::insert($string, ['sum' => 26083, 'ob' => 'foo']); // crc32('ob') = 26083 + $this->assertSame($expected, $result); + } + + /** + * test Clean Insert + */ + public function testCleanInsert(): void + { + $result = Text::cleanInsert(':incomplete', [ + 'clean' => true, 'before' => ':', 'after' => '', + ]); + $this->assertSame('', $result); + + $result = Text::cleanInsert( + ':incomplete', + [ + 'clean' => ['method' => 'text', 'replacement' => 'complete'], + 'before' => ':', 'after' => ''], + ); + $this->assertSame('complete', $result); + + $result = Text::cleanInsert(':in.complete', [ + 'clean' => true, 'before' => ':', 'after' => '', + ]); + $this->assertSame('', $result); + + $result = Text::cleanInsert( + ':in.complete and', + [ + 'clean' => true, 'before' => ':', 'after' => ''], + ); + $this->assertSame('', $result); + + $result = Text::cleanInsert(':in.complete or stuff', [ + 'clean' => true, 'before' => ':', 'after' => '', + ]); + $this->assertSame('stuff', $result); + + $result = Text::cleanInsert( + '

    Text here

    ', + ['clean' => 'html', 'before' => ':', 'after' => ''], + ); + $this->assertSame('

    Text here

    ', $result); + } + + /** + * Tests that non-insertable variables (i.e. arrays) are skipped when used as values in + * Text::insert(). + */ + public function testAutoIgnoreBadInsertData(): void + { + $data = ['foo' => 'alpha', 'bar' => 'beta', 'fale' => []]; + $result = Text::insert('(:foo > :bar || :fale!)', $data, ['clean' => 'text']); + $this->assertSame('(alpha > beta || !)', $result); + } + + /** + * testTokenize method + */ + public function testTokenize(): void + { + $result = Text::tokenize('A,(short,boring test)'); + $expected = ['A', '(short,boring test)']; + $this->assertSame($expected, $result); + + $result = Text::tokenize('A,(short,more interesting( test)'); + $expected = ['A', '(short,more interesting( test)']; + $this->assertSame($expected, $result); + + $result = Text::tokenize('A,(short,very interesting( test))'); + $expected = ['A', '(short,very interesting( test))']; + $this->assertSame($expected, $result); + + $result = Text::tokenize('"single tag"', ' ', '"', '"'); + $expected = ['"single tag"']; + $this->assertSame($expected, $result); + + $result = Text::tokenize('tagA "single tag" tagB', ' ', '"', '"'); + $expected = ['tagA', '"single tag"', 'tagB']; + $this->assertSame($expected, $result); + + $result = Text::tokenize('tagA "first tag" tagB "second tag" tagC', ' ', '"', '"'); + $expected = ['tagA', '"first tag"', 'tagB', '"second tag"', 'tagC']; + $this->assertSame($expected, $result); + + // Ideographic width space. + $result = Text::tokenize("tagA\xe3\x80\x80\"single\xe3\x80\x80tag\"\xe3\x80\x80tagB", "\xe3\x80\x80", '"', '"'); + $expected = ['tagA', '"single tag"', 'tagB']; + $this->assertSame($expected, $result); + } + + public function testReplaceWithQuestionMarkInString(): void + { + $string = ':a, :b and :c?'; + $expected = '2 and 3?'; + $result = Text::insert($string, ['b' => 2, 'c' => 3], ['clean' => true]); + $this->assertSame($expected, $result); + } + + /** + * test that wordWrap() works the same as built-in wordwrap function + */ + #[DataProvider('wordWrapProvider')] + public function testWordWrap(string $text, int $width, string $break = "\n", bool $cut = false): void + { + $result = Text::wordWrap($text, $width, $break, $cut); + $expected = wordwrap($text, $width, $break, $cut); + $this->assertTextEquals($expected, $result, 'Text not wrapped same as built-in function.'); + } + + /** + * data provider for testWordWrap method + * + * @return array + */ + public static function wordWrapProvider(): array + { + return [ + [ + 'The quick brown fox jumped over the lazy dog.', + 33, + ], + [ + 'A very long woooooooooooord.', + 8, + ], + [ + 'A very long woooooooooooord. Right.', + 8, + ], + ]; + } + + /** + * test that wordWrap() properly handle unicode strings. + */ + public function testWordWrapUnicodeAware(): void + { + $text = 'Но вим омниюм факёльиси элыктрам, мюнырэ лэгыры векж ыт. Выльёт квюандо нюмквуам ты кюм. Зыд эю рыбюм.'; + $result = Text::wordWrap($text, 33, "\n", true); + $expected = <<assertTextEquals($expected, $result, 'Text not wrapped.'); + + $text = 'Но вим омниюм факёльиси элыктрам, мюнырэ лэгыры векж ыт. Выльёт квюандо нюмквуам ты кюм. Зыд эю рыбюм.'; + $result = Text::wordWrap($text, 33, "\n"); + $expected = <<assertTextEquals($expected, $result, 'Text not wrapped.'); + } + + /** + * test that wordWrap() properly handle newline characters. + */ + public function testWordWrapNewlineAware(): void + { + $text = 'This is a line that is almost the 55 chars long. +This is a new sentence which is manually newlined, but is so long it needs two lines.'; + $result = Text::wordWrap($text, 55); + $expected = <<assertTextEquals($expected, $result, 'Text not wrapped.'); + } + + /** + * test wrap method. + */ + public function testWrap(): void + { + $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; + $result = Text::wrap($text, 33); + $expected = <<assertTextEquals($expected, $result, 'Text not wrapped.'); + + $result = Text::wrap($text, ['width' => 20, 'wordWrap' => false]); + $expected = 'This is the song th' . "\n" . + 'at never ends. This' . "\n" . + ' is the song that n' . "\n" . + 'ever ends. This is ' . "\n" . + 'the song that never' . "\n" . + ' ends.'; + $this->assertTextEquals($expected, $result, 'Text not wrapped.'); + } + + /** + * test wrap() indenting + */ + public function testWrapIndent(): void + { + $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; + $result = Text::wrap($text, ['width' => 33, 'indent' => "\t", 'indentAt' => 1]); + $expected = <<assertTextEquals($expected, $result); + } + + /** + * test wrapBlock() identical to wrap() + */ + public function testWrapBlockIndenticalToWrap(): void + { + $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; + $result = Text::wrapBlock($text, 33); + $expected = Text::wrap($text, 33); + $this->assertTextEquals($expected, $result); + + $result = Text::wrapBlock($text, ['width' => 33, 'indentAt' => 0]); + $expected = Text::wrap($text, ['width' => 33, 'indentAt' => 0]); + $this->assertTextEquals($expected, $result); + } + + /** + * test wrapBlock() indenting from first line + */ + public function testWrapBlockWithIndentAt0(): void + { + $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; + $result = Text::wrapBlock($text, ['width' => 33, 'indent' => "\t", 'indentAt' => 0]); + $expected = <<assertTextEquals($expected, $result); + } + + /** + * test wrapBlock() indenting from second line + */ + public function testWrapBlockWithIndentAt1(): void + { + $text = 'This is the song that never ends. This is the song that never ends. This is the song that never ends.'; + $result = Text::wrapBlock($text, ['width' => 33, 'indent' => "\t", 'indentAt' => 1]); + $expected = <<assertTextEquals($expected, $result); + } + + /** + * test wrapBlock() indenting with multibyte caracters + */ + public function testWrapBlockIndentWithMultibyte(): void + { + $text = 'This is the song that never ends. 这是永远不会结束的歌曲。 This is the song that never ends.'; + $result = Text::wrapBlock($text, ['width' => 33, 'indent' => ' → ', 'indentAt' => 1]); + $expected = <<assertTextEquals($expected, $result); + } + + /** + * test isMultibyte() checking multibyte characters + */ + public function testIsMultibyteString(): void + { + $text = 'This is a test string without multi-bytes'; + $result = Text::isMultibyte($text); + $this->assertFalse($result); + + $text = 'This is a test string with multi-bytes 这是永远不会结束的歌曲'; + $result = Text::isMultibyte($text); + $this->assertTrue($result); + } + + /** + * testTruncate method + */ + public function testTruncate(): void + { + $text1 = 'The quick brown fox jumps over the lazy dog'; + $text2 = 'Heizölrückstoßabdämpfung'; + $text3 = '© 2005-2007, Cake Software Foundation, Inc.
    written by Alexander Wegener'; + $text4 = ' This image tag is not XHTML conform!

    But the following image tag should be conform Me, myself and I
    Great, or?'; + $text5 = '01234567890'; + $text6 = "

    Extra dates have been announced for this year's tour.

    Tickets for the new shows in

    "; + $text7 = 'El moño está en el lugar correcto. Eso fue lo que dijo la niña, ¿habrá dicho la verdad?'; + $text8 = 'Vive la R' . chr(195) . chr(169) . 'publique de France'; + $text9 = 'НОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; + $text10 = 'http://example.com/something/foo:bar'; + + $this->assertSame('H…', $this->Text->truncate('Hello', 2)); + $this->assertSame('Hel…', $this->Text->truncate('Hello', 3, ['exact' => false])); + $this->assertSame('The quick brow…', $this->Text->truncate($text1, 15)); + $this->assertSame('The quick…', $this->Text->truncate($text1, 15, ['exact' => false])); + $this->assertSame('The quick brown fox jumps over the lazy dog', $this->Text->truncate($text1, 100)); + $this->assertSame('Heizö…', $this->Text->truncate($text2, 10)); + $this->assertSame('Heizö…', $this->Text->truncate($text2, 10, ['exact' => false])); + $this->assertSame('© 2005-2007…', $this->Text->truncate($text3, 20)); + $this->assertSame('Text->truncate($text1, 15, ['html' => true])); + $this->assertSame("The quick\xe2\x80\xa6", $this->Text->truncate($text1, 15, ['exact' => false, 'html' => true])); + $this->assertSame("Heizölrüc\xe2\x80\xa6", $this->Text->truncate($text2, 10, ['html' => true])); + $this->assertSame("Heizölrück\xe2\x80\xa6", $this->Text->truncate($text2, 10, ['exact' => false, 'html' => true])); + $this->assertSame("© 2005-2007, Cake S\xe2\x80\xa6", $this->Text->truncate($text3, 20, ['html' => true])); + $this->assertSame(" This image ta\xe2\x80\xa6", $this->Text->truncate($text4, 15, ['html' => true])); + $this->assertSame(" This image tag is not XHTML conform!

    But the\xe2\x80\xa6", $this->Text->truncate($text4, 45, ['html' => true])); + $this->assertSame(' This image tag is not XHTML conform!

    But the following image tag should be conform Me, myself and I
    Great,' . "\xe2\x80\xa6", $this->Text->truncate($text4, 90, ['html' => true])); + $this->assertSame('012345', $this->Text->truncate($text5, 6, ['ellipsis' => '', 'html' => true])); + $this->assertSame($text5, $this->Text->truncate($text5, 20, ['ellipsis' => '', 'html' => true])); + $this->assertSame("

    Extra dates have been announced for this year's\xe2\x80\xa6

    ", $this->Text->truncate($text6, 48, ['exact' => false, 'html' => true])); + $this->assertSame($text7, $this->Text->truncate($text7, 255)); + $this->assertSame('El moño está e…', $this->Text->truncate($text7, 15)); + $this->assertSame('Vive la R' . chr(195) . chr(169) . 'publ…', $this->Text->truncate($text8, 15)); + $this->assertSame('НОПРСТУФХ…', $this->Text->truncate($text9, 10)); + $this->assertSame('http://example.com/something/…', $this->Text->truncate($text10, 30)); + $this->assertSame('1 2...', $this->Text->truncate('1 2 345', 6, ['exact' => false, 'html' => true, 'ellipsis' => '...'])); + $this->assertSame('&', $this->Text->truncate('&', 1, ['html' => true])); + + $text = '

    Iamatestwithnospacesandhtml

    '; + $result = $this->Text->truncate($text, 10, [ + 'ellipsis' => '...', + 'exact' => false, + 'html' => true, + ]); + $expected = '

    Iamatestwi...

    '; + $this->assertSame($expected, $result); + + $text = "

    The quick brown fox jumps over the lazy dog

    "; + $expected = "

    The qu...

    "; + $result = $this->Text->truncate($text, 9, ['html' => true, 'ellipsis' => '...']); + $this->assertSame($expected, $result); + } + + /** + * Test truncate() method with both exact and html. + */ + public function testTruncateExactHtml(): void + { + $text = 'hello world'; + $expected = 'hell..'; + $result = Text::truncate($text, 6, [ + 'ellipsis' => '..', + 'exact' => true, + 'html' => true, + ]); + $this->assertSame($expected, $result); + + $expected = 'hello..'; + $result = Text::truncate($text, 6, [ + 'ellipsis' => '..', + 'exact' => false, + 'html' => true, + ]); + $this->assertSame($expected, $result); + } + + /** + * testTruncate method with non utf8 sites + */ + public function testTruncateLegacy(): void + { + mb_internal_encoding('ISO-8859-1'); + $text = '© 2005-2007, Cake Software Foundation, Inc.
    written by Alexander Wegener'; + $result = $this->Text->truncate($text, 31, [ + 'html' => true, + 'exact' => false, + ]); + $expected = '© 2005-2007, Cake Software…'; + $this->assertSame($expected, $result); + + $result = $this->Text->truncate($text, 31, [ + 'html' => true, + 'exact' => true, + ]); + $expected = '© 2005-2007, Cake Software F…'; + $this->assertSame($expected, $result); + } + + /** + * Test truncate() method with trimWidth + */ + public function testTruncateTrimWidth(): void + { + $text = 'The quick brown fox jumps over the lazy dog'; + $this->assertSame('The quick brown...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => false])); + $this->assertSame('The quick brown...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => true])); + + $text = 'はしこい茶色の狐はのろまな犬を飛び越える'; + $this->assertSame('はしこい茶色の狐はのろまな犬を...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => false])); + $this->assertSame('はしこい茶色の...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => true])); + + $text = 'はしこい茶色の狐 - The quick brown fox'; + $this->assertSame('はしこい茶色の狐 - The quick bro...', Text::truncate($text, 27, ['ellipsis' => '...', 'trimWidth' => false])); + $this->assertSame('はしこい茶色の狐 - The q...', Text::truncate($text, 27, ['ellipsis' => '...', 'trimWidth' => true])); + $this->assertSame('はしこい茶色の狐 - The...', Text::truncate($text, 27, ['ellipsis' => '...', 'trimWidth' => true, 'exact' => false])); + + $text = '

    はしこい茶色の狐はのろまな犬を飛び越える

    '; + $this->assertSame('

    はしこい茶色の狐はのろまな犬を...

    ', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => false, 'html' => true])); + $this->assertSame('

    はしこい茶色の...

    ', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => true, 'html' => true])); + + $text = <<このimageタグはXHTMLに準拠していない!
    +
    でも次のimageタグは準拠しているはず 私の、私自身そして私
    +素晴らしい、でしょ? +HTML; + $this->assertSame("このimageタグはXHTMLに準拠していない!
    \n
    でも次の…", Text::truncate($text, 30, ['html' => true])); + $this->assertSame('このimageタグはXHTMLに準拠し…', Text::truncate($text, 30, ['html' => true, 'trimWidth' => true])); + } + + /** + * testTail method + */ + public function testTail(): void + { + $text1 = 'The quick brown fox jumps over the lazy dog'; + $text2 = 'Heizölrückstoßabdämpfung'; + $text3 = 'El moño está en el lugar correcto. Eso fue lo que dijo la niña, ¿habrá dicho la verdad?'; + $text4 = 'Vive la R' . chr(195) . chr(169) . 'publique de France'; + $text5 = 'НОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; + + $result = $this->Text->tail($text1, 13); + $this->assertSame('…the lazy dog', $result); + + $result = $this->Text->tail($text1, 13, ['exact' => false]); + $this->assertSame('…lazy dog', $result); + + $result = $this->Text->tail($text1, 100); + $this->assertSame('The quick brown fox jumps over the lazy dog', $result); + + $result = $this->Text->tail($text2, 10); + $this->assertSame('…ml;mpfung', $result); + + $result = $this->Text->tail($text2, 10, ['exact' => false]); + $this->assertSame('…', $result); + + $result = $this->Text->tail($text3, 255); + $this->assertSame($text3, $result); + + $result = $this->Text->tail($text3, 21); + $this->assertSame('…brá dicho la verdad?', $result); + + $result = $this->Text->tail($text4, 25); + $this->assertSame('… la R' . chr(195) . chr(169) . 'publique de France', $result); + + $result = $this->Text->tail($text5, 10); + $this->assertSame('…фхцчшщъыь', $result); + + $result = $this->Text->tail($text5, 6, ['ellipsis' => '']); + $this->assertSame('чшщъыь', $result); + } + + /** + * Tests highlight() method. + */ + public function testHighlight(): void + { + $text = 'This is a test text'; + $phrases = ['This', 'text']; + $result = $this->Text->highlight($text, $phrases, ['format' => '\1']); + $expected = 'This is a test text'; + $this->assertSame($expected, $result); + + $phrases = ['is', 'text']; + $result = $this->Text->highlight($text, $phrases, ['format' => '\1', 'regex' => "|\b%s\b|iu"]); + $expected = 'This is a test text'; + $this->assertSame($expected, $result); + + $text = 'This is a test text'; + $phrases = ''; + $result = $this->Text->highlight($text, $phrases, ['format' => '\1']); + $this->assertSame($text, $result); + + $text = 'This is a (test) text'; + $phrases = '(test'; + $result = $this->Text->highlight($text, $phrases, ['format' => '\1']); + $this->assertSame('This is a (test) text', $result); + + $text = 'Ich saß in einem Café am Übergang'; + $expected = 'Ich saß in einem Café am Übergang'; + $phrases = ['saß', 'café', 'übergang']; + $result = $this->Text->highlight($text, $phrases, ['format' => '\1']); + $this->assertSame($expected, $result); + } + + /** + * Tests highlight() method with limit. + */ + public function testHighlightLimit(): void + { + $text = 'This is a test text with some more text'; + $phrases = ['This', 'text']; + $result = $this->Text->highlight($text, $phrases, ['format' => '\1']); + $expected = 'This is a test text with some more text'; + $this->assertSame($expected, $result); + + $result = $this->Text->highlight($text, $phrases, ['format' => '\1', 'limit' => 1]); + $expected = 'This is a test text with some more text'; + $this->assertSame($expected, $result); + } + + /** + * testHighlightHtml method + */ + public function testHighlightHtml(): void + { + $text1 = '

    strongbow isn’t real cider

    '; + $text2 = '

    strongbow isn’t real cider

    '; + $text3 = 'What a strong mouse!'; + $text4 = 'What a strong mouse: What a strong mouse!'; + $options = ['format' => '\1', 'html' => true]; + + $expected = '

    strongbow isn’t real cider

    '; + $this->assertSame($expected, $this->Text->highlight($text1, 'strong', $options)); + + $expected = '

    strongbow isn’t real cider

    '; + $this->assertSame($expected, $this->Text->highlight($text2, 'strong', $options)); + + $this->assertSame($text3, $this->Text->highlight($text3, 'strong', $options)); + + $this->assertSame($text3, $this->Text->highlight($text3, ['strong', 'what'], $options)); + + $expected = 'What a strong mouse: What a strong mouse!'; + $this->assertSame($expected, $this->Text->highlight($text4, ['strong', 'what'], $options)); + } + + /** + * testHighlightMulti method + */ + public function testHighlightMulti(): void + { + $text = 'This is a test text'; + $phrases = ['This', 'text']; + $result = $this->Text->highlight($text, $phrases, ['format' => ['\1', '\1']]); + $expected = 'This is a test text'; + $this->assertSame($expected, $result); + } + + /** + * testHighlightCaseInsensitivity method + */ + public function testHighlightCaseInsensitivity(): void + { + $text = 'This is a Test text'; + $expected = 'This is a Test text'; + + $result = $this->Text->highlight($text, 'test', ['format' => '\1']); + $this->assertSame($expected, $result); + + $result = $this->Text->highlight($text, ['test'], ['format' => '\1']); + $this->assertSame($expected, $result); + } + + /** + * testExcerpt method + */ + public function testExcerpt(): void + { + $text = 'This is a phrase with test text to play with'; + + $expected = '...ase with test text to ...'; + $result = $this->Text->excerpt($text, 'test', 9, '...'); + $this->assertSame($expected, $result); + + $expected = 'This is a...'; + $result = $this->Text->excerpt($text, 'not_found', 9, '...'); + $this->assertSame($expected, $result); + + $expected = 'This is a phras...'; + $result = $this->Text->excerpt($text, '', 9, '...'); + $this->assertSame($expected, $result); + + $expected = $text; + $result = $this->Text->excerpt($text, '', 200, '...'); + $this->assertSame($expected, $result); + + $expected = '...a phrase w...'; + $result = $this->Text->excerpt($text, 'phrase', 2, '...'); + $this->assertSame($expected, $result); + + $phrase = 'This is a phrase with test text'; + $expected = $text; + $result = $this->Text->excerpt($text, $phrase, 13, '...'); + $this->assertSame($expected, $result); + + $text = 'aaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbaaaaaaaaaaaaaaaaaaaaaaaa'; + $phrase = 'bbbbbbbb'; + $result = $this->Text->excerpt($text, $phrase, 10); + $expected = '…aaaaaaaaaabbbbbbbbaaaaaaaaaa…'; + $this->assertSame($expected, $result); + } + + /** + * testExcerptCaseInsensitivity method + */ + public function testExcerptCaseInsensitivity(): void + { + $text = 'This is a phrase with test text to play with'; + + $expected = '...ase with test text to ...'; + $result = $this->Text->excerpt($text, 'TEST', 9, '...'); + $this->assertSame($expected, $result); + + $expected = 'This is a...'; + $result = $this->Text->excerpt($text, 'NOT_FOUND', 9, '...'); + $this->assertSame($expected, $result); + } + + /** + * testListGeneration method + */ + public function testListGeneration(): void + { + $result = $this->Text->toList([]); + $this->assertSame('', $result); + + $result = $this->Text->toList(['One']); + $this->assertSame('One', $result); + + $result = $this->Text->toList(['Larry', 'Curly', 'Moe']); + $this->assertSame('Larry, Curly and Moe', $result); + + $result = $this->Text->toList(['Dusty', 'Lucky', 'Ned'], 'y'); + $this->assertSame('Dusty, Lucky y Ned', $result); + + $result = $this->Text->toList([1 => 'Dusty', 2 => 'Lucky', 3 => 'Ned'], 'y'); + $this->assertSame('Dusty, Lucky y Ned', $result); + + $result = $this->Text->toList([1 => 'Dusty', 2 => 'Lucky', 3 => 'Ned'], 'and', ' + '); + $this->assertSame('Dusty + Lucky and Ned', $result); + + $result = $this->Text->toList(['name1' => 'Dusty', 'name2' => 'Lucky']); + $this->assertSame('Dusty and Lucky', $result); + + $result = $this->Text->toList(['test_0' => 'banana', 'test_1' => 'apple', 'test_2' => 'lemon']); + $this->assertSame('banana, apple and lemon', $result); + } + + /** + * testUtf8 method + */ + public function testUtf8(): void + { + $string = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + $result = Text::utf8($string); + $expected = [33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, + 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, + 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, + 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126]; + $this->assertSame($expected, $result); + + $string = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; + $result = Text::utf8($string); + $expected = [161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, + 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200]; + $this->assertSame($expected, $result); + + $string = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; + $result = Text::utf8($string); + $expected = [201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, + 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, + 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, + 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, + 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300]; + $this->assertSame($expected, $result); + + $string = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; + $result = Text::utf8($string); + $expected = [301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, + 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, + 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, + 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, + 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400]; + $this->assertSame($expected, $result); + + $string = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; + $result = Text::utf8($string); + $expected = [401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, + 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, + 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, + 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, + 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500]; + $this->assertSame($expected, $result); + + $string = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; + $result = Text::utf8($string); + $expected = [601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, + 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, + 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, + 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, + 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700]; + $this->assertSame($expected, $result); + + $string = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; + $result = Text::utf8($string); + $expected = [1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 1040, 1041, + 1042, 1043, 1044, 1045, 1046, 1047, 1048, 1049, 1050, 1051]; + $this->assertSame($expected, $result); + + $string = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; + $result = Text::utf8($string); + $expected = [1052, 1053, 1054, 1055, 1056, 1057, 1058, 1059, 1060, 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, + 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, + 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100]; + $this->assertSame($expected, $result); + + $string = 'չպջռսվտ'; + $result = Text::utf8($string); + $expected = [1401, 1402, 1403, 1404, 1405, 1406, 1407]; + $this->assertSame($expected, $result); + + $string = 'فقكلمنهوىيًٌٍَُ'; + $result = Text::utf8($string); + $expected = [1601, 1602, 1603, 1604, 1605, 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615]; + $this->assertSame($expected, $result); + + $string = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; + $result = Text::utf8($string); + $expected = [10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, + 10045, 10046, 10047, 10048, 10049, 10050, 10051, 10052, 10053, 10054, 10055, 10056, 10057, + 10058, 10059, 10060, 10061, 10062, 10063, 10064, 10065, 10066, 10067, 10068, 10069, 10070, + 10071, 10072, 10073, 10074, 10075, 10076, 10077, 10078]; + $this->assertSame($expected, $result); + + $string = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; + $result = Text::utf8($string); + $expected = [11904, 11905, 11906, 11907, 11908, 11909, 11910, 11911, 11912, 11913, 11914, 11915, 11916, 11917, 11918, 11919, + 11920, 11921, 11922, 11923, 11924, 11925, 11926, 11927, 11928, 11929, 11931, 11932, 11933, 11934, 11935, 11936, + 11937, 11938, 11939, 11940, 11941, 11942, 11943, 11944, 11945, 11946, 11947, 11948, 11949, 11950, 11951, 11952, + 11953, 11954, 11955, 11956, 11957, 11958, 11959, 11960, 11961, 11962, 11963, 11964, 11965, 11966, 11967, 11968, + 11969, 11970, 11971, 11972, 11973, 11974, 11975, 11976, 11977, 11978, 11979, 11980, 11981, 11982, 11983, 11984, + 11985, 11986, 11987, 11988, 11989, 11990, 11991, 11992, 11993, 11994, 11995, 11996, 11997, 11998, 11999, 12000]; + $this->assertSame($expected, $result); + + $string = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; + $result = Text::utf8($string); + $expected = [12101, 12102, 12103, 12104, 12105, 12106, 12107, 12108, 12109, 12110, 12111, 12112, 12113, 12114, 12115, 12116, + 12117, 12118, 12119, 12120, 12121, 12122, 12123, 12124, 12125, 12126, 12127, 12128, 12129, 12130, 12131, 12132, + 12133, 12134, 12135, 12136, 12137, 12138, 12139, 12140, 12141, 12142, 12143, 12144, 12145, 12146, 12147, 12148, + 12149, 12150, 12151, 12152, 12153, 12154, 12155, 12156, 12157, 12158, 12159]; + $this->assertSame($expected, $result); + + $string = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; + $result = Text::utf8($string); + $expected = [45601, 45602, 45603, 45604, 45605, 45606, 45607, 45608, 45609, 45610, 45611, 45612, 45613, 45614, 45615, 45616, + 45617, 45618, 45619, 45620, 45621, 45622, 45623, 45624, 45625, 45626, 45627, 45628, 45629, 45630, 45631, 45632, + 45633, 45634, 45635, 45636, 45637, 45638, 45639, 45640, 45641, 45642, 45643, 45644, 45645, 45646, 45647, 45648, + 45649, 45650, 45651, 45652, 45653, 45654, 45655, 45656, 45657, 45658, 45659, 45660, 45661, 45662, 45663, 45664, + 45665, 45666, 45667, 45668, 45669, 45670, 45671, 45672, 45673, 45674, 45675, 45676, 45677, 45678, 45679, 45680, + 45681, 45682, 45683, 45684, 45685, 45686, 45687, 45688, 45689, 45690, 45691, 45692, 45693, 45694, 45695, 45696, + 45697, 45698, 45699, 45700]; + $this->assertSame($expected, $result); + + $string = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; + $result = Text::utf8($string); + $expected = [65136, 65137, 65138, 65139, 65140, 65141, 65142, 65143, 65144, 65145, 65146, 65147, 65148, 65149, 65150, 65151, + 65152, 65153, 65154, 65155, 65156, 65157, 65158, 65159, 65160, 65161, 65162, 65163, 65164, 65165, 65166, 65167, + 65168, 65169, 65170, 65171, 65172, 65173, 65174, 65175, 65176, 65177, 65178, 65179, 65180, 65181, 65182, 65183, + 65184, 65185, 65186, 65187, 65188, 65189, 65190, 65191, 65192, 65193, 65194, 65195, 65196, 65197, 65198, 65199, + 65200]; + $this->assertSame($expected, $result); + + $string = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; + $result = Text::utf8($string); + $expected = [65201, 65202, 65203, 65204, 65205, 65206, 65207, 65208, 65209, 65210, 65211, 65212, 65213, 65214, 65215, 65216, + 65217, 65218, 65219, 65220, 65221, 65222, 65223, 65224, 65225, 65226, 65227, 65228, 65229, 65230, 65231, 65232, + 65233, 65234, 65235, 65236, 65237, 65238, 65239, 65240, 65241, 65242, 65243, 65244, 65245, 65246, 65247, 65248, + 65249, 65250, 65251, 65252, 65253, 65254, 65255, 65256, 65257, 65258, 65259, 65260, 65261, 65262, 65263, 65264, + 65265, 65266, 65267, 65268, 65269, 65270, 65271, 65272, 65273, 65274, 65275, 65276]; + $this->assertSame($expected, $result); + + $string = 'abcdefghijklmnopqrstuvwxyz'; + $result = Text::utf8($string); + $expected = [65345, 65346, 65347, 65348, 65349, 65350, 65351, 65352, 65353, 65354, 65355, 65356, 65357, 65358, 65359, 65360, + 65361, 65362, 65363, 65364, 65365, 65366, 65367, 65368, 65369, 65370]; + $this->assertSame($expected, $result); + + $string = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; + $result = Text::utf8($string); + $expected = [65377, 65378, 65379, 65380, 65381, 65382, 65383, 65384, 65385, 65386, 65387, 65388, 65389, 65390, 65391, 65392, + 65393, 65394, 65395, 65396, 65397, 65398, 65399, 65400]; + $this->assertSame($expected, $result); + + $string = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; + $result = Text::utf8($string); + $expected = [65401, 65402, 65403, 65404, 65405, 65406, 65407, 65408, 65409, 65410, 65411, 65412, 65413, 65414, 65415, 65416, + 65417, 65418, 65419, 65420, 65421, 65422, 65423, 65424, 65425, 65426, 65427, 65428, 65429, 65430, 65431, 65432, + 65433, 65434, 65435, 65436, 65437, 65438]; + $this->assertSame($expected, $result); + + $string = 'Ĥēĺļŏ, Ŵőřļď!'; + $result = Text::utf8($string); + $expected = [292, 275, 314, 316, 335, 44, 32, 372, 337, 345, 316, 271, 33]; + $this->assertSame($expected, $result); + + $string = 'Hello, World!'; + $result = Text::utf8($string); + $expected = [72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]; + $this->assertSame($expected, $result); + + $string = '¨'; + $result = Text::utf8($string); + $expected = [168]; + $this->assertSame($expected, $result); + + $string = '¿'; + $result = Text::utf8($string); + $expected = [191]; + $this->assertSame($expected, $result); + + $string = 'čini'; + $result = Text::utf8($string); + $expected = [269, 105, 110, 105]; + $this->assertSame($expected, $result); + + $string = 'moći'; + $result = Text::utf8($string); + $expected = [109, 111, 263, 105]; + $this->assertSame($expected, $result); + + $string = 'državni'; + $result = Text::utf8($string); + $expected = [100, 114, 382, 97, 118, 110, 105]; + $this->assertSame($expected, $result); + + $string = '把百度设为首页'; + $result = Text::utf8($string); + $expected = [25226, 30334, 24230, 35774, 20026, 39318, 39029]; + $this->assertSame($expected, $result); + + $string = '一二三周永龍'; + $result = Text::utf8($string); + $expected = [19968, 20108, 19977, 21608, 27704, 40845]; + $this->assertSame($expected, $result); + + $string = 'ԀԂԄԆԈԊԌԎԐԒ'; + $result = Text::utf8($string); + $expected = [1280, 1282, 1284, 1286, 1288, 1290, 1292, 1294, 1296, 1298]; + $this->assertSame($expected, $result); + + $string = 'ԁԃԅԇԉԋԍԏԐԒ'; + $result = Text::utf8($string); + $expected = [1281, 1283, 1285, 1287, 1289, 1291, 1293, 1295, 1296, 1298]; + $this->assertSame($expected, $result); + + $string = 'ԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖև'; + $result = Text::utf8($string); + $expected = [1329, 1330, 1331, 1332, 1333, 1334, 1335, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 1343, 1344, 1345, 1346, + 1347, 1348, 1349, 1350, 1351, 1352, 1353, 1354, 1355, 1356, 1357, 1358, 1359, 1360, 1361, 1362, 1363, 1364, + 1365, 1366, 1415]; + $this->assertSame($expected, $result); + + $string = 'աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև'; + $result = Text::utf8($string); + $expected = [1377, 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1387, 1388, 1389, 1390, 1391, 1392, 1393, 1394, + 1395, 1396, 1397, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407, 1408, 1409, 1410, 1411, 1412, + 1413, 1414, 1415]; + $this->assertSame($expected, $result); + + $string = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; + $result = Text::utf8($string); + $expected = [4256, 4257, 4258, 4259, 4260, 4261, 4262, 4263, 4264, 4265, 4266, 4267, 4268, 4269, 4270, 4271, 4272, 4273, + 4274, 4275, 4276, 4277, 4278, 4279, 4280, 4281, 4282, 4283, 4284, 4285, 4286, 4287, 4288, 4289, 4290, 4291, + 4292, 4293]; + $this->assertSame($expected, $result); + + $string = 'ḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẖẗẘẙẚẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸ'; + $result = Text::utf8($string); + $expected = [7680, 7682, 7684, 7686, 7688, 7690, 7692, 7694, 7696, 7698, 7700, 7702, 7704, 7706, 7708, 7710, 7712, 7714, + 7716, 7718, 7720, 7722, 7724, 7726, 7728, 7730, 7732, 7734, 7736, 7738, 7740, 7742, 7744, 7746, 7748, 7750, + 7752, 7754, 7756, 7758, 7760, 7762, 7764, 7766, 7768, 7770, 7772, 7774, 7776, 7778, 7780, 7782, 7784, 7786, + 7788, 7790, 7792, 7794, 7796, 7798, 7800, 7802, 7804, 7806, 7808, 7810, 7812, 7814, 7816, 7818, 7820, 7822, + 7824, 7826, 7828, 7830, 7831, 7832, 7833, 7834, 7840, 7842, 7844, 7846, 7848, 7850, 7852, 7854, 7856, + 7858, 7860, 7862, 7864, 7866, 7868, 7870, 7872, 7874, 7876, 7878, 7880, 7882, 7884, 7886, 7888, 7890, 7892, + 7894, 7896, 7898, 7900, 7902, 7904, 7906, 7908, 7910, 7912, 7914, 7916, 7918, 7920, 7922, 7924, 7926, 7928]; + $this->assertSame($expected, $result); + + $string = 'ḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕẖẗẘẙẚạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ'; + $result = Text::utf8($string); + $expected = [7681, 7683, 7685, 7687, 7689, 7691, 7693, 7695, 7697, 7699, 7701, 7703, 7705, 7707, 7709, 7711, 7713, 7715, + 7717, 7719, 7721, 7723, 7725, 7727, 7729, 7731, 7733, 7735, 7737, 7739, 7741, 7743, 7745, 7747, 7749, 7751, + 7753, 7755, 7757, 7759, 7761, 7763, 7765, 7767, 7769, 7771, 7773, 7775, 7777, 7779, 7781, 7783, 7785, 7787, + 7789, 7791, 7793, 7795, 7797, 7799, 7801, 7803, 7805, 7807, 7809, 7811, 7813, 7815, 7817, 7819, 7821, 7823, + 7825, 7827, 7829, 7830, 7831, 7832, 7833, 7834, 7841, 7843, 7845, 7847, 7849, 7851, 7853, 7855, 7857, 7859, + 7861, 7863, 7865, 7867, 7869, 7871, 7873, 7875, 7877, 7879, 7881, 7883, 7885, 7887, 7889, 7891, 7893, 7895, + 7897, 7899, 7901, 7903, 7905, 7907, 7909, 7911, 7913, 7915, 7917, 7919, 7921, 7923, 7925, 7927, 7929]; + $this->assertSame($expected, $result); + + $string = 'ΩKÅℲ'; + $result = Text::utf8($string); + $expected = [8486, 8490, 8491, 8498]; + $this->assertSame($expected, $result); + + $string = 'ωkåⅎ'; + $result = Text::utf8($string); + $expected = [969, 107, 229, 8526]; + $this->assertSame($expected, $result); + + $string = 'ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯↃ'; + $result = Text::utf8($string); + $expected = [8544, 8545, 8546, 8547, 8548, 8549, 8550, 8551, 8552, 8553, 8554, 8555, 8556, 8557, 8558, 8559, 8579]; + $this->assertSame($expected, $result); + + $string = 'ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↄ'; + $result = Text::utf8($string); + $expected = [8560, 8561, 8562, 8563, 8564, 8565, 8566, 8567, 8568, 8569, 8570, 8571, 8572, 8573, 8574, 8575, 8580]; + $this->assertSame($expected, $result); + + $string = 'ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ'; + $result = Text::utf8($string); + $expected = [9398, 9399, 9400, 9401, 9402, 9403, 9404, 9405, 9406, 9407, 9408, 9409, 9410, 9411, 9412, 9413, 9414, + 9415, 9416, 9417, 9418, 9419, 9420, 9421, 9422, 9423]; + $this->assertSame($expected, $result); + + $string = 'ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ'; + $result = Text::utf8($string); + $expected = [9424, 9425, 9426, 9427, 9428, 9429, 9430, 9431, 9432, 9433, 9434, 9435, 9436, 9437, 9438, 9439, 9440, 9441, + 9442, 9443, 9444, 9445, 9446, 9447, 9448, 9449]; + $this->assertSame($expected, $result); + + $string = 'ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮ'; + $result = Text::utf8($string); + $expected = [11264, 11265, 11266, 11267, 11268, 11269, 11270, 11271, 11272, 11273, 11274, 11275, 11276, 11277, 11278, + 11279, 11280, 11281, 11282, 11283, 11284, 11285, 11286, 11287, 11288, 11289, 11290, 11291, 11292, 11293, + 11294, 11295, 11296, 11297, 11298, 11299, 11300, 11301, 11302, 11303, 11304, 11305, 11306, 11307, 11308, + 11309, 11310]; + $this->assertSame($expected, $result); + + $string = 'ⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞ'; + $result = Text::utf8($string); + $expected = [11312, 11313, 11314, 11315, 11316, 11317, 11318, 11319, 11320, 11321, 11322, 11323, 11324, 11325, 11326, 11327, + 11328, 11329, 11330, 11331, 11332, 11333, 11334, 11335, 11336, 11337, 11338, 11339, 11340, 11341, 11342, 11343, + 11344, 11345, 11346, 11347, 11348, 11349, 11350, 11351, 11352, 11353, 11354, 11355, 11356, 11357, 11358]; + $this->assertSame($expected, $result); + + $string = 'ⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢ'; + $result = Text::utf8($string); + $expected = [11392, 11394, 11396, 11398, 11400, 11402, 11404, 11406, 11408, 11410, 11412, 11414, 11416, 11418, 11420, + 11422, 11424, 11426, 11428, 11430, 11432, 11434, 11436, 11438, 11440, 11442, 11444, 11446, 11448, 11450, + 11452, 11454, 11456, 11458, 11460, 11462, 11464, 11466, 11468, 11470, 11472, 11474, 11476, 11478, 11480, + 11482, 11484, 11486, 11488, 11490]; + $this->assertSame($expected, $result); + + $string = 'ⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣ'; + $result = Text::utf8($string); + $expected = [11393, 11395, 11397, 11399, 11401, 11403, 11405, 11407, 11409, 11411, 11413, 11415, 11417, 11419, 11421, 11423, + 11425, 11427, 11429, 11431, 11433, 11435, 11437, 11439, 11441, 11443, 11445, 11447, 11449, 11451, 11453, 11455, + 11457, 11459, 11461, 11463, 11465, 11467, 11469, 11471, 11473, 11475, 11477, 11479, 11481, 11483, 11485, 11487, + 11489, 11491]; + $this->assertSame($expected, $result); + + $string = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; + $result = Text::utf8($string); + $expected = [64256, 64257, 64258, 64259, 64260, 64261, 64262, 64275, 64276, 64277, 64278, 64279]; + $this->assertSame($expected, $result); + } + + /** + * testAscii method + */ + public function testAscii(): void + { + $input = [33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, + 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, + 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, + 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126]; + $result = Text::ascii($input); + + $expected = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; + $this->assertSame($expected, $result); + + $input = [161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, + 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200]; + $result = Text::ascii($input); + + $expected = '¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈ'; + $this->assertSame($expected, $result); + + $input = [201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, + 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, + 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, + 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, + 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300]; + $result = Text::ascii($input); + $expected = 'ÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬ'; + $this->assertSame($expected, $result); + + $input = [301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, + 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, + 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, + 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, + 385, 386, 387, 388, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400]; + $expected = 'ĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, + 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, + 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, + 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, + 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500]; + $expected = 'ƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, + 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 641, 642, + 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, 658, 659, 660, 661, 662, 663, + 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, + 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700]; + $expected = 'əɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 1040, 1041, + 1042, 1043, 1044, 1045, 1046, 1047, 1048, 1049, 1050, 1051]; + $expected = 'ЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [1052, 1053, 1054, 1055, 1056, 1057, 1058, 1059, 1060, 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, + 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, + 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100]; + $expected = 'МНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [1401, 1402, 1403, 1404, 1405, 1406, 1407]; + $expected = 'չպջռսվտ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [1601, 1602, 1603, 1604, 1605, 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615]; + $expected = 'فقكلمنهوىيًٌٍَُ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, + 10045, 10046, 10047, 10048, 10049, 10050, 10051, 10052, 10053, 10054, 10055, 10056, 10057, + 10058, 10059, 10060, 10061, 10062, 10063, 10064, 10065, 10066, 10067, 10068, 10069, 10070, + 10071, 10072, 10073, 10074, 10075, 10076, 10077, 10078]; + $expected = '✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❌❍❎❏❐❑❒❓❔❕❖❗❘❙❚❛❜❝❞'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [11904, 11905, 11906, 11907, 11908, 11909, 11910, 11911, 11912, 11913, 11914, 11915, 11916, 11917, 11918, 11919, + 11920, 11921, 11922, 11923, 11924, 11925, 11926, 11927, 11928, 11929, 11931, 11932, 11933, 11934, 11935, 11936, + 11937, 11938, 11939, 11940, 11941, 11942, 11943, 11944, 11945, 11946, 11947, 11948, 11949, 11950, 11951, 11952, + 11953, 11954, 11955, 11956, 11957, 11958, 11959, 11960, 11961, 11962, 11963, 11964, 11965, 11966, 11967, 11968, + 11969, 11970, 11971, 11972, 11973, 11974, 11975, 11976, 11977, 11978, 11979, 11980, 11981, 11982, 11983, 11984, + 11985, 11986, 11987, 11988, 11989, 11990, 11991, 11992, 11993, 11994, 11995, 11996, 11997, 11998, 11999, 12000]; + $expected = '⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [12101, 12102, 12103, 12104, 12105, 12106, 12107, 12108, 12109, 12110, 12111, 12112, 12113, 12114, 12115, 12116, + 12117, 12118, 12119, 12120, 12121, 12122, 12123, 12124, 12125, 12126, 12127, 12128, 12129, 12130, 12131, 12132, + 12133, 12134, 12135, 12136, 12137, 12138, 12139, 12140, 12141, 12142, 12143, 12144, 12145, 12146, 12147, 12148, + 12149, 12150, 12151, 12152, 12153, 12154, 12155, 12156, 12157, 12158, 12159]; + $expected = '⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [45601, 45602, 45603, 45604, 45605, 45606, 45607, 45608, 45609, 45610, 45611, 45612, 45613, 45614, 45615, 45616, + 45617, 45618, 45619, 45620, 45621, 45622, 45623, 45624, 45625, 45626, 45627, 45628, 45629, 45630, 45631, 45632, + 45633, 45634, 45635, 45636, 45637, 45638, 45639, 45640, 45641, 45642, 45643, 45644, 45645, 45646, 45647, 45648, + 45649, 45650, 45651, 45652, 45653, 45654, 45655, 45656, 45657, 45658, 45659, 45660, 45661, 45662, 45663, 45664, + 45665, 45666, 45667, 45668, 45669, 45670, 45671, 45672, 45673, 45674, 45675, 45676, 45677, 45678, 45679, 45680, + 45681, 45682, 45683, 45684, 45685, 45686, 45687, 45688, 45689, 45690, 45691, 45692, 45693, 45694, 45695, 45696, + 45697, 45698, 45699, 45700]; + $expected = '눡눢눣눤눥눦눧눨눩눪눫눬눭눮눯눰눱눲눳눴눵눶눷눸눹눺눻눼눽눾눿뉀뉁뉂뉃뉄뉅뉆뉇뉈뉉뉊뉋뉌뉍뉎뉏뉐뉑뉒뉓뉔뉕뉖뉗뉘뉙뉚뉛뉜뉝뉞뉟뉠뉡뉢뉣뉤뉥뉦뉧뉨뉩뉪뉫뉬뉭뉮뉯뉰뉱뉲뉳뉴뉵뉶뉷뉸뉹뉺뉻뉼뉽뉾뉿늀늁늂늃늄'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [65136, 65137, 65138, 65139, 65140, 65141, 65142, 65143, 65144, 65145, 65146, 65147, 65148, 65149, 65150, 65151, + 65152, 65153, 65154, 65155, 65156, 65157, 65158, 65159, 65160, 65161, 65162, 65163, 65164, 65165, 65166, 65167, + 65168, 65169, 65170, 65171, 65172, 65173, 65174, 65175, 65176, 65177, 65178, 65179, 65180, 65181, 65182, 65183, + 65184, 65185, 65186, 65187, 65188, 65189, 65190, 65191, 65192, 65193, 65194, 65195, 65196, 65197, 65198, 65199, + 65200]; + $expected = 'ﹰﹱﹲﹳﹴ﹵ﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [65201, 65202, 65203, 65204, 65205, 65206, 65207, 65208, 65209, 65210, 65211, 65212, 65213, 65214, 65215, 65216, + 65217, 65218, 65219, 65220, 65221, 65222, 65223, 65224, 65225, 65226, 65227, 65228, 65229, 65230, 65231, 65232, + 65233, 65234, 65235, 65236, 65237, 65238, 65239, 65240, 65241, 65242, 65243, 65244, 65245, 65246, 65247, 65248, + 65249, 65250, 65251, 65252, 65253, 65254, 65255, 65256, 65257, 65258, 65259, 65260, 65261, 65262, 65263, 65264, + 65265, 65266, 65267, 65268, 65269, 65270, 65271, 65272, 65273, 65274, 65275, 65276]; + $expected = 'ﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [65345, 65346, 65347, 65348, 65349, 65350, 65351, 65352, 65353, 65354, 65355, 65356, 65357, 65358, 65359, 65360, + 65361, 65362, 65363, 65364, 65365, 65366, 65367, 65368, 65369, 65370]; + $expected = 'abcdefghijklmnopqrstuvwxyz'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [65377, 65378, 65379, 65380, 65381, 65382, 65383, 65384, 65385, 65386, 65387, 65388, 65389, 65390, 65391, 65392, + 65393, 65394, 65395, 65396, 65397, 65398, 65399, 65400]; + $expected = '。「」、・ヲァィゥェォャュョッーアイウエオカキク'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [65401, 65402, 65403, 65404, 65405, 65406, 65407, 65408, 65409, 65410, 65411, 65412, 65413, 65414, 65415, 65416, + 65417, 65418, 65419, 65420, 65421, 65422, 65423, 65424, 65425, 65426, 65427, 65428, 65429, 65430, 65431, 65432, + 65433, 65434, 65435, 65436, 65437, 65438]; + $expected = 'ケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [292, 275, 314, 316, 335, 44, 32, 372, 337, 345, 316, 271, 33]; + $expected = 'Ĥēĺļŏ, Ŵőřļď!'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]; + $expected = 'Hello, World!'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [168]; + $expected = '¨'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [191]; + $expected = '¿'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [269, 105, 110, 105]; + $expected = 'čini'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [109, 111, 263, 105]; + $expected = 'moći'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [100, 114, 382, 97, 118, 110, 105]; + $expected = 'državni'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [25226, 30334, 24230, 35774, 20026, 39318, 39029]; + $expected = '把百度设为首页'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [19968, 20108, 19977, 21608, 27704, 40845]; + $expected = '一二三周永龍'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [1280, 1282, 1284, 1286, 1288, 1290, 1292, 1294, 1296, 1298]; + $expected = 'ԀԂԄԆԈԊԌԎԐԒ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [1281, 1283, 1285, 1287, 1289, 1291, 1293, 1295, 1296, 1298]; + $expected = 'ԁԃԅԇԉԋԍԏԐԒ'; + $result = Text::ascii($input); + $this->assertSame($expected, $result); + + $input = [1329, 1330, 1331, 1332, 1333, 1334, 1335, 1336, 1337, 1338, 1339, 1340, 1341, 1342, 1343, 1344, 1345, 1346, 1347, + 1348, 1349, 1350, 1351, 1352, 1353, 1354, 1355, 1356, 1357, 1358, 1359, 1360, 1361, 1362, 1363, 1364, 1365, + 1366, 1415]; + $result = Text::ascii($input); + $expected = 'ԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖև'; + $this->assertSame($expected, $result); + + $input = [1377, 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1387, 1388, 1389, 1390, 1391, 1392, 1393, 1394, + 1395, 1396, 1397, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407, 1408, 1409, 1410, 1411, 1412, + 1413, 1414, 1415]; + $result = Text::ascii($input); + $expected = 'աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև'; + $this->assertSame($expected, $result); + + $input = [4256, 4257, 4258, 4259, 4260, 4261, 4262, 4263, 4264, 4265, 4266, 4267, 4268, 4269, 4270, 4271, 4272, 4273, 4274, + 4275, 4276, 4277, 4278, 4279, 4280, 4281, 4282, 4283, 4284, 4285, 4286, 4287, 4288, 4289, 4290, 4291, 4292, 4293]; + $result = Text::ascii($input); + $expected = 'ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅ'; + $this->assertSame($expected, $result); + + $input = [7680, 7682, 7684, 7686, 7688, 7690, 7692, 7694, 7696, 7698, 7700, 7702, 7704, 7706, 7708, 7710, 7712, 7714, + 7716, 7718, 7720, 7722, 7724, 7726, 7728, 7730, 7732, 7734, 7736, 7738, 7740, 7742, 7744, 7746, 7748, 7750, + 7752, 7754, 7756, 7758, 7760, 7762, 7764, 7766, 7768, 7770, 7772, 7774, 7776, 7778, 7780, 7782, 7784, 7786, + 7788, 7790, 7792, 7794, 7796, 7798, 7800, 7802, 7804, 7806, 7808, 7810, 7812, 7814, 7816, 7818, 7820, 7822, + 7824, 7826, 7828, 7830, 7831, 7832, 7833, 7834, 7840, 7842, 7844, 7846, 7848, 7850, 7852, 7854, 7856, + 7858, 7860, 7862, 7864, 7866, 7868, 7870, 7872, 7874, 7876, 7878, 7880, 7882, 7884, 7886, 7888, 7890, 7892, + 7894, 7896, 7898, 7900, 7902, 7904, 7906, 7908, 7910, 7912, 7914, 7916, 7918, 7920, 7922, 7924, 7926, 7928]; + $result = Text::ascii($input); + $expected = 'ḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẖẗẘẙẚẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸ'; + $this->assertSame($expected, $result); + + $input = [7681, 7683, 7685, 7687, 7689, 7691, 7693, 7695, 7697, 7699, 7701, 7703, 7705, 7707, 7709, 7711, 7713, 7715, + 7717, 7719, 7721, 7723, 7725, 7727, 7729, 7731, 7733, 7735, 7737, 7739, 7741, 7743, 7745, 7747, 7749, 7751, + 7753, 7755, 7757, 7759, 7761, 7763, 7765, 7767, 7769, 7771, 7773, 7775, 7777, 7779, 7781, 7783, 7785, 7787, + 7789, 7791, 7793, 7795, 7797, 7799, 7801, 7803, 7805, 7807, 7809, 7811, 7813, 7815, 7817, 7819, 7821, 7823, + 7825, 7827, 7829, 7830, 7831, 7832, 7833, 7834, 7841, 7843, 7845, 7847, 7849, 7851, 7853, 7855, 7857, 7859, + 7861, 7863, 7865, 7867, 7869, 7871, 7873, 7875, 7877, 7879, 7881, 7883, 7885, 7887, 7889, 7891, 7893, 7895, + 7897, 7899, 7901, 7903, 7905, 7907, 7909, 7911, 7913, 7915, 7917, 7919, 7921, 7923, 7925, 7927, 7929]; + $result = Text::ascii($input); + $expected = 'ḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕẖẗẘẙẚạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ'; + $this->assertSame($expected, $result); + + $input = [8486, 8490, 8491, 8498]; + $result = Text::ascii($input); + $expected = 'ΩKÅℲ'; + $this->assertSame($expected, $result); + + $input = [969, 107, 229, 8526]; + $result = Text::ascii($input); + $expected = 'ωkåⅎ'; + $this->assertSame($expected, $result); + + $input = [8544, 8545, 8546, 8547, 8548, 8549, 8550, 8551, 8552, 8553, 8554, 8555, 8556, 8557, 8558, 8559, 8579]; + $result = Text::ascii($input); + $expected = 'ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯↃ'; + $this->assertSame($expected, $result); + + $input = [8560, 8561, 8562, 8563, 8564, 8565, 8566, 8567, 8568, 8569, 8570, 8571, 8572, 8573, 8574, 8575, 8580]; + $result = Text::ascii($input); + $expected = 'ⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↄ'; + $this->assertSame($expected, $result); + + $input = [9398, 9399, 9400, 9401, 9402, 9403, 9404, 9405, 9406, 9407, 9408, 9409, 9410, 9411, 9412, 9413, 9414, + 9415, 9416, 9417, 9418, 9419, 9420, 9421, 9422, 9423]; + $result = Text::ascii($input); + $expected = 'ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏ'; + $this->assertSame($expected, $result); + + $input = [9424, 9425, 9426, 9427, 9428, 9429, 9430, 9431, 9432, 9433, 9434, 9435, 9436, 9437, 9438, 9439, 9440, 9441, + 9442, 9443, 9444, 9445, 9446, 9447, 9448, 9449]; + $result = Text::ascii($input); + $expected = 'ⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ'; + $this->assertSame($expected, $result); + + $input = [11264, 11265, 11266, 11267, 11268, 11269, 11270, 11271, 11272, 11273, 11274, 11275, 11276, 11277, 11278, 11279, + 11280, 11281, 11282, 11283, 11284, 11285, 11286, 11287, 11288, 11289, 11290, 11291, 11292, 11293, 11294, 11295, + 11296, 11297, 11298, 11299, 11300, 11301, 11302, 11303, 11304, 11305, 11306, 11307, 11308, 11309, 11310]; + $result = Text::ascii($input); + $expected = 'ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮ'; + $this->assertSame($expected, $result); + + $input = [11312, 11313, 11314, 11315, 11316, 11317, 11318, 11319, 11320, 11321, 11322, 11323, 11324, 11325, 11326, 11327, + 11328, 11329, 11330, 11331, 11332, 11333, 11334, 11335, 11336, 11337, 11338, 11339, 11340, 11341, 11342, 11343, + 11344, 11345, 11346, 11347, 11348, 11349, 11350, 11351, 11352, 11353, 11354, 11355, 11356, 11357, 11358]; + $result = Text::ascii($input); + $expected = 'ⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞ'; + $this->assertSame($expected, $result); + + $input = [11392, 11394, 11396, 11398, 11400, 11402, 11404, 11406, 11408, 11410, 11412, 11414, 11416, 11418, 11420, + 11422, 11424, 11426, 11428, 11430, 11432, 11434, 11436, 11438, 11440, 11442, 11444, 11446, 11448, 11450, + 11452, 11454, 11456, 11458, 11460, 11462, 11464, 11466, 11468, 11470, 11472, 11474, 11476, 11478, 11480, + 11482, 11484, 11486, 11488, 11490]; + $result = Text::ascii($input); + $expected = 'ⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢ'; + $this->assertSame($expected, $result); + + $input = [11393, 11395, 11397, 11399, 11401, 11403, 11405, 11407, 11409, 11411, 11413, 11415, 11417, 11419, 11421, 11423, + 11425, 11427, 11429, 11431, 11433, 11435, 11437, 11439, 11441, 11443, 11445, 11447, 11449, 11451, 11453, 11455, + 11457, 11459, 11461, 11463, 11465, 11467, 11469, 11471, 11473, 11475, 11477, 11479, 11481, 11483, 11485, 11487, + 11489, 11491]; + $result = Text::ascii($input); + $expected = 'ⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣ'; + $this->assertSame($expected, $result); + + $input = [64256, 64257, 64258, 64259, 64260, 64261, 64262, 64275, 64276, 64277, 64278, 64279]; + $result = Text::ascii($input); + $expected = 'fffiflffifflſtstﬓﬔﬕﬖﬗ'; + $this->assertSame($expected, $result); + } + + /** + * testparseFileSize + * + * @param mixed $expected + */ + #[DataProvider('filesizes')] + public function testParseFileSize(array $params, $expected): void + { + $result = Text::parseFileSize($params['size'], $params['default']); + $this->assertSame($expected, $result); + } + + /** + * testparseFileSizeException + */ + public function testParseFileSizeException(): void + { + $this->expectException(InvalidArgumentException::class); + Text::parseFileSize('bogus', false); + } + + /** + * filesizes dataprovider + * + * @return array + */ + public static function filesizes(): array + { + return [ + [['size' => '512B', 'default' => false], 512], + [['size' => '1KB', 'default' => false], 1024], + [['size' => '1.5KB', 'default' => false], 1536], + [['size' => '1MB', 'default' => false], 1048576], + [['size' => '1mb', 'default' => false], 1048576], + [['size' => '1.5MB', 'default' => false], 1572864], + [['size' => '1GB', 'default' => false], 1073741824], + [['size' => '1.5GB', 'default' => false], 1610612736], + [['size' => '1K', 'default' => false], 1024], + [['size' => '1.5K', 'default' => false], 1536], + [['size' => '1M', 'default' => false], 1048576], + [['size' => '1m', 'default' => false], 1048576], + [['size' => '1.5M', 'default' => false], 1572864], + [['size' => '1G', 'default' => false], 1073741824], + [['size' => '1.5G', 'default' => false], 1610612736], + [['size' => '512', 'default' => 'Unknown type'], 512], + [['size' => '2VB', 'default' => 'Unknown type'], 'Unknown type'], + ]; + } + + /** + * Test getting/setting default transliterator. + */ + public function testGetSetTransliterator(): void + { + $this->assertNull(Text::getTransliterator()); + + $transliterator = Transliterator::createFromRules(' + $nonletter = [:^Letter:]; + $nonletter → \'*\'; + ::Latin-ASCII; + '); + $this->assertInstanceOf(Transliterator::class, $transliterator); + Text::setTransliterator($transliterator); + $this->assertSame($transliterator, Text::getTransliterator()); + } + + /** + * Test getting/setting default transliterator id. + */ + public function testGetSetTransliteratorId(): void + { + $defaultTransliteratorId = 'Any-Latin; Latin-ASCII; [\u0080-\u7fff] remove'; + $this->assertSame($defaultTransliteratorId, Text::getTransliteratorId()); + + $expected = 'Latin-ASCII;[\u0080-\u7fff] remove'; + Text::setTransliteratorId($expected); + $this->assertSame($expected, Text::getTransliteratorId()); + + $this->assertInstanceOf(Transliterator::class, Text::getTransliterator()); + $this->assertSame($expected, Text::getTransliterator()->id); + + Text::setTransliteratorId($defaultTransliteratorId); + } + + /** + * Data provider for testTransliterate() + * + * @return array + */ + public static function transliterateInputProvider(): array + { + return [ + [ + 'Foo Bar: Not just for breakfast any-more', null, + 'Foo Bar: Not just for breakfast any-more', + ], + [ + 'A æ Übérmensch på høyeste nivå! И я люблю PHP! ест. fi ¦', null, + 'A ae Ubermensch pa hoyeste niva! I a lublu PHP! est. fi ', + ], + [ + 'Äpfel Über Öl grün ärgert groß öko', + transliterator_create_from_rules(' + $AE = [Ä {A \u0308}]; + $OE = [Ö {O \u0308}]; + $UE = [Ü {U \u0308}]; + [ä {a \u0308}] → ae; + [ö {o \u0308}] → oe; + [ü {u \u0308}] → ue; + {$AE} [:Lowercase:] → Ae; + {$OE} [:Lowercase:] → Oe; + {$UE} [:Lowercase:] → Ue; + $AE → AE; + $OE → OE; + $UE → UE; + ::Latin-ASCII; + '), + 'Aepfel Ueber Oel gruen aergert gross oeko', + ], + [ + 'La langue française est un attribut de souveraineté en France', null, + 'La langue francaise est un attribut de souverainete en France', + ], + [ + '!@$#exciting stuff! - what !@-# was that?', null, + '!@$#exciting stuff! - what !@-# was that?', + ], + [ + 'controller/action/りんご/1', null, + 'controller/action/ringo/1', + ], + [ + 'の話が出たので大丈夫かなあと', null, + 'no huaga chutanode da zhang fukanaato', + ], + [ + 'posts/view/한국어/page:1/sort:asc', null, + 'posts/view/hangug-eo/page:1/sort:asc', + ], + [ + "non\xc2\xa0breaking\xc2\xa0space", null, + 'non breaking space', + ], + ]; + } + + /** + * testTransliterate method + * + * @param string $string String + * @param \Transliterator|string|null $transliterator Transliterator + * @param String $expected Expected string + */ + #[DataProvider('transliterateInputProvider')] + public function testTransliterate($string, $transliterator, $expected): void + { + $result = Text::transliterate($string, $transliterator); + $this->assertSame($expected, $result); + } + + /** + * @return array + */ + public static function slugInputProvider(): array + { + return [ + [ + 'Foo Bar: Not just for breakfast any-more', [], + 'Foo-Bar-Not-just-for-breakfast-any-more', + ], + [ + 'Foo Bar: Not just for breakfast any-more', ['replacement' => '_'], + 'Foo_Bar_Not_just_for_breakfast_any_more', + ], + [ + 'Foo Bar: Not just for breakfast any-more', ['replacement' => '+'], + 'Foo+Bar+Not+just+for+breakfast+any+more', + ], + [ + 'A æ Übérmensch på høyeste nivå! И я люблю PHP! есть. fi ¦', [], + 'A-ae-Ubermensch-pa-hoyeste-niva-I-a-lublu-PHP-est-fi', + ], + [ + 'A æ Übérmensch på høyeste nivå! И я люблю PHP! есть. fi ¦', ['transliteratorId' => 'Latin-ASCII'], + 'A-ae-Ubermensch-pa-hoyeste-niva-И-я-люблю-PHP-есть-fi', + ], + [ + 'Äpfel Über Öl grün ärgert groß öko', [], + 'Apfel-Uber-Ol-grun-argert-gross-oko', + ], + [ + 'The truth - and- more- news', [], + 'The-truth-and-more-news', + ], + [ + 'The truth: and more news', [], + 'The-truth-and-more-news', + ], + [ + 'La langue française est un attribut de souveraineté en France', [], + 'La-langue-francaise-est-un-attribut-de-souverainete-en-France', + ], + [ + '!@$#exciting stuff! - what !@-# was that?', [], + 'exciting-stuff-what-was-that', + ], + [ + '20% of profits went to me!', [], + '20-of-profits-went-to-me', + ], + [ + '#this melts your face1#2#3', [], + 'this-melts-your-face1-2-3', + ], + [ + 'controller/action/りんご/1', ['transliteratorId' => false], + 'controller-action-りんご-1', + ], + [ + 'の話が出たので大丈夫かなあと', ['transliteratorId' => false], + 'の話が出たので大丈夫かなあと', + ], + [ + 'posts/view/한국어/page:1/sort:asc', ['transliteratorId' => false], + 'posts-view-한국어-page-1-sort-asc', + ], + [ + "non\xc2\xa0breaking\xc2\xa0space", [], + 'non-breaking-space', + ], + [ + 'Foo Bar: Not just for breakfast any-more', ['replacement' => ''], + 'FooBarNotjustforbreakfastanymore', + ], + [ + 'clean!_me.tar.gz', ['preserve' => '.'], + 'clean-me.tar.gz', + ], + [ + 'cl#ean(me', [], + 'cl-ean-me', + ], + [ + 'cl#e|an(me.jpg', ['preserve' => '.'], + 'cl-e-an-me.jpg', + ], + [ + 'Foo Bar: Not just for breakfast any-more', ['preserve' => ' '], + 'Foo Bar- Not just for breakfast any-more', + ], + [ + 'Foo Bar: Not just for (breakfast) any-more', ['preserve' => ' ()'], + 'Foo Bar- Not just for (breakfast) any-more', + ], + [ + 'Foo Bar: Not just for breakfast any-more', ['replacement' => null], + 'FooBarNotjustforbreakfastanymore', + ], + [ + 'Foo Bar: Not just for breakfast any-more', ['replacement' => false], + 'FooBarNotjustforbreakfastanymore', + ], + ]; + } + + /** + * testSlug method + * + * @param string $string String + * @param array $options Options + * @param String $expected Expected string + */ + #[DataProvider('slugInputProvider')] + public function testSlug($string, $options, $expected): void + { + $result = Text::slug($string, $options); + $this->assertSame($expected, $result); + } + + /** + * Text truncateByWidth method + */ + public function testTruncateByWidth(): void + { + $this->assertSame('

    あい…', Text::truncateByWidth('

    あいうえお

    ', 8)); + $this->assertSame('

    あい...

    ', Text::truncateByWidth('

    あいうえお

    ', 8, ['html' => true, 'ellipsis' => '...'])); + } + + /** + * Test _strlen method + */ + public function testStrlen(): void + { + $method = new ReflectionMethod(Text::class, '_strlen'); + $strlen = function () use ($method) { + return $method->invokeArgs(null, func_get_args()); + }; + + $text = 'データベースアクセス & ORM'; + $this->assertSame(20, $strlen($text, [])); + $this->assertSame(16, $strlen($text, ['html' => true])); + $this->assertSame(30, $strlen($text, ['trimWidth' => true])); + $this->assertSame(26, $strlen($text, ['html' => true, 'trimWidth' => true])); + + $text = '&undefined;'; + $this->assertSame(11, $strlen($text, [])); + $this->assertSame(11, $strlen($text, ['trimWidth' => true])); + $this->assertSame(11, $strlen($text, ['html' => true])); + $this->assertSame(11, $strlen($text, ['html' => true, 'trimWidth' => true])); + } + + /** + * Test _substr method + */ + public function testSubstr(): void + { + $method = new ReflectionMethod(Text::class, '_substr'); + $substr = function () use ($method) { + return $method->invokeArgs(null, func_get_args()); + }; + + $text = 'データベースアクセス & ORM'; + $this->assertSame('アクセス', $substr($text, 6, 4, [])); + $this->assertSame('アクセス', $substr($text, 6, 8, ['trimWidth' => true])); + $this->assertSame('アクセス', $substr($text, 6, 4, ['html' => true])); + $this->assertSame(' & ', $substr($text, 10, 7, [])); + $this->assertSame(' & ', $substr($text, 10, 7, ['trimWidth' => true])); + $this->assertSame(' & ', $substr($text, 10, 3, ['html' => true])); + $this->assertSame(' & ', $substr($text, -10, 7, [])); + $this->assertSame(' & ', $substr($text, -10, 7, ['trimWidth' => true])); + $this->assertSame(' & ', $substr($text, -6, 3, ['html' => true])); + $this->assertSame(' & ', $substr($text, -10, -3, [])); + $this->assertSame(' & ', $substr($text, -10, -3, ['trimWidth' => true])); + $this->assertSame(' & ', $substr($text, -6, -3, ['html' => true])); + $this->assertSame('ORM', $substr($text, -3, 1000, [])); + $this->assertSame('ORM', $substr($text, -3, 1000, ['trimWidth' => true])); + $this->assertSame('ORM', $substr($text, -3, 1000, ['html' => true])); + $this->assertSame('ORM', $substr($text, -3, null, [])); + $this->assertSame('ORM', $substr($text, -3, null, ['trimWidth' => true])); + $this->assertSame('ORM', $substr($text, -3, null, ['html' => true])); + $this->assertSame('データ', $substr($text, -1000, 3, [])); + $this->assertSame('データ', $substr($text, -1000, 6, ['trimWidth' => true])); + $this->assertSame('データ', $substr($text, -1000, 3, ['html' => true])); + $this->assertSame('', $substr($text, 0, 0, [])); + $this->assertSame('', $substr($text, 0, 0, ['trimWidth' => true])); + $this->assertSame('', $substr($text, 0, 0, ['html' => true])); + $this->assertSame('', $substr($text, 1000, 1, [])); + $this->assertSame('', $substr($text, 1000, 1, ['trimWidth' => true])); + $this->assertSame('', $substr($text, 1000, 1, ['html' => true])); + $this->assertSame('', $substr($text, 0, -1000, [])); + $this->assertSame('', $substr($text, 0, -1000, ['trimWidth' => true])); + $this->assertSame('', $substr($text, 0, -1000, ['html' => true])); + + // ABCDE + $text = 'ABCDE'; + $this->assertSame('BCD', $substr($text, 1, 3, ['html' => true])); + $this->assertSame('BCD', $substr($text, 1, 3, ['html' => true, 'trimWidth' => true])); + $this->assertSame('BCD', $substr($text, -4, -1, ['html' => true])); + $this->assertSame('BCD', $substr($text, -4, -1, ['html' => true, 'trimWidth' => true])); + + // あいうえお + $text = 'あいうえお'; + $this->assertSame('いうえ', $substr($text, 1, 3, ['html' => true])); + $this->assertSame('いうえ', $substr($text, 1, 6, ['html' => true, 'trimWidth' => true])); + $this->assertSame('いうえ', $substr($text, -4, -1, ['html' => true])); + $this->assertSame('いうえ', $substr($text, -4, -1, ['html' => true, 'trimWidth' => true])); + $this->assertSame('いうえ', $substr($text, -4, -2, ['html' => true, 'trimWidth' => true])); + } +} diff --git a/tests/TestCase/Utility/XmlTest.php b/tests/TestCase/Utility/XmlTest.php new file mode 100644 index 00000000000..a148496cc3f --- /dev/null +++ b/tests/TestCase/Utility/XmlTest.php @@ -0,0 +1,1245 @@ +fail('This line should not be executed because of exception above.'); + } catch (XmlException $exception) { + $cause = $exception->getPrevious(); + $this->assertNotNull($cause); + $this->assertInstanceOf(Exception::class, $cause); + } + } + + /** + * testBuild method + */ + public function testBuild(): void + { + $xml = 'value'; + $obj = Xml::build($xml); + $this->assertInstanceOf(SimpleXMLElement::class, $obj); + $this->assertSame('tag', (string)$obj->getName()); + $this->assertSame('value', (string)$obj); + + $xml = 'value'; + $this->assertEquals($obj, Xml::build($xml)); + + $obj = Xml::build($xml, ['return' => 'domdocument']); + $this->assertInstanceOf(DOMDocument::class, $obj); + $this->assertSame('tag', $obj->firstChild->nodeName); + $this->assertSame('value', $obj->firstChild->nodeValue); + + $xml = CORE_TESTS . 'Fixture/sample.xml'; + $obj = Xml::build($xml, ['readFile' => true]); + $this->assertSame('tags', $obj->getName()); + $this->assertSame(2, count($obj)); + + $this->assertEquals( + Xml::build($xml, ['readFile' => true]), + Xml::build(file_get_contents($xml)), + ); + + $obj = Xml::build($xml, ['return' => 'domdocument', 'readFile' => true]); + $this->assertSame('tags', $obj->firstChild->nodeName); + + $this->assertEquals( + Xml::build($xml, ['return' => 'domdocument', 'readFile' => true]), + Xml::build(file_get_contents($xml), ['return' => 'domdocument']), + ); + + $xml = ['tag' => 'value']; + $obj = Xml::build($xml); + $this->assertSame('tag', $obj->getName()); + $this->assertSame('value', (string)$obj); + + $obj = Xml::build($xml, ['return' => 'domdocument']); + $this->assertSame('tag', $obj->firstChild->nodeName); + $this->assertSame('value', $obj->firstChild->nodeValue); + + $obj = Xml::build($xml, ['return' => 'domdocument', 'encoding' => '']); + $this->assertDoesNotMatchRegularExpression('/encoding/', $obj->saveXML()); + } + + /** + * test build() method with huge option + */ + public function testBuildHuge(): void + { + $xml = 'value'; + $obj = Xml::build($xml, ['parseHuge' => true]); + $this->assertSame('tag', $obj->getName()); + $this->assertSame('value', (string)$obj); + } + + /** + * Test that the readFile option disables local file parsing. + */ + public function testBuildFromFileWhenDisabled(): void + { + $this->expectException(XmlException::class); + $xml = CORE_TESTS . 'Fixture/sample.xml'; + Xml::build($xml, ['readFile' => false]); + } + + /** + * Test build() with a Collection instance. + */ + public function testBuildCollection(): void + { + $xml = new Collection(['tag' => 'value']); + $obj = Xml::build($xml); + + $this->assertSame('tag', $obj->getName()); + $this->assertSame('value', (string)$obj); + + $xml = new Collection([ + 'response' => [ + 'users' => new Collection(['leonardo', 'raphael']), + ], + ]); + $obj = Xml::build($xml); + $this->assertStringContainsString('leonardo', $obj->saveXML()); + } + + /** + * Test build() with ORM\Entity instances wrapped in a Collection. + */ + public function testBuildOrmEntity(): void + { + $user = new Entity(['username' => 'mark', 'email' => 'mark@example.com']); + $xml = new Collection([ + 'response' => [ + 'users' => new Collection([$user]), + ], + ]); + $obj = Xml::build($xml); + $output = $obj->saveXML(); + $this->assertStringContainsString('mark', $output); + $this->assertStringContainsString('mark@example.com', $output); + } + + /** + * data provider function for testBuildInvalidData + * + * @return array + */ + public static function invalidDataProvider(): array + { + return [ + [''], + ['http://localhost/notthere.xml'], + ]; + } + + /** + * testBuildInvalidData + * + * @param mixed $value + */ + #[DataProvider('invalidDataProvider')] + public function testBuildInvalidData($value): void + { + $this->expectException(CakeException::class); + Xml::build($value); + } + + /** + * Test that building SimpleXmlElement with invalid XML causes the right exception. + */ + public function testBuildInvalidDataSimpleXml(): void + { + $this->expectException(XmlException::class); + $input = ' 'simplexml']); + } + + /** + * test build with a single empty tag + */ + public function testBuildEmptyTag(): void + { + try { + Xml::build(''); + $this->fail('No exception'); + } catch (Exception) { + $this->assertTrue(true, 'An exception was raised'); + } + } + + /** + * testLoadHtml method + */ + public function testLoadHtml(): void + { + $htmlFile = CORE_TESTS . 'Fixture/sample.html'; + $html = file_get_contents($htmlFile); + $paragraph = 'Browsers usually indent blockquote elements.'; + $blockquote = " +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. +"; + + $xml = Xml::loadHtml($html); + $this->assertTrue(isset($xml->body->p), 'Paragraph present'); + $this->assertSame($paragraph, (string)$xml->body->p); + $this->assertTrue(isset($xml->body->blockquote), 'Blockquote present'); + $this->assertSame($blockquote, (string)$xml->body->blockquote); + + $xml = Xml::loadHtml($html, ['parseHuge' => true]); + $this->assertTrue(isset($xml->body->p), 'Paragraph present'); + $this->assertSame($paragraph, (string)$xml->body->p); + $this->assertTrue(isset($xml->body->blockquote), 'Blockquote present'); + $this->assertSame($blockquote, (string)$xml->body->blockquote); + + $xml = Xml::loadHtml($html); + $this->assertSame($html, "\n" . $xml->asXML() . "\n"); + + $xml = Xml::loadHtml($html, ['return' => 'dom']); + $this->assertSame($html, $xml->saveHTML()); + } + + /** + * test loadHtml with a empty HTML string + */ + public function testLoadHtmlEmptyHtml(): void + { + $this->expectException(TypeError::class); + Xml::loadHtml(null); + } + + /** + * testFromArray method + */ + public function testFromArray(): void + { + $xml = ['tag' => 'value']; + $obj = Xml::fromArray($xml); + $this->assertSame('tag', $obj->getName()); + $this->assertSame('value', (string)$obj); + + $xml = ['tag' => null]; + $obj = Xml::fromArray($xml); + $this->assertSame('tag', $obj->getName()); + $this->assertSame('', (string)$obj); + + $xml = ['tag' => ['@' => 'value']]; + $obj = Xml::fromArray($xml); + $this->assertSame('tag', $obj->getName()); + $this->assertSame('value', (string)$obj); + + $xml = [ + 'tags' => [ + 'tag' => [ + [ + 'id' => '1', + 'name' => 'defect', + ], + [ + 'id' => '2', + 'name' => 'enhancement', + ], + ], + ], + ]; + $obj = Xml::fromArray($xml, ['format' => 'attributes']); + $this->assertInstanceOf(SimpleXMLElement::class, $obj); + $this->assertSame('tags', $obj->getName()); + $this->assertSame(2, count($obj)); + $xmlText = << + + + + +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $obj->asXML()); + + $obj = Xml::fromArray($xml); + $this->assertInstanceOf(SimpleXMLElement::class, $obj); + $this->assertSame('tags', $obj->getName()); + $this->assertSame(2, count($obj)); + $xmlText = << + + + 1 + defect + + + 2 + enhancement + + +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $obj->asXML()); + + $xml = [ + 'tags' => [ + ], + ]; + $obj = Xml::fromArray($xml); + $this->assertSame('tags', $obj->getName()); + $this->assertSame('', (string)$obj); + + $xml = [ + 'tags' => [ + 'bool' => true, + 'int' => 1, + 'float' => 10.2, + 'string' => 'ok', + 'null' => null, + 'array' => [], + ], + ]; + $obj = Xml::fromArray($xml, ['format' => 'tags']); + $this->assertSame(6, count($obj)); + $this->assertSame((string)$obj->bool, '1'); + $this->assertSame((string)$obj->int, '1'); + $this->assertSame((string)$obj->float, '10.2'); + $this->assertSame((string)$obj->string, 'ok'); + $this->assertSame((string)$obj->null, ''); + $this->assertSame((string)$obj->array, ''); + + $xml = [ + 'tags' => [ + 'tag' => [ + [ + '@id' => '1', + 'name' => 'defect', + ], + [ + '@id' => '2', + 'name' => 'enhancement', + ], + ], + ], + ]; + $obj = Xml::fromArray($xml, ['format' => 'tags']); + $xmlText = << + + + defect + + + enhancement + + +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $obj->asXML()); + + $xml = [ + 'tags' => [ + 'tag' => [ + [ + '@id' => '1', + 'name' => 'defect', + '@' => 'Tag 1', + ], + [ + '@id' => '2', + 'name' => 'enhancement', + ], + ], + '@' => 'All tags', + ], + ]; + $obj = Xml::fromArray($xml, ['format' => 'tags']); + $xmlText = << +All tagsTag 1defectenhancement +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $obj->asXML()); + + $xml = [ + 'tags' => [ + 'tag' => [ + 'id' => 1, + '@' => 'defect', + ], + ], + ]; + $obj = Xml::fromArray($xml, ['format' => 'attributes']); + $xmlText = '<' . '?xml version="1.0" encoding="UTF-8"?>defect'; + $this->assertXmlStringEqualsXmlString($xmlText, $obj->asXML()); + } + + /** + * Test fromArray() with zero values. + */ + public function testFromArrayZeroValue(): void + { + $xml = [ + 'tag' => [ + '@' => 0, + '@test' => 'A test', + ], + ]; + $obj = Xml::fromArray($xml); + $xmlText = << +0 +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $obj->asXML()); + + $xml = [ + 'tag' => ['0'], + ]; + $obj = Xml::fromArray($xml); + $xmlText = << +0 +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $obj->asXML()); + } + + /** + * Test non-sequential keys in list types. + */ + public function testFromArrayNonSequentialKeys(): void + { + $xmlArray = [ + 'Event' => [ + [ + 'id' => '235', + 'Attribute' => [ + 0 => [ + 'id' => '9646', + ], + 2 => [ + 'id' => '9647', + ], + ], + ], + ], + ]; + $obj = Xml::fromArray($xmlArray); + $expected = << + + 235 + + 9646 + + + 9647 + + +XML; + $this->assertXmlStringEqualsXmlString($expected, $obj->asXML()); + } + + /** + * testFromArrayPretty method + */ + public function testFromArrayPretty(): void + { + $xml = [ + 'tags' => [ + 'tag' => [ + [ + 'id' => '1', + 'name' => 'defect', + ], + [ + 'id' => '2', + 'name' => 'enhancement', + ], + ], + ], + ]; + + $expected = << +1defect2enhancement + +XML; + $xmlResponse = Xml::fromArray($xml, ['pretty' => false]); + $this->assertTextEquals($expected, $xmlResponse->asXML()); + + $expected = << + + + 1 + defect + + + 2 + enhancement + + + +XML; + $xmlResponse = Xml::fromArray($xml, ['pretty' => true]); + $this->assertTextEquals($expected, $xmlResponse->asXML()); + + $xml = [ + 'tags' => [ + 'tag' => [ + [ + 'id' => '1', + 'name' => 'defect', + ], + [ + 'id' => '2', + 'name' => 'enhancement', + ], + ], + ], + ]; + + $expected = << + + +XML; + $xmlResponse = Xml::fromArray($xml, ['pretty' => false, 'format' => 'attributes']); + $this->assertTextEquals($expected, $xmlResponse->asXML()); + + $expected = << + + + + + +XML; + $xmlResponse = Xml::fromArray($xml, ['pretty' => true, 'format' => 'attributes']); + $this->assertTextEquals($expected, $xmlResponse->asXML()); + } + + /** + * data provider for fromArray() failures + * + * @return array + */ + public static function invalidArrayDataProvider(): array + { + return [ + [[]], + [['numeric key as root']], + [['item1' => '', 'item2' => '']], + [['items' => ['item1', 'item2']]], + [[ + 'tags' => [ + 'tag' => [ + [ + [ + 'string', + ], + ], + ], + ], + ]], + [[ + 'tags' => [ + '@tag' => [ + [ + '@id' => '1', + 'name' => 'defect', + ], + [ + '@id' => '2', + 'name' => 'enhancement', + ], + ], + ], + ]], + [new DateTime()], + ]; + } + + /** + * testFromArrayFail method + * + * @param mixed $value + */ + #[DataProvider('invalidArrayDataProvider')] + public function testFromArrayFail($value): void + { + $this->expectException(Exception::class); + Xml::fromArray($value); + } + + /** + * Test that there are not unterminated errors when building XML + */ + public function testFromArrayUnterminatedError(): void + { + $data = [ + 'product_ID' => 'GENERT-DL', + 'deeplink' => 'http://example.com/deep', + 'image_URL' => 'http://example.com/image', + 'thumbnail_image_URL' => 'http://example.com/thumb', + 'brand' => 'Malte Lange & Co', + 'availability' => 'in stock', + 'authors' => [ + 'author' => ['Malte Lange & Co'], + ], + ]; + $xml = Xml::fromArray(['products' => $data], ['format' => 'tags']); + $expected = << + + GENERT-DL + http://example.com/deep + http://example.com/image + http://example.com/thumb + Malte Lange & Co + in stock + + Malte Lange & Co + + +XML; + $this->assertXmlStringEqualsXmlString($expected, $xml->asXML()); + } + + /** + * testToArray method + */ + public function testToArray(): void + { + $xml = 'name'; + $obj = Xml::build($xml); + $this->assertSame(['tag' => 'name'], Xml::toArray($obj)); + + $xml = CORE_TESTS . 'Fixture/sample.xml'; + $obj = Xml::build($xml, ['readFile' => true]); + $expected = [ + 'tags' => [ + 'tag' => [ + [ + '@id' => '1', + 'name' => 'defect', + ], + [ + '@id' => '2', + 'name' => 'enhancement', + ], + ], + ], + ]; + $this->assertSame($expected, Xml::toArray($obj)); + + $array = [ + 'tags' => [ + 'tag' => [ + [ + 'id' => '1', + 'name' => 'defect', + ], + [ + 'id' => '2', + 'name' => 'enhancement', + ], + ], + ], + ]; + $this->assertSame(Xml::toArray(Xml::fromArray($array, ['format' => 'tags'])), $array); + + $expected = [ + 'tags' => [ + 'tag' => [ + [ + '@id' => '1', + '@name' => 'defect', + ], + [ + '@id' => '2', + '@name' => 'enhancement', + ], + ], + ], + ]; + $this->assertSame($expected, Xml::toArray(Xml::fromArray($array, ['format' => 'attributes']))); + $this->assertSame($expected, Xml::toArray(Xml::fromArray($array, ['return' => 'domdocument', 'format' => 'attributes']))); + $this->assertSame(Xml::toArray(Xml::fromArray($array)), $array); + $this->assertSame(Xml::toArray(Xml::fromArray($array, ['return' => 'domdocument'])), $array); + + $array = [ + 'tags' => [ + 'tag' => [ + 'id' => '1', + 'posts' => [ + ['id' => '1'], + ['id' => '2'], + ], + ], + 'tagOther' => [ + 'subtag' => [ + 'id' => '1', + ], + ], + ], + ]; + $expected = [ + 'tags' => [ + 'tag' => [ + '@id' => '1', + 'posts' => [ + ['@id' => '1'], + ['@id' => '2'], + ], + ], + 'tagOther' => [ + 'subtag' => [ + '@id' => '1', + ], + ], + ], + ]; + $this->assertSame($expected, Xml::toArray(Xml::fromArray($array, ['format' => 'attributes']))); + $this->assertSame($expected, Xml::toArray(Xml::fromArray($array, ['format' => 'attributes', 'return' => 'domdocument']))); + + $xml = << +defect + +XML; + $obj = Xml::build($xml); + + $expected = [ + 'root' => [ + 'tag' => [ + '@id' => '1', + '@' => 'defect', + ], + ], + ]; + $this->assertSame($expected, Xml::toArray($obj)); + + $xml = << +
    ApplesBananas
    + CakePHPMIT
    + The book is on the table.
    + +XML; + $obj = Xml::build($xml); + + $expected = [ + 'root' => [ + 'table' => [ + ['tr' => ['td' => ['Apples', 'Bananas']]], + ['name' => 'CakePHP', 'license' => 'MIT'], + 'The book is on the table.', + ], + ], + ]; + $this->assertSame($expected, Xml::toArray($obj)); + + $xml = << +defect +1 + +XML; + $obj = Xml::build($xml); + + $expected = [ + 'root' => [ + 'tag' => 'defect', + 'cake:bug' => '1', + ], + ]; + $this->assertSame($expected, Xml::toArray($obj)); + + $xml = '0'; + $obj = Xml::build($xml); + $expected = [ + 'tag' => [ + '@type' => 'myType', + '@' => '0', + ], + ]; + $this->assertSame($expected, Xml::toArray($obj)); + } + + /** + * testRss + */ + public function testRss(): void + { + $rss = file_get_contents(CORE_TESTS . 'Fixture/rss.xml'); + $rssAsArray = Xml::toArray(Xml::build($rss)); + $this->assertSame('2.0', $rssAsArray['rss']['@version']); + $this->assertCount(2, $rssAsArray['rss']['channel']['item']); + + $atomLink = ['@href' => 'http://bakery.cakephp.org/articles/rss', '@rel' => 'self', '@type' => 'application/rss+xml']; + $this->assertSame($rssAsArray['rss']['channel']['atom:link'], $atomLink); + $this->assertSame('http://bakery.cakephp.org/', $rssAsArray['rss']['channel']['link']); + + $expected = [ + 'title' => 'Alertpay automated sales via IPN', + 'link' => 'http://bakery.cakephp.org/articles/view/alertpay-automated-sales-via-ipn', + 'description' => "I'm going to show you how I implemented a payment module via the Alertpay payment processor.", + 'pubDate' => 'Tue, 31 Aug 2010 01:42:00 -0500', + 'guid' => 'http://bakery.cakephp.org/articles/view/alertpay-automated-sales-via-ipn', + ]; + $this->assertSame($expected, $rssAsArray['rss']['channel']['item'][1]); + + $rss = [ + 'rss' => [ + 'xmlns:atom' => 'http://www.w3.org/2005/Atom', + '@version' => '2.0', + 'channel' => [ + 'atom:link' => [ + '@href' => 'http://bakery.cakephp.org/articles/rss', + '@rel' => 'self', + '@type' => 'application/rss+xml', + ], + 'title' => 'The Bakery: ', + 'link' => 'http://bakery.cakephp.org/', + 'description' => 'Recent Articles at The Bakery.', + 'pubDate' => 'Sun, 12 Sep 2010 04:18:26 -0500', + 'item' => [ + [ + 'title' => 'CakePHP 1.3.4 released', + 'link' => 'http://bakery.cakephp.org/articles/view/cakephp-1-3-4-released', + ], + [ + 'title' => 'Wizard Component 1.2 Tutorial', + 'link' => 'http://bakery.cakephp.org/articles/view/wizard-component-1-2-tutorial', + ], + ], + ], + ], + ]; + $rssAsSimpleXML = Xml::fromArray($rss); + $xmlText = << + + + + The Bakery: + http://bakery.cakephp.org/ + Recent Articles at The Bakery. + Sun, 12 Sep 2010 04:18:26 -0500 + + CakePHP 1.3.4 released + http://bakery.cakephp.org/articles/view/cakephp-1-3-4-released + + + Wizard Component 1.2 Tutorial + http://bakery.cakephp.org/articles/view/wizard-component-1-2-tutorial + + + +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $rssAsSimpleXML->asXML()); + } + + /** + * testXmlRpc + */ + public function testXmlRpc(): void + { + $xml = Xml::build('test'); + $expected = [ + 'methodCall' => [ + 'methodName' => 'test', + 'params' => '', + ], + ]; + $this->assertSame($expected, Xml::toArray($xml)); + + $xml = Xml::build('test12Egypt0-31'); + $expected = [ + 'methodCall' => [ + 'methodName' => 'test', + 'params' => [ + 'param' => [ + 'value' => [ + 'array' => [ + 'data' => [ + 'value' => [ + ['int' => '12'], + ['string' => 'Egypt'], + ['boolean' => '0'], + ['int' => '-31'], + ], + ], + ], + ], + ], + ], + ], + ]; + $this->assertSame($expected, Xml::toArray($xml)); + + $xmlText = << + + + + + + + + 1 + + + testing + + + + + + + +XML; + $xml = Xml::build($xmlText); + $expected = [ + 'methodResponse' => [ + 'params' => [ + 'param' => [ + 'value' => [ + 'array' => [ + 'data' => [ + 'value' => [ + ['int' => '1'], + ['string' => 'testing'], + ], + ], + ], + ], + ], + ], + ], + ]; + $this->assertSame($expected, Xml::toArray($xml)); + + $xml = Xml::fromArray($expected, ['format' => 'tags']); + $this->assertXmlStringEqualsXmlString($xmlText, $xml->asXML()); + } + + /** + * testSoap + */ + public function testSoap(): void + { + $xmlRequest = Xml::build(CORE_TESTS . 'Fixture/soap_request.xml', ['readFile' => true]); + $expected = [ + 'Envelope' => [ + '@soap:encodingStyle' => 'http://www.w3.org/2001/12/soap-encoding', + 'soap:Body' => [ + 'm:GetStockPrice' => [ + 'm:StockName' => 'IBM', + ], + ], + ], + ]; + $this->assertSame($expected, Xml::toArray($xmlRequest)); + + $xmlResponse = Xml::build(CORE_TESTS . DS . 'Fixture/soap_response.xml', ['readFile' => true]); + $expected = [ + 'Envelope' => [ + '@soap:encodingStyle' => 'http://www.w3.org/2001/12/soap-encoding', + 'soap:Body' => [ + 'm:GetStockPriceResponse' => [ + 'm:Price' => '34.5', + ], + ], + ], + ]; + $this->assertSame($expected, Xml::toArray($xmlResponse)); + + $xml = [ + 'soap:Envelope' => [ + 'xmlns:soap' => 'http://www.w3.org/2001/12/soap-envelope', + '@soap:encodingStyle' => 'http://www.w3.org/2001/12/soap-encoding', + 'soap:Body' => [ + 'xmlns:m' => 'http://www.example.org/stock', + 'm:GetStockPrice' => [ + 'm:StockName' => 'IBM', + ], + ], + ], + ]; + $xmlRequest = Xml::fromArray($xml, ['encoding' => '']); + $xmlText = << + + + + IBM + + + +XML; + $this->assertXmlStringEqualsXmlString($xmlText, $xmlRequest->asXML()); + } + + /** + * testNamespace + */ + public function testNamespace(): void + { + $xml = << + + good + bad + + Tag without ns + +XML; + $xmlResponse = Xml::build($xml); + $expected = [ + 'root' => [ + 'ns:tag' => [ + '@id' => '1', + 'child' => 'good', + 'otherchild' => 'bad', + ], + 'tag' => 'Tag without ns', + ], + ]; + $this->assertEquals($expected, Xml::toArray($xmlResponse)); + + $xmlResponse = Xml::build('1'); + $expected = [ + 'root' => [ + 'ns:tag' => [ + '@id' => '1', + ], + 'tag' => [ + 'id' => '1', + ], + ], + ]; + $this->assertEquals($expected, Xml::toArray($xmlResponse)); + + $xmlResponse = Xml::build('1'); + $expected = [ + 'root' => [ + 'ns:attr' => '1', + ], + ]; + $this->assertSame($expected, Xml::toArray($xmlResponse)); + + $xmlResponse = Xml::build('1'); + $this->assertSame($expected, Xml::toArray($xmlResponse)); + + $xml = [ + 'root' => [ + 'ns:attr' => [ + 'xmlns:ns' => 'http://cakephp.org', + '@' => 1, + ], + ], + ]; + $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>1'; + $xmlResponse = Xml::fromArray($xml); + $this->assertSame($expected, str_replace(["\r", "\n"], '', $xmlResponse->asXML())); + + $xml = [ + 'root' => [ + 'tag' => [ + 'xmlns:pref' => 'http://cakephp.org', + 'pref:item' => [ + 'item 1', + 'item 2', + ], + ], + ], + ]; + $expected = << + + + item 1 + item 2 + + +XML; + $xmlResponse = Xml::fromArray($xml); + $this->assertXmlStringEqualsXmlString($expected, $xmlResponse->asXML()); + + $xml = [ + 'root' => [ + 'tag' => [ + 'xmlns:' => 'http://cakephp.org', + ], + ], + ]; + $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; + $xmlResponse = Xml::fromArray($xml); + $this->assertXmlStringEqualsXmlString($expected, $xmlResponse->asXML()); + + $xml = [ + 'root' => [ + 'xmlns:' => 'http://cakephp.org', + ], + ]; + $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; + $xmlResponse = Xml::fromArray($xml); + $this->assertXmlStringEqualsXmlString($expected, $xmlResponse->asXML()); + + $xml = [ + 'root' => [ + 'xmlns:ns' => 'http://cakephp.org', + ], + ]; + $expected = '<' . '?xml version="1.0" encoding="UTF-8"?>'; + $xmlResponse = Xml::fromArray($xml); + $this->assertXmlStringEqualsXmlString($expected, $xmlResponse->asXML()); + } + + /** + * Tests that Enums don't blow up SimpleXml. + */ + public function testEnum(): void + { + $xml = [ + 'root' => [ + 'tag' => [ + 'xmlns:pref' => 'http://cakephp.org', + 'backed-int' => Priority::Medium, + 'backend-string' => ArticleStatus::Published, + 'non-backed' => NonBacked::Basic, + ], + ], + ]; + $expected = << + + + 2 + Y + Basic + + +XML; + $xmlResponse = Xml::fromArray($xml); + $this->assertXmlStringEqualsXmlString($expected, $xmlResponse->asXML()); + } + + /** + * test that CDATA blocks don't get screwed up by SimpleXml + */ + public function testCdata(): void + { + $xml = '<' . '?xml version="1.0" encoding="UTF-8"?>' . + ''; + + $result = Xml::build($xml); + $this->assertSame(' Mark ', (string)$result->name); + } + + /** + * Test ampersand in text elements. + */ + public function testAmpInText(): void + { + $data = [ + 'outer' => [ + 'inner' => ['name' => 'mark & mark'], + ], + ]; + $obj = Xml::build($data); + $result = $obj->asXml(); + $this->assertStringContainsString('mark & mark', $result); + } + + /** + * Test that entity loading is disabled by default. + */ + public function testNoEntityLoading(): void + { + $file = str_replace(' ', '%20', CAKE . 'VERSION.txt'); + $xml = <<]> + + &payload; + +XML; + $result = Xml::build($xml); + $this->assertSame('', (string)$result->xxe); + } + + /** + * Test building Xml with valid class-name in value. + * + * @see https://github.com/cakephp/cakephp/pull/9754 + */ + public function testClassnameInValueRegressionTest(): void + { + $classname = self::class; // Will always be a valid class name + $data = [ + 'outer' => [ + 'inner' => $classname, + ], + ]; + $obj = Xml::build($data); + $result = $obj->asXml(); + $this->assertStringContainsString('' . $classname . '', $result); + } + + /** + * Needed function for testClassnameInValueRegressionTest. + * + * @ignore + * @return array + */ + public function toArray(): array + { + return []; + } +} diff --git a/tests/TestCase/Validation/NoI18nValidator.php b/tests/TestCase/Validation/NoI18nValidator.php new file mode 100644 index 00000000000..5864ab62823 --- /dev/null +++ b/tests/TestCase/Validation/NoI18nValidator.php @@ -0,0 +1,19 @@ +deprecated(function (): void { + $provider = new RulesProvider(); + $this->assertTrue($provider->extension('foo.jpg', compact('provider'))); + $this->assertFalse($provider->extension('foo.jpg', ['png'], compact('provider'))); + }); + } + + /** + * Tests that it is possible to use a custom object as the provider to + * be decorated + */ + public function testCustomObject(): void + { + $this->deprecated(function (): void { + $object = new CustomProvider(); + + /** @var \TestApp\Validation\CustomProvider|\Cake\Validation\RulesProvider $provider */ + $provider = new RulesProvider($object); + $this->assertFalse($provider->validate('string', 'context')); + }); + } +} diff --git a/tests/TestCase/Validation/ValidationRuleTest.php b/tests/TestCase/Validation/ValidationRuleTest.php new file mode 100644 index 00000000000..426b5835a2d --- /dev/null +++ b/tests/TestCase/Validation/ValidationRuleTest.php @@ -0,0 +1,201 @@ + + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests + * @since 2.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Validation; + +use Cake\Core\Exception\CakeException; +use Cake\TestSuite\TestCase; +use Cake\Validation\ValidationRule; +use Cake\Validation\ValidationSet; +use Error; + +/** + * ValidationRuleTest + */ +class ValidationRuleTest extends TestCase +{ + /** + * Auxiliary method to test custom validators + */ + public function willFail(): bool + { + return false; + } + + /** + * Auxiliary method to test custom validators + */ + public function willPass(): bool + { + return true; + } + + /** + * Auxiliary method to test custom validators + */ + public function willFail3(): string + { + return 'string'; + } + + /** + * tests that passing custom validation methods work + */ + public function testCustomMethods(): void + { + $data = 'some data'; + $providers = ['default' => $this]; + + $context = ['newRecord' => true]; + $Rule = new ValidationRule(['rule' => 'willFail']); + $this->assertFalse($Rule->process($data, $providers, $context)); + + $Rule = new ValidationRule(['rule' => 'willPass', 'pass' => ['key' => 'value']]); + $this->assertTrue($Rule->process($data, $providers, $context)); + + $Rule = new ValidationRule(['rule' => 'willFail3']); + $this->assertSame('string', $Rule->process($data, $providers, $context)); + + $Rule = new ValidationRule(['rule' => 'willFail', 'message' => 'foo']); + $this->assertSame('foo', $Rule->process($data, $providers, $context)); + } + + /** + * Test using a custom validation method with no provider declared. + */ + public function testCustomMethodNoProvider(): void + { + $data = 'some data'; + $context = ['field' => 'custom', 'newRecord' => true]; + $providers = ['default' => '']; + + $rule = new ValidationRule([ + 'rule' => $this->willFail(...), + ]); + $this->assertFalse($rule->process($data, $providers, $context)); + } + + /** + * Make sure errors are triggered when validation is missing. + */ + public function testCustomMethodMissingError(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage('Call to undefined method Cake\Test\TestCase\Validation\ValidationRuleTest::totallyMissing()'); + $def = ['rule' => ['totallyMissing']]; + $data = 'some data'; + $providers = ['default' => $this]; + + $Rule = new ValidationRule($def); + $Rule->process($data, $providers, ['newRecord' => true, 'field' => 'test']); + } + + /** + * Tests that a rule can be skipped + */ + public function testSkip(): void + { + $data = 'some data'; + $providers = ['default' => $this]; + + $Rule = new ValidationRule([ + 'rule' => 'willFail', + 'on' => 'create', + ]); + $this->assertFalse($Rule->process($data, $providers, ['newRecord' => true])); + + $Rule = new ValidationRule([ + 'rule' => 'willFail', + 'on' => 'update', + ]); + $this->assertTrue($Rule->process($data, $providers, ['newRecord' => true])); + + $Rule = new ValidationRule([ + 'rule' => 'willFail', + 'on' => 'update', + ]); + $this->assertFalse($Rule->process($data, $providers, ['newRecord' => false])); + } + + /** + * Tests that the 'on' key can be a callable function + */ + public function testCallableOn(): void + { + $data = 'some data'; + $providers = ['default' => $this]; + + $Rule = new ValidationRule([ + 'rule' => 'willFail', + 'on' => function ($context) use ($providers) { + $expected = compact('providers') + ['newRecord' => true, 'data' => []]; + $this->assertEquals($expected, $context); + + return true; + }, + ]); + $this->assertFalse($Rule->process($data, $providers, ['newRecord' => true])); + + $Rule = new ValidationRule([ + 'rule' => 'willFail', + 'on' => function ($context) use ($providers) { + $expected = compact('providers') + ['newRecord' => true, 'data' => []]; + $this->assertEquals($expected, $context); + + return false; + }, + ]); + $this->assertTrue($Rule->process($data, $providers, ['newRecord' => true])); + } + + /** + * testGet + */ + public function testGet(): void + { + $Rule = new ValidationRule(['rule' => 'willFail', 'message' => 'foo']); + + $this->assertSame('willFail', $Rule->get('rule')); + $this->assertSame('foo', $Rule->get('message')); + $this->assertSame('default', $Rule->get('provider')); + $this->assertEquals([], $Rule->get('pass')); + $this->assertNull($Rule->get('nonexistent')); + + $Rule = new ValidationRule(['rule' => ['willPass', 'param'], 'message' => 'bar']); + + $this->assertSame('willPass', $Rule->get('rule')); + $this->assertSame('bar', $Rule->get('message')); + $this->assertEquals(['param'], $Rule->get('pass')); + } + + public function testAddDuplicateName(): void + { + $rules = new ValidationSet(); + $rules->add('myUniqueName', ['rule' => fn() => false]); + + $this->expectException(CakeException::class); + $rules->add('myUniqueName', ['rule' => fn() => true]); + } + + public function testHasName(): void + { + $rules = new ValidationSet(); + $rules->add('myUniqueName', ['rule' => fn() => false]); + + $this->assertTrue($rules->has('myUniqueName')); + $this->assertFalse($rules->has('myMadeUpName')); + } +} diff --git a/tests/TestCase/Validation/ValidationSetTest.php b/tests/TestCase/Validation/ValidationSetTest.php new file mode 100644 index 00000000000..19755a6fb5c --- /dev/null +++ b/tests/TestCase/Validation/ValidationSetTest.php @@ -0,0 +1,226 @@ + + * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license infValidationation, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org) + * @link https://book.cakephp.org/view/1196/Testing CakePHP(tm) Tests + * @since 2.2.0 + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\Validation; + +use Cake\TestSuite\TestCase; +use Cake\Validation\ValidationRule; +use Cake\Validation\ValidationSet; + +/** + * ValidationSetTest + */ +class ValidationSetTest extends TestCase +{ + /** + * testGetRule method + */ + public function testGetRule(): void + { + $field = new ValidationSet(); + $field->add('notBlank', ['rule' => 'notBlank', 'message' => 'Can not be empty']); + $result = $field->rule('notBlank'); + $this->assertInstanceOf(ValidationRule::class, $result); + $expected = new ValidationRule(['rule' => 'notBlank', 'message' => 'Can not be empty']); + $this->assertEquals($expected, $result); + } + + /** + * testGetRules method + */ + public function testGetRules(): void + { + $field = new ValidationSet(); + $field->add('notBlank', ['rule' => 'notBlank', 'message' => 'Can not be empty']); + + $result = $field->rules(); + $this->assertEquals(['notBlank'], array_keys($result)); + $this->assertInstanceOf(ValidationRule::class, $result['notBlank']); + } + + /** + * Tests getting a rule from the set using array access + */ + public function testArrayAccessGet(): void + { + $set = (new ValidationSet()) + ->add('notBlank', ['rule' => 'notBlank']) + ->add('numeric', ['rule' => 'numeric']) + ->add('other', ['rule' => 'email']); + + $rule = $set['notBlank']; + $this->assertInstanceOf(ValidationRule::class, $rule); + $this->assertEquals(new ValidationRule(['rule' => 'notBlank']), $rule); + + $rule = $set['numeric']; + $this->assertInstanceOf(ValidationRule::class, $rule); + $this->assertEquals(new ValidationRule(['rule' => 'numeric']), $rule); + + $rule = $set['other']; + $this->assertInstanceOf(ValidationRule::class, $rule); + $this->assertEquals(new ValidationRule(['rule' => 'email']), $rule); + } + + /** + * Tests checking a rule from the set using array access + */ + public function testArrayAccessExists(): void + { + $set = (new ValidationSet()) + ->add('notBlank', ['rule' => 'notBlank']) + ->add('numeric', ['rule' => 'numeric']) + ->add('other', ['rule' => 'email']); + + $this->assertArrayHasKey('notBlank', $set); + $this->assertArrayHasKey('numeric', $set); + $this->assertArrayHasKey('other', $set); + $this->assertArrayNotHasKey('fail', $set); + } + + /** + * Tests setting a rule in the set using array access + */ + public function testArrayAccessSet(): void + { + $set = (new ValidationSet()) + ->add('notBlank', ['rule' => 'notBlank']); + + $this->assertArrayNotHasKey('other', $set); + $set['other'] = ['rule' => 'email']; + $rule = $set['other']; + $this->assertInstanceOf(ValidationRule::class, $rule); + $this->assertEquals(new ValidationRule(['rule' => 'email']), $rule); + } + + /** + * Tests unseting a rule from the set using array access + */ + public function testArrayAccessUnset(): void + { + $set = (new ValidationSet()) + ->add('notBlank', ['rule' => 'notBlank']) + ->add('numeric', ['rule' => 'numeric']) + ->add('other', ['rule' => 'email']); + + unset($set['notBlank']); + $this->assertArrayNotHasKey('notBlank', $set); + + unset($set['numeric']); + $this->assertArrayNotHasKey('numeric', $set); + + unset($set['other']); + $this->assertArrayNotHasKey('other', $set); + } + + /** + * Tests it is possible to iterate a validation set object + */ + public function testIterator(): void + { + $set = (new ValidationSet()) + ->add('notBlank', ['rule' => 'notBlank']) + ->add('numeric', ['rule' => 'numeric']) + ->add('other', ['rule' => 'email']); + + $i = 0; + foreach ($set as $name => $rule) { + if ($i === 0) { + $this->assertSame('notBlank', $name); + } + if ($i === 1) { + $this->assertSame('numeric', $name); + } + if ($i === 2) { + $this->assertSame('other', $name); + } + $this->assertInstanceOf(ValidationRule::class, $rule); + $i++; + } + $this->assertSame(3, $i); + } + + /** + * Tests countable interface + */ + public function testCount(): void + { + $set = (new ValidationSet()) + ->add('notBlank', ['rule' => 'notBlank']) + ->add('numeric', ['rule' => 'numeric']) + ->add('other', ['rule' => 'email']); + $this->assertCount(3, $set); + + unset($set['other']); + $this->assertCount(2, $set); + } + + /** + * Test removeRule method + */ + public function testRemoveRule(): void + { + $set = (new ValidationSet()) + ->add('notBlank', ['rule' => 'notBlank']) + ->add('numeric', ['rule' => 'numeric']) + ->add('other', ['rule' => 'email']); + + $this->assertArrayHasKey('notBlank', $set); + $set->remove('notBlank'); + $this->assertArrayNotHasKey('notBlank', $set); + + $this->assertArrayHasKey('numeric', $set); + $set->remove('numeric'); + $this->assertArrayNotHasKey('numeric', $set); + + $this->assertArrayHasKey('other', $set); + $set->remove('other'); + $this->assertArrayNotHasKey('other', $set); + } + + /** + * Test requirePresence and isPresenceRequired methods + */ + public function testRequirePresence(): void + { + $set = new ValidationSet(); + + $this->assertFalse($set->isPresenceRequired()); + + $set->requirePresence(true); + $this->assertTrue($set->isPresenceRequired()); + + $set->requirePresence(false); + $this->assertFalse($set->isPresenceRequired()); + } + + /** + * Test allowEmpty and isEmptyAllowed methods + */ + public function testAllowEmpty(): void + { + $set = new ValidationSet(); + + $this->assertFalse($set->isEmptyAllowed()); + + $set->allowEmpty(true); + $this->assertTrue($set->isEmptyAllowed()); + + $set->allowEmpty(false); + $this->assertFalse($set->isEmptyAllowed()); + } +} diff --git a/tests/TestCase/Validation/ValidationTest.php b/tests/TestCase/Validation/ValidationTest.php new file mode 100644 index 00000000000..ff757e1d0b2 --- /dev/null +++ b/tests/TestCase/Validation/ValidationTest.php @@ -0,0 +1,3207 @@ +assertTrue(Validation::notBlank('abcdefg')); + $this->assertTrue(Validation::notBlank('fasdf ')); + $this->assertTrue(Validation::notBlank('fooo' . chr(243) . 'blabla')); + $this->assertTrue(Validation::notBlank('abçďĕʑʘπй')); + $this->assertTrue(Validation::notBlank('José')); + $this->assertTrue(Validation::notBlank('é')); + $this->assertTrue(Validation::notBlank('π')); + $this->assertTrue(Validation::notBlank('0')); + $this->assertTrue(Validation::notBlank(0)); + $this->assertTrue(Validation::notBlank(0.0)); + $this->assertTrue(Validation::notBlank('0.0')); + $this->assertFalse(Validation::notBlank("\t ")); + $this->assertFalse(Validation::notBlank('')); + } + + /** + * testNotEmptyISO88591Encoding method + */ + public function testNotBlankIso88591AppEncoding(): void + { + Configure::write('App.encoding', 'ISO-8859-1'); + $this->assertTrue(Validation::notBlank('abcdefg')); + $this->assertTrue(Validation::notBlank('fasdf ')); + $this->assertTrue(Validation::notBlank('fooo' . chr(243) . 'blabla')); + $this->assertTrue(Validation::notBlank('abçďĕʑʘπй')); + $this->assertTrue(Validation::notBlank('José')); + $this->assertTrue(Validation::notBlank(mb_convert_encoding('José', 'ISO-8859-1', 'UTF-8'))); + $this->assertFalse(Validation::notBlank("\t ")); + $this->assertFalse(Validation::notBlank('')); + } + + /** + * testAlphaNumeric method + */ + public function testAlphaNumeric(): void + { + $this->assertTrue(Validation::alphaNumeric('frferrf')); + $this->assertTrue(Validation::alphaNumeric('12234')); + $this->assertTrue(Validation::alphaNumeric('1w2e2r3t4y')); + $this->assertTrue(Validation::alphaNumeric('0')); + $this->assertTrue(Validation::alphaNumeric('abçďĕʑʘπй')); + $this->assertTrue(Validation::alphaNumeric('ˇˆๆゞ')); + $this->assertTrue(Validation::alphaNumeric('אกあアꀀ豈')); + $this->assertTrue(Validation::alphaNumeric('Džᾈᾨ')); + $this->assertTrue(Validation::alphaNumeric('ÆΔΩЖÇ')); + + $this->assertFalse(Validation::alphaNumeric('12 234')); + $this->assertFalse(Validation::alphaNumeric('dfd 234')); + $this->assertFalse(Validation::alphaNumeric("0\n")); + $this->assertFalse(Validation::alphaNumeric("\n")); + $this->assertFalse(Validation::alphaNumeric("\t")); + $this->assertFalse(Validation::alphaNumeric("\r")); + $this->assertFalse(Validation::alphaNumeric(' ')); + $this->assertFalse(Validation::alphaNumeric('')); + } + + /** + * testAlphaNumericPassedAsArray method + */ + public function testAlphaNumericPassedAsArray(): void + { + $this->assertFalse(Validation::alphaNumeric(['foo'])); + } + + /** + * testNotAlphaNumeric method + */ + public function testNotAlphaNumeric(): void + { + $this->assertFalse(Validation::notAlphaNumeric('frferrf')); + $this->assertFalse(Validation::notAlphaNumeric('12234')); + $this->assertFalse(Validation::notAlphaNumeric('1w2e2r3t4y')); + $this->assertFalse(Validation::notAlphaNumeric('0')); + $this->assertFalse(Validation::notAlphaNumeric('abçďĕʑʘπй')); + $this->assertFalse(Validation::notAlphaNumeric('ˇˆๆゞ')); + $this->assertFalse(Validation::notAlphaNumeric('אกあアꀀ豈')); + $this->assertFalse(Validation::notAlphaNumeric('Džᾈᾨ')); + $this->assertFalse(Validation::notAlphaNumeric('ÆΔΩЖÇ')); + + $this->assertTrue(Validation::notAlphaNumeric('12 234')); + $this->assertTrue(Validation::notAlphaNumeric('dfd 234')); + $this->assertTrue(Validation::notAlphaNumeric("0\n")); + $this->assertTrue(Validation::notAlphaNumeric("\n")); + $this->assertTrue(Validation::notAlphaNumeric("\t")); + $this->assertTrue(Validation::notAlphaNumeric("\r")); + $this->assertTrue(Validation::notAlphaNumeric(' ')); + $this->assertTrue(Validation::notAlphaNumeric('')); + } + + /** + * testAsciiAlphaNumeric method + */ + public function testAsciiAlphaNumeric(): void + { + $this->assertTrue(Validation::asciiAlphaNumeric('frferrf')); + $this->assertTrue(Validation::asciiAlphaNumeric('12234')); + $this->assertTrue(Validation::asciiAlphaNumeric('1w2e2r3t4y')); + $this->assertTrue(Validation::asciiAlphaNumeric('0')); + + $this->assertFalse(Validation::asciiAlphaNumeric('1 two')); + $this->assertFalse(Validation::asciiAlphaNumeric('abçďĕʑʘπй')); + $this->assertFalse(Validation::asciiAlphaNumeric('ˇˆๆゞ')); + $this->assertFalse(Validation::asciiAlphaNumeric('אกあアꀀ豈')); + $this->assertFalse(Validation::asciiAlphaNumeric('Džᾈᾨ')); + $this->assertFalse(Validation::asciiAlphaNumeric('ÆΔΩЖÇ')); + $this->assertFalse(Validation::asciiAlphaNumeric('12 234')); + $this->assertFalse(Validation::asciiAlphaNumeric('dfd 234')); + $this->assertFalse(Validation::asciiAlphaNumeric("\n")); + $this->assertFalse(Validation::asciiAlphaNumeric("\t")); + $this->assertFalse(Validation::asciiAlphaNumeric("\r")); + $this->assertFalse(Validation::asciiAlphaNumeric(' ')); + $this->assertFalse(Validation::asciiAlphaNumeric('')); + } + + /** + * testAlphaNumericPassedAsArray method + */ + public function testAsciiAlphaNumericPassedAsArray(): void + { + $this->assertFalse(Validation::asciiAlphaNumeric(['foo'])); + } + + /** + * testNotAlphaNumeric method + */ + public function testNotAsciiAlphaNumeric(): void + { + $this->assertFalse(Validation::notAsciiAlphaNumeric('frferrf')); + $this->assertFalse(Validation::notAsciiAlphaNumeric('12234')); + $this->assertFalse(Validation::notAsciiAlphaNumeric('1w2e2r3t4y')); + $this->assertFalse(Validation::notAsciiAlphaNumeric('0')); + + $this->assertTrue(Validation::notAsciiAlphaNumeric('abçďĕʑʘπй')); + $this->assertTrue(Validation::notAsciiAlphaNumeric('ˇˆๆゞ')); + $this->assertTrue(Validation::notAsciiAlphaNumeric('אกあアꀀ豈')); + $this->assertTrue(Validation::notAsciiAlphaNumeric('Džᾈᾨ')); + $this->assertTrue(Validation::notAsciiAlphaNumeric('ÆΔΩЖÇ')); + $this->assertTrue(Validation::notAsciiAlphaNumeric('12 234')); + $this->assertTrue(Validation::notAsciiAlphaNumeric('dfd 234')); + $this->assertTrue(Validation::notAsciiAlphaNumeric("\n")); + $this->assertTrue(Validation::notAsciiAlphaNumeric("\t")); + $this->assertTrue(Validation::notAsciiAlphaNumeric("\r")); + $this->assertTrue(Validation::notAsciiAlphaNumeric(' ')); + $this->assertTrue(Validation::notAsciiAlphaNumeric('')); + } + + /** + * testLengthBetween method + */ + public function testLengthBetween(): void + { + $this->assertTrue(Validation::lengthBetween('abcdefg', 1, 7)); + $this->assertTrue(Validation::lengthBetween('', 0, 7)); + $this->assertTrue(Validation::lengthBetween('אกあアꀀ豈', 1, 7)); + $this->assertTrue(Validation::lengthBetween(1, 1, 3)); + + $this->assertFalse(Validation::lengthBetween('abcdefg', 1, 6)); + $this->assertFalse(Validation::lengthBetween('ÆΔΩЖÇ', 1, 3)); + } + + /** + * testCreditCard method + */ + public function testCreditCard(): void + { + // American Express + $this->assertTrue(Validation::creditCard('370482756063980', ['amex'])); + $this->assertTrue(Validation::creditCard('349106433773483', ['amex'])); + $this->assertTrue(Validation::creditCard('344671486204764', ['amex'])); + $this->assertTrue(Validation::creditCard('344042544509943', ['amex'])); + $this->assertTrue(Validation::creditCard('377147515754475', ['amex'])); + $this->assertTrue(Validation::creditCard('375239372816422', ['amex'])); + $this->assertTrue(Validation::creditCard('376294341957707', ['amex'])); + $this->assertTrue(Validation::creditCard('341779292230411', ['amex'])); + $this->assertTrue(Validation::creditCard('341646919853372', ['amex'])); + $this->assertTrue(Validation::creditCard('348498616319346', ['amex'])); + // BankCard + $this->assertTrue(Validation::creditCard('5610745867413420', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5610376649499352', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5610091936000694', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5602248780118788', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5610631567676765', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5602238211270795', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5610173951215470', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5610139705753702', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5602226032150551', ['bankcard'])); + $this->assertTrue(Validation::creditCard('5602223993735777', ['bankcard'])); + // Diners Club 14 + $this->assertTrue(Validation::creditCard('30155483651028', ['diners'])); + $this->assertTrue(Validation::creditCard('36371312803821', ['diners'])); + $this->assertTrue(Validation::creditCard('38801277489875', ['diners'])); + $this->assertTrue(Validation::creditCard('30348560464296', ['diners'])); + $this->assertTrue(Validation::creditCard('30349040317708', ['diners'])); + $this->assertTrue(Validation::creditCard('36567413559978', ['diners'])); + $this->assertTrue(Validation::creditCard('36051554732702', ['diners'])); + $this->assertTrue(Validation::creditCard('30391842198191', ['diners'])); + $this->assertTrue(Validation::creditCard('30172682197745', ['diners'])); + $this->assertTrue(Validation::creditCard('30162056566641', ['diners'])); + $this->assertTrue(Validation::creditCard('30085066927745', ['diners'])); + $this->assertTrue(Validation::creditCard('36519025221976', ['diners'])); + $this->assertTrue(Validation::creditCard('30372679371044', ['diners'])); + $this->assertTrue(Validation::creditCard('38913939150124', ['diners'])); + $this->assertTrue(Validation::creditCard('36852899094637', ['diners'])); + $this->assertTrue(Validation::creditCard('30138041971120', ['diners'])); + $this->assertTrue(Validation::creditCard('36184047836838', ['diners'])); + $this->assertTrue(Validation::creditCard('30057460264462', ['diners'])); + $this->assertTrue(Validation::creditCard('38980165212050', ['diners'])); + $this->assertTrue(Validation::creditCard('30356516881240', ['diners'])); + $this->assertTrue(Validation::creditCard('38744810033182', ['diners'])); + $this->assertTrue(Validation::creditCard('30173638706621', ['diners'])); + $this->assertTrue(Validation::creditCard('30158334709185', ['diners'])); + $this->assertTrue(Validation::creditCard('30195413721186', ['diners'])); + $this->assertTrue(Validation::creditCard('38863347694793', ['diners'])); + $this->assertTrue(Validation::creditCard('30275627009113', ['diners'])); + $this->assertTrue(Validation::creditCard('30242860404971', ['diners'])); + $this->assertTrue(Validation::creditCard('30081877595151', ['diners'])); + $this->assertTrue(Validation::creditCard('38053196067461', ['diners'])); + $this->assertTrue(Validation::creditCard('36520379984870', ['diners'])); + // 2004 MasterCard/Diners Club Alliance International 14 + $this->assertTrue(Validation::creditCard('36747701998969', ['diners'])); + $this->assertTrue(Validation::creditCard('36427861123159', ['diners'])); + $this->assertTrue(Validation::creditCard('36150537602386', ['diners'])); + $this->assertTrue(Validation::creditCard('36582388820610', ['diners'])); + $this->assertTrue(Validation::creditCard('36729045250216', ['diners'])); + // 2004 MasterCard/Diners Club Alliance US & Canada 16 + $this->assertTrue(Validation::creditCard('5597511346169950', ['diners'])); + $this->assertTrue(Validation::creditCard('5526443162217562', ['diners'])); + $this->assertTrue(Validation::creditCard('5577265786122391', ['diners'])); + $this->assertTrue(Validation::creditCard('5534061404676989', ['diners'])); + $this->assertTrue(Validation::creditCard('5545313588374502', ['diners'])); + // Discover + $this->assertTrue(Validation::creditCard('6011802876467237', ['disc'])); + $this->assertTrue(Validation::creditCard('6506432777720955', ['disc'])); + $this->assertTrue(Validation::creditCard('6011126265283942', ['disc'])); + $this->assertTrue(Validation::creditCard('6502187151579252', ['disc'])); + $this->assertTrue(Validation::creditCard('6506600836002298', ['disc'])); + $this->assertTrue(Validation::creditCard('6504376463615189', ['disc'])); + $this->assertTrue(Validation::creditCard('6011440907005377', ['disc'])); + $this->assertTrue(Validation::creditCard('6509735979634270', ['disc'])); + $this->assertTrue(Validation::creditCard('6011422366775856', ['disc'])); + $this->assertTrue(Validation::creditCard('6500976374623323', ['disc'])); + // enRoute + $this->assertTrue(Validation::creditCard('201496944158937', ['enroute'])); + $this->assertTrue(Validation::creditCard('214945833739665', ['enroute'])); + $this->assertTrue(Validation::creditCard('214982692491187', ['enroute'])); + $this->assertTrue(Validation::creditCard('214901395949424', ['enroute'])); + $this->assertTrue(Validation::creditCard('201480676269187', ['enroute'])); + $this->assertTrue(Validation::creditCard('214911922887807', ['enroute'])); + $this->assertTrue(Validation::creditCard('201485025457250', ['enroute'])); + $this->assertTrue(Validation::creditCard('201402662758866', ['enroute'])); + $this->assertTrue(Validation::creditCard('214981579370225', ['enroute'])); + $this->assertTrue(Validation::creditCard('201447595859877', ['enroute'])); + // JCB 15 digit + $this->assertTrue(Validation::creditCard('213134762247898', ['jcb'])); + $this->assertTrue(Validation::creditCard('180078671678892', ['jcb'])); + $this->assertTrue(Validation::creditCard('180010559353736', ['jcb'])); + $this->assertTrue(Validation::creditCard('213195474464253', ['jcb'])); + $this->assertTrue(Validation::creditCard('213106675562183', ['jcb'])); + $this->assertTrue(Validation::creditCard('213163299662667', ['jcb'])); + $this->assertTrue(Validation::creditCard('180032506857825', ['jcb'])); + $this->assertTrue(Validation::creditCard('213157919192733', ['jcb'])); + $this->assertTrue(Validation::creditCard('180031358949367', ['jcb'])); + $this->assertTrue(Validation::creditCard('180033802147846', ['jcb'])); + // JCB 16 digit + $this->assertTrue(Validation::creditCard('3096806857839939', ['jcb'])); + $this->assertTrue(Validation::creditCard('3158699503187091', ['jcb'])); + $this->assertTrue(Validation::creditCard('3112549607186579', ['jcb'])); + $this->assertTrue(Validation::creditCard('3112332922425604', ['jcb'])); + $this->assertTrue(Validation::creditCard('3112001541159239', ['jcb'])); + $this->assertTrue(Validation::creditCard('3112162495317841', ['jcb'])); + $this->assertTrue(Validation::creditCard('3337562627732768', ['jcb'])); + $this->assertTrue(Validation::creditCard('3337107161330775', ['jcb'])); + $this->assertTrue(Validation::creditCard('3528053736003621', ['jcb'])); + $this->assertTrue(Validation::creditCard('3528915255020360', ['jcb'])); + $this->assertTrue(Validation::creditCard('3096786059660921', ['jcb'])); + $this->assertTrue(Validation::creditCard('3528264799292320', ['jcb'])); + $this->assertTrue(Validation::creditCard('3096469164130136', ['jcb'])); + $this->assertTrue(Validation::creditCard('3112127443822853', ['jcb'])); + $this->assertTrue(Validation::creditCard('3096849995802328', ['jcb'])); + $this->assertTrue(Validation::creditCard('3528090735127407', ['jcb'])); + $this->assertTrue(Validation::creditCard('3112101006819234', ['jcb'])); + $this->assertTrue(Validation::creditCard('3337444428040784', ['jcb'])); + $this->assertTrue(Validation::creditCard('3088043154151061', ['jcb'])); + $this->assertTrue(Validation::creditCard('3088295969414866', ['jcb'])); + $this->assertTrue(Validation::creditCard('3158748843158575', ['jcb'])); + $this->assertTrue(Validation::creditCard('3158709206148538', ['jcb'])); + $this->assertTrue(Validation::creditCard('3158365159575324', ['jcb'])); + $this->assertTrue(Validation::creditCard('3158671691305165', ['jcb'])); + $this->assertTrue(Validation::creditCard('3528523028771093', ['jcb'])); + $this->assertTrue(Validation::creditCard('3096057126267870', ['jcb'])); + $this->assertTrue(Validation::creditCard('3158514047166834', ['jcb'])); + $this->assertTrue(Validation::creditCard('3528274546125962', ['jcb'])); + $this->assertTrue(Validation::creditCard('3528890967705733', ['jcb'])); + $this->assertTrue(Validation::creditCard('3337198811307545', ['jcb'])); + // Maestro (debit card) + $this->assertTrue(Validation::creditCard('5020147409985219', ['maestro'])); + $this->assertTrue(Validation::creditCard('5020931809905616', ['maestro'])); + $this->assertTrue(Validation::creditCard('5020412965470224', ['maestro'])); + $this->assertTrue(Validation::creditCard('5020129740944022', ['maestro'])); + $this->assertTrue(Validation::creditCard('5020024696747943', ['maestro'])); + $this->assertTrue(Validation::creditCard('5020581514636509', ['maestro'])); + $this->assertTrue(Validation::creditCard('5020695008411987', ['maestro'])); + $this->assertTrue(Validation::creditCard('5020565359718977', ['maestro'])); + $this->assertTrue(Validation::creditCard('6339931536544062', ['maestro'])); + $this->assertTrue(Validation::creditCard('6465028615704406', ['maestro'])); + // Mastercard + $this->assertTrue(Validation::creditCard('5580424361774366', ['mc'])); + $this->assertTrue(Validation::creditCard('5589563059318282', ['mc'])); + $this->assertTrue(Validation::creditCard('5387558333690047', ['mc'])); + $this->assertTrue(Validation::creditCard('5163919215247175', ['mc'])); + $this->assertTrue(Validation::creditCard('5386742685055055', ['mc'])); + $this->assertTrue(Validation::creditCard('5102303335960674', ['mc'])); + $this->assertTrue(Validation::creditCard('5526543403964565', ['mc'])); + $this->assertTrue(Validation::creditCard('5538725892618432', ['mc'])); + $this->assertTrue(Validation::creditCard('5119543573129778', ['mc'])); + $this->assertTrue(Validation::creditCard('5391174753915767', ['mc'])); + $this->assertTrue(Validation::creditCard('5510994113980714', ['mc'])); + $this->assertTrue(Validation::creditCard('5183720260418091', ['mc'])); + $this->assertTrue(Validation::creditCard('5488082196086704', ['mc'])); + $this->assertTrue(Validation::creditCard('5484645164161834', ['mc'])); + $this->assertTrue(Validation::creditCard('5171254350337031', ['mc'])); + $this->assertTrue(Validation::creditCard('5526987528136452', ['mc'])); + $this->assertTrue(Validation::creditCard('5504148941409358', ['mc'])); + $this->assertTrue(Validation::creditCard('5240793507243615', ['mc'])); + $this->assertTrue(Validation::creditCard('5162114693017107', ['mc'])); + $this->assertTrue(Validation::creditCard('5163104807404753', ['mc'])); + $this->assertTrue(Validation::creditCard('5590136167248365', ['mc'])); + $this->assertTrue(Validation::creditCard('5565816281038948', ['mc'])); + $this->assertTrue(Validation::creditCard('5467639122779531', ['mc'])); + $this->assertTrue(Validation::creditCard('5297350261550024', ['mc'])); + $this->assertTrue(Validation::creditCard('5162739131368058', ['mc'])); + // Mastercard (additional 2016 BIN) + $this->assertTrue(Validation::creditCard('2221000000000009', ['mc'])); + $this->assertTrue(Validation::creditCard('2720999999999996', ['mc'])); + $this->assertTrue(Validation::creditCard('2223000010005798', ['mc'])); + $this->assertTrue(Validation::creditCard('2623430710235708', ['mc'])); + $this->assertTrue(Validation::creditCard('2420452519835723', ['mc'])); + // Solo 16 + $this->assertTrue(Validation::creditCard('6767432107064987', ['solo'])); + $this->assertTrue(Validation::creditCard('6334667758225411', ['solo'])); + $this->assertTrue(Validation::creditCard('6767037421954068', ['solo'])); + $this->assertTrue(Validation::creditCard('6767823306394854', ['solo'])); + $this->assertTrue(Validation::creditCard('6334768185398134', ['solo'])); + $this->assertTrue(Validation::creditCard('6767286729498589', ['solo'])); + $this->assertTrue(Validation::creditCard('6334972104431261', ['solo'])); + $this->assertTrue(Validation::creditCard('6334843427400616', ['solo'])); + $this->assertTrue(Validation::creditCard('6767493947881311', ['solo'])); + $this->assertTrue(Validation::creditCard('6767194235798817', ['solo'])); + // Solo 18 + $this->assertTrue(Validation::creditCard('676714834398858593', ['solo'])); + $this->assertTrue(Validation::creditCard('676751666435130857', ['solo'])); + $this->assertTrue(Validation::creditCard('676781908573924236', ['solo'])); + $this->assertTrue(Validation::creditCard('633488724644003240', ['solo'])); + $this->assertTrue(Validation::creditCard('676732252338067316', ['solo'])); + $this->assertTrue(Validation::creditCard('676747520084495821', ['solo'])); + $this->assertTrue(Validation::creditCard('633465488901381957', ['solo'])); + $this->assertTrue(Validation::creditCard('633487484858610484', ['solo'])); + $this->assertTrue(Validation::creditCard('633453764680740694', ['solo'])); + $this->assertTrue(Validation::creditCard('676768613295414451', ['solo'])); + // Solo 19 + $this->assertTrue(Validation::creditCard('6767838565218340113', ['solo'])); + $this->assertTrue(Validation::creditCard('6767760119829705181', ['solo'])); + $this->assertTrue(Validation::creditCard('6767265917091593668', ['solo'])); + $this->assertTrue(Validation::creditCard('6767938856947440111', ['solo'])); + $this->assertTrue(Validation::creditCard('6767501945697390076', ['solo'])); + $this->assertTrue(Validation::creditCard('6334902868716257379', ['solo'])); + $this->assertTrue(Validation::creditCard('6334922127686425532', ['solo'])); + $this->assertTrue(Validation::creditCard('6334933119080706440', ['solo'])); + $this->assertTrue(Validation::creditCard('6334647959628261714', ['solo'])); + $this->assertTrue(Validation::creditCard('6334527312384101382', ['solo'])); + // Switch 16 + $this->assertTrue(Validation::creditCard('5641829171515733', ['switch'])); + $this->assertTrue(Validation::creditCard('5641824852820809', ['switch'])); + $this->assertTrue(Validation::creditCard('6759129648956909', ['switch'])); + $this->assertTrue(Validation::creditCard('6759626072268156', ['switch'])); + $this->assertTrue(Validation::creditCard('5641822698388957', ['switch'])); + $this->assertTrue(Validation::creditCard('5641827123105470', ['switch'])); + $this->assertTrue(Validation::creditCard('5641823755819553', ['switch'])); + $this->assertTrue(Validation::creditCard('5641821939587682', ['switch'])); + $this->assertTrue(Validation::creditCard('4936097148079186', ['switch'])); + $this->assertTrue(Validation::creditCard('5641829739125009', ['switch'])); + $this->assertTrue(Validation::creditCard('5641822860725507', ['switch'])); + $this->assertTrue(Validation::creditCard('4936717688865831', ['switch'])); + $this->assertTrue(Validation::creditCard('6759487613615441', ['switch'])); + $this->assertTrue(Validation::creditCard('5641821346840617', ['switch'])); + $this->assertTrue(Validation::creditCard('5641825793417126', ['switch'])); + $this->assertTrue(Validation::creditCard('5641821302759595', ['switch'])); + $this->assertTrue(Validation::creditCard('6759784969918837', ['switch'])); + $this->assertTrue(Validation::creditCard('5641824910667036', ['switch'])); + $this->assertTrue(Validation::creditCard('6759139909636173', ['switch'])); + $this->assertTrue(Validation::creditCard('6333425070638022', ['switch'])); + $this->assertTrue(Validation::creditCard('5641823910382067', ['switch'])); + $this->assertTrue(Validation::creditCard('4936295218139423', ['switch'])); + $this->assertTrue(Validation::creditCard('6333031811316199', ['switch'])); + $this->assertTrue(Validation::creditCard('4936912044763198', ['switch'])); + $this->assertTrue(Validation::creditCard('4936387053303824', ['switch'])); + $this->assertTrue(Validation::creditCard('6759535838760523', ['switch'])); + $this->assertTrue(Validation::creditCard('6333427174594051', ['switch'])); + $this->assertTrue(Validation::creditCard('5641829037102700', ['switch'])); + $this->assertTrue(Validation::creditCard('5641826495463046', ['switch'])); + $this->assertTrue(Validation::creditCard('6333480852979946', ['switch'])); + $this->assertTrue(Validation::creditCard('5641827761302876', ['switch'])); + $this->assertTrue(Validation::creditCard('5641825083505317', ['switch'])); + $this->assertTrue(Validation::creditCard('6759298096003991', ['switch'])); + $this->assertTrue(Validation::creditCard('4936119165483420', ['switch'])); + $this->assertTrue(Validation::creditCard('4936190990500993', ['switch'])); + $this->assertTrue(Validation::creditCard('4903356467384927', ['switch'])); + $this->assertTrue(Validation::creditCard('6333372765092554', ['switch'])); + $this->assertTrue(Validation::creditCard('5641821330950570', ['switch'])); + $this->assertTrue(Validation::creditCard('6759841558826118', ['switch'])); + $this->assertTrue(Validation::creditCard('4936164540922452', ['switch'])); + // Switch 18 + $this->assertTrue(Validation::creditCard('493622764224625174', ['switch'])); + $this->assertTrue(Validation::creditCard('564182823396913535', ['switch'])); + $this->assertTrue(Validation::creditCard('675917308304801234', ['switch'])); + $this->assertTrue(Validation::creditCard('675919890024220298', ['switch'])); + $this->assertTrue(Validation::creditCard('633308376862556751', ['switch'])); + $this->assertTrue(Validation::creditCard('564182377633208779', ['switch'])); + $this->assertTrue(Validation::creditCard('564182870014926787', ['switch'])); + $this->assertTrue(Validation::creditCard('675979788553829819', ['switch'])); + $this->assertTrue(Validation::creditCard('493668394358130935', ['switch'])); + $this->assertTrue(Validation::creditCard('493637431790930965', ['switch'])); + $this->assertTrue(Validation::creditCard('633321438601941513', ['switch'])); + $this->assertTrue(Validation::creditCard('675913800898840986', ['switch'])); + $this->assertTrue(Validation::creditCard('564182592016841547', ['switch'])); + $this->assertTrue(Validation::creditCard('564182428380440899', ['switch'])); + $this->assertTrue(Validation::creditCard('493696376827623463', ['switch'])); + $this->assertTrue(Validation::creditCard('675977939286485757', ['switch'])); + $this->assertTrue(Validation::creditCard('490302699502091579', ['switch'])); + $this->assertTrue(Validation::creditCard('564182085013662230', ['switch'])); + $this->assertTrue(Validation::creditCard('493693054263310167', ['switch'])); + $this->assertTrue(Validation::creditCard('633321755966697525', ['switch'])); + $this->assertTrue(Validation::creditCard('675996851719732811', ['switch'])); + $this->assertTrue(Validation::creditCard('493699211208281028', ['switch'])); + $this->assertTrue(Validation::creditCard('493697817378356614', ['switch'])); + $this->assertTrue(Validation::creditCard('675968224161768150', ['switch'])); + $this->assertTrue(Validation::creditCard('493669416873337627', ['switch'])); + $this->assertTrue(Validation::creditCard('564182439172549714', ['switch'])); + $this->assertTrue(Validation::creditCard('675926914467673598', ['switch'])); + $this->assertTrue(Validation::creditCard('564182565231977809', ['switch'])); + $this->assertTrue(Validation::creditCard('675966282607849002', ['switch'])); + $this->assertTrue(Validation::creditCard('493691609704348548', ['switch'])); + $this->assertTrue(Validation::creditCard('675933118546065120', ['switch'])); + $this->assertTrue(Validation::creditCard('493631116677238592', ['switch'])); + $this->assertTrue(Validation::creditCard('675921142812825938', ['switch'])); + $this->assertTrue(Validation::creditCard('633338311815675113', ['switch'])); + $this->assertTrue(Validation::creditCard('633323539867338621', ['switch'])); + $this->assertTrue(Validation::creditCard('675964912740845663', ['switch'])); + $this->assertTrue(Validation::creditCard('633334008833727504', ['switch'])); + $this->assertTrue(Validation::creditCard('493631941273687169', ['switch'])); + $this->assertTrue(Validation::creditCard('564182971729706785', ['switch'])); + $this->assertTrue(Validation::creditCard('633303461188963496', ['switch'])); + // Switch 19 + $this->assertTrue(Validation::creditCard('6759603460617628716', ['switch'])); + $this->assertTrue(Validation::creditCard('4936705825268647681', ['switch'])); + $this->assertTrue(Validation::creditCard('5641829846600479183', ['switch'])); + $this->assertTrue(Validation::creditCard('6759389846573792530', ['switch'])); + $this->assertTrue(Validation::creditCard('4936189558712637603', ['switch'])); + $this->assertTrue(Validation::creditCard('5641822217393868189', ['switch'])); + $this->assertTrue(Validation::creditCard('4903075563780057152', ['switch'])); + $this->assertTrue(Validation::creditCard('4936510653566569547', ['switch'])); + $this->assertTrue(Validation::creditCard('4936503083627303364', ['switch'])); + $this->assertTrue(Validation::creditCard('4936777334398116272', ['switch'])); + $this->assertTrue(Validation::creditCard('5641823876900554860', ['switch'])); + $this->assertTrue(Validation::creditCard('6759619236903407276', ['switch'])); + $this->assertTrue(Validation::creditCard('6759011470269978117', ['switch'])); + $this->assertTrue(Validation::creditCard('6333175833997062502', ['switch'])); + $this->assertTrue(Validation::creditCard('6759498728789080439', ['switch'])); + $this->assertTrue(Validation::creditCard('4903020404168157841', ['switch'])); + $this->assertTrue(Validation::creditCard('6759354334874804313', ['switch'])); + $this->assertTrue(Validation::creditCard('6759900856420875115', ['switch'])); + $this->assertTrue(Validation::creditCard('5641827269346868860', ['switch'])); + $this->assertTrue(Validation::creditCard('5641828995047453870', ['switch'])); + $this->assertTrue(Validation::creditCard('6333321884754806543', ['switch'])); + $this->assertTrue(Validation::creditCard('6333108246283715901', ['switch'])); + $this->assertTrue(Validation::creditCard('6759572372800700102', ['switch'])); + $this->assertTrue(Validation::creditCard('4903095096797974933', ['switch'])); + $this->assertTrue(Validation::creditCard('6333354315797920215', ['switch'])); + $this->assertTrue(Validation::creditCard('6759163746089433755', ['switch'])); + $this->assertTrue(Validation::creditCard('6759871666634807647', ['switch'])); + $this->assertTrue(Validation::creditCard('5641827883728575248', ['switch'])); + $this->assertTrue(Validation::creditCard('4936527975051407847', ['switch'])); + $this->assertTrue(Validation::creditCard('5641823318396882141', ['switch'])); + $this->assertTrue(Validation::creditCard('6759123772311123708', ['switch'])); + $this->assertTrue(Validation::creditCard('4903054736148271088', ['switch'])); + $this->assertTrue(Validation::creditCard('4936477526808883952', ['switch'])); + $this->assertTrue(Validation::creditCard('4936433964890967966', ['switch'])); + $this->assertTrue(Validation::creditCard('6333245128906049344', ['switch'])); + $this->assertTrue(Validation::creditCard('4936321036970553134', ['switch'])); + $this->assertTrue(Validation::creditCard('4936111816358702773', ['switch'])); + $this->assertTrue(Validation::creditCard('4936196077254804290', ['switch'])); + $this->assertTrue(Validation::creditCard('6759558831206830183', ['switch'])); + $this->assertTrue(Validation::creditCard('5641827998830403137', ['switch'])); + // VISA 13 digit + $this->assertTrue(Validation::creditCard('4024007174754', ['visa'])); + $this->assertTrue(Validation::creditCard('4104816460717', ['visa'])); + $this->assertTrue(Validation::creditCard('4716229700437', ['visa'])); + $this->assertTrue(Validation::creditCard('4539305400213', ['visa'])); + $this->assertTrue(Validation::creditCard('4728260558665', ['visa'])); + $this->assertTrue(Validation::creditCard('4929100131792', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007117308', ['visa'])); + $this->assertTrue(Validation::creditCard('4539915491024', ['visa'])); + $this->assertTrue(Validation::creditCard('4539790901139', ['visa'])); + $this->assertTrue(Validation::creditCard('4485284914909', ['visa'])); + $this->assertTrue(Validation::creditCard('4782793022350', ['visa'])); + $this->assertTrue(Validation::creditCard('4556899290685', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007134774', ['visa'])); + $this->assertTrue(Validation::creditCard('4333412341316', ['visa'])); + $this->assertTrue(Validation::creditCard('4539534204543', ['visa'])); + $this->assertTrue(Validation::creditCard('4485640373626', ['visa'])); + $this->assertTrue(Validation::creditCard('4929911445746', ['visa'])); + $this->assertTrue(Validation::creditCard('4539292550806', ['visa'])); + $this->assertTrue(Validation::creditCard('4716523014030', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007125152', ['visa'])); + $this->assertTrue(Validation::creditCard('4539758883311', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007103258', ['visa'])); + $this->assertTrue(Validation::creditCard('4916933155767', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007159672', ['visa'])); + $this->assertTrue(Validation::creditCard('4716935544871', ['visa'])); + $this->assertTrue(Validation::creditCard('4929415177779', ['visa'])); + $this->assertTrue(Validation::creditCard('4929748547896', ['visa'])); + $this->assertTrue(Validation::creditCard('4929153468612', ['visa'])); + $this->assertTrue(Validation::creditCard('4539397132104', ['visa'])); + $this->assertTrue(Validation::creditCard('4485293435540', ['visa'])); + $this->assertTrue(Validation::creditCard('4485799412720', ['visa'])); + $this->assertTrue(Validation::creditCard('4916744757686', ['visa'])); + $this->assertTrue(Validation::creditCard('4556475655426', ['visa'])); + $this->assertTrue(Validation::creditCard('4539400441625', ['visa'])); + $this->assertTrue(Validation::creditCard('4485437129173', ['visa'])); + $this->assertTrue(Validation::creditCard('4716253605320', ['visa'])); + $this->assertTrue(Validation::creditCard('4539366156589', ['visa'])); + $this->assertTrue(Validation::creditCard('4916498061392', ['visa'])); + $this->assertTrue(Validation::creditCard('4716127163779', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007183078', ['visa'])); + $this->assertTrue(Validation::creditCard('4041553279654', ['visa'])); + $this->assertTrue(Validation::creditCard('4532380121960', ['visa'])); + $this->assertTrue(Validation::creditCard('4485906062491', ['visa'])); + $this->assertTrue(Validation::creditCard('4539365115149', ['visa'])); + $this->assertTrue(Validation::creditCard('4485146516702', ['visa'])); + // VISA 16 digit + $this->assertTrue(Validation::creditCard('4916375389940009', ['visa'])); + $this->assertTrue(Validation::creditCard('4929167481032610', ['visa'])); + $this->assertTrue(Validation::creditCard('4485029969061519', ['visa'])); + $this->assertTrue(Validation::creditCard('4485573845281759', ['visa'])); + $this->assertTrue(Validation::creditCard('4485669810383529', ['visa'])); + $this->assertTrue(Validation::creditCard('4929615806560327', ['visa'])); + $this->assertTrue(Validation::creditCard('4556807505609535', ['visa'])); + $this->assertTrue(Validation::creditCard('4532611336232890', ['visa'])); + $this->assertTrue(Validation::creditCard('4532201952422387', ['visa'])); + $this->assertTrue(Validation::creditCard('4485073797976290', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007157580969', ['visa'])); + $this->assertTrue(Validation::creditCard('4053740470212274', ['visa'])); + $this->assertTrue(Validation::creditCard('4716265831525676', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007100222966', ['visa'])); + $this->assertTrue(Validation::creditCard('4539556148303244', ['visa'])); + $this->assertTrue(Validation::creditCard('4532449879689709', ['visa'])); + $this->assertTrue(Validation::creditCard('4916805467840986', ['visa'])); + $this->assertTrue(Validation::creditCard('4532155644440233', ['visa'])); + $this->assertTrue(Validation::creditCard('4467977802223781', ['visa'])); + $this->assertTrue(Validation::creditCard('4539224637000686', ['visa'])); + $this->assertTrue(Validation::creditCard('4556629187064965', ['visa'])); + $this->assertTrue(Validation::creditCard('4532970205932943', ['visa'])); + $this->assertTrue(Validation::creditCard('4821470132041850', ['visa'])); + $this->assertTrue(Validation::creditCard('4916214267894485', ['visa'])); + $this->assertTrue(Validation::creditCard('4024007169073284', ['visa'])); + $this->assertTrue(Validation::creditCard('4716783351296122', ['visa'])); + $this->assertTrue(Validation::creditCard('4556480171913795', ['visa'])); + $this->assertTrue(Validation::creditCard('4929678411034997', ['visa'])); + $this->assertTrue(Validation::creditCard('4682061913519392', ['visa'])); + $this->assertTrue(Validation::creditCard('4916495481746474', ['visa'])); + $this->assertTrue(Validation::creditCard('4929007108460499', ['visa'])); + $this->assertTrue(Validation::creditCard('4539951357838586', ['visa'])); + $this->assertTrue(Validation::creditCard('4716482691051558', ['visa'])); + $this->assertTrue(Validation::creditCard('4916385069917516', ['visa'])); + $this->assertTrue(Validation::creditCard('4929020289494641', ['visa'])); + $this->assertTrue(Validation::creditCard('4532176245263774', ['visa'])); + $this->assertTrue(Validation::creditCard('4556242273553949', ['visa'])); + $this->assertTrue(Validation::creditCard('4481007485188614', ['visa'])); + $this->assertTrue(Validation::creditCard('4716533372139623', ['visa'])); + $this->assertTrue(Validation::creditCard('4929152038152632', ['visa'])); + $this->assertTrue(Validation::creditCard('4539404037310550', ['visa'])); + $this->assertTrue(Validation::creditCard('4532800925229140', ['visa'])); + $this->assertTrue(Validation::creditCard('4916845885268360', ['visa'])); + $this->assertTrue(Validation::creditCard('4394514669078434', ['visa'])); + $this->assertTrue(Validation::creditCard('4485611378115042', ['visa'])); + $this->assertTrue(Validation::creditCard('4485-6113-7811-5042', ['visa'])); + // Visa Electron + $this->assertTrue(Validation::creditCard('4175003346287100', ['electron'])); + $this->assertTrue(Validation::creditCard('4913042516577228', ['electron'])); + $this->assertTrue(Validation::creditCard('4917592325659381', ['electron'])); + $this->assertTrue(Validation::creditCard('4917084924450511', ['electron'])); + $this->assertTrue(Validation::creditCard('4917994610643999', ['electron'])); + $this->assertTrue(Validation::creditCard('4175005933743585', ['electron'])); + $this->assertTrue(Validation::creditCard('4175008373425044', ['electron'])); + $this->assertTrue(Validation::creditCard('4913119763664154', ['electron'])); + $this->assertTrue(Validation::creditCard('4913189017481812', ['electron'])); + $this->assertTrue(Validation::creditCard('4913085104968622', ['electron'])); + $this->assertTrue(Validation::creditCard('4175008803122021', ['electron'])); + $this->assertTrue(Validation::creditCard('4913294453962489', ['electron'])); + $this->assertTrue(Validation::creditCard('4175009797419290', ['electron'])); + $this->assertTrue(Validation::creditCard('4175005028142917', ['electron'])); + $this->assertTrue(Validation::creditCard('4913940802385364', ['electron'])); + // Voyager + $this->assertTrue(Validation::creditCard('869940697287073', ['voyager'])); + $this->assertTrue(Validation::creditCard('869934523596112', ['voyager'])); + $this->assertTrue(Validation::creditCard('869958670174621', ['voyager'])); + $this->assertTrue(Validation::creditCard('869921250068209', ['voyager'])); + $this->assertTrue(Validation::creditCard('869972521242198', ['voyager'])); + // Credit card number should not pass as array + $this->assertFalse(Validation::creditCard(['869972521242198'], ['voyager'])); + } + + /** + * testLuhn method + */ + public function testLuhn(): void + { + // American Express + $this->assertTrue(Validation::luhn('370482756063980')); + // BankCard + $this->assertTrue(Validation::luhn('5610745867413420')); + // Diners Club 14 + $this->assertTrue(Validation::luhn('30155483651028')); + // 2004 MasterCard/Diners Club Alliance International 14 + $this->assertTrue(Validation::luhn('36747701998969')); + // 2004 MasterCard/Diners Club Alliance US & Canada 16 + $this->assertTrue(Validation::luhn('5597511346169950')); + // Discover + $this->assertTrue(Validation::luhn('6011802876467237')); + // enRoute + $this->assertTrue(Validation::luhn('201496944158937')); + // JCB 15 digit + $this->assertTrue(Validation::luhn('213134762247898')); + // JCB 16 digit + $this->assertTrue(Validation::luhn('3096806857839939')); + // Maestro (debit card) + $this->assertTrue(Validation::luhn('5020147409985219')); + // Mastercard + $this->assertTrue(Validation::luhn('5580424361774366')); + // Solo 16 + $this->assertTrue(Validation::luhn('6767432107064987')); + // Solo 18 + $this->assertTrue(Validation::luhn('676714834398858593')); + // Solo 19 + $this->assertTrue(Validation::luhn('6767838565218340113')); + // Switch 16 + $this->assertTrue(Validation::luhn('5641829171515733')); + // Switch 18 + $this->assertTrue(Validation::luhn('493622764224625174')); + // Switch 19 + $this->assertTrue(Validation::luhn('6759603460617628716')); + // VISA 13 digit + $this->assertTrue(Validation::luhn('4024007174754')); + // VISA 16 digit + $this->assertTrue(Validation::luhn('4916375389940009')); + // Visa Electron + $this->assertTrue(Validation::luhn('4175003346287100')); + // Voyager + $this->assertTrue(Validation::luhn('869940697287073')); + + $this->assertFalse(Validation::luhn('0000000000000000')); + $this->assertFalse(Validation::luhn('869940697287173')); + } + + /** + * testCustomRegexForCreditCard method + */ + public function testCustomRegexForCreditCard(): void + { + $this->assertTrue(Validation::creditCard('370482756063980', 'fast', false, '/123321\\d{11}/')); + $this->assertFalse(Validation::creditCard('1233210593374358', 'fast', false, '/123321\\d{11}/')); + } + + /** + * testCustomRegexForCreditCardWithLuhnCheck method + */ + public function testCustomRegexForCreditCardWithLuhnCheck(): void + { + $this->assertTrue(Validation::creditCard('12332110426226941', 'fast', true, '/123321\\d{11}/')); + $this->assertFalse(Validation::creditCard('12332105933743585', 'fast', true, '/123321\\d{11}/')); + $this->assertFalse(Validation::creditCard('12332105933743587', 'fast', true, '/123321\\d{11}/')); + $this->assertFalse(Validation::creditCard('12312305933743585', 'fast', true, '/123321\\d{11}/')); + } + + /** + * testFastCreditCard method + */ + public function testFastCreditCard(): void + { + // too short + $this->assertFalse(Validation::creditCard('123456789012')); + // American Express + $this->assertTrue(Validation::creditCard('370482756063980')); + // Diners Club 14 + $this->assertTrue(Validation::creditCard('30155483651028')); + // 2004 MasterCard/Diners Club Alliance International 14 + $this->assertTrue(Validation::creditCard('36747701998969')); + // 2004 MasterCard/Diners Club Alliance US & Canada 16 + $this->assertTrue(Validation::creditCard('5597511346169950')); + // Discover + $this->assertTrue(Validation::creditCard('6011802876467237')); + // Mastercard + $this->assertTrue(Validation::creditCard('5580424361774366')); + // VISA 13 digit + $this->assertTrue(Validation::creditCard('4024007174754')); + // VISA 16 digit + $this->assertTrue(Validation::creditCard('4916375389940009')); + // Visa Electron + $this->assertTrue(Validation::creditCard('4175003346287100')); + } + + /** + * testAllCreditCard method + */ + public function testAllCreditCard(): void + { + // American Express + $this->assertTrue(Validation::creditCard('370482756063980', 'all')); + // BankCard + $this->assertTrue(Validation::creditCard('5610745867413420', 'all')); + // Diners Club 14 + $this->assertTrue(Validation::creditCard('30155483651028', 'all')); + // 2004 MasterCard/Diners Club Alliance International 14 + $this->assertTrue(Validation::creditCard('36747701998969', 'all')); + // 2004 MasterCard/Diners Club Alliance US & Canada 16 + $this->assertTrue(Validation::creditCard('5597511346169950', 'all')); + // Discover + $this->assertTrue(Validation::creditCard('6011802876467237', 'all')); + // enRoute + $this->assertTrue(Validation::creditCard('201496944158937', 'all')); + // JCB 15 digit + $this->assertTrue(Validation::creditCard('213134762247898', 'all')); + // JCB 16 digit + $this->assertTrue(Validation::creditCard('3096806857839939', 'all')); + // Maestro (debit card) + $this->assertTrue(Validation::creditCard('5020147409985219', 'all')); + // Mastercard + $this->assertTrue(Validation::creditCard('5580424361774366', 'all')); + // Solo 16 + $this->assertTrue(Validation::creditCard('6767432107064987', 'all')); + // Solo 18 + $this->assertTrue(Validation::creditCard('676714834398858593', 'all')); + // Solo 19 + $this->assertTrue(Validation::creditCard('6767838565218340113', 'all')); + // Switch 16 + $this->assertTrue(Validation::creditCard('5641829171515733', 'all')); + // Switch 18 + $this->assertTrue(Validation::creditCard('493622764224625174', 'all')); + // Switch 19 + $this->assertTrue(Validation::creditCard('6759603460617628716', 'all')); + // VISA 13 digit + $this->assertTrue(Validation::creditCard('4024007174754', 'all')); + // VISA 16 digit + $this->assertTrue(Validation::creditCard('4916375389940009', 'all')); + // Visa Electron + $this->assertTrue(Validation::creditCard('4175003346287100', 'all')); + // Voyager + $this->assertTrue(Validation::creditCard('869940697287073', 'all')); + } + + /** + * testAllCreditCardDeep method + */ + public function testAllCreditCardDeep(): void + { + // American Express + $this->assertTrue(Validation::creditCard('370482756063980', 'all', true)); + // BankCard + $this->assertTrue(Validation::creditCard('5610745867413420', 'all', true)); + // Diners Club 14 + $this->assertTrue(Validation::creditCard('30155483651028', 'all', true)); + // 2004 MasterCard/Diners Club Alliance International 14 + $this->assertTrue(Validation::creditCard('36747701998969', 'all', true)); + // 2004 MasterCard/Diners Club Alliance US & Canada 16 + $this->assertTrue(Validation::creditCard('5597511346169950', 'all', true)); + // Discover + $this->assertTrue(Validation::creditCard('6011802876467237', 'all', true)); + // enRoute + $this->assertTrue(Validation::creditCard('201496944158937', 'all', true)); + // JCB 15 digit + $this->assertTrue(Validation::creditCard('213134762247898', 'all', true)); + // JCB 16 digit + $this->assertTrue(Validation::creditCard('3096806857839939', 'all', true)); + // Maestro (debit card) + $this->assertTrue(Validation::creditCard('5020147409985219', 'all', true)); + // Mastercard + $this->assertTrue(Validation::creditCard('5580424361774366', 'all', true)); + // Solo 16 + $this->assertTrue(Validation::creditCard('6767432107064987', 'all', true)); + // Solo 18 + $this->assertTrue(Validation::creditCard('676714834398858593', 'all', true)); + // Solo 19 + $this->assertTrue(Validation::creditCard('6767838565218340113', 'all', true)); + // Switch 16 + $this->assertTrue(Validation::creditCard('5641829171515733', 'all', true)); + // Switch 18 + $this->assertTrue(Validation::creditCard('493622764224625174', 'all', true)); + // Switch 19 + $this->assertTrue(Validation::creditCard('6759603460617628716', 'all', true)); + // VISA 13 digit + $this->assertTrue(Validation::creditCard('4024007174754', 'all', true)); + // VISA 16 digit + $this->assertTrue(Validation::creditCard('4916375389940009', 'all', true)); + // Visa Electron + $this->assertTrue(Validation::creditCard('4175003346287100', 'all', true)); + // Voyager + $this->assertTrue(Validation::creditCard('869940697287073', 'all', true)); + } + + /** + * testComparison method + */ + public function testComparison(): void + { + $this->assertTrue(Validation::comparison(7, Validation::COMPARE_GREATER, 6)); + $this->assertTrue(Validation::comparison(6, Validation::COMPARE_LESS, 7)); + $this->assertTrue(Validation::comparison(7, Validation::COMPARE_GREATER_OR_EQUAL, 7)); + $this->assertTrue(Validation::comparison(7, Validation::COMPARE_GREATER_OR_EQUAL, 6)); + $this->assertTrue(Validation::comparison(6, Validation::COMPARE_LESS_OR_EQUAL, 7)); + $this->assertTrue(Validation::comparison(7, Validation::COMPARE_EQUAL, 7)); + $this->assertTrue(Validation::comparison(7, Validation::COMPARE_NOT_EQUAL, 6)); + $this->assertTrue(Validation::comparison(7, Validation::COMPARE_SAME, 7)); + $this->assertTrue(Validation::comparison(7, Validation::COMPARE_NOT_SAME, '7')); + $this->assertFalse(Validation::comparison(6, Validation::COMPARE_GREATER, 7)); + $this->assertFalse(Validation::comparison(7, Validation::COMPARE_LESS, 6)); + $this->assertFalse(Validation::comparison(6, Validation::COMPARE_GREATER_OR_EQUAL, 7)); + $this->assertFalse(Validation::comparison(6, Validation::COMPARE_GREATER_OR_EQUAL, 7)); + $this->assertFalse(Validation::comparison(7, Validation::COMPARE_LESS_OR_EQUAL, 6)); + $this->assertFalse(Validation::comparison(7, Validation::COMPARE_EQUAL, 6)); + $this->assertFalse(Validation::comparison(7, Validation::COMPARE_NOT_EQUAL, 7)); + $this->assertFalse(Validation::comparison(7, Validation::COMPARE_SAME, '7')); + $this->assertFalse(Validation::comparison(7, Validation::COMPARE_NOT_SAME, 7)); + $this->assertTrue(Validation::comparison('6.5', Validation::COMPARE_NOT_EQUAL, 6)); + $this->assertTrue(Validation::comparison('6.5', Validation::COMPARE_LESS, 7)); + } + + /** + * Test comparison casting values before comparisons. + */ + public function testComparisonTypeChecks(): void + { + $this->assertFalse(Validation::comparison('\x028', Validation::COMPARE_GREATER_OR_EQUAL, 1), 'hexish encoding fails'); + $this->assertFalse(Validation::comparison('0b010', Validation::COMPARE_GREATER_OR_EQUAL, 1), 'binary string data fails'); + $this->assertFalse(Validation::comparison('0x01', Validation::COMPARE_GREATER_OR_EQUAL, 1), 'hex string data fails'); + $this->assertFalse(Validation::comparison('0x1', Validation::COMPARE_GREATER_OR_EQUAL, 1), 'hex string data fails'); + + $this->assertFalse(Validation::comparison('\x028', Validation::COMPARE_GREATER_OR_EQUAL, 1.5), 'hexish encoding fails'); + $this->assertFalse(Validation::comparison('0b010', Validation::COMPARE_GREATER_OR_EQUAL, 1.5), 'binary string data fails'); + $this->assertFalse(Validation::comparison('0x02', Validation::COMPARE_GREATER_OR_EQUAL, 1.5), 'hex string data fails'); + } + + /** + * testCustom method + */ + public function testCustom(): void + { + $this->assertTrue(Validation::custom('12345', '/(?assertFalse(Validation::custom('Text', '/(?assertFalse(Validation::custom('123.45', '/(?assertTrue(Validation::custom(1, '/.*/')); + $this->assertTrue(Validation::custom('1', '/^[0-9A-Za-z\s&]*$/')); + $this->assertFalse(Validation::custom(['input is not string'], '/.*/')); + $this->assertFalse(Validation::custom('missing regex')); + } + + /** + * testCustomAsArray method + */ + public function testCustomAsArray(): void + { + $this->assertTrue(Validation::custom('12345', '/(?assertFalse(Validation::custom('Text', '/(?assertFalse(Validation::custom('123.45', '/(?assertTrue(Validation::date($dateTime)); + $this->assertTrue(Validation::time($dateTime)); + $this->assertTrue(Validation::dateTime($dateTime)); + $this->assertTrue(Validation::localizedTime($dateTime)); + + $dateTime = new DateTimeImmutable(); + $this->assertTrue(Validation::date($dateTime)); + $this->assertTrue(Validation::time($dateTime)); + $this->assertTrue(Validation::dateTime($dateTime)); + $this->assertTrue(Validation::localizedTime($dateTime)); + + $this->assertFalse(Validation::time(new stdClass())); + $this->assertFalse(Validation::date(new stdClass())); + $this->assertFalse(Validation::dateTime(new stdClass())); + $this->assertFalse(Validation::localizedTime(new stdClass())); + + $cakeDate = new Date(); + $this->assertTrue(Validation::date($cakeDate)); + $this->assertFalse(Validation::time($cakeDate)); + $this->assertFalse(Validation::dateTime($cakeDate)); + $this->assertFalse(Validation::localizedTime($cakeDate)); + + $cakeDateTime = new CakeDateTime(); + $this->assertTrue(Validation::date($cakeDateTime)); + $this->assertTrue(Validation::time($cakeDateTime)); + $this->assertTrue(Validation::dateTime($cakeDateTime)); + $this->assertTrue(Validation::localizedTime($cakeDateTime)); + } + + /** + * testDateTimeObject + */ + public function testDateTimeBoolean(): void + { + $dateTime = true; + $this->assertFalse(Validation::date($dateTime)); + $this->assertFalse(Validation::time($dateTime)); + $this->assertFalse(Validation::dateTime($dateTime)); + $this->assertFalse(Validation::localizedTime($dateTime)); + } + + /** + * testDateDdmmyyyy method + */ + public function testDateDdmmyyyy(): void + { + $this->assertTrue(Validation::date('27-12-0001', ['dmy'])); + $this->assertTrue(Validation::date('27-12-2006', ['dmy'])); + $this->assertTrue(Validation::date('27.12.2006', ['dmy'])); + $this->assertTrue(Validation::date('27/12/2006', ['dmy'])); + $this->assertTrue(Validation::date('27 12 2006', ['dmy'])); + $this->assertTrue(Validation::date('31-10-0001', ['dmy'])); + $this->assertTrue(Validation::date('31-10-2006', ['dmy'])); + $this->assertFalse(Validation::date('00-00-0000', ['dmy'])); + $this->assertFalse(Validation::date('00.00.0000', ['dmy'])); + $this->assertFalse(Validation::date('00/00/0000', ['dmy'])); + $this->assertFalse(Validation::date('00 00 0000', ['dmy'])); + $this->assertFalse(Validation::date('01-01-0000', ['dmy'])); + $this->assertFalse(Validation::date('01-01-300', ['dmy'])); + $this->assertFalse(Validation::date('31-11-2006', ['dmy'])); + $this->assertFalse(Validation::date('31.11.2006', ['dmy'])); + $this->assertFalse(Validation::date('31/11/2006', ['dmy'])); + $this->assertFalse(Validation::date('31 11 2006', ['dmy'])); + $this->assertFalse(Validation::date('30-0,-2006', ['dmy'])); + } + + /** + * testDateDdmmyyyyLeapYear method + */ + public function testDateDdmmyyyyLeapYear(): void + { + $this->assertTrue(Validation::date('29-02-0004', ['dmy'])); + $this->assertTrue(Validation::date('29-02-2004', ['dmy'])); + $this->assertTrue(Validation::date('29.02.2004', ['dmy'])); + $this->assertTrue(Validation::date('29/02/2004', ['dmy'])); + $this->assertTrue(Validation::date('29 02 2004', ['dmy'])); + $this->assertFalse(Validation::date('29-02-2006', ['dmy'])); + $this->assertFalse(Validation::date('29.02.2006', ['dmy'])); + $this->assertFalse(Validation::date('29/02/2006', ['dmy'])); + $this->assertFalse(Validation::date('29 02 2006', ['dmy'])); + } + + /** + * testDateDdmmyy method + */ + public function testDateDdmmyy(): void + { + $this->assertTrue(Validation::date('27-12-06', ['dmy'])); + $this->assertTrue(Validation::date('27.12.06', ['dmy'])); + $this->assertTrue(Validation::date('27/12/06', ['dmy'])); + $this->assertTrue(Validation::date('27 12 06', ['dmy'])); + $this->assertFalse(Validation::date('00-00-00', ['dmy'])); + $this->assertFalse(Validation::date('00.00.00', ['dmy'])); + $this->assertFalse(Validation::date('00/00/00', ['dmy'])); + $this->assertFalse(Validation::date('00 00 00', ['dmy'])); + $this->assertFalse(Validation::date('31-11-06', ['dmy'])); + $this->assertFalse(Validation::date('31.11.06', ['dmy'])); + $this->assertFalse(Validation::date('31/11/06', ['dmy'])); + $this->assertFalse(Validation::date('31 11 06', ['dmy'])); + $this->assertFalse(Validation::date('30-0,-06', ['dmy'])); + } + + /** + * testDateDdmmyyLeapYear method + */ + public function testDateDdmmyyLeapYear(): void + { + $this->assertTrue(Validation::date('29-02-04', ['dmy'])); + $this->assertTrue(Validation::date('29.02.04', ['dmy'])); + $this->assertTrue(Validation::date('29/02/04', ['dmy'])); + $this->assertTrue(Validation::date('29 02 04', ['dmy'])); + $this->assertFalse(Validation::date('29-02-06', ['dmy'])); + $this->assertFalse(Validation::date('29.02.06', ['dmy'])); + $this->assertFalse(Validation::date('29/02/06', ['dmy'])); + $this->assertFalse(Validation::date('29 02 06', ['dmy'])); + } + + /** + * testDateDmyy method + */ + public function testDateDmyy(): void + { + $this->assertTrue(Validation::date('7-2-06', ['dmy'])); + $this->assertTrue(Validation::date('7.2.06', ['dmy'])); + $this->assertTrue(Validation::date('7/2/06', ['dmy'])); + $this->assertTrue(Validation::date('7 2 06', ['dmy'])); + $this->assertFalse(Validation::date('0-0-00', ['dmy'])); + $this->assertFalse(Validation::date('0.0.00', ['dmy'])); + $this->assertFalse(Validation::date('0/0/00', ['dmy'])); + $this->assertFalse(Validation::date('0 0 00', ['dmy'])); + $this->assertFalse(Validation::date('32-2-06', ['dmy'])); + $this->assertFalse(Validation::date('32.2.06', ['dmy'])); + $this->assertFalse(Validation::date('32/2/06', ['dmy'])); + $this->assertFalse(Validation::date('32 2 06', ['dmy'])); + } + + /** + * testDateDmyyLeapYear method + */ + public function testDateDmyyLeapYear(): void + { + $this->assertTrue(Validation::date('29-2-04', ['dmy'])); + $this->assertTrue(Validation::date('29.2.04', ['dmy'])); + $this->assertTrue(Validation::date('29/2/04', ['dmy'])); + $this->assertTrue(Validation::date('29 2 04', ['dmy'])); + $this->assertFalse(Validation::date('29-2-06', ['dmy'])); + $this->assertFalse(Validation::date('29.2.06', ['dmy'])); + $this->assertFalse(Validation::date('29/2/06', ['dmy'])); + $this->assertFalse(Validation::date('29 2 06', ['dmy'])); + } + + /** + * testDateDmyyyy method + */ + public function testDateDmyyyy(): void + { + $this->assertTrue(Validation::date('1-1-0001', ['dmy'])); + $this->assertTrue(Validation::date('7-2-2006', ['dmy'])); + $this->assertTrue(Validation::date('7.2.2006', ['dmy'])); + $this->assertTrue(Validation::date('7/2/2006', ['dmy'])); + $this->assertTrue(Validation::date('7 2 2006', ['dmy'])); + $this->assertFalse(Validation::date('0-0-0000', ['dmy'])); + $this->assertFalse(Validation::date('0.0.0000', ['dmy'])); + $this->assertFalse(Validation::date('0/0/0000', ['dmy'])); + $this->assertFalse(Validation::date('0 0 0000', ['dmy'])); + $this->assertFalse(Validation::date('1 1 300', ['dmy'])); + $this->assertFalse(Validation::date('32-2-2006', ['dmy'])); + $this->assertFalse(Validation::date('32.2.2006', ['dmy'])); + $this->assertFalse(Validation::date('32/2/2006', ['dmy'])); + $this->assertFalse(Validation::date('32 2 2006', ['dmy'])); + } + + /** + * testDateDmyyyyLeapYear method + */ + public function testDateDmyyyyLeapYear(): void + { + $this->assertTrue(Validation::date('29-2-0004', ['dmy'])); + $this->assertTrue(Validation::date('29-2-2004', ['dmy'])); + $this->assertTrue(Validation::date('29.2.2004', ['dmy'])); + $this->assertTrue(Validation::date('29/2/2004', ['dmy'])); + $this->assertTrue(Validation::date('29 2 2004', ['dmy'])); + $this->assertFalse(Validation::date('29-2-2006', ['dmy'])); + $this->assertFalse(Validation::date('29.2.2006', ['dmy'])); + $this->assertFalse(Validation::date('29/2/2006', ['dmy'])); + $this->assertFalse(Validation::date('29 2 2006', ['dmy'])); + } + + /** + * testDateMmddyyyy method + */ + public function testDateMmddyyyy(): void + { + $this->assertTrue(Validation::date('01-01-0001', ['mdy'])); + $this->assertTrue(Validation::date('12-27-2006', ['mdy'])); + $this->assertTrue(Validation::date('12.27.2006', ['mdy'])); + $this->assertTrue(Validation::date('12/27/2006', ['mdy'])); + $this->assertTrue(Validation::date('12 27 2006', ['mdy'])); + $this->assertFalse(Validation::date('00-00-0000', ['mdy'])); + $this->assertFalse(Validation::date('00.00.0000', ['mdy'])); + $this->assertFalse(Validation::date('00/00/0000', ['mdy'])); + $this->assertFalse(Validation::date('00 00 0000', ['mdy'])); + $this->assertFalse(Validation::date('10-31-300', ['mdy'])); + $this->assertFalse(Validation::date('11-31-2006', ['mdy'])); + $this->assertFalse(Validation::date('11.31.2006', ['mdy'])); + $this->assertFalse(Validation::date('11/31/2006', ['mdy'])); + $this->assertFalse(Validation::date('11 31 2006', ['mdy'])); + $this->assertFalse(Validation::date('0,-30-2006', ['mdy'])); + } + + /** + * testDateMmddyyyyLeapYear method + */ + public function testDateMmddyyyyLeapYear(): void + { + $this->assertTrue(Validation::date('02-29-0004', ['mdy'])); + $this->assertTrue(Validation::date('02-29-2004', ['mdy'])); + $this->assertTrue(Validation::date('02.29.2004', ['mdy'])); + $this->assertTrue(Validation::date('02/29/2004', ['mdy'])); + $this->assertTrue(Validation::date('02 29 2004', ['mdy'])); + $this->assertFalse(Validation::date('02-29-2006', ['mdy'])); + $this->assertFalse(Validation::date('02.29.2006', ['mdy'])); + $this->assertFalse(Validation::date('02/29/2006', ['mdy'])); + $this->assertFalse(Validation::date('02 29 2006', ['mdy'])); + } + + /** + * testDateMmddyy method + */ + public function testDateMmddyy(): void + { + $this->assertTrue(Validation::date('12-27-06', ['mdy'])); + $this->assertTrue(Validation::date('12.27.06', ['mdy'])); + $this->assertTrue(Validation::date('12/27/06', ['mdy'])); + $this->assertTrue(Validation::date('12 27 06', ['mdy'])); + $this->assertFalse(Validation::date('00-00-00', ['mdy'])); + $this->assertFalse(Validation::date('00.00.00', ['mdy'])); + $this->assertFalse(Validation::date('00/00/00', ['mdy'])); + $this->assertFalse(Validation::date('00 00 00', ['mdy'])); + $this->assertFalse(Validation::date('11-31-06', ['mdy'])); + $this->assertFalse(Validation::date('11.31.06', ['mdy'])); + $this->assertFalse(Validation::date('11/31/06', ['mdy'])); + $this->assertFalse(Validation::date('11 31 06', ['mdy'])); + $this->assertFalse(Validation::date('0,-30-06', ['mdy'])); + } + + /** + * testDateMmddyyLeapYear method + */ + public function testDateMmddyyLeapYear(): void + { + $this->assertTrue(Validation::date('02-29-04', ['mdy'])); + $this->assertTrue(Validation::date('02.29.04', ['mdy'])); + $this->assertTrue(Validation::date('02/29/04', ['mdy'])); + $this->assertTrue(Validation::date('02 29 04', ['mdy'])); + $this->assertFalse(Validation::date('02-29-06', ['mdy'])); + $this->assertFalse(Validation::date('02.29.06', ['mdy'])); + $this->assertFalse(Validation::date('02/29/06', ['mdy'])); + $this->assertFalse(Validation::date('02 29 06', ['mdy'])); + } + + /** + * testDateMdyy method + */ + public function testDateMdyy(): void + { + $this->assertTrue(Validation::date('2-7-06', ['mdy'])); + $this->assertTrue(Validation::date('2.7.06', ['mdy'])); + $this->assertTrue(Validation::date('2/7/06', ['mdy'])); + $this->assertTrue(Validation::date('2 7 06', ['mdy'])); + $this->assertFalse(Validation::date('0-0-00', ['mdy'])); + $this->assertFalse(Validation::date('0.0.00', ['mdy'])); + $this->assertFalse(Validation::date('0/0/00', ['mdy'])); + $this->assertFalse(Validation::date('0 0 00', ['mdy'])); + $this->assertFalse(Validation::date('2-32-06', ['mdy'])); + $this->assertFalse(Validation::date('2.32.06', ['mdy'])); + $this->assertFalse(Validation::date('2/32/06', ['mdy'])); + $this->assertFalse(Validation::date('2 32 06', ['mdy'])); + } + + /** + * testDateMdyyLeapYear method + */ + public function testDateMdyyLeapYear(): void + { + $this->assertTrue(Validation::date('2-29-04', ['mdy'])); + $this->assertTrue(Validation::date('2.29.04', ['mdy'])); + $this->assertTrue(Validation::date('2/29/04', ['mdy'])); + $this->assertTrue(Validation::date('2 29 04', ['mdy'])); + $this->assertFalse(Validation::date('2-29-06', ['mdy'])); + $this->assertFalse(Validation::date('2.29.06', ['mdy'])); + $this->assertFalse(Validation::date('2/29/06', ['mdy'])); + $this->assertFalse(Validation::date('2 29 06', ['mdy'])); + } + + /** + * testDateMdyyyy method + */ + public function testDateMdyyyy(): void + { + $this->assertTrue(Validation::date('1-1-0001', ['mdy'])); + $this->assertTrue(Validation::date('2-7-2006', ['mdy'])); + $this->assertTrue(Validation::date('2.7.2006', ['mdy'])); + $this->assertTrue(Validation::date('2/7/2006', ['mdy'])); + $this->assertTrue(Validation::date('2 7 2006', ['mdy'])); + $this->assertFalse(Validation::date('0-0-0000', ['mdy'])); + $this->assertFalse(Validation::date('0.0.0000', ['mdy'])); + $this->assertFalse(Validation::date('0/0/0000', ['mdy'])); + $this->assertFalse(Validation::date('0 0 0000', ['mdy'])); + $this->assertFalse(Validation::date('2-21-300', ['mdy'])); + $this->assertFalse(Validation::date('2-32-2006', ['mdy'])); + $this->assertFalse(Validation::date('2.32.2006', ['mdy'])); + $this->assertFalse(Validation::date('2/32/2006', ['mdy'])); + $this->assertFalse(Validation::date('2 32 2006', ['mdy'])); + } + + /** + * testDateMdyyyyLeapYear method + */ + public function testDateMdyyyyLeapYear(): void + { + $this->assertTrue(Validation::date('2-29-0004', ['mdy'])); + $this->assertTrue(Validation::date('2-29-2004', ['mdy'])); + $this->assertTrue(Validation::date('2.29.2004', ['mdy'])); + $this->assertTrue(Validation::date('2/29/2004', ['mdy'])); + $this->assertTrue(Validation::date('2 29 2004', ['mdy'])); + $this->assertFalse(Validation::date('2-29-2006', ['mdy'])); + $this->assertFalse(Validation::date('2.29.2006', ['mdy'])); + $this->assertFalse(Validation::date('2/29/2006', ['mdy'])); + $this->assertFalse(Validation::date('2 29 2006', ['mdy'])); + } + + /** + * testDateYyyymmdd method + */ + public function testDateYyyymmdd(): void + { + $this->assertTrue(Validation::date('0001-01-01', ['ymd'])); + $this->assertTrue(Validation::date('0401-01-01', ['ymd'])); + $this->assertTrue(Validation::date('2006-12-27', ['ymd'])); + $this->assertTrue(Validation::date('2006.12.27', ['ymd'])); + $this->assertTrue(Validation::date('2006/12/27', ['ymd'])); + $this->assertTrue(Validation::date('2006 12 27', ['ymd'])); + $this->assertFalse(Validation::date('300-01-31', ['ymd'])); + $this->assertFalse(Validation::date('2006-11-31', ['ymd'])); + $this->assertFalse(Validation::date('2006.11.31', ['ymd'])); + $this->assertFalse(Validation::date('2006/11/31', ['ymd'])); + $this->assertFalse(Validation::date('2006 11 31', ['ymd'])); + $this->assertFalse(Validation::date('2006-0,-30', ['ymd'])); + } + + /** + * testDateYyyymmddLeapYear method + */ + public function testDateYyyymmddLeapYear(): void + { + $this->assertTrue(Validation::date('0004-02-29', ['ymd'])); + $this->assertTrue(Validation::date('2004-02-29', ['ymd'])); + $this->assertTrue(Validation::date('2004.02.29', ['ymd'])); + $this->assertTrue(Validation::date('2004/02/29', ['ymd'])); + $this->assertTrue(Validation::date('2004 02 29', ['ymd'])); + $this->assertFalse(Validation::date('0000-02-29', ['ymd'])); + $this->assertFalse(Validation::date('2006-02-29', ['ymd'])); + $this->assertFalse(Validation::date('2006.02.29', ['ymd'])); + $this->assertFalse(Validation::date('2006/02/29', ['ymd'])); + $this->assertFalse(Validation::date('2006 02 29', ['ymd'])); + } + + /** + * testDateYymmdd method + */ + public function testDateYymmdd(): void + { + $this->assertTrue(Validation::date('06-12-27', ['ymd'])); + $this->assertTrue(Validation::date('06.12.27', ['ymd'])); + $this->assertTrue(Validation::date('06/12/27', ['ymd'])); + $this->assertTrue(Validation::date('06 12 27', ['ymd'])); + $this->assertFalse(Validation::date('12/27/2600', ['ymd'])); + $this->assertFalse(Validation::date('12.27.2600', ['ymd'])); + $this->assertFalse(Validation::date('12/27/2600', ['ymd'])); + $this->assertFalse(Validation::date('12 27 2600', ['ymd'])); + $this->assertFalse(Validation::date('06-11-31', ['ymd'])); + $this->assertFalse(Validation::date('06.11.31', ['ymd'])); + $this->assertFalse(Validation::date('06/11/31', ['ymd'])); + $this->assertFalse(Validation::date('06 11 31', ['ymd'])); + $this->assertFalse(Validation::date('06-0,-30', ['ymd'])); + } + + /** + * testDateYymmddLeapYear method + */ + public function testDateYymmddLeapYear(): void + { + $this->assertTrue(Validation::date('0004-04-29', ['ymd'])); + $this->assertTrue(Validation::date('2004-02-29', ['ymd'])); + $this->assertTrue(Validation::date('2004.02.29', ['ymd'])); + $this->assertTrue(Validation::date('2004/02/29', ['ymd'])); + $this->assertTrue(Validation::date('2004 02 29', ['ymd'])); + $this->assertFalse(Validation::date('2006-02-29', ['ymd'])); + $this->assertFalse(Validation::date('2006.02.29', ['ymd'])); + $this->assertFalse(Validation::date('2006/02/29', ['ymd'])); + $this->assertFalse(Validation::date('2006 02 29', ['ymd'])); + } + + /** + * testDateDdMMMMyyyy method + */ + public function testDateDdMMMMyyyy(): void + { + $this->assertTrue(Validation::date('01 January 0001', ['dMy'])); + $this->assertTrue(Validation::date('27 December 2006', ['dMy'])); + $this->assertTrue(Validation::date('27 Dec 2006', ['dMy'])); + $this->assertFalse(Validation::date('2006 Dec 27', ['dMy'])); + $this->assertFalse(Validation::date('2006 December 27', ['dMy'])); + } + + /** + * testDateDdMMMMyyyyLeapYear method + */ + public function testDateDdMMMMyyyyLeapYear(): void + { + $this->assertTrue(Validation::date('29 February 0004', ['dMy'])); + $this->assertTrue(Validation::date('29 February 2004', ['dMy'])); + $this->assertFalse(Validation::date('29 February 2006', ['dMy'])); + } + + /** + * testDateMmmmDdyyyy method + */ + public function testDateMmmmDdyyyy(): void + { + $this->assertTrue(Validation::date('January 01, 0001', ['Mdy'])); + $this->assertTrue(Validation::date('December 27, 2006', ['Mdy'])); + $this->assertTrue(Validation::date('Dec 27, 2006', ['Mdy'])); + $this->assertTrue(Validation::date('December 27 2006', ['Mdy'])); + $this->assertTrue(Validation::date('Dec 27 2006', ['Mdy'])); + $this->assertFalse(Validation::date('27 Dec 2006', ['Mdy'])); + $this->assertFalse(Validation::date('2006 December 27', ['Mdy'])); + $this->assertTrue(Validation::date('Sep 12, 2011', ['Mdy'])); + } + + /** + * testDateMmmmDdyyyyLeapYear method + */ + public function testDateMmmmDdyyyyLeapYear(): void + { + $this->assertTrue(Validation::date('February 29, 0004', ['Mdy'])); + $this->assertTrue(Validation::date('February 29, 2004', ['Mdy'])); + $this->assertTrue(Validation::date('Feb 29, 2004', ['Mdy'])); + $this->assertTrue(Validation::date('February 29 2004', ['Mdy'])); + $this->assertTrue(Validation::date('Feb 29 2004', ['Mdy'])); + $this->assertFalse(Validation::date('February 29, 2006', ['Mdy'])); + } + + /** + * testDateMy method + */ + public function testDateMy(): void + { + $this->assertTrue(Validation::date('January 0001', ['My'])); + $this->assertTrue(Validation::date('December 2006', ['My'])); + $this->assertTrue(Validation::date('Dec 2006', ['My'])); + $this->assertTrue(Validation::date('December/2006', ['My'])); + $this->assertTrue(Validation::date('Dec/2006', ['My'])); + } + + /** + * testDateMyNumeric method + */ + public function testDateMyNumeric(): void + { + $this->assertTrue(Validation::date('01/0001', ['my'])); + $this->assertTrue(Validation::date('01/2006', ['my'])); + $this->assertTrue(Validation::date('12-2006', ['my'])); + $this->assertTrue(Validation::date('12.2006', ['my'])); + $this->assertTrue(Validation::date('12 2006', ['my'])); + $this->assertTrue(Validation::date('01/06', ['my'])); + $this->assertTrue(Validation::date('12-06', ['my'])); + $this->assertTrue(Validation::date('12.06', ['my'])); + $this->assertTrue(Validation::date('12 06', ['my'])); + $this->assertFalse(Validation::date('13 06', ['my'])); + $this->assertFalse(Validation::date('13 2006', ['my'])); + } + + /** + * testDateYmNumeric method + */ + public function testDateYmNumeric(): void + { + $this->assertTrue(Validation::date('0001/01', ['ym'])); + $this->assertTrue(Validation::date('2006/12', ['ym'])); + $this->assertTrue(Validation::date('2006-12', ['ym'])); + $this->assertTrue(Validation::date('2006-12', ['ym'])); + $this->assertTrue(Validation::date('2006 12', ['ym'])); + $this->assertTrue(Validation::date('2006 12', ['ym'])); + $this->assertTrue(Validation::date('1900-01', ['ym'])); + $this->assertTrue(Validation::date('2153-01', ['ym'])); + $this->assertTrue(Validation::date('06/12', ['ym'])); + $this->assertTrue(Validation::date('06-12', ['ym'])); + $this->assertTrue(Validation::date('06-12', ['ym'])); + $this->assertTrue(Validation::date('06 12', ['ym'])); + $this->assertFalse(Validation::date('2006/12 ', ['ym'])); + $this->assertFalse(Validation::date('2006/12/', ['ym'])); + $this->assertFalse(Validation::date('06/12 ', ['ym'])); + $this->assertFalse(Validation::date('06/13 ', ['ym'])); + } + + /** + * testDateY method + */ + public function testDateY(): void + { + $this->assertTrue(Validation::date('0001', ['y'])); + $this->assertTrue(Validation::date('1900', ['y'])); + $this->assertTrue(Validation::date('1984', ['y'])); + $this->assertTrue(Validation::date('2006', ['y'])); + $this->assertTrue(Validation::date('2008', ['y'])); + $this->assertTrue(Validation::date('2013', ['y'])); + $this->assertTrue(Validation::date('2104', ['y'])); + $this->assertTrue(Validation::date('1899', ['y'])); + $this->assertFalse(Validation::date('20009', ['y'])); + $this->assertFalse(Validation::date('0000', ['y'])); + $this->assertFalse(Validation::date(' 2012', ['y'])); + $this->assertFalse(Validation::date('3000', ['y'])); + } + + /** + * test date validation when passing an array + */ + public function testDateArray(): void + { + $date = ['year' => 2014, 'month' => 2, 'day' => 14]; + $this->assertTrue(Validation::date($date)); + $date = ['year' => 'farts', 'month' => 'derp', 'day' => 'farts']; + $this->assertFalse(Validation::date($date)); + + $date = ['year' => 2014, 'month' => 2, 'day' => 14]; + $this->assertTrue(Validation::date($date, 'mdy')); + } + + /** + * test datetime validation when passing an array + */ + public function testDateTimeArray(): void + { + $date = [ + 'year' => 2014, + 'month' => '02', + 'day' => '14', + 'hour' => '12', + 'minute' => '14', + 'second' => '15', + 'microsecond' => '123456', + 'meridian' => 'pm', + ]; + $this->assertTrue(Validation::datetime($date)); + + $date = ['year' => 2014, 'month' => 2, 'day' => 14, 'hour' => 13, 'minute' => 14, 'second' => 15]; + $this->assertTrue(Validation::datetime($date)); + + $date = [ + 'year' => 2014, 'month' => 2, 'day' => 14, + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'am', + ]; + $this->assertTrue(Validation::datetime($date)); + $this->assertTrue(Validation::datetime($date, 'mdy')); + + // test fractional seconds greater than microseconds failing + $date = [ + 'year' => 2014, + 'month' => '02', + 'day' => '14', + 'hour' => '00', + 'minute' => '00', + 'second' => '00', + 'microsecond' => '1234567', + ]; + $this->assertFalse(Validation::datetime($date)); + + $date = [ + 'year' => '2014', 'month' => '02', 'day' => '14', + 'hour' => 'farts', 'minute' => 'farts', + ]; + $this->assertFalse(Validation::datetime($date)); + } + + /** + * Test validating dates with multiple formats + */ + public function testDateMultiple(): void + { + $this->assertTrue(Validation::date('2011-12-31', ['ymd', 'dmy'])); + $this->assertTrue(Validation::date('31-12-2011', ['ymd', 'dmy'])); + } + + /** + * testTime method + */ + public function testTime(): void + { + $this->assertTrue(Validation::time('00:00')); + $this->assertTrue(Validation::time('23:59')); + $this->assertTrue(Validation::time('12:00')); + $this->assertTrue(Validation::time('12:01')); + $this->assertTrue(Validation::time('12:01am')); + $this->assertTrue(Validation::time('12:01pm')); + $this->assertTrue(Validation::time('1pm')); + $this->assertTrue(Validation::time('1 pm')); + $this->assertTrue(Validation::time('1 PM')); + $this->assertTrue(Validation::time('01:00')); + $this->assertTrue(Validation::time('1:00pm')); + $this->assertTrue(Validation::time(new ChronosTime())); + + $this->assertFalse(Validation::time('1:00')); + $this->assertFalse(Validation::time('13:00pm')); + $this->assertFalse(Validation::time('9:00')); + $this->assertFalse(Validation::time('00')); + $this->assertFalse(Validation::time('0')); + $this->assertFalse(Validation::time('09')); + $this->assertFalse(Validation::time('9')); + $this->assertFalse(Validation::time('10')); + $this->assertFalse(Validation::time('23')); + $this->assertFalse(Validation::time('24:00')); + } + + /** + * test time validation when passing an array + */ + public function testTimeArray(): void + { + $date = ['hour' => 13, 'minute' => 14, 'second' => 15]; + $this->assertTrue(Validation::time($date)); + + $date = [ + 'hour' => 1, 'minute' => 14, 'second' => 15, + 'meridian' => 'am', + ]; + $this->assertTrue(Validation::time($date)); + + $date = [ + 'hour' => 12, 'minute' => 14, 'second' => 15, + 'meridian' => 'pm', + ]; + $this->assertTrue(Validation::time($date)); + + $date = [ + 'hour' => 'farts', 'minute' => 'farts', + ]; + $this->assertFalse(Validation::time($date)); + + $date = []; + $this->assertFalse(Validation::time($date)); + } + + /** + * Tests that it is possible to pass a median (AM, PM) to the dateTime validation + */ + public function testDateTimeWithMeriadian(): void + { + $this->assertTrue(Validation::dateTime('10/04/2007 1:50 AM', ['dmy'])); + $this->assertTrue(Validation::dateTime('12/04/2017 1:38 PM', ['dmy'])); + $this->assertTrue(Validation::dateTime('10/04/2007 1:50 am', ['dmy'])); + $this->assertTrue(Validation::dateTime('12/04/2017 1:38 pm', ['dmy'])); + $this->assertTrue(Validation::dateTime('12/04/2017 1:38pm', ['dmy'])); + $this->assertTrue(Validation::dateTime('12/04/2017 1:38AM', ['dmy'])); + $this->assertTrue(Validation::dateTime('12/04/2017, 1:38AM', ['dmy'])); + $this->assertTrue(Validation::dateTime('28/10/2015, 3:21 PM', ['dmy'])); + $this->assertFalse(Validation::dateTime('12/04/2017 58:38AM', ['dmy'])); + } + + /** + * Tests that it is possible to pass a date with a T separator + */ + public function testDateTimeISO(): void + { + $this->assertTrue(Validation::dateTime('2007/10/04T01:50')); + $this->assertTrue(Validation::dateTime('2017/12/04T15:38')); + $this->assertTrue(Validation::dateTime('04.12.2017T15:38', ['dmy'])); + $this->assertTrue(Validation::dateTime('24-02-2019T2:38am', ['dmy'])); + $this->assertFalse(Validation::dateTime('2007/10/04T1:50')); + $this->assertFalse(Validation::dateTime('2007/10/04T58:38')); + } + + /** + * Tests that it is possible to pass an ISO8601 value + * + * @see Validation tests values credits: https://www.myintervals.com/blog/2009/05/20/iso-8601-date-validation-that-doesnt-suck/ + */ + public function testDateTimeISO8601(): void + { + // Valid ISO8601 + $this->assertTrue(Validation::iso8601('2007')); + $this->assertTrue(Validation::iso8601('2007-12')); + $this->assertTrue(Validation::iso8601('2009W511')); + $this->assertTrue(Validation::iso8601('2007-12-26')); + $this->assertTrue(Validation::iso8601('2007-12-26T15:20+02:00')); + $this->assertTrue(Validation::iso8601('2007-12-26T15:20:39+02:00')); + $this->assertTrue(Validation::iso8601('2007-12-26T15:20:39.59+02:00')); + // Invalid ISO8601 + $this->assertFalse(Validation::iso8601('2009-')); + $this->assertFalse(Validation::iso8601('2009M511')); + $this->assertFalse(Validation::iso8601('2009-05-19T14a39r')); + $this->assertFalse(Validation::iso8601('2010-02-18T16:23.33.600')); + // Valid ISO8601 but incomplete date + $this->assertFalse(Validation::datetime('2007', 'iso8601')); + $this->assertFalse(Validation::datetime('2007-12', 'iso8601')); + $this->assertFalse(Validation::datetime('2009W511', 'iso8601')); + $this->assertFalse(Validation::datetime('2007-12-26', 'iso8601')); + // Valid ISO8601 and complete date and time + $this->assertTrue(Validation::datetime('2007-12-26T15:20+02:00', 'iso8601')); + $this->assertTrue(Validation::datetime('2007-12-26T15:20:39+02:00', 'iso8601')); + $this->assertTrue(Validation::datetime('2007-12-26T15:20:39.59+02:00', 'iso8601')); + // Valid ISO8601 and complete date and time BUT Weekdays are not validated by Validation::date() + $this->assertFalse(Validation::datetime('2009-W21-2T01:22', 'iso8601')); + } + + /** + * Test localizedTime + */ + public function testLocalizedTime(): void + { + $this->assertTrue(Validation::localizedTime(new ChronosTime())); + + $this->assertFalse(Validation::localizedTime('', 'date')); + $this->assertFalse(Validation::localizedTime('invalid', 'date')); + $this->assertFalse(Validation::localizedTime(1, 'date')); + $this->assertFalse(Validation::localizedTime(['an array'], 'date')); + + // English (US) + I18n::setLocale('en_US'); + $this->assertTrue(Validation::localizedTime('12/31/2006', 'date')); + $this->assertTrue(Validation::localizedTime('6.40pm', 'time')); + $this->assertTrue(Validation::localizedTime('12/31/2006 6.40pm', 'datetime')); + $this->assertTrue(Validation::localizedTime('December 31, 2006', 'date')); + + $this->assertFalse(Validation::localizedTime('31. Dezember 2006', 'date')); // non-US format + $this->assertFalse(Validation::localizedTime('18:40', 'time')); // non-US format + + // German + I18n::setLocale('de_DE'); + $this->assertTrue(Validation::localizedTime('31.12.2006', 'date')); + $this->assertTrue(Validation::localizedTime('31. Dezember 2006', 'date')); + $this->assertTrue(Validation::localizedTime('18:40', 'time')); + + $this->assertFalse(Validation::localizedTime('December 31, 2006', 'date')); // non-German format + + // Russian + I18n::setLocale('ru_RU'); + $this->assertTrue(Validation::localizedTime('31 декабря 2006', 'date')); + + $this->assertFalse(Validation::localizedTime('December 31, 2006', 'date')); // non-Russian format + } + + /** + * testBoolean method + */ + public function testBoolean(): void + { + $this->assertTrue(Validation::boolean('0')); + $this->assertTrue(Validation::boolean('1')); + $this->assertTrue(Validation::boolean(0)); + $this->assertTrue(Validation::boolean(1)); + $this->assertTrue(Validation::boolean(true)); + $this->assertTrue(Validation::boolean(false)); + $this->assertFalse(Validation::boolean('true')); + $this->assertFalse(Validation::boolean('false')); + $this->assertFalse(Validation::boolean('-1')); + $this->assertFalse(Validation::boolean('2')); + $this->assertFalse(Validation::boolean('Boo!')); + } + + /** + * testBooleanWithOptions method + */ + public function testBooleanWithOptions(): void + { + $this->assertTrue(Validation::boolean('0', ['0', '1'])); + $this->assertTrue(Validation::boolean('1', ['0', '1'])); + $this->assertFalse(Validation::boolean(0, ['0', '1'])); + $this->assertFalse(Validation::boolean(1, ['0', '1'])); + $this->assertFalse(Validation::boolean(false, ['0', '1'])); + $this->assertFalse(Validation::boolean(true, ['0', '1'])); + $this->assertFalse(Validation::boolean('false', ['0', '1'])); + $this->assertFalse(Validation::boolean('true', ['0', '1'])); + $this->assertTrue(Validation::boolean(0, [0, 1])); + $this->assertTrue(Validation::boolean(1, [0, 1])); + } + + /** + * testTruthy method + */ + public function testTruthy(): void + { + $this->assertTrue(Validation::truthy(1)); + $this->assertTrue(Validation::truthy(true)); + $this->assertTrue(Validation::truthy('1')); + + $this->assertFalse(Validation::truthy('true')); + $this->assertFalse(Validation::truthy('on')); + $this->assertFalse(Validation::truthy('yes')); + + $this->assertFalse(Validation::truthy(0)); + $this->assertFalse(Validation::truthy(false)); + $this->assertFalse(Validation::truthy('0')); + $this->assertFalse(Validation::truthy('false')); + + $this->assertTrue(Validation::truthy('on', ['on', 'yes', 'true'])); + $this->assertTrue(Validation::truthy('yes', ['on', 'yes', 'true'])); + $this->assertTrue(Validation::truthy('true', ['on', 'yes', 'true'])); + + $this->assertFalse(Validation::truthy(1, ['on', 'yes', 'true'])); + $this->assertFalse(Validation::truthy(true, ['on', 'yes', 'true'])); + $this->assertFalse(Validation::truthy('1', ['on', 'yes', 'true'])); + + $this->assertTrue(Validation::truthy('true', ['on', 'yes', 'true'])); + } + + /** + * testTruthy method + */ + public function testFalsey(): void + { + $this->assertTrue(Validation::falsey(0)); + $this->assertTrue(Validation::falsey(false)); + $this->assertTrue(Validation::falsey('0')); + + $this->assertFalse(Validation::falsey('false')); + $this->assertFalse(Validation::falsey('off')); + $this->assertFalse(Validation::falsey('no')); + + $this->assertFalse(Validation::falsey(1)); + $this->assertFalse(Validation::falsey(true)); + $this->assertFalse(Validation::falsey('1')); + $this->assertFalse(Validation::falsey('true')); + + $this->assertTrue(Validation::falsey('off', ['off', 'no', 'false'])); + $this->assertTrue(Validation::falsey('no', ['off', 'no', 'false'])); + $this->assertTrue(Validation::falsey('false', ['off', 'no', 'false'])); + + $this->assertFalse(Validation::falsey(0, ['off', 'no', 'false'])); + $this->assertFalse(Validation::falsey(false, ['off', 'no', 'false'])); + $this->assertFalse(Validation::falsey('0', ['off', 'yes', 'false'])); + + $this->assertTrue(Validation::falsey('false', ['off', 'no', 'false'])); + } + + /** + * testDateCustomRegx method + */ + public function testDateCustomRegx(): void + { + $this->assertTrue(Validation::date('2006-12-27', 'ymd', '%^(19|20)[0-9]{2}[- /.](0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])$%')); + $this->assertFalse(Validation::date('12-27-2006', 'ymd', '%^(19|20)[0-9]{2}[- /.](0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])$%')); + } + + /** + * Test numbers with any number of decimal places, including none. + */ + public function testDecimalWithPlacesNull(): void + { + $this->assertTrue(Validation::decimal('+1234.54321')); + $this->assertTrue(Validation::decimal('-1234.54321')); + $this->assertTrue(Validation::decimal('1234.54321')); + $this->assertTrue(Validation::decimal('+0123.45e6')); + $this->assertTrue(Validation::decimal('-0123.45e6')); + $this->assertTrue(Validation::decimal('0123.45e6')); + $this->assertTrue(Validation::decimal(1234.56)); + $this->assertTrue(Validation::decimal(1234.00)); + $this->assertTrue(Validation::decimal(1234.)); + $this->assertTrue(Validation::decimal('1234.00')); + $this->assertTrue(Validation::decimal(.0)); + $this->assertTrue(Validation::decimal(.00)); + $this->assertTrue(Validation::decimal('.00')); + $this->assertTrue(Validation::decimal(.01)); + $this->assertTrue(Validation::decimal('.01')); + $this->assertTrue(Validation::decimal('1234')); + $this->assertTrue(Validation::decimal('-1234')); + $this->assertTrue(Validation::decimal('+1234')); + $this->assertTrue(Validation::decimal((float)1234)); + $this->assertTrue(Validation::decimal((float)1234)); + $this->assertTrue(Validation::decimal((int)1234)); + + $this->assertFalse(Validation::decimal('')); + $this->assertFalse(Validation::decimal('string')); + $this->assertFalse(Validation::decimal('1234.')); + } + + /** + * Test numbers with any number of decimal places greater than 0, or a float|double. + */ + public function testDecimalWithPlacesTrue(): void + { + $this->assertTrue(Validation::decimal('+1234.54321', true)); + $this->assertTrue(Validation::decimal('-1234.54321', true)); + $this->assertTrue(Validation::decimal('1234.54321', true)); + $this->assertTrue(Validation::decimal('+0123.45e6', true)); + $this->assertTrue(Validation::decimal('-0123.45e6', true)); + $this->assertTrue(Validation::decimal('0123.45e6', true)); + $this->assertTrue(Validation::decimal(1234.56, true)); + $this->assertTrue(Validation::decimal(1234.00, true)); + $this->assertTrue(Validation::decimal(1234., true)); + $this->assertTrue(Validation::decimal('1234.00', true)); + $this->assertTrue(Validation::decimal(.0, true)); + $this->assertTrue(Validation::decimal(.00, true)); + $this->assertTrue(Validation::decimal('.00', true)); + $this->assertTrue(Validation::decimal(.01, true)); + $this->assertTrue(Validation::decimal('.01', true)); + $this->assertTrue(Validation::decimal((float)1234, true)); + $this->assertTrue(Validation::decimal((float)1234, true)); + + $this->assertFalse(Validation::decimal('', true)); + $this->assertFalse(Validation::decimal('string', true)); + $this->assertFalse(Validation::decimal('1234.', true)); + $this->assertFalse(Validation::decimal((int)1234, true)); + $this->assertFalse(Validation::decimal('1234', true)); + $this->assertFalse(Validation::decimal('-1234', true)); + $this->assertFalse(Validation::decimal('+1234', true)); + } + + /** + * Test numbers with exactly that many number of decimal places. + */ + public function testDecimalWithPlacesNumeric(): void + { + $this->assertTrue(Validation::decimal(0.27, 2)); + $this->assertTrue(Validation::decimal(-0.27, 2)); + $this->assertTrue(Validation::decimal(0.27, 2)); + $this->assertTrue(Validation::decimal(0.277, 3)); + $this->assertTrue(Validation::decimal(-0.277, 3)); + $this->assertTrue(Validation::decimal(0.277, 3)); + $this->assertTrue(Validation::decimal(1234.5678, 4)); + $this->assertTrue(Validation::decimal(-1234.5678, 4)); + $this->assertTrue(Validation::decimal(1234.5678, 4)); + $this->assertTrue(Validation::decimal('.00', 2)); + $this->assertTrue(Validation::decimal(.01, 2)); + $this->assertTrue(Validation::decimal('.01', 2)); + + $this->assertFalse(Validation::decimal('', 1)); + $this->assertFalse(Validation::decimal('string', 1)); + $this->assertFalse(Validation::decimal(1234., 1)); + $this->assertFalse(Validation::decimal('1234.', 1)); + $this->assertFalse(Validation::decimal(.0, 1)); + $this->assertFalse(Validation::decimal(.00, 2)); + $this->assertFalse(Validation::decimal((float)1234, 1)); + $this->assertFalse(Validation::decimal((float)1234, 1)); + $this->assertFalse(Validation::decimal((int)1234, 1)); + $this->assertFalse(Validation::decimal(1234.5678, 3)); + $this->assertFalse(Validation::decimal(-1234.5678, 3)); + $this->assertFalse(Validation::decimal(1234.5678, 3)); + } + + /** + * testDecimalCustomRegex method + */ + public function testDecimalCustomRegex(): void + { + $this->assertTrue(Validation::decimal('1.54321', null, '/^[-+]?[0-9]+(\\.[0-9]+)?$/s')); + $this->assertFalse(Validation::decimal('.54321', null, '/^[-+]?[0-9]+(\\.[0-9]+)?$/s')); + } + + /** + * Test localized floats with decimal. + */ + public function testDecimalLocaleSet(): void + { + $this->skipIf(DS === '\\', 'The locale is not supported in Windows and affects other tests.'); + $this->skipIf(Locale::setDefault('da_DK') === false, "The Danish locale isn't available."); + + $this->assertTrue(Validation::decimal(1.54), '1.54 should be considered a valid decimal'); + $this->assertTrue(Validation::decimal('1.54'), '"1.54" should be considered a valid decimal'); + + $this->assertTrue(Validation::decimal(12345.67), '12345.67 should be considered a valid decimal'); + $this->assertTrue(Validation::decimal('12,345.67'), '"12,345.67" should be considered a valid decimal'); + + $this->skipIf(Locale::setDefault('pl_PL') === false, "The Polish locale isn't available."); + $this->assertTrue(Validation::decimal('1 200,99'), 'should be considered a valid decimal'); + } + + /** + * testEmail method + */ + public function testEmail(): void + { + $this->assertTrue(Validation::email('abc.efg@domain.com')); + $this->assertTrue(Validation::email('efg@domain.com')); + $this->assertTrue(Validation::email('abc-efg@domain.com')); + $this->assertTrue(Validation::email('abc_efg@domain.com')); + $this->assertTrue(Validation::email('raw@test.ra.ru')); + $this->assertTrue(Validation::email('abc-efg@domain-hyphened.com')); + $this->assertTrue(Validation::email("p.o'malley@domain.com")); + $this->assertTrue(Validation::email('abc+efg@domain.com')); + $this->assertTrue(Validation::email('abc&efg@domain.com')); + $this->assertTrue(Validation::email('abc.efg@12345.com')); + $this->assertTrue(Validation::email('abc.efg@12345.co.jp')); + $this->assertTrue(Validation::email('abc@g.cn')); + $this->assertTrue(Validation::email('abc@x.com')); + $this->assertTrue(Validation::email('henrik@sbcglobal.net')); + $this->assertTrue(Validation::email('sani@sbcglobal.net')); + + // all ICANN TLDs + $this->assertTrue(Validation::email('abc@example.aero')); + $this->assertTrue(Validation::email('abc@example.asia')); + $this->assertTrue(Validation::email('abc@example.biz')); + $this->assertTrue(Validation::email('abc@example.cat')); + $this->assertTrue(Validation::email('abc@example.com')); + $this->assertTrue(Validation::email('abc@example.coop')); + $this->assertTrue(Validation::email('abc@example.edu')); + $this->assertTrue(Validation::email('abc@example.gov')); + $this->assertTrue(Validation::email('abc@example.info')); + $this->assertTrue(Validation::email('abc@example.int')); + $this->assertTrue(Validation::email('abc@example.jobs')); + $this->assertTrue(Validation::email('abc@example.mil')); + $this->assertTrue(Validation::email('abc@example.mobi')); + $this->assertTrue(Validation::email('abc@example.museum')); + $this->assertTrue(Validation::email('abc@example.name')); + $this->assertTrue(Validation::email('abc@example.net')); + $this->assertTrue(Validation::email('abc@example.org')); + $this->assertTrue(Validation::email('abc@example.pro')); + $this->assertTrue(Validation::email('abc@example.tel')); + $this->assertTrue(Validation::email('abc@example.travel')); + $this->assertTrue(Validation::email('someone@st.t-com.hr')); + + // gTLD's + $this->assertTrue(Validation::email('example@host.local')); + $this->assertTrue(Validation::email('example@x.org')); + $this->assertTrue(Validation::email('example@host.xxx')); + + // strange, but technically valid email addresses + $this->assertTrue(Validation::email('S=postmaster/OU=rz/P=uni-frankfurt/A=d400/C=de@gateway.d400.de')); + $this->assertTrue(Validation::email('customer/department=shipping@example.com')); + $this->assertTrue(Validation::email('$A12345@example.com')); + $this->assertTrue(Validation::email('!def!xyz%abc@example.com')); + $this->assertTrue(Validation::email('_somename@example.com')); + + // Unicode + $this->assertTrue(Validation::email('some@eräume.foo')); + $this->assertTrue(Validation::email('äu@öe.eräume.foo')); + $this->assertTrue(Validation::email('Nyrée.surname@example.com')); + + // invalid addresses + $this->assertFalse(Validation::email('abc@example')); + $this->assertFalse(Validation::email('abc@example.c')); + $this->assertFalse(Validation::email('abc@example.com.')); + $this->assertFalse(Validation::email('abc.@example.com')); + $this->assertFalse(Validation::email('abc@example..com')); + $this->assertFalse(Validation::email('abc@example.com.a')); + $this->assertFalse(Validation::email('abc;@example.com')); + $this->assertFalse(Validation::email('abc@example.com;')); + $this->assertFalse(Validation::email('abc@efg@example.com')); + $this->assertFalse(Validation::email('abc@@example.com')); + $this->assertFalse(Validation::email('abc efg@example.com')); + $this->assertFalse(Validation::email('abc,efg@example.com')); + $this->assertFalse(Validation::email('abc@sub,example.com')); + $this->assertFalse(Validation::email("abc@sub'example.com")); + $this->assertFalse(Validation::email('abc@sub/example.com')); + $this->assertFalse(Validation::email('abc@yahoo!.com')); + $this->assertFalse(Validation::email('abc@example_underscored.com')); + $this->assertFalse(Validation::email('raw@test.ra.ru....com')); + $this->assertFalse(Validation::email(1)); + } + + /** + * testEmailDeep method + */ + public function testEmailDeep(): void + { + $this->skipIf((bool)gethostbynamel('example.abcd'), 'Your DNS service responds for nonexistent domains, skipping deep email checks.'); + + $this->assertTrue(Validation::email('abc.efg@cakephp.org', true)); + $this->assertFalse(Validation::email('abc.efg@caphpkeinvalid.com', true)); + } + + /** + * testEmailCustomRegex method + */ + public function testEmailCustomRegex(): void + { + $this->assertTrue(Validation::email('abc.efg@cakephp.org', null, '/^[A-Z0-9._%-]+@[A-Z0-9.-]+\\.[A-Z]{2,4}$/i')); + $this->assertFalse(Validation::email('abc.efg@com.caphpkeinvalid', null, '/^[A-Z0-9._%-]+@[A-Z0-9.-]+\\.[A-Z]{2,4}$/i')); + } + + public function testEnum(): void + { + $this->assertTrue(Validation::enum(ArticleStatus::Published, ArticleStatus::class)); + $this->assertTrue(Validation::enum('Y', ArticleStatus::class)); + + $this->assertTrue(Validation::enum(Priority::Low, Priority::class)); + $this->assertTrue(Validation::enum(1, Priority::class)); + + $this->assertFalse(Validation::enum(Priority::Low, ArticleStatus::class)); + $this->assertFalse(Validation::enum(1, ArticleStatus::class)); + $this->assertFalse(Validation::enum('non-existent', ArticleStatus::class)); + + $this->assertFalse(Validation::enum(ArticleStatus::Published, Priority::class)); + $this->assertFalse(Validation::enum('wrong type', Priority::class)); + $this->assertFalse(Validation::enum(123, Priority::class)); + + $this->assertTrue(Validation::enum('1', Priority::class)); + $this->assertFalse(Validation::enum('a1', Priority::class)); + } + + public function testEnumOnly(): void + { + $this->assertTrue(Validation::enumOnly(ArticleStatus::Published, [ArticleStatus::Published])); + $this->assertFalse(Validation::enumOnly(ArticleStatus::Published, [ArticleStatus::Unpublished])); + } + + public function testEnumExcept(): void + { + $this->assertFalse(Validation::enumExcept(ArticleStatus::Published, [ArticleStatus::Published])); + $this->assertTrue(Validation::enumExcept(ArticleStatus::Published, [ArticleStatus::Unpublished])); + } + + public function testEnumNonBacked(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); + + Validation::enum(NonBacked::Basic, NonBacked::class); + } + + public function testEnumNonEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); + + Validation::enum('non-enum class', TestCase::class); + } + + /** + * testEqualTo method + */ + public function testEqualTo(): void + { + $this->assertTrue(Validation::equalTo('1', '1')); + $this->assertFalse(Validation::equalTo(1, '1')); + $this->assertFalse(Validation::equalTo('', null)); + $this->assertFalse(Validation::equalTo('', false)); + $this->assertFalse(Validation::equalTo(0, false)); + $this->assertFalse(Validation::equalTo(null, false)); + } + + /** + * testIpV4 method + */ + public function testIpV4(): void + { + $this->assertTrue(Validation::ip('0.0.0.0', 'ipv4')); + $this->assertTrue(Validation::ip('192.168.1.156')); + $this->assertTrue(Validation::ip('255.255.255.255')); + $this->assertFalse(Validation::ip('127.0.0')); + $this->assertFalse(Validation::ip('127.0.0.a')); + $this->assertFalse(Validation::ip('127.0.0.256')); + $this->assertFalse(Validation::ip('2001:0db8:85a3:0000:0000:8a2e:0370:7334', 'ipv4'), 'IPv6 is not valid IPv4'); + } + + /** + * testIp v6 + */ + public function testIpv6(): void + { + $this->assertTrue(Validation::ip('2001:0db8:85a3:0000:0000:8a2e:0370:7334', 'IPv6')); + $this->assertTrue(Validation::ip('2001:db8:85a3:0:0:8a2e:370:7334', 'IPv6')); + $this->assertTrue(Validation::ip('2001:db8:85a3::8a2e:370:7334', 'IPv6')); + $this->assertTrue(Validation::ip('2001:0db8:0000:0000:0000:0000:1428:57ab', 'IPv6')); + $this->assertTrue(Validation::ip('2001:0db8:0000:0000:0000::1428:57ab', 'IPv6')); + $this->assertTrue(Validation::ip('2001:0db8:0:0:0:0:1428:57ab', 'IPv6')); + $this->assertTrue(Validation::ip('2001:0db8:0:0::1428:57ab', 'IPv6')); + $this->assertTrue(Validation::ip('2001:0db8::1428:57ab', 'IPv6')); + $this->assertTrue(Validation::ip('2001:db8::1428:57ab', 'IPv6')); + $this->assertTrue(Validation::ip('0000:0000:0000:0000:0000:0000:0000:0001', 'IPv6')); + $this->assertTrue(Validation::ip('::1', 'IPv6')); + $this->assertTrue(Validation::ip('::ffff:12.34.56.78', 'IPv6')); + $this->assertTrue(Validation::ip('::ffff:0c22:384e', 'IPv6')); + $this->assertTrue(Validation::ip('2001:0db8:1234:0000:0000:0000:0000:0000', 'IPv6')); + $this->assertTrue(Validation::ip('2001:0db8:1234:ffff:ffff:ffff:ffff:ffff', 'IPv6')); + $this->assertTrue(Validation::ip('2001:db8:a::123', 'IPv6')); + $this->assertTrue(Validation::ip('fe80::', 'IPv6')); + $this->assertTrue(Validation::ip('::ffff:192.0.2.128', 'IPv6')); + $this->assertTrue(Validation::ip('::ffff:c000:280', 'IPv6')); + + $this->assertFalse(Validation::ip('123', 'IPv6')); + $this->assertFalse(Validation::ip('ldkfj', 'IPv6')); + $this->assertFalse(Validation::ip('2001::FFD3::57ab', 'IPv6')); + $this->assertFalse(Validation::ip('2001:db8:85a3::8a2e:37023:7334', 'IPv6')); + $this->assertFalse(Validation::ip('2001:db8:85a3::8a2e:370k:7334', 'IPv6')); + $this->assertFalse(Validation::ip('1:2:3:4:5:6:7:8:9', 'IPv6')); + $this->assertFalse(Validation::ip('1::2::3', 'IPv6')); + $this->assertFalse(Validation::ip('1:::3:4:5', 'IPv6')); + $this->assertFalse(Validation::ip('1:2:3::4:5:6:7:8:9', 'IPv6')); + $this->assertFalse(Validation::ip('::ffff:2.3.4', 'IPv6')); + $this->assertFalse(Validation::ip('::ffff:257.1.2.3', 'IPv6')); + $this->assertFalse(Validation::ip('255.255.255.255', 'ipv6'), 'IPv4 is not valid IPv6'); + } + + /** + * Tests ipOrRange(). + */ + public function testIpOrRange(): void + { + $this->assertTrue(Validation::ipOrRange('192.168.1.0')); + $this->assertTrue(Validation::ipOrRange('192.168.1.0/24')); + $this->assertTrue(Validation::ipOrRange('2001:db8::')); + $this->assertTrue(Validation::ipOrRange('2001:db8::/64')); + + $this->assertFalse(Validation::ipOrRange('192.168.1')); + $this->assertFalse(Validation::ipOrRange('192.168.1.0/33')); + $this->assertFalse(Validation::ipOrRange('2001:db8')); + $this->assertFalse(Validation::ipOrRange('2001:db8::/1111')); + } + + /** + * testMaxLength method + */ + public function testMaxLength(): void + { + $this->assertTrue(Validation::maxLength('ab', 3)); + $this->assertTrue(Validation::maxLength('abc', 3)); + $this->assertTrue(Validation::maxLength('ÆΔΩЖÇ', 10)); + + $this->assertFalse(Validation::maxLength('abcd', 3)); + $this->assertFalse(Validation::maxLength('ÆΔΩЖÇ', 3)); + $this->assertFalse(Validation::maxLength(['abc'], 10)); + } + + /** + * maxLengthBytes method + */ + public function testMaxLengthBytes(): void + { + $this->assertTrue(Validation::maxLengthBytes('ab', 3)); + $this->assertTrue(Validation::maxLengthBytes('abc', 3)); + $this->assertTrue(Validation::maxLengthBytes('ÆΔΩЖÇ', 10)); + $this->assertTrue(Validation::maxLengthBytes('ÆΔΩЖÇ', 11)); + + $this->assertFalse(Validation::maxLengthBytes('abcd', 3)); + $this->assertFalse(Validation::maxLengthBytes('ÆΔΩЖÇ', 9)); + $this->assertFalse(Validation::maxLengthBytes(['abc'], 10)); + } + + /** + * testMinLength method + */ + public function testMinLength(): void + { + $this->assertFalse(Validation::minLength('ab', 3)); + $this->assertFalse(Validation::minLength('ÆΔΩЖÇ', 10)); + + $this->assertTrue(Validation::minLength('abc', 3)); + $this->assertTrue(Validation::minLength('abcd', 3)); + $this->assertTrue(Validation::minLength('ÆΔΩЖÇ', 2)); + + $this->assertFalse(Validation::minLength(['abc'], 1)); + } + + /** + * minLengthBytes method + */ + public function testMinLengthBytes(): void + { + $this->assertFalse(Validation::minLengthBytes('ab', 3)); + $this->assertFalse(Validation::minLengthBytes('ÆΔΩЖÇ', 11)); + + $this->assertTrue(Validation::minLengthBytes('abc', 3)); + $this->assertTrue(Validation::minLengthBytes('abcd', 3)); + $this->assertTrue(Validation::minLengthBytes('ÆΔΩЖÇ', 10)); + $this->assertTrue(Validation::minLengthBytes('ÆΔΩЖÇ', 9)); + + $this->assertFalse(Validation::minLengthBytes(['abc'], 1)); + } + + /** + * testUrl method + */ + public function testUrl(): void + { + $this->assertTrue(Validation::url('http://www.cakephp.org')); + $this->assertTrue(Validation::url('http://cakephp.org')); + $this->assertTrue(Validation::url('http://www.cakephp.org/somewhere#anchor')); + $this->assertTrue(Validation::url('http://192.168.0.1')); + $this->assertTrue(Validation::url('https://www.cakephp.org')); + $this->assertTrue(Validation::url('https://cakephp.org')); + $this->assertTrue(Validation::url('https://www.cakephp.org/somewhere#anchor')); + $this->assertTrue(Validation::url('https://192.168.0.1')); + $this->assertTrue(Validation::url('https://example.com/kibana/app/kibana#/dashboard/4422c500-8e1b?_g=()')); + $this->assertTrue(Validation::url('ftps://www.cakephp.org/pub/cake')); + $this->assertTrue(Validation::url('ftps://cakephp.org/pub/cake')); + $this->assertTrue(Validation::url('ftps://192.168.0.1/pub/cake')); + $this->assertTrue(Validation::url('ftp://www.cakephp.org/pub/cake')); + $this->assertTrue(Validation::url('ftp://cakephp.org/pub/cake')); + $this->assertTrue(Validation::url('ftp://192.168.0.1/pub/cake')); + $this->assertTrue(Validation::url('sftp://192.168.0.1/pub/cake')); + $this->assertTrue(Validation::url('https://my.domain.com/gizmo/app?class=MySip;proc=start')); + $this->assertTrue(Validation::url('www.domain.tld')); + $this->assertTrue(Validation::url('http://123456789112345678921234567893123456789412345678951234567896123.com')); + $this->assertTrue(Validation::url('http://www.domain.com/blogs/index.php?blog=6&tempskin=_rss2')); + $this->assertTrue(Validation::url('http://www.domain.com/blogs/parenth()eses.php')); + $this->assertTrue(Validation::url('http://www.domain.com/index.php?get=params&get2=params')); + $this->assertTrue(Validation::url('http://www.domain.com/ndex.php?get=params&get2=params#anchor')); + $this->assertTrue(Validation::url('http://www.domain.com/real%20url%20encodeing')); + $this->assertTrue(Validation::url('http://en.wikipedia.org/wiki/Architectural_pattern_(computer_science)')); + $this->assertTrue(Validation::url('http://www.cakephp.org', true)); + $this->assertTrue(Validation::url('http://example.com/~userdir/')); + $this->assertTrue(Validation::url('http://underscore_subdomain.example.org')); + $this->assertTrue(Validation::url('http://_jabber._tcp.gmail.com')); + $this->assertTrue(Validation::url('http://www.domain.longttldnotallowed')); + $this->assertFalse(Validation::url('ftps://256.168.0.1/pub/cake')); + $this->assertFalse(Validation::url('ftp://256.168.0.1/pub/cake')); + $this->assertFalse(Validation::url('http://w_w.domain.co_m')); + $this->assertFalse(Validation::url('http://www.domain.12com')); + $this->assertFalse(Validation::url('http://www.-invaliddomain.tld')); + $this->assertFalse(Validation::url('http://www.domain.-invalidtld')); + $this->assertFalse(Validation::url('http://this-domain-is-too-loooooong-by-icann-rules-maximum-length-is-63.com')); + $this->assertFalse(Validation::url('http://www.underscore_domain.org')); + $this->assertFalse(Validation::url('http://_jabber._tcp.g_mail.com')); + $this->assertFalse(Validation::url('http://en.(wikipedia).org/')); + $this->assertFalse(Validation::url('http://www.domain.com/fakeenco%ode')); + $this->assertFalse(Validation::url('--.example.com')); + $this->assertFalse(Validation::url('www.cakephp.org', true)); + + $this->assertTrue(Validation::url('http://example.com/~userdir/subdir/index.html')); + $this->assertTrue(Validation::url('http://www.zwischenraume.de')); + $this->assertTrue(Validation::url('http://www.zwischenraume.cz')); + $this->assertTrue(Validation::url('http://www.last.fm/music/浜崎あゆみ'), 'utf8 path failed'); + $this->assertTrue(Validation::url('http://www.electrohome.ro/images/239537750-284232-215_300[1].jpg')); + $this->assertTrue(Validation::url('http://www.eräume.foo')); + $this->assertTrue(Validation::url('http://äüö.eräume.foo')); + $this->assertTrue(Validation::url('http://www.domain.com/👹/🧀'), 'utf8Extended path failed'); + + $this->assertTrue(Validation::url('http://cakephp.org:80')); + $this->assertTrue(Validation::url('http://cakephp.org:443')); + $this->assertTrue(Validation::url('http://cakephp.org:2000')); + $this->assertTrue(Validation::url('http://cakephp.org:27000')); + $this->assertTrue(Validation::url('http://cakephp.org:65000')); + + $this->assertTrue(Validation::url('[2001:0db8::1428:57ab]')); + $this->assertTrue(Validation::url('[::1]')); + $this->assertTrue(Validation::url('[2001:0db8::1428:57ab]:80')); + $this->assertTrue(Validation::url('[::1]:80')); + $this->assertTrue(Validation::url('http://[2001:0db8::1428:57ab]')); + $this->assertTrue(Validation::url('http://[::1]')); + $this->assertTrue(Validation::url('http://[2001:0db8::1428:57ab]:80')); + $this->assertTrue(Validation::url('http://[::1]:80')); + + $this->assertFalse(Validation::url('[1::2::3]')); + } + + public function testUuid(): void + { + // v0 + $this->assertTrue(Validation::uuid('00000000-0000-0000-0000-000000000000')); + // v1 + $this->assertTrue(Validation::uuid('550e8400-e29b-11d4-a716-446655440000')); + $this->assertTrue(Validation::uuid('550E8400-e29b-11D4-A716-446655440000')); + // v4 + $this->assertTrue(Validation::uuid('5b79da75-e0d7-4059-a759-ad6c20684376')); + // v7 + $this->assertTrue(Validation::uuid('01989013-1fe3-702f-9c63-d4e051daa3d9')); + + $this->assertFalse(Validation::uuid('BRAP-e29b-11d4-a716-446655440000')); + $this->assertFalse(Validation::uuid('550e8400-e29b11d4-a716-446655440000')); + $this->assertFalse(Validation::uuid('550e8400-e29b-11d4-a716-4466440000')); + $this->assertFalse(Validation::uuid('550e8400-e29b-11d4-a71-446655440000')); + $this->assertFalse(Validation::uuid('550e8400-e29b-11d-a716-446655440000')); + $this->assertFalse(Validation::uuid('550e8400-e29-11d4-a716-446655440000')); + } + + /** + * testInList method + */ + public function testInList(): void + { + $this->assertTrue(Validation::inList('one', ['one', 'two'])); + $this->assertTrue(Validation::inList('two', ['one', 'two'])); + $this->assertFalse(Validation::inList('three', ['one', 'two'])); + $this->assertFalse(Validation::inList('1one', [0, 1, 2, 3])); + $this->assertFalse(Validation::inList('one', [0, 1, 2, 3])); + $this->assertTrue(Validation::inList('2', [1, 2, 3])); + $this->assertFalse(Validation::inList('2x', [1, 2, 3])); + $this->assertFalse(Validation::inList(2, ['1', '2x', '3'])); + $this->assertFalse(Validation::inList('One', ['one', 'two'])); + $this->assertFalse(Validation::inList(['one'], ['one', 'two'])); + + // No hexadecimal for numbers. + $this->assertFalse(Validation::inList('0x7B', ['ABC', '123'])); + $this->assertFalse(Validation::inList('0x7B', ['ABC', 123])); + + // case insensitive + $this->assertTrue(Validation::inList('one', ['One', 'Two'], true)); + $this->assertTrue(Validation::inList('Two', ['one', 'two'], true)); + $this->assertFalse(Validation::inList('three', ['one', 'two'], true)); + $this->assertFalse(Validation::inList(null, ['one', 'two'], true)); + $this->assertFalse(Validation::inList(false, ['one', 'two'], true)); + } + + /** + * testRange method + */ + public function testRange(): void + { + $this->assertFalse(Validation::range(20, 100, 1)); + $this->assertTrue(Validation::range(20, 1, 100)); + $this->assertFalse(Validation::range(.5, 1, 100)); + $this->assertTrue(Validation::range(.5, 0, 100)); + $this->assertTrue(Validation::range(5)); + $this->assertTrue(Validation::range(-5, -10, 1)); + $this->assertFalse(Validation::range('word')); + $this->assertTrue(Validation::range(5.1)); + $this->assertTrue(Validation::range(2.1, 2.1, 3.2)); + $this->assertTrue(Validation::range(3.2, 2.1, 3.2)); + $this->assertFalse(Validation::range(2.099, 2.1, 3.2)); + } + + /** + * Test range type checks + */ + public function testRangeTypeChecks(): void + { + $this->assertFalse(Validation::range('\x028', 1, 5), 'hexish encoding fails'); + $this->assertFalse(Validation::range('0b010', 1, 5), 'binary string data fails'); + $this->assertFalse(Validation::range('0x01', 1, 5), 'hex string data fails'); + $this->assertFalse(Validation::range('0x1', 1, 5), 'hex string data fails'); + + $this->assertFalse(Validation::range('\x028', 1, 5), 'hexish encoding fails'); + $this->assertFalse(Validation::range('0b010', 1, 5), 'binary string data fails'); + $this->assertFalse(Validation::range('0x02', 1, 5), 'hex string data fails'); + } + + /** + * testExtension method + */ + public function testExtension(): void + { + $this->assertTrue(Validation::extension('extension.jpeg')); + $this->assertTrue(Validation::extension('extension.JPEG')); + $this->assertTrue(Validation::extension('extension.gif')); + $this->assertTrue(Validation::extension('extension.GIF')); + $this->assertTrue(Validation::extension('extension.png')); + $this->assertTrue(Validation::extension('extension.jpg')); + $this->assertTrue(Validation::extension('extension.JPG')); + $this->assertFalse(Validation::extension('noextension')); + $this->assertTrue(Validation::extension('extension.pdf', ['PDF'])); + $this->assertFalse(Validation::extension('extension.jpg', ['GIF'])); + $this->assertTrue(Validation::extension(['extension.JPG', 'extension.gif', 'extension.png'])); + $this->assertFalse(Validation::extension(['extension.JPG', 'extension.gif', 'extension.png'], ['gif'])); + + $this->assertTrue(Validation::extension(['file' => ['name' => 'file.jpg']])); + $this->assertTrue(Validation::extension([ + 'file1' => ['name' => 'file.jpg'], + 'file2' => ['name' => 'file.jpg'], + 'file3' => ['name' => 'file.jpg'], + ])); + $this->assertFalse(Validation::extension( + [ + 'file1' => ['name' => 'file.jpg'], + 'file2' => ['name' => 'file.gif'], + ], + ['gif'], + ), 'Only the first element should be checked'); + $this->assertTrue(Validation::extension( + [ + 'file1' => ['name' => 'file.gif'], + 'file2' => ['name' => 'file.jpg'], + ], + ['gif'], + ), 'Only the first element should be checked'); + + $file = [ + 'tmp_name' => '/var/private/secret-file', + 'name' => 'cats.gif', + ]; + $this->assertTrue(Validation::extension($file), 'Uses filename if available.'); + $this->assertTrue(Validation::extension(['file' => $file]), 'Walks through arrays.'); + + $this->assertFalse(Validation::extension(['noextension', 'extension.JPG'])); + $this->assertFalse(Validation::extension(['extension.pdf', 'extension.JPG'])); + } + + /** + * Test extension with a PSR7 object + */ + public function testExtensionPsr7(): void + { + $file = WWW_ROOT . 'test_theme' . DS . 'img' . DS . 'test.jpg'; + + $upload = new UploadedFile($file, 5308, UPLOAD_ERR_OK, 'extension.jpeg', 'image/jpeg'); + $this->assertTrue(Validation::extension($upload)); + + $upload = new UploadedFile($file, 163, UPLOAD_ERR_OK, 'no_php_extension', 'text/plain'); + $this->assertFalse(Validation::extension($upload)); + } + + /** + * testMoney method + */ + public function testMoney(): void + { + $this->assertTrue(Validation::money('100')); + $this->assertTrue(Validation::money('100.11')); + $this->assertTrue(Validation::money('100.112')); + $this->assertTrue(Validation::money('100.1')); + $this->assertTrue(Validation::money('100.111,1')); + $this->assertTrue(Validation::money('100.111,11')); + $this->assertFalse(Validation::money('100.111,111')); + + $this->assertTrue(Validation::money('$100')); + $this->assertTrue(Validation::money('$100.11')); + $this->assertTrue(Validation::money('$100.112')); + $this->assertTrue(Validation::money('$100.1')); + $this->assertFalse(Validation::money('$100.1111')); + $this->assertFalse(Validation::money('text')); + + $this->assertTrue(Validation::money('100', 'right')); + $this->assertTrue(Validation::money('100.11$', 'right')); + $this->assertTrue(Validation::money('100.112$', 'right')); + $this->assertTrue(Validation::money('100.1$', 'right')); + $this->assertFalse(Validation::money('100.1111$', 'right')); + + $this->assertTrue(Validation::money('€100')); + $this->assertTrue(Validation::money('€100.11')); + $this->assertTrue(Validation::money('€100.112')); + $this->assertTrue(Validation::money('€100.1')); + $this->assertFalse(Validation::money('€100.1111')); + + $this->assertTrue(Validation::money('100', 'right')); + $this->assertTrue(Validation::money('100.11€', 'right')); + $this->assertTrue(Validation::money('100.112€', 'right')); + $this->assertTrue(Validation::money('100.1€', 'right')); + $this->assertFalse(Validation::money('100.1111€', 'right')); + } + + /** + * Test Multiple Select Validation + */ + public function testMultiple(): void + { + $this->assertTrue(Validation::multiple([0, 1, 2, 3])); + $this->assertTrue(Validation::multiple([50, 32, 22, 0])); + $this->assertTrue(Validation::multiple(['str', 'var', 'enum', 0])); + $this->assertFalse(Validation::multiple('')); + $this->assertFalse(Validation::multiple(null)); + $this->assertFalse(Validation::multiple([])); + $this->assertTrue(Validation::multiple([0])); + $this->assertTrue(Validation::multiple(['0'])); + + $this->assertTrue(Validation::multiple([0, 3, 4, 5], ['in' => range(0, 10)])); + $this->assertFalse(Validation::multiple([0, 15, 20, 5], ['in' => range(0, 10)])); + $this->assertFalse(Validation::multiple([0, 5, 10, 11], ['in' => range(0, 10)])); + $this->assertFalse(Validation::multiple(['boo', 'foo', 'bar'], ['in' => ['foo', 'bar', 'baz']])); + $this->assertFalse(Validation::multiple(['foo', '1bar'], ['in' => range(0, 10)])); + + $this->assertFalse(Validation::multiple([1, 5, 10, 11], ['max' => 3])); + $this->assertTrue(Validation::multiple([0, 5, 10, 11], ['max' => 4])); + $this->assertFalse(Validation::multiple([0, 5, 10, 11, 55], ['max' => 4])); + $this->assertTrue(Validation::multiple(['foo', 'bar', 'baz'], ['max' => 3])); + $this->assertFalse(Validation::multiple(['foo', 'bar', 'baz', 'squirrel'], ['max' => 3])); + + $this->assertTrue(Validation::multiple([0, 5, 10, 11], ['min' => 3])); + $this->assertTrue(Validation::multiple([0, 5, 10, 11, 55], ['min' => 3])); + $this->assertFalse(Validation::multiple(['foo', 'bar', 'baz'], ['min' => 5])); + $this->assertFalse(Validation::multiple(['foo', 'bar', 'baz', 'squirrel'], ['min' => 10])); + + $this->assertTrue(Validation::multiple([0, 5, 9], ['in' => range(0, 10), 'max' => 5])); + $this->assertTrue(Validation::multiple(['0', '5', '9'], ['in' => range(0, 10), 'max' => 5])); + + $this->assertFalse(Validation::multiple([0, 5, 9, 8, 6, 2, 1], ['in' => range(0, 10), 'max' => 5])); + $this->assertFalse(Validation::multiple([0, 5, 9, 8, 11], ['in' => range(0, 10), 'max' => 5])); + + $this->assertTrue(Validation::multiple([0, 5, 9], ['in' => range(0, 10), 'max' => 5, 'min' => 3])); + $this->assertFalse(Validation::multiple(['', '5', '9'], ['max' => 5, 'min' => 3])); + $this->assertFalse(Validation::multiple([0, 5, 9, 8, 6, 2, 1], ['in' => range(0, 10), 'max' => 5, 'min' => 2])); + $this->assertFalse(Validation::multiple([0, 5, 9, 8, 11], ['in' => range(0, 10), 'max' => 5, 'min' => 2])); + + $this->assertFalse(Validation::multiple(['2x', '3x'], ['in' => [1, 2, 3, 4, 5]])); + $this->assertFalse(Validation::multiple([2, 3], ['in' => ['1x', '2x', '3x', '4x']])); + $this->assertFalse(Validation::multiple(['one'], ['in' => ['One', 'Two']])); + $this->assertFalse(Validation::multiple(['Two'], ['in' => ['one', 'two']])); + + // case insensitive + $this->assertTrue(Validation::multiple(['one'], ['in' => ['One', 'Two']], true)); + $this->assertTrue(Validation::multiple(['Two'], ['in' => ['one', 'two']], true)); + $this->assertFalse(Validation::multiple(['three'], ['in' => ['one', 'two']], true)); + } + + /** + * testNumeric method + */ + public function testNumeric(): void + { + $this->assertFalse(Validation::numeric('teststring')); + $this->assertFalse(Validation::numeric('1.1test')); + $this->assertFalse(Validation::numeric('2test')); + + $this->assertTrue(Validation::numeric('2')); + $this->assertTrue(Validation::numeric(2)); + $this->assertTrue(Validation::numeric(2.2)); + $this->assertTrue(Validation::numeric('2.2')); + } + + /** + * testNaturalNumber method + */ + public function testNaturalNumber(): void + { + $this->assertFalse(Validation::naturalNumber('teststring')); + $this->assertFalse(Validation::naturalNumber('5.4')); + $this->assertFalse(Validation::naturalNumber(99.004)); + $this->assertFalse(Validation::naturalNumber('0,05')); + $this->assertFalse(Validation::naturalNumber('-2')); + $this->assertFalse(Validation::naturalNumber(-2)); + $this->assertFalse(Validation::naturalNumber('0')); + $this->assertFalse(Validation::naturalNumber('050')); + + $this->assertTrue(Validation::naturalNumber('2')); + $this->assertTrue(Validation::naturalNumber(49)); + $this->assertTrue(Validation::naturalNumber('0', true)); + $this->assertTrue(Validation::naturalNumber(0, true)); + } + + /** + * testDatetime method + */ + public function testDatetime(): void + { + $this->assertTrue(Validation::datetime('27-12-2006 01:00', 'dmy')); + $this->assertTrue(Validation::datetime('27-12-2006 01:00', ['dmy'])); + $this->assertFalse(Validation::datetime('27-12-2006 1:00', 'dmy')); + + $this->assertTrue(Validation::datetime('27.12.2006 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('27.12.2006 13:00pm', 'dmy')); + + $this->assertTrue(Validation::datetime('27/12/2006 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('27/12/2006 9:00', 'dmy')); + + $this->assertFalse(Validation::datetime('27 12 2006 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('27 12 2006 24:00', 'dmy')); + + $this->assertFalse(Validation::datetime('00-00-0000 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('00.00.0000 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('00/00/0000 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('00 00 0000 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('31-11-2006 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('31.11.2006 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('31/11/2006 1:00pm', 'dmy')); + $this->assertFalse(Validation::datetime('31 11 2006 1:00pm', 'dmy')); + } + + /** + * testMimeType method + */ + public function testMimeType(): void + { + $image = TEST_APP . 'webroot/img/cake.power.gif'; + + $this->assertTrue(Validation::mimeType($image, ['image/gif'])); + $this->assertTrue(Validation::mimeType($image, '#image/.+#')); + $this->assertTrue(Validation::mimeType($image, ['image/GIF'])); + + $this->assertFalse(Validation::mimeType($image, ['image/png'])); + $this->assertFalse(Validation::mimeType(['tmp_name' => $image], ['image/png'])); + $this->assertFalse(Validation::mimeType([], ['image/png'])); + } + + /** + * testMimeTypeCaseInsensitive method + */ + public function testMimeTypeCaseInsensitive(): void + { + $algol68 = CORE_TESTS . 'Fixture/sample.a68'; + + $this->assertTrue(Validation::mimeType($algol68, ['text/x-Algol68'])); + $this->assertTrue(Validation::mimeType($algol68, ['text/x-algol68'])); + $this->assertTrue(Validation::mimeType($algol68, ['text/X-ALGOL68'])); + + $this->assertFalse(Validation::mimeType($algol68, ['image/png'])); + } + + /** + * Test mimetype with a PSR7 object + */ + public function testMimeTypePsr7(): void + { + $image = TEST_APP . 'webroot/img/cake.power.gif'; + $file = new UploadedFile($image, 1000, UPLOAD_ERR_OK, 'cake.power.gif', 'image/lies'); + $this->assertTrue(Validation::mimeType($file, ['image/gif'])); + $this->assertFalse(Validation::mimeType($file, ['image/png'])); + + $image = CORE_TESTS . 'test_app/webroot/img/cake.power.gif'; + $file = new UploadedFile($image, 1000, UPLOAD_ERR_INI_SIZE, 'cake.power.gif', 'image/lies'); + $this->assertFalse(Validation::mimeType($file, ['image/gif']), 'Fails on upload error'); + } + + /** + * testMimeTypeFalse method + */ + public function testMimeTypeFalse(): void + { + $this->expectException(CakeException::class); + $image = CORE_TESTS . 'invalid-file.png'; + Validation::mimeType($image, ['image/gif']); + } + + /** + * testUploadError method + */ + public function testUploadError(): void + { + $this->assertTrue(Validation::uploadError(0)); + $this->assertTrue(Validation::uploadError(['error' => 0])); + $this->assertTrue(Validation::uploadError(['error' => '0'])); + + $this->assertFalse(Validation::uploadError(2)); + $this->assertFalse(Validation::uploadError(['error' => 2])); + $this->assertFalse(Validation::uploadError(['error' => '2'])); + + $this->assertFalse(Validation::uploadError(UPLOAD_ERR_NO_FILE)); + $this->assertFalse(Validation::uploadError(UPLOAD_ERR_FORM_SIZE, true)); + $this->assertFalse(Validation::uploadError(UPLOAD_ERR_INI_SIZE, true)); + $this->assertFalse(Validation::uploadError(UPLOAD_ERR_NO_TMP_DIR, true)); + $this->assertTrue(Validation::uploadError(UPLOAD_ERR_NO_FILE, true)); + } + + /** + * testUploadError method with an UploadedFile + */ + public function testUploadErrorPsr7(): void + { + $image = TEST_APP . 'webroot/img/cake.power.gif'; + $file = new UploadedFile($image, 1000, UPLOAD_ERR_OK, 'cake.power.gif', 'image/gif'); + $this->assertTrue(Validation::uploadError($file)); + + $file = new UploadedFile($image, 1000, UPLOAD_ERR_NO_FILE, 'cake.power.gif', 'image/gif'); + $this->assertFalse(Validation::uploadError($file)); + $this->assertTrue(Validation::uploadError($file, true)); + } + + /** + * testFileSize method + */ + public function testFileSize(): void + { + $image = TEST_APP . 'webroot/img/cake.power.gif'; + $this->assertTrue(Validation::fileSize($image, Validation::COMPARE_LESS, 1024)); + $this->assertTrue(Validation::fileSize($image, Validation::COMPARE_LESS, '1KB')); + $this->assertTrue(Validation::fileSize($image, Validation::COMPARE_GREATER_OR_EQUAL, 200)); + $this->assertTrue(Validation::fileSize($image, Validation::COMPARE_EQUAL, 201)); + $this->assertTrue(Validation::fileSize($image, Validation::COMPARE_EQUAL, '201B')); + + $this->assertFalse(Validation::fileSize($image, Validation::COMPARE_GREATER, 1024)); + } + + /** + * Test fileSize() with a PSR7 object. + */ + public function testFileSizePsr7(): void + { + $image = TEST_APP . 'webroot/img/cake.power.gif'; + $file = new UploadedFile($image, 1000, UPLOAD_ERR_OK, 'cake.power.gif', 'image/gif'); + + $this->assertTrue(Validation::fileSize($file, Validation::COMPARE_EQUAL, 201)); + $this->assertTrue(Validation::fileSize($file, Validation::COMPARE_LESS, 1024)); + $this->assertFalse(Validation::fileSize($file, Validation::COMPARE_GREATER, 202)); + $this->assertFalse(Validation::fileSize($file, Validation::COMPARE_GREATER, 1000)); + } + + /** + * Test uploaded file validation. + */ + public function testUploadedFileFailure(): void + { + $this->assertFalse(Validation::uploadedFile('derp')); + $invalid = [ + 'name' => 'testing', + ]; + $this->assertFalse(Validation::uploadedFile($invalid)); + } + + /** + * Test uploaded file validation. + */ + public function testUploadedFileNoFile(): void + { + $image = TEST_APP . 'webroot/img/cake.power.gif'; + $file = new UploadedFile($image, 1000, UPLOAD_ERR_NO_FILE, 'cake.power.gif', 'image/gif'); + $options = [ + 'optional' => true, + 'minSize' => 500, + 'types' => ['image/gif', 'image/png'], + ]; + $this->assertTrue(Validation::uploadedFile($file, $options), 'No file should be ok.'); + + $options = [ + 'optional' => false, + ]; + $this->assertFalse(Validation::uploadedFile($file, $options), 'File is required.'); + } + + /** + * Provider for uploaded file tests. + * + * @return array + */ + public static function uploadedFileProvider(): array + { + return [ + 'minSize fail' => [false, ['minSize' => 500]], + 'minSize pass' => [true, ['minSize' => 190]], + 'maxSize fail' => [false, ['maxSize' => 100]], + 'maxSize pass' => [true, ['maxSize' => 202]], + 'types fail' => [false, ['types' => ['text/plain']]], + 'types fail - string' => [false, ['types' => '/^text.*$/']], + 'types pass - string' => [true, ['types' => '/^image.*$/']], + 'types pass' => [true, ['types' => ['image/gif', 'image/png']]], + ]; + } + + /** + * Test uploadedFile with a PSR7 object. + */ + #[DataProvider('uploadedFileProvider')] + public function testUploadedFile(bool $expected, array $options): void + { + $image = TEST_APP . 'webroot/img/cake.power.gif'; + $file = new UploadedFile($image, 1000, UPLOAD_ERR_OK, 'cake.power.gif', 'image/gif'); + $this->assertSame($expected, Validation::uploadedFile($file, $options)); + } + + /** + * Test the compareFields method with equal result. + */ + public function testCompareFieldsEqualTo(): void + { + $context = [ + 'data' => [ + 'other' => 'a value', + ], + ]; + $this->assertTrue(Validation::compareFields('a value', 'other', Validation::COMPARE_EQUAL, $context)); + + $context = [ + 'data' => [ + 'other' => 'different', + ], + ]; + $this->assertFalse(Validation::compareFields('a value', 'other', Validation::COMPARE_EQUAL, $context)); + + $context = []; + $this->assertFalse(Validation::compareFields('a value', 'other', Validation::COMPARE_EQUAL, $context)); + + $context = [ + 'data' => ['other' => null], + ]; + $this->assertFalse(Validation::compareFields('a value', 'other', Validation::COMPARE_EQUAL, $context)); + $this->assertFalse(Validation::compareFields(false, 'other', Validation::COMPARE_SAME, $context)); + $this->assertTrue(Validation::compareFields(false, 'other', Validation::COMPARE_EQUAL, $context)); + $this->assertTrue(Validation::compareFields(null, 'other', Validation::COMPARE_SAME, $context)); + $this->assertFalse(Validation::compareFields(false, 'other', Validation::COMPARE_SAME, $context)); + } + + /** + * Test the compareFields method with not equal result. + */ + public function testCompareFieldsNotEqual(): void + { + $context = [ + 'data' => [ + 'other' => 'different', + ], + ]; + $this->assertTrue(Validation::compareFields('a value', 'other', Validation::COMPARE_NOT_EQUAL, $context)); + + $context = [ + 'data' => [ + 'other' => 'a value', + ], + ]; + $this->assertFalse(Validation::compareFields('a value', 'other', Validation::COMPARE_NOT_EQUAL, $context)); + + $context = []; + $this->assertFalse(Validation::compareFields('a value', 'other', Validation::COMPARE_NOT_EQUAL, $context)); + } + + /** + * Test the geoCoordinate method. + */ + public function testGeoCoordinate(): void + { + $this->assertTrue(Validation::geoCoordinate('51.165691, 10.451526')); + $this->assertTrue(Validation::geoCoordinate('-25.274398, 133.775136')); + $this->assertFalse(Validation::geoCoordinate('51.165691 10.451526')); + $this->assertFalse(Validation::geoCoordinate('-245.274398, -133.775136')); + $this->assertTrue(Validation::geoCoordinate('51.165691', ['format' => 'lat'])); + $this->assertTrue(Validation::geoCoordinate('10.451526', ['format' => 'long'])); + $this->assertFalse(Validation::geoCoordinate([])); + } + + /** + * Test the geoCoordinate method. + */ + public function testLatitude(): void + { + $this->assertTrue(Validation::latitude('0')); + $this->assertTrue(Validation::latitude('0.000000')); + $this->assertTrue(Validation::latitude('51.165691')); + $this->assertFalse(Validation::latitude('200.23552')); + } + + /** + * Test the geoCoordinate method. + */ + public function testLongitude(): void + { + $this->assertTrue(Validation::longitude('0')); + $this->assertTrue(Validation::longitude('0.000000')); + $this->assertTrue(Validation::longitude('0.123456')); + $this->assertTrue(Validation::longitude('10.451526')); + $this->assertFalse(Validation::longitude('-190.52236')); + } + + /** + * Test isArray + */ + public function testIsArray(): void + { + $this->assertTrue(Validation::isArray([])); + $this->assertTrue(Validation::isArray([1, 2, 3])); + $this->assertTrue(Validation::isArray(['key' => 'value'])); + $this->assertFalse(Validation::isArray('[1,2,3]')); + $this->assertFalse(Validation::isArray(new Collection([]))); + $this->assertFalse(Validation::isArray(10)); + } + + /** + * Test isScalar + */ + public function testIsScalar(): void + { + $this->assertTrue(Validation::isScalar(1)); + $this->assertTrue(Validation::isScalar(0.0)); + $this->assertTrue(Validation::isScalar('')); + $this->assertTrue(Validation::isScalar(true)); + $this->assertFalse(Validation::isScalar([1])); + $this->assertFalse(Validation::isScalar(new stdClass())); + $this->assertFalse(Validation::isScalar(STDOUT)); + $this->assertFalse(Validation::isScalar(null)); + } + + /** + * Test isInteger + */ + public function testIsInteger(): void + { + $this->assertTrue(Validation::isInteger(-10)); + $this->assertTrue(Validation::isInteger(0)); + $this->assertTrue(Validation::isInteger(10)); + $this->assertTrue(Validation::isInteger(012)); + $this->assertTrue(Validation::isInteger(-012)); + $this->assertTrue(Validation::isInteger('-10')); + $this->assertTrue(Validation::isInteger('0')); + $this->assertTrue(Validation::isInteger('10')); + $this->assertTrue(Validation::isInteger('012')); + $this->assertTrue(Validation::isInteger('-012')); + + $this->assertFalse(Validation::isInteger('2.5')); + $this->assertFalse(Validation::isInteger(2.5)); + $this->assertFalse(Validation::isInteger([])); + $this->assertFalse(Validation::isInteger(new stdClass())); + $this->assertFalse(Validation::isInteger('2 bears')); + $this->assertFalse(Validation::isInteger(true)); + $this->assertFalse(Validation::isInteger(false)); + } + + /** + * Test ascii + */ + public function testAscii(): void + { + $this->assertTrue(Validation::ascii('1 big blue bus.')); + $this->assertTrue(Validation::ascii(',.<>[]{;/?\)()')); + + $this->assertFalse(Validation::ascii([])); + $this->assertFalse(Validation::ascii(1001)); + $this->assertFalse(Validation::ascii(3.14)); + $this->assertFalse(Validation::ascii(new stdClass())); + + // Latin-1 supplement + $this->assertFalse(Validation::ascii('some' . "\xc2\x82" . 'value')); + $this->assertFalse(Validation::ascii('some' . "\xc3\xbf" . 'value')); + + // End of BMP + $this->assertFalse(Validation::ascii('some' . "\xef\xbf\xbd" . 'value')); + + // Start of supplementary multilingual plane + $this->assertFalse(Validation::ascii('some' . "\xf0\x90\x80\x80" . 'value')); + } + + /** + * Test utf8 basic + */ + public function testUtf8Basic(): void + { + $this->assertFalse(Validation::utf8([])); + $this->assertFalse(Validation::utf8(1001)); + $this->assertFalse(Validation::utf8(3.14)); + $this->assertFalse(Validation::utf8(new stdClass())); + $this->assertTrue(Validation::utf8('1 big blue bus.')); + $this->assertTrue(Validation::utf8(',.<>[]{;/?\)()')); + + // Latin-1 supplement + $this->assertTrue(Validation::utf8('some' . "\xc2\x82" . 'value')); + $this->assertTrue(Validation::utf8('some' . "\xc3\xbf" . 'value')); + + // End of BMP + $this->assertTrue(Validation::utf8('some' . "\xef\xbf\xbd" . 'value')); + + // Start of supplementary multilingual plane + $this->assertFalse(Validation::utf8('some' . "\xf0\x90\x80\x80" . 'value')); + + // Grinning face + $this->assertFalse(Validation::utf8('some' . "\xf0\x9f\x98\x80" . 'value')); + + // incomplete character + $this->assertFalse(Validation::utf8("\xfe\xfe")); + } + + /** + * Test utf8 extended + */ + public function testUtf8Extended(): void + { + $this->assertFalse(Validation::utf8([], ['extended' => true])); + $this->assertFalse(Validation::utf8(1001, ['extended' => true])); + $this->assertFalse(Validation::utf8(3.14, ['extended' => true])); + $this->assertFalse(Validation::utf8(new stdClass(), ['extended' => true])); + $this->assertTrue(Validation::utf8('1 big blue bus.', ['extended' => true])); + $this->assertTrue(Validation::utf8(',.<>[]{;/?\)()', ['extended' => true])); + + // Latin-1 supplement + $this->assertTrue(Validation::utf8('some' . "\xc2\x82" . 'value', ['extended' => true])); + $this->assertTrue(Validation::utf8('some' . "\xc3\xbf" . 'value', ['extended' => true])); + + // End of BMP + $this->assertTrue(Validation::utf8('some' . "\xef\xbf\xbd" . 'value', ['extended' => true])); + + // Start of supplementary multilingual plane + $this->assertTrue(Validation::utf8('some' . "\xf0\x90\x80\x80" . 'value', ['extended' => true])); + + // Grinning face + $this->assertTrue(Validation::utf8('some' . "\xf0\x9f\x98\x80" . 'value', ['extended' => true])); + + // incomplete characters + $this->assertFalse(Validation::utf8("\xfe\xfe", ['extended' => true])); + } + + /** + * Test numElements + */ + public function testNumElements(): void + { + $array = ['cake', 'php']; + $this->assertTrue(Validation::numElements($array, Validation::COMPARE_EQUAL, 2)); + $this->assertFalse(Validation::numElements($array, Validation::COMPARE_GREATER, 3)); + $this->assertFalse(Validation::numElements($array, Validation::COMPARE_LESS, 1)); + + $callable = function () { + return ''; + }; + + $this->assertFalse(Validation::numElements(null, Validation::COMPARE_EQUAL, 0)); + $this->assertFalse(Validation::numElements(new stdClass(), Validation::COMPARE_EQUAL, 0)); + $this->assertFalse(Validation::numElements($callable, Validation::COMPARE_EQUAL, 0)); + $this->assertFalse(Validation::numElements(false, Validation::COMPARE_EQUAL, 0)); + $this->assertFalse(Validation::numElements(true, Validation::COMPARE_EQUAL, 0)); + } + + /** + * Test ImageSize InvalidArgumentException + */ + public function testImageSizeInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->assertTrue(Validation::imageSize([], [])); + } + + /** + * Test imageSize + */ + public function testImageSize(): void + { + $image = WWW_ROOT . 'test_theme' . DS . 'img' . DS . 'test.jpg'; + $upload = new UploadedFile($image, 5308, UPLOAD_ERR_OK, 'test.jpg', 'image/jpeg'); + + $this->assertTrue(Validation::imageSize($upload, [ + 'width' => [Validation::COMPARE_GREATER, 100], + 'height' => [Validation::COMPARE_GREATER, 100], + ])); + + $this->assertFalse(Validation::imageSize($upload, [ + 'width' => [Validation::COMPARE_GREATER, 100], + 'height' => [Validation::COMPARE_LESS, 100], + ])); + + $this->assertFalse(Validation::imageSize($upload, [ + 'width' => [Validation::COMPARE_EQUAL, 100], + 'height' => [Validation::COMPARE_EQUAL, 300], + ])); + + $this->assertTrue(Validation::imageSize($upload, [ + 'width' => [Validation::COMPARE_GREATER_OR_EQUAL, 300], + 'height' => [Validation::COMPARE_GREATER_OR_EQUAL, 300], + ])); + + $this->assertTrue(Validation::imageSize($upload, [ + 'width' => [Validation::COMPARE_LESS_OR_EQUAL, 300], + 'height' => [Validation::COMPARE_LESS_OR_EQUAL, 300], + ])); + + $this->assertTrue(Validation::imageSize($upload, [ + 'width' => [Validation::COMPARE_LESS_OR_EQUAL, 300], + 'height' => [Validation::COMPARE_GREATER_OR_EQUAL, 300], + ])); + + $this->assertFalse(Validation::imageSize($upload, [ + 'width' => [Validation::COMPARE_LESS_OR_EQUAL, 299], + 'height' => [Validation::COMPARE_GREATER_OR_EQUAL, 300], + ])); + } + + /** + * Test imageHeight + */ + public function testImageHeight(): void + { + $image = WWW_ROOT . 'test_theme' . DS . 'img' . DS . 'test.jpg'; + $upload = new UploadedFile($image, 5308, UPLOAD_ERR_OK, 'test.jpg', 'image/jpeg'); + + $this->assertTrue(Validation::imageHeight($upload, Validation::COMPARE_GREATER, 100)); + $this->assertTrue(Validation::imageHeight($upload, Validation::COMPARE_LESS, 2000)); + $this->assertTrue(Validation::imageHeight($upload, Validation::COMPARE_EQUAL, 300)); + + $this->assertFalse(Validation::imageHeight($upload, Validation::COMPARE_LESS, 100)); + $this->assertFalse(Validation::imageHeight($upload, Validation::COMPARE_GREATER, 2000)); + $this->assertFalse(Validation::imageHeight($upload, Validation::COMPARE_EQUAL, 3000)); + } + + /** + * Test imageWidth + */ + public function testImageWidth(): void + { + $image = WWW_ROOT . 'test_theme' . DS . 'img' . DS . 'test.jpg'; + $upload = new UploadedFile($image, 5308, UPLOAD_ERR_OK, 'test.jpg', 'image/jpeg'); + + $this->assertTrue(Validation::imageWidth($upload, Validation::COMPARE_GREATER, 100)); + $this->assertTrue(Validation::imageWidth($upload, Validation::COMPARE_LESS, 2000)); + $this->assertTrue(Validation::imageWidth($upload, Validation::COMPARE_EQUAL, 300)); + + $this->assertFalse(Validation::imageWidth($upload, Validation::COMPARE_LESS, 100)); + $this->assertFalse(Validation::imageWidth($upload, Validation::COMPARE_GREATER, 2000)); + $this->assertFalse(Validation::imageWidth($upload, Validation::COMPARE_EQUAL, 3000)); + } + + /** + * Test hexColor + */ + public function testHexColor(): void + { + $this->assertTrue(Validation::hexColor('#F01234')); + $this->assertTrue(Validation::hexColor('#F56789')); + $this->assertTrue(Validation::hexColor('#abcdef')); + $this->assertTrue(Validation::hexColor('#ABCDEF')); + + $this->assertFalse(Validation::hexColor('#fff')); + $this->assertFalse(Validation::hexColor('ffffff')); + } + + /** + * Test IBAN + */ + public function testIban(): void + { + $this->assertTrue(Validation::iban('AD1200012030200359100100')); + $this->assertTrue(Validation::iban('BA391290079401028494')); + $this->assertTrue(Validation::iban('BE68539007547034')); + $this->assertTrue(Validation::iban('LC55HEMM000100010012001200023015')); + $this->assertTrue(Validation::iban('AL35202111090000000001234567')); + $this->assertTrue(Validation::iban('AD1400080001001234567890')); + $this->assertTrue(Validation::iban('AT483200000012345864')); + $this->assertTrue(Validation::iban('AZ96AZEJ00000000001234567890')); + $this->assertTrue(Validation::iban('BH02CITI00001077181611')); + $this->assertTrue(Validation::iban('BY86AKBB10100000002966000000')); + $this->assertTrue(Validation::iban('BE71096123456769')); + $this->assertTrue(Validation::iban('BA275680000123456789')); + $this->assertTrue(Validation::iban('BR1500000000000010932840814P2')); + $this->assertTrue(Validation::iban('BG18RZBB91550123456789')); + $this->assertTrue(Validation::iban('CR37012600000123456789')); + $this->assertTrue(Validation::iban('HR1723600001101234565')); + $this->assertTrue(Validation::iban('CY21002001950000357001234567')); + $this->assertTrue(Validation::iban('CZ5508000000001234567899')); + $this->assertTrue(Validation::iban('DK9520000123456789')); + $this->assertTrue(Validation::iban('DO22ACAU00000000000123456789')); + $this->assertTrue(Validation::iban('SV43ACAT00000000000000123123')); + $this->assertTrue(Validation::iban('EE471000001020145685')); + $this->assertTrue(Validation::iban('FO9264600123456789')); + $this->assertTrue(Validation::iban('FI1410093000123458')); + $this->assertTrue(Validation::iban('FR7630006000011234567890189')); + $this->assertTrue(Validation::iban('GE60NB0000000123456789')); + $this->assertTrue(Validation::iban('DE91100000000123456789')); + $this->assertTrue(Validation::iban('GI04BARC000001234567890')); + $this->assertTrue(Validation::iban('GR9608100010000001234567890')); + $this->assertTrue(Validation::iban('GL8964710123456789')); + $this->assertTrue(Validation::iban('GT20AGRO00000000001234567890')); + $this->assertTrue(Validation::iban('HU93116000060000000012345676')); + $this->assertTrue(Validation::iban('IS030001121234561234567890')); + $this->assertTrue(Validation::iban('IQ20CBIQ861800101010500')); + $this->assertTrue(Validation::iban('IE64IRCE92050112345678')); + $this->assertTrue(Validation::iban('IL170108000000012612345')); + $this->assertTrue(Validation::iban('IT60X0542811101000000123456')); + $this->assertTrue(Validation::iban('JO71CBJO0000000000001234567890')); + $this->assertTrue(Validation::iban('KZ563190000012344567')); + $this->assertTrue(Validation::iban('XK051212012345678906')); + $this->assertTrue(Validation::iban('KW81CBKU0000000000001234560101')); + $this->assertTrue(Validation::iban('LV97HABA0012345678910')); + $this->assertTrue(Validation::iban('LB92000700000000123123456123')); + $this->assertTrue(Validation::iban('LI7408806123456789012')); + $this->assertTrue(Validation::iban('LT601010012345678901')); + $this->assertTrue(Validation::iban('LU120010001234567891')); + $this->assertTrue(Validation::iban('MK07200002785123453')); + $this->assertTrue(Validation::iban('MT31MALT01100000000000000000123')); + $this->assertTrue(Validation::iban('MR1300020001010000123456753')); + $this->assertTrue(Validation::iban('MU43BOMM0101123456789101000MUR')); + $this->assertTrue(Validation::iban('MD21EX000000000001234567')); + $this->assertTrue(Validation::iban('MC5810096180790123456789085')); + $this->assertTrue(Validation::iban('ME25505000012345678951')); + $this->assertTrue(Validation::iban('NL02ABNA0123456789')); + $this->assertTrue(Validation::iban('NO8330001234567')); + $this->assertTrue(Validation::iban('PK36SCBL0000001123456702')); + $this->assertTrue(Validation::iban('PS92PALS000000000400123456702')); + $this->assertTrue(Validation::iban('PL10105000997603123456789123')); + $this->assertTrue(Validation::iban('PT50002700000001234567833')); + $this->assertTrue(Validation::iban('QA54QNBA000000000000693123456')); + $this->assertTrue(Validation::iban('RO09BCYP0000001234567890')); + $this->assertTrue(Validation::iban('LC14BOSL123456789012345678901234')); + $this->assertTrue(Validation::iban('SM76P0854009812123456789123')); + $this->assertTrue(Validation::iban('ST23000200000289355710148')); + $this->assertTrue(Validation::iban('SA4420000001234567891234')); + $this->assertTrue(Validation::iban('RS35105008123123123173')); + $this->assertTrue(Validation::iban('SC52BAHL01031234567890123456USD')); + $this->assertTrue(Validation::iban('SK8975000000000012345671')); + $this->assertTrue(Validation::iban('SI56192001234567892')); + $this->assertTrue(Validation::iban('ES7921000813610123456789')); + $this->assertTrue(Validation::iban('SE1412345678901234567890')); + $this->assertTrue(Validation::iban('CH5604835012345678009')); + $this->assertTrue(Validation::iban('TL380080012345678910157')); + $this->assertTrue(Validation::iban('TN4401000067123456789123')); + $this->assertTrue(Validation::iban('TR320010009999901234567890')); + $this->assertTrue(Validation::iban('TR320010009999901234567890')); + $this->assertTrue(Validation::iban('AE460090000000123456789')); + $this->assertTrue(Validation::iban('GB98MIDL07009312345678')); + $this->assertTrue(Validation::iban('VG21PACG0000000123456789')); + + $this->assertFalse(Validation::iban('AD1200012030200359100101')); + $this->assertFalse(Validation::iban('BE68539007547032')); + $this->assertFalse(Validation::iban('LC55HEMM000100010012001200023014')); + } +} diff --git a/tests/TestCase/Validation/ValidatorTest.php b/tests/TestCase/Validation/ValidatorTest.php new file mode 100644 index 00000000000..4f02e7f6809 --- /dev/null +++ b/tests/TestCase/Validation/ValidatorTest.php @@ -0,0 +1,3214 @@ +assertNull($validator->getRequiredMessage('field')); + + $validator = new Validator(); + $validator->requirePresence('field'); + $this->assertSame('This field is required', $validator->getRequiredMessage('field')); + + $validator = new NoI18nValidator(); + $validator->requirePresence('field'); + $this->assertSame('This field is required', $validator->getRequiredMessage('field')); + + $validator = new Validator(); + $validator->requirePresence('field', true, 'Custom message'); + $this->assertSame('Custom message', $validator->getRequiredMessage('field')); + } + + /** + * tests getNotEmptyMessage + */ + public function testGetNotEmptyMessage(): void + { + $validator = new Validator(); + $this->assertNull($validator->getNotEmptyMessage('field')); + + $validator = new Validator(); + $validator->requirePresence('field'); + $this->assertSame('This field cannot be left empty', $validator->getNotEmptyMessage('field')); + + $validator = new NoI18nValidator(); + $validator->requirePresence('field'); + $this->assertSame('This field cannot be left empty', $validator->getNotEmptyMessage('field')); + + $validator = new Validator(); + $validator->notEmptyString('field', 'Custom message'); + $this->assertSame('Custom message', $validator->getNotEmptyMessage('field')); + + $validator = new Validator(); + $validator->notBlank('field', 'Cannot be blank'); + $this->assertSame('Cannot be blank', $validator->getNotEmptyMessage('field')); + + $validator = new Validator(); + $validator->notEmptyString('field', 'Cannot be empty'); + $validator->notBlank('field', 'Cannot be blank'); + $this->assertSame('Cannot be blank', $validator->getNotEmptyMessage('field')); + } + + /** + * Testing you can dynamically add rules to a field + */ + public function testAddingRulesToField(): void + { + $validator = new Validator(); + $validator->add('title', 'not-blank', ['rule' => 'notBlank']); + $set = $validator->field('title'); + $this->assertInstanceOf(ValidationSet::class, $set); + $this->assertCount(1, $set); + + $validator->add('title', 'another', ['rule' => 'alphanumeric']); + $this->assertCount(2, $set); + + $validator->add('body', 'another', ['rule' => 'crazy']); + $this->assertCount(1, $validator->field('body')); + $this->assertCount(2, $validator); + + $validator->add('email', 'notBlank'); + $result = $validator->field('email')->rule('notBlank')->get('rule'); + $this->assertSame('notBlank', $result); + + $rule = new ValidationRule([]); + $validator->add('field', 'myrule', $rule); + $result = $validator->field('field')->rule('myrule'); + $this->assertSame($rule, $result); + } + + /** + * Testing addNested field rules + */ + public function testAddNestedSingle(): void + { + $validator = new Validator(); + $inner = new Validator(); + $inner->add('username', 'not-blank', ['rule' => 'notBlank']); + $this->assertSame($validator, $validator->addNested('user', $inner)); + + $this->assertCount(1, $validator->field('user')); + } + + /** + * Testing addNested connects providers + */ + public function testAddNestedSingleProviders(): void + { + $validator = new Validator(); + $validator->setProvider('test', $this); + + $inner = new Validator(); + $inner->add('username', 'not-blank', ['rule' => function () use ($inner, $validator) { + $this->assertSame($validator->providers(), $inner->providers(), 'Providers should match'); + + return false; + }]); + $validator->addNested('user', $inner); + + $result = $validator->validate(['user' => ['username' => 'example']]); + $this->assertNotEmpty($result, 'Validation should fail'); + } + + /** + * Testing addNested with extra `$message` and `$when` params + */ + public function testAddNestedWithExtra(): void + { + $inner = new Validator(); + $inner->requirePresence('username'); + + $validator = new Validator(); + $validator->addNested('user', $inner, 'errors found', 'create'); + + $this->assertCount(1, $validator->field('user')); + + $rule = $validator->field('user')->rule(Validator::NESTED); + $this->assertSame('create', $rule->get('on')); + + $errors = $validator->validate(['user' => 'string']); + $this->assertArrayHasKey('user', $errors); + $this->assertArrayHasKey(Validator::NESTED, $errors['user']); + $this->assertSame('errors found', $errors['user'][Validator::NESTED]); + + $errors = $validator->validate(['user' => ['key' => 'value']]); + $this->assertArrayHasKey('user', $errors); + $this->assertArrayHasKey(Validator::NESTED, $errors['user']); + + $this->assertEmpty($validator->validate(['user' => ['key' => 'value']], false)); + } + + /** + * Testing addNestedMany field rules + */ + public function testAddNestedMany(): void + { + $validator = new Validator(); + $inner = new Validator(); + $inner->add('comment', 'not-blank', ['rule' => 'notBlank']); + $this->assertSame($validator, $validator->addNestedMany('comments', $inner)); + + $this->assertCount(1, $validator->field('comments')); + } + + /** + * Testing addNestedMany connects providers + */ + public function testAddNestedManyProviders(): void + { + $validator = new Validator(); + $validator->setProvider('test', $this); + + $inner = new Validator(); + $inner->add('comment', 'not-blank', ['rule' => function () use ($inner, $validator) { + $this->assertSame($validator->providers(), $inner->providers(), 'Providers should match'); + + return false; + }]); + $validator->addNestedMany('comments', $inner); + + $result = $validator->validate(['comments' => [['comment' => 'example']]]); + $this->assertNotEmpty($result, 'Validation should fail'); + } + + /** + * Testing addNestedMany with extra `$message` and `$when` params + */ + public function testAddNestedManyWithExtra(): void + { + $inner = new Validator(); + $inner->requirePresence('body'); + + $validator = new Validator(); + $validator->addNestedMany('comments', $inner, 'errors found', 'create'); + + $this->assertCount(1, $validator->field('comments')); + + $rule = $validator->field('comments')->rule(Validator::NESTED); + $this->assertSame('create', $rule->get('on')); + + $errors = $validator->validate(['comments' => 'string']); + $this->assertArrayHasKey('comments', $errors); + $this->assertArrayHasKey(Validator::NESTED, $errors['comments']); + $this->assertSame('errors found', $errors['comments'][Validator::NESTED]); + + $errors = $validator->validate(['comments' => ['string']]); + $this->assertArrayHasKey('comments', $errors); + $this->assertArrayHasKey(Validator::NESTED, $errors['comments']); + $this->assertSame('errors found', $errors['comments'][Validator::NESTED]); + + $errors = $validator->validate(['comments' => [['body' => null]]]); + $this->assertArrayHasKey('comments', $errors); + $this->assertArrayHasKey(Validator::NESTED, $errors['comments']); + + $this->assertEmpty($validator->validate(['comments' => [['body' => null]]], false)); + } + + /** + * Tests that calling field will create a default validation set for it + */ + public function testFieldDefault(): void + { + $validator = new Validator(); + $this->assertFalse($validator->hasField('foo')); + + $field = $validator->field('foo'); + $this->assertInstanceOf(ValidationSet::class, $field); + $this->assertCount(0, $field); + $this->assertTrue($validator->hasField('foo')); + } + + /** + * Tests that field method can be used as a setter + */ + public function testFieldSetter(): void + { + $validator = new Validator(); + $validationSet = new ValidationSet(); + $validator->field('thing', $validationSet); + $this->assertSame($validationSet, $validator->field('thing')); + } + + /** + * Tests the remove method + */ + public function testRemove(): void + { + $validator = new Validator(); + $validator->add('title', 'not-blank', ['rule' => 'notBlank']); + $validator->add('title', 'foo', ['rule' => 'bar']); + $this->assertCount(2, $validator->field('title')); + $validator->remove('title'); + $this->assertCount(0, $validator->field('title')); + $validator->remove('title'); + + $validator->add('title', 'not-blank', ['rule' => 'notBlank']); + $validator->add('title', 'foo', ['rule' => 'bar']); + $this->assertCount(2, $validator->field('title')); + $validator->remove('title', 'foo'); + $this->assertCount(1, $validator->field('title')); + $this->assertNull($validator->field('title')->rule('foo')); + } + + /** + * Tests the requirePresence method + */ + public function testRequirePresence(): void + { + $validator = new Validator(); + $this->assertSame($validator, $validator->requirePresence('title')); + $this->assertTrue($validator->field('title')->isPresenceRequired()); + + $validator->requirePresence('title', false); + $this->assertFalse($validator->field('title')->isPresenceRequired()); + + $validator->requirePresence('title', 'create'); + $this->assertSame('create', $validator->field('title')->isPresenceRequired()); + + $validator->requirePresence('title', 'update'); + $this->assertSame('update', $validator->field('title')->isPresenceRequired()); + } + + /** + * Tests the requirePresence method with an array + */ + public function testRequirePresenceAsArray(): void + { + $validator = new Validator(); + $validator->requirePresence(['title', 'created']); + $this->assertTrue($validator->field('title')->isPresenceRequired()); + $this->assertTrue($validator->field('created')->isPresenceRequired()); + + $validator->requirePresence([ + 'title' => [ + 'mode' => false, + ], + 'content' => [ + 'mode' => 'update', + ], + 'subject', + ], true); + $this->assertFalse($validator->field('title')->isPresenceRequired()); + $this->assertSame('update', $validator->field('content')->isPresenceRequired()); + $this->assertTrue($validator->field('subject')->isPresenceRequired()); + } + + /** + * Tests the requirePresence method with an array containing an integer key + */ + public function testRequirePresenceAsArrayWithIntegerKey(): void + { + $validator = new Validator(); + + $validator->requirePresence([ + 0, + 1 => [ + 'mode' => false, + ], + 'another_field', + ]); + $this->assertTrue($validator->field('0')->isPresenceRequired()); + $this->assertFalse($validator->field('1')->isPresenceRequired()); + } + + /** + * Tests the requirePresence method when passing a callback + */ + public function testRequirePresenceCallback(): void + { + $validator = new Validator(); + $require = true; + $validator->requirePresence('title', function ($context) use (&$require) { + $this->assertEquals([], $context['data']); + $this->assertEquals(['default' => Validation::class], $context['providers']); + $this->assertSame('title', $context['field']); + $this->assertTrue($context['newRecord']); + + return $require; + }); + $this->assertTrue($validator->isPresenceRequired('title', true)); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $require = false; + $this->assertFalse($validator->isPresenceRequired('title', true)); + } + + /** + * Tests the isPresenceRequired method + */ + public function testIsPresenceRequired(): void + { + $validator = new Validator(); + $this->assertSame($validator, $validator->requirePresence('title')); + $this->assertTrue($validator->isPresenceRequired('title', true)); + $this->assertTrue($validator->isPresenceRequired('title', false)); + + $validator->requirePresence('title', false); + $this->assertFalse($validator->isPresenceRequired('title', true)); + $this->assertFalse($validator->isPresenceRequired('title', false)); + + $validator->requirePresence('title', 'create'); + $this->assertTrue($validator->isPresenceRequired('title', true)); + $this->assertFalse($validator->isPresenceRequired('title', false)); + + $validator->requirePresence('title', 'update'); + $this->assertTrue($validator->isPresenceRequired('title', false)); + $this->assertFalse($validator->isPresenceRequired('title', true)); + } + + /** + * Tests errors generated when a field presence is required + */ + public function testErrorsWithPresenceRequired(): void + { + $validator = new Validator(); + $validator->requirePresence('title'); + $errors = $validator->validate(['foo' => 'something']); + $expected = ['title' => ['_required' => 'This field is required']]; + $this->assertEquals($expected, $errors); + + $this->assertEmpty($validator->validate(['title' => 'bar'])); + + $validator->requirePresence('title', false); + $this->assertEmpty($validator->validate(['foo' => 'bar'])); + } + + /** + * Test that validation on a certain condition generate errors + */ + public function testErrorsWithPresenceRequiredOnCreate(): void + { + $validator = new Validator(); + $validator->requirePresence('id', 'update'); + $validator->allowEmptyString('id', 'create'); + $validator->requirePresence('title'); + + $data = [ + 'title' => 'Example title', + ]; + + $expected = []; + $result = $validator->validate($data); + + $this->assertEquals($expected, $result); + } + + /** + * Test that validate() can work with nested data. + */ + public function testErrorsWithNestedFields(): void + { + $validator = new Validator(); + $user = new Validator(); + $user->add('username', 'letter', ['rule' => 'alphanumeric']); + + $comments = new Validator(); + $comments->add('comment', 'letter', ['rule' => 'alphanumeric']); + + $validator->addNested('user', $user); + $validator->addNestedMany('comments', $comments); + + $data = [ + 'user' => [ + 'username' => 'is wrong', + ], + 'comments' => [ + ['comment' => 'is wrong'], + ], + ]; + $errors = $validator->validate($data); + $expected = [ + 'user' => [ + 'username' => ['letter' => 'The provided value is invalid'], + ], + 'comments' => [ + 0 => ['comment' => ['letter' => 'The provided value is invalid']], + ], + ]; + $this->assertEquals($expected, $errors); + } + + /** + * Test nested fields with many, but invalid data. + */ + public function testErrorsWithNestedSingleInvalidType(): void + { + $validator = new Validator(); + + $user = new Validator(); + $user->add('user', 'letter', ['rule' => 'alphanumeric']); + $validator->addNested('user', $user); + + $data = [ + 'user' => 'a string', + ]; + $errors = $validator->validate($data); + $expected = [ + 'user' => ['_nested' => 'The provided value is invalid'], + ]; + $this->assertEquals($expected, $errors); + } + + /** + * Test nested fields with many, but invalid data. + */ + public function testErrorsWithNestedManyInvalidType(): void + { + $validator = new Validator(); + + $comments = new Validator(); + $comments->add('comment', 'letter', ['rule' => 'alphanumeric']); + $validator->addNestedMany('comments', $comments); + + $data = [ + 'comments' => 'a string', + ]; + $errors = $validator->validate($data); + $expected = [ + 'comments' => ['_nested' => 'The provided value is invalid'], + ]; + $this->assertEquals($expected, $errors); + } + + /** + * Test nested fields with many, but invalid data. + */ + public function testErrorsWithNestedManySomeInvalid(): void + { + $validator = new Validator(); + + $comments = new Validator(); + $comments->add('comment', 'letter', ['rule' => 'alphanumeric']); + $validator->addNestedMany('comments', $comments); + + $data = [ + 'comments' => [ + 'a string', + ['comment' => 'letters'], + ['comment' => 'more invalid'], + ], + ]; + $errors = $validator->validate($data); + $expected = [ + 'comments' => [ + '_nested' => 'The provided value is invalid', + ], + ]; + $this->assertEquals($expected, $errors); + } + + /** + * Tests custom error messages generated when a field presence is required + */ + public function testCustomErrorsWithPresenceRequired(): void + { + $validator = new Validator(); + $validator->requirePresence('title', true, 'Custom message'); + $errors = $validator->validate(['foo' => 'something']); + $expected = ['title' => ['_required' => 'Custom message']]; + $this->assertEquals($expected, $errors); + } + + /** + * Tests custom error messages generated when a field presence is required + */ + public function testCustomErrorsWithPresenceRequiredAsArray(): void + { + $validator = new Validator(); + $validator->requirePresence(['title', 'content'], true, 'Custom message'); + $errors = $validator->validate(['foo' => 'something']); + $expected = [ + 'title' => ['_required' => 'Custom message'], + 'content' => ['_required' => 'Custom message'], + ]; + $this->assertEquals($expected, $errors); + + $validator->requirePresence([ + 'title' => [ + 'message' => 'Test message', + ], + 'content', + ], true, 'Custom message'); + $errors = $validator->validate(['foo' => 'something']); + $expected = [ + 'title' => ['_required' => 'Test message'], + 'content' => ['_required' => 'Custom message'], + ]; + $this->assertEquals($expected, $errors); + } + + /** + * Tests the testAllowEmptyFor method + */ + public function testAllowEmptyFor(): void + { + $validator = new Validator(); + $validator + ->allowEmptyFor('title') + ->minLength('title', 5, 'Min. length 5 chars'); + + $results = $validator->validate(['title' => null]); + $this->assertSame([], $results); + + $results = $validator->validate(['title' => '']); + $this->assertSame(['title' => ['minLength' => 'Min. length 5 chars']], $results); + + $results = $validator->validate(['title' => 0]); + $this->assertSame(['title' => ['minLength' => 'Min. length 5 chars']], $results); + + $results = $validator->validate(['title' => []]); + $this->assertSame(['title' => ['minLength' => 'Min. length 5 chars']], $results); + + $validator + ->allowEmptyFor('name', Validator::EMPTY_STRING) + ->minLength('name', 5, 'Min. length 5 chars'); + + $results = $validator->validate(['name' => null]); + $this->assertSame([], $results); + + $results = $validator->validate(['name' => '']); + $this->assertSame([], $results); + + $results = $validator->validate(['name' => 0]); + $this->assertSame(['name' => ['minLength' => 'Min. length 5 chars']], $results); + + $results = $validator->validate(['name' => []]); + $this->assertSame(['name' => ['minLength' => 'Min. length 5 chars']], $results); + } + + /** + * Tests the allowEmpty method + */ + public function testAllowEmpty(): void + { + $validator = new Validator(); + $this->assertSame($validator, $validator->allowEmptyString('title')); + $this->assertTrue($validator->field('title')->isEmptyAllowed()); + + $validator->allowEmptyString('title', null, 'create'); + $this->assertSame('create', $validator->field('title')->isEmptyAllowed()); + + $validator->allowEmptyString('title', null, 'update'); + $this->assertSame('update', $validator->field('title')->isEmptyAllowed()); + } + + /** + * Tests the allowEmpty method with date/time fields. + */ + public function testAllowEmptyWithDateTimeFields(): void + { + $validator = new Validator(); + $validator->allowEmptyDate('created') + ->add('created', 'date', ['rule' => 'date']); + + $data = [ + 'created' => [ + 'year' => '', + 'month' => '', + 'day' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result, 'No errors on empty date'); + + $data = [ + 'created' => [ + 'year' => '', + 'month' => '', + 'day' => '', + 'hour' => '', + 'minute' => '', + 'second' => '', + 'meridian' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result, 'No errors on empty datetime'); + + $validator->allowEmptyTime('created'); + $data = [ + 'created' => [ + 'hour' => '', + 'minute' => '', + 'meridian' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result, 'No errors on empty time'); + } + + /** + * Tests the allowEmpty method with file fields. + */ + public function testAllowEmptyWithFileFields(): void + { + $validator = new Validator(); + $validator->allowEmptyFile('picture') + ->add('picture', 'file', ['rule' => 'uploadedFile']); + + $data = [ + 'picture' => new UploadedFile( + '', + 0, + UPLOAD_ERR_NO_FILE, + ), + ]; + $result = $validator->validate($data); + $this->assertEmpty($result, 'No errors on empty file'); + } + + /** + * Tests the allowEmptyString method + */ + public function testAllowEmptyString(): void + { + $validator = new Validator(); + $validator->allowEmptyString('title') + ->scalar('title'); + + $this->assertTrue($validator->isEmptyAllowed('title', true)); + $this->assertTrue($validator->isEmptyAllowed('title', false)); + + $data = [ + 'title' => '', + ]; + $this->assertEmpty($validator->validate($data)); + + $data = [ + 'title' => null, + ]; + $this->assertEmpty($validator->validate($data)); + + $data = [ + 'title' => [], + ]; + $this->assertNotEmpty($validator->validate($data)); + + $validator = new Validator(); + $validator->allowEmptyString('title', 'message', 'update'); + $this->assertFalse($validator->isEmptyAllowed('title', true)); + $this->assertTrue($validator->isEmptyAllowed('title', false)); + + $data = [ + 'title' => null, + ]; + $expected = [ + 'title' => ['_empty' => 'message'], + ]; + $this->assertSame($expected, $validator->validate($data, true)); + $this->assertEmpty($validator->validate($data, false)); + } + + /** + * Test allowEmptyString with callback + */ + public function testAllowEmptyStringCallbackWhen(): void + { + $validator = new Validator(); + $validator->allowEmptyString( + 'title', + 'very required', + function ($context) { + return $context['data']['otherField'] === true; + }, + ) + ->scalar('title'); + + $data = [ + 'title' => '', + 'otherField' => false, + ]; + $result = $validator->validate($data); + $this->assertNotEmpty($result); + $this->assertEquals(['_empty' => 'very required'], $result['title']); + + $data = [ + 'title' => '', + 'otherField' => true, + ]; + $this->assertEmpty($validator->validate($data)); + } + + /** + * Tests the notEmptyArray method + */ + public function testNotEmptyArray(): void + { + $validator = new Validator(); + $validator->notEmptyArray('items', 'not empty'); + + $this->assertFalse($validator->field('items')->isEmptyAllowed()); + + $error = [ + 'items' => ['_empty' => 'not empty'], + ]; + $data = ['items' => '']; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['items' => null]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['items' => []]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = [ + 'items' => [1], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + } + + /** + * Tests the allowEmptyFile method + */ + public function testAllowEmptyFile(): void + { + $validator = new Validator(); + $validator->allowEmptyFile('photo') + ->uploadedFile('photo', []); + + $this->assertTrue($validator->field('photo')->isEmptyAllowed()); + + $data = [ + 'photo' => null, + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = [ + 'photo' => [], + ]; + $expected = [ + 'photo' => [ + 'uploadedFile' => 'The provided value must be an uploaded file', + ], + ]; + $result = $validator->validate($data); + $this->assertSame($expected, $result); + + $data = [ + 'photo' => '', + ]; + $result = $validator->validate($data); + $this->assertSame($expected, $result); + + $data = ['photo' => []]; + $result = $validator->validate($data); + $this->assertSame($expected, $result); + + $validator = new Validator(); + $validator->allowEmptyFile('photo', 'message', 'update'); + $this->assertFalse($validator->isEmptyAllowed('photo', true)); + $this->assertTrue($validator->isEmptyAllowed('photo', false)); + + $data = [ + 'photo' => null, + ]; + $expected = [ + 'photo' => ['_empty' => 'message'], + ]; + $this->assertSame($expected, $validator->validate($data, true)); + $this->assertEmpty($validator->validate($data, false)); + } + + /** + * Tests the notEmptyFile method + */ + public function testNotEmptyFile(): void + { + $validator = new Validator(); + $validator->notEmptyFile('photo', 'required field'); + + $this->assertFalse($validator->isEmptyAllowed('photo', true)); + $this->assertFalse($validator->isEmptyAllowed('photo', false)); + + $error = ['photo' => ['_empty' => 'required field']]; + $data = ['photo' => null]; + $this->assertSame($error, $validator->validate($data)); + + // Empty string and empty array don't trigger errors + // as rejecting them here would mean accepting them in + // allowEmptyFile() which is not desirable. + $data = ['photo' => '']; + $this->assertEmpty($validator->validate($data)); + + $data = ['photo' => []]; + $this->assertEmpty($validator->validate($data)); + + $data = [ + 'photo' => [ + 'name' => '', + 'type' => '', + 'tmp_name' => '', + 'error' => UPLOAD_ERR_FORM_SIZE, + ], + ]; + $this->assertEmpty($validator->validate($data)); + } + + /** + * Test notEmptyFile with update mode. + * + * @retrn void + */ + public function testNotEmptyFileUpdate(): void + { + $validator = new Validator(); + $validator->notEmptyArray('photo', 'message', 'update'); + $this->assertTrue($validator->isEmptyAllowed('photo', true)); + $this->assertFalse($validator->isEmptyAllowed('photo', false)); + + $data = ['photo' => null]; + $expected = [ + 'photo' => ['_empty' => 'message'], + ]; + $this->assertEmpty($validator->validate($data, true)); + $this->assertSame($expected, $validator->validate($data, false)); + } + + /** + * Tests the allowEmptyDate method + */ + public function testAllowEmptyDate(): void + { + $validator = new Validator(); + $validator->allowEmptyDate('date') + ->date('date'); + + $this->assertTrue($validator->field('date')->isEmptyAllowed()); + + $data = [ + 'date' => [ + 'year' => '', + 'month' => '', + 'day' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = [ + 'date' => '', + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = [ + 'date' => null, + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = ['date' => []]; + $result = $validator->validate($data); + $this->assertEmpty($result); + } + + /** + * test allowEmptyDate() with an update condition + */ + public function testAllowEmptyDateUpdate(): void + { + $validator = new Validator(); + $validator->allowEmptyArray('date', 'be valid', 'update'); + $this->assertFalse($validator->isEmptyAllowed('date', true)); + $this->assertTrue($validator->isEmptyAllowed('date', false)); + + $data = [ + 'date' => null, + ]; + $expected = [ + 'date' => ['_empty' => 'be valid'], + ]; + $this->assertSame($expected, $validator->validate($data, true)); + $this->assertEmpty($validator->validate($data, false)); + } + + /** + * Tests the notEmptyDate method + */ + public function testNotEmptyDate(): void + { + $validator = new Validator(); + $validator->notEmptyDate('date', 'required field'); + + $this->assertFalse($validator->isEmptyAllowed('date', true)); + $this->assertFalse($validator->isEmptyAllowed('date', false)); + + $error = ['date' => ['_empty' => 'required field']]; + $data = [ + 'date' => [ + 'year' => '', + 'month' => '', + 'day' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['date' => '']; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['date' => null]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['date' => []]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = [ + 'date' => [ + 'year' => 2019, + 'month' => 2, + 'day' => 17, + ], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + } + + /** + * Test notEmptyDate with update mode + */ + public function testNotEmptyDateUpdate(): void + { + $validator = new Validator(); + $validator->notEmptyDate('date', 'message', 'update'); + $this->assertTrue($validator->isEmptyAllowed('date', true)); + $this->assertFalse($validator->isEmptyAllowed('date', false)); + + $data = ['date' => null]; + $expected = ['date' => ['_empty' => 'message']]; + $this->assertSame($expected, $validator->validate($data, false)); + $this->assertEmpty($validator->validate($data, true)); + } + + /** + * Tests the allowEmptyTime method + */ + public function testAllowEmptyTime(): void + { + $validator = new Validator(); + $validator->allowEmptyTime('time') + ->time('time'); + + $this->assertTrue($validator->field('time')->isEmptyAllowed()); + + $data = [ + 'time' => [ + 'hour' => '', + 'minute' => '', + 'second' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = [ + 'time' => '', + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = [ + 'time' => null, + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = ['time' => []]; + $result = $validator->validate($data); + $this->assertEmpty($result); + } + + /** + * test allowEmptyTime with condition + */ + public function testAllowEmptyTimeCondition(): void + { + $validator = new Validator(); + $validator->allowEmptyTime('time', 'valid time', 'update'); + $this->assertFalse($validator->isEmptyAllowed('time', true)); + $this->assertTrue($validator->isEmptyAllowed('time', false)); + + $data = [ + 'time' => null, + ]; + $expected = [ + 'time' => ['_empty' => 'valid time'], + ]; + $this->assertSame($expected, $validator->validate($data, true)); + $this->assertEmpty($validator->validate($data, false)); + } + + /** + * Tests the notEmptyTime method + */ + public function testNotEmptyTime(): void + { + $validator = new Validator(); + $validator->notEmptyTime('time', 'required field'); + + $this->assertFalse($validator->isEmptyAllowed('time', true)); + $this->assertFalse($validator->isEmptyAllowed('time', false)); + + $error = ['time' => ['_empty' => 'required field']]; + $data = [ + 'time' => [ + 'hour' => '', + 'minute' => '', + 'second' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['time' => '']; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['time' => null]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['time' => []]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['time' => ['hour' => 12, 'minute' => 12, 'second' => 12]]; + $result = $validator->validate($data); + $this->assertEmpty($result); + } + + /** + * Test notEmptyTime with update mode + */ + public function testNotEmptyTimeUpdate(): void + { + $validator = new Validator(); + $validator->notEmptyTime('time', 'message', 'update'); + $this->assertTrue($validator->isEmptyAllowed('time', true)); + $this->assertFalse($validator->isEmptyAllowed('time', false)); + + $data = ['time' => null]; + $expected = ['time' => ['_empty' => 'message']]; + $this->assertEmpty($validator->validate($data, true)); + $this->assertSame($expected, $validator->validate($data, false)); + } + + /** + * Tests the allowEmptyDateTime method + */ + public function testAllowEmptyDateTime(): void + { + $validator = new Validator(); + $validator->allowEmptyDate('published') + ->dateTime('published'); + + $this->assertTrue($validator->field('published')->isEmptyAllowed()); + + $data = [ + 'published' => [ + 'year' => '', + 'month' => '', + 'day' => '', + 'hour' => '', + 'minute' => '', + 'second' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = [ + 'published' => '', + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = [ + 'published' => null, + ]; + $result = $validator->validate($data); + $this->assertEmpty($result); + + $data = ['published' => []]; + $this->assertEmpty($validator->validate($data)); + } + + /** + * test allowEmptyDateTime with a condition + */ + public function testAllowEmptyDateTimeCondition(): void + { + $validator = new Validator(); + $validator->allowEmptyDateTime('published', 'datetime required', 'update'); + $this->assertFalse($validator->isEmptyAllowed('published', true)); + $this->assertTrue($validator->isEmptyAllowed('published', false)); + + $data = [ + 'published' => null, + ]; + $expected = [ + 'published' => ['_empty' => 'datetime required'], + ]; + $this->assertSame($expected, $validator->validate($data, true)); + $this->assertEmpty($validator->validate($data, false)); + } + + /** + * Tests the notEmptyDateTime method + */ + public function testNotEmptyDateTime(): void + { + $validator = new Validator(); + $validator->notEmptyDateTime('published', 'required field'); + + $this->assertFalse($validator->isEmptyAllowed('published', true)); + $this->assertFalse($validator->isEmptyAllowed('published', false)); + + $error = ['published' => ['_empty' => 'required field']]; + $data = [ + 'published' => [ + 'year' => '', + 'month' => '', + 'day' => '', + 'hour' => '', + 'minute' => '', + 'second' => '', + ], + ]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['published' => '']; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['published' => null]; + $result = $validator->validate($data); + $this->assertSame($error, $result); + + $data = ['published' => []]; + $this->assertSame($error, $validator->validate($data)); + + $data = [ + 'published' => [ + 'year' => '2018', + 'month' => '2', + 'day' => '17', + 'hour' => '14', + 'minute' => '32', + 'second' => '33', + ], + ]; + $this->assertEmpty($validator->validate($data)); + } + + /** + * Test notEmptyDateTime with update mode + */ + public function testNotEmptyDateTimeUpdate(): void + { + $validator = new Validator(); + $validator->notEmptyDateTime('published', 'message', 'update'); + $this->assertTrue($validator->isEmptyAllowed('published', true)); + $this->assertFalse($validator->isEmptyAllowed('published', false)); + + $data = ['published' => null]; + $expected = ['published' => ['_empty' => 'message']]; + $this->assertSame($expected, $validator->validate($data, false)); + $this->assertEmpty($validator->validate($data, true)); + } + + /** + * Test the notEmpty() method. + */ + public function testNotEmpty(): void + { + $validator = new Validator(); + $validator->notEmptyString('title'); + $this->assertFalse($validator->field('title')->isEmptyAllowed()); + + $validator->allowEmptyString('title'); + $this->assertTrue($validator->field('title')->isEmptyAllowed()); + } + + /** + * Test the notEmpty() method. + */ + public function testNotEmptyModes(): void + { + $validator = new Validator(); + $validator->notEmptyString('title', 'Need a title', 'create'); + $this->assertFalse($validator->isEmptyAllowed('title', true)); + $this->assertTrue($validator->isEmptyAllowed('title', false)); + + $validator->notEmptyString('title', 'Need a title', 'update'); + $this->assertTrue($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + + $validator->notEmptyString('title', 'Need a title'); + $this->assertFalse($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + + $validator->notEmptyString('title'); + $this->assertFalse($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + } + + /** + * Test interactions between notEmpty() and isAllowed(). + */ + public function testNotEmptyAndIsAllowed(): void + { + $validator = new Validator(); + $validator->allowEmptyString('title') + ->notEmptyString('title', 'Need it', 'update'); + $this->assertTrue($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + + $validator->allowEmptyString('title') + ->notEmptyString('title'); + $this->assertFalse($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + + $validator->notEmptyString('title') + ->allowEmptyString('title', null, 'create'); + $this->assertTrue($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + } + + /** + * Tests the allowEmpty method when passing a callback + */ + public function testAllowEmptyCallback(): void + { + $validator = new Validator(); + $allow = true; + $validator->allowEmptyString('title', null, function ($context) use (&$allow) { + $this->assertEquals([], $context['data']); + $this->assertEquals(['default' => Validation::class], $context['providers']); + $this->assertTrue($context['newRecord']); + + return $allow; + }); + $this->assertTrue($validator->isEmptyAllowed('title', true)); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $allow = false; + $this->assertFalse($validator->isEmptyAllowed('title', true)); + } + + /** + * Tests the notEmpty method when passing a callback + */ + public function testNotEmptyCallback(): void + { + $validator = new Validator(); + $prevent = true; + $validator->notEmptyString('title', 'error message', function ($context) use (&$prevent) { + $this->assertEquals([], $context['data']); + $this->assertEquals(['default' => Validation::class], $context['providers']); + $this->assertFalse($context['newRecord']); + + return $prevent; + }); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $prevent = false; + $this->assertTrue($validator->isEmptyAllowed('title', false)); + } + + /** + * Tests the isEmptyAllowed method + */ + public function testIsEmptyAllowed(): void + { + $validator = new Validator(); + $this->assertSame($validator, $validator->allowEmptyString('title')); + $this->assertTrue($validator->isEmptyAllowed('title', true)); + $this->assertTrue($validator->isEmptyAllowed('title', false)); + + $validator->notEmptyString('title'); + $this->assertFalse($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + + $validator->allowEmptyString('title', null, 'create'); + $this->assertTrue($validator->isEmptyAllowed('title', true)); + $this->assertFalse($validator->isEmptyAllowed('title', false)); + + $validator->allowEmptyString('title', null, 'update'); + $this->assertTrue($validator->isEmptyAllowed('title', false)); + $this->assertFalse($validator->isEmptyAllowed('title', true)); + } + + /** + * Tests errors generated when a field is not allowed to be empty + */ + public function testErrorsWithEmptyNotAllowed(): void + { + $validator = new Validator(); + $validator->notEmptyString('title'); + $errors = $validator->validate(['title' => '']); + $expected = ['title' => ['_empty' => 'This field cannot be left empty']]; + $this->assertEquals($expected, $errors); + + $errors = $validator->validate(['title' => null]); + $expected = ['title' => ['_empty' => 'This field cannot be left empty']]; + $this->assertEquals($expected, $errors); + + $errors = $validator->validate(['title' => 0]); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => '0']); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => false]); + $this->assertEmpty($errors); + } + + /** + * Tests custom error messages generated when a field is allowed to be empty + */ + public function testCustomErrorsWithAllowedEmpty(): void + { + $validator = new Validator(); + $validator->allowEmptyString('title', 'Custom message', false); + + $errors = $validator->validate(['title' => null]); + $expected = ['title' => ['_empty' => 'Custom message']]; + $this->assertEquals($expected, $errors); + } + + /** + * Tests custom error messages generated when a field is not allowed to be empty + */ + public function testCustomErrorsWithEmptyNotAllowed(): void + { + $validator = new Validator(); + $validator->notEmptyString('title', 'Custom message'); + $errors = $validator->validate(['title' => '']); + $expected = ['title' => ['_empty' => 'Custom message']]; + $this->assertEquals($expected, $errors); + } + + /** + * Tests errors generated when a field is allowed to be empty + */ + public function testErrorsWithEmptyAllowed(): void + { + $validator = new Validator(); + $validator->allowEmptyString('title'); + $errors = $validator->validate(['title' => '']); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => []]); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => null]); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => 0]); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => 0.0]); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => '0']); + $this->assertEmpty($errors); + + $errors = $validator->validate(['title' => false]); + $this->assertEmpty($errors); + } + + /** + * Test the provider() method + */ + public function testProvider(): void + { + $validator = new Validator(); + $object = new stdClass(); + $this->assertSame($validator, $validator->setProvider('foo', $object)); + $this->assertSame($object, $validator->getProvider('foo')); + $this->assertNull($validator->getProvider('bar')); + + $another = new stdClass(); + $this->assertSame($validator, $validator->setProvider('bar', $another)); + $this->assertSame($another, $validator->getProvider('bar')); + + $this->assertEquals(Validation::class, $validator->getProvider('default')); + } + + /** + * Tests validate() method when using validators from the default provider, this proves + * that it returns a default validation message and the custom one set in the rule + */ + public function testErrorsFromDefaultProvider(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('email', 'notBlank', ['rule' => 'notBlank']) + ->add('email', 'email', ['rule' => 'email', 'message' => 'Y u no write email?']); + $errors = $validator->validate(['email' => 'not an email!']); + $expected = [ + 'email' => [ + 'alpha' => 'The provided value is invalid', + 'email' => 'Y u no write email?', + ], + ]; + $this->assertEquals($expected, $errors); + + $noI18nValidator = new NoI18nValidator(); + $noI18nValidator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('email', 'notBlank', ['rule' => 'notBlank']) + ->add('email', 'email', ['rule' => 'email', 'message' => 'Y u no write email?']); + $errors = $noI18nValidator->validate(['email' => 'not an email!']); + $this->assertEquals($expected, $errors); + } + + /** + * Tests using validation methods from different providers and returning the error + * as a string + */ + public function testErrorsFromCustomProvider(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('title', 'cool', ['rule' => 'isCool', 'provider' => 'thing']); + + $thing = new class { + public $args = []; + + public function isCool($data, $context): string + { + $this->args = [$data, $context]; + + return "That ain't cool, yo"; + } + }; + + $validator->setProvider('thing', $thing); + $errors = $validator->validate(['email' => '!', 'title' => 'bar']); + $expected = [ + 'email' => ['alpha' => 'The provided value is invalid'], + 'title' => ['cool' => "That ain't cool, yo"], + ]; + $this->assertEquals($expected, $errors); + + $this->assertSame('bar', $thing->args[0]); + + $context = $thing->args[1]; + $provider = $context['providers']['thing']; + $this->assertSame($thing, $provider); + + unset($context['providers']['thing']); + $expected = [ + 'newRecord' => true, + 'providers' => [ + 'default' => 'Cake\Validation\Validation', + ], + 'data' => [ + 'email' => '!', + 'title' => 'bar', + ], + 'field' => 'title', + ]; + $this->assertEquals($expected, $context); + } + + /** + * Tests that it is possible to pass extra arguments to the validation function + * and it still gets the providers as last argument + */ + public function testMethodsWithExtraArguments(): void + { + $validator = new Validator(); + $validator->add('title', 'cool', [ + 'rule' => ['isCool', 'and', 'awesome'], + 'provider' => 'thing', + ]); + $thing = new class { + public $args = []; + + public function isCool($data, $a, $b, $context): string + { + $this->args = [$data, $a, $b, $context]; + + return "That ain't cool, yo"; + } + }; + + $validator->setProvider('thing', $thing); + $errors = $validator->validate(['email' => '!', 'title' => 'bar']); + $expected = [ + 'title' => ['cool' => "That ain't cool, yo"], + ]; + $this->assertEquals($expected, $errors); + + $this->assertSame('bar', $thing->args[0]); + $this->assertSame('and', $thing->args[1]); + $this->assertSame('awesome', $thing->args[2]); + + $context = $thing->args[3]; + $provider = $context['providers']['thing']; + $this->assertSame($thing, $provider); + + unset($context['providers']['thing']); + $expected = [ + 'newRecord' => true, + 'providers' => [ + 'default' => 'Cake\Validation\Validation', + ], + 'data' => [ + 'email' => '!', + 'title' => 'bar', + ], + 'field' => 'title', + ]; + $this->assertEquals($expected, $context); + } + + /** + * Tests that it is possible to use a closure as a rule + */ + public function testUsingClosureAsRule(): void + { + $validator = new Validator(); + $validator->add('name', 'myRule', [ + 'rule' => function ($data) { + $this->assertSame('foo', $data); + + return 'You fail'; + }, + ]); + $expected = ['name' => ['myRule' => 'You fail']]; + $this->assertEquals($expected, $validator->validate(['name' => 'foo'])); + } + + /** + * Tests that setting last globally will stop validating the rest of the rules + */ + public function testErrorsWithLastRuleGlobal(): void + { + $validator = new Validator(); + $validator->setStopOnFailure() + ->notBlank('email', 'Fill something in!') + ->email('email', false, 'Y u no write email?'); + $errors = $validator->validate(['email' => '']); + $expected = [ + 'email' => [ + 'notBlank' => 'Fill something in!', + ], + ]; + + $this->assertEquals($expected, $errors); + } + + /** + * Tests that setting last to a rule will stop validating the rest of the rules + */ + public function testErrorsWithLastRule(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric', 'last' => true]) + ->add('email', 'email', ['rule' => 'email', 'message' => 'Y u no write email?']); + $errors = $validator->validate(['email' => 'not an email!']); + $expected = [ + 'email' => [ + 'alpha' => 'The provided value is invalid', + ], + ]; + + $this->assertEquals($expected, $errors); + } + + /** + * Tests it is possible to get validation sets for a field using an array interface + */ + public function testArrayAccessGet(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('title', 'cool', ['rule' => 'isCool', 'provider' => 'thing']); + $this->assertSame($validator['email'], $validator->field('email')); + $this->assertSame($validator['title'], $validator->field('title')); + } + + /** + * Tests direct usage the offsetGet method with an integer field name + */ + public function testOffsetGetWithIntegerFieldName(): void + { + $validator = new Validator(); + $validator + ->add('0', 'alpha', ['rule' => 'alphanumeric']); + $this->assertSame($validator->offsetGet(0), $validator->field('0')); + } + + /** + * Tests it is possible to check for validation sets for a field using an array interface + */ + public function testArrayAccessExists(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('title', 'cool', ['rule' => 'isCool', 'provider' => 'thing']); + $this->assertArrayHasKey('email', $validator); + $this->assertArrayHasKey('title', $validator); + $this->assertArrayNotHasKey('foo', $validator); + } + + /** + * Tests it is possible to set validation rules for a field using an array interface + */ + public function testArrayAccessSet(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('title', 'cool', ['rule' => 'isCool', 'provider' => 'thing']); + $validator['name'] = $validator->field('title'); + $this->assertSame($validator->field('title'), $validator->field('name')); + $validator['name'] = ['alpha' => ['rule' => 'alphanumeric']]; + $this->assertEquals($validator->field('email'), $validator->field('email')); + } + + /** + * Tests it is possible to unset validation rules + */ + public function testArrayAccessUnset(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('title', 'cool', ['rule' => 'isCool', 'provider' => 'thing']); + $this->assertArrayHasKey('title', $validator); + unset($validator['title']); + $this->assertArrayNotHasKey('title', $validator); + } + + /** + * Tests the getIterator method + */ + public function testGetIterator(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('title', 'cool', ['rule' => 'isCool', 'provider' => 'thing']); + $fieldIterator = $validator->getIterator(); + $this->assertInstanceOf(Traversable::class, $fieldIterator); + $this->assertCount(2, $validator); + } + + /** + * Tests the countable interface + */ + public function testCount(): void + { + $validator = new Validator(); + $validator + ->add('email', 'alpha', ['rule' => 'alphanumeric']) + ->add('title', 'cool', ['rule' => 'isCool', 'provider' => 'thing']); + $this->assertCount(2, $validator); + } + + /** + * Tests adding rules via alternative syntax + */ + public function testAddMultiple(): void + { + $validator = new Validator(); + $validator->add('title', [ + 'notBlank' => [ + 'rule' => 'notBlank', + 'message' => 'Title cannot be blank', + 'last' => true, + ], + 'length' => [ + 'rule' => ['minLength', 10], + 'message' => 'Titles need to be at least 10 characters long', + 'last' => true, + ], + ]); + $set = $validator->field('title'); + $this->assertInstanceOf(ValidationSet::class, $set); + $this->assertCount(2, $set); + + $errors = $validator->validate(['title' => ' ']); + $expected = [ + 'title' => [ + 'notBlank' => 'Title cannot be blank', + ], + ]; + $this->assertEquals($expected, $errors); + + $validator = new Validator(); + $validator->add('title', [ + 'notBlank' => [ + 'rule' => ['notBlank'], + ], + 'length' => [ + 'rule' => ['minLength', 10], + 'message' => 'Titles need to be at least 10 characters long', + ], + ]); + $set = $validator->field('title'); + $this->assertInstanceOf(ValidationSet::class, $set); + $this->assertCount(2, $set); + + $errors = $validator->validate(['title' => ' ']); + $expected = [ + 'title' => [ + 'notBlank' => 'The provided value is invalid', + 'length' => 'Titles need to be at least 10 characters long', + ], + ]; + $this->assertEquals($expected, $errors); + } + + /** + * Tests adding rules via alternative syntax and numeric keys + */ + public function testAddMultipleNumericKeyArraysInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You cannot add validation rules without a `name` key. Update rules array to have string keys.'); + + $validator = new Validator(); + $validator->add('title', [ + [ + 'rule' => 'notBlank', + ], + [ + 'rule' => ['minLength', 10], + 'message' => 'Titles need to be at least 10 characters long', + ], + ]); + } + + /** + * Integration test for compareWith validator. + */ + public function testCompareWithIntegration(): void + { + $validator = new Validator(); + $validator->add('password', [ + 'compare' => [ + 'rule' => ['compareWith', 'password_compare'], + ], + ]); + $data = [ + 'password' => 'test', + 'password_compare' => 'not the same', + ]; + $this->assertNotEmpty($validator->validate($data), 'Validation should fail.'); + } + + /** + * Test debugInfo helper method. + */ + public function testDebugInfo(): void + { + $validator = new Validator(); + $validator->setProvider('test', $this); + $validator->add('title', 'not-empty', ['rule' => 'notBlank']); + $validator->requirePresence('body'); + $validator->allowEmptyString('published'); + + $result = $validator->__debugInfo(); + $expected = [ + '_providers' => ['default', 'test'], + '_fields' => [ + 'title' => [ + 'isPresenceRequired' => false, + 'isEmptyAllowed' => false, + 'rules' => ['not-empty'], + ], + 'body' => [ + 'isPresenceRequired' => true, + 'isEmptyAllowed' => false, + 'rules' => [], + ], + 'published' => [ + 'isPresenceRequired' => false, + 'isEmptyAllowed' => true, + 'rules' => [], + ], + ], + '_presenceMessages' => [], + '_allowEmptyMessages' => [], + '_allowEmptyFlags' => [ + 'published' => Validator::EMPTY_STRING, + ], + '_useI18n' => true, + '_stopOnFailure' => false, + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests that the 'create' and 'update' modes are preserved when using + * nested validators + */ + public function testNestedValidatorCreate(): void + { + $validator = new Validator(); + $inner = new Validator(); + $inner->add('username', 'email', ['rule' => 'email', 'on' => 'create']); + $validator->addNested('user', $inner); + $this->assertNotEmpty($validator->validate(['user' => ['username' => 'example']], true)); + $this->assertEmpty($validator->validate(['user' => ['username' => 'a']], false)); + } + + /** + * Tests that the 'create' and 'update' modes are preserved when using + * nested validators + */ + public function testNestedManyValidatorCreate(): void + { + $validator = new Validator(); + $inner = new Validator(); + $inner->add('username', 'email', ['rule' => 'email', 'on' => 'create']); + $validator->addNestedMany('user', $inner); + $this->assertNotEmpty($validator->validate(['user' => [['username' => 'example']]], true)); + $this->assertEmpty($validator->validate(['user' => [['username' => 'a']]], false)); + } + + /** + * Tests the notBlank proxy method + */ + public function testNotBlank(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'notBlank'); + $this->assertNotEmpty($validator->validate(['username' => ' '])); + + $fieldName = 'field_name'; + $rule = 'notBlank'; + $expectedMessage = 'This field cannot be left empty'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the alphanumeric proxy method + */ + public function testAlphanumeric(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'alphaNumeric'); + $this->assertNotEmpty($validator->validate(['username' => '$'])); + + $fieldName = 'field_name'; + $rule = 'alphaNumeric'; + $expectedMessage = 'The provided value must be alphanumeric'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the notalphanumeric proxy method + */ + public function testNotAlphanumeric(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'notAlphaNumeric'); + $this->assertEmpty($validator->validate(['username' => '$'])); + + $fieldName = 'field_name'; + $rule = 'notAlphaNumeric'; + $expectedMessage = 'The provided value must not be alphanumeric'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the asciialphanumeric proxy method + */ + public function testAsciiAlphanumeric(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'asciiAlphaNumeric'); + $this->assertNotEmpty($validator->validate(['username' => '$'])); + + $fieldName = 'field_name'; + $rule = 'asciiAlphaNumeric'; + $expectedMessage = 'The provided value must be ASCII-alphanumeric'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the notalphanumeric proxy method + */ + public function testNotAsciiAlphanumeric(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'notAsciiAlphaNumeric'); + $this->assertEmpty($validator->validate(['username' => '$'])); + + $fieldName = 'field_name'; + $rule = 'notAsciiAlphaNumeric'; + $expectedMessage = 'The provided value must not be ASCII-alphanumeric'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the lengthBetween proxy method + */ + public function testLengthBetween(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'lengthBetween', [5, 7], [5, 7]); + $this->assertNotEmpty($validator->validate(['username' => 'foo'])); + + $fieldName = 'field_name'; + $rule = 'lengthBetween'; + $expectedMessage = 'The length of the provided value must be between `5` and `7`, inclusively'; + $lengthBetween = [5, 7]; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $lengthBetween); + } + + /** + * Tests the lengthBetween proxy method + */ + public function testLengthBetweenFailure(): void + { + $this->expectException(InvalidArgumentException::class); + $validator = new Validator(); + $validator->lengthBetween('username', [7]); + } + + /** + * Tests the creditCard proxy method + */ + public function testCreditCard(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'creditCard', 'all', ['all', true], 'creditCard'); + $this->assertNotEmpty($validator->validate(['username' => 'foo'])); + + $fieldName = 'field_name'; + $rule = 'creditCard'; + $expectedMessage = 'The provided value must be a valid credit card number of any type'; + $cardType = 'all'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $cardType); + + $expectedMessage = 'The provided value must be a valid credit card number of these types: `amex, bankcard, maestro`'; + $cardType = ['amex', 'bankcard', 'maestro']; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $cardType); + + $expectedMessage = 'The provided value must be a valid credit card number of these types: `amex`'; + $cardType = 'amex'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $cardType); + + // This should lead to a RangeException or an UnexpectedValueException, instead. + // As it could never successfully validate the data. + $expectedMessage = 'The provided value must be a valid credit card number of these types: ``'; + $cardType = []; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $cardType); + } + + /** + * Tests the greaterThan proxy method + */ + public function testGreaterThan(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'greaterThan', 5, [Validation::COMPARE_GREATER, 5], 'comparison'); + $this->assertNotEmpty($validator->validate(['username' => 2])); + + $fieldName = 'field_name'; + $rule = 'greaterThan'; + $expectedMessage = 'The provided value must be greater than `5`'; + $greaterThan = 5; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $greaterThan); + } + + /** + * Tests the greaterThanOrEqual proxy method + */ + public function testGreaterThanOrEqual(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'greaterThanOrEqual', 5, [Validation::COMPARE_GREATER_OR_EQUAL, 5], 'comparison'); + $this->assertNotEmpty($validator->validate(['username' => 2])); + + $fieldName = 'field_name'; + $rule = 'greaterThanOrEqual'; + $expectedMessage = 'The provided value must be greater than or equal to `5`'; + $greaterThanOrEqualTo = 5; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $greaterThanOrEqualTo); + } + + /** + * Tests the lessThan proxy method + */ + public function testLessThan(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'lessThan', 5, [Validation::COMPARE_LESS, 5], 'comparison'); + $this->assertNotEmpty($validator->validate(['username' => 5])); + + $fieldName = 'field_name'; + $rule = 'lessThan'; + $expectedMessage = 'The provided value must be less than `5`'; + $lessThan = 5; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $lessThan); + } + + /** + * Tests the lessThanOrEqual proxy method + */ + public function testLessThanOrEqual(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'lessThanOrEqual', 5, [Validation::COMPARE_LESS_OR_EQUAL, 5], 'comparison'); + $this->assertNotEmpty($validator->validate(['username' => 6])); + + $fieldName = 'field_name'; + $rule = 'lessThanOrEqual'; + $expectedMessage = 'The provided value must be less than or equal to `5`'; + $lessThanOrEqualTo = 5; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $lessThanOrEqualTo); + } + + /** + * Tests the equals proxy method + */ + public function testEquals(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'equals', 5, [Validation::COMPARE_EQUAL, 5], 'comparison'); + $this->assertEmpty($validator->validate(['username' => 5])); + $this->assertNotEmpty($validator->validate(['username' => 6])); + + $fieldName = 'field_name'; + $rule = 'equals'; + $expectedMessage = 'The provided value must be equal to `5`'; + $equalTo = 5; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $equalTo); + } + + /** + * Tests the notEquals proxy method + */ + public function testNotEquals(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'notEquals', 5, [Validation::COMPARE_NOT_EQUAL, 5], 'comparison'); + $this->assertNotEmpty($validator->validate(['username' => 5])); + + $fieldName = 'field_name'; + $rule = 'notEquals'; + $expectedMessage = 'The provided value must not be equal to `5`'; + $notEqualTo = 5; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $notEqualTo); + } + + /** + * Tests the sameAs proxy method + */ + public function testSameAs(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'sameAs', 'other', ['other', Validation::COMPARE_SAME], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 'foo'])); + $this->assertNotEmpty($validator->validate(['username' => 1, 'other' => '1'])); + + $fieldName = 'field_name'; + $rule = 'sameAs'; + $expectedMessage = 'The provided value must be same as `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the notSameAs proxy method + */ + public function testNotSameAs(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'notSameAs', 'other', ['other', Validation::COMPARE_NOT_SAME], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 'foo', 'other' => 'foo'])); + + $fieldName = 'field_name'; + $rule = 'notSameAs'; + $expectedMessage = 'The provided value must not be same as `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the equalToField proxy method + */ + public function testEqualToField(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'equalToField', 'other', ['other', Validation::COMPARE_EQUAL], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 'foo'])); + $this->assertNotEmpty($validator->validate(['username' => 'foo', 'other' => 'bar'])); + + $fieldName = 'field_name'; + $rule = 'equalToField'; + $expectedMessage = 'The provided value must be equal to the one of field `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the notEqualToField proxy method + */ + public function testNotEqualToField(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'notEqualToField', 'other', ['other', Validation::COMPARE_NOT_EQUAL], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 'foo', 'other' => 'foo'])); + + $fieldName = 'field_name'; + $rule = 'notEqualToField'; + $expectedMessage = 'The provided value must not be equal to the one of field `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the greaterThanField proxy method + */ + public function testGreaterThanField(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'greaterThanField', 'other', ['other', Validation::COMPARE_GREATER], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 1, 'other' => 1])); + $this->assertNotEmpty($validator->validate(['username' => 1, 'other' => 2])); + + $fieldName = 'field_name'; + $rule = 'greaterThanField'; + $expectedMessage = 'The provided value must be greater than the one of field `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the greaterThanOrEqualToField proxy method + */ + public function testGreaterThanOrEqualToField(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'greaterThanOrEqualToField', 'other', ['other', Validation::COMPARE_GREATER_OR_EQUAL], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 1, 'other' => 2])); + + $fieldName = 'field_name'; + $rule = 'greaterThanOrEqualToField'; + $expectedMessage = 'The provided value must be greater than or equal to the one of field `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the lessThanField proxy method + */ + public function testLessThanField(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'lessThanField', 'other', ['other', Validation::COMPARE_LESS], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 1, 'other' => 1])); + $this->assertNotEmpty($validator->validate(['username' => 2, 'other' => 1])); + + $fieldName = 'field_name'; + $rule = 'lessThanField'; + $expectedMessage = 'The provided value must be less than the one of field `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the lessThanOrEqualToField proxy method + */ + public function testLessThanOrEqualToField(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'lessThanOrEqualToField', 'other', ['other', Validation::COMPARE_LESS_OR_EQUAL], 'compareFields'); + $this->assertNotEmpty($validator->validate(['username' => 2, 'other' => 1])); + + $fieldName = 'field_name'; + $rule = 'lessThanOrEqualToField'; + $expectedMessage = 'The provided value must be less than or equal to the one of field `other`'; + $otherField = 'other'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $otherField); + } + + /** + * Tests the date proxy method + */ + public function testDate(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'date', ['ymd'], [['ymd']]); + $this->assertNotEmpty($validator->validate(['username' => 'not a date'])); + + $fieldName = 'field_name'; + $rule = 'date'; + $expectedMessage = 'The provided value must be a date of one of these formats: `ymd`'; + $format = ['ymd']; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + + // Same expected message + $format = null; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + + $expectedMessage = 'The provided value must be a date of one of these formats: `ymd, dmy`'; + $format = ['ymd', 'dmy']; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + + // This should lead to a RangeException or an UnexpectedValueException, instead. + // As it could never successfully validate the data. + $expectedMessage = 'The provided value must be a date of one of these formats: ``'; + $format = []; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + } + + /** + * Tests the dateTime proxy method + */ + public function testDateTime(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'dateTime', ['ymd'], [['ymd']], 'datetime'); + $this->assertNotEmpty($validator->validate(['username' => 'not a date'])); + + $validator = (new Validator())->dateTime('thedate', ['iso8601']); + $this->assertEmpty($validator->validate(['thedate' => '2020-05-01T12:34:56Z'])); + + $fieldName = 'field_name'; + $rule = 'dateTime'; + $expectedMessage = 'The provided value must be a date and time of one of these formats: `ymd`'; + $format = ['ymd']; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + + $format = null; + // Same message expected + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + + $expectedMessage = 'The provided value must be a date and time of one of these formats: `ymd, dmy`'; + $format = ['ymd', 'dmy']; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + + // This should lead to a RangeException or an UnexpectedValueException, instead. + // As it could never successfully validate the data. + $expectedMessage = 'The provided value must be a date and time of one of these formats: ``'; + $format = []; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $format); + } + + /** + * Tests the time proxy method + */ + public function testTime(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'time'); + $this->assertNotEmpty($validator->validate(['username' => 'not a time'])); + + $fieldName = 'field_name'; + $rule = 'time'; + $expectedMessage = 'The provided value must be a time'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the localizedTime proxy method + */ + public function testLocalizedTime(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'localizedTime', 'date', ['date']); + $this->assertNotEmpty($validator->validate(['username' => 'not a date'])); + + $fieldName = 'field_name'; + $rule = 'localizedTime'; + $expectedMessage = 'The provided value must be a localized time, date or date and time'; + $type = 'datetime'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $type); + } + + /** + * Tests the boolean proxy method + */ + public function testBoolean(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'boolean'); + $this->assertNotEmpty($validator->validate(['username' => 'not a boolean'])); + + $fieldName = 'field_name'; + $rule = 'boolean'; + $expectedMessage = 'The provided value must be a boolean'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the decimal proxy method + */ + public function testDecimal(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'decimal', 2, [2]); + $this->assertNotEmpty($validator->validate(['username' => 10.1])); + + $fieldName = 'field_name'; + $rule = 'decimal'; + $expectedMessage = 'The provided value must be decimal with `2` decimal places'; + $places = 2; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $places); + + $expectedMessage = 'The provided value must be decimal with any number of decimal places, including none'; + $places = null; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $places); + } + + /** + * Tests the IP proxy methods + */ + public function testIps(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'ip'); + $this->assertNotEmpty($validator->validate(['username' => 'not ip'])); + + $this->assertProxyMethod($validator, 'ipv4', null, ['ipv4'], 'ip'); + $this->assertNotEmpty($validator->validate(['username' => 'not ip'])); + + $this->assertProxyMethod($validator, 'ipv6', null, ['ipv6'], 'ip'); + $this->assertNotEmpty($validator->validate(['username' => 'not ip'])); + + $fieldName = 'field_name'; + $rule = 'ip'; + $expectedMessage = 'The provided value must be an IP address'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + + $fieldName = 'field_name'; + $rule = 'ipv4'; + $expectedMessage = 'The provided value must be an IPv4 address'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + + $fieldName = 'field_name'; + $rule = 'ipv6'; + $expectedMessage = 'The provided value must be an IPv6 address'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the minLength proxy method + */ + public function testMinLength(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'minLength', 2, [2]); + $this->assertNotEmpty($validator->validate(['username' => 'a'])); + + $fieldName = 'field_name'; + $rule = 'minLength'; + $expectedMessage = 'The provided value must be at least `2` characters long'; + $minLength = 2; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $minLength); + } + + /** + * Tests the minLengthBytes proxy method + */ + public function testMinLengthBytes(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'minLengthBytes', 11, [11]); + $this->assertNotEmpty($validator->validate(['username' => 'ÆΔΩЖÇ'])); + + $fieldName = 'field_name'; + $rule = 'minLengthBytes'; + $expectedMessage = 'The provided value must be at least `11` bytes long'; + $minLengthBytes = 11; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $minLengthBytes); + } + + /** + * Tests the maxLength proxy method + */ + public function testMaxLength(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'maxLength', 2, [2]); + $this->assertNotEmpty($validator->validate(['username' => 'aaa'])); + + $fieldName = 'field_name'; + $rule = 'maxLength'; + $expectedMessage = 'The provided value must be at most `2` characters long'; + $maxLength = 2; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $maxLength); + } + + /** + * Tests the maxLengthBytes proxy method + */ + public function testMaxLengthBytes(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'maxLengthBytes', 9, [9]); + $this->assertNotEmpty($validator->validate(['username' => 'ÆΔΩЖÇ'])); + + $fieldName = 'field_name'; + $rule = 'maxLengthBytes'; + $expectedMessage = 'The provided value must be at most `11` bytes long'; + $maxLengthBytes = 11; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $maxLengthBytes); + } + + /** + * Tests the numeric proxy method + */ + public function testNumeric(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'numeric'); + $this->assertEmpty($validator->validate(['username' => '22'])); + $this->assertNotEmpty($validator->validate(['username' => 'a'])); + + $fieldName = 'field_name'; + $rule = 'numeric'; + $expectedMessage = 'The provided value must be numeric'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the naturalNumber proxy method + */ + public function testNaturalNumber(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'naturalNumber', null, [false]); + $this->assertNotEmpty($validator->validate(['username' => 0])); + + $fieldName = 'field_name'; + $rule = 'naturalNumber'; + $expectedMessage = 'The provided value must be a natural number'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the nonNegativeInteger proxy method + */ + public function testNonNegativeInteger(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'nonNegativeInteger', null, [true], 'naturalNumber'); + $this->assertNotEmpty($validator->validate(['username' => -1])); + + $fieldName = 'field_name'; + $rule = 'nonNegativeInteger'; + $expectedMessage = 'The provided value must be a non-negative integer'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the range proxy method + */ + public function testRange(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'range', [1, 4], [1, 4]); + $this->assertNotEmpty($validator->validate(['username' => 5])); + + $fieldName = 'field_name'; + $rule = 'range'; + $expectedMessage = 'The provided value must be between `1` and `4`, inclusively'; + $range = [1, 4]; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $range); + } + + /** + * Tests the range failure case + */ + public function testRangeFailure(): void + { + $this->expectException(InvalidArgumentException::class); + $validator = new Validator(); + $validator->range('username', [1]); + } + + /** + * Tests the url proxy method + */ + public function testUrl(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'url', null, [false]); + $this->assertNotEmpty($validator->validate(['username' => 'not url'])); + + $fieldName = 'field_name'; + $rule = 'url'; + $expectedMessage = 'The provided value must be a URL'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the urlWithProtocol proxy method + */ + public function testUrlWithProtocol(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'urlWithProtocol', null, [true], 'url'); + $this->assertNotEmpty($validator->validate(['username' => 'google.com'])); + + $fieldName = 'field_name'; + $rule = 'urlWithProtocol'; + $expectedMessage = 'The provided value must be a URL with protocol'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the inList proxy method + */ + public function testInList(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'inList', ['a', 'b'], [['a', 'b']]); + $this->assertNotEmpty($validator->validate(['username' => 'c'])); + + $fieldName = 'field_name'; + $rule = 'inList'; + $expectedMessage = 'The provided value must be one of: `a, b`'; + $list = ['a', 'b']; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $list); + + // This should lead to a RangeException or an UnexpectedValueException, instead. + // As it could never successfully validate the data. + $expectedMessage = 'The provided value must be one of: ``'; + $list = []; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $list); + } + + /** + * Tests the uuid proxy method + */ + public function testUuid(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'uuid'); + $this->assertNotEmpty($validator->validate(['username' => 'not uuid'])); + + $fieldName = 'field_name'; + $rule = 'uuid'; + $expectedMessage = 'The provided value must be a UUID'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the uploadedFile proxy method + */ + public function testUploadedFile(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'uploadedFile', ['foo' => 'bar'], [['foo' => 'bar']]); + $this->assertNotEmpty($validator->validate(['username' => []])); + + $fieldName = 'field_name'; + $rule = 'uploadedFile'; + $expectedMessage = 'The provided value must be an uploaded file'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, ['foo' => 'bar']); + } + + /** + * Tests the latlog proxy methods + */ + public function testLatLong(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'latLong', null, [], 'geoCoordinate'); + $this->assertNotEmpty($validator->validate(['username' => 2000])); + + $this->assertProxyMethod($validator, 'latitude'); + $this->assertNotEmpty($validator->validate(['username' => 2000])); + + $this->assertProxyMethod($validator, 'longitude'); + $this->assertNotEmpty($validator->validate(['username' => 2000])); + + $fieldName = 'field_name'; + $rule = 'latLong'; + $expectedMessage = 'The provided value must be a latitude/longitude coordinate'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + + $rule = 'latitude'; + $expectedMessage = 'The provided value must be a latitude'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + + $rule = 'longitude'; + $expectedMessage = 'The provided value must be a longitude'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the ascii proxy method + */ + public function testAscii(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'ascii'); + $this->assertNotEmpty($validator->validate(['username' => 'ü'])); + + $fieldName = 'field_name'; + $rule = 'ascii'; + $expectedMessage = 'The provided value must be ASCII bytes only'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the utf8 proxy methods + */ + public function testUtf8(): void + { + // Grinning face + $extended = 'some' . "\xf0\x9f\x98\x80" . 'value'; + $validator = new Validator(); + + $this->assertProxyMethod($validator, 'utf8', null, [['extended' => false]]); + $this->assertEmpty($validator->validate(['username' => 'ü'])); + $this->assertNotEmpty($validator->validate(['username' => $extended])); + + $fieldName = 'field_name'; + $rule = 'utf8'; + $expectedMessage = 'The provided value must be UTF-8 bytes only'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Test utf8extended proxy method. + */ + public function testUtf8Extended(): void + { + // Grinning face + $extended = 'some' . "\xf0\x9f\x98\x80" . 'value'; + $validator = new Validator(); + + $this->assertProxyMethod($validator, 'utf8Extended', null, [['extended' => true]], 'utf8'); + $this->assertEmpty($validator->validate(['username' => 'ü'])); + $this->assertEmpty($validator->validate(['username' => $extended])); + + $fieldName = 'field_name'; + $rule = 'utf8Extended'; + $expectedMessage = 'The provided value must be 3 and 4 byte UTF-8 sequences only'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the email proxy method + */ + public function testEmail(): void + { + $validator = new Validator(); + $validator->email('username'); + $this->assertEmpty($validator->validate(['username' => 'test@example.com'])); + $this->assertNotEmpty($validator->validate(['username' => 'not an email'])); + + $fieldName = 'field_name'; + $rule = 'email'; + $expectedMessage = 'The provided value must be an e-mail address'; + $checkMx = false; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $checkMx); + } + + public function testEnum(): void + { + $validator = new Validator(); + $validator->enum('status', ArticleStatus::class); + + $this->assertEmpty($validator->validate(['status' => ArticleStatus::Published])); + $this->assertEmpty($validator->validate(['status' => 'Y'])); + + $this->assertNotEmpty($validator->validate(['status' => Priority::Low])); + $this->assertNotEmpty($validator->validate(['status' => 'wrong type'])); + $this->assertNotEmpty($validator->validate(['status' => 123])); + $this->assertNotEmpty($validator->validate(['status' => NonBacked::Basic])); + + $fieldName = 'status'; + $rule = 'enum'; + $expectedMessage = 'The provided value must be one of `Y`, `N`'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, ArticleStatus::class); + } + + public function testEnumNonBacked(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); + + $validator = new Validator(); + $validator->enum('status', NonBacked::class); + } + + public function testEnumNonEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); + + $validator = new Validator(); + $validator->enum('status', TestCase::class); + } + + /** + * Tests the integer proxy method + */ + public function testInteger(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'integer', null, [], 'isInteger'); + $this->assertNotEmpty($validator->validate(['username' => 'not integer'])); + + $fieldName = 'field_name'; + $rule = 'integer'; + $expectedMessage = 'The provided value must be an integer'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the array proxy method + */ + public function testArray(): void + { + $validator = new Validator(); + $validator->array('username'); + $this->assertEmpty($validator->validate(['username' => [1, 2, 3]])); + $this->assertSame( + ['username' => ['array' => 'The provided value must be an array']], + $validator->validate(['username' => 'is not an array']), + ); + + $fieldName = 'field_name'; + $rule = 'array'; + $expectedMessage = 'The provided value must be an array'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the scalar proxy method + */ + public function testScalar(): void + { + $validator = new Validator(); + $validator->scalar('username'); + $this->assertEmpty($validator->validate(['username' => 'scalar'])); + $this->assertSame( + ['username' => ['scalar' => 'The provided value must be scalar']], + $validator->validate(['username' => ['array']]), + ); + + $fieldName = 'field_name'; + $rule = 'scalar'; + $expectedMessage = 'The provided value must be scalar'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the hexColor proxy method + */ + public function testHexColor(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'hexColor'); + $this->assertEmpty($validator->validate(['username' => '#FFFFFF'])); + $this->assertNotEmpty($validator->validate(['username' => 'FFFFFF'])); + + $fieldName = 'field_name'; + $rule = 'hexColor'; + $expectedMessage = 'The provided value must be a hex color'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage); + } + + /** + * Tests the multiple proxy method + */ + public function testMultiple(): void + { + $validator = new Validator(); + $this->assertProxyMethod( + $validator, + 'multipleOptions', + ['min' => 1, 'caseInsensitive' => true], + [['min' => 1], true], + 'multiple', + ); + + $this->assertProxyMethod( + $validator, + 'multipleOptions', + ['min' => 1, 'caseInsensitive' => false], + [['min' => 1], false], + 'multiple', + ); + + $this->assertNotEmpty($validator->validate(['username' => ''])); + + $fieldName = 'field_name'; + $rule = 'multipleOptions'; + $expectedMessage = 'The provided value must be a set of multiple options'; + $options = ['min' => 1, 'caseInsensitive' => false]; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $options); + } + + /** + * Tests the hasAtLeast method + */ + public function testHasAtLeast(): void + { + $validator = new Validator(); + $validator->hasAtLeast('things', 3); + $this->assertEmpty($validator->validate(['things' => [1, 2, 3]])); + $this->assertEmpty($validator->validate(['things' => [1, 2, 3, 4]])); + $this->assertNotEmpty($validator->validate(['things' => [1, 2]])); + $this->assertNotEmpty($validator->validate(['things' => []])); + $this->assertNotEmpty($validator->validate(['things' => 'string'])); + + $this->assertEmpty($validator->validate(['things' => ['_ids' => [1, 2, 3]]])); + $this->assertEmpty($validator->validate(['things' => ['_ids' => [1, 2, 3, 4]]])); + $this->assertNotEmpty($validator->validate(['things' => ['_ids' => [1, 2]]])); + $this->assertNotEmpty($validator->validate(['things' => ['_ids' => []]])); + $this->assertNotEmpty($validator->validate(['things' => ['_ids' => 'string']])); + + $fieldName = 'field_name'; + $rule = 'hasAtLeast'; + $expectedMessage = 'The provided value must have at least `5` elements'; + $atLeast = 5; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $atLeast); + } + + /** + * Tests the hasAtMost method + */ + public function testHasAtMost(): void + { + $validator = new Validator(); + $validator->hasAtMost('things', 3); + $this->assertEmpty($validator->validate(['things' => [1, 2, 3]])); + $this->assertEmpty($validator->validate(['things' => [1]])); + $this->assertNotEmpty($validator->validate(['things' => [1, 2, 3, 4]])); + + $this->assertEmpty($validator->validate(['things' => ['_ids' => [1, 2, 3]]])); + $this->assertEmpty($validator->validate(['things' => ['_ids' => [1, 2]]])); + $this->assertNotEmpty($validator->validate(['things' => ['_ids' => [1, 2, 3, 4]]])); + + $fieldName = 'field_name'; + $rule = 'hasAtMost'; + $expectedMessage = 'The provided value must have at most `4` elements'; + $atMost = 4; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $atMost); + } + + /** + * Tests the regex proxy method + */ + public function testRegex(): void + { + $validator = new Validator(); + $this->assertProxyMethod($validator, 'regex', '/(?assertEmpty($validator->validate(['username' => '123'])); + $this->assertNotEmpty($validator->validate(['username' => 'Foo'])); + + $fieldName = 'field_name'; + $rule = 'regex'; + $expectedMessage = 'The provided value must match against the pattern `/(?assertValidationMessage($fieldName, $rule, $expectedMessage, $regex); + } + + /** + * Tests the validate method with an integer key + */ + public function testValidateWithIntegerKey(): void + { + $validator = new Validator(); + $validator->requirePresence('0'); + $errors = $validator->validate([1 => 'Not integer zero']); + $expected = ['0' => ['_required' => 'This field is required']]; + $this->assertEquals($expected, $errors); + } + + /** + * Tests that a rule in the Validator class exists and was configured as expected. + * + * @param Validator $validator + * @param string $method + * @param mixed $extra + * @param array $pass + * @param string|null $name + */ + protected function assertProxyMethod($validator, $method, $extra = null, $pass = [], $name = null): void + { + $validator->remove('username', $method); + $name = $name ?: $method; + if ($extra !== null) { + $this->assertSame($validator, $validator->{$method}('username', $extra)); + } else { + $this->assertSame($validator, $validator->{$method}('username')); + } + + $rule = $validator->field('username')->rule($method); + $this->assertNotEmpty($rule, "Rule was not found for {$method}"); + $this->assertNotNull($rule->get('message'), 'Message is not present when it should be'); + $this->assertNull($rule->get('on'), 'On clause is present when it should not be'); + $this->assertSame($name, $rule->get('rule'), 'Rule name does not match'); + $this->assertEquals($pass, $rule->get('pass'), 'Passed options are different'); + $this->assertSame('default', $rule->get('provider'), 'Provider does not match'); + + $validator->remove('username', $method); + if ($extra !== null) { + $validator->{$method}('username', $extra, 'the message', 'create'); + } else { + $validator->{$method}('username', 'the message', 'create'); + } + + $rule = $validator->field('username')->rule($method); + $this->assertSame('the message', $rule->get('message'), 'Error messages are not the same'); + $this->assertSame('create', $rule->get('on'), 'On clause is wrong'); + } + + /** + * Testing adding DefaultProvider + */ + public function testAddingDefaultProvider(): void + { + $validator = new Validator(); + $this->assertSame(['default'], $validator->providers(), '`default` validator provider is missing'); + $this->assertSame(Validation::class, $validator->getProvider('default')); + + Validator::addDefaultProvider('test-provider', 'MyNameSpace\Validation\MyProvider'); + $validator = new Validator(); + $this->assertEquals( + ['test-provider', 'default'], + $validator->providers(), + 'Default provider `test-provider` is missing', + ); + } + + /** + * Testing getting DefaultProvider(s) + */ + public function testGetDefaultProvider(): void + { + Validator::addDefaultProvider('test-provider', 'MyNameSpace\Validation\MyProvider'); + $this->assertEquals(Validator::getDefaultProvider('test-provider'), 'MyNameSpace\Validation\MyProvider', 'Default provider `test-provider` is missing'); + + $this->assertNull(Validator::getDefaultProvider('invalid-provider'), 'Default provider (`invalid-provider`) should be missing'); + + Validator::addDefaultProvider('test-provider2', 'MyNameSpace\Validation\MySecondProvider'); + $this->assertEquals(Validator::getDefaultProviders(), ['test-provider', 'test-provider2'], 'Default providers incorrect'); + } + + /** + * Test ensuring that context array doesn't get passed for an optional validation method argument. + * Validation::decimal() has 2 arguments and only 1 is being passed here. + * + * @return void + */ + public function testWithoutPassingAllArguments(): void + { + $validator = new Validator(); + $validator->setProvider('default', Validation::class); + + $validator->decimal('field', 2); + $this->assertEmpty($validator->validate(['field' => 1.23])); + } + + /** + * Test that nested validators receive parent context information + */ + public function testNestedValidationWithParentContext(): void + { + $contextCapture = null; + + $nestedValidator = new Validator(); + $nestedValidator->add('nested_field', 'custom', [ + 'rule' => function ($value, $context) use (&$contextCapture) { + $contextCapture = $context; + // Access parent data through context + if (isset($context['parentContext'])) { + return $context['parentContext']['data']['parent_field'] === 'valid'; + } + + return true; + }, + 'message' => 'Parent field must be valid', + ]); + + $validator = new Validator(); + $validator->notEmptyString('parent_field'); + $validator->addNested('nested', $nestedValidator); + + $data = [ + 'parent_field' => 'invalid', + 'nested' => [ + 'nested_field' => 'test', + ], + ]; + + $errors = $validator->validate($data); + + // Verify parent context was passed + $this->assertNotNull($contextCapture); + $this->assertArrayHasKey('parentContext', $contextCapture); + $this->assertArrayHasKey('data', $contextCapture['parentContext']); + $this->assertEquals('invalid', $contextCapture['parentContext']['data']['parent_field']); + + // Should have validation error because parent_field is 'invalid' + $this->assertArrayHasKey('nested', $errors); + $this->assertArrayHasKey('nested_field', $errors['nested']); + + // Test with valid parent field + $data['parent_field'] = 'valid'; + $errors = $validator->validate($data); + $this->assertArrayNotHasKey('nested', $errors); + } + + /** + * Test that nested many validators receive parent context and index information + */ + public function testNestedManyValidationWithParentContextAndIndex(): void + { + $contextCaptures = []; + + $nestedValidator = new Validator(); + $nestedValidator->add('item_name', 'custom', [ + 'rule' => function ($value, $context) use (&$contextCaptures) { + $contextCaptures[] = $context; + // Validate based on index + if (isset($context['nestedManyIndex']) && $context['nestedManyIndex'] === 0) { + // First item must not be empty + return !empty($value); + } + + return true; + }, + 'message' => 'First item name cannot be empty', + ]); + + $validator = new Validator(); + $validator->notEmptyString('list_name'); + $validator->addNestedMany('items', $nestedValidator); + + $data = [ + 'list_name' => 'My List', + 'items' => [ + ['item_name' => ''], // Should fail - first item + ['item_name' => ''], // Should pass - not first item + ['item_name' => 'Third'], + ], + ]; + + $errors = $validator->validate($data); + + // Verify context was captured for all items + $this->assertCount(3, $contextCaptures); + + // Check each captured context has correct index + foreach ($contextCaptures as $i => $context) { + $this->assertArrayHasKey('nestedManyIndex', $context); + $this->assertEquals($i, $context['nestedManyIndex']); + $this->assertArrayHasKey('parentContext', $context); + $this->assertEquals('My List', $context['parentContext']['data']['list_name']); + } + + // Should have error only for first item + $this->assertArrayHasKey('items', $errors); + $this->assertArrayHasKey(0, $errors['items']); + $this->assertArrayNotHasKey(1, $errors['items']); + } + + /** + * Assert for the data validation message for a given field's rule for a I18n-enabled & a I18n-disabled validator + * + * @param string $fieldName The field name. + * @param string $rule The rule name. + * @param string $expectedMessage The expected data validation message. + * @param mixed $additional Additional configuration (optional). + * @return void + */ + protected function assertValidationMessage( + string $fieldName, + string $rule, + string $expectedMessage, + mixed $additional = null, + ): void { + $validator = new Validator(); + if ($additional !== null) { + $validator->{$rule}($fieldName, $additional); + } else { + $validator->{$rule}($fieldName); + } + + $this->assertSame( + $expectedMessage, + $validator->field($fieldName)->rule($rule)->get('message'), + ); + + $noI18nValidator = new NoI18nValidator(); + if ($additional !== null) { + $noI18nValidator->{$rule}($fieldName, $additional); + } else { + $noI18nValidator->{$rule}($fieldName); + } + + $this->assertSame( + $expectedMessage, + $noI18nValidator->field($fieldName)->rule($rule)->get('message'), + ); + } +} + +// phpcs:disable +class stdMock extends stdClass +{ + public function isCool() {} +} +// phpcs:enable diff --git a/tests/TestCase/Validation/stubs.php b/tests/TestCase/Validation/stubs.php new file mode 100644 index 00000000000..829e7ad6507 --- /dev/null +++ b/tests/TestCase/Validation/stubs.php @@ -0,0 +1,29 @@ +clearPlugins(); + $this->loadPlugins(['TestPlugin', 'TestTheme']); + $request = new ServerRequest(); + $response = new Response(); + $this->View = new View($request, $response); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->View); + } + + /** + * Tests basic cell rendering. + */ + public function testCellRender(): void + { + $cell = $this->View->cell('Articles::teaserList'); + $render = "{$cell}"; + + $this->assertSame('teaser_list', $cell->viewBuilder()->getTemplate()); + $this->assertStringContainsString('

    Lorem ipsum

    ', $render); + $this->assertStringContainsString('

    Usectetur adipiscing eli

    ', $render); + $this->assertStringContainsString('

    Topis semper blandit eu non

    ', $render); + $this->assertStringContainsString('

    Suspendisse gravida neque

    ', $render); + + $cell = $this->View->cell('Cello'); + $this->assertInstanceOf(CelloCell::class, $cell); + $this->assertSame("Cellos\n", $cell->render()); + } + + /** + * Tests debug output. + */ + public function testDebugInfo(): void + { + $cell = $this->View->cell('Articles::teaserList'); + $data = $cell->__debugInfo(); + $this->assertArrayHasKey('request', $data); + $this->assertArrayHasKey('response', $data); + $this->assertSame('teaserList', $data['action']); + $this->assertEquals([], $data['args']); + } + + /** + * Test __toString() hitting an error when rendering views. + */ + public function testCellImplictRenderWithError(): void + { + $capture = function ($errno, $msg): void { + restore_error_handler(); + $this->assertSame(E_USER_WARNING, $errno); + $this->assertStringContainsString('Could not render cell - Cell template file', $msg); + }; + set_error_handler($capture); + + $cell = $this->View->cell('Articles::teaserList'); + $cell->viewBuilder()->setTemplate('nope'); + (string)$cell; + } + + /** + * Tests that we are able pass multiple arguments to cell methods. + * + * This test sets its own error handler, as PHPUnit won't convert + * errors into exceptions when the caller is a __toString() method. + */ + public function testCellWithArguments(): void + { + $cell = $this->View->cell('Articles::doEcho', ['dummy', ' message']); + $render = "{$cell}"; + $this->assertStringContainsString('dummy message', $render); + } + + public function testCellWithNamedArguments(): void + { + $cell = $this->View->cell('Articles::doEcho', ['msg1' => 'dummy', 'msg2' => ' message']); + $render = "{$cell}"; + $this->assertStringContainsString('dummy message', $render); + + $cell = $this->View->cell('Articles::doEcho', ['msg2' => ' dummy', 'msg1' => 'message']); + $render = "{$cell}"; + $this->assertStringContainsString('message dummy', $render); + } + + /** + * Tests that cell runs default action when none is provided. + */ + public function testDefaultCellAction(): void + { + $appCell = $this->View->cell('Articles'); + + $this->assertSame('display', $appCell->viewBuilder()->getTemplate()); + $this->assertStringContainsString('dummy', "{$appCell}"); + + $pluginCell = $this->View->cell('TestPlugin.Dummy'); + $this->assertStringContainsString('dummy', "{$pluginCell}"); + $this->assertSame('display', $pluginCell->viewBuilder()->getTemplate()); + } + + /** + * Tests that cell action setting the templatePath + */ + public function testSettingCellTemplatePathFromAction(): void + { + $appCell = $this->View->cell('Articles::customTemplatePath'); + + $this->assertStringContainsString('Articles subdir custom_template_path template', "{$appCell}"); + $this->assertSame('custom_template_path', $appCell->viewBuilder()->getTemplate()); + $this->assertSame(Cell::TEMPLATE_FOLDER . '/Articles/Subdir', $appCell->viewBuilder()->getTemplatePath()); + } + + /** + * Tests that cell action setting the template using the ViewBuilder renders the correct template + */ + public function testSettingCellTemplateFromActionViewBuilder(): void + { + $appCell = $this->View->cell('Articles::customTemplateViewBuilder'); + + $this->assertStringContainsString('This is the alternate template', "{$appCell}"); + $this->assertSame('alternate_teaser_list', $appCell->viewBuilder()->getTemplate()); + } + + /** + * Tests manual render() invocation. + */ + public function testCellManualRender(): void + { + /** @var \TestApp\View\Cell\ArticlesCell $cell */ + $cell = $this->View->cell('Articles::doEcho', ['msg1' => 'dummy', 'msg2' => ' message']); + $this->assertStringContainsString('dummy message', $cell->render()); + + $cell->teaserList(); + $this->assertStringContainsString('

    Lorem ipsum

    ', $cell->render('teaser_list')); + } + + /** + * Tests manual render() invocation with error + */ + public function testCellManualRenderError(): void + { + $cell = $this->View->cell('Articles'); + + $e = null; + try { + $cell->render('fooBar'); + } catch (MissingCellTemplateException $e) { + } + + $this->assertNotNull($e); + $message = $e->getMessage(); + $this->assertStringContainsString( + str_replace('/', DS, 'Cell template file `cell/Articles/foo_bar.php` could not be found.'), + $message, + ); + $this->assertStringContainsString('The following paths', $message); + $this->assertStringContainsString(ROOT . DS . 'templates', $message); + $this->assertInstanceOf(MissingTemplateException::class, $e->getPrevious()); + } + + /** + * Test rendering a cell with a theme. + */ + public function testCellRenderThemed(): void + { + $this->View->setTheme('TestTheme'); + $cell = $this->View->cell('Articles'); + + $this->assertEquals($this->View->getTheme(), $cell->viewBuilder()->getTheme()); + $this->assertStringContainsString('Themed cell content.', $cell->render()); + } + + /** + * Test that a cell can render a plugin view. + */ + public function testCellRenderPluginTemplate(): void + { + $cell = $this->View->cell('Articles'); + $this->assertStringContainsString( + 'TestPlugin Articles/display', + $cell->render('TestPlugin.display'), + ); + + $cell = $this->View->cell('Articles'); + $cell->viewBuilder()->setPlugin('TestPlugin'); + $this->assertStringContainsString( + 'TestPlugin Articles/display', + $cell->render('display'), + ); + } + + /** + * Tests that using plugin's cells works. + */ + public function testPluginCell(): void + { + $cell = $this->View->cell('TestPlugin.Dummy::echoThis', ['msg' => 'hello world!']); + $this->assertStringContainsString('hello world!', "{$cell}"); + } + + /** + * Tests that using namespaced cells works. + */ + public function testNamespacedCell(): void + { + $cell = $this->View->cell('Admin/Menu'); + $this->assertStringContainsString('Admin Menu Cell', $cell->render()); + } + + /** + * Tests that using namespaced cells in plugins works + */ + public function testPluginNamespacedCell(): void + { + $cell = $this->View->cell('TestPlugin.Admin/Menu'); + $this->assertStringContainsString('Test Plugin Admin Menu Cell', $cell->render()); + } + + /** + * Test that plugin cells can render other view templates. + */ + public function testPluginCellAlternateTemplate(): void + { + $cell = $this->View->cell('TestPlugin.Dummy::echoThis', ['msg' => 'hello world!']); + $cell->viewBuilder()->setTemplate('../../element/translate'); + $this->assertStringContainsString('This is a translatable string', "{$cell}"); + } + + /** + * Test that plugin cells can render other view templates. + */ + public function testPluginCellAlternateTemplateRenderParam(): void + { + $cell = $this->View->cell('TestPlugin.Dummy::echoThis', ['msg' => 'hello world!']); + $result = $cell->render('../../element/translate'); + $this->assertStringContainsString('This is a translatable string', $result); + } + + /** + * Tests that plugin name is accessible during initialize(). + */ + public function testPluginAccessibleDuringInitialize(): void + { + $cell = $this->View->cell('TestPlugin.PluginAware'); + + $this->assertSame('TestPlugin', $cell->pluginFromInitialize, 'Plugin should be accessible during initialize()'); + + $output = $cell->render(); + $this->assertStringContainsString('Plugin from initialize: TestPlugin', $output); + $this->assertStringContainsString('Plugin from action: TestPlugin', $output); + $this->assertSame('TestPlugin', $cell->pluginFromAction, 'Plugin should be accessible during action'); + } + + /** + * Tests that plugin is null for non-plugin cells during initialize(). + */ + public function testNonPluginCellDuringInitialize(): void + { + $cell = $this->View->cell('PluginAware'); + + $this->assertNull($cell->pluginFromInitialize, 'Plugin should be null for non-plugin cells during initialize()'); + + $output = $cell->render(); + $this->assertStringContainsString('Plugin from initialize: null', $output); + $this->assertStringContainsString('Plugin from action: null', $output); + $this->assertNull($cell->pluginFromAction, 'Plugin should be null for non-plugin cells during action'); + } + + /** + * Tests that using an nonexistent cell throws an exception. + */ + public function testNonExistentCell(): void + { + $this->expectException(MissingCellException::class); + $this->View->cell('Void::echoThis', ['arg1' => 'v1', 'arg2' => 'v2']); + } + + /** + * Tests missing method errors + */ + public function testCellMissingMethod(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Class `TestApp\View\Cell\ArticlesCell` does not have a `nope` method.'); + $cell = $this->View->cell('Articles::nope'); + $cell->render(); + } + + /** + * Test that cell options are passed on. + */ + public function testCellOptions(): void + { + /** @var \TestApp\View\Cell\ArticlesCell $cell */ + $cell = $this->View->cell('Articles', [], ['limit' => 10, 'nope' => 'nope']); + $this->assertSame(10, $cell->limit); + $this->assertTrue(!isset($cell->nope), 'Not a valid option'); + } + + /** + * Test that cells get the helper configuration from the view that created them. + */ + public function testCellInheritsHelperConfig(): void + { + $request = new ServerRequest(); + $response = new Response(); + $helpers = ['Url', 'Form', 'Banana']; + + $view = new View($request, $response, null, ['helpers' => $helpers]); + + $cell = $view->cell('Articles'); + $expected = array_combine($helpers, [[], [], []]); + $this->assertSame($expected, $cell->viewBuilder()->getHelpers()); + } + + /** + * Test that cells the view class name of a custom view passed on. + */ + public function testCellInheritsCustomViewClass(): void + { + $request = new ServerRequest(); + $response = new Response(); + $view = new CustomJsonView($request, $response); + $view->setTheme('Pretty'); + $cell = $view->cell('Articles'); + $this->assertSame(CustomJsonView::class, $cell->viewBuilder()->getClassName()); + $this->assertSame('Pretty', $cell->viewBuilder()->getTheme()); + } + + /** + * Test that cells the view class name of a controller passed on. + */ + public function testCellInheritsController(): void + { + $request = new ServerRequest(); + $controller = new CellTraitTestController($request); + $controller->viewBuilder()->setTheme('Pretty'); + $controller->viewBuilder()->setClassName('Json'); + $cell = $controller->cell('Articles'); + $this->assertSame('Json', $cell->viewBuilder()->getClassName()); + $this->assertSame('Pretty', $cell->viewBuilder()->getTheme()); + } + + /** + * Test cached render. + */ + public function testCachedRenderSimple(): void + { + Cache::setConfig('default', ['className' => 'Array']); + + $cell = $this->View->cell('Articles', [], ['cache' => true]); + $result = $cell->render(); + $expected = "dummy\n"; + $this->assertSame($expected, $result); + + $result = Cache::read('cell_test_app_view_cell_articles_cell_display_default', 'default'); + $this->assertSame($expected, $result); + Cache::drop('default'); + } + + /** + * Test read cached cell. + */ + public function testReadCachedCell(): void + { + Cache::setConfig('default', ['className' => 'Array']); + Cache::write('cell_test_app_view_cell_articles_cell_display_default', 'from cache'); + + $cell = $this->View->cell('Articles', [], ['cache' => true]); + $result = $cell->render(); + $this->assertSame('from cache', $result); + Cache::drop('default'); + } + + /** + * Test cached render array config + */ + public function testCachedRenderArrayConfig(): void + { + Cache::setConfig('cell', ['className' => 'Array']); + Cache::write('my_key', 'from cache', 'cell'); + + $cell = $this->View->cell('Articles', [], [ + 'cache' => ['key' => 'my_key', 'config' => 'cell'], + ]); + $result = $cell->render(); + $this->assertSame('from cache', $result); + Cache::drop('cell'); + } + + /** + * Test cached render when using an action changing the template used + */ + public function testCachedRenderSimpleCustomTemplate(): void + { + Cache::setConfig('default', ['className' => 'Array']); + + $cell = $this->View->cell('Articles::customTemplateViewBuilder', [], ['cache' => true]); + $result = $cell->render(); + $expected = 'This is the alternate template'; + $this->assertStringContainsString($expected, $result); + + $result = Cache::read('cell_test_app_view_cell_articles_cell_customTemplateViewBuilder_default'); + $this->assertStringContainsString($expected, $result); + Cache::drop('default'); + } + + /** + * Test that when the cell cache is enabled, the cell action is only invoke the first + * time the cell is rendered + */ + public function testCachedRenderSimpleCustomTemplateViewBuilder(): void + { + Cache::setConfig('default', ['className' => 'Array']); + /** @var \TestApp\View\Cell\ArticlesCell $cell */ + $cell = $this->View->cell('Articles::customTemplateViewBuilder', [], ['cache' => ['key' => 'celltest']]); + $result = $cell->render(); + $this->assertSame(1, $cell->counter); + $cell->render(); + + $this->assertSame(1, $cell->counter); + $this->assertStringContainsString('This is the alternate template', $result); + Cache::drop('default'); + } + + /** + * Test that when the cell cache is enabled, the cell action is only invoke the first + * time the cell is rendered + */ + public function testACachedViewCellReRendersWhenGivenADifferentTemplate(): void + { + Cache::setConfig('default', ['className' => 'Array']); + $cell = $this->View->cell('Articles::customTemplateViewBuilder', [], ['cache' => true]); + $result = $cell->render('alternate_teaser_list'); + $result2 = $cell->render('not_the_alternate_teaser_list'); + $this->assertStringContainsString('This is the alternate template', $result); + $this->assertStringContainsString('This is NOT the alternate template', $result2); + Cache::delete('celltest'); + Cache::drop('default'); + } + + /** + * Tests events are dispatched correctly + */ + public function testCellRenderDispatchesEvents(): void + { + $args = ['msg1' => 'dummy', 'msg2' => ' message']; + /** @var \TestApp\View\Cell\ArticlesCell $cell */ + $cell = $this->View->cell('Articles::doEcho', $args); + $beforeEventIsCalled = false; + $afterEventIsCalled = false; + $manager = $this->View->getEventManager(); + $manager->on('Cell.beforeAction', function ($event, $eventCell, $action, $eventArgs) use ($cell, $args, &$beforeEventIsCalled): void { + $this->assertSame($eventCell, $cell); + $this->assertEquals('doEcho', $action); + $this->assertEquals($args, $eventArgs); + $beforeEventIsCalled = true; + }); + $manager->on('Cell.afterAction', function ($event, $eventCell, $action, $eventArgs) use ($cell, $args, &$afterEventIsCalled): void { + $this->assertSame($eventCell, $cell); + $this->assertEquals('doEcho', $action); + $this->assertEquals($args, $eventArgs); + $afterEventIsCalled = true; + }); + $cell->render(); + $this->assertTrue($beforeEventIsCalled); + $this->assertTrue($afterEventIsCalled); + } +} diff --git a/tests/TestCase/View/Form/ArrayContextTest.php b/tests/TestCase/View/Form/ArrayContextTest.php new file mode 100644 index 00000000000..8c04f585fc9 --- /dev/null +++ b/tests/TestCase/View/Form/ArrayContextTest.php @@ -0,0 +1,321 @@ + [ + 'Comments' => [ + 'required' => 'My custom message', + 'nope' => false, + 'tags' => true, + ], + ], + ]); + + $this->assertSame('My custom message', $context->getRequiredMessage('Comments.required')); + $this->assertSame('This field cannot be left empty', $context->getRequiredMessage('Comments.tags')); + $this->assertSame(null, $context->getRequiredMessage('Comments.nope')); + } + + /** + * Test getting the primary key. + */ + public function testPrimaryKey(): void + { + $context = new ArrayContext([]); + $this->assertEquals([], $context->getPrimaryKey()); + + $context = new ArrayContext([ + 'schema' => [ + '_constraints' => 'mistake', + ], + ]); + $this->assertEquals([], $context->getPrimaryKey()); + + $data = [ + 'schema' => [ + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ], + ]; + $context = new ArrayContext($data); + + $expected = ['id']; + $this->assertEquals($expected, $context->getPrimaryKey()); + } + + /** + * Test isPrimaryKey. + */ + public function testIsPrimaryKey(): void + { + $context = new ArrayContext([]); + $this->assertFalse($context->isPrimaryKey('id')); + + $context = new ArrayContext([ + 'schema' => [ + '_constraints' => 'mistake', + ], + ]); + $this->assertFalse($context->isPrimaryKey('mistake')); + + $data = [ + 'schema' => [ + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ], + ]; + $context = new ArrayContext($data); + $this->assertTrue($context->isPrimaryKey('id')); + $this->assertFalse($context->isPrimaryKey('name')); + + $data = [ + 'schema' => [ + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id', 'name']], + ], + ], + ]; + $context = new ArrayContext($data); + $this->assertTrue($context->isPrimaryKey('id')); + $this->assertTrue($context->isPrimaryKey('name')); + } + + /** + * Test the isCreate method. + */ + public function testIsCreate(): void + { + $context = new ArrayContext([]); + $this->assertTrue($context->isCreate()); + + $data = [ + 'schema' => [ + '_constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id']], + ], + ], + ]; + $context = new ArrayContext($data); + $this->assertTrue($context->isCreate()); + + $data['defaults'] = ['id' => 2]; + $context = new ArrayContext($data); + $this->assertFalse($context->isCreate()); + } + + /** + * Test reading values from data & defaults. + */ + public function testValPresent(): void + { + $context = new ArrayContext([ + 'data' => [ + 'Articles' => [ + 'title' => 'New title', + 'body' => 'My copy', + ], + ], + 'defaults' => [ + 'Articles' => [ + 'title' => 'Default value', + 'published' => 0, + ], + ], + ]); + $this->assertSame('New title', $context->val('Articles.title')); + $this->assertSame('My copy', $context->val('Articles.body')); + $this->assertSame(0, $context->val('Articles.published')); + $this->assertNull($context->val('Articles.nope')); + } + + /** + * Test getting values when the data and defaults are missing. + */ + public function testValMissing(): void + { + $context = new ArrayContext([]); + $this->assertNull($context->val('Comments.field')); + } + + /** + * Test getting default value + * + * Tests includes making sure numeric elements are stripped but not keys beginning with numeric + * value + */ + public function testValDefault(): void + { + $context = new ArrayContext([ + 'defaults' => [ + 'title' => 'Default value', + 'users' => ['tags' => 'common1', '9tags' => 'common2'], + ], + ]); + + $this->assertSame('Default value', $context->val('title')); + $this->assertSame('common1', $context->val('users.0.tags')); + $this->assertSame('common1', $context->val('users.99.tags')); + $this->assertSame('common2', $context->val('users.9.9tags')); + $result = $context->val('title', ['default' => 'explicit default']); + $this->assertSame('explicit default', $result); + } + + /** + * Test isRequired + */ + public function testIsRequired(): void + { + $context = new ArrayContext([ + 'required' => [ + 'Comments' => [ + 'required' => true, + 'nope' => false, + 'tags' => true, + ], + ], + ]); + $this->assertTrue($context->isRequired('Comments.required')); + $this->assertFalse($context->isRequired('Comments.nope')); + $this->assertTrue($context->isRequired('Comments.0.tags')); + $this->assertNull($context->isRequired('Articles.id')); + } + + /** + * Test isRequired when the required key is omitted + */ + public function testIsRequiredUndefined(): void + { + $context = new ArrayContext([]); + $this->assertNull($context->isRequired('Comments.field')); + } + + /** + * Test the type method. + */ + public function testType(): void + { + $context = new ArrayContext([ + 'schema' => [ + 'Comments' => [ + 'id' => ['type' => 'integer'], + 'tags' => ['type' => 'string'], + 'comment' => ['length' => 255], + ], + ], + ]); + $this->assertNull($context->type('Comments.undefined')); + $this->assertSame('integer', $context->type('Comments.id')); + $this->assertSame('string', $context->type('Comments.0.tags')); + $this->assertNull($context->type('Comments.comment')); + } + + /** + * Test the type method when the data is missing. + */ + public function testIsTypeUndefined(): void + { + $context = new ArrayContext([]); + $this->assertNull($context->type('Comments.undefined')); + } + + /** + * Test fetching attributes. + */ + public function testAttributes(): void + { + $context = new ArrayContext([ + 'schema' => [ + 'Comments' => [ + 'id' => ['type' => 'integer'], + 'comment' => ['type' => 'string', 'length' => 255], + 'decimal' => ['type' => 'decimal', 'precision' => 2, 'length' => 5], + 'floaty' => ['type' => 'float', 'precision' => 2, 'length' => 5], + 'tags' => ['type' => 'string', 'length' => 25], + ], + ], + ]); + $this->assertEquals([], $context->attributes('Comments.id')); + $this->assertEquals(['length' => 25], $context->attributes('Comments.0.tags')); + $this->assertEquals(['length' => 255], $context->attributes('Comments.comment')); + $this->assertEquals(['precision' => 2, 'length' => 5], $context->attributes('Comments.decimal')); + $this->assertEquals(['precision' => 2, 'length' => 5], $context->attributes('Comments.floaty')); + } + + /** + * Test fetching errors. + */ + public function testError(): void + { + $context = new ArrayContext([]); + $this->assertEquals([], $context->error('Comments.empty')); + + $context = new ArrayContext([ + 'errors' => [ + 'Comments' => [ + 'comment' => ['Comment is required'], + 'empty' => [], + 'user_id' => 'A valid userid is required', + ], + ], + ]); + $this->assertEquals(['Comment is required'], $context->error('Comments.comment')); + $this->assertEquals(['A valid userid is required'], $context->error('Comments.user_id')); + $this->assertEquals([], $context->error('Comments.empty')); + $this->assertEquals([], $context->error('Comments.not_there')); + } + + /** + * Test checking errors. + */ + public function testHasError(): void + { + $context = new ArrayContext([ + 'errors' => [ + 'Comments' => [ + 'comment' => ['Comment is required'], + 'empty' => [], + 'user_id' => 'A valid userid is required', + ], + ], + ]); + $this->assertFalse($context->hasError('Comments.not_there')); + $this->assertFalse($context->hasError('Comments.empty')); + $this->assertTrue($context->hasError('Comments.user_id')); + $this->assertTrue($context->hasError('Comments.comment')); + } +} diff --git a/tests/TestCase/View/Form/ContextFactoryTest.php b/tests/TestCase/View/Form/ContextFactoryTest.php new file mode 100644 index 00000000000..c265632f001 --- /dev/null +++ b/tests/TestCase/View/Form/ContextFactoryTest.php @@ -0,0 +1,40 @@ +expectException(CakeException::class); + $this->expectExceptionMessage( + 'No context provider found for value of type `bool`.' + . ' Use `null` as 1st argument of FormHelper::create() to create a context-less form.', + ); + + $factory = new ContextFactory(); + $factory->get(new ServerRequest(), ['entity' => false]); + } +} diff --git a/tests/TestCase/View/Form/EntityContextTest.php b/tests/TestCase/View/Form/EntityContextTest.php new file mode 100644 index 00000000000..7417b952dbb --- /dev/null +++ b/tests/TestCase/View/Form/EntityContextTest.php @@ -0,0 +1,1411 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Comments', 'core.Tags', 'core.ArticlesTags']; + + /** + * tests getRequiredMessage + */ + public function testGetRequiredMessage(): void + { + $this->_setupTables(); + + $context = new EntityContext([ + 'entity' => new Article(), + 'table' => 'Articles', + 'validator' => 'create', + ]); + + $this->assertNull($context->getRequiredMessage('body')); + $this->assertSame("Don't forget a title!", $context->getRequiredMessage('title')); + } + + /** + * Test getting entity back from context. + */ + public function testEntity(): void + { + $row = new Article(); + $context = new EntityContext([ + 'entity' => $row, + ]); + $this->assertSame($row, $context->entity()); + } + + /** + * Test getting primary key data. + */ + public function testPrimaryKey(): void + { + $row = new Article(); + $context = new EntityContext([ + 'entity' => $row, + ]); + $this->assertEquals(['id'], $context->getPrimaryKey()); + } + + /** + * Test isPrimaryKey + */ + public function testIsPrimaryKey(): void + { + $this->_setupTables(); + + $row = new Article(); + $context = new EntityContext([ + 'entity' => $row, + ]); + $this->assertTrue($context->isPrimaryKey('id')); + $this->assertFalse($context->isPrimaryKey('title')); + $this->assertTrue($context->isPrimaryKey('1.id')); + $this->assertTrue($context->isPrimaryKey('Articles.1.id')); + $this->assertTrue($context->isPrimaryKey('comments.0.id')); + $this->assertTrue($context->isPrimaryKey('1.comments.0.id')); + $this->assertFalse($context->isPrimaryKey('1.comments.0.comment')); + $this->assertFalse($context->isPrimaryKey('Articles.1.comments.0.comment')); + $this->assertTrue($context->isPrimaryKey('tags.0._joinData.article_id')); + $this->assertTrue($context->isPrimaryKey('tags.0._joinData.tag_id')); + } + + /** + * Test isCreate on a single entity. + */ + public function testIsCreateSingle(): void + { + $row = new Article(); + $context = new EntityContext([ + 'entity' => $row, + ]); + $this->assertTrue($context->isCreate()); + + $row->setNew(false); + $this->assertFalse($context->isCreate()); + + $row->setNew(true); + $this->assertTrue($context->isCreate()); + } + + /** + * Test isCreate on a collection. + * + * @param mixed $collection + */ + #[DataProvider('collectionProvider')] + public function testIsCreateCollection($collection): void + { + $context = new EntityContext([ + 'entity' => $collection, + ]); + $this->assertTrue($context->isCreate()); + } + + /** + * Test an invalid table scope throws an error. + */ + public function testInvalidTable(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unable to find table class for current entity'); + $row = new stdClass(); + new EntityContext([ + 'entity' => $row, + ]); + } + + /** + * Tests that passing a plain entity will give an error as it cannot be matched + */ + public function testDefaultEntityError(): void + { + $this->expectException(CakeException::class); + $this->expectExceptionMessage('Unable to find table class for current entity'); + new EntityContext([ + 'entity' => new Entity(), + ]); + } + + /** + * Tests that the table can be derived from the entity source if it is present + */ + public function testTableFromEntitySource(): void + { + $entity = new Entity(); + $entity->setSource('Articles'); + $context = new EntityContext([ + 'entity' => $entity, + ]); + $expected = ['id', 'author_id', 'title', 'body', 'published']; + $this->assertEquals($expected, $context->fieldNames()); + } + + /** + * Test operations with no entity. + */ + public function testOperationsNoEntity(): void + { + $context = new EntityContext([ + 'table' => 'Articles', + ]); + + $this->assertNull($context->val('title')); + $this->assertNull($context->isRequired('title')); + $this->assertFalse($context->hasError('title')); + $this->assertSame('string', $context->type('title')); + $this->assertEquals([], $context->error('title')); + + $attrs = $context->attributes('title'); + $this->assertArrayHasKey('length', $attrs); + $this->assertArrayHasKey('precision', $attrs); + } + + /** + * Test operations that lack a table argument. + */ + public function testOperationsNoTableArg(): void + { + $row = new Article([ + 'title' => 'Test entity', + 'body' => 'Something new', + ]); + $row->setError('title', ['Title is required.']); + + $context = new EntityContext([ + 'entity' => $row, + ]); + + $result = $context->val('title'); + $this->assertEquals($row->title, $result); + + $result = $context->error('title'); + $this->assertEquals($row->getError('title'), $result); + $this->assertTrue($context->hasError('title')); + } + + /** + * Test collection operations that lack a table argument. + * + * @param mixed $collection + */ + #[DataProvider('collectionProvider')] + public function testCollectionOperationsNoTableArg($collection): void + { + $context = new EntityContext([ + 'entity' => $collection, + ]); + + $result = $context->val('0.title'); + $this->assertSame('First post', $result); + + $result = $context->error('1.body'); + $this->assertEquals(['Not long enough'], $result); + + $this->assertNull($context->val('0')); + } + + /** + * Data provider for testing collections. + * + * @return array + */ + public static function collectionProvider(): array + { + $one = new Article([ + 'title' => 'First post', + 'body' => 'Stuff', + 'user' => new Entity(['username' => 'mark']), + ]); + $one->setError('title', 'Required field'); + + $two = new Article([ + 'title' => 'Second post', + 'body' => 'Some text', + 'user' => new Entity(['username' => 'jose']), + ]); + $two->setError('body', 'Not long enough'); + + return [ + 'array' => [[$one, $two]], + 'basic iterator' => [new ArrayObject([$one, $two])], + 'array iterator' => [new ArrayIterator([$one, $two])], + 'collection' => [new Collection([$one, $two])], + ]; + } + + /** + * Test operations on a collection of entities. + * + * @param mixed $collection + */ + #[DataProvider('collectionProvider')] + public function testValOnCollections($collection): void + { + $context = new EntityContext([ + 'entity' => $collection, + 'table' => 'Articles', + ]); + + $result = $context->val('0.title'); + $this->assertSame('First post', $result); + + $result = $context->val('0.user.username'); + $this->assertSame('mark', $result); + + $result = $context->val('1.title'); + $this->assertSame('Second post', $result); + + $result = $context->val('1.user.username'); + $this->assertSame('jose', $result); + + $this->assertNull($context->val('nope')); + $this->assertNull($context->val('99.title')); + } + + /** + * Test operations on a collection of entities when prefixing with the + * table name + * + * @param mixed $collection + */ + #[DataProvider('collectionProvider')] + public function testValOnCollectionsWithRootName($collection): void + { + $context = new EntityContext([ + 'entity' => $collection, + 'table' => 'Articles', + ]); + + $result = $context->val('Articles.0.title'); + $this->assertSame('First post', $result); + + $result = $context->val('Articles.0.user.username'); + $this->assertSame('mark', $result); + + $result = $context->val('Articles.1.title'); + $this->assertSame('Second post', $result); + + $result = $context->val('Articles.1.user.username'); + $this->assertSame('jose', $result); + + $this->assertNull($context->val('Articles.99.title')); + } + + /** + * Test error operations on a collection of entities. + * + * @param mixed $collection + */ + #[DataProvider('collectionProvider')] + public function testErrorsOnCollections($collection): void + { + $context = new EntityContext([ + 'entity' => $collection, + 'table' => 'Articles', + ]); + + $this->assertTrue($context->hasError('0.title')); + $this->assertEquals(['Required field'], $context->error('0.title')); + $this->assertFalse($context->hasError('0.body')); + + $this->assertFalse($context->hasError('1.title')); + $this->assertEquals(['Not long enough'], $context->error('1.body')); + $this->assertTrue($context->hasError('1.body')); + + $this->assertFalse($context->hasError('nope')); + $this->assertFalse($context->hasError('99.title')); + } + + /** + * Test schema operations on a collection of entities. + * + * @param mixed $collection + */ + #[DataProvider('collectionProvider')] + public function testSchemaOnCollections($collection): void + { + $this->_setupTables(); + $context = new EntityContext([ + 'entity' => $collection, + 'table' => 'Articles', + ]); + + $this->assertSame('string', $context->type('0.title')); + $this->assertSame('text', $context->type('1.body')); + $this->assertSame('string', $context->type('0.user.username')); + $this->assertSame('string', $context->type('1.user.username')); + $this->assertSame('string', $context->type('99.title')); + $this->assertNull($context->type('0.nope')); + + $expected = [ + 'length' => 255, 'precision' => null, + 'null' => null, 'default' => null, 'comment' => null, + ]; + $this->assertEquals($expected, $context->attributes('0.user.username')); + } + + /** + * Test validation operations on a collection of entities. + * + * @param mixed $collection + */ + #[DataProvider('collectionProvider')] + public function testValidatorsOnCollections($collection): void + { + $this->_setupTables(); + + $context = new EntityContext([ + 'entity' => $collection, + 'table' => 'Articles', + 'validator' => [ + 'Articles' => 'create', + 'Users' => 'custom', + ], + ]); + $this->assertNull($context->isRequired('nope')); + + $this->assertTrue($context->isRequired('0.title')); + $this->assertTrue($context->isRequired('0.user.username')); + $this->assertFalse($context->isRequired('1.body')); + + $this->assertTrue($context->isRequired('99.title')); + $this->assertNull($context->isRequired('99.nope')); + } + + /** + * Test reading data. + */ + public function testValBasic(): void + { + $row = new Article([ + 'title' => 'Test entity', + 'body' => 'Something new', + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $result = $context->val('title'); + $this->assertEquals($row->title, $result); + + $result = $context->val('body'); + $this->assertEquals($row->body, $result); + + $row->requireFieldPresence(true); + $result = $context->val('nope'); + $this->assertNull($result); + } + + /** + * Test reading invalid data. + */ + public function testValInvalid(): void + { + $row = new Article([ + 'title' => 'Valid title', + ]); + $row->setInvalidField('title', 'Invalid title'); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $result = $context->val('title'); + $this->assertSame('Invalid title', $result); + } + + /** + * Test default values when entity is an array. + */ + public function testValDefaultArray(): void + { + $context = new EntityContext([ + 'entity' => new Article([ + 'prop' => ['title' => 'foo'], + ]), + 'table' => 'Articles', + ]); + $this->assertSame('foo', $context->val('prop.title', ['default' => 'bar'])); + $this->assertSame('bar', $context->val('prop.nope', ['default' => 'bar'])); + } + + /** + * Test reading array values from an entity. + */ + public function testValGetArrayValue(): void + { + $row = new Article([ + 'title' => 'Test entity', + 'types' => [1, 2, 3], + 'tag' => [ + 'name' => 'Test tag', + ], + 'author' => new Entity([ + 'roles' => ['admin', 'publisher'], + 'aliases' => new ArrayObject(['dave', 'david']), + ]), + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $result = $context->val('types'); + $this->assertEquals($row->types, $result); + + $result = $context->val('author.roles'); + $this->assertEquals($row->author->roles, $result); + + $result = $context->val('tag.name'); + $this->assertEquals($row->tag['name'], $result); + + $result = $context->val('author.aliases.0'); + $this->assertEquals($row->author->aliases[0], $result, 'ArrayAccess can be read'); + + $this->assertNull($context->val('author.aliases.3')); + $this->assertNull($context->val('tag.nope')); + $this->assertNull($context->val('author.roles.3')); + } + + /** + * Test reading values from associated entities. + */ + public function testValAssociated(): void + { + $row = new Article([ + 'title' => 'Test entity', + 'user' => new Entity([ + 'username' => 'mark', + 'fname' => 'Mark', + ]), + 'comments' => [ + new Entity(['comment' => 'Test comment']), + new Entity(['comment' => 'Second comment']), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $result = $context->val('user.fname'); + $this->assertEquals($row->user->fname, $result); + + $result = $context->val('comments.0.comment'); + $this->assertEquals($row->comments[0]->comment, $result); + + $result = $context->val('comments.1.comment'); + $this->assertEquals($row->comments[1]->comment, $result); + + $result = $context->val('comments.0.nope'); + $this->assertNull($result); + + $result = $context->val('comments.0.nope.no_way'); + $this->assertNull($result); + } + + /** + * Tests that trying to get values from missing associations returns null + */ + public function testValMissingAssociation(): void + { + $row = new Article([ + 'id' => 1, + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $result = $context->val('id'); + $this->assertEquals($row->id, $result); + $this->assertNull($context->val('profile.id')); + } + + /** + * Test reading values from associated entities. + */ + public function testValAssociatedHasMany(): void + { + $row = new Article([ + 'title' => 'First post', + 'user' => new Entity([ + 'username' => 'mark', + 'fname' => 'Mark', + 'articles' => [ + new Article(['title' => 'First post']), + new Article(['title' => 'Second post']), + ], + ]), + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $result = $context->val('user.articles.0.title'); + $this->assertSame('First post', $result); + + $result = $context->val('user.articles.1.title'); + $this->assertSame('Second post', $result); + } + + /** + * Test reading values for magic _ids input + */ + public function testValAssociatedDefaultIds(): void + { + $row = new Article([ + 'title' => 'First post', + 'user' => new Entity([ + 'username' => 'mark', + 'fname' => 'Mark', + 'sections' => [ + new Entity(['title' => 'PHP', 'id' => 1]), + new Entity(['title' => 'Javascript', 'id' => 2]), + ], + ]), + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $result = $context->val('user.sections._ids'); + $this->assertEquals([1, 2], $result); + } + + /** + * Test reading values for magic _ids input + */ + public function testValAssociatedCustomIds(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'First post', + 'user' => new Entity([ + 'username' => 'mark', + 'fname' => 'Mark', + 'sections' => [ + new Entity(['title' => 'PHP', 'thing' => 1]), + new Entity(['title' => 'Javascript', 'thing' => 4]), + ], + ]), + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->getTableLocator()->get('Users')->belongsToMany('Sections'); + $this->getTableLocator()->get('Sections')->setPrimaryKey('thing'); + + $result = $context->val('user.sections._ids'); + $this->assertEquals([1, 4], $result); + } + + /** + * Test getting default value from table schema. + */ + public function testValSchemaDefault(): void + { + $table = $this->getTableLocator()->get('Articles'); + $column = $table->getSchema()->getColumn('title'); + $table->getSchema()->addColumn('title', ['default' => 'default title'] + $column); + $row = $table->newEmptyEntity(); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $result = $context->val('title'); + $this->assertSame('default title', $result); + } + + /** + * Test getting association default value from table schema. + */ + public function testValAssociatedSchemaDefault(): void + { + $table = $this->getTableLocator()->get('Articles'); + $associatedTable = $table->hasMany('Comments')->getTarget(); + $column = $associatedTable->getSchema()->getColumn('comment'); + $associatedTable->getSchema()->addColumn('comment', ['default' => 'default comment'] + $column); + $row = $table->newEmptyEntity(); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $result = $context->val('comments.0.comment'); + $this->assertSame('default comment', $result); + } + + /** + * Test getting association join table default value from table schema. + */ + public function testValAssociatedJoinTableSchemaDefault(): void + { + $table = $this->getTableLocator()->get('Articles'); + $joinTable = $table + ->belongsToMany('Tags') + ->setThrough('ArticlesTags') + ->junction(); + $joinTable->getSchema()->addColumn('column', [ + 'default' => 'default join table column value', + 'type' => 'text', + ]); + $row = $table->newEmptyEntity(); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $result = $context->val('tags.0._joinData.column'); + $this->assertSame('default join table column value', $result); + } + + /** + * Test validator for boolean fields. + */ + public function testIsRequiredBooleanField(): void + { + $this->_setupTables(); + + $context = new EntityContext([ + 'entity' => new Entity(), + 'table' => 'Articles', + ]); + $articles = $this->getTableLocator()->get('Articles'); + $articles->getSchema()->addColumn('comments_on', [ + 'type' => 'boolean', + ]); + + $validator = $articles->getValidator(); + $validator->add('comments_on', 'is_bool', [ + 'rule' => 'boolean', + ]); + $articles->setValidator('default', $validator); + + $this->assertNull($context->isRequired('title')); + } + + /** + * Test that isRequired() returns null when allowEmptyString() is given a callable condition. + * + * The callable cannot be evaluated at render time (no submitted data available), + * so FormHelper must not add required="required" to the input. + */ + public function testIsRequiredWithCallableAllowEmpty(): void + { + $this->_setupTables(); + + $articles = $this->getTableLocator()->get('Articles'); + $validator = new Validator(); + $validator->notEmptyString('title', 'Title is required', function ($context) { + // Condition depends on submitted data — cannot be evaluated at render time + return $context['newRecord']; + }); + $articles->setValidator('default', $validator); + + $context = new EntityContext([ + 'entity' => new Article(), + 'table' => 'Articles', + ]); + + // Must return null so FormHelper skips required="required" on the input + $this->assertNull($context->isRequired('title')); + } + + /** + * Test validator as a string. + */ + public function testIsRequiredStringValidator(): void + { + $this->_setupTables(); + + $context = new EntityContext([ + 'entity' => new Entity(), + 'table' => 'Articles', + 'validator' => 'create', + ]); + + $this->assertTrue($context->isRequired('title')); + $this->assertFalse($context->isRequired('body')); + + $this->assertNull($context->isRequired('Herp.derp.derp')); + $this->assertNull($context->isRequired('nope')); + $this->assertNull($context->isRequired('')); + } + + /** + * Test isRequired on associated entities. + */ + public function testIsRequiredAssociatedHasMany(): void + { + $this->_setupTables(); + + $comments = $this->getTableLocator()->get('Comments'); + $validator = $comments->getValidator(); + $validator->add('user_id', 'number', [ + 'rule' => 'numeric', + ]); + + $row = new Article([ + 'title' => 'My title', + 'comments' => [ + new Entity(['comment' => 'First comment']), + new Entity(['comment' => 'Second comment']), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + 'validator' => 'default', + ]); + + $this->assertTrue($context->isRequired('comments.0.user_id')); + $this->assertNull($context->isRequired('comments.0.other')); + $this->assertNull($context->isRequired('user.0.other')); + $this->assertNull($context->isRequired('')); + } + + /** + * Test isRequired on associated entities with boolean fields + */ + public function testIsRequiredAssociatedHasManyBoolean(): void + { + $this->_setupTables(); + + $comments = $this->getTableLocator()->get('Comments'); + $comments->getSchema()->addColumn('starred', 'boolean'); + $comments->getValidator()->add('starred', 'valid', ['rule' => 'boolean']); + + $row = new Article([ + 'title' => 'My title', + 'comments' => [ + new Entity(['comment' => 'First comment']), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + 'validator' => 'default', + ]); + + $this->assertFalse($context->isRequired('comments.0.starred')); + } + + /** + * Test isRequired on associated entities with custom validators. + * + * Ensures that missing associations use the correct entity class + * so provider methods work correctly. + */ + public function testIsRequiredAssociatedCustomValidator(): void + { + $this->_setupTables(); + $articles = $this->getTableLocator()->get('Articles'); + + $validator = $articles->getValidator(); + $validator->notEmptyString('title', 'nope', function ($context) { + return $context['providers']['entity']->isRequired(); + }); + $articles->setValidator('default', $validator); + + $row = new Entity([ + 'username' => 'mark', + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Users', + 'validator' => 'default', + ]); + + // Callable conditions cannot be evaluated at render time, so null is returned + $this->assertNull($context->isRequired('articles.0.title')); + } + + /** + * Test isRequired on associated entities. + */ + public function testIsRequiredAssociatedHasManyMissingObject(): void + { + $this->_setupTables(); + + $comments = $this->getTableLocator()->get('Comments'); + $validator = $comments->getValidator(); + $validator->allowEmptyString('comment', null, function ($context) { + return $context['providers']['entity']->isNew(); + }); + + $row = new Article([ + 'title' => 'My title', + 'comments' => [ + new Entity(['comment' => 'First comment'], ['markNew' => false]), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + 'validator' => 'default', + ]); + + // Callable conditions cannot be evaluated at render time, so null is returned + // regardless of entity state — FormHelper will not add required="required" + $this->assertNull($context->isRequired('comments.0.comment')); + $this->assertNull($context->isRequired('comments.1.comment')); + } + + /** + * Test isRequired on associated entities with custom validators. + */ + public function testIsRequiredAssociatedValidator(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'comments' => [ + new Entity(['comment' => 'First comment']), + new Entity(['comment' => 'Second comment']), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + 'validator' => [ + 'Articles' => 'create', + 'Comments' => 'custom', + ], + ]); + + $this->assertTrue($context->isRequired('title')); + $this->assertFalse($context->isRequired('body')); + $this->assertTrue($context->isRequired('comments.0.comment')); + $this->assertTrue($context->isRequired('comments.1.comment')); + } + + /** + * Test isRequired on associated entities. + */ + public function testIsRequiredAssociatedBelongsTo(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'user' => new Entity(['username' => 'Mark']), + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + 'validator' => [ + 'Articles' => 'create', + 'Users' => 'custom', + ], + ]); + + $this->assertTrue($context->isRequired('user.username')); + $this->assertNull($context->isRequired('user.first_name')); + } + + /** + * Test isRequired on associated join table entities. + */ + public function testIsRequiredAssociatedJoinTable(): void + { + $this->_setupTables(); + + $row = new Article([ + 'tags' => [ + new Tag([ + '_joinData' => new ArticlesTag([ + 'article_id' => 1, + 'tag_id' => 2, + ]), + ]), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertTrue($context->isRequired('tags.0._joinData.article_id')); + $this->assertTrue($context->isRequired('tags.0._joinData.tag_id')); + } + + /** + * Test type() basic + */ + public function testType(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'body' => 'Some content', + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertSame('string', $context->type('title')); + $this->assertSame('text', $context->type('body')); + $this->assertSame('integer', $context->type('user_id')); + $this->assertNull($context->type('nope')); + } + + /** + * Test getting types for associated records. + */ + public function testTypeAssociated(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'user' => new Entity(['username' => 'Mark']), + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertSame('string', $context->type('user.username')); + $this->assertSame('text', $context->type('user.bio')); + $this->assertNull($context->type('user.nope')); + } + + /** + * Test getting types for associated join data records. + */ + public function testTypeAssociatedJoinData(): void + { + $this->_setupTables(); + + $row = new Article([ + 'tags' => [ + new Tag([ + '_joinData' => new ArticlesTag([ + 'article_id' => 1, + 'tag_id' => 2, + ]), + ]), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertSame('integer', $context->type('tags.0._joinData.article_id')); + $this->assertNull($context->type('tags.0._joinData.nonexistent')); + + // tests the fallback behavior + $this->assertSame('integer', $context->type('tags.0._joinData._joinData.article_id')); + $this->assertSame('integer', $context->type('tags.0._joinData.nonexistent.article_id')); + $this->assertNull($context->type('tags.0._joinData._joinData.nonexistent')); + $this->assertNull($context->type('tags.0._joinData.nonexistent')); + } + + /** + * Test attributes for fields. + */ + public function testAttributes(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'user' => new Entity(['username' => 'Mark']), + 'tags' => [ + new Tag([ + '_joinData' => new ArticlesTag([ + 'article_id' => 1, + 'tag_id' => 2, + ]), + ]), + ], + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $expected = [ + 'length' => 255, 'precision' => null, + 'null' => null, 'default' => null, 'comment' => null, + ]; + $this->assertEquals($expected, $context->attributes('title')); + + $expected = [ + 'length' => null, 'precision' => null, + 'null' => null, 'default' => null, 'comment' => null, + ]; + $this->assertEquals($expected, $context->attributes('body')); + + $expected = [ + 'length' => 10, 'precision' => 3, + 'null' => null, 'default' => null, 'comment' => null, + ]; + $this->assertEquals($expected, $context->attributes('user.rating')); + + $expected = [ + 'length' => 11, 'precision' => null, + 'null' => false, 'default' => null, 'comment' => null, + ]; + $this->assertEquals($expected, $context->attributes('tags.0._joinData.article_id')); + } + + /** + * Test hasError + */ + public function testHasError(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'user' => new Entity(['username' => 'Mark']), + ]); + $row->setError('title', []); + $row->setError('body', 'Gotta have one'); + $row->setError('user_id', ['Required field']); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertFalse($context->hasError('title')); + $this->assertFalse($context->hasError('nope')); + $this->assertTrue($context->hasError('body')); + $this->assertTrue($context->hasError('user_id')); + } + + /** + * Test hasError on associated records + */ + public function testHasErrorAssociated(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'user' => new Entity(['username' => 'Mark']), + ]); + $row->setError('title', []); + $row->setError('body', 'Gotta have one'); + $row->user->setError('username', ['Required']); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertTrue($context->hasError('user.username')); + $this->assertFalse($context->hasError('user.nope')); + $this->assertFalse($context->hasError('no.nope')); + } + + /** + * Test error + */ + public function testError(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'user' => new Entity(['username' => 'Mark']), + ]); + $row->setError('title', []); + $row->setError('body', 'Gotta have one'); + $row->setError('user_id', ['Required field']); + + $row->user->setError('username', ['Required']); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertEquals([], $context->error('title')); + + $expected = ['Gotta have one']; + $this->assertEquals($expected, $context->error('body')); + + $expected = ['Required']; + $this->assertEquals($expected, $context->error('user.username')); + } + + /** + * Test error on associated entities. + */ + public function testErrorAssociatedHasMany(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'comments' => [ + new Entity(['comment' => '']), + new Entity(['comment' => 'Second comment']), + ], + ]); + $row->comments[0]->setError('comment', ['Is required']); + $row->comments[0]->setError('article_id', ['Is required']); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + 'validator' => 'default', + ]); + + $this->assertEquals([], $context->error('title')); + $this->assertEquals([], $context->error('comments.0.user_id')); + $this->assertEquals([], $context->error('comments.0')); + $this->assertEquals(['Is required'], $context->error('comments.0.comment')); + $this->assertEquals(['Is required'], $context->error('comments.0.article_id')); + $this->assertEquals([], $context->error('comments.1')); + $this->assertEquals([], $context->error('comments.1.comment')); + $this->assertEquals([], $context->error('comments.1.article_id')); + } + + /** + * Test error on associated join table entities. + */ + public function testErrorAssociatedJoinTable(): void + { + $this->_setupTables(); + + $row = new Article([ + 'tags' => [ + new Tag([ + '_joinData' => new ArticlesTag([ + 'article_id' => 1, + ]), + ]), + ], + ]); + $row->tags[0]->_joinData->setError('tag_id', ['Is required']); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $this->assertEquals([], $context->error('tags.0._joinData.article_id')); + $this->assertEquals(['Is required'], $context->error('tags.0._joinData.tag_id')); + } + + /** + * Test error on nested validation + */ + public function testErrorNestedValidator(): void + { + $this->_setupTables(); + + $row = new Article([ + 'title' => 'My title', + 'options' => ['subpages' => ''], + ]); + $row->setError('options', ['subpages' => ['_empty' => 'required value']]); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $expected = ['_empty' => 'required value']; + $this->assertEquals($expected, $context->error('options.subpages')); + } + + /** + * Test error on nested validation + */ + public function testErrorAssociatedNestedValidator(): void + { + $this->_setupTables(); + + $tagOne = new Tag(['name' => 'first-post']); + $tagTwo = new Tag(['name' => 'second-post']); + $tagOne->setError( + 'metadata', + ['description' => ['_empty' => 'required value']], + ); + $row = new Article([ + 'title' => 'My title', + 'tags' => [ + $tagOne, + $tagTwo, + ], + ]); + + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $expected = ['_empty' => 'required value']; + $this->assertSame([], $context->error('tags.0.notthere')); + $this->assertSame([], $context->error('tags.1.notthere')); + $this->assertEquals($expected, $context->error('tags.0.metadata.description')); + } + + /** + * Setup tables for tests. + */ + protected function _setupTables(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Users'); + $articles->belongsToMany('Tags'); + $articles->hasMany('Comments'); + $articles->setEntityClass(Article::class); + + $articlesTags = $this->getTableLocator()->get('ArticlesTags'); + $comments = $this->getTableLocator()->get('Comments'); + $users = $this->getTableLocator()->get('Users'); + $users->hasMany('Articles'); + + $articles->setSchema([ + 'id' => ['type' => 'integer', 'length' => 11, 'null' => false], + 'title' => ['type' => 'string', 'length' => 255], + 'user_id' => ['type' => 'integer', 'length' => 11, 'null' => false], + 'body' => ['type' => 'crazy_text', 'baseType' => 'text'], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ]); + $articlesTags->setSchema([ + 'article_id' => ['type' => 'integer', 'length' => 11, 'null' => false], + 'tag_id' => ['type' => 'integer', 'length' => 11, 'null' => false], + '_constraints' => ['unique_tag' => ['type' => 'primary', 'columns' => ['article_id', 'tag_id']]], + ]); + $users->setSchema([ + 'id' => ['type' => 'integer', 'length' => 11], + 'username' => ['type' => 'string', 'length' => 255], + 'bio' => ['type' => 'text'], + 'rating' => ['type' => 'decimal', 'length' => 10, 'precision' => 3], + ]); + + $validator = new Validator(); + $validator->notEmptyString('title', "Don't forget a title!"); + $validator->add('title', 'minlength', [ + 'rule' => ['minlength', 10], + ]) + ->add('body', 'maxlength', [ + 'rule' => ['maxlength', 1000], + ])->allowEmptyString('body'); + $articles->setValidator('create', $validator); + + $validator = new Validator(); + $validator->add('username', 'length', [ + 'rule' => ['minlength', 10], + ]); + $users->setValidator('custom', $validator); + + $validator = new Validator(); + $validator->add('comment', 'length', [ + 'rule' => ['minlength', 10], + ]); + $comments->setValidator('custom', $validator); + + $validator = new Validator(); + $validator->requirePresence('article_id', 'create'); + $validator->requirePresence('tag_id', 'create'); + $articlesTags->setValidator('default', $validator); + } + + /** + * Test the fieldnames method. + */ + public function testFieldNames(): void + { + $context = new EntityContext([ + 'entity' => new Entity(), + 'table' => 'Articles', + ]); + $articles = $this->getTableLocator()->get('Articles'); + $this->assertEquals($articles->getSchema()->columns(), $context->fieldNames()); + } + + /** + * Test automatic entity provider setting + */ + public function testValidatorEntityProvider(): void + { + $row = new Article([ + 'title' => 'Test entity', + 'body' => 'Something new', + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + $context->isRequired('title'); + $articles = $this->getTableLocator()->get('Articles'); + $this->assertSame($row, $articles->getValidator()->getProvider('entity')); + + $row = new Article([ + 'title' => 'First post', + 'user' => new Entity([ + 'username' => 'mark', + 'fname' => 'Mark', + 'articles' => [ + new Article(['title' => 'First post']), + new Article(['title' => 'Second post']), + ], + ]), + ]); + $context = new EntityContext([ + 'entity' => $row, + 'table' => 'Articles', + ]); + + $validator = $articles->getValidator(); + $context->isRequired('user.articles.0.title'); + $this->assertSame($row->user->articles[0], $validator->getProvider('entity')); + + $context->isRequired('user.articles.1.title'); + $this->assertSame($row->user->articles[1], $validator->getProvider('entity')); + + $context->isRequired('title'); + $this->assertSame($row, $validator->getProvider('entity')); + } +} diff --git a/tests/TestCase/View/Form/FormContextTest.php b/tests/TestCase/View/Form/FormContextTest.php new file mode 100644 index 00000000000..ed4b3808516 --- /dev/null +++ b/tests/TestCase/View/Form/FormContextTest.php @@ -0,0 +1,308 @@ +notEmptyString('title', "Don't forget a title!"); + + $form = new Form(); + $form->setValidator(Form::DEFAULT_VALIDATOR, $validator); + + $context = new FormContext([ + 'entity' => $form, + ]); + + $this->assertNull($context->getRequiredMessage('body')); + $this->assertSame("Don't forget a title!", $context->getRequiredMessage('title')); + } + + /** + * Test getting the primary key. + */ + public function testPrimaryKey(): void + { + $context = new FormContext(['entity' => new Form()]); + $this->assertEquals([], $context->getPrimaryKey()); + } + + /** + * Test isPrimaryKey. + */ + public function testIsPrimaryKey(): void + { + $context = new FormContext(['entity' => new Form()]); + $this->assertFalse($context->isPrimaryKey('id')); + } + + /** + * Test the isCreate method. + */ + public function testIsCreate(): void + { + $context = new FormContext(['entity' => new Form()]); + $this->assertTrue($context->isCreate()); + } + + /** + * Test reading values from form data. + */ + public function testValPresent(): void + { + $form = new Form(); + $form->setData(['title' => 'set title']); + + $context = new FormContext(['entity' => $form]); + + $this->assertSame('set title', $context->val('title')); + } + + /** + * Test getting values when data and defaults are missing. + */ + public function testValMissing(): void + { + $context = new FormContext(['entity' => new Form()]); + $this->assertNull($context->val('Comments.field')); + } + + /** + * Test getting default value + */ + public function testValDefault(): void + { + $form = new Form(); + $form->getSchema()->addField('name', ['default' => 'schema default']); + $context = new FormContext(['entity' => $form]); + + $result = $context->val('title'); + $this->assertNull($result); + + $result = $context->val('title', ['default' => 'default default']); + $this->assertSame('default default', $result); + + $result = $context->val('name'); + $this->assertSame('schema default', $result); + + $result = $context->val('name', ['default' => 'custom default']); + $this->assertSame('custom default', $result); + + $result = $context->val('name', ['schemaDefault' => false]); + $this->assertNull($result); + } + + /** + * Test isRequired + */ + public function testIsRequired(): void + { + $form = new Form(); + $form->getValidator() + ->requirePresence('name') + ->add('email', 'format', ['rule' => 'email']); + + $context = new FormContext([ + 'entity' => $form, + ]); + $this->assertTrue($context->isRequired('name')); + $this->assertTrue($context->isRequired('email')); + $this->assertNull($context->isRequired('body')); + $this->assertNull($context->isRequired('Prefix.body')); + + // Non-default validator name. + $form = new Form(); + $form->setValidator('custom', new Validator()); + $form->getValidator('custom') + ->notEmptyString('title'); + $form->validate([ + 'title' => '', + ], 'custom'); + + $context = new FormContext(['entity' => $form, 'validator' => 'custom']); + $this->assertTrue($context->isRequired('title')); + } + + /** + * Test the type method. + */ + public function testType(): void + { + $form = new Form(); + $form->getSchema() + ->addField('email', 'string') + ->addField('user_id', 'integer'); + + $context = new FormContext([ + 'entity' => $form, + ]); + $this->assertNull($context->type('undefined')); + $this->assertSame('integer', $context->type('user_id')); + $this->assertSame('string', $context->type('email')); + $this->assertNull($context->type('Prefix.email')); + } + + /** + * Test the fieldNames method. + */ + public function testFieldNames(): void + { + $form = new Form(); + $context = new FormContext([ + 'entity' => $form, + ]); + $expected = []; + $result = $context->fieldNames(); + $this->assertEquals($expected, $result); + + $form->getSchema() + ->addField('email', 'string') + ->addField('password', 'string'); + $context = new FormContext([ + 'entity' => $form, + ]); + + $expected = ['email', 'password']; + $result = $context->fieldNames(); + $this->assertEquals($expected, $result); + } + + /** + * Test fetching attributes. + */ + public function testAttributes(): void + { + $form = new Form(); + $form->getSchema() + ->addField('email', [ + 'type' => 'string', + 'length' => 10, + ]) + ->addField('amount', [ + 'type' => 'decimal', + 'length' => 5, + 'precision' => 2, + ]); + $context = new FormContext([ + 'entity' => $form, + ]); + $this->assertEquals([], $context->attributes('id')); + $this->assertEquals( + ['length' => 10, 'precision' => null, 'default' => null], + $context->attributes('email'), + ); + $this->assertEquals( + ['precision' => 2, 'length' => 5, 'default' => null], + $context->attributes('amount'), + ); + } + + /** + * Test fetching errors. + */ + public function testError(): void + { + $nestedValidator = new Validator(); + $nestedValidator + ->add('password', 'length', ['rule' => ['minLength', 8]]) + ->add('confirm', 'length', ['rule' => ['minLength', 8]]); + $form = new Form(); + $form->getValidator() + ->add('email', 'format', ['rule' => 'email']) + ->add('name', 'length', ['rule' => ['minLength', 10]]) + ->addNested('pass', $nestedValidator); + $form->validate([ + 'email' => 'derp', + 'name' => 'derp', + 'pass' => [ + 'password' => 'short', + 'confirm' => 'long enough', + ], + ]); + + $context = new FormContext(['entity' => $form]); + $this->assertEquals([], $context->error('empty')); + $this->assertEquals(['format' => 'The provided value is invalid'], $context->error('email')); + $this->assertEquals(['length' => 'The provided value is invalid'], $context->error('name')); + $this->assertEquals(['length' => 'The provided value is invalid'], $context->error('pass.password')); + $this->assertEquals([], $context->error('Alias.name')); + $this->assertEquals([], $context->error('nope.nope')); + + $validator = new Validator(); + $validator->requirePresence('key', true, 'should be an array, not a string'); + $form->setValidator('default', $validator); + $form->validate([]); + $context = new FormContext(['entity' => $form]); + $this->assertEquals( + ['_required' => 'should be an array, not a string'], + $context->error('key'), + 'This test should not produce a PHP warning from array_values().', + ); + } + + /** + * Test checking errors. + */ + public function testHasError(): void + { + $nestedValidator = new Validator(); + $nestedValidator + ->add('password', 'length', ['rule' => ['minLength', 8]]) + ->add('confirm', 'length', ['rule' => ['minLength', 8]]); + $form = new Form(); + $form->getValidator() + ->add('email', 'format', ['rule' => 'email']) + ->add('name', 'length', ['rule' => ['minLength', 10]]) + ->addNested('pass', $nestedValidator); + $form->validate([ + 'email' => 'derp', + 'name' => 'derp', + 'pass' => [ + 'password' => 'short', + 'confirm' => 'long enough', + ], + ]); + + $context = new FormContext(['entity' => $form]); + $this->assertTrue($context->hasError('email')); + $this->assertTrue($context->hasError('name')); + $this->assertFalse($context->hasError('nope')); + $this->assertFalse($context->hasError('nope.nope')); + $this->assertTrue($context->hasError('pass.password')); + } +} diff --git a/tests/TestCase/View/Helper/BreadcrumbsHelperTest.php b/tests/TestCase/View/Helper/BreadcrumbsHelperTest.php new file mode 100644 index 00000000000..fb76c44540a --- /dev/null +++ b/tests/TestCase/View/Helper/BreadcrumbsHelperTest.php @@ -0,0 +1,735 @@ +breadcrumbs = new BreadcrumbsHelper($view); + + Router::reload(); + Router::createRouteBuilder('/')->fallbacks(); + } + + /** + * Test adding crumbs to the trail using add() + */ + public function testAdd(): void + { + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->add('Some text', ['controller' => 'Some', 'action' => 'text']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [ + 'class' => 'first', + ], + ], + [ + 'title' => 'Some text', + 'url' => [ + 'controller' => 'Some', + 'action' => 'text', + ], + 'options' => [], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test adding multiple crumbs at once to the trail using add() + */ + public function testAddMultiple(): void + { + $this->deprecated(function (): void { + $this->breadcrumbs + ->add([ + [ + '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 crumbs to the trail using prepend() + */ + public function testPrepend(): void + { + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->prepend('Some text', ['controller' => 'Some', 'action' => 'text']) + ->prepend('The root', '/root', ['data-name' => 'some-name']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'The root', + 'url' => '/root', + 'options' => ['data-name' => 'some-name'], + ], + [ + 'title' => 'Some text', + 'url' => [ + 'controller' => 'Some', + 'action' => 'text', + ], + 'options' => [], + ], + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [ + 'class' => 'first', + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test adding crumbs to the trail using prepend() + */ + public function testPrependMultiple(): void + { + $this->deprecated(function (): void { + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->prepend([ + ['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 ability to empty crumbs list. + */ + public function testReset(): void + { + $this->breadcrumbs->add('Home', '/'); + $this->breadcrumbs->add('Products', '/products'); + + $crumbs = $this->breadcrumbs->getCrumbs(); + $this->assertSame(count($crumbs), 2); + + $this->breadcrumbs->reset(); + $actual = $this->breadcrumbs->getCrumbs(); + $this->assertEquals($actual, []); + } + + /** + * Test adding crumbs to a specific index + */ + public function testInsertAt(): void + { + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->prepend('Some text', ['controller' => 'Some', 'action' => 'text']) + ->insertAt(1, 'Insert At', ['controller' => 'Insert', 'action' => 'at']) + ->insertAt(1, 'Insert At Again', ['controller' => 'Insert', 'action' => 'at_again']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Some text', + 'url' => [ + 'controller' => 'Some', + 'action' => 'text', + ], + 'options' => [], + ], + [ + 'title' => 'Insert At Again', + 'url' => [ + 'controller' => 'Insert', + 'action' => 'at_again', + ], + 'options' => [], + ], + [ + 'title' => 'Insert At', + 'url' => [ + 'controller' => 'Insert', + 'action' => 'at', + ], + 'options' => [], + ], + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [ + 'class' => 'first', + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test adding crumbs to a specific index + */ + public function testInsertAtIndexOutOfBounds(): void + { + $this->expectException(LogicException::class); + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->insertAt(2, 'Insert At Again', ['controller' => 'Insert', 'action' => 'at_again']); + } + + /** + * Test adding crumbs before a specific one + */ + public function testInsertBefore(): void + { + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->prepend('Some text', ['controller' => 'Some', 'action' => 'text']) + ->prepend('The root', '/root', ['data-name' => 'some-name']) + ->insertBefore('The root', 'The super root'); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'The super root', + 'url' => null, + 'options' => [], + ], + [ + 'title' => 'The root', + 'url' => '/root', + 'options' => ['data-name' => 'some-name'], + ], + [ + 'title' => 'Some text', + 'url' => [ + 'controller' => 'Some', + 'action' => 'text', + ], + 'options' => [], + ], + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [ + 'class' => 'first', + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test adding crumbs after a specific one + */ + public function testInsertAfter(): void + { + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->prepend('Some text', ['controller' => 'Some', 'action' => 'text']) + ->prepend('The root', '/root', ['data-name' => 'some-name']) + ->insertAfter('The root', 'The less super root'); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'The root', + 'url' => '/root', + 'options' => ['data-name' => 'some-name'], + ], + [ + 'title' => 'The less super root', + 'url' => null, + 'options' => [], + ], + [ + 'title' => 'Some text', + 'url' => [ + 'controller' => 'Some', + 'action' => 'text', + ], + 'options' => [], + ], + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [ + 'class' => 'first', + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test adding crumbs after a specific one + */ + public function testInsertAfterLastItem(): void + { + $this->breadcrumbs + ->add('Home', '/') + ->insertAfter('Home', 'Below Home', '/below', ['class' => 'second']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [], + ], + [ + 'title' => 'Below Home', + 'url' => '/below', + 'options' => [ + 'class' => 'second', + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the render method + */ + public function testRender(): void + { + $this->assertSame('', $this->breadcrumbs->render()); + + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first', 'innerAttrs' => ['data-foo' => 'bar']]) + ->add('Some text', ['controller' => 'TestsApps', 'action' => 'someMethod']) + ->add('Final crumb', null, ['class' => 'final', 'innerAttrs' => ['class' => 'final-link']]); + + $result = $this->breadcrumbs->render( + ['data-stuff' => 'foo and bar'], + ['separator' => '', 'class' => 'separator'], + ); + $expected = [ + ['ul' => ['data-stuff' => 'foo and bar']], + ['li' => ['class' => 'first']], + ['a' => ['href' => '/', 'data-foo' => 'bar']], + 'Home', + '/a', + '/li', + ['li' => ['class' => 'separator']], + ['span' => []], + ['i' => ['class' => 'fa fa-angle-right']], + '/i', + '/span', + '/li', + ['li' => []], + ['a' => ['href' => '/TestsApps/someMethod']], + 'Some text', + '/a', + '/li', + ['li' => ['class' => 'separator']], + ['span' => []], + ['i' => ['class' => 'fa fa-angle-right']], + '/i', + '/span', + '/li', + ['li' => ['class' => 'final']], + ['span' => ['class' => 'final-link']], + 'Final crumb', + '/span', + '/li', + '/ul', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Tests the render method with custom templates + */ + public function testRenderCustomTemplate(): void + { + $this->breadcrumbs = new BreadcrumbsHelper(new View(), [ + 'templates' => [ + 'wrapper' => '
      {{content}}
    ', + 'item' => '
  • {{title}}
  • ', + 'itemWithoutLink' => '
  • {{title}}
  • ', + ], + ]); + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first', 'innerAttrs' => ['data-foo' => 'bar']]) + ->add('Final crumb', null, ['class' => 'final', 'innerAttrs' => ['class' => 'final-link']]); + + $result = $this->breadcrumbs->render( + ['data-stuff' => 'foo and bar'], + ['separator' => ' > ', 'class' => 'separator'], + ); + $expected = [ + ['ol' => ['itemtype' => 'http://schema.org/BreadcrumbList', 'data-stuff' => 'foo and bar']], + ['li' => ['itemprop' => 'itemListElement', 'itemtype' => 'http://schema.org/ListItem', 'class' => 'first']], + ['a' => ['itemtype' => 'http://schema.org/Thing', 'itemprop' => 'item', 'href' => '/', 'data-foo' => 'bar']], + ['span' => ['itemprop' => 'name']], + 'Home', + '/span', + '/a', + '/li', + ['li' => ['itemprop' => 'itemListElement', 'itemtype' => 'http://schema.org/ListItem', 'class' => 'final']], + ['span' => ['itemprop' => 'name', 'class' => 'final-link']], + 'Final crumb', + '/span', + '/li', + '/ol', + ]; + $this->assertHtml($expected, $result, true); + } + + /** + * Tests the render method with template vars + */ + 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']]) + ->add('Final crumb', null, ['class' => 'final', 'innerAttrs' => ['class' => 'final-link'], 'templateVars' => ['barbaz' => 'foo']]); + + $result = $this->breadcrumbs->render( + ['data-stuff' => 'foo and bar', 'templateVars' => ['thing' => 'somestuff']], + ['separator' => ' > ', 'class' => 'separator'], + ); + $expected = [ + 'somestuff', + ['ol' => ['itemtype' => 'http://schema.org/BreadcrumbList', 'data-stuff' => 'foo and bar']], + ['li' => ['itemprop' => 'itemListElement', 'itemtype' => 'http://schema.org/ListItem', 'class' => 'first']], + ['a' => ['itemtype' => 'http://schema.org/Thing', 'itemprop' => 'item', 'href' => '/', 'data-foo' => 'bar']], + ['span' => ['itemprop' => 'name']], + 'Home', + '/span', + '/a', + 'barbaz', + '/li', + ['li' => ['itemprop' => 'itemListElement', 'itemtype' => 'http://schema.org/ListItem', 'class' => 'final']], + ['span' => ['itemprop' => 'name', 'class' => 'final-link']], + 'Final crumb', + '/span', + 'foo', + '/li', + '/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 new file mode 100644 index 00000000000..94b8a654fa7 --- /dev/null +++ b/tests/TestCase/View/Helper/FlashHelperTest.php @@ -0,0 +1,219 @@ +View = new View(new ServerRequest(['session' => $session])); + $this->Flash = new FlashHelper($this->View); + + $session->write([ + 'Flash' => [ + 'flash' => [ + [ + 'key' => 'flash', + 'message' => 'This is a calling', + 'element' => 'flash/default', + 'params' => [], + ], + ], + 'notification' => [ + [ + 'key' => 'notification', + 'message' => 'This is a test of the emergency broadcasting system', + 'element' => 'flash_helper', + 'params' => [ + 'title' => 'Notice!', + 'name' => 'Alert!', + ], + ], + ], + 'classy' => [ + [ + 'key' => 'classy', + 'message' => 'Recorded', + 'element' => 'flash_classy', + 'params' => [], + ], + ], + 'stack' => [ + [ + 'key' => 'flash', + 'message' => 'This is a calling', + 'element' => 'flash/default', + 'params' => [], + ], + [ + 'key' => 'notification', + 'message' => 'This is a test of the emergency broadcasting system', + 'element' => 'flash_helper', + 'params' => [ + 'title' => 'Notice!', + 'name' => 'Alert!', + ], + ], + [ + 'key' => 'classy', + 'message' => 'Recorded', + 'element' => 'flash_classy', + 'params' => [], + ], + ], + ], + ]); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->View, $this->Flash); + $this->clearPlugins(); + } + + /** + * testFlash method + */ + public function testFlash(): void + { + $result = $this->Flash->render(); + $expected = '
    This is a calling
    '; + $this->assertStringContainsString($expected, $result); + + $expected = '
    Recorded
    '; + $result = $this->Flash->render('classy'); + $this->assertSame($expected, $result); + + $result = $this->Flash->render('notification'); + $expected = [ + 'div' => ['id' => 'notificationLayout'], + 'assertHtml($expected, $result); + $this->assertNull($this->Flash->render('nonexistent')); + } + + /** + * test setting the element from the attrs. + */ + public function testFlashElementInAttrs(): void + { + $result = $this->Flash->render('notification', [ + 'element' => 'flash_helper', + 'params' => ['title' => 'Notice!', 'name' => 'Alert!'], + ]); + + $expected = [ + 'div' => ['id' => 'notificationLayout'], + 'assertHtml($expected, $result); + } + + /** + * test using elements in plugins. + */ + public function testFlashWithPluginElement(): void + { + $this->loadPlugins(['TestPlugin']); + + $result = $this->Flash->render('flash', ['element' => 'TestPlugin.flash/plugin_element']); + $expected = 'this is the plugin element'; + $this->assertSame($expected, $result); + } + + /** + * test that when View theme is set, flash element from that theme (plugin) is used. + */ + public function testFlashWithTheme(): void + { + $this->loadPlugins(['TestTheme']); + + $this->View->setTheme('TestTheme'); + $result = $this->Flash->render('flash'); + $expected = 'flash element from TestTheme'; + $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 + */ + public function testFlashWithStack(): void + { + $result = $this->Flash->render('stack'); + $expected = [ + ['div' => ['class' => 'message']], 'This is a calling', '/div', + ['div' => ['id' => 'notificationLayout']], + ' ['id' => 'classy-message']], 'Recorded', '/div', + ]; + $this->assertHtml($expected, $result); + $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. + */ + public function testFlashWithPrefix(): void + { + $this->View->setRequest($this->View->getRequest()->withParam('prefix', 'Admin')); + $result = $this->Flash->render('flash'); + $expected = 'flash element from Admin prefix folder'; + $this->assertStringContainsString($expected, $result); + } +} diff --git a/tests/TestCase/View/Helper/FormHelperTest.php b/tests/TestCase/View/Helper/FormHelperTest.php new file mode 100644 index 00000000000..86db938309a --- /dev/null +++ b/tests/TestCase/View/Helper/FormHelperTest.php @@ -0,0 +1,9566 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Comments']; + + /** + * @var array + */ + protected $article = []; + + /** + * @var string + */ + protected $url; + + /** + * @var \Cake\View\Helper\FormHelper + */ + protected $Form; + + /** + * @var \Cake\View\View + */ + protected $View; + + /** + * setUp method + */ + protected function setUp(): void + { + parent::setUp(); + + Configure::write('Config.language', 'eng'); + Configure::write('App.base', ''); + static::setAppNamespace('Cake\Test\TestCase\View\Helper'); + + $request = new ServerRequest([ + 'webroot' => '', + 'base' => '', + 'url' => '/articles/add', + 'params' => [ + 'controller' => 'Articles', + 'action' => 'add', + 'plugin' => null, + ], + ]); + $this->View = new View($request); + Router::reload(); + Router::setRequest($request); + + $this->url = '/articles/add'; + $this->Form = new FormHelper($this->View); + + $this->article = [ + 'schema' => [ + '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']]], + ], + 'required' => [ + 'author_id' => true, + 'title' => true, + ], + ]; + + Security::setSalt('foo!'); + $builder = Router::createRouteBuilder('/'); + $builder->connect('/{controller}', ['action' => 'index']); + $builder->connect('/{controller}/{action}/*'); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + unset($this->Form, $this->View); + } + + /** + * Test construct() with the templates option. + */ + public function testConstructTemplatesFile(): void + { + $helper = new FormHelper($this->View, [ + 'templates' => 'htmlhelper_tags', + ]); + $result = $helper->control('name'); + $this->assertStringContainsString(' [ + 'datetime' => [LabelWidget::class, 'select'], + ], + ]; + $helper = new FormHelper($this->View, $config); + $locator = $helper->getWidgetLocator(); + $this->assertInstanceOf(LabelWidget::class, $locator->get('datetime')); + } + + /** + * Test that when specifying custom widgets config file and it should be + * added to widgets array. WidgetLocator will load widgets in constructor. + */ + public function testConstructWithWidgetsConfig(): void + { + $helper = new FormHelper($this->View, ['widgets' => ['test_widgets']]); + $locator = $helper->getWidgetLocator(); + $this->assertInstanceOf(LabelWidget::class, $locator->get('text')); + } + + /** + * Test setting the widget locator + */ + public function testSetAndGetWidgetLocator(): void + { + $helper = new FormHelper($this->View); + $locator = new WidgetLocator($helper->templater(), $this->View); + $helper->setWidgetLocator($locator); + + $this->assertSame($locator, $helper->getWidgetLocator()); + } + + /** + * Test overridding grouped input types which controls generation of "for" + * attribute of labels. + */ + public function testConstructWithGroupedInputTypes(): void + { + $helper = new FormHelper($this->View, [ + 'groupedInputTypes' => ['radio'], + ]); + + $result = $helper->control('when', ['type' => 'datetime-local']); + $this->assertStringContainsString('', $result); + } + + /** + * Test registering a new widget class and rendering it. + */ + public function testAddWidgetAndRenderWidget(): void + { + $widget = Mockery::spy(WidgetInterface::class)->makePartial(); + $this->Form->addWidget('test', $widget); + $this->Form->widget('test', ['val' => 1]); + + $widget->shouldHaveReceived('render') + ->withArgs(function (array $data) { + return $data === ['val' => 1]; + }) + ->once(); + } + + /** + * Test that secureFields() of widget is called after calling render(), + * not before. + */ + public function testOrderForRenderingWidgetAndFetchingSecureFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => [], + ])); + + $widget = Mockery::spy(WidgetInterface::class); + + $this->Form->addWidget('test', $widget); + $this->Form->create(); + $this->Form->widget('test', ['val' => 1, 'name' => 'test', 'secure' => true]); + + $widget->shouldHaveReceived('render') + ->withArgs(function (array $data) { + return $data === ['val' => 1, 'name' => 'test']; + }) + ->once(); + $widget->shouldHaveReceived('secureFields') + ->with(['val' => 1, 'name' => 'test']) + ->once(); + } + + /** + * Test that empty string is not added to secure fields list when + * rendering input widget without name. + */ + public function testRenderingWidgetWithEmptyName(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $result = $this->Form->widget('select', ['secure' => true, 'name' => '']); + $this->assertSame('', $result); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals([], $result); + + $result = $this->Form->widget('select', ['secure' => true, 'name' => '0']); + $this->assertSame('', $result); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals(['0'], $result); + } + + /** + * Test adding a new context class. + */ + public function testAddContextProvider(): void + { + $context = 'My data'; + $stub = new StubContext(); + $this->Form->addContextProvider('test', function ($request, $data) use ($context, $stub) { + $this->assertInstanceOf(ServerRequest::class, $request); + $this->assertSame($context, $data['entity']); + + return $stub; + }); + $this->Form->create($context); + $result = $this->Form->context(); + $this->assertSame($stub, $result); + } + + /** + * Test replacing a context class. + */ + public function testAddContextProviderReplace(): void + { + $entity = new Article(); + $stub = new StubContext(); + $this->Form->addContextProvider('orm', function ($request, $data) use ($stub) { + return $stub; + }); + $this->Form->create($entity); + $result = $this->Form->context(); + $this->assertSame($stub, $result); + } + + /** + * Test overriding a context class. + */ + public function testAddContextProviderAdd(): void + { + $entity = new Article(); + $stub = new StubContext(); + $this->Form->addContextProvider('newshiny', function ($request, $data) use ($stub) { + if ($data['entity'] instanceof Entity) { + return $stub; + } + }); + $this->Form->create($entity); + $result = $this->Form->context(); + $this->assertSame($stub, $result); + } + + /** + * Provides context options for create(). + * + * @return array + */ + public static function contextSelectionProvider(): array + { + $entity = new Article(); + $collection = new Collection([$entity]); + $emptyCollection = new Collection([]); + $data = [ + 'schema' => [ + 'title' => ['type' => 'string'], + ], + ]; + $form = new Form(); + $custom = new StubContext(); + + return [ + 'entity' => [$entity, EntityContext::class], + 'collection' => [$collection, EntityContext::class], + 'empty_collection' => [$emptyCollection, NullContext::class], + 'array' => [$data, ArrayContext::class], + 'form' => [$form, FormContext::class], + 'none' => [null, NullContext::class], + 'custom' => [$custom, $custom::class], + ]; + } + + /** + * Test default context selection in create() + * + * @param mixed $data + */ + #[DataProvider('contextSelectionProvider')] + public function testCreateContextSelectionBuiltIn($data, string $class): void + { + $this->Form->create($data); + $this->assertInstanceOf($class, $this->Form->context()); + } + + /** + * Data provider for type option. + * + * @return array + */ + public static function requestTypeProvider(): array + { + return [ + // type, method, override + ['post', 'post', 'POST'], + ['put', 'post', 'PUT'], + ['patch', 'post', 'PATCH'], + ['delete', 'post', 'DELETE'], + ]; + } + + /** + * Test creating file forms. + */ + public function testCreateFile(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $result = $this->Form->create(null, ['type' => 'file']); + $expected = [ + 'form' => [ + 'method' => 'post', 'action' => '/articles/add', + 'accept-charset' => $encoding, 'enctype' => 'multipart/form-data', + ], + 'div' => ['style' => 'display:none;'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => 'POST'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->create(options: ['type' => 'file', 'templates' => ['hiddenClass' => 'hidden']]); + $expected = [ + 'form' => [ + 'method' => 'post', 'action' => '/articles/add', + 'accept-charset' => $encoding, 'enctype' => 'multipart/form-data', + ], + 'div' => ['class' => 'hidden'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => 'POST'], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test creating GET forms. + */ + public function testCreateGet(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $result = $this->Form->create(null, ['type' => 'get']); + $expected = ['form' => [ + 'method' => 'get', 'action' => '/articles/add', + 'accept-charset' => $encoding, + ]]; + $this->assertHtml($expected, $result); + + $request = $this->View->getRequest()->withAttribute('csrfToken', 'this-is-a-csrf-token'); + $this->View->setRequest($request); + + $result = $this->Form->create(null, ['method' => 'get']); + $this->assertStringNotContainsString('this-is-a-csrf-token', $result); + } + + /** + * Test explicit method/enctype options. + * + * Explicit method overwrites inferred method from 'type' + */ + public function testCreateExplicitMethodEnctype(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $result = $this->Form->create(null, [ + 'type' => 'get', + 'method' => 'put', + 'enctype' => 'multipart/form-data', + ]); + $expected = ['form' => [ + 'method' => 'post', + 'action' => '/articles/add', + 'enctype' => 'multipart/form-data', + 'accept-charset' => $encoding, + ]]; + $this->assertHtml($expected, $result); + } + + /** + * Test create() with the templates option. + */ + public function testCreateTemplatesArray(): void + { + $result = $this->Form->create($this->article, [ + 'templates' => [ + 'formStart' => '
    ', + ], + ]); + $expected = [ + 'form' => [ + 'class' => 'form-horizontal', + 'method' => 'post', + 'action' => '/articles/add', + 'accept-charset' => 'utf-8', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test create() with the templates option. + */ + public function testCreateTemplatesFile(): void + { + $result = $this->Form->create($this->article, [ + 'templates' => 'htmlhelper_tags', + ]); + $expected = [ + 'start form', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test that create() and end() restore templates. + */ + public function testCreateEndRestoreTemplates(): void + { + $this->Form->create($this->article, [ + 'templates' => ['input' => 'custom input element'], + ]); + $this->Form->end(); + $this->assertNotEquals('custom input element', $this->Form->templater()->get('input')); + } + + /** + * Test create() with the templates option. + */ + public function testCreateTemplatesRequiredClass(): void + { + $this->Form->create($this->article, [ + 'templates' => [ + 'requiredClass' => 'is-required', + ], + ]); + $result = $this->Form->control('title'); + $expected = [ + 'div' => ['class' => 'input text is-required'], + 'label' => ['for' => 'title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'title', + 'id' => 'title', + 'required' => 'required', + 'data-validity-message' => 'This field cannot be left empty', + 'oninvalid' => 'this.setCustomValidity(''); if (!this.value) this.setCustomValidity(this.dataset.validityMessage)', + 'oninput' => 'this.setCustomValidity('')', + ], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test using template vars in various templates used by control() method. + */ + public function testControlTemplateVars(): void + { + $result = $this->Form->control('text', [ + 'templates' => [ + 'input' => '', + 'label' => '{{text}} {{forlabel}}', + 'formGroup' => '{{label}}{{forgroup}}{{input}}', + 'inputContainer' => '
    {{content}}{{forcontainer}}
    ', + ], + 'templateVars' => [ + 'forinput' => 'in-input', + 'forlabel' => 'in-label', + 'forgroup' => 'in-group', + 'forcontainer' => 'in-container', + ], + ]); + $expected = [ + 'div' => ['class'], + 'label' => ['for'], + 'Text in-label', + '/label', + 'in-group', + 'input' => ['name', 'type' => 'text', 'id', 'custom' => 'in-input'], + 'in-container', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test ensuring template variables work in template files loaded + * during control(). + */ + public function testControlTemplatesFromFile(): void + { + $result = $this->Form->control('title', [ + 'templates' => 'test_templates', + 'templateVars' => [ + 'forcontainer' => 'container-data', + ], + ]); + $expected = [ + 'div' => ['class'], + 'label' => ['for'], + 'Title', + '/label', + 'input' => ['name', 'type' => 'text', 'id'], + 'container-data', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test using template vars in inputSubmit and submitContainer template. + */ + public function testSubmitTemplateVars(): void + { + $this->Form->setTemplates([ + 'inputSubmit' => '', + 'submitContainer' => '
    {{content}}{{forcontainer}}
    ', + ]); + $result = $this->Form->submit('Submit', [ + 'templateVars' => [ + 'forinput' => 'in-input', + 'forcontainer' => 'in-container', + ], + ]); + $expected = [ + 'div' => ['class'], + 'input' => ['custom' => 'in-input', 'type' => 'submit', 'value' => 'Submit'], + 'in-container', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * test the create() method + */ + #[DataProvider('requestTypeProvider')] + public function testCreateTypeOptions(string $type, string $method, string $override): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $result = $this->Form->create(null, ['type' => $type]); + $expected = [ + 'form' => [ + 'method' => $method, 'action' => '/articles/add', + 'accept-charset' => $encoding, + ], + ]; + + $extra = [ + 'div' => ['style' => 'display:none;'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => $override], + '/div', + ]; + + if ($type !== 'post') { + $expected = array_merge($expected, $extra); + } + + $this->assertHtml($expected, $result); + } + + /** + * Test using template vars in Create (formStart template) + */ + public function testCreateTemplateVars(): void + { + $result = $this->Form->create($this->article, [ + 'templates' => [ + 'formStart' => '

    {{header}}

    ', + ], + 'templateVars' => ['header' => 'headertext'], + ]); + $expected = [ + 'h4' => ['class'], + 'headertext', + '/h4', + 'form' => [ + 'method' => 'post', + 'action' => '/articles/add', + 'accept-charset' => 'utf-8', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test opening a form for an update operation. + */ + public function testCreateUpdateForm(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + + $this->View->setRequest($this->View->getRequest() + ->withRequestTarget('/articles/edit/1') + ->withParam('action', 'edit')); + + $this->article['defaults']['id'] = 1; + + $result = $this->Form->create($this->article); + $expected = [ + 'form' => [ + 'method' => 'post', + 'action' => '/articles/edit/1', + 'accept-charset' => $encoding, + ], + 'div' => ['style' => 'display:none;'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => 'PUT'], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * test create() with automatic url generation + */ + public function testCreateAutoUrl(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + + $this->View->setRequest($this->View->getRequest() + ->withRequestTarget('/articles/delete/10') + ->withParam('action', 'delete')); + $result = $this->Form->create($this->article); + $expected = [ + 'form' => [ + 'method' => 'post', 'action' => '/articles/delete/10', + 'accept-charset' => $encoding, + ], + ]; + $this->assertHtml($expected, $result); + + $this->article['defaults'] = ['id' => 1]; + $this->View->setRequest($this->View->getRequest() + ->withRequestTarget('/Articles/edit/1') + ->withParam('action', 'delete')); + $result = $this->Form->create($this->article, ['url' => ['action' => 'edit', 1]]); + $expected = [ + 'form' => [ + 'method' => 'post', + 'action' => '/Articles/edit/1', + 'accept-charset' => $encoding, + ], + 'div' => ['style' => 'display:none;'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => 'PUT'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest() + ->withParam('action', 'add')); + $result = $this->Form->create($this->article, ['url' => ['action' => 'publish', 1]]); + $expected = [ + 'form' => [ + 'method' => 'post', + 'action' => '/Articles/publish/1', + 'accept-charset' => $encoding, + ], + 'div' => ['style' => 'display:none;'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => 'PUT'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->create($this->article, ['url' => '/Articles/publish']); + $expected = [ + 'form' => ['method' => 'post', 'action' => '/Articles/publish', 'accept-charset' => $encoding], + 'div' => ['style' => 'display:none;'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => 'PUT'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest() + ->withParam('controller', 'Pages')); + $result = $this->Form->create($this->article, ['url' => ['action' => 'signup', 1]]); + $expected = [ + 'form' => [ + 'method' => 'post', 'action' => '/Pages/signup/1', + 'accept-charset' => $encoding, + ], + 'div' => ['style' => 'display:none;'], + 'input' => ['type' => 'hidden', 'name' => '_method', 'value' => 'PUT'], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test create() with no URL (no "action" attribute for tag) + */ + public function testCreateNoUrl(): void + { + $result = $this->Form->create(null, ['url' => false]); + $expected = [ + 'form' => [ + 'method' => 'post', + 'accept-charset' => strtolower(Configure::read('App.encoding')), + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * test create() with a custom route + */ + public function testCreateCustomRoute(): void + { + $builder = Router::createRouteBuilder('/'); + $builder->connect('/login', ['controller' => 'Users', 'action' => 'login']); + $encoding = strtolower(Configure::read('App.encoding')); + + $this->View->setRequest($this->View->getRequest() + ->withParam('controller', 'Users')); + + $result = $this->Form->create(null, ['url' => ['action' => 'login']]); + $expected = [ + 'form' => [ + 'method' => 'post', 'action' => '/login', + 'accept-charset' => $encoding, + ], + ]; + $this->assertHtml($expected, $result); + + $builder->connect( + '/new-article', + ['controller' => 'Articles', 'action' => 'myAction'], + ['_name' => 'my-route'], + ); + $result = $this->Form->create(null, ['url' => ['_name' => 'my-route']]); + $expected = [ + 'form' => [ + 'method' => 'post', 'action' => '/new-article', + 'accept-charset' => $encoding, + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->create(null, ['url' => ['_path' => 'Articles::myAction']]); + $this->assertHtml($expected, $result); + } + + /** + * test automatic accept-charset overriding + */ + public function testCreateWithAcceptCharset(): void + { + $result = $this->Form->create( + $this->article, + [ + 'type' => 'post', 'url' => ['action' => 'index'], 'encoding' => 'iso-8859-1', + ], + ); + $expected = [ + 'form' => [ + 'method' => 'post', 'action' => '/Articles', + 'accept-charset' => 'iso-8859-1', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test base form URL when 'url' param is passed with multiple parameters (&) + */ + public function testCreateQueryStringRequest(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $result = $this->Form->create($this->article, [ + 'type' => 'post', + 'escape' => false, + 'url' => [ + 'controller' => 'Controller', + 'action' => 'action', + '?' => ['param1' => 'value1', 'param2' => 'value2'], + ], + ]); + $expected = [ + 'form' => [ + 'method' => 'post', + 'action' => '/Controller/action?param1=value1&param2=value2', + 'accept-charset' => $encoding, + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->create($this->article, [ + 'type' => 'post', + 'url' => [ + 'controller' => 'Controller', + 'action' => 'action', + '?' => ['param1' => 'value1', 'param2' => 'value2'], + ], + ]); + $this->assertHtml($expected, $result); + } + + /** + * test that create() doesn't cause errors by multiple id's being in the primary key + * as could happen with multiple select or checkboxes. + */ + public function testCreateWithMultipleIdInData(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + + $this->View->setRequest($this->View->getRequest()->withData('Article.id', [1, 2])); + $result = $this->Form->create($this->article); + $expected = [ + 'form' => [ + 'method' => 'post', + 'action' => '/articles/add', + 'accept-charset' => $encoding, + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * test that create() doesn't add in extra passed params. + */ + public function testCreatePassedArgs(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $this->View->setRequest($this->View->getRequest()->withData('Article.id', 1)); + $result = $this->Form->create($this->article, [ + 'type' => 'post', + 'escape' => false, + 'url' => [ + 'action' => 'edit', + 'myparam', + ], + ]); + $expected = [ + 'form' => [ + 'method' => 'post', + 'action' => '/Articles/edit/myparam', + 'accept-charset' => $encoding, + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * test creating a get form, and get form inputs. + */ + public function testGetFormCreate(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $result = $this->Form->create($this->article, ['type' => 'get']); + $expected = ['form' => [ + 'method' => 'get', 'action' => '/articles/add', + 'accept-charset' => $encoding, + ]]; + $this->assertHtml($expected, $result); + + $result = $this->Form->text('title'); + $expected = ['input' => [ + 'name' => 'title', 'type' => 'text', 'required' => 'required', + ]]; + $this->assertHtml($expected, $result); + + $result = $this->Form->password('password'); + $expected = ['input' => [ + 'name' => 'password', 'type' => 'password', + ]]; + $this->assertHtml($expected, $result); + $this->assertDoesNotMatchRegularExpression('/]+[^id|name|type|value]=[^<>]*\/>$/', $result); + + $result = $this->Form->text('user_form'); + $expected = ['input' => [ + 'name' => 'user_form', 'type' => 'text', + ]]; + $this->assertHtml($expected, $result); + } + + /** + * test get form, and inputs when the model param is false + */ + public function testGetFormWithFalseModel(): void + { + $encoding = strtolower(Configure::read('App.encoding')); + $this->View->setRequest($this->View->getRequest()->withParam('controller', 'ContactTest')); + $result = $this->Form->create(null, [ + 'type' => 'get', 'url' => ['controller' => 'ContactTest'], + ]); + + $expected = ['form' => [ + 'method' => 'get', 'action' => '/ContactTest/add', + 'accept-charset' => $encoding, + ]]; + $this->assertHtml($expected, $result); + + $result = $this->Form->text('reason'); + $expected = [ + 'input' => ['type' => 'text', 'name' => 'reason'], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormCreateWithSecurity method + * + * Test form->create() with security key. + */ + public function testCreateWithSecurity(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('csrfToken', 'testKey')); + $encoding = strtolower(Configure::read('App.encoding')); + + $article = new Article(); + $article->requireFieldPresence(true); + $result = $this->Form->create($article, [ + 'url' => '/articles/publish', + ]); + $expected = [ + 'form' => ['method' => 'post', 'action' => '/articles/publish', 'accept-charset' => $encoding], + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_csrfToken', + 'value' => 'testKey', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->create($this->article, ['url' => '/articles/publish', 'id' => 'MyForm']); + $expected['form']['id'] = 'MyForm'; + $this->assertHtml($expected, $result); + } + + /** + * testFormCreateGetNoSecurity method + * + * Test form->create() with no security key as its a get form + */ + public function testCreateEndGetNoSecurity(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('csrfToken', 'testKey')); + $article = new Article(); + $result = $this->Form->create($article, [ + 'type' => 'get', + 'url' => '/contacts/add', + ]); + $this->assertStringNotContainsString('testKey', $result); + + $result = $this->Form->end(); + $this->assertStringNotContainsString('testKey', $result); + } + + /** + * Tests form hash generation with model-less data + */ + public function testValidateHashNoModel(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $fields = ['anything']; + $this->Form->create(); + $result = $this->Form->secure($fields); + + $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt()); + $this->assertStringContainsString($hash, $result); + } + + /** + * Tests that hidden fields generated for checkboxes don't get locked + */ + public function testNoCheckboxLocking(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->assertSame([], $this->Form->getFormProtector()->__debugInfo()['fields']); + + $this->Form->checkbox('check', ['value' => '1']); + $this->assertSame(['check'], $this->Form->getFormProtector()->__debugInfo()['fields']); + } + + /** + * testFormSecurityFields method + * + * Test generation of secure form hash generation. + */ + public function testFormSecurityFields(): void + { + $fields = ['Model.password', 'Model.username', 'Model.valid' => '0']; + + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + $result = $this->Form->secure($fields); + + $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt()); + $hash .= ':' . 'Model.valid'; + $hash = urlencode($hash); + $tokenDebug = urlencode(json_encode([ + $this->url, + $fields, + [], + ])); + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => '', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[debug]', + 'value' => $tokenDebug, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->Form->create(options: ['templates' => ['hiddenClass' => 'hideme']]); + $result = $this->Form->secure($fields); + $expected['div'] = ['class' => 'hideme']; + + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityFields method + * + * Test debug token is not generated if debug is false + */ + public function testFormSecurityFieldsNoDebugMode(): void + { + Configure::write('debug', false); + $fields = ['Model.password', 'Model.username', 'Model.valid' => '0']; + + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + $result = $this->Form->secure($fields); + + $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt()); + $hash .= ':' . 'Model.valid'; + $hash = urlencode($hash); + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => '', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Tests correct generation of number fields for smallint + */ + public function testTextFieldGenerationForSmallint(): void + { + $this->article['schema'] = [ + 'foo' => [ + 'type' => 'smallinteger', + 'null' => false, + 'default' => null, + 'length' => 10, + ], + ]; + + $this->Form->create($this->article); + $result = $this->Form->control('foo'); + $this->assertStringContainsString('class="input number"', $result); + $this->assertStringContainsString('type="number"', $result); + } + + /** + * Tests correct generation of number fields for tinyint + */ + public function testTextFieldGenerationForTinyint(): void + { + $this->article['schema'] = [ + 'foo' => [ + 'type' => 'tinyinteger', + 'null' => false, + 'default' => null, + 'length' => 10, + ], + ]; + + $this->Form->create($this->article); + $result = $this->Form->control('foo'); + $this->assertStringContainsString('class="input number"', $result); + $this->assertStringContainsString('type="number"', $result); + } + + /** + * Tests correct generation of number fields for double and float fields + */ + public function testTextFieldGenerationForFloats(): void + { + $this->article['schema'] = [ + 'foo' => [ + 'type' => 'float', + 'null' => false, + 'default' => null, + 'length' => 10, + ], + ]; + + $this->Form->create($this->article); + $result = $this->Form->control('foo'); + $expected = [ + 'div' => ['class' => 'input number'], + 'label' => ['for' => 'foo'], + 'Foo', + '/label', + ['input' => [ + 'type' => 'number', + 'name' => 'foo', + 'id' => 'foo', + 'step' => 'any', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('foo', ['step' => 0.5]); + $expected = [ + 'div' => ['class' => 'input number'], + 'label' => ['for' => 'foo'], + 'Foo', + '/label', + ['input' => [ + 'type' => 'number', + 'name' => 'foo', + 'id' => 'foo', + 'step' => '0.5', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Tests correct generation of number fields for integer fields + */ + public function testTextFieldTypeNumberGenerationForIntegers(): void + { + $this->getTableLocator()->get('Contacts', [ + 'className' => ContactsTable::class, + ]); + $this->Form->create([], ['context' => ['table' => 'Contacts']]); + $result = $this->Form->control('age'); + $expected = [ + 'div' => ['class' => 'input number'], + 'label' => ['for' => 'age'], + 'Age', + '/label', + ['input' => [ + 'type' => 'number', 'name' => 'age', + 'id' => 'age', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Tests correct generation of file upload fields for binary fields + */ + public function testFileUploadFieldTypeGenerationForBinaries(): void + { + $table = $this->getTableLocator()->get('Contacts', [ + 'className' => ContactsTable::class, + ]); + $table->setSchema(['foo' => [ + 'type' => 'binary', + 'null' => false, + 'default' => null, + 'length' => 1024, + ]]); + $this->Form->create([], ['context' => ['table' => 'Contacts']]); + + $result = $this->Form->control('foo'); + $expected = [ + 'div' => ['class' => 'input file'], + 'label' => ['for' => 'foo'], + 'Foo', + '/label', + ['input' => [ + 'type' => 'file', 'name' => 'foo', + 'id' => 'foo', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityMultipleFields method + * + * Test secure() with multiple row form. Ensure hash is correct. + */ + public function testFormSecurityMultipleFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $fields = [ + 'Model.0.password', 'Model.0.username', 'Model.0.hidden' => 'value', + 'Model.0.valid' => '0', 'Model.1.password', 'Model.1.username', + 'Model.1.hidden' => 'value', 'Model.1.valid' => '0', + ]; + $result = $this->Form->secure($fields); + + $sortedFields = [ + 'Model.0.password', + 'Model.0.username', + 'Model.1.password', + 'Model.1.username', + 'Model.0.hidden' => 'value', + 'Model.0.valid' => '0', + 'Model.1.hidden' => 'value', + 'Model.1.valid' => '0', + ]; + $hash = hash_hmac('sha1', $this->url . serialize($sortedFields) . session_id(), Security::getSalt()); + $hash .= ':Model.0.hidden|Model.0.valid|Model.1.hidden|Model.1.valid'; + $hash = urlencode($hash); + + $tokenDebug = urlencode(json_encode([ + $this->url, + $fields, + [], + ])); + + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => '', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[debug]', + 'value' => $tokenDebug, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityMultipleSubmitButtons + * + * test form submit generation and ensure that _Token is only created on end() + */ + public function testFormSecurityMultipleSubmitButtons(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $this->Form->create($this->article); + $this->Form->text('Address.title'); + $this->Form->text('Address.first_name'); + + $result = $this->Form->submit('Save', ['name' => 'save']); + $expected = [ + 'div' => ['class' => 'submit'], + 'input' => ['type' => 'submit', 'name' => 'save', 'value' => 'Save'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->submit('Cancel', ['name' => 'cancel']); + $expected = [ + 'div' => ['class' => 'submit'], + 'input' => ['type' => 'submit', 'name' => 'cancel', 'value' => 'Cancel'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->end(); + $tokenDebug = urlencode(json_encode([ + '/articles/add', + [ + 'Address.title', + 'Address.first_name', + ], + ['save', 'cancel'], + ])); + + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => 'cancel%7Csave', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[debug]', + 'value' => $tokenDebug, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test that buttons created with foo[bar] name attributes are unlocked correctly. + */ + public function testSecurityButtonNestedNamed(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $this->Form->create(); + $this->Form->button('Test', ['type' => 'submit', 'name' => 'Address[button]']); + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals(['Address.button'], $result); + } + + /** + * Test that submit inputs created with foo[bar] name attributes are unlocked correctly. + */ + public function testSecuritySubmitNestedNamed(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $this->Form->create($this->article); + $this->Form->submit('Test', ['type' => 'submit', 'name' => 'Address[button]']); + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals(['Address.button'], $result); + } + + /** + * Test that the correct fields are unlocked for image submits with no names. + */ + public function testSecuritySubmitImageNoName(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $this->Form->create(); + $result = $this->Form->submit('save.png'); + $expected = [ + 'div' => ['class' => 'submit'], + 'input' => ['type' => 'image', 'src' => 'img/save.png'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals(['x', 'y'], $result); + } + + /** + * Test that the correct fields are unlocked for image submits with names. + */ + public function testSecuritySubmitImageName(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $this->Form->create(); + $result = $this->Form->submit('save.png', ['name' => 'test']); + $expected = [ + 'div' => ['class' => 'submit'], + 'input' => ['type' => 'image', 'name' => 'test', 'src' => 'img/save.png'], + '/div', + ]; + $this->assertHtml($expected, $result); + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals(['test', 'test_x', 'test_y'], $result); + } + + /** + * testFormSecurityMultipleControlFields method + * + * Test secure form creation with multiple row creation. Checks hidden, text, checkbox field types + */ + public function testFormSecurityMultipleControlFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->hidden('Addresses.0.id', ['value' => '123456']); + $this->Form->control('Addresses.0.title'); + $this->Form->control('Addresses.0.first_name'); + $this->Form->control('Addresses.0.last_name'); + $this->Form->control('Addresses.0.address'); + $this->Form->control('Addresses.0.city'); + $this->Form->control('Addresses.0.phone'); + $this->Form->control('Addresses.0.primary', ['type' => 'checkbox']); + + $this->Form->hidden('Addresses.1.id', ['value' => '654321']); + $this->Form->control('Addresses.1.title'); + $this->Form->control('Addresses.1.first_name'); + $this->Form->control('Addresses.1.last_name'); + $this->Form->control('Addresses.1.address'); + $this->Form->control('Addresses.1.city'); + $this->Form->control('Addresses.1.phone'); + $this->Form->control('Addresses.1.primary', ['type' => 'checkbox']); + + $result = $this->Form->secure(); + $hash = 'a4fe49bde94894a01375e7aa2873ea8114a96471%3AAddresses.0.id%7CAddresses.1.id'; + $tokenDebug = urlencode(json_encode([ + '/articles/add', + [ + 'Addresses.0.id' => '123456', + 'Addresses.0.title', + 'Addresses.0.first_name', + 'Addresses.0.last_name', + 'Addresses.0.address', + 'Addresses.0.city', + 'Addresses.0.phone', + 'Addresses.0.primary', + 'Addresses.1.id' => '654321', + 'Addresses.1.title', + 'Addresses.1.first_name', + 'Addresses.1.last_name', + 'Addresses.1.address', + 'Addresses.1.city', + 'Addresses.1.phone', + 'Addresses.1.primary', + ], + [], + ])); + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => '', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[debug]', + 'value' => $tokenDebug, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityArrayFields method + * + * Test form security with Model.field.0 style inputs. + */ + public function testFormSecurityArrayFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $this->Form->create(); + $this->Form->text('Address.primary.1'); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertSame('Address.primary', $result[0]); + + $this->Form->text('Address.secondary.1.0'); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertSame('Address.secondary', $result[1]); + } + + /** + * testFormSecurityMultipleControlDisabledFields method + * + * Test secure form generation with multiple records and disabled fields. + */ + public function testFormSecurityMultipleControlDisabledFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => ['first_name', 'address'], + ])); + $this->Form->create(); + + $this->Form->hidden('Addresses.0.id', ['value' => '123456']); + $this->Form->text('Addresses.0.title'); + $this->Form->text('Addresses.0.first_name'); + $this->Form->text('Addresses.0.last_name'); + $this->Form->text('Addresses.0.address'); + $this->Form->text('Addresses.0.city'); + $this->Form->text('Addresses.0.phone'); + $this->Form->hidden('Addresses.1.id', ['value' => '654321']); + $this->Form->text('Addresses.1.title'); + $this->Form->text('Addresses.1.first_name'); + $this->Form->text('Addresses.1.last_name'); + $this->Form->text('Addresses.1.address'); + $this->Form->text('Addresses.1.city'); + $this->Form->text('Addresses.1.phone'); + + $result = $this->Form->secure(); + $hash = '43c4db25e4162c5e4edd9dea51f5f9d9d92215ec%3AAddresses.0.id%7CAddresses.1.id'; + $tokenDebug = urlencode(json_encode([ + '/articles/add', + [ + 'Addresses.0.id' => '123456', + 'Addresses.0.title', + 'Addresses.0.last_name', + 'Addresses.0.city', + 'Addresses.0.phone', + 'Addresses.1.id' => '654321', + 'Addresses.1.title', + 'Addresses.1.last_name', + 'Addresses.1.city', + 'Addresses.1.phone', + ], + [ + 'first_name', + 'address', + ], + ])); + + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => 'address%7Cfirst_name', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[debug]', + 'value' => $tokenDebug, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityControlDisabledFields method + * + * Test single record form with disabled fields. + */ + public function testFormSecurityControlUnlockedFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => ['first_name', 'address'], + ])); + $this->Form->create(); + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals( + $this->View->getRequest()->getAttribute('formTokenData'), + ['unlockedFields' => $result], + ); + + $this->Form->hidden('Addresses.id', ['value' => '123456']); + $this->Form->text('Addresses.title'); + $this->Form->text('Addresses.first_name'); + $this->Form->text('Addresses.last_name'); + $this->Form->text('Addresses.address'); + $this->Form->text('Addresses.city'); + $this->Form->text('Addresses.phone'); + + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $expected = [ + 'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name', + 'Addresses.city', 'Addresses.phone', + ]; + $this->assertEquals($expected, $result); + + $result = $this->Form->secure($expected, ['data-foo' => 'bar']); + + $hash = 'f98315a7d5515e5ae32e35f7d680207c085fae69%3AAddresses.id'; + $tokenDebug = urlencode(json_encode([ + '/articles/add', + [ + 'Addresses.id' => '123456', + 'Addresses.title', + 'Addresses.last_name', + 'Addresses.city', + 'Addresses.phone', + ], + [ + 'first_name', + 'address', + ], + ])); + + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + 'data-foo' => 'bar', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => 'address%7Cfirst_name', + 'data-foo' => 'bar', + ]], + ['input' => [ + 'type' => 'hidden', 'name' => '_Token[debug]', + 'value' => $tokenDebug, + 'data-foo' => 'bar', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityControlUnlockedFieldsDebugSecurityTrue method + * + * Test single record form with debugSecurity param. + */ + public function testFormSecurityControlUnlockedFieldsDebugSecurityTrue(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => ['first_name', 'address'], + ])); + $this->Form->create(); + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals( + $this->View->getRequest()->getAttribute('formTokenData'), + ['unlockedFields' => $result], + ); + + $this->Form->hidden('Addresses.id', ['value' => '123456']); + $this->Form->text('Addresses.title'); + $this->Form->text('Addresses.first_name'); + $this->Form->text('Addresses.last_name'); + $this->Form->text('Addresses.address'); + $this->Form->text('Addresses.city'); + $this->Form->text('Addresses.phone'); + + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $expected = [ + 'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name', + 'Addresses.city', 'Addresses.phone', + ]; + $this->assertEquals($expected, $result); + $result = $this->Form->secure($expected, ['data-foo' => 'bar', 'debugSecurity' => true]); + + $hash = 'f98315a7d5515e5ae32e35f7d680207c085fae69%3AAddresses.id'; + $tokenDebug = urlencode(json_encode([ + '/articles/add', + [ + 'Addresses.id' => '123456', + 'Addresses.title', + 'Addresses.last_name', + 'Addresses.city', + 'Addresses.phone', + ], + [ + 'first_name', + 'address', + ], + ])); + + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + 'data-foo' => 'bar', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => 'address%7Cfirst_name', + 'data-foo' => 'bar', + ]], + ['input' => [ + 'type' => 'hidden', 'name' => '_Token[debug]', + 'value' => $tokenDebug, + 'data-foo' => 'bar', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityControlUnlockedFieldsDebugSecurityFalse method + * + * Debug is false, debugSecurity is true -> no debug + */ + public function testFormSecurityControlUnlockedFieldsDebugSecurityDebugFalse(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => ['first_name', 'address'], + ])); + $this->Form->create(); + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals( + $this->View->getRequest()->getAttribute('formTokenData'), + ['unlockedFields' => $result], + ); + + $this->Form->hidden('Addresses.id', ['value' => '123456']); + $this->Form->text('Addresses.title'); + $this->Form->text('Addresses.first_name'); + $this->Form->text('Addresses.last_name'); + $this->Form->text('Addresses.address'); + $this->Form->text('Addresses.city'); + $this->Form->text('Addresses.phone'); + + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $expected = [ + 'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name', + 'Addresses.city', 'Addresses.phone', + ]; + $this->assertEquals($expected, $result); + Configure::write('debug', false); + $result = $this->Form->secure($expected, ['data-foo' => 'bar', 'debugSecurity' => true]); + + $hash = 'f98315a7d5515e5ae32e35f7d680207c085fae69%3AAddresses.id'; + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + 'data-foo' => 'bar', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => 'address%7Cfirst_name', + 'data-foo' => 'bar', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormSecurityControlUnlockedFieldsDebugSecurityFalse method + * + * Test single record form with debugSecurity param. + */ + public function testFormSecurityControlUnlockedFieldsDebugSecurityFalse(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => ['first_name', 'address'], + ])); + $this->Form->create(); + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals( + $this->View->getRequest()->getAttribute('formTokenData'), + ['unlockedFields' => $result], + ); + + $this->Form->hidden('Addresses.id', ['value' => '123456']); + $this->Form->text('Addresses.title'); + $this->Form->text('Addresses.first_name'); + $this->Form->text('Addresses.last_name'); + $this->Form->text('Addresses.address'); + $this->Form->text('Addresses.city'); + $this->Form->text('Addresses.phone'); + + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $expected = [ + 'Addresses.id' => '123456', 'Addresses.title', 'Addresses.last_name', + 'Addresses.city', 'Addresses.phone', + ]; + $this->assertEquals($expected, $result); + + $result = $this->Form->secure($expected, ['data-foo' => 'bar', 'debugSecurity' => false]); + $hash = 'f98315a7d5515e5ae32e35f7d680207c085fae69%3AAddresses.id'; + + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value' => $hash, + 'data-foo' => 'bar', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => 'address%7Cfirst_name', + 'data-foo' => 'bar', + ]], + '/div', + ]; + + $this->assertHtml($expected, $result); + } + + /** + * testFormSecureWithCustomNameAttribute method + * + * Test securing inputs with custom name attributes. + */ + public function testFormSecureWithCustomNameAttribute(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->text('UserForm.published', ['name' => 'User[custom]']); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertSame('User.custom', $result[0]); + + $this->Form->text('UserForm.published', ['name' => 'User[custom][another][value]']); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertSame('User.custom.another.value', $result[1]); + } + + /** + * testFormSecuredControl method + * + * Test generation of entire secure form, assertions made on control() output. + */ + public function testFormSecuredControl(): void + { + $this->View->setRequest($this->View->getRequest() + ->withAttribute('formTokenData', []) + ->withAttribute('csrfToken', 'testKey')); + $this->article['schema'] = [ + 'ratio' => ['type' => 'decimal', 'length' => 5, 'precision' => 6], + 'population' => ['type' => 'decimal', 'length' => 15, 'precision' => 0], + ]; + + $result = $this->Form->create($this->article, ['url' => '/articles/add']); + $encoding = strtolower(Configure::read('App.encoding')); + $expected = [ + 'form' => ['method' => 'post', 'action' => '/articles/add', 'accept-charset' => $encoding], + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_csrfToken', + 'value' => 'testKey', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('ratio'); + $expected = [ + 'div' => ['class'], + 'label' => ['for'], + 'Ratio', + '/label', + 'input' => ['name', 'type' => 'number', 'step' => '0.000001', 'id'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('population'); + $expected = [ + 'div' => ['class'], + 'label' => ['for'], + 'Population', + '/label', + 'input' => ['name', 'type' => 'number', 'step' => '1', 'id'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('published', ['type' => 'text']); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'published'], + 'Published', + '/label', + ['input' => [ + 'type' => 'text', + 'name' => 'published', + 'id' => 'published', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('other', ['type' => 'text']); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'other'], + 'Other', + '/label', + ['input' => [ + 'type' => 'text', + 'name' => 'other', + 'id', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->hidden('stuff'); + $expected = [ + 'input' => [ + 'type' => 'hidden', + 'name' => 'stuff', + ], + ]; + + $this->assertHtml($expected, $result); + + $result = $this->Form->hidden('hidden', ['value' => false]); + $expected = ['input' => [ + 'type' => 'hidden', + 'name' => 'hidden', + 'value' => '0', + ]]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('something', ['type' => 'checkbox']); + $expected = [ + 'div' => ['class' => 'input checkbox'], + ['input' => [ + 'type' => 'hidden', + 'name' => 'something', + 'value' => '0', + ]], + 'label' => ['for' => 'something'], + ['input' => [ + 'type' => 'checkbox', + 'name' => 'something', + 'value' => '1', + 'id' => 'something', + ]], + 'Something', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $expectedFields = [ + 'ratio', + 'population', + 'published', + 'other', + 'stuff' => '', + 'hidden' => '0', + 'something', + ]; + $this->assertEquals($expectedFields, $result); + + $result = $this->Form->secure(); + $tokenDebug = urlencode(json_encode([ + '/articles/add', + $expectedFields, + [], + ])); + + $expected = [ + 'div' => ['style' => 'display:none;'], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[fields]', + 'value', + ]], + ['input' => [ + 'type' => 'hidden', + 'name' => '_Token[unlocked]', + 'value' => '', + ]], + ['input' => [ + 'type' => 'hidden', 'name' => '_Token[debug]', + 'value' => $tokenDebug, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $data = [ + 'ratio' => '', + 'population' => '', + 'published' => '', + 'other' => '', + 'stuff' => '', + 'hidden' => '0', + 'something' => '', + '_Token' => $this->Form->getFormProtector()->buildTokenData(), + ]; + + $this->assertTrue($this->Form->getFormProtector()->validate($data, '', '')); + } + + /** + * testSecuredControlCustomName method + * + * Test secured inputs with custom names. + */ + public function testSecuredControlCustomName(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->text('text_input', [ + 'name' => 'Option[General.default_role]', + ]); + $expected = ['Option.General.default_role']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->select('select_box', [1, 2], [ + 'name' => 'Option[General.select_role]', + ]); + $expected[] = 'Option.General.select_role'; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->text('other.things[]'); + $expected[] = 'other.things'; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * testSecuredControlDuplicate method + * + * Test that a hidden field followed by a visible field + * undoes the hidden field locking. + */ + public function testSecuredControlDuplicate(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->control('text_val', [ + 'type' => 'hidden', + 'value' => 'some text', + ]); + $expected = ['text_val' => 'some text']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->control('text_val', [ + 'type' => 'text', + ]); + $expected = ['text_val']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * testFormSecuredFileControl method + * + * Tests that the correct keys are added to the field hash index. + */ + public function testFormSecuredFileControl(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->file('Attachment.file'); + $expected = ['Attachment.file']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * testFormSecuredMultipleSelect method + * + * Test that multiple selects keys are added to field hash. + */ + public function testFormSecuredMultipleSelect(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $options = ['1' => 'one', '2' => 'two']; + $this->Form->select('Model.select', $options); + $expected = ['Model.select']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->select('Model.select', $options, ['multiple' => true]); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * testFormSecuredRadio method + */ + public function testFormSecuredRadio(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $options = ['1' => 'option1', '2' => 'option2']; + + $this->Form->radio('Test.test', $options); + $expected = ['Test.test']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->radio('Test.all', $options, [ + 'disabled' => ['option1', 'option2'], + ]); + $expected = ['Test.test', 'Test.all' => '']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->radio('Test.some', $options, [ + 'disabled' => ['option1'], + ]); + $expected = ['Test.test', 'Test.all' => '', 'Test.some']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * testFormSecuredAndDisabled method + * + * Test that forms with disabled inputs + secured forms leave off the inputs from the form + * hashing. + */ + public function testFormSecuredAndDisabled(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->checkbox('Model.checkbox', ['disabled' => true]); + $this->Form->textarea('Model.textarea', ['disabled' => true]); + $this->Form->select('Model.select', [1, 2], ['disabled' => true]); + $this->Form->radio('Model.radio', [1, 2], ['disabled' => [1, 2]]); + $this->Form->year('Model.year', ['disabled' => true]); + $this->Form->month('Model.month', ['disabled' => true]); + $this->Form->text('Model.text', ['disabled' => true]); + $this->Form->password('Model.text', ['disabled' => true]); + $this->Form->day('Model.day', ['disabled' => true]); + $this->Form->hour('Model.hour', ['disabled' => true]); + $this->Form->minute('Model.minute', ['disabled' => true]); + $this->Form->meridian('Model.meridian', ['disabled' => true]); + + $expected = [ + 'Model.radio' => '', + ]; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * Test that postLink() with additional data generates a valid secure token. + */ + public function testFormSecuredControlPostLink(): void + { + $this->View->setRequest($this->View->getRequest() + ->withAttribute('formTokenData', []) + ->withAttribute('csrfToken', 'testKey')); + + $options = [ + 'data' => ['string' => 'value', 'boolean' => true, 'falsey' => false], + ]; + $result = $this->Form->postLink('title', '/articles/add', $options); + + // Because postLink() creates a standalone form protector + // we can't inspect its internal state at all. + // Use the generated HTML to extract token data so we + // can craft a request. + $dom = new DOMDocument(); + $dom->loadHTML($result); + $xpath = new DOMXPath($dom); + $inputs = $xpath->query("//form//input[contains(@name,'_Token')]"); + $token = []; + foreach ($inputs as $item) { + $name = $item->getAttribute('name'); + [, $field] = explode('[', $name); + $field = trim($field, ']'); + $token[$field] = $item->getAttribute('value'); + } + + // Create a simulated request + // boolean is `'1'` because that is what the request + // same with falsey being '0' + // data will be. + $data = [ + 'boolean' => '1', + 'falsey' => '0', + 'string' => 'value', + '_Token' => $token, + ]; + $formProtector = new FormProtector([]); + $this->assertTrue( + $formProtector->validate($data, '/articles/add', 'cli'), + $formProtector->getError() ?? 'no formprotector->getError', + ); + } + + /** + * testUnlockFieldAddsToList method + * + * Test disableField. + */ + public function testUnlockFieldAddsToList(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => [], + ])); + $this->Form->create(); + + $this->Form->unlockField('Contact.name'); + $this->Form->text('Contact.name'); + + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals([], $result); + + $result = $this->Form->getFormProtector()->__debugInfo()['unlockedFields']; + $this->assertEquals(['Contact.name'], $result); + } + + /** + * testUnlockFieldRemovingFromFields method + * + * Test unlockField removing from fields array. + */ + public function testUnlockFieldRemovingFromFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'unlockedFields' => [], + ])); + $this->Form->create($this->article); + $this->Form->hidden('Article.id', ['value' => 1]); + $this->Form->text('Article.title'); + + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertSame('1', $result['Article.id'], 'Hidden input should be secured.'); + $this->assertContains('Article.title', $result, 'Field should be secured.'); + + $this->Form->unlockField('Article.title'); + $this->Form->unlockField('Article.id'); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals([], $result); + } + + /** + * testResetUnlockFields method + * + * Test reset unlockFields, when create new form. + */ + public function testResetUnlockFields(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [ + 'key' => 'testKey', + 'unlockedFields' => [], + ])); + + $this->Form->create(); + $this->Form->unlockField('Contact.id'); + $this->Form->hidden('Contact.id', ['value' => 1]); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEmpty($result, 'Field should be unlocked'); + $this->Form->end(); + + $this->Form->create(); + $this->Form->hidden('Contact.id', ['value' => 1]); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertSame('1', $result['Contact.id'], 'Hidden input should be secured.'); + } + + /** + * testUnlockFieldWithFormTokenData method + * + * Test that unlockField() becomes a no-op and does not throw an exception + * when called without `formTokenData` being present in the request. + * + * @return void + */ + public function testUnlockFieldWithFormTokenData(): void + { + $this->Form->create(); + $result = $this->Form->unlockField('foo'); + + $this->assertSame($this->Form, $result); + } + + /** + * testSecuredFormUrlIgnoresHost method + * + * Test that only the path + query elements of a form's URL show up in their hash. + */ + public function testSecuredFormUrlIgnoresHost(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', ['key' => 'testKey'])); + + $expected = '2548654895b160d724042ed269a2a863fd9d66ee%3A'; + $this->Form->create($this->article, [ + 'url' => ['controller' => 'articles', 'action' => 'view', 1, '?' => ['page' => 1]], + ]); + $result = $this->Form->secure(); + $this->assertStringContainsString($expected, $result); + + $this->Form->create($this->article, ['url' => 'http://localhost/articles/view/1?page=1']); + $result = $this->Form->secure(); + $this->assertStringContainsString($expected, $result, 'Full URL should only use path and query.'); + + $this->Form->create($this->article, ['url' => '/articles/view/1?page=1']); + $result = $this->Form->secure(); + $this->assertStringContainsString($expected, $result, 'URL path + query should work.'); + + $this->Form->create($this->article, ['url' => '/articles/view/1']); + $result = $this->Form->secure(); + $this->assertStringNotContainsString($expected, $result, 'URL is different'); + } + + /** + * testSecuredFormUrlHasHtmlAndIdentifier method + * + * Test that URL, HTML and identifier show up in their hashes. + */ + public function testSecuredFormUrlHasHtmlAndIdentifier(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + + $expected = '0a913f45b887b4d9cc2650ef1edc50183896959c%3A'; + $this->Form->create($this->article, [ + 'url' => [ + 'controller' => 'articles', + 'action' => 'view', + '?' => [ + 'page' => 1, + 'limit' => 10, + 'html' => '<>"', + ], + '#' => 'result', + ], + ]); + $result = $this->Form->secure(); + $this->assertStringContainsString($expected, $result); + + $this->Form->create($this->article, [ + 'url' => 'http://localhost/articles/view?page=1&limit=10&html=%3C%3E%22#result', + ]); + $result = $this->Form->secure(); + $this->assertStringContainsString($expected, $result, 'Full URL should only use path and query.'); + + $this->Form->create($this->article, [ + 'url' => '/articles/view?page=1&limit=10&html=%3C%3E%22#result', + ]); + $result = $this->Form->secure(); + $this->assertStringContainsString($expected, $result, 'URL path + query should work.'); + } + + /** + * testErrorMessageDisplay method + * + * Test error message display. + */ + public function testErrorMessageDisplay(): void + { + $this->article['errors'] = [ + 'Article' => [ + 'title' => 'error message', + 'content' => 'some test data with HTML chars', + 'user_id' => 'error message', + ], + ]; + $this->Form->create($this->article); + + $result = $this->Form->control('Article.title'); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[title]', + 'id' => 'article-title', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-title-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-title-error']], + 'error message', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Article.title', ['templates' => ['errorClass' => 'danger']]); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[title]', + 'id' => 'article-title', + 'class' => 'danger', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-title-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-title-error']], + 'error message', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Article.user_id', [ + 'type' => 'select', + 'options' => ['1' => 'One', '2' => 'Two'], + ]); + $expected = [ + 'div' => ['class' => 'input select error'], + 'label' => ['for' => 'article-user-id'], + 'User', + '/label', + ['select' => [ + 'name' => 'Article[user_id]', + 'id' => 'article-user-id', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-user-id-error', + ]], + ['option' => ['value' => '1']], + 'One', + '/option', + ['option' => ['value' => '2']], + 'Two', + '/option', + '/select', + ['div' => ['class' => 'error-message', 'id' => 'article-user-id-error']], + 'error message', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Article.title', [ + 'templates' => [ + 'inputContainerError' => '
    {{content}}
    ', + ], + ]); + + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[title]', + 'id' => 'article-title', + 'class' => 'form-error', + // No aria-describedby because error template is custom + 'aria-invalid' => 'true', + ], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Article.content'); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-content'], + 'Content', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[content]', + 'id' => 'article-content', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-content-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-content-error']], + 'some <strong>test</strong> data with <a href="#">HTML</a> chars', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Article.content', ['error' => ['escape' => true]]); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-content'], + 'Content', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[content]', + 'id' => 'article-content', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-content-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-content-error']], + 'some <strong>test</strong> data with <a href="#">HTML</a> chars', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Article.content', ['error' => ['escape' => false]]); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-content'], + 'Content', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[content]', + 'id' => 'article-content', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-content-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-content-error']], + 'some test data with HTML chars', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * @deprecated + */ + public function testWarningForDeprecatedErrorClassConfig(): void + { + $this->Form->setConfig('errorClass', 'danger'); + $this->article['errors'] = [ + 'Article' => [ + 'title' => 'error message', + ], + ]; + $this->Form->create($this->article); + + $this->deprecated(function (): void { + $result = $this->Form->control('Article.title'); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[title]', + 'id' => 'article-title', + 'class' => 'danger', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-title-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-title-error']], + 'error message', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + }); + } + + /** + * testEmptyErrorValidation method + * + * Test validation errors, when validation message is an empty string. + */ + public function testEmptyErrorValidation(): void + { + $this->article['errors'] = [ + 'Article' => ['title' => ''], + ]; + $this->Form->create($this->article); + + $result = $this->Form->control('Article.title'); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'Article[title]', + 'id' => 'article-title', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-title-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-title-error']], + [], + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testEmptyControlErrorValidation method + * + * Test validation errors, when calling control() overriding validation message by an empty string. + */ + public function testEmptyControlErrorValidation(): void + { + $this->article['errors'] = [ + 'Article' => ['title' => 'error message'], + ]; + $this->Form->create($this->article); + + $result = $this->Form->control('Article.title', ['error' => '']); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'article-title'], + 'Title', + '/label', + 'input' => [ + 'aria-invalid' => 'true', + 'aria-describedby' => 'article-title-error', + 'type' => 'text', + 'name' => 'Article[title]', + 'id' => 'article-title', + 'class' => 'form-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'article-title-error']], + [], + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlErrorMessage method + * + * Test validation errors, when calling control() overriding validation messages. + */ + public function testControlErrorMessage(): void + { + $this->article['errors'] = [ + 'title' => ['error message'], + ]; + $this->Form->create($this->article); + + $result = $this->Form->control('title', [ + 'error' => 'Custom error!', + ]); + $expected = [ + 'div' => ['class' => 'input text required error'], + 'label' => ['for' => 'title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'title', + 'id' => 'title', + 'class' => 'form-error', + 'required' => 'required', + 'data-validity-message' => 'This field cannot be left empty', + 'oninvalid' => 'this.setCustomValidity(''); if (!this.value) this.setCustomValidity(this.dataset.validityMessage)', + 'oninput' => 'this.setCustomValidity('')', + 'aria-invalid' => 'true', + 'aria-describedby' => 'title-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'title-error']], + 'Custom error!', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('title', [ + 'error' => ['error message' => 'Custom error!'], + ]); + $expected = [ + 'div' => ['class' => 'input text required error'], + 'label' => ['for' => 'title'], + 'Title', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'title', + 'id' => 'title', + 'aria-invalid' => 'true', + 'aria-describedby' => 'title-error', + 'class' => 'form-error', + 'required' => 'required', + 'data-validity-message' => 'This field cannot be left empty', + 'oninvalid' => 'this.setCustomValidity(''); if (!this.value) this.setCustomValidity(this.dataset.validityMessage)', + 'oninput' => 'this.setCustomValidity('')', + ], + ['div' => ['class' => 'error-message', 'id' => 'title-error']], + 'Custom error!', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormValidationAssociated method + * + * Tests displaying errors for nested entities. + */ + public function testFormValidationAssociated(): void + { + $nested = new Entity(['foo' => 'bar']); + $nested->setError('foo', ['not a valid bar']); + $entity = new Entity(['nested' => $nested]); + $this->Form->create($entity, ['context' => ['table' => 'Articles']]); + + $result = $this->Form->error('nested.foo'); + $this->assertSame('
    not a valid bar
    ', $result); + } + + /** + * testFormValidationAssociatedSecondLevel method + * + * Test form error display with associated model. + */ + public function testFormValidationAssociatedSecondLevel(): void + { + $inner = new Entity(['bar' => 'baz']); + $nested = new Entity(['foo' => $inner]); + $entity = new Entity(['nested' => $nested]); + $inner->setError('bar', ['not a valid one']); + $this->Form->create($entity, ['context' => ['table' => 'Articles']]); + $result = $this->Form->error('nested.foo.bar'); + $this->assertSame('
    not a valid one
    ', $result); + } + + /** + * testFormValidationMultiRecord method + * + * Test form error display with multiple records. + */ + public function testFormValidationMultiRecord(): void + { + $one = new Entity(); + $two = new Entity(); + $this->getTableLocator()->get('Contacts', [ + 'className' => ContactsTable::class, + ]); + $one->set('email', ''); + $one->setError('email', ['invalid email']); + + $two->set('name', ''); + $two->setError('name', ['This is wrong']); + $this->Form->create([$one, $two], ['context' => ['table' => 'Contacts']]); + + $result = $this->Form->control('0.email'); + $expected = [ + 'div' => ['class' => 'input email error'], + 'label' => ['for' => '0-email'], + 'Email', + '/label', + 'input' => [ + 'type' => 'email', + 'name' => '0[email]', + 'id' => '0-email', + 'class' => 'form-error', + 'maxlength' => 255, + 'value' => '', + 'aria-invalid' => 'true', + 'aria-describedby' => '0-email-error', + ], + ['div' => ['class' => 'error-message', 'id' => '0-email-error']], + 'invalid email', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('1.name'); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => '1-name'], + 'Name', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => '1[name]', + 'id' => '1-name', + 'class' => 'form-error', + 'maxlength' => 255, + 'value' => '', + 'aria-invalid' => 'true', + 'aria-describedby' => '1-name-error', + ], + ['div' => ['class' => 'error-message', 'id' => '1-name-error']], + 'This is wrong', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControl method + * + * Test various incarnations of control(). + */ + public function testControl(): void + { + $this->getTableLocator()->get('ValidateUsers', [ + 'className' => ValidateUsersTable::class, + ]); + $this->Form->create([], ['context' => ['table' => 'ValidateUsers']]); + $result = $this->Form->control('ValidateUsers.balance'); + $expected = [ + 'div' => ['class'], + 'label' => ['for'], + 'Balance', + '/label', + 'input' => ['name', 'type' => 'number', 'id', 'step'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('ValidateUser.cost_decimal'); + $expected = [ + 'div' => ['class'], + 'label' => ['for'], + 'Cost Decimal', + '/label', + 'input' => ['name', 'type' => 'number', 'step' => '0.001', 'id'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('ValidateUser.null_decimal'); + $expected = [ + 'div' => ['class'], + 'label' => ['for'], + 'Null Decimal', + '/label', + 'input' => ['name', 'type' => 'number', 'id'], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + public function testControlEntityWithRequirePresence(): void + { + $article = new Article(); + $article->requireFieldPresence(true); + $this->Form->create($article); + $this->Form->control('title'); + + // We only need to check that Cake\Datasource\Exception\MissingPropertyException is not thrown + $this->expectNotToPerformAssertions(); + } + + /** + * testControlCustomization method + * + * Tests the input method and passing custom options. + */ + public function testControlCustomization(): void + { + $this->getTableLocator()->get('Contacts', [ + 'className' => ContactsTable::class, + ]); + $this->Form->create([], ['context' => ['table' => 'Contacts']]); + $result = $this->Form->control('Contact.email', [ + 'id' => 'custom', + 'templates' => [ + 'containerClass' => 'ic', + ], + ]); + $expected = [ + 'div' => ['class' => 'ic email'], + 'label' => ['for' => 'custom'], + 'Email', + '/label', + ['input' => [ + 'type' => 'email', 'name' => 'Contact[email]', + 'id' => 'custom', 'maxlength' => 255, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Contact.email', [ + 'templates' => ['inputContainer' => '
    {{content}}
    '], + ]); + $expected = [ + ' ['for' => 'contact-email'], + 'Email', + '/label', + ['input' => [ + 'type' => 'email', 'name' => 'Contact[email]', + 'id' => 'contact-email', 'maxlength' => 255, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Contact.email', [ + 'templates' => [ + 'formGroup' => '{{input}}', + 'inputContainer' => '
    {{label}}
    {{content}}
    ', + ], + ]); + $expected = [ + ' ['for' => 'contact-email'], + 'Email', + '/label', + '/div', + ['input' => [ + 'type' => 'email', 'name' => 'Contact[email]', + 'id' => 'contact-email', 'maxlength' => 255, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Contact.email', ['type' => 'text']); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'contact-email'], + 'Email', + '/label', + ['input' => [ + 'type' => 'text', 'name' => 'Contact[email]', + 'id' => 'contact-email', 'maxlength' => '255', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Contact.5.email', ['type' => 'text']); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'contact-5-email'], + 'Email', + '/label', + ['input' => [ + 'type' => 'text', 'name' => 'Contact[5][email]', + 'id' => 'contact-5-email', 'maxlength' => '255', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Contact.password'); + $expected = [ + 'div' => ['class' => 'input password'], + 'label' => ['for' => 'contact-password'], + 'Password', + '/label', + ['input' => [ + 'type' => 'password', 'name' => 'Contact[password]', + 'id' => 'contact-password', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Contact.email', [ + 'type' => 'file', 'class' => 'textbox', + ]); + $expected = [ + 'div' => ['class' => 'input file'], + 'label' => ['for' => 'contact-email'], + 'Email', + '/label', + ['input' => [ + 'type' => 'file', 'name' => 'Contact[email]', 'class' => 'textbox', + 'id' => 'contact-email', + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $entity = new Entity(['phone' => 'Hello & World > weird chars']); + $this->Form->create($entity, ['context' => ['table' => 'Contacts']]); + $result = $this->Form->control('phone'); + $expected = [ + 'div' => ['class' => 'input tel'], + 'label' => ['for' => 'phone'], + 'Phone', + '/label', + ['input' => [ + 'type' => 'tel', 'name' => 'phone', + 'value' => 'Hello & World > weird chars', + 'id' => 'phone', 'maxlength' => 255, + ]], + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest( + $this->View->getRequest()->withData('Model.0.OtherModel.field', 'My value'), + ); + $this->Form->create(); + $result = $this->Form->control('Model.0.OtherModel.field', ['id' => 'myId']); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'myId'], + 'Field', + '/label', + 'input' => [ + 'type' => 'text', 'name' => 'Model[0][OtherModel][field]', + 'value' => 'My value', 'id' => 'myId', + ], + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withParsedBody([])); + $this->Form->create(); + + $entity->setError('field', 'Badness!'); + $this->Form->create($entity, ['context' => ['table' => 'Contacts']]); + $result = $this->Form->control('field'); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'field'], + 'Field', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'field', + 'id' => 'field', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'field-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'field-error']], + 'Badness!', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('field', [ + 'templates' => [ + 'inputContainerError' => '{{content}}{{error}}', + 'error' => '{{content}}', + ], + ]); + $expected = [ + 'label' => ['for' => 'field'], + 'Field', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'field', + 'id' => 'field', + 'class' => 'form-error', + // No aria-describedby because error template is custom + 'aria-invalid' => 'true', + ], + ['span' => ['class' => 'error-message']], + 'Badness!', + '/span', + ]; + $this->assertHtml($expected, $result); + + $entity->setError('field', ['minLength'], true); + $result = $this->Form->control('field', [ + 'error' => [ + 'minLength' => 'Le login doit contenir au moins 2 caractères', + 'maxLength' => 'login too large', + ], + ]); + $expected = [ + 'div' => ['class' => 'input text error'], + 'label' => ['for' => 'field'], + 'Field', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'field', + 'id' => 'field', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'field-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'field-error']], + 'Le login doit contenir au moins 2 caractères', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $entity->setError('field', ['maxLength'], true); + $result = $this->Form->control('field', [ + 'templates' => [ + 'containerClass' => 'input-container', + ], + 'error' => [ + 'minLength' => 'Le login doit contenir au moins 2 caractères', + 'maxLength' => 'login too large', + ], + ]); + $expected = [ + 'div' => ['class' => 'input-container text error'], + 'label' => ['for' => 'field'], + 'Field', + '/label', + 'input' => [ + 'type' => 'text', + 'name' => 'field', + 'id' => 'field', + 'class' => 'form-error', + 'aria-invalid' => 'true', + 'aria-describedby' => 'field-error', + ], + ['div' => ['class' => 'error-message', 'id' => 'field-error']], + 'login too large', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlWithTemplateFile method + * + * Test that control() accepts a template file. + */ + public function testControlWithTemplateFile(): void + { + $result = $this->Form->control('field', [ + 'templates' => 'htmlhelper_tags', + ]); + $expected = [ + 'label' => ['for' => 'field'], + 'Field', + '/label', + 'input' => [ + 'type' => 'text', 'name' => 'field', + 'id' => 'field', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testNestedControlsEndWithBrackets method + * + * Test that nested inputs end with brackets. + */ + public function testNestedControlsEndWithBrackets(): void + { + $result = $this->Form->text('nested.text[]'); + $expected = [ + 'input' => [ + 'type' => 'text', 'name' => 'nested[text][]', + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->file('nested.file[]'); + $expected = [ + 'input' => [ + 'type' => 'file', 'name' => 'nested[file][]', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testCreateIdPrefix method + * + * Test id prefix. + */ + public function testCreateIdPrefix(): void + { + $this->Form->create(null, ['idPrefix' => 'prefix']); + + $result = $this->Form->control('field'); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'prefix-field'], + 'Field', + '/label', + 'input' => ['type' => 'text', 'name' => 'field', 'id' => 'prefix-field'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('field', ['id' => 'custom-id']); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'custom-id'], + 'Field', + '/label', + 'input' => ['type' => 'text', 'name' => 'field', 'id' => 'custom-id'], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->radio('Model.field', ['option A']); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '', 'id' => 'prefix-model-field'], + 'label' => ['for' => 'prefix-model-field-0'], + ['input' => [ + 'type' => 'radio', + 'name' => 'Model[field]', + 'value' => '0', + 'id' => 'prefix-model-field-0', + ]], + 'option A', + '/label', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->radio('Model.field', ['option A', 'option']); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '', 'id' => 'prefix-model-field'], + 'label' => ['for' => 'prefix-model-field-0'], + ['input' => [ + 'type' => 'radio', + 'name' => 'Model[field]', + 'value' => '0', + 'id' => 'prefix-model-field-0', + ]], + 'option A', + '/label', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select( + 'Model.multi_field', + ['first'], + ['multiple' => 'checkbox'], + ); + $expected = [ + 'input' => [ + 'type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'prefix-model-multi-field', + ], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'prefix-model-multi-field-0']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => '0', 'id' => 'prefix-model-multi-field-0', + ]], + 'first', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->Form->end(); + $result = $this->Form->control('field'); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'field'], + 'Field', + '/label', + 'input' => ['type' => 'text', 'name' => 'field', 'id' => 'field'], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlZero method + * + * Test that inputs with 0 can be created. + */ + public function testControlZero(): void + { + $this->getTableLocator()->get('Contacts', [ + 'className' => ContactsTable::class, + ]); + $this->Form->create([], ['context' => ['table' => 'Contacts']]); + $result = $this->Form->control('0'); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => '0'], '/label', + 'input' => ['type' => 'text', 'name' => '0', 'id' => '0'], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlCheckbox method + * + * Test control() with checkbox creation. + */ + public function testControlCheckbox(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->getSchema()->addColumn('active', ['type' => 'boolean', 'default' => null]); + $article = $articles->newEmptyEntity(); + + $this->Form->create($article); + + $result = $this->Form->control('Articles.active'); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'input' => ['type' => 'hidden', 'name' => 'Articles[active]', 'value' => '0'], + 'label' => ['for' => 'articles-active'], + ['input' => ['type' => 'checkbox', 'name' => 'Articles[active]', 'value' => '1', 'id' => 'articles-active']], + 'Active', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Articles.active', ['label' => false, 'checked' => true]); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'input' => ['type' => 'hidden', 'name' => 'Articles[active]', 'value' => '0'], + ['input' => ['type' => 'checkbox', 'name' => 'Articles[active]', 'value' => '1', 'id' => 'articles-active', 'checked' => 'checked']], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Articles.active', ['label' => false, 'checked' => 1]); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'input' => ['type' => 'hidden', 'name' => 'Articles[active]', 'value' => '0'], + ['input' => ['type' => 'checkbox', 'name' => 'Articles[active]', 'value' => '1', 'id' => 'articles-active', 'checked' => 'checked']], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Articles.active', ['label' => false, 'checked' => '1']); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'input' => ['type' => 'hidden', 'name' => 'Articles[active]', 'value' => '0'], + ['input' => ['type' => 'checkbox', 'name' => 'Articles[active]', 'value' => '1', 'id' => 'articles-active', 'checked' => 'checked']], + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Articles.disabled', [ + 'label' => 'Disabled', + 'type' => 'checkbox', + 'data-foo' => 'disabled', + ]); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'input' => ['type' => 'hidden', 'name' => 'Articles[disabled]', 'value' => '0'], + 'label' => ['for' => 'articles-disabled'], + ['input' => [ + 'type' => 'checkbox', + 'name' => 'Articles[disabled]', + 'value' => '1', + 'id' => 'articles-disabled', + 'data-foo' => 'disabled', + ]], + 'Disabled', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Articles.confirm', [ + 'label' => 'Confirm me!', + 'type' => 'checkbox', + 'escape' => false, + ]); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'input' => ['type' => 'hidden', 'name' => 'Articles[confirm]', 'value' => '0'], + 'label' => ['for' => 'articles-confirm'], + ['input' => [ + 'type' => 'checkbox', + 'name' => 'Articles[confirm]', + 'value' => '1', + 'id' => 'articles-confirm', + ]], + 'Confirm me!', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlHidden method + * + * Test that control() does not create wrapping div and label tag for hidden fields. + */ + public function testControlHidden(): void + { + $this->getTableLocator()->get('ValidateUsers', [ + 'className' => ValidateUsersTable::class, + ]); + $this->Form->create([], ['context' => ['table' => 'ValidateUsers']]); + + $result = $this->Form->control('ValidateUser.id'); + $expected = [ + 'input' => ['name', 'type' => 'hidden', 'id'], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('ValidateUser.custom', ['type' => 'hidden']); + $expected = [ + 'input' => ['name', 'type' => 'hidden', 'id'], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlDatetime method + * + * Test form->control() with datetime. + */ + public function testControlDatetime(): void + { + $result = $this->Form->control('prueba', [ + 'type' => 'datetime', + 'value' => new DateTime('2019-09-27 02:52:43'), + ]); + $expected = [ + 'div' => ['class' => 'input datetime'], + 'label' => ['for' => 'prueba'], + 'Prueba', + '/label', + 'input' => [ + 'name' => 'prueba', + 'id' => 'prueba', + 'type' => 'datetime-local', + 'value' => '2019-09-27T02:52:43', + 'step' => '1', + ], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlDatetimeIdPrefix method + * + * Test form->control() with datetime with id prefix. + */ + public function testControlDatetimeIdPrefix(): void + { + $this->Form->create(null, ['idPrefix' => 'prefix']); + + $result = $this->Form->control('prueba', [ + 'type' => 'datetime', + ]); + $expected = [ + 'div' => ['class' => 'input datetime'], + 'label' => ['for' => 'prefix-prueba'], + 'Prueba', + '/label', + 'input' => [ + 'name' => 'prueba', + 'id' => 'prefix-prueba', + 'type' => 'datetime-local', + 'value' => '', + 'step' => '1', + ], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlDatetimeStep method + * + * Test form->control() with datetime with custom step size. + */ + public function testControlDatetimeStep(): void + { + $result = $this->Form->control('prueba', [ + 'type' => 'datetime', + 'value' => new DateTime('2019-09-27 02:52:43'), + 'step' => '0.5', + ]); + $expected = [ + 'div' => ['class' => 'input datetime'], + 'label' => ['for' => 'prueba'], + 'Prueba', + '/label', + 'input' => [ + 'name' => 'prueba', + 'id' => 'prueba', + 'type' => 'datetime-local', + 'value' => '2019-09-27T02:52:43.000', + 'step' => '0.5', + ], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlCheckboxWithDisabledElements method + * + * Test generating checkboxes with disabled elements. + */ + public function testControlCheckboxWithDisabledElements(): void + { + $options = [1 => 'One', 2 => 'Two', '3' => 'Three']; + $result = $this->Form->control('Contact.multiple', [ + 'multiple' => 'checkbox', + 'disabled' => 'disabled', + 'options' => $options, + ]); + + $expected = [ + ['div' => ['class' => 'input select']], + ['label' => ['for' => 'contact-multiple']], + 'Multiple', + '/label', + ['input' => ['type' => 'hidden', 'name' => 'Contact[multiple]', 'disabled' => 'disabled', 'value' => '', 'id' => 'contact-multiple']], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'contact-multiple-1']], + ['input' => ['type' => 'checkbox', 'name' => 'Contact[multiple][]', 'value' => 1, 'disabled' => 'disabled', 'id' => 'contact-multiple-1']], + 'One', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'contact-multiple-2']], + ['input' => ['type' => 'checkbox', 'name' => 'Contact[multiple][]', 'value' => 2, 'disabled' => 'disabled', 'id' => 'contact-multiple-2']], + 'Two', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'contact-multiple-3']], + ['input' => ['type' => 'checkbox', 'name' => 'Contact[multiple][]', 'value' => 3, 'disabled' => 'disabled', 'id' => 'contact-multiple-3']], + 'Three', + '/label', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + // make sure 50 does only disable 50, and not 50f5c0cf + $options = ['50' => 'Fifty', '50f5c0cf' => 'Stringy']; + $disabled = [50]; + + $expected = [ + ['div' => ['class' => 'input select']], + ['label' => ['for' => 'contact-multiple']], + 'Multiple', + '/label', + ['input' => ['type' => 'hidden', 'name' => 'Contact[multiple]', 'value' => '', 'id' => 'contact-multiple']], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'contact-multiple-50']], + ['input' => ['type' => 'checkbox', 'name' => 'Contact[multiple][]', 'value' => 50, 'disabled' => 'disabled', 'id' => 'contact-multiple-50']], + 'Fifty', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'contact-multiple-50f5c0cf']], + ['input' => ['type' => 'checkbox', 'name' => 'Contact[multiple][]', 'value' => '50f5c0cf', 'id' => 'contact-multiple-50f5c0cf']], + 'Stringy', + '/label', + '/div', + '/div', + ]; + $result = $this->Form->control('Contact.multiple', ['multiple' => 'checkbox', 'disabled' => $disabled, 'options' => $options]); + $this->assertHtml($expected, $result); + } + + /** + * testControlWithLeadingInteger method + * + * Test input name with leading integer, ensure attributes are generated correctly. + */ + public function testControlWithLeadingInteger(): void + { + $result = $this->Form->text('0.Node.title'); + $expected = [ + 'input' => ['name' => '0[Node][title]', 'type' => 'text'], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlSelectType method + * + * Test form->control() with select type inputs. + */ + public function testControlSelectType(): void + { + $result = $this->Form->control( + 'email', + [ + 'options' => ['è' => 'Firést', 'é' => 'Secoènd'], 'empty' => true], + ); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'email'], + 'Email', + '/label', + ['select' => ['name' => 'email', 'id' => 'email']], + ['option' => ['value' => '']], + '/option', + ['option' => ['value' => 'è']], + 'Firést', + '/option', + ['option' => ['value' => 'é']], + 'Secoènd', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control( + 'email', + [ + 'options' => ['First', 'Second'], 'empty' => true], + ); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'email'], + 'Email', + '/label', + ['select' => ['name' => 'email', 'id' => 'email']], + ['option' => ['value' => '']], + '/option', + ['option' => ['value' => '0']], + 'First', + '/option', + ['option' => ['value' => '1']], + 'Second', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('email', [ + 'type' => 'select', + 'options' => new ArrayObject(['First', 'Second']), + 'empty' => true, + ]); + $this->assertHtml($expected, $result); + + $this->View->set('users', ['value' => 'good', 'other' => 'bad']); + $this->View->setRequest( + $this->View->getRequest()->withData('Model', ['user_id' => 'value']), + ); + $this->Form->create(); + $result = $this->Form->control('Model.user_id', ['empty' => true]); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'model-user-id'], + 'User', + '/label', + 'select' => ['name' => 'Model[user_id]', 'id' => 'model-user-id'], + ['option' => ['value' => '']], + '/option', + ['option' => ['value' => 'value', 'selected' => 'selected']], + 'good', + '/option', + ['option' => ['value' => 'other']], + 'bad', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->View->set('users', ['value' => 'good', 'other' => 'bad']); + $this->View->setRequest( + $this->View->getRequest()->withData('Thing', ['user_id' => null]), + ); + $result = $this->Form->control('Thing.user_id', ['empty' => 'Some Empty']); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'thing-user-id'], + 'User', + '/label', + 'select' => ['name' => 'Thing[user_id]', 'id' => 'thing-user-id'], + ['option' => ['value' => '']], + 'Some Empty', + '/option', + ['option' => ['value' => 'value']], + 'good', + '/option', + ['option' => ['value' => 'other']], + 'bad', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + $this->View->set('users', ['value' => 'good', 'other' => 'bad']); + $this->View->setRequest( + $this->View->getRequest()->withData('Thing', ['user_id' => 'value']), + ); + $this->Form->create(); + $result = $this->Form->control('Thing.user_id', ['empty' => 'Some Empty']); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'thing-user-id'], + 'User', + '/label', + 'select' => ['name' => 'Thing[user_id]', 'id' => 'thing-user-id'], + ['option' => ['value' => '']], + 'Some Empty', + '/option', + ['option' => ['value' => 'value', 'selected' => 'selected']], + 'good', + '/option', + ['option' => ['value' => 'other']], + 'bad', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Publisher.id', [ + 'label' => 'Publisher', + 'type' => 'select', + 'multiple' => 'checkbox', + 'options' => ['Value 1' => 'Label 1', 'Value 2' => 'Label 2'], + ]); + $expected = [ + ['div' => ['class' => 'input select']], + ['label' => ['for' => 'publisher-id']], + 'Publisher', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'Publisher[id]', 'value' => '', 'id' => 'publisher-id'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'publisher-id-value-1']], + ['input' => ['type' => 'checkbox', 'name' => 'Publisher[id][]', 'value' => 'Value 1', 'id' => 'publisher-id-value-1']], + 'Label 1', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'publisher-id-value-2']], + ['input' => ['type' => 'checkbox', 'name' => 'Publisher[id][]', 'value' => 'Value 2', 'id' => 'publisher-id-value-2']], + 'Label 2', + '/label', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $articlesTable = $this->getTableLocator()->get('Articles'); + $articlesTable->getSchema()->setColumnType( + 'published', + EnumType::from(ArticleStatus::class), + ); + $this->Form->create($articlesTable->newEmptyEntity()); + $result = $this->Form->control('published'); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'published'], + 'Published', + '/label', + 'select' => ['name' => 'published', 'id' => 'published'], + ['option' => ['value' => 'Y']], + 'Published', + '/option', + ['option' => ['value' => 'N', 'selected' => 'selected']], + 'Unpublished', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + $articlesTable->getSchema()->setColumnType( + 'published', + EnumType::from(ArticleStatusLabelInterface::class), + ); + + $this->Form->create($articlesTable->newEmptyEntity()); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'published'], + 'Published', + '/label', + 'select' => ['name' => 'published', 'id' => 'published'], + ['option' => ['value' => 'Y']], + 'Is Published', + '/option', + ['option' => ['value' => 'N', 'selected' => 'selected']], + 'Is Unpublished', + '/option', + '/select', + '/div', + ]; + $result = $this->Form->control('published'); + $this->assertHtml($expected, $result); + + $articlePriosTable = $this->getTableLocator()->get('ArticlePrios'); + $articlePriosTable->getSchema()->setColumnType( + 'priority', + EnumType::from(Priority::class), + ); + + $this->Form->create($articlePriosTable->newEmptyEntity()); + // Select empty by default + $result = $this->Form->control('priority', ['empty' => ['' => ' - please select - '], 'default' => '']); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'priority'], + 'Priority', + '/label', + 'select' => ['name' => 'priority', 'id' => 'priority'], + ['option' => ['value' => '', 'selected' => 'selected']], + ' - please select - ', + '/option', + ['option' => ['value' => '1']], + 'Is Low', + '/option', + ['option' => ['value' => '2']], + 'Is Medium', + '/option', + ['option' => ['value' => '3']], + 'Is High', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * @deprecated + */ + public function testEnumOptionsDeprecationMessage(): void + { + $this->deprecated(function (): void { + $articlesTable = $this->getTableLocator()->get('Articles'); + $articlesTable->getSchema()->setColumnType( + 'published', + EnumType::from(ArticleStatusLabel::class), + ); + $this->Form->create($articlesTable->newEmptyEntity()); + $result = $this->Form->control('published'); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'published'], + 'Published', + '/label', + 'select' => ['name' => 'published', 'id' => 'published'], + ['option' => ['value' => 'Y']], + 'Is Published', + '/option', + ['option' => ['value' => 'N', 'selected' => 'selected']], + 'Is Unpublished', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + }); + } + + /** + * testControlWithNonStandardPrimaryKeyMakesHidden method + * + * Test that control() and a non standard primary key makes a hidden input by default. + */ + public function testControlWithNonStandardPrimaryKeyMakesHidden(): void + { + $this->article['schema']['_constraints']['primary']['columns'] = ['title']; + $this->Form->create($this->article); + $result = $this->Form->control('title'); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'title', 'id' => 'title'], + ]; + $this->assertHtml($expected, $result); + + $this->article['schema']['_constraints']['primary']['columns'] = ['title', 'body']; + $this->Form->create($this->article); + $result = $this->Form->control('title'); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'title', 'id' => 'title'], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('body'); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'body', 'id' => 'body'], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlOverridingMagicSelectType method + * + * Test that overriding the magic select type widget is possible. + */ + public function testControlOverridingMagicSelectType(): void + { + $this->View->set('users', ['value' => 'good', 'other' => 'bad']); + $result = $this->Form->control('Model.user_id', ['type' => 'text']); + $expected = [ + 'div' => ['class' => 'input text'], + 'label' => ['for' => 'model-user-id'], 'User', '/label', + 'input' => ['name' => 'Model[user_id]', 'type' => 'text', 'id' => 'model-user-id'], + '/div', + ]; + $this->assertHtml($expected, $result); + + //Check that magic types still work for plural/singular vars + $this->View->set('types', ['value' => 'good', 'other' => 'bad']); + $result = $this->Form->control('Model.type'); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'model-type'], 'Type', '/label', + 'select' => ['name' => 'Model[type]', 'id' => 'model-type'], + ['option' => ['value' => 'value']], 'good', '/option', + ['option' => ['value' => 'other']], 'bad', '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlMagicTypeDoesNotOverride method + * + * Test that inferred types do not override developer input. + */ + public function testControlMagicTypeDoesNotOverride(): void + { + $this->View->set('users', ['value' => 'good', 'other' => 'bad']); + $result = $this->Form->control('Model.user', ['type' => 'checkbox']); + $expected = [ + 'div' => ['class' => 'input checkbox'], + ['input' => [ + 'type' => 'hidden', + 'name' => 'Model[user]', + 'value' => 0, + ]], + 'label' => ['for' => 'model-user'], + ['input' => [ + 'name' => 'Model[user]', + 'type' => 'checkbox', + 'id' => 'model-user', + 'value' => 1, + ]], + 'User', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + // make sure that for HABTM the multiple option is not being overwritten in case it's truly + $options = [ + 1 => 'blue', + 2 => 'red', + ]; + $result = $this->Form->control('tags._ids', ['options' => $options, 'multiple' => 'checkbox']); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'tags-ids'], + 'Tags', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'tags[_ids]', 'value' => '', 'id' => 'tags-ids'], + + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'tags-ids-1']], + ['input' => [ + 'id' => 'tags-ids-1', 'type' => 'checkbox', + 'value' => '1', 'name' => 'tags[_ids][]', + ]], + 'blue', + '/label', + '/div', + + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'tags-ids-2']], + ['input' => [ + 'id' => 'tags-ids-2', 'type' => 'checkbox', + 'value' => '2', 'name' => 'tags[_ids][]', + ]], + 'red', + '/label', + '/div', + + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlMagicSelectForTypeNumber method + * + * Test that magic control() selects are created for type=number. + */ + public function testControlMagicSelectForTypeNumber(): void + { + $this->getTableLocator()->get('ValidateUsers', [ + 'className' => ValidateUsersTable::class, + ]); + $entity = new Entity(['balance' => 1]); + $this->Form->create($entity, ['context' => ['table' => 'ValidateUsers']]); + $this->View->set('balances', [0 => 'nothing', 1 => 'some', 100 => 'a lot']); + $result = $this->Form->control('balance'); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'balance'], + 'Balance', + '/label', + 'select' => ['name' => 'balance', 'id' => 'balance'], + ['option' => ['value' => '0']], + 'nothing', + '/option', + ['option' => ['value' => '1', 'selected' => 'selected']], + 'some', + '/option', + ['option' => ['value' => '100']], + 'a lot', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testInvalidControlTypeOption method + * + * Test invalid 'input' type option to control() function. + */ + public function testInvalidControlTypeOption(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid type `input` used for field `text`.'); + $this->Form->control('text', ['type' => 'input']); + } + + /** + * testControlMagicSelectChangeToRadio method + * + * Test that magic control() selects can easily be converted into radio types without error. + */ + public function testControlMagicSelectChangeToRadio(): void + { + $this->View->set('users', ['value' => 'good', 'other' => 'bad']); + $result = $this->Form->control('Model.user_id', ['type' => 'radio']); + $this->assertStringContainsString('input type="radio"', $result); + } + + /** + * testFormControlSubmit method + * + * Test correct results for form::control() and type submit. + */ + public function testFormControlSubmit(): void + { + $result = $this->Form->control('Test Submit', ['type' => 'submit', 'class' => 'foobar']); + $expected = [ + 'div' => ['class' => 'submit'], + 'input' => ['type' => 'submit', 'class' => 'foobar', 'id' => 'test-submit', 'value' => 'Test Submit'], + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testFormControls method + * + * Test correct results from Form::controls(). + */ + public function testFormControlsLegendFieldset(): void + { + $this->Form->create($this->article); + $result = $this->Form->allControls([], ['legend' => 'The Legend']); + $expected = [ + 'assertHtml($expected, $result); + + $result = $this->Form->allControls([], ['fieldset' => true, 'legend' => 'Field of Dreams']); + $this->assertStringContainsString('Field of Dreams', $result); + $this->assertStringContainsString('
    ', $result); + + $result = $this->Form->allControls([], ['fieldset' => false, 'legend' => false]); + $this->assertStringNotContainsString('', $result); + $this->assertStringNotContainsString('
    ', $result); + + $result = $this->Form->allControls([], ['fieldset' => false, 'legend' => 'Hello']); + $this->assertStringNotContainsString('', $result); + $this->assertStringNotContainsString('
    ', $result); + + $this->Form->create($this->article); + $this->View->setRequest($this->View->getRequest() + ->withParam('prefix', 'admin') + ->withParam('action', 'admin_edit') + ->withParam('controller', 'articles')); + $result = $this->Form->allControls(); + $expected = [ + 'assertHtml($expected, $result); + + $this->Form->create($this->article); + $result = $this->Form->allControls([], ['fieldset' => [], 'legend' => 'The Legend']); + $expected = [ + 'assertHtml($expected, $result); + + $this->Form->create($this->article); + $result = $this->Form->allControls([], [ + 'fieldset' => [ + 'class' => 'some-class some-other-class', + 'disabled' => true, + 'data-param' => 'a-param', + ], + 'legend' => 'The Legend', + ]); + $expected = [ + '
    assertHtml($expected, $result); + } + + /** + * testFormControls method + * + * Test the controls() method. + */ + public function testFormControls(): void + { + $this->Form->create($this->article); + $result = $this->Form->allControls(); + $expected = [ + ' ['type' => 'hidden', 'name' => 'id', 'id' => 'id'], + ['div' => ['class' => 'input select required']], + '*/div', + ['div' => ['class' => 'input text required']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + '/fieldset', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->allControls([ + 'published' => ['type' => 'boolean'], + ]); + $expected = [ + ' ['type' => 'hidden', 'name' => 'id', 'id' => 'id'], + ['div' => ['class' => 'input select required']], + '*/div', + ['div' => ['class' => 'input text required']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + ['div' => ['class' => 'input boolean']], + '*/div', + '/fieldset', + ]; + $this->assertHtml($expected, $result); + + $this->Form->create($this->article); + $result = $this->Form->allControls([], ['legend' => 'Hello']); + $expected = [ + 'fieldset' => [], + 'legend' => [], + 'Hello', + '/legend', + 'input' => ['type' => 'hidden', 'name' => 'id', 'id' => 'id'], + ['div' => ['class' => 'input select required']], + '*/div', + ['div' => ['class' => 'input text required']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + '/fieldset', + ]; + $this->assertHtml($expected, $result); + + $this->Form->create(); + $expected = [ + 'fieldset' => [], + ['div' => ['class' => 'input text']], + 'label' => ['for' => 'foo'], + 'Foo', + '/label', + 'input' => ['type' => 'text', 'name' => 'foo', 'id' => 'foo'], + '*/div', + '/fieldset', + ]; + $result = $this->Form->allControls( + ['foo' => ['type' => 'text']], + ['legend' => false], + ); + $this->assertHtml($expected, $result); + } + + /** + * testFormControlsBlacklist method + */ + public function testFormControlsBlacklist(): void + { + $this->Form->create($this->article); + $result = $this->Form->allControls([ + 'id' => false, + ]); + $expected = [ + ' ['class' => 'input select required']], + '*/div', + ['div' => ['class' => 'input text required']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + '/fieldset', + ]; + $this->assertHtml($expected, $result); + + $this->Form->create($this->article); + $result = $this->Form->allControls([ + 'id' => [], + ]); + $expected = [ + ' ['type' => 'hidden', 'name' => 'id', 'id' => 'id'], + ['div' => ['class' => 'input select required']], + '*/div', + ['div' => ['class' => 'input text required']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + ['div' => ['class' => 'input text']], + '*/div', + '/fieldset', + ]; + $this->assertHtml($expected, $result, true); + } + + /** + * testSelectAsCheckbox method + * + * Test multi-select widget with checkbox formatting. + */ + public function testSelectAsCheckbox(): void + { + $result = $this->Form->select( + 'Model.multi_field', + ['first', 'second', 'third'], + ['multiple' => 'checkbox', 'value' => [0, 1]], + ); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'model-multi-field'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-0', 'class' => 'selected']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'checked' => 'checked', 'value' => '0', 'id' => 'model-multi-field-0']], + 'first', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-1', 'class' => 'selected']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'checked' => 'checked', 'value' => '1', 'id' => 'model-multi-field-1']], + 'second', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-2']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => '2', 'id' => 'model-multi-field-2']], + 'third', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select( + 'Model.multi_field', + ['1/2' => 'half'], + ['multiple' => 'checkbox'], + ); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'model-multi-field'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-1-2']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => '1/2', 'id' => 'model-multi-field-1-2']], + 'half', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testLabel method + * + * Test label generation. + */ + public function testLabel(): void + { + $result = $this->Form->label('Person.name'); + $expected = ['label' => ['for' => 'person-name'], 'Name', '/label']; + $this->assertHtml($expected, $result); + + $result = $this->Form->label('Person.name'); + $expected = ['label' => ['for' => 'person-name'], 'Name', '/label']; + $this->assertHtml($expected, $result); + + $result = $this->Form->label('Person.first_name'); + $expected = ['label' => ['for' => 'person-first-name'], 'First Name', '/label']; + $this->assertHtml($expected, $result); + + $result = $this->Form->label('Person.first_name', 'Your first name'); + $expected = ['label' => ['for' => 'person-first-name'], 'Your first name', '/label']; + $this->assertHtml($expected, $result); + + $result = $this->Form->label('Person.first_name', 'Your first name', ['class' => 'my-class']); + $expected = ['label' => ['for' => 'person-first-name', 'class' => 'my-class'], 'Your first name', '/label']; + $this->assertHtml($expected, $result); + + $result = $this->Form->label('Person.first_name', 'Your first name', ['class' => 'my-class', 'id' => 'LabelID']); + $expected = ['label' => ['for' => 'person-first-name', 'class' => 'my-class', 'id' => 'LabelID'], 'Your first name', '/label']; + $this->assertHtml($expected, $result); + + $result = $this->Form->label('Person.first_name', ''); + $expected = ['label' => ['for' => 'person-first-name'], '/label']; + $this->assertHtml($expected, $result); + + $result = $this->Form->label('Person.2.name', ''); + $expected = ['label' => ['for' => 'person-2-name'], '/label']; + $this->assertHtml($expected, $result); + } + + /** + * testLabelContainControl method + * + * Test that label() can accept an input with the correct template vars. + */ + public function testLabelContainControl(): void + { + $this->Form->setTemplates([ + 'label' => '{{input}}{{text}}', + ]); + $result = $this->Form->label('Person.accept_terms', 'Accept', [ + 'input' => '', + ]); + $expected = [ + 'label' => ['for' => 'person-accept-terms'], + 'input' => ['type' => 'checkbox', 'name' => 'accept_tos'], + 'Accept', + '/label', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testTextbox method + * + * Test textbox element generation. + */ + public function testTextbox(): void + { + $result = $this->Form->text('Model.field'); + $expected = ['input' => ['type' => 'text', 'name' => 'Model[field]']]; + $this->assertHtml($expected, $result); + + $result = $this->Form->text('Model.field', ['type' => 'password']); + $expected = ['input' => ['type' => 'password', 'name' => 'Model[field]']]; + $this->assertHtml($expected, $result); + + $result = $this->Form->text('Model.field', ['id' => 'theID']); + $expected = ['input' => ['type' => 'text', 'name' => 'Model[field]', 'id' => 'theID']]; + $this->assertHtml($expected, $result); + } + + /** + * testTextBoxDataAndError method + * + * Test that text() hooks up with request data and error fields. + */ + public function testTextBoxDataAndError(): void + { + $this->article['errors'] = [ + 'Contact' => ['text' => 'wrong'], + ]; + $this->View->setRequest($this->View->getRequest() + ->withData('Model.text', 'test HTML values') + ->withData('Contact.text', 'test')); + $this->Form->create($this->article); + + $result = $this->Form->text('Model.text'); + $expected = [ + 'input' => [ + 'type' => 'text', + 'name' => 'Model[text]', + 'value' => 'test <strong>HTML</strong> values', + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->text('Contact.text', ['id' => 'theID']); + $expected = [ + 'input' => [ + 'type' => 'text', + 'name' => 'Contact[text]', + 'value' => 'test', + 'id' => 'theID', + 'class' => 'form-error', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testDefaultValue method + * + * Test default value setting. + */ + public function testTextDefaultValue(): void + { + $this->View->setRequest($this->View->getRequest()->withData('Model.field', 'test')); + $result = $this->Form->text('Model.field', ['default' => 'default value']); + $expected = ['input' => ['type' => 'text', 'name' => 'Model[field]', 'value' => 'test']]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withParsedBody([])); + $this->Form->create(); + $result = $this->Form->text('Model.field', ['default' => 'default value']); + $expected = ['input' => ['type' => 'text', 'name' => 'Model[field]', 'value' => 'default value']]; + $this->assertHtml($expected, $result); + + $Articles = $this->getTableLocator()->get('Articles'); + $title = $Articles->getSchema()->getColumn('title'); + $Articles->getSchema()->addColumn( + 'title', + ['default' => 'default title', 'length' => 255] + $title, + ); + + $entity = $Articles->newEmptyEntity(); + $this->Form->create($entity); + + // Get default value from schema + $result = $this->Form->text('title'); + $expected = ['input' => ['type' => 'text', 'name' => 'title', 'value' => 'default title', 'maxlength' => '255']]; + $this->assertHtml($expected, $result); + + // Don't get value from schema + $result = $this->Form->text('title', ['schemaDefault' => false]); + $expected = ['input' => ['type' => 'text', 'name' => 'title', 'maxlength' => '255']]; + $this->assertHtml($expected, $result); + + // Custom default value overrides default value from schema + $result = $this->Form->text('title', ['default' => 'override default']); + $expected = ['input' => ['type' => 'text', 'name' => 'title', 'value' => 'override default', 'maxlength' => '255']]; + $this->assertHtml($expected, $result); + + // Default value from schema is used only for new entities. + $entity->setNew(false); + $result = $this->Form->text('title'); + $expected = ['input' => ['type' => 'text', 'name' => 'title', 'maxlength' => '255']]; + $this->assertHtml($expected, $result); + } + + /** + * testError method + * + * Test field error generation. + */ + public function testError(): void + { + $this->article['errors'] = [ + 'Article' => ['field' => 'email'], + ]; + $this->Form->create($this->article); + + $result = $this->Form->error('Article.field'); + $expected = [ + ['div' => ['class' => 'error-message', 'id' => 'article-field-error']], + 'email', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->error('Article.field', 'Badness!'); + $expected = [ + ['div' => ['class' => 'error-message', 'id' => 'article-field-error']], + '<strong>Badness!</strong>', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->error('Article.field', 'Badness!', ['escape' => false]); + $expected = [ + ['div' => ['class' => 'error-message', 'id' => 'article-field-error']], + 'assertHtml($expected, $result); + } + + /** + * testErrorRuleName method + * + * Test error translation can use rule names for translating. + */ + public function testErrorRuleName(): void + { + $this->article['errors'] = [ + 'Article' => [ + 'field' => ['email' => 'Your email was not good'], + ], + ]; + $this->Form->create($this->article); + + $result = $this->Form->error('Article.field'); + $expected = [ + ['div' => ['class' => 'error-message', 'id' => 'article-field-error']], + 'Your email was not good', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->error('Article.field', ['email' => 'Email in use']); + $expected = [ + ['div' => ['class' => 'error-message', 'id' => 'article-field-error']], + 'Email in use', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->error('Article.field', ['Your email was not good' => 'Email in use']); + $expected = [ + ['div' => ['class' => 'error-message', 'id' => 'article-field-error']], + 'Email in use', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->error('Article.field', [ + 'email' => 'Key is preferred', + 'Your email was not good' => 'Email in use', + ]); + $expected = [ + ['div' => ['class' => 'error-message', 'id' => 'article-field-error']], + 'Key is preferred', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testErrorMessages method + * + * Test error with nested lists. + */ + public function testErrorMessages(): void + { + $this->article['errors'] = [ + 'Article' => ['field' => 'email'], + ]; + $this->Form->create($this->article); + + $result = $this->Form->error('Article.field', [ + 'email' => 'No good!', + ]); + $expected = [ + 'div' => ['class' => 'error-message', 'id' => 'article-field-error'], + 'No good!', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testErrorMultipleMessages method + * + * Test error() with multiple messages. + */ + public function testErrorMultipleMessages(): void + { + $this->article['errors'] = [ + 'field' => ['notBlank', 'email', 'Something else'], + ]; + $this->Form->create($this->article); + + $result = $this->Form->error('field', [ + 'notBlank' => 'Cannot be empty', + 'email' => 'No good!', + ]); + $expected = [ + 'div' => ['class' => 'error-message', 'id' => 'field-error'], + 'ul' => [], + 'assertHtml($expected, $result); + } + + /** + * testPassword method + * + * Test password element generation. + */ + public function testPassword(): void + { + $this->article['errors'] = [ + 'Contact' => [ + 'passwd' => 1, + ], + ]; + $this->Form->create($this->article); + + $result = $this->Form->password('Contact.field'); + $expected = ['input' => ['type' => 'password', 'name' => 'Contact[field]']]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withData('Contact.passwd', 'test')); + $this->Form->create($this->article); + $result = $this->Form->password('Contact.passwd', ['id' => 'theID']); + $expected = ['input' => ['type' => 'password', 'name' => 'Contact[passwd]', 'value' => 'test', 'id' => 'theID', 'class' => 'form-error']]; + $this->assertHtml($expected, $result); + } + + /** + * testRadio method + * + * Test radio element set generation. + */ + public function testRadio(): void + { + $result = $this->Form->radio('Model.field', ['option A', 'option B']); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '', 'id' => 'model-field'], + ['label' => ['for' => 'model-field-0']], + ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => '0', 'id' => 'model-field-0']], + 'option A', + '/label', + ['label' => ['for' => 'model-field-1']], + ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => '1', 'id' => 'model-field-1']], + 'option B', + '/label', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->radio('Model.field', new Collection(['option A', 'option B'])); + $this->assertHtml($expected, $result); + + $result = $this->Form->radio( + 'Employee.vegetarian', + ['yes' => 'Yes', 'no' => 'No'], + ['form' => 'my-form', 'id' => 'id-veg'], + ); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Employee[vegetarian]', 'value' => '', 'form' => 'my-form', 'id' => 'id-veg'], + ['label' => ['for' => 'id-veg-yes']], + ['input' => ['type' => 'radio', 'name' => 'Employee[vegetarian]', 'value' => 'yes', 'id' => 'id-veg-yes', 'form' => 'my-form']], + 'Yes', + '/label', + ['label' => ['for' => 'id-veg-no']], + ['input' => ['type' => 'radio', 'name' => 'Employee[vegetarian]', 'value' => 'no', 'id' => 'id-veg-no', 'form' => 'my-form']], + 'No', + '/label', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->radio('Model.field', ['option A', 'option B'], ['name' => 'Model[custom]']); + $expected = [ + ['input' => ['type' => 'hidden', 'name' => 'Model[custom]', 'value' => '', 'id' => 'model-field']], + ['label' => ['for' => 'model-custom-0']], + ['input' => ['type' => 'radio', 'name' => 'Model[custom]', 'value' => '0', 'id' => 'model-custom-0']], + 'option A', + '/label', + ['label' => ['for' => 'model-custom-1']], + ['input' => ['type' => 'radio', 'name' => 'Model[custom]', 'value' => '1', 'id' => 'model-custom-1']], + 'option B', + '/label', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->radio( + 'Employee.gender', + [ + ['value' => 'male', 'text' => 'Male', 'style' => 'width:20px'], + ['value' => 'female', 'text' => 'Female', 'style' => 'width:20px'], + ], + ); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Employee[gender]', 'value' => '', 'id' => 'employee-gender'], + ['label' => ['for' => 'employee-gender-male']], + ['input' => ['type' => 'radio', 'name' => 'Employee[gender]', 'value' => 'male', + 'id' => 'employee-gender-male', 'style' => 'width:20px']], + 'Male', + '/label', + ['label' => ['for' => 'employee-gender-female']], + ['input' => ['type' => 'radio', 'name' => 'Employee[gender]', 'value' => 'female', + 'id' => 'employee-gender-female', 'style' => 'width:20px']], + 'Female', + '/label', + ]; + $this->assertHtml($expected, $result); + + $article = new Article([ + 'status' => ArticleStatus::Unpublished, + ]); + $this->Form->create($article); + $result = $this->Form->radio('status', ['Y' => 'Published', 'N' => 'Unpublished']); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'status', 'value' => '', 'id' => 'status'], + ['label' => ['for' => 'status-y']], + ['input' => ['type' => 'radio', 'name' => 'status', 'value' => 'Y', 'id' => 'status-y']], + 'Published', + '/label', + ['label' => ['for' => 'status-n']], + ['input' => ['type' => 'radio', 'name' => 'status', 'value' => 'N', 'id' => 'status-n', 'checked' => 'checked']], + 'Unpublished', + '/label', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test radio with complex options and empty disabled data. + */ + public function testRadioComplexDisabled(): void + { + $options = [ + ['value' => 'r', 'text' => 'red'], + ['value' => 'b', 'text' => 'blue'], + ]; + $attrs = ['disabled' => []]; + $result = $this->Form->radio('Model.field', $options, $attrs); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '', 'id' => 'model-field'], + ['label' => ['for' => 'model-field-r']], + ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => 'r', 'id' => 'model-field-r']], + 'red', + '/label', + ['label' => ['for' => 'model-field-b']], + ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => 'b', 'id' => 'model-field-b']], + 'blue', + '/label', + ]; + $this->assertHtml($expected, $result); + + $attrs = ['disabled' => ['r']]; + $result = $this->Form->radio('Model.field', $options, $attrs); + $this->assertStringContainsString('disabled="disabled"', $result); + } + + /** + * testRadioDefaultValue method + * + * Test default value setting on radio() method. + */ + public function testRadioDefaultValue(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $title = $Articles->getSchema()->getColumn('title'); + $Articles->getSchema()->addColumn( + 'title', + ['default' => '1'] + $title, + ); + + $this->Form->create($Articles->newEmptyEntity()); + + $result = $this->Form->radio('title', ['option A', 'option B']); + $expected = [ + ['input' => ['type' => 'hidden', 'name' => 'title', 'value' => '', 'id' => 'title']], + ['label' => ['for' => 'title-0']], + ['input' => ['type' => 'radio', 'name' => 'title', 'value' => '0', 'id' => 'title-0']], + 'option A', + '/label', + ['label' => ['for' => 'title-1']], + ['input' => ['type' => 'radio', 'name' => 'title', 'value' => '1', 'id' => 'title-1', 'checked' => 'checked']], + 'option B', + '/label', + ]; + $this->assertHtml($expected, $result); + } + + /** + * Test setting a hiddenField value on radio buttons. + */ + public function testRadioHiddenFieldValue(): void + { + $result = $this->Form->radio('title', ['option A'], ['hiddenField' => 'N']); + $expected = [ + ['input' => ['type' => 'hidden', 'name' => 'title', 'value' => 'N', 'id' => 'title']], + 'label' => ['for' => 'title-0'], + ['input' => ['type' => 'radio', 'name' => 'title', 'value' => '0', 'id' => 'title-0']], + 'option A', + '/label', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->radio('title', ['option A'], ['hiddenField' => '']); + $expected = [ + ['input' => ['type' => 'hidden', 'name' => 'title', 'value' => '', 'id' => 'title']], + 'label' => ['for' => 'title-0'], + ['input' => ['type' => 'radio', 'name' => 'title', 'value' => '0', 'id' => 'title-0']], + 'option A', + '/label', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testControlRadio method + * + * Test that input works with radio types. + */ + public function testControlRadio(): void + { + $result = $this->Form->control('test', [ + 'type' => 'radio', + 'options' => ['A', 'B'], + ]); + $expected = [ + ['div' => ['class' => 'input radio']], + ' ['type' => 'hidden', 'name' => 'test', 'value' => '', 'id' => 'test']], + ['label' => ['for' => 'test-0']], + ['input' => ['type' => 'radio', 'name' => 'test', 'value' => '0', 'id' => 'test-0']], + 'A', + '/label', + ['label' => ['for' => 'test-1']], + ['input' => ['type' => 'radio', 'name' => 'test', 'value' => '1', 'id' => 'test-1']], + 'B', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('test', [ + 'type' => 'radio', + 'options' => ['A', 'B'], + 'value' => '0', + ]); + $expected = [ + ['div' => ['class' => 'input radio']], + ' ['type' => 'hidden', 'name' => 'test', 'value' => '', 'id' => 'test']], + ['label' => ['for' => 'test-0']], + ['input' => ['type' => 'radio', 'checked' => 'checked', 'name' => 'test', 'value' => '0', 'id' => 'test-0']], + 'A', + '/label', + ['label' => ['for' => 'test-1']], + ['input' => ['type' => 'radio', 'name' => 'test', 'value' => '1', 'id' => 'test-1']], + 'B', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('test', [ + 'type' => 'radio', + 'options' => ['A', 'B'], + 'label' => false, + ]); + $expected = [ + ['div' => ['class' => 'input radio']], + ['input' => ['type' => 'hidden', 'name' => 'test', 'value' => '', 'id' => 'test']], + ['label' => ['for' => 'test-0']], + ['input' => ['type' => 'radio', 'name' => 'test', 'value' => '0', 'id' => 'test-0']], + 'A', + '/label', + ['label' => ['for' => 'test-1']], + ['input' => ['type' => 'radio', 'name' => 'test', 'value' => '1', 'id' => 'test-1']], + 'B', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('accept', [ + 'type' => 'radio', + 'options' => [ + 1 => 'positive', + -1 => 'negative', + ], + 'label' => false, + ]); + $expected = [ + ['div' => ['class' => 'input radio']], + ['input' => ['type' => 'hidden', 'name' => 'accept', 'value' => '', 'id' => 'accept']], + ['label' => ['for' => 'accept-1']], + ['input' => [ + 'type' => 'radio', + 'name' => 'accept', + 'value' => '1', + 'id' => 'accept-1', + ]], + 'positive', + '/label', + ['label' => ['for' => 'accept--1']], + ['input' => [ + 'type' => 'radio', + 'name' => 'accept', + 'value' => '-1', + 'id' => 'accept--1', + ]], + 'negative', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $articlesTable = $this->getTableLocator()->get('Articles'); + $articlesTable->getSchema()->setColumnType( + 'published', + EnumType::from(ArticleStatus::class), + ); + $this->Form->create($articlesTable->newEmptyEntity()); + $result = $this->Form->control('published', [ + 'type' => 'radio', + 'label' => false, + ]); + $expected = [ + ['div' => ['class' => 'input radio']], + 'input' => ['type' => 'hidden', 'name' => 'published', 'value' => '', 'id' => 'published'], + ['label' => ['for' => 'published-y']], + ['input' => ['type' => 'radio', 'name' => 'published', 'value' => 'Y', 'id' => 'published-y']], + 'Published', + '/label', + ['label' => ['for' => 'published-n']], + ['input' => ['type' => 'radio', 'name' => 'published', 'value' => 'N', 'id' => 'published-n', 'checked' => 'checked']], + 'Unpublished', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testRadioNoLabel method + * + * Test that radio() works with label = false. + */ + public function testRadioNoLabel(): void + { + $result = $this->Form->radio('Model.field', ['A', 'B'], ['label' => false]); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '', 'id' => 'model-field'], + ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => '0', 'id' => 'model-field-0']], + ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => '1', 'id' => 'model-field-1']], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testRadioControlInsideLabel method + * + * Test generating radio input inside label ala twitter bootstrap. + */ + public function testRadioControlInsideLabel(): void + { + $this->Form->setTemplates([ + 'label' => '{{input}}{{text}}', + 'radioWrapper' => '{{label}}', + ]); + + $result = $this->Form->radio('Model.field', ['option A', 'option B']); + // phpcs:disable + $expected = [ + ['input' => [ + 'type' => 'hidden', + 'name' => 'Model[field]', + 'value' => '', + 'id' => 'model-field' + ]], + ['label' => ['for' => 'model-field-0']], + ['input' => [ + 'type' => 'radio', + 'name' => 'Model[field]', + 'value' => '0', + 'id' => 'model-field-0' + ]], + 'option A', + '/label', + ['label' => ['for' => 'model-field-1']], + ['input' => [ + 'type' => 'radio', + 'name' => 'Model[field]', + 'value' => '1', + 'id' => 'model-field-1' + ]], + 'option B', + '/label', + ]; + // phpcs:enable + $this->assertHtml($expected, $result); + } + + /** + * testRadioHiddenControlDisabling method + * + * Test disabling the hidden input for radio buttons. + */ + public function testRadioHiddenControlDisabling(): void + { + $result = $this->Form->radio('Model.1.field', ['option A'], ['hiddenField' => false]); + $expected = [ + 'label' => ['for' => 'model-1-field-0'], + 'input' => ['type' => 'radio', 'name' => 'Model[1][field]', 'value' => '0', 'id' => 'model-1-field-0'], + 'option A', + '/label', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testRadioOutOfRange method + * + * Test radio element set generation. + */ + public function testRadioOutOfRange(): void + { + $result = $this->Form->radio('Model.field', ['v' => 'value'], ['value' => 'nope']); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '', 'id' => 'model-field'], + 'label' => ['for' => 'model-field-v'], + ['input' => ['type' => 'radio', 'name' => 'Model[field]', 'value' => 'v', 'id' => 'model-field-v']], + 'value', + '/label', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelect method + * + * Test select element generation. + */ + public function testSelect(): void + { + $result = $this->Form->select('Model.field', []); + $expected = [ + 'select' => ['name' => 'Model[field]'], + '/select', + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withData('Model', ['field' => 'value'])); + $this->Form->create(); + $result = $this->Form->select('Model.field', ['value' => 'good', 'other' => 'bad']); + $expected = [ + 'select' => ['name' => 'Model[field]'], + ['option' => ['value' => 'value', 'selected' => 'selected']], + 'good', + '/option', + ['option' => ['value' => 'other']], + 'bad', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select('Model.field', new Collection(['value' => 'good', 'other' => 'bad'])); + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withParsedBody([])); + $this->Form->create(); + $result = $this->Form->select('Model.field', ['value' => 'good', 'other' => 'bad']); + $expected = [ + 'select' => ['name' => 'Model[field]'], + ['option' => ['value' => 'value']], + 'good', + '/option', + ['option' => ['value' => 'other']], + 'bad', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $options = [ + ['value' => 'first', 'text' => 'First'], + ['value' => 'first', 'text' => 'Another First'], + ]; + $result = $this->Form->select( + 'Model.field', + $options, + ['escape' => false, 'empty' => false], + ); + $expected = [ + 'select' => ['name' => 'Model[field]'], + ['option' => ['value' => 'first']], + 'First', + '/option', + ['option' => ['value' => 'first']], + 'Another First', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withParsedBody(['Model' => ['contact_id' => 228]])); + $this->Form->create(); + $result = $this->Form->select( + 'Model.contact_id', + ['228' => '228 value', '228-1' => '228-1 value', '228-2' => '228-2 value'], + ['escape' => false, 'empty' => 'pick something'], + ); + + $expected = [ + 'select' => ['name' => 'Model[contact_id]'], + ['option' => ['value' => '']], 'pick something', '/option', + ['option' => ['value' => '228', 'selected' => 'selected']], '228 value', '/option', + ['option' => ['value' => '228-1']], '228-1 value', '/option', + ['option' => ['value' => '228-2']], '228-2 value', '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withData('Model.field', 0)); + $this->Form->create(); + $result = $this->Form->select('Model.field', ['0' => 'No', '1' => 'Yes']); + $expected = [ + 'select' => ['name' => 'Model[field]'], + ['option' => ['value' => '0', 'selected' => 'selected']], 'No', '/option', + ['option' => ['value' => '1']], 'Yes', '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $article = new Article([ + 'status' => ArticleStatus::Unpublished, + ]); + $this->Form->create($article); + $result = $this->Form->select('status', ['Y' => 'Published', 'N' => 'Unpublished']); + $expected = [ + 'select' => ['name' => 'status'], + ['option' => ['value' => 'Y']], 'Published', '/option', + ['option' => ['value' => 'N', 'selected' => 'selected']], 'Unpublished', '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelectEscapeHtml method + * + * Test that select() escapes HTML. + */ + public function testSelectEscapeHtml(): void + { + $result = $this->Form->select( + 'Model.field', + ['first' => 'first "html" ', 'second' => 'value'], + ['empty' => false], + ); + $expected = [ + 'select' => ['name' => 'Model[field]'], + ['option' => ['value' => 'first']], + 'first "html" <chars>', + '/option', + ['option' => ['value' => 'second']], + 'value', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select( + 'Model.field', + ['first' => 'first "html" ', 'second' => 'value'], + ['escape' => false, 'empty' => false], + ); + $expected = [ + 'select' => ['name' => 'Model[field]'], + ['option' => ['value' => 'first']], + 'first "html" ', + '/option', + ['option' => ['value' => 'second']], + 'value', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelectRequired method + * + * Test select() with required and disabled attributes. + */ + public function testSelectRequired(): void + { + $this->article['required'] = [ + 'user_id' => true, + ]; + $this->Form->create($this->article); + $result = $this->Form->select('user_id', ['option A']); + $expected = [ + 'select' => [ + 'name' => 'user_id', + 'required' => 'required', + ], + ['option' => ['value' => '0']], 'option A', '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select('user_id', ['option A'], ['disabled' => true]); + $expected = [ + 'select' => [ + 'name' => 'user_id', + 'disabled' => 'disabled', + ], + ['option' => ['value' => '0']], 'option A', '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + } + + public function testSelectEmptyWithRequiredFalse(): void + { + $Articles = $this->getTableLocator()->get('Articles'); + $validator = $Articles->getValidator('default'); + $validator->allowEmptyString('user_id'); + $Articles->setValidator('default', $validator); + + $entity = $Articles->newEmptyEntity(); + $this->Form->create($entity); + $result = $this->Form->select('user_id', ['option A']); + $expected = [ + 'select' => [ + 'name' => 'user_id', + ], + ['option' => ['value' => '']], '/option', + ['option' => ['value' => '0']], 'option A', '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testNestedSelect method + * + * Test select element generation with optgroups. + */ + public function testNestedSelect(): void + { + $result = $this->Form->select( + 'Model.field', + [1 => 'One', 2 => 'Two', 'Three' => [ + 3 => 'Three', 4 => 'Four', 5 => 'Five', + ]], + ['empty' => false], + ); + $expected = [ + 'select' => ['name' => 'Model[field]'], + ['option' => ['value' => 1]], + 'One', + '/option', + ['option' => ['value' => 2]], + 'Two', + '/option', + ['optgroup' => ['label' => 'Three']], + ['option' => ['value' => 3]], + 'Three', + '/option', + ['option' => ['value' => 4]], + 'Four', + '/option', + ['option' => ['value' => 5]], + 'Five', + '/option', + '/optgroup', + '/select', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelectMultiple method + * + * Test generation of multiple select elements. + */ + public function testSelectMultiple(): void + { + $options = ['first', 'second', 'third']; + $result = $this->Form->select( + 'Model.multi_field', + $options, + ['form' => 'my-form', 'multiple' => true], + ); + $expected = [ + 'input' => [ + 'type' => 'hidden', + 'name' => 'Model[multi_field]', + 'value' => '', + 'form' => 'my-form', + ], + 'select' => [ + 'name' => 'Model[multi_field][]', + 'multiple' => 'multiple', + 'form' => 'my-form', + ], + ['option' => ['value' => '0']], + 'first', + '/option', + ['option' => ['value' => '1']], + 'second', + '/option', + ['option' => ['value' => '2']], + 'third', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select( + 'Model.multi_field', + $options, + ['multiple' => 'multiple', 'form' => 'my-form'], + ); + $this->assertHtml($expected, $result); + + $result = $this->Form->select( + 'Model.multi_field', + $options, + ['form' => 'my-form', 'multiple' => false], + ); + $this->assertStringNotContainsString('multiple', $result); + } + + /** + * testCheckboxZeroValue method + * + * Test that a checkbox can have 0 for the value and 1 for the hidden input. + */ + public function testCheckboxZeroValue(): void + { + $result = $this->Form->control('User.get_spam', [ + 'type' => 'checkbox', + 'value' => '0', + 'hiddenField' => '1', + ]); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'label' => ['for' => 'user-get-spam'], + ['input' => [ + 'type' => 'hidden', 'name' => 'User[get_spam]', + 'value' => '1', + ]], + ['input' => [ + 'type' => 'checkbox', 'name' => 'User[get_spam]', + 'value' => '0', 'id' => 'user-get-spam', + ]], + 'Get Spam', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('User.get_spam', [ + 'type' => 'checkbox', + 'value' => '0', + 'hiddenField' => '', + ]); + $expected = [ + 'div' => ['class' => 'input checkbox'], + 'label' => ['for' => 'user-get-spam'], + ['input' => [ + 'type' => 'hidden', 'name' => 'User[get_spam]', + 'value' => '', + ]], + ['input' => [ + 'type' => 'checkbox', 'name' => 'User[get_spam]', + 'value' => '0', 'id' => 'user-get-spam', + ]], + 'Get Spam', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testHabtmSelectBox method + * + * Test generation of habtm select boxes. + */ + public function testHabtmSelectBox(): void + { + $options = [ + 1 => 'blue', + 2 => 'red', + 3 => 'green', + ]; + $tags = [ + new Entity(['id' => 1, 'name' => 'blue']), + new Entity(['id' => 3, 'name' => 'green']), + ]; + $article = new Article(['tags' => $tags]); + $this->Form->create($article); + $result = $this->Form->control('tags._ids', ['options' => $options]); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'tags-ids'], + 'Tags', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'tags[_ids]', 'value' => ''], + 'select' => [ + 'name' => 'tags[_ids][]', 'id' => 'tags-ids', + 'multiple' => 'multiple', + ], + ['option' => ['value' => '1', 'selected' => 'selected']], + 'blue', + '/option', + ['option' => ['value' => '2']], + 'red', + '/option', + ['option' => ['value' => '3', 'selected' => 'selected']], + 'green', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + // make sure only 50 is selected, and not 50f5c0cf + $options = [ + '1' => 'blue', + '50f5c0cf' => 'red', + '50' => 'green', + ]; + $tags = [ + new Entity(['id' => 1, 'name' => 'blue']), + new Entity(['id' => 50, 'name' => 'green']), + ]; + $article = new Article(['tags' => $tags]); + $this->Form->create($article); + $result = $this->Form->control('tags._ids', ['options' => $options]); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'tags-ids'], + 'Tags', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'tags[_ids]', 'value' => ''], + 'select' => [ + 'name' => 'tags[_ids][]', 'id' => 'tags-ids', + 'multiple' => 'multiple', + ], + ['option' => ['value' => '1', 'selected' => 'selected']], + 'blue', + '/option', + ['option' => ['value' => '50f5c0cf']], + 'red', + '/option', + ['option' => ['value' => '50', 'selected' => 'selected']], + 'green', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + + $spacecraft = [ + 1 => 'Orion', + 2 => 'Helios', + ]; + $this->View->set('spacecraft', $spacecraft); + $this->Form->create(); + $result = $this->Form->control('spacecraft._ids'); + $expected = [ + 'div' => ['class' => 'input select'], + 'label' => ['for' => 'spacecraft-ids'], + 'Spacecraft', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'spacecraft[_ids]', 'value' => ''], + 'select' => [ + 'name' => 'spacecraft[_ids][]', 'id' => 'spacecraft-ids', + 'multiple' => 'multiple', + ], + ['option' => ['value' => '1']], + 'Orion', + '/option', + ['option' => ['value' => '2']], + 'Helios', + '/option', + '/select', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testErrorsForBelongsToManySelect method + * + * Tests that errors for belongsToMany select fields are being + * picked up properly. + */ + public function testErrorsForBelongsToManySelect(): void + { + $spacecraft = [ + 1 => 'Orion', + 2 => 'Helios', + ]; + $this->View->set('spacecraft', $spacecraft); + + $article = new Article(); + $article->setError('spacecraft', ['Invalid']); + + $this->Form->create($article); + $result = $this->Form->control('spacecraft._ids'); + + $expected = [ + ['div' => ['class' => 'input select error']], + 'label' => ['for' => 'spacecraft-ids'], + 'Spacecraft', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'spacecraft[_ids]', 'value' => ''], + 'select' => [ + 'name' => 'spacecraft[_ids][]', + 'id' => 'spacecraft-ids', + 'multiple' => 'multiple', + ], + ['option' => ['value' => '1']], + 'Orion', + '/option', + ['option' => ['value' => '2']], + 'Helios', + '/option', + '/select', + ['div' => ['class' => 'error-message', 'id' => 'spacecraft-error']], + 'Invalid', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelectMultipleCheckboxes method + * + * Test generation of multi select elements in checkbox format. + */ + public function testSelectMultipleCheckboxes(): void + { + $result = $this->Form->select( + 'Model.multi_field', + ['first', 'second', 'third'], + ['multiple' => 'checkbox'], + ); + + $expected = [ + 'input' => [ + 'type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'model-multi-field', + ], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-0']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => '0', 'id' => 'model-multi-field-0', + ]], + 'first', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-1']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => '1', 'id' => 'model-multi-field-1', + ]], + 'second', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-2']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => '2', 'id' => 'model-multi-field-2', + ]], + 'third', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select( + 'Model.multi_field', + ['a+' => 'first', 'a++' => 'second', 'a+++' => 'third'], + ['multiple' => 'checkbox'], + ); + $expected = [ + 'input' => [ + 'type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'model-multi-field', + ], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-a+']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => 'a+', 'id' => 'model-multi-field-a+', + ]], + 'first', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-a++']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => 'a++', 'id' => 'model-multi-field-a++', + ]], + 'second', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-a+++']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => 'a+++', 'id' => 'model-multi-field-a+++', + ]], + 'third', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->select( + 'Model.multi_field', + ['a>b' => 'first', 'a 'second', 'a"b' => 'third'], + ['multiple' => 'checkbox'], + ); + $expected = [ + 'input' => [ + 'type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'model-multi-field', + ], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-a-b']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => 'a>b', 'id' => 'model-multi-field-a-b', + ]], + 'first', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-a-b1']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => 'a<b', 'id' => 'model-multi-field-a-b1', + ]], + 'second', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-a-b2']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[multi_field][]', + 'value' => 'a"b', 'id' => 'model-multi-field-a-b2', + ]], + 'third', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelectMultipleCheckboxRequestData method + * + * Ensure that multiCheckbox reads from the request data. + */ + public function testSelectMultipleCheckboxRequestData(): void + { + $this->View->setRequest($this->View->getRequest()->withData('Model', ['tags' => [1]])); + $result = $this->Form->select( + 'Model.tags', + ['1' => 'first', 'Array' => 'Array'], + ['multiple' => 'checkbox'], + ); + $expected = [ + 'input' => [ + 'type' => 'hidden', 'name' => 'Model[tags]', 'value' => '', 'id' => 'model-tags', + ], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-tags-1', 'class' => 'selected']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[tags][]', + 'value' => '1', 'id' => 'model-tags-1', 'checked' => 'checked', + ]], + 'first', + '/label', + '/div', + + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-tags-array']], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[tags][]', + 'value' => 'Array', 'id' => 'model-tags-array', + ]], + 'Array', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelectMultipleCheckboxSecurity method + * + * Checks the security hash array generated for multiple-input checkbox elements. + */ + public function testSelectMultipleCheckboxSecurity(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->select( + 'Model.multi_field', + ['1' => 'first', '2' => 'second', '3' => 'third'], + ['multiple' => 'checkbox'], + ); + $fields = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals(['Model.multi_field'], $fields); + + $result = $this->Form->secure(); + $hash = hash_hmac('sha1', $this->url . serialize($fields) . session_id(), Security::getSalt()); + $hash = urlencode($hash . ':'); + $this->assertStringContainsString('"' . $hash . '"', $result); + } + + /** + * testSelectMultipleSecureWithNoOptions method + * + * Multiple select elements should always be secured as they always participate + * in the POST data. + */ + public function testSelectMultipleSecureWithNoOptions(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->select( + 'Model.select', + [], + ['multiple' => true], + ); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals(['Model.select'], $result); + } + + /** + * testSelectNoSecureWithNoOptions method + * + * When a select box has no options it should not be added to the fields list + * as it always fail post validation. + */ + public function testSelectNoSecureWithNoOptions(): void + { + $this->View->setRequest($this->View->getRequest()->withAttribute('formTokenData', [])); + $this->Form->create(); + + $this->Form->select( + 'Model.select', + [], + ); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals([], $result); + + $this->Form->select( + 'Model.user_id', + [], + ['empty' => true], + ); + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals(['Model.user_id'], $result); + } + + /** + * testControlMultipleCheckboxes method + * + * Test control() resulting in multi select elements being generated. + */ + public function testControlMultipleCheckboxes(): void + { + $result = $this->Form->control('Model.multi_field', [ + 'options' => ['first', 'second', 'third'], + 'multiple' => 'checkbox', + ]); + $expected = [ + ['div' => ['class' => 'input select']], + ['label' => ['for' => 'model-multi-field']], + 'Multi Field', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'model-multi-field'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-0']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => '0', 'id' => 'model-multi-field-0']], + 'first', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-1']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => '1', 'id' => 'model-multi-field-1']], + 'second', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-2']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => '2', 'id' => 'model-multi-field-2']], + 'third', + '/label', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->control('Model.multi_field', [ + 'options' => ['a' => 'first', 'b' => 'second', 'c' => 'third'], + 'multiple' => 'checkbox', + ]); + $expected = [ + ['div' => ['class' => 'input select']], + ['label' => ['for' => 'model-multi-field']], + 'Multi Field', + '/label', + 'input' => ['type' => 'hidden', 'name' => 'Model[multi_field]', 'value' => '', 'id' => 'model-multi-field'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-a']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => 'a', 'id' => 'model-multi-field-a']], + 'first', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-b']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => 'b', 'id' => 'model-multi-field-b']], + 'second', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'model-multi-field-c']], + ['input' => ['type' => 'checkbox', 'name' => 'Model[multi_field][]', 'value' => 'c', 'id' => 'model-multi-field-c']], + 'third', + '/label', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testSelectHiddenFieldOmission method + * + * Test that select() with 'hiddenField' => false omits the hidden field. + */ + public function testSelectHiddenFieldOmission(): void + { + $result = $this->Form->select( + 'Model.multi_field', + ['first', 'second'], + ['multiple' => 'checkbox', 'hiddenField' => false, 'value' => null], + ); + $this->assertStringNotContainsString('type="hidden"', $result); + } + + /** + * testSelectCheckboxMultipleOverrideName method + * + * Test that select() with multiple = checkbox works with overriding name attribute. + */ + public function testSelectCheckboxMultipleOverrideName(): void + { + $result = $this->Form->select('category', ['1', '2'], [ + 'multiple' => 'checkbox', + 'name' => 'fish', + ]); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'fish', 'value' => '', 'id' => 'category'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'fish-0']], + ['input' => ['type' => 'checkbox', 'name' => 'fish[]', 'value' => '0', 'id' => 'fish-0']], + '1', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'fish-1']], + ['input' => ['type' => 'checkbox', 'name' => 'fish[]', 'value' => '1', 'id' => 'fish-1']], + '2', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->multiCheckbox( + 'category', + new Collection(['1', '2']), + ['name' => 'fish'], + ); + $this->assertHtml($expected, $result); + + $result = $this->Form->multiCheckbox('category', ['1', '2'], [ + 'name' => 'fish', + ]); + $this->assertHtml($expected, $result); + } + + /** + * testSelectCheckboxMultipleOverrideName method + * + * Test that select() with multiple = checkbox works with overriding name attribute. + */ + public function testSelectCheckboxMultipleCustomId(): void + { + $result = $this->Form->select('category', ['1', '2'], [ + 'multiple' => 'checkbox', + 'id' => 'cat', + ]); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'category', 'value' => '', 'id' => 'cat'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'cat-0']], + ['input' => ['type' => 'checkbox', 'name' => 'category[]', 'value' => '0', 'id' => 'cat-0']], + '1', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'cat-1']], + ['input' => ['type' => 'checkbox', 'name' => 'category[]', 'value' => '1', 'id' => 'cat-1']], + '2', + '/label', + '/div', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->multiCheckbox( + 'category', + ['1', '2'], + ['id' => 'cat'], + ); + $this->assertHtml($expected, $result); + } + + /** + * testControlMultiCheckbox method + * + * Test that control() works with multicheckbox. + */ + public function testControlMultiCheckbox(): void + { + $result = $this->Form->control('category', [ + 'type' => 'multicheckbox', + 'options' => ['1', '2'], + ]); + $expected = [ + ['div' => ['class' => 'input multicheckbox']], + ' ['type' => 'hidden', 'name' => 'category', 'value' => '', 'id' => 'category'], + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'category-0']], + ['input' => ['type' => 'checkbox', 'name' => 'category[]', 'value' => '0', 'id' => 'category-0']], + '1', + '/label', + '/div', + ['div' => ['class' => 'checkbox']], + ['label' => ['for' => 'category-1']], + ['input' => ['type' => 'checkbox', 'name' => 'category[]', 'value' => '1', 'id' => 'category-1']], + '2', + '/label', + '/div', + '/div', + ]; + $this->assertHtml($expected, $result); + } + + /** + * testCheckbox method + * + * Test generation of checkboxes. + */ + public function testCheckbox(): void + { + $result = $this->Form->checkbox('Model.field'); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '0'], + ['input' => ['type' => 'checkbox', 'name' => 'Model[field]', 'value' => '1']], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->checkbox('Model.field', [ + 'id' => 'theID', + 'value' => 'myvalue', + 'form' => 'my-form', + ]); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'Model[field]', 'value' => '0', 'form' => 'my-form'], + ['input' => [ + 'type' => 'checkbox', 'name' => 'Model[field]', + 'value' => 'myvalue', 'id' => 'theID', + 'form' => 'my-form', + ]], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testCheckboxDefaultValue method + * + * Test default value setting on checkbox() method. + */ + public function testCheckboxDefaultValue(): void + { + $this->View->setRequest($this->View->getRequest()->withData('Model.field', false)); + $result = $this->Form->checkbox('Model.field', ['default' => true, 'hiddenField' => false]); + $expected = ['input' => ['type' => 'checkbox', 'name' => 'Model[field]', 'value' => '1']]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withData('Model.field', null)); + $this->Form->create(); + $result = $this->Form->checkbox('Model.field', ['default' => true, 'hiddenField' => false]); + $expected = ['input' => ['type' => 'checkbox', 'name' => 'Model[field]', 'value' => '1', 'checked' => 'checked']]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withData('Model.field', true)); + $this->Form->create(); + $result = $this->Form->checkbox('Model.field', ['default' => false, 'hiddenField' => false]); + $expected = ['input' => ['type' => 'checkbox', 'name' => 'Model[field]', 'value' => '1', 'checked' => 'checked']]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withData('Model.field', null)); + $this->Form->create(); + $result = $this->Form->checkbox('Model.field', ['default' => false, 'hiddenField' => false]); + $expected = ['input' => ['type' => 'checkbox', 'name' => 'Model[field]', 'value' => '1']]; + $this->assertHtml($expected, $result); + + $Articles = $this->getTableLocator()->get('Articles'); + $Articles->getSchema()->addColumn( + 'published', + ['type' => 'boolean', 'null' => false, 'default' => true], + ); + + $this->Form->create($Articles->newEmptyEntity()); + $result = $this->Form->checkbox('published', ['hiddenField' => false]); + $expected = ['input' => ['type' => 'checkbox', 'name' => 'published', 'value' => '1', 'checked' => 'checked']]; + $this->assertHtml($expected, $result); + } + + /** + * testCheckboxCheckedAndError method + * + * Test checkbox being checked or having errors. + */ + public function testCheckboxCheckedAndError(): void + { + $this->article['errors'] = [ + 'published' => true, + ]; + $this->View->setRequest($this->View->getRequest()->withData('published', 'myvalue')); + $this->Form->create($this->article); + + $result = $this->Form->checkbox('published', ['id' => 'theID', 'value' => 'myvalue']); + $expected = [ + 'input' => ['type' => 'hidden', 'class' => 'form-error', 'name' => 'published', 'value' => '0'], + ['input' => [ + 'type' => 'checkbox', + 'name' => 'published', + 'value' => 'myvalue', + 'id' => 'theID', + 'checked' => 'checked', + 'class' => 'form-error', + ]], + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest($this->View->getRequest()->withData('published', '')); + $this->Form->create($this->article); + $result = $this->Form->checkbox('published'); + $expected = [ + 'input' => ['type' => 'hidden', 'class' => 'form-error', 'name' => 'published', 'value' => '0'], + ['input' => ['type' => 'checkbox', 'name' => 'published', 'value' => '1', 'class' => 'form-error']], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testCheckboxCustomNameAttribute method + * + * Test checkbox() with a custom name attribute. + */ + public function testCheckboxCustomNameAttribute(): void + { + $result = $this->Form->checkbox('Test.test', ['name' => 'myField']); + $expected = [ + 'input' => ['type' => 'hidden', 'name' => 'myField', 'value' => '0'], + ['input' => ['type' => 'checkbox', 'name' => 'myField', 'value' => '1']], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testCheckboxHiddenField method + * + * Test that the hidden input for checkboxes can be omitted or set to a + * specific value. + */ + public function testCheckboxHiddenField(): void + { + $result = $this->Form->checkbox('UserForm.something', [ + 'hiddenField' => false, + ]); + $expected = [ + 'input' => [ + 'type' => 'checkbox', + 'name' => 'UserForm[something]', + 'value' => '1', + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->checkbox('UserForm.something', [ + 'value' => 'Y', + 'hiddenField' => '', + ]); + $expected = [ + ['input' => [ + 'type' => 'hidden', 'name' => 'UserForm[something]', + 'value' => '', + ]], + ['input' => [ + 'type' => 'checkbox', 'name' => 'UserForm[something]', + 'value' => 'Y', + ]], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->checkbox('UserForm.something', [ + 'value' => 'Y', + 'hiddenField' => 'N', + ]); + $expected = [ + ['input' => [ + 'type' => 'hidden', 'name' => 'UserForm[something]', + 'value' => 'N', + ]], + ['input' => [ + 'type' => 'checkbox', 'name' => 'UserForm[something]', + 'value' => 'Y', + ]], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testTime method + * + * Test the time type. + */ + public function testTime(): void + { + $result = $this->Form->time('start_time', [ + 'value' => '2014-03-08 16:30:00', + ]); + + $expected = [ + 'input' => [ + 'type' => 'time', + 'name' => 'start_time', + 'value' => '16:30:00', + 'step' => '1', + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->time('start_time', [ + 'value' => new ChronosTime('16:30:00'), + ]); + + $expected = [ + 'input' => [ + 'type' => 'time', + 'name' => 'start_time', + 'value' => '16:30:00', + 'step' => '1', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testDate method + * + * Test the date type. + */ + public function testDate(): void + { + $result = $this->Form->date('start_day', [ + 'value' => '2014-03-08', + ]); + + $expected = [ + 'input' => [ + 'type' => 'date', + 'name' => 'start_day', + 'value' => '2014-03-08', + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->date('start_day', [ + 'value' => new Date('2014-03-08'), + ]); + $this->assertHtml($expected, $result); + } + + /** + * testDateTime method + */ + public function testDateTime(): void + { + $result = $this->Form->dateTime('date', ['default' => true]); + $expected = [ + 'input' => [ + 'type' => 'datetime-local', + 'name' => 'date', + 'value' => 'preg:/' . date('Y-m-d') . 'T\d{2}:\d{2}:\d{2}/', + 'step' => '1', + ], + ]; + + $this->assertHtml($expected, $result); + } + + /** + * testDateTimeSecured method + * + * Test that datetime fields are added to protected fields list. + */ + public function testDateTimeSecured(): void + { + $this->View->setRequest( + $this->View->getRequest()->withAttribute('formTokenData', ['unlockedFields' => []]), + ); + $this->Form->create(); + + $this->Form->dateTime('date'); + $expected = ['date']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->date('published'); + $expected = ['date', 'published']; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * testDateTimeSecuredDisabled method + * + * Test that datetime fields are added to protected fields list. + */ + public function testDateTimeSecuredDisabled(): void + { + $this->View->setRequest( + $this->View->getRequest()->withAttribute('formTokenData', ['unlockedFields' => []]), + ); + $this->Form->create(); + + $this->Form->dateTime('date', ['secure' => false]); + $expected = []; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + + $this->Form->date('published', ['secure' => false]); + $expected = []; + $result = $this->Form->getFormProtector()->__debugInfo()['fields']; + $this->assertEquals($expected, $result); + } + + /** + * testDatetimeWithDefault method + * + * Test that datetime() and default values work. + */ + public function testDatetimeWithDefault(): void + { + $result = $this->Form->dateTime('updated', ['value' => '2009-06-01 11:15:30']); + $expected = [ + 'input' => [ + 'type' => 'datetime-local', + 'name' => 'updated', + 'value' => '2009-06-01T11:15:30', + 'step' => '1', + ], + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->dateTime('updated', [ + 'default' => '2009-06-01 11:15:30', + ]); + $expected = [ + 'input' => [ + 'type' => 'datetime-local', + 'name' => 'updated', + 'value' => '2009-06-01T11:15:30', + 'step' => '1', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testMonth method + * + * Test generation of a month input. + */ + public function testMonth(): void + { + $result = $this->Form->month('field', ['value' => '']); + $expected = [ + 'input' => [ + 'type' => 'month', + 'name' => 'field', + 'value' => '', + ], + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest( + $this->View->getRequest()->withData('release', '2050-02-10'), + ); + $this->Form->create(); + $result = $this->Form->month('release'); + + $expected = [ + 'input' => [ + 'type' => 'month', + 'name' => 'release', + 'value' => '2050-02', + ], + ]; + $this->assertHtml($expected, $result); + + $this->View->setRequest( + $this->View->getRequest()->withData('release', '2050-03'), + ); + $this->Form->create(); + $result = $this->Form->month('release'); + $expected = [ + 'input' => [ + 'type' => 'month', + 'name' => 'release', + 'value' => '2050-03', + ], + ]; + $this->assertHtml($expected, $result); + } + + /** + * testYear method + * + * Test generation of a year input. + */ + public function testYear(): void + { + $this->View->setRequest( + $this->View->getRequest()->withData('published', '2006'), + ); + + $result = $this->Form->year('field', ['value' => '', 'min' => 2006, 'max' => 2007]); + $expected = [ + ['select' => ['name' => 'field']], + ['option' => ['selected' => 'selected', 'value' => '']], + '/option', + ['option' => ['value' => '2007']], + '2007', + '/option', + ['option' => ['value' => '2006']], + '2006', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->year('field', [ + 'value' => '', + 'min' => 2006, + 'max' => 2007, + 'order' => 'asc', + ]); + $expected = [ + ['select' => ['name' => 'field']], + ['option' => ['selected' => 'selected', 'value' => '']], + '/option', + ['option' => ['value' => '2006']], + '2006', + '/option', + ['option' => ['value' => '2007']], + '2007', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->year('published', [ + 'empty' => false, + 'min' => 2006, + 'max' => 2007, + ]); + $expected = [ + ['select' => ['name' => 'published']], + ['option' => ['value' => '2007']], + '2007', + '/option', + ['option' => ['value' => '2006', 'selected' => 'selected']], + '2006', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->year('published', [ + 'empty' => false, + 'value' => new Date('2008-01-12'), + 'min' => 2007, + 'max' => 2009, + ]); + $expected = [ + ['select' => ['name' => 'published']], + ['option' => ['value' => '2009']], + '2009', + '/option', + ['option' => ['value' => '2008', 'selected' => 'selected']], + '2008', + '/option', + ['option' => ['value' => '2007']], + '2007', + '/option', + '/select', + ]; + $this->assertHtml($expected, $result); + + $result = $this->Form->year('published', [ + 'empty' => 'Published on', + ]); + $this->assertStringContainsString('Published on', $result); + } + + /** + * testControlYearPreEpoch method + * + * Test minYear being prior to the unix epoch. + */ + public function testControlYearPreEpoch(): void + { + $start = date('Y') - 80; + $end = date('Y') - 18; + $result = $this->Form->control('birth_year', [ + 'type' => 'year', + 'label' => 'Birth Year', + 'min' => $start, + 'max' => $end, + ]); + $this->assertStringContainsString('value="' . $start . '">' . $start, $result); + $this->assertStringContainsString('value="' . $end . '">' . $end, $result); + $this->assertStringNotContainsString('value="00">00', $result); + } + + /** + * test control() datetime & required attributes + */ + public function testControlDatetimeRequired(): void + { + $result = $this->Form->control('birthday', [ + 'type' => 'date', + 'required' => true, + ]); + $this->assertStringContainsString( + 'View->setRequest($this->View->getRequest()->withData('birthday', '1930')); + $result = $this->Form->year('birthday'); + preg_match_all('/